mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 19:52:49 +00:00
7582 lines
225 KiB
TypeScript
7582 lines
225 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid'
|
||
import { watch, type FSWatcher } from 'node:fs'
|
||
import { stat } from 'fs/promises'
|
||
import { exec } from 'child_process'
|
||
import { promisify } from 'util'
|
||
import { join, resolve, sep } from 'path'
|
||
import { homedir } from 'node:os'
|
||
import { z } from 'zod'
|
||
import type { ToolSet } from 'ai'
|
||
import {
|
||
serializeAgentStreamEvent,
|
||
type AgentSnapshotPayload,
|
||
type SessionInboundMessage,
|
||
type SessionOutboundMessage,
|
||
type FileExplorerRequest,
|
||
type FileDownloadTokenRequest,
|
||
type GitSetupOptions,
|
||
type ListTerminalsRequest,
|
||
type SubscribeTerminalsRequest,
|
||
type UnsubscribeTerminalsRequest,
|
||
type CreateTerminalRequest,
|
||
type SubscribeTerminalRequest,
|
||
type UnsubscribeTerminalRequest,
|
||
type TerminalInput,
|
||
type KillTerminalRequest,
|
||
type AttachTerminalStreamRequest,
|
||
type DetachTerminalStreamRequest,
|
||
type SubscribeCheckoutDiffRequest,
|
||
type UnsubscribeCheckoutDiffRequest,
|
||
type DirectorySuggestionsRequest,
|
||
type ProjectCheckoutLitePayload,
|
||
type ProjectPlacementPayload,
|
||
type WorkspaceDescriptorPayload,
|
||
type WorkspaceStateBucket,
|
||
} from './messages.js'
|
||
import type { TerminalManager, TerminalsChangedEvent } from '../terminal/terminal-manager.js'
|
||
import type { TerminalSession } from '../terminal/terminal.js'
|
||
import {
|
||
BinaryMuxChannel,
|
||
TerminalBinaryFlags,
|
||
TerminalBinaryMessageType,
|
||
type BinaryMuxFrame,
|
||
} from '../shared/binary-mux.js'
|
||
import { TTSManager } from './agent/tts-manager.js'
|
||
import { STTManager } from './agent/stt-manager.js'
|
||
import type { SpeechToTextProvider, TextToSpeechProvider } from './speech/speech-provider.js'
|
||
import { maybePersistTtsDebugAudio } from './agent/tts-debug.js'
|
||
import { isPaseoDictationDebugEnabled } from './agent/recordings-debug.js'
|
||
import {
|
||
DictationStreamManager,
|
||
type DictationStreamOutboundMessage,
|
||
} from './dictation/dictation-stream-manager.js'
|
||
import { buildConfigOverrides, buildSessionConfig, extractTimestamps } from './persistence-hooks.js'
|
||
import { experimental_createMCPClient } from 'ai'
|
||
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'
|
||
import type { VoiceCallerContext, VoiceMcpStdioConfig, VoiceSpeakHandler } from './voice-types.js'
|
||
|
||
export type AgentMcpTransportFactory = () => Promise<Transport>
|
||
import { buildProviderRegistry } from './agent/provider-registry.js'
|
||
import type { AgentProviderRuntimeSettingsMap } from './agent/provider-launch-config.js'
|
||
import { AgentManager } from './agent/agent-manager.js'
|
||
import type {
|
||
AgentTimelineCursor,
|
||
AgentTimelineFetchDirection,
|
||
ManagedAgent,
|
||
} from './agent/agent-manager.js'
|
||
import { scheduleAgentMetadataGeneration } from './agent/agent-metadata-generator.js'
|
||
import { resolveEffectiveThinkingOptionId, toAgentPayload } from './agent/agent-projections.js'
|
||
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from './agent/agent-title-limits.js'
|
||
import {
|
||
appendTimelineItemIfAgentKnown,
|
||
emitLiveTimelineItemIfAgentKnown,
|
||
} from './agent/timeline-append.js'
|
||
import {
|
||
projectTimelineRows,
|
||
selectTimelineWindowByProjectedLimit,
|
||
type TimelineProjectionMode,
|
||
} from './agent/timeline-projection.js'
|
||
import {
|
||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||
StructuredAgentFallbackError,
|
||
StructuredAgentResponseError,
|
||
generateStructuredAgentResponseWithFallback,
|
||
} from './agent/agent-response-loop.js'
|
||
import type {
|
||
AgentPermissionResponse,
|
||
AgentPromptContentBlock,
|
||
AgentPromptInput,
|
||
AgentRunOptions,
|
||
McpServerConfig,
|
||
AgentSessionConfig,
|
||
AgentStreamEvent,
|
||
AgentProvider,
|
||
AgentPersistenceHandle,
|
||
} from './agent/agent-sdk-types.js'
|
||
import { AgentStorage, type StoredAgentRecord } from './agent/agent-storage.js'
|
||
import { isValidAgentProvider, AGENT_PROVIDER_IDS } from './agent/provider-manifest.js'
|
||
import {
|
||
buildVoiceAgentMcpServerConfig,
|
||
buildVoiceModeSystemPrompt,
|
||
stripVoiceModeSystemPrompt,
|
||
} from './voice-config.js'
|
||
import { isVoicePermissionAllowed } from './voice-permission-policy.js'
|
||
import {
|
||
listDirectoryEntries,
|
||
readExplorerFile,
|
||
getDownloadableFileInfo,
|
||
} from './file-explorer/service.js'
|
||
import { DownloadTokenStore } from './file-download/token-store.js'
|
||
import { PushTokenStore } from './push/token-store.js'
|
||
import {
|
||
type WorktreeConfig,
|
||
slugify,
|
||
validateBranchSlug,
|
||
listPaseoWorktrees,
|
||
deletePaseoWorktree,
|
||
isPaseoOwnedWorktreeCwd,
|
||
resolvePaseoWorktreeRootForCwd,
|
||
} from '../utils/worktree.js'
|
||
import { createAgentWorktree, runAsyncWorktreeBootstrap } from './worktree-bootstrap.js'
|
||
import {
|
||
getCheckoutDiff,
|
||
getCheckoutStatus,
|
||
getCheckoutStatusLite,
|
||
listBranchSuggestions,
|
||
NotGitRepoError,
|
||
MergeConflictError,
|
||
MergeFromBaseConflictError,
|
||
commitChanges,
|
||
mergeToBase,
|
||
mergeFromBase,
|
||
pushCurrentBranch,
|
||
createPullRequest,
|
||
getPullRequestStatus,
|
||
} from '../utils/checkout-git.js'
|
||
import { getProjectIcon } from '../utils/project-icon.js'
|
||
import { expandTilde } from '../utils/path.js'
|
||
import { searchHomeDirectories, searchWorkspaceEntries } from '../utils/directory-suggestions.js'
|
||
import {
|
||
ensureLocalSpeechModels,
|
||
getLocalSpeechModelDir,
|
||
listLocalSpeechModels,
|
||
type LocalSpeechModelId,
|
||
} from './speech/providers/local/models.js'
|
||
import type { Resolvable } from './speech/provider-resolver.js'
|
||
import type { SpeechReadinessSnapshot, SpeechReadinessState } from './speech/speech-runtime.js'
|
||
import type pino from 'pino'
|
||
import { resolveClientMessageId } from './client-message-id.js'
|
||
|
||
const execAsync = promisify(exec)
|
||
const MAX_INITIAL_AGENT_TITLE_CHARS = Math.min(60, MAX_EXPLICIT_AGENT_TITLE_CHARS)
|
||
const READ_ONLY_GIT_ENV: NodeJS.ProcessEnv = {
|
||
...process.env,
|
||
GIT_OPTIONAL_LOCKS: '0',
|
||
}
|
||
const pendingAgentInitializations = new Map<string, Promise<ManagedAgent>>()
|
||
const DEFAULT_AGENT_PROVIDER = AGENT_PROVIDER_IDS[0]
|
||
const CHECKOUT_DIFF_WATCH_DEBOUNCE_MS = 150
|
||
const CHECKOUT_DIFF_FALLBACK_REFRESH_MS = 5_000
|
||
const TERMINAL_STREAM_WINDOW_BYTES = 256 * 1024
|
||
const TERMINAL_STREAM_MAX_PENDING_BYTES = 2 * 1024 * 1024
|
||
const TERMINAL_STREAM_MAX_PENDING_CHUNKS = 2048
|
||
|
||
function deriveInitialAgentTitle(prompt: string): string | null {
|
||
const firstContentLine = prompt
|
||
.split(/\r?\n/)
|
||
.map((line) => line.trim())
|
||
.find((line) => line.length > 0)
|
||
if (!firstContentLine) {
|
||
return null
|
||
}
|
||
const normalized = firstContentLine.replace(/\s+/g, ' ').trim()
|
||
if (!normalized) {
|
||
return null
|
||
}
|
||
const clamped = normalized.slice(0, MAX_INITIAL_AGENT_TITLE_CHARS).trim()
|
||
return clamped.length > 0 ? clamped : null
|
||
}
|
||
|
||
function deriveRemoteProjectKey(remoteUrl: string | null): string | null {
|
||
if (!remoteUrl) {
|
||
return null
|
||
}
|
||
|
||
const trimmed = remoteUrl.trim()
|
||
if (!trimmed) {
|
||
return null
|
||
}
|
||
|
||
let host: string | null = null
|
||
let path: string | null = null
|
||
|
||
const scpLike = trimmed.match(/^[^@]+@([^:]+):(.+)$/)
|
||
if (scpLike) {
|
||
host = scpLike[1] ?? null
|
||
path = scpLike[2] ?? null
|
||
} else if (trimmed.includes('://')) {
|
||
try {
|
||
const parsed = new URL(trimmed)
|
||
host = parsed.hostname || null
|
||
path = parsed.pathname ? parsed.pathname.replace(/^\//, '') : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
if (!host || !path) {
|
||
return null
|
||
}
|
||
|
||
let cleanedPath = path.trim().replace(/^\/+/, '').replace(/\/+$/, '')
|
||
if (cleanedPath.endsWith('.git')) {
|
||
cleanedPath = cleanedPath.slice(0, -4)
|
||
}
|
||
if (!cleanedPath.includes('/')) {
|
||
return null
|
||
}
|
||
|
||
const cleanedHost = host.toLowerCase()
|
||
if (cleanedHost === 'github.com') {
|
||
return `remote:github.com/${cleanedPath}`
|
||
}
|
||
|
||
return `remote:${cleanedHost}/${cleanedPath}`
|
||
}
|
||
|
||
function deriveProjectGroupingKey(options: {
|
||
cwd: string
|
||
remoteUrl: string | null
|
||
isPaseoOwnedWorktree: boolean
|
||
mainRepoRoot: string | null
|
||
}): string {
|
||
const remoteKey = deriveRemoteProjectKey(options.remoteUrl)
|
||
if (remoteKey) {
|
||
return remoteKey
|
||
}
|
||
|
||
const mainRepoRoot = options.mainRepoRoot?.trim()
|
||
if (options.isPaseoOwnedWorktree && mainRepoRoot) {
|
||
return mainRepoRoot
|
||
}
|
||
|
||
return options.cwd
|
||
}
|
||
|
||
function deriveProjectGroupingName(projectKey: string): string {
|
||
const githubRemotePrefix = 'remote:github.com/'
|
||
if (projectKey.startsWith(githubRemotePrefix)) {
|
||
return projectKey.slice(githubRemotePrefix.length) || projectKey
|
||
}
|
||
|
||
const segments = projectKey.split(/[\\/]/).filter(Boolean)
|
||
return segments[segments.length - 1] || projectKey
|
||
}
|
||
|
||
type ProcessingPhase = 'idle' | 'transcribing'
|
||
|
||
type CheckoutDiffCompareInput = SubscribeCheckoutDiffRequest['compare']
|
||
|
||
type CheckoutDiffSnapshotPayload = Omit<
|
||
Extract<SessionOutboundMessage, { type: 'checkout_diff_update' }>['payload'],
|
||
'subscriptionId'
|
||
>
|
||
|
||
type CheckoutDiffWatchTarget = {
|
||
key: string
|
||
cwd: string
|
||
diffCwd: string
|
||
compare: CheckoutDiffCompareInput
|
||
subscriptions: Set<string>
|
||
watchers: FSWatcher[]
|
||
fallbackRefreshInterval: NodeJS.Timeout | null
|
||
debounceTimer: NodeJS.Timeout | null
|
||
refreshPromise: Promise<void> | null
|
||
refreshQueued: boolean
|
||
latestPayload: CheckoutDiffSnapshotPayload | null
|
||
latestFingerprint: string | null
|
||
}
|
||
|
||
type NormalizedGitOptions = {
|
||
baseBranch?: string
|
||
createNewBranch: boolean
|
||
newBranchName?: string
|
||
createWorktree: boolean
|
||
worktreeSlug?: string
|
||
}
|
||
|
||
type CheckoutErrorCode = 'NOT_GIT_REPO' | 'NOT_ALLOWED' | 'MERGE_CONFLICT' | 'UNKNOWN'
|
||
|
||
type CheckoutErrorPayload = {
|
||
code: CheckoutErrorCode
|
||
message: string
|
||
}
|
||
|
||
type TerminalStreamPendingChunk = {
|
||
data: string
|
||
startOffset: number
|
||
endOffset: number
|
||
replay: boolean
|
||
}
|
||
|
||
type FetchAgentsRequestMessage = Extract<SessionInboundMessage, { type: 'fetch_agents_request' }>
|
||
type FetchAgentsRequestFilter = NonNullable<FetchAgentsRequestMessage['filter']>
|
||
type FetchAgentsRequestSort = NonNullable<FetchAgentsRequestMessage['sort']>[number]
|
||
type FetchAgentsResponsePayload = Extract<
|
||
SessionOutboundMessage,
|
||
{ type: 'fetch_agents_response' }
|
||
>['payload']
|
||
type FetchAgentsResponseEntry = FetchAgentsResponsePayload['entries'][number]
|
||
type FetchAgentsResponsePageInfo = FetchAgentsResponsePayload['pageInfo']
|
||
type AgentUpdatePayload = Extract<SessionOutboundMessage, { type: 'agent_update' }>['payload']
|
||
type AgentUpdatesFilter = FetchAgentsRequestFilter
|
||
type AgentUpdatesSubscriptionState = {
|
||
subscriptionId: string
|
||
filter?: AgentUpdatesFilter
|
||
isBootstrapping: boolean
|
||
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>
|
||
}
|
||
type FetchAgentsCursor = {
|
||
sort: FetchAgentsRequestSort[]
|
||
values: Record<string, string | number | null>
|
||
id: string
|
||
}
|
||
type FetchWorkspacesRequestMessage = Extract<
|
||
SessionInboundMessage,
|
||
{ type: 'fetch_workspaces_request' }
|
||
>
|
||
type FetchWorkspacesRequestFilter = NonNullable<FetchWorkspacesRequestMessage['filter']>
|
||
type FetchWorkspacesRequestSort = NonNullable<FetchWorkspacesRequestMessage['sort']>[number]
|
||
type FetchWorkspacesResponsePayload = Extract<
|
||
SessionOutboundMessage,
|
||
{ type: 'fetch_workspaces_response' }
|
||
>['payload']
|
||
type FetchWorkspacesResponseEntry = FetchWorkspacesResponsePayload['entries'][number]
|
||
type FetchWorkspacesResponsePageInfo = FetchWorkspacesResponsePayload['pageInfo']
|
||
type WorkspaceUpdatePayload = Extract<SessionOutboundMessage, { type: 'workspace_update' }>['payload']
|
||
type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter
|
||
type WorkspaceUpdatesSubscriptionState = {
|
||
subscriptionId: string
|
||
filter?: WorkspaceUpdatesFilter
|
||
isBootstrapping: boolean
|
||
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>
|
||
}
|
||
type FetchWorkspacesCursor = {
|
||
sort: FetchWorkspacesRequestSort[]
|
||
values: Record<string, string | number | null>
|
||
id: string
|
||
}
|
||
|
||
class SessionRequestError extends Error {
|
||
constructor(
|
||
readonly code: string,
|
||
message: string
|
||
) {
|
||
super(message)
|
||
this.name = 'SessionRequestError'
|
||
}
|
||
}
|
||
|
||
const PCM_SAMPLE_RATE = 16000
|
||
const PCM_CHANNELS = 1
|
||
const PCM_BITS_PER_SAMPLE = 16
|
||
const PCM_BYTES_PER_MS = (PCM_SAMPLE_RATE * PCM_CHANNELS * (PCM_BITS_PER_SAMPLE / 8)) / 1000
|
||
const MIN_STREAMING_SEGMENT_DURATION_MS = 1000
|
||
const MIN_STREAMING_SEGMENT_BYTES = Math.round(PCM_BYTES_PER_MS * MIN_STREAMING_SEGMENT_DURATION_MS)
|
||
const VOICE_MODE_INACTIVITY_FLUSH_MS = 4500
|
||
const VOICE_INTERNAL_DICTATION_ID_PREFIX = '__voice_turn__:'
|
||
const SAFE_GIT_REF_PATTERN = /^[A-Za-z0-9._\/-]+$/
|
||
const AgentIdSchema = z.string().uuid()
|
||
const VOICE_MCP_SERVER_NAME = 'paseo_voice'
|
||
|
||
type VoiceModeBaseConfig = {
|
||
systemPrompt?: string
|
||
mcpServers?: Record<string, McpServerConfig>
|
||
}
|
||
|
||
interface AudioBufferState {
|
||
chunks: Buffer[]
|
||
format: string
|
||
isPCM: boolean
|
||
totalPCMBytes: number
|
||
}
|
||
|
||
type VoiceTranscriptionResultPayload = {
|
||
text: string
|
||
requestId: string
|
||
language?: string
|
||
duration?: number
|
||
avgLogprob?: number
|
||
isLowConfidence?: boolean
|
||
byteLength?: number
|
||
format?: string
|
||
debugRecordingPath?: string
|
||
}
|
||
|
||
export type SessionOptions = {
|
||
clientId: string
|
||
onMessage: (msg: SessionOutboundMessage) => void
|
||
onBinaryMessage?: (frame: BinaryMuxFrame) => void
|
||
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void
|
||
logger: pino.Logger
|
||
downloadTokenStore: DownloadTokenStore
|
||
pushTokenStore: PushTokenStore
|
||
paseoHome: string
|
||
agentManager: AgentManager
|
||
agentStorage: AgentStorage
|
||
createAgentMcpTransport: AgentMcpTransportFactory
|
||
stt: Resolvable<SpeechToTextProvider | null>
|
||
tts: Resolvable<TextToSpeechProvider | null>
|
||
terminalManager: TerminalManager | null
|
||
voice?: {
|
||
voiceAgentMcpStdio?: VoiceMcpStdioConfig | null
|
||
}
|
||
voiceBridge?: {
|
||
registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void
|
||
unregisterVoiceSpeakHandler?: (agentId: string) => void
|
||
registerVoiceCallerContext?: (agentId: string, context: VoiceCallerContext) => void
|
||
unregisterVoiceCallerContext?: (agentId: string) => void
|
||
ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>
|
||
removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>
|
||
}
|
||
dictation?: {
|
||
finalTimeoutMs?: number
|
||
stt?: Resolvable<SpeechToTextProvider | null>
|
||
localModels?: {
|
||
modelsDir: string
|
||
defaultModelIds: LocalSpeechModelId[]
|
||
}
|
||
getSpeechReadiness?: () => SpeechReadinessSnapshot
|
||
}
|
||
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap
|
||
}
|
||
|
||
export type SessionLifecycleIntent =
|
||
| {
|
||
type: 'shutdown'
|
||
clientId: string
|
||
requestId: string
|
||
}
|
||
| {
|
||
type: 'restart'
|
||
clientId: string
|
||
requestId: string
|
||
reason?: string
|
||
}
|
||
|
||
type VoiceFeatureUnavailableContext = {
|
||
reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode']
|
||
message: string
|
||
retryable: boolean
|
||
missingModelIds: LocalSpeechModelId[]
|
||
}
|
||
|
||
type VoiceFeatureUnavailableResponseMetadata = {
|
||
reasonCode?: SpeechReadinessSnapshot['voiceFeature']['reasonCode']
|
||
retryable?: boolean
|
||
missingModelIds?: LocalSpeechModelId[]
|
||
}
|
||
|
||
class VoiceFeatureUnavailableError extends Error {
|
||
readonly reasonCode: SpeechReadinessSnapshot['voiceFeature']['reasonCode']
|
||
readonly retryable: boolean
|
||
readonly missingModelIds: LocalSpeechModelId[]
|
||
|
||
constructor(context: VoiceFeatureUnavailableContext) {
|
||
super(context.message)
|
||
this.name = 'VoiceFeatureUnavailableError'
|
||
this.reasonCode = context.reasonCode
|
||
this.retryable = context.retryable
|
||
this.missingModelIds = [...context.missingModelIds]
|
||
}
|
||
}
|
||
|
||
function convertPCMToWavBuffer(
|
||
pcmBuffer: Buffer,
|
||
sampleRate: number,
|
||
channels: number,
|
||
bitsPerSample: number
|
||
): Buffer {
|
||
const headerSize = 44
|
||
const wavBuffer = Buffer.alloc(headerSize + pcmBuffer.length)
|
||
const byteRate = (sampleRate * channels * bitsPerSample) / 8
|
||
const blockAlign = (channels * bitsPerSample) / 8
|
||
|
||
wavBuffer.write('RIFF', 0)
|
||
wavBuffer.writeUInt32LE(36 + pcmBuffer.length, 4)
|
||
wavBuffer.write('WAVE', 8)
|
||
wavBuffer.write('fmt ', 12)
|
||
wavBuffer.writeUInt32LE(16, 16)
|
||
wavBuffer.writeUInt16LE(1, 20)
|
||
wavBuffer.writeUInt16LE(channels, 22)
|
||
wavBuffer.writeUInt32LE(sampleRate, 24)
|
||
wavBuffer.writeUInt32LE(byteRate, 28)
|
||
wavBuffer.writeUInt16LE(blockAlign, 32)
|
||
wavBuffer.writeUInt16LE(bitsPerSample, 34)
|
||
wavBuffer.write('data', 36)
|
||
wavBuffer.writeUInt32LE(pcmBuffer.length, 40)
|
||
pcmBuffer.copy(wavBuffer, 44)
|
||
|
||
return wavBuffer
|
||
}
|
||
|
||
function coerceAgentProvider(logger: pino.Logger, value: string, agentId?: string): AgentProvider {
|
||
if (isValidAgentProvider(value)) {
|
||
return value
|
||
}
|
||
logger.warn(
|
||
{ value, agentId, defaultProvider: DEFAULT_AGENT_PROVIDER },
|
||
`Unknown provider '${value}' for agent ${agentId ?? 'unknown'}; defaulting to '${DEFAULT_AGENT_PROVIDER}'`
|
||
)
|
||
return DEFAULT_AGENT_PROVIDER
|
||
}
|
||
|
||
function toAgentPersistenceHandle(
|
||
logger: pino.Logger,
|
||
handle: StoredAgentRecord['persistence']
|
||
): AgentPersistenceHandle | null {
|
||
if (!handle) {
|
||
return null
|
||
}
|
||
const provider = handle.provider
|
||
if (!isValidAgentProvider(provider)) {
|
||
logger.warn({ provider }, `Ignoring persistence handle with unknown provider '${provider}'`)
|
||
return null
|
||
}
|
||
if (!handle.sessionId) {
|
||
logger.warn('Ignoring persistence handle missing sessionId')
|
||
return null
|
||
}
|
||
return {
|
||
provider,
|
||
sessionId: handle.sessionId,
|
||
nativeHandle: handle.nativeHandle,
|
||
metadata: handle.metadata,
|
||
} satisfies AgentPersistenceHandle
|
||
}
|
||
|
||
/**
|
||
* Session represents a single connected client session.
|
||
* It owns all state management, orchestration logic, and message processing.
|
||
* Session has no knowledge of WebSockets - it only emits and receives messages.
|
||
*/
|
||
export class Session {
|
||
private readonly clientId: string
|
||
private readonly sessionId: string
|
||
private readonly onMessage: (msg: SessionOutboundMessage) => void
|
||
private readonly onBinaryMessage: ((frame: BinaryMuxFrame) => void) | null
|
||
private readonly onLifecycleIntent: ((intent: SessionLifecycleIntent) => void) | null
|
||
private readonly sessionLogger: pino.Logger
|
||
private readonly paseoHome: string
|
||
|
||
// State machine
|
||
private abortController: AbortController
|
||
private processingPhase: ProcessingPhase = 'idle'
|
||
|
||
// Voice mode state
|
||
private isVoiceMode = false
|
||
private speechInProgress = false
|
||
|
||
private readonly dictationStreamManager: DictationStreamManager
|
||
private readonly voiceStreamManager: DictationStreamManager
|
||
|
||
// Audio buffering for interruption handling
|
||
private pendingAudioSegments: Array<{ audio: Buffer; format: string }> = []
|
||
private bufferTimeout: NodeJS.Timeout | null = null
|
||
private voiceModeInactivityTimeout: NodeJS.Timeout | null = null
|
||
private audioBuffer: AudioBufferState | null = null
|
||
private activeVoiceDictationId: string | null = null
|
||
private activeVoiceDictationFormat: string | null = null
|
||
private activeVoiceDictationNextSeq = 0
|
||
private activeVoiceDictationStartPromise: Promise<void> | null = null
|
||
private activeVoiceDictationFinalizePromise: Promise<void> | null = null
|
||
private activeVoiceDictationResultPromise: Promise<{
|
||
text: string
|
||
debugRecordingPath?: string
|
||
}> | null = null
|
||
private activeVoiceDictationResolve:
|
||
| ((value: { text: string; debugRecordingPath?: string }) => void)
|
||
| null = null
|
||
private activeVoiceDictationReject: ((error: Error) => void) | null = null
|
||
|
||
// Optional TTS debug capture (persisted per utterance)
|
||
private readonly ttsDebugStreams = new Map<string, { format: string; chunks: Buffer[] }>()
|
||
|
||
// Per-session managers
|
||
private readonly ttsManager: TTSManager
|
||
private readonly sttManager: STTManager
|
||
|
||
// Per-session MCP client and tools
|
||
private agentMcpClient: Awaited<ReturnType<typeof experimental_createMCPClient>> | null = null
|
||
private agentTools: ToolSet | null = null
|
||
private agentManager: AgentManager
|
||
private readonly agentStorage: AgentStorage
|
||
private readonly createAgentMcpTransport: AgentMcpTransportFactory
|
||
private readonly downloadTokenStore: DownloadTokenStore
|
||
private readonly pushTokenStore: PushTokenStore
|
||
private readonly providerRegistry: ReturnType<typeof buildProviderRegistry>
|
||
private unsubscribeAgentEvents: (() => void) | null = null
|
||
private agentUpdatesSubscription: AgentUpdatesSubscriptionState | null = null
|
||
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null
|
||
private clientActivity: {
|
||
deviceType: 'web' | 'mobile'
|
||
focusedAgentId: string | null
|
||
lastActivityAt: Date
|
||
appVisible: boolean
|
||
appVisibilityChangedAt: Date
|
||
} | null = null
|
||
private readonly MOBILE_BACKGROUND_STREAM_GRACE_MS = 60_000
|
||
private readonly terminalManager: TerminalManager | null
|
||
private readonly subscribedTerminalDirectories = new Set<string>()
|
||
private unsubscribeTerminalsChanged: (() => void) | null = null
|
||
private terminalSubscriptions: Map<string, () => void> = new Map()
|
||
private terminalExitSubscriptions: Map<string, () => void> = new Map()
|
||
private readonly terminalStreams = new Map<
|
||
number,
|
||
{
|
||
terminalId: string
|
||
unsubscribe: () => void
|
||
lastOutputOffset: number
|
||
lastAckOffset: number
|
||
pendingChunks: TerminalStreamPendingChunk[]
|
||
pendingBytes: number
|
||
}
|
||
>()
|
||
private readonly terminalStreamByTerminalId = new Map<string, number>()
|
||
private nextTerminalStreamId = 1
|
||
private readonly checkoutDiffSubscriptions = new Map<string, { targetKey: string }>()
|
||
private readonly checkoutDiffTargets = new Map<string, CheckoutDiffWatchTarget>()
|
||
private readonly voiceAgentMcpStdio: VoiceMcpStdioConfig | null
|
||
private readonly localSpeechModelsDir: string
|
||
private readonly defaultLocalSpeechModelIds: LocalSpeechModelId[]
|
||
private readonly registerVoiceSpeakHandler?: (agentId: string, handler: VoiceSpeakHandler) => void
|
||
private readonly unregisterVoiceSpeakHandler?: (agentId: string) => void
|
||
private readonly registerVoiceCallerContext?: (
|
||
agentId: string,
|
||
context: VoiceCallerContext
|
||
) => void
|
||
private readonly unregisterVoiceCallerContext?: (agentId: string) => void
|
||
private readonly ensureVoiceMcpSocketForAgent?: (agentId: string) => Promise<string>
|
||
private readonly removeVoiceMcpSocketForAgent?: (agentId: string) => Promise<void>
|
||
private readonly getSpeechReadiness?: () => SpeechReadinessSnapshot
|
||
private readonly agentProviderRuntimeSettings: AgentProviderRuntimeSettingsMap | undefined
|
||
private voiceModeAgentId: string | null = null
|
||
private voiceModeBaseConfig: VoiceModeBaseConfig | null = null
|
||
|
||
constructor(options: SessionOptions) {
|
||
const {
|
||
clientId,
|
||
onMessage,
|
||
onBinaryMessage,
|
||
onLifecycleIntent,
|
||
logger,
|
||
downloadTokenStore,
|
||
pushTokenStore,
|
||
paseoHome,
|
||
agentManager,
|
||
agentStorage,
|
||
createAgentMcpTransport,
|
||
stt,
|
||
tts,
|
||
terminalManager,
|
||
voice,
|
||
voiceBridge,
|
||
dictation,
|
||
agentProviderRuntimeSettings,
|
||
} = options
|
||
this.clientId = clientId
|
||
this.sessionId = uuidv4()
|
||
this.onMessage = onMessage
|
||
this.onBinaryMessage = onBinaryMessage ?? null
|
||
this.onLifecycleIntent = onLifecycleIntent ?? null
|
||
this.downloadTokenStore = downloadTokenStore
|
||
this.pushTokenStore = pushTokenStore
|
||
this.paseoHome = paseoHome
|
||
this.agentManager = agentManager
|
||
this.agentStorage = agentStorage
|
||
this.createAgentMcpTransport = createAgentMcpTransport
|
||
this.terminalManager = terminalManager
|
||
if (this.terminalManager) {
|
||
this.unsubscribeTerminalsChanged = this.terminalManager.subscribeTerminalsChanged((event) =>
|
||
this.handleTerminalsChanged(event)
|
||
)
|
||
}
|
||
this.voiceAgentMcpStdio = voice?.voiceAgentMcpStdio ?? null
|
||
const configuredModelsDir = dictation?.localModels?.modelsDir?.trim()
|
||
this.localSpeechModelsDir =
|
||
configuredModelsDir && configuredModelsDir.length > 0
|
||
? configuredModelsDir
|
||
: join(this.paseoHome, 'models', 'local-speech')
|
||
this.defaultLocalSpeechModelIds =
|
||
dictation?.localModels?.defaultModelIds && dictation.localModels.defaultModelIds.length > 0
|
||
? [...new Set(dictation.localModels.defaultModelIds)]
|
||
: ['parakeet-tdt-0.6b-v2-int8', 'kokoro-en-v0_19']
|
||
this.registerVoiceSpeakHandler = voiceBridge?.registerVoiceSpeakHandler
|
||
this.unregisterVoiceSpeakHandler = voiceBridge?.unregisterVoiceSpeakHandler
|
||
this.registerVoiceCallerContext = voiceBridge?.registerVoiceCallerContext
|
||
this.unregisterVoiceCallerContext = voiceBridge?.unregisterVoiceCallerContext
|
||
this.ensureVoiceMcpSocketForAgent = voiceBridge?.ensureVoiceMcpSocketForAgent
|
||
this.removeVoiceMcpSocketForAgent = voiceBridge?.removeVoiceMcpSocketForAgent
|
||
this.getSpeechReadiness = dictation?.getSpeechReadiness
|
||
this.agentProviderRuntimeSettings = agentProviderRuntimeSettings
|
||
this.abortController = new AbortController()
|
||
this.sessionLogger = logger.child({
|
||
module: 'session',
|
||
clientId: this.clientId,
|
||
sessionId: this.sessionId,
|
||
})
|
||
this.providerRegistry = buildProviderRegistry(this.sessionLogger, {
|
||
runtimeSettings: this.agentProviderRuntimeSettings,
|
||
})
|
||
|
||
// Initialize per-session managers
|
||
this.ttsManager = new TTSManager(this.sessionId, this.sessionLogger, tts)
|
||
this.sttManager = new STTManager(this.sessionId, this.sessionLogger, stt)
|
||
this.dictationStreamManager = new DictationStreamManager({
|
||
logger: this.sessionLogger,
|
||
sessionId: this.sessionId,
|
||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||
stt: dictation?.stt ?? null,
|
||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||
})
|
||
this.voiceStreamManager = new DictationStreamManager({
|
||
logger: this.sessionLogger.child({ stream: 'voice-internal' }),
|
||
sessionId: this.sessionId,
|
||
emit: (msg) => this.handleDictationManagerMessage(msg),
|
||
stt: stt,
|
||
finalTimeoutMs: dictation?.finalTimeoutMs,
|
||
})
|
||
|
||
// Initialize agent MCP client asynchronously
|
||
void this.initializeAgentMcp()
|
||
this.subscribeToAgentEvents()
|
||
|
||
this.sessionLogger.trace('Session created')
|
||
}
|
||
|
||
/**
|
||
* Get the client's current activity state
|
||
*/
|
||
public getClientActivity(): {
|
||
deviceType: 'web' | 'mobile'
|
||
focusedAgentId: string | null
|
||
lastActivityAt: Date
|
||
appVisible: boolean
|
||
appVisibilityChangedAt: Date
|
||
} | null {
|
||
return this.clientActivity
|
||
}
|
||
|
||
/**
|
||
* Send initial state to client after connection
|
||
*/
|
||
public async sendInitialState(): Promise<void> {
|
||
// No unsolicited agent list hydration. Callers must use fetch_agents_request.
|
||
}
|
||
|
||
/**
|
||
* Normalize a user prompt (with optional image metadata) for AgentManager
|
||
*/
|
||
private buildAgentPrompt(
|
||
text: string,
|
||
images?: Array<{ data: string; mimeType: string }>
|
||
): AgentPromptInput {
|
||
const normalized = text?.trim() ?? ''
|
||
if (!images || images.length === 0) {
|
||
return normalized
|
||
}
|
||
const blocks: AgentPromptContentBlock[] = []
|
||
if (normalized.length > 0) {
|
||
blocks.push({ type: 'text', text: normalized })
|
||
}
|
||
for (const image of images) {
|
||
blocks.push({ type: 'image', data: image.data, mimeType: image.mimeType })
|
||
}
|
||
return blocks
|
||
}
|
||
|
||
/**
|
||
* Interrupt the agent's active run so the next prompt starts a fresh turn.
|
||
* Returns once the manager confirms the stream has been cancelled.
|
||
*/
|
||
private async interruptAgentIfRunning(agentId: string): Promise<void> {
|
||
const snapshot = this.agentManager.getAgent(agentId)
|
||
if (!snapshot) {
|
||
this.sessionLogger.trace({ agentId }, 'interruptAgentIfRunning: agent not found')
|
||
throw new Error(`Agent ${agentId} not found`)
|
||
}
|
||
|
||
if (snapshot.lifecycle !== 'running' && !snapshot.pendingRun) {
|
||
this.sessionLogger.trace(
|
||
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
|
||
'interruptAgentIfRunning: skipping because agent is not running'
|
||
)
|
||
return
|
||
}
|
||
|
||
this.sessionLogger.debug(
|
||
{ agentId, lifecycle: snapshot.lifecycle, pendingRun: Boolean(snapshot.pendingRun) },
|
||
'interruptAgentIfRunning: interrupting'
|
||
)
|
||
|
||
try {
|
||
const t0 = Date.now()
|
||
const cancelled = await this.agentManager.cancelAgentRun(agentId)
|
||
this.sessionLogger.debug(
|
||
{ agentId, cancelled, durationMs: Date.now() - t0 },
|
||
'interruptAgentIfRunning: cancelAgentRun completed'
|
||
)
|
||
if (!cancelled) {
|
||
this.sessionLogger.warn(
|
||
{ agentId },
|
||
'interruptAgentIfRunning: reported running but no active run was cancelled'
|
||
)
|
||
}
|
||
} catch (error) {
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private hasActiveAgentRun(agentId: string | null): boolean {
|
||
if (!agentId) {
|
||
return false
|
||
}
|
||
|
||
const snapshot = this.agentManager.getAgent(agentId)
|
||
if (!snapshot) {
|
||
return false
|
||
}
|
||
|
||
return snapshot.lifecycle === 'running' || Boolean(snapshot.pendingRun)
|
||
}
|
||
|
||
/**
|
||
* Start streaming an agent run and forward results via the websocket broadcast
|
||
*/
|
||
private startAgentStream(
|
||
agentId: string,
|
||
prompt: AgentPromptInput,
|
||
runOptions?: AgentRunOptions
|
||
): { ok: true } | { ok: false; error: string } {
|
||
this.sessionLogger.trace(
|
||
{
|
||
agentId,
|
||
promptType: typeof prompt === 'string' ? 'string' : 'structured',
|
||
hasRunOptions: Boolean(runOptions),
|
||
},
|
||
'startAgentStream: requested'
|
||
)
|
||
let iterator: AsyncGenerator<AgentStreamEvent>
|
||
try {
|
||
iterator = this.agentManager.streamAgent(agentId, prompt, runOptions)
|
||
this.sessionLogger.trace({ agentId }, 'startAgentStream: streamAgent returned iterator')
|
||
} catch (error) {
|
||
this.handleAgentRunError(agentId, error, 'Failed to start agent run')
|
||
const message =
|
||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
||
return { ok: false, error: message }
|
||
}
|
||
|
||
void (async () => {
|
||
try {
|
||
for await (const _ of iterator) {
|
||
// Events are forwarded via the session's AgentManager subscription.
|
||
}
|
||
this.sessionLogger.trace({ agentId }, 'startAgentStream: iterator drained')
|
||
} catch (error) {
|
||
this.sessionLogger.trace({ agentId, err: error }, 'startAgentStream: iterator threw')
|
||
this.handleAgentRunError(agentId, error, 'Agent stream failed')
|
||
}
|
||
})()
|
||
|
||
return { ok: true }
|
||
}
|
||
|
||
private handleAgentRunError(agentId: string, error: unknown, context: string): void {
|
||
const message =
|
||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
||
this.sessionLogger.error({ err: error, agentId, context }, `${context} for agent ${agentId}`)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `${context}: ${message}`,
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Initialize Agent MCP client for this session using in-memory transport
|
||
*/
|
||
private async initializeAgentMcp(): Promise<void> {
|
||
try {
|
||
// Create an in-memory transport connected to the Agent MCP server
|
||
const transport = await this.createAgentMcpTransport()
|
||
|
||
this.agentMcpClient = await experimental_createMCPClient({
|
||
transport,
|
||
})
|
||
|
||
this.agentTools = (await this.agentMcpClient.tools()) as ToolSet
|
||
const agentToolCount = Object.keys(this.agentTools ?? {}).length
|
||
this.sessionLogger.trace(
|
||
{ agentToolCount },
|
||
`Agent MCP initialized with ${agentToolCount} tools`
|
||
)
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to initialize Agent MCP')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Subscribe to AgentManager events and forward them to the client
|
||
*/
|
||
private subscribeToAgentEvents(): void {
|
||
if (this.unsubscribeAgentEvents) {
|
||
this.unsubscribeAgentEvents()
|
||
}
|
||
|
||
this.unsubscribeAgentEvents = this.agentManager.subscribe(
|
||
(event) => {
|
||
if (event.type === 'agent_state') {
|
||
void this.forwardAgentUpdate(event.agent)
|
||
return
|
||
}
|
||
|
||
if (
|
||
this.isVoiceMode &&
|
||
this.voiceModeAgentId === event.agentId &&
|
||
event.event.type === 'permission_requested' &&
|
||
isVoicePermissionAllowed(event.event.request)
|
||
) {
|
||
const requestId = event.event.request.id
|
||
void this.agentManager
|
||
.respondToPermission(event.agentId, requestId, {
|
||
behavior: 'allow',
|
||
})
|
||
.catch((error) => {
|
||
this.sessionLogger.warn(
|
||
{
|
||
err: error,
|
||
agentId: event.agentId,
|
||
requestId,
|
||
},
|
||
'Failed to auto-allow speak tool permission in voice mode'
|
||
)
|
||
})
|
||
}
|
||
|
||
// Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events
|
||
// for the focused agent, with a short grace window while backgrounded.
|
||
// History catch-up is handled via pull-based `fetch_agent_timeline_request`.
|
||
const activity = this.clientActivity
|
||
if (activity?.deviceType === 'mobile') {
|
||
if (!activity.focusedAgentId) {
|
||
return
|
||
}
|
||
if (activity.focusedAgentId !== event.agentId) {
|
||
return
|
||
}
|
||
if (!activity.appVisible) {
|
||
const hiddenForMs = Date.now() - activity.appVisibilityChangedAt.getTime()
|
||
if (hiddenForMs >= this.MOBILE_BACKGROUND_STREAM_GRACE_MS) {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
const serializedEvent = serializeAgentStreamEvent(event.event)
|
||
if (!serializedEvent) {
|
||
return
|
||
}
|
||
|
||
const payload = {
|
||
agentId: event.agentId,
|
||
event: serializedEvent,
|
||
timestamp: new Date().toISOString(),
|
||
...(typeof event.seq === 'number' ? { seq: event.seq } : {}),
|
||
...(typeof event.epoch === 'string' ? { epoch: event.epoch } : {}),
|
||
} as const
|
||
|
||
this.emit({
|
||
type: 'agent_stream',
|
||
payload,
|
||
})
|
||
|
||
if (event.event.type === 'permission_requested') {
|
||
this.emit({
|
||
type: 'agent_permission_request',
|
||
payload: {
|
||
agentId: event.agentId,
|
||
request: event.event.request,
|
||
},
|
||
})
|
||
} else if (event.event.type === 'permission_resolved') {
|
||
this.emit({
|
||
type: 'agent_permission_resolved',
|
||
payload: {
|
||
agentId: event.agentId,
|
||
requestId: event.event.requestId,
|
||
resolution: event.event.resolution,
|
||
},
|
||
})
|
||
}
|
||
|
||
// Title updates may be applied asynchronously after agent creation.
|
||
},
|
||
{ replayState: false }
|
||
)
|
||
}
|
||
|
||
private async buildAgentPayload(agent: ManagedAgent): Promise<AgentSnapshotPayload> {
|
||
const storedRecord = await this.agentStorage.get(agent.id)
|
||
const title = storedRecord?.title ?? storedRecord?.config?.title ?? null
|
||
const payload = toAgentPayload(agent, { title })
|
||
payload.archivedAt = storedRecord?.archivedAt ?? null
|
||
return payload
|
||
}
|
||
|
||
private buildStoredAgentPayload(record: StoredAgentRecord): AgentSnapshotPayload {
|
||
const defaultCapabilities = {
|
||
supportsStreaming: false,
|
||
supportsSessionPersistence: true,
|
||
supportsDynamicModes: false,
|
||
supportsMcpServers: false,
|
||
supportsReasoningStream: false,
|
||
supportsToolInvocations: true,
|
||
} as const
|
||
|
||
const createdAt = new Date(record.createdAt)
|
||
const updatedAt = new Date(record.lastActivityAt ?? record.updatedAt)
|
||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null
|
||
|
||
const provider = coerceAgentProvider(this.sessionLogger, record.provider, record.id)
|
||
const runtimeInfo = record.runtimeInfo
|
||
? {
|
||
provider: coerceAgentProvider(this.sessionLogger, record.runtimeInfo.provider, record.id),
|
||
sessionId: record.runtimeInfo.sessionId,
|
||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, 'model')
|
||
? { model: record.runtimeInfo.model ?? null }
|
||
: {}),
|
||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, 'thinkingOptionId')
|
||
? { thinkingOptionId: record.runtimeInfo.thinkingOptionId ?? null }
|
||
: {}),
|
||
...(Object.prototype.hasOwnProperty.call(record.runtimeInfo, 'modeId')
|
||
? { modeId: record.runtimeInfo.modeId ?? null }
|
||
: {}),
|
||
...(record.runtimeInfo.extra ? { extra: record.runtimeInfo.extra } : {}),
|
||
}
|
||
: undefined
|
||
return {
|
||
id: record.id,
|
||
provider,
|
||
cwd: record.cwd,
|
||
model: record.config?.model ?? null,
|
||
thinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||
effectiveThinkingOptionId: resolveEffectiveThinkingOptionId({
|
||
runtimeInfo,
|
||
configuredThinkingOptionId: record.config?.thinkingOptionId ?? null,
|
||
}),
|
||
...(runtimeInfo ? { runtimeInfo } : {}),
|
||
createdAt: createdAt.toISOString(),
|
||
updatedAt: updatedAt.toISOString(),
|
||
lastUserMessageAt: lastUserMessageAt ? lastUserMessageAt.toISOString() : null,
|
||
status: record.lastStatus,
|
||
capabilities: defaultCapabilities,
|
||
currentModeId: record.lastModeId ?? null,
|
||
availableModes: [],
|
||
pendingPermissions: [],
|
||
persistence: toAgentPersistenceHandle(this.sessionLogger, record.persistence),
|
||
lastUsage: undefined,
|
||
lastError: undefined,
|
||
title: record.title ?? record.config?.title ?? null,
|
||
requiresAttention: record.requiresAttention ?? false,
|
||
attentionReason: record.attentionReason ?? null,
|
||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||
archivedAt: record.archivedAt ?? null,
|
||
labels: record.labels,
|
||
}
|
||
}
|
||
|
||
private async ensureAgentLoaded(agentId: string): Promise<ManagedAgent> {
|
||
const existing = this.agentManager.getAgent(agentId)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
|
||
const inflight = pendingAgentInitializations.get(agentId)
|
||
if (inflight) {
|
||
return inflight
|
||
}
|
||
|
||
const initPromise = (async () => {
|
||
const record = await this.agentStorage.get(agentId)
|
||
if (!record) {
|
||
throw new Error(`Agent not found: ${agentId}`)
|
||
}
|
||
|
||
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence)
|
||
let snapshot: ManagedAgent
|
||
if (handle) {
|
||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||
handle,
|
||
buildConfigOverrides(record),
|
||
agentId,
|
||
extractTimestamps(record)
|
||
)
|
||
this.sessionLogger.info(
|
||
{ agentId, provider: record.provider },
|
||
'Agent resumed from persistence'
|
||
)
|
||
} else {
|
||
const config = buildSessionConfig(record)
|
||
snapshot = await this.agentManager.createAgent(config, agentId, { labels: record.labels })
|
||
this.sessionLogger.info(
|
||
{ agentId, provider: record.provider },
|
||
'Agent created from stored config'
|
||
)
|
||
}
|
||
|
||
await this.agentManager.hydrateTimelineFromProvider(agentId)
|
||
return this.agentManager.getAgent(agentId) ?? snapshot
|
||
})()
|
||
|
||
pendingAgentInitializations.set(agentId, initPromise)
|
||
|
||
try {
|
||
return await initPromise
|
||
} finally {
|
||
const current = pendingAgentInitializations.get(agentId)
|
||
if (current === initPromise) {
|
||
pendingAgentInitializations.delete(agentId)
|
||
}
|
||
}
|
||
}
|
||
|
||
private matchesAgentFilter(options: {
|
||
agent: AgentSnapshotPayload
|
||
project: ProjectPlacementPayload
|
||
filter?: AgentUpdatesFilter
|
||
}): boolean {
|
||
const { agent, project, filter } = options
|
||
|
||
if (filter?.labels) {
|
||
const matchesLabels = Object.entries(filter.labels).every(
|
||
([key, value]) => agent.labels[key] === value
|
||
)
|
||
if (!matchesLabels) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
const includeArchived = filter?.includeArchived ?? false
|
||
if (!includeArchived && agent.archivedAt) {
|
||
return false
|
||
}
|
||
|
||
if (filter?.thinkingOptionId !== undefined) {
|
||
const expectedThinkingOptionId = resolveEffectiveThinkingOptionId({
|
||
configuredThinkingOptionId: filter.thinkingOptionId ?? null,
|
||
})
|
||
const resolvedThinkingOptionId =
|
||
agent.effectiveThinkingOptionId ??
|
||
resolveEffectiveThinkingOptionId({
|
||
runtimeInfo: agent.runtimeInfo,
|
||
configuredThinkingOptionId: agent.thinkingOptionId ?? null,
|
||
})
|
||
if (resolvedThinkingOptionId !== expectedThinkingOptionId) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
if (filter?.statuses && filter.statuses.length > 0) {
|
||
const statuses = new Set(filter.statuses)
|
||
if (!statuses.has(agent.status)) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
if (typeof filter?.requiresAttention === 'boolean') {
|
||
const requiresAttention = agent.requiresAttention ?? false
|
||
if (requiresAttention !== filter.requiresAttention) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
if (filter?.projectKeys && filter.projectKeys.length > 0) {
|
||
const projectKeys = new Set(filter.projectKeys.filter((item) => item.trim().length > 0))
|
||
if (projectKeys.size > 0 && !projectKeys.has(project.projectKey)) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
private getAgentUpdateTargetId(update: AgentUpdatePayload): string {
|
||
return update.kind === 'remove' ? update.agentId : update.agent.id
|
||
}
|
||
|
||
private bufferOrEmitAgentUpdate(
|
||
subscription: AgentUpdatesSubscriptionState,
|
||
payload: AgentUpdatePayload
|
||
): void {
|
||
if (subscription.isBootstrapping) {
|
||
subscription.pendingUpdatesByAgentId.set(this.getAgentUpdateTargetId(payload), payload)
|
||
return
|
||
}
|
||
|
||
this.emit({
|
||
type: 'agent_update',
|
||
payload,
|
||
})
|
||
}
|
||
|
||
private flushBootstrappedAgentUpdates(options?: {
|
||
snapshotUpdatedAtByAgentId?: Map<string, number>
|
||
}): void {
|
||
const subscription = this.agentUpdatesSubscription
|
||
if (!subscription || !subscription.isBootstrapping) {
|
||
return
|
||
}
|
||
|
||
subscription.isBootstrapping = false
|
||
const pending = Array.from(subscription.pendingUpdatesByAgentId.values())
|
||
subscription.pendingUpdatesByAgentId.clear()
|
||
|
||
for (const payload of pending) {
|
||
if (payload.kind === 'upsert') {
|
||
const snapshotUpdatedAt = options?.snapshotUpdatedAtByAgentId?.get(payload.agent.id)
|
||
if (typeof snapshotUpdatedAt === 'number') {
|
||
const updateUpdatedAt = Date.parse(payload.agent.updatedAt)
|
||
if (!Number.isNaN(updateUpdatedAt) && updateUpdatedAt <= snapshotUpdatedAt) {
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
|
||
this.emit({
|
||
type: 'agent_update',
|
||
payload,
|
||
})
|
||
}
|
||
}
|
||
|
||
private buildFallbackProjectCheckout(cwd: string): ProjectCheckoutLitePayload {
|
||
return {
|
||
cwd,
|
||
isGit: false,
|
||
currentBranch: null,
|
||
remoteUrl: null,
|
||
isPaseoOwnedWorktree: false,
|
||
mainRepoRoot: null,
|
||
}
|
||
}
|
||
|
||
private toProjectCheckoutLite(
|
||
cwd: string,
|
||
status: Awaited<ReturnType<typeof getCheckoutStatusLite>>
|
||
): ProjectCheckoutLitePayload {
|
||
if (!status.isGit) {
|
||
return this.buildFallbackProjectCheckout(cwd)
|
||
}
|
||
|
||
if (status.isPaseoOwnedWorktree) {
|
||
return {
|
||
cwd,
|
||
isGit: true,
|
||
currentBranch: status.currentBranch,
|
||
remoteUrl: status.remoteUrl,
|
||
isPaseoOwnedWorktree: true,
|
||
mainRepoRoot: status.mainRepoRoot,
|
||
}
|
||
}
|
||
|
||
return {
|
||
cwd,
|
||
isGit: true,
|
||
currentBranch: status.currentBranch,
|
||
remoteUrl: status.remoteUrl,
|
||
isPaseoOwnedWorktree: false,
|
||
mainRepoRoot: null,
|
||
}
|
||
}
|
||
|
||
private async buildProjectPlacement(cwd: string): Promise<ProjectPlacementPayload> {
|
||
const checkout = await getCheckoutStatusLite(cwd, { paseoHome: this.paseoHome })
|
||
.then((status) => this.toProjectCheckoutLite(cwd, status))
|
||
.catch(() => this.buildFallbackProjectCheckout(cwd))
|
||
const projectKey = deriveProjectGroupingKey({
|
||
cwd,
|
||
remoteUrl: checkout.remoteUrl,
|
||
isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree,
|
||
mainRepoRoot: checkout.mainRepoRoot,
|
||
})
|
||
return {
|
||
projectKey,
|
||
projectName: deriveProjectGroupingName(projectKey),
|
||
checkout,
|
||
}
|
||
}
|
||
|
||
private async forwardAgentUpdate(agent: ManagedAgent): Promise<void> {
|
||
try {
|
||
const subscription = this.agentUpdatesSubscription
|
||
const payload = await this.buildAgentPayload(agent)
|
||
if (subscription) {
|
||
const project = await this.buildProjectPlacement(payload.cwd)
|
||
const matches = this.matchesAgentFilter({
|
||
agent: payload,
|
||
project,
|
||
filter: subscription.filter,
|
||
})
|
||
|
||
if (matches) {
|
||
this.bufferOrEmitAgentUpdate(subscription, {
|
||
kind: 'upsert',
|
||
agent: payload,
|
||
project,
|
||
})
|
||
} else {
|
||
this.bufferOrEmitAgentUpdate(subscription, {
|
||
kind: 'remove',
|
||
agentId: payload.id,
|
||
})
|
||
}
|
||
}
|
||
|
||
await this.emitWorkspaceUpdateForCwd(payload.cwd)
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to emit agent update')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Main entry point for processing session messages
|
||
*/
|
||
public async handleMessage(msg: SessionInboundMessage): Promise<void> {
|
||
try {
|
||
switch (msg.type) {
|
||
case 'voice_audio_chunk':
|
||
await this.handleAudioChunk(msg)
|
||
break
|
||
|
||
case 'abort_request':
|
||
await this.handleAbort()
|
||
break
|
||
|
||
case 'audio_played':
|
||
this.handleAudioPlayed(msg.id)
|
||
break
|
||
|
||
case 'fetch_agents_request':
|
||
await this.handleFetchAgents(msg)
|
||
break
|
||
|
||
case 'fetch_workspaces_request':
|
||
await this.handleFetchWorkspacesRequest(msg)
|
||
break
|
||
|
||
case 'fetch_agent_request':
|
||
await this.handleFetchAgent(msg.agentId, msg.requestId)
|
||
break
|
||
|
||
case 'delete_agent_request':
|
||
await this.handleDeleteAgentRequest(msg.agentId, msg.requestId)
|
||
break
|
||
|
||
case 'archive_agent_request':
|
||
await this.handleArchiveAgentRequest(msg.agentId, msg.requestId)
|
||
break
|
||
|
||
case 'update_agent_request':
|
||
await this.handleUpdateAgentRequest(msg.agentId, msg.name, msg.labels, msg.requestId)
|
||
break
|
||
|
||
case 'set_voice_mode':
|
||
await this.handleSetVoiceMode(msg.enabled, msg.agentId, msg.requestId)
|
||
break
|
||
|
||
case 'send_agent_message_request':
|
||
await this.handleSendAgentMessageRequest(msg)
|
||
break
|
||
|
||
case 'wait_for_finish_request':
|
||
await this.handleWaitForFinish(msg.agentId, msg.requestId, msg.timeoutMs)
|
||
break
|
||
|
||
case 'dictation_stream_start':
|
||
{
|
||
const unavailable = this.resolveVoiceFeatureUnavailableContext('dictation')
|
||
if (unavailable) {
|
||
this.emit({
|
||
type: 'dictation_stream_error',
|
||
payload: {
|
||
dictationId: msg.dictationId,
|
||
error: unavailable.message,
|
||
retryable: unavailable.retryable,
|
||
reasonCode: unavailable.reasonCode,
|
||
missingModelIds: unavailable.missingModelIds,
|
||
},
|
||
})
|
||
break
|
||
}
|
||
}
|
||
await this.dictationStreamManager.handleStart(msg.dictationId, msg.format)
|
||
break
|
||
|
||
case 'dictation_stream_chunk':
|
||
await this.dictationStreamManager.handleChunk({
|
||
dictationId: msg.dictationId,
|
||
seq: msg.seq,
|
||
audioBase64: msg.audio,
|
||
format: msg.format,
|
||
})
|
||
break
|
||
|
||
case 'dictation_stream_finish':
|
||
await this.dictationStreamManager.handleFinish(msg.dictationId, msg.finalSeq)
|
||
break
|
||
|
||
case 'dictation_stream_cancel':
|
||
this.dictationStreamManager.handleCancel(msg.dictationId)
|
||
break
|
||
|
||
case 'create_agent_request':
|
||
await this.handleCreateAgentRequest(msg)
|
||
break
|
||
|
||
case 'resume_agent_request':
|
||
await this.handleResumeAgentRequest(msg)
|
||
break
|
||
|
||
case 'refresh_agent_request':
|
||
await this.handleRefreshAgentRequest(msg)
|
||
break
|
||
|
||
case 'cancel_agent_request':
|
||
await this.handleCancelAgentRequest(msg.agentId)
|
||
break
|
||
|
||
case 'restart_server_request':
|
||
await this.handleRestartServerRequest(msg.requestId, msg.reason)
|
||
break
|
||
|
||
case 'shutdown_server_request':
|
||
await this.handleShutdownServerRequest(msg.requestId)
|
||
break
|
||
|
||
case 'fetch_agent_timeline_request':
|
||
await this.handleFetchAgentTimelineRequest(msg)
|
||
break
|
||
|
||
case 'set_agent_mode_request':
|
||
await this.handleSetAgentModeRequest(msg.agentId, msg.modeId, msg.requestId)
|
||
break
|
||
|
||
case 'set_agent_model_request':
|
||
await this.handleSetAgentModelRequest(msg.agentId, msg.modelId, msg.requestId)
|
||
break
|
||
|
||
case 'set_agent_thinking_request':
|
||
await this.handleSetAgentThinkingRequest(msg.agentId, msg.thinkingOptionId, msg.requestId)
|
||
break
|
||
|
||
case 'agent_permission_response':
|
||
await this.handleAgentPermissionResponse(msg.agentId, msg.requestId, msg.response)
|
||
break
|
||
|
||
case 'checkout_status_request':
|
||
await this.handleCheckoutStatusRequest(msg)
|
||
break
|
||
|
||
case 'validate_branch_request':
|
||
await this.handleValidateBranchRequest(msg)
|
||
break
|
||
|
||
case 'branch_suggestions_request':
|
||
await this.handleBranchSuggestionsRequest(msg)
|
||
break
|
||
|
||
case 'directory_suggestions_request':
|
||
await this.handleDirectorySuggestionsRequest(msg)
|
||
break
|
||
|
||
case 'subscribe_checkout_diff_request':
|
||
await this.handleSubscribeCheckoutDiffRequest(msg)
|
||
break
|
||
|
||
case 'unsubscribe_checkout_diff_request':
|
||
this.handleUnsubscribeCheckoutDiffRequest(msg)
|
||
break
|
||
|
||
case 'checkout_commit_request':
|
||
await this.handleCheckoutCommitRequest(msg)
|
||
break
|
||
|
||
case 'checkout_merge_request':
|
||
await this.handleCheckoutMergeRequest(msg)
|
||
break
|
||
|
||
case 'checkout_merge_from_base_request':
|
||
await this.handleCheckoutMergeFromBaseRequest(msg)
|
||
break
|
||
|
||
case 'checkout_push_request':
|
||
await this.handleCheckoutPushRequest(msg)
|
||
break
|
||
|
||
case 'checkout_pr_create_request':
|
||
await this.handleCheckoutPrCreateRequest(msg)
|
||
break
|
||
|
||
case 'checkout_pr_status_request':
|
||
await this.handleCheckoutPrStatusRequest(msg)
|
||
break
|
||
|
||
case 'paseo_worktree_list_request':
|
||
await this.handlePaseoWorktreeListRequest(msg)
|
||
break
|
||
|
||
case 'paseo_worktree_archive_request':
|
||
await this.handlePaseoWorktreeArchiveRequest(msg)
|
||
break
|
||
|
||
case 'file_explorer_request':
|
||
await this.handleFileExplorerRequest(msg)
|
||
break
|
||
|
||
case 'project_icon_request':
|
||
await this.handleProjectIconRequest(msg)
|
||
break
|
||
|
||
case 'file_download_token_request':
|
||
await this.handleFileDownloadTokenRequest(msg)
|
||
break
|
||
|
||
case 'list_provider_models_request':
|
||
await this.handleListProviderModelsRequest(msg)
|
||
break
|
||
|
||
case 'list_available_providers_request':
|
||
await this.handleListAvailableProvidersRequest(msg)
|
||
break
|
||
|
||
case 'speech_models_list_request':
|
||
await this.handleSpeechModelsListRequest(msg)
|
||
break
|
||
|
||
case 'speech_models_download_request':
|
||
await this.handleSpeechModelsDownloadRequest(msg)
|
||
break
|
||
|
||
case 'clear_agent_attention':
|
||
await this.handleClearAgentAttention(msg.agentId)
|
||
break
|
||
|
||
case 'client_heartbeat':
|
||
this.handleClientHeartbeat(msg)
|
||
break
|
||
|
||
case 'ping': {
|
||
const now = Date.now()
|
||
this.emit({
|
||
type: 'pong',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
clientSentAt: msg.clientSentAt,
|
||
serverReceivedAt: now,
|
||
serverSentAt: now,
|
||
},
|
||
})
|
||
break
|
||
}
|
||
|
||
case 'list_commands_request':
|
||
await this.handleListCommandsRequest(msg)
|
||
break
|
||
|
||
case 'register_push_token':
|
||
this.handleRegisterPushToken(msg.token)
|
||
break
|
||
|
||
case 'subscribe_terminals_request':
|
||
this.handleSubscribeTerminalsRequest(msg)
|
||
break
|
||
|
||
case 'unsubscribe_terminals_request':
|
||
this.handleUnsubscribeTerminalsRequest(msg)
|
||
break
|
||
|
||
case 'list_terminals_request':
|
||
await this.handleListTerminalsRequest(msg)
|
||
break
|
||
|
||
case 'create_terminal_request':
|
||
await this.handleCreateTerminalRequest(msg)
|
||
break
|
||
|
||
case 'subscribe_terminal_request':
|
||
await this.handleSubscribeTerminalRequest(msg)
|
||
break
|
||
|
||
case 'unsubscribe_terminal_request':
|
||
this.handleUnsubscribeTerminalRequest(msg)
|
||
break
|
||
|
||
case 'terminal_input':
|
||
this.handleTerminalInput(msg)
|
||
break
|
||
|
||
case 'kill_terminal_request':
|
||
await this.handleKillTerminalRequest(msg)
|
||
break
|
||
|
||
case 'attach_terminal_stream_request':
|
||
await this.handleAttachTerminalStreamRequest(msg)
|
||
break
|
||
|
||
case 'detach_terminal_stream_request':
|
||
this.handleDetachTerminalStreamRequest(msg)
|
||
break
|
||
}
|
||
} catch (error: any) {
|
||
const err = error instanceof Error ? error : new Error(String(error))
|
||
this.sessionLogger.error({ err }, 'Error handling message')
|
||
|
||
const requestId = (msg as { requestId?: unknown }).requestId
|
||
if (typeof requestId === 'string') {
|
||
try {
|
||
this.emit({
|
||
type: 'rpc_error',
|
||
payload: {
|
||
requestId,
|
||
requestType: msg.type,
|
||
error: 'Request failed',
|
||
code: 'handler_error',
|
||
},
|
||
})
|
||
} catch (emitError) {
|
||
this.sessionLogger.error({ err: emitError }, 'Failed to emit rpc_error')
|
||
}
|
||
}
|
||
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Error: ${err.message}`,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
public handleBinaryFrame(frame: BinaryMuxFrame): void {
|
||
switch (frame.channel) {
|
||
case BinaryMuxChannel.Terminal:
|
||
this.handleTerminalBinaryFrame(frame)
|
||
break
|
||
default:
|
||
this.sessionLogger.warn(
|
||
{ channel: frame.channel, messageType: frame.messageType },
|
||
'Unhandled binary mux channel'
|
||
)
|
||
break
|
||
}
|
||
}
|
||
|
||
private handleTerminalBinaryFrame(frame: BinaryMuxFrame): void {
|
||
if (frame.messageType === TerminalBinaryMessageType.InputUtf8) {
|
||
const binding = this.terminalStreams.get(frame.streamId)
|
||
if (!binding) {
|
||
this.sessionLogger.warn({ streamId: frame.streamId }, 'Terminal stream not found for input')
|
||
return
|
||
}
|
||
if (!this.terminalManager) {
|
||
return
|
||
}
|
||
const session = this.terminalManager.getTerminal(binding.terminalId)
|
||
if (!session) {
|
||
this.detachTerminalStream(frame.streamId, { emitExit: true })
|
||
return
|
||
}
|
||
|
||
const payload = frame.payload ?? new Uint8Array(0)
|
||
if (payload.byteLength === 0) {
|
||
return
|
||
}
|
||
const text = Buffer.from(payload).toString('utf8')
|
||
if (!text) {
|
||
return
|
||
}
|
||
session.send({ type: 'input', data: text })
|
||
return
|
||
}
|
||
|
||
if (frame.messageType === TerminalBinaryMessageType.Ack) {
|
||
const binding = this.terminalStreams.get(frame.streamId)
|
||
if (binding) {
|
||
if (!Number.isFinite(frame.offset) || frame.offset < 0) {
|
||
return
|
||
}
|
||
const nextAckOffset = Math.max(
|
||
binding.lastAckOffset,
|
||
Math.min(Math.floor(frame.offset), binding.lastOutputOffset)
|
||
)
|
||
if (nextAckOffset > binding.lastAckOffset) {
|
||
binding.lastAckOffset = nextAckOffset
|
||
this.flushPendingTerminalStreamChunks(frame.streamId, binding)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
this.sessionLogger.warn(
|
||
{ streamId: frame.streamId, messageType: frame.messageType },
|
||
'Unhandled terminal binary frame'
|
||
)
|
||
}
|
||
|
||
private async handleRestartServerRequest(requestId: string, reason?: string): Promise<void> {
|
||
const payload: { status: string } & Record<string, unknown> = {
|
||
status: 'restart_requested',
|
||
clientId: this.clientId,
|
||
}
|
||
if (reason && reason.trim().length > 0) {
|
||
payload.reason = reason
|
||
}
|
||
payload.requestId = requestId
|
||
|
||
this.sessionLogger.warn({ reason }, 'Restart requested via websocket')
|
||
this.emit({
|
||
type: 'status',
|
||
payload,
|
||
})
|
||
|
||
this.emitLifecycleIntent({
|
||
type: 'restart',
|
||
clientId: this.clientId,
|
||
requestId,
|
||
...(reason ? { reason } : {}),
|
||
})
|
||
}
|
||
|
||
private async handleShutdownServerRequest(requestId: string): Promise<void> {
|
||
this.sessionLogger.warn('Shutdown requested via websocket')
|
||
this.emit({
|
||
type: 'status',
|
||
payload: {
|
||
status: 'shutdown_requested',
|
||
clientId: this.clientId,
|
||
requestId,
|
||
},
|
||
})
|
||
|
||
this.emitLifecycleIntent({
|
||
type: 'shutdown',
|
||
clientId: this.clientId,
|
||
requestId,
|
||
})
|
||
}
|
||
|
||
private emitLifecycleIntent(intent: SessionLifecycleIntent): void {
|
||
if (!this.onLifecycleIntent) {
|
||
return
|
||
}
|
||
try {
|
||
this.onLifecycleIntent(intent)
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error, intent }, 'Lifecycle intent handler failed')
|
||
}
|
||
}
|
||
|
||
private async handleDeleteAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||
this.sessionLogger.info({ agentId }, `Deleting agent ${agentId} from registry`)
|
||
|
||
const knownCwd =
|
||
this.agentManager.getAgent(agentId)?.cwd ??
|
||
(await this.agentStorage.get(agentId))?.cwd ??
|
||
null
|
||
|
||
// Prevent the persistence hook from re-creating the record while we close/delete.
|
||
this.agentStorage.beginDelete(agentId)
|
||
|
||
try {
|
||
await this.agentManager.closeAgent(agentId)
|
||
} catch (error: any) {
|
||
this.sessionLogger.warn(
|
||
{ err: error, agentId },
|
||
`Failed to close agent ${agentId} during delete`
|
||
)
|
||
}
|
||
|
||
try {
|
||
await this.agentStorage.remove(agentId)
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId },
|
||
`Failed to remove agent ${agentId} from registry`
|
||
)
|
||
}
|
||
|
||
this.emit({
|
||
type: 'agent_deleted',
|
||
payload: {
|
||
agentId,
|
||
requestId,
|
||
},
|
||
})
|
||
|
||
if (this.agentUpdatesSubscription) {
|
||
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
|
||
kind: 'remove',
|
||
agentId,
|
||
})
|
||
}
|
||
|
||
if (knownCwd) {
|
||
await this.emitWorkspaceUpdateForCwd(knownCwd)
|
||
}
|
||
}
|
||
|
||
private async handleArchiveAgentRequest(agentId: string, requestId: string): Promise<void> {
|
||
this.sessionLogger.info({ agentId }, `Archiving agent ${agentId}`)
|
||
|
||
const { archivedAt, archivedRecord } = await this.archiveAgentState(agentId)
|
||
|
||
this.emit({
|
||
type: 'agent_archived',
|
||
payload: {
|
||
agentId,
|
||
archivedAt,
|
||
requestId,
|
||
},
|
||
})
|
||
|
||
await this.maybeArchiveWorktreeAfterLastAgentArchived({
|
||
archivedAgentId: agentId,
|
||
archivedAgentCwd: archivedRecord.cwd,
|
||
requestId,
|
||
})
|
||
}
|
||
|
||
private async archiveAgentState(agentId: string): Promise<{
|
||
archivedAt: string
|
||
archivedRecord: StoredAgentRecord
|
||
}> {
|
||
if (this.agentManager.getAgent(agentId)) {
|
||
await this.interruptAgentIfRunning(agentId)
|
||
await this.agentManager.clearAgentAttention(agentId).catch(() => undefined)
|
||
}
|
||
|
||
const archivedAt = new Date().toISOString()
|
||
const existing = await this.agentStorage.get(agentId)
|
||
let archivedRecord: StoredAgentRecord | null = existing
|
||
if (!archivedRecord) {
|
||
const liveAgent = this.agentManager.getAgent(agentId)
|
||
if (!liveAgent) {
|
||
throw new Error(`Agent not found: ${agentId}`)
|
||
}
|
||
|
||
await this.agentStorage.applySnapshot(liveAgent, {
|
||
internal: liveAgent.internal,
|
||
})
|
||
archivedRecord = await this.agentStorage.get(agentId)
|
||
if (!archivedRecord) {
|
||
throw new Error(`Agent not found in storage after snapshot: ${agentId}`)
|
||
}
|
||
}
|
||
|
||
const normalizedStatus =
|
||
archivedRecord.lastStatus === 'running' || archivedRecord.lastStatus === 'initializing'
|
||
? 'idle'
|
||
: archivedRecord.lastStatus
|
||
|
||
const nextRecord: StoredAgentRecord = {
|
||
...archivedRecord,
|
||
archivedAt,
|
||
lastStatus: normalizedStatus,
|
||
requiresAttention: false,
|
||
attentionReason: null,
|
||
attentionTimestamp: null,
|
||
}
|
||
await this.agentStorage.upsert(nextRecord)
|
||
this.agentManager.notifyAgentState(agentId)
|
||
return { archivedAt, archivedRecord: nextRecord }
|
||
}
|
||
|
||
private async unarchiveAgentState(agentId: string): Promise<boolean> {
|
||
const record = await this.agentStorage.get(agentId)
|
||
if (!record || !record.archivedAt) {
|
||
return false
|
||
}
|
||
await this.agentStorage.upsert({
|
||
...record,
|
||
archivedAt: null,
|
||
})
|
||
this.agentManager.notifyAgentState(agentId)
|
||
return true
|
||
}
|
||
|
||
private async unarchiveAgentByHandle(handle: AgentPersistenceHandle): Promise<void> {
|
||
const records = await this.agentStorage.list()
|
||
const matched = records.find(
|
||
(record) =>
|
||
record.persistence?.provider === handle.provider &&
|
||
record.persistence?.sessionId === handle.sessionId
|
||
)
|
||
if (!matched) {
|
||
return
|
||
}
|
||
await this.unarchiveAgentState(matched.id)
|
||
}
|
||
|
||
private async handleUpdateAgentRequest(
|
||
agentId: string,
|
||
name: string | undefined,
|
||
labels: Record<string, string> | undefined,
|
||
requestId: string
|
||
): Promise<void> {
|
||
this.sessionLogger.info(
|
||
{
|
||
agentId,
|
||
requestId,
|
||
hasName: typeof name === 'string',
|
||
labelCount: labels ? Object.keys(labels).length : 0,
|
||
},
|
||
'session: update_agent_request'
|
||
)
|
||
|
||
const normalizedName = name?.trim()
|
||
const normalizedLabels = labels && Object.keys(labels).length > 0 ? labels : undefined
|
||
|
||
if (!normalizedName && !normalizedLabels) {
|
||
this.emit({
|
||
type: 'update_agent_response',
|
||
payload: {
|
||
requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: 'Nothing to update (provide name and/or labels)',
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const liveAgent = this.agentManager.getAgent(agentId)
|
||
if (liveAgent) {
|
||
if (normalizedName) {
|
||
await this.agentManager.setTitle(agentId, normalizedName)
|
||
}
|
||
if (normalizedLabels) {
|
||
await this.agentManager.setLabels(agentId, normalizedLabels)
|
||
}
|
||
} else {
|
||
const existing = await this.agentStorage.get(agentId)
|
||
if (!existing) {
|
||
throw new Error(`Agent not found: ${agentId}`)
|
||
}
|
||
|
||
await this.agentStorage.upsert({
|
||
...existing,
|
||
...(normalizedName ? { title: normalizedName } : {}),
|
||
...(normalizedLabels ? { labels: { ...existing.labels, ...normalizedLabels } } : {}),
|
||
})
|
||
}
|
||
|
||
this.emit({
|
||
type: 'update_agent_response',
|
||
payload: { requestId, agentId, accepted: true, error: null },
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId, requestId },
|
||
'session: update_agent_request error'
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to update agent: ${error.message}`,
|
||
},
|
||
})
|
||
this.emit({
|
||
type: 'update_agent_response',
|
||
payload: {
|
||
requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: error?.message ? String(error.message) : 'Failed to update agent',
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private toVoiceFeatureUnavailableContext(
|
||
state: SpeechReadinessState
|
||
): VoiceFeatureUnavailableContext {
|
||
return {
|
||
reasonCode: state.reasonCode,
|
||
message: state.message,
|
||
retryable: state.retryable,
|
||
missingModelIds: [...state.missingModelIds],
|
||
}
|
||
}
|
||
|
||
private resolveModeReadinessState(
|
||
readiness: SpeechReadinessSnapshot,
|
||
mode: 'voice_mode' | 'dictation'
|
||
): SpeechReadinessState {
|
||
if (mode === 'voice_mode') {
|
||
return readiness.realtimeVoice
|
||
}
|
||
return readiness.dictation
|
||
}
|
||
|
||
private getVoiceFeatureUnavailableResponseMetadata(
|
||
error: unknown
|
||
): VoiceFeatureUnavailableResponseMetadata {
|
||
if (!(error instanceof VoiceFeatureUnavailableError)) {
|
||
return {}
|
||
}
|
||
return {
|
||
reasonCode: error.reasonCode,
|
||
retryable: error.retryable,
|
||
missingModelIds: error.missingModelIds,
|
||
}
|
||
}
|
||
|
||
private resolveVoiceFeatureUnavailableContext(
|
||
mode: 'voice_mode' | 'dictation'
|
||
): VoiceFeatureUnavailableContext | null {
|
||
const readiness = this.getSpeechReadiness?.()
|
||
if (!readiness) {
|
||
return null
|
||
}
|
||
|
||
const modeReadiness = this.resolveModeReadinessState(readiness, mode)
|
||
if (!modeReadiness.enabled) {
|
||
return this.toVoiceFeatureUnavailableContext(modeReadiness)
|
||
}
|
||
if (!readiness.voiceFeature.available) {
|
||
return this.toVoiceFeatureUnavailableContext(readiness.voiceFeature)
|
||
}
|
||
if (!modeReadiness.available) {
|
||
return this.toVoiceFeatureUnavailableContext(modeReadiness)
|
||
}
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Handle voice mode toggle
|
||
*/
|
||
private async handleSetVoiceMode(
|
||
enabled: boolean,
|
||
agentId?: string,
|
||
requestId?: string
|
||
): Promise<void> {
|
||
try {
|
||
if (enabled) {
|
||
const unavailable = this.resolveVoiceFeatureUnavailableContext('voice_mode')
|
||
if (unavailable) {
|
||
throw new VoiceFeatureUnavailableError(unavailable)
|
||
}
|
||
|
||
const normalizedAgentId = this.parseVoiceTargetAgentId(agentId ?? '', 'set_voice_mode')
|
||
|
||
if (
|
||
this.isVoiceMode &&
|
||
this.voiceModeAgentId &&
|
||
this.voiceModeAgentId !== normalizedAgentId
|
||
) {
|
||
await this.disableVoiceModeForActiveAgent(true)
|
||
}
|
||
|
||
if (!this.isVoiceMode || this.voiceModeAgentId !== normalizedAgentId) {
|
||
const refreshedAgentId = await this.enableVoiceModeForAgent(normalizedAgentId)
|
||
this.voiceModeAgentId = refreshedAgentId
|
||
}
|
||
|
||
this.isVoiceMode = true
|
||
this.sessionLogger.info(
|
||
{
|
||
agentId: this.voiceModeAgentId,
|
||
},
|
||
'Voice mode enabled for existing agent'
|
||
)
|
||
if (requestId) {
|
||
this.emit({
|
||
type: 'set_voice_mode_response',
|
||
payload: {
|
||
requestId,
|
||
enabled: true,
|
||
agentId: this.voiceModeAgentId,
|
||
accepted: true,
|
||
error: null,
|
||
},
|
||
})
|
||
}
|
||
return
|
||
}
|
||
|
||
await this.disableVoiceModeForActiveAgent(true)
|
||
this.isVoiceMode = false
|
||
this.sessionLogger.info('Voice mode disabled')
|
||
if (requestId) {
|
||
this.emit({
|
||
type: 'set_voice_mode_response',
|
||
payload: {
|
||
requestId,
|
||
enabled: false,
|
||
agentId: null,
|
||
accepted: true,
|
||
error: null,
|
||
},
|
||
})
|
||
}
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : 'Failed to set voice mode'
|
||
const unavailable = this.getVoiceFeatureUnavailableResponseMetadata(error)
|
||
this.sessionLogger.error(
|
||
{
|
||
err: error,
|
||
enabled,
|
||
requestedAgentId: agentId ?? null,
|
||
},
|
||
'set_voice_mode failed'
|
||
)
|
||
if (requestId) {
|
||
this.emit({
|
||
type: 'set_voice_mode_response',
|
||
payload: {
|
||
requestId,
|
||
enabled: this.isVoiceMode,
|
||
agentId: this.voiceModeAgentId,
|
||
accepted: false,
|
||
error: errorMessage,
|
||
...unavailable,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private parseVoiceTargetAgentId(rawId: string, source: string): string {
|
||
const parsed = AgentIdSchema.safeParse(rawId.trim())
|
||
if (!parsed.success) {
|
||
throw new Error(`${source}: agentId must be a UUID`)
|
||
}
|
||
return parsed.data
|
||
}
|
||
|
||
private cloneMcpServers(
|
||
servers: Record<string, McpServerConfig> | undefined
|
||
): Record<string, McpServerConfig> | undefined {
|
||
if (!servers) {
|
||
return undefined
|
||
}
|
||
return JSON.parse(JSON.stringify(servers)) as Record<string, McpServerConfig>
|
||
}
|
||
|
||
private buildVoiceModeMcpServers(
|
||
existing: Record<string, McpServerConfig> | undefined,
|
||
socketPath: string
|
||
): Record<string, McpServerConfig> {
|
||
const mcpStdio = this.voiceAgentMcpStdio
|
||
if (!mcpStdio) {
|
||
throw new Error('Voice MCP stdio bridge is not configured')
|
||
}
|
||
return {
|
||
...(existing ?? {}),
|
||
[VOICE_MCP_SERVER_NAME]: buildVoiceAgentMcpServerConfig({
|
||
command: mcpStdio.command,
|
||
baseArgs: mcpStdio.baseArgs,
|
||
socketPath,
|
||
env: mcpStdio.env,
|
||
}),
|
||
}
|
||
}
|
||
|
||
private async enableVoiceModeForAgent(agentId: string): Promise<string> {
|
||
const ensureVoiceSocket = this.ensureVoiceMcpSocketForAgent
|
||
if (!ensureVoiceSocket) {
|
||
throw new Error('Voice MCP socket bridge is not configured')
|
||
}
|
||
|
||
const existing = await this.ensureAgentLoaded(agentId)
|
||
|
||
const socketPath = await ensureVoiceSocket(agentId)
|
||
this.registerVoiceBridgeForAgent(agentId)
|
||
|
||
const baseConfig: VoiceModeBaseConfig = {
|
||
systemPrompt: stripVoiceModeSystemPrompt(existing.config.systemPrompt),
|
||
mcpServers: this.cloneMcpServers(existing.config.mcpServers),
|
||
}
|
||
this.voiceModeBaseConfig = baseConfig
|
||
const refreshOverrides: Partial<AgentSessionConfig> = {
|
||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, true),
|
||
mcpServers: this.buildVoiceModeMcpServers(baseConfig.mcpServers, socketPath),
|
||
}
|
||
|
||
try {
|
||
const refreshed = await this.agentManager.reloadAgentSession(agentId, refreshOverrides)
|
||
return refreshed.id
|
||
} catch (error) {
|
||
this.unregisterVoiceSpeakHandler?.(agentId)
|
||
this.unregisterVoiceCallerContext?.(agentId)
|
||
await this.removeVoiceMcpSocketForAgent?.(agentId).catch(() => undefined)
|
||
this.voiceModeBaseConfig = null
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async disableVoiceModeForActiveAgent(restoreAgentConfig: boolean): Promise<void> {
|
||
this.clearVoiceModeInactivityTimeout()
|
||
this.cancelActiveVoiceDictationStream('voice mode disabled')
|
||
|
||
const agentId = this.voiceModeAgentId
|
||
if (!agentId) {
|
||
this.voiceModeBaseConfig = null
|
||
return
|
||
}
|
||
|
||
this.unregisterVoiceSpeakHandler?.(agentId)
|
||
this.unregisterVoiceCallerContext?.(agentId)
|
||
await this.removeVoiceMcpSocketForAgent?.(agentId).catch((error) => {
|
||
this.sessionLogger.warn(
|
||
{ err: error, agentId },
|
||
'Failed to remove voice MCP socket bridge on disable'
|
||
)
|
||
})
|
||
|
||
if (restoreAgentConfig && this.voiceModeBaseConfig) {
|
||
const baseConfig = this.voiceModeBaseConfig
|
||
try {
|
||
await this.agentManager.reloadAgentSession(agentId, {
|
||
systemPrompt: buildVoiceModeSystemPrompt(baseConfig.systemPrompt, false),
|
||
mcpServers: this.cloneMcpServers(baseConfig.mcpServers),
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.warn(
|
||
{ err: error, agentId },
|
||
'Failed to restore agent config while disabling voice mode'
|
||
)
|
||
}
|
||
}
|
||
|
||
this.voiceModeBaseConfig = null
|
||
this.voiceModeAgentId = null
|
||
}
|
||
|
||
private isInternalVoiceDictationId(dictationId: string): boolean {
|
||
return dictationId.startsWith(VOICE_INTERNAL_DICTATION_ID_PREFIX)
|
||
}
|
||
|
||
private handleDictationManagerMessage(msg: DictationStreamOutboundMessage): void {
|
||
if (msg.type === 'activity_log') {
|
||
const metadata = msg.payload.metadata as { dictationId?: unknown } | undefined
|
||
const dictationId =
|
||
metadata && typeof metadata.dictationId === 'string' ? metadata.dictationId : null
|
||
if (dictationId && this.isInternalVoiceDictationId(dictationId)) {
|
||
return
|
||
}
|
||
this.emit(msg as unknown as SessionOutboundMessage)
|
||
return
|
||
}
|
||
|
||
const payloadWithDictationId = msg.payload as { dictationId?: unknown }
|
||
const dictationId =
|
||
payloadWithDictationId && typeof payloadWithDictationId.dictationId === 'string'
|
||
? payloadWithDictationId.dictationId
|
||
: null
|
||
|
||
if (!dictationId || !this.isInternalVoiceDictationId(dictationId)) {
|
||
this.emit(msg as unknown as SessionOutboundMessage)
|
||
return
|
||
}
|
||
|
||
if (msg.type === 'dictation_stream_final') {
|
||
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationResolve) {
|
||
return
|
||
}
|
||
this.activeVoiceDictationResolve({
|
||
text: msg.payload.text,
|
||
...(msg.payload.debugRecordingPath
|
||
? { debugRecordingPath: msg.payload.debugRecordingPath }
|
||
: {}),
|
||
})
|
||
return
|
||
}
|
||
|
||
if (msg.type === 'dictation_stream_error') {
|
||
if (dictationId !== this.activeVoiceDictationId || !this.activeVoiceDictationReject) {
|
||
return
|
||
}
|
||
this.activeVoiceDictationReject(new Error(msg.payload.error))
|
||
return
|
||
}
|
||
|
||
// Ack/partial messages for internal voice dictation are consumed server-side.
|
||
}
|
||
|
||
private resetActiveVoiceDictationState(): void {
|
||
this.activeVoiceDictationId = null
|
||
this.activeVoiceDictationFormat = null
|
||
this.activeVoiceDictationNextSeq = 0
|
||
this.activeVoiceDictationStartPromise = null
|
||
this.activeVoiceDictationFinalizePromise = null
|
||
this.activeVoiceDictationResultPromise = null
|
||
this.activeVoiceDictationResolve = null
|
||
this.activeVoiceDictationReject = null
|
||
}
|
||
|
||
private cancelActiveVoiceDictationStream(reason: string): void {
|
||
const dictationId = this.activeVoiceDictationId
|
||
if (!dictationId) {
|
||
return
|
||
}
|
||
|
||
this.sessionLogger.debug(
|
||
{ dictationId, reason },
|
||
'Cancelling active internal voice dictation stream'
|
||
)
|
||
if (this.activeVoiceDictationReject) {
|
||
this.activeVoiceDictationReject(new Error(`Voice dictation cancelled: ${reason}`))
|
||
}
|
||
this.voiceStreamManager.handleCancel(dictationId)
|
||
this.resetActiveVoiceDictationState()
|
||
}
|
||
|
||
private async ensureActiveVoiceDictationStream(format: string): Promise<void> {
|
||
if (this.activeVoiceDictationId && this.activeVoiceDictationFormat === format) {
|
||
if (this.activeVoiceDictationStartPromise) {
|
||
await this.activeVoiceDictationStartPromise
|
||
}
|
||
return
|
||
}
|
||
|
||
if (this.activeVoiceDictationId) {
|
||
await this.finalizeActiveVoiceDictationStream('voice format changed')
|
||
}
|
||
|
||
const dictationId = `${VOICE_INTERNAL_DICTATION_ID_PREFIX}${uuidv4()}`
|
||
let resolve: ((value: { text: string; debugRecordingPath?: string }) => void) | null = null
|
||
let reject: ((error: Error) => void) | null = null
|
||
const resultPromise = new Promise<{ text: string; debugRecordingPath?: string }>(
|
||
(resolveFn, rejectFn) => {
|
||
resolve = resolveFn
|
||
reject = rejectFn
|
||
}
|
||
)
|
||
// Prevent process-level unhandled rejection warnings when cancellation races are resolved later.
|
||
void resultPromise.catch(() => undefined)
|
||
|
||
this.activeVoiceDictationId = dictationId
|
||
this.activeVoiceDictationFormat = format
|
||
this.activeVoiceDictationNextSeq = 0
|
||
this.activeVoiceDictationFinalizePromise = null
|
||
this.activeVoiceDictationResultPromise = resultPromise
|
||
this.activeVoiceDictationResolve = resolve
|
||
this.activeVoiceDictationReject = reject
|
||
this.setPhase('transcribing')
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'system',
|
||
content: 'Transcribing audio...',
|
||
},
|
||
})
|
||
|
||
const startPromise = this.voiceStreamManager.handleStart(dictationId, format)
|
||
this.activeVoiceDictationStartPromise = startPromise
|
||
try {
|
||
await startPromise
|
||
} catch (error) {
|
||
this.resetActiveVoiceDictationState()
|
||
throw error
|
||
} finally {
|
||
if (this.activeVoiceDictationId === dictationId) {
|
||
this.activeVoiceDictationStartPromise = null
|
||
}
|
||
}
|
||
}
|
||
|
||
private async appendToActiveVoiceDictationStream(
|
||
audioBase64: string,
|
||
format: string
|
||
): Promise<void> {
|
||
if (this.activeVoiceDictationFinalizePromise) {
|
||
await this.activeVoiceDictationFinalizePromise.catch(() => undefined)
|
||
}
|
||
await this.ensureActiveVoiceDictationStream(format)
|
||
const dictationId = this.activeVoiceDictationId
|
||
if (!dictationId) {
|
||
throw new Error('Voice dictation stream did not initialize')
|
||
}
|
||
|
||
const seq = this.activeVoiceDictationNextSeq
|
||
this.activeVoiceDictationNextSeq += 1
|
||
await this.voiceStreamManager.handleChunk({
|
||
dictationId,
|
||
seq,
|
||
audioBase64,
|
||
format,
|
||
})
|
||
}
|
||
|
||
private async finalizeActiveVoiceDictationStream(reason: string): Promise<void> {
|
||
const dictationId = this.activeVoiceDictationId
|
||
if (!dictationId) {
|
||
return
|
||
}
|
||
this.clearVoiceModeInactivityTimeout()
|
||
if (this.activeVoiceDictationStartPromise) {
|
||
await this.activeVoiceDictationStartPromise
|
||
}
|
||
|
||
if (this.activeVoiceDictationFinalizePromise) {
|
||
await this.activeVoiceDictationFinalizePromise
|
||
return
|
||
}
|
||
|
||
const finalSeq = this.activeVoiceDictationNextSeq - 1
|
||
const resultPromise = this.activeVoiceDictationResultPromise
|
||
if (!resultPromise) {
|
||
this.resetActiveVoiceDictationState()
|
||
return
|
||
}
|
||
|
||
this.activeVoiceDictationFinalizePromise = (async () => {
|
||
this.sessionLogger.debug(
|
||
{ dictationId, finalSeq, reason },
|
||
'Finalizing internal voice dictation stream'
|
||
)
|
||
await this.voiceStreamManager.handleFinish(dictationId, finalSeq)
|
||
const result = await resultPromise
|
||
this.resetActiveVoiceDictationState()
|
||
const requestId = uuidv4()
|
||
const transcriptText = result.text.trim()
|
||
this.sessionLogger.info(
|
||
{
|
||
requestId,
|
||
isVoiceMode: this.isVoiceMode,
|
||
transcriptLength: transcriptText.length,
|
||
transcript: transcriptText,
|
||
},
|
||
'Transcription result'
|
||
)
|
||
await this.handleTranscriptionResultPayload({
|
||
text: result.text,
|
||
requestId,
|
||
...(result.debugRecordingPath
|
||
? { debugRecordingPath: result.debugRecordingPath, format: 'audio/wav' }
|
||
: {}),
|
||
})
|
||
})()
|
||
|
||
try {
|
||
await this.activeVoiceDictationFinalizePromise
|
||
} catch (error) {
|
||
this.resetActiveVoiceDictationState()
|
||
this.setPhase('idle')
|
||
this.clearSpeechInProgress('transcription error')
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Transcription error: ${error instanceof Error ? error.message : String(error)}`,
|
||
},
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle text message to agent (with optional image attachments)
|
||
*/
|
||
private async handleSendAgentMessage(
|
||
agentId: string,
|
||
text: string,
|
||
messageId?: string,
|
||
images?: Array<{ data: string; mimeType: string }>,
|
||
runOptions?: AgentRunOptions
|
||
): Promise<void> {
|
||
this.sessionLogger.info(
|
||
{ agentId, textPreview: text.substring(0, 50), imageCount: images?.length ?? 0 },
|
||
`Sending text to agent ${agentId}${images && images.length > 0 ? ` with ${images.length} image attachment(s)` : ''}`
|
||
)
|
||
|
||
await this.unarchiveAgentState(agentId)
|
||
|
||
try {
|
||
await this.ensureAgentLoaded(agentId)
|
||
} catch (error) {
|
||
this.handleAgentRunError(agentId, error, 'Failed to initialize agent before sending prompt')
|
||
return
|
||
}
|
||
|
||
try {
|
||
await this.interruptAgentIfRunning(agentId)
|
||
} catch (error) {
|
||
this.handleAgentRunError(
|
||
agentId,
|
||
error,
|
||
'Failed to interrupt running agent before sending prompt'
|
||
)
|
||
return
|
||
}
|
||
|
||
const prompt = this.buildAgentPrompt(text, images)
|
||
|
||
try {
|
||
this.agentManager.recordUserMessage(agentId, text, {
|
||
messageId,
|
||
emitState: false,
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId },
|
||
`Failed to record user message for agent ${agentId}`
|
||
)
|
||
}
|
||
|
||
this.startAgentStream(agentId, prompt, runOptions)
|
||
}
|
||
|
||
/**
|
||
* Handle create agent request
|
||
*/
|
||
private async handleCreateAgentRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'create_agent_request' }>
|
||
): Promise<void> {
|
||
const {
|
||
config,
|
||
worktreeName,
|
||
requestId,
|
||
initialPrompt,
|
||
clientMessageId,
|
||
outputSchema,
|
||
git,
|
||
images,
|
||
labels,
|
||
} =
|
||
msg
|
||
this.sessionLogger.info(
|
||
{ cwd: config.cwd, provider: config.provider, worktreeName },
|
||
`Creating agent in ${config.cwd} (${config.provider})${
|
||
worktreeName ? ` with worktree ${worktreeName}` : ''
|
||
}`
|
||
)
|
||
|
||
try {
|
||
const trimmedPrompt = initialPrompt?.trim()
|
||
const derivedTitle =
|
||
typeof config.title === 'string' && config.title.trim().length > 0
|
||
? config.title.trim()
|
||
: trimmedPrompt
|
||
? deriveInitialAgentTitle(trimmedPrompt)
|
||
: null
|
||
const resolvedConfig: AgentSessionConfig = {
|
||
...config,
|
||
...(derivedTitle ? { title: derivedTitle } : {}),
|
||
}
|
||
|
||
const { sessionConfig, worktreeConfig } = await this.buildAgentSessionConfig(
|
||
resolvedConfig,
|
||
git,
|
||
worktreeName,
|
||
labels
|
||
)
|
||
const snapshot = await this.agentManager.createAgent(sessionConfig, undefined, { labels })
|
||
await this.forwardAgentUpdate(snapshot)
|
||
|
||
if (requestId) {
|
||
const agentPayload = await this.getAgentPayloadById(snapshot.id)
|
||
if (!agentPayload) {
|
||
throw new Error(`Agent ${snapshot.id} not found after creation`)
|
||
}
|
||
this.emit({
|
||
type: 'status',
|
||
payload: {
|
||
status: 'agent_created',
|
||
agentId: snapshot.id,
|
||
requestId,
|
||
agent: agentPayload,
|
||
},
|
||
})
|
||
}
|
||
|
||
if (trimmedPrompt) {
|
||
scheduleAgentMetadataGeneration({
|
||
agentManager: this.agentManager,
|
||
agentId: snapshot.id,
|
||
cwd: snapshot.cwd,
|
||
initialPrompt: trimmedPrompt,
|
||
explicitTitle: snapshot.config.title,
|
||
paseoHome: this.paseoHome,
|
||
logger: this.sessionLogger,
|
||
})
|
||
|
||
void this.handleSendAgentMessage(
|
||
snapshot.id,
|
||
trimmedPrompt,
|
||
resolveClientMessageId(clientMessageId),
|
||
images,
|
||
outputSchema ? { outputSchema } : undefined
|
||
).catch((promptError) => {
|
||
this.sessionLogger.error(
|
||
{ err: promptError, agentId: snapshot.id },
|
||
`Failed to run initial prompt for agent ${snapshot.id}`
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Initial prompt failed: ${(promptError as Error)?.message ?? promptError}`,
|
||
},
|
||
})
|
||
})
|
||
}
|
||
|
||
if (worktreeConfig) {
|
||
void runAsyncWorktreeBootstrap({
|
||
agentId: snapshot.id,
|
||
worktree: worktreeConfig,
|
||
terminalManager: this.terminalManager,
|
||
appendTimelineItem: (item) =>
|
||
appendTimelineItemIfAgentKnown({
|
||
agentManager: this.agentManager,
|
||
agentId: snapshot.id,
|
||
item,
|
||
}),
|
||
emitLiveTimelineItem: (item) =>
|
||
emitLiveTimelineItemIfAgentKnown({
|
||
agentManager: this.agentManager,
|
||
agentId: snapshot.id,
|
||
item,
|
||
}),
|
||
logger: this.sessionLogger,
|
||
})
|
||
}
|
||
|
||
this.sessionLogger.info(
|
||
{ agentId: snapshot.id, provider: snapshot.provider },
|
||
`Created agent ${snapshot.id} (${snapshot.provider})`
|
||
)
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to create agent')
|
||
if (requestId) {
|
||
this.emit({
|
||
type: 'status',
|
||
payload: {
|
||
status: 'agent_create_failed',
|
||
requestId,
|
||
error: (error as Error)?.message ?? String(error),
|
||
},
|
||
})
|
||
}
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to create agent: ${error.message}`,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleResumeAgentRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'resume_agent_request' }>
|
||
): Promise<void> {
|
||
const { handle, overrides, requestId } = msg
|
||
if (!handle) {
|
||
this.sessionLogger.warn('Resume request missing persistence handle')
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: 'Unable to resume agent: missing persistence handle',
|
||
},
|
||
})
|
||
return
|
||
}
|
||
this.sessionLogger.info(
|
||
{ sessionId: handle.sessionId, provider: handle.provider },
|
||
`Resuming agent ${handle.sessionId} (${handle.provider})`
|
||
)
|
||
try {
|
||
await this.unarchiveAgentByHandle(handle)
|
||
const snapshot = await this.agentManager.resumeAgentFromPersistence(handle, overrides)
|
||
await this.unarchiveAgentState(snapshot.id)
|
||
await this.agentManager.hydrateTimelineFromProvider(snapshot.id)
|
||
await this.forwardAgentUpdate(snapshot)
|
||
const timelineSize = this.agentManager.getTimeline(snapshot.id).length
|
||
if (requestId) {
|
||
const agentPayload = await this.getAgentPayloadById(snapshot.id)
|
||
if (!agentPayload) {
|
||
throw new Error(`Agent ${snapshot.id} not found after resume`)
|
||
}
|
||
this.emit({
|
||
type: 'status',
|
||
payload: {
|
||
status: 'agent_resumed',
|
||
agentId: snapshot.id,
|
||
requestId,
|
||
timelineSize,
|
||
agent: agentPayload,
|
||
},
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to resume agent')
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to resume agent: ${error.message}`,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleRefreshAgentRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'refresh_agent_request' }>
|
||
): Promise<void> {
|
||
const { agentId, requestId } = msg
|
||
this.sessionLogger.info({ agentId }, `Refreshing agent ${agentId} from persistence`)
|
||
|
||
try {
|
||
await this.unarchiveAgentState(agentId)
|
||
let snapshot: ManagedAgent
|
||
const existing = this.agentManager.getAgent(agentId)
|
||
if (existing) {
|
||
await this.interruptAgentIfRunning(agentId)
|
||
if (existing.persistence) {
|
||
snapshot = await this.agentManager.reloadAgentSession(agentId)
|
||
} else {
|
||
snapshot = existing
|
||
}
|
||
} else {
|
||
const record = await this.agentStorage.get(agentId)
|
||
if (!record) {
|
||
throw new Error(`Agent not found: ${agentId}`)
|
||
}
|
||
const handle = toAgentPersistenceHandle(this.sessionLogger, record.persistence)
|
||
if (!handle) {
|
||
throw new Error(`Agent ${agentId} cannot be refreshed because it lacks persistence`)
|
||
}
|
||
snapshot = await this.agentManager.resumeAgentFromPersistence(
|
||
handle,
|
||
buildConfigOverrides(record),
|
||
agentId,
|
||
extractTimestamps(record)
|
||
)
|
||
}
|
||
await this.agentManager.hydrateTimelineFromProvider(agentId)
|
||
await this.forwardAgentUpdate(snapshot)
|
||
const timelineSize = this.agentManager.getTimeline(agentId).length
|
||
if (requestId) {
|
||
this.emit({
|
||
type: 'status',
|
||
payload: {
|
||
status: 'agent_refreshed',
|
||
agentId,
|
||
requestId,
|
||
timelineSize,
|
||
},
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to refresh agent: ${error.message}`,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCancelAgentRequest(agentId: string): Promise<void> {
|
||
this.sessionLogger.info({ agentId }, `Cancel request received for agent ${agentId}`)
|
||
|
||
try {
|
||
await this.interruptAgentIfRunning(agentId)
|
||
} catch (error) {
|
||
this.handleAgentRunError(agentId, error, 'Failed to cancel running agent on request')
|
||
}
|
||
}
|
||
|
||
private async buildAgentSessionConfig(
|
||
config: AgentSessionConfig,
|
||
gitOptions?: GitSetupOptions,
|
||
legacyWorktreeName?: string,
|
||
_labels?: Record<string, string>
|
||
): Promise<{ sessionConfig: AgentSessionConfig; worktreeConfig?: WorktreeConfig }> {
|
||
let cwd = expandTilde(config.cwd)
|
||
const normalized = this.normalizeGitOptions(gitOptions, legacyWorktreeName)
|
||
let worktreeConfig: WorktreeConfig | undefined
|
||
|
||
if (!normalized) {
|
||
return {
|
||
sessionConfig: {
|
||
...config,
|
||
cwd,
|
||
},
|
||
}
|
||
}
|
||
|
||
if (normalized.createWorktree) {
|
||
let targetBranch: string
|
||
|
||
if (normalized.createNewBranch) {
|
||
targetBranch = normalized.newBranchName!
|
||
} else {
|
||
// Resolve current branch name from HEAD
|
||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
targetBranch = stdout.trim()
|
||
}
|
||
|
||
if (!targetBranch) {
|
||
throw new Error('A branch name is required when creating a worktree.')
|
||
}
|
||
|
||
this.sessionLogger.info(
|
||
{ worktreeSlug: normalized.worktreeSlug ?? targetBranch, branch: targetBranch },
|
||
`Creating worktree '${normalized.worktreeSlug ?? targetBranch}' for branch ${targetBranch}`
|
||
)
|
||
|
||
const createdWorktree = await createAgentWorktree({
|
||
branchName: targetBranch,
|
||
cwd,
|
||
baseBranch: normalized.baseBranch!,
|
||
worktreeSlug: normalized.worktreeSlug ?? targetBranch,
|
||
paseoHome: this.paseoHome,
|
||
})
|
||
cwd = createdWorktree.worktreePath
|
||
worktreeConfig = createdWorktree
|
||
} else if (normalized.createNewBranch) {
|
||
await this.createBranchFromBase({
|
||
cwd,
|
||
baseBranch: normalized.baseBranch!,
|
||
newBranchName: normalized.newBranchName!,
|
||
})
|
||
} else if (normalized.baseBranch) {
|
||
await this.checkoutExistingBranch(cwd, normalized.baseBranch)
|
||
}
|
||
|
||
return {
|
||
sessionConfig: {
|
||
...config,
|
||
cwd,
|
||
},
|
||
worktreeConfig,
|
||
}
|
||
}
|
||
|
||
private async handleListProviderModelsRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'list_provider_models_request' }>
|
||
): Promise<void> {
|
||
const fetchedAt = new Date().toISOString()
|
||
try {
|
||
const models = await this.providerRegistry[msg.provider].fetchModels({
|
||
cwd: msg.cwd ? expandTilde(msg.cwd) : undefined,
|
||
})
|
||
this.emit({
|
||
type: 'list_provider_models_response',
|
||
payload: {
|
||
provider: msg.provider,
|
||
models,
|
||
error: null,
|
||
fetchedAt,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.error(
|
||
{ err: error, provider: msg.provider },
|
||
`Failed to list models for ${msg.provider}`
|
||
)
|
||
this.emit({
|
||
type: 'list_provider_models_response',
|
||
payload: {
|
||
provider: msg.provider,
|
||
error: (error as Error)?.message ?? String(error),
|
||
fetchedAt,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleListAvailableProvidersRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'list_available_providers_request' }>
|
||
): Promise<void> {
|
||
const fetchedAt = new Date().toISOString()
|
||
try {
|
||
const providers = await this.agentManager.listProviderAvailability()
|
||
this.emit({
|
||
type: 'list_available_providers_response',
|
||
payload: {
|
||
providers,
|
||
error: null,
|
||
fetchedAt,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to list provider availability')
|
||
this.emit({
|
||
type: 'list_available_providers_response',
|
||
payload: {
|
||
providers: [],
|
||
error: (error as Error)?.message ?? String(error),
|
||
fetchedAt,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleSpeechModelsListRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'speech_models_list_request' }>
|
||
): Promise<void> {
|
||
const modelsDir = this.localSpeechModelsDir
|
||
|
||
const models = await Promise.all(
|
||
listLocalSpeechModels().map(async (model) => {
|
||
const modelDir = getLocalSpeechModelDir(modelsDir, model.id)
|
||
const missingFiles: string[] = []
|
||
for (const rel of model.requiredFiles) {
|
||
const filePath = join(modelDir, rel)
|
||
try {
|
||
const fileStat = await stat(filePath)
|
||
if (fileStat.isDirectory()) {
|
||
continue
|
||
}
|
||
if (!fileStat.isFile() || fileStat.size <= 0) {
|
||
missingFiles.push(rel)
|
||
}
|
||
} catch {
|
||
missingFiles.push(rel)
|
||
}
|
||
}
|
||
|
||
return {
|
||
id: model.id,
|
||
kind: model.kind,
|
||
description: model.description,
|
||
modelDir,
|
||
isDownloaded: missingFiles.length === 0,
|
||
...(missingFiles.length > 0 ? { missingFiles } : {}),
|
||
}
|
||
})
|
||
)
|
||
|
||
this.emit({
|
||
type: 'speech_models_list_response',
|
||
payload: {
|
||
modelsDir,
|
||
models,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
private async handleSpeechModelsDownloadRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'speech_models_download_request' }>
|
||
): Promise<void> {
|
||
const modelsDir = this.localSpeechModelsDir
|
||
|
||
const modelIdsRaw =
|
||
msg.modelIds && msg.modelIds.length > 0 ? msg.modelIds : this.defaultLocalSpeechModelIds
|
||
|
||
const allModelIds = new Set(listLocalSpeechModels().map((m) => m.id))
|
||
const invalid = modelIdsRaw.filter((id) => !allModelIds.has(id as LocalSpeechModelId))
|
||
if (invalid.length > 0) {
|
||
this.emit({
|
||
type: 'speech_models_download_response',
|
||
payload: {
|
||
modelsDir,
|
||
downloadedModelIds: [],
|
||
error: `Unknown speech model id(s): ${invalid.join(', ')}`,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const modelIds = modelIdsRaw as LocalSpeechModelId[]
|
||
try {
|
||
await ensureLocalSpeechModels({
|
||
modelsDir,
|
||
modelIds,
|
||
logger: this.sessionLogger,
|
||
})
|
||
this.emit({
|
||
type: 'speech_models_download_response',
|
||
payload: {
|
||
modelsDir,
|
||
downloadedModelIds: modelIds,
|
||
error: null,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error, modelIds }, 'Failed to download speech models')
|
||
this.emit({
|
||
type: 'speech_models_download_response',
|
||
payload: {
|
||
modelsDir,
|
||
downloadedModelIds: [],
|
||
error: error instanceof Error ? error.message : String(error),
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private normalizeGitOptions(
|
||
gitOptions?: GitSetupOptions,
|
||
legacyWorktreeName?: string
|
||
): NormalizedGitOptions | null {
|
||
const fallbackOptions: GitSetupOptions | undefined = legacyWorktreeName
|
||
? {
|
||
createWorktree: true,
|
||
createNewBranch: true,
|
||
newBranchName: legacyWorktreeName,
|
||
worktreeSlug: legacyWorktreeName,
|
||
}
|
||
: undefined
|
||
|
||
const merged = gitOptions ?? fallbackOptions
|
||
if (!merged) {
|
||
return null
|
||
}
|
||
|
||
const baseBranch = merged.baseBranch?.trim() || undefined
|
||
const createWorktree = Boolean(merged.createWorktree)
|
||
const createNewBranch = Boolean(merged.createNewBranch)
|
||
const normalizedBranchName = merged.newBranchName ? slugify(merged.newBranchName) : undefined
|
||
const normalizedWorktreeSlug = merged.worktreeSlug
|
||
? slugify(merged.worktreeSlug)
|
||
: normalizedBranchName
|
||
|
||
if (!createWorktree && !createNewBranch && !baseBranch) {
|
||
return null
|
||
}
|
||
|
||
if (baseBranch) {
|
||
this.assertSafeGitRef(baseBranch, 'base branch')
|
||
}
|
||
|
||
if (createWorktree && !baseBranch) {
|
||
throw new Error('Base branch is required when creating a worktree')
|
||
}
|
||
if (createNewBranch && !baseBranch) {
|
||
throw new Error('Base branch is required when creating a new branch')
|
||
}
|
||
|
||
if (createNewBranch) {
|
||
if (!normalizedBranchName) {
|
||
throw new Error('New branch name is required')
|
||
}
|
||
const validation = validateBranchSlug(normalizedBranchName)
|
||
if (!validation.valid) {
|
||
throw new Error(`Invalid branch name: ${validation.error}`)
|
||
}
|
||
}
|
||
|
||
if (normalizedWorktreeSlug) {
|
||
const validation = validateBranchSlug(normalizedWorktreeSlug)
|
||
if (!validation.valid) {
|
||
throw new Error(`Invalid worktree name: ${validation.error}`)
|
||
}
|
||
}
|
||
|
||
return {
|
||
baseBranch,
|
||
createNewBranch,
|
||
newBranchName: normalizedBranchName,
|
||
createWorktree,
|
||
worktreeSlug: normalizedWorktreeSlug,
|
||
}
|
||
}
|
||
|
||
private assertSafeGitRef(ref: string, label: string): void {
|
||
if (!SAFE_GIT_REF_PATTERN.test(ref) || ref.includes('..') || ref.includes('@{')) {
|
||
throw new Error(`Invalid ${label}: ${ref}`)
|
||
}
|
||
}
|
||
|
||
private toCheckoutError(error: unknown): CheckoutErrorPayload {
|
||
if (error instanceof NotGitRepoError) {
|
||
return { code: 'NOT_GIT_REPO', message: error.message }
|
||
}
|
||
if (error instanceof MergeConflictError) {
|
||
return { code: 'MERGE_CONFLICT', message: error.message }
|
||
}
|
||
if (error instanceof MergeFromBaseConflictError) {
|
||
return { code: 'MERGE_CONFLICT', message: error.message }
|
||
}
|
||
if (error instanceof Error) {
|
||
return { code: 'UNKNOWN', message: error.message }
|
||
}
|
||
return { code: 'UNKNOWN', message: String(error) }
|
||
}
|
||
|
||
private isPathWithinRoot(rootPath: string, candidatePath: string): boolean {
|
||
const resolvedRoot = resolve(rootPath)
|
||
const resolvedCandidate = resolve(candidatePath)
|
||
if (resolvedCandidate === resolvedRoot) {
|
||
return true
|
||
}
|
||
return resolvedCandidate.startsWith(resolvedRoot + sep)
|
||
}
|
||
|
||
private async generateCommitMessage(cwd: string): Promise<string> {
|
||
const diff = await getCheckoutDiff(
|
||
cwd,
|
||
{ mode: 'uncommitted', includeStructured: true },
|
||
{ paseoHome: this.paseoHome }
|
||
)
|
||
const schema = z.object({
|
||
message: z
|
||
.string()
|
||
.min(1)
|
||
.max(72)
|
||
.describe('Concise git commit message, imperative mood, no trailing period.'),
|
||
})
|
||
const fileList =
|
||
diff.structured && diff.structured.length > 0
|
||
? [
|
||
'Files changed:',
|
||
...diff.structured.map((file) => {
|
||
const changeType = file.isNew ? 'A' : file.isDeleted ? 'D' : 'M'
|
||
const status = file.status && file.status !== 'ok' ? ` [${file.status}]` : ''
|
||
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`
|
||
}),
|
||
].join('\n')
|
||
: 'Files changed: (unknown)'
|
||
const maxPatchChars = 120_000
|
||
const patch =
|
||
diff.diff.length > maxPatchChars
|
||
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||
: diff.diff
|
||
const prompt = [
|
||
'Write a concise git commit message for the changes below.',
|
||
"Return JSON only with a single field 'message'.",
|
||
'',
|
||
fileList,
|
||
'',
|
||
patch.length > 0 ? patch : '(No diff available)',
|
||
].join('\n')
|
||
try {
|
||
const result = await generateStructuredAgentResponseWithFallback({
|
||
manager: this.agentManager,
|
||
cwd,
|
||
prompt,
|
||
schema,
|
||
schemaName: 'CommitMessage',
|
||
maxRetries: 2,
|
||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||
agentConfigOverrides: {
|
||
title: 'Commit generator',
|
||
internal: true,
|
||
},
|
||
})
|
||
return result.message
|
||
} catch (error) {
|
||
if (
|
||
error instanceof StructuredAgentResponseError ||
|
||
error instanceof StructuredAgentFallbackError
|
||
) {
|
||
return 'Update files'
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async generatePullRequestText(
|
||
cwd: string,
|
||
baseRef?: string
|
||
): Promise<{
|
||
title: string
|
||
body: string
|
||
}> {
|
||
const diff = await getCheckoutDiff(
|
||
cwd,
|
||
{
|
||
mode: 'base',
|
||
baseRef,
|
||
includeStructured: true,
|
||
},
|
||
{ paseoHome: this.paseoHome }
|
||
)
|
||
const schema = z.object({
|
||
title: z.string().min(1).max(72),
|
||
body: z.string().min(1),
|
||
})
|
||
const fileList =
|
||
diff.structured && diff.structured.length > 0
|
||
? [
|
||
'Files changed:',
|
||
...diff.structured.map((file) => {
|
||
const changeType = file.isNew ? 'A' : file.isDeleted ? 'D' : 'M'
|
||
const status = file.status && file.status !== 'ok' ? ` [${file.status}]` : ''
|
||
return `${changeType}\t${file.path}\t(+${file.additions} -${file.deletions})${status}`
|
||
}),
|
||
].join('\n')
|
||
: 'Files changed: (unknown)'
|
||
const maxPatchChars = 200_000
|
||
const patch =
|
||
diff.diff.length > maxPatchChars
|
||
? `${diff.diff.slice(0, maxPatchChars)}\n\n... (diff truncated to ${maxPatchChars} chars)\n`
|
||
: diff.diff
|
||
const prompt = [
|
||
'Write a pull request title and body for the changes below.',
|
||
"Return JSON only with fields 'title' and 'body'.",
|
||
'',
|
||
fileList,
|
||
'',
|
||
patch.length > 0 ? patch : '(No diff available)',
|
||
].join('\n')
|
||
try {
|
||
return await generateStructuredAgentResponseWithFallback({
|
||
manager: this.agentManager,
|
||
cwd,
|
||
prompt,
|
||
schema,
|
||
schemaName: 'PullRequest',
|
||
maxRetries: 2,
|
||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||
agentConfigOverrides: {
|
||
title: 'PR generator',
|
||
internal: true,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
if (
|
||
error instanceof StructuredAgentResponseError ||
|
||
error instanceof StructuredAgentFallbackError
|
||
) {
|
||
return {
|
||
title: 'Update changes',
|
||
body: 'Automated PR generated by Paseo.',
|
||
}
|
||
}
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async ensureCleanWorkingTree(cwd: string): Promise<void> {
|
||
const dirty = await this.isWorkingTreeDirty(cwd)
|
||
if (dirty) {
|
||
throw new Error(
|
||
'Working directory has uncommitted changes. Commit or stash before switching branches.'
|
||
)
|
||
}
|
||
}
|
||
|
||
private async isWorkingTreeDirty(cwd: string): Promise<boolean> {
|
||
try {
|
||
const { stdout } = await execAsync('git status --porcelain', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
return stdout.trim().length > 0
|
||
} catch (error) {
|
||
throw new Error(`Unable to inspect git status for ${cwd}: ${(error as Error).message}`)
|
||
}
|
||
}
|
||
|
||
private async checkoutExistingBranch(cwd: string, branch: string): Promise<void> {
|
||
this.assertSafeGitRef(branch, 'branch')
|
||
try {
|
||
await execAsync(`git rev-parse --verify ${branch}`, { cwd })
|
||
} catch (error) {
|
||
throw new Error(`Branch not found: ${branch}`)
|
||
}
|
||
|
||
const { stdout } = await execAsync('git rev-parse --abbrev-ref HEAD', {
|
||
cwd,
|
||
})
|
||
const current = stdout.trim()
|
||
if (current === branch) {
|
||
return
|
||
}
|
||
|
||
await this.ensureCleanWorkingTree(cwd)
|
||
await execAsync(`git checkout ${branch}`, { cwd })
|
||
}
|
||
|
||
private async createBranchFromBase(params: {
|
||
cwd: string
|
||
baseBranch: string
|
||
newBranchName: string
|
||
}): Promise<void> {
|
||
const { cwd, baseBranch, newBranchName } = params
|
||
this.assertSafeGitRef(baseBranch, 'base branch')
|
||
|
||
try {
|
||
await execAsync(`git rev-parse --verify ${baseBranch}`, { cwd })
|
||
} catch (error) {
|
||
throw new Error(`Base branch not found: ${baseBranch}`)
|
||
}
|
||
|
||
const exists = await this.doesLocalBranchExist(cwd, newBranchName)
|
||
if (exists) {
|
||
throw new Error(`Branch already exists: ${newBranchName}`)
|
||
}
|
||
|
||
await this.ensureCleanWorkingTree(cwd)
|
||
await execAsync(`git checkout -b ${newBranchName} ${baseBranch}`, {
|
||
cwd,
|
||
})
|
||
}
|
||
|
||
private async doesLocalBranchExist(cwd: string, branch: string): Promise<boolean> {
|
||
try {
|
||
await execAsync(`git show-ref --verify --quiet refs/heads/${branch}`, {
|
||
cwd,
|
||
})
|
||
return true
|
||
} catch (error: any) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle set agent mode request
|
||
*/
|
||
private async handleSetAgentModeRequest(
|
||
agentId: string,
|
||
modeId: string,
|
||
requestId: string
|
||
): Promise<void> {
|
||
this.sessionLogger.info({ agentId, modeId, requestId }, 'session: set_agent_mode_request')
|
||
|
||
try {
|
||
await this.agentManager.setAgentMode(agentId, modeId)
|
||
this.sessionLogger.info(
|
||
{ agentId, modeId, requestId },
|
||
'session: set_agent_mode_request success'
|
||
)
|
||
this.emit({
|
||
type: 'set_agent_mode_response',
|
||
payload: { requestId, agentId, accepted: true, error: null },
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId, modeId, requestId },
|
||
'session: set_agent_mode_request error'
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to set agent mode: ${error.message}`,
|
||
},
|
||
})
|
||
this.emit({
|
||
type: 'set_agent_mode_response',
|
||
payload: {
|
||
requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: error?.message ? String(error.message) : 'Failed to set agent mode',
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleSetAgentModelRequest(
|
||
agentId: string,
|
||
modelId: string | null,
|
||
requestId: string
|
||
): Promise<void> {
|
||
this.sessionLogger.info({ agentId, modelId, requestId }, 'session: set_agent_model_request')
|
||
|
||
try {
|
||
await this.agentManager.setAgentModel(agentId, modelId)
|
||
this.sessionLogger.info(
|
||
{ agentId, modelId, requestId },
|
||
'session: set_agent_model_request success'
|
||
)
|
||
this.emit({
|
||
type: 'set_agent_model_response',
|
||
payload: { requestId, agentId, accepted: true, error: null },
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId, modelId, requestId },
|
||
'session: set_agent_model_request error'
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to set agent model: ${error.message}`,
|
||
},
|
||
})
|
||
this.emit({
|
||
type: 'set_agent_model_response',
|
||
payload: {
|
||
requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: error?.message ? String(error.message) : 'Failed to set agent model',
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleSetAgentThinkingRequest(
|
||
agentId: string,
|
||
thinkingOptionId: string | null,
|
||
requestId: string
|
||
): Promise<void> {
|
||
this.sessionLogger.info(
|
||
{ agentId, thinkingOptionId, requestId },
|
||
'session: set_agent_thinking_request'
|
||
)
|
||
|
||
try {
|
||
await this.agentManager.setAgentThinkingOption(agentId, thinkingOptionId)
|
||
this.sessionLogger.info(
|
||
{ agentId, thinkingOptionId, requestId },
|
||
'session: set_agent_thinking_request success'
|
||
)
|
||
this.emit({
|
||
type: 'set_agent_thinking_response',
|
||
payload: { requestId, agentId, accepted: true, error: null },
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId, thinkingOptionId, requestId },
|
||
'session: set_agent_thinking_request error'
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to set agent thinking option: ${error.message}`,
|
||
},
|
||
})
|
||
this.emit({
|
||
type: 'set_agent_thinking_response',
|
||
payload: {
|
||
requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: error?.message ? String(error.message) : 'Failed to set agent thinking option',
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle clearing agent attention flag
|
||
*/
|
||
private async handleClearAgentAttention(agentId: string | string[]): Promise<void> {
|
||
const agentIds = Array.isArray(agentId) ? agentId : [agentId]
|
||
|
||
try {
|
||
await Promise.all(agentIds.map((id) => this.agentManager.clearAgentAttention(id)))
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error, agentIds }, 'Failed to clear agent attention')
|
||
// Don't throw - this is not critical
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle client heartbeat for activity tracking
|
||
*/
|
||
private handleClientHeartbeat(msg: {
|
||
deviceType: 'web' | 'mobile'
|
||
focusedAgentId: string | null
|
||
lastActivityAt: string
|
||
appVisible: boolean
|
||
appVisibilityChangedAt?: string
|
||
}): void {
|
||
const appVisibilityChangedAt = msg.appVisibilityChangedAt
|
||
? new Date(msg.appVisibilityChangedAt)
|
||
: new Date(msg.lastActivityAt)
|
||
this.clientActivity = {
|
||
deviceType: msg.deviceType,
|
||
focusedAgentId: msg.focusedAgentId,
|
||
lastActivityAt: new Date(msg.lastActivityAt),
|
||
appVisible: msg.appVisible,
|
||
appVisibilityChangedAt,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle push token registration
|
||
*/
|
||
private handleRegisterPushToken(token: string): void {
|
||
this.pushTokenStore.addToken(token)
|
||
this.sessionLogger.info('Registered push token')
|
||
}
|
||
|
||
/**
|
||
* Handle list commands request for an agent
|
||
*/
|
||
private async handleListCommandsRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'list_commands_request' }>
|
||
): Promise<void> {
|
||
const { agentId, requestId, draftConfig } = msg
|
||
this.sessionLogger.debug(
|
||
{ agentId, draftConfig },
|
||
`Handling list commands request for agent ${agentId}`
|
||
)
|
||
|
||
try {
|
||
const agents = this.agentManager.listAgents()
|
||
const agent = agents.find((a) => a.id === agentId)
|
||
|
||
if (agent?.session?.listCommands) {
|
||
const commands = await agent.session.listCommands()
|
||
this.emit({
|
||
type: 'list_commands_response',
|
||
payload: {
|
||
agentId,
|
||
commands,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!agent && draftConfig) {
|
||
const sessionConfig: AgentSessionConfig = {
|
||
provider: draftConfig.provider,
|
||
cwd: expandTilde(draftConfig.cwd),
|
||
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
|
||
...(draftConfig.model ? { model: draftConfig.model } : {}),
|
||
...(draftConfig.thinkingOptionId
|
||
? { thinkingOptionId: draftConfig.thinkingOptionId }
|
||
: {}),
|
||
}
|
||
|
||
const commands = await this.agentManager.listDraftCommands(sessionConfig)
|
||
this.emit({
|
||
type: 'list_commands_response',
|
||
payload: {
|
||
agentId,
|
||
commands,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
this.emit({
|
||
type: 'list_commands_response',
|
||
payload: {
|
||
agentId,
|
||
commands: [],
|
||
error: agent ? `Agent does not support listing commands` : `Agent not found: ${agentId}`,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error, agentId, draftConfig }, 'Failed to list commands')
|
||
this.emit({
|
||
type: 'list_commands_response',
|
||
payload: {
|
||
agentId,
|
||
commands: [],
|
||
error: error.message,
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle agent permission response from user
|
||
*/
|
||
private async handleAgentPermissionResponse(
|
||
agentId: string,
|
||
requestId: string,
|
||
response: AgentPermissionResponse
|
||
): Promise<void> {
|
||
this.sessionLogger.debug(
|
||
{ agentId, requestId },
|
||
`Handling permission response for agent ${agentId}, request ${requestId}`
|
||
)
|
||
|
||
try {
|
||
await this.agentManager.respondToPermission(agentId, requestId, response)
|
||
this.sessionLogger.debug({ agentId }, `Permission response forwarded to agent ${agentId}`)
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId, requestId },
|
||
'Failed to respond to permission'
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Failed to respond to permission: ${error.message}`,
|
||
},
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutStatusRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_status_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
const resolvedCwd = expandTilde(cwd)
|
||
|
||
try {
|
||
const status = await getCheckoutStatus(resolvedCwd, { paseoHome: this.paseoHome })
|
||
if (!status.isGit) {
|
||
this.emit({
|
||
type: 'checkout_status_response',
|
||
payload: {
|
||
cwd,
|
||
isGit: false,
|
||
repoRoot: null,
|
||
currentBranch: null,
|
||
isDirty: null,
|
||
baseRef: null,
|
||
aheadBehind: null,
|
||
aheadOfOrigin: null,
|
||
behindOfOrigin: null,
|
||
hasRemote: false,
|
||
remoteUrl: null,
|
||
isPaseoOwnedWorktree: false,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
if (status.isPaseoOwnedWorktree) {
|
||
this.emit({
|
||
type: 'checkout_status_response',
|
||
payload: {
|
||
cwd,
|
||
isGit: true,
|
||
repoRoot: status.repoRoot ?? null,
|
||
mainRepoRoot: status.mainRepoRoot,
|
||
currentBranch: status.currentBranch ?? null,
|
||
isDirty: status.isDirty ?? null,
|
||
baseRef: status.baseRef,
|
||
aheadBehind: status.aheadBehind ?? null,
|
||
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
||
behindOfOrigin: status.behindOfOrigin ?? null,
|
||
hasRemote: status.hasRemote,
|
||
remoteUrl: status.remoteUrl,
|
||
isPaseoOwnedWorktree: true,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
this.emit({
|
||
type: 'checkout_status_response',
|
||
payload: {
|
||
cwd,
|
||
isGit: true,
|
||
repoRoot: status.repoRoot ?? null,
|
||
currentBranch: status.currentBranch ?? null,
|
||
isDirty: status.isDirty ?? null,
|
||
baseRef: status.baseRef ?? null,
|
||
aheadBehind: status.aheadBehind ?? null,
|
||
aheadOfOrigin: status.aheadOfOrigin ?? null,
|
||
behindOfOrigin: status.behindOfOrigin ?? null,
|
||
hasRemote: status.hasRemote,
|
||
remoteUrl: status.remoteUrl,
|
||
isPaseoOwnedWorktree: false,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_status_response',
|
||
payload: {
|
||
cwd,
|
||
isGit: false,
|
||
repoRoot: null,
|
||
currentBranch: null,
|
||
isDirty: null,
|
||
baseRef: null,
|
||
aheadBehind: null,
|
||
aheadOfOrigin: null,
|
||
behindOfOrigin: null,
|
||
hasRemote: false,
|
||
remoteUrl: null,
|
||
isPaseoOwnedWorktree: false,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleValidateBranchRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'validate_branch_request' }>
|
||
): Promise<void> {
|
||
const { cwd, branchName, requestId } = msg
|
||
|
||
try {
|
||
const resolvedCwd = expandTilde(cwd)
|
||
|
||
// Try local branch first
|
||
try {
|
||
await execAsync(`git rev-parse --verify ${branchName}`, {
|
||
cwd: resolvedCwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
this.emit({
|
||
type: 'validate_branch_response',
|
||
payload: {
|
||
exists: true,
|
||
resolvedRef: branchName,
|
||
isRemote: false,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
} catch {
|
||
// Local branch doesn't exist, try remote
|
||
}
|
||
|
||
// Try remote branch (origin/{branchName})
|
||
try {
|
||
await execAsync(`git rev-parse --verify origin/${branchName}`, {
|
||
cwd: resolvedCwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
this.emit({
|
||
type: 'validate_branch_response',
|
||
payload: {
|
||
exists: true,
|
||
resolvedRef: `origin/${branchName}`,
|
||
isRemote: true,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
} catch {
|
||
// Remote branch doesn't exist either
|
||
}
|
||
|
||
// Branch not found anywhere
|
||
this.emit({
|
||
type: 'validate_branch_response',
|
||
payload: {
|
||
exists: false,
|
||
resolvedRef: null,
|
||
isRemote: false,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'validate_branch_response',
|
||
payload: {
|
||
exists: false,
|
||
resolvedRef: null,
|
||
isRemote: false,
|
||
error: error instanceof Error ? error.message : String(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleBranchSuggestionsRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'branch_suggestions_request' }>
|
||
): Promise<void> {
|
||
const { cwd, query, limit, requestId } = msg
|
||
|
||
try {
|
||
const resolvedCwd = expandTilde(cwd)
|
||
const branches = await listBranchSuggestions(resolvedCwd, { query, limit })
|
||
this.emit({
|
||
type: 'branch_suggestions_response',
|
||
payload: {
|
||
branches,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'branch_suggestions_response',
|
||
payload: {
|
||
branches: [],
|
||
error: error instanceof Error ? error.message : String(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleDirectorySuggestionsRequest(msg: DirectorySuggestionsRequest): Promise<void> {
|
||
const { query, limit, requestId, cwd, includeFiles, includeDirectories } = msg
|
||
|
||
try {
|
||
const workspaceCwd = cwd?.trim()
|
||
const entries = workspaceCwd
|
||
? await searchWorkspaceEntries({
|
||
cwd: expandTilde(workspaceCwd),
|
||
query,
|
||
limit,
|
||
includeFiles,
|
||
includeDirectories,
|
||
})
|
||
: (
|
||
await searchHomeDirectories({
|
||
homeDir: process.env.HOME ?? homedir(),
|
||
query,
|
||
limit,
|
||
})
|
||
).map((path) => ({ path, kind: 'directory' as const }))
|
||
const directories = entries
|
||
.filter((entry) => entry.kind === 'directory')
|
||
.map((entry) => entry.path)
|
||
this.emit({
|
||
type: 'directory_suggestions_response',
|
||
payload: {
|
||
directories,
|
||
entries,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'directory_suggestions_response',
|
||
payload: {
|
||
directories: [],
|
||
entries: [],
|
||
error: error instanceof Error ? error.message : String(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private normalizeCheckoutDiffCompare(
|
||
compare: CheckoutDiffCompareInput
|
||
): CheckoutDiffCompareInput {
|
||
if (compare.mode === 'uncommitted') {
|
||
return { mode: 'uncommitted' }
|
||
}
|
||
const trimmedBaseRef = compare.baseRef?.trim()
|
||
return trimmedBaseRef ? { mode: 'base', baseRef: trimmedBaseRef } : { mode: 'base' }
|
||
}
|
||
|
||
private buildCheckoutDiffTargetKey(cwd: string, compare: CheckoutDiffCompareInput): string {
|
||
return JSON.stringify([
|
||
cwd,
|
||
compare.mode,
|
||
compare.mode === 'base' ? (compare.baseRef ?? '') : '',
|
||
])
|
||
}
|
||
|
||
private closeCheckoutDiffWatchTarget(target: CheckoutDiffWatchTarget): void {
|
||
if (target.debounceTimer) {
|
||
clearTimeout(target.debounceTimer)
|
||
target.debounceTimer = null
|
||
}
|
||
if (target.fallbackRefreshInterval) {
|
||
clearInterval(target.fallbackRefreshInterval)
|
||
target.fallbackRefreshInterval = null
|
||
}
|
||
for (const watcher of target.watchers) {
|
||
watcher.close()
|
||
}
|
||
target.watchers = []
|
||
}
|
||
|
||
private removeCheckoutDiffSubscription(subscriptionId: string): void {
|
||
const subscription = this.checkoutDiffSubscriptions.get(subscriptionId)
|
||
if (!subscription) {
|
||
return
|
||
}
|
||
this.checkoutDiffSubscriptions.delete(subscriptionId)
|
||
|
||
const target = this.checkoutDiffTargets.get(subscription.targetKey)
|
||
if (!target) {
|
||
return
|
||
}
|
||
target.subscriptions.delete(subscriptionId)
|
||
if (target.subscriptions.size === 0) {
|
||
this.closeCheckoutDiffWatchTarget(target)
|
||
this.checkoutDiffTargets.delete(subscription.targetKey)
|
||
}
|
||
}
|
||
|
||
private async resolveCheckoutGitDir(cwd: string): Promise<string | null> {
|
||
try {
|
||
const { stdout } = await execAsync('git rev-parse --absolute-git-dir', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
const gitDir = stdout.trim()
|
||
return gitDir.length > 0 ? gitDir : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
private async resolveCheckoutWatchRoot(cwd: string): Promise<string | null> {
|
||
try {
|
||
const { stdout } = await execAsync('git rev-parse --path-format=absolute --show-toplevel', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
const root = stdout.trim()
|
||
return root.length > 0 ? root : null
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
private scheduleCheckoutDiffTargetRefresh(target: CheckoutDiffWatchTarget): void {
|
||
if (target.debounceTimer) {
|
||
clearTimeout(target.debounceTimer)
|
||
}
|
||
target.debounceTimer = setTimeout(() => {
|
||
target.debounceTimer = null
|
||
void this.refreshCheckoutDiffTarget(target)
|
||
}, CHECKOUT_DIFF_WATCH_DEBOUNCE_MS)
|
||
}
|
||
|
||
private emitCheckoutDiffUpdate(
|
||
target: CheckoutDiffWatchTarget,
|
||
snapshot: CheckoutDiffSnapshotPayload
|
||
): void {
|
||
if (target.subscriptions.size === 0) {
|
||
return
|
||
}
|
||
for (const subscriptionId of target.subscriptions) {
|
||
this.emit({
|
||
type: 'checkout_diff_update',
|
||
payload: {
|
||
subscriptionId,
|
||
...snapshot,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private checkoutDiffSnapshotFingerprint(snapshot: CheckoutDiffSnapshotPayload): string {
|
||
return JSON.stringify(snapshot)
|
||
}
|
||
|
||
private async computeCheckoutDiffSnapshot(
|
||
cwd: string,
|
||
compare: CheckoutDiffCompareInput,
|
||
options?: { diffCwd?: string }
|
||
): Promise<CheckoutDiffSnapshotPayload> {
|
||
const diffCwd = options?.diffCwd ?? cwd
|
||
try {
|
||
const diffResult = await getCheckoutDiff(
|
||
diffCwd,
|
||
{
|
||
mode: compare.mode,
|
||
baseRef: compare.baseRef,
|
||
includeStructured: true,
|
||
},
|
||
{ paseoHome: this.paseoHome }
|
||
)
|
||
const files = [...(diffResult.structured ?? [])]
|
||
files.sort((a, b) => {
|
||
if (a.path === b.path) return 0
|
||
return a.path < b.path ? -1 : 1
|
||
})
|
||
return {
|
||
cwd,
|
||
files,
|
||
error: null,
|
||
}
|
||
} catch (error) {
|
||
return {
|
||
cwd,
|
||
files: [],
|
||
error: this.toCheckoutError(error),
|
||
}
|
||
}
|
||
}
|
||
|
||
private async refreshCheckoutDiffTarget(target: CheckoutDiffWatchTarget): Promise<void> {
|
||
if (target.refreshPromise) {
|
||
target.refreshQueued = true
|
||
return
|
||
}
|
||
|
||
target.refreshPromise = (async () => {
|
||
do {
|
||
target.refreshQueued = false
|
||
const snapshot = await this.computeCheckoutDiffSnapshot(target.cwd, target.compare, {
|
||
diffCwd: target.diffCwd,
|
||
})
|
||
target.latestPayload = snapshot
|
||
const fingerprint = this.checkoutDiffSnapshotFingerprint(snapshot)
|
||
if (fingerprint !== target.latestFingerprint) {
|
||
target.latestFingerprint = fingerprint
|
||
this.emitCheckoutDiffUpdate(target, snapshot)
|
||
}
|
||
} while (target.refreshQueued)
|
||
})()
|
||
|
||
try {
|
||
await target.refreshPromise
|
||
} finally {
|
||
target.refreshPromise = null
|
||
}
|
||
}
|
||
|
||
private async ensureCheckoutDiffWatchTarget(
|
||
cwd: string,
|
||
compare: CheckoutDiffCompareInput
|
||
): Promise<CheckoutDiffWatchTarget> {
|
||
const targetKey = this.buildCheckoutDiffTargetKey(cwd, compare)
|
||
const existing = this.checkoutDiffTargets.get(targetKey)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
|
||
const watchRoot = await this.resolveCheckoutWatchRoot(cwd)
|
||
const target: CheckoutDiffWatchTarget = {
|
||
key: targetKey,
|
||
cwd,
|
||
diffCwd: watchRoot ?? cwd,
|
||
compare,
|
||
subscriptions: new Set(),
|
||
watchers: [],
|
||
fallbackRefreshInterval: null,
|
||
debounceTimer: null,
|
||
refreshPromise: null,
|
||
refreshQueued: false,
|
||
latestPayload: null,
|
||
latestFingerprint: null,
|
||
}
|
||
|
||
const repoWatchPath = watchRoot ?? cwd
|
||
const watchPaths = new Set<string>([repoWatchPath])
|
||
const gitDir = await this.resolveCheckoutGitDir(cwd)
|
||
if (gitDir) {
|
||
watchPaths.add(gitDir)
|
||
}
|
||
|
||
let hasRecursiveRepoCoverage = false
|
||
const allowRecursiveRepoWatch = process.platform !== 'linux'
|
||
for (const watchPath of watchPaths) {
|
||
const shouldTryRecursive = watchPath === repoWatchPath && allowRecursiveRepoWatch
|
||
const createWatcher = (recursive: boolean): FSWatcher =>
|
||
watch(watchPath, { recursive }, () => {
|
||
this.scheduleCheckoutDiffTargetRefresh(target)
|
||
})
|
||
|
||
let watcher: FSWatcher | null = null
|
||
let watcherIsRecursive = false
|
||
try {
|
||
if (shouldTryRecursive) {
|
||
watcher = createWatcher(true)
|
||
watcherIsRecursive = true
|
||
} else {
|
||
watcher = createWatcher(false)
|
||
}
|
||
} catch (error) {
|
||
if (shouldTryRecursive) {
|
||
try {
|
||
watcher = createWatcher(false)
|
||
this.sessionLogger.warn(
|
||
{ err: error, watchPath, cwd, compare },
|
||
'Checkout diff recursive watch unavailable; using non-recursive fallback'
|
||
)
|
||
} catch (fallbackError) {
|
||
this.sessionLogger.warn(
|
||
{ err: fallbackError, watchPath, cwd, compare },
|
||
'Failed to start checkout diff watcher'
|
||
)
|
||
}
|
||
} else {
|
||
this.sessionLogger.warn(
|
||
{ err: error, watchPath, cwd, compare },
|
||
'Failed to start checkout diff watcher'
|
||
)
|
||
}
|
||
}
|
||
|
||
if (!watcher) {
|
||
continue
|
||
}
|
||
|
||
watcher.on('error', (error) => {
|
||
this.sessionLogger.warn(
|
||
{ err: error, watchPath, cwd, compare },
|
||
'Checkout diff watcher error'
|
||
)
|
||
})
|
||
target.watchers.push(watcher)
|
||
if (watchPath === repoWatchPath && watcherIsRecursive) {
|
||
hasRecursiveRepoCoverage = true
|
||
}
|
||
}
|
||
|
||
const missingRepoCoverage = !hasRecursiveRepoCoverage
|
||
if (target.watchers.length === 0 || missingRepoCoverage) {
|
||
target.fallbackRefreshInterval = setInterval(() => {
|
||
this.scheduleCheckoutDiffTargetRefresh(target)
|
||
}, CHECKOUT_DIFF_FALLBACK_REFRESH_MS)
|
||
this.sessionLogger.warn(
|
||
{
|
||
cwd,
|
||
compare,
|
||
intervalMs: CHECKOUT_DIFF_FALLBACK_REFRESH_MS,
|
||
reason:
|
||
target.watchers.length === 0 ? 'no_watchers' : 'missing_recursive_repo_root_coverage',
|
||
},
|
||
'Checkout diff watchers unavailable; using timed refresh fallback'
|
||
)
|
||
}
|
||
|
||
this.checkoutDiffTargets.set(targetKey, target)
|
||
return target
|
||
}
|
||
|
||
private async handleSubscribeCheckoutDiffRequest(
|
||
msg: SubscribeCheckoutDiffRequest
|
||
): Promise<void> {
|
||
const cwd = expandTilde(msg.cwd)
|
||
const compare = this.normalizeCheckoutDiffCompare(msg.compare)
|
||
|
||
this.removeCheckoutDiffSubscription(msg.subscriptionId)
|
||
const target = await this.ensureCheckoutDiffWatchTarget(cwd, compare)
|
||
target.subscriptions.add(msg.subscriptionId)
|
||
this.checkoutDiffSubscriptions.set(msg.subscriptionId, {
|
||
targetKey: target.key,
|
||
})
|
||
|
||
const snapshot =
|
||
target.latestPayload ??
|
||
(await this.computeCheckoutDiffSnapshot(cwd, compare, {
|
||
diffCwd: target.diffCwd,
|
||
}))
|
||
target.latestPayload = snapshot
|
||
target.latestFingerprint = this.checkoutDiffSnapshotFingerprint(snapshot)
|
||
|
||
this.emit({
|
||
type: 'subscribe_checkout_diff_response',
|
||
payload: {
|
||
subscriptionId: msg.subscriptionId,
|
||
...snapshot,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
private handleUnsubscribeCheckoutDiffRequest(msg: UnsubscribeCheckoutDiffRequest): void {
|
||
this.removeCheckoutDiffSubscription(msg.subscriptionId)
|
||
}
|
||
|
||
private scheduleCheckoutDiffRefreshForCwd(cwd: string): void {
|
||
const resolvedCwd = expandTilde(cwd)
|
||
for (const target of this.checkoutDiffTargets.values()) {
|
||
if (target.cwd !== resolvedCwd && target.diffCwd !== resolvedCwd) {
|
||
continue
|
||
}
|
||
this.scheduleCheckoutDiffTargetRefresh(target)
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutCommitRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_commit_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
|
||
try {
|
||
let message = msg.message?.trim() ?? ''
|
||
if (!message) {
|
||
message = await this.generateCommitMessage(cwd)
|
||
}
|
||
if (!message) {
|
||
throw new Error('Commit message is required')
|
||
}
|
||
|
||
await commitChanges(cwd, {
|
||
message,
|
||
addAll: msg.addAll ?? true,
|
||
})
|
||
this.scheduleCheckoutDiffRefreshForCwd(cwd)
|
||
|
||
this.emit({
|
||
type: 'checkout_commit_response',
|
||
payload: {
|
||
cwd,
|
||
success: true,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_commit_response',
|
||
payload: {
|
||
cwd,
|
||
success: false,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutMergeRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_merge_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
|
||
try {
|
||
const status = await getCheckoutStatus(cwd, { paseoHome: this.paseoHome })
|
||
if (!status.isGit) {
|
||
try {
|
||
await execAsync('git rev-parse --is-inside-work-tree', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
} catch (error) {
|
||
const details =
|
||
typeof (error as any)?.stderr === 'string'
|
||
? String((error as any).stderr).trim()
|
||
: error instanceof Error
|
||
? error.message
|
||
: String(error)
|
||
throw new Error(`Not a git repository: ${cwd}\n${details}`.trim())
|
||
}
|
||
}
|
||
|
||
if (msg.requireCleanTarget) {
|
||
const { stdout } = await execAsync('git status --porcelain', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
if (stdout.trim().length > 0) {
|
||
throw new Error('Working directory has uncommitted changes.')
|
||
}
|
||
}
|
||
|
||
let baseRef = msg.baseRef ?? (status.isGit ? status.baseRef : null)
|
||
if (!baseRef) {
|
||
throw new Error('Base branch is required for merge')
|
||
}
|
||
if (baseRef.startsWith('origin/')) {
|
||
baseRef = baseRef.slice('origin/'.length)
|
||
}
|
||
|
||
await mergeToBase(
|
||
cwd,
|
||
{
|
||
baseRef,
|
||
mode: msg.strategy === 'squash' ? 'squash' : 'merge',
|
||
},
|
||
{ paseoHome: this.paseoHome }
|
||
)
|
||
this.scheduleCheckoutDiffRefreshForCwd(cwd)
|
||
|
||
this.emit({
|
||
type: 'checkout_merge_response',
|
||
payload: {
|
||
cwd,
|
||
success: true,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_merge_response',
|
||
payload: {
|
||
cwd,
|
||
success: false,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutMergeFromBaseRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_merge_from_base_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
|
||
try {
|
||
if (msg.requireCleanTarget ?? true) {
|
||
const { stdout } = await execAsync('git status --porcelain', {
|
||
cwd,
|
||
env: READ_ONLY_GIT_ENV,
|
||
})
|
||
if (stdout.trim().length > 0) {
|
||
throw new Error('Working directory has uncommitted changes.')
|
||
}
|
||
}
|
||
|
||
await mergeFromBase(cwd, {
|
||
baseRef: msg.baseRef,
|
||
requireCleanTarget: msg.requireCleanTarget ?? true,
|
||
})
|
||
this.scheduleCheckoutDiffRefreshForCwd(cwd)
|
||
|
||
this.emit({
|
||
type: 'checkout_merge_from_base_response',
|
||
payload: {
|
||
cwd,
|
||
success: true,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_merge_from_base_response',
|
||
payload: {
|
||
cwd,
|
||
success: false,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutPushRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_push_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
|
||
try {
|
||
await pushCurrentBranch(cwd)
|
||
this.emit({
|
||
type: 'checkout_push_response',
|
||
payload: {
|
||
cwd,
|
||
success: true,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_push_response',
|
||
payload: {
|
||
cwd,
|
||
success: false,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutPrCreateRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_pr_create_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
|
||
try {
|
||
let title = msg.title?.trim() ?? ''
|
||
let body = msg.body?.trim() ?? ''
|
||
|
||
if (!title || !body) {
|
||
const generated = await this.generatePullRequestText(cwd, msg.baseRef)
|
||
if (!title) title = generated.title
|
||
if (!body) body = generated.body
|
||
}
|
||
|
||
const result = await createPullRequest(cwd, {
|
||
title,
|
||
body,
|
||
base: msg.baseRef,
|
||
})
|
||
|
||
this.emit({
|
||
type: 'checkout_pr_create_response',
|
||
payload: {
|
||
cwd,
|
||
url: result.url ?? null,
|
||
number: result.number ?? null,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_pr_create_response',
|
||
payload: {
|
||
cwd,
|
||
url: null,
|
||
number: null,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCheckoutPrStatusRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'checkout_pr_status_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = msg
|
||
|
||
try {
|
||
const prStatus = await getPullRequestStatus(cwd)
|
||
this.emit({
|
||
type: 'checkout_pr_status_response',
|
||
payload: {
|
||
cwd,
|
||
status: prStatus.status,
|
||
githubFeaturesEnabled: prStatus.githubFeaturesEnabled,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'checkout_pr_status_response',
|
||
payload: {
|
||
cwd,
|
||
status: null,
|
||
githubFeaturesEnabled: true,
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handlePaseoWorktreeListRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'paseo_worktree_list_request' }>
|
||
): Promise<void> {
|
||
const { requestId } = msg
|
||
const cwd = msg.repoRoot ?? msg.cwd
|
||
if (!cwd) {
|
||
this.emit({
|
||
type: 'paseo_worktree_list_response',
|
||
payload: {
|
||
worktrees: [],
|
||
error: { code: 'UNKNOWN', message: 'cwd or repoRoot is required' },
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const worktrees = await listPaseoWorktrees({ cwd, paseoHome: this.paseoHome })
|
||
this.emit({
|
||
type: 'paseo_worktree_list_response',
|
||
payload: {
|
||
worktrees: worktrees.map((entry) => ({
|
||
worktreePath: entry.path,
|
||
createdAt: entry.createdAt,
|
||
branchName: entry.branchName ?? null,
|
||
head: entry.head ?? null,
|
||
})),
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'paseo_worktree_list_response',
|
||
payload: {
|
||
worktrees: [],
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async maybeArchiveWorktreeAfterLastAgentArchived(options: {
|
||
archivedAgentId: string
|
||
archivedAgentCwd: string
|
||
requestId: string
|
||
}): Promise<void> {
|
||
try {
|
||
const ownership = await isPaseoOwnedWorktreeCwd(options.archivedAgentCwd, {
|
||
paseoHome: this.paseoHome,
|
||
})
|
||
if (!ownership.allowed) {
|
||
return
|
||
}
|
||
|
||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(options.archivedAgentCwd, {
|
||
paseoHome: this.paseoHome,
|
||
})
|
||
if (!resolvedWorktree) {
|
||
return
|
||
}
|
||
|
||
const records = await this.agentStorage.list()
|
||
const recordsById = new Map(records.map((record) => [record.id, record]))
|
||
const targetPath = resolvedWorktree.worktreePath
|
||
const hasRemainingNonArchivedRecord = records.some((record) => {
|
||
if (record.id === options.archivedAgentId || record.archivedAt) {
|
||
return false
|
||
}
|
||
return this.isPathWithinRoot(targetPath, record.cwd)
|
||
})
|
||
if (hasRemainingNonArchivedRecord) {
|
||
return
|
||
}
|
||
|
||
const hasUnknownLiveAgent = this.agentManager.listAgents().some((agent) => {
|
||
if (agent.id === options.archivedAgentId) {
|
||
return false
|
||
}
|
||
if (!this.isPathWithinRoot(targetPath, agent.cwd)) {
|
||
return false
|
||
}
|
||
return !recordsById.has(agent.id)
|
||
})
|
||
if (hasUnknownLiveAgent) {
|
||
return
|
||
}
|
||
|
||
const repoRoot = ownership.repoRoot
|
||
if (!repoRoot) {
|
||
this.sessionLogger.warn(
|
||
{ agentId: options.archivedAgentId, worktreePath: targetPath },
|
||
'Unable to resolve repo root for auto-archive after agent archive'
|
||
)
|
||
return
|
||
}
|
||
|
||
await this.archivePaseoWorktree({
|
||
targetPath,
|
||
repoRoot,
|
||
requestId: options.requestId,
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.warn(
|
||
{ err: error, agentId: options.archivedAgentId, cwd: options.archivedAgentCwd },
|
||
'Failed to auto-archive worktree after agent archive'
|
||
)
|
||
}
|
||
}
|
||
|
||
private async archivePaseoWorktree(options: {
|
||
targetPath: string
|
||
repoRoot: string
|
||
requestId: string
|
||
}): Promise<string[]> {
|
||
let targetPath = options.targetPath
|
||
const resolvedWorktree = await resolvePaseoWorktreeRootForCwd(targetPath, {
|
||
paseoHome: this.paseoHome,
|
||
})
|
||
if (resolvedWorktree) {
|
||
targetPath = resolvedWorktree.worktreePath
|
||
}
|
||
|
||
const removedAgents = new Set<string>()
|
||
const affectedWorkspaceCwds = new Set<string>([targetPath])
|
||
const agents = this.agentManager.listAgents()
|
||
for (const agent of agents) {
|
||
if (this.isPathWithinRoot(targetPath, agent.cwd)) {
|
||
removedAgents.add(agent.id)
|
||
affectedWorkspaceCwds.add(agent.cwd)
|
||
try {
|
||
await this.agentManager.closeAgent(agent.id)
|
||
} catch {
|
||
// ignore cleanup errors
|
||
}
|
||
try {
|
||
await this.agentStorage.remove(agent.id)
|
||
} catch {
|
||
// ignore cleanup errors
|
||
}
|
||
}
|
||
}
|
||
|
||
const registryRecords = await this.agentStorage.list()
|
||
for (const record of registryRecords) {
|
||
if (this.isPathWithinRoot(targetPath, record.cwd)) {
|
||
removedAgents.add(record.id)
|
||
affectedWorkspaceCwds.add(record.cwd)
|
||
try {
|
||
await this.agentStorage.remove(record.id)
|
||
} catch {
|
||
// ignore cleanup errors
|
||
}
|
||
}
|
||
}
|
||
|
||
await this.killTerminalsUnderPath(targetPath)
|
||
|
||
await deletePaseoWorktree({
|
||
cwd: options.repoRoot,
|
||
worktreePath: targetPath,
|
||
paseoHome: this.paseoHome,
|
||
})
|
||
|
||
for (const agentId of removedAgents) {
|
||
this.emit({
|
||
type: 'agent_deleted',
|
||
payload: {
|
||
agentId,
|
||
requestId: options.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
await this.emitWorkspaceUpdatesForCwds(affectedWorkspaceCwds)
|
||
|
||
return Array.from(removedAgents)
|
||
}
|
||
|
||
private async handlePaseoWorktreeArchiveRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'paseo_worktree_archive_request' }>
|
||
): Promise<void> {
|
||
const { requestId } = msg
|
||
let targetPath = msg.worktreePath
|
||
let repoRoot = msg.repoRoot ?? null
|
||
|
||
try {
|
||
if (!targetPath) {
|
||
if (!repoRoot || !msg.branchName) {
|
||
throw new Error('worktreePath or repoRoot+branchName is required')
|
||
}
|
||
const worktrees = await listPaseoWorktrees({ cwd: repoRoot, paseoHome: this.paseoHome })
|
||
const match = worktrees.find((entry) => entry.branchName === msg.branchName)
|
||
if (!match) {
|
||
throw new Error(`Paseo worktree not found for branch ${msg.branchName}`)
|
||
}
|
||
targetPath = match.path
|
||
}
|
||
|
||
const ownership = await isPaseoOwnedWorktreeCwd(targetPath, {
|
||
paseoHome: this.paseoHome,
|
||
})
|
||
if (!ownership.allowed) {
|
||
this.emit({
|
||
type: 'paseo_worktree_archive_response',
|
||
payload: {
|
||
success: false,
|
||
removedAgents: [],
|
||
error: {
|
||
code: 'NOT_ALLOWED',
|
||
message: 'Worktree is not a Paseo-owned worktree',
|
||
},
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
repoRoot = ownership.repoRoot ?? repoRoot ?? null
|
||
if (!repoRoot) {
|
||
throw new Error('Unable to resolve repo root for worktree')
|
||
}
|
||
|
||
const removedAgents = await this.archivePaseoWorktree({
|
||
targetPath,
|
||
repoRoot,
|
||
requestId,
|
||
})
|
||
|
||
this.emit({
|
||
type: 'paseo_worktree_archive_response',
|
||
payload: {
|
||
success: true,
|
||
removedAgents,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.emit({
|
||
type: 'paseo_worktree_archive_response',
|
||
payload: {
|
||
success: false,
|
||
removedAgents: [],
|
||
error: this.toCheckoutError(error),
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle read-only file explorer requests scoped to a workspace cwd
|
||
*/
|
||
private async handleFileExplorerRequest(request: FileExplorerRequest): Promise<void> {
|
||
const { cwd: workspaceCwd, path: requestedPath = '.', mode, requestId } = request
|
||
const cwd = workspaceCwd.trim()
|
||
if (!cwd) {
|
||
this.emit({
|
||
type: 'file_explorer_response',
|
||
payload: {
|
||
cwd: workspaceCwd,
|
||
path: requestedPath,
|
||
mode,
|
||
directory: null,
|
||
file: null,
|
||
error: 'cwd is required',
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
if (mode === 'list') {
|
||
const directory = await listDirectoryEntries({
|
||
root: cwd,
|
||
relativePath: requestedPath,
|
||
})
|
||
|
||
this.emit({
|
||
type: 'file_explorer_response',
|
||
payload: {
|
||
cwd,
|
||
path: directory.path,
|
||
mode,
|
||
directory,
|
||
file: null,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} else {
|
||
const file = await readExplorerFile({
|
||
root: cwd,
|
||
relativePath: requestedPath,
|
||
})
|
||
|
||
this.emit({
|
||
type: 'file_explorer_response',
|
||
payload: {
|
||
cwd,
|
||
path: file.path,
|
||
mode,
|
||
directory: null,
|
||
file,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, cwd, path: requestedPath },
|
||
`Failed to fulfill file explorer request for workspace ${cwd}`
|
||
)
|
||
this.emit({
|
||
type: 'file_explorer_response',
|
||
payload: {
|
||
cwd,
|
||
path: requestedPath,
|
||
mode,
|
||
directory: null,
|
||
file: null,
|
||
error: error.message,
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle project icon request for a given cwd
|
||
*/
|
||
private async handleProjectIconRequest(
|
||
request: Extract<SessionInboundMessage, { type: 'project_icon_request' }>
|
||
): Promise<void> {
|
||
const { cwd, requestId } = request
|
||
|
||
try {
|
||
const icon = await getProjectIcon(cwd)
|
||
this.emit({
|
||
type: 'project_icon_response',
|
||
payload: {
|
||
cwd,
|
||
icon,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
this.emit({
|
||
type: 'project_icon_response',
|
||
payload: {
|
||
cwd,
|
||
icon: null,
|
||
error: error.message,
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle file download token request scoped to a workspace cwd
|
||
*/
|
||
private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise<void> {
|
||
const { cwd: workspaceCwd, path: requestedPath, requestId } = request
|
||
const cwd = workspaceCwd.trim()
|
||
if (!cwd) {
|
||
this.emit({
|
||
type: 'file_download_token_response',
|
||
payload: {
|
||
cwd: workspaceCwd,
|
||
path: requestedPath,
|
||
token: null,
|
||
fileName: null,
|
||
mimeType: null,
|
||
size: null,
|
||
error: 'cwd is required',
|
||
requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
this.sessionLogger.debug(
|
||
{ cwd, path: requestedPath },
|
||
`Handling file download token request for workspace ${cwd} (${requestedPath})`
|
||
)
|
||
|
||
try {
|
||
const info = await getDownloadableFileInfo({
|
||
root: cwd,
|
||
relativePath: requestedPath,
|
||
})
|
||
|
||
const entry = this.downloadTokenStore.issueToken({
|
||
path: info.path,
|
||
absolutePath: info.absolutePath,
|
||
fileName: info.fileName,
|
||
mimeType: info.mimeType,
|
||
size: info.size,
|
||
})
|
||
|
||
this.emit({
|
||
type: 'file_download_token_response',
|
||
payload: {
|
||
cwd,
|
||
path: info.path,
|
||
token: entry.token,
|
||
fileName: entry.fileName,
|
||
mimeType: entry.mimeType,
|
||
size: entry.size,
|
||
error: null,
|
||
requestId,
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error(
|
||
{ err: error, cwd, path: requestedPath },
|
||
`Failed to issue download token for workspace ${cwd}`
|
||
)
|
||
this.emit({
|
||
type: 'file_download_token_response',
|
||
payload: {
|
||
cwd,
|
||
path: requestedPath,
|
||
token: null,
|
||
fileName: null,
|
||
mimeType: null,
|
||
size: null,
|
||
error: error.message,
|
||
requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Build the current agent list payload (live + persisted), optionally filtered by labels.
|
||
*/
|
||
private async listAgentPayloads(filter?: {
|
||
labels?: Record<string, string>
|
||
}): Promise<AgentSnapshotPayload[]> {
|
||
// Get live agents with session modes
|
||
const agentSnapshots = this.agentManager.listAgents()
|
||
const liveAgents = await Promise.all(
|
||
agentSnapshots.map((agent) => this.buildAgentPayload(agent))
|
||
)
|
||
|
||
// Add persisted agents that have not been lazily initialized yet
|
||
// (excluding internal agents which are for ephemeral system tasks)
|
||
const registryRecords = await this.agentStorage.list()
|
||
const liveIds = new Set(agentSnapshots.map((a) => a.id))
|
||
const persistedAgents = registryRecords
|
||
.filter((record) => !liveIds.has(record.id) && !record.internal)
|
||
.map((record) => this.buildStoredAgentPayload(record))
|
||
|
||
let agents = [...liveAgents, ...persistedAgents]
|
||
|
||
// Filter by labels if filter provided
|
||
if (filter?.labels) {
|
||
const filterLabels = filter.labels
|
||
agents = agents.filter((agent) =>
|
||
Object.entries(filterLabels).every(([key, value]) => agent.labels[key] === value)
|
||
)
|
||
}
|
||
|
||
return agents
|
||
}
|
||
|
||
private async resolveAgentIdentifier(
|
||
identifier: string
|
||
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }> {
|
||
const trimmed = identifier.trim()
|
||
if (!trimmed) {
|
||
return { ok: false, error: 'Agent identifier cannot be empty' }
|
||
}
|
||
|
||
const stored = await this.agentStorage.list()
|
||
const storedRecords = stored.filter((record) => !record.internal)
|
||
const knownIds = new Set<string>()
|
||
for (const record of storedRecords) {
|
||
knownIds.add(record.id)
|
||
}
|
||
for (const agent of this.agentManager.listAgents()) {
|
||
knownIds.add(agent.id)
|
||
}
|
||
|
||
if (knownIds.has(trimmed)) {
|
||
return { ok: true, agentId: trimmed }
|
||
}
|
||
|
||
const prefixMatches = Array.from(knownIds).filter((id) => id.startsWith(trimmed))
|
||
if (prefixMatches.length === 1) {
|
||
return { ok: true, agentId: prefixMatches[0] }
|
||
}
|
||
if (prefixMatches.length > 1) {
|
||
return {
|
||
ok: false,
|
||
error: `Agent identifier "${trimmed}" is ambiguous (${prefixMatches
|
||
.slice(0, 5)
|
||
.map((id) => id.slice(0, 8))
|
||
.join(', ')}${prefixMatches.length > 5 ? ', …' : ''})`,
|
||
}
|
||
}
|
||
|
||
const titleMatches = storedRecords.filter((record) => record.title === trimmed)
|
||
if (titleMatches.length === 1) {
|
||
return { ok: true, agentId: titleMatches[0].id }
|
||
}
|
||
if (titleMatches.length > 1) {
|
||
return {
|
||
ok: false,
|
||
error: `Agent title "${trimmed}" is ambiguous (${titleMatches
|
||
.slice(0, 5)
|
||
.map((r) => r.id.slice(0, 8))
|
||
.join(', ')}${titleMatches.length > 5 ? ', …' : ''})`,
|
||
}
|
||
}
|
||
|
||
return { ok: false, error: `Agent not found: ${trimmed}` }
|
||
}
|
||
|
||
private async getAgentPayloadById(agentId: string): Promise<AgentSnapshotPayload | null> {
|
||
const live = this.agentManager.getAgent(agentId)
|
||
if (live) {
|
||
return await this.buildAgentPayload(live)
|
||
}
|
||
|
||
const record = await this.agentStorage.get(agentId)
|
||
if (!record || record.internal) {
|
||
return null
|
||
}
|
||
return this.buildStoredAgentPayload(record)
|
||
}
|
||
|
||
private normalizeFetchAgentsSort(
|
||
sort: FetchAgentsRequestSort[] | undefined
|
||
): FetchAgentsRequestSort[] {
|
||
const fallback: FetchAgentsRequestSort[] = [{ key: 'updated_at', direction: 'desc' }]
|
||
if (!sort || sort.length === 0) {
|
||
return fallback
|
||
}
|
||
|
||
const deduped: FetchAgentsRequestSort[] = []
|
||
const seen = new Set<string>()
|
||
for (const entry of sort) {
|
||
if (seen.has(entry.key)) {
|
||
continue
|
||
}
|
||
seen.add(entry.key)
|
||
deduped.push(entry)
|
||
}
|
||
return deduped.length > 0 ? deduped : fallback
|
||
}
|
||
|
||
private getStatusPriority(agent: AgentSnapshotPayload): number {
|
||
const attentionReason = agent.attentionReason ?? null
|
||
const hasPendingPermission = (agent.pendingPermissions?.length ?? 0) > 0
|
||
if (hasPendingPermission || attentionReason === 'permission') {
|
||
return 0
|
||
}
|
||
if (agent.status === 'error' || attentionReason === 'error') {
|
||
return 1
|
||
}
|
||
if (agent.status === 'running') {
|
||
return 2
|
||
}
|
||
if (agent.status === 'initializing') {
|
||
return 3
|
||
}
|
||
return 4
|
||
}
|
||
|
||
private getFetchAgentsSortValue(
|
||
entry: FetchAgentsResponseEntry,
|
||
key: FetchAgentsRequestSort['key']
|
||
): string | number | null {
|
||
switch (key) {
|
||
case 'status_priority':
|
||
return this.getStatusPriority(entry.agent)
|
||
case 'created_at':
|
||
return Date.parse(entry.agent.createdAt)
|
||
case 'updated_at':
|
||
return Date.parse(entry.agent.updatedAt)
|
||
case 'title':
|
||
return entry.agent.title?.toLocaleLowerCase() ?? ''
|
||
}
|
||
}
|
||
|
||
private getFetchAgentsSortValueFromAgent(
|
||
agent: AgentSnapshotPayload,
|
||
key: FetchAgentsRequestSort['key']
|
||
): string | number | null {
|
||
switch (key) {
|
||
case 'status_priority':
|
||
return this.getStatusPriority(agent)
|
||
case 'created_at':
|
||
return Date.parse(agent.createdAt)
|
||
case 'updated_at':
|
||
return Date.parse(agent.updatedAt)
|
||
case 'title':
|
||
return agent.title?.toLocaleLowerCase() ?? ''
|
||
}
|
||
}
|
||
|
||
private compareSortValues(left: string | number | null, right: string | number | null): number {
|
||
if (left === right) {
|
||
return 0
|
||
}
|
||
if (left === null) {
|
||
return -1
|
||
}
|
||
if (right === null) {
|
||
return 1
|
||
}
|
||
if (typeof left === 'number' && typeof right === 'number') {
|
||
return left < right ? -1 : 1
|
||
}
|
||
return String(left).localeCompare(String(right))
|
||
}
|
||
|
||
private compareFetchAgentsAgents(
|
||
left: AgentSnapshotPayload,
|
||
right: AgentSnapshotPayload,
|
||
sort: FetchAgentsRequestSort[]
|
||
): number {
|
||
for (const spec of sort) {
|
||
const leftValue = this.getFetchAgentsSortValueFromAgent(left, spec.key)
|
||
const rightValue = this.getFetchAgentsSortValueFromAgent(right, spec.key)
|
||
const base = this.compareSortValues(leftValue, rightValue)
|
||
if (base === 0) {
|
||
continue
|
||
}
|
||
return spec.direction === 'asc' ? base : -base
|
||
}
|
||
return left.id.localeCompare(right.id)
|
||
}
|
||
|
||
private encodeFetchAgentsCursor(
|
||
entry: FetchAgentsResponseEntry,
|
||
sort: FetchAgentsRequestSort[]
|
||
): string {
|
||
const values: Record<string, string | number | null> = {}
|
||
for (const spec of sort) {
|
||
values[spec.key] = this.getFetchAgentsSortValue(entry, spec.key)
|
||
}
|
||
return Buffer.from(
|
||
JSON.stringify({
|
||
sort,
|
||
values,
|
||
id: entry.agent.id,
|
||
}),
|
||
'utf8'
|
||
).toString('base64url')
|
||
}
|
||
|
||
private decodeFetchAgentsCursor(
|
||
cursor: string,
|
||
sort: FetchAgentsRequestSort[]
|
||
): FetchAgentsCursor {
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
||
} catch {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor')
|
||
}
|
||
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor')
|
||
}
|
||
|
||
const payload = parsed as {
|
||
sort?: unknown
|
||
values?: unknown
|
||
id?: unknown
|
||
}
|
||
|
||
if (!Array.isArray(payload.sort) || typeof payload.id !== 'string') {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor')
|
||
}
|
||
if (!payload.values || typeof payload.values !== 'object') {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor')
|
||
}
|
||
|
||
const cursorSort: FetchAgentsRequestSort[] = []
|
||
for (const item of payload.sort) {
|
||
if (
|
||
!item ||
|
||
typeof item !== 'object' ||
|
||
typeof (item as { key?: unknown }).key !== 'string' ||
|
||
typeof (item as { direction?: unknown }).direction !== 'string'
|
||
) {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor')
|
||
}
|
||
|
||
const key = (item as { key: string }).key
|
||
const direction = (item as { direction: string }).direction
|
||
if (
|
||
(key !== 'status_priority' &&
|
||
key !== 'created_at' &&
|
||
key !== 'updated_at' &&
|
||
key !== 'title') ||
|
||
(direction !== 'asc' && direction !== 'desc')
|
||
) {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_agents cursor')
|
||
}
|
||
cursorSort.push({ key, direction })
|
||
}
|
||
|
||
if (
|
||
cursorSort.length !== sort.length ||
|
||
cursorSort.some(
|
||
(entry, index) =>
|
||
entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction
|
||
)
|
||
) {
|
||
throw new SessionRequestError(
|
||
'invalid_cursor',
|
||
'fetch_agents cursor does not match current sort'
|
||
)
|
||
}
|
||
|
||
return {
|
||
sort: cursorSort,
|
||
values: payload.values as Record<string, string | number | null>,
|
||
id: payload.id,
|
||
}
|
||
}
|
||
|
||
private compareAgentWithCursor(
|
||
agent: AgentSnapshotPayload,
|
||
cursor: FetchAgentsCursor,
|
||
sort: FetchAgentsRequestSort[]
|
||
): number {
|
||
for (const spec of sort) {
|
||
const leftValue = this.getFetchAgentsSortValueFromAgent(agent, spec.key)
|
||
const rightValue =
|
||
cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null
|
||
const base = this.compareSortValues(leftValue, rightValue)
|
||
if (base === 0) {
|
||
continue
|
||
}
|
||
return spec.direction === 'asc' ? base : -base
|
||
}
|
||
return agent.id.localeCompare(cursor.id)
|
||
}
|
||
|
||
private async listFetchAgentsEntries(
|
||
request: Extract<SessionInboundMessage, { type: 'fetch_agents_request' }>
|
||
): Promise<{
|
||
entries: FetchAgentsResponseEntry[]
|
||
pageInfo: FetchAgentsResponsePageInfo
|
||
}> {
|
||
const filter = request.filter
|
||
const sort = this.normalizeFetchAgentsSort(request.sort)
|
||
|
||
const agents = await this.listAgentPayloads({
|
||
labels: filter?.labels,
|
||
})
|
||
|
||
const placementByCwd = new Map<string, Promise<ProjectPlacementPayload>>()
|
||
const getPlacement = (cwd: string): Promise<ProjectPlacementPayload> => {
|
||
const existing = placementByCwd.get(cwd)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
const placementPromise = this.buildProjectPlacement(cwd)
|
||
placementByCwd.set(cwd, placementPromise)
|
||
return placementPromise
|
||
}
|
||
|
||
let candidates = [...agents]
|
||
candidates.sort((left, right) => this.compareFetchAgentsAgents(left, right, sort))
|
||
const cursorToken = request.page?.cursor
|
||
if (cursorToken) {
|
||
const cursor = this.decodeFetchAgentsCursor(cursorToken, sort)
|
||
candidates = candidates.filter((agent) => this.compareAgentWithCursor(agent, cursor, sort) > 0)
|
||
}
|
||
|
||
const limit = request.page?.limit ?? 200
|
||
|
||
const matchedEntries: FetchAgentsResponseEntry[] = []
|
||
const batchSize = 25
|
||
for (let start = 0; start < candidates.length && matchedEntries.length <= limit; start += batchSize) {
|
||
const batch = candidates.slice(start, start + batchSize)
|
||
const batchEntries = await Promise.all(
|
||
batch.map(async (agent) => ({
|
||
agent,
|
||
project: await getPlacement(agent.cwd),
|
||
}))
|
||
)
|
||
for (const entry of batchEntries) {
|
||
if (
|
||
!this.matchesAgentFilter({
|
||
agent: entry.agent,
|
||
project: entry.project,
|
||
filter,
|
||
})
|
||
) {
|
||
continue
|
||
}
|
||
matchedEntries.push(entry)
|
||
if (matchedEntries.length > limit) {
|
||
break
|
||
}
|
||
}
|
||
}
|
||
|
||
const pagedEntries = matchedEntries.slice(0, limit)
|
||
const hasMore = matchedEntries.length > limit
|
||
const nextCursor =
|
||
hasMore && pagedEntries.length > 0
|
||
? this.encodeFetchAgentsCursor(pagedEntries[pagedEntries.length - 1], sort)
|
||
: null
|
||
|
||
return {
|
||
entries: pagedEntries,
|
||
pageInfo: {
|
||
nextCursor,
|
||
prevCursor: request.page?.cursor ?? null,
|
||
hasMore,
|
||
},
|
||
}
|
||
}
|
||
|
||
private readonly workspaceStatePriority: Record<WorkspaceStateBucket, number> = {
|
||
needs_input: 0,
|
||
failed: 1,
|
||
running: 2,
|
||
attention: 3,
|
||
done: 4,
|
||
}
|
||
|
||
private normalizeWorkspaceId(cwd: string): string {
|
||
const trimmed = cwd.trim()
|
||
if (!trimmed) {
|
||
return cwd
|
||
}
|
||
return resolve(trimmed)
|
||
}
|
||
|
||
private deriveWorkspaceStateBucket(agent: AgentSnapshotPayload): WorkspaceStateBucket {
|
||
const pendingPermissionCount = agent.pendingPermissions?.length ?? 0
|
||
if (pendingPermissionCount > 0 || agent.attentionReason === 'permission') {
|
||
return 'needs_input'
|
||
}
|
||
if (agent.status === 'error' || agent.attentionReason === 'error') {
|
||
return 'failed'
|
||
}
|
||
if (agent.status === 'running' || agent.status === 'initializing') {
|
||
return 'running'
|
||
}
|
||
if (agent.requiresAttention) {
|
||
return 'attention'
|
||
}
|
||
return 'done'
|
||
}
|
||
|
||
private deriveWorkspaceDirectoryName(cwd: string): string {
|
||
const normalized = cwd.replace(/\\/g, '/')
|
||
const segments = normalized.split('/').filter(Boolean)
|
||
return segments[segments.length - 1] ?? cwd
|
||
}
|
||
|
||
private deriveWorkspaceName(input: {
|
||
cwd: string
|
||
checkout: ProjectCheckoutLitePayload
|
||
}): string {
|
||
const branch = input.checkout.currentBranch?.trim() ?? null
|
||
if (branch && branch.toUpperCase() !== 'HEAD') {
|
||
return branch
|
||
}
|
||
return this.deriveWorkspaceDirectoryName(input.cwd)
|
||
}
|
||
|
||
private accumulateLatestActivityAt(
|
||
current: string | null,
|
||
agent: AgentSnapshotPayload
|
||
): string | null {
|
||
const candidateRaw = agent.lastUserMessageAt ?? agent.updatedAt
|
||
const candidateMs = Date.parse(candidateRaw)
|
||
if (Number.isNaN(candidateMs)) {
|
||
return current
|
||
}
|
||
if (!current) {
|
||
return new Date(candidateMs).toISOString()
|
||
}
|
||
const currentMs = Date.parse(current)
|
||
if (Number.isNaN(currentMs) || candidateMs > currentMs) {
|
||
return new Date(candidateMs).toISOString()
|
||
}
|
||
return current
|
||
}
|
||
|
||
private async listWorkspaceDescriptors(): Promise<WorkspaceDescriptorPayload[]> {
|
||
const agents = await this.listAgentPayloads()
|
||
|
||
const descriptorsByWorkspaceId = new Map<string, WorkspaceDescriptorPayload>()
|
||
const placementByWorkspaceId = new Map<string, Promise<ProjectPlacementPayload>>()
|
||
const getPlacement = (workspaceCwd: string): Promise<ProjectPlacementPayload> => {
|
||
const key = this.normalizeWorkspaceId(workspaceCwd)
|
||
const existing = placementByWorkspaceId.get(key)
|
||
if (existing) {
|
||
return existing
|
||
}
|
||
const next = this.buildProjectPlacement(workspaceCwd)
|
||
placementByWorkspaceId.set(key, next)
|
||
return next
|
||
}
|
||
|
||
for (const agent of agents) {
|
||
if (agent.archivedAt) {
|
||
continue
|
||
}
|
||
|
||
const workspaceId = this.normalizeWorkspaceId(agent.cwd)
|
||
const placement = await getPlacement(workspaceId)
|
||
const existing = descriptorsByWorkspaceId.get(workspaceId)
|
||
if (!existing) {
|
||
const bucket = this.deriveWorkspaceStateBucket(agent)
|
||
descriptorsByWorkspaceId.set(workspaceId, {
|
||
id: workspaceId,
|
||
projectId: placement.projectKey,
|
||
name: this.deriveWorkspaceName({
|
||
cwd: workspaceId,
|
||
checkout: placement.checkout,
|
||
}),
|
||
status: bucket,
|
||
activityAt: this.accumulateLatestActivityAt(null, agent),
|
||
})
|
||
continue
|
||
}
|
||
|
||
const bucket = this.deriveWorkspaceStateBucket(agent)
|
||
if (this.workspaceStatePriority[bucket] < this.workspaceStatePriority[existing.status]) {
|
||
existing.status = bucket
|
||
}
|
||
existing.activityAt = this.accumulateLatestActivityAt(existing.activityAt, agent)
|
||
}
|
||
|
||
return Array.from(descriptorsByWorkspaceId.values())
|
||
}
|
||
|
||
private normalizeFetchWorkspacesSort(
|
||
sort: FetchWorkspacesRequestSort[] | undefined
|
||
): FetchWorkspacesRequestSort[] {
|
||
const fallback: FetchWorkspacesRequestSort[] = [{ key: 'activity_at', direction: 'desc' }]
|
||
if (!sort || sort.length === 0) {
|
||
return fallback
|
||
}
|
||
const deduped: FetchWorkspacesRequestSort[] = []
|
||
const seen = new Set<string>()
|
||
for (const entry of sort) {
|
||
if (seen.has(entry.key)) {
|
||
continue
|
||
}
|
||
seen.add(entry.key)
|
||
deduped.push(entry)
|
||
}
|
||
return deduped.length > 0 ? deduped : fallback
|
||
}
|
||
|
||
private getFetchWorkspacesSortValue(
|
||
workspace: WorkspaceDescriptorPayload,
|
||
key: FetchWorkspacesRequestSort['key']
|
||
): string | number | null {
|
||
switch (key) {
|
||
case 'status_priority':
|
||
return this.workspaceStatePriority[workspace.status]
|
||
case 'activity_at':
|
||
return workspace.activityAt ? Date.parse(workspace.activityAt) : null
|
||
case 'name':
|
||
return workspace.name.toLocaleLowerCase()
|
||
case 'project_id':
|
||
return workspace.projectId.toLocaleLowerCase()
|
||
}
|
||
}
|
||
|
||
private compareFetchWorkspacesEntries(
|
||
left: WorkspaceDescriptorPayload,
|
||
right: WorkspaceDescriptorPayload,
|
||
sort: FetchWorkspacesRequestSort[]
|
||
): number {
|
||
for (const spec of sort) {
|
||
const leftValue = this.getFetchWorkspacesSortValue(left, spec.key)
|
||
const rightValue = this.getFetchWorkspacesSortValue(right, spec.key)
|
||
const base = this.compareSortValues(leftValue, rightValue)
|
||
if (base === 0) {
|
||
continue
|
||
}
|
||
return spec.direction === 'asc' ? base : -base
|
||
}
|
||
return left.id.localeCompare(right.id)
|
||
}
|
||
|
||
private encodeFetchWorkspacesCursor(
|
||
entry: FetchWorkspacesResponseEntry,
|
||
sort: FetchWorkspacesRequestSort[]
|
||
): string {
|
||
const values: Record<string, string | number | null> = {}
|
||
for (const spec of sort) {
|
||
values[spec.key] = this.getFetchWorkspacesSortValue(entry, spec.key)
|
||
}
|
||
return Buffer.from(
|
||
JSON.stringify({
|
||
sort,
|
||
values,
|
||
id: entry.id,
|
||
}),
|
||
'utf8'
|
||
).toString('base64url')
|
||
}
|
||
|
||
private decodeFetchWorkspacesCursor(
|
||
cursor: string,
|
||
sort: FetchWorkspacesRequestSort[]
|
||
): FetchWorkspacesCursor {
|
||
let parsed: unknown
|
||
try {
|
||
parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8'))
|
||
} catch {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor')
|
||
}
|
||
|
||
if (!parsed || typeof parsed !== 'object') {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor')
|
||
}
|
||
|
||
const payload = parsed as {
|
||
sort?: unknown
|
||
values?: unknown
|
||
id?: unknown
|
||
}
|
||
|
||
if (!Array.isArray(payload.sort) || typeof payload.id !== 'string') {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor')
|
||
}
|
||
if (!payload.values || typeof payload.values !== 'object') {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor')
|
||
}
|
||
|
||
const cursorSort: FetchWorkspacesRequestSort[] = []
|
||
for (const item of payload.sort) {
|
||
if (
|
||
!item ||
|
||
typeof item !== 'object' ||
|
||
typeof (item as { key?: unknown }).key !== 'string' ||
|
||
typeof (item as { direction?: unknown }).direction !== 'string'
|
||
) {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor')
|
||
}
|
||
|
||
const key = (item as { key: string }).key
|
||
const direction = (item as { direction: string }).direction
|
||
if (
|
||
(key !== 'status_priority' &&
|
||
key !== 'activity_at' &&
|
||
key !== 'name' &&
|
||
key !== 'project_id') ||
|
||
(direction !== 'asc' && direction !== 'desc')
|
||
) {
|
||
throw new SessionRequestError('invalid_cursor', 'Invalid fetch_workspaces cursor')
|
||
}
|
||
cursorSort.push({ key, direction })
|
||
}
|
||
|
||
if (
|
||
cursorSort.length !== sort.length ||
|
||
cursorSort.some(
|
||
(entry, index) =>
|
||
entry.key !== sort[index]?.key || entry.direction !== sort[index]?.direction
|
||
)
|
||
) {
|
||
throw new SessionRequestError(
|
||
'invalid_cursor',
|
||
'fetch_workspaces cursor does not match current sort'
|
||
)
|
||
}
|
||
|
||
return {
|
||
sort: cursorSort,
|
||
values: payload.values as Record<string, string | number | null>,
|
||
id: payload.id,
|
||
}
|
||
}
|
||
|
||
private compareWorkspaceWithCursor(
|
||
workspace: WorkspaceDescriptorPayload,
|
||
cursor: FetchWorkspacesCursor,
|
||
sort: FetchWorkspacesRequestSort[]
|
||
): number {
|
||
for (const spec of sort) {
|
||
const leftValue = this.getFetchWorkspacesSortValue(workspace, spec.key)
|
||
const rightValue =
|
||
cursor.values[spec.key] !== undefined ? (cursor.values[spec.key] ?? null) : null
|
||
const base = this.compareSortValues(leftValue, rightValue)
|
||
if (base === 0) {
|
||
continue
|
||
}
|
||
return spec.direction === 'asc' ? base : -base
|
||
}
|
||
return workspace.id.localeCompare(cursor.id)
|
||
}
|
||
|
||
private matchesWorkspaceFilter(input: {
|
||
workspace: WorkspaceDescriptorPayload
|
||
filter: FetchWorkspacesRequestFilter | undefined
|
||
}): boolean {
|
||
const { workspace, filter } = input
|
||
if (!filter) {
|
||
return true
|
||
}
|
||
|
||
if (filter.projectId && filter.projectId.trim().length > 0) {
|
||
if (workspace.projectId !== filter.projectId.trim()) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
if (filter.idPrefix && filter.idPrefix.trim().length > 0) {
|
||
if (!workspace.id.startsWith(filter.idPrefix.trim())) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
if (filter.query && filter.query.trim().length > 0) {
|
||
const query = filter.query.trim().toLocaleLowerCase()
|
||
const haystacks = [workspace.name, workspace.projectId, workspace.id]
|
||
if (!haystacks.some((value) => value.toLocaleLowerCase().includes(query))) {
|
||
return false
|
||
}
|
||
}
|
||
|
||
return true
|
||
}
|
||
|
||
private async listFetchWorkspacesEntries(
|
||
request: Extract<SessionInboundMessage, { type: 'fetch_workspaces_request' }>
|
||
): Promise<{
|
||
entries: FetchWorkspacesResponseEntry[]
|
||
pageInfo: FetchWorkspacesResponsePageInfo
|
||
}> {
|
||
const filter = request.filter
|
||
const sort = this.normalizeFetchWorkspacesSort(request.sort)
|
||
let entries = await this.listWorkspaceDescriptors()
|
||
entries = entries.filter((workspace) => this.matchesWorkspaceFilter({ workspace, filter }))
|
||
entries.sort((left, right) => this.compareFetchWorkspacesEntries(left, right, sort))
|
||
|
||
const cursorToken = request.page?.cursor
|
||
if (cursorToken) {
|
||
const cursor = this.decodeFetchWorkspacesCursor(cursorToken, sort)
|
||
entries = entries.filter((workspace) => this.compareWorkspaceWithCursor(workspace, cursor, sort) > 0)
|
||
}
|
||
|
||
const limit = request.page?.limit ?? 200
|
||
const pagedEntries = entries.slice(0, limit)
|
||
const hasMore = entries.length > limit
|
||
const nextCursor =
|
||
hasMore && pagedEntries.length > 0
|
||
? this.encodeFetchWorkspacesCursor(pagedEntries[pagedEntries.length - 1], sort)
|
||
: null
|
||
|
||
return {
|
||
entries: pagedEntries,
|
||
pageInfo: {
|
||
nextCursor,
|
||
prevCursor: request.page?.cursor ?? null,
|
||
hasMore,
|
||
},
|
||
}
|
||
}
|
||
|
||
private bufferOrEmitWorkspaceUpdate(
|
||
subscription: WorkspaceUpdatesSubscriptionState,
|
||
payload: WorkspaceUpdatePayload
|
||
): void {
|
||
if (subscription.isBootstrapping) {
|
||
const workspaceId = payload.kind === 'upsert' ? payload.workspace.id : payload.id
|
||
subscription.pendingUpdatesByWorkspaceId.set(workspaceId, payload)
|
||
return
|
||
}
|
||
this.emit({
|
||
type: 'workspace_update',
|
||
payload,
|
||
})
|
||
}
|
||
|
||
private flushBootstrappedWorkspaceUpdates(options?: {
|
||
snapshotLatestActivityByWorkspaceId?: Map<string, number>
|
||
}): void {
|
||
const subscription = this.workspaceUpdatesSubscription
|
||
if (!subscription || !subscription.isBootstrapping) {
|
||
return
|
||
}
|
||
|
||
subscription.isBootstrapping = false
|
||
const pending = Array.from(subscription.pendingUpdatesByWorkspaceId.values())
|
||
subscription.pendingUpdatesByWorkspaceId.clear()
|
||
|
||
for (const payload of pending) {
|
||
if (payload.kind === 'upsert') {
|
||
const snapshotLatestActivity = options?.snapshotLatestActivityByWorkspaceId?.get(
|
||
payload.workspace.id
|
||
)
|
||
if (typeof snapshotLatestActivity === 'number') {
|
||
const updateLatestActivity = payload.workspace.activityAt
|
||
? Date.parse(payload.workspace.activityAt)
|
||
: Number.NEGATIVE_INFINITY
|
||
if (!Number.isNaN(updateLatestActivity) && updateLatestActivity <= snapshotLatestActivity) {
|
||
continue
|
||
}
|
||
}
|
||
}
|
||
this.emit({
|
||
type: 'workspace_update',
|
||
payload,
|
||
})
|
||
}
|
||
}
|
||
|
||
private async emitWorkspaceUpdateForCwd(cwd: string): Promise<void> {
|
||
const subscription = this.workspaceUpdatesSubscription
|
||
if (!subscription) {
|
||
return
|
||
}
|
||
|
||
const workspaceId = this.normalizeWorkspaceId(cwd)
|
||
const all = await this.listWorkspaceDescriptors()
|
||
const workspace = all.find((entry) => entry.id === workspaceId)
|
||
if (!workspace) {
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: 'remove',
|
||
id: workspaceId,
|
||
})
|
||
return
|
||
}
|
||
|
||
if (!this.matchesWorkspaceFilter({ workspace, filter: subscription.filter })) {
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: 'remove',
|
||
id: workspaceId,
|
||
})
|
||
return
|
||
}
|
||
|
||
this.bufferOrEmitWorkspaceUpdate(subscription, {
|
||
kind: 'upsert',
|
||
workspace,
|
||
})
|
||
}
|
||
|
||
private async emitWorkspaceUpdatesForCwds(cwds: Iterable<string>): Promise<void> {
|
||
if (!this.workspaceUpdatesSubscription) {
|
||
return
|
||
}
|
||
|
||
const uniqueWorkspaceCwds = new Set<string>()
|
||
for (const cwd of cwds) {
|
||
const normalized = this.normalizeWorkspaceId(cwd)
|
||
if (!normalized) {
|
||
continue
|
||
}
|
||
uniqueWorkspaceCwds.add(normalized)
|
||
}
|
||
|
||
for (const workspaceCwd of uniqueWorkspaceCwds) {
|
||
await this.emitWorkspaceUpdateForCwd(workspaceCwd)
|
||
}
|
||
}
|
||
|
||
private async handleFetchAgents(
|
||
request: Extract<SessionInboundMessage, { type: 'fetch_agents_request' }>
|
||
): Promise<void> {
|
||
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim()
|
||
const subscriptionId = request.subscribe
|
||
? requestedSubscriptionId && requestedSubscriptionId.length > 0
|
||
? requestedSubscriptionId
|
||
: uuidv4()
|
||
: null
|
||
|
||
try {
|
||
if (subscriptionId) {
|
||
this.agentUpdatesSubscription = {
|
||
subscriptionId,
|
||
filter: request.filter,
|
||
isBootstrapping: true,
|
||
pendingUpdatesByAgentId: new Map(),
|
||
}
|
||
}
|
||
|
||
const payload = await this.listFetchAgentsEntries(request)
|
||
const snapshotUpdatedAtByAgentId = new Map<string, number>()
|
||
for (const entry of payload.entries) {
|
||
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt)
|
||
if (!Number.isNaN(parsedUpdatedAt)) {
|
||
snapshotUpdatedAtByAgentId.set(entry.agent.id, parsedUpdatedAt)
|
||
}
|
||
}
|
||
|
||
this.emit({
|
||
type: 'fetch_agents_response',
|
||
payload: {
|
||
requestId: request.requestId,
|
||
...(subscriptionId ? { subscriptionId } : {}),
|
||
...payload,
|
||
},
|
||
})
|
||
|
||
if (subscriptionId && this.agentUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||
this.flushBootstrappedAgentUpdates({ snapshotUpdatedAtByAgentId })
|
||
}
|
||
} catch (error) {
|
||
if (subscriptionId && this.agentUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||
this.agentUpdatesSubscription = null
|
||
}
|
||
const code = error instanceof SessionRequestError ? error.code : 'fetch_agents_failed'
|
||
const message = error instanceof Error ? error.message : 'Failed to fetch agents'
|
||
this.sessionLogger.error({ err: error }, 'Failed to handle fetch_agents_request')
|
||
this.emit({
|
||
type: 'rpc_error',
|
||
payload: {
|
||
requestId: request.requestId,
|
||
requestType: request.type,
|
||
error: message,
|
||
code,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleFetchWorkspacesRequest(
|
||
request: Extract<SessionInboundMessage, { type: 'fetch_workspaces_request' }>
|
||
): Promise<void> {
|
||
const requestedSubscriptionId = request.subscribe?.subscriptionId?.trim()
|
||
const subscriptionId = request.subscribe
|
||
? requestedSubscriptionId && requestedSubscriptionId.length > 0
|
||
? requestedSubscriptionId
|
||
: uuidv4()
|
||
: null
|
||
|
||
try {
|
||
if (subscriptionId) {
|
||
this.workspaceUpdatesSubscription = {
|
||
subscriptionId,
|
||
filter: request.filter,
|
||
isBootstrapping: true,
|
||
pendingUpdatesByWorkspaceId: new Map(),
|
||
}
|
||
}
|
||
|
||
const payload = await this.listFetchWorkspacesEntries(request)
|
||
const snapshotLatestActivityByWorkspaceId = new Map<string, number>()
|
||
for (const entry of payload.entries) {
|
||
const parsedLatestActivity = entry.activityAt
|
||
? Date.parse(entry.activityAt)
|
||
: Number.NEGATIVE_INFINITY
|
||
if (!Number.isNaN(parsedLatestActivity)) {
|
||
snapshotLatestActivityByWorkspaceId.set(entry.id, parsedLatestActivity)
|
||
}
|
||
}
|
||
|
||
this.emit({
|
||
type: 'fetch_workspaces_response',
|
||
payload: {
|
||
requestId: request.requestId,
|
||
...(subscriptionId ? { subscriptionId } : {}),
|
||
...payload,
|
||
},
|
||
})
|
||
|
||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||
this.flushBootstrappedWorkspaceUpdates({ snapshotLatestActivityByWorkspaceId })
|
||
}
|
||
} catch (error) {
|
||
if (subscriptionId && this.workspaceUpdatesSubscription?.subscriptionId === subscriptionId) {
|
||
this.workspaceUpdatesSubscription = null
|
||
}
|
||
const code = error instanceof SessionRequestError ? error.code : 'fetch_workspaces_failed'
|
||
const message = error instanceof Error ? error.message : 'Failed to fetch workspaces'
|
||
this.sessionLogger.error({ err: error }, 'Failed to handle fetch_workspaces_request')
|
||
this.emit({
|
||
type: 'rpc_error',
|
||
payload: {
|
||
requestId: request.requestId,
|
||
requestType: request.type,
|
||
error: message,
|
||
code,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleFetchAgent(agentIdOrIdentifier: string, requestId: string): Promise<void> {
|
||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier)
|
||
if (!resolved.ok) {
|
||
this.emit({
|
||
type: 'fetch_agent_response',
|
||
payload: { requestId, agent: null, project: null, error: resolved.error },
|
||
})
|
||
return
|
||
}
|
||
|
||
const agent = await this.getAgentPayloadById(resolved.agentId)
|
||
if (!agent) {
|
||
this.emit({
|
||
type: 'fetch_agent_response',
|
||
payload: {
|
||
requestId,
|
||
agent: null,
|
||
project: null,
|
||
error: `Agent not found: ${resolved.agentId}`,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const project = await this.buildProjectPlacement(agent.cwd)
|
||
this.emit({
|
||
type: 'fetch_agent_response',
|
||
payload: { requestId, agent, project, error: null },
|
||
})
|
||
}
|
||
|
||
private async handleFetchAgentTimelineRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'fetch_agent_timeline_request' }>
|
||
): Promise<void> {
|
||
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? 'after' : 'tail')
|
||
const projection: TimelineProjectionMode = msg.projection ?? 'projected'
|
||
const requestedLimit = msg.limit
|
||
const limit = requestedLimit ?? (direction === 'after' ? 0 : undefined)
|
||
const shouldLimitByProjectedWindow =
|
||
projection === 'canonical' &&
|
||
direction === 'tail' &&
|
||
typeof requestedLimit === 'number' &&
|
||
requestedLimit > 0
|
||
const cursor: AgentTimelineCursor | undefined = msg.cursor
|
||
? {
|
||
epoch: msg.cursor.epoch,
|
||
seq: msg.cursor.seq,
|
||
}
|
||
: undefined
|
||
|
||
try {
|
||
const snapshot = await this.ensureAgentLoaded(msg.agentId)
|
||
|
||
let timeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||
direction,
|
||
cursor,
|
||
limit:
|
||
shouldLimitByProjectedWindow && typeof requestedLimit === 'number'
|
||
? Math.max(1, Math.floor(requestedLimit))
|
||
: limit,
|
||
})
|
||
|
||
let hasOlder = timeline.hasOlder
|
||
let hasNewer = timeline.hasNewer
|
||
let startCursor: { epoch: string; seq: number } | null = null
|
||
let endCursor: { epoch: string; seq: number } | null = null
|
||
let entries: ReturnType<typeof projectTimelineRows>
|
||
|
||
if (shouldLimitByProjectedWindow) {
|
||
const projectedLimit = Math.max(1, Math.floor(requestedLimit))
|
||
let fetchLimit = projectedLimit
|
||
let projectedWindow = selectTimelineWindowByProjectedLimit({
|
||
rows: timeline.rows,
|
||
provider: snapshot.provider,
|
||
direction,
|
||
limit: projectedLimit,
|
||
collapseToolLifecycle: false,
|
||
})
|
||
|
||
while (timeline.hasOlder) {
|
||
const needsMoreProjectedEntries =
|
||
projectedWindow.projectedEntries.length < projectedLimit
|
||
const firstLoadedRow = timeline.rows[0]
|
||
const firstSelectedRow = projectedWindow.selectedRows[0]
|
||
const startsAtLoadedBoundary =
|
||
firstLoadedRow != null &&
|
||
firstSelectedRow != null &&
|
||
firstSelectedRow.seq === firstLoadedRow.seq
|
||
const boundaryIsAssistantChunk =
|
||
startsAtLoadedBoundary && firstLoadedRow.item.type === 'assistant_message'
|
||
|
||
if (!needsMoreProjectedEntries && !boundaryIsAssistantChunk) {
|
||
break
|
||
}
|
||
|
||
const maxRows = Math.max(
|
||
0,
|
||
timeline.window.maxSeq - timeline.window.minSeq + 1
|
||
)
|
||
const nextFetchLimit = Math.min(maxRows, fetchLimit * 2)
|
||
if (nextFetchLimit <= fetchLimit) {
|
||
break
|
||
}
|
||
|
||
fetchLimit = nextFetchLimit
|
||
timeline = this.agentManager.fetchTimeline(msg.agentId, {
|
||
direction,
|
||
cursor,
|
||
limit: fetchLimit,
|
||
})
|
||
projectedWindow = selectTimelineWindowByProjectedLimit({
|
||
rows: timeline.rows,
|
||
provider: snapshot.provider,
|
||
direction,
|
||
limit: projectedLimit,
|
||
collapseToolLifecycle: false,
|
||
})
|
||
}
|
||
|
||
const selectedRows = projectedWindow.selectedRows
|
||
|
||
entries = projectTimelineRows(selectedRows, snapshot.provider, projection)
|
||
|
||
if (projectedWindow.minSeq !== null && projectedWindow.maxSeq !== null) {
|
||
startCursor = { epoch: timeline.epoch, seq: projectedWindow.minSeq }
|
||
endCursor = { epoch: timeline.epoch, seq: projectedWindow.maxSeq }
|
||
hasOlder = projectedWindow.minSeq > timeline.window.minSeq
|
||
hasNewer = false
|
||
}
|
||
} else {
|
||
const firstRow = timeline.rows[0]
|
||
const lastRow = timeline.rows[timeline.rows.length - 1]
|
||
startCursor = firstRow ? { epoch: timeline.epoch, seq: firstRow.seq } : null
|
||
endCursor = lastRow ? { epoch: timeline.epoch, seq: lastRow.seq } : null
|
||
entries = projectTimelineRows(timeline.rows, snapshot.provider, projection)
|
||
}
|
||
|
||
this.emit({
|
||
type: 'fetch_agent_timeline_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId: msg.agentId,
|
||
direction,
|
||
projection,
|
||
epoch: timeline.epoch,
|
||
reset: timeline.reset,
|
||
staleCursor: timeline.staleCursor,
|
||
gap: timeline.gap,
|
||
window: timeline.window,
|
||
startCursor,
|
||
endCursor,
|
||
hasOlder,
|
||
hasNewer,
|
||
entries,
|
||
error: null,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId: msg.agentId },
|
||
'Failed to handle fetch_agent_timeline_request'
|
||
)
|
||
this.emit({
|
||
type: 'fetch_agent_timeline_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId: msg.agentId,
|
||
direction,
|
||
projection,
|
||
epoch: '',
|
||
reset: false,
|
||
staleCursor: false,
|
||
gap: false,
|
||
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
|
||
startCursor: null,
|
||
endCursor: null,
|
||
hasOlder: false,
|
||
hasNewer: false,
|
||
entries: [],
|
||
error: error instanceof Error ? error.message : String(error),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleSendAgentMessageRequest(
|
||
msg: Extract<SessionInboundMessage, { type: 'send_agent_message_request' }>
|
||
): Promise<void> {
|
||
const resolved = await this.resolveAgentIdentifier(msg.agentId)
|
||
if (!resolved.ok) {
|
||
this.emit({
|
||
type: 'send_agent_message_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId: msg.agentId,
|
||
accepted: false,
|
||
error: resolved.error,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const agentId = resolved.agentId
|
||
await this.unarchiveAgentState(agentId)
|
||
|
||
await this.ensureAgentLoaded(agentId)
|
||
await this.interruptAgentIfRunning(agentId)
|
||
|
||
try {
|
||
this.agentManager.recordUserMessage(agentId, msg.text, {
|
||
messageId: msg.messageId,
|
||
emitState: false,
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.error(
|
||
{ err: error, agentId },
|
||
'Failed to record user message for send_agent_message_request'
|
||
)
|
||
}
|
||
|
||
const prompt = this.buildAgentPrompt(msg.text, msg.images)
|
||
const started = this.startAgentStream(agentId, prompt)
|
||
if (!started.ok) {
|
||
this.emit({
|
||
type: 'send_agent_message_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: started.error,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const startAbort = new AbortController()
|
||
const startTimeoutMs = 15_000
|
||
const startTimeout = setTimeout(() => startAbort.abort('timeout'), startTimeoutMs)
|
||
try {
|
||
await this.agentManager.waitForAgentRunStart(agentId, { signal: startAbort.signal })
|
||
} catch (error) {
|
||
const message =
|
||
error instanceof Error
|
||
? error.message
|
||
: typeof error === 'string'
|
||
? error
|
||
: 'Unknown error'
|
||
this.emit({
|
||
type: 'send_agent_message_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId,
|
||
accepted: false,
|
||
error: message,
|
||
},
|
||
})
|
||
return
|
||
} finally {
|
||
clearTimeout(startTimeout)
|
||
}
|
||
|
||
this.emit({
|
||
type: 'send_agent_message_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId,
|
||
accepted: true,
|
||
error: null,
|
||
},
|
||
})
|
||
} catch (error) {
|
||
const message =
|
||
error instanceof Error ? error.message : typeof error === 'string' ? error : 'Unknown error'
|
||
this.emit({
|
||
type: 'send_agent_message_response',
|
||
payload: {
|
||
requestId: msg.requestId,
|
||
agentId: resolved.agentId,
|
||
accepted: false,
|
||
error: message,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleWaitForFinish(
|
||
agentIdOrIdentifier: string,
|
||
requestId: string,
|
||
timeoutMs?: number
|
||
): Promise<void> {
|
||
const resolved = await this.resolveAgentIdentifier(agentIdOrIdentifier)
|
||
if (!resolved.ok) {
|
||
this.emit({
|
||
type: 'wait_for_finish_response',
|
||
payload: {
|
||
requestId,
|
||
status: 'error',
|
||
final: null,
|
||
error: resolved.error,
|
||
lastMessage: null,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const agentId = resolved.agentId
|
||
const live = this.agentManager.getAgent(agentId)
|
||
if (!live) {
|
||
const record = await this.agentStorage.get(agentId)
|
||
if (!record || record.internal) {
|
||
this.emit({
|
||
type: 'wait_for_finish_response',
|
||
payload: {
|
||
requestId,
|
||
status: 'error',
|
||
final: null,
|
||
error: `Agent not found: ${agentId}`,
|
||
lastMessage: null,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
const final = this.buildStoredAgentPayload(record)
|
||
const status =
|
||
record.attentionReason === 'permission'
|
||
? 'permission'
|
||
: record.lastStatus === 'error'
|
||
? 'error'
|
||
: 'idle'
|
||
this.emit({
|
||
type: 'wait_for_finish_response',
|
||
payload: { requestId, status, final, error: null, lastMessage: null },
|
||
})
|
||
return
|
||
}
|
||
|
||
const abortController = new AbortController()
|
||
const hasTimeout = typeof timeoutMs === 'number' && timeoutMs > 0
|
||
const timeoutHandle = hasTimeout
|
||
? setTimeout(() => {
|
||
abortController.abort('timeout')
|
||
}, timeoutMs)
|
||
: null
|
||
|
||
try {
|
||
let result = await this.agentManager.waitForAgentEvent(agentId, {
|
||
signal: abortController.signal,
|
||
})
|
||
let final = await this.getAgentPayloadById(agentId)
|
||
if (!final) {
|
||
throw new Error(`Agent ${agentId} disappeared while waiting`)
|
||
}
|
||
|
||
let status: 'permission' | 'error' | 'idle' =
|
||
result.permission ? 'permission' : result.status === 'error' ? 'error' : 'idle'
|
||
|
||
this.emit({
|
||
type: 'wait_for_finish_response',
|
||
payload: { requestId, status, final, error: null, lastMessage: result.lastMessage },
|
||
})
|
||
} catch (error) {
|
||
const isAbort =
|
||
error instanceof Error &&
|
||
(error.name === 'AbortError' || error.message.toLowerCase().includes('aborted'))
|
||
if (!isAbort) {
|
||
const message =
|
||
error instanceof Error
|
||
? error.message
|
||
: typeof error === 'string'
|
||
? error
|
||
: 'Unknown error'
|
||
this.sessionLogger.error({ err: error, agentId }, 'wait_for_finish_request failed')
|
||
const final = await this.getAgentPayloadById(agentId)
|
||
this.emit({
|
||
type: 'wait_for_finish_response',
|
||
payload: {
|
||
requestId,
|
||
status: 'error',
|
||
final,
|
||
error: message,
|
||
lastMessage: null,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const final = await this.getAgentPayloadById(agentId)
|
||
if (!final) {
|
||
throw new Error(`Agent ${agentId} disappeared while waiting`)
|
||
}
|
||
this.emit({
|
||
type: 'wait_for_finish_response',
|
||
payload: { requestId, status: 'timeout', final, error: null, lastMessage: null },
|
||
})
|
||
} finally {
|
||
if (timeoutHandle) {
|
||
clearTimeout(timeoutHandle)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle audio chunk for buffering and transcription
|
||
*/
|
||
private async handleAudioChunk(
|
||
msg: Extract<SessionInboundMessage, { type: 'voice_audio_chunk' }>
|
||
): Promise<void> {
|
||
if (!this.isVoiceMode) {
|
||
this.sessionLogger.warn(
|
||
'Received voice_audio_chunk while voice mode is disabled; transcript will be emitted but voice assistant turn is skipped'
|
||
)
|
||
}
|
||
|
||
await this.handleVoiceSpeechStart()
|
||
|
||
const chunkFormat = msg.format || 'audio/wav'
|
||
|
||
if (this.isVoiceMode) {
|
||
await this.appendToActiveVoiceDictationStream(msg.audio, chunkFormat)
|
||
if (!msg.isLast) {
|
||
this.setVoiceModeInactivityTimeout()
|
||
this.sessionLogger.debug('Voice mode: streaming chunk, waiting for speech end')
|
||
return
|
||
}
|
||
|
||
this.clearVoiceModeInactivityTimeout()
|
||
this.sessionLogger.debug('Voice mode: speech ended, finalizing streaming transcription')
|
||
await this.finalizeActiveVoiceDictationStream('speech ended')
|
||
return
|
||
}
|
||
|
||
const chunkBuffer = Buffer.from(msg.audio, 'base64')
|
||
const isPCMChunk = chunkFormat.toLowerCase().includes('pcm')
|
||
|
||
if (!this.audioBuffer) {
|
||
this.audioBuffer = {
|
||
chunks: [],
|
||
format: chunkFormat,
|
||
isPCM: isPCMChunk,
|
||
totalPCMBytes: 0,
|
||
}
|
||
}
|
||
|
||
// If the format changes mid-stream, flush what we have first
|
||
if (this.audioBuffer.isPCM !== isPCMChunk) {
|
||
this.sessionLogger.debug(
|
||
{
|
||
oldFormat: this.audioBuffer.isPCM ? 'pcm' : this.audioBuffer.format,
|
||
newFormat: chunkFormat,
|
||
},
|
||
`Audio format changed mid-stream, flushing current buffer`
|
||
)
|
||
const finalized = this.finalizeBufferedAudio()
|
||
if (finalized) {
|
||
await this.processCompletedAudio(finalized.audio, finalized.format)
|
||
}
|
||
this.audioBuffer = {
|
||
chunks: [],
|
||
format: chunkFormat,
|
||
isPCM: isPCMChunk,
|
||
totalPCMBytes: 0,
|
||
}
|
||
} else if (!this.audioBuffer.isPCM) {
|
||
// Keep latest format info for non-PCM blobs
|
||
this.audioBuffer.format = chunkFormat
|
||
}
|
||
|
||
this.audioBuffer.chunks.push(chunkBuffer)
|
||
if (this.audioBuffer.isPCM) {
|
||
this.audioBuffer.totalPCMBytes += chunkBuffer.length
|
||
}
|
||
|
||
// In non-voice mode, use streaming threshold to process chunks
|
||
const reachedStreamingThreshold =
|
||
!this.isVoiceMode &&
|
||
this.audioBuffer.isPCM &&
|
||
this.audioBuffer.totalPCMBytes >= MIN_STREAMING_SEGMENT_BYTES
|
||
|
||
if (!msg.isLast && reachedStreamingThreshold) {
|
||
return
|
||
}
|
||
|
||
const bufferedState = this.audioBuffer
|
||
const finalized = this.finalizeBufferedAudio()
|
||
if (!finalized) {
|
||
return
|
||
}
|
||
|
||
if (!msg.isLast && reachedStreamingThreshold) {
|
||
this.sessionLogger.debug(
|
||
{
|
||
minDuration: MIN_STREAMING_SEGMENT_DURATION_MS,
|
||
pcmBytes: bufferedState?.totalPCMBytes ?? 0,
|
||
},
|
||
`Minimum chunk duration reached (~${MIN_STREAMING_SEGMENT_DURATION_MS}ms, ${
|
||
bufferedState?.totalPCMBytes ?? 0
|
||
} PCM bytes) – triggering STT`
|
||
)
|
||
} else {
|
||
this.sessionLogger.debug(
|
||
{ audioBytes: finalized.audio.length, chunks: bufferedState?.chunks.length ?? 0 },
|
||
`Complete audio segment (${finalized.audio.length} bytes, ${bufferedState?.chunks.length ?? 0} chunk(s))`
|
||
)
|
||
}
|
||
|
||
await this.processCompletedAudio(finalized.audio, finalized.format)
|
||
}
|
||
|
||
private finalizeBufferedAudio(): { audio: Buffer; format: string } | null {
|
||
if (!this.audioBuffer) {
|
||
return null
|
||
}
|
||
|
||
const bufferState = this.audioBuffer
|
||
this.audioBuffer = null
|
||
|
||
if (bufferState.isPCM) {
|
||
const pcmBuffer = Buffer.concat(bufferState.chunks)
|
||
const wavBuffer = convertPCMToWavBuffer(
|
||
pcmBuffer,
|
||
PCM_SAMPLE_RATE,
|
||
PCM_CHANNELS,
|
||
PCM_BITS_PER_SAMPLE
|
||
)
|
||
return {
|
||
audio: wavBuffer,
|
||
format: 'audio/wav',
|
||
}
|
||
}
|
||
|
||
return {
|
||
audio: Buffer.concat(bufferState.chunks),
|
||
format: bufferState.format,
|
||
}
|
||
}
|
||
|
||
private async processCompletedAudio(audio: Buffer, format: string): Promise<void> {
|
||
const shouldBuffer =
|
||
this.processingPhase === 'transcribing' && this.pendingAudioSegments.length === 0
|
||
|
||
if (shouldBuffer) {
|
||
this.sessionLogger.debug(
|
||
{ phase: this.processingPhase },
|
||
`Buffering audio segment (phase: ${this.processingPhase})`
|
||
)
|
||
this.pendingAudioSegments.push({
|
||
audio,
|
||
format,
|
||
})
|
||
this.setBufferTimeout()
|
||
return
|
||
}
|
||
|
||
if (this.pendingAudioSegments.length > 0) {
|
||
this.pendingAudioSegments.push({
|
||
audio,
|
||
format,
|
||
})
|
||
this.sessionLogger.debug(
|
||
{ segmentCount: this.pendingAudioSegments.length },
|
||
`Processing ${this.pendingAudioSegments.length} buffered segments together`
|
||
)
|
||
|
||
const pendingSegments = [...this.pendingAudioSegments]
|
||
this.pendingAudioSegments = []
|
||
this.clearBufferTimeout()
|
||
|
||
const combinedAudio = Buffer.concat(pendingSegments.map((segment) => segment.audio))
|
||
const combinedFormat = pendingSegments[pendingSegments.length - 1].format
|
||
|
||
await this.processAudio(combinedAudio, combinedFormat)
|
||
return
|
||
}
|
||
|
||
await this.processAudio(audio, format)
|
||
}
|
||
|
||
/**
|
||
* Process audio through STT and then LLM
|
||
*/
|
||
private async processAudio(audio: Buffer, format: string): Promise<void> {
|
||
this.setPhase('transcribing')
|
||
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'system',
|
||
content: 'Transcribing audio...',
|
||
},
|
||
})
|
||
|
||
try {
|
||
const requestId = uuidv4()
|
||
const result = await this.sttManager.transcribe(audio, format, {
|
||
requestId,
|
||
label: this.isVoiceMode ? 'voice' : 'buffered',
|
||
})
|
||
|
||
const transcriptText = result.text.trim()
|
||
this.sessionLogger.info(
|
||
{
|
||
requestId,
|
||
isVoiceMode: this.isVoiceMode,
|
||
transcriptLength: transcriptText.length,
|
||
transcript: transcriptText,
|
||
},
|
||
'Transcription result'
|
||
)
|
||
|
||
await this.handleTranscriptionResultPayload({
|
||
text: result.text,
|
||
language: result.language,
|
||
duration: result.duration,
|
||
requestId,
|
||
avgLogprob: result.avgLogprob,
|
||
isLowConfidence: result.isLowConfidence,
|
||
byteLength: result.byteLength,
|
||
format: result.format,
|
||
debugRecordingPath: result.debugRecordingPath,
|
||
})
|
||
} catch (error: any) {
|
||
this.setPhase('idle')
|
||
this.clearSpeechInProgress('transcription error')
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'error',
|
||
content: `Transcription error: ${error.message}`,
|
||
},
|
||
})
|
||
throw error
|
||
}
|
||
}
|
||
|
||
private async handleTranscriptionResultPayload(
|
||
result: VoiceTranscriptionResultPayload
|
||
): Promise<void> {
|
||
const transcriptText = result.text.trim()
|
||
|
||
this.emit({
|
||
type: 'transcription_result',
|
||
payload: {
|
||
text: result.text,
|
||
...(result.language ? { language: result.language } : {}),
|
||
...(result.duration !== undefined ? { duration: result.duration } : {}),
|
||
requestId: result.requestId,
|
||
...(result.avgLogprob !== undefined ? { avgLogprob: result.avgLogprob } : {}),
|
||
...(result.isLowConfidence !== undefined
|
||
? { isLowConfidence: result.isLowConfidence }
|
||
: {}),
|
||
...(result.byteLength !== undefined ? { byteLength: result.byteLength } : {}),
|
||
...(result.format ? { format: result.format } : {}),
|
||
...(result.debugRecordingPath ? { debugRecordingPath: result.debugRecordingPath } : {}),
|
||
},
|
||
})
|
||
|
||
if (!transcriptText) {
|
||
this.sessionLogger.debug('Empty transcription (false positive), not aborting')
|
||
this.setPhase('idle')
|
||
this.clearSpeechInProgress('empty transcription')
|
||
return
|
||
}
|
||
|
||
// Has content - abort any in-progress stream now
|
||
this.createAbortController()
|
||
|
||
if (result.debugRecordingPath) {
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'system',
|
||
content: `Saved input audio: ${result.debugRecordingPath}`,
|
||
metadata: {
|
||
recordingPath: result.debugRecordingPath,
|
||
...(result.format ? { format: result.format } : {}),
|
||
requestId: result.requestId,
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'transcript',
|
||
content: result.text,
|
||
metadata: {
|
||
...(result.language ? { language: result.language } : {}),
|
||
...(result.duration !== undefined ? { duration: result.duration } : {}),
|
||
},
|
||
},
|
||
})
|
||
|
||
this.clearSpeechInProgress('transcription complete')
|
||
this.setPhase('idle')
|
||
if (!this.isVoiceMode) {
|
||
this.sessionLogger.debug(
|
||
{ requestId: result.requestId },
|
||
'Skipping voice agent processing because voice mode is disabled'
|
||
)
|
||
return
|
||
}
|
||
|
||
const agentId = this.voiceModeAgentId
|
||
if (!agentId) {
|
||
this.sessionLogger.warn(
|
||
{ requestId: result.requestId },
|
||
'Skipping voice agent processing because no agent is currently voice-enabled'
|
||
)
|
||
return
|
||
}
|
||
|
||
// Route voice utterances through the same send path as regular text input:
|
||
// interrupt-if-running, record message, then start a new stream.
|
||
await this.handleSendAgentMessage(agentId, result.text)
|
||
}
|
||
|
||
private registerVoiceBridgeForAgent(agentId: string): void {
|
||
this.registerVoiceSpeakHandler?.(agentId, async ({ text, signal }) => {
|
||
this.sessionLogger.info(
|
||
{
|
||
agentId,
|
||
textLength: text.length,
|
||
preview: text.slice(0, 160),
|
||
},
|
||
'Voice speak tool call received by session handler'
|
||
)
|
||
const abortSignal = signal ?? this.abortController.signal
|
||
await this.ttsManager.generateAndWaitForPlayback(
|
||
text,
|
||
(msg) => this.emit(msg),
|
||
abortSignal,
|
||
true
|
||
)
|
||
this.sessionLogger.info(
|
||
{ agentId, textLength: text.length },
|
||
'Voice speak tool call finished playback'
|
||
)
|
||
this.emit({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'assistant',
|
||
content: text,
|
||
},
|
||
})
|
||
})
|
||
|
||
this.registerVoiceCallerContext?.(agentId, {
|
||
childAgentDefaultLabels: {},
|
||
allowCustomCwd: false,
|
||
enableVoiceTools: true,
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Handle abort request from client
|
||
*/
|
||
private async handleAbort(): Promise<void> {
|
||
this.sessionLogger.info(
|
||
{ phase: this.processingPhase },
|
||
`Abort request, phase: ${this.processingPhase}`
|
||
)
|
||
|
||
this.abortController.abort()
|
||
this.ttsManager.cancelPendingPlaybacks('abort request')
|
||
|
||
// Voice abort should always interrupt active agent output immediately.
|
||
if (this.isVoiceMode && this.voiceModeAgentId) {
|
||
try {
|
||
await this.interruptAgentIfRunning(this.voiceModeAgentId)
|
||
} catch (error) {
|
||
this.sessionLogger.warn(
|
||
{ err: error, agentId: this.voiceModeAgentId },
|
||
'Failed to interrupt active voice-mode agent on abort'
|
||
)
|
||
}
|
||
}
|
||
|
||
if (this.processingPhase === 'transcribing') {
|
||
// Still in STT phase - we'll buffer the next audio
|
||
this.sessionLogger.debug('Will buffer next audio (currently transcribing)')
|
||
// Phase stays as 'transcribing', handleAudioChunk will handle buffering
|
||
return
|
||
}
|
||
|
||
// Reset phase to idle and clear pending non-voice buffers.
|
||
this.setPhase('idle')
|
||
this.pendingAudioSegments = []
|
||
this.clearBufferTimeout()
|
||
}
|
||
|
||
/**
|
||
* Handle audio playback confirmation from client
|
||
*/
|
||
private handleAudioPlayed(id: string): void {
|
||
this.ttsManager.confirmAudioPlayed(id)
|
||
}
|
||
|
||
/**
|
||
* Mark speech detection start and abort any active playback/agent run.
|
||
*/
|
||
private async handleVoiceSpeechStart(): Promise<void> {
|
||
if (this.speechInProgress) {
|
||
return
|
||
}
|
||
|
||
const chunkReceivedAt = Date.now()
|
||
const phaseBeforeAbort = this.processingPhase
|
||
const hadActiveStream = this.hasActiveAgentRun(this.voiceModeAgentId)
|
||
|
||
this.speechInProgress = true
|
||
this.sessionLogger.debug('Voice speech detected – aborting playback and active agent run')
|
||
|
||
if (this.pendingAudioSegments.length > 0) {
|
||
this.sessionLogger.debug(
|
||
{ segmentCount: this.pendingAudioSegments.length },
|
||
`Dropping ${this.pendingAudioSegments.length} buffered audio segment(s) due to voice speech`
|
||
)
|
||
this.pendingAudioSegments = []
|
||
}
|
||
|
||
if (this.audioBuffer) {
|
||
this.sessionLogger.debug(
|
||
{ chunks: this.audioBuffer.chunks.length, pcmBytes: this.audioBuffer.totalPCMBytes },
|
||
`Clearing partial audio buffer (${this.audioBuffer.chunks.length} chunk(s)${
|
||
this.audioBuffer.isPCM ? `, ${this.audioBuffer.totalPCMBytes} PCM bytes` : ''
|
||
})`
|
||
)
|
||
this.audioBuffer = null
|
||
}
|
||
|
||
this.cancelActiveVoiceDictationStream('new speech turn started')
|
||
this.clearVoiceModeInactivityTimeout()
|
||
this.clearBufferTimeout()
|
||
|
||
this.abortController.abort()
|
||
await this.handleAbort()
|
||
|
||
const latencyMs = Date.now() - chunkReceivedAt
|
||
this.sessionLogger.debug(
|
||
{ latencyMs, phaseBeforeAbort, hadActiveStream },
|
||
'[Telemetry] barge_in.llm_abort_latency'
|
||
)
|
||
}
|
||
|
||
/**
|
||
* Clear speech-in-progress flag once the user turn has completed
|
||
*/
|
||
private clearSpeechInProgress(reason: string): void {
|
||
if (!this.speechInProgress) {
|
||
return
|
||
}
|
||
|
||
this.speechInProgress = false
|
||
this.sessionLogger.debug({ reason }, `Speech turn complete (${reason}) – resuming TTS`)
|
||
}
|
||
|
||
/**
|
||
* Create new AbortController, aborting the previous one
|
||
*/
|
||
private createAbortController(): AbortController {
|
||
this.abortController.abort()
|
||
this.abortController = new AbortController()
|
||
this.ttsDebugStreams.clear()
|
||
return this.abortController
|
||
}
|
||
|
||
/**
|
||
* Set the processing phase
|
||
*/
|
||
private setPhase(phase: ProcessingPhase): void {
|
||
this.processingPhase = phase
|
||
this.sessionLogger.debug({ phase }, `Phase: ${phase}`)
|
||
}
|
||
|
||
/**
|
||
* Set timeout to process buffered audio segments
|
||
*/
|
||
private setBufferTimeout(): void {
|
||
this.clearBufferTimeout()
|
||
|
||
this.bufferTimeout = setTimeout(async () => {
|
||
this.sessionLogger.debug('Buffer timeout reached, processing pending segments')
|
||
|
||
if (this.pendingAudioSegments.length > 0) {
|
||
const segments = [...this.pendingAudioSegments]
|
||
this.pendingAudioSegments = []
|
||
this.bufferTimeout = null
|
||
|
||
const combined = Buffer.concat(segments.map((s) => s.audio))
|
||
await this.processAudio(combined, segments[0].format)
|
||
}
|
||
}, 10000) // 10 second timeout
|
||
}
|
||
|
||
private setVoiceModeInactivityTimeout(): void {
|
||
if (!this.isVoiceMode) {
|
||
return
|
||
}
|
||
|
||
this.clearVoiceModeInactivityTimeout()
|
||
this.voiceModeInactivityTimeout = setTimeout(() => {
|
||
this.voiceModeInactivityTimeout = null
|
||
if (!this.isVoiceMode || !this.activeVoiceDictationId) {
|
||
return
|
||
}
|
||
|
||
this.sessionLogger.warn(
|
||
{
|
||
timeoutMs: VOICE_MODE_INACTIVITY_FLUSH_MS,
|
||
dictationId: this.activeVoiceDictationId,
|
||
nextSeq: this.activeVoiceDictationNextSeq,
|
||
},
|
||
'Voice mode inactivity timeout reached without isLast; finalizing active voice dictation stream'
|
||
)
|
||
|
||
void this.finalizeActiveVoiceDictationStream('inactivity timeout').catch((error) => {
|
||
this.sessionLogger.error(
|
||
{ err: error },
|
||
'Failed to finalize voice dictation stream after inactivity timeout'
|
||
)
|
||
})
|
||
}, VOICE_MODE_INACTIVITY_FLUSH_MS)
|
||
}
|
||
|
||
private clearVoiceModeInactivityTimeout(): void {
|
||
if (this.voiceModeInactivityTimeout) {
|
||
clearTimeout(this.voiceModeInactivityTimeout)
|
||
this.voiceModeInactivityTimeout = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clear buffer timeout
|
||
*/
|
||
private clearBufferTimeout(): void {
|
||
if (this.bufferTimeout) {
|
||
clearTimeout(this.bufferTimeout)
|
||
this.bufferTimeout = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Emit a message to the client
|
||
*/
|
||
private emit(msg: SessionOutboundMessage): void {
|
||
if (
|
||
msg.type === 'audio_output' &&
|
||
(process.env.TTS_DEBUG_AUDIO_DIR || isPaseoDictationDebugEnabled()) &&
|
||
msg.payload.groupId &&
|
||
typeof msg.payload.audio === 'string'
|
||
) {
|
||
const groupId = msg.payload.groupId
|
||
const existing =
|
||
this.ttsDebugStreams.get(groupId) ??
|
||
({ format: msg.payload.format, chunks: [] } satisfies {
|
||
format: string
|
||
chunks: Buffer[]
|
||
})
|
||
|
||
try {
|
||
existing.chunks.push(Buffer.from(msg.payload.audio, 'base64'))
|
||
existing.format = msg.payload.format
|
||
this.ttsDebugStreams.set(groupId, existing)
|
||
} catch {
|
||
// ignore malformed base64
|
||
}
|
||
|
||
if (msg.payload.isLastChunk) {
|
||
const final = this.ttsDebugStreams.get(groupId)
|
||
this.ttsDebugStreams.delete(groupId)
|
||
if (final && final.chunks.length > 0) {
|
||
void (async () => {
|
||
const recordingPath = await maybePersistTtsDebugAudio(
|
||
Buffer.concat(final.chunks),
|
||
{ sessionId: this.sessionId, groupId, format: final.format },
|
||
this.sessionLogger
|
||
)
|
||
if (recordingPath) {
|
||
this.onMessage({
|
||
type: 'activity_log',
|
||
payload: {
|
||
id: uuidv4(),
|
||
timestamp: new Date(),
|
||
type: 'system',
|
||
content: `Saved TTS audio: ${recordingPath}`,
|
||
metadata: { recordingPath, format: final.format, groupId },
|
||
},
|
||
})
|
||
}
|
||
})()
|
||
}
|
||
}
|
||
}
|
||
this.onMessage(msg)
|
||
}
|
||
|
||
private emitBinary(frame: BinaryMuxFrame): void {
|
||
if (!this.onBinaryMessage) {
|
||
return
|
||
}
|
||
try {
|
||
this.onBinaryMessage(frame)
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to emit binary frame')
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Clean up session resources
|
||
*/
|
||
public async cleanup(): Promise<void> {
|
||
this.sessionLogger.trace('Cleaning up')
|
||
|
||
if (this.unsubscribeAgentEvents) {
|
||
this.unsubscribeAgentEvents()
|
||
this.unsubscribeAgentEvents = null
|
||
}
|
||
|
||
// Abort any ongoing operations
|
||
this.abortController.abort()
|
||
|
||
// Clear timeouts
|
||
this.clearVoiceModeInactivityTimeout()
|
||
this.clearBufferTimeout()
|
||
|
||
// Clear buffers
|
||
this.cancelActiveVoiceDictationStream('session cleanup')
|
||
this.pendingAudioSegments = []
|
||
this.audioBuffer = null
|
||
|
||
// Cleanup managers
|
||
this.ttsManager.cleanup()
|
||
this.sttManager.cleanup()
|
||
this.voiceStreamManager.cleanupAll()
|
||
this.dictationStreamManager.cleanupAll()
|
||
|
||
// Close MCP clients
|
||
if (this.agentMcpClient) {
|
||
try {
|
||
await this.agentMcpClient.close()
|
||
} catch (error) {
|
||
this.sessionLogger.error({ err: error }, 'Failed to close Agent MCP client')
|
||
}
|
||
this.agentMcpClient = null
|
||
this.agentTools = null
|
||
}
|
||
|
||
await this.disableVoiceModeForActiveAgent(true)
|
||
this.isVoiceMode = false
|
||
|
||
// Unsubscribe from all terminals
|
||
if (this.unsubscribeTerminalsChanged) {
|
||
this.unsubscribeTerminalsChanged()
|
||
this.unsubscribeTerminalsChanged = null
|
||
}
|
||
this.subscribedTerminalDirectories.clear()
|
||
|
||
for (const unsubscribe of this.terminalSubscriptions.values()) {
|
||
unsubscribe()
|
||
}
|
||
this.terminalSubscriptions.clear()
|
||
for (const unsubscribeExit of this.terminalExitSubscriptions.values()) {
|
||
unsubscribeExit()
|
||
}
|
||
this.terminalExitSubscriptions.clear()
|
||
this.detachAllTerminalStreams({ emitExit: false })
|
||
|
||
for (const target of this.checkoutDiffTargets.values()) {
|
||
this.closeCheckoutDiffWatchTarget(target)
|
||
}
|
||
this.checkoutDiffTargets.clear()
|
||
this.checkoutDiffSubscriptions.clear()
|
||
}
|
||
|
||
// ============================================================================
|
||
// Terminal Handlers
|
||
// ============================================================================
|
||
|
||
private ensureTerminalExitSubscription(terminal: TerminalSession): void {
|
||
if (this.terminalExitSubscriptions.has(terminal.id)) {
|
||
return
|
||
}
|
||
|
||
const unsubscribeExit = terminal.onExit(() => {
|
||
this.handleTerminalExited(terminal.id)
|
||
})
|
||
this.terminalExitSubscriptions.set(terminal.id, unsubscribeExit)
|
||
}
|
||
|
||
private handleTerminalExited(terminalId: string): void {
|
||
const unsubscribeExit = this.terminalExitSubscriptions.get(terminalId)
|
||
if (unsubscribeExit) {
|
||
unsubscribeExit()
|
||
this.terminalExitSubscriptions.delete(terminalId)
|
||
}
|
||
|
||
const unsubscribe = this.terminalSubscriptions.get(terminalId)
|
||
if (unsubscribe) {
|
||
try {
|
||
unsubscribe()
|
||
} catch (error) {
|
||
this.sessionLogger.warn(
|
||
{ err: error, terminalId },
|
||
'Failed to unsubscribe terminal after process exit'
|
||
)
|
||
}
|
||
this.terminalSubscriptions.delete(terminalId)
|
||
}
|
||
|
||
const streamId = this.terminalStreamByTerminalId.get(terminalId)
|
||
if (typeof streamId === 'number') {
|
||
this.detachTerminalStream(streamId, { emitExit: true })
|
||
}
|
||
}
|
||
|
||
private emitTerminalsChangedSnapshot(input: {
|
||
cwd: string
|
||
terminals: Array<{ id: string; name: string }>
|
||
}): void {
|
||
this.emit({
|
||
type: 'terminals_changed',
|
||
payload: {
|
||
cwd: input.cwd,
|
||
terminals: input.terminals,
|
||
},
|
||
})
|
||
}
|
||
|
||
private handleTerminalsChanged(event: TerminalsChangedEvent): void {
|
||
if (!this.subscribedTerminalDirectories.has(event.cwd)) {
|
||
return
|
||
}
|
||
|
||
this.emitTerminalsChangedSnapshot({
|
||
cwd: event.cwd,
|
||
terminals: event.terminals.map((terminal) => ({
|
||
id: terminal.id,
|
||
name: terminal.name,
|
||
})),
|
||
})
|
||
}
|
||
|
||
private handleSubscribeTerminalsRequest(msg: SubscribeTerminalsRequest): void {
|
||
this.subscribedTerminalDirectories.add(msg.cwd)
|
||
void this.emitInitialTerminalsChangedSnapshot(msg.cwd)
|
||
}
|
||
|
||
private handleUnsubscribeTerminalsRequest(msg: UnsubscribeTerminalsRequest): void {
|
||
this.subscribedTerminalDirectories.delete(msg.cwd)
|
||
}
|
||
|
||
private async emitInitialTerminalsChangedSnapshot(cwd: string): Promise<void> {
|
||
if (!this.terminalManager || !this.subscribedTerminalDirectories.has(cwd)) {
|
||
return
|
||
}
|
||
|
||
try {
|
||
const terminals = await this.terminalManager.getTerminals(cwd)
|
||
for (const terminal of terminals) {
|
||
this.ensureTerminalExitSubscription(terminal)
|
||
}
|
||
|
||
if (!this.subscribedTerminalDirectories.has(cwd)) {
|
||
return
|
||
}
|
||
|
||
this.emitTerminalsChangedSnapshot({
|
||
cwd,
|
||
terminals: terminals.map((terminal) => ({
|
||
id: terminal.id,
|
||
name: terminal.name,
|
||
})),
|
||
})
|
||
} catch (error) {
|
||
this.sessionLogger.warn({ err: error, cwd }, 'Failed to emit initial terminal snapshot')
|
||
}
|
||
}
|
||
|
||
private async handleListTerminalsRequest(msg: ListTerminalsRequest): Promise<void> {
|
||
if (!this.terminalManager) {
|
||
this.emit({
|
||
type: 'list_terminals_response',
|
||
payload: {
|
||
cwd: msg.cwd,
|
||
terminals: [],
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const terminals = await this.terminalManager.getTerminals(msg.cwd)
|
||
for (const terminal of terminals) {
|
||
this.ensureTerminalExitSubscription(terminal)
|
||
}
|
||
this.emit({
|
||
type: 'list_terminals_response',
|
||
payload: {
|
||
cwd: msg.cwd,
|
||
terminals: terminals.map((t) => ({ id: t.id, name: t.name })),
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error, cwd: msg.cwd }, 'Failed to list terminals')
|
||
this.emit({
|
||
type: 'list_terminals_response',
|
||
payload: {
|
||
cwd: msg.cwd,
|
||
terminals: [],
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleCreateTerminalRequest(msg: CreateTerminalRequest): Promise<void> {
|
||
if (!this.terminalManager) {
|
||
this.emit({
|
||
type: 'create_terminal_response',
|
||
payload: {
|
||
terminal: null,
|
||
error: 'Terminal manager not available',
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
try {
|
||
const session = await this.terminalManager.createTerminal({
|
||
cwd: msg.cwd,
|
||
name: msg.name,
|
||
})
|
||
this.ensureTerminalExitSubscription(session)
|
||
this.emit({
|
||
type: 'create_terminal_response',
|
||
payload: {
|
||
terminal: { id: session.id, name: session.name, cwd: session.cwd },
|
||
error: null,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
} catch (error: any) {
|
||
this.sessionLogger.error({ err: error, cwd: msg.cwd }, 'Failed to create terminal')
|
||
this.emit({
|
||
type: 'create_terminal_response',
|
||
payload: {
|
||
terminal: null,
|
||
error: error.message,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
}
|
||
|
||
private async handleSubscribeTerminalRequest(msg: SubscribeTerminalRequest): Promise<void> {
|
||
if (!this.terminalManager) {
|
||
this.emit({
|
||
type: 'subscribe_terminal_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
state: null,
|
||
error: 'Terminal manager not available',
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const session = this.terminalManager.getTerminal(msg.terminalId)
|
||
if (!session) {
|
||
this.emit({
|
||
type: 'subscribe_terminal_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
state: null,
|
||
error: 'Terminal not found',
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
this.ensureTerminalExitSubscription(session)
|
||
|
||
// Unsubscribe from previous subscription if any
|
||
const existing = this.terminalSubscriptions.get(msg.terminalId)
|
||
if (existing) {
|
||
existing()
|
||
}
|
||
|
||
// Subscribe to terminal updates
|
||
const unsubscribe = session.subscribe((serverMsg) => {
|
||
if (serverMsg.type === 'full') {
|
||
this.emit({
|
||
type: 'terminal_output',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
state: serverMsg.state,
|
||
},
|
||
})
|
||
}
|
||
})
|
||
this.terminalSubscriptions.set(msg.terminalId, unsubscribe)
|
||
|
||
// Send initial state
|
||
this.emit({
|
||
type: 'subscribe_terminal_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
state: session.getState(),
|
||
error: null,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
private handleUnsubscribeTerminalRequest(msg: UnsubscribeTerminalRequest): void {
|
||
const unsubscribe = this.terminalSubscriptions.get(msg.terminalId)
|
||
if (unsubscribe) {
|
||
unsubscribe()
|
||
this.terminalSubscriptions.delete(msg.terminalId)
|
||
}
|
||
}
|
||
|
||
private handleTerminalInput(msg: TerminalInput): void {
|
||
if (!this.terminalManager) {
|
||
return
|
||
}
|
||
|
||
const session = this.terminalManager.getTerminal(msg.terminalId)
|
||
if (!session) {
|
||
this.sessionLogger.warn({ terminalId: msg.terminalId }, 'Terminal not found for input')
|
||
return
|
||
}
|
||
this.ensureTerminalExitSubscription(session)
|
||
|
||
session.send(msg.message)
|
||
}
|
||
|
||
private killTrackedTerminal(terminalId: string, options?: { emitExit: boolean }): void {
|
||
const unsubscribe = this.terminalSubscriptions.get(terminalId)
|
||
if (unsubscribe) {
|
||
unsubscribe()
|
||
this.terminalSubscriptions.delete(terminalId)
|
||
}
|
||
|
||
const streamId = this.terminalStreamByTerminalId.get(terminalId)
|
||
if (typeof streamId === 'number') {
|
||
this.detachTerminalStream(streamId, { emitExit: options?.emitExit ?? true })
|
||
}
|
||
|
||
this.terminalManager?.killTerminal(terminalId)
|
||
}
|
||
|
||
private async killTerminalsUnderPath(rootPath: string): Promise<void> {
|
||
if (!this.terminalManager) {
|
||
return
|
||
}
|
||
|
||
const cleanupErrors: Array<{ cwd: string; message: string }> = []
|
||
const terminalDirectories = [...this.terminalManager.listDirectories()]
|
||
for (const terminalCwd of terminalDirectories) {
|
||
if (!this.isPathWithinRoot(rootPath, terminalCwd)) {
|
||
continue
|
||
}
|
||
|
||
try {
|
||
const terminals = await this.terminalManager.getTerminals(terminalCwd)
|
||
for (const terminal of [...terminals]) {
|
||
this.killTrackedTerminal(terminal.id, { emitExit: true })
|
||
}
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : String(error)
|
||
cleanupErrors.push({ cwd: terminalCwd, message })
|
||
this.sessionLogger.warn(
|
||
{ err: error, cwd: terminalCwd },
|
||
'Failed to clean up worktree terminals during archive'
|
||
)
|
||
}
|
||
}
|
||
|
||
if (cleanupErrors.length > 0) {
|
||
const details = cleanupErrors.map((entry) => `${entry.cwd}: ${entry.message}`).join('; ')
|
||
throw new Error(`Failed to clean up worktree terminals during archive (${details})`)
|
||
}
|
||
}
|
||
|
||
private async handleKillTerminalRequest(msg: KillTerminalRequest): Promise<void> {
|
||
if (!this.terminalManager) {
|
||
this.emit({
|
||
type: 'kill_terminal_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
success: false,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
this.killTrackedTerminal(msg.terminalId, { emitExit: true })
|
||
this.emit({
|
||
type: 'kill_terminal_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
success: true,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
private async handleAttachTerminalStreamRequest(msg: AttachTerminalStreamRequest): Promise<void> {
|
||
if (!this.terminalManager || !this.onBinaryMessage) {
|
||
this.emit({
|
||
type: 'attach_terminal_stream_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
streamId: null,
|
||
replayedFrom: 0,
|
||
currentOffset: 0,
|
||
earliestAvailableOffset: 0,
|
||
reset: true,
|
||
error: 'Terminal streaming not available',
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
const session = this.terminalManager.getTerminal(msg.terminalId)
|
||
if (!session) {
|
||
this.emit({
|
||
type: 'attach_terminal_stream_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
streamId: null,
|
||
replayedFrom: 0,
|
||
currentOffset: 0,
|
||
earliestAvailableOffset: 0,
|
||
reset: true,
|
||
error: 'Terminal not found',
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
if (msg.rows || msg.cols) {
|
||
const state = session.getState()
|
||
session.send({
|
||
type: 'resize',
|
||
rows: msg.rows ?? state.rows,
|
||
cols: msg.cols ?? state.cols,
|
||
})
|
||
}
|
||
|
||
const existingStreamId = this.terminalStreamByTerminalId.get(msg.terminalId)
|
||
if (typeof existingStreamId === 'number') {
|
||
// Replacing an active stream can happen when multiple UI surfaces attach to the
|
||
// same terminal. Emit exit for the replaced stream so stale listeners reconnect
|
||
// instead of continuing to send input to an invalid stream id.
|
||
this.detachTerminalStream(existingStreamId, { emitExit: true })
|
||
}
|
||
|
||
const streamId = this.allocateTerminalStreamId()
|
||
const requestedResumeOffset =
|
||
typeof msg.resumeOffset === 'number'
|
||
? msg.resumeOffset
|
||
: session.getOutputOffset()
|
||
const initialOffset = Math.max(0, Math.floor(requestedResumeOffset))
|
||
const binding: {
|
||
terminalId: string
|
||
unsubscribe: () => void
|
||
lastOutputOffset: number
|
||
lastAckOffset: number
|
||
pendingChunks: TerminalStreamPendingChunk[]
|
||
pendingBytes: number
|
||
} = {
|
||
terminalId: msg.terminalId,
|
||
unsubscribe: () => {},
|
||
lastOutputOffset: initialOffset,
|
||
lastAckOffset: initialOffset,
|
||
pendingChunks: [],
|
||
pendingBytes: 0,
|
||
}
|
||
this.terminalStreams.set(streamId, binding)
|
||
this.terminalStreamByTerminalId.set(msg.terminalId, streamId)
|
||
|
||
let rawSub
|
||
try {
|
||
rawSub = session.subscribeRaw(
|
||
(chunk) => {
|
||
const currentBinding = this.terminalStreams.get(streamId)
|
||
if (!currentBinding) {
|
||
return
|
||
}
|
||
this.enqueueOrEmitTerminalStreamChunk(streamId, currentBinding, {
|
||
data: chunk.data,
|
||
startOffset: chunk.startOffset,
|
||
endOffset: chunk.endOffset,
|
||
replay: chunk.replay,
|
||
})
|
||
},
|
||
{ fromOffset: requestedResumeOffset }
|
||
)
|
||
} catch (error) {
|
||
this.terminalStreams.delete(streamId)
|
||
this.terminalStreamByTerminalId.delete(msg.terminalId)
|
||
throw error
|
||
}
|
||
|
||
binding.unsubscribe = rawSub.unsubscribe
|
||
binding.lastAckOffset = rawSub.replayedFrom
|
||
if (binding.lastOutputOffset < rawSub.replayedFrom) {
|
||
binding.lastOutputOffset = rawSub.replayedFrom
|
||
}
|
||
this.flushPendingTerminalStreamChunks(streamId, binding)
|
||
|
||
this.emit({
|
||
type: 'attach_terminal_stream_response',
|
||
payload: {
|
||
terminalId: msg.terminalId,
|
||
streamId,
|
||
replayedFrom: rawSub.replayedFrom,
|
||
currentOffset: rawSub.currentOffset,
|
||
earliestAvailableOffset: rawSub.earliestAvailableOffset,
|
||
reset: rawSub.reset,
|
||
error: null,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
private getTerminalStreamChunkByteLength(chunk: TerminalStreamPendingChunk): number {
|
||
return Math.max(0, chunk.endOffset - chunk.startOffset)
|
||
}
|
||
|
||
private canEmitTerminalStreamChunk(
|
||
binding: {
|
||
lastAckOffset: number
|
||
},
|
||
chunk: TerminalStreamPendingChunk
|
||
): boolean {
|
||
return chunk.startOffset < binding.lastAckOffset + TERMINAL_STREAM_WINDOW_BYTES
|
||
}
|
||
|
||
private emitTerminalStreamChunk(
|
||
streamId: number,
|
||
binding: {
|
||
lastOutputOffset: number
|
||
},
|
||
chunk: TerminalStreamPendingChunk
|
||
): void {
|
||
const payload = new Uint8Array(Buffer.from(chunk.data, 'utf8'))
|
||
this.emitBinary({
|
||
channel: BinaryMuxChannel.Terminal,
|
||
messageType: TerminalBinaryMessageType.OutputUtf8,
|
||
streamId,
|
||
offset: chunk.startOffset,
|
||
flags: chunk.replay ? TerminalBinaryFlags.Replay : 0,
|
||
payload,
|
||
})
|
||
binding.lastOutputOffset = chunk.endOffset
|
||
}
|
||
|
||
private enqueueOrEmitTerminalStreamChunk(
|
||
streamId: number,
|
||
binding: {
|
||
lastAckOffset: number
|
||
lastOutputOffset: number
|
||
pendingChunks: TerminalStreamPendingChunk[]
|
||
pendingBytes: number
|
||
},
|
||
chunk: TerminalStreamPendingChunk
|
||
): void {
|
||
const chunkBytes = this.getTerminalStreamChunkByteLength(chunk)
|
||
|
||
if (binding.pendingChunks.length > 0 || !this.canEmitTerminalStreamChunk(binding, chunk)) {
|
||
if (
|
||
binding.pendingChunks.length >= TERMINAL_STREAM_MAX_PENDING_CHUNKS ||
|
||
binding.pendingBytes + chunkBytes > TERMINAL_STREAM_MAX_PENDING_BYTES
|
||
) {
|
||
this.sessionLogger.warn(
|
||
{
|
||
streamId,
|
||
pendingChunks: binding.pendingChunks.length,
|
||
pendingBytes: binding.pendingBytes,
|
||
chunkBytes,
|
||
},
|
||
'Terminal stream pending buffer overflow; closing stream'
|
||
)
|
||
this.detachTerminalStream(streamId, { emitExit: true })
|
||
return
|
||
}
|
||
binding.pendingChunks.push(chunk)
|
||
binding.pendingBytes += chunkBytes
|
||
return
|
||
}
|
||
|
||
this.emitTerminalStreamChunk(streamId, binding, chunk)
|
||
}
|
||
|
||
private flushPendingTerminalStreamChunks(
|
||
streamId: number,
|
||
binding: {
|
||
lastAckOffset: number
|
||
lastOutputOffset: number
|
||
pendingChunks: TerminalStreamPendingChunk[]
|
||
pendingBytes: number
|
||
}
|
||
): void {
|
||
while (binding.pendingChunks.length > 0) {
|
||
const next = binding.pendingChunks[0]
|
||
if (!next || !this.canEmitTerminalStreamChunk(binding, next)) {
|
||
break
|
||
}
|
||
binding.pendingChunks.shift()
|
||
binding.pendingBytes -= this.getTerminalStreamChunkByteLength(next)
|
||
if (binding.pendingBytes < 0) {
|
||
binding.pendingBytes = 0
|
||
}
|
||
this.emitTerminalStreamChunk(streamId, binding, next)
|
||
}
|
||
}
|
||
|
||
private handleDetachTerminalStreamRequest(msg: DetachTerminalStreamRequest): void {
|
||
const success = this.detachTerminalStream(msg.streamId, { emitExit: false })
|
||
this.emit({
|
||
type: 'detach_terminal_stream_response',
|
||
payload: {
|
||
streamId: msg.streamId,
|
||
success,
|
||
requestId: msg.requestId,
|
||
},
|
||
})
|
||
}
|
||
|
||
private detachAllTerminalStreams(options?: { emitExit: boolean }): void {
|
||
for (const streamId of Array.from(this.terminalStreams.keys())) {
|
||
this.detachTerminalStream(streamId, options)
|
||
}
|
||
}
|
||
|
||
private detachTerminalStream(streamId: number, options?: { emitExit: boolean }): boolean {
|
||
const binding = this.terminalStreams.get(streamId)
|
||
if (!binding) {
|
||
return false
|
||
}
|
||
|
||
try {
|
||
binding.unsubscribe()
|
||
} catch (error) {
|
||
this.sessionLogger.warn({ err: error, streamId }, 'Failed to unsubscribe terminal stream')
|
||
}
|
||
this.terminalStreams.delete(streamId)
|
||
if (this.terminalStreamByTerminalId.get(binding.terminalId) === streamId) {
|
||
this.terminalStreamByTerminalId.delete(binding.terminalId)
|
||
}
|
||
|
||
if (options?.emitExit) {
|
||
this.emit({
|
||
type: 'terminal_stream_exit',
|
||
payload: {
|
||
streamId,
|
||
terminalId: binding.terminalId,
|
||
},
|
||
})
|
||
}
|
||
return true
|
||
}
|
||
|
||
private allocateTerminalStreamId(): number {
|
||
let attempts = 0
|
||
while (attempts < 0xffffffff) {
|
||
const candidate = this.nextTerminalStreamId >>> 0
|
||
this.nextTerminalStreamId = ((this.nextTerminalStreamId + 1) & 0xffffffff) >>> 0
|
||
if (candidate === 0) {
|
||
attempts += 1
|
||
continue
|
||
}
|
||
if (!this.terminalStreams.has(candidate)) {
|
||
return candidate
|
||
}
|
||
attempts += 1
|
||
}
|
||
throw new Error('Unable to allocate terminal stream id')
|
||
}
|
||
}
|