diff --git a/docs/design/2026-08-19-agent-view-command-handoff.md b/docs/design/2026-08-19-agent-view-command-handoff.md new file mode 100644 index 00000000000..274e7c23716 --- /dev/null +++ b/docs/design/2026-08-19-agent-view-command-handoff.md @@ -0,0 +1,25 @@ +# Agent View command handoff + +PR 7802 exposes Agent View entry points that must be safe without the roster UI +from PR 7803. This change moves only the runtime behavior required by those +entry points. + +- Initial background prompts are passed in the worker argv while dispatch still + waits for the worker-ready event. +- A native `/background` adoption shuts down and exits the foreground runtime + after the supervisor accepts the handoff. +- An attached worker detaches through the existing worker sideband event. +- Interactive resume rejects live managed sessions after direct or picker + resolution. +- `agents` is a reserved command word with an explicit `list` spelling; + ambiguous separator and boolean-assignment forms fail loudly. +- Background dispatch rejects every explicitly supplied per-invocation boolean, + including false and negated forms that are not forwarded to the worker. +- A hibernated worker is treated like an exited worker for foreground + `--continue` takeover. +- A startup worktree created before managed-session routing is discarded on an + early attach or rejection, so it cannot pin a branch without a session + sidecar. + +Roster rendering, peek, answer, redraw, and general worker-control polling stay +in PR 7803. diff --git a/packages/cli/src/agent-view/attach-lease.test.ts b/packages/cli/src/agent-view/attach-lease.test.ts new file mode 100644 index 00000000000..c06c08bff75 --- /dev/null +++ b/packages/cli/src/agent-view/attach-lease.test.ts @@ -0,0 +1,257 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + AgentViewAttachLeaseManager, + MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS, +} from './attach-lease.js'; + +describe('AgentViewAttachLeaseManager', () => { + it('acquires a lease for an unattached session', () => { + const clock = fakeClock('2026-07-17T00:00:00.000Z'); + const manager = new AgentViewAttachLeaseManager({ + now: clock.now, + createLeaseId: () => 'lease-1', + defaultTtlMs: 1000, + }); + + expect(manager.acquire('session-1', { clientId: 'terminal-1' })).toEqual({ + ok: true, + lease: { + sessionId: 'session-1', + leaseId: 'lease-1', + clientId: 'terminal-1', + acquiredAt: '2026-07-17T00:00:00.000Z', + lastHeartbeatAt: '2026-07-17T00:00:00.000Z', + expiresAt: '2026-07-17T00:00:01.000Z', + }, + }); + }); + + it('uses a random lease id when no id factory is provided', () => { + const manager = new AgentViewAttachLeaseManager(); + const result = manager.acquire('session-1'); + + expect(result).toMatchObject({ + ok: true, + lease: { + sessionId: 'session-1', + }, + }); + if (result.ok) { + expect(result.lease.leaseId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + } + }); + + it('isolates leases across sessions', () => { + const clock = fakeClock('2026-07-17T00:00:00.000Z'); + let idCounter = 0; + const manager = new AgentViewAttachLeaseManager({ + now: clock.now, + createLeaseId: () => `lease-${++idCounter}`, + }); + + const first = manager.acquire('session-a', { clientId: 'terminal-a' }); + const second = manager.acquire('session-b', { clientId: 'terminal-b' }); + + expect(first.ok && first.lease.leaseId).toBe('lease-1'); + expect(second.ok && second.lease.leaseId).toBe('lease-2'); + + // Session B's lease must not disturb session A's. + expect(manager.get('session-a')?.leaseId).toBe('lease-1'); + expect(manager.heartbeat('session-a', 'lease-1')?.leaseId).toBe('lease-1'); + + // Releasing session A must not affect session B. + expect(manager.release('session-a', 'lease-1')).toBe(true); + expect(manager.get('session-a')).toBeUndefined(); + expect(manager.get('session-b')?.leaseId).toBe('lease-2'); + }); + + it('rejects a second acquire while a lease is active', () => { + const clock = fakeClock('2026-07-17T00:00:00.000Z'); + const manager = new AgentViewAttachLeaseManager({ + now: clock.now, + createLeaseId: () => 'lease-1', + }); + const first = manager.acquire('session-1'); + + expect( + manager.acquire('session-1', { + leaseId: 'lease-2', + }), + ).toEqual({ + ok: false, + reason: 'already_attached', + lease: { + sessionId: first.lease.sessionId, + acquiredAt: first.lease.acquiredAt, + lastHeartbeatAt: first.lease.lastHeartbeatAt, + expiresAt: first.lease.expiresAt, + }, + }); + }); + + it('does not disclose the active lease id on contested acquire', () => { + const manager = new AgentViewAttachLeaseManager({ + createLeaseId: () => 'lease-1', + }); + manager.acquire('session-1'); + + const result = manager.acquire('session-1', { leaseId: 'lease-2' }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect('leaseId' in result.lease).toBe(false); + } + }); + + it('generates a lease id when the provided id is empty', () => { + const manager = new AgentViewAttachLeaseManager({ + createLeaseId: () => 'generated-lease', + }); + + expect(manager.acquire('session-1', { leaseId: '' })).toMatchObject({ + ok: true, + lease: { + leaseId: 'generated-lease', + }, + }); + }); + + it('releases only the matching lease', () => { + const manager = new AgentViewAttachLeaseManager({ + createLeaseId: () => 'lease-1', + }); + manager.acquire('session-1'); + + expect(manager.release('session-1', 'other-lease')).toBe(false); + expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' }); + expect(manager.release('session-1', 'lease-1')).toBe(true); + expect(manager.get('session-1')).toBeUndefined(); + }); + + it('rejects non-positive, oversized, and non-finite ttls', () => { + const manager = new AgentViewAttachLeaseManager(); + + expect(() => manager.acquire('session-1', { ttlMs: 0 })).toThrow( + 'Attach lease ttlMs must be positive.', + ); + expect(() => manager.acquire('session-1', { ttlMs: Number.NaN })).toThrow( + RangeError, + ); + expect(() => + manager.acquire('session-1', { + ttlMs: MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS + 1, + }), + ).toThrow('Attach lease ttlMs must not exceed'); + expect(() => manager.acquire('')).toThrow( + 'Agent View session id is required.', + ); + expect(manager.get('session-1')).toBeUndefined(); + }); + + it('honors per-call ttls and expires lazily through get and release', () => { + const clock = fakeClock('2026-07-17T00:00:00.000Z'); + const manager = new AgentViewAttachLeaseManager({ + now: clock.now, + createLeaseId: () => 'lease-1', + defaultTtlMs: 1000, + }); + manager.acquire('session-1', { ttlMs: 2000 }); + + clock.advance(1500); + expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' }); + expect( + manager.heartbeat('session-1', 'lease-1', { ttlMs: 3000 }), + ).toMatchObject({ expiresAt: '2026-07-17T00:00:04.500Z' }); + + clock.advance(3000); + expect(manager.get('session-1')).toBeUndefined(); + expect(manager.release('session-1', 'lease-1')).toBe(false); + }); + + it('expires stale leases and allows reacquire', () => { + const clock = fakeClock('2026-07-17T00:00:00.000Z'); + const leaseIds = ['lease-1', 'lease-2']; + const manager = new AgentViewAttachLeaseManager({ + now: clock.now, + createLeaseId: () => leaseIds.shift() ?? 'missing-lease', + defaultTtlMs: 1000, + }); + const first = manager.acquire('session-1'); + + clock.advance(1000); + + expect(manager.expire()).toEqual([first.lease]); + expect(manager.acquire('session-1')).toMatchObject({ + ok: true, + lease: { + leaseId: 'lease-2', + acquiredAt: '2026-07-17T00:00:01.000Z', + }, + }); + }); + + it('expires corrupted leases with invalid timestamps', () => { + const manager = new AgentViewAttachLeaseManager({ + createLeaseId: () => 'lease-1', + }); + manager.acquire('session-1'); + ( + manager as unknown as { + leases: Map; + } + ).leases.get('session-1')!.expiresAt = 'not-a-date'; + + expect(manager.expire()).toEqual([ + expect.objectContaining({ + sessionId: 'session-1', + leaseId: 'lease-1', + expiresAt: 'not-a-date', + }), + ]); + expect(manager.get('session-1')).toBeUndefined(); + }); + + it('heartbeat extends the matching lease', () => { + const clock = fakeClock('2026-07-17T00:00:00.000Z'); + const manager = new AgentViewAttachLeaseManager({ + now: clock.now, + createLeaseId: () => 'lease-1', + defaultTtlMs: 1000, + }); + manager.acquire('session-1'); + clock.advance(500); + + expect(manager.heartbeat('session-1', 'wrong-lease')).toBeUndefined(); + expect(manager.heartbeat('session-1', 'lease-1')).toMatchObject({ + sessionId: 'session-1', + leaseId: 'lease-1', + acquiredAt: '2026-07-17T00:00:00.000Z', + lastHeartbeatAt: '2026-07-17T00:00:00.500Z', + expiresAt: '2026-07-17T00:00:01.500Z', + }); + + clock.advance(999); + expect(manager.get('session-1')).toMatchObject({ leaseId: 'lease-1' }); + }); +}); + +function fakeClock(start: string): { + now: () => Date; + advance: (ms: number) => void; +} { + let nowMs = Date.parse(start); + return { + now: () => new Date(nowMs), + advance: (ms: number) => { + nowMs += ms; + }, + }; +} diff --git a/packages/cli/src/agent-view/attach-lease.ts b/packages/cli/src/agent-view/attach-lease.ts new file mode 100644 index 00000000000..65b249a40c9 --- /dev/null +++ b/packages/cli/src/agent-view/attach-lease.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; + +export const DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS = 30_000; +export const MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS = 3_600_000; + +export interface AgentViewAttachLease { + sessionId: string; + leaseId: string; + clientId?: string; + acquiredAt: string; + lastHeartbeatAt: string; + expiresAt: string; +} + +export type AgentViewAttachLeaseConflict = Omit< + AgentViewAttachLease, + 'leaseId' +>; + +export type AgentViewAttachLeaseAcquireResult = + | { ok: true; lease: AgentViewAttachLease } + | { + ok: false; + reason: 'already_attached'; + lease: AgentViewAttachLeaseConflict; + }; + +export interface AgentViewAttachLeaseAcquireOptions { + clientId?: string; + leaseId?: string; + ttlMs?: number; +} + +export interface AgentViewAttachLeaseHeartbeatOptions { + ttlMs?: number; +} + +export interface AgentViewAttachLeaseManagerOptions { + defaultTtlMs?: number; + now?: () => Date; + createLeaseId?: () => string; +} + +export class AgentViewAttachLeaseManager { + private readonly leases = new Map(); + private readonly defaultTtlMs: number; + private readonly now: () => Date; + private readonly createLeaseId: () => string; + + constructor(options: AgentViewAttachLeaseManagerOptions = {}) { + this.defaultTtlMs = + options.defaultTtlMs ?? DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS; + this.now = options.now ?? (() => new Date()); + this.createLeaseId = options.createLeaseId ?? randomUUID; + } + + acquire( + sessionId: string, + options: AgentViewAttachLeaseAcquireOptions = {}, + ): AgentViewAttachLeaseAcquireResult { + this.requireSessionId(sessionId); + this.expire(); + + const existing = this.leases.get(sessionId); + if (existing) { + return { + ok: false, + reason: 'already_attached', + lease: redactLeaseId(existing), + }; + } + + const acquiredAt = this.now(); + const lease: AgentViewAttachLease = { + sessionId, + leaseId: options.leaseId || this.createLeaseId(), + ...(options.clientId ? { clientId: options.clientId } : {}), + acquiredAt: acquiredAt.toISOString(), + lastHeartbeatAt: acquiredAt.toISOString(), + expiresAt: this.expiresAt(acquiredAt, options.ttlMs).toISOString(), + }; + this.leases.set(sessionId, lease); + return { ok: true, lease }; + } + + heartbeat( + sessionId: string, + leaseId: string, + options: AgentViewAttachLeaseHeartbeatOptions = {}, + ): AgentViewAttachLease | undefined { + this.requireSessionId(sessionId); + this.expire(); + + const lease = this.leases.get(sessionId); + if (!lease || lease.leaseId !== leaseId) { + return undefined; + } + + const now = this.now(); + const next: AgentViewAttachLease = { + ...lease, + lastHeartbeatAt: now.toISOString(), + expiresAt: this.expiresAt(now, options.ttlMs).toISOString(), + }; + this.leases.set(sessionId, next); + return next; + } + + release(sessionId: string, leaseId: string): boolean { + this.requireSessionId(sessionId); + this.expire(); + + const lease = this.leases.get(sessionId); + if (!lease || lease.leaseId !== leaseId) { + return false; + } + this.leases.delete(sessionId); + return true; + } + + expire(): AgentViewAttachLease[] { + const nowMs = this.now().getTime(); + const expired: AgentViewAttachLease[] = []; + + for (const [sessionId, lease] of this.leases) { + const expiresAtMs = Date.parse(lease.expiresAt); + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs) { + this.leases.delete(sessionId); + expired.push(lease); + } + } + + return expired; + } + + get(sessionId: string): AgentViewAttachLease | undefined { + this.requireSessionId(sessionId); + this.expire(); + return this.leases.get(sessionId); + } + + private expiresAt(now: Date, ttlMs: number | undefined): Date { + const resolvedTtlMs = ttlMs ?? this.defaultTtlMs; + if (!Number.isFinite(resolvedTtlMs) || resolvedTtlMs <= 0) { + throw new RangeError('Attach lease ttlMs must be positive.'); + } + if (resolvedTtlMs > MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS) { + throw new RangeError( + `Attach lease ttlMs must not exceed ${MAX_AGENT_VIEW_ATTACH_LEASE_TTL_MS}.`, + ); + } + return new Date(now.getTime() + resolvedTtlMs); + } + + private requireSessionId(sessionId: string): void { + if (sessionId.length === 0) { + throw new Error('Agent View session id is required.'); + } + } +} + +function redactLeaseId( + lease: AgentViewAttachLease, +): AgentViewAttachLeaseConflict { + return { + sessionId: lease.sessionId, + ...(lease.clientId ? { clientId: lease.clientId } : {}), + acquiredAt: lease.acquiredAt, + lastHeartbeatAt: lease.lastHeartbeatAt, + expiresAt: lease.expiresAt, + }; +} diff --git a/packages/cli/src/agent-view/current-cli-argv.ts b/packages/cli/src/agent-view/current-cli-argv.ts index 2d034327cb0..ef69becbd55 100644 --- a/packages/cli/src/agent-view/current-cli-argv.ts +++ b/packages/cli/src/agent-view/current-cli-argv.ts @@ -8,7 +8,11 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; export function getCurrentQwenCliEntrypoint(): string { - return process.argv[1] ?? 'qwen'; + const entry = process.argv[1]; + if (!entry) return 'qwen'; + // Absolute-ize so the persisted argv and the PTY spawn resolve the same + // binary regardless of the supervisor daemon's cwd. + return path.isAbsolute(entry) ? entry : path.resolve(entry); } export function buildCurrentQwenCliArgv(args: readonly string[]): string[] { diff --git a/packages/cli/src/agent-view/feature.test.ts b/packages/cli/src/agent-view/feature.test.ts new file mode 100644 index 00000000000..66734f78475 --- /dev/null +++ b/packages/cli/src/agent-view/feature.test.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { FatalError } from '@qwen-code/qwen-code-core'; +import type { Settings } from '../config/settingsSchema.js'; +import { + AGENT_VIEW_DISABLED_MESSAGE, + isAgentViewEnabled, + requireAgentViewEnabled, +} from './feature.js'; + +describe('Agent View feature gate', () => { + it('is opt-in', () => { + expect(isAgentViewEnabled({} as Settings)).toBe(false); + expect( + isAgentViewEnabled({ experimental: { agentView: true } } as Settings), + ).toBe(true); + }); + + it('returns one stable enablement hint', () => { + const invoke = () => requireAgentViewEnabled({} as Settings); + + expect(invoke).toThrow(AGENT_VIEW_DISABLED_MESSAGE); + expect(invoke).toThrow(FatalError); + }); +}); diff --git a/packages/cli/src/agent-view/feature.ts b/packages/cli/src/agent-view/feature.ts new file mode 100644 index 00000000000..4b7b3bc766b --- /dev/null +++ b/packages/cli/src/agent-view/feature.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { FatalError } from '@qwen-code/qwen-code-core'; +import { loadSettings } from '../config/settings.js'; +import type { Settings } from '../config/settingsSchema.js'; + +export const AGENT_VIEW_DISABLED_MESSAGE = + 'Agent View is disabled. Set `experimental.agentView` to `true` in settings to enable it.'; + +export function isAgentViewEnabled(settings: Settings): boolean { + return settings.experimental?.agentView === true; +} + +export function requireAgentViewEnabled(settings?: Settings): void { + if (!isAgentViewEnabled(settings ?? loadSettings().merged)) { + throw new FatalError(AGENT_VIEW_DISABLED_MESSAGE, 1); + } +} diff --git a/packages/cli/src/agent-view/managed-detach.test.ts b/packages/cli/src/agent-view/managed-detach.test.ts new file mode 100644 index 00000000000..35ac44e0ada --- /dev/null +++ b/packages/cli/src/agent-view/managed-detach.test.ts @@ -0,0 +1,130 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { detachCurrentSessionToAgentView } from './managed-detach.js'; + +describe('detachCurrentSessionToAgentView', () => { + it('asks the supervisor to adopt the current idle session', async () => { + const globalDir = '/tmp/qwen-agent-view-detach'; + const adopt = vi.fn(async () => ({ sessionId, adopted: true })); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const config = { + getSessionId: () => sessionId, + getProjectRoot: () => path.join(globalDir, 'project'), + getTargetDir: () => path.join(globalDir, 'project', 'src'), + getApprovalMode: () => 'default', + getSandbox: () => undefined, + }; + + const result = await detachCurrentSessionToAgentView(config, { + globalDir, + terminal: { columns: 100, rows: 40 }, + ensureSupervisor: async () => ({ + adopt, + }), + }); + + expect(result).toEqual({ sessionId }); + expect(adopt).toHaveBeenCalledWith({ + sessionId, + projectCwd: path.resolve(globalDir, 'project'), + activeCwd: path.resolve(globalDir, 'project', 'src'), + approvalMode: 'default', + sandbox: undefined, + terminal: { columns: 100, rows: 40 }, + }); + }); + + it('does not stringify a missing approval mode', async () => { + const adopt = vi.fn(async () => ({ sessionId, adopted: true })); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const config = { + getSessionId: () => sessionId, + getProjectRoot: () => '/project', + getTargetDir: () => '/project', + getApprovalMode: () => undefined, + getSandbox: () => undefined, + }; + + await detachCurrentSessionToAgentView(config, { + ensureSupervisor: async () => ({ adopt }), + }); + + expect(adopt).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: undefined, + }), + ); + }); + + it('passes a string sandbox mode through without JSON quoting', async () => { + const adopt = vi.fn(async () => ({ sessionId, adopted: true })); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const config = { + getSessionId: () => sessionId, + getProjectRoot: () => '/project', + getTargetDir: () => '/project', + getApprovalMode: () => undefined, + getSandbox: () => 'linux', + }; + + await detachCurrentSessionToAgentView(config, { + ensureSupervisor: async () => ({ adopt }), + }); + + expect(adopt).toHaveBeenCalledWith( + expect.objectContaining({ + sandbox: 'linux', + }), + ); + }); + + it('does not stringify a null sandbox mode', async () => { + const adopt = vi.fn(async () => ({ sessionId, adopted: true })); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const config = { + getSessionId: () => sessionId, + getProjectRoot: () => '/project', + getTargetDir: () => '/project', + getApprovalMode: () => undefined, + getSandbox: () => null, + }; + + await detachCurrentSessionToAgentView(config, { + ensureSupervisor: async () => ({ adopt }), + }); + + expect(adopt).toHaveBeenCalledWith( + expect.objectContaining({ + sandbox: undefined, + }), + ); + }); + + it('JSON-stringifies an object sandbox config', async () => { + const adopt = vi.fn(async () => ({ sessionId, adopted: true })); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const config = { + getSessionId: () => sessionId, + getProjectRoot: () => '/project', + getTargetDir: () => '/project', + getApprovalMode: () => undefined, + getSandbox: () => ({ command: 'docker', image: 'qwen-sandbox' }), + }; + + await detachCurrentSessionToAgentView(config, { + ensureSupervisor: async () => ({ adopt }), + }); + + expect(adopt).toHaveBeenCalledWith( + expect.objectContaining({ + sandbox: '{"command":"docker","image":"qwen-sandbox"}', + }), + ); + }); +}); diff --git a/packages/cli/src/agent-view/managed-detach.ts b/packages/cli/src/agent-view/managed-detach.ts new file mode 100644 index 00000000000..c0025b56be6 --- /dev/null +++ b/packages/cli/src/agent-view/managed-detach.ts @@ -0,0 +1,70 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { ensureAgentViewSupervisor } from './supervisor-runner.js'; +import type { AgentViewSupervisorClientHandle } from './supervisor-runner.js'; + +interface DetachableConfig { + getSessionId(): string; + getProjectRoot(): string; + getTargetDir(): string; + getApprovalMode(): unknown; + getSandbox(): unknown; +} + +interface DetachTerminalSize { + columns: number; + rows: number; +} + +interface DetachOptions { + globalDir?: string; + terminal?: Partial; + ensureSupervisor?: (options: { + globalDir?: string; + }) => Promise>; +} + +export async function detachCurrentSessionToAgentView( + config: DetachableConfig, + options: DetachOptions = {}, +): Promise<{ sessionId: string }> { + const sessionId = config.getSessionId(); + const projectCwd = path.resolve(config.getProjectRoot()); + const activeCwd = path.resolve(config.getTargetDir()); + const supervisor = await ( + options.ensureSupervisor ?? ensureAgentViewSupervisor + )(storeOptions(options)); + + await supervisor.adopt({ + sessionId, + projectCwd, + activeCwd, + approvalMode: stringifyOptional(config.getApprovalMode()), + sandbox: stringifySandbox(config.getSandbox()), + terminal: { + columns: options.terminal?.columns ?? process.stdout.columns ?? 80, + rows: options.terminal?.rows ?? process.stdout.rows ?? 24, + }, + }); + return { sessionId }; +} + +function stringifyOptional(value: unknown): string | undefined { + return value === undefined || value === null ? undefined : String(value); +} + +function stringifySandbox(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'boolean') return String(value); + return JSON.stringify(value); +} + +function storeOptions(options: DetachOptions): { globalDir?: string } { + return options.globalDir ? { globalDir: options.globalDir } : {}; +} diff --git a/packages/cli/src/agent-view/presentation.test.ts b/packages/cli/src/agent-view/presentation.test.ts new file mode 100644 index 00000000000..ffd1de4f8d4 --- /dev/null +++ b/packages/cli/src/agent-view/presentation.test.ts @@ -0,0 +1,178 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { + AgentViewActivityFile, + AgentViewSessionStateFile, +} from './protocol.js'; +import { deriveAgentViewPresentation } from './presentation.js'; + +describe('deriveAgentViewPresentation', () => { + it('maps running sessions to the Working group', () => { + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'working' }), + now: '2026-07-17T10:00:00.000Z', + }), + ).toMatchObject({ + taskState: 'running', + group: 'working', + iconShape: 'alive', + iconTone: 'working', + actions: { + canReply: false, + canStop: true, + }, + }); + }); + + it('distinguishes blocking needs input from soft questions', () => { + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'needs_input' }), + activity: activity({ waitingFor: 'approval', inputKind: 'blocking' }), + }), + ).toMatchObject({ + taskState: 'waiting', + inputState: 'permission', + group: 'needs_input', + actions: { + canReply: false, + canHibernate: false, + needsBlockingAnswer: true, + }, + }); + + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'needs_input' }), + activity: activity({ waitingFor: 'response' }), + }), + ).toMatchObject({ + taskState: 'waiting', + inputState: 'soft_question', + actions: { + canReply: true, + canHibernate: true, + needsBlockingAnswer: false, + }, + }); + + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'needs_input' }), + activity: activity({ waitingFor: 'question', inputKind: 'soft' }), + }), + ).toMatchObject({ + taskState: 'waiting', + inputState: 'soft_question', + group: 'needs_input', + actions: { + canReply: true, + canHibernate: true, + needsBlockingAnswer: false, + }, + }); + }); + + it('classifies settings confirmations as auth or settings input', () => { + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'needs_input' }), + activity: activity({ + waitingFor: 'setting_confirmation', + inputKind: 'blocking', + }), + }), + ).toMatchObject({ + inputState: 'auth_or_settings', + group: 'needs_input', + }); + }); + + it('keeps ready, stopped, and failed sessions in the Completed group', () => { + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'completed' }), + activity: activity({ lastResult: 'Done' }), + }), + ).toMatchObject({ + taskState: 'ready', + group: 'completed', + iconTone: 'ready', + subtitle: 'Done', + actions: { canReply: true }, + }); + + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'stopped', processState: 'exited' }), + }), + ).toMatchObject({ + taskState: 'stopped', + group: 'completed', + recoverability: 'restartable', + iconShape: 'exited', + iconTone: 'stopped', + subtitle: 'Stopped by user', + actions: { + canAttach: true, + canReply: true, + canRespawn: true, + }, + }); + + expect( + deriveAgentViewPresentation({ + state: session({ sessionState: 'failed', processState: 'exited' }), + }), + ).toMatchObject({ + taskState: 'failed', + group: 'completed', + recoverability: 'restartable', + iconShape: 'exited', + iconTone: 'failed', + subtitle: 'Session failed', + actions: { + canAttach: true, + canReply: true, + canRespawn: true, + }, + }); + }); +}); + +function session( + overrides: Partial = {}, +): AgentViewSessionStateFile { + return { + schemaVersion: 1, + sessionId: 'session-1', + ownership: 'managed', + sessionState: 'idle', + processState: 'alive', + attachState: 'detached', + projectCwd: '/workspace/qwen-code', + originalCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code', + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + worktree: { mode: 'none' }, + ...overrides, + }; +} + +function activity( + overrides: Partial = {}, +): AgentViewActivityFile { + return { + schemaVersion: 1, + lastActivityAt: '2026-07-17T09:00:00.000Z', + capabilities: [], + ...overrides, + }; +} diff --git a/packages/cli/src/agent-view/presentation.ts b/packages/cli/src/agent-view/presentation.ts new file mode 100644 index 00000000000..d6756093725 --- /dev/null +++ b/packages/cli/src/agent-view/presentation.ts @@ -0,0 +1,384 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AgentViewActivityFile, + AgentViewLaunchFile, + AgentViewProcessState, + AgentViewRosterEntry, + AgentViewSessionSnapshot, + AgentViewSessionStateFile, +} from './protocol.js'; + +export type AgentViewTaskState = + | 'running' + | 'waiting' + | 'ready' + | 'stopped' + | 'failed'; + +export type AgentViewInputState = + | 'none' + | 'soft_question' + | 'permission' + | 'confirmation' + | 'external_dialog' + | 'auth_or_settings'; + +export type AgentViewRuntimeState = + | 'starting' + | 'alive' + | 'hibernated' + | 'exited' + | 'restarting'; + +export type AgentViewPresentationGroup = + | 'needs_input' + | 'working' + | 'completed'; + +export type AgentViewRecoverability = 'live' | 'restartable' | 'blocked'; + +export type AgentViewIconShape = 'alive' | 'exited' | 'sleeping'; + +export type AgentViewIconTone = + | 'working' + | 'needs_input' + | 'ready' + | 'stopped' + | 'failed'; + +export interface AgentViewPresentationActions { + canAttach: boolean; + canPeek: boolean; + canReply: boolean; + canStop: boolean; + canRemove: boolean; + canRespawn: boolean; + canHibernate: boolean; + needsBlockingAnswer: boolean; +} + +export interface AgentViewPresentation { + sessionId: string; + taskState: AgentViewTaskState; + inputState: AgentViewInputState; + runtimeState: AgentViewRuntimeState; + recoverability: AgentViewRecoverability; + group: AgentViewPresentationGroup; + iconShape: AgentViewIconShape; + iconTone: AgentViewIconTone; + title: string; + subtitle: string; + ageLabel: string; + actions: AgentViewPresentationActions; +} + +export interface AgentViewPresentationInput { + state: AgentViewSessionStateFile; + rosterEntry?: AgentViewRosterEntry; + launch?: AgentViewLaunchFile; + activity?: AgentViewActivityFile; + now?: Date | string; +} + +export function deriveAgentViewPresentation( + input: AgentViewPresentationInput | AgentViewSessionSnapshot, +): AgentViewPresentation { + // Keep UI grouping capability-driven while the persisted state remains split. + const state = input.state; + const activity = input.activity; + const now = 'now' in input ? input.now : undefined; + const runtimeState = deriveRuntimeState(state.processState); + const inputState = deriveInputState(activity); + const taskState = deriveTaskState(state, inputState); + const recoverability = deriveRecoverability(state, taskState, inputState); + const actions = deriveActions( + state, + taskState, + inputState, + recoverability, + Boolean(input.rosterEntry?.pinned), + ); + const createdAt = toTime(state.createdAt); + + return { + sessionId: state.sessionId, + taskState, + inputState, + runtimeState, + recoverability, + group: deriveGroup(taskState), + iconShape: deriveIconShape(runtimeState), + iconTone: deriveIconTone(taskState), + title: deriveTitle(input.rosterEntry, input.launch, activity), + subtitle: deriveSubtitle(activity, taskState), + // An unparseable createdAt must not surface as a ~56-year duration. + ageLabel: Number.isNaN(createdAt) + ? '' + : formatDuration(Math.max(0, toTime(now ?? new Date()) - createdAt)), + actions, + }; +} + +export function getAgentViewActivityInputState( + activity: AgentViewActivityFile | undefined, +): AgentViewInputState { + return deriveInputState(activity); +} + +export function canAgentViewQueueFollowUp( + state: AgentViewSessionStateFile, + activity: AgentViewActivityFile | undefined, +): boolean { + const inputState = deriveInputState(activity); + const taskState = deriveTaskState(state, inputState); + const recoverability = deriveRecoverability(state, taskState, inputState); + return deriveActions(state, taskState, inputState, recoverability, false) + .canReply; +} + +export function canAgentViewHibernate( + snapshot: AgentViewSessionSnapshot, +): boolean { + return deriveAgentViewPresentation(snapshot).actions.canHibernate; +} + +function deriveTaskState( + state: AgentViewSessionStateFile, + _inputState: AgentViewInputState, +): AgentViewTaskState { + switch (state.sessionState) { + case 'starting': + case 'working': + return 'running'; + case 'needs_input': + return 'waiting'; + case 'failed': + return 'failed'; + case 'stopped': + return 'stopped'; + case 'idle': + case 'completed': + return 'ready'; + default: + return assertNever(state.sessionState); + } +} + +function deriveRuntimeState( + processState: AgentViewProcessState, +): AgentViewRuntimeState { + switch (processState) { + case 'starting': + return 'starting'; + case 'alive': + return 'alive'; + case 'hibernating': + case 'hibernated': + return 'hibernated'; + case 'restarting': + return 'restarting'; + case 'exited': + return 'exited'; + default: + return assertNever(processState); + } +} + +function deriveInputState( + activity: AgentViewActivityFile | undefined, +): AgentViewInputState { + // The explicit structured kind a worker reported is authoritative; the + // waitingFor text rules only infer a kind the worker did not state. A + // soft question without a waitingFor phrase must stay answerable. + const inputKind = activity?.inputKind; + if (inputKind === 'soft') { + return 'soft_question'; + } + const waitingFor = activity?.waitingFor?.toLowerCase(); + if (!waitingFor) { + return inputKind === 'blocking' ? 'confirmation' : 'none'; + } + if (inputKind !== 'blocking' && waitingFor === 'response') { + return 'soft_question'; + } + if (waitingFor.includes('permission') || waitingFor.includes('approval')) { + return 'permission'; + } + if (waitingFor.includes('auth') || waitingFor.includes('setting')) { + return 'auth_or_settings'; + } + if (waitingFor.includes('confirm')) { + return 'confirmation'; + } + return inputKind === 'blocking' ? 'confirmation' : 'external_dialog'; +} + +function deriveRecoverability( + state: AgentViewSessionStateFile, + taskState: AgentViewTaskState, + inputState: AgentViewInputState, +): AgentViewRecoverability { + if (state.attachState === 'attached') { + return 'blocked'; + } + if (state.processState === 'alive') { + return 'live'; + } + if ( + state.processState === 'starting' || + state.processState === 'restarting' || + state.processState === 'hibernating' + ) { + return 'blocked'; + } + if (taskState === 'waiting' && inputState !== 'soft_question') { + return 'blocked'; + } + return 'restartable'; +} + +function deriveGroup( + taskState: AgentViewTaskState, +): AgentViewPresentationGroup { + switch (taskState) { + case 'waiting': + return 'needs_input'; + case 'running': + return 'working'; + case 'ready': + case 'stopped': + case 'failed': + return 'completed'; + default: + return assertNever(taskState); + } +} + +function deriveIconShape( + runtimeState: AgentViewRuntimeState, +): AgentViewIconShape { + if (runtimeState === 'alive' || runtimeState === 'starting') return 'alive'; + if (runtimeState === 'hibernated') return 'sleeping'; + return 'exited'; +} + +function deriveIconTone(taskState: AgentViewTaskState): AgentViewIconTone { + switch (taskState) { + case 'running': + return 'working'; + case 'waiting': + return 'needs_input'; + case 'ready': + return 'ready'; + case 'stopped': + return 'stopped'; + case 'failed': + return 'failed'; + default: + return assertNever(taskState); + } +} + +function deriveActions( + state: AgentViewSessionStateFile, + taskState: AgentViewTaskState, + inputState: AgentViewInputState, + recoverability: AgentViewRecoverability, + pinned: boolean, +): AgentViewPresentationActions { + const detached = state.attachState === 'detached'; + const needsBlockingAnswer = + taskState === 'waiting' && inputState !== 'soft_question'; + const canRecover = detached && recoverability !== 'blocked'; + return { + canAttach: canRecover, + canPeek: true, + canReply: + canRecover && + (taskState === 'ready' || + taskState === 'stopped' || + taskState === 'failed' || + (taskState === 'waiting' && inputState === 'soft_question')), + canStop: detached && taskState === 'running', + canRemove: detached, + canRespawn: canRecover && recoverability === 'restartable', + canHibernate: + !pinned && + detached && + (taskState === 'ready' || + (taskState === 'waiting' && inputState === 'soft_question')) && + state.processState === 'alive', + needsBlockingAnswer, + }; +} + +function deriveTitle( + rosterEntry: AgentViewRosterEntry | undefined, + launch: AgentViewLaunchFile | undefined, + activity: AgentViewActivityFile | undefined, +): string { + return ( + cleanText(rosterEntry?.displayName) ?? + cleanRuntimeSummary(activity?.summary) ?? + cleanText(launch?.initialPrompt) ?? + 'Untitled session' + ); +} + +function deriveSubtitle( + activity: AgentViewActivityFile | undefined, + taskState: AgentViewTaskState, +): string { + const result = cleanText(activity?.lastResult); + if (result) return result; + if (taskState === 'stopped') return 'Stopped by user'; + if (taskState === 'failed') return 'Session failed'; + return ''; +} + +function cleanRuntimeSummary(value: string | undefined): string | undefined { + const text = cleanText(value); + if (!text) return undefined; + if ( + text === 'Working' || + text === 'Idle' || + text === 'Completed' || + text === 'Stopped' || + text === 'Failed' || + text === 'Needs Input' || + text.startsWith('Running ') || + text.startsWith('Waiting for ') + ) { + return undefined; + } + return text; +} + +function cleanText(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +function formatDuration(durationMs: number): string { + const seconds = Math.floor(durationMs / 1000); + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + return `${Math.floor(hours / 24)}d`; +} + +function toTime(value: Date | string): number { + const date = value instanceof Date ? value : new Date(value); + return date.getTime(); +} + +function assertNever(value: never): never { + throw new Error(`Unexpected value: ${String(value)}`); +} diff --git a/packages/cli/src/agent-view/protocol.ts b/packages/cli/src/agent-view/protocol.ts index 488ebc1502d..6f9f63f6973 100644 --- a/packages/cli/src/agent-view/protocol.ts +++ b/packages/cli/src/agent-view/protocol.ts @@ -37,6 +37,8 @@ export interface AgentViewLastError { at: string; } +export type AgentViewInputKind = 'blocking' | 'soft'; + export interface AgentViewWorktreeState { mode: 'none' | 'worktree' | 'shared-unisolated'; path?: string; @@ -58,6 +60,7 @@ export interface AgentViewSessionStateFile { activeCwd: string; createdAt: string; updatedAt: string; + initialPromptPending?: boolean; lastError?: AgentViewLastError; worktree: AgentViewWorktreeState; } @@ -66,6 +69,13 @@ export interface AgentViewLaunchFile { [key: string]: unknown; schemaVersion: 1; sessionId: string; + /** + * The spelling-preserving id passed to --resume. The store canonicalizes + * sessionIds for directory naming, but the native session store keeps the + * original spelling; resuming with a rewritten id fails on + * case-sensitive filesystems. + */ + resumeSessionId?: string; argv: string[]; env: Record; entrypoint: string; @@ -77,6 +87,7 @@ export interface AgentViewLaunchFile { settingsDigest?: string; mcpDigest?: string; includeDirectories: string[]; + initialPrompt?: string; terminal: { columns: number; rows: number; @@ -88,7 +99,14 @@ export interface AgentViewActivityFile { schemaVersion: 1; summary?: string; waitingFor?: string; + inputKind?: AgentViewInputKind; lastResult?: string; + queuedPromptCount?: number; + queuedPromptPreview?: string; + queuedPromptId?: string; + queuedPromptText?: string; + queuedPromptDeliveredAt?: string; + lastQueuedPromptAt?: string; lastActivityAt: string; capabilities: string[]; } @@ -101,6 +119,7 @@ export interface AgentViewWorkerFile { endpoint?: string; hostEndpoint?: string; hostAuthToken?: string; + hostId?: string; tokenDigest?: string; lastHeartbeatAt?: string; protocolVersion: number; @@ -140,6 +159,7 @@ export interface AgentViewSupervisorFile { export interface AgentViewSessionSnapshot { sessionId: string; state: AgentViewSessionStateFile; + launch?: AgentViewLaunchFile; activity?: AgentViewActivityFile; worker?: AgentViewWorkerFile; rosterEntry?: AgentViewRosterEntry; @@ -171,7 +191,9 @@ export type AgentViewWorkerEvent = cwd?: string; summary?: string; waitingFor?: string; + inputKind?: AgentViewInputKind; lastResult?: string; + promptId?: string; at?: string; }; @@ -184,6 +206,7 @@ export type AgentViewWorkerControlEvent = | { type: 'prompt'; sequence: number; + promptId: string; text: string; at: string; } @@ -195,6 +218,11 @@ export type AgentViewWorkerControlEvent = callId?: string; outcome?: AgentViewWorkerAnswerOutcome; payload?: Record; + } + | { + type: 'stop'; + sequence: number; + at: string; }; export type AgentViewWorkerAnswerOutcome = diff --git a/packages/cli/src/agent-view/pty-host-env.ts b/packages/cli/src/agent-view/pty-host-env.ts new file mode 100644 index 00000000000..49c8bddebb8 --- /dev/null +++ b/packages/cli/src/agent-view/pty-host-env.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PTY_HOST_AUTH_TOKEN_ENV = 'QWEN_AGENT_VIEW_PTY_HOST_TOKEN'; +export const PTY_HOST_ID_ENV = 'QWEN_AGENT_VIEW_PTY_HOST_ID'; diff --git a/packages/cli/src/agent-view/pty-host-process.test.ts b/packages/cli/src/agent-view/pty-host-process.test.ts new file mode 100644 index 00000000000..04fe7173767 --- /dev/null +++ b/packages/cli/src/agent-view/pty-host-process.test.ts @@ -0,0 +1,1446 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs/promises'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + createAgentViewPtyHostServer, + connectAgentViewPtyHostProcess, + getAgentViewPtyHostSocketPath, + INTERNAL_AGENT_VIEW_PTY_HOST_ARG, + launchAgentViewPtyHostProcess, + runAgentViewPtyHostProcess, +} from './pty-host-process.js'; +import { PTY_HOST_AUTH_TOKEN_ENV, PTY_HOST_ID_ENV } from './pty-host-env.js'; +import { + BoundedOutputRing, + type AgentViewPtyHostExit, + type AgentViewPtyHostHandle, +} from './pty-host.js'; +import { getAgentViewSessionPaths } from './supervisor-store.js'; + +const socketDirs = new Set(); + +describe('Agent View PTY host process server', () => { + const servers: Array<{ close(): Promise }> = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); + await Promise.all( + [...socketDirs].map((dir) => + isWindowsPipePath(dir) + ? Promise.resolve() + : fs.rm(dir, { recursive: true, force: true }), + ), + ); + socketDirs.clear(); + }); + + it('bridges an attach stream to the PTY handle', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const socket = net.createConnection(socketPath); + socket.write(`${JSON.stringify({ id: '1', op: 'attachStream' })}\n`); + await expect(readLine(socket)).resolves.toMatchObject({ + id: '1', + ok: true, + }); + + socket.write('hello'); + await waitFor(() => host.input === 'hello'); + + host.emitData('world'); + await expect(readChunk(socket)).resolves.toBe('world'); + + socket.destroy(); + }); + + it('forwards input coalesced with the attach request', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const socket = net.createConnection(socketPath); + socket.write(`${JSON.stringify({ id: '1', op: 'attachStream' })}\nhello`); + await expect(readLine(socket)).resolves.toMatchObject({ + id: '1', + ok: true, + }); + + await waitFor(() => host.input === 'hello'); + socket.destroy(); + }); + + it('rejects a second attach stream while one is active', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const firstSocket = net.createConnection(socketPath); + firstSocket.write(`${JSON.stringify({ id: '1', op: 'attachStream' })}\n`); + await expect(readLine(firstSocket)).resolves.toMatchObject({ + id: '1', + ok: true, + }); + + const secondSocket = net.createConnection(socketPath); + secondSocket.write(`${JSON.stringify({ id: '2', op: 'attachStream' })}\n`); + await expect(readLine(secondSocket)).resolves.toMatchObject({ + id: '2', + ok: false, + error: { code: 'already_attached' }, + }); + + firstSocket.destroy(); + secondSocket.destroy(); + }); + + it('closes while an attach stream is active', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + await server.listen(); + + const socket = net.createConnection(socketPath); + socket.write(`${JSON.stringify({ id: '1', op: 'attachStream' })}\n`); + await expect(readLine(socket)).resolves.toMatchObject({ + id: '1', + ok: true, + }); + + await expect(server.close()).resolves.toBeUndefined(); + await expect(waitForClose(socket)).resolves.toBeUndefined(); + }); + + it('handles resize, logs, and kill requests', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + host.emitData('0123456789'); + await expect( + requestHost(socketPath, 'resize', { columns: 120, rows: 40 }), + ).resolves.toMatchObject({ resized: true }); + await expect(requestHost(socketPath, 'logs')).resolves.toEqual({ + output: '56789', + }); + await expect( + requestHost(socketPath, 'kill', { signal: 'SIGTERM' }), + ).resolves.toMatchObject({ killed: true }); + + expect(host.resizes).toEqual([{ columns: 120, rows: 40 }]); + expect(host.killedWith).toBe('SIGTERM'); + }); + + it('forwards attach input bytes without UTF-8 re-encoding', async () => { + const host = fakeHost(); + const rawWrites: Buffer[] = []; + host.write = (data: Buffer) => { + rawWrites.push(Buffer.from(data)); + }; + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const socket = net.createConnection(socketPath); + await new Promise((resolve) => socket.once('connect', resolve)); + const request = Buffer.from( + `${JSON.stringify({ id: 'attach-1', op: 'attachStream' })}\n`, + 'utf8', + ); + // Latin-1 'e-acute' + 'A': invalid UTF-8 that a transparent + // transport must deliver verbatim. + const keystrokes = Buffer.from([0xe9, 0x41]); + socket.write(Buffer.concat([request, keystrokes])); + await waitFor(() => rawWrites.length > 0); + socket.write(Buffer.from([0xff, 0x00])); + await waitFor(() => Buffer.concat(rawWrites).length === 4); + + expect([...Buffer.concat(rawWrites)]).toEqual([0xe9, 0x41, 0xff, 0x00]); + + socket.destroy(); + }); + + it.skipIf(process.platform === 'win32')( + 'reclaims a stale socket lock left by a dead process', + async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + await fs.writeFile(`${socketPath}.lock`, '2147483647'); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + + await expect(server.listen()).resolves.toBeUndefined(); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'releases the socket lock on close so the path can be reused', + async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const first = createAgentViewPtyHostServer(host, socketPath); + await first.listen(); + const second = createAgentViewPtyHostServer(fakeHost(), socketPath); + servers.push(second); + + await expect(second.listen()).rejects.toThrow('already in use'); + await first.close(); + await expect(second.listen()).resolves.toBeUndefined(); + }, + ); + + it('rejects non-positive or non-integer resize dimensions', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + await expect( + requestHost(socketPath, 'resize', { columns: 0, rows: 40 }), + ).rejects.toThrow('columns must be a positive integer'); + await expect( + requestHost(socketPath, 'resize', { columns: 120, rows: 2.5 }), + ).rejects.toThrow('rows must be a positive integer'); + + expect(host.resizes).toEqual([]); + }); + + it('rejects unsupported kill signals', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + await expect( + requestHost(socketPath, 'kill', { signal: 'SIGUSR1' }), + ).rejects.toThrow('Agent View PTY host signal is not allowed.'); + + expect(host.killedWith).toBeUndefined(); + }); + + it('escalates a TERM-resistant worker to SIGKILL after the grace period', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath, { + shutdownGraceMs: 20, + }); + servers.push(server); + await server.listen(); + + await expect(requestHost(socketPath, 'shutdown')).resolves.toEqual({ + shuttingDown: true, + }); + expect(host.shutdowns).toBe(1); + + await waitFor(() => host.killedWith === 'SIGKILL'); + }); + + it('does not escalate when the worker exits within the grace period', async () => { + const host = fakeHost(); + let resolveExited: (exit: AgentViewPtyHostExit) => void = () => {}; + host.exited = new Promise((resolve) => { + resolveExited = resolve; + }); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath, { + shutdownGraceMs: 20, + }); + servers.push(server); + await server.listen(); + + await expect(requestHost(socketPath, 'shutdown')).resolves.toEqual({ + shuttingDown: true, + }); + resolveExited({ kind: 'exited', exitCode: 0 }); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(host.killedWith).toBeUndefined(); + }); + + it('falls back to kill(SIGTERM) when the host has no shutdown method', async () => { + const host = fakeHost(); + delete (host as Partial).shutdown; + host.exited = Promise.resolve({ kind: 'exited', exitCode: 0 }); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath, { + shutdownGraceMs: 20, + }); + servers.push(server); + await server.listen(); + + await expect(requestHost(socketPath, 'shutdown')).resolves.toEqual({ + shuttingDown: true, + }); + + expect(host.killedWith).toBe('SIGTERM'); + }); + + it('requires auth when the host server has a token', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath, { + authToken: 'secret', + }); + servers.push(server); + await server.listen(); + + await expect(requestHost(socketPath, 'status')).rejects.toThrow( + 'Unauthorized PTY host request.', + ); + await expect( + requestHost(socketPath, 'status', undefined, 'secret'), + ).resolves.toMatchObject({ + workerPid: 1234, + }); + }); + + it('wires the env host token into the entrypoint server', async () => { + const launchDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-pty-entrypoint-'), + ); + socketDirs.add(launchDir); + const launchPath = path.join(launchDir, 'launch.json'); + await fs.writeFile( + launchPath, + JSON.stringify({ + schemaVersion: 1, + sessionId: 'session-entry', + argv: ['qwen', '--agent-view-worker'], + env: { QWEN_AGENT_VIEW_WORKER: '1' }, + entrypoint: 'qwen', + projectCwd: '/repo', + activeCwd: '/repo', + includeDirectories: [], + terminal: { columns: 80, rows: 24 }, + }), + ); + let exitCallback: ((event: { exitCode: number }) => void) | undefined; + const socketPath = shortSocketPath(); + // The token travels via the host env, mirroring the spawn contract. + const previousToken = process.env[PTY_HOST_AUTH_TOKEN_ENV]; + process.env[PTY_HOST_AUTH_TOKEN_ENV] = 'entry-token'; + try { + const runPromise = runAgentViewPtyHostProcess({ + launchPath, + socketPath, + loadPty: async () => ({ + name: 'injected', + module: { + spawn: () => ({ + pid: 4321, + write: () => {}, + onData: () => ({ dispose: () => {} }), + onExit: (callback: (event: { exitCode: number }) => void) => { + exitCallback = callback; + return { dispose: () => {} }; + }, + resize: () => {}, + kill: () => {}, + }), + }, + }), + }); + try { + let status: unknown; + for (let attempt = 0; attempt < 50 && status === undefined; attempt++) { + try { + status = await requestHost( + socketPath, + 'status', + undefined, + 'entry-token', + ); + } catch { + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } + + expect(status).toEqual({ pid: process.pid, workerPid: 4321 }); + await expect(requestHost(socketPath, 'status')).rejects.toThrow( + 'Unauthorized PTY host request.', + ); + } finally { + // A non-zero worker exit must surface verbatim to the CLI entrypoint. + exitCallback?.({ exitCode: 3 }); + await expect(runPromise).resolves.toEqual({ + kind: 'exited', + exitCode: 3, + }); + } + } finally { + if (previousToken === undefined) { + delete process.env[PTY_HOST_AUTH_TOKEN_ENV]; + } else { + process.env[PTY_HOST_AUTH_TOKEN_ENV] = previousToken; + } + } + }); + + it('requires auth for attach streams', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath, { + authToken: 'secret', + }); + servers.push(server); + await server.listen(); + + const rejectedSocket = net.createConnection(socketPath); + rejectedSocket.write( + `${JSON.stringify({ id: '1', op: 'attachStream' })}\n`, + ); + await expect(readLine(rejectedSocket)).resolves.toMatchObject({ + id: '1', + ok: false, + error: { code: 'unauthorized' }, + }); + + const acceptedSocket = net.createConnection(socketPath); + acceptedSocket.write( + `${JSON.stringify({ + id: '2', + op: 'attachStream', + authToken: 'secret', + })}\nhello`, + ); + await expect(readLine(acceptedSocket)).resolves.toMatchObject({ + id: '2', + ok: true, + }); + await waitFor(() => host.input === 'hello'); + + rejectedSocket.destroy(); + acceptedSocket.destroy(); + }); + + it('closes requests with oversized lines', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const socket = net.createConnection(socketPath); + socket.write('x'.repeat(1024 * 1024 + 1)); + + await expect(waitForClose(socket)).resolves.toBeUndefined(); + }); + + it('closes while a silent request socket is open', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + await server.listen(); + + const socket = net.createConnection(socketPath); + await new Promise((resolve) => socket.once('connect', resolve)); + const closed = waitForClose(socket); + + await expect(server.close()).resolves.toBeUndefined(); + await expect(closed).resolves.toBeUndefined(); + }); + + it('returns logs near the output retention cap', async () => { + const host = fakeHost(1024 * 1024); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + host.emitData('x'.repeat(1024 * 1024)); + + await expect(requestHost(socketPath, 'logs')).resolves.toEqual({ + output: 'x'.repeat(1024 * 1024), + }); + }); + + it('returns escape-heavy logs near the output retention cap', async () => { + const host = fakeHost(1024 * 1024); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const output = '\x1b[0m'.repeat(256 * 1024); + host.emitData(output); + + await expect(requestHost(socketPath, 'logs')).resolves.toEqual({ + output, + }); + }); + + it('returns control-byte-heavy logs through the connected handle', async () => { + const host = fakeHost(1024 * 1024); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const output = '\x01'.repeat(1024 * 1024); + host.emitData(output); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-control-byte-logs'), + socketPath, + ); + + await expect(connected.getOutput?.()).resolves.toBe(output); + }); + + it.skipIf(process.platform === 'win32')( + 'restricts Unix socket and parent directory permissions', + async () => { + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(fakeHost(), socketPath); + servers.push(server); + + await server.listen(); + + const [dirStat, socketStat] = await Promise.all([ + fs.stat(path.dirname(socketPath)), + fs.stat(socketPath), + ]); + expect(dirStat.mode & 0o777).toBe(0o700); + expect(socketStat.mode & 0o777).toBe(0o600); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects listening on a socket path owned by a live server', + async () => { + const socketPath = shortSocketPath(); + const first = createAgentViewPtyHostServer(fakeHost(), socketPath); + servers.push(first); + await first.listen(); + + const second = createAgentViewPtyHostServer(fakeHost(), socketPath); + + await expect(second.listen()).rejects.toThrow('already in use'); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'replaces a stale socket file when listening', + async () => { + const socketPath = shortSocketPath(); + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + await fs.writeFile(socketPath, ''); + const server = createAgentViewPtyHostServer(fakeHost(), socketPath); + servers.push(server); + + await expect(server.listen()).resolves.toBeUndefined(); + }, + ); + + it.skipIf(process.platform === 'win32')( + 'does not unlink a socket path taken over by another live server', + async () => { + const socketPath = shortSocketPath(); + const replacement = net.createServer((socket) => { + socket.on('error', () => {}); + }); + await listenServer(replacement, socketPath); + try { + const displaced = createAgentViewPtyHostServer(fakeHost(), socketPath); + + await displaced.close(); + + await expect(fs.stat(socketPath)).resolves.toBeDefined(); + await expect(connectOnce(socketPath)).resolves.toBe(true); + } finally { + replacement.close(); + await removeTestSocket(socketPath); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'rejects a symlinked socket parent directory', + async () => { + const realDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qah-real-')); + const linkDir = path.join( + os.tmpdir(), + `qah-link-${process.pid}-${Date.now()}`, + ); + await fs.symlink(realDir, linkDir); + const socketPath = path.join(linkDir, 'pty.sock'); + const server = createAgentViewPtyHostServer(fakeHost(), socketPath); + try { + await expect(server.listen()).rejects.toThrow('must not be a symlink'); + } finally { + await fs.rm(linkDir, { force: true }); + await fs.rm(realDir, { recursive: true, force: true }); + } + }, + ); + + it('handles shutdown requests', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + await expect(requestHost(socketPath, 'shutdown')).resolves.toEqual({ + shuttingDown: true, + }); + + expect(host.shutdowns).toBe(1); + }); + + it('delivers a connected host shutdown without forging its exit', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + connected.shutdown?.(); + + await waitFor(() => host.shutdowns === 1); + // The RPC only starts the drain. Retirement is confirmed after the + // endpoint disappears, so a replacement cannot race socket teardown. + await expect( + Promise.race([ + connected.exited.then(() => true), + new Promise((resolve) => + setTimeout(() => resolve(false), 100), + ), + ]), + ).resolves.toBe(false); + + servers.splice(servers.indexOf(server), 1); + await server.close(); + await expect(connected.exited).resolves.toEqual({ + kind: 'confirmed-shutdown', + }); + }); + + it('waits for the remote endpoint to close after SIGKILL', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + connected.kill('SIGKILL'); + + await waitFor(() => host.killedWith === 'SIGKILL'); + servers.splice(servers.indexOf(server), 1); + await server.close(); + await expect(connected.exited).resolves.toEqual({ + kind: 'confirmed-kill', + }); + }); + + it('only resolves exited on SIGKILL for a connected (childless) handle', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + const notSettledWithin = (ms: number) => + Promise.race([ + connected.exited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ]); + + // SIGINT is trappable — exited must not settle within a grace window. + connected.kill('SIGINT'); + await waitFor(() => host.killedWith === 'SIGINT'); + expect(await notSettledWithin(100)).toBe(false); + + // SIGTERM is trappable too (server kill has no SIGKILL escalation) — + // exited must likewise stay pending for the exit poller to observe. + connected.kill('SIGTERM'); + await waitFor(() => host.killedWith === 'SIGTERM'); + expect(await notSettledWithin(100)).toBe(false); + + // SIGKILL is confirmed only after the authenticated RPC lands and the + // endpoint disappears; replacement launch must wait for socket teardown. + connected.kill('SIGKILL'); + await waitFor(() => host.killedWith === 'SIGKILL'); + expect(await notSettledWithin(100)).toBe(false); + servers.splice(servers.indexOf(server), 1); + await server.close(); + await expect(connected.exited).resolves.toEqual({ + kind: 'confirmed-kill', + }); + }); + + it('keeps exited pending when the kill or shutdown RPC never lands', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + await server.listen(); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + const notSettledWithin = (ms: number) => + Promise.race([ + connected.exited.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), ms)), + ]); + + await server.close(); + + // A lost RPC must not settle the tracker: the host may still be alive + // holding the socket lock, and only the exit poller may declare it dead. + connected.kill('SIGKILL'); + expect(await notSettledWithin(100)).toBe(false); + // shutdown is optional on the handle type; the connected handle always + // provides it. + connected.shutdown?.(); + expect(await notSettledWithin(100)).toBe(false); + }); + + it('defaults a signal-less kill to SIGTERM instead of node-pty SIGHUP', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + connected.kill(); + await waitFor(() => host.killedWith === 'SIGTERM'); + }); + + it('resolves connected host exit when status polling fails', async () => { + vi.useFakeTimers(); + try { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + await server.listen(); + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + await server.close(); + await vi.advanceTimersByTimeAsync(10000); + + await expect(connected.exited).resolves.toEqual({ + kind: 'unreachable', + }); + } finally { + vi.useRealTimers(); + } + }); + + it('fails fast when connecting with the wrong host token', async () => { + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(fakeHost(), socketPath, { + authToken: 'expected-token', + }); + servers.push(server); + await server.listen(); + + await expect( + connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + 'wrong-token', + ), + ).rejects.toThrow('Unauthorized PTY host request.'); + }); + + it('disposes a connected host by asking the remote host to shut down', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + + connected.dispose(); + + await waitFor(() => host.shutdowns === 1); + await expect(connected.exited).resolves.toEqual({ + kind: 'unreachable', + }); + }); + + it('rejects input written before an attach stream is established', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-early-write'), + socketPath, + ); + + expect(() => connected.write(Buffer.from('early'))).toThrow( + 'Agent View PTY host input requires an active attach stream.', + ); + expect(host.input).toBe(''); + }); + + it('bridges data through a connected host handle', async () => { + const host = fakeHost(); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath); + servers.push(server); + await server.listen(); + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-1'), + socketPath, + ); + const data: string[] = []; + + const disposable = connected.onData((chunk) => data.push(chunk)); + + connected.write(Buffer.from('hello')); + await waitFor(() => host.input === 'hello'); + host.emitData('output'); + await waitFor(() => data.join('') === 'output'); + + disposable?.dispose(); + }); + + it('passes auth tokens through connected host handle operations', async () => { + const host = fakeHost(1024 * 1024); + const socketPath = shortSocketPath(); + const server = createAgentViewPtyHostServer(host, socketPath, { + authToken: 'secret', + }); + servers.push(server); + await server.listen(); + const connected = await connectAgentViewPtyHostProcess( + createLaunch('session-token-handle'), + socketPath, + 'secret', + ); + const data: string[] = []; + + const disposable = connected.onData((chunk) => data.push(chunk)); + connected.write(Buffer.from('hello')); + await waitFor(() => host.input === 'hello'); + host.emitData('output'); + await waitFor(() => data.join('') === 'output'); + await expect(connected.getOutput?.()).resolves.toBe('output'); + connected.resize({ columns: 120, rows: 40 }); + await waitFor(() => host.resizes.length === 1); + connected.kill('SIGTERM'); + await waitFor(() => host.killedWith === 'SIGTERM'); + + disposable?.dispose(); + }); + + it('computes Unix and Windows host socket paths', () => { + expect( + getAgentViewPtyHostSocketPath('session-1', { + globalDir: '/tmp/qwen-agent-view-test', + platform: 'linux', + }), + ).toBe( + path.join( + '/tmp/qwen-agent-view-test', + 'jobs', + 'session-1', + 'tmp', + 'pty-host.sock', + ), + ); + expect( + getAgentViewPtyHostSocketPath('session-1', { + globalDir: 'C:\\Users\\test\\.qwen', + platform: 'win32', + }), + ).toMatch(/^\\\\\.\\pipe\\qwen-agent-pty-[a-f0-9]{12}$/); + + const fallbackPath = getAgentViewPtyHostSocketPath('session-1', { + globalDir: path.join(os.tmpdir(), 'qwen-agent-view-test'.repeat(10)), + platform: 'linux', + }); + const uid = + typeof process.getuid === 'function' ? process.getuid() : 'user'; + expect([ + path.join(os.tmpdir(), `qwen-avp-${uid}`), + path.join('/tmp', `qwen-avp-${uid}`), + ]).toContain(path.dirname(fallbackPath)); + expect(path.basename(fallbackPath)).toMatch(/^[a-f0-9]{12}\.sock$/); + expect(Buffer.byteLength(fallbackPath)).toBeLessThan(100); + }); + + it('returns a short fallback path when the temp directory is long', () => { + const fallbackPath = getAgentViewPtyHostSocketPath('session-1', { + globalDir: path.join('/very-long-path'.repeat(20), '.qwen'), + platform: 'linux', + }); + + expect(Buffer.byteLength(fallbackPath)).toBeLessThan(100); + }); + + it.skipIf(process.platform === 'win32')( + 'skips an unusable fallback socket directory', + async () => { + const tmpRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-pty-bad-tmp-'), + ); + const tmpFile = path.join(tmpRoot, 'tmp-file'); + const previousTmpDir = process.env['TMPDIR']; + await fs.writeFile(tmpFile, 'not a directory'); + process.env['TMPDIR'] = tmpFile; + try { + const fallbackPath = getAgentViewPtyHostSocketPath('session-1', { + globalDir: path.join('/very-long-path'.repeat(20), '.qwen'), + platform: 'linux', + }); + const uid = + typeof process.getuid === 'function' ? process.getuid() : 'user'; + + expect(path.dirname(fallbackPath)).toBe( + path.join('/tmp', `qwen-avp-${uid}`), + ); + } finally { + if (previousTmpDir === undefined) { + delete process.env['TMPDIR']; + } else { + process.env['TMPDIR'] = previousTmpDir; + } + await fs.rm(tmpRoot, { recursive: true, force: true }); + } + }, + ); + + it('rejects oversized PTY host responses', async () => { + const socketPath = shortSocketPath(); + const server = net.createServer((socket) => { + socket.on('error', () => {}); + socket.end(`${'x'.repeat(8 * 1024 * 1024 + 1)}\n`); + }); + await listenServer(server, socketPath); + try { + await expect( + connectAgentViewPtyHostProcess( + createLaunch('session-oversized-response'), + socketPath, + undefined, + { readyRetries: 3, requestTimeoutMs: 5000 }, + ), + ).rejects.toThrow('Agent View PTY host response line is too large.'); + } finally { + server.close(); + await removeTestSocket(socketPath); + } + }); + + it('fails fast on malformed PTY host responses', async () => { + const socketPath = shortSocketPath(); + const server = net.createServer((socket) => { + socket.on('error', () => {}); + socket.end('not-json\n'); + }); + await listenServer(server, socketPath); + try { + await expect( + connectAgentViewPtyHostProcess( + createLaunch('session-malformed-response'), + socketPath, + ), + ).rejects.toMatchObject({ + name: 'AgentViewPtyHostProtocolError', + }); + } finally { + server.close(); + await removeTestSocket(socketPath); + } + }); + + it('rejects promptly when the host closes without a response', async () => { + const socketPath = shortSocketPath(); + const server = net.createServer((socket) => { + socket.on('error', () => {}); + socket.end(); + }); + await listenServer(server, socketPath); + try { + await expect( + connectAgentViewPtyHostProcess( + createLaunch('session-closed-response'), + socketPath, + ), + ).rejects.toThrow(); + } finally { + server.close(); + await removeTestSocket(socketPath); + } + }); + + it('fails quickly when the spawned PTY host exits before ready', async () => { + const child = fakeChildProcess(2468); + const launched = launchAgentViewPtyHostProcess( + { + schemaVersion: 1, + sessionId: 'session-early-exit', + argv: ['qwen'], + env: {}, + entrypoint: 'qwen', + projectCwd: '/workspace/project', + activeCwd: '/workspace/project', + includeDirectories: [], + terminal: { columns: 80, rows: 24 }, + }, + { + globalDir: '/tmp/qwen-agent-view-test', + spawnProcess: () => child, + }, + ); + child.emit('exit', 1, null); + + await expect(launched).rejects.toThrow( + 'Agent View PTY host exited before ready (code 1).', + ); + expect(child.killedWith).toBe('SIGKILL'); + }); + + it('fails quickly when the spawned PTY host emits an error before ready', async () => { + const child = fakeChildProcess(2468); + const launched = launchAgentViewPtyHostProcess( + createLaunch('session-spawn-error'), + { + globalDir: '/tmp/qwen-agent-view-test', + spawnProcess: () => child, + }, + ); + child.emit('error', new Error('spawn failed')); + + await expect(launched).rejects.toThrow('spawn failed'); + expect(child.killedWith).toBe('SIGKILL'); + }); + + it('passes the launch file, socket path, and token to spawned PTY hosts', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-pty-spawn-'), + ); + const launch = createLaunch('session-spawn-contract'); + const socketPath = getAgentViewPtyHostSocketPath(launch.sessionId, { + globalDir, + }); + socketDirs.add(path.dirname(socketPath)); + const hostId = 'host-spawn-contract'; + const server = await createStatusServer(socketPath, [], hostId); + const child = fakeChildProcess(2468); + const spawnProcess = vi.fn(() => child); + try { + await launchAgentViewPtyHostProcess(launch, { + globalDir, + identity: { hostId, endpoint: socketPath, authToken: 'host-token' }, + spawnProcess, + }); + + expect(spawnProcess).toHaveBeenCalledWith( + [ + INTERNAL_AGENT_VIEW_PTY_HOST_ARG, + getAgentViewSessionPaths(launch.sessionId, { globalDir }).launchPath, + socketPath, + ], + expect.objectContaining({ + [PTY_HOST_AUTH_TOKEN_ENV]: 'host-token', + [PTY_HOST_ID_ENV]: hostId, + }), + expect.stringContaining('host-stderr.log'), + ); + } finally { + server.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + } + }); + + it('asks a spawned host to shut down when the handle is disposed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-pty-launch-'), + ); + const launch = createLaunch('session-dispose-child'); + const socketPath = getAgentViewPtyHostSocketPath(launch.sessionId, { + globalDir, + }); + socketDirs.add(path.dirname(socketPath)); + const operations: string[] = []; + const hostId = 'host-dispose-child'; + const server = await createStatusServer(socketPath, operations, hostId); + const child = fakeChildProcess(2468); + try { + const handle = await launchAgentViewPtyHostProcess(launch, { + globalDir, + identity: { hostId, endpoint: socketPath, authToken: 'host-token' }, + spawnProcess: () => child, + }); + + handle.dispose(); + + await waitFor(() => operations.includes('shutdown')); + expect(child.killedWith).toBeUndefined(); + await expect(handle.exited).resolves.toEqual({ + kind: 'unreachable', + }); + } finally { + server.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + } + }); + + it('asks a spawned host to deliver kill signals before falling back to the child', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-pty-kill-'), + ); + const launch = createLaunch('session-kill-child'); + const socketPath = getAgentViewPtyHostSocketPath(launch.sessionId, { + globalDir, + }); + socketDirs.add(path.dirname(socketPath)); + const operations: string[] = []; + const hostId = 'host-kill-child'; + const server = await createStatusServer(socketPath, operations, hostId); + const child = fakeChildProcess(2468); + try { + const handle = await launchAgentViewPtyHostProcess(launch, { + globalDir, + identity: { hostId, endpoint: socketPath, authToken: 'host-token' }, + spawnProcess: () => child, + }); + + handle.kill('SIGTERM'); + + await waitFor(() => operations.includes('kill')); + expect(child.killedWith).toBeUndefined(); + } finally { + server.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + } + }); + + it('reports child exit signals when they are known', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-pty-signal-'), + ); + const launch = createLaunch('session-child-signal'); + const socketPath = getAgentViewPtyHostSocketPath(launch.sessionId, { + globalDir, + }); + socketDirs.add(path.dirname(socketPath)); + const hostId = 'host-child-signal'; + const server = await createStatusServer(socketPath, [], hostId); + const child = fakeChildProcess(2468); + try { + const handle = await launchAgentViewPtyHostProcess(launch, { + globalDir, + identity: { hostId, endpoint: socketPath, authToken: 'host-token' }, + spawnProcess: () => child, + }); + + child.emit('exit', null, 'SIGKILL'); + + await expect(handle.exited).resolves.toEqual({ + kind: 'exited', + exitCode: 1, + signal: os.constants.signals.SIGKILL, + }); + } finally { + server.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + } + }); +}); + +type FakeChildProcess = ChildProcess & { killedWith?: NodeJS.Signals }; + +function fakeChildProcess(pid: number): FakeChildProcess { + const child = new EventEmitter() as ChildProcess; + Object.defineProperty(child, 'pid', { value: pid }); + child.unref = () => child; + child.kill = ((signal?: NodeJS.Signals | number) => { + if (typeof signal === 'string') { + (child as FakeChildProcess).killedWith = signal; + } + return true; + }) as ChildProcess['kill']; + return child as FakeChildProcess; +} + +function fakeHost(maxOutputBytes = 5): AgentViewPtyHostHandle & { + input: string; + resizes: Array<{ columns: number; rows: number }>; + killedWith?: string; + shutdowns: number; + emitData(data: string): void; +} { + let dataCallbacks: Array<(data: string) => void> = []; + const host: AgentViewPtyHostHandle & { + input: string; + resizes: Array<{ columns: number; rows: number }>; + killedWith?: string; + shutdowns: number; + emitData(data: string): void; + } = { + pid: process.pid, + workerPid: 1234, + command: ['fake'], + output: new BoundedOutputRing(maxOutputBytes), + input: '', + resizes: [], + shutdowns: 0, + exited: new Promise(() => {}), + write(data: Buffer) { + host.input += data.toString('utf8'); + }, + onData(callback: (data: string) => void) { + dataCallbacks.push(callback); + return { + dispose() { + dataCallbacks = dataCallbacks.filter((item) => item !== callback); + }, + }; + }, + resize(size: { columns: number; rows: number }) { + host.resizes.push(size); + }, + kill(signal?: string) { + host.killedWith = signal; + }, + shutdown() { + host.shutdowns += 1; + }, + dispose() {}, + emitData(data: string) { + host.output.append(data); + for (const callback of dataCallbacks) { + callback(data); + } + }, + }; + return host; +} + +function shortSocketPath(): string { + const unique = `qah-${process.pid}-${Date.now()}-${Math.random() + .toString(16) + .slice(2)}`; + if (process.platform === 'win32') { + return `\\\\.\\pipe\\${unique}`; + } + const socketDir = path.join('/tmp', unique); + socketDirs.add(socketDir); + return path.join(socketDir, 'pty.sock'); +} + +function createLaunch( + sessionId: string, +): Parameters[0] { + return { + schemaVersion: 1, + sessionId, + argv: ['qwen'], + env: {}, + entrypoint: 'qwen', + projectCwd: '/workspace/project', + activeCwd: '/workspace/project', + includeDirectories: [], + terminal: { columns: 80, rows: 24 }, + }; +} + +async function requestHost( + socketPath: string, + op: string, + params?: Record, + authToken?: string, +): Promise { + const socket = net.createConnection(socketPath); + socket.write(`${JSON.stringify({ id: '1', op, params, authToken })}\n`); + const response = await readLine(socket); + socket.end(); + if (response['ok'] !== true) { + const error = response['error']; + const message = + isRecord(error) && typeof error['message'] === 'string' + ? error['message'] + : 'Agent View PTY host request failed.'; + throw new Error(message); + } + return response['result']; +} + +async function listenServer( + server: net.Server, + socketPath: string, +): Promise { + if (!isWindowsPipePath(socketPath)) { + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + } + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }); +} + +async function createStatusServer( + socketPath: string, + operations: string[] = [], + hostId?: string, +): Promise { + if (!isWindowsPipePath(socketPath)) { + await fs.mkdir(path.dirname(socketPath), { recursive: true }); + } + const server = net.createServer((socket) => { + socket.setEncoding('utf8'); + let buffer = ''; + socket.on('data', (chunk) => { + buffer += chunk; + const newline = buffer.indexOf('\n'); + if (newline === -1) return; + const request = JSON.parse(buffer.slice(0, newline)) as { + id: string; + op: string; + params?: Record; + }; + operations.push(request.op); + socket.end( + `${JSON.stringify({ + id: request.id, + ok: true, + result: + request.op === 'status' + ? { + ...(hostId ? { hostId } : {}), + pid: process.pid, + workerPid: 1234, + } + : request.op === 'kill' + ? { killed: true } + : { shuttingDown: true }, + })}\n`, + ); + }); + }); + await listenServer(server, socketPath); + return server; +} + +async function waitForClose(socket: net.Socket): Promise { + if (socket.destroyed) return; + await new Promise((resolve) => { + socket.once('close', () => resolve()); + socket.once('error', () => resolve()); + }); +} + +async function connectOnce(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + socket.once('connect', () => { + socket.destroy(); + resolve(true); + }); + socket.once('error', () => resolve(false)); + }); +} + +async function readLine(socket: net.Socket): Promise> { + const line = await new Promise((resolve, reject) => { + let buffer = ''; + const onData = (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const newline = buffer.indexOf('\n'); + if (newline === -1) return; + cleanup(); + resolve(buffer.slice(0, newline)); + }; + const onError = (error: Error) => { + cleanup(); + reject(error); + }; + const cleanup = () => { + socket.off('data', onData); + socket.off('error', onError); + }; + socket.on('data', onData); + socket.once('error', onError); + }); + return JSON.parse(line) as Record; +} + +async function readChunk(socket: net.Socket): Promise { + return new Promise((resolve, reject) => { + socket.once('data', (chunk) => resolve(chunk.toString('utf8'))); + socket.once('error', reject); + }); +} + +async function waitFor(assertion: () => boolean): Promise { + for (let index = 0; index < 20; index++) { + if (assertion()) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('Timed out waiting for condition.'); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isWindowsPipePath(socketPath: string): boolean { + return socketPath.startsWith('\\\\.\\pipe\\'); +} + +async function removeTestSocket(socketPath: string): Promise { + if (isWindowsPipePath(socketPath)) return; + await fs.rm(path.dirname(socketPath), { recursive: true, force: true }); +} diff --git a/packages/cli/src/agent-view/pty-host-process.ts b/packages/cli/src/agent-view/pty-host-process.ts new file mode 100644 index 00000000000..56c6d15dcd9 --- /dev/null +++ b/packages/cli/src/agent-view/pty-host-process.ts @@ -0,0 +1,1394 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn, type ChildProcess } from 'node:child_process'; +import { + accessSync, + closeSync, + constants as fsConstants, + lstatSync, + openSync, + statSync, +} from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; +import type { AgentViewLaunchFile } from './protocol.js'; +import { PTY_HOST_AUTH_TOKEN_ENV, PTY_HOST_ID_ENV } from './pty-host-env.js'; +import { + BoundedOutputRing, + launchAgentViewPtyHost, + type AgentViewPtyDisposable, + type AgentViewPtyHostExit, + type AgentViewPtyHostHandle, + type AgentViewPtyImplementation, +} from './pty-host.js'; +import { getAgentViewSessionPaths } from './supervisor-store.js'; +import { bridgeAgentViewTerminal } from './terminal-bridge.js'; +import { buildCurrentQwenCliArgv } from './current-cli-argv.js'; + +export const INTERNAL_AGENT_VIEW_PTY_HOST_ARG = + '--internal-agent-view-pty-host'; + +// Wall budget ≈ 15 s once per-probe request timeouts are counted. +const HOST_READY_RETRIES = 50; +const CONNECT_HOST_READY_RETRIES = 10; +const HOST_READY_DELAY_MS = 50; +const HOST_READY_REQUEST_TIMEOUT_MS = 250; +const REMOTE_HOST_EXIT_POLL_MS = 5000; +const SHUTDOWN_GRACE_MS = 2_000; +const UNIX_SOCKET_PATH_LIMIT = 100; +const MAX_PTY_HOST_REQUEST_LINE_BYTES = 1024 * 1024; +// JSON escaping inflates control bytes up to 6x, so a full 1 MiB +// retained ring can serialize to ~6 MiB; keep the wire cap above that. +const MAX_PTY_HOST_RESPONSE_LINE_BYTES = 8 * 1024 * 1024; +const ALLOWED_KILL_SIGNALS = new Set([ + 'SIGINT', + 'SIGKILL', + 'SIGTERM', +]); + +type AgentViewPtyHostOperation = + | 'status' + | 'logs' + | 'resize' + | 'kill' + | 'shutdown' + | 'attachStream'; + +const HOST_OPERATIONS = [ + 'status', + 'logs', + 'resize', + 'kill', + 'shutdown', + 'attachStream', +] as const satisfies readonly AgentViewPtyHostOperation[]; + +type AgentViewPtyHostResponse = + | { id: string; ok: true; result: unknown } + | { id: string; ok: false; error: { code: string; message: string } }; + +interface AgentViewPtyHostRequest { + id: string; + op: AgentViewPtyHostOperation; + authToken?: string; + params?: Record; +} + +export interface AgentViewPtyHostProcessOptions { + globalDir?: string; + identity?: AgentViewPtyHostIdentity; + spawnProcess?: ( + args: readonly string[], + env: Readonly>, + stderrLogPath?: string, + ) => ChildProcess; +} + +export interface RunAgentViewPtyHostProcessOptions { + launchPath: string; + socketPath: string; + authToken?: string; + hostId?: string; + loadPty?: () => Promise; +} + +export interface AgentViewPtyHostIdentity { + hostId: string; + endpoint: string; + authToken: string; +} + +export function createAgentViewPtyHostIdentity( + sessionId: string, + options: { globalDir?: string } = {}, +): AgentViewPtyHostIdentity { + return { + hostId: randomUUID(), + endpoint: getAgentViewPtyHostSocketPath(sessionId, options), + authToken: randomUUID(), + }; +} + +export async function launchAgentViewPtyHostProcess( + launch: AgentViewLaunchFile, + options: AgentViewPtyHostProcessOptions = {}, +): Promise { + const identity = + options.identity ?? + createAgentViewPtyHostIdentity(launch.sessionId, options); + const socketPath = identity.endpoint; + const authToken = identity.authToken; + const launchPath = getAgentViewSessionPaths(launch.sessionId, { + ...(options.globalDir ? { globalDir: options.globalDir } : {}), + }).launchPath; + const stderrLogPath = `${launchPath}.host-stderr.log`; + const child = (options.spawnProcess ?? defaultSpawnPtyHost)( + [INTERNAL_AGENT_VIEW_PTY_HOST_ARG, launchPath, socketPath], + { + [PTY_HOST_AUTH_TOKEN_ENV]: authToken, + [PTY_HOST_ID_ENV]: identity.hostId, + }, + stderrLogPath, + ); + child.unref?.(); + + let status: { pid: number; workerPid: number }; + try { + status = await waitForSpawnedPtyHost( + socketPath, + child, + authToken, + identity.hostId, + ); + } catch (error) { + child.kill?.('SIGKILL'); + throw await withHostStderrTail(error, stderrLogPath); + } + return createRemotePtyHostHandle({ + hostId: identity.hostId, + socketPath, + launch, + authToken, + pid: child.pid ?? status.pid, + workerPid: status.workerPid, + child, + }); +} + +export interface AgentViewPtyHostConnectOptions { + readyRetries?: number; + requestTimeoutMs?: number; + expectedHostId?: string; +} + +export async function connectAgentViewPtyHostProcess( + launch: AgentViewLaunchFile, + socketPath: string, + authToken?: string, + options: AgentViewPtyHostConnectOptions = {}, +): Promise { + const status = await waitForPtyHost( + socketPath, + options.readyRetries ?? CONNECT_HOST_READY_RETRIES, + authToken, + { + requestTimeoutMs: + options.requestTimeoutMs ?? HOST_READY_REQUEST_TIMEOUT_MS, + expectedHostId: options.expectedHostId, + }, + ); + return createRemotePtyHostHandle({ + hostId: status.hostId, + socketPath, + launch, + authToken, + pid: status.pid, + workerPid: status.workerPid, + }); +} + +function createRemotePtyHostHandle({ + hostId, + socketPath, + launch, + authToken, + pid, + workerPid, + child, +}: { + hostId?: string; + socketPath: string; + launch: AgentViewLaunchFile; + authToken?: string; + pid: number; + workerPid: number; + child?: ChildProcess; +}): AgentViewPtyHostHandle { + const output = new BoundedOutputRing(); + let attachSocket: net.Socket | undefined; + const exitTracker = child + ? createChildExitTracker(child) + : createRemoteExitTracker(socketPath, authToken); + + return { + ...(hostId ? { hostId } : {}), + pid, + workerPid, + command: launch.argv, + endpoint: socketPath, + ...(authToken ? { authToken } : {}), + output, + exited: exitTracker.exited, + async getOutput(): Promise { + const result = await callAgentViewPtyHost(socketPath, authToken, 'logs'); + if (isRecord(result) && typeof result['output'] === 'string') { + return result['output']; + } + return ''; + }, + write(data: Buffer): void { + if (!attachSocket) { + throw new Error( + 'Agent View PTY host input requires an active attach stream.', + ); + } + attachSocket.write(data); + }, + onData(callback: (data: string) => void): AgentViewPtyDisposable { + attachSocket?.destroy(); + const socket = net.createConnection(socketPath); + attachSocket = socket; + socket.setEncoding('utf8'); + socket.write( + `${JSON.stringify({ + id: createRequestId(), + op: 'attachStream', + ...(authToken ? { authToken } : {}), + })}\n`, + ); + let attached = false; + let buffer = ''; + const onData = (textChunk: string) => { + if (attached) { + output.append(textChunk); + callback(textChunk); + return; + } + buffer += textChunk; + if ( + Buffer.byteLength(buffer, 'utf8') > MAX_PTY_HOST_RESPONSE_LINE_BYTES + ) { + socket.destroy( + new AgentViewPtyHostProtocolError( + 'Agent View PTY host response line is too large.', + ), + ); + return; + } + const newline = buffer.indexOf('\n'); + if (newline === -1) return; + let response: AgentViewPtyHostResponse; + try { + response = parseHostResponse(buffer.slice(0, newline)); + } catch (error) { + socket.destroy( + error instanceof Error ? error : new Error(String(error)), + ); + return; + } + if (!response.ok) { + socket.destroy(new Error(response.error.message)); + return; + } + attached = true; + const leftover = buffer.slice(newline + 1); + buffer = ''; + if (leftover) { + output.append(leftover); + callback(leftover); + } + }; + socket.on('data', onData); + socket.once('error', () => { + if (attachSocket === socket) { + attachSocket = undefined; + } + }); + socket.once('close', () => { + if (attachSocket === socket) { + attachSocket = undefined; + } + }); + return { + dispose() { + socket.off('data', onData); + socket.destroy(); + }, + }; + }, + resize(size): void { + void callAgentViewPtyHost(socketPath, authToken, 'resize', { + columns: size.columns, + rows: size.rows, + }).catch(() => {}); + }, + kill(signal?: string): void { + const allowedSignal = killSignalValue(signal); + void callAgentViewPtyHost(socketPath, authToken, 'kill', { + signal: allowedSignal, + }).then( + () => { + // Only SIGKILL cannot be trapped. Remember that the authenticated + // RPC landed, but keep polling until the endpoint disappears so a + // replacement cannot race the old host's socket teardown. + if (!child && allowedSignal === 'SIGKILL') { + exitTracker.markKillConfirmed?.(); + } + }, + () => { + child?.kill(allowedSignal); + }, + ); + }, + shutdown(): void { + void callAgentViewPtyHost(socketPath, authToken, 'shutdown').then( + () => { + if (!child) { + exitTracker.markShutdownConfirmed?.(); + } + }, + () => { + child?.kill('SIGTERM'); + }, + ); + attachSocket?.destroy(); + }, + dispose(): void { + void callAgentViewPtyHost(socketPath, authToken, 'shutdown').catch(() => { + child?.kill('SIGTERM'); + }); + attachSocket?.destroy(); + exitTracker.resolve({ kind: 'unreachable' }); + }, + }; +} + +function createChildExitTracker(child: ChildProcess): { + exited: Promise; + resolve(exit: AgentViewPtyHostExit): void; + markKillConfirmed?: () => void; + markShutdownConfirmed?: () => void; +} { + let resolveExit: (exit: AgentViewPtyHostExit) => void = () => {}; + const exited = new Promise((resolve) => { + resolveExit = resolve; + child.once('exit', (code, signal) => { + const signalNumber = signal ? os.constants.signals[signal] : undefined; + resolve({ + kind: 'exited', + exitCode: typeof code === 'number' ? code : 1, + ...(signalNumber ? { signal: signalNumber } : {}), + }); + }); + }); + return { exited, resolve: resolveExit }; +} + +function createRemoteExitTracker( + socketPath: string, + authToken: string | undefined, +): { + exited: Promise; + resolve(exit: AgentViewPtyHostExit): void; + markKillConfirmed(): void; + markShutdownConfirmed(): void; +} { + let settled = false; + let pollInFlight = false; + let resolveExit: (exit: AgentViewPtyHostExit) => void = () => {}; + const exited = new Promise((resolve) => { + resolveExit = resolve; + }); + let consecutiveFailures = 0; + let confirmedTermination: 'kill' | 'shutdown' | undefined; + let confirmedTerminationPoll: NodeJS.Timeout | undefined; + const scheduleConfirmedPoll = () => { + if (settled || !confirmedTermination) return; + clearTimeout(confirmedTerminationPoll); + confirmedTerminationPoll = setTimeout(poll, 50); + confirmedTerminationPoll.unref?.(); + }; + const poll = () => { + if (settled || pollInFlight) return; + pollInFlight = true; + void callAgentViewPtyHost(socketPath, authToken, 'status') + .then(() => { + if (settled) return; + consecutiveFailures = 0; + }) + .catch(() => { + if (settled) return; + if (++consecutiveFailures >= 2) { + resolveExitOnce( + confirmedTermination === 'kill' + ? { kind: 'confirmed-kill' } + : confirmedTermination === 'shutdown' + ? { kind: 'confirmed-shutdown' } + : { kind: 'unreachable' }, + ); + } + }) + .finally(() => { + pollInFlight = false; + scheduleConfirmedPoll(); + }); + }; + const interval = setInterval(poll, REMOTE_HOST_EXIT_POLL_MS); + interval.unref?.(); + + const resolveExitOnce = (exit: AgentViewPtyHostExit) => { + if (settled) return; + settled = true; + clearInterval(interval); + clearTimeout(confirmedTerminationPoll); + resolveExit(exit); + }; + return { + exited, + resolve: resolveExitOnce, + markKillConfirmed: () => { + if (confirmedTermination) return; + confirmedTermination = 'kill'; + poll(); + }, + markShutdownConfirmed: () => { + if (confirmedTermination) return; + confirmedTermination = 'shutdown'; + poll(); + }, + }; +} + +export async function runAgentViewPtyHostProcess({ + launchPath, + socketPath, + authToken, + hostId, + loadPty, +}: RunAgentViewPtyHostProcessOptions): Promise { + const launch = JSON.parse(await fs.readFile(launchPath, 'utf8')) as unknown; + const host = await launchAgentViewPtyHost(launch, { + ...(loadPty ? { loadPty } : {}), + }); + const server = createAgentViewPtyHostServer(host, socketPath, { + authToken: authToken ?? process.env[PTY_HOST_AUTH_TOKEN_ENV], + hostId: hostId ?? process.env[PTY_HOST_ID_ENV], + }); + try { + await server.listen(); + } catch (error) { + host.dispose(); + throw error; + } + const exit = await host.exited.finally(async () => { + host.dispose(); + await server.close(); + }); + return exit; +} + +export function getAgentViewPtyHostSocketPath( + sessionId: string, + options: { globalDir?: string; platform?: NodeJS.Platform } = {}, +): string { + const platform = options.platform ?? process.platform; + const digest = shortHash(`${options.globalDir ?? ''}:${sessionId}:pty-host`); + if (platform === 'win32') { + return `\\\\.\\pipe\\qwen-agent-pty-${digest}`; + } + + const tmpDir = getAgentViewSessionPaths(sessionId, { + ...(options.globalDir ? { globalDir: options.globalDir } : {}), + }).tmpDir; + const candidate = path.join(tmpDir, 'pty-host.sock'); + if (Buffer.byteLength(candidate) < UNIX_SOCKET_PATH_LIMIT) { + return candidate; + } + const uid = typeof process.getuid === 'function' ? process.getuid() : 'user'; + const fallbackCandidates = [ + path.join(os.tmpdir(), `qwen-avp-${uid}`, `${digest}.sock`), + path.join('/tmp', `qwen-avp-${uid}`, `${digest}.sock`), + ]; + const fallback = fallbackCandidates.find( + (item) => + Buffer.byteLength(item) < UNIX_SOCKET_PATH_LIMIT && + canPrepareSocketDirectory(path.dirname(item)), + ); + const lengthFallback = fallbackCandidates.find( + (item) => Buffer.byteLength(item) < UNIX_SOCKET_PATH_LIMIT, + ); + if (fallback) { + return fallback; + } + if (lengthFallback) { + // Both fallback directories failed the availability check; fail fast + // instead of spawning a host that can never bind its socket. + throw new Error( + 'Agent View PTY host socket fallback directories are not writable.', + ); + } + throw new Error('Agent View PTY host socket path is too long.'); +} + +async function callAgentViewPtyHost( + socketPath: string, + authToken: string | undefined, + op: AgentViewPtyHostOperation, + params?: Record, + timeoutMs?: number, +): Promise { + const response = await requestAgentViewPtyHost( + socketPath, + { + id: createRequestId(), + op, + ...(authToken ? { authToken } : {}), + ...(params ? { params } : {}), + }, + timeoutMs ? { timeoutMs } : {}, + ); + if (response.ok) return response.result; + throw new AgentViewPtyHostRequestError( + response.error.code, + response.error.message, + ); +} + +class AgentViewPtyHostRequestError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = 'AgentViewPtyHostRequestError'; + } +} + +class AgentViewPtyHostProtocolError extends Error { + constructor(message: string) { + super(message); + this.name = 'AgentViewPtyHostProtocolError'; + } +} + +async function requestAgentViewPtyHost( + socketPath: string, + request: AgentViewPtyHostRequest, + options: { timeoutMs?: number } = {}, +): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection(socketPath); + let buffer = ''; + let settled = false; + const timeout = setTimeout(() => { + finish( + undefined, + new Error('Timed out waiting for Agent View PTY host.'), + ); + socket.destroy(); + }, options.timeoutMs ?? 5000); + const finish = ( + response: AgentViewPtyHostResponse | undefined, + error?: Error, + ) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.removeAllListeners(); + if (error) { + reject(error); + } else { + resolve(response as AgentViewPtyHostResponse); + } + }; + socket.setEncoding('utf8'); + socket.on('connect', () => { + socket.write(`${JSON.stringify(request)}\n`); + }); + socket.on('data', (chunk) => { + buffer += chunk; + if ( + Buffer.byteLength(buffer, 'utf8') > MAX_PTY_HOST_RESPONSE_LINE_BYTES + ) { + finish( + undefined, + new AgentViewPtyHostProtocolError( + 'Agent View PTY host response line is too large.', + ), + ); + socket.destroy(); + return; + } + const newline = buffer.indexOf('\n'); + if (newline === -1) return; + try { + finish(parseHostResponse(buffer.slice(0, newline))); + } catch (error) { + finish(undefined, error as Error); + } finally { + socket.end(); + } + }); + socket.on('error', (error) => finish(undefined, error)); + socket.on('close', () => { + finish(undefined, new Error('Agent View PTY host connection closed.')); + }); + }); +} + +export function createAgentViewPtyHostServer( + host: AgentViewPtyHostHandle, + socketPath: string, + options: { + authToken?: string; + hostId?: string; + shutdownGraceMs?: number; + } = {}, +): { listen(): Promise; close(): Promise } { + const attachState: { + activeAttachSocket: net.Socket | undefined; + } = { + activeAttachSocket: undefined, + }; + const openSockets = new Set(); + const server = net.createServer((socket) => { + openSockets.add(socket); + socket.once('close', () => { + openSockets.delete(socket); + }); + socket.on('error', () => {}); + socket.setTimeout(5000, () => { + socket.destroy(); + }); + // Byte-transparent framing: only the request line is decoded, so + // coalesced keystrokes after an attach request reach the worker verbatim. + let buffer = Buffer.alloc(0); + socket.on('data', (chunk: Buffer) => { + buffer = buffer.length === 0 ? chunk : Buffer.concat([buffer, chunk]); + if (buffer.length > MAX_PTY_HOST_REQUEST_LINE_BYTES) { + socket.destroy(); + return; + } + const newline = buffer.indexOf(0x0a); + if (newline === -1) return; + const line = buffer.toString('utf8', 0, newline); + const leftover = Buffer.from(buffer.subarray(newline + 1)); + buffer = Buffer.alloc(0); + socket.pause(); + void respondToHostLine( + host, + line, + socket, + attachState, + leftover, + options.authToken, + options.hostId, + options.shutdownGraceMs, + ).catch(() => { + socket.destroy(); + }); + }); + }); + + let releaseLock: (() => Promise) | undefined; + return { + async listen() { + if (!isWindowsPipePath(socketPath)) { + releaseLock = await acquireSocketPathLock(socketPath); + } + try { + await prepareSocketPath(socketPath); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + server.on('error', () => {}); + if (isWindowsPipePath(socketPath)) { + resolve(); + return; + } + fs.chmod(socketPath, 0o600).then(resolve, (error) => { + server.close(() => reject(error)); + }); + }); + }); + if (!isWindowsPipePath(socketPath)) { + // The reclaim path is racy: two processes can both delete a stale + // lock and proceed, and a rival can reclaim this process's + // lockfile between its O_EXCL create and the pid write. Re-verify + // lock ownership now and fail closed when displaced, so at most one + // host serves the socket. + const lockContent = await fs + .readFile(`${socketPath}.lock`, 'utf8') + .catch(() => ''); + if (lockContent !== String(process.pid)) { + releaseLock = undefined; // the lock no longer belongs to us + for (const socket of openSockets) { + socket.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + const displaced = new Error( + `Agent View PTY host socket is already in use: ${socketPath}`, + ) as NodeJS.ErrnoException; + displaced.code = 'EADDRINUSE'; + throw displaced; + } + } + } catch (error) { + await releaseLock?.(); + releaseLock = undefined; + throw error; + } + }, + async close() { + attachState.activeAttachSocket?.destroy(); + for (const socket of openSockets) { + socket.destroy(); + } + await new Promise((resolve, reject) => { + if (!server.listening) { + resolve(); + return; + } + server.close((error) => (error ? reject(error) : resolve())); + }); + await removeOwnedSocketPath(socketPath); + await releaseLock?.(); + releaseLock = undefined; + }, + }; +} + +async function respondToHostLine( + host: AgentViewPtyHostHandle, + line: string, + socket: net.Socket, + attachState: { + activeAttachSocket: net.Socket | undefined; + }, + leftover: Buffer = Buffer.alloc(0), + authToken?: string, + hostId?: string, + shutdownGraceMs?: number, +): Promise { + const request = parseHostRequest(line); + if (!request) { + socket.end( + `${JSON.stringify(errorResponse('', 'invalid_json', 'Invalid JSON.'))}\n`, + ); + return; + } + if (authToken && !isValidAuthToken(request.authToken, authToken)) { + socket.end( + `${JSON.stringify( + errorResponse( + request.id, + 'unauthorized', + 'Unauthorized PTY host request.', + ), + )}\n`, + ); + return; + } + + if (request.op === 'attachStream') { + if (attachState.activeAttachSocket?.destroyed === false) { + socket.end( + `${JSON.stringify( + errorResponse( + request.id, + 'already_attached', + 'Agent View PTY host already has an attached stream.', + ), + )}\n`, + ); + return; + } + + attachState.activeAttachSocket = socket; + host.resetInput?.(); + const clearActiveAttach = () => { + if (attachState.activeAttachSocket === socket) { + attachState.activeAttachSocket = undefined; + } + }; + socket.once('close', clearActiveAttach); + socket.removeAllListeners('data'); + socket.setTimeout(0); + socket.resume(); + try { + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { attached: true }, + })}\n`, + ); + if (leftover.length > 0) { + // Forward keystrokes that were coalesced with the attach request. + host.write(leftover); + } + await bridgeAgentViewTerminal({ + stdin: socket, + stdout: socket, + pty: host, + }); + } finally { + socket.off('close', clearActiveAttach); + clearActiveAttach(); + socket.end(); + } + return; + } + + try { + const result = await handleHostRequest( + host, + request, + hostId, + shutdownGraceMs, + ); + socket.end(`${JSON.stringify({ id: request.id, ok: true, result })}\n`); + } catch (error) { + socket.end( + `${JSON.stringify( + errorResponse( + request.id, + 'internal_error', + error instanceof Error ? error.message : 'PTY host request failed.', + ), + )}\n`, + ); + } +} + +async function handleHostRequest( + host: AgentViewPtyHostHandle, + request: AgentViewPtyHostRequest, + hostId?: string, + shutdownGraceMs?: number, +): Promise { + switch (request.op) { + case 'status': + return { + ...(hostId ? { hostId } : {}), + pid: process.pid, + workerPid: host.workerPid, + }; + case 'logs': + return { output: host.output.toString() }; + case 'resize': + host.resize({ + columns: positiveIntegerParam(request.params, 'columns'), + rows: positiveIntegerParam(request.params, 'rows'), + }); + return { resized: true }; + case 'kill': + // Default to SIGTERM, not node-pty's POSIX fallback SIGHUP: SIGHUP is + // outside ALLOWED_KILL_SIGNALS and is commonly ignored (nohup-style + // workers), so kill and shutdown would disagree on whether the worker + // dies. + host.kill(signalParam(request.params) ?? 'SIGTERM'); + return { killed: true }; + case 'shutdown': + await shutdownHost(host, shutdownGraceMs); + return { shuttingDown: true }; + case 'attachStream': + throw new Error('attachStream must use the streaming path.'); + default: { + const unknownOperation: never = request.op; + throw new Error(`Unsupported PTY host operation: ${unknownOperation}`); + } + } +} + +async function shutdownHost( + host: AgentViewPtyHostHandle, + graceMs: number = SHUTDOWN_GRACE_MS, +): Promise { + if (host.shutdown) { + await host.shutdown(); + } else { + host.kill('SIGTERM'); + } + // A TERM-resistant worker (e.g. `trap '' TERM`) would otherwise keep + // host.exited pending forever: the host process would never exit and its + // socket lock would block every future launch of the session. + const timer = setTimeout(() => { + try { + host.kill('SIGKILL'); + } catch { + // The worker exited between the grace deadline and this kill. + } + }, graceMs); + timer.unref?.(); + const cancel = () => clearTimeout(timer); + void host.exited.then(cancel, cancel); +} + +async function waitForSpawnedPtyHost( + socketPath: string, + child: ChildProcess, + authToken: string, + hostId: string, +): Promise<{ hostId?: string; pid: number; workerPid: number }> { + return new Promise((resolve, reject) => { + let settled = false; + const abortController = new AbortController(); + const cleanup = () => { + abortController.abort(); + child.off('exit', onExit); + child.off('error', onError); + }; + const finishResolve = (value: { pid: number; workerPid: number }) => { + if (settled) return; + settled = true; + cleanup(); + resolve(value); + }; + const finishReject = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + const suffix = signal ? `signal ${signal}` : `code ${code ?? 'unknown'}`; + finishReject( + new Error(`Agent View PTY host exited before ready (${suffix}).`), + ); + }; + const onError = (error: Error) => { + finishReject(error); + }; + child.once('exit', onExit); + child.once('error', onError); + void waitForPtyHost(socketPath, HOST_READY_RETRIES, authToken, { + requestTimeoutMs: HOST_READY_REQUEST_TIMEOUT_MS, + expectedHostId: hostId, + signal: abortController.signal, + }).then( + (status) => finishResolve(status), + (error) => { + if (abortController.signal.aborted && settled) return; + finishReject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} + +async function waitForPtyHost( + socketPath: string, + retries = HOST_READY_RETRIES, + authToken?: string, + options: { + requestTimeoutMs?: number; + signal?: AbortSignal; + expectedHostId?: string; + } = {}, +): Promise<{ hostId?: string; pid: number; workerPid: number }> { + const requestTimeoutMs = options.requestTimeoutMs ?? 5000; + // Model the deadline as a wall-clock budget covering each probe's delay + // and request timeout, so slow probes cannot silently exhaust retries. + const deadlineMs = + Date.now() + retries * (HOST_READY_DELAY_MS + requestTimeoutMs); + for (let attempt = 0; attempt < retries; attempt++) { + if (options.signal?.aborted || Date.now() >= deadlineMs) break; + try { + const result = await callAgentViewPtyHost( + socketPath, + authToken, + 'status', + undefined, + requestTimeoutMs, + ); + if (isRecord(result) && Number.isInteger(result['workerPid'])) { + const hostId = + typeof result['hostId'] === 'string' ? result['hostId'] : undefined; + if (options.expectedHostId && hostId !== options.expectedHostId) { + throw new AgentViewPtyHostProtocolError( + 'Agent View PTY host identity does not match.', + ); + } + return { + ...(hostId ? { hostId } : {}), + pid: Number.isInteger(result['pid']) + ? Number(result['pid']) + : process.pid, + workerPid: Number(result['workerPid']), + }; + } + } catch (error) { + if ( + error instanceof AgentViewPtyHostProtocolError || + (error instanceof AgentViewPtyHostRequestError && + error.code === 'unauthorized') + ) { + throw error; + } + // Retry until the host socket is ready. + } + await delay(HOST_READY_DELAY_MS, options.signal); + } + throw new Error('Agent View PTY host did not become ready.'); +} + +function defaultSpawnPtyHost( + args: readonly string[], + env: Readonly>, + stderrLogPath?: string, +): ChildProcess { + const argv = buildCurrentQwenCliArgv(args); + // Route stderr to a per-session file so fail-closed startup errors stay + // observable; a pipe would tie the detached host's lifetime to ours. + let stderrFd: number | undefined; + if (stderrLogPath) { + try { + stderrFd = openSync(stderrLogPath, 'w', 0o600); + } catch { + // Fall back to discarding stderr. + } + } + try { + return spawn(argv[0]!, argv.slice(1), { + detached: true, + windowsHide: true, + stdio: ['ignore', 'ignore', stderrFd ?? 'ignore'], + env: { + ...process.env, + ...env, + QWEN_CODE_NO_RELAUNCH: '1', + }, + }); + } finally { + if (stderrFd !== undefined) closeSync(stderrFd); + } +} + +async function withHostStderrTail( + error: unknown, + stderrLogPath: string, +): Promise { + const base = error instanceof Error ? error : new Error(String(error)); + const tail = await fs + .readFile(stderrLogPath, 'utf8') + .then((text) => text.trim().slice(-2048)) + .catch(() => ''); + if (!tail) return base; + return new Error(`${base.message} Host stderr: ${tail}`, { cause: base }); +} + +function parseHostRequest(line: string): AgentViewPtyHostRequest | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if ( + !isRecord(parsed) || + typeof parsed['id'] !== 'string' || + !isHostOperation(parsed['op']) + ) { + return undefined; + } + return { + id: parsed['id'], + op: parsed['op'], + ...(typeof parsed['authToken'] === 'string' + ? { authToken: parsed['authToken'] } + : {}), + ...(isRecord(parsed['params']) ? { params: parsed['params'] } : {}), + }; + } catch { + return undefined; + } +} + +function parseHostResponse(line: string): AgentViewPtyHostResponse { + let parsed: unknown; + try { + parsed = JSON.parse(line) as unknown; + } catch (error) { + throw new AgentViewPtyHostProtocolError( + error instanceof Error ? error.message : 'Invalid PTY host response.', + ); + } + if (!isRecord(parsed) || typeof parsed['id'] !== 'string') { + throw new AgentViewPtyHostProtocolError( + 'Invalid Agent View PTY host response.', + ); + } + if (parsed['ok'] === true) { + return { id: parsed['id'], ok: true, result: parsed['result'] }; + } + if ( + parsed['ok'] === false && + isRecord(parsed['error']) && + typeof parsed['error']['code'] === 'string' && + typeof parsed['error']['message'] === 'string' + ) { + return { + id: parsed['id'], + ok: false, + error: { + code: parsed['error']['code'], + message: parsed['error']['message'], + }, + }; + } + throw new AgentViewPtyHostProtocolError( + 'Invalid Agent View PTY host response.', + ); +} + +function isHostOperation(value: unknown): value is AgentViewPtyHostOperation { + return HOST_OPERATIONS.includes(value as AgentViewPtyHostOperation); +} + +function errorResponse( + id: string, + code: string, + message: string, +): AgentViewPtyHostResponse { + return { id, ok: false, error: { code, message } }; +} + +// A pid lockfile makes the prepare->listen sequence mutually exclusive: +// canConnect alone is a false-negative-prone liveness oracle, so two +// concurrent launches could both unlink and bind the same session path. +async function acquireSocketPathLock( + socketPath: string, +): Promise<() => Promise> { + const lockPath = `${socketPath}.lock`; + await fs.mkdir(path.dirname(socketPath), { recursive: true, mode: 0o700 }); + // Loop until the O_EXCL create wins or a confirmed-live holder is found: + // every iteration that continues has just removed a stale lock, so a + // successful reclaim always earns another create attempt. + while (true) { + try { + await fs.writeFile(lockPath, String(process.pid), { flag: 'wx' }); + return async () => { + // Only remove a lock that still belongs to this process: a rival + // reclaim may have replaced it, and removing the replacement would + // strip the new owner's lock. + const current = await fs.readFile(lockPath, 'utf8').catch(() => ''); + if (current === String(process.pid)) { + await fs.rm(lockPath, { force: true }); + } + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const raw = await fs.readFile(lockPath, 'utf8').catch(() => ''); + const holderPid = Number.parseInt(raw, 10); + // An empty/non-numeric lock means the writer died before recording its + // pid; reclaim it the same as a confirmed-dead holder. + if (!Number.isInteger(holderPid) || !isProcessAlive(holderPid)) { + // Re-read right before removing: a concurrent reclaim may have + // already replaced the stale lock, and removing the replacement + // would delete the new owner's lock. + const current = await fs.readFile(lockPath, 'utf8').catch(() => ''); + if (current === raw) { + await fs.rm(lockPath, { force: true }); + } + continue; + } + break; + } + } + const busy = new Error( + `Agent View PTY host socket is already in use: ${socketPath}`, + ) as NodeJS.ErrnoException; + busy.code = 'EADDRINUSE'; + throw busy; +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === 'EPERM'; + } +} + +async function prepareSocketPath(socketPath: string): Promise { + if (isWindowsPipePath(socketPath)) return; + const socketDir = path.dirname(socketPath); + await fs.mkdir(socketDir, { recursive: true, mode: 0o700 }); + await ensurePrivateSocketDirectory(socketDir); + if (!(await socketPathExists(socketPath))) return; + // Fail closed instead of unlinking a live socket: the listening host is + // detached and untracked, so replacing it would orphan it irrecoverably. + if (await canConnect(socketPath)) { + const error = new Error( + `Agent View PTY host socket is already in use: ${socketPath}`, + ) as NodeJS.ErrnoException; + error.code = 'EADDRINUSE'; + throw error; + } + await removeSocketPath(socketPath); +} + +async function ensurePrivateSocketDirectory(socketDir: string): Promise { + // lstat (not stat) so a planted symlink at the predictable fallback + // location cannot redirect the ownership check, chmod, or socket bind. + const stat = await fs.lstat(socketDir); + if (stat.isSymbolicLink()) { + throw new Error('Agent View PTY host socket parent must not be a symlink.'); + } + if (!stat.isDirectory()) { + throw new Error('Agent View PTY host socket parent is not a directory.'); + } + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + throw new Error('Agent View PTY host socket parent is not owned by you.'); + } + if ((stat.mode & 0o077) !== 0) { + await fs.chmod(socketDir, 0o700); + } +} + +function canPrepareSocketDirectory(socketDir: string): boolean { + try { + const stat = lstatSync(socketDir); + if (stat.isSymbolicLink() || !stat.isDirectory()) return false; + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) { + return false; + } + accessSync(socketDir, fsConstants.W_OK | fsConstants.X_OK); + return true; + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') return false; + } + + const parentDir = path.dirname(socketDir); + try { + const parentStat = statSync(parentDir); + if (!parentStat.isDirectory()) return false; + accessSync(parentDir, fsConstants.W_OK | fsConstants.X_OK); + return true; + } catch { + return false; + } +} + +async function removeSocketPath(socketPath: string): Promise { + if (isWindowsPipePath(socketPath)) return; + try { + await fs.unlink(socketPath); + } catch (error) { + if (!isNodeError(error) || error.code !== 'ENOENT') { + throw error; + } + } +} + +async function removeOwnedSocketPath(socketPath: string): Promise { + if (isWindowsPipePath(socketPath)) return; + if (!(await socketPathExists(socketPath))) return; + // A live listener means a replacement host took over the path while this + // server was shutting down; unlinking would orphan its socket. + if (await canConnect(socketPath)) return; + await removeSocketPath(socketPath); +} + +async function socketPathExists(socketPath: string): Promise { + try { + await fs.lstat(socketPath); + return true; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') return false; + throw error; + } +} + +async function canConnect(socketPath: string): Promise { + return new Promise((resolve) => { + const socket = net.createConnection(socketPath); + let settled = false; + function finish(result: boolean) { + if (settled) return; + settled = true; + clearTimeout(timeout); + socket.removeAllListeners(); + socket.destroy(); + resolve(result); + } + + socket.once('connect', () => finish(true)); + socket.once('error', () => finish(false)); + const timeout = setTimeout(() => finish(false), 250); + }); +} + +function positiveIntegerParam( + params: Record | undefined, + key: string, +): number { + const value = params?.[key]; + if (!Number.isInteger(value) || Number(value) <= 0) { + throw new Error(`Agent View PTY host ${key} must be a positive integer.`); + } + return Number(value); +} + +function signalParam( + params: Record | undefined, +): NodeJS.Signals | undefined { + return killSignalValue(params?.['signal']); +} + +function killSignalValue(value: unknown): NodeJS.Signals | undefined { + if (value === undefined || value === '') return undefined; + if ( + typeof value === 'string' && + ALLOWED_KILL_SIGNALS.has(value as NodeJS.Signals) + ) { + return value as NodeJS.Signals; + } + throw new Error('Agent View PTY host signal is not allowed.'); +} + +function isValidAuthToken( + provided: string | undefined, + expected: string, +): boolean { + if (!provided) return false; + const providedBuffer = Buffer.from(provided); + const expectedBuffer = Buffer.from(expected); + return ( + providedBuffer.length === expectedBuffer.length && + timingSafeEqual(providedBuffer, expectedBuffer) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error; +} + +function isWindowsPipePath(socketPath: string): boolean { + return socketPath.startsWith('\\\\.\\pipe\\'); +} + +function shortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +function createRequestId(): string { + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +async function delay(ms: number, signal?: AbortSignal): Promise { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const onAbort = () => { + clearTimeout(timeout); + resolve(); + }; + const timeout = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} diff --git a/packages/cli/src/agent-view/pty-host.test.ts b/packages/cli/src/agent-view/pty-host.test.ts new file mode 100644 index 00000000000..209984981d3 --- /dev/null +++ b/packages/cli/src/agent-view/pty-host.test.ts @@ -0,0 +1,751 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { AgentViewLaunchFile } from './protocol.js'; +import { PTY_HOST_AUTH_TOKEN_ENV, PTY_HOST_ID_ENV } from './pty-host-env.js'; +import { + AgentViewLaunchConfigError, + AgentViewPtyUnavailableError, + BoundedOutputRing, + checkAgentViewPtyAvailability, + launchAgentViewPtyHost, + validateAgentViewLaunchConfig, + type AgentViewPtyImplementation, + type AgentViewPtyProcess, + type AgentViewPtySpawnOptions, +} from './pty-host.js'; + +describe('BoundedOutputRing', () => { + it('retains only the newest bytes', () => { + const ring = new BoundedOutputRing(5); + + ring.append('abc'); + ring.append('def'); + + expect(ring.toString()).toBe('bcdef'); + expect(ring.totalBytes).toBe(6); + expect(ring.retainedBytes).toBe(5); + expect(ring.droppedBytes).toBe(1); + }); + + it('truncates oversized chunks to the tail', () => { + const ring = new BoundedOutputRing(4); + + ring.append('123456'); + + expect(ring.toString()).toBe('3456'); + expect(ring.totalBytes).toBe(6); + expect(ring.retainedBytes).toBe(4); + }); + + it('does not retain partial UTF-8 characters when trimming', () => { + const ring = new BoundedOutputRing(4); + + ring.append('a你b'); + + expect(ring.toString()).toBe('你b'); + expect(ring.toString()).not.toContain('\uFFFD'); + expect(ring.retainedBytes).toBeLessThanOrEqual(5); + }); + + it('does not retain partial UTF-8 characters from oversized chunks', () => { + const ring = new BoundedOutputRing(5); + + ring.append('🙂你'); + + expect(ring.toString()).toBe('你'); + expect(ring.toString()).not.toContain('\uFFFD'); + expect(ring.retainedBytes).toBeLessThanOrEqual(5); + }); + + it('does not retain partial UTF-8 characters across chunks', () => { + const ring = new BoundedOutputRing(4); + + ring.append(Buffer.from([0x41, 0xe2, 0x82])); + ring.append(Buffer.from([0xac, 0x42, 0x43])); + + expect(ring.toString()).toBe('BC'); + expect(ring.toString()).not.toContain('\uFFFD'); + expect(ring.retainedBytes).toBeLessThanOrEqual(4); + }); + + it('does not retain partial UTF-8 characters when sub-capacity chunks overflow', () => { + const ring = new BoundedOutputRing(6); + + ring.append('ab你'); + ring.append('你x'); + + expect(ring.toString()).toBe('你x'); + expect(ring.toString()).not.toContain('\uFFFD'); + expect(ring.retainedBytes).toBeLessThanOrEqual(6); + }); + + it('keeps leading continuation bytes when the window never overflowed', () => { + const ring = new BoundedOutputRing(23); + + ring.append(Buffer.from('aa35d7e816b5', 'hex')); + + expect(ring.toBuffer().toString('hex')).toBe('aa35d7e816b5'); + expect(ring.droppedBytes).toBe(0); + }); + + it('copies the retained tail of oversized chunks off the source buffer', () => { + const ring = new BoundedOutputRing(4); + const source = Buffer.alloc(1024, 0x61); + + ring.append(source); + // A retained subarray view would observe this mutation. + source.fill(0x62); + + expect(ring.toString()).toBe('aaaa'); + }); + + it('coalesces small chunks while preserving the byte cap', () => { + const ring = new BoundedOutputRing(1024 * 1024); + + for (let index = 0; index < 10_000; index++) { + ring.append('x'); + } + + expect(ring.retainedBytes).toBe(10_000); + expect(ring.toString()).toBe('x'.repeat(10_000)); + }); +}); + +describe('PTY availability', () => { + it('reports injected PTY availability', async () => { + await expect( + checkAgentViewPtyAvailability(async () => createFakePty()), + ).resolves.toEqual({ + available: true, + implementationName: 'injected', + }); + }); + + it('reports missing PTY without throwing', async () => { + await expect( + checkAgentViewPtyAvailability(async () => null), + ).resolves.toEqual({ + available: false, + reason: 'missing', + }); + }); +}); + +describe('validateAgentViewLaunchConfig', () => { + it('accepts a minimal launch config', () => { + const result = validateAgentViewLaunchConfig(createLaunch()); + + expect(result.ok).toBe(true); + }); + + it('rejects malformed launch config fields', () => { + const result = validateAgentViewLaunchConfig({ + ...createLaunch(), + argv: [], + env: { OK: 'yes', BAD: 1 }, + terminal: { columns: 0, rows: 24 }, + }); + + expect(result).toEqual({ + ok: false, + errors: expect.arrayContaining([ + 'argv must not be empty', + 'env must contain only string values', + 'terminal.columns must be a positive integer', + ]), + }); + }); + + it('rejects a non-string initialPrompt', () => { + const result = validateAgentViewLaunchConfig({ + ...createLaunch(), + initialPrompt: 42, + }); + + expect(result).toEqual({ + ok: false, + errors: expect.arrayContaining([ + 'initialPrompt must be a string when present', + ]), + }); + }); +}); + +describe('launchAgentViewPtyHost', () => { + it('rejects commands containing empty segments', async () => { + const pty = createFakePty(); + + await expect( + launchAgentViewPtyHost(createLaunch(), { + pty, + fakeCommand: ['fake-worker', ''], + }), + ).rejects.toThrow('command must contain at least one non-empty string'); + + expect(pty.spawnCalls).toEqual([]); + }); + + it('spawns the provided fake command in a PTY and captures output', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { + pty, + fakeCommand: ['fake-worker', '--script', 'ready'], + maxOutputBytes: 8, + }); + + expect(pty.spawnCalls).toEqual([ + { + file: 'fake-worker', + args: ['--script', 'ready'], + options: expect.objectContaining({ + cwd: '/repo/work', + cols: 100, + rows: 30, + handleFlowControl: false, + }), + }, + ]); + expect(handle.workerPid).toBe(1234); + + pty.process.emitData('hello'); + pty.process.emitData(' world'); + pty.process.emitExit({ exitCode: 0 }); + + await expect(handle.exited).resolves.toEqual({ + kind: 'exited', + exitCode: 0, + }); + expect(handle.output.toString()).toBe('lo world'); + }); + + it('uses launch argv when no fake command is provided', async () => { + const pty = createFakePty(); + + await launchAgentViewPtyHost(createLaunch(), { pty }); + + expect(pty.spawnCalls[0]?.file).toBe('qwen'); + expect(pty.spawnCalls[0]?.args).toEqual(['--agent-view-worker']); + }); + + it('does not inherit color-disabling or CI supervisor environment into workers', async () => { + const pty = createFakePty(); + const originalTerm = process.env['TERM']; + const originalNoColor = process.env['NO_COLOR']; + const originalForceColor = process.env['FORCE_COLOR']; + const originalCi = process.env['CI']; + const originalBare = process.env['QWEN_CODE_SIMPLE']; + process.env['TERM'] = 'dumb'; + process.env['NO_COLOR'] = '1'; + process.env['FORCE_COLOR'] = '0'; + process.env['CI'] = '1'; + process.env['QWEN_CODE_SIMPLE'] = '1'; + try { + await launchAgentViewPtyHost(createLaunch(), { pty }); + } finally { + if (originalTerm === undefined) { + delete process.env['TERM']; + } else { + process.env['TERM'] = originalTerm; + } + if (originalNoColor === undefined) { + delete process.env['NO_COLOR']; + } else { + process.env['NO_COLOR'] = originalNoColor; + } + if (originalForceColor === undefined) { + delete process.env['FORCE_COLOR']; + } else { + process.env['FORCE_COLOR'] = originalForceColor; + } + if (originalCi === undefined) { + delete process.env['CI']; + } else { + process.env['CI'] = originalCi; + } + if (originalBare === undefined) { + delete process.env['QWEN_CODE_SIMPLE']; + } else { + process.env['QWEN_CODE_SIMPLE'] = originalBare; + } + } + + expect(pty.spawnCalls[0]?.options.name).toBe('xterm-256color'); + expect(pty.spawnCalls[0]?.options.env['TERM']).toBe('xterm-256color'); + expect(pty.spawnCalls[0]?.options.env['NO_COLOR']).toBeUndefined(); + expect(pty.spawnCalls[0]?.options.env['FORCE_COLOR']).toBeUndefined(); + expect(pty.spawnCalls[0]?.options.env['CI']).toBeUndefined(); + // A --bare caller's marker in the daemon env must not silently turn + // every background worker into bare mode. + expect(pty.spawnCalls[0]?.options.env['QWEN_CODE_SIMPLE']).toBeUndefined(); + }); + + it('falls back when launch TERM is empty', async () => { + const pty = createFakePty(); + + await launchAgentViewPtyHost( + { + ...createLaunch(), + env: { TERM: '' }, + }, + { pty }, + ); + + expect(pty.spawnCalls[0]?.options.name).toBe('xterm-256color'); + expect(pty.spawnCalls[0]?.options.env['TERM']).toBe('xterm-256color'); + }); + + it('honours a launch-provided TERM for the pty name and worker env', async () => { + const pty = createFakePty(); + + await launchAgentViewPtyHost( + { + ...createLaunch(), + env: { TERM: 'xterm-direct' }, + }, + { pty }, + ); + + expect(pty.spawnCalls[0]?.options.name).toBe('xterm-direct'); + expect(pty.spawnCalls[0]?.options.env['TERM']).toBe('xterm-direct'); + }); + + it('exposes PTY write, data subscription, and resize controls', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + const data: string[] = []; + const disposable = handle.onData((chunk) => data.push(chunk)); + + handle.write(Buffer.from('hello ')); + handle.write(Buffer.from([0xe4, 0xbd])); + handle.write(Buffer.from([0xa0, 0xe5, 0xa5, 0xbd])); + handle.resize({ columns: 120, rows: 40 }); + handle.pause?.(); + handle.resume?.(); + pty.process.emitData('output'); + disposable?.dispose(); + pty.process.emitData('ignored'); + + expect(pty.process.input).toBe('hello 你好'); + expect(pty.process.resizes).toEqual([{ columns: 120, rows: 40 }]); + expect(pty.process.pauses).toBe(1); + expect(pty.process.resumes).toBe(1); + expect(data).toEqual(['output']); + }); + + it('passes no signal to the pty on Windows', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + + const original = process.platform; + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true, + }); + try { + handle.kill('SIGKILL'); + handle.shutdown?.(); + } finally { + Object.defineProperty(process, 'platform', { + value: original, + configurable: true, + }); + } + + expect(pty.process.killCalls).toEqual([undefined, undefined]); + }); + + it('resets the input decoder between attach sessions', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + + // 0xE4 0xBD are the first two bytes of U+4F60 (你); without a reset + // they would leak into the next session as a replacement character. + handle.write(Buffer.from([0xe4, 0xbd])); + handle.resetInput?.(); + handle.write(Buffer.from('A')); + + expect(pty.process.input).toBe('A'); + }); + + it('passes worker env while stripping host-only secrets', async () => { + const pty = createFakePty(); + const previousToken = process.env[PTY_HOST_AUTH_TOKEN_ENV]; + const previousTerm = process.env['TERM']; + const previousMarker = process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER']; + const previousTmux = process.env['TMUX']; + const previousColumns = process.env['COLUMNS']; + process.env[PTY_HOST_AUTH_TOKEN_ENV] = 'host-secret'; + process.env['TERM'] = 'ambient-term'; + process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER'] = 'ambient-value'; + process.env['TMUX'] = '/tmp/tmux-501/default,123,0'; + process.env['COLUMNS'] = '200'; + try { + await launchAgentViewPtyHost(createLaunch(), { pty }); + } finally { + if (previousToken === undefined) { + delete process.env[PTY_HOST_AUTH_TOKEN_ENV]; + } else { + process.env[PTY_HOST_AUTH_TOKEN_ENV] = previousToken; + } + if (previousTerm === undefined) { + delete process.env['TERM']; + } else { + process.env['TERM'] = previousTerm; + } + if (previousMarker === undefined) { + delete process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER']; + } else { + process.env['QWEN_AGENT_VIEW_AMBIENT_MARKER'] = previousMarker; + } + if (previousTmux === undefined) { + delete process.env['TMUX']; + } else { + process.env['TMUX'] = previousTmux; + } + if (previousColumns === undefined) { + delete process.env['COLUMNS']; + } else { + process.env['COLUMNS'] = previousColumns; + } + } + + expect(pty.spawnCalls[0]?.options.env).toEqual( + expect.objectContaining({ + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_AMBIENT_MARKER: 'ambient-value', + TERM: 'xterm-256color', + }), + ); + expect( + pty.spawnCalls[0]?.options.env[PTY_HOST_AUTH_TOKEN_ENV], + ).toBeUndefined(); + expect(pty.spawnCalls[0]?.options.env['TMUX']).toBeUndefined(); + expect(pty.spawnCalls[0]?.options.env['COLUMNS']).toBeUndefined(); + }); + + it('strips host-only secrets even when the launch env re-adds them', async () => { + const pty = createFakePty(); + + await launchAgentViewPtyHost( + { + ...createLaunch(), + env: { + QWEN_AGENT_VIEW_WORKER: '1', + [PTY_HOST_AUTH_TOKEN_ENV]: 'injected-token', + [PTY_HOST_ID_ENV]: 'injected-host-id', + TMUX: '/tmp/tmux-501/default,456,0', + TMUX_PANE: '%1', + STY: '12345.pts-0.host', + WINDOW: '2', + WINDOWID: '77594631', + TERMCAP: 'SC|screen|VT 100/ANSI X3.64 virtual terminal', + COLUMNS: '80', + LINES: '60', + }, + }, + { pty }, + ); + + expect(pty.spawnCalls[0]?.options.env).toEqual( + expect.objectContaining({ QWEN_AGENT_VIEW_WORKER: '1' }), + ); + for (const key of [ + PTY_HOST_AUTH_TOKEN_ENV, + PTY_HOST_ID_ENV, + 'TMUX', + 'TMUX_PANE', + 'STY', + 'WINDOW', + 'WINDOWID', + 'TERMCAP', + 'COLUMNS', + 'LINES', + ]) { + expect(pty.spawnCalls[0]?.options.env[key]).toBeUndefined(); + } + }); + + it('strips the inherited sideband identity but honors the launch env', async () => { + const pty = createFakePty(); + const savedEnv: Record = {}; + const outerKeys = [ + 'QWEN_AGENT_VIEW_WORKER', + 'QWEN_AGENT_VIEW_SESSION_ID', + 'QWEN_AGENT_VIEW_SIDEBAND', + 'QWEN_AGENT_VIEW_TOKEN', + 'QWEN_AGENT_VIEW_ACTIVE_CWD', + ]; + for (const key of outerKeys) { + savedEnv[key] = process.env[key]; + process.env[key] = `outer-${key}`; + } + try { + await launchAgentViewPtyHost( + { + ...createLaunch(), + env: { + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_TOKEN: 'inner-token', + }, + }, + { pty }, + ); + } finally { + for (const key of outerKeys) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } + } + + const env = pty.spawnCalls[0]?.options.env ?? {}; + expect(env['QWEN_AGENT_VIEW_TOKEN']).toBe('inner-token'); + expect(env['QWEN_AGENT_VIEW_WORKER']).toBe('1'); + expect(env['QWEN_AGENT_VIEW_SESSION_ID']).toBeUndefined(); + expect(env['QWEN_AGENT_VIEW_SIDEBAND']).toBeUndefined(); + expect(env['QWEN_AGENT_VIEW_ACTIVE_CWD']).toBeUndefined(); + }); + + it('lets the launch env override inherited process env values', async () => { + const pty = createFakePty(); + const key = 'QWEN_AGENT_VIEW_MERGE_TEST'; + const previous = process.env[key]; + process.env[key] = 'inherited'; + try { + await launchAgentViewPtyHost( + { ...createLaunch(), env: { [key]: 'from-launch' } }, + { pty }, + ); + } finally { + if (previous === undefined) { + delete process.env[key]; + } else { + process.env[key] = previous; + } + } + + expect(pty.spawnCalls[0]?.options.env[key]).toBe('from-launch'); + }); + + it('spawns the PTY with an explicit xterm-256color terminal name', async () => { + const pty = createFakePty(); + + await launchAgentViewPtyHost(createLaunch(), { pty }); + + // node-pty overrides env.TERM with the spawn name, so both must agree. + expect(pty.spawnCalls[0]?.options.name).toBe('xterm-256color'); + expect(pty.spawnCalls[0]?.options.env['TERM']).toBe('xterm-256color'); + }); + + it('strips an inherited sideband token the launch env does not replace', async () => { + const pty = createFakePty(); + const previous = process.env['QWEN_AGENT_VIEW_TOKEN']; + process.env['QWEN_AGENT_VIEW_TOKEN'] = 'outer-token'; + try { + await launchAgentViewPtyHost(createLaunch(), { pty }); + } finally { + if (previous === undefined) { + delete process.env['QWEN_AGENT_VIEW_TOKEN']; + } else { + process.env['QWEN_AGENT_VIEW_TOKEN'] = previous; + } + } + + expect( + pty.spawnCalls[0]?.options.env['QWEN_AGENT_VIEW_TOKEN'], + ).toBeUndefined(); + }); + + it.skipIf(process.platform === 'win32')( + 'passes kill signals through to the PTY process', + async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + + handle.kill('SIGKILL'); + + expect(pty.process.killCalls).toEqual(['SIGKILL']); + }, + ); + + it('stops capturing output after dispose', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + pty.process.emitData('before'); + + handle.dispose(); + pty.process.emitData('leak'); + + expect(handle.output.toString()).toBe('before'); + }); + + it('kills the PTY process when disposed', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + + handle.dispose(); + + expect(pty.process.killedWith).toBe( + process.platform === 'win32' ? undefined : 'SIGTERM', + ); + expect(pty.process.killCalls).toEqual( + process.platform === 'win32' ? [undefined] : ['SIGTERM'], + ); + await expect(handle.exited).resolves.toEqual({ kind: 'unreachable' }); + }); + + it.skipIf(process.platform === 'win32')( + 'gracefully shuts down the PTY process with SIGTERM', + async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { pty }); + + handle.shutdown?.(); + + expect(pty.process.killedWith).toBe('SIGTERM'); + }, + ); + + it('loads PTY through the configured loader', async () => { + const pty = createFakePty(); + const handle = await launchAgentViewPtyHost(createLaunch(), { + loadPty: async () => pty, + }); + + expect(handle.workerPid).toBe(1234); + expect(pty.spawnCalls).toHaveLength(1); + }); + + it('throws a typed error when PTY is unavailable', async () => { + await expect( + launchAgentViewPtyHost(createLaunch(), { pty: null }), + ).rejects.toBeInstanceOf(AgentViewPtyUnavailableError); + }); + + it('throws a typed error for invalid launch config', async () => { + await expect( + launchAgentViewPtyHost({ ...createLaunch(), terminal: undefined }), + ).rejects.toBeInstanceOf(AgentViewLaunchConfigError); + }); +}); + +function createLaunch(): AgentViewLaunchFile { + return { + schemaVersion: 1, + sessionId: 'session-1', + argv: ['qwen', '--agent-view-worker'], + env: { QWEN_AGENT_VIEW_WORKER: '1' }, + entrypoint: 'qwen', + projectCwd: '/repo', + activeCwd: '/repo/work', + includeDirectories: [], + terminal: { + columns: 100, + rows: 30, + }, + }; +} + +function createFakePty(): AgentViewPtyImplementation & { + process: FakePtyProcess; + spawnCalls: Array<{ + file: string; + args: readonly string[] | string; + options: AgentViewPtySpawnOptions; + }>; +} { + const process = new FakePtyProcess(); + const spawnCalls: Array<{ + file: string; + args: readonly string[] | string; + options: AgentViewPtySpawnOptions; + }> = []; + + return { + name: 'injected', + process, + spawnCalls, + module: { + spawn(file, args, options): AgentViewPtyProcess { + spawnCalls.push({ file, args, options }); + return process; + }, + }, + }; +} + +class FakePtyProcess implements AgentViewPtyProcess { + readonly pid = 1234; + private dataCallbacks: Array<(data: string) => void> = []; + private exitCallbacks: Array< + (event: { exitCode: number; signal?: number }) => void + > = []; + input = ''; + resizes: Array<{ columns: number; rows: number }> = []; + killedWith: string | undefined; + killCalls: Array = []; + pauses = 0; + resumes = 0; + + write(data: string): void { + this.input += data; + } + + onData(callback: (data: string) => void) { + this.dataCallbacks.push(callback); + return { + dispose: () => { + this.dataCallbacks = this.dataCallbacks.filter( + (item) => item !== callback, + ); + }, + }; + } + + onExit(callback: (event: { exitCode: number; signal?: number }) => void) { + this.exitCallbacks.push(callback); + return { + dispose: () => { + this.exitCallbacks = this.exitCallbacks.filter( + (item) => item !== callback, + ); + }, + }; + } + + kill(signal?: string): void { + this.killCalls.push(signal); + this.killedWith = signal; + } + + resize(columns: number, rows: number): void { + this.resizes.push({ columns, rows }); + } + + pause(): void { + this.pauses += 1; + } + + resume(): void { + this.resumes += 1; + } + + emitData(data: string): void { + for (const callback of this.dataCallbacks) { + callback(data); + } + } + + emitExit(event: { exitCode: number; signal?: number }): void { + for (const callback of this.exitCallbacks) { + callback(event); + } + } +} diff --git a/packages/cli/src/agent-view/pty-host.ts b/packages/cli/src/agent-view/pty-host.ts new file mode 100644 index 00000000000..330ad3c22fa --- /dev/null +++ b/packages/cli/src/agent-view/pty-host.ts @@ -0,0 +1,554 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { StringDecoder } from 'node:string_decoder'; +import { PTY_HOST_AUTH_TOKEN_ENV, PTY_HOST_ID_ENV } from './pty-host-env.js'; +import { AGENT_VIEW_WORKER_ENV_KEYS } from './worker-sideband.js'; +import type { AgentViewLaunchFile } from './protocol.js'; + +export const DEFAULT_AGENT_VIEW_PTY_OUTPUT_BYTES = 1024 * 1024; +const INTERNAL_ONLY_WORKER_ENV_KEYS = new Set([ + PTY_HOST_AUTH_TOKEN_ENV, + PTY_HOST_ID_ENV, + // The supervisor startup-gate marker (INTERNAL_AGENT_VIEW_SUPERVISOR_ENV): + // workers must not carry it, or an agent-run `qwen` whose argv mentions + // the internal flag could re-enter supervisor mode from inside a session. + 'QWEN_AGENT_VIEW_SUPERVISOR', + // The --bare invocation marker must never leak from the daemon env into + // workers: a bare-mode `qwen agents` invocation that spawned the daemon + // would otherwise silently run every later background session bare + // (minimal settings: no user model/MCP servers/approval mode). + 'QWEN_CODE_SIMPLE', + 'TMUX', + 'TMUX_PANE', + 'STY', + 'WINDOW', + 'WINDOWID', + 'TERMCAP', + 'COLUMNS', + 'LINES', + 'NO_COLOR', + 'FORCE_COLOR', + 'CI', +]); + +export interface AgentViewPtySpawnOptions { + cwd: string; + name: string; + cols: number; + rows: number; + env: Record; + handleFlowControl: boolean; +} + +export interface AgentViewPtyDisposable { + dispose(): void; +} + +export interface AgentViewPtyProcess { + readonly pid: number; + write(data: string): void; + onData(callback: (data: string) => void): AgentViewPtyDisposable | void; + onExit( + callback: (event: { exitCode: number; signal?: number }) => void, + ): AgentViewPtyDisposable | void; + resize(cols: number, rows: number): void; + kill(signal?: string): void; + pause?(): void; + resume?(): void; +} + +export interface AgentViewPtyModule { + spawn( + file: string, + args: readonly string[] | string, + options: AgentViewPtySpawnOptions, + ): AgentViewPtyProcess; +} + +export interface AgentViewPtyImplementation { + module: AgentViewPtyModule; + name: 'lydell-node-pty' | 'node-pty' | 'injected'; +} + +export type AgentViewPtyAvailability = + | { available: true; implementationName: AgentViewPtyImplementation['name'] } + | { available: false; reason: 'missing' }; + +export type AgentViewLaunchValidationResult = + | { ok: true; launch: AgentViewLaunchFile } + | { ok: false; errors: string[] }; + +export interface AgentViewPtyHostOptions { + fakeCommand?: readonly string[]; + maxOutputBytes?: number; + pty?: AgentViewPtyImplementation | null; + loadPty?: () => Promise; +} + +export type AgentViewPtyHostExit = + | { kind: 'exited'; exitCode: number; signal?: number } + | { kind: 'confirmed-kill' } + | { kind: 'confirmed-shutdown' } + | { kind: 'unreachable' }; + +export interface AgentViewPtyHostHandle { + hostId?: string; + pid: number; + workerPid: number; + command: readonly string[]; + endpoint?: string; + authToken?: string; + output: BoundedOutputRing; + exited: Promise; + getOutput?(): Promise; + write(data: Buffer): void; + resetInput?(): void; + onData(callback: (data: string) => void): AgentViewPtyDisposable | void; + resize(size: { columns: number; rows: number }): void; + kill(signal?: string): void; + pause?(): void; + resume?(): void; + shutdown?(): void | Promise; + dispose(): void; +} + +export class AgentViewPtyUnavailableError extends Error { + constructor() { + super('Agent View PTY is unavailable in this runtime.'); + this.name = 'AgentViewPtyUnavailableError'; + } +} + +export class AgentViewLaunchConfigError extends Error { + constructor(readonly errors: readonly string[]) { + super(`Invalid Agent View launch config: ${errors.join('; ')}`); + this.name = 'AgentViewLaunchConfigError'; + } +} + +export class BoundedOutputRing { + private static readonly MAX_CHUNK_BYTES = 8192; + + private chunks: Buffer[] = []; + private retainedBytesValue = 0; + private totalBytesValue = 0; + + constructor(readonly maxBytes: number = DEFAULT_AGENT_VIEW_PTY_OUTPUT_BYTES) { + if (!Number.isInteger(maxBytes) || maxBytes < 1) { + throw new RangeError('maxBytes must be a positive integer'); + } + } + + get retainedBytes(): number { + return this.retainedBytesValue; + } + + get totalBytes(): number { + return this.totalBytesValue; + } + + get droppedBytes(): number { + return this.totalBytesValue - this.retainedBytesValue; + } + + append(data: string | Buffer): void { + const chunk = Buffer.isBuffer(data) ? data : Buffer.from(data, 'utf8'); + this.totalBytesValue += chunk.byteLength; + + if (chunk.byteLength >= this.maxBytes) { + // Copy instead of retaining a subarray view: a view would pin the + // entire backing ArrayBuffer of the (potentially huge) source chunk. + const retained = trimUtf8Start( + Buffer.from(chunk.subarray(chunk.byteLength - this.maxBytes)), + ); + this.chunks = [retained]; + this.retainedBytesValue = retained.byteLength; + return; + } + + this.appendChunk(chunk); + this.retainedBytesValue += chunk.byteLength; + this.trim(); + } + + toBuffer(): Buffer { + return Buffer.concat(this.chunks, this.retainedBytesValue); + } + + toString(encoding: BufferEncoding = 'utf8'): string { + return this.toBuffer().toString(encoding); + } + + private trim(): void { + let trimmed = false; + while (this.retainedBytesValue > this.maxBytes) { + trimmed = true; + const excess = this.retainedBytesValue - this.maxBytes; + const first = this.chunks[0]; + if (!first) { + this.retainedBytesValue = 0; + return; + } + if (first.byteLength <= excess) { + this.chunks.shift(); + this.retainedBytesValue -= first.byteLength; + } else { + const retained = trimUtf8Start(first.subarray(excess)); + if (retained.byteLength === 0) { + this.chunks.shift(); + } else { + this.chunks[0] = retained; + } + this.retainedBytesValue -= first.byteLength - retained.byteLength; + } + } + // Only a size trim can leave continuation bytes at the window start; + // without one, dropping them would discard data with room still free. + if (trimmed) this.trimLeadingUtf8ContinuationBytes(); + } + + private appendChunk(chunk: Buffer): void { + const previous = this.chunks[this.chunks.length - 1]; + if ( + previous && + previous.byteLength + chunk.byteLength <= + BoundedOutputRing.MAX_CHUNK_BYTES + ) { + this.chunks[this.chunks.length - 1] = Buffer.concat([previous, chunk]); + return; + } + this.chunks.push(chunk); + } + + private trimLeadingUtf8ContinuationBytes(): void { + while (this.chunks.length > 0) { + const first = this.chunks[0]!; + const retained = trimUtf8Start(first); + if (retained.byteLength === first.byteLength) { + return; + } + if (retained.byteLength === 0) { + this.chunks.shift(); + } else { + this.chunks[0] = retained; + } + this.retainedBytesValue -= first.byteLength - retained.byteLength; + if (retained.byteLength > 0) return; + } + } +} + +function trimUtf8Start(buffer: Buffer): Buffer { + let offset = 0; + while ( + offset < buffer.byteLength && + isUtf8ContinuationByte(buffer[offset]!) + ) { + offset++; + } + return offset === 0 ? buffer : buffer.subarray(offset); +} + +function isUtf8ContinuationByte(value: number): boolean { + return value >= 0x80 && value <= 0xbf; +} + +export async function checkAgentViewPtyAvailability( + loadPty: () => Promise = loadAgentViewPty, +): Promise { + const pty = await loadPty(); + if (!pty) { + return { available: false, reason: 'missing' }; + } + return { available: true, implementationName: pty.name }; +} + +export async function loadAgentViewPty(): Promise { + if ('bun' in process.versions) { + return null; + } + + const lydell = await importPty('@lydell/node-pty', 'lydell-node-pty'); + if (lydell) { + return lydell; + } + return importPty('node-pty', 'node-pty'); +} + +export function validateAgentViewLaunchConfig( + value: unknown, +): AgentViewLaunchValidationResult { + const errors: string[] = []; + + if (!isRecord(value)) { + return { ok: false, errors: ['launch config must be an object'] }; + } + + requireLiteral(value, 'schemaVersion', 1, errors); + requireNonEmptyString(value, 'sessionId', errors); + requireStringArray(value, 'argv', errors, { nonEmpty: true }); + requireStringRecord(value, 'env', errors); + requireNonEmptyString(value, 'entrypoint', errors); + requireNonEmptyString(value, 'projectCwd', errors); + requireNonEmptyString(value, 'activeCwd', errors); + requireStringArray(value, 'includeDirectories', errors); + validateOptionalString(value, 'model', errors); + validateOptionalString(value, 'approvalMode', errors); + validateOptionalString(value, 'sandbox', errors); + validateOptionalString(value, 'initialPrompt', errors); + validateOptionalString(value, 'settingsDigest', errors); + validateOptionalString(value, 'mcpDigest', errors); + validateTerminal(value['terminal'], errors); + + if (errors.length > 0) { + return { ok: false, errors }; + } + + return { ok: true, launch: value as unknown as AgentViewLaunchFile }; +} + +export async function launchAgentViewPtyHost( + rawLaunch: unknown, + options: AgentViewPtyHostOptions = {}, +): Promise { + const validation = validateAgentViewLaunchConfig(rawLaunch); + if (!validation.ok) { + throw new AgentViewLaunchConfigError(validation.errors); + } + + const pty = + options.pty === undefined + ? await (options.loadPty ?? loadAgentViewPty)() + : options.pty; + if (!pty) { + throw new AgentViewPtyUnavailableError(); + } + + const launch = validation.launch; + const command = options.fakeCommand ?? launch.argv; + validateCommand(command); + + const output = new BoundedOutputRing( + options.maxOutputBytes ?? DEFAULT_AGENT_VIEW_PTY_OUTPUT_BYTES, + ); + // Strip the outer session's sideband identity from the inherited env so a + // nested host cannot leak its token/endpoint into the inner worker; the + // launch env intentionally carries the inner worker's own sideband keys. + const inheritedEnv = stringProcessEnv(process.env); + for (const key of AGENT_VIEW_WORKER_ENV_KEYS) { + delete inheritedEnv[key]; + } + const term = launch.env['TERM'] || 'xterm-256color'; + const workerEnv: Record = { + ...inheritedEnv, + ...launch.env, + TERM: term, + }; + for (const key of INTERNAL_ONLY_WORKER_ENV_KEYS) { + delete workerEnv[key]; + } + const ptyProcess = pty.module.spawn(command[0], command.slice(1), { + cwd: launch.activeCwd, + name: term, + cols: launch.terminal.columns, + rows: launch.terminal.rows, + env: workerEnv, + handleFlowControl: false, + }); + let inputDecoder = new StringDecoder('utf8'); + + const disposables: AgentViewPtyDisposable[] = []; + let settled = false; + let resolveExit: (exit: AgentViewPtyHostExit) => void = () => {}; + const resolveExitOnce = (exit: AgentViewPtyHostExit) => { + if (settled) return; + settled = true; + resolveExit(exit); + }; + const dataDisposable = ptyProcess.onData((data) => { + output.append(data); + }); + if (dataDisposable) { + disposables.push(dataDisposable); + } + + const exited = new Promise((resolve) => { + resolveExit = resolve; + const exitDisposable = ptyProcess.onExit((event) => { + resolveExitOnce({ kind: 'exited', ...event }); + }); + if (exitDisposable) { + disposables.push(exitDisposable); + } + }); + + return { + pid: process.pid, + workerPid: ptyProcess.pid, + command: [...command], + output, + exited, + write(data: Buffer): void { + ptyProcess.write(inputDecoder.write(data)); + }, + onData(callback: (data: string) => void): AgentViewPtyDisposable | void { + return ptyProcess.onData(callback); + }, + resize(size: { columns: number; rows: number }): void { + ptyProcess.resize(size.columns, size.rows); + }, + kill(signal?: string): void { + // WindowsTerminal.kill throws for any signal string; the argument-less + // kill terminates the conpty process tree instead. + ptyProcess.kill(process.platform === 'win32' ? undefined : signal); + }, + pause(): void { + ptyProcess.pause?.(); + }, + resume(): void { + ptyProcess.resume?.(); + }, + shutdown(): void { + ptyProcess.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); + }, + resetInput(): void { + inputDecoder = new StringDecoder('utf8'); + }, + dispose(): void { + // Match shutdown(): node-pty's signal-less kill falls back to SIGHUP on + // POSIX, which nohup-style workers ignore. + ptyProcess.kill(process.platform === 'win32' ? undefined : 'SIGTERM'); + resolveExitOnce({ kind: 'unreachable' }); + for (const disposable of disposables.splice(0)) { + disposable.dispose(); + } + }, + }; +} + +async function importPty( + specifier: '@lydell/node-pty' | 'node-pty', + name: AgentViewPtyImplementation['name'], +): Promise { + try { + const module = await import(specifier); + const ptyModule = asPtyModule(module); + return ptyModule ? { module: ptyModule, name } : null; + } catch { + return null; + } +} + +function asPtyModule(module: unknown): AgentViewPtyModule | undefined { + if (!isRecord(module) || typeof module['spawn'] !== 'function') { + return undefined; + } + + return module as unknown as AgentViewPtyModule; +} + +function validateCommand(command: readonly string[]): void { + if (command.length === 0 || command.some((part) => part.length === 0)) { + throw new AgentViewLaunchConfigError([ + 'command must contain at least one non-empty string', + ]); + } +} + +function stringProcessEnv(env: NodeJS.ProcessEnv): Record { + return Object.fromEntries( + Object.entries(env).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +function requireLiteral( + record: Record, + key: string, + expected: unknown, + errors: string[], +): void { + if (record[key] !== expected) { + errors.push(`${key} must be ${String(expected)}`); + } +} + +function requireNonEmptyString( + record: Record, + key: string, + errors: string[], +): void { + if (typeof record[key] !== 'string' || record[key].length === 0) { + errors.push(`${key} must be a non-empty string`); + } +} + +function validateOptionalString( + record: Record, + key: string, + errors: string[], +): void { + if (record[key] !== undefined && typeof record[key] !== 'string') { + errors.push(`${key} must be a string when present`); + } +} + +function requireStringArray( + record: Record, + key: string, + errors: string[], + options: { nonEmpty?: boolean } = {}, +): void { + const value = record[key]; + if (!Array.isArray(value)) { + errors.push(`${key} must be an array of strings`); + return; + } + if (options.nonEmpty && value.length === 0) { + errors.push(`${key} must not be empty`); + } + if (value.some((item) => typeof item !== 'string')) { + errors.push(`${key} must contain only strings`); + } +} + +function requireStringRecord( + record: Record, + key: string, + errors: string[], +): void { + const value = record[key]; + if (!isRecord(value)) { + errors.push(`${key} must be an object with string values`); + return; + } + if (Object.values(value).some((item) => typeof item !== 'string')) { + errors.push(`${key} must contain only string values`); + } +} + +function validateTerminal(value: unknown, errors: string[]): void { + if (!isRecord(value)) { + errors.push('terminal must be an object'); + return; + } + if (!isPositiveInteger(value['columns'])) { + errors.push('terminal.columns must be a positive integer'); + } + if (!isPositiveInteger(value['rows'])) { + errors.push('terminal.rows must be a positive integer'); + } +} + +function isPositiveInteger(value: unknown): boolean { + return Number.isInteger(value) && Number(value) > 0; +} diff --git a/packages/cli/src/agent-view/supervisor-client.ts b/packages/cli/src/agent-view/supervisor-client.ts index 77ba797a975..c6c0e5854dc 100644 --- a/packages/cli/src/agent-view/supervisor-client.ts +++ b/packages/cli/src/agent-view/supervisor-client.ts @@ -33,6 +33,7 @@ export type AgentViewSupervisorOperation = | 'stop' | 'kill' | 'respawn' + | 'release' | 'remove' | 'pin' | 'rename'; @@ -101,6 +102,7 @@ export interface AgentViewSupervisorRequestMap { stop: { sessionId: string }; kill: { sessionId: string }; respawn: { sessionId: string } | { all: true }; + release: { sessionId: string }; remove: { sessionId: string }; pin: { sessionId: string; pinned?: boolean }; rename: { sessionId: string; displayName: string }; @@ -127,6 +129,7 @@ export interface AgentViewSupervisorResponseMap { stop: unknown; kill: unknown; respawn: unknown; + release: unknown; remove: unknown; pin: unknown; rename: unknown; diff --git a/packages/cli/src/agent-view/supervisor-dispatch.test.ts b/packages/cli/src/agent-view/supervisor-dispatch.test.ts new file mode 100644 index 00000000000..35209ea220d --- /dev/null +++ b/packages/cli/src/agent-view/supervisor-dispatch.test.ts @@ -0,0 +1,146 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { dispatchAgentViewSession } from './supervisor-dispatch.js'; +import { + getAgentViewStorePaths, + readAgentViewLaunch, + readAgentViewRoster, + readAgentViewSessionState, +} from './supervisor-store.js'; + +const injected = vi.hoisted(() => ({ failActivityWrite: false })); + +vi.mock('./supervisor-store.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + writeAgentViewActivity: ( + ...args: Parameters + ) => + injected.failActivityWrite + ? Promise.reject(new Error('injected activity write failure')) + : original.writeAgentViewActivity(...args), + }; +}); + +describe('dispatchAgentViewSession', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-agent-view-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it('writes shared-cwd launch and roster metadata', async () => { + const result = await dispatchAgentViewSession('write tests', '/repo/pkg', { + globalDir: tempDir, + token: 'token', + sidebandEndpoint: '/tmp/agent-view.sock', + }); + + const state = await readAgentViewSessionState(result.sessionId, { + globalDir: tempDir, + }); + const launch = await readAgentViewLaunch(result.sessionId, { + globalDir: tempDir, + }); + const roster = await readAgentViewRoster({ globalDir: tempDir }); + + expect(state).toMatchObject({ + sessionId: result.sessionId, + projectCwd: path.resolve('/repo/pkg'), + originalCwd: path.resolve('/repo/pkg'), + activeCwd: path.resolve('/repo/pkg'), + worktree: { mode: 'none' }, + }); + expect(launch).toMatchObject({ + sessionId: result.sessionId, + projectCwd: path.resolve('/repo/pkg'), + activeCwd: path.resolve('/repo/pkg'), + env: { + QWEN_AGENT_VIEW_ACTIVE_CWD: path.resolve('/repo/pkg'), + QWEN_AGENT_VIEW_SIDEBAND: '/tmp/agent-view.sock', + }, + }); + expect(roster.sessions[0]).toMatchObject({ + sessionId: result.sessionId, + projectCwd: path.resolve('/repo/pkg'), + activeCwd: path.resolve('/repo/pkg'), + }); + }); + + it('rolls back session files and roster when a mid-dispatch write fails', async () => { + // Fail the activity write: the session-state and launch writes have + // already succeeded at that point, so rollback must actually remove the + // partially written session directory. The roster upsert is the last + // persistence step, so the roster must stay empty; this pins the + // upsert-last ordering the rollback relies on. + injected.failActivityWrite = true; + try { + await expect( + dispatchAgentViewSession('write tests', '/repo/pkg', { + globalDir: tempDir, + token: 'token', + sidebandEndpoint: '/tmp/agent-view.sock', + }), + ).rejects.toThrow('injected activity write failure'); + } finally { + injected.failActivityWrite = false; + } + + const paths = getAgentViewStorePaths({ globalDir: tempDir }); + const jobs = await fs.readdir(paths.jobsDir); + expect(jobs).toEqual([]); + await expect(readAgentViewRoster({ globalDir: tempDir })).resolves.toEqual( + expect.objectContaining({ sessions: [] }), + ); + }); + + it('removes session files when roster persistence fails', async () => { + const paths = getAgentViewStorePaths({ globalDir: tempDir }); + await fs.mkdir(path.join(paths.daemonDir, 'roster.json'), { + recursive: true, + }); + + await expect( + dispatchAgentViewSession('write tests', '/repo/pkg', { + globalDir: tempDir, + token: 'token', + sidebandEndpoint: '/tmp/agent-view.sock', + }), + ).rejects.toThrow(); + + const jobs = await fs.readdir(paths.jobsDir); + expect(jobs).toEqual([]); + }); + + it('rejects oversized UTF-8 argv prompts before creating a session', async () => { + const prompt = '你'.repeat(Math.floor((16 * 1024) / 3) + 1); + + await expect( + dispatchAgentViewSession(prompt, '/repo/pkg', { + globalDir: tempDir, + }), + ).rejects.toThrow('too large for argv'); + + const paths = getAgentViewStorePaths({ globalDir: tempDir }); + await expect(fs.access(paths.jobsDir)).rejects.toMatchObject({ + code: 'ENOENT', + }); + await expect(readAgentViewRoster({ globalDir: tempDir })).resolves.toEqual( + expect.objectContaining({ sessions: [] }), + ); + }); +}); diff --git a/packages/cli/src/agent-view/supervisor-dispatch.ts b/packages/cli/src/agent-view/supervisor-dispatch.ts new file mode 100644 index 00000000000..c5f2f9aab91 --- /dev/null +++ b/packages/cli/src/agent-view/supervisor-dispatch.ts @@ -0,0 +1,203 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { AGENT_VIEW_PROTOCOL_VERSION } from './protocol.js'; +import { + digestAgentViewWorkerToken, + getAgentViewSessionPaths, + removeAgentViewRosterEntry, + upsertAgentViewRosterEntry, + writeAgentViewActivity, + writeAgentViewLaunch, + writeAgentViewSessionState, + writeAgentViewWorker, +} from './supervisor-store.js'; +import { createAgentViewWorkerSidebandEnv } from './worker-sideband.js'; +import { + buildCurrentQwenCliArgv, + getCurrentQwenCliEntrypoint, +} from './current-cli-argv.js'; + +interface DispatchOptions { + globalDir?: string; + sidebandEndpoint?: string; + token?: string; + publishRoster?: boolean; + promptInArgv?: boolean; +} + +// activity.json is re-read on every list() poll; keep the summary a +// display-sized preview, matching the queued-prompt preview cap. +const MAX_ACTIVITY_SUMMARY_CHARS = 500; +const MAX_ARGV_PROMPT_BYTES = 16 * 1024; + +export async function dispatchAgentViewSession( + prompt: string, + cwd: string, + options: DispatchOptions = {}, +): Promise<{ sessionId: string; state: 'created' }> { + const sessionId = randomUUID(); + const token = options.token ?? randomUUID(); + const now = new Date().toISOString(); + const resolvedCwd = path.resolve(cwd); + if ( + options.promptInArgv !== false && + Buffer.byteLength(prompt, 'utf8') > MAX_ARGV_PROMPT_BYTES + ) { + throw new Error( + `Agent View prompt is too large for argv (${MAX_ARGV_PROMPT_BYTES} UTF-8 bytes maximum).`, + ); + } + const state = { + schemaVersion: 1 as const, + sessionId, + ownership: 'managed' as const, + sessionState: 'starting' as const, + processState: 'starting' as const, + attachState: 'detached' as const, + projectCwd: resolvedCwd, + originalCwd: resolvedCwd, + activeCwd: resolvedCwd, + createdAt: now, + updatedAt: now, + ...(options.promptInArgv === false ? {} : { initialPromptPending: true }), + worktree: { mode: 'none' as const }, + }; + try { + await writeAgentViewSessionState(state, options); + await writeAgentViewLaunch( + { + schemaVersion: 1, + sessionId, + argv: buildNativeWorkerArgv( + sessionId, + options.promptInArgv === false ? undefined : prompt, + ), + env: createAgentViewWorkerSidebandEnv({ + sessionId, + sidebandEndpoint: options.sidebandEndpoint ?? '', + token, + activeCwd: resolvedCwd, + }), + entrypoint: getCurrentQwenCliEntrypoint(), + projectCwd: resolvedCwd, + activeCwd: resolvedCwd, + includeDirectories: [], + terminal: { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + }, + initialPrompt: prompt, + }, + options, + ); + await writeAgentViewActivity( + sessionId, + { + schemaVersion: 1, + summary: prompt.slice(0, MAX_ACTIVITY_SUMMARY_CHARS), + lastActivityAt: now, + capabilities: [], + }, + options, + ); + await writeAgentViewWorker( + sessionId, + { + schemaVersion: 1, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + ...(options.sidebandEndpoint + ? { endpoint: options.sidebandEndpoint } + : {}), + tokenDigest: digestAgentViewWorkerToken(token), + recentOutputBytes: 0, + }, + options, + ); + if (options.publishRoster ?? true) { + await upsertAgentViewRosterEntry( + { + sessionId, + projectCwd: resolvedCwd, + activeCwd: resolvedCwd, + createdAt: now, + updatedAt: now, + }, + options, + ); + } + } catch (error) { + await cleanupFailedDispatchCreation(sessionId, state, options); + throw error; + } + return { sessionId, state: 'created' }; +} + +async function cleanupFailedDispatchCreation( + sessionId: string, + state: { + schemaVersion: 1; + sessionId: string; + ownership: 'managed'; + sessionState: 'starting'; + processState: 'starting'; + attachState: 'detached'; + projectCwd: string; + originalCwd: string; + activeCwd: string; + createdAt: string; + updatedAt: string; + worktree: { mode: 'none' }; + }, + options: DispatchOptions, +): Promise { + try { + await writeAgentViewSessionState( + { + ...state, + ownership: 'unmanaged', + sessionState: 'failed', + processState: 'exited', + updatedAt: new Date().toISOString(), + }, + options, + ); + } catch { + // Best-effort rollback only. + } + + try { + if (options.publishRoster ?? true) { + await removeAgentViewRosterEntry(sessionId, options); + } + } catch { + // Best-effort rollback only. + } + + try { + await fs.rm(getAgentViewSessionPaths(sessionId, options).sessionDir, { + recursive: true, + force: true, + }); + } catch { + // Best-effort rollback only. + } +} + +function buildNativeWorkerArgv(sessionId: string, prompt?: string): string[] { + return buildCurrentQwenCliArgv([ + '--session-id', + sessionId, + // Attached-value form: a bare token after the flag would be re-parsed + // by yargs when the prompt starts with '-', turning e.g. '-y' into + // flags instead of prompt text. + ...(prompt ? [`--prompt-interactive=${prompt}`] : []), + ]); +} diff --git a/packages/cli/src/agent-view/supervisor-process.test.ts b/packages/cli/src/agent-view/supervisor-process.test.ts index 82cc06a6e1d..9833c8c755c 100644 --- a/packages/cli/src/agent-view/supervisor-process.test.ts +++ b/packages/cli/src/agent-view/supervisor-process.test.ts @@ -5,345 +5,5370 @@ */ import * as fs from 'node:fs/promises'; +import * as fsSync from 'node:fs'; import type { Socket } from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { AgentViewSessionStateFile } from './protocol.js'; +import { Duplex } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS } from './attach-lease.js'; +import { AGENT_VIEW_PROTOCOL_VERSION } from './protocol.js'; +import type { + AgentViewLaunchFile, + AgentViewSessionStateFile, + AgentViewWorkerFile, +} from './protocol.js'; import { createAgentViewSupervisorHandler, getAgentViewSupervisorSocketPath, } from './supervisor-process.js'; import { + clearAgentViewWorkerPids, getAgentViewSessionPaths, + readAgentViewActivity, + readAgentViewLaunch, + readAgentViewRoster, + readAgentViewSessionState, + readAgentViewWorker, + upsertAgentViewRosterEntry, + writeAgentViewActivity, + writeAgentViewLaunch, + writeAgentViewWorker, writeAgentViewSessionState, } from './supervisor-store.js'; +import * as supervisorStore from './supervisor-store.js'; +import type { + AgentViewPtyHostExit, + AgentViewPtyHostHandle, +} from './pty-host.js'; +import { BoundedOutputRing } from './pty-host.js'; +import { createAgentViewPtyHostServer } from './pty-host-process.js'; -describe('getAgentViewSupervisorSocketPath', () => { - it('returns a named pipe on win32', () => { +describe('Agent View supervisor process helpers', () => { + it('computes a stable Unix socket path under the Agent View store', () => { + const globalDir = path.join(os.tmpdir(), 'qwen-agent-view-paths'); + + expect( + getAgentViewSupervisorSocketPath({ + globalDir, + platform: 'linux', + }), + ).toBe(path.join(globalDir, 'daemon', 'supervisor.sock')); + }); + + it('falls back to a short runtime socket path when the store path is long', () => { + const runtimeDir = os.tmpdir(); const socketPath = getAgentViewSupervisorSocketPath({ - globalDir: '/tmp/qwen-agent-view-win', - platform: 'win32', + globalDir: path.join(runtimeDir, 'a'.repeat(140)), + platform: 'linux', + runtimeDir, }); - expect(socketPath.startsWith('\\\\.\\pipe\\qwen-agent-view-')).toBe(true); + + expect(path.dirname(socketPath)).toEqual( + expect.stringMatching( + new RegExp(`^${escapeRegExp(runtimeDir)}${escapeRegExp(path.sep)}`), + ), + ); + expect(path.basename(socketPath)).toMatch(/^[a-z0-9-]+\.sock$/); + expect(Buffer.byteLength(socketPath)).toBeLessThan(100); }); - it('returns a daemon-dir socket for short unix paths', () => { - const globalDir = '/tmp/qwen-av'; + it('uses a private runtime fallback directory by default', () => { + const runtimeDir = '/tmp'; const socketPath = getAgentViewSupervisorSocketPath({ - globalDir, + globalDir: path.join(runtimeDir, 'a'.repeat(140)), platform: 'linux', + runtimeDir, }); - expect(socketPath).toBe(path.join(globalDir, 'daemon', 'supervisor.sock')); + if (process.getuid === undefined) { + expect(path.basename(path.dirname(socketPath))).toMatch( + /^qwen-agent-view-[a-f0-9]{12}$/, + ); + } else { + expect(path.dirname(socketPath)).toBe( + path.join(runtimeDir, `qwen-agent-view-${process.getuid()}`), + ); + } + expect(path.basename(socketPath)).toMatch(/^[a-z0-9-]+\.sock$/); + expect(Buffer.byteLength(socketPath)).toBeLessThan(100); }); - it('falls back to the runtime dir for long unix paths', () => { - const globalDir = path.join(os.tmpdir(), `qwen-${'x'.repeat(200)}`); - const runtimeDir = '/tmp/qwen-runtime'; + it('uses the compact runtime tier when the fallback path is too long', () => { + const runtimeDir = path.join('/tmp', 'r'.repeat(50)); const socketPath = getAgentViewSupervisorSocketPath({ - globalDir, + globalDir: path.join(runtimeDir, 'a'.repeat(60)), platform: 'linux', runtimeDir, }); - expect(socketPath.startsWith(`${path.join(runtimeDir)}${path.sep}`)).toBe( - true, - ); - expect(socketPath.endsWith('.sock')).toBe(true); + + const uid = process.getuid?.(); + if (uid === undefined) { + expect(path.basename(path.dirname(socketPath))).toMatch( + /^qav-[a-f0-9]{8}$/, + ); + } else { + expect(path.dirname(socketPath)).toBe( + path.join(runtimeDir, `qav-${uid}`), + ); + } + expect(path.basename(socketPath)).toMatch(/^[a-f0-9]{12}\.sock$/); + expect(Buffer.byteLength(socketPath)).toBeLessThan(100); }); - it('is deterministic for the same global dir', () => { - const globalDir = '/tmp/qwen-agent-view-deterministic'; + it('computes a Windows named pipe path', () => { expect( - getAgentViewSupervisorSocketPath({ globalDir, platform: 'linux' }), - ).toBe(getAgentViewSupervisorSocketPath({ globalDir, platform: 'linux' })); + getAgentViewSupervisorSocketPath({ + globalDir: 'C:\\Users\\test\\.qwen', + platform: 'win32', + }), + ).toMatch(/^\\\\\.\\pipe\\qwen-agent-view-[a-f0-9]{12}$/); }); - it('isolates the tmpdir fallback socket in a per-uid directory', () => { - if (process.platform === 'win32') return; - const globalDir = path.join(os.tmpdir(), `qwen-${'x'.repeat(200)}`); - const socketPath = getAgentViewSupervisorSocketPath({ + it('creates a minimal default handler for status/list/shutdown', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const onShutdown = vi.fn(); + const handler = createAgentViewSupervisorHandler({ globalDir, platform: 'linux', + onShutdown, }); - const uid = process.getuid?.(); - expect(socketPath).toContain( - `${path.sep}qwen-agent-view-${uid}${path.sep}`, + + expect(await handler.status()).toMatchObject({ + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + pid: process.pid, + }); + await expect(handler.list()).resolves.toEqual([]); + await expect(handler.shutdown()).resolves.toEqual({ + shuttingDown: true, + workersStopped: 0, + workersFailed: [], + }); + expect(onShutdown).toHaveBeenCalledOnce(); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('dispatches a managed session into the Agent View store', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + let launchedArgv: string[] | undefined; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async (launch) => { + launchedArgv = launch.argv; + return fakePtyHost(); + }, + }); + + const result = await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + }); + + expect(result).toMatchObject({ state: 'created' }); + const sessionId = (result as { sessionId: string }).sessionId; + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionId, + ownership: 'managed', + sessionState: 'starting', + processState: 'starting', + }); + await expect( + readAgentViewActivity(sessionId, { globalDir }), + ).resolves.toMatchObject({ + summary: 'write tests', + }); + await expect( + readAgentViewLaunch(sessionId, { globalDir }), + ).resolves.toMatchObject({ + argv: expect.arrayContaining([ + '--session-id', + sessionId, + // Attached-value form: a bare token would be re-parsed by yargs + // when the prompt starts with '-'. + '--prompt-interactive=write tests', + ]), + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [expect.objectContaining({ sessionId })], + }); + await expect( + readAgentViewWorker(sessionId, { globalDir }), + ).resolves.toMatchObject({ + hostPid: 999_999_001, + workerPid: 999_999_002, + }); + await expect(handler.list()).resolves.toEqual([ + expect.objectContaining({ + sessionId, + state: expect.objectContaining({ + sessionId, + sessionState: 'starting', + }), + activity: expect.objectContaining({ + summary: 'write tests', + }), + worker: expect.objectContaining({ + workerPid: 999_999_002, + }), + }), + ]); + await expect(handler.peek?.({ sessionId })).resolves.toMatchObject({ + sessionId, + state: expect.objectContaining({ + sessionId, + sessionState: 'starting', + }), + activity: expect.objectContaining({ + summary: 'write tests', + }), + worker: expect.objectContaining({ + workerPid: 999_999_002, + }), + live: true, + }); + await expect( + handler.peek?.({ sessionId: sessionId.slice(0, 8) }), + ).resolves.toMatchObject({ + sessionId, + state: expect.objectContaining({ sessionId }), + }); + await expect( + handler.peek?.({ sessionId: sessionId.slice(0, 8).toUpperCase() }), + ).resolves.toMatchObject({ + sessionId, + state: expect.objectContaining({ sessionId }), + }); + expect(launchedArgv).toEqual( + expect.arrayContaining([ + '--session-id', + sessionId, + '--prompt-interactive=write tests', + ]), ); - expect(socketPath.endsWith('.sock')).toBe(true); + + await fs.rm(globalDir, { recursive: true, force: true }); }); -}); -describe('createAgentViewSupervisorHandler', () => { - const cleanupDirs: string[] = []; + it('lists sessions scoped through a symlinked project path', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const linksDir = path.join(globalDir, 'links'); + const realProject = path.join(globalDir, 'real-project'); + const linkedProject = path.join(linksDir, 'project'); + await fs.mkdir(linksDir); + await fs.mkdir(realProject); + await fs.symlink( + realProject, + linkedProject, + process.platform === 'win32' ? 'junction' : 'dir', + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: linkedProject, + })) as { sessionId: string }; + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token: await readWorkerTokenForTest(result.sessionId, globalDir), + cwd: realProject, + }); + + await expect(handler.list({ cwd: linksDir })).resolves.toEqual([ + expect.objectContaining({ sessionId: result.sessionId }), + ]); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('passes the prompt in argv while waiting for worker ready', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + let launchedArgv: string[] | undefined; + let token = ''; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + workerReadyTimeoutMs: 1000, + launchPtyHost: async (launch) => { + launchedArgv = launch.argv; + token = launch.env['QWEN_AGENT_VIEW_TOKEN'] ?? ''; + setImmediate(() => { + void Promise.resolve( + handler.workerEvent?.({ + type: 'ready', + sessionId: launch.sessionId, + token, + cwd: launch.activeCwd, + at: '2026-07-17T00:00:00.000Z', + }), + ).catch(() => {}); + }); + return fakePtyHost(); + }, + }); + + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; - afterEach(async () => { - await Promise.all( - cleanupDirs - .splice(0) - .map((dir) => fs.rm(dir, { recursive: true, force: true })), + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'idle', + processState: 'alive', + activeCwd: globalDir, + updatedAt: '2026-07-17T00:00:00.000Z', + }); + expect(launchedArgv).toEqual( + expect.arrayContaining(['--prompt-interactive=write tests']), ); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - async function makeGlobalDir(): Promise { - const dir = await fs.mkdtemp( - path.join(os.tmpdir(), 'qwen-agent-view-process-'), + it('replays the initial prompt until the worker reports working', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), ); - cleanupDirs.push(dir); - return dir; - } + const hosts: FakePtyHost[] = []; + const launches: AgentViewLaunchFile[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async (launch) => { + launches.push(launch); + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const firstToken = await readWorkerTokenForTest( + result.sessionId, + globalDir, + ); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token: firstToken, + cwd: globalDir, + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ initialPromptPending: true }); + + hosts[0]?.resolveExit(1); + await waitForSessionState( + result.sessionId, + globalDir, + (state) => state.processState === 'exited', + ); + await handler.respawn?.({ sessionId: result.sessionId }); + expect(launches[1]?.argv).toEqual( + expect.arrayContaining([ + `--resume=${result.sessionId}`, + '--prompt-interactive=write tests', + ]), + ); + + const replacementToken = await readWorkerTokenForTest( + result.sessionId, + globalDir, + ); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token: replacementToken, + sessionState: 'working', + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.not.toHaveProperty('initialPromptPending'); + + hosts[1]?.resolveExit(1); + await waitForSessionState( + result.sessionId, + globalDir, + (state) => state.processState === 'exited', + ); + await handler.respawn?.({ sessionId: result.sessionId }); + expect(launches[2]?.argv).toEqual( + expect.arrayContaining([`--resume=${result.sessionId}`]), + ); + expect(launches[2]?.argv).not.toContain('--prompt-interactive=write tests'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('fails the ready wait as soon as the launched host exits', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + workerReadyTimeoutMs: 1000, + launchPtyHost: async () => { + const host = fakePtyHost(); + setImmediate(() => host.resolveExit(1)); + return host; + }, + }); + + await expect( + handler.dispatch?.({ prompt: 'write tests', cwd: globalDir }), + ).rejects.toThrow('exited before ready'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not let a stale ready event resolve a replacement waiter', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const { sessionId } = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const oldToken = await readWorkerTokenForTest(sessionId, globalDir); + await patchSessionStateForTest(sessionId, globalDir, { + sessionState: 'completed', + processState: 'exited', + }); + + let replacementLaunched!: () => void; + const launched = new Promise((resolve) => { + replacementLaunched = resolve; + }); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + workerReadyTimeoutMs: 1000, + launchPtyHost: async () => { + replacementLaunched(); + return fakePtyHost(); + }, + }); + const readState = supervisorStore.readAgentViewSessionState; + let staleReadStarted!: () => void; + const readStarted = new Promise((resolve) => { + staleReadStarted = resolve; + }); + let releaseStaleRead!: () => void; + const staleReadGate = new Promise((resolve) => { + releaseStaleRead = resolve; + }); + const readSpy = vi + .spyOn(supervisorStore, 'readAgentViewSessionState') + .mockImplementationOnce(async (...args) => { + staleReadStarted(); + await staleReadGate; + return readState(...args); + }); + try { + const staleReady = handler.workerEvent?.({ + type: 'ready', + sessionId, + token: oldToken, + cwd: globalDir, + }); + await readStarted; + const respawn = Promise.resolve(handler.respawn?.({ sessionId })); + await launched; + releaseStaleRead(); + await expect(staleReady).resolves.toMatchObject({ accepted: true }); + const settled = vi.fn(); + void respawn.then(settled, settled); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).not.toHaveBeenCalled(); + + const newToken = await readWorkerTokenForTest(sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId, + token: newToken, + cwd: globalDir, + }); + await expect(respawn).resolves.toMatchObject({ respawned: true }); + } finally { + releaseStaleRead(); + readSpy.mockRestore(); + } + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('marks dispatch failed when the worker never reports ready', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + let sessionId = ''; + let host: FakePtyHost | undefined; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + workerReadyTimeoutMs: 1, + launchPtyHost: async (launch) => { + sessionId = launch.sessionId; + host = fakePtyHost(); + return host; + }, + }); + + await expect( + handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + }), + ).rejects.toThrow('did not report ready'); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'failed', + processState: 'exited', + lastError: { + code: 'pty_launch_failed', + message: expect.stringContaining('did not report ready'), + }, + }); + expect(host?.shutdowns).toBe(1); + await expect(handler.peek?.({ sessionId })).resolves.toMatchObject({ + live: false, + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('dispatches another shared-directory session when the previous session is idle', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + + const first = (await handler.dispatch?.({ + prompt: 'first', + cwd: globalDir, + })) as { sessionId: string }; + await writeSessionStateForTest(first.sessionId, globalDir, 'idle'); + await expect( + handler.dispatch?.({ + prompt: 'second', + cwd: globalDir, + }), + ).resolves.toMatchObject({ + state: 'created', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('dispatches another shared-directory session while a previous session is working', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + + const first = (await handler.dispatch?.({ + prompt: 'first', + cwd: globalDir, + })) as { sessionId: string }; + await writeSessionStateForTest(first.sessionId, globalDir, 'working'); + await expect( + handler.dispatch?.({ + prompt: 'second', + cwd: globalDir, + }), + ).resolves.toMatchObject({ + state: 'created', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); - async function writeSession( - globalDir: string, - overrides: Partial = {}, - ): Promise { + it('dispatches another session even when an existing session uses a user-owned worktree', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'first', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('Missing test session state.'); + } await writeAgentViewSessionState( { - schemaVersion: 1, - sessionId: 'session-1', + ...state, + activeCwd: path.join(globalDir, '.qwen', 'worktrees', 'topic'), + worktree: { + mode: 'worktree', + path: path.join(globalDir, '.qwen', 'worktrees', 'topic'), + owner: 'user', + }, + }, + { globalDir }, + ); + + await expect( + handler.dispatch?.({ + prompt: 'second', + cwd: globalDir, + }), + ).resolves.toMatchObject({ + state: 'created', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('adopts an existing idle session through a resumed native worker', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + let launched: AgentViewLaunchFile | undefined; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async (launch) => { + launched = launch; + return fakePtyHost(); + }, + }); + + await expect( + handler.adopt?.({ + sessionId, + projectCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project', 'src'), + approvalMode: 'yolo', + sandbox: JSON.stringify({ command: 'docker', image: 'test-image' }), + terminal: { columns: 100, rows: 40 }, + }), + ).resolves.toEqual({ sessionId, adopted: true }); + + expect(launched).toMatchObject({ + argv: [ + process.execPath, + process.argv[1], + `--resume=${sessionId}`, + '--approval-mode=yolo', + ], + env: expect.objectContaining({ + QWEN_SANDBOX: 'docker', + QWEN_SANDBOX_IMAGE: 'test-image', + }), + }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionId, + ownership: 'managed', + sessionState: 'starting', + processState: 'starting', + attachState: 'detached', + activeCwd: path.join(globalDir, 'project', 'src'), + projectCwd: path.join(globalDir, 'project'), + worktree: { mode: 'none' }, + }); + await expect( + readAgentViewLaunch(sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionId, + argv: launched?.argv, + activeCwd: path.join(globalDir, 'project', 'src'), + projectCwd: path.join(globalDir, 'project'), + approvalMode: 'yolo', + terminal: { columns: 100, rows: 40 }, + }); + await expect( + readAgentViewActivity(sessionId, { globalDir }), + ).resolves.toMatchObject({ + summary: 'Backgrounded from native session', + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [ + expect.objectContaining({ + sessionId, + activeCwd: path.join(globalDir, 'project', 'src'), + projectCwd: path.join(globalDir, 'project'), + }), + ], + }); + await expect( + readAgentViewWorker(sessionId, { globalDir }), + ).resolves.toMatchObject({ + hostPid: 999_999_001, + workerPid: 999_999_002, + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('preserves ready state when it races the adoption commit', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + let handler!: ReturnType; + let readyInjected = false; + const writeWorker = supervisorStore.writeAgentViewWorker; + const writeSpy = vi + .spyOn(supervisorStore, 'writeAgentViewWorker') + .mockImplementation(async (...args) => { + await writeWorker(...args); + const [writtenSessionId, worker] = args; + if ( + !readyInjected && + writtenSessionId === sessionId && + worker.hostPid !== undefined + ) { + readyInjected = true; + await handler.workerEvent?.({ + type: 'ready', + sessionId, + token: await readWorkerTokenForTest(sessionId, globalDir), + cwd: globalDir, + }); + } + }); + try { + handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + launchPtyHost: async () => fakePtyHost(), + }); + + await expect( + handler.adopt?.({ + sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + terminal: { columns: 80, rows: 24 }, + }), + ).resolves.toEqual({ sessionId, adopted: true }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ ownership: 'managed', sessionState: 'idle', - processState: 'hibernated', + processState: 'alive', + }); + } finally { + writeSpy.mockRestore(); + await fs.rm(globalDir, { recursive: true, force: true }); + } + }); + + it('treats adoption of an already managed session as idempotent', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const sessionsBefore = (await handler.list()) as unknown[]; + + await expect( + handler.adopt?.({ + sessionId: result.sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + terminal: { columns: 80, rows: 24 }, + }), + ).resolves.toEqual({ + sessionId: result.sessionId, + adopted: false, + alreadyManaged: true, + }); + await expect(handler.list()).resolves.toHaveLength(sessionsBefore.length); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('refuses to adopt a session while removal is incomplete', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const now = new Date().toISOString(); + await writeAgentViewSessionState( + { + schemaVersion: 1, + sessionId, + ownership: 'removing', + sessionState: 'idle', + processState: 'alive', attachState: 'detached', projectCwd: globalDir, originalCwd: globalDir, activeCwd: globalDir, - createdAt: '2026-07-17T00:00:00.000Z', - updatedAt: '2026-07-17T00:00:00.000Z', + createdAt: now, + updatedAt: now, worktree: { mode: 'none' }, - ...overrides, }, { globalDir }, ); - } - - it('reports session count and socket path in status', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir); - const handler = createAgentViewSupervisorHandler({ globalDir }); + const launchPtyHost = vi.fn(async () => fakePtyHost()); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost, + }); + + await expect( + handler.adopt?.({ + sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + terminal: { columns: 80, rows: 24 }, + }), + ).rejects.toThrow('is being removed'); + expect(launchPtyHost).not.toHaveBeenCalled(); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ ownership: 'removing' }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('fails closed on adopting records with live unverified pids', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const staleId = '123e4567-e89b-12d3-a456-426614174000'; + const liveId = '223e4567-e89b-12d3-a456-426614174000'; + const now = new Date().toISOString(); + const adoptingState = (sessionId: string): AgentViewSessionStateFile => ({ + schemaVersion: 1, + sessionId, + ownership: 'adopting', + sessionState: 'idle', + processState: 'starting', + attachState: 'detached', + projectCwd: path.join(globalDir, 'project'), + originalCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + createdAt: now, + updatedAt: now, + worktree: { mode: 'none' }, + }); + await writeAgentViewSessionState( + { + ...adoptingState(staleId), + updatedAt: new Date(Date.now() - 60_000).toISOString(), + }, + { globalDir }, + ); + await writeAgentViewSessionState(adoptingState(liveId), { globalDir }); + await writeAgentViewWorker( + staleId, + { + schemaVersion: 1, + hostPid: process.pid, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + { globalDir }, + ); + await writeAgentViewWorker( + liveId, + { + schemaVersion: 1, + hostPid: process.pid, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + { globalDir }, + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + try { + await expect( + handler.adopt?.({ + sessionId: staleId, + projectCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + terminal: { columns: 80, rows: 24 }, + }), + ).resolves.toEqual({ + sessionId: staleId, + adopted: false, + alreadyManaged: true, + }); + await expect( + readAgentViewSessionState(staleId, { globalDir }), + ).resolves.toMatchObject({ ownership: 'adopting' }); + expect(killSpy.mock.calls.filter(([, signal]) => signal !== 0)).toEqual( + [], + ); + } finally { + killSpy.mockRestore(); + } + + await expect( + handler.adopt?.({ + sessionId: liveId, + projectCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + terminal: { columns: 80, rows: 24 }, + }), + ).resolves.toEqual({ + sessionId: liveId, + adopted: false, + alreadyManaged: true, + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rolls back adoption when the resumed worker cannot be launched', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + throw new Error('spawn failed'); + }, + }); + + await expect( + handler.adopt?.({ + sessionId, + projectCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + terminal: { columns: 80, rows: 24 }, + }), + ).rejects.toThrow('spawn failed'); + + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + processState: 'exited', + lastError: { + code: 'adoption_failed', + message: 'spawn failed', + }, + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [], + }); + await expect(handler.list()).resolves.toEqual([]); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('fails a stale adopting re-adoption into a terminal state', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + const now = new Date().toISOString(); + await writeAgentViewSessionState( + { + schemaVersion: 1, + sessionId, + ownership: 'adopting', + sessionState: 'idle', + processState: 'starting', + attachState: 'detached', + projectCwd: path.join(globalDir, 'project'), + originalCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + createdAt: now, + updatedAt: now, + worktree: { mode: 'none' }, + }, + { globalDir }, + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + throw new Error('spawn failed'); + }, + }); + + await expect( + handler.adopt?.({ + sessionId, + projectCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + terminal: { columns: 80, rows: 24 }, + }), + ).rejects.toThrow('spawn failed'); + + // The stale 'adopting' record must not be restored: the session lands in + // a terminal unmanaged state so a later adopt can retry. + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + processState: 'exited', + lastError: { + code: 'adoption_failed', + message: 'spawn failed', + }, + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rolls back adoption when the resumed worker reports a different cwd', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + let host: FakePtyHost | undefined; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + workerReadyTimeoutMs: 1000, + launchPtyHost: async (launch) => { + setImmediate(() => { + void Promise.resolve( + handler.workerEvent?.({ + type: 'ready', + sessionId: launch.sessionId, + token: launch.env['QWEN_AGENT_VIEW_TOKEN'], + cwd: path.join(globalDir, 'other'), + }), + ).catch(() => {}); + }); + host = fakePtyHost(); + return host; + }, + }); + + await expect( + handler.adopt?.({ + sessionId, + projectCwd: path.join(globalDir, 'project'), + activeCwd: path.join(globalDir, 'project'), + terminal: { columns: 80, rows: 24 }, + }), + ).rejects.toThrow('reported cwd'); + + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + processState: 'exited', + lastError: { + code: 'adoption_failed', + message: expect.stringContaining('reported cwd'), + }, + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [], + }); + expect(host?.shutdowns).toBe(1); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('applies worker sideband events to session state', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + + await expect( + handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + capabilities: ['ready'], + summary: 'ready summary', + at: '2026-07-17T00:00:00.000Z', + }), + ).resolves.toEqual({ + sessionId: result.sessionId, + accepted: true, + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'idle', + processState: 'alive', + activeCwd: globalDir, + updatedAt: '2026-07-17T00:00:00.000Z', + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + summary: 'ready summary', + capabilities: ['ready'], + lastActivityAt: '2026-07-17T00:00:00.000Z', + }); + await expect( + readAgentViewWorker(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + lastHeartbeatAt: '2026-07-17T00:00:00.000Z', + }); + + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'approval', + at: '2026-07-17T00:00:01.000Z', + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'needs_input', + processState: 'alive', + updatedAt: '2026-07-17T00:00:01.000Z', + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + waitingFor: 'approval', + lastActivityAt: '2026-07-17T00:00:01.000Z', + }); + + const blockedState = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!blockedState) { + throw new Error('expected blocked state'); + } + await writeAgentViewSessionState( + { + ...blockedState, + lastError: { + code: 'stale_worker', + message: 'old failure', + at: '2026-07-17T00:00:01.000Z', + }, + }, + { globalDir }, + ); + await writeAgentViewActivity( + result.sessionId, + { + schemaVersion: 1, + waitingFor: 'approval', + inputKind: 'blocking', + lastResult: 'old result', + queuedPromptCount: 1, + queuedPromptPreview: 'old prompt', + lastQueuedPromptAt: '2026-07-17T00:00:01.000Z', + lastActivityAt: '2026-07-17T00:00:01.000Z', + capabilities: ['ready'], + }, + { globalDir }, + ); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + capabilities: ['ready'], + at: '2026-07-17T00:00:02.000Z', + }); + const readyState = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + const readyActivity = await readAgentViewActivity(result.sessionId, { + globalDir, + }); + expect(readyState).not.toHaveProperty('lastError'); + expect(readyActivity).not.toHaveProperty('waitingFor'); + expect(readyActivity).not.toHaveProperty('inputKind'); + expect(readyActivity).not.toHaveProperty('lastResult'); + expect(readyActivity).toMatchObject({ queuedPromptCount: 1 }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects worker sideband calls with missing or invalid tokens', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + await expect( + handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + sessionState: 'idle', + }), + ).rejects.toThrow('worker token is required'); + await expect( + handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token: 'wrong-token', + sessionState: 'idle', + }), + ).rejects.toThrow('worker token is invalid'); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'starting', + }); + await expect( + handler.workerControl?.({ + sessionId: result.sessionId, + token: 'wrong-token', + }), + ).rejects.toThrow('worker token is invalid'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('queues follow-up text for detached live sessions', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + capabilities: ['reply', 'hibernate'], + }); + + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'next step' }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + expect(hosts[0]?.input).toBe(''); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + events: [ + { + type: 'prompt', + sequence: 1, + promptId: expect.any(String), + text: 'next step', + at: expect.any(String), + }, + ], + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ sessionState: 'idle' }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + summary: 'write tests', + queuedPromptCount: 1, + queuedPromptPreview: 'next step', + capabilities: ['reply', 'hibernate'], + }); + const firstPromptId = ( + await readAgentViewActivity(result.sessionId, { globalDir }) + )?.queuedPromptId; + if (!firstPromptId) throw new Error('Missing queued prompt id.'); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'working', + promptId: firstPromptId, + }); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'queued follow-up' }), + ).rejects.toThrow('is waiting for the previous response'); + + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'completed', + lastResult: 'done', + promptId: firstPromptId, + }); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'continue' }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + expect(hosts[0]?.input).toBe(''); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + events: [ + { + type: 'prompt', + sequence: 2, + promptId: expect.any(String), + text: 'continue', + at: expect.any(String), + }, + ], + }); + const secondPromptId = ( + await readAgentViewActivity(result.sessionId, { globalDir }) + )?.queuedPromptId; + if (!secondPromptId) throw new Error('Missing queued prompt id.'); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + inputKind: 'soft', + lastResult: 'Anything else?', + promptId: secondPromptId, + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.not.toMatchObject({ queuedPromptCount: 1 }); + await writeAgentViewActivity( + result.sessionId, + { + schemaVersion: 1, + inputKind: 'soft', + queuedPromptCount: 1, + queuedPromptPreview: 'legacy prompt', + queuedPromptId: undefined, + queuedPromptText: undefined, + queuedPromptDeliveredAt: undefined, + lastQueuedPromptAt: '2026-07-17T00:00:00.000Z', + lastActivityAt: '2026-07-17T00:00:01.000Z', + capabilities: ['reply', 'hibernate'], + }, + { globalDir }, + ); + await expect( + handler.peek?.({ sessionId: result.sessionId }), + ).resolves.not.toMatchObject({ + activity: { queuedPromptCount: 1 }, + }); + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'no' }), + ).resolves.toEqual({ sessionId: result.sessionId, answered: true }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + capabilities: ['reply', 'hibernate'], + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }, 20_000); + + it('serializes concurrent follow-up prompts for the same session', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + await handler.kill?.({ sessionId: result.sessionId }); + const settled = await Promise.allSettled([ + handler.send?.({ sessionId: result.sessionId, text: 'first' }), + handler.send?.({ sessionId: result.sessionId, text: 'second' }), + ]); + + expect(settled.filter((item) => item.status === 'fulfilled')).toHaveLength( + 1, + ); + const rejected = settled.find((item) => item.status === 'rejected'); + expect( + rejected && rejected.status === 'rejected' ? rejected.reason : undefined, + ).toMatchObject({ + message: expect.stringContaining('waiting for the previous response'), + }); + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + const controls = (await handler.workerControl?.({ + sessionId: result.sessionId, + token, + })) as { events: Array<{ type: string; text?: string }> }; + expect(controls.events).toEqual([ + expect.objectContaining({ + type: 'prompt', + text: expect.stringMatching(/^(first|second)$/), + }), + ]); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('serializes concurrent answers for the same session', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'approval', + }); + + const settled = await Promise.allSettled([ + handler.answer?.({ sessionId: result.sessionId, text: 'yes' }), + handler.answer?.({ sessionId: result.sessionId, text: 'no' }), + ]); + + expect(settled.filter((item) => item.status === 'fulfilled')).toHaveLength( + 1, + ); + const rejected = settled.find((item) => item.status === 'rejected'); + expect( + rejected && rejected.status === 'rejected' ? rejected.reason : undefined, + ).toMatchObject({ + message: expect.stringContaining('waiting for the previous response'), + }); + const controls = (await handler.workerControl?.({ + sessionId: result.sessionId, + token, + })) as { events: Array<{ type: string; text?: string }> }; + expect(controls.events).toEqual([ + expect.objectContaining({ + type: 'answer', + text: expect.stringMatching(/^(yes|no)$/), + }), + ]); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('clears pending worker controls when a session is killed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + await handler.kill?.({ sessionId: result.sessionId }); + await handler.send?.({ sessionId: result.sessionId, text: 'follow up' }); + await handler.kill?.({ sessionId: result.sessionId }); + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + events: [], + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not queue a control when the queued-prompt marker write fails', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + + // The marker write surfaces EISDIR; with the marker persisted before + // the control push, the failed send leaves no control behind. + const activityPath = getAgentViewSessionPaths(result.sessionId, { + globalDir, + }).activityPath; + await fs.rm(activityPath); + await fs.mkdir(activityPath); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'task A' }), + ).rejects.toThrow(); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + + // A retry after the store recovers delivers the prompt exactly once. + await fs.rm(activityPath, { recursive: true, force: true }); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'task A' }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + events: [expect.objectContaining({ type: 'prompt', text: 'task A' })], + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('delivers a durable queued prompt after the daemon restarts', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const firstDaemon = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await firstDaemon.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await firstDaemon.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + const prompt = `first prompt ${'x'.repeat(700)}`; + await expect( + firstDaemon.send?.({ + sessionId: result.sessionId, + text: prompt, + }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ queuedPromptCount: 1 }); + + // The durable prompt remains private at the supervisor API boundary. + await expect(firstDaemon.list()).resolves.not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + activity: expect.objectContaining({ queuedPromptText: prompt }), + }), + ]), + ); + await expect( + firstDaemon.peek?.({ sessionId: result.sessionId }), + ).resolves.not.toMatchObject({ + activity: { queuedPromptText: prompt }, + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + queuedPromptCount: 1, + queuedPromptId: expect.any(String), + queuedPromptText: prompt, + }); + + // A restarted daemon re-serves the prompt until the worker reports that + // processing began. The pre-submit idle report must not acknowledge it. + const restartedDaemon = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const control = (await restartedDaemon.workerControl?.({ + sessionId: result.sessionId, + token, + })) as { events: Array<{ type: string; promptId?: string }> }; + expect(control).toMatchObject({ + events: [ + expect.objectContaining({ + type: 'prompt', + promptId: expect.any(String), + text: prompt, + }), + ], + }); + const queuedActivity = await readAgentViewActivity(result.sessionId, { + globalDir, + }); + expect(control.events[0]?.promptId).toBe(queuedActivity?.queuedPromptId); + await restartedDaemon.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'idle', + }); + await expect( + restartedDaemon.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + events: [ + expect.objectContaining({ promptId: control.events[0]?.promptId }), + ], + }); + await expect( + restartedDaemon.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'working', + promptId: control.events[0]?.promptId, + }), + ).resolves.toMatchObject({ accepted: true }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ sessionState: 'working' }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ queuedPromptDeliveredAt: expect.any(String) }); + const completedAt = ( + await readAgentViewActivity(result.sessionId, { globalDir }) + )?.lastQueuedPromptAt; + await restartedDaemon.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'completed', + promptId: control.events[0]?.promptId, + at: completedAt, + }); + await expect( + restartedDaemon.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.not.toMatchObject({ queuedPromptCount: 1 }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('persists prompt acknowledgements across restarts and transient reads', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const firstDaemon = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const { sessionId } = (await firstDaemon.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(sessionId, globalDir); + await firstDaemon.workerEvent?.({ + type: 'ready', + sessionId, + token, + cwd: globalDir, + }); + await firstDaemon.send?.({ sessionId, text: 'create the PR' }); + const firstControl = (await firstDaemon.workerControl?.({ + sessionId, + token, + })) as { events: Array<{ type: string; promptId?: string }> }; + expect(firstControl).toMatchObject({ + events: [ + expect.objectContaining({ type: 'prompt', text: 'create the PR' }), + ], + }); + const firstPromptId = firstControl.events[0]?.promptId; + if (!firstPromptId) throw new Error('Missing prompt id.'); + await expect( + readAgentViewActivity(sessionId, { globalDir }), + ).resolves.not.toMatchObject({ + queuedPromptDeliveredAt: expect.any(String), + }); + await firstDaemon.workerEvent?.({ + type: 'state', + sessionId, + token, + sessionState: 'working', + promptId: firstPromptId, + }); + await expect( + readAgentViewActivity(sessionId, { globalDir }), + ).resolves.toMatchObject({ queuedPromptDeliveredAt: expect.any(String) }); + + const restartedDaemon = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + await restartedDaemon.workerEvent?.({ + type: 'state', + sessionId, + token, + sessionState: 'completed', + lastResult: 'done', + promptId: firstPromptId, + }); + await expect( + readAgentViewActivity(sessionId, { globalDir }), + ).resolves.not.toMatchObject({ queuedPromptCount: 1 }); + await expect( + restartedDaemon.workerControl?.({ sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + + await firstDaemon.send?.({ sessionId, text: 'task A' }); + const secondControl = (await firstDaemon.workerControl?.({ + sessionId, + token, + })) as { events: Array<{ type: string; promptId?: string }> }; + expect(secondControl).toMatchObject({ + events: [expect.objectContaining({ type: 'prompt', text: 'task A' })], + }); + await firstDaemon.workerEvent?.({ + type: 'state', + sessionId, + token, + sessionState: 'working', + promptId: secondControl.events[0]?.promptId, + }); + const readActivity = supervisorStore.readAgentViewActivity; + let activityReads = 0; + const readSpy = vi + .spyOn(supervisorStore, 'readAgentViewActivity') + .mockImplementation((...args) => + ++activityReads === 2 + ? Promise.resolve(undefined) + : readActivity(...args), + ); + try { + await expect( + firstDaemon.workerEvent?.({ + type: 'state', + sessionId, + token, + sessionState: 'working', + }), + ).resolves.toMatchObject({ accepted: true }); + } finally { + readSpy.mockRestore(); + } + + await expect( + firstDaemon.workerControl?.({ sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('preserves a queued prompt across an unplanned host exit and respawn', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'follow up' }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + const firstControl = (await handler.workerControl?.({ + sessionId: result.sessionId, + token, + })) as { events: Array<{ type: string; promptId?: string }> }; + expect(firstControl).toMatchObject({ + events: [expect.objectContaining({ type: 'prompt', text: 'follow up' })], + }); + const promptId = firstControl.events[0]?.promptId; + if (!promptId) throw new Error('Missing prompt id.'); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'working', + promptId, + }); + + hosts[0]?.resolveExit(1); + await waitForSessionState( + result.sessionId, + globalDir, + (state) => + state.sessionState === 'failed' && state.processState === 'exited', + ); + // The crash is unplanned, so the accepted prompt stays queued for the + // next worker instead of being destroyed with the dead host. + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ queuedPromptCount: 1 }); + + await expect( + handler.respawn?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ sessionId: result.sessionId, respawned: true }); + const nextToken = await readWorkerTokenForTest(result.sessionId, globalDir); + const replacementControl = (await handler.workerControl?.({ + sessionId: result.sessionId, + token: nextToken, + })) as { events: Array<{ type: string; promptId?: string }> }; + expect(replacementControl).toMatchObject({ + events: [expect.objectContaining({ type: 'prompt', text: 'follow up' })], + }); + expect(replacementControl.events[0]?.promptId).toBe(promptId); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects an old worker control request after respawn rotates its token', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const { sessionId } = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const oldToken = await readWorkerTokenForTest(sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId, + token: oldToken, + cwd: globalDir, + }); + await handler.send?.({ sessionId, text: 'follow up' }); + const control = (await handler.workerControl?.({ + sessionId, + token: oldToken, + })) as { events: Array<{ promptId?: string }> }; + const promptId = control.events[0]?.promptId; + if (!promptId) throw new Error('Missing prompt id.'); + await patchSessionStateForTest(sessionId, globalDir, { + sessionState: 'completed', + processState: 'exited', + }); + + let shutdownReached!: () => void; + const reached = new Promise((resolve) => { + shutdownReached = resolve; + }); + let releaseShutdown!: () => void; + const shutdownGate = new Promise((resolve) => { + releaseShutdown = resolve; + }); + hosts[0]!.shutdown = async () => { + shutdownReached(); + await shutdownGate; + hosts[0]!.resolveExit(0); + }; + + const respawn = handler.respawn?.({ sessionId }); + await reached; + const readWorker = supervisorStore.readAgentViewWorker; + let oldRequestAuthenticated!: () => void; + const authenticated = new Promise((resolve) => { + oldRequestAuthenticated = resolve; + }); + const readSpy = vi + .spyOn(supervisorStore, 'readAgentViewWorker') + .mockImplementation(async (...args) => { + const worker = await readWorker(...args); + oldRequestAuthenticated(); + return worker; + }); + try { + const staleControl = handler.workerControl?.({ + sessionId, + token: oldToken, + }); + await Promise.race([ + authenticated, + new Promise((resolve) => setTimeout(resolve, 25)), + ]); + releaseShutdown(); + await expect(respawn).resolves.toMatchObject({ respawned: true }); + await expect(staleControl).rejects.toThrow('worker token is invalid'); + } finally { + releaseShutdown(); + readSpy.mockRestore(); + } + + const newToken = await readWorkerTokenForTest(sessionId, globalDir); + await expect( + handler.workerControl?.({ sessionId, token: newToken }), + ).resolves.toMatchObject({ + events: [expect.objectContaining({ type: 'prompt', promptId })], + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('answers needs-input sessions and recovers stale attach markers', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'yes' }), + ).rejects.toThrow('is not waiting for input'); + + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'approval', + }); + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'yes' }), + ).resolves.toEqual({ sessionId: result.sessionId, answered: true }); + expect(hosts[0]?.input).toBe(''); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + events: [ + { + type: 'answer', + sequence: 1, + text: 'yes', + at: expect.any(String), + }, + ], + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.not.toMatchObject({ queuedPromptCount: 1 }); + + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'approval', + }); + await patchSessionStateForTest(result.sessionId, globalDir, { + attachState: 'attached', + }); + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'yes' }), + ).resolves.toEqual({ sessionId: result.sessionId, answered: true }); + await handler.workerControl?.({ + sessionId: result.sessionId, + token, + }); + + await patchSessionStateForTest(result.sessionId, globalDir, { + attachState: 'detached', + }); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'response', + }); + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'src/index.ts' }), + ).resolves.toEqual({ sessionId: result.sessionId, answered: true }); + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'src/app.ts' }), + ).rejects.toThrow('is waiting for the previous response'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not answer a different question that appears during reconnect', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const { sessionId } = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(sessionId, globalDir); + await handler.workerEvent?.({ + type: 'state', + sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'approval-a', + }); + + const readActivity = supervisorStore.readAgentViewActivity; + let activityReads = 0; + let releaseRead = () => {}; + const readBlocked = new Promise((resolve) => { + releaseRead = resolve; + }); + let markReadStarted = () => {}; + const readStarted = new Promise((resolve) => { + markReadStarted = resolve; + }); + const readSpy = vi + .spyOn(supervisorStore, 'readAgentViewActivity') + .mockImplementation(async (...args) => { + if (++activityReads === 2) { + markReadStarted(); + await readBlocked; + } + return readActivity(...args); + }); + try { + const answer = handler.answer?.({ sessionId, text: 'yes' }); + await readStarted; + const state = await readAgentViewSessionState(sessionId, { globalDir }); + const activity = await readAgentViewActivity(sessionId, { globalDir }); + if (!state || !activity) throw new Error('Missing test session.'); + const changedAt = new Date( + Date.parse(state.updatedAt) + 1000, + ).toISOString(); + await writeAgentViewSessionState( + { ...state, updatedAt: changedAt }, + { globalDir }, + ); + await writeAgentViewActivity( + sessionId, + { + ...activity, + waitingFor: 'approval-b', + lastActivityAt: changedAt, + }, + { globalDir }, + ); + releaseRead(); + await expect(answer).rejects.toThrow( + 'is no longer waiting for the same input', + ); + } finally { + releaseRead(); + readSpy.mockRestore(); + } + await expect( + handler.workerControl?.({ sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not revive a stopped session during an attach handshake', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const originalPatch = supervisorStore.patchAgentViewSessionState; + let releaseAttachWrite = () => {}; + const attachWriteBlocked = new Promise((resolve) => { + releaseAttachWrite = resolve; + }); + let markAttachWriteStarted = () => {}; + const attachWriteStarted = new Promise((resolve) => { + markAttachWriteStarted = resolve; + }); + const patchSpy = vi + .spyOn(supervisorStore, 'patchAgentViewSessionState') + .mockImplementation(async (sessionId, patch, options) => { + if (patch.attachState === 'attached') { + markAttachWriteStarted(); + await attachWriteBlocked; + } + return originalPatch(sessionId, patch, options); + }); + const socket = new FakeAttachSocket(); + const attached = handler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await attachWriteStarted; + + await handler.stop?.({ sessionId: result.sessionId }); + await expect( + handler.respawn?.({ sessionId: result.sessionId }), + ).rejects.toThrow('currently attached'); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'follow up' }), + ).rejects.toThrow('currently attached elsewhere'); + expect(hosts).toHaveLength(1); + + releaseAttachWrite(); + await socket.waitForOutput('request-1'); + socket.closeInput(); + await attached; + patchSpy.mockRestore(); + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects send and answer while a live attach is open', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'approval', + }); + const socket = new FakeAttachSocket(); + const attached = handler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'follow up' }), + ).rejects.toThrow('currently attached elsewhere'); + await expect( + handler.answer?.({ sessionId: result.sessionId, text: 'yes' }), + ).rejects.toThrow('currently attached elsewhere'); + + socket.closeInput(); + await attached; + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('attaches a stream to a running PTY host with a single lease', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + const socket = new FakeAttachSocket(); + + const attached = handler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: 'request-1', + ok: true, + result: { sessionId: result.sessionId }, + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ attachState: 'attached' }); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + events: [ + { + type: 'redraw', + sequence: 1, + at: expect.any(String), + }, + ], + }); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toEqual({ + sessionId: result.sessionId, + events: [], + }); + + socket.pushInput('hello'); + await waitFor(() => hosts[0]?.input === 'hello'); + hosts[0]?.emitData('world'); + await socket.waitForOutput('world'); + + const secondSocket = new FakeAttachSocket(); + await handler.attachStream?.( + { sessionId: result.sessionId }, + secondSocket as unknown as Socket, + 'request-2', + ); + expect(JSON.parse(secondSocket.outputLine())).toMatchObject({ + id: 'request-2', + ok: false, + error: { code: 'already_attached' }, + }); + + socket.closeInput(); + await attached; + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ attachState: 'detached' }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('serializes concurrent attach recovery for the same inactive session', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + let launchCount = 0; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + launchCount++; + return fakePtyHost(); + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await handler.kill?.({ sessionId: result.sessionId }); + const firstSocket = new FakeAttachSocket(); + const secondSocket = new FakeAttachSocket(); + + const firstAttached = handler.attachStream?.( + { sessionId: result.sessionId }, + firstSocket as unknown as Socket, + 'request-1', + ); + const secondAttached = handler.attachStream?.( + { sessionId: result.sessionId }, + secondSocket as unknown as Socket, + 'request-2', + ); + await Promise.all([ + firstSocket.waitForOutput('request-1'), + secondSocket.waitForOutput('request-2'), + ]); + + expect(launchCount).toBe(2); + // The winner is whichever attach acquires the setup lock first, which + // is nondeterministic; assert the pair of outcomes, not the order. + const outcomes = [ + JSON.parse(firstSocket.outputLine()) as { + id: string; + ok: boolean; + error?: { code?: string }; + }, + JSON.parse(secondSocket.outputLine()) as { + id: string; + ok: boolean; + error?: { code?: string }; + }, + ]; + expect(outcomes.map((o) => o.id).sort()).toEqual([ + 'request-1', + 'request-2', + ]); + expect(outcomes.find((o) => o.ok)).toMatchObject({ ok: true }); + expect(outcomes.find((o) => !o.ok)).toMatchObject({ + ok: false, + error: { code: 'already_attached' }, + }); + + firstSocket.closeInput(); + secondSocket.closeInput(); + await firstAttached; + await secondAttached; + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('keeps an active attach lease alive with heartbeats', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-07-17T00:00:00.000Z')); + try { + const socket = new FakeAttachSocket(); + const attached = handler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + await vi.advanceTimersByTimeAsync( + DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS + 5_000, + ); + + const secondSocket = new FakeAttachSocket(); + await handler.attachStream?.( + { sessionId: result.sessionId }, + secondSocket as unknown as Socket, + 'request-2', + ); + expect(JSON.parse(secondSocket.outputLine())).toMatchObject({ + id: 'request-2', + ok: false, + error: { code: 'already_attached' }, + }); + + socket.closeInput(); + await attached; + + const reattachSocket = new FakeAttachSocket(); + const reattached = handler.attachStream?.( + { sessionId: result.sessionId }, + reattachSocket as unknown as Socket, + 'request-3', + ); + await reattachSocket.waitForOutput('request-3'); + expect(JSON.parse(reattachSocket.outputLine())).toMatchObject({ + id: 'request-3', + ok: true, + result: { sessionId: result.sessionId }, + }); + reattachSocket.closeInput(); + await reattached; + } finally { + vi.useRealTimers(); + await fs.rm(globalDir, { recursive: true, force: true }); + } + }); + + it('respawns an inactive managed session on attach when no live PTY host is loaded', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await seedHandler.stop?.({ sessionId: result.sessionId }); + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('Missing test session state.'); + } + await writeAgentViewSessionState( + { + ...state, + sessionState: 'idle', + processState: 'exited', + attachState: 'detached', + }, + { globalDir }, + ); + const hosts: FakePtyHost[] = []; + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_004 + hosts.length); + hosts.push(host); + return host; + }, + }); + const socket = new FakeAttachSocket(); + + const attached = recoveredHandler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + expect(hosts).toHaveLength(1); + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: 'request-1', + ok: true, + result: { sessionId: result.sessionId }, + }); + await expect( + readAgentViewWorker(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + workerPid: 999_999_004, + }); + + socket.closeInput(); + await attached; + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('returns an attach error when respawned worker does not become ready', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await seedHandler.stop?.({ sessionId: result.sessionId }); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + waitForWorkerReady: true, + workerReadyTimeoutMs: 1, + launchPtyHost: async () => fakePtyHost(999_999_004), + }); + const socket = new FakeAttachSocket(); + + await recoveredHandler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: 'request-1', + ok: false, + error: { + message: expect.stringContaining('did not report ready'), + }, + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'failed', + processState: 'exited', + }); + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('fails attach quickly for an active session with a stale persisted PTY host', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const worker = await readAgentViewWorker(result.sessionId, { globalDir }); + if (!worker) { + throw new Error('Missing worker state.'); + } + const hostEndpoint = shortHostSocketPath(); + await writeAgentViewWorker( + result.sessionId, + { + ...worker, + hostEndpoint: hostEndpoint.path, + }, + { globalDir }, + ); + const launchPtyHost = vi.fn(async () => fakePtyHost(999_999_005)); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost, + }); + const socket = new FakeAttachSocket(); + + await recoveredHandler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + + expect(launchPtyHost).not.toHaveBeenCalled(); + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: 'request-1', + ok: false, + error: { + code: 'pty_launch_failed', + message: expect.stringContaining('is still starting'), + }, + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'starting', + processState: 'starting', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + if (hostEndpoint.dir) { + await fs.rm(hostEndpoint.dir, { recursive: true, force: true }); + } + }); + + it('respawns failed or stopped sessions on attach', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('Missing test session state.'); + } + const hosts: FakePtyHost[] = []; + const launchedArgv: string[][] = []; + const launchPtyHost = vi.fn(async (launch: AgentViewLaunchFile) => { + launchedArgv.push(launch.argv); + const host = fakePtyHost(999_999_005 + hosts.length); + hosts.push(host); + return host; + }); + + for (const sessionState of ['failed', 'stopped'] as const) { + await writeAgentViewSessionState( + { + ...state, + sessionState, + processState: 'exited', + attachState: 'detached', + }, + { globalDir }, + ); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost, + }); + const socket = new FakeAttachSocket(); + + const attached = recoveredHandler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + `request-${sessionState}`, + ); + await socket.waitForOutput(`request-${sessionState}`); + + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: `request-${sessionState}`, + ok: true, + result: { sessionId: result.sessionId }, + }); + socket.closeInput(); + await attached; + } + expect(launchPtyHost).toHaveBeenCalledTimes(2); + expect(hosts).toHaveLength(2); + for (const argv of launchedArgv) { + expect(argv).toEqual( + expect.arrayContaining([`--resume=${result.sessionId}`]), + ); + expect(argv).not.toContain('--session-id'); + expect(argv).not.toContain('--prompt-interactive'); + } + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('clears a stale persisted attach before respawning a stopped session on attach', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('Missing test session state.'); + } + // A daemon restart during an attach leaves this flag behind. + await writeAgentViewSessionState( + { + ...state, + sessionState: 'stopped', + processState: 'exited', + attachState: 'attached', + }, + { globalDir }, + ); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_005), + }); + const socket = new FakeAttachSocket(); + + const attached = recoveredHandler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: 'request-1', + ok: true, + result: { sessionId: result.sessionId }, + }); + + socket.closeInput(); + await attached; + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('respawns a stopped session on attach instead of bridging to the old host', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + await expect( + handler.stop?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + stopped: true, + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'stopped', + processState: 'alive', + }); + + const socket = new FakeAttachSocket(); + const attached = handler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + + expect(hosts).toHaveLength(2); + expect(hosts[0]?.shutdowns).toBe(1); + expect(JSON.parse(socket.outputLine())).toMatchObject({ + id: 'request-1', + ok: true, + result: { sessionId: result.sessionId }, + }); + socket.pushInput('hello after stop'); + await waitFor(() => hosts[1]?.input === 'hello after stop'); + expect(hosts[0]?.input).toBe(''); + + socket.closeInput(); + await attached; + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('reconnects a persisted PTY host before respawning on attach', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const liveHost = fakePtyHost(999_999_005); + const hostEndpoint = shortHostSocketPath(); + const hostServer = createAgentViewPtyHostServer( + liveHost, + hostEndpoint.path, + ); + await hostServer.listen(); + const worker = await readAgentViewWorker(result.sessionId, { globalDir }); + if (!worker) { + throw new Error('Missing worker state.'); + } + await writeAgentViewWorker( + result.sessionId, + { + ...worker, + hostEndpoint: hostEndpoint.path, + }, + { globalDir }, + ); + const launchPtyHost = vi.fn(async () => { + throw new Error('should not respawn'); + }); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost, + }); + const socket = new FakeAttachSocket(); + + const attached = recoveredHandler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + socket.pushInput('hello'); + await waitFor(() => liveHost.input === 'hello'); + + expect(launchPtyHost).not.toHaveBeenCalled(); + await expect( + readAgentViewWorker(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + workerPid: 999_999_005, + hostEndpoint: hostEndpoint.path, + }); + + socket.closeInput(); + await attached; + await hostServer.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + if (hostEndpoint.dir) { + await fs.rm(hostEndpoint.dir, { recursive: true, force: true }); + } + }); + + it('reconnects a persisted PTY host for logs and stop', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const liveHost = fakePtyHost(999_999_005); + liveHost.output.append('hello logs'); + const hostEndpoint = shortHostSocketPath(); + const hostServer = createAgentViewPtyHostServer( + liveHost, + hostEndpoint.path, + ); + await hostServer.listen(); + const worker = await readAgentViewWorker(result.sessionId, { globalDir }); + if (!worker) { + throw new Error('Missing worker state.'); + } + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await writeAgentViewWorker( + result.sessionId, + { + ...worker, + hostEndpoint: hostEndpoint.path, + }, + { globalDir }, + ); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + throw new Error('should not respawn'); + }, + }); + + await expect( + recoveredHandler.logs?.({ sessionId: result.sessionId }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + output: 'hello logs', + live: true, + }); + await expect( + recoveredHandler.stop?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + stopped: true, + }); + expect(liveHost.killedWith).toBeUndefined(); + const restartedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + }); + await restartedHandler.list(); + await expect( + restartedHandler.workerControl?.({ + sessionId: result.sessionId, + token, + }), + ).resolves.toMatchObject({ + events: [ + { + type: 'stop', + sequence: 1, + at: expect.any(String), + }, + ], + }); + + await hostServer.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + if (hostEndpoint.dir) { + await fs.rm(hostEndpoint.dir, { recursive: true, force: true }); + } + }); + + it('detaches the active attach stream from a worker sideband event', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + const socket = new FakeAttachSocket(); + + const attached = handler.attachStream?.( + { sessionId: result.sessionId }, + socket as unknown as Socket, + 'request-1', + ); + await socket.waitForOutput('request-1'); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ attachState: 'attached' }); + + await expect( + handler.workerEvent?.({ + type: 'detach', + sessionId: result.sessionId, + token, + }), + ).resolves.toEqual({ + sessionId: result.sessionId, + accepted: true, + }); + await attached; + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ attachState: 'detached' }); + + const secondSocket = new FakeAttachSocket(); + const secondAttached = handler.attachStream?.( + { sessionId: result.sessionId }, + secondSocket as unknown as Socket, + 'request-2', + ); + await secondSocket.waitForOutput('request-2'); + expect(JSON.parse(secondSocket.outputLine())).toMatchObject({ + id: 'request-2', + ok: true, + }); + secondSocket.closeInput(); + await secondAttached; + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('resizes the running PTY host through supervisor IPC', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + await expect( + handler.resize?.({ + sessionId: result.sessionId, + columns: 120, + rows: 40, + }), + ).resolves.toEqual({ + sessionId: result.sessionId, + resized: true, + }); + expect(hosts[0]?.resizes).toEqual([{ columns: 120, rows: 40 }]); + + await expect( + handler.resize?.({ + sessionId: result.sessionId, + columns: 0, + rows: 40, + }), + ).rejects.toThrow('Agent View columns must be a positive integer.'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('respawns a refreshed stopped session on the first attempt', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('Missing test session state.'); + } + // A supervisor dying inside the graceful-stop window leaves the record + // claiming the worker is still alive. + await writeAgentViewSessionState( + { + ...state, + sessionState: 'stopped', + processState: 'alive', + attachState: 'detached', + }, + { globalDir }, + ); + const hosts: FakePtyHost[] = []; + const readState = supervisorStore.readAgentViewSessionState; + let spawned = false; + let workerAtFirstPostSpawnRead: AgentViewWorkerFile | undefined; + const readSpy = vi + .spyOn(supervisorStore, 'readAgentViewSessionState') + .mockImplementation(async (...args) => { + if (spawned && !workerAtFirstPostSpawnRead) { + workerAtFirstPostSpawnRead = await readAgentViewWorker( + result.sessionId, + { globalDir }, + ); + } + return readState(...args); + }); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_005); + hosts.push(host); + spawned = true; + return host; + }, + }); + + try { + await expect( + recoveredHandler.respawn?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ sessionId: result.sessionId, respawned: true }); + } finally { + readSpy.mockRestore(); + } + expect(workerAtFirstPostSpawnRead).toMatchObject({ + hostPid: 999_999_001, + workerPid: 999_999_005, + }); + // The refresh repairs the record with a new updatedAt; the respawn must + // not mistake that rewrite for a user stop and kill the fresh host. + expect(hosts).toHaveLength(1); + expect(hosts[0]?.killedWith).toBeUndefined(); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'starting', + processState: 'starting', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('fails closed instead of signaling unauthenticated stored pids', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002, 999_999_001), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + try { + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_003, 999_999_004), + }); + await expect( + recoveredHandler.stop?.({ sessionId: result.sessionId }), + ).rejects.toThrow('identity cannot be verified'); + await expect( + recoveredHandler.respawn?.({ sessionId: result.sessionId }), + ).rejects.toThrow(); + const signalled = killSpy.mock.calls + .filter(([, signal]) => signal !== 0) + .map(([pid]) => pid); + expect(signalled).toEqual([]); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'starting', + processState: 'starting', + }); + } finally { + killSpy.mockRestore(); + } + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('preserves a queued prompt across a stop and a send-triggered revive', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'first prompt' }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + await expect( + handler.stop?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ sessionId: result.sessionId, stopped: true }); + + // The revive keeps the accepted prompt queued (and its persisted + // marker), so the follow-up send must reject instead of silently + // dropping the first prompt. + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'second prompt' }), + ).rejects.toThrow('is waiting for the previous response'); + + // The replacement worker receives only the accepted prompt: the + // superseded stop control was filtered before launch. + const nextToken = await readWorkerTokenForTest(result.sessionId, globalDir); + await expect( + handler.workerControl?.({ + sessionId: result.sessionId, + token: nextToken, + }), + ).resolves.toMatchObject({ + events: [ + expect.objectContaining({ type: 'prompt', text: 'first prompt' }), + ], + }); + expect(hosts).toHaveLength(2); + expect(hosts[0]?.shutdowns).toBe(1); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not requeue a predecessor stop during respawn healing', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await expect( + handler.stop?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ sessionId: result.sessionId, stopped: true }); + + let retireReached!: () => void; + const reached = new Promise((resolve) => { + retireReached = resolve; + }); + let releaseRetire!: () => void; + const retireGate = new Promise((resolve) => { + releaseRetire = resolve; + }); + const predecessor = hosts[0]; + predecessor!.shutdown = async () => { + retireReached(); + await retireGate; + predecessor!.shutdowns += 1; + predecessor!.resolveExit(0); + }; + + const respawn = handler.respawn?.({ sessionId: result.sessionId }); + await reached; + const listSnapshots = supervisorStore.listAgentViewSessionSnapshots; + let snapshotsRead!: () => void; + const read = new Promise((resolve) => { + snapshotsRead = resolve; + }); + const snapshotSpy = vi + .spyOn(supervisorStore, 'listAgentViewSessionSnapshots') + .mockImplementation(async (...args) => { + const snapshots = await listSnapshots(...args); + snapshotsRead(); + return snapshots; + }); + const list = handler.list(); + await read; + await Promise.resolve(); + snapshotSpy.mockRestore(); + releaseRetire(); + + await expect(respawn).resolves.toEqual({ + sessionId: result.sessionId, + respawned: true, + }); + await list; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ events: [] }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not queue a stop control for an unauthenticated stored worker', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_002, 999_999_001), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + try { + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(999_999_003, 999_999_004), + }); + await expect( + recoveredHandler.stop?.({ sessionId: result.sessionId }), + ).rejects.toThrow('identity cannot be verified'); + await expect( + recoveredHandler.workerControl?.({ + sessionId: result.sessionId, + token, + }), + ).resolves.toMatchObject({ events: [] }); + expect(killSpy.mock.calls.filter(([, signal]) => signal !== 0)).toEqual( + [], + ); + } finally { + killSpy.mockRestore(); + } + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('serializes kill with a concurrent respawn', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const first = hosts[0]; + if (!first) throw new Error('Missing first PTY host.'); + first.kill = (signal) => { + first.killedWith = signal; + }; + + const killing = handler.kill?.({ sessionId: result.sessionId }); + await waitFor(() => first.killedWith === 'SIGKILL'); + const respawning = handler.respawn?.({ sessionId: result.sessionId }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(hosts).toHaveLength(1); + + first.resolveExit(1); + await expect(killing).resolves.toMatchObject({ killed: true }); + await expect(respawning).resolves.toMatchObject({ respawned: true }); + expect(hosts).toHaveLength(2); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'starting', + processState: 'starting', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('bounds the wait when a killed host never exits', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const host = fakePtyHost(); + let markKillCalled = () => {}; + const killCalled = new Promise((resolve) => { + markKillCalled = resolve; + }); + host.kill = (signal) => { + host.killedWith = signal; + markKillCalled(); + }; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => host, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + vi.useFakeTimers(); + try { + const killing = handler.kill?.({ + sessionId: result.sessionId, + }) as Promise; + await killCalled; + const outcome = killing?.then( + () => undefined, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(10_001); + await expect(outcome).resolves.toMatchObject({ + message: expect.stringContaining('Timed out waiting'), + }); + } finally { + vi.useRealTimers(); + } + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('manages logs, stop, respawn, and remove for a dispatched session', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const launches: AgentViewLaunchFile[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async (launch) => { + launches.push(launch); + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + hosts[0]?.output.append('hello from worker'); + + await expect( + handler.respawn?.({ sessionId: result.sessionId }), + ).rejects.toThrow( + `Agent View session ${result.sessionId} cannot be respawned: its process is starting.`, + ); + await expect(handler.respawn?.({ all: true })).resolves.toEqual({ + all: true, + results: [ + { + sessionId: result.sessionId, + skipped: true, + reason: 'its process is starting', + }, + ], + }); + + await expect( + handler.logs?.({ sessionId: result.sessionId }), + ).resolves.toMatchObject({ + sessionId: result.sessionId, + output: 'hello from worker', + live: true, + }); + + await expect( + handler.stop?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + stopped: true, + }); + expect(hosts[0]?.killedWith).toBeUndefined(); + await expect( + handler.workerControl?.({ sessionId: result.sessionId, token }), + ).resolves.toMatchObject({ + events: [ + { + type: 'stop', + sequence: 1, + at: expect.any(String), + }, + ], + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'stopped', + processState: 'alive', + }); + const staleLaunch = await readAgentViewLaunch(result.sessionId, { + globalDir, + }); + if (!staleLaunch) { + throw new Error('expected launch record'); + } + await writeAgentViewLaunch( + { + ...staleLaunch, + entrypoint: '/old/qwen', + argv: ['/old/node', '/old/qwen', '--resume', result.sessionId], + }, + { globalDir }, + ); + await handler.kill?.({ sessionId: result.sessionId }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'stopped', + processState: 'exited', + }); + + await expect( + handler.respawn?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + respawned: true, + }); + expect(hosts).toHaveLength(2); + expect(launches[1]).toMatchObject({ + entrypoint: process.argv[1], + argv: [ + process.execPath, + process.argv[1], + `--resume=${result.sessionId}`, + '--prompt-interactive=write tests', + ], + }); + await expect( + readAgentViewLaunch(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + entrypoint: process.argv[1], + argv: [ + process.execPath, + process.argv[1], + `--resume=${result.sessionId}`, + '--prompt-interactive=write tests', + ], + }); + await expect( + readAgentViewWorker(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + workerPid: 999_999_003, + }); + + await expect( + handler.rename?.({ + sessionId: result.sessionId, + displayName: ' Build Fix ', + }), + ).resolves.toEqual({ + sessionId: result.sessionId, + displayName: 'Build Fix', + }); + await expect( + handler.pin?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + pinned: true, + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [ + expect.objectContaining({ + sessionId: result.sessionId, + displayName: 'Build Fix', + pinned: true, + }), + ], + }); + + await expect( + handler.pin?.({ sessionId: result.sessionId, pinned: false }), + ).resolves.toEqual({ + sessionId: result.sessionId, + pinned: false, + }); + await expect( + handler.rename?.({ sessionId: result.sessionId, displayName: ' ' }), + ).resolves.toEqual({ + sessionId: result.sessionId, + displayName: '', + }); + const renamedRoster = await readAgentViewRoster({ globalDir }); + expect(renamedRoster.sessions[0]).toMatchObject({ + sessionId: result.sessionId, + pinned: false, + }); + expect(renamedRoster.sessions[0]?.displayName).toBeUndefined(); + + await expect( + handler.remove?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + removed: true, + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [], + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + }); + await expect(handler.list()).resolves.toEqual([]); + await expect( + handler.respawn?.({ sessionId: result.sessionId }), + ).rejects.toThrow(`Agent View session ${result.sessionId} is not managed.`); + await expect( + handler.logs?.({ sessionId: result.sessionId }), + ).rejects.toThrow(`Agent View session ${result.sessionId} is not managed.`); + await expect(handler.respawn?.({ all: true })).resolves.toEqual({ + all: true, + results: [], + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('maps an unplanned non-zero exit of a live session to failed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + + hosts[0]?.resolveExit(1); + await waitForSessionState( + result.sessionId, + globalDir, + (state) => + state.sessionState === 'failed' && state.processState === 'exited', + ); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('maps a clean exit of a live session to completed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + + hosts[0]?.resolveExit(0); + await waitForSessionState( + result.sessionId, + globalDir, + (state) => + state.sessionState === 'completed' && state.processState === 'exited', + ); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('does not turn an unreachable remote host into a terminal verdict', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const host = fakePtyHost(); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => host, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + try { + host.resolveUnreachable(); + await vi.waitFor(async () => { + await expect( + handler.peek?.({ sessionId: result.sessionId }), + ).resolves.toMatchObject({ live: false }); + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'idle', + processState: 'alive', + }); + expect(killSpy.mock.calls.filter(([, signal]) => signal !== 0)).toEqual( + [], + ); + } finally { + killSpy.mockRestore(); + } + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('keeps a queued prompt marker that is newer than the worker event', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + await expect( + handler.send?.({ sessionId: result.sessionId, text: 'queued prompt' }), + ).resolves.toEqual({ sessionId: result.sessionId, sent: true }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ queuedPromptCount: 1 }); + + const queuedAt = ( + await readAgentViewActivity(result.sessionId, { globalDir }) + )?.lastQueuedPromptAt; + if (!queuedAt) throw new Error('Missing queued prompt timestamp.'); + // Millisecond-equal events can still predate the accepted send, so they + // must not dequeue the fresh marker. + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'needs_input', + waitingFor: 'response', + at: queuedAt, + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + queuedPromptCount: 1, + queuedPromptPreview: 'queued prompt', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('labels a host crash while hibernating as failed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + }); + await patchSessionStateForTest(result.sessionId, globalDir, { + processState: 'hibernating', + }); + + hosts[0]?.resolveExit(1); + // A crash mid-hibernation is a failure, not a clean hibernation. + await waitForSessionState( + result.sessionId, + globalDir, + (state) => + state.sessionState === 'failed' && state.processState === 'exited', + ); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('ignores stale host exits after a session respawns', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('expected session state'); + } + await writeAgentViewSessionState( + { + ...state, + sessionState: 'completed', + processState: 'exited', + }, + { globalDir }, + ); + + await expect( + handler.respawn?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + respawned: true, + }); + hosts[0]?.resolveExit(1); + // Poll across a bounded window: the stale host exit must never clobber + // the respawned session's state. + for (let attempt = 0; attempt < 20; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 10)); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'starting', + processState: 'starting', + }); + } + await expect( + readAgentViewWorker(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + workerPid: 999_999_003, + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('removes Agent View ownership without touching legacy worktree metadata', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(result.sessionId, { + globalDir, + }); + if (!state) { + throw new Error('Missing test session state.'); + } + await writeAgentViewSessionState( + { + ...state, + worktree: { + mode: 'worktree', + path: '/workspace/project/.qwen/worktrees/agent-1234567', + owner: 'agent-view', + }, + }, + { globalDir }, + ); + + await expect( + handler.remove?.({ sessionId: result.sessionId }), + ).resolves.toEqual({ + sessionId: result.sessionId, + removed: true, + }); + expect(hosts[0]?.killedWith).toBeUndefined(); + expect(hosts[0]?.shutdowns).toBe(1); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [], + }); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + processState: 'exited', + worktree: { + mode: 'worktree', + owner: 'agent-view', + }, + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('releases an exited managed session for foreground continue', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-42d3-a456-426614174000'; + const now = '2026-07-17T00:00:00.000Z'; + await writeAgentViewSessionState( + { + schemaVersion: 1, + sessionId, + ownership: 'managed', + sessionState: 'idle', + processState: 'exited', + attachState: 'detached', + projectCwd: globalDir, + originalCwd: globalDir, + activeCwd: globalDir, + createdAt: now, + updatedAt: now, + worktree: { mode: 'none' }, + }, + { globalDir }, + ); + await upsertAgentViewRosterEntry( + { + sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + createdAt: now, + updatedAt: now, + }, + { globalDir }, + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + }); + + await expect(handler.release?.({ sessionId })).resolves.toMatchObject({ + sessionId, + released: true, + }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ ownership: 'unmanaged' }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [], + }); + + await writeAgentViewSessionState( + { + ...(await readAgentViewSessionState(sessionId, { globalDir }))!, + ownership: 'removing', + }, + { globalDir }, + ); + await upsertAgentViewRosterEntry( + { + sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + createdAt: now, + updatedAt: now, + }, + { globalDir }, + ); + + await expect(handler.release?.({ sessionId })).resolves.toMatchObject({ + sessionId, + released: true, + resumedRelease: true, + }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ ownership: 'unmanaged' }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('releases an exited session after healing a stale attach', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-42d3-a456-426614174000'; + const now = '2026-07-17T00:00:00.000Z'; + await writeAgentViewSessionState( + { + schemaVersion: 1, + sessionId, + ownership: 'managed', + sessionState: 'idle', + processState: 'exited', + attachState: 'attached', + projectCwd: globalDir, + originalCwd: globalDir, + activeCwd: globalDir, + createdAt: now, + updatedAt: now, + worktree: { mode: 'none' }, + }, + { globalDir }, + ); + await upsertAgentViewRosterEntry( + { + sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + createdAt: now, + updatedAt: now, + }, + { globalDir }, + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + }); + + await expect(handler.release?.({ sessionId })).resolves.toMatchObject({ + sessionId, + released: true, + }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + attachState: 'detached', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('finishes an interrupted remove after a daemon restart', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const { sessionId } = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const state = await readAgentViewSessionState(sessionId, { globalDir }); + if (!state) throw new Error('Missing test session.'); + await writeAgentViewSessionState( + { ...state, ownership: 'removing' }, + { globalDir }, + ); + const restarted = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + }); + + await expect(restarted.list()).resolves.toEqual([]); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [], + }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'unmanaged', + processState: 'exited', + }); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects unknown session management requests', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + }); + + await expect( + handler.remove?.({ sessionId: 'missing-session' }), + ).rejects.toThrow('No Agent View session found for missing-session.'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects a dispatch with a blank prompt', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + + await expect( + handler.dispatch?.({ prompt: ' ', cwd: globalDir }), + ).rejects.toThrow('Agent View dispatch prompt cannot be empty.'); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects send and answer with blank text', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '123e4567-e89b-12d3-a456-426614174000'; + await writeAgentViewSessionState( + managedSessionStateForTest(sessionId, globalDir), + { globalDir }, + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + + await expect(handler.send?.({ sessionId, text: ' ' })).rejects.toThrow( + 'Agent View message text is required.', + ); + await expect(handler.answer?.({ sessionId, text: '' })).rejects.toThrow( + 'Agent View message text is required.', + ); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('rejects an ambiguous session id prefix', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + await writeAgentViewSessionState( + managedSessionStateForTest( + 'aaaaaaaa-0000-0000-0000-000000000001', + globalDir, + ), + { globalDir }, + ); + await writeAgentViewSessionState( + managedSessionStateForTest( + 'aaaaaaaa-0000-0000-0000-000000000002', + globalDir, + ), + { globalDir }, + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + + await expect( + handler.send?.({ sessionId: 'aaaaaaaa', text: 'hello' }), + ).rejects.toThrow( + 'Agent View session id aaaaaaaa is ambiguous. Use a longer id.', + ); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('notifies subscribers when session state changes', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const socket = new FakeAttachSocket(); + + await handler.subscribe?.(undefined, socket as unknown as Socket, 'sub-1'); + await socket.waitForOutput('sub-1'); + + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await socket.waitForOutput('"type":"changed"'); + await handler.workerEvent?.({ + type: 'state', + sessionId: result.sessionId, + token, + sessionState: 'idle', + }); + await waitFor( + () => socket.output().split('"type":"changed"').length >= 3, + (notify) => { + setTimeout(notify, 10); + }, + ); + + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('hibernates only idle or completed detached unpinned live sessions', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + hibernationPolicy: { idleMs: 1000, autoExit: false }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; + }, + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + at: '2026-07-17T00:00:00.000Z', + }); + await handler.workerEvent?.({ + type: 'heartbeat', + sessionId: result.sessionId, + token, + at: '2026-07-17T00:00:05.000Z', + }); + await expect( + readAgentViewActivity(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + lastActivityAt: '2026-07-17T00:00:00.000Z', + }); + await expect( + readAgentViewWorker(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + lastHeartbeatAt: '2026-07-17T00:00:05.000Z', + }); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [result.sessionId], + }); + expect(hosts[0]?.shutdowns).toBe(1); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ processState: 'hibernated' }); + await new Promise((resolve) => setImmediate(resolve)); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'idle', + processState: 'hibernated', + }); + + await handler.respawn?.({ sessionId: result.sessionId }); + await writeSessionStateForTest(result.sessionId, globalDir, 'working'); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [], + }); + expect(hosts[1]?.killedWith).toBeUndefined(); + + await writeSessionStateForTest(result.sessionId, globalDir, 'needs_input'); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [], + }); + expect(hosts[1]?.killedWith).toBeUndefined(); + + await writeAgentViewActivity( + result.sessionId, + { + schemaVersion: 1, + waitingFor: 'response', + inputKind: 'soft', + lastActivityAt: '2026-07-17T00:00:00.000Z', + capabilities: [], + }, + { globalDir }, + ); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [result.sessionId], + }); + expect(hosts[1]?.shutdowns).toBe(1); - const status = (await handler.status()) as { - state: string; - socketPath: string; - sessions: number; - }; + await handler.respawn?.({ sessionId: result.sessionId }); + await writeSessionStateForTest(result.sessionId, globalDir, 'idle'); + await patchSessionStateForTest(result.sessionId, globalDir, { + attachState: 'attached', + }); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [], + }); + expect(hosts[2]?.killedWith).toBeUndefined(); - expect(status.state).toBe('ready'); - expect(status.sessions).toBe(1); - expect(status.socketPath).toBe( - getAgentViewSupervisorSocketPath({ - globalDir, - platform: process.platform, - }), - ); - }); + await patchSessionStateForTest(result.sessionId, globalDir, { + attachState: 'detached', + }); + await handler.pin?.({ sessionId: result.sessionId, pinned: true }); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [], + }); + expect(hosts[2]?.killedWith).toBeUndefined(); - it('reports status even when a jobs entry cannot be read', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir); - // A directory where state.json should be makes the read fail with EISDIR; - // status must stay healthy rather than wedge on the bad entry. - const bad = getAgentViewSessionPaths('bad', { globalDir }); - await fs.mkdir(bad.statePath, { recursive: true }); - const handler = createAgentViewSupervisorHandler({ globalDir }); + await handler.pin?.({ sessionId: result.sessionId, pinned: false }); + await writeSessionStateForTest(result.sessionId, globalDir, 'completed'); + await expect(handler.hibernateIdleSessions()).resolves.toEqual({ + hibernated: [result.sessionId], + }); + expect(hosts[2]?.shutdowns).toBe(1); - const status = (await handler.status()) as { - state: string; - sessions: number; - }; - expect(status.state).toBe('ready'); - expect(status.sessions).toBe(1); + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('acknowledges subscribers and registers a close handler', () => { + it('restores alive when a prompt lands inside the hibernation mark window', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const hosts: FakePtyHost[] = []; const handler = createAgentViewSupervisorHandler({ - globalDir: '/tmp/qwen-agent-view-subscribe', - }); - const writes: string[] = []; - let onClose: (() => void) | undefined; - const socket = { - write: (chunk: string) => { - writes.push(chunk); - }, - once: (event: string, listener: () => void) => { - if (event === 'close') onClose = listener; + globalDir, + platform: 'linux', + hibernationPolicy: { idleMs: 1000, autoExit: false }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => { + const host = fakePtyHost(999_999_002 + hosts.length); + hosts.push(host); + return host; }, - } as unknown as Socket; + }); + const first = (await handler.dispatch?.({ + prompt: 'task A', + cwd: globalDir, + })) as { sessionId: string }; + const second = (await handler.dispatch?.({ + prompt: 'task B', + cwd: globalDir, + })) as { sessionId: string }; + await writeSessionStateForTest(first.sessionId, globalDir, 'idle'); + await writeSessionStateForTest(second.sessionId, globalDir, 'idle'); + // The sweep processes snapshots newest-updatedAt first: second (:03) + // before first (:02), so gating second's shutdown suspends the sweep + // ahead of first's mark. + await patchSessionStateForTest(second.sessionId, globalDir, { + updatedAt: '2026-07-17T00:00:03.000Z', + }); + await patchSessionStateForTest(first.sessionId, globalDir, { + updatedAt: '2026-07-17T00:00:02.000Z', + }); + + let gateReached!: () => void; + const reached = new Promise((resolve) => { + gateReached = resolve; + }); + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + const gatedHost = hosts[1]; + gatedHost!.shutdown = async () => { + gateReached(); + await gate; + // The sweep now confirms the exit before the hibernated verdict: the + // gated drain must settle the exit like a real drain completing. + gatedHost!.shutdowns += 1; + gatedHost!.resolveExit(0); + }; - handler.subscribe?.(undefined, socket, 'req-1'); + const sweep = handler.hibernateIdleSessions(); + await reached; + // A real send lands on first after the sweep's snapshot read. The prompt + // lock serializes its durable write ahead of hibernation's final gate. + await expect( + handler.send?.({ sessionId: first.sessionId, text: 'late prompt' }), + ).resolves.toEqual({ sessionId: first.sessionId, sent: true }); + releaseGate(); - expect(writes).toHaveLength(1); - expect(JSON.parse(writes[0] ?? '')).toEqual({ - id: 'req-1', - ok: true, - result: { subscribed: true }, + await expect(sweep).resolves.toEqual({ hibernated: [second.sessionId] }); + await expect( + readAgentViewSessionState(first.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'idle', + processState: 'alive', + }); + await expect( + readAgentViewSessionState(second.sessionId, { globalDir }), + ).resolves.toMatchObject({ processState: 'hibernated' }); + expect(hosts[0]?.killedWith).toBeUndefined(); + const firstToken = await readWorkerTokenForTest(first.sessionId, globalDir); + await expect( + handler.workerControl?.({ + sessionId: first.sessionId, + token: firstToken, + }), + ).resolves.toMatchObject({ + events: [ + expect.objectContaining({ type: 'prompt', text: 'late prompt' }), + ], }); - expect(onClose).toBeTypeOf('function'); - expect(() => onClose?.()).not.toThrow(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('does not request shutdown when there are no sessions', async () => { - const globalDir = await makeGlobalDir(); + it('auto-exits after every managed worker is hibernated and subscribers leave', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); const onShutdown = vi.fn(); const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 0 }, + platform: 'linux', onShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 0 }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => fakePtyHost(), + }); + const socket = new FakeAttachSocket(); + await handler.subscribe?.(undefined, socket as unknown as Socket, 'sub-1'); + await socket.waitForOutput('sub-1'); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + at: '2026-07-17T00:00:00.000Z', }); await expect(handler.tickIdleHibernation()).resolves.toEqual({ - hibernated: [], + hibernated: [result.sessionId], shutdownRequested: false, }); expect(onShutdown).not.toHaveBeenCalled(); + + socket.closeInput(); + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [], + shutdownRequested: true, + }); + expect(onShutdown).toHaveBeenCalledOnce(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('does not request shutdown when autoExit is disabled', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir); - const onShutdown = vi.fn(); - const handler = createAgentViewSupervisorHandler({ - globalDir, - hibernationPolicy: { autoExit: false, autoExitGraceMs: 0 }, - onShutdown, + it('does not auto-exit when disabled, empty, or a worker is still alive', async () => { + const emptyDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const emptyShutdown = vi.fn(); + const emptyHandler = createAgentViewSupervisorHandler({ + globalDir: emptyDir, + platform: 'linux', + onShutdown: emptyShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 0 }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + }); + await expect(emptyHandler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [], + shutdownRequested: false, }); + expect(emptyShutdown).not.toHaveBeenCalled(); + await fs.rm(emptyDir, { recursive: true, force: true }); - await expect(handler.tickIdleHibernation()).resolves.toEqual({ + const activeDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const activeShutdown = vi.fn(); + const activeHandler = createAgentViewSupervisorHandler({ + globalDir: activeDir, + platform: 'linux', + onShutdown: activeShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 0 }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => fakePtyHost(), + }); + await activeHandler.dispatch?.({ + prompt: 'write tests', + cwd: activeDir, + }); + await expect(activeHandler.tickIdleHibernation()).resolves.toEqual({ hibernated: [], shutdownRequested: false, }); - expect(onShutdown).not.toHaveBeenCalled(); + expect(activeShutdown).not.toHaveBeenCalled(); + await fs.rm(activeDir, { recursive: true, force: true }); + + const disabledDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const disabledShutdown = vi.fn(); + const disabledHandler = createAgentViewSupervisorHandler({ + globalDir: disabledDir, + platform: 'linux', + onShutdown: disabledShutdown, + hibernationPolicy: { + idleMs: 1000, + autoExit: false, + autoExitGraceMs: 0, + }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await disabledHandler.dispatch?.({ + prompt: 'write tests', + cwd: disabledDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, disabledDir); + await disabledHandler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: disabledDir, + at: '2026-07-17T00:00:00.000Z', + }); + await expect(disabledHandler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [result.sessionId], + shutdownRequested: false, + }); + expect(disabledShutdown).not.toHaveBeenCalled(); + await fs.rm(disabledDir, { recursive: true, force: true }); }); - it('does not request shutdown while a managed session is still alive', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir, { processState: 'alive' }); + it('waits through the default supervisor auto-exit grace period', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); const onShutdown = vi.fn(); + let nowMs = Date.parse('2026-07-17T00:00:10.000Z'); const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 0 }, + platform: 'linux', onShutdown, + hibernationPolicy: { idleMs: 1000 }, + now: () => new Date(nowMs), + launchPtyHost: async () => fakePtyHost(), + }); + const socket = new FakeAttachSocket(); + await handler.subscribe?.(undefined, socket as unknown as Socket, 'sub-1'); + await socket.waitForOutput('sub-1'); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + at: '2026-07-17T00:00:00.000Z', + }); + + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [result.sessionId], + shutdownRequested: false, }); + socket.closeInput(); await expect(handler.tickIdleHibernation()).resolves.toEqual({ hibernated: [], shutdownRequested: false, }); - expect(onShutdown).not.toHaveBeenCalled(); + nowMs += 10 * 60 * 1000 - 1; + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [], + shutdownRequested: false, + }); + nowMs += 1; + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [], + shutdownRequested: true, + }); + expect(onShutdown).toHaveBeenCalledOnce(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('does not request shutdown when a session is not managed', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir, { ownership: 'unmanaged' }); + it('auto-exits after the last managed session is removed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); const onShutdown = vi.fn(); const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 0 }, + platform: 'linux', onShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 0 }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => fakePtyHost(), }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await expect( + handler.remove?.({ sessionId: result.sessionId }), + ).resolves.toMatchObject({ + removed: true, + }); await expect(handler.tickIdleHibernation()).resolves.toEqual({ hibernated: [], - shutdownRequested: false, + shutdownRequested: true, }); - expect(onShutdown).not.toHaveBeenCalled(); + expect(onShutdown).toHaveBeenCalledOnce(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('requests shutdown once only-inactive managed sessions pass the grace period', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir); + it('restarts the auto-exit grace period after a session becomes alive again', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); const onShutdown = vi.fn(); + let nowMs = Date.parse('2026-07-17T00:00:10.000Z'); const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 0 }, + platform: 'linux', onShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 60_000 }, + now: () => new Date(nowMs), + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + const token = await readWorkerTokenForTest(result.sessionId, globalDir); + await handler.workerEvent?.({ + type: 'ready', + sessionId: result.sessionId, + token, + cwd: globalDir, + at: '2026-07-17T00:00:00.000Z', + }); + + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [result.sessionId], + shutdownRequested: false, + }); + + nowMs += 60_000; + await patchSessionStateForTest(result.sessionId, globalDir, { + processState: 'alive', + }); + // Simulate a real respawn: update the worker record with a running + // PID so refreshMissingWorkerState recognizes the session as alive. + const worker = await readAgentViewWorker(result.sessionId, { + globalDir, + }); + if (worker) { + await writeAgentViewWorker( + result.sessionId, + { ...worker, hostPid: process.pid }, + { globalDir }, + ); + } + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [], + shutdownRequested: false, }); + await patchSessionStateForTest(result.sessionId, globalDir, { + processState: 'hibernated', + }); + await expect(handler.tickIdleHibernation()).resolves.toEqual({ + hibernated: [], + shutdownRequested: false, + }); + + nowMs += 60_000; await expect(handler.tickIdleHibernation()).resolves.toEqual({ hibernated: [], shutdownRequested: true, }); - expect(onShutdown).toHaveBeenCalledTimes(1); + expect(onShutdown).toHaveBeenCalledOnce(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('requests shutdown when only-exited managed sessions pass the grace period', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir, { processState: 'exited' }); + it('does not auto-exit while an adoption is in flight', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); const onShutdown = vi.fn(); const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 0 }, + platform: 'linux', onShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 0 }, + now: () => new Date('2026-07-17T00:00:10.000Z'), + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await patchSessionStateForTest(result.sessionId, globalDir, { + processState: 'hibernated', }); + const adoptingId = '223e4567-e89b-12d3-a456-426614174000'; + const now = new Date().toISOString(); + await writeAgentViewSessionState( + { + schemaVersion: 1, + sessionId: adoptingId, + ownership: 'adopting', + sessionState: 'idle', + processState: 'starting', + attachState: 'detached', + projectCwd: globalDir, + originalCwd: globalDir, + activeCwd: globalDir, + createdAt: now, + updatedAt: now, + worktree: { mode: 'none' }, + }, + { globalDir }, + ); await expect(handler.tickIdleHibernation()).resolves.toEqual({ hibernated: [], - shutdownRequested: true, + shutdownRequested: false, }); - expect(onShutdown).toHaveBeenCalledTimes(1); + expect(onShutdown).not.toHaveBeenCalled(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); - it('waits for the grace period to elapse before requesting shutdown', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir); - let nowMs = 1_000_000; + it('keeps a stale adoption with live unverified pids fail-closed', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const sessionId = '223e4567-e89b-12d3-a456-426614174000'; + const staleAt = '2026-07-17T00:00:00.000Z'; const onShutdown = vi.fn(); + await writeAgentViewSessionState( + { + schemaVersion: 1, + sessionId, + ownership: 'adopting', + sessionState: 'idle', + processState: 'starting', + attachState: 'detached', + projectCwd: globalDir, + originalCwd: globalDir, + activeCwd: globalDir, + createdAt: staleAt, + updatedAt: staleAt, + worktree: { mode: 'none' }, + }, + { globalDir }, + ); + await writeAgentViewWorker( + sessionId, + { + schemaVersion: 1, + hostPid: process.pid, + workerPid: process.pid, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + { globalDir }, + ); + await upsertAgentViewRosterEntry( + { + sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + createdAt: staleAt, + updatedAt: staleAt, + }, + { globalDir }, + ); const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 5_000 }, - now: () => new Date(nowMs), + platform: 'linux', onShutdown, + hibernationPolicy: { idleMs: 1000, autoExitGraceMs: 0 }, + now: () => new Date('2026-07-17T00:00:20.000Z'), + launchPtyHost: async () => fakePtyHost(), }); await expect(handler.tickIdleHibernation()).resolves.toEqual({ hibernated: [], shutdownRequested: false, }); + await expect( + readAgentViewSessionState(sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'adopting', + processState: 'starting', + }); + await expect( + readAgentViewWorker(sessionId, { globalDir }), + ).resolves.toMatchObject({ + hostPid: expect.any(Number), + workerPid: expect.any(Number), + }); + await expect(readAgentViewRoster({ globalDir })).resolves.toMatchObject({ + sessions: [expect.objectContaining({ sessionId })], + }); + expect(onShutdown).not.toHaveBeenCalled(); - nowMs += 4_999; - await expect(handler.tickIdleHibernation()).resolves.toEqual({ - hibernated: [], - shutdownRequested: false, + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('repairs stale lifecycle state when its persisted host reconnects', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const seedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + launchPtyHost: async () => fakePtyHost(), + }); + const result = (await seedHandler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + await patchSessionStateForTest(result.sessionId, globalDir, { + ownership: 'adopting', + processState: 'starting', + updatedAt: '2026-07-17T00:00:00.000Z', + }); + const liveHost = fakePtyHost(); + const hostEndpoint = shortHostSocketPath(); + const hostServer = createAgentViewPtyHostServer( + liveHost, + hostEndpoint.path, + ); + await hostServer.listen(); + const worker = await readAgentViewWorker(result.sessionId, { globalDir }); + if (!worker) throw new Error('Missing worker state.'); + await writeAgentViewWorker( + result.sessionId, + { ...worker, hostEndpoint: hostEndpoint.path }, + { globalDir }, + ); + await clearAgentViewWorkerPids(result.sessionId, { globalDir }); + const launchPtyHost = vi.fn(async () => { + throw new Error('must not spawn'); + }); + const recoveredHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + now: () => new Date('2026-07-17T00:01:00.000Z'), + hibernationPolicy: { autoExit: false }, + launchPtyHost, }); - nowMs += 1; - await expect(handler.tickIdleHibernation()).resolves.toEqual({ - hibernated: [], - shutdownRequested: true, + await expect( + recoveredHandler.adopt?.({ + sessionId: result.sessionId, + projectCwd: globalDir, + activeCwd: globalDir, + terminal: { columns: 80, rows: 24 }, + }), + ).resolves.toMatchObject({ adopted: false, alreadyManaged: true }); + expect(launchPtyHost).not.toHaveBeenCalled(); + await expect(recoveredHandler.list()).resolves.toEqual([ + expect.objectContaining({ + sessionId: result.sessionId, + state: expect.objectContaining({ + ownership: 'managed', + processState: 'alive', + }), + }), + ]); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + ownership: 'managed', + processState: 'alive', + }); + + await patchSessionStateForTest(result.sessionId, globalDir, { + sessionState: 'idle', + processState: 'hibernating', + }); + const restartedHandler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + hibernationPolicy: { autoExit: false }, }); - expect(onShutdown).toHaveBeenCalledTimes(1); + await expect( + restartedHandler.logs?.({ sessionId: result.sessionId }), + ).resolves.toMatchObject({ live: true }); + const patchSpy = vi + .spyOn(supervisorStore, 'patchAgentViewSessionStateIf') + .mockRejectedValueOnce(new Error('transient write failure')); + await expect(restartedHandler.list()).resolves.toEqual([ + expect.objectContaining({ + state: expect.objectContaining({ processState: 'hibernating' }), + }), + ]); + await expect(restartedHandler.list()).resolves.toEqual([ + expect.objectContaining({ + state: expect.objectContaining({ processState: 'alive' }), + }), + ]); + patchSpy.mockRestore(); + await expect( + restartedHandler.send?.({ + sessionId: result.sessionId, + text: 'follow up', + }), + ).resolves.toMatchObject({ sent: true }); + + await hostServer.close(); + await fs.rm(globalDir, { recursive: true, force: true }); + if (hostEndpoint.dir) { + await fs.rm(hostEndpoint.dir, { recursive: true, force: true }); + } }); - it('restarts the auto-exit grace period when a session becomes active again', async () => { - const globalDir = await makeGlobalDir(); - await writeSession(globalDir); - let nowMs = 1_000_000; + it('stops workers on shutdown unless workers are kept', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); const onShutdown = vi.fn(); + const hosts: FakePtyHost[] = []; const handler = createAgentViewSupervisorHandler({ globalDir, - hibernationPolicy: { autoExitGraceMs: 5_000 }, - now: () => new Date(nowMs), + platform: 'linux', onShutdown, + launchPtyHost: async () => { + const host = fakePtyHost(); + hosts.push(host); + return host; + }, }); - await expect(handler.tickIdleHibernation()).resolves.toMatchObject({ - shutdownRequested: false, + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; + + await expect(handler.shutdown()).resolves.toEqual({ + shuttingDown: true, + workersStopped: 1, + workersFailed: [], }); + expect(hosts[0]?.shutdowns).toBe(1); + await expect( + readAgentViewSessionState(result.sessionId, { globalDir }), + ).resolves.toMatchObject({ + sessionState: 'stopped', + processState: 'exited', + }); + expect(onShutdown).toHaveBeenCalledOnce(); - nowMs += 5_000; - await writeSession(globalDir, { processState: 'alive' }); - await expect(handler.tickIdleHibernation()).resolves.toMatchObject({ - shutdownRequested: false, + await handler.respawn?.({ sessionId: result.sessionId }); + await expect(handler.shutdown({ keepWorkers: true })).resolves.toEqual({ + shuttingDown: true, + keepWorkers: true, }); + expect(hosts).toHaveLength(2); + expect(hosts[1]?.killedWith).toBeUndefined(); + expect(onShutdown).toHaveBeenCalledTimes(2); - await writeSession(globalDir, { processState: 'hibernated' }); - await expect(handler.tickIdleHibernation()).resolves.toMatchObject({ - shutdownRequested: false, + await fs.rm(globalDir, { recursive: true, force: true }); + }); + + it('stays online and reports workers that fail to stop on shutdown', async () => { + const globalDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'qwen-agent-view-store-'), + ); + const onShutdown = vi.fn(); + const handler = createAgentViewSupervisorHandler({ + globalDir, + platform: 'linux', + onShutdown, + launchPtyHost: async () => { + const host = fakePtyHost(); + host.shutdown = () => { + throw new Error('host refused shutdown'); + }; + return host; + }, }); + const result = (await handler.dispatch?.({ + prompt: 'write tests', + cwd: globalDir, + })) as { sessionId: string }; - nowMs += 5_000; - await expect(handler.tickIdleHibernation()).resolves.toMatchObject({ - shutdownRequested: true, + await expect(handler.shutdown()).resolves.toEqual({ + shuttingDown: false, + workersStopped: 0, + workersFailed: [ + { sessionId: result.sessionId, error: 'host refused shutdown' }, + ], }); - expect(onShutdown).toHaveBeenCalledTimes(1); + expect(onShutdown).not.toHaveBeenCalled(); + + await fs.rm(globalDir, { recursive: true, force: true }); }); }); + +type FakePtyHost = AgentViewPtyHostHandle & { + killedWith?: string; + shutdowns: number; + input: string; + resizes: Array<{ columns: number; rows: number }>; + emitData(data: string): void; + resolveExit(exitCode: number): void; + resolveUnreachable(): void; +}; + +function fakePtyHost( + workerPid = 999_999_002, + hostPid = 999_999_001, +): FakePtyHost { + let resolveExit: (exit: AgentViewPtyHostExit) => void = () => {}; + let dataCallbacks: Array<(data: string) => void> = []; + const host: FakePtyHost = { + pid: hostPid, + workerPid, + command: ['fake'], + output: new BoundedOutputRing(100), + input: '', + resizes: [], + shutdowns: 0, + exited: new Promise((resolve) => { + resolveExit = resolve; + }), + write: (data) => { + host.input += data.toString('utf8'); + }, + onData: (callback) => { + dataCallbacks.push(callback); + return { + dispose: () => { + dataCallbacks = dataCallbacks.filter((item) => item !== callback); + }, + }; + }, + resize: (size) => { + host.resizes.push(size); + }, + kill: (signal) => { + host.killedWith = signal; + if (signal === 'SIGKILL') { + resolveExit({ kind: 'confirmed-kill' }); + } + }, + shutdown: () => { + host.shutdowns += 1; + resolveExit({ kind: 'exited', exitCode: 0 }); + }, + resolveExit: (exitCode) => { + resolveExit({ kind: 'exited', exitCode }); + }, + resolveUnreachable: () => { + resolveExit({ kind: 'unreachable' }); + }, + emitData: (data) => { + for (const callback of dataCallbacks) { + callback(data); + } + }, + dispose: () => {}, + }; + return host; +} + +async function patchSessionStateForTest( + sessionId: string, + globalDir: string, + patch: Partial, +): Promise { + const state = await readAgentViewSessionState(sessionId, { globalDir }); + if (!state) { + throw new Error(`Missing state for ${sessionId}`); + } + await writeAgentViewSessionState({ ...state, ...patch }, { globalDir }); +} + +async function writeSessionStateForTest( + sessionId: string, + globalDir: string, + sessionState: 'working' | 'needs_input' | 'idle' | 'completed', +): Promise { + const state = await readAgentViewSessionState(sessionId, { globalDir }); + if (!state) { + throw new Error(`Missing state for ${sessionId}`); + } + const at = '2026-07-17T00:00:00.000Z'; + await writeAgentViewSessionState( + { + ...state, + sessionState, + processState: 'alive', + attachState: 'detached', + updatedAt: at, + }, + { globalDir }, + ); + await writeAgentViewActivity( + sessionId, + { + schemaVersion: 1, + lastActivityAt: at, + capabilities: [], + }, + { globalDir }, + ); +} + +async function readWorkerTokenForTest( + sessionId: string, + globalDir: string, +): Promise { + const launch = await readAgentViewLaunch(sessionId, { globalDir }); + const token = launch?.env['QWEN_AGENT_VIEW_TOKEN']; + if (!token) { + throw new Error(`Missing worker token for ${sessionId}`); + } + return token; +} + +function managedSessionStateForTest( + sessionId: string, + globalDir: string, + overrides: Partial = {}, +): AgentViewSessionStateFile { + return { + schemaVersion: 1, + sessionId, + ownership: 'managed', + sessionState: 'idle', + processState: 'alive', + attachState: 'detached', + projectCwd: globalDir, + originalCwd: globalDir, + activeCwd: globalDir, + createdAt: '2026-07-17T00:00:00.000Z', + updatedAt: '2026-07-17T00:00:00.000Z', + worktree: { mode: 'none' }, + ...overrides, + }; +} + +function shortHostSocketPath(): { path: string; dir?: string } { + const unique = `qah-${process.pid}-${Date.now()}`; + if (process.platform === 'win32') { + return { path: `\\\\.\\pipe\\${unique}` }; + } + const dir = fsSync.mkdtempSync(path.join(os.tmpdir(), `${unique}-`)); + return { path: path.join(dir, 'host.sock'), dir }; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +class FakeAttachSocket extends Duplex { + private readonly outputChunks: Buffer[] = []; + private outputWaiters: Array<() => void> = []; + + override _read(): void {} + + override _write( + chunk: Buffer, + _encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): void { + this.outputChunks.push(Buffer.from(chunk)); + for (const waiter of this.outputWaiters.splice(0)) { + waiter(); + } + callback(); + } + + pushInput(data: string): void { + this.push(Buffer.from(data)); + } + + closeInput(): void { + this.push(null); + this.emit('close'); + } + + output(): string { + return Buffer.concat(this.outputChunks).toString('utf8'); + } + + outputLine(): string { + return this.output().split('\n')[0] ?? ''; + } + + async waitForOutput(pattern: string): Promise { + await waitFor( + () => this.output().includes(pattern), + (notify) => { + this.outputWaiters.push(notify); + }, + ); + } +} + +async function waitFor( + predicate: () => boolean, + subscribe?: (notify: () => void) => void, +): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + if (predicate()) return; + await new Promise((resolve) => { + subscribe?.(resolve); + setTimeout(resolve, 10); + }); + } + throw new Error('Timed out waiting for condition.'); +} + +async function waitForSessionState( + sessionId: string, + globalDir: string, + predicate: (state: AgentViewSessionStateFile) => boolean, +): Promise { + for (let attempt = 0; attempt < 50; attempt++) { + const state = await readAgentViewSessionState(sessionId, { globalDir }); + if (state && predicate(state)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error('Timed out waiting for session state.'); +} diff --git a/packages/cli/src/agent-view/supervisor-process.ts b/packages/cli/src/agent-view/supervisor-process.ts index 8ec637e1601..1e0f6910a4d 100644 --- a/packages/cli/src/agent-view/supervisor-process.ts +++ b/packages/cli/src/agent-view/supervisor-process.ts @@ -4,25 +4,100 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID, timingSafeEqual } from 'node:crypto'; import type { Socket } from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; +import * as fs from 'node:fs'; +import { + AgentViewAttachLeaseManager, + DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS, +} from './attach-lease.js'; +import type { AgentViewAttachLease } from './attach-lease.js'; +import { AGENT_VIEW_PROTOCOL_VERSION } from './protocol.js'; import type { + AgentViewActivityFile, + AgentViewLaunchFile, + AgentViewRosterEntry, AgentViewSessionSnapshot, AgentViewSessionStateFile, + AgentViewWorkerControlEvent, + AgentViewWorkerEvent, } from './protocol.js'; -import type { AgentViewSupervisorHandler } from './supervisor-server.js'; +import type { + AgentViewPtyHostExit, + AgentViewPtyHostHandle, +} from './pty-host.js'; +import { + connectAgentViewPtyHostProcess, + createAgentViewPtyHostIdentity, + launchAgentViewPtyHostProcess, +} from './pty-host-process.js'; +import type { AgentViewPtyHostIdentity } from './pty-host-process.js'; +import { bridgeAgentViewTerminal } from './terminal-bridge.js'; +import { dispatchAgentViewSession } from './supervisor-dispatch.js'; import { + clearAgentViewWorkerPids, + digestAgentViewWorkerToken, + getAgentViewSessionPaths, getAgentViewStorePaths, + inputKindValue, + isAgentViewSessionState, listAgentViewSessionSnapshots, listAgentViewSessionStates, + patchAgentViewActivityIf, + patchAgentViewSessionState, + patchAgentViewSessionStateIf, + readAgentViewLaunch, + readAgentViewActivity, + readAgentViewRosterStrict, + readAgentViewSessionState, + readAgentViewWorker, + redactAgentViewActivity, + redactAgentViewWorker, + removeAgentViewRosterEntry, + sanitizeSessionId, + upsertAgentViewRosterEntry, + updateAgentViewRosterEntry, + writeAgentViewActivity, + writeAgentViewLaunch, + writeAgentViewSessionState, + writeAgentViewWorker, } from './supervisor-store.js'; +import type { AgentViewSupervisorHandler } from './supervisor-server.js'; +import { + createAgentViewWorkerSidebandEnv, + QWEN_AGENT_VIEW_TOKEN, +} from './worker-sideband.js'; +import { + buildCurrentQwenCliArgv, + getCurrentQwenCliEntrypoint, +} from './current-cli-argv.js'; +import { + canAgentViewHibernate, + canAgentViewQueueFollowUp, + getAgentViewActivityInputState, +} from './presentation.js'; + +function resolveSessionCwd(cwd: string): string { + try { + return fs.realpathSync(cwd); + } catch { + return path.resolve(cwd); + } +} const UNIX_SOCKET_PATH_LIMIT = 100; +const DEFAULT_IDLE_HIBERNATION_MS = 30 * 60 * 1000; const DEFAULT_SUPERVISOR_AUTO_EXIT_GRACE_MS = 10 * 60 * 1000; +const DEFAULT_WORKER_READY_TIMEOUT_MS = 15_000; +const DEFAULT_GRACEFUL_STOP_TIMEOUT_MS = 10_000; +const DEFAULT_HOST_EXIT_TIMEOUT_MS = 10_000; +const DEFAULT_ATTACH_LEASE_HEARTBEAT_MS = + DEFAULT_AGENT_VIEW_ATTACH_LEASE_TTL_MS / 3; export interface AgentViewSupervisorHibernationPolicy { + idleMs?: number; autoExit?: boolean; autoExitGraceMs?: number; } @@ -36,11 +111,26 @@ export interface AgentViewSupervisorMaintenanceResult shutdownRequested: boolean; } +type AgentViewStoreOptions = { globalDir?: string }; + export interface AgentViewSupervisorMaintenance { hibernateIdleSessions(): Promise; tickIdleHibernation(): Promise; } +interface AgentViewWorkerReadyWaiter { + expectedCwd: string; + timeout: NodeJS.Timeout; + resolve(): void; + reject(error: Error): void; + generation: number; +} + +interface PreparedAttach { + host: AgentViewPtyHostHandle; + lease: AgentViewAttachLease; +} + export interface AgentViewSupervisorPathOptions { globalDir?: string; platform?: NodeJS.Platform; @@ -49,13 +139,16 @@ export interface AgentViewSupervisorPathOptions { export interface AgentViewSupervisorProcessOptions extends AgentViewSupervisorPathOptions { + launchPtyHost?: ( + launch: AgentViewLaunchFile, + ) => Promise; hibernationPolicy?: AgentViewSupervisorHibernationPolicy; + waitForWorkerReady?: boolean; + workerReadyTimeoutMs?: number; now?: () => Date; onShutdown?: () => void | Promise; } -type AgentViewStoreOptions = { globalDir?: string }; - export function getAgentViewSupervisorSocketPath( options: AgentViewSupervisorPathOptions = {}, ): string { @@ -85,11 +178,26 @@ export function getAgentViewSupervisorSocketPath( const uid = process.getuid?.(); const fallbackDir = uid === undefined ? `qwen-agent-view-${digest}` : `qwen-agent-view-${uid}`; - return path.join( + const fallbackPath = path.join( options.runtimeDir ?? os.tmpdir(), fallbackDir, `supervisor-${digest}.sock`, ); + if (Buffer.byteLength(fallbackPath) < UNIX_SOCKET_PATH_LIMIT) { + return fallbackPath; + } + + const compactPath = path.join( + options.runtimeDir ?? os.tmpdir(), + uid === undefined ? `qav-${digest.slice(0, 8)}` : `qav-${uid}`, + `${digest}.sock`, + ); + if (Buffer.byteLength(compactPath) < UNIX_SOCKET_PATH_LIMIT) { + return compactPath; + } + throw new Error( + `Agent View supervisor socket path is too long: ${compactPath}`, + ); } export function createAgentViewSupervisorHandler( @@ -102,36 +210,151 @@ class AgentViewSupervisorProcessHandler implements AgentViewSupervisorHandler, AgentViewSupervisorMaintenance { private readonly socketPath: string; - private readonly startedAt = new Date().toISOString(); + private readonly startedAt: string; + private readonly attachSockets = new Map(); + private readonly attachLeases = new AgentViewAttachLeaseManager(); private readonly subscribers = new Set(); - private autoExitEligibleSinceMs?: number; + private readonly snapshotCache = new SessionSnapshotCache(); + private readonly pendingWorkerControls = new Map< + string, + AgentViewWorkerControlEvent[] + >(); + private readonly promptQueues = new Map>(); + private readonly attachSetupQueues = new Map>(); + private readonly activeHibernations = new Set(); + private readonly workers: WorkerRegistry; + private workerControlSequence = 0; + private shuttingDown = false; + private autoExitRequested = false; + private autoExitEligibleSinceMs: number | undefined; - constructor(private readonly options: AgentViewSupervisorProcessOptions) { + constructor( + private readonly options: AgentViewSupervisorProcessOptions = {}, + ) { this.socketPath = getAgentViewSupervisorSocketPath(options); + this.startedAt = new Date().toISOString(); + this.workers = new WorkerRegistry( + options, + () => this.notifyChanged(), + (sessionId) => { + this.pendingWorkerControls.delete(sessionId); + }, + (sessionId) => { + // prompt/answer controls hold accepted user input and survive a + // worker replacement; superseded stop/redraw controls must not + // reach the replacement worker. + const kept = (this.pendingWorkerControls.get(sessionId) ?? []).filter( + (event) => event.type === 'prompt' || event.type === 'answer', + ); + if (kept.length > 0) { + this.pendingWorkerControls.set(sessionId, kept); + } else { + this.pendingWorkerControls.delete(sessionId); + } + }, + (sessionId) => this.hasPendingWorkerStopControl(sessionId), + (sessionId) => this.hasLiveAttach(sessionId), + (sessionId) => this.queueWorkerStop(sessionId), + (sessionId) => this.activeHibernations.has(sessionId), + ); } - async status(): Promise<{ - state: 'ready'; - socketPath: string; - startedAt: string; - sessions: number; - }> { - const sessions = await listAgentViewSessionStates(this.store); + private get store(): AgentViewStoreOptions { + return storeOptions(this.options); + } + + private nextSequence(): number { + return ++this.workerControlSequence; + } + + private notifyChanged(): void { + this.snapshotCache.markDirty(); + notifyAgentViewSubscribers(this.subscribers); + } + + status() { return { - state: 'ready', + running: true, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + pid: process.pid, socketPath: this.socketPath, startedAt: this.startedAt, - sessions: sessions.length, }; } - - async list(): Promise { - return listAgentViewSessionSnapshots(this.store); + async list(params?: Record) { + const store = this.store; + const snapshots = []; + let changed = false; + for (const snapshot of await this.snapshotCache.list( + store, + (this.options.now?.() ?? new Date()).getTime(), + )) { + if (snapshot.state.ownership === 'unmanaged') { + continue; + } + if (snapshot.state.ownership === 'removing') { + try { + await this.finishRemovingSession(snapshot.sessionId); + changed = true; + } catch { + // Keep the durable removing intent for an explicit retry. + } + continue; + } + let state = snapshot.state; + let activity = snapshot.activity; + try { + state = await this.workers.refreshMissingWorkerState(snapshot.state); + activity = await clearStalePendingPromptIfNeeded( + state, + snapshot.activity, + store, + this.hasPendingWorkerInputControl(snapshot.sessionId), + ); + } catch { + // Per-session healing is best-effort: one unreadable session + // directory must not reject the whole listing — fall back to the + // cached snapshot and continue. + state = snapshot.state; + activity = snapshot.activity; + } + if (state !== snapshot.state || activity !== snapshot.activity) { + changed = true; + } + snapshots.push({ + ...snapshot, + state, + activity: redactAgentViewActivity(activity), + }); + } + if (changed) { + this.notifyChanged(); + } + const cwd = typeof params?.['cwd'] === 'string' ? params['cwd'] : undefined; + if (!cwd) return snapshots; + const roots = [path.resolve(cwd), resolveSessionCwd(cwd)].map((root) => ({ + root, + prefix: root.endsWith(path.sep) ? root : `${root}${path.sep}`, + })); + return snapshots.filter((snapshot) => + [snapshot.state.projectCwd, snapshot.state.activeCwd] + .flatMap((candidate) => [ + path.resolve(candidate), + resolveSessionCwd(candidate), + ]) + .some((candidate) => + roots.some( + ({ root, prefix }) => + candidate === root || candidate.startsWith(prefix), + ), + ), + ); } - - subscribe(_params: undefined, socket: Socket, requestId: string): void { - this.subscribers.add(socket); - socket.once('close', () => this.subscribers.delete(socket)); + subscribe( + _params: Record | undefined, + socket: Socket, + requestId: string, + ) { socket.write( `${JSON.stringify({ id: requestId, @@ -139,60 +362,4143 @@ class AgentViewSupervisorProcessHandler result: { subscribed: true }, })}\n`, ); + this.subscribers.add(socket); + socket.once('close', () => { + this.subscribers.delete(socket); + }); + socket.once('error', () => { + this.subscribers.delete(socket); + }); } + async dispatch(params?: Record) { + const prompt = + typeof params?.['prompt'] === 'string' ? params['prompt'] : ''; + const cwd = + typeof params?.['cwd'] === 'string' ? params['cwd'] : process.cwd(); + if (!prompt.trim()) { + throw new Error('Agent View dispatch prompt cannot be empty.'); + } + const store = { + ...(this.options.globalDir ? { globalDir: this.options.globalDir } : {}), + }; + const result = await dispatchAgentViewSession(prompt, cwd, { + ...store, + sidebandEndpoint: this.socketPath, + publishRoster: false, + promptInArgv: true, + }); + const launch = await readAgentViewLaunch(result.sessionId, store); + if (!launch) { + throw new Error('Agent View dispatch launch record was not written.'); + } - shutdown(): { shuttingDown: true; workersStopped: 0 } { - void Promise.resolve(this.options.onShutdown?.()).catch(() => {}); - return { shuttingDown: true, workersStopped: 0 }; + let readyCompleted = false; + let host: AgentViewPtyHostHandle | undefined; + try { + await this.workers.withHostSetupLock(result.sessionId, async () => { + const ready = this.workers.waitForWorkerReadyIfNeeded( + result.sessionId, + launch.activeCwd, + ); + void ready.catch(() => {}); + const hostIdentity = this.workers.createHostIdentity(result.sessionId); + if (hostIdentity) { + await writeAgentViewWorker( + result.sessionId, + { + schemaVersion: 1, + hostId: hostIdentity.hostId, + hostEndpoint: hostIdentity.endpoint, + hostAuthToken: hostIdentity.authToken, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + store, + ); + } + host = await this.workers.launchPtyHostForSupervisor( + launch, + store, + hostIdentity, + ); + // Persist the pids right after spawn, before any store I/O: a crash + // before the ready wait must not leave an unsignalable orphan host + // holding the deterministic session socket (mirrors adopt()). + await writeAgentViewWorker( + result.sessionId, + { + schemaVersion: 1, + hostPid: host.pid, + workerPid: host.workerPid, + ...(host.hostId ? { hostId: host.hostId } : {}), + ...(host.endpoint ? { hostEndpoint: host.endpoint } : {}), + ...(host.authToken ? { hostAuthToken: host.authToken } : {}), + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + store, + ); + await ensureSessionStillLaunchable(result.sessionId, store, host); + this.workers.set(result.sessionId, host); + await ready; + readyCompleted = true; + await ensureSessionStillLaunchable(result.sessionId, store); + }); + const state = await readAgentViewSessionState(result.sessionId, store); + const publishedAt = new Date().toISOString(); + try { + await upsertAgentViewRosterEntry( + { + sessionId: result.sessionId, + projectCwd: launch.projectCwd, + activeCwd: launch.activeCwd, + createdAt: state?.createdAt ?? publishedAt, + updatedAt: publishedAt, + }, + store, + ); + } catch { + // The worker is already running and ready; a roster publication + // failure must not fail the dispatch or orphan the live worker. + } + this.notifyChanged(); + return result; + } catch (error) { + if (!readyCompleted) { + await this.workers.withHostSetupLock(result.sessionId, async () => { + if (isStoppedError(error)) { + // A deliberate stop during the ready wait must not be mislabeled + // as a launch failure. If the racing stop found no host and no + // stored pid to signal, it queued no stop control, so nothing + // graceful will ever reach the just-launched host — + // terminate it. A pending stop control means a graceful stop is + // already in flight and must not be hard-killed — it also owns + // the verdict (its stopped+alive write; an 'exited' verdict here + // would falsely declare a still-draining worker gone). + if (!this.hasPendingWorkerStopControl(result.sessionId)) { + if (host) { + await this.workers.retireSessionHost(result.sessionId, host); + } + await markStoppedSession(result.sessionId, store, 'exited'); + } + } else { + this.workers.rejectPendingWorkerReady(result.sessionId, error); + if (host) { + await this.workers.retireSessionHost(result.sessionId, host); + } + await markFailedSession(result.sessionId, error, store); + } + }); + this.notifyChanged(); + } + throw error; + } } + async adopt(params?: Record) { + const adoption = parseAdoptParams(params); + const store = this.store; + return this.workers.withHostSetupLock(adoption.sessionId, async () => { + const existingState = await readAgentViewSessionState( + adoption.sessionId, + store, + ); + if (existingState?.ownership === 'managed') { + return { + sessionId: adoption.sessionId, + adopted: false, + alreadyManaged: true, + }; + } + if (existingState?.ownership === 'removing') { + throw new Error( + `Agent View session ${adoption.sessionId} is being removed. Retry the removal before adopting it.`, + ); + } + if (existingState?.ownership === 'adopting') { + const connected = + this.workers.has(adoption.sessionId) || + (await this.workers.reconnectSessionHostLocked(adoption.sessionId)); + const worker = await readAgentViewWorker(adoption.sessionId, store); + const pidAlive = + isPidRunning(worker?.hostPid) || isPidRunning(worker?.workerPid); + if (connected || pidAlive || worker?.hostEndpoint) { + return { + sessionId: adoption.sessionId, + adopted: false, + alreadyManaged: true, + }; + } + } + if (this.workers.has(adoption.sessionId)) { + throw new Error( + `Agent View session ${adoption.sessionId} is already running.`, + ); + } - async hibernateIdleSessions(): Promise { - return { hibernated: [] }; + const token = randomUUID(); + const now = new Date().toISOString(); + const activeCwd = path.resolve(adoption.activeCwd); + const projectCwd = path.resolve(adoption.projectCwd); + const createdAt = existingState?.createdAt ?? now; + const adoptingState = { + schemaVersion: 1 as const, + sessionId: adoption.sessionId, + ownership: 'adopting' as const, + sessionState: 'idle' as const, + processState: 'starting' as const, + attachState: 'detached' as const, + projectCwd, + originalCwd: activeCwd, + activeCwd, + createdAt, + updatedAt: now, + worktree: { mode: 'none' as const }, + }; + + let readyCompleted = false; + let host: AgentViewPtyHostHandle | undefined; + try { + await writeAgentViewSessionState(adoptingState, store); + await writeAgentViewLaunch( + { + schemaVersion: 1, + sessionId: adoption.sessionId, + // --resume needs the spelling the native session store knows; + // sessionId is the canonical (directory-safe) form. + resumeSessionId: adoption.resumeSessionId, + argv: buildResumeWorkerArgv( + adoption.resumeSessionId, + undefined, + adoption.approvalMode, + ), + env: createAgentViewWorkerSidebandEnv({ + sessionId: adoption.sessionId, + sidebandEndpoint: this.socketPath, + token, + activeCwd, + }), + entrypoint: getCurrentQwenCliEntrypoint(), + projectCwd, + activeCwd, + ...(adoption.approvalMode + ? { approvalMode: adoption.approvalMode } + : {}), + ...(adoption.sandbox ? { sandbox: adoption.sandbox } : {}), + includeDirectories: [], + terminal: adoption.terminal, + }, + store, + ); + await writeAgentViewActivity( + adoption.sessionId, + { + schemaVersion: 1, + summary: 'Backgrounded from native session', + lastActivityAt: now, + capabilities: [], + }, + store, + ); + await writeAgentViewWorker( + adoption.sessionId, + { + schemaVersion: 1, + endpoint: this.socketPath, + tokenDigest: digestAgentViewWorkerToken(token), + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + store, + ); + await upsertAgentViewRosterEntry( + { + sessionId: adoption.sessionId, + projectCwd, + activeCwd, + createdAt, + updatedAt: now, + }, + store, + ); + const launch = await readAgentViewLaunch(adoption.sessionId, store); + if (!launch) { + throw new Error('Agent View adoption launch record was not written.'); + } + const ready = this.workers.waitForWorkerReadyIfNeeded( + adoption.sessionId, + activeCwd, + ); + void ready.catch(() => {}); + const hostIdentity = this.workers.createHostIdentity( + adoption.sessionId, + ); + if (hostIdentity) { + await writeAgentViewWorker( + adoption.sessionId, + { + schemaVersion: 1, + hostId: hostIdentity.hostId, + hostEndpoint: hostIdentity.endpoint, + hostAuthToken: hostIdentity.authToken, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + store, + ); + } + host = await this.workers.launchPtyHostForSupervisor( + launch, + store, + hostIdentity, + ); + // Persist the pids immediately: a crash anywhere between spawn + // and the ready wait must not leave an unsignalable orphan host + // (every later adopt of this session would hit EADDRINUSE). + await writeAgentViewWorker( + adoption.sessionId, + { + schemaVersion: 1, + hostPid: host.pid, + workerPid: host.workerPid, + ...(host.hostId ? { hostId: host.hostId } : {}), + ...(host.endpoint ? { hostEndpoint: host.endpoint } : {}), + ...(host.authToken ? { hostAuthToken: host.authToken } : {}), + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + store, + ); + await ensureSessionStillLaunchable(adoption.sessionId, store, host); + this.workers.set(adoption.sessionId, host); + await patchAgentViewSessionStateIf( + adoption.sessionId, + (existing) => ({ + ownership: 'managed', + sessionState: + existing.processState === 'alive' + ? existing.sessionState + : 'starting', + processState: + existing.processState === 'alive' ? 'alive' : 'starting', + attachState: 'detached', + updatedAt: new Date().toISOString(), + }), + store, + ); + await ready; + readyCompleted = true; + await ensureSessionStillLaunchable(adoption.sessionId, store); + this.notifyChanged(); + return { sessionId: adoption.sessionId, adopted: true }; + } catch (error) { + if (!readyCompleted) { + if (isStoppedError(error)) { + // A pending stop control owns the verdict; otherwise retire the + // host a racing stop missed. + if (!this.hasPendingWorkerStopControl(adoption.sessionId)) { + if (host) { + await this.workers.retireSessionHost(adoption.sessionId, host); + } + await markStoppedSession(adoption.sessionId, store, 'exited'); + } + } else { + this.workers.rejectPendingWorkerReady(adoption.sessionId, error); + if (host) { + await this.workers.retireSessionHost(adoption.sessionId, host); + } + const failedAt = new Date().toISOString(); + const failedError = { + code: 'adoption_failed', + message: + error instanceof Error + ? error.message + : 'Agent View adoption failed.', + at: failedAt, + }; + const restorableState = + existingState && existingState.ownership !== 'adopting' + ? existingState + : undefined; + await patchAgentViewSessionStateIf( + adoption.sessionId, + (existing) => { + if ( + existing.sessionState === 'stopped' || + existing.sessionState === 'completed' + ) { + return undefined; + } + return restorableState + ? { ...restorableState, updatedAt: failedAt } + : { + ownership: 'unmanaged', + processState: 'exited', + updatedAt: failedAt, + lastError: failedError, + }; + }, + store, + ); + } + await removeAgentViewRosterEntry(adoption.sessionId, store); + this.notifyChanged(); + } + throw error; + } + }); + } + async workerEvent(params?: Record) { + const event = parseWorkerEvent(params); + const readyGeneration = + event.type === 'ready' + ? (this.workers.getBootGeneration(event.sessionId) ?? null) + : undefined; + await requireValidWorkerToken(event.sessionId, params, this.store); + if (event.type === 'ready') { + this.workers.validatePendingWorkerReady(event, readyGeneration); + } + if (event.type === 'detach') { + await requireKnownSession(event.sessionId, this.store); + this.attachSockets.get(event.sessionId)?.destroy(); + await writeAttachState(event.sessionId, 'detached', this.store); + this.notifyChanged(); + return { sessionId: event.sessionId, accepted: true }; + } + if (event.type === 'heartbeat') { + await applyWorkerHeartbeatEvent(event, this.store); + return { sessionId: event.sessionId, accepted: true }; + } + let applied = true; + try { + const apply = async () => { + if (event.type === 'state') { + await requireValidWorkerToken(event.sessionId, params, this.store); + } + return applyWorkerEvent( + event, + this.store, + this.hasPendingWorkerInputControl(event.sessionId), + this.workers.has(event.sessionId), + this.workers.hasPendingWorkerReady(event.sessionId), + ); + }; + applied = + event.type === 'state' + ? await this.withPromptQueueLock(event.sessionId, apply) + : await apply(); + } catch (error) { + if (event.type === 'ready') { + this.workers.rejectPendingWorkerReady( + event.sessionId, + error, + readyGeneration, + ); + } + throw error; + } + if (event.type === 'ready') { + if (applied) { + this.workers.resolvePendingWorkerReady( + event.sessionId, + readyGeneration, + ); + } else { + // The ready was dropped (dead-worker guard or in-queue re-validation): + // fail the waiter fast instead of hanging until the ready timeout. + this.workers.rejectPendingWorkerReady( + event.sessionId, + new AgentViewSessionStoppedError(event.sessionId, 'worker'), + readyGeneration, + ); + } + } + this.notifyChanged(); + return { sessionId: event.sessionId, accepted: true }; + } + async workerControl(params?: Record) { + const sessionId = requireSessionId(params); + await requireKnownSession(sessionId, this.store); + return this.withPromptQueueLock(sessionId, async () => { + await requireValidWorkerToken(sessionId, params, this.store); + const activity = await readAgentViewActivity(sessionId, this.store); + const pendingEvents = this.pendingWorkerControls.get(sessionId) ?? []; + this.pendingWorkerControls.delete(sessionId); + const events: AgentViewWorkerControlEvent[] = pendingEvents.filter( + (event) => event.type !== 'prompt', + ); + const pendingPrompt = pendingEvents.find( + (event) => event.type === 'prompt', + ); + if ( + activity?.queuedPromptId && + activity.queuedPromptText && + !activity.queuedPromptDeliveredAt + ) { + events.push( + pendingPrompt?.promptId === activity.queuedPromptId + ? pendingPrompt + : { + type: 'prompt', + sequence: this.nextSequence(), + promptId: activity.queuedPromptId, + text: activity.queuedPromptText, + at: activity.lastQueuedPromptAt ?? new Date().toISOString(), + }, + ); + } else if (!activity?.queuedPromptId && pendingPrompt) { + events.push(pendingPrompt); + } + return { sessionId, events }; + }); + } + async attachStream( + params: Record | undefined, + socket: Socket, + requestId: string, + ) { + if (this.shuttingDown) { + writeAttachError( + socket, + requestId, + 'not_running', + 'Agent View supervisor is shutting down.', + ); + return; + } + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + const prepared = await this.withAttachSetupLock(sessionId, () => + this.withPromptQueueLock(sessionId, async () => { + if (this.shuttingDown) { + writeAttachError( + socket, + requestId, + 'not_running', + 'Agent View supervisor is shutting down.', + ); + return undefined; + } + if ( + !(await this.prepareSessionForAttach(sessionId, socket, requestId)) + ) { + return undefined; + } + if (this.shuttingDown) { + writeAttachError( + socket, + requestId, + 'not_running', + 'Agent View supervisor is shutting down.', + ); + return undefined; + } + return this.beginAttach(sessionId, socket, requestId); + }), + ); + if (!prepared) return; + await this.attachSessionStream(sessionId, socket, requestId, prepared); } - async tickIdleHibernation(): Promise { - const states = await listAgentViewSessionStates(this.store); - if (this.shouldAutoExit(states)) { - void Promise.resolve(this.options.onShutdown?.()).catch(() => {}); - return { hibernated: [], shutdownRequested: true }; + private async prepareSessionForAttach( + sessionId: string, + socket: Socket, + requestId: string, + ): Promise { + try { + const state = await readAgentViewSessionState(sessionId, this.store); + if (state) { + await this.detachIfAttachIsStale(state); + } + if (await this.workers.respawnStoppedOrFailedSessionIfNeeded(sessionId)) { + this.notifyChanged(); + } + } catch (error) { + writeAttachError( + socket, + requestId, + 'pty_launch_failed', + error instanceof Error ? error.message : String(error), + ); + this.notifyChanged(); + return false; } + if (!this.workers.has(sessionId)) { + if (!(await this.workers.reconnectSessionHost(sessionId))) { + try { + if ( + !(await this.workers.respawnSessionForAttachIfInactive(sessionId)) + ) { + writeAttachError( + socket, + requestId, + 'stale_host', + `Agent View session ${sessionId} has no live PTY host.`, + ); + this.notifyChanged(); + return false; + } + } catch (error) { + writeAttachError( + socket, + requestId, + 'pty_launch_failed', + error instanceof Error ? error.message : String(error), + ); + this.notifyChanged(); + return false; + } + } + this.notifyChanged(); + } + return true; + } - return { hibernated: [], shutdownRequested: false }; + private async withAttachSetupLock( + sessionId: string, + action: () => Promise, + ): Promise { + const previous = this.attachSetupQueues.get(sessionId) ?? Promise.resolve(); + const current = previous.then(action, action); + const queued = current + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (this.attachSetupQueues.get(sessionId) === queued) { + this.attachSetupQueues.delete(sessionId); + } + }); + this.attachSetupQueues.set(sessionId, queued); + return current; } - private shouldAutoExit(states: AgentViewSessionStateFile[]): boolean { - const policy = this.options.hibernationPolicy; - if (policy?.autoExit === false || states.length === 0) { - this.autoExitEligibleSinceMs = undefined; - return false; + private async beginAttach( + sessionId: string, + socket: Socket, + requestId: string, + ): Promise { + const leaseResult = this.attachLeases.acquire(sessionId); + if (!leaseResult.ok) { + writeAttachError( + socket, + requestId, + 'already_attached', + `Agent View session ${sessionId} is already attached.`, + ); + return undefined; } + const liveBridge = this.attachSockets.get(sessionId); + if (liveBridge && !liveBridge.destroyed) { + this.attachLeases.release(sessionId, leaseResult.lease.leaseId); + writeAttachError( + socket, + requestId, + 'already_attached', + `Agent View session ${sessionId} is already attached.`, + ); + return undefined; + } + const host = this.workers.get(sessionId); + if (!host) { + this.attachLeases.release(sessionId, leaseResult.lease.leaseId); + writeAttachError( + socket, + requestId, + 'not_running', + `Agent View session ${sessionId} is not running.`, + ); + return undefined; + } + try { + await writeAttachState(sessionId, 'attaching', this.store); + } catch (error) { + this.attachLeases.release(sessionId, leaseResult.lease.leaseId); + throw error; + } + this.attachSockets.set(sessionId, socket); + return { host, lease: leaseResult.lease }; + } + + async resize(params?: Record) { + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + const host = await this.workers.getOrReconnectSessionHost(sessionId); + if (!host) { + throw new Error(`Agent View session ${sessionId} is not running.`); + } + host.resize({ + columns: positiveIntegerParam(params, 'columns'), + rows: positiveIntegerParam(params, 'rows'), + }); + return { sessionId, resized: true }; + } + async peek(params?: Record) { + const store = this.store; + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + store, + ); + const storedState = await readAgentViewSessionState(sessionId, store); + const state = storedState + ? await this.workers.refreshMissingWorkerState(storedState) + : undefined; + if (state && state !== storedState) { + this.notifyChanged(); + } + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + const storedActivity = await readAgentViewActivity(sessionId, store); + const activity = await clearStalePendingPromptIfNeeded( + state, + storedActivity, + store, + this.hasPendingWorkerInputControl(sessionId), + ); + if (activity !== storedActivity) { + this.notifyChanged(); + } + return { + sessionId, + state, + activity: redactAgentViewActivity(activity), + worker: redactAgentViewWorker( + await readAgentViewWorker(sessionId, store), + ), + live: this.workers.has(sessionId), + }; + } + async send(params?: Record) { + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + const text = requireText(params); + await this.queuePromptForSession(sessionId, text); + this.notifyChanged(); + return { sessionId, sent: true }; + } + async answer(params?: Record) { + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + const text = requireText(params); + await this.queueAnswerForSession(sessionId, text); + this.notifyChanged(); + return { sessionId, answered: true }; + } + async logs(params?: Record) { + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + const host = await this.workers.getOrReconnectSessionHost(sessionId); + return { + sessionId, + output: host + ? ((await host.getOutput?.()) ?? host.output.toString()) + : '', + live: Boolean(host), + }; + } + async stop(params?: Record) { + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + await this.withPromptQueueLock(sessionId, () => + this.workers.stopSession(sessionId, () => + this.queueWorkerStop(sessionId), + ), + ); + this.notifyChanged(); + return { sessionId, stopped: true }; + } + async kill(params?: Record) { + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + await this.withPromptQueueLock(sessionId, async () => { + await this.workers.killSession(sessionId, 'SIGKILL'); + await clearPersistedPromptQueue(sessionId, this.store); + }); + this.notifyChanged(); + return { sessionId, killed: true }; + } + async respawn(params?: Record) { + const all = params?.['all'] === true; + if (all) { + const states = await listAgentViewSessionStates(this.store); + const results = []; + for (const state of states) { + if (state.ownership !== 'managed') continue; + const attachRefreshedState = await this.detachIfAttachIsStale(state); + const refreshedState = + await this.workers.refreshMissingWorkerState(attachRefreshedState); + const blockReason = getRespawnBlockReason( + refreshedState, + await readAgentViewActivity(state.sessionId, this.store), + ); + if (blockReason) { + results.push({ + sessionId: state.sessionId, + skipped: true, + reason: blockReason, + }); + continue; + } + try { + results.push( + await this.withPromptQueueLock(state.sessionId, () => + this.workers.respawnSession(state.sessionId), + ), + ); + } catch (error) { + results.push({ + sessionId: state.sessionId, + skipped: true, + reason: error instanceof Error ? error.message : String(error), + }); + } + } + this.notifyChanged(); + return { all: true, results }; + } + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + this.store, + ); + const state = await readAgentViewSessionState(sessionId, this.store); + if (state) { + await this.detachIfAttachIsStale(state); + } + const result = await this.withPromptQueueLock(sessionId, () => + this.workers.respawnSession(sessionId), + ); + this.notifyChanged(); + return result; + } + async remove(params?: Record) { + const store = this.store; + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + store, + { allowRemoving: true }, + ); + await this.withAttachSetupLock(sessionId, () => + this.withPromptQueueLock(sessionId, () => + this.workers.withHostSetupLock(sessionId, async () => { + await patchAgentViewSessionStateIf( + sessionId, + (state) => + state.ownership === 'managed' + ? { + ownership: 'removing', + updatedAt: new Date().toISOString(), + } + : undefined, + store, + ); + this.attachSockets.get(sessionId)?.destroy(); + await this.finishRemovingSessionLocked(sessionId); + }), + ), + ); + this.notifyChanged(); + return { sessionId, removed: true }; + } + async release(params?: Record) { + const sessionId = requireSessionId(params); + return this.withAttachSetupLock(sessionId, () => + this.withPromptQueueLock(sessionId, () => + this.workers.withHostSetupLock(sessionId, async () => { + let state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + state = await this.detachIfAttachIsStale(state); + if (state.ownership === 'unmanaged') { + return { sessionId, released: true, alreadyReleased: true }; + } + if (state.ownership === 'removing') { + await this.finishRemovingSessionLocked(sessionId); + this.notifyChanged(); + return { sessionId, released: true, resumedRelease: true }; + } + if ( + state.ownership !== 'managed' || + (state.processState !== 'exited' && + state.processState !== 'hibernated') || + state.attachState !== 'detached' + ) { + throw new Error( + `Agent View session ${sessionId} cannot be released while it is active.`, + ); + } + const accepted = await patchAgentViewSessionStateIf( + sessionId, + (current) => + current.ownership === 'managed' && + (current.processState === 'exited' || + current.processState === 'hibernated') && + current.attachState === 'detached' + ? { + ownership: 'removing', + updatedAt: new Date().toISOString(), + } + : undefined, + this.store, + ); + if (!accepted) { + throw new Error( + `Agent View session ${sessionId} changed while it was being released.`, + ); + } + await this.finishRemovingSessionLocked(sessionId); + this.notifyChanged(); + return { sessionId, released: true }; + }), + ), + ); + } + private async finishRemovingSessionLocked(sessionId: string): Promise { + const state = await readAgentViewSessionState(sessionId, this.store); + if (state?.ownership !== 'removing') { + throw new Error(`Agent View session ${sessionId} is not being removed.`); + } + await this.workers.retireSessionLocked(sessionId); + await clearPersistedPromptQueue(sessionId, this.store); + await removeAgentViewRosterEntry(sessionId, this.store); + await patchAgentViewSessionStateIf( + sessionId, + (current) => + current.ownership === 'removing' + ? { + ownership: 'unmanaged', + processState: 'exited', + updatedAt: new Date().toISOString(), + } + : undefined, + this.store, + ); + } - const onlyInactiveManagedSessions = states.every( - (state) => - state.ownership === 'managed' && - (state.processState === 'hibernated' || - state.processState === 'exited'), + private async finishRemovingSession(sessionId: string): Promise { + return this.withAttachSetupLock(sessionId, () => + this.withPromptQueueLock(sessionId, () => + this.workers.withHostSetupLock(sessionId, async () => { + this.attachSockets.get(sessionId)?.destroy(); + await this.finishRemovingSessionLocked(sessionId); + }), + ), + ); + } + async pin(params?: Record) { + const store = this.store; + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + store, + ); + const pinned = + typeof params?.['pinned'] === 'boolean' ? params['pinned'] : undefined; + const now = new Date().toISOString(); + const update = (current: AgentViewRosterEntry): AgentViewRosterEntry => ({ + ...current, + pinned: pinned ?? !current.pinned, + updatedAt: now, + }); + let entry = await updateAgentViewRosterEntry(sessionId, update, store); + if (!entry) { + // The dispatch-time roster publication is best-effort (it must not + // fail a live dispatch); heal the missing entry lazily so pin/rename + // do not wedge forever after a transient roster write failure. + await this.republishMissingRosterEntry(sessionId); + entry = await updateAgentViewRosterEntry(sessionId, update, store); + } + if (!entry) { + throw new Error(`No Agent View roster entry found for ${sessionId}.`); + } + this.notifyChanged(); + return { sessionId, pinned: Boolean(entry.pinned) }; + } + async rename(params?: Record) { + const store = this.store; + const sessionId = await resolveManagedSessionId( + requireSessionId(params), + store, ); - if (!onlyInactiveManagedSessions) { + const displayName = + typeof params?.['displayName'] === 'string' + ? params['displayName'].trim() + : ''; + const now = new Date().toISOString(); + const update = (current: AgentViewRosterEntry): AgentViewRosterEntry => { + const next = { + ...current, + updatedAt: now, + }; + if (displayName) { + return { + ...next, + displayName, + }; + } + delete next.displayName; + return next; + }; + let entry = await updateAgentViewRosterEntry(sessionId, update, store); + if (!entry) { + await this.republishMissingRosterEntry(sessionId); + entry = await updateAgentViewRosterEntry(sessionId, update, store); + } + if (!entry) { + throw new Error(`No Agent View roster entry found for ${sessionId}.`); + } + this.notifyChanged(); + return { sessionId, displayName: entry.displayName ?? '' }; + } + private async republishMissingRosterEntry(sessionId: string): Promise { + const state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + return; + } + await upsertAgentViewRosterEntry( + { + sessionId, + projectCwd: state.projectCwd, + activeCwd: state.activeCwd, + createdAt: state.createdAt, + updatedAt: new Date().toISOString(), + }, + this.store, + ); + } + async shutdown(params?: Record) { + this.shuttingDown = true; + await Promise.allSettled(this.attachSetupQueues.values()); + if (params?.['keepWorkers'] === true) { + await this.options.onShutdown?.(); + return { shuttingDown: true, keepWorkers: true }; + } + for (const socket of this.attachSockets.values()) { + socket.destroy(); + } + this.attachSockets.clear(); + const workers = await this.workers.shutdownAll(); + if (workers.failed.length > 0) { + this.shuttingDown = false; + return { + shuttingDown: false, + workersStopped: workers.stopped.length, + workersFailed: workers.failed, + }; + } + await this.options.onShutdown?.(); + return { + shuttingDown: true, + workersStopped: workers.stopped.length, + workersFailed: workers.failed, + }; + } + async hibernateIdleSessions() { + const result = await this.hibernateIdleSessionsWithPolicy( + getHibernationPolicy(this.options), + ); + if (result.hibernated.length > 0) { + this.notifyChanged(); + } + return result; + } + async tickIdleHibernation() { + const policy = getHibernationPolicy(this.options); + const hibernation = await this.hibernateIdleSessionsWithPolicy(policy); + if (hibernation.hibernated.length > 0) { + this.notifyChanged(); + } + const autoExitEligible = + !this.autoExitRequested && + (await this.shouldAutoExitAfterHibernation(policy)); + const nowMs = (this.options.now?.() ?? new Date()).getTime(); + if (!autoExitEligible) { this.autoExitEligibleSinceMs = undefined; - return false; + } else if (this.autoExitEligibleSinceMs === undefined) { + this.autoExitEligibleSinceMs = nowMs; + } + const shutdownRequested = + autoExitEligible && + this.autoExitEligibleSinceMs !== undefined && + nowMs - this.autoExitEligibleSinceMs >= policy.autoExitGraceMs; + if (shutdownRequested) { + this.autoExitRequested = true; + await this.options.onShutdown?.(); } + return { + ...hibernation, + shutdownRequested: shutdownRequested || this.autoExitRequested, + }; + } + private async hibernateIdleSessionsWithPolicy( + policy: ReturnType, + ): Promise { + const snapshots = await listAgentViewSessionSnapshots(this.store); const nowMs = (this.options.now?.() ?? new Date()).getTime(); - this.autoExitEligibleSinceMs ??= nowMs; + const hibernated: string[] = []; + for (const snapshot of snapshots) { + if (snapshot.state.ownership === 'removing') { + await this.finishRemovingSession(snapshot.sessionId).catch(() => {}); + continue; + } + if (snapshot.state.ownership === 'adopting') { + await this.workers.refreshMissingWorkerState(snapshot.state); + continue; + } + const host = this.workers.get(snapshot.sessionId); + if (!host) { + await this.workers.refreshMissingWorkerState(snapshot.state); + continue; + } + if ( + this.hasLiveAttach(snapshot.sessionId) || + this.hasPendingWorkerInputControl(snapshot.sessionId) || + this.hasPendingWorkerStopControl(snapshot.sessionId) || + !canHibernateSession(snapshot, nowMs, policy.idleMs) + ) { + continue; + } + + const didHibernate = await this.withPromptQueueLock( + snapshot.sessionId, + () => + this.workers.withHostSetupLock(snapshot.sessionId, async () => { + // Re-check inside the lock: a concurrent dispatch or respawn + // may have changed the session between the snapshot and now. + if ( + this.workers.get(snapshot.sessionId) !== host || + this.hasLiveAttach(snapshot.sessionId) || + this.hasPendingWorkerInputControl(snapshot.sessionId) || + this.hasPendingWorkerStopControl(snapshot.sessionId) + ) { + return false; + } + // Re-verify the pin inside the lock: the snapshot's rosterEntry + // comes from a soft-fail join, so a transient roster read error + // must not silently void the user's keep-alive opt-out. An + // unreadable or corrupt roster fails closed (skip this + // candidate). + let roster; + try { + roster = await readAgentViewRosterStrict(this.store); + } catch { + return false; + } + if ( + roster.sessions.some( + (entry) => + entry.pinned && + sanitizeSessionId(entry.sessionId) === snapshot.sessionId, + ) + ) { + return false; + } + this.activeHibernations.add(snapshot.sessionId); + try { + if (!(await markSessionHibernating(snapshot.state, this.store))) { + return false; + } + const latestState = await readAgentViewSessionState( + snapshot.sessionId, + this.store, + ); + const latestActivity = await readAgentViewActivity( + snapshot.sessionId, + this.store, + ); + // A pin committed after the pre-hibernating roster check above + // must not be ignored either; an unreadable roster fails closed. + let pinnedLate = true; + try { + pinnedLate = ( + await readAgentViewRosterStrict(this.store) + ).sessions.some( + (entry) => + entry.pinned && + sanitizeSessionId(entry.sessionId) === snapshot.sessionId, + ); + } catch { + // pinnedLate stays true. + } + if ( + !latestState || + pinnedLate || + latestState.sessionState === 'stopped' || + latestState.sessionState === 'working' || + latestState.processState !== 'hibernating' || + this.hasLiveAttach(snapshot.sessionId) || + this.hasPendingWorkerInputControl(snapshot.sessionId) || + this.hasPendingWorkerStopControl(snapshot.sessionId) || + hasPendingPrompt(latestActivity) || + // Re-run the idle-window freshness check against the re-read + // record: activity a worker reported since the pre-lock + // snapshot makes hibernating now cost the user a respawn. + (latestActivity?.lastActivityAt !== undefined && + nowMs - Date.parse(latestActivity.lastActivityAt) < + policy.idleMs) + ) { + if (latestState?.processState === 'hibernating') { + // Flip back inside the queued mutation: a concurrent stop + // verdict enqueued between the re-read above and this + // rollback must not be re-asserted away by a stale snapshot. + await patchAgentViewSessionStateIf( + snapshot.sessionId, + (existing) => + existing.processState === 'hibernating' + ? { + processState: 'alive', + updatedAt: new Date().toISOString(), + } + : undefined, + this.store, + ); + } + return false; + } + try { + await this.workers.shutdownHost(snapshot.sessionId, host); + } catch { + await patchAgentViewSessionStateIf( + snapshot.sessionId, + (existing) => + existing.processState === 'hibernating' + ? { + processState: 'alive', + updatedAt: new Date().toISOString(), + } + : undefined, + this.store, + ); + return false; + } + return markSessionHibernated(snapshot.state, this.store); + } finally { + this.activeHibernations.delete(snapshot.sessionId); + } + }), + ); + if (didHibernate) { + hibernated.push(snapshot.sessionId); + } + } + + return { hibernated }; + } + + private async shouldAutoExitAfterHibernation( + policy: ReturnType, + ): Promise { + if (!policy.autoExit) { + return false; + } + pruneClosedSockets(this.subscribers); + pruneClosedSocketMap(this.attachSockets); + if (this.subscribers.size > 0 || this.attachSockets.size > 0) { + return false; + } + + const states = await listAgentViewSessionStates(this.store); + // An adoption in flight launches a detached host and waits for ready; do + // not auto-exit underneath it. + if ( + states.some( + (state) => + state.ownership === 'adopting' || state.ownership === 'removing', + ) + ) { + return false; + } + const managed = states.filter((state) => state.ownership === 'managed'); + return ( + states.length > 0 && + managed.every((state) => !isAliveProcessState(state.processState)) + ); + } + + private async attachSessionStream( + sessionId: string, + socket: Socket, + requestId: string, + prepared: PreparedAttach, + ): Promise { + const { host, lease } = prepared; + + const controller = new AbortController(); + if (socket.destroyed) { + controller.abort(); + } + socket.once('close', () => controller.abort()); + void host.exited + .catch(() => {}) + .finally(() => { + controller.abort(); + }); + const heartbeat = setInterval(() => { + this.attachLeases.heartbeat(sessionId, lease.leaseId); + }, DEFAULT_ATTACH_LEASE_HEARTBEAT_MS); + heartbeat.unref?.(); + let bridged = false; + try { + if ( + controller.signal.aborted || + this.workers.get(sessionId) !== host || + this.attachLeases.get(sessionId)?.leaseId !== lease.leaseId + ) { + return; + } + await writeAttachState(sessionId, 'attached', this.store); + this.attachSockets.set(sessionId, socket); + socket.write( + `${JSON.stringify({ + id: requestId, + ok: true, + result: { sessionId, lease }, + })}\n`, + ); + this.queueWorkerRedraw(sessionId); + bridged = true; + await bridgeAgentViewTerminal({ + stdin: socket, + stdout: socket, + pty: host, + detachSignal: controller.signal, + }); + } finally { + clearInterval(heartbeat); + const wasCurrent = this.attachSockets.get(sessionId) === socket; + if (wasCurrent) { + this.attachSockets.delete(sessionId); + } + const released = this.attachLeases.release(sessionId, lease.leaseId); + if (released && (wasCurrent || !bridged)) { + // Identity-guarded like the map deletion: a superseded bridge's + // teardown must not flip the persisted state to detached under a + // live attach. + try { + await writeAttachState(sessionId, 'detached', this.store); + } catch { + // Best-effort: a store error during detach must not mask + // the original error from the try block. + } + socket.end(); + } + // Pre-bridge failure: leave the socket open so the RPC layer can + // deliver the structured error envelope instead of a bare EOF. + } + } + + private queueWorkerRedraw(sessionId: string): void { + const events = this.pendingWorkerControls.get(sessionId) ?? []; + events.push({ + type: 'redraw', + sequence: this.nextSequence(), + at: new Date().toISOString(), + }); + this.pendingWorkerControls.set(sessionId, events); + } + + private queueWorkerStop(sessionId: string): void { + const events = this.pendingWorkerControls.get(sessionId) ?? []; + events.push({ + type: 'stop', + sequence: this.nextSequence(), + at: new Date().toISOString(), + }); + this.pendingWorkerControls.set(sessionId, events); + } + + private async queuePromptForSession( + sessionId: string, + text: string, + ): Promise { + return this.withPromptQueueLock(sessionId, async () => { + await this.queuePromptForSessionLocked(sessionId, text); + }); + } + + private async queuePromptForSessionLocked( + sessionId: string, + text: string, + ): Promise { + let state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.attachState === 'attached') { + state = await this.detachIfAttachIsStale(state); + } + if (state.attachState === 'attached' || this.hasLiveAttach(sessionId)) { + throw new Error( + `Agent View session ${sessionId} is currently attached elsewhere.`, + ); + } + + const respawnedStoppedOrFailed = + await this.workers.respawnStoppedOrFailedSessionIfNeeded(sessionId); + if (respawnedStoppedOrFailed) { + state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + } + // Heal a dead-but-non-terminal session (e.g. persisted 'working' after a + // daemon restart) so its orphaned pending-prompt marker can be cleared. + state = await this.workers.refreshMissingWorkerState(state); + + const storedActivity = await readAgentViewActivity(sessionId, this.store); + const activity = await clearStalePendingPromptIfNeeded( + state, + storedActivity, + this.store, + this.hasPendingWorkerInputControl(sessionId), + ); + if ( + hasPendingPrompt(activity) || + this.hasPendingWorkerInputControl(sessionId) + ) { + throw new Error( + `Agent View session ${sessionId} is waiting for the previous response.`, + ); + } + if ( + !respawnedStoppedOrFailed && + !canAgentViewQueueFollowUp(state, activity) + ) { + throw new Error( + `Agent View session ${sessionId} is not ready for follow-up.`, + ); + } + + if (!this.workers.has(sessionId)) { + await this.workers.reconnectSessionHost(sessionId); + } + if (!this.workers.has(sessionId)) { + await this.workers.respawnSession(sessionId); + } + + await this.workers.withHostSetupLock(sessionId, async () => { + state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.attachState === 'attached') { + state = await this.detachIfAttachIsStale(state); + } + if (state.attachState === 'attached' || this.hasLiveAttach(sessionId)) { + throw new Error( + `Agent View session ${sessionId} is currently attached elsewhere.`, + ); + } + if ( + state.processState === 'hibernating' || + state.processState === 'hibernated' + ) { + throw new Error( + `Agent View session ${sessionId} is not ready for follow-up.`, + ); + } + + const now = new Date().toISOString(); + const promptId = randomUUID(); + const latestActivity = await readAgentViewActivity(sessionId, this.store); + await writeAgentViewActivity( + sessionId, + { + schemaVersion: 1, + ...getQueuedPromptActivityPatch(text, promptId, now), + lastActivityAt: now, + capabilities: + latestActivity?.capabilities ?? activity?.capabilities ?? [], + }, + this.store, + ); + const events = this.pendingWorkerControls.get(sessionId) ?? []; + events.push({ + type: 'prompt', + sequence: this.nextSequence(), + promptId, + text, + at: now, + }); + this.pendingWorkerControls.set(sessionId, events); + }); + } + + private async withPromptQueueLock( + sessionId: string, + action: () => Promise, + ): Promise { + const previous = this.promptQueues.get(sessionId) ?? Promise.resolve(); + const current = previous.then(action, action); + const queued = current + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (this.promptQueues.get(sessionId) === queued) { + this.promptQueues.delete(sessionId); + } + }); + this.promptQueues.set(sessionId, queued); + return current; + } + + private async queueAnswerForSession( + sessionId: string, + text: string, + ): Promise { + return this.withPromptQueueLock(sessionId, async () => { + await this.queueAnswerForSessionLocked(sessionId, text); + }); + } + + private async queueAnswerForSessionLocked( + sessionId: string, + text: string, + ): Promise { + let state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.attachState === 'attached') { + state = await this.detachIfAttachIsStale(state); + } + if (state.attachState === 'attached') { + throw new Error( + `Agent View session ${sessionId} is currently attached elsewhere.`, + ); + } + if (state.sessionState !== 'needs_input') { + throw new Error( + `Agent View session ${sessionId} is not waiting for input.`, + ); + } + + const now = new Date().toISOString(); + const activity = await readAgentViewActivity(sessionId, this.store); + const inputStateUpdatedAt = state.updatedAt; + if (getAgentViewActivityInputState(activity) === 'soft_question') { + await this.queuePromptForSessionLocked(sessionId, text); + return; + } + if (!this.workers.has(sessionId)) { + await this.workers.reconnectSessionHost(sessionId); + } + if (!this.workers.has(sessionId)) { + throw new Error(`Agent View session ${sessionId} is not running.`); + } + state = await readAgentViewSessionState(sessionId, this.store); + const latestActivity = await readAgentViewActivity(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.attachState === 'attached') { + state = await this.detachIfAttachIsStale(state); + } + if (state.attachState === 'attached' || this.hasLiveAttach(sessionId)) { + throw new Error( + `Agent View session ${sessionId} is currently attached elsewhere.`, + ); + } + if ( + state.sessionState !== 'needs_input' || + state.updatedAt !== inputStateUpdatedAt || + latestActivity?.lastActivityAt !== activity?.lastActivityAt || + latestActivity?.waitingFor !== activity?.waitingFor || + latestActivity?.inputKind !== activity?.inputKind + ) { + throw new Error( + `Agent View session ${sessionId} is no longer waiting for the same input.`, + ); + } + if (this.hasPendingWorkerInputControl(sessionId)) { + throw new Error( + `Agent View session ${sessionId} is waiting for the previous response.`, + ); + } + // Touch the activity record before pushing the in-memory control: if + // this write fails the RPC rejects with no side effects, and a retry + // still passes the pending-answer checks. Queued answers are + // intentionally ephemeral — the in-memory control is their only record. + await writeAgentViewActivity( + sessionId, + { + schemaVersion: 1, + lastActivityAt: now, + capabilities: activity?.capabilities ?? [], + }, + this.store, + ); + const events = this.pendingWorkerControls.get(sessionId) ?? []; + events.push({ + type: 'answer', + sequence: this.nextSequence(), + text, + at: now, + }); + this.pendingWorkerControls.set(sessionId, events); + } + + private hasPendingWorkerInputControl(sessionId: string): boolean { + return (this.pendingWorkerControls.get(sessionId) ?? []).some( + (event) => event.type === 'prompt' || event.type === 'answer', + ); + } + + private hasPendingWorkerStopControl(sessionId: string): boolean { + return (this.pendingWorkerControls.get(sessionId) ?? []).some( + (event) => event.type === 'stop', + ); + } + + private hasLiveAttach(sessionId: string): boolean { return ( - nowMs - this.autoExitEligibleSinceMs >= - (policy?.autoExitGraceMs ?? DEFAULT_SUPERVISOR_AUTO_EXIT_GRACE_MS) + this.attachSockets.has(sessionId) || + this.attachLeases.get(sessionId) !== undefined + ); + } + + private async detachIfAttachIsStale( + state: AgentViewSessionStateFile, + ): Promise { + if ( + (state.attachState !== 'attached' && state.attachState !== 'attaching') || + this.attachSockets.has(state.sessionId) || + this.attachLeases.get(state.sessionId) + ) { + return state; + } + // Patch only the owned field through the queued mutation: re-asserting + // a whole stale snapshot here would erase a concurrent exit verdict. + // Keep the original updatedAt: clearing a stale attach flag is a + // reconciliation, not a lifecycle change — bumping it would reset the + // clock isStaleStartingState uses to recover a stuck starting session. + await patchAgentViewSessionState( + state.sessionId, + { attachState: 'detached' }, + this.store, ); + this.notifyChanged(); + return { ...state, attachState: 'detached' }; } +} + +class WorkerRegistry { + private readonly ptyHosts = new Map(); + private readonly hostSetupQueues = new Map>(); + private readonly pendingWorkerReady = new Map< + string, + AgentViewWorkerReadyWaiter + >(); + private readonly bootGeneration = new Map(); + private readonly stopFallbacks = new Map< + string, + { host: AgentViewPtyHostHandle; timeout: NodeJS.Timeout } + >(); + + constructor( + private readonly options: AgentViewSupervisorProcessOptions, + private readonly onChanged: () => void, + private readonly onHostReleased: (sessionId: string) => void, + private readonly preserveQueuedInputControls: (sessionId: string) => void, + private readonly hasPendingStopControl: (sessionId: string) => boolean, + private readonly hasLiveAttach: (sessionId: string) => boolean, + private readonly queueStop: (sessionId: string) => void, + private readonly isHibernationInProgress: (sessionId: string) => boolean, + ) {} private get store(): AgentViewStoreOptions { - return { - ...(this.options.globalDir ? { globalDir: this.options.globalDir } : {}), - }; + return storeOptions(this.options); + } + + has(sessionId: string): boolean { + return this.ptyHosts.has(sessionId); + } + + get(sessionId: string): AgentViewPtyHostHandle | undefined { + return this.ptyHosts.get(sessionId); + } + + set(sessionId: string, host: AgentViewPtyHostHandle): void { + const previous = this.ptyHosts.get(sessionId); + if (previous && previous !== host) { + throw new Error( + `Agent View session ${sessionId} already has a registered PTY host.`, + ); + } + this.ptyHosts.set(sessionId, host); + this.trackHostExit(sessionId, host); + } + + async stopSession(sessionId: string, queueStop: () => void): Promise { + return this.withHostSetupLock(sessionId, () => + this.stopSessionLocked(sessionId, queueStop), + ); + } + + private async stopSessionLocked( + sessionId: string, + queueStop: () => void, + ): Promise { + const host = await this.getOrReconnectSessionHostLocked(sessionId); + const generation = this.bootGeneration.get(sessionId); + this.rejectPendingWorkerReady( + sessionId, + new AgentViewSessionStoppedError(sessionId, 'worker'), + generation, + ); + if (host) { + queueStop(); + this.scheduleStopFallback(sessionId, host); + await markStoppedSession(sessionId, this.store, 'alive'); + return; + } + const storedWorker = await readAgentViewWorker(sessionId, this.store); + if ( + [storedWorker?.hostPid, storedWorker?.workerPid].some( + (pid) => pid !== undefined && isPidRunning(pid), + ) + ) { + throw new Error( + `Agent View session ${sessionId} has a stored worker whose identity cannot be verified.`, + ); + } + await clearAgentViewWorkerPids(sessionId, this.store); + await markStoppedSession(sessionId, this.store, 'exited'); + } + + async killSession( + sessionId: string, + signal: NodeJS.Signals = 'SIGTERM', + ): Promise { + return this.withHostSetupLock(sessionId, () => + this.killSessionLocked(sessionId, signal), + ); + } + + private async killSessionLocked( + sessionId: string, + signal: NodeJS.Signals, + ): Promise { + const host = await this.getOrReconnectSessionHostLocked(sessionId); + if (host) { + await this.retireSessionHost(sessionId, host, false, signal); + } else { + await this.ensureNoStoredWorkerProcess(sessionId); + this.onHostReleased(sessionId); + } + await markStoppedSession(sessionId, this.store, 'exited'); + } + + async retireSessionLocked(sessionId: string): Promise { + const host = await this.getOrReconnectSessionHostLocked(sessionId); + if (host) { + await this.retireSessionHost(sessionId, host); + } else { + await this.ensureNoStoredWorkerProcess(sessionId); + this.onHostReleased(sessionId); + } + await markStoppedSession(sessionId, this.store, 'exited'); + } + + async retireSessionHost( + sessionId: string, + host: AgentViewPtyHostHandle, + preserveInput = false, + signal?: NodeJS.Signals, + ): Promise { + const generation = this.bootGeneration.get(sessionId); + this.rejectPendingWorkerReady( + sessionId, + new AgentViewSessionStoppedError(sessionId, 'worker'), + generation, + ); + const exit = await terminatePtyHost(sessionId, host, signal); + if (exit.kind === 'unreachable') { + throw new Error( + `Agent View session ${sessionId} host became unreachable before its exit was confirmed.`, + ); + } + if (this.ptyHosts.get(sessionId) === host) { + this.clearStopFallback(sessionId, host); + this.ptyHosts.delete(sessionId); + await clearAgentViewWorkerPids(sessionId, this.store); + if (preserveInput) { + this.preserveQueuedInputControls(sessionId); + } else { + this.onHostReleased(sessionId); + } + } + } + + async shutdownHost( + sessionId: string, + host: AgentViewPtyHostHandle, + ): Promise { + await this.retireSessionHost(sessionId, host); + } + + async shutdownAll(): Promise<{ + stopped: string[]; + failed: Array<{ sessionId: string; error: string }>; + }> { + const sessionIds = Array.from(this.ptyHosts.keys()); + const results = await Promise.allSettled( + sessionIds.map((sessionId) => + this.withHostSetupLock(sessionId, async () => { + const host = this.ptyHosts.get(sessionId); + if (!host) return undefined; + await this.shutdownHost(sessionId, host); + await markStoppedSession(sessionId, this.store, 'exited'); + return sessionId; + }), + ), + ); + const stopped: string[] = []; + const failed: Array<{ sessionId: string; error: string }> = []; + for (const [index, result] of results.entries()) { + const sessionId = sessionIds[index]!; + if (result.status === 'fulfilled') { + if (result.value !== undefined) stopped.push(result.value); + } else { + failed.push({ + sessionId, + error: + result.reason instanceof Error + ? result.reason.message + : String(result.reason), + }); + } + } + return { stopped, failed }; + } + + async launchPtyHostForSupervisor( + launchRecord: AgentViewLaunchFile, + store: AgentViewStoreOptions, + identity?: AgentViewPtyHostIdentity, + ): Promise { + const launch = await refreshStoredResumeWorkerLaunchIfNeeded( + launchRecord, + store, + ); + if (this.options.launchPtyHost) { + return this.options.launchPtyHost(launch); + } + return launchAgentViewPtyHostProcess(launch, { + ...store, + ...(identity ? { identity } : {}), + }); } + + createHostIdentity(sessionId: string): AgentViewPtyHostIdentity | undefined { + return this.options.launchPtyHost + ? undefined + : createAgentViewPtyHostIdentity(sessionId, this.store); + } + + waitForWorkerReadyIfNeeded( + sessionId: string, + expectedCwd: string, + ): Promise { + if (!shouldWaitForWorkerReady(this.options)) { + return Promise.resolve(); + } + + this.rejectPendingWorkerReady( + sessionId, + new Error(`Agent View worker ${sessionId} was superseded before ready.`), + ); + + const generation = (this.bootGeneration.get(sessionId) ?? 0) + 1; + this.bootGeneration.set(sessionId, generation); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pendingWorkerReady.delete(sessionId); + reject( + new Error( + `Agent View worker ${sessionId} did not report ready before timeout.`, + ), + ); + }, this.options.workerReadyTimeoutMs ?? DEFAULT_WORKER_READY_TIMEOUT_MS); + timeout.unref?.(); + this.pendingWorkerReady.set(sessionId, { + expectedCwd: resolveSessionCwd(expectedCwd), + timeout, + generation, + resolve: () => { + clearTimeout(timeout); + resolve(); + }, + reject: (error) => { + clearTimeout(timeout); + reject(error); + }, + }); + }); + } + + hasPendingWorkerReady(sessionId: string): boolean { + return this.pendingWorkerReady.has(sessionId); + } + + getBootGeneration(sessionId: string): number | undefined { + return this.bootGeneration.get(sessionId); + } + + validatePendingWorkerReady( + event: Extract, + expectedGeneration?: number | null, + ): void { + const waiter = this.pendingWorkerReady.get(event.sessionId); + if (!waiter) return; + if ( + expectedGeneration !== undefined && + waiter.generation !== expectedGeneration + ) { + throw new Error( + `Agent View worker ${event.sessionId} reported ready for a stale generation.`, + ); + } + + const actualCwd = resolveSessionCwd(event.cwd); + if (actualCwd !== waiter.expectedCwd) { + const error = new Error( + `Agent View worker ${event.sessionId} reported cwd ${actualCwd}, expected ${waiter.expectedCwd}.`, + ); + this.pendingWorkerReady.delete(event.sessionId); + waiter.reject(error); + throw error; + } + } + + resolvePendingWorkerReady( + sessionId: string, + expectedGeneration?: number | null, + ): void { + const waiter = this.pendingWorkerReady.get(sessionId); + if (!waiter) return; + if ( + expectedGeneration !== undefined && + waiter.generation !== expectedGeneration + ) { + return; + } + this.pendingWorkerReady.delete(sessionId); + waiter.resolve(); + } + + rejectPendingWorkerReady( + sessionId: string, + error: unknown, + expectedGeneration?: number | null, + ): void { + const waiter = this.pendingWorkerReady.get(sessionId); + if (!waiter) return; + if ( + expectedGeneration !== undefined && + waiter.generation !== expectedGeneration + ) { + return; + } + this.pendingWorkerReady.delete(sessionId); + waiter.reject(error instanceof Error ? error : new Error(String(error))); + } + + async reconnectSessionHost(sessionId: string): Promise { + return this.withHostSetupLock(sessionId, () => + this.reconnectSessionHostLocked(sessionId), + ); + } + + async reconnectSessionHostLocked(sessionId: string): Promise { + if (this.ptyHosts.has(sessionId)) { + return true; + } + const [launch, worker] = await Promise.all([ + readAgentViewLaunch(sessionId, this.store), + readAgentViewWorker(sessionId, this.store), + ]); + if (!launch || !worker?.hostEndpoint) { + return false; + } + + try { + const host = await connectAgentViewPtyHostProcess( + launch, + worker.hostEndpoint, + worker.hostAuthToken, + worker.hostId ? { expectedHostId: worker.hostId } : {}, + ); + this.set(sessionId, host); + try { + await writeAgentViewWorker( + sessionId, + { + schemaVersion: 1, + hostPid: host.pid, + workerPid: host.workerPid, + ...(host.hostId ? { hostId: host.hostId } : {}), + hostEndpoint: worker.hostEndpoint, + ...(worker.hostAuthToken + ? { hostAuthToken: worker.hostAuthToken } + : {}), + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: worker.recentOutputBytes, + }, + this.store, + ); + } catch { + // The reconnected worker is already alive; do not kill it just because + // pid bookkeeping could not be refreshed. + } + return true; + } catch { + return false; + } + } + + async getOrReconnectSessionHost( + sessionId: string, + ): Promise { + return this.withHostSetupLock(sessionId, () => + this.getOrReconnectSessionHostLocked(sessionId), + ); + } + + private async getOrReconnectSessionHostLocked( + sessionId: string, + ): Promise { + const existing = this.ptyHosts.get(sessionId); + if (existing) { + return existing; + } + if (await this.reconnectSessionHostLocked(sessionId)) { + return this.ptyHosts.get(sessionId); + } + return undefined; + } + + async respawnSession( + sessionId: string, + ): Promise<{ sessionId: string; respawned: true }> { + return this.withHostSetupLock(sessionId, () => + this.respawnSessionLocked(sessionId), + ); + } + + private async respawnSessionLocked( + sessionId: string, + ): Promise<{ sessionId: string; respawned: true }> { + const state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.ownership !== 'managed') { + throw new Error(`Agent View session ${sessionId} is not managed.`); + } + const refreshedState = await this.refreshMissingWorkerState(state, () => + this.reconnectSessionHostLocked(sessionId), + ); + if (this.hasLiveAttach(sessionId)) { + throw new Error( + `Agent View session ${sessionId} cannot be respawned: it is currently attached.`, + ); + } + const activity = await readAgentViewActivity(sessionId, this.store); + const blockReason = getRespawnBlockReason(refreshedState, activity); + if (blockReason) { + throw new Error( + `Agent View session ${sessionId} cannot be respawned: ${blockReason}.`, + ); + } + const launch = await readAgentViewLaunch(sessionId, this.store); + if (!launch) { + throw new Error(`No Agent View launch record found for ${sessionId}.`); + } + // Rotate the worker sideband token on respawn so the replaced worker's + // predecessor token can no longer authenticate worker-side calls. + const token = randomUUID(); + const resumeLaunch = await writeResumeWorkerLaunch( + launch, + token, + refreshedState.initialPromptPending === true, + this.store, + ); + // The replacement worker must not inherit stop/redraw controls queued + // for its predecessor; prompt/answer controls are preserved. + this.preserveQueuedInputControls(sessionId); + await patchAgentViewActivityIf( + sessionId, + (activity) => + hasPendingPrompt(activity) && activity.queuedPromptDeliveredAt + ? { queuedPromptDeliveredAt: undefined } + : undefined, + this.store, + ); + const existingHost = this.ptyHosts.get(sessionId); + if (existingHost) { + await this.retirePredecessorHost(sessionId, existingHost); + if (this.ptyHosts.get(sessionId) === existingHost) { + // Release the registry entry before launching: the graceful-stop + // fallback timer matches the registered host, and a stale match + // mid-respawn would record an 'exited' verdict for the replacement + // (the revive path releases its entry the same way). + this.ptyHosts.delete(sessionId); + } + } else { + // No in-memory host: a persisted worker process may still be running + // after a supervisor restart. Without an authenticated handle its + // identity cannot be trusted, so fail closed unless every pid is gone. + await this.ensureNoStoredWorkerProcess(sessionId); + } + let host: AgentViewPtyHostHandle | undefined; + try { + // Persist the respawn bookkeeping before launch so any event the + // replacement worker emits right away authenticates against the + // rotated tokenDigest and passes the dead-worker guard (processState + // 'starting'); writing them after launch lets a fast ready die at + // the ready timeout. + const latestState = + (await readAgentViewSessionState(sessionId, this.store)) ?? state; + let preLaunchPatchApplied = false; + await patchAgentViewSessionStateIf( + sessionId, + (existing) => { + // Re-validate inside the queue: a concurrent stop verdict enqueued + // after the pre-launch read must not be overwritten by 'starting'. + if ( + existing.sessionState === 'stopped' && + (latestState.sessionState !== 'stopped' || + existing.updatedAt !== latestState.updatedAt) + ) { + return undefined; + } + preLaunchPatchApplied = true; + // A stopped session's 'alive' marker is a straggler this respawn + // just signalled; the replacement starts fresh instead of + // inheriting the stale alive verdict. + const keepAlive = + existing.processState === 'alive' && + existing.sessionState !== 'stopped'; + return { + sessionState: keepAlive ? existing.sessionState : 'starting', + processState: keepAlive ? 'alive' : 'starting', + attachState: 'detached', + updatedAt: new Date().toISOString(), + }; + }, + this.store, + ); + if (!preLaunchPatchApplied) { + throw new AgentViewSessionStoppedError(sessionId, 'session'); + } + await writeAgentViewWorker( + sessionId, + { + schemaVersion: 1, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + tokenDigest: digestAgentViewWorkerToken(token), + recentOutputBytes: 0, + }, + this.store, + ); + const ready = this.waitForWorkerReadyIfNeeded( + sessionId, + resumeLaunch.activeCwd, + ); + void ready.catch(() => {}); + const hostIdentity = this.createHostIdentity(sessionId); + if (hostIdentity) { + await writeAgentViewWorker( + sessionId, + { + schemaVersion: 1, + hostId: hostIdentity.hostId, + hostEndpoint: hostIdentity.endpoint, + hostAuthToken: hostIdentity.authToken, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + tokenDigest: digestAgentViewWorkerToken(token), + recentOutputBytes: 0, + }, + this.store, + ); + } + host = await this.launchPtyHostForSupervisor( + resumeLaunch, + this.store, + hostIdentity, + ); + await writeAgentViewWorker( + sessionId, + { + schemaVersion: 1, + hostPid: host.pid, + workerPid: host.workerPid, + ...(host.hostId ? { hostId: host.hostId } : {}), + ...(host.endpoint ? { hostEndpoint: host.endpoint } : {}), + ...(host.authToken ? { hostAuthToken: host.authToken } : {}), + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + recentOutputBytes: 0, + }, + this.store, + ); + await ensureSessionStillLaunchable(sessionId, this.store, host, { + allowStopped: refreshedState.sessionState === 'stopped', + stoppedUpdatedAt: refreshedState.updatedAt, + }); + this.set(sessionId, host); + await ready; + await ensureSessionStillLaunchable(sessionId, this.store); + } catch (error) { + this.rejectPendingWorkerReady(sessionId, error); + if (isStoppedError(error) && this.hasPendingStopControl(sessionId)) { + // A concurrent graceful stop already owns this shutdown: it queued + // a stop control and scheduled the fallback. Hard-killing the host + // and dropping the registry entry here would strand the queued + // control with no host to deliver it to, and an 'exited' verdict + // would contradict the still-running host. + throw error; + } + if (host) { + // SIGTERM alone can leave a draining host holding the session + // socket lock, failing the next relaunch with EADDRINUSE: retire + // it fully (SIGKILL escalation + exit wait) before rethrowing. + await this.retirePredecessorHost(sessionId, host); + } + if (host && this.ptyHosts.get(sessionId) === host) { + // Only drop the registry entry — preserveQueuedInputControls already + // saved the pending prompt/answer controls for the next respawn. + this.ptyHosts.delete(sessionId); + } + if (isStoppedError(error)) { + await markStoppedSession(sessionId, this.store, 'exited'); + } else { + await markFailedSession(sessionId, error, this.store); + } + throw error; + } + return { sessionId, respawned: true }; + } + + async withHostSetupLock( + sessionId: string, + action: () => Promise, + ): Promise { + const previous = this.hostSetupQueues.get(sessionId) ?? Promise.resolve(); + const current = previous.then(action, action); + const queued = current + .then( + () => undefined, + () => undefined, + ) + .finally(() => { + if (this.hostSetupQueues.get(sessionId) === queued) { + this.hostSetupQueues.delete(sessionId); + } + }); + this.hostSetupQueues.set(sessionId, queued); + return current; + } + + private async retirePredecessorHost( + sessionId: string, + host: AgentViewPtyHostHandle, + ): Promise { + // SIGTERM alone leaves a draining worker holding the per-session socket + // lock, failing the replacement launch with EADDRINUSE: shut down with + // SIGKILL escalation and wait for the actual exit before relaunching. + const exit = await terminatePtyHost(sessionId, host); + if (exit.kind === 'unreachable') { + throw new Error( + `Agent View session ${sessionId} host became unreachable before its exit was confirmed.`, + ); + } + await clearAgentViewWorkerPids(sessionId, this.store); + } + + async ensureNoStoredWorkerProcess(sessionId: string): Promise { + await this.assertNoStoredWorkerProcess(sessionId); + await clearAgentViewWorkerPids(sessionId, this.store); + } + + private async assertNoStoredWorkerProcess(sessionId: string): Promise { + const worker = await readAgentViewWorker(sessionId, this.store); + for (const pid of [worker?.hostPid, worker?.workerPid]) { + if (!pid) continue; + if (isPidRunning(pid)) { + throw new Error( + `Agent View session ${sessionId} has a stored worker whose identity cannot be verified.`, + ); + } + } + } + + async respawnSessionForAttachIfInactive(sessionId: string): Promise { + return this.withHostSetupLock(sessionId, () => + this.respawnSessionForAttachIfInactiveLocked(sessionId), + ); + } + + private async respawnSessionForAttachIfInactiveLocked( + sessionId: string, + ): Promise { + let state = await readAgentViewSessionState(sessionId, this.store); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.ownership !== 'managed') { + throw new Error(`Agent View session ${sessionId} is not managed.`); + } + if ( + (state.processState === 'starting' || + state.processState === 'restarting') && + !isStaleStartingState(state, this.options) + ) { + throw new Error( + `Agent View session ${sessionId} is still ${state.processState}.`, + ); + } + const staleStarting = isStaleStartingState(state, this.options); + if (state.attachState === 'attached') { + state = { + ...state, + attachState: 'detached', + updatedAt: new Date().toISOString(), + }; + await patchAgentViewSessionState( + sessionId, + { attachState: 'detached', updatedAt: state.updatedAt }, + this.store, + ); + } + if (staleStarting) { + // A reconnect failure is not proof of death. Stored pids are only + // liveness probes; an unverified live process blocks this verdict. + await this.ensureNoStoredWorkerProcess(sessionId); + const failedAt = new Date().toISOString(); + let failedApplied = false; + await patchAgentViewSessionStateIf( + sessionId, + (existing) => { + // Re-validate inside the queue: a concurrent stop (or completed) + // verdict that landed first must not be rewritten as a failure. + if ( + existing.sessionState === 'stopped' || + existing.sessionState === 'completed' + ) { + return undefined; + } + failedApplied = true; + return { + sessionState: 'failed', + processState: 'exited', + attachState: 'detached', + updatedAt: failedAt, + }; + }, + this.store, + ); + state = failedApplied + ? { + ...state, + sessionState: 'failed', + processState: 'exited', + attachState: 'detached', + updatedAt: failedAt, + } + : ((await readAgentViewSessionState(sessionId, this.store)) ?? state); + } + if (state.sessionState === 'stopped') { + state = { + ...state, + processState: 'exited', + attachState: 'detached', + updatedAt: new Date().toISOString(), + }; + await patchAgentViewSessionState( + sessionId, + { + processState: 'exited', + attachState: 'detached', + updatedAt: state.updatedAt, + }, + this.store, + ); + } + state = await this.refreshMissingWorkerState(state, () => + this.reconnectSessionHostLocked(sessionId), + ); + const blockReason = getRespawnBlockReason( + state, + await readAgentViewActivity(sessionId, this.store), + ); + if (!blockReason) { + await this.respawnSessionLocked(sessionId); + return true; + } + return false; + } + + async respawnStoppedOrFailedSessionIfNeeded( + sessionId: string, + ): Promise { + return this.withHostSetupLock(sessionId, async () => { + const state = await readAgentViewSessionState(sessionId, this.store); + if ( + !state || + (state.sessionState !== 'stopped' && state.sessionState !== 'failed') + ) { + return false; + } + + let host = this.ptyHosts.get(sessionId); + const refreshedState = host + ? state + : await this.refreshMissingWorkerState(state, () => + this.reconnectSessionHostLocked(sessionId), + ); + host ??= this.ptyHosts.get(sessionId); + // A live attach must block the respawn even when a host is still + // registered (e.g. draining inside the graceful-stop window); the + // surviving process itself is handled below, so only the attach check + // applies to the stopped/failed states this method revives. + if ( + refreshedState.attachState !== 'detached' || + this.hasLiveAttach(sessionId) + ) { + throw new Error( + `Agent View session ${sessionId} cannot be respawned: it is currently attached.`, + ); + } + if (host) { + await this.retirePredecessorHost(sessionId, host); + if (this.ptyHosts.get(sessionId) === host) { + // Reviving releases only the registry entry: queued prompt/answer + // controls and the persisted marker belong to accepted user input + // the replacement worker must still deliver. respawnSessionLocked + // filters out superseded stop/redraw controls before launching. + this.ptyHosts.delete(sessionId); + } + } else { + await this.ensureNoStoredWorkerProcess(sessionId); + } + await patchAgentViewSessionState( + sessionId, + { + processState: 'exited', + attachState: 'detached', + updatedAt: new Date().toISOString(), + }, + this.store, + ); + await this.respawnSessionLocked(sessionId); + return true; + }); + } + + private trackHostExit(sessionId: string, host: AgentViewPtyHostHandle): void { + const generation = this.bootGeneration.get(sessionId); + void host.exited + .then((exit) => { + if (exit.kind === 'exited' && generation !== undefined) { + this.rejectPendingWorkerReady( + sessionId, + new Error(`Agent View worker ${sessionId} exited before ready.`), + generation, + ); + } + // Serialize the exit verdict with respawn: a superseded host killed + // during respawnSessionLocked must not clobber the replacement's + // freshly written state once the registry has swapped. + return this.withHostSetupLock(sessionId, async () => { + if (this.ptyHosts.get(sessionId) !== host) { + return; + } + try { + if (exit.kind === 'exited') { + await updateExitedSession(sessionId, exit, this.store); + } + } finally { + // Reaching here means the host exited on its own: planned releases + // remove the registry entry first. Keep the queued prompt/answer + // controls and the persisted queue marker so a later respawn can + // still deliver accepted user input. + this.clearStopFallback(sessionId, host); + this.ptyHosts.delete(sessionId); + this.preserveQueuedInputControls(sessionId); + this.onChanged(); + } + }); + }) + .catch(() => {}); + } + + private scheduleStopFallback( + sessionId: string, + host: AgentViewPtyHostHandle, + ): void { + const existing = this.stopFallbacks.get(sessionId); + if (existing?.host === host) { + return; + } + if (existing) { + clearTimeout(existing.timeout); + } + const timeout = setTimeout(() => { + void this.withHostSetupLock(sessionId, async () => { + if (this.ptyHosts.get(sessionId) !== host) { + return; + } + await this.retireSessionHost(sessionId, host, true); + await markStoppedSession(sessionId, this.store, 'exited'); + }) + .catch(() => {}) + .finally(() => { + this.clearStopFallback(sessionId, host); + this.onChanged(); + }); + }, DEFAULT_GRACEFUL_STOP_TIMEOUT_MS); + timeout.unref?.(); + this.stopFallbacks.set(sessionId, { host, timeout }); + } + + private clearStopFallback( + sessionId: string, + host?: AgentViewPtyHostHandle, + ): void { + const fallback = this.stopFallbacks.get(sessionId); + if (!fallback || (host && fallback.host !== host)) { + return; + } + clearTimeout(fallback.timeout); + this.stopFallbacks.delete(sessionId); + } + + async refreshMissingWorkerState( + state: AgentViewSessionStateFile, + reconnect?: () => Promise, + ): Promise { + // A supplied reconnect callback is used only by callers already holding + // the host-setup lock; lockless stopped-state healing must revalidate. + if ( + !reconnect && + state.sessionState === 'stopped' && + state.processState === 'alive' + ) { + return this.withHostSetupLock(state.sessionId, async () => { + const latest = await readAgentViewSessionState( + state.sessionId, + this.store, + ); + if (!latest) return state; + return this.refreshMissingWorkerState(latest, () => + this.reconnectSessionHostLocked(state.sessionId), + ); + }); + } + const reconnectHost = + reconnect ?? (() => this.reconnectSessionHost(state.sessionId)); + if (state.sessionState === 'stopped' && state.processState === 'alive') { + const connected = + this.ptyHosts.has(state.sessionId) || (await reconnectHost()); + if (connected) { + const host = this.ptyHosts.get(state.sessionId); + if (host) { + if (!this.hasPendingStopControl(state.sessionId)) { + this.queueStop(state.sessionId); + } + this.scheduleStopFallback(state.sessionId, host); + } + return state; + } + const worker = await readAgentViewWorker(state.sessionId, this.store); + if (isPidRunning(worker?.hostPid) || isPidRunning(worker?.workerPid)) { + return state; + } + await clearAgentViewWorkerPids(state.sessionId, this.store); + await markStoppedSession(state.sessionId, this.store, 'exited'); + return { ...state, processState: 'exited' }; + } + if ( + state.ownership === 'adopting' && + isStaleStartingState(state, this.options) && + !this.hasPendingWorkerReady(state.sessionId) + ) { + const connected = + this.ptyHosts.has(state.sessionId) || (await reconnectHost()); + if (!connected) { + const worker = await readAgentViewWorker(state.sessionId, this.store); + if (isPidRunning(worker?.hostPid) || isPidRunning(worker?.workerPid)) { + return state; + } + } + const now = new Date().toISOString(); + const patch: Partial = connected + ? { ownership: 'managed', processState: 'alive', updatedAt: now } + : { + ownership: 'unmanaged', + processState: 'exited', + updatedAt: now, + lastError: { + code: 'adoption_failed', + message: 'Agent View adoption did not complete.', + at: now, + }, + }; + let applied = false; + await patchAgentViewSessionStateIf( + state.sessionId, + (existing) => { + if ( + existing.ownership !== 'adopting' || + existing.updatedAt !== state.updatedAt + ) { + return undefined; + } + applied = true; + return patch; + }, + this.store, + ); + if (applied && !connected) { + await clearAgentViewWorkerPids(state.sessionId, this.store); + await removeAgentViewRosterEntry(state.sessionId, this.store); + } + if (applied) { + return { ...state, ...patch }; + } + return ( + (await readAgentViewSessionState(state.sessionId, this.store)) ?? state + ); + } + if (state.processState === 'hibernating') { + if (this.isHibernationInProgress(state.sessionId)) { + return state; + } + let host = this.ptyHosts.get(state.sessionId); + if (!host && (await reconnectHost())) { + host = this.ptyHosts.get(state.sessionId); + } + if (host) { + const patch = { + processState: 'alive' as const, + updatedAt: new Date().toISOString(), + }; + let applied = false; + await patchAgentViewSessionStateIf( + state.sessionId, + (existing) => { + if (existing.processState !== 'hibernating') { + return undefined; + } + applied = true; + return patch; + }, + this.store, + ); + return applied + ? { ...state, ...patch } + : ((await readAgentViewSessionState(state.sessionId, this.store)) ?? + state); + } + const worker = await readAgentViewWorker(state.sessionId, this.store); + if (isPidRunning(worker?.hostPid) || isPidRunning(worker?.workerPid)) { + return state; + } + const patch = { + processState: 'hibernated' as const, + attachState: 'detached' as const, + updatedAt: new Date().toISOString(), + }; + // Decide inside the queued mutation so a concurrent stop verdict + // that lands mid-heal is not overwritten. + let applied = false; + await patchAgentViewSessionStateIf( + state.sessionId, + (existing) => { + if ( + existing.processState !== 'hibernating' || + existing.sessionState === 'stopped' + ) { + return undefined; + } + applied = true; + return patch; + }, + this.store, + ); + return applied ? { ...state, ...patch } : state; + } + if ( + state.processState !== 'alive' && + state.processState !== 'starting' && + state.processState !== 'restarting' + ) { + return state; + } + if (this.ptyHosts.has(state.sessionId)) { + return state; + } + if ( + (state.processState === 'starting' || + state.processState === 'restarting') && + !isStaleStartingState(state, this.options) + ) { + return state; + } + if (await reconnectHost()) { + return state; + } + + const worker = await readAgentViewWorker(state.sessionId, this.store); + if (isPidRunning(worker?.hostPid) || isPidRunning(worker?.workerPid)) { + return state; + } + + const now = new Date().toISOString(); + // Decide the heal inside the queued mutation so a concurrent terminal + // verdict (stop/complete) that lands mid-heal is not overwritten. + let appliedPatch: Partial | undefined; + await patchAgentViewSessionStateIf( + state.sessionId, + (existing) => { + // Freshness guard: a record updated after the caller's snapshot + // means an authenticated worker event landed mid-heal, so the + // worker is not stale and the heal must not overwrite it. + if (existing.updatedAt !== state.updatedAt) { + return undefined; + } + const nextSessionState = + existing.sessionState === 'starting' || + existing.sessionState === 'working' || + existing.sessionState === 'needs_input' + ? 'failed' + : existing.sessionState; + appliedPatch = { + sessionState: nextSessionState, + processState: 'exited' as const, + attachState: 'detached' as const, + updatedAt: now, + ...(nextSessionState === 'failed' + ? { + lastError: { + code: 'stale_worker', + message: 'Agent View worker process is no longer running.', + at: now, + }, + } + : {}), + }; + return appliedPatch; + }, + this.store, + ); + return appliedPatch ? { ...state, ...appliedPatch } : state; + } +} + +class SessionSnapshotCache { + private snapshots: AgentViewSessionSnapshot[] | undefined; + private expiresAt = 0; + + markDirty(): void { + this.snapshots = undefined; + } + + async list( + store: AgentViewStoreOptions, + nowMs: number, + ): Promise { + if (this.snapshots && nowMs < this.expiresAt) { + return this.snapshots; + } + this.snapshots = await listAgentViewSessionSnapshots(store); + this.expiresAt = nowMs + 1000; + return this.snapshots; + } +} + +async function shutdownPtyHost(host: AgentViewPtyHostHandle): Promise { + if (host.shutdown) { + await host.shutdown(); + return; + } + host.kill('SIGTERM'); +} + +async function terminatePtyHost( + sessionId: string, + host: AgentViewPtyHostHandle, + signal?: NodeJS.Signals, +): Promise { + if (signal) { + host.kill(signal); + } else { + await shutdownPtyHost(host); + } + try { + return await waitForPtyHostExit(sessionId, host); + } catch (error) { + if (signal === 'SIGKILL') throw error; + host.kill('SIGKILL'); + return waitForPtyHostExit(sessionId, host); + } +} + +async function waitForPtyHostExit( + sessionId: string, + host: AgentViewPtyHostHandle, +): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + return await Promise.race([ + host.exited, + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => + reject( + new Error( + `Timed out waiting for Agent View session ${sessionId} host to exit.`, + ), + ), + DEFAULT_HOST_EXIT_TIMEOUT_MS, + ); + timeout.unref?.(); + }), + ]); + } finally { + clearTimeout(timeout); + } +} + +async function ensureSessionStillLaunchable( + sessionId: string, + store: AgentViewStoreOptions, + host?: AgentViewPtyHostHandle, + options: { allowStopped?: boolean; stoppedUpdatedAt?: string } = {}, +): Promise { + const state = await readAgentViewSessionState(sessionId, store); + const stoppedAfterLaunch = + state?.sessionState === 'stopped' && + options.allowStopped && + options.stoppedUpdatedAt !== undefined && + state.updatedAt !== options.stoppedUpdatedAt; + if (!state) { + // Distinguish a transient state-read failure (EMFILE/EIO/EACCES — + // readAgentViewSessionState fail-softs and returns undefined) from a + // genuine deletion: if the state file still exists, the read failure is + // transient and the just-launched host must not be killed. + const paths = getAgentViewSessionPaths(sessionId, store); + try { + await fs.promises.access(paths.statePath); + return; + } catch { + host?.kill('SIGTERM'); + throw new AgentViewSessionStoppedError(sessionId, 'session'); + } + } + if ( + (state.ownership !== 'managed' && state.ownership !== 'adopting') || + (state.sessionState === 'stopped' && !options.allowStopped) || + stoppedAfterLaunch + ) { + host?.kill('SIGTERM'); + throw new AgentViewSessionStoppedError(sessionId, 'session'); + } +} + +function isAliveProcessState( + processState: AgentViewSessionStateFile['processState'], +): boolean { + return ( + processState === 'starting' || + processState === 'alive' || + processState === 'hibernating' || + processState === 'restarting' + ); +} + +function canHibernateSession( + snapshot: AgentViewSessionSnapshot, + nowMs: number, + idleMs: number, +): boolean { + if (snapshot.state.ownership !== 'managed') return false; + if (!canAgentViewHibernate(snapshot)) { + return false; + } + if ( + snapshot.state.processState === 'hibernated' || + snapshot.state.processState === 'exited' || + hasPendingPrompt(snapshot.activity) + ) { + return false; + } + + const activityAt = Date.parse( + snapshot.activity?.lastActivityAt ?? snapshot.state.updatedAt, + ); + if (!Number.isFinite(activityAt)) { + return false; + } + return nowMs - activityAt >= idleMs; +} + +async function markSessionHibernating( + state: AgentViewSessionStateFile, + options: { globalDir?: string }, +): Promise { + const latest = await readAgentViewSessionState(state.sessionId, options); + if (!latest || latest.ownership !== 'managed') return false; + if (latest.sessionState === 'stopped' || latest.processState !== 'alive') { + return false; + } + let applied = false; + await patchAgentViewSessionStateIf( + state.sessionId, + (existing) => { + // Re-validate inside the queue: a concurrent kill verdict enqueued + // after the read above must not be re-asserted as hibernating. + if ( + existing.ownership !== 'managed' || + existing.sessionState === 'stopped' || + existing.processState !== 'alive' + ) { + return undefined; + } + applied = true; + return { + processState: 'hibernating', + updatedAt: new Date().toISOString(), + }; + }, + options, + ); + return applied; +} + +async function markSessionHibernated( + state: AgentViewSessionStateFile, + options: { globalDir?: string }, +): Promise { + const latest = await readAgentViewSessionState(state.sessionId, options); + if (!latest || latest.ownership !== 'managed') return false; + if ( + latest.sessionState === 'stopped' || + latest.processState !== 'hibernating' + ) { + return false; + } + let applied = false; + await patchAgentViewSessionStateIf( + state.sessionId, + (existing) => { + // Re-validate inside the queue: a concurrent kill verdict enqueued + // after the read above must not be re-asserted as hibernated. + if ( + existing.ownership !== 'managed' || + existing.sessionState === 'stopped' || + existing.processState !== 'hibernating' + ) { + return undefined; + } + applied = true; + return { + processState: 'hibernated', + updatedAt: new Date().toISOString(), + }; + }, + options, + ); + return applied; +} + +function pruneClosedSockets(sockets: Set): void { + for (const socket of sockets) { + if (socket.destroyed) { + sockets.delete(socket); + } + } +} + +function pruneClosedSocketMap(sockets: Map): void { + for (const [sessionId, socket] of sockets) { + if (socket.destroyed) { + sockets.delete(sessionId); + } + } +} + +function getHibernationPolicy( + options: AgentViewSupervisorProcessOptions, +): Required { + return { + idleMs: options.hibernationPolicy?.idleMs ?? DEFAULT_IDLE_HIBERNATION_MS, + autoExit: options.hibernationPolicy?.autoExit ?? true, + autoExitGraceMs: + options.hibernationPolicy?.autoExitGraceMs ?? + DEFAULT_SUPERVISOR_AUTO_EXIT_GRACE_MS, + }; +} + +function notifyAgentViewSubscribers(subscribers: Set): void { + const payload = `${JSON.stringify({ + type: 'changed', + at: new Date().toISOString(), + })}\n`; + for (const socket of subscribers) { + if (socket.destroyed) { + subscribers.delete(socket); + continue; + } + socket.write(payload, (error) => { + if (error) { + subscribers.delete(socket); + socket.destroy(); + } + }); + } +} + +function writeAttachError( + socket: Socket, + requestId: string, + code: string, + message: string, +): void { + socket.end( + `${JSON.stringify({ + id: requestId, + ok: false, + error: { code, message }, + })}\n`, + ); +} + +async function writeAttachState( + sessionId: string, + attachState: AgentViewSessionStateFile['attachState'], + options: { globalDir?: string }, +): Promise { + // Patch only the owned fields: re-asserting a whole stale snapshot here + // would erase a concurrent exit verdict from updateExitedSession. + await patchAgentViewSessionState( + sessionId, + { attachState, updatedAt: new Date().toISOString() }, + options, + ); +} + +const MAX_QUEUED_PROMPT_PREVIEW_CHARS = 500; + +function getQueuedPromptActivityPatch( + text: string, + promptId: string, + at: string, +): Partial { + return { + queuedPromptCount: 1, + queuedPromptPreview: text.slice(0, MAX_QUEUED_PROMPT_PREVIEW_CHARS), + queuedPromptId: promptId, + queuedPromptText: text, + queuedPromptDeliveredAt: undefined, + lastQueuedPromptAt: at, + }; +} + +function getDequeuedPromptActivityPatch(): Partial { + return { + queuedPromptCount: undefined, + queuedPromptPreview: undefined, + queuedPromptId: undefined, + queuedPromptText: undefined, + queuedPromptDeliveredAt: undefined, + lastQueuedPromptAt: undefined, + }; +} + +function getQueuedPromptCount( + activity: AgentViewActivityFile | undefined, +): number { + return typeof activity?.queuedPromptCount === 'number' && + Number.isFinite(activity.queuedPromptCount) + ? Math.max(0, Math.floor(activity.queuedPromptCount)) + : 0; +} + +function hasPendingPrompt( + activity: AgentViewActivityFile | undefined, +): boolean { + return getQueuedPromptCount(activity) > 0; +} + +async function refreshStoredResumeWorkerLaunchIfNeeded( + launch: AgentViewLaunchFile, + store: { globalDir?: string }, +): Promise { + if (!isResumeWorkerLaunch(launch)) { + return launch; + } + const refreshed = refreshResumeWorkerLaunch(launch); + await writeAgentViewLaunch(refreshed, store); + return refreshed; +} + +function shouldWaitForWorkerReady( + options: AgentViewSupervisorProcessOptions, +): boolean { + return options.waitForWorkerReady ?? !options.launchPtyHost; +} + +function getRespawnBlockReason( + state: AgentViewSessionStateFile, + activity?: AgentViewActivityFile, +): string | undefined { + if (state.attachState !== 'detached') { + return 'it is currently attached'; + } + if ( + state.processState === 'alive' || + state.processState === 'starting' || + state.processState === 'restarting' || + state.processState === 'hibernating' + ) { + // A stopped session's 'alive' marker is a straggler inside the stop + // fallback's grace window; respawn retires its authenticated host before + // launching the replacement. + if (!(state.sessionState === 'stopped' && state.processState === 'alive')) { + return `its process is ${state.processState}`; + } + } + if ( + state.sessionState === 'starting' || + state.sessionState === 'working' || + (state.sessionState === 'needs_input' && + getAgentViewActivityInputState(activity) !== 'soft_question') + ) { + return `it is ${state.sessionState}`; + } + return undefined; +} + +function isPidRunning(pid: number | undefined): boolean { + if (!pid) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + error.code === 'EPERM' + ); + } +} + +function isStaleStartingState( + state: AgentViewSessionStateFile, + options: AgentViewSupervisorProcessOptions, +): boolean { + if ( + state.processState !== 'starting' && + state.processState !== 'restarting' + ) { + return false; + } + const updatedAt = Date.parse(state.updatedAt); + if (!Number.isFinite(updatedAt)) { + return false; + } + const timeoutMs = + options.workerReadyTimeoutMs ?? DEFAULT_WORKER_READY_TIMEOUT_MS; + return (options.now?.() ?? new Date()).getTime() - updatedAt > timeoutMs; +} + +class AgentViewSessionStoppedError extends Error { + constructor(sessionId: string, kind: 'worker' | 'session') { + super(`Agent View ${kind} ${sessionId} was stopped.`); + this.name = 'AgentViewSessionStoppedError'; + } +} + +function isStoppedError(error: unknown): boolean { + return error instanceof AgentViewSessionStoppedError; +} + +async function markStoppedSession( + sessionId: string, + options: { globalDir?: string }, + processState: AgentViewSessionStateFile['processState'] = 'exited', +): Promise { + // Always write, even when the values are unchanged: the bumped updatedAt + // is what lets a concurrent respawn observe a repeated stop through + // ensureSessionStillLaunchable's stoppedAfterLaunch check. + await patchAgentViewSessionStateIf( + sessionId, + (existing) => + existing.ownership === 'managed' + ? { + sessionState: 'stopped', + processState, + updatedAt: new Date().toISOString(), + } + : undefined, + options, + ); +} + +async function updateExitedSession( + sessionId: string, + exit: Extract, + options: { globalDir?: string }, +): Promise { + const applied = await patchAgentViewSessionStateIf( + sessionId, + (existing) => { + // Re-validate inside the queue: a concurrent stop verdict enqueued + // after the read above must keep its terminal sessionState. + if ( + existing.ownership !== 'managed' || + existing.processState === 'hibernated' + ) { + return undefined; + } + return { + sessionState: + existing.sessionState === 'stopped' || + existing.sessionState === 'failed' || + existing.sessionState === 'completed' + ? existing.sessionState + : exit.exitCode === 0 && existing.sessionState !== 'starting' + ? 'completed' + : 'failed', + processState: 'exited', + updatedAt: new Date().toISOString(), + }; + }, + options, + ); + if (applied) { + // The exit verdict is authoritative: drop the persisted pids so later + // liveness probes and signaling paths cannot target a reused pid. + await clearAgentViewWorkerPids(sessionId, options); + } +} + +async function markFailedSession( + sessionId: string, + error: unknown, + options: { globalDir?: string }, + code = 'pty_launch_failed', +): Promise { + const now = new Date().toISOString(); + const message = error instanceof Error ? error.message : String(error); + await patchAgentViewSessionStateIf( + sessionId, + (existing) => { + // Re-validate inside the queued mutation: a concurrent stop verdict + // enqueued after the read above must keep its terminal sessionState. + if ( + existing.ownership !== 'managed' || + existing.sessionState === 'stopped' || + existing.sessionState === 'completed' + ) { + return existing.ownership === 'managed' && + existing.processState !== 'exited' + ? { processState: 'exited', updatedAt: now } + : undefined; + } + return { + sessionState: 'failed', + processState: 'exited', + updatedAt: now, + lastError: { code, message, at: now }, + }; + }, + options, + ); +} + +// A store read that returns nothing can mean the file never existed or a +// transient read failure (concurrent rewrite/rename). Only a missing file +// proves absence; fail closed on the present-but-unread case instead of +// reporting "not found", which would misroute the caller's verdict. +async function readOrThrowIfAbsent( + filePath: string, + read: () => Promise, + missingMessage: string, +): Promise { + const filePresent = () => + fs.promises + .access(filePath) + .then(() => true) + .catch(() => false); + let value = await read(); + if (value) { + return value; + } + if (!(await filePresent())) { + throw new Error(missingMessage); + } + for (let attempt = 1; attempt <= 3; attempt++) { + await new Promise((resolve) => setTimeout(resolve, 50 * attempt)); + value = await read(); + if (value) { + return value; + } + if (!(await filePresent())) { + throw new Error(missingMessage); + } + } + throw new Error( + `Agent View record at ${filePath} is temporarily unreadable. Retry the operation.`, + ); +} + +async function applyWorkerEvent( + event: AgentViewWorkerEvent, + options: { globalDir?: string }, + hasPendingInputControl = false, + hostRegistered = false, + readyWaiterPending = false, +): Promise { + const state = await readOrThrowIfAbsent( + getAgentViewSessionPaths(event.sessionId, options).statePath, + () => readAgentViewSessionState(event.sessionId, options), + `No Agent View session found for ${event.sessionId}.`, + ); + if (state.ownership !== 'managed' && state.ownership !== 'adopting') { + throw new Error(`Agent View session ${event.sessionId} is not managed.`); + } + if ( + state.ownership === 'adopting' && + !hostRegistered && + !readyWaiterPending + ) { + // A ghost 'adopting' record left by a supervisor that died mid-adopt: + // with no registered host and no pending ready wait, no live adoption + // owns it, so its events must not advance it (toward 'managed'). + return false; + } + if ( + (state.sessionState === 'stopped' || + state.processState === 'exited' || + state.processState === 'hibernated' || + // The hibernation sweep's point of no return: a straggler event must + // not flip the record back to 'alive' once the sweep has committed + // to hibernating (hibernation does not rotate the worker token, so + // the event still authenticates). + state.processState === 'hibernating') && + (event.type === 'ready' || event.type === 'state') + ) { + // A buffered/in-flight event from a dead worker must not clobber the + // exit verdict. A legitimate replacement worker's ready arrives only + // after respawn writes processState 'starting'. + return false; + } + + const now = event.at ?? new Date().toISOString(); + const activeCwd = + event.type === 'ready' || event.type === 'state' + ? path.resolve(event.cwd ?? state.activeCwd) + : state.activeCwd; + const sessionState = + event.type === 'ready' + ? 'idle' + : event.type === 'state' + ? event.sessionState + : state.sessionState; + + let statePatchApplied = false; + await patchAgentViewSessionStateIf( + event.sessionId, + (existing) => { + // Re-validate inside the queued mutation: an exit verdict enqueued + // after the guard read above must not be clobbered by this patch. + if ( + (existing.ownership !== 'managed' && + existing.ownership !== 'adopting') || + ((existing.sessionState === 'stopped' || + existing.processState === 'exited' || + existing.processState === 'hibernated' || + existing.processState === 'hibernating') && + (event.type === 'ready' || event.type === 'state')) + ) { + return undefined; + } + statePatchApplied = true; + return { + sessionState, + processState: 'alive', + // The event authenticated against a registered host while the + // record still says 'adopting' (e.g. refresh reconnected the + // adoption's surviving host): finish the interrupted adoption. + ...(existing.ownership === 'adopting' && hostRegistered + ? { ownership: 'managed' } + : {}), + activeCwd, + updatedAt: now, + ...(event.type === 'state' && event.sessionState === 'working' + ? { initialPromptPending: undefined } + : {}), + ...(event.type === 'ready' ? { lastError: undefined } : {}), + }; + }, + options, + ); + if (!statePatchApplied) { + return false; + } + + const existingActivity = await readAgentViewActivity( + event.sessionId, + options, + ); + const dequeuePendingPrompt = shouldClearPendingPrompt( + event, + state, + existingActivity, + hasPendingInputControl, + ); + const activityPatch = + event.type === 'state' || event.type === 'ready' + ? { + ...(event.summary ? { summary: event.summary } : {}), + ...(event.type === 'state' + ? { waitingFor: event.waitingFor || undefined } + : { waitingFor: undefined }), + ...(event.type === 'state' + ? { + inputKind: + event.inputKind ?? + inferInputKind(event.sessionState, event.waitingFor), + } + : { inputKind: undefined }), + ...(event.type === 'state' && event.lastResult + ? { lastResult: event.lastResult } + : { lastResult: undefined }), + capabilities: + event.type === 'ready' + ? (event.capabilities ?? []) + : (existingActivity?.capabilities ?? []), + ...(event.type === 'state' && + existingActivity && + event.promptId === existingActivity.queuedPromptId && + !existingActivity.queuedPromptDeliveredAt + ? { queuedPromptDeliveredAt: now } + : {}), + } + : { capabilities: [] }; + const lastActivityAt = shouldAdvanceActivityTime({ + event, + previousState: state, + nextSessionState: sessionState, + existingActivity, + activityPatch, + hasPendingInputControl, + }) + ? now + : (existingActivity?.lastActivityAt ?? now); + await writeAgentViewActivity( + event.sessionId, + { + schemaVersion: 1, + ...activityPatch, + lastActivityAt, + }, + options, + ); + if (dequeuePendingPrompt) { + // Route the dequeue through the queued mutation, re-validating the + // marker inside it: a concurrent kill/send may have replaced the + // marker after the activity read above, and erasing a fresh marker + // would drop the double-submit guard for the newly queued prompt. + const baselineMarker = existingActivity?.lastQueuedPromptAt; + await patchAgentViewActivityIf( + event.sessionId, + (latest) => + hasPendingPrompt(latest) && latest.lastQueuedPromptAt === baselineMarker + ? getDequeuedPromptActivityPatch() + : undefined, + options, + ); + } + await writeAgentViewWorker( + event.sessionId, + { + schemaVersion: 1, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + lastHeartbeatAt: now, + recentOutputBytes: 0, + }, + options, + ); + return true; +} + +async function applyWorkerHeartbeatEvent( + event: Extract, + options: { globalDir?: string }, +): Promise { + const state = await readAgentViewSessionState(event.sessionId, options); + if (!state) { + throw new Error(`No Agent View session found for ${event.sessionId}.`); + } + if (state.ownership !== 'managed' && state.ownership !== 'adopting') { + throw new Error(`Agent View session ${event.sessionId} is not managed.`); + } + + await writeAgentViewWorker( + event.sessionId, + { + schemaVersion: 1, + protocolVersion: AGENT_VIEW_PROTOCOL_VERSION, + platform: process.platform, + lastHeartbeatAt: event.at ?? new Date().toISOString(), + recentOutputBytes: 0, + }, + options, + ); +} + +function shouldClearPendingPrompt( + event: AgentViewWorkerEvent, + previousState: AgentViewSessionStateFile, + activity: AgentViewActivityFile | undefined, + hasPendingInputControl = false, +): boolean { + if (!hasPendingPrompt(activity)) { + return false; + } + // A prompt/answer control still queued for the worker means the persisted + // marker is the only durable record of an accepted prompt; a replacement + // worker's ready must not erase it until the control is consumed. + if (hasPendingInputControl) { + return false; + } + const promptCorrelated = + activity?.queuedPromptId !== undefined && + event.type === 'state' && + event.promptId === activity.queuedPromptId; + if (activity?.queuedPromptId && !promptCorrelated) { + return false; + } + // A queued-prompt marker newer than this event belongs to a prompt the + // worker has not seen yet; a stale buffered event must not erase it. + const queuedAt = activity?.lastQueuedPromptAt; + if (!promptCorrelated && queuedAt && event.at && event.at <= queuedAt) { + return false; + } + return ( + event.type === 'state' && + (event.sessionState === 'idle' || + event.sessionState === 'completed' || + (event.sessionState === 'needs_input' && + (event.waitingFor === 'response' || event.inputKind === 'soft') && + previousState.sessionState !== 'needs_input')) + ); +} + +function shouldAdvanceActivityTime({ + event, + previousState, + nextSessionState, + existingActivity, + activityPatch, + hasPendingInputControl = false, +}: { + event: AgentViewWorkerEvent; + previousState: AgentViewSessionStateFile; + nextSessionState: AgentViewSessionStateFile['sessionState']; + existingActivity: AgentViewActivityFile | undefined; + activityPatch: Partial; + hasPendingInputControl?: boolean; +}): boolean { + if (!existingActivity) { + return true; + } + // While an input control is still queued the marker dequeue is + // deliberately suppressed; advancing lastActivityAt here would poison the + // wall-clock evidence shouldClearStalePendingPrompt trusts to declare the + // queued-prompt marker drained. + if (hasPendingInputControl && hasPendingPrompt(existingActivity)) { + return false; + } + if (event.type === 'ready') { + return true; + } + if (event.type !== 'state') { + return false; + } + if (event.sessionState === 'working') { + return true; + } + if (previousState.sessionState !== nextSessionState) { + return true; + } + if ( + activityPatch.summary !== undefined && + activityPatch.summary !== existingActivity.summary + ) { + return true; + } + if ( + activityPatch.waitingFor !== existingActivity.waitingFor || + activityPatch.inputKind !== existingActivity.inputKind + ) { + return true; + } + if ( + activityPatch.lastResult !== undefined && + activityPatch.lastResult !== existingActivity.lastResult + ) { + return true; + } + return ( + shouldClearPendingPrompt( + event, + previousState, + existingActivity, + hasPendingInputControl, + ) && getQueuedPromptCount(existingActivity) > 0 + ); +} + +function inferInputKind( + sessionState: AgentViewSessionStateFile['sessionState'], + waitingFor: string | undefined, +): 'blocking' | 'soft' | undefined { + if (sessionState !== 'needs_input') { + return undefined; + } + // Presentation compares waitingFor case-insensitively; infer must agree. + return waitingFor?.toLowerCase() === 'response' ? 'soft' : 'blocking'; +} + +async function clearStalePendingPromptIfNeeded( + state: AgentViewSessionStateFile, + activity: AgentViewActivityFile | undefined, + store: { globalDir?: string }, + hasLiveInputControl: boolean, +): Promise { + if (!activity || !hasPendingPrompt(activity)) { + return activity; + } + if (hasLiveInputControl) { + // This daemon still holds the undelivered input control, so the + // persisted marker is provably not stale. + return activity; + } + if (activity.queuedPromptId) { + return activity; + } + if (!shouldClearStalePendingPrompt(state, activity)) { + return activity; + } + // Decide inside the queued mutation, re-validating against the latest + // persisted record: a concurrent send may have written a fresh marker + // after the read above, and an unqueued full write landing last would + // erase it and drop the double-submit guard for the new prompt. + const baselineMarker = activity.lastQueuedPromptAt; + let result: AgentViewActivityFile | undefined; + await patchAgentViewActivityIf( + state.sessionId, + (latest) => { + if ( + !hasPendingPrompt(latest) || + latest.lastQueuedPromptAt !== baselineMarker + ) { + result = latest; + return undefined; + } + if (latest.queuedPromptId) { + result = latest; + return undefined; + } + if (!shouldClearStalePendingPrompt(state, latest)) { + result = latest; + return undefined; + } + result = { ...latest, ...getDequeuedPromptActivityPatch() }; + return getDequeuedPromptActivityPatch(); + }, + store, + ); + return result ?? activity; +} + +async function clearPersistedPromptQueue( + sessionId: string, + store: { globalDir?: string }, +): Promise { + const activity = await readAgentViewActivity(sessionId, store); + if (!activity || !hasPendingPrompt(activity)) { + return false; + } + // Re-validate inside the queued mutation: a concurrent send may have + // replaced the marker after the read above, and erasing a fresh marker + // would drop the double-submit guard for the newly queued prompt. + const baselineMarker = activity.lastQueuedPromptAt; + return patchAgentViewActivityIf( + sessionId, + (latest) => { + if ( + !hasPendingPrompt(latest) || + latest.lastQueuedPromptAt !== baselineMarker + ) { + return undefined; + } + return getDequeuedPromptActivityPatch(); + }, + store, + ); +} + +function shouldClearStalePendingPrompt( + state: AgentViewSessionStateFile, + activity: AgentViewActivityFile | undefined, +): activity is AgentViewActivityFile { + if ( + state.sessionState !== 'needs_input' || + (activity?.waitingFor !== 'response' && activity?.inputKind !== 'soft') || + !hasPendingPrompt(activity) || + !activity.lastQueuedPromptAt + ) { + return false; + } + const lastActivityAt = Date.parse(activity.lastActivityAt); + const lastQueuedPromptAt = Date.parse(activity.lastQueuedPromptAt); + return ( + Number.isFinite(lastActivityAt) && + Number.isFinite(lastQueuedPromptAt) && + lastActivityAt > lastQueuedPromptAt + ); +} + +function requireSessionId(params: Record | undefined): string { + const sessionId = params?.['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new Error('Agent View session id is required.'); + } + return sessionId; +} + +function requireText(params: Record | undefined): string { + const text = params?.['text']; + if (typeof text !== 'string' || text.trim().length === 0) { + throw new Error('Agent View message text is required.'); + } + return text; +} + +function positiveIntegerParam( + params: Record | undefined, + key: string, +): number { + const value = params?.[key]; + if (!Number.isInteger(value) || Number(value) <= 0) { + throw new Error(`Agent View ${key} must be a positive integer.`); + } + return Number(value); +} + +function parseAdoptParams(params: Record | undefined): { + sessionId: string; + resumeSessionId: string; + projectCwd: string; + activeCwd: string; + approvalMode?: string; + sandbox?: string; + terminal: { columns: number; rows: number }; +} { + if (!params) { + throw new Error('Agent View adoption params are required.'); + } + // Canonicalize at the RPC boundary: registry keys, control-queue keys, + // worker env values, and store-resolved ids must be one string; the + // store lowercases directory names, so a raw mixed-case id here would + // fork every lookup keyed on it. The raw spelling is kept separately: + // the native session store is case-sensitive, so --resume must use it. + const rawSessionId = requireSessionId(params); + if (rawSessionId.startsWith('-')) { + throw new Error('Agent View sessionId must not start with "-".'); + } + const sessionId = sanitizeSessionId(rawSessionId); + const projectCwd = stringParam(params, 'projectCwd', { required: true }); + const activeCwd = stringParam(params, 'activeCwd', { required: true }); + if (!projectCwd || !activeCwd) { + throw new Error('Agent View adoption cwd is required.'); + } + const terminal = params['terminal']; + if (!isRecord(terminal)) { + throw new Error('Agent View adoption terminal size is required.'); + } + const approvalMode = stringParam(params, 'approvalMode'); + const sandbox = stringParam(params, 'sandbox'); + return { + sessionId, + resumeSessionId: rawSessionId, + projectCwd, + activeCwd, + ...(approvalMode !== undefined ? { approvalMode } : {}), + ...(sandbox !== undefined ? { sandbox } : {}), + terminal: { + columns: positiveIntegerParam(terminal, 'columns'), + rows: positiveIntegerParam(terminal, 'rows'), + }, + }; +} + +function parseWorkerEvent( + params: Record | undefined, +): AgentViewWorkerEvent { + if (!params) { + throw new Error('Agent View worker event is required.'); + } + const type = params['type']; + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw new Error('Agent View worker event session id is required.'); + } + if (type === 'ready') { + const cwd = stringParam(params, 'cwd', { required: true }); + if (!cwd) { + throw new Error('Agent View worker event cwd is required.'); + } + const summary = stringParam(params, 'summary'); + const at = stringParam(params, 'at'); + return { + type, + sessionId, + cwd, + ...(summary !== undefined ? { summary } : {}), + ...(at !== undefined ? { at } : {}), + capabilities: stringArrayParam(params, 'capabilities'), + }; + } + if (type === 'heartbeat') { + const at = stringParam(params, 'at'); + return { + type, + sessionId, + ...(at !== undefined ? { at } : {}), + }; + } + if (type === 'detach') { + const at = stringParam(params, 'at'); + return { + type, + sessionId, + ...(at !== undefined ? { at } : {}), + }; + } + if (type === 'state') { + const sessionState = params['sessionState']; + if (!isAgentViewSessionState(sessionState)) { + throw new Error('Agent View worker event state is invalid.'); + } + const cwd = stringParam(params, 'cwd'); + const summary = stringParam(params, 'summary'); + const waitingForRaw = stringParam(params, 'waitingFor'); + // Normalize case at ingest: the dequeue gates and the presentation + // layer compare waitingFor case-insensitively, so every consumer must + // see the same casing. + const waitingFor = waitingForRaw?.toLowerCase(); + const inputKind = inputKindValue(params['inputKind']); + const lastResult = stringParam(params, 'lastResult'); + const promptId = stringParam(params, 'promptId'); + const at = stringParam(params, 'at'); + return { + type, + sessionId, + sessionState, + ...(cwd !== undefined ? { cwd } : {}), + ...(summary !== undefined ? { summary } : {}), + ...(waitingFor !== undefined ? { waitingFor } : {}), + ...(inputKind !== undefined ? { inputKind } : {}), + ...(lastResult !== undefined ? { lastResult } : {}), + ...(promptId !== undefined ? { promptId } : {}), + ...(at !== undefined ? { at } : {}), + }; + } + throw new Error('Agent View worker event type is invalid.'); +} + +function stringParam( + params: Record, + key: string, + options: { required?: boolean } = {}, +): string | undefined { + const value = params[key]; + if (typeof value === 'string' && value.length > 0) return value; + if (options.required) { + throw new Error(`Agent View ${key} is required.`); + } + return undefined; +} + +function stringArrayParam( + params: Record, + key: string, +): string[] { + const value = params[key]; + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === 'string') + : []; +} + +function buildResumeWorkerArgv( + sessionId: string, + initialPrompt?: string, + approvalMode?: string, +): string[] { + // Attached form: the id stays one token even if it starts with '-', so + // the argument parser can never drop or reinterpret it as a flag. + return buildCurrentQwenCliArgv([ + `--resume=${sessionId}`, + ...(initialPrompt ? [`--prompt-interactive=${initialPrompt}`] : []), + ...(approvalMode ? [`--approval-mode=${approvalMode}`] : []), + ]); +} + +function refreshResumeWorkerLaunch( + launch: AgentViewLaunchFile, + token?: string, + // Entrypoint migration runs after a respawn launch is built; preserve a + // still-pending initial prompt already encoded in that argv. + replayInitialPrompt = launch.argv.includes( + `--prompt-interactive=${launch.initialPrompt ?? ''}`, + ), +): AgentViewLaunchFile { + return { + ...launch, + entrypoint: getCurrentQwenCliEntrypoint(), + // --resume must keep the original spelling: the native session store + // is case-sensitive and the canonical id may rewrite it. + argv: buildResumeWorkerArgv( + launch.resumeSessionId ?? launch.sessionId, + replayInitialPrompt ? launch.initialPrompt : undefined, + launch.approvalMode, + ), + env: { + ...launch.env, + ...getResumeWorkerSandboxEnv(launch.sandbox), + ...(token === undefined ? {} : { [QWEN_AGENT_VIEW_TOKEN]: token }), + }, + }; +} + +function getResumeWorkerSandboxEnv( + sandbox: string | undefined, +): Record { + if (!sandbox) return {}; + try { + const parsed: unknown = JSON.parse(sandbox); + if ( + typeof parsed === 'object' && + parsed !== null && + 'command' in parsed && + typeof parsed.command === 'string' + ) { + return { + QWEN_SANDBOX: parsed.command, + ...('image' in parsed && typeof parsed.image === 'string' + ? { QWEN_SANDBOX_IMAGE: parsed.image } + : {}), + }; + } + } catch { + // Plain command names are not JSON. + } + return { QWEN_SANDBOX: sandbox }; +} + +async function writeResumeWorkerLaunch( + launch: AgentViewLaunchFile, + token: string, + replayInitialPrompt: boolean, + store: { globalDir?: string }, +): Promise { + const resumeLaunch = refreshResumeWorkerLaunch( + launch, + token, + replayInitialPrompt, + ); + await writeAgentViewLaunch(resumeLaunch, store); + return resumeLaunch; +} + +function isResumeWorkerLaunch(launch: AgentViewLaunchFile): boolean { + const expected = launch.resumeSessionId ?? launch.sessionId; + if (launch.argv.includes(`--resume=${expected}`)) { + return true; + } + // Legacy launches used the split form; keep accepting it so stored + // records still get refreshed to the attached form. + const resumeIndex = launch.argv.indexOf('--resume'); + return resumeIndex >= 0 && launch.argv[resumeIndex + 1] === expected; +} + +export async function requireValidWorkerToken( + sessionId: string, + params: Record | undefined, + options: { globalDir?: string }, +): Promise { + const token = params?.['token']; + if (typeof token !== 'string' || token.length === 0) { + throw new Error('Agent View worker token is required.'); + } + const worker = await readOrThrowIfAbsent( + getAgentViewSessionPaths(sessionId, options).workerPath, + () => readAgentViewWorker(sessionId, options), + `No Agent View worker token found for ${sessionId}.`, + ); + if (!worker.tokenDigest) { + throw new Error(`No Agent View worker token found for ${sessionId}.`); + } + if (!tokenDigestMatches(token, worker.tokenDigest)) { + throw new Error('Agent View worker token is invalid.'); + } +} + +function tokenDigestMatches(token: string, expectedDigest: string): boolean { + const actual = Buffer.from(digestAgentViewWorkerToken(token), 'hex'); + const expected = Buffer.from(expectedDigest, 'hex'); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +async function requireKnownSession( + sessionId: string, + options: { globalDir?: string }, +): Promise { + const state = await readAgentViewSessionState(sessionId, options); + if (!state) { + throw new Error(`No Agent View session found for ${sessionId}.`); + } + if (state.ownership !== 'managed') { + throw new Error(`Agent View session ${sessionId} is not managed.`); + } +} + +async function resolveManagedSessionId( + requestedSessionId: string, + options: { globalDir?: string }, + resolveOptions: { allowRemoving?: boolean } = {}, +): Promise { + const exact = await readAgentViewSessionState(requestedSessionId, options); + if (exact) { + if ( + exact.ownership !== 'managed' && + !(resolveOptions.allowRemoving && exact.ownership === 'removing') + ) { + throw new Error( + `Agent View session ${requestedSessionId} is not managed.`, + ); + } + return exact.sessionId; + } + + // The exact session directory exists but its state is unreadable right + // now: refuse instead of falling through to prefix matching, which + // could route a stop/kill at a different session. + const exactDirExists = await fs.promises + .access(getAgentViewSessionPaths(requestedSessionId, options).sessionDir) + .then(() => true) + .catch(() => false); + if (exactDirExists) { + throw new Error( + `Agent View session ${requestedSessionId} is temporarily unreadable. Retry the operation.`, + ); + } + + const requestedPrefix = requestedSessionId.toLowerCase(); + // Enumerate the session directories directly: listAgentViewSessionStates + // fail-softs unreadable entries, which would shrink the candidate set and + // could route this operation at the wrong session. + let candidates: string[]; + try { + candidates = await fs.promises.readdir( + getAgentViewStorePaths(options).jobsDir, + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + candidates = []; + } else { + throw new Error( + `Agent View session ${requestedSessionId} is temporarily unreadable. Retry the operation.`, + ); + } + } + const matches: string[] = []; + for (const candidate of candidates) { + if (!candidate.toLowerCase().startsWith(requestedPrefix)) { + continue; + } + const state = await readAgentViewSessionState(candidate, options); + if (!state) { + throw new Error( + `Agent View session ${candidate} is temporarily unreadable. Retry the operation.`, + ); + } + if ( + state.ownership === 'managed' || + (resolveOptions.allowRemoving && state.ownership === 'removing') + ) { + matches.push(state.sessionId); + } + } + if (matches.length === 1) { + return matches[0]; + } + if (matches.length > 1) { + throw new Error( + `Agent View session id ${requestedSessionId} is ambiguous. Use a longer id.`, + ); + } + throw new Error(`No Agent View session found for ${requestedSessionId}.`); +} + +function storeOptions(options: AgentViewSupervisorProcessOptions): { + globalDir?: string; +} { + return { + ...(options.globalDir ? { globalDir: options.globalDir } : {}), + }; } -function shortHash(input: string): string { - return createHash('sha256').update(input).digest('hex').slice(0, 12); +function shortHash(value: string): string { + return createHash('sha256').update(value).digest('hex').slice(0, 12); } diff --git a/packages/cli/src/agent-view/supervisor-runner.test.ts b/packages/cli/src/agent-view/supervisor-runner.test.ts index de5417cbe51..fff2c358f34 100644 --- a/packages/cli/src/agent-view/supervisor-runner.test.ts +++ b/packages/cli/src/agent-view/supervisor-runner.test.ts @@ -25,9 +25,21 @@ import type { import { createAgentViewSupervisorServer } from './supervisor-server.js'; import { readAgentViewSupervisor, + writeAgentViewSupervisor, writeAgentViewSessionState, } from './supervisor-store.js'; +const mockAttachAgentViewSupervisorTerminal = vi.hoisted(() => vi.fn()); + +vi.mock('./supervisor-client.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + attachAgentViewSupervisorTerminal: mockAttachAgentViewSupervisorTerminal, + }; +}); + const cleanupDirs: string[] = []; const cleanupServers: AgentViewSupervisorServerHandle[] = []; @@ -92,6 +104,31 @@ describe('Agent View supervisor runner', () => { await expect(handle.status()).resolves.toEqual({ state: 'ready' }); }); + it('binds attach to the blocking terminal bridge', async () => { + const { globalDir, socketPath } = await makeSupervisorPath(); + const server = createFakeSupervisor(socketPath, { + status: () => ({ state: 'ready' }), + list: () => [], + shutdown: () => ({ shuttingDown: true }), + }); + await server.listen(); + cleanupServers.push(server); + mockAttachAgentViewSupervisorTerminal.mockClear(); + + const handle = await ensureAgentViewSupervisor({ + globalDir, + spawnProcess: vi.fn(() => createFakeProcess()), + }); + + await handle.attach('session-9'); + + expect(mockAttachAgentViewSupervisorTerminal).toHaveBeenCalledOnce(); + const [callSocketPath, callSessionId] = + mockAttachAgentViewSupervisorTerminal.mock.calls[0]; + expect(callSocketPath).toBe(socketPath); + expect(callSessionId).toBe('session-9'); + }); + it('spawns through the injected process factory and waits for readiness', async () => { const { globalDir, socketPath } = await makeSupervisorPath(); const startedProcess = createFakeProcess(); @@ -182,6 +219,7 @@ describe('Agent View supervisor runner', () => { logs: vi.fn(() => ({ logs: ['line-1'] })), stop: vi.fn(() => ({ stopped: true })), kill: vi.fn(() => ({ killed: true })), + release: vi.fn(() => ({ released: true })), remove: vi.fn(() => ({ removed: true })), respawn: vi.fn(() => ({ respawned: true })), pin: vi.fn(() => ({ sessionId: 'session-3', pinned: true })), @@ -280,6 +318,11 @@ describe('Agent View supervisor runner', () => { }); expect(handler.kill).toHaveBeenCalledWith({ sessionId: 'session-3' }); + await expect(handle.release('session-3')).resolves.toEqual({ + released: true, + }); + expect(handler.release).toHaveBeenCalledWith({ sessionId: 'session-3' }); + await expect(handle.remove('session-3')).resolves.toEqual({ removed: true, }); @@ -325,7 +368,7 @@ describe('Agent View supervisor runner', () => { await expect(handle.shutdown(false)).resolves.toEqual({ shuttingDown: true, }); - expect(handler.shutdown).toHaveBeenCalledWith({ keepWorkers: false }); + expect(handler.shutdown).toHaveBeenCalledOnce(); }); it('closes the supervisor server when shutdown is requested', async () => { @@ -349,10 +392,43 @@ describe('Agent View supervisor runner', () => { ).resolves.toEqual({ shuttingDown: true, workersStopped: 0, + workersFailed: [], }); await supervisorPromise; await expectSupervisorUnreachable(socketPath, authToken); + await expect(readAgentViewSupervisor({ globalDir })).resolves.toMatchObject( + { + pid: process.pid, + authToken, + }, + ); + }); + + it('does not remove metadata written by a replacement supervisor', async () => { + const { globalDir, socketPath } = await makeSupervisorPath(); + const supervisorPromise = runAgentViewSupervisor({ globalDir }); + + await waitForSupervisor(socketPath, globalDir); + const authToken = await readAuthToken(globalDir); + const replacement = { + schemaVersion: 1 as const, + pid: process.pid + 1, + socketPath: `${socketPath}.replacement`, + authToken: 'replacement-token', + startedAt: '2026-08-20T00:00:00.000Z', + updatedAt: '2026-08-20T00:00:00.000Z', + protocolVersion: 1, + }; + await writeAgentViewSupervisor(replacement, { globalDir }); + await callAgentViewSupervisor(socketPath, 'shutdown', undefined, { + authToken, + }); + await supervisorPromise; + + await expect(readAgentViewSupervisor({ globalDir })).resolves.toEqual( + replacement, + ); }); it('auto-exits when maintenance sees only hibernated managed sessions', async () => { diff --git a/packages/cli/src/agent-view/supervisor-runner.ts b/packages/cli/src/agent-view/supervisor-runner.ts index 8a9a316b911..19c3639db13 100644 --- a/packages/cli/src/agent-view/supervisor-runner.ts +++ b/packages/cli/src/agent-view/supervisor-runner.ts @@ -24,23 +24,31 @@ import type { import { createAgentViewSupervisorHandler, getAgentViewSupervisorSocketPath, + requireValidWorkerToken, } from './supervisor-process.js'; import type { AgentViewSupervisorHibernationPolicy } from './supervisor-process.js'; import { createAgentViewSupervisorServer } from './supervisor-server.js'; +import type { AgentViewSidebandAuthorizer } from './supervisor-server.js'; import { getAgentViewStorePaths, readAgentViewSupervisor, writeAgentViewSupervisor, } from './supervisor-store.js'; import { buildCurrentQwenCliArgv } from './current-cli-argv.js'; +import { restoreInvocationScopedEnv } from '../config/invocation-env.js'; export const INTERNAL_AGENT_VIEW_SUPERVISOR_ARG = '--internal-agent-view-supervisor'; +// Set on the spawned supervisor child so the startup branch can tell a real +// daemon launch from a natural-language prompt that mentions the flag. +export const INTERNAL_AGENT_VIEW_SUPERVISOR_ENV = 'QWEN_AGENT_VIEW_SUPERVISOR'; + const SUPERVISOR_READY_RETRIES = 600; const SUPERVISOR_READY_DELAY_MS = 50; const SUPERVISOR_MAINTENANCE_INTERVAL_MS = 5000; const LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS = 30_000; +const AGENT_VIEW_SHUTDOWN_TIMEOUT_MS = 60_000; export interface AgentViewSupervisorClientHandle { socketPath: string; @@ -61,6 +69,7 @@ export interface AgentViewSupervisorClientHandle { stop(sessionId: string): Promise; kill(sessionId: string): Promise; respawn(sessionId?: string): Promise; + release(sessionId: string): Promise; remove(sessionId: string): Promise; pin(sessionId: string, pinned?: boolean): Promise; rename(sessionId: string, displayName: string): Promise; @@ -138,6 +147,7 @@ export async function runAgentViewSupervisor( const authToken = randomUUID(); const startedAt = new Date().toISOString(); let closeRequested = false; + let closeServer = (): Promise => Promise.resolve(); const handler = createAgentViewSupervisorHandler({ ...(options.globalDir ? { globalDir: options.globalDir } : {}), ...(options.hibernationPolicy @@ -146,14 +156,34 @@ export async function runAgentViewSupervisor( onShutdown: () => { closeRequested = true; setImmediate(() => { - void Promise.resolve(server.close()).catch(() => {}); + void closeServer().catch(() => {}); }); }, }); + const authorizeSideband: AgentViewSidebandAuthorizer = async ( + _op, + params, + ) => { + const sessionId = params?.['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) return false; + try { + await requireValidWorkerToken( + sessionId, + params, + options.globalDir ? { globalDir: options.globalDir } : {}, + ); + return true; + } catch { + return false; + } + }; const server = createAgentViewSupervisorServer(handler, { socketPath, authToken, + authorizeSideband, }); + let serverClosePromise: Promise | undefined; + closeServer = () => (serverClosePromise ??= server.close()); await server.listen(); await writeAgentViewSupervisor( @@ -175,16 +205,14 @@ export async function runAgentViewSupervisor( const onSigterm = () => { clearInterval(maintenanceInterval); clearInterval(closeInterval); - void server - .close() + void closeServer() .catch(() => {}) .finally(resolve); }; const onSigint = () => { clearInterval(maintenanceInterval); clearInterval(closeInterval); - void server - .close() + void closeServer() .catch(() => {}) .finally(resolve); }; @@ -200,13 +228,7 @@ export async function runAgentViewSupervisor( process.once('SIGTERM', onSigterm); process.once('SIGINT', onSigint); }); - await fs - .unlink( - getAgentViewStorePaths({ - ...(options.globalDir ? { globalDir: options.globalDir } : {}), - }).supervisorPath, - ) - .catch(() => {}); + await closeServer().catch(() => {}); } function createSupervisorHandle( @@ -256,21 +278,43 @@ function createSupervisorHandle( socketPath, 'send', { sessionId, text }, - authOptions, + { + ...authOptions, + timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, + }, ), answer: (sessionId: string, text: string) => callAgentViewSupervisor( socketPath, 'answer', { sessionId, text }, - authOptions, + { + ...authOptions, + timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, + }, ), logs: (sessionId: string) => callAgentViewSupervisor(socketPath, 'logs', { sessionId }, authOptions), stop: (sessionId: string) => - callAgentViewSupervisor(socketPath, 'stop', { sessionId }, authOptions), + callAgentViewSupervisor( + socketPath, + 'stop', + { sessionId }, + { + ...authOptions, + timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, + }, + ), kill: (sessionId: string) => - callAgentViewSupervisor(socketPath, 'kill', { sessionId }, authOptions), + callAgentViewSupervisor( + socketPath, + 'kill', + { sessionId }, + { + ...authOptions, + timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, + }, + ), respawn: (sessionId?: string) => callAgentViewSupervisor( socketPath, @@ -281,8 +325,26 @@ function createSupervisorHandle( timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, }, ), + release: (sessionId: string) => + callAgentViewSupervisor( + socketPath, + 'release', + { sessionId }, + { + ...authOptions, + timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, + }, + ), remove: (sessionId: string) => - callAgentViewSupervisor(socketPath, 'remove', { sessionId }, authOptions), + callAgentViewSupervisor( + socketPath, + 'remove', + { sessionId }, + { + ...authOptions, + timeoutMs: LONG_AGENT_VIEW_OPERATION_TIMEOUT_MS, + }, + ), pin: (sessionId: string, pinned?: boolean) => callAgentViewSupervisor( socketPath, @@ -305,7 +367,10 @@ function createSupervisorHandle( socketPath, 'shutdown', keepWorkers === undefined ? undefined : { keepWorkers }, - authOptions, + { + ...authOptions, + timeoutMs: AGENT_VIEW_SHUTDOWN_TIMEOUT_MS, + }, ), }; } @@ -470,12 +535,18 @@ async function readSupervisorAuthToken( function defaultSpawnSupervisor(args: readonly string[]): ChildProcess { const argv = buildCurrentQwenCliArgv(args); + // Invocation-scoped flags must not leak into the long-lived daemon: a + // `--bare` caller would otherwise contaminate every session the daemon + // later spawns (bare mode is env-driven via QWEN_CODE_SIMPLE). + const env = restoreInvocationScopedEnv(process.env); + delete env['QWEN_CODE_SIMPLE']; return spawn(argv[0]!, argv.slice(1), { detached: true, stdio: 'ignore', env: { - ...process.env, + ...env, QWEN_CODE_NO_RELAUNCH: '1', + [INTERNAL_AGENT_VIEW_SUPERVISOR_ENV]: '1', }, }); } diff --git a/packages/cli/src/agent-view/supervisor-server.test.ts b/packages/cli/src/agent-view/supervisor-server.test.ts index cf2d7d34749..e6bee0a79b9 100644 --- a/packages/cli/src/agent-view/supervisor-server.test.ts +++ b/packages/cli/src/agent-view/supervisor-server.test.ts @@ -68,6 +68,104 @@ describe('Agent View supervisor server', () => { } }); + it('drains active operations before shutdown and rejects new mutations', async () => { + const { dir, socketPath } = await makeSocketPath(); + cleanupPaths.push(dir); + let releaseDispatch = () => {}; + const dispatchBlocked = new Promise((resolve) => { + releaseDispatch = resolve; + }); + const handler = { + status: vi.fn(() => ({ state: 'ok' })), + list: vi.fn(() => []), + dispatch: vi.fn(async () => { + await dispatchBlocked; + return { sessionId: 'session-1' }; + }), + send: vi.fn(() => ({ sent: true })), + workerControl: vi.fn(() => ({ events: [] })), + shutdown: vi.fn(() => ({ shuttingDown: true })), + }; + const server = createAgentViewSupervisorServer(handler, { + socketPath, + authorizeSideband: () => true, + }); + + await server.listen(); + try { + const dispatch = callAgentViewSupervisor(socketPath, 'dispatch', { + prompt: 'task', + cwd: dir, + }); + await waitFor(() => handler.dispatch.mock.calls.length === 1); + const shutdown = callAgentViewSupervisor(socketPath, 'shutdown'); + await Promise.resolve(); + const repeatedShutdown = callAgentViewSupervisor(socketPath, 'shutdown'); + + await expect( + callAgentViewSupervisor(socketPath, 'send', { + sessionId: 'session-1', + text: 'next', + }), + ).rejects.toThrow('is shutting down'); + await expect( + callAgentViewSupervisor(socketPath, 'workerControl', { + sessionId: 'session-1', + token: 'token-1', + }), + ).resolves.toEqual({ events: [] }); + expect(handler.shutdown).not.toHaveBeenCalled(); + + releaseDispatch(); + await expect(dispatch).resolves.toEqual({ sessionId: 'session-1' }); + await expect(shutdown).resolves.toEqual({ shuttingDown: true }); + await expect(repeatedShutdown).resolves.toEqual({ shuttingDown: true }); + expect(handler.shutdown).toHaveBeenCalledOnce(); + } finally { + await server.close(); + } + }); + + it('reopens the operation gate when shutdown cannot stop every worker', async () => { + const { dir, socketPath } = await makeSocketPath(); + cleanupPaths.push(dir); + const handler = { + status: vi.fn(() => ({ running: true })), + list: vi.fn(() => [{ sessionId: 'session-1' }]), + shutdown: vi + .fn() + .mockReturnValueOnce({ + shuttingDown: false, + workersStopped: 0, + workersFailed: [ + { sessionId: 'session-1', error: 'host refused shutdown' }, + ], + }) + .mockReturnValueOnce({ + shuttingDown: true, + workersStopped: 1, + workersFailed: [], + }), + }; + const server = createAgentViewSupervisorServer(handler, { socketPath }); + + await server.listen(); + try { + await expect( + callAgentViewSupervisor(socketPath, 'shutdown'), + ).resolves.toMatchObject({ shuttingDown: false }); + await expect( + callAgentViewSupervisor(socketPath, 'list'), + ).resolves.toEqual([{ sessionId: 'session-1' }]); + await expect( + callAgentViewSupervisor(socketPath, 'shutdown'), + ).resolves.toMatchObject({ shuttingDown: true }); + expect(handler.shutdown).toHaveBeenCalledTimes(2); + } finally { + await server.close(); + } + }); + it('requires the supervisor auth token when configured', async () => { const { dir, socketPath } = await makeSocketPath(); cleanupPaths.push(dir); diff --git a/packages/cli/src/agent-view/supervisor-server.ts b/packages/cli/src/agent-view/supervisor-server.ts index 4de40673b40..78cce4a2ad6 100644 --- a/packages/cli/src/agent-view/supervisor-server.ts +++ b/packages/cli/src/agent-view/supervisor-server.ts @@ -45,6 +45,7 @@ export interface AgentViewSupervisorHandler { stop?: AgentViewSupervisorHandlerMethod<'stop'>; kill?: AgentViewSupervisorHandlerMethod<'kill'>; respawn?: AgentViewSupervisorHandlerMethod<'respawn'>; + release?: AgentViewSupervisorHandlerMethod<'release'>; remove?: AgentViewSupervisorHandlerMethod<'remove'>; pin?: AgentViewSupervisorHandlerMethod<'pin'>; rename?: AgentViewSupervisorHandlerMethod<'rename'>; @@ -68,12 +69,14 @@ export interface AgentViewSupervisorServerHandle { } const MAX_SUPERVISOR_REQUEST_LINE_BYTES = 1024 * 1024; +const SUPERVISOR_OPERATION_DRAIN_TIMEOUT_MS = 30_000; export function createAgentViewSupervisorServer( handler: AgentViewSupervisorHandler, options: AgentViewSupervisorServerOptions, ): AgentViewSupervisorServerHandle { const sockets = new Set(); + const operationGate = new SupervisorOperationGate(); const server = net.createServer((socket) => { sockets.add(socket); socket.once('close', () => sockets.delete(socket)); @@ -99,6 +102,7 @@ export function createAgentViewSupervisorServer( options.authToken, options.authorizeSideband, remaining, + operationGate, ); }); }); @@ -143,6 +147,10 @@ export async function handleAgentViewSupervisorRequest( handler: AgentViewSupervisorHandler, authToken?: string, authorizeSideband?: AgentViewSidebandAuthorizer, + invoke: ( + op: AgentViewSupervisorOperation, + action: () => Promise | unknown, + ) => Promise = async (_op, action) => action(), ): Promise { if (!isRecord(request) || typeof request['id'] !== 'string') { return errorResponse('', 'invalid_request', 'Invalid supervisor request.'); @@ -205,7 +213,7 @@ export async function handleAgentViewSupervisorRequest( params: Record | undefined, ) => Promise | unknown) | undefined; - const result = await method?.call(handler, params); + const result = await invoke(op, () => method?.call(handler, params)); return { id: request['id'], ok: true, @@ -228,6 +236,90 @@ interface ParsedRequest { params?: Record; } +class SupervisorOperationGate { + private state: 'running' | 'draining' | 'closed' = 'running'; + private readonly active = new Set>(); + private shutdownPromise: Promise | undefined; + + async run( + op: AgentViewSupervisorOperation, + action: () => Promise | unknown, + ): Promise { + if (op === 'shutdown') { + if (this.shutdownPromise) return this.shutdownPromise; + this.state = 'draining'; + this.shutdownPromise = this.drainAndShutdown(action); + return this.shutdownPromise; + } + if (isDrainSafeOperation(op)) { + return action(); + } + if (this.state !== 'running') { + throw new Error('Agent View supervisor is shutting down.'); + } + const operation = Promise.resolve().then(action); + this.active.add(operation); + try { + return await operation; + } finally { + this.active.delete(operation); + } + } + + private async drainAndShutdown( + action: () => Promise | unknown, + ): Promise { + try { + await waitForSupervisorOperations(this.active); + const result = await action(); + if (isRecord(result) && result['shuttingDown'] === false) { + this.state = 'running'; + this.shutdownPromise = undefined; + } else { + this.state = 'closed'; + } + return result; + } catch (error) { + this.state = 'running'; + this.shutdownPromise = undefined; + throw error; + } + } + + canStartStream(op: 'attachStream' | 'subscribe'): boolean { + return this.state === 'running' || op === 'subscribe'; + } +} + +async function waitForSupervisorOperations( + active: ReadonlySet>, +): Promise { + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + Promise.allSettled([...active]).then(() => undefined), + new Promise((_resolve, reject) => { + timeout = setTimeout( + () => + reject( + new Error( + 'Agent View supervisor shutdown timed out while waiting for active operations.', + ), + ), + SUPERVISOR_OPERATION_DRAIN_TIMEOUT_MS, + ); + timeout.unref?.(); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function isDrainSafeOperation(op: AgentViewSupervisorOperation): boolean { + return op === 'status' || op === 'workerEvent' || op === 'workerControl'; +} + async function respondToLine( line: string, handler: AgentViewSupervisorHandler, @@ -235,6 +327,7 @@ async function respondToLine( authToken: string | undefined, authorizeSideband: AgentViewSidebandAuthorizer | undefined, remaining: Buffer, + operationGate: SupervisorOperationGate, ): Promise { const request = parseRequestLine(line); if ( @@ -242,6 +335,18 @@ async function respondToLine( request.op === 'attachStream' && typeof handler.attachStream === 'function' ) { + if (!operationGate.canStartStream('attachStream')) { + socket.end( + `${JSON.stringify( + errorResponse( + request.id, + 'internal_error', + 'Agent View supervisor is shutting down.', + ), + )}\n`, + ); + return; + } await handleStreamingOp( request, socket, @@ -278,6 +383,7 @@ async function respondToLine( handler, authToken, authorizeSideband, + operationGate.run.bind(operationGate), ); } catch { response = errorResponse('', 'invalid_json', 'Invalid JSON request.'); @@ -424,6 +530,7 @@ function isSupervisorOperation( value === 'stop' || value === 'kill' || value === 'respawn' || + value === 'release' || value === 'remove' || value === 'pin' || value === 'rename' diff --git a/packages/cli/src/agent-view/supervisor-store.test.ts b/packages/cli/src/agent-view/supervisor-store.test.ts index 80004439f80..b9bf35069d7 100644 --- a/packages/cli/src/agent-view/supervisor-store.test.ts +++ b/packages/cli/src/agent-view/supervisor-store.test.ts @@ -13,11 +13,14 @@ import { getAgentViewStorePaths, listAgentViewSessionSnapshots, listAgentViewSessionStates, + patchAgentViewSessionState, + patchAgentViewSessionStateIf, readAgentViewRoster, readAgentViewSessionState, + readAgentViewSessionStateStrict, removeAgentViewRosterEntry, - updateAgentViewRosterEntry, upsertAgentViewRosterEntry, + writeAgentViewRoster, writeAgentViewActivity, writeAgentViewLaunch, writeAgentViewSessionState, @@ -27,6 +30,7 @@ import { readAgentViewLaunch, readAgentViewSupervisor, readAgentViewWorker, + updateAgentViewRosterEntry, } from './supervisor-store.js'; import type { AgentViewActivityFile, @@ -48,36 +52,6 @@ describe('agent view supervisor store', () => { fs.rmSync(tempDir, { recursive: true, force: true }); }); - it('writes credential files without following a pre-placed symlink', async () => { - if (process.platform === 'win32') return; - const paths = getAgentViewStorePaths({ globalDir: tempDir }); - fs.mkdirSync(paths.daemonDir, { recursive: true }); - const symlinkTarget = path.join(tempDir, 'attacker-controlled.json'); - fs.writeFileSync(symlinkTarget, 'untouched'); - fs.symlinkSync(symlinkTarget, paths.supervisorPath); - - await writeAgentViewSupervisor( - { - schemaVersion: 1, - pid: 1234, - socketPath: path.join(tempDir, 'supervisor.sock'), - authToken: 'secret-token', - startedAt: '2026-07-17T00:00:00.000Z', - updatedAt: '2026-07-17T00:00:00.000Z', - protocolVersion: 1, - }, - { globalDir: tempDir }, - ); - - // The symlink target must not receive the auth token, and the store path - // is replaced with a regular file rather than written through the link. - expect(fs.readFileSync(symlinkTarget, 'utf8')).toBe('untouched'); - expect(fs.lstatSync(paths.supervisorPath).isSymbolicLink()).toBe(false); - expect(fs.readFileSync(paths.supervisorPath, 'utf8')).toContain( - 'secret-token', - ); - }); - it('uses daemon and jobs directories under the global qwen dir', () => { expect(getAgentViewStorePaths({ globalDir: tempDir })).toEqual({ globalDir: tempDir, @@ -168,49 +142,98 @@ describe('agent view supervisor store', () => { ]); }); - it('updates roster entries and keeps pinned entries first', async () => { - await upsertAgentViewRosterEntry( - rosterEntry('one', { - updatedAt: '2026-07-16T00:00:00.000Z', - }), - { globalDir: tempDir }, + it('serializes concurrent roster upserts', async () => { + await Promise.all( + Array.from({ length: 20 }, (_, index) => + upsertAgentViewRosterEntry( + rosterEntry(`session-${index}`, { + updatedAt: `2026-07-16T00:00:${String(index).padStart(2, '0')}.000Z`, + }), + { globalDir: tempDir }, + ), + ), + ); + + const roster = await readAgentViewRoster({ globalDir: tempDir }); + expect(roster.sessions).toHaveLength(20); + expect(new Set(roster.sessions.map((entry) => entry.sessionId)).size).toBe( + 20, ); + }); + + it('matches roster entries by sanitized session id', async () => { + await upsertAgentViewRosterEntry(rosterEntry('MySession'), { + globalDir: tempDir, + }); await upsertAgentViewRosterEntry( - rosterEntry('two', { + rosterEntry('mysession', { + displayName: 'lowercase', updatedAt: '2026-07-16T00:00:01.000Z', }), { globalDir: tempDir }, ); + await expect(readAgentViewRoster({ globalDir: tempDir })).resolves.toEqual( + expect.objectContaining({ + sessions: [expect.objectContaining({ sessionId: 'mysession' })], + }), + ); await expect( updateAgentViewRosterEntry( - 'missing', - (entry) => ({ ...entry, displayName: 'Missing' }), - { globalDir: tempDir }, - ), - ).resolves.toBeUndefined(); - - await expect( - updateAgentViewRosterEntry( - 'one', + 'MYSESSION', (entry) => ({ ...entry, pinned: true, - displayName: 'Pinned', updatedAt: '2026-07-16T00:00:02.000Z', }), { globalDir: tempDir }, ), - ).resolves.toMatchObject({ - sessionId: 'one', - displayName: 'Pinned', - pinned: true, - }); + ).resolves.toMatchObject({ sessionId: 'mysession', pinned: true }); + await expect( + updateAgentViewRosterEntry('missing', (entry) => entry, { + globalDir: tempDir, + }), + ).resolves.toBeUndefined(); + await expect( + removeAgentViewRosterEntry('MySession', { globalDir: tempDir }), + ).resolves.toMatchObject({ sessions: [] }); + }); + + it('collapses pre-existing case-variant roster duplicates on update', async () => { + await writeAgentViewRoster( + { + schemaVersion: 1, + updatedAt: '2026-07-16T00:00:00.000Z', + sessions: [ + rosterEntry('MySession', { + displayName: 'upper', + updatedAt: '2026-07-16T00:00:00.000Z', + }), + rosterEntry('mysession', { + displayName: 'lower', + updatedAt: '2026-07-16T00:00:01.000Z', + }), + ], + }, + { globalDir: tempDir }, + ); + + await updateAgentViewRosterEntry( + 'MYSESSION', + (entry) => ({ + ...entry, + displayName: 'merged', + updatedAt: '2026-07-16T00:00:02.000Z', + }), + { globalDir: tempDir }, + ); const roster = await readAgentViewRoster({ globalDir: tempDir }); - expect(roster.sessions.map((entry) => entry.sessionId)).toEqual([ - 'one', - 'two', + expect(roster.sessions).toEqual([ + expect.objectContaining({ + sessionId: 'mysession', + displayName: 'merged', + }), ]); }); @@ -243,6 +266,92 @@ describe('agent view supervisor store', () => { ).toBe(true); }); + it('surfaces state read errors for destructive safety checks', async () => { + const paths = getAgentViewSessionPaths('unreadable', { + globalDir: tempDir, + }); + fs.mkdirSync(paths.statePath, { recursive: true }); + + await expect( + readAgentViewSessionStateStrict('unreadable', { globalDir: tempDir }), + ).rejects.toThrow(); + }); + + it.each([undefined, 'unknown'])( + 'rejects processState %s in destructive safety checks', + async (processState) => { + const paths = getAgentViewSessionPaths('invalid-state', { + globalDir: tempDir, + }); + fs.mkdirSync(paths.sessionDir, { recursive: true }); + fs.writeFileSync( + paths.statePath, + JSON.stringify({ + ...sessionState('invalid-state'), + processState, + }), + ); + + await expect( + readAgentViewSessionStateStrict('invalid-state', { + globalDir: tempDir, + }), + ).rejects.toThrow('is invalid'); + }, + ); + + it('patches only the specified session state fields', async () => { + await writeAgentViewSessionState( + sessionState('session-1', { + customState: 'keep', + sessionState: 'idle', + }), + { globalDir: tempDir }, + ); + + await patchAgentViewSessionState( + 'session-1', + { sessionState: 'completed', updatedAt: '2026-07-16T00:00:01.000Z' }, + { globalDir: tempDir }, + ); + + await expect( + readAgentViewSessionState('session-1', { globalDir: tempDir }), + ).resolves.toMatchObject({ + sessionId: 'session-1', + sessionState: 'completed', + processState: 'alive', + customState: 'keep', + updatedAt: '2026-07-16T00:00:01.000Z', + }); + }); + + it('does nothing when patching a session that has no state file', async () => { + await expect( + patchAgentViewSessionState( + 'missing', + { sessionState: 'completed' }, + { globalDir: tempDir }, + ), + ).resolves.toBeUndefined(); + }); + + it('rejects a conditional verdict write when state is corrupt', async () => { + const paths = getAgentViewSessionPaths('corrupt', { + globalDir: tempDir, + }); + fs.mkdirSync(paths.sessionDir, { recursive: true }); + fs.writeFileSync(paths.statePath, '{'); + + await expect( + patchAgentViewSessionStateIf( + 'corrupt', + () => ({ processState: 'exited' }), + { globalDir: tempDir }, + ), + ).rejects.toThrow('is corrupt'); + }); + it('lists valid session states sorted by most recent update', async () => { await writeAgentViewSessionState( sessionState('older', { @@ -261,46 +370,42 @@ describe('agent view supervisor store', () => { }); fs.mkdirSync(invalid.sessionDir, { recursive: true }); fs.writeFileSync(invalid.statePath, '{"sessionId":"invalid"}'); - const paths = getAgentViewStorePaths({ globalDir: tempDir }); - fs.mkdirSync(paths.jobsDir, { recursive: true }); - fs.writeFileSync(path.join(paths.jobsDir, '.DS_Store'), 'ignored'); const states = await listAgentViewSessionStates({ globalDir: tempDir }); expect(states.map((state) => state.sessionId)).toEqual(['newer', 'older']); }); it('isolates an unreadable session entry instead of failing the list', async () => { - await writeAgentViewSessionState( - sessionState('healthy', { updatedAt: '2026-07-16T00:00:01.000Z' }), - { globalDir: tempDir }, - ); - // A directory where state.json should be makes readFile fail with EISDIR, - // which previously rejected the entire listing. - const bad = getAgentViewSessionPaths('bad', { globalDir: tempDir }); - fs.mkdirSync(bad.statePath, { recursive: true }); - - await expect( - listAgentViewSessionStates({ globalDir: tempDir }), - ).resolves.toMatchObject([{ sessionId: 'healthy' }]); - }); - - it('trusts the session directory name over the state file contents', async () => { - const paths = getAgentViewStorePaths({ globalDir: tempDir }); - const sessionDir = path.join(paths.jobsDir, 'dir-alpha'); - fs.mkdirSync(sessionDir, { recursive: true }); - fs.writeFileSync( - path.join(sessionDir, 'state.json'), - JSON.stringify(sessionState('victim-session')), - ); + await writeAgentViewSessionState(sessionState('healthy'), { + globalDir: tempDir, + }); + const unreadable = getAgentViewSessionPaths('unreadable', { + globalDir: tempDir, + }); + fs.mkdirSync(unreadable.statePath, { recursive: true }); const states = await listAgentViewSessionStates({ globalDir: tempDir }); - expect(states.map((state) => state.sessionId)).toEqual(['dir-alpha']); + expect(states.map((state) => state.sessionId)).toEqual(['healthy']); }); it('includes roster entries in session snapshots', async () => { await writeAgentViewSessionState(sessionState('session-1'), { globalDir: tempDir, }); + await writeAgentViewLaunch( + { + schemaVersion: 1, + sessionId: 'session-1', + argv: ['qwen'], + env: { QWEN_AGENT_VIEW_TOKEN: 'secret' }, + entrypoint: '/tmp/qwen', + projectCwd: tempDir, + activeCwd: tempDir, + includeDirectories: [], + terminal: { columns: 80, rows: 24 }, + }, + { globalDir: tempDir }, + ); await upsertAgentViewRosterEntry( rosterEntry('session-1', { displayName: 'Build Fix', @@ -308,6 +413,17 @@ describe('agent view supervisor store', () => { }), { globalDir: tempDir }, ); + await writeAgentViewWorker( + 'session-1', + { + schemaVersion: 1, + hostAuthToken: 'host-secret', + protocolVersion: 1, + platform: process.platform, + recentOutputBytes: 0, + }, + { globalDir: tempDir }, + ); const snapshots = await listAgentViewSessionSnapshots({ globalDir: tempDir, @@ -320,7 +436,15 @@ describe('agent view supervisor store', () => { displayName: 'Build Fix', pinned: true, }, + launch: expect.objectContaining({ + env: {}, + }), + }); + expect(snapshots[0]?.worker).toMatchObject({ + protocolVersion: 1, + recentOutputBytes: 0, }); + expect(snapshots[0]?.worker).not.toHaveProperty('hostAuthToken'); }); it('round trips launch, activity, worker, and supervisor files', async () => { @@ -388,98 +512,7 @@ describe('agent view supervisor store', () => { ); }); - it('sanitizes a dot-only session id instead of escaping the jobs dir', () => { - expect( - getAgentViewSessionPaths('..', { globalDir: tempDir }).sessionDir, - ).toBe(path.join(tempDir, 'jobs', '_')); - }); - - it('joins roster entries to snapshots case-insensitively', async () => { - await writeAgentViewSessionState(sessionState('ABC123'), { - globalDir: tempDir, - }); - await upsertAgentViewRosterEntry( - rosterEntry('ABC123', { displayName: 'Upper', pinned: true }), - { globalDir: tempDir }, - ); - - const snapshots = await listAgentViewSessionSnapshots({ - globalDir: tempDir, - }); - - expect(snapshots).toHaveLength(1); - expect(snapshots[0]).toMatchObject({ - sessionId: 'abc123', - rosterEntry: { sessionId: 'ABC123', displayName: 'Upper', pinned: true }, - }); - }); - - it('preserves unknown fields from a prior writer when merging a write', async () => { - const paths = getAgentViewSessionPaths('session-1', { globalDir: tempDir }); - fs.mkdirSync(paths.sessionDir, { recursive: true }); - fs.writeFileSync( - paths.statePath, - JSON.stringify({ ...sessionState('session-1'), futureField: 'keep' }), - ); - - await writeAgentViewSessionState(sessionState('session-1'), { - globalDir: tempDir, - }); - - const raw = JSON.parse(fs.readFileSync(paths.statePath, 'utf8')); - expect(raw.futureField).toBe('keep'); - }); - - it('returns the sanitized directory name as sessionId from direct reads', async () => { - await writeAgentViewSessionState(sessionState('MySession'), { - globalDir: tempDir, - }); - await writeAgentViewLaunch( - { - schemaVersion: 1, - sessionId: 'MySession', - argv: [], - env: {}, - entrypoint: '/tmp/qwen', - projectCwd: tempDir, - activeCwd: tempDir, - includeDirectories: [], - terminal: { columns: 80, rows: 24 }, - }, - { globalDir: tempDir }, - ); - - const state = await readAgentViewSessionState('MySession', { - globalDir: tempDir, - }); - expect(state?.sessionId).toBe('mysession'); - - const launch = await readAgentViewLaunch('MySession', { - globalDir: tempDir, - }); - expect(launch?.sessionId).toBe('mysession'); - - const listed = await listAgentViewSessionStates({ globalDir: tempDir }); - expect(listed[0]?.sessionId).toBe('mysession'); - }); - - it('throws when a transient read error hits the roster during a mutation', async () => { - const paths = getAgentViewStorePaths({ globalDir: tempDir }); - // A directory where roster.json should be makes readFile fail with EISDIR. - fs.mkdirSync(paths.rosterPath, { recursive: true }); - - await expect( - upsertAgentViewRosterEntry(rosterEntry('one'), { globalDir: tempDir }), - ).rejects.toThrow(); - await expect( - removeAgentViewRosterEntry('one', { globalDir: tempDir }), - ).rejects.toThrow(); - await expect( - updateAgentViewRosterEntry('one', (e) => e, { globalDir: tempDir }), - ).rejects.toThrow(); - }); - - it('strips wrong-typed optional fields during activity normalization', async () => { + it('strips wrong-typed optional fields during normalization', async () => { const paths = getAgentViewSessionPaths('session-1', { globalDir: tempDir, }); @@ -487,87 +520,40 @@ describe('agent view supervisor store', () => { fs.writeFileSync( paths.activityPath, JSON.stringify({ - lastActivityAt: '2026-07-16T00:00:00.000Z', + schemaVersion: 1, summary: 42, waitingFor: true, - lastResult: ['wrong'], + queuedPromptCount: '1', + lastActivityAt: '2026-07-16T00:00:00.000Z', + capabilities: ['state'], }), ); - - const activity = await readAgentViewActivity('session-1', { - globalDir: tempDir, - }); - expect(activity?.summary).toBeUndefined(); - expect(activity?.waitingFor).toBeUndefined(); - expect(activity?.lastResult).toBeUndefined(); - expect(activity?.lastActivityAt).toBe('2026-07-16T00:00:00.000Z'); - }); - - it('strips a wrong-typed authToken during supervisor normalization', async () => { - const paths = getAgentViewStorePaths({ globalDir: tempDir }); - fs.mkdirSync(paths.daemonDir, { recursive: true }); fs.writeFileSync( - paths.supervisorPath, + paths.launchPath, JSON.stringify({ - pid: 123, - socketPath: path.join(tempDir, 'test.sock'), - authToken: 42, - startedAt: '2026-07-16T00:00:00.000Z', - updatedAt: '2026-07-16T00:00:00.000Z', - }), - ); - - const supervisor = await readAgentViewSupervisor({ globalDir: tempDir }); - expect(supervisor?.authToken).toBeUndefined(); - expect(supervisor?.pid).toBe(123); - }); - - it('deduplicates roster entries that differ only in case', async () => { - await upsertAgentViewRosterEntry( - rosterEntry('MySession', { - displayName: 'First', - updatedAt: '2026-07-16T00:00:00.000Z', - }), - { globalDir: tempDir }, - ); - await upsertAgentViewRosterEntry( - rosterEntry('mysession', { - displayName: 'Second', - updatedAt: '2026-07-16T00:00:01.000Z', + schemaVersion: 1, + sessionId: 'session-1', + argv: ['qwen'], + env: {}, + entrypoint: '/tmp/qwen', + initialPrompt: 42, + projectCwd: tempDir, + activeCwd: tempDir, + includeDirectories: [], + terminal: { columns: 80, rows: 24 }, }), - { globalDir: tempDir }, ); - const roster = await readAgentViewRoster({ globalDir: tempDir }); - expect(roster.sessions).toHaveLength(1); - expect(roster.sessions[0]).toMatchObject({ - sessionId: 'mysession', - displayName: 'Second', - }); - }); - - it('removes roster entries case-insensitively', async () => { - await upsertAgentViewRosterEntry(rosterEntry('MySession'), { - globalDir: tempDir, - }); - - const next = await removeAgentViewRosterEntry('MYSESSION', { + const activity = await readAgentViewActivity('session-1', { globalDir: tempDir, }); - expect(next.sessions).toHaveLength(0); - }); - - it('updates roster entries case-insensitively', async () => { - await upsertAgentViewRosterEntry(rosterEntry('MySession'), { + expect(activity).not.toHaveProperty('summary'); + expect(activity).not.toHaveProperty('waitingFor'); + expect(activity).not.toHaveProperty('queuedPromptCount'); + const launch = await readAgentViewLaunch('session-1', { globalDir: tempDir, }); - - const updated = await updateAgentViewRosterEntry( - 'MYSESSION', - (entry) => ({ ...entry, displayName: 'Updated' }), - { globalDir: tempDir }, - ); - expect(updated?.displayName).toBe('Updated'); + expect(launch).not.toHaveProperty('initialPrompt'); }); }); diff --git a/packages/cli/src/agent-view/supervisor-store.ts b/packages/cli/src/agent-view/supervisor-store.ts index 4963e6ba20a..07ef7b0dbcc 100644 --- a/packages/cli/src/agent-view/supervisor-store.ts +++ b/packages/cli/src/agent-view/supervisor-store.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { createHash } from 'node:crypto'; import { atomicWriteFile, Storage } from '@qwen-code/qwen-code-core'; import type { AgentViewActivityFile, @@ -42,6 +43,11 @@ interface StoreOptions { globalDir?: string; } +const rosterMutationQueues = new Map>(); +const stateMutationQueues = new Map>(); +const activityMutationQueues = new Map>(); +const workerMutationQueues = new Map>(); + export function getAgentViewStorePaths( options: StoreOptions = {}, ): AgentViewStorePaths { @@ -80,13 +86,77 @@ export async function readAgentViewRoster( return normalizeRoster(raw); } -async function readAgentViewRosterForWrite( +export async function readAgentViewRosterForWrite( options: StoreOptions = {}, ): Promise { - const raw = await readJsonRecordForWrite( - getAgentViewStorePaths(options).rosterPath, - ); - return normalizeRoster(raw); + // Roster pins exist nowhere else: a corrupt-but-present file must fail + // closed exactly like readAgentViewRosterStrict, or the mutation would + // write back an emptied roster and silently drop every other session's + // pin. ENOENT still reads as an empty roster so first-dispatch creation + // works. + return readAgentViewRosterStrict(options); +} + +/** + * Like readAgentViewRosterForWrite, but corrupt or non-object content is a + * hard error instead of an empty roster: callers making safety decisions + * (e.g. the hibernation pin check) must fail closed when the file exists + * but its pins cannot be read. + */ +export async function readAgentViewRosterStrict( + options: StoreOptions = {}, +): Promise { + const rosterPath = getAgentViewStorePaths(options).rosterPath; + let text: string; + try { + text = await fs.readFile(rosterPath, 'utf8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return normalizeRoster(undefined); + } + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`Agent View roster at ${rosterPath} is corrupt.`, { + cause: error, + }); + } + if (!isRecord(parsed)) { + throw new Error(`Agent View roster at ${rosterPath} is not a JSON object.`); + } + // Failing open here would void the hibernation pin check: a missing + // sessions array or a dropped (structurally incomplete) entry reads as + // "no pins", hibernating a session its owner explicitly kept alive. + if (!Array.isArray(parsed['sessions'])) { + throw new Error( + `Agent View roster at ${rosterPath} has no sessions array.`, + ); + } + // A declared-but-unreadable pin (e.g. "true" as a string) must not be + // silently coerced away by normalizeRosterEntry: the hibernation pin + // checks test truthiness, so failing open here would void the very + // keep-alive opt-out this reader exists to protect. + for (const entry of parsed['sessions']) { + if ( + isRecord(entry) && + 'pinned' in entry && + typeof entry['pinned'] !== 'boolean' + ) { + throw new Error( + `Agent View roster at ${rosterPath} has a non-boolean pinned field.`, + ); + } + } + const roster = normalizeRoster(parsed); + if (roster.sessions.length !== parsed['sessions'].length) { + throw new Error( + `Agent View roster at ${rosterPath} has incomplete session entries.`, + ); + } + return roster; } export async function writeAgentViewRoster( @@ -100,45 +170,49 @@ export async function upsertAgentViewRosterEntry( entry: AgentViewRosterEntry, options: StoreOptions = {}, ): Promise { - const roster = await readAgentViewRosterForWrite(options); - const key = sanitizeSessionId(entry.sessionId); - const sessions = roster.sessions.filter( - (item) => sanitizeSessionId(item.sessionId) !== key, - ); - const existing = roster.sessions.find( - (item) => sanitizeSessionId(item.sessionId) === key, - ); - const updated: AgentViewRosterEntry = { - ...existing, - ...entry, - }; - const next: AgentViewRosterFile = { - ...roster, - schemaVersion: 1, - updatedAt: entry.updatedAt, - sessions: [...sessions, updated].sort(compareRosterEntries), - }; - await writeAgentViewRoster(next, options); - return next; + return mutateAgentViewRoster(options, async () => { + const roster = await readAgentViewRosterForWrite(options); + const entryKey = sanitizeSessionId(entry.sessionId); + const sessions = roster.sessions.filter( + (item) => sanitizeSessionId(item.sessionId) !== entryKey, + ); + const existing = roster.sessions.find( + (item) => sanitizeSessionId(item.sessionId) === entryKey, + ); + const updated: AgentViewRosterEntry = { + ...existing, + ...entry, + }; + const next: AgentViewRosterFile = { + ...roster, + schemaVersion: 1, + updatedAt: entry.updatedAt, + sessions: [...sessions, updated].sort(compareRosterEntries), + }; + await writeAgentViewRoster(next, options); + return next; + }); } export async function removeAgentViewRosterEntry( sessionId: string, options: StoreOptions = {}, ): Promise { - const roster = await readAgentViewRosterForWrite(options); - const key = sanitizeSessionId(sessionId); - const now = new Date().toISOString(); - const next: AgentViewRosterFile = { - ...roster, - schemaVersion: 1, - updatedAt: now, - sessions: roster.sessions.filter( - (item) => sanitizeSessionId(item.sessionId) !== key, - ), - }; - await writeAgentViewRoster(next, options); - return next; + return mutateAgentViewRoster(options, async () => { + const roster = await readAgentViewRosterForWrite(options); + const sessionKey = sanitizeSessionId(sessionId); + const now = new Date().toISOString(); + const next: AgentViewRosterFile = { + ...roster, + schemaVersion: 1, + updatedAt: now, + sessions: roster.sessions.filter( + (item) => sanitizeSessionId(item.sessionId) !== sessionKey, + ), + }; + await writeAgentViewRoster(next, options); + return next; + }); } export async function updateAgentViewRosterEntry( @@ -146,24 +220,27 @@ export async function updateAgentViewRosterEntry( update: (entry: AgentViewRosterEntry) => AgentViewRosterEntry, options: StoreOptions = {}, ): Promise { - const roster = await readAgentViewRosterForWrite(options); - const key = sanitizeSessionId(sessionId); - let updated: AgentViewRosterEntry | undefined; - const sessions = roster.sessions.map((entry) => { - if (sanitizeSessionId(entry.sessionId) !== key) return entry; - updated = update(entry); + return mutateAgentViewRoster(options, async () => { + const roster = await readAgentViewRosterForWrite(options); + const sessionKey = sanitizeSessionId(sessionId); + const existing = roster.sessions.find( + (entry) => sanitizeSessionId(entry.sessionId) === sessionKey, + ); + if (!existing) return undefined; + const updated = update(existing); + const sessions = roster.sessions.filter( + (entry) => sanitizeSessionId(entry.sessionId) !== sessionKey, + ); + + const next: AgentViewRosterFile = { + ...roster, + schemaVersion: 1, + updatedAt: updated.updatedAt, + sessions: [...sessions, updated].sort(compareRosterEntries), + }; + await writeAgentViewRoster(next, options); return updated; }); - if (!updated) return undefined; - - const next: AgentViewRosterFile = { - ...roster, - schemaVersion: 1, - updatedAt: updated.updatedAt, - sessions: sessions.sort(compareRosterEntries), - }; - await writeAgentViewRoster(next, options); - return updated; } export async function readAgentViewSessionState( @@ -175,18 +252,99 @@ export async function readAgentViewSessionState( return normalizeSessionState(raw, path.basename(paths.sessionDir)); } +export async function readAgentViewSessionStateStrict( + sessionId: string, + options: StoreOptions = {}, +): Promise { + const paths = getAgentViewSessionPaths(sessionId, options); + const raw = await readJsonRecordForConditionalWrite(paths.statePath); + if (!raw) return undefined; + if (!isAgentViewProcessState(raw['processState'])) { + throw new Error(`Agent View state at ${paths.statePath} is invalid.`); + } + const state = normalizeSessionState(raw, path.basename(paths.sessionDir)); + if (!state) { + throw new Error(`Agent View state at ${paths.statePath} is invalid.`); + } + return state; +} + export async function writeAgentViewSessionState( state: AgentViewSessionStateFile, options: StoreOptions = {}, ): Promise { - const paths = getAgentViewSessionPaths(state.sessionId, options); - const existing = await readJsonRecordForWrite(paths.statePath); - await writeJsonFile(paths.statePath, { - ...existing, - ...state, - schemaVersion: 1, + // Serialize with the per-session queue: an unqueued full-snapshot write + // landing after a queued verdict patch would re-assert stale fields over + // it. + return mutateAgentViewState(state.sessionId, options, async () => { + const paths = getAgentViewSessionPaths(state.sessionId, options); + const existing = await readJsonRecordForWrite(paths.statePath); + await writeJsonFile(paths.statePath, { + ...existing, + ...state, + schemaVersion: 1, + }); + await fs.mkdir(paths.tmpDir, { recursive: true }); + }); +} + +/** + * Merges only the given fields into the persisted session state, so the + * writer never re-asserts fields it does not own from a stale read. + */ +export async function patchAgentViewSessionState( + sessionId: string, + patch: Partial, + options: StoreOptions = {}, +): Promise { + return mutateAgentViewState(sessionId, options, async () => { + const paths = getAgentViewSessionPaths(sessionId, options); + const existing = await readJsonRecordForWrite(paths.statePath); + if (existing === undefined) { + return; + } + await writeJsonFile(paths.statePath, { + ...existing, + ...patch, + schemaVersion: 1, + }); + await fs.mkdir(paths.tmpDir, { recursive: true }); + }); +} + +export async function patchAgentViewSessionStateIf( + sessionId: string, + decidePatch: ( + existing: AgentViewSessionStateFile, + ) => Partial | undefined, + options: StoreOptions = {}, +): Promise { + let applied = false; + await mutateAgentViewState(sessionId, options, async () => { + const paths = getAgentViewSessionPaths(sessionId, options); + const existing = await readJsonRecordForConditionalWrite(paths.statePath); + if (existing === undefined) { + return; + } + const normalized = normalizeSessionState(existing, sessionId); + if (normalized === undefined) { + throw new Error( + `Agent View session state at ${paths.statePath} is incomplete.`, + ); + } + const patch = decidePatch(normalized); + if (patch === undefined) { + return; + } + applied = true; + await writeJsonFile(paths.statePath, { + ...existing, + ...patch, + schemaVersion: 1, + }); + await fs.mkdir(paths.tmpDir, { recursive: true }); }); - await fs.mkdir(paths.tmpDir, { recursive: true }); + return applied; } export async function listAgentViewSessionStates( @@ -223,9 +381,16 @@ export async function listAgentViewSessionSnapshots( states.map(async (state) => ({ sessionId: state.sessionId, state, - activity: await readAgentViewActivity(state.sessionId, options), - worker: await readAgentViewWorker(state.sessionId, options), - rosterEntry: rosterEntries.get(state.sessionId), + launch: redactAgentViewLaunch( + await readAgentViewLaunch(state.sessionId, options), + ), + activity: redactAgentViewActivity( + await readAgentViewActivity(state.sessionId, options), + ), + worker: redactAgentViewWorker( + await readAgentViewWorker(state.sessionId, options), + ), + rosterEntry: rosterEntries.get(sanitizeSessionId(state.sessionId)), })), ); return snapshots.sort((left, right) => @@ -269,16 +434,78 @@ export async function writeAgentViewActivity( sessionId: string, activity: AgentViewActivityFile, options: StoreOptions = {}, +): Promise { + return mutateAgentViewActivity(sessionId, options, async () => { + const paths = getAgentViewSessionPaths(sessionId, options); + const existing = await readJsonRecordForWrite(paths.activityPath); + await writeJsonFile(paths.activityPath, { + ...existing, + ...activity, + schemaVersion: 1, + }); + }); +} + +/** + * Applies a patch decided inside the per-session activity mutation queue, + * so the decision sees the latest persisted record (e.g. a queued-prompt + * marker a concurrent send just wrote) instead of a stale read. + */ +export async function patchAgentViewActivityIf( + sessionId: string, + decidePatch: ( + existing: AgentViewActivityFile, + ) => Partial | undefined, + options: StoreOptions = {}, +): Promise { + let applied = false; + await mutateAgentViewActivity(sessionId, options, async () => { + const paths = getAgentViewSessionPaths(sessionId, options); + const existing = await readJsonRecordForWrite(paths.activityPath); + const normalized = normalizeActivity(existing); + if (normalized === undefined) { + return; + } + const patch = decidePatch(normalized); + if (patch === undefined) { + return; + } + applied = true; + await writeJsonFile(paths.activityPath, { + ...existing, + ...patch, + schemaVersion: 1, + }); + }); + return applied; +} + +/** + * Drops the persisted pids once a terminal exit verdict is authoritative: + * stale pids may be reused by unrelated processes, and leaving them makes + * later liveness probes and signaling paths target the wrong process. + */ +export async function clearAgentViewWorkerPids( + sessionId: string, + options: StoreOptions = {}, ): Promise { const paths = getAgentViewSessionPaths(sessionId, options); - const existing = await readJsonRecordForWrite(paths.activityPath); - await writeJsonFile(paths.activityPath, { - ...existing, - ...activity, - schemaVersion: 1, + await withMutationQueue(workerMutationQueues, paths.workerPath, async () => { + const existing = await readJsonRecordForWrite(paths.workerPath); + if (existing === undefined) { + return; + } + const next: JsonRecord = { ...existing }; + delete next['hostPid']; + delete next['workerPid']; + await writeJsonFile(paths.workerPath, { ...next, schemaVersion: 1 }); }); } +export function digestAgentViewWorkerToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + export async function readAgentViewWorker( sessionId: string, options: StoreOptions = {}, @@ -295,11 +522,15 @@ export async function writeAgentViewWorker( options: StoreOptions = {}, ): Promise { const paths = getAgentViewSessionPaths(sessionId, options); - const existing = await readJsonRecordForWrite(paths.workerPath); - await writeJsonFile(paths.workerPath, { - ...existing, - ...worker, - schemaVersion: 1, + // Serialize with the per-path queue: unlocked heartbeat writes would + // otherwise merge over a concurrent pid write and drop it. + await withMutationQueue(workerMutationQueues, paths.workerPath, async () => { + const existing = await readJsonRecordForWrite(paths.workerPath); + await writeJsonFile(paths.workerPath, { + ...existing, + ...worker, + schemaVersion: 1, + }); }); } @@ -325,7 +556,7 @@ export async function writeAgentViewSupervisor( }); } -function sanitizeSessionId(sessionId: string): string { +export function sanitizeSessionId(sessionId: string): string { const safe = path .basename(sessionId.replace(/\\/g, '/')) .toLowerCase() @@ -384,6 +615,90 @@ async function readJsonRecordForWrite( } } +async function readJsonRecordForConditionalWrite( + filePath: string, +): Promise { + let text: string; + try { + text = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return undefined; + } + throw error; + } + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error) { + throw new Error(`Agent View record at ${filePath} is corrupt.`, { + cause: error, + }); + } + if (!isRecord(parsed)) { + throw new Error(`Agent View record at ${filePath} is not a JSON object.`); + } + return parsed; +} + +async function withMutationQueue( + queues: Map>, + key: string, + action: () => Promise, +): Promise { + const previous = queues.get(key) ?? Promise.resolve(); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const current = previous.catch(() => {}).then(() => gate); + queues.set(key, current); + await previous.catch(() => {}); + try { + return await action(); + } finally { + release(); + if (queues.get(key) === current) { + queues.delete(key); + } + } +} + +async function mutateAgentViewRoster( + options: StoreOptions, + action: () => Promise, +): Promise { + return withMutationQueue( + rosterMutationQueues, + getAgentViewStorePaths(options).rosterPath, + action, + ); +} + +async function mutateAgentViewActivity( + sessionId: string, + options: StoreOptions, + action: () => Promise, +): Promise { + return withMutationQueue( + activityMutationQueues, + getAgentViewSessionPaths(sessionId, options).activityPath, + action, + ); +} + +async function mutateAgentViewState( + sessionId: string, + options: StoreOptions, + action: () => Promise, +): Promise { + return withMutationQueue( + stateMutationQueues, + getAgentViewSessionPaths(sessionId, options).statePath, + action, + ); +} + async function writeJsonFile( filePath: string, value: JsonRecord, @@ -475,6 +790,9 @@ function normalizeSessionState( activeCwd: path.resolve(activeCwd), createdAt, updatedAt, + ...(typeof raw['initialPromptPending'] === 'boolean' + ? { initialPromptPending: raw['initialPromptPending'] } + : {}), worktree: isRecord(raw['worktree']) ? { ...raw['worktree'], @@ -496,17 +814,28 @@ function normalizeLaunch( const projectCwd = stringValue(raw['projectCwd']); const activeCwd = stringValue(raw['activeCwd']); if (!sessionId || !entrypoint || !projectCwd || !activeCwd) return undefined; - return { + return stripUndefined({ ...raw, schemaVersion: 1, sessionId, argv: stringArrayValue(raw['argv']), env: stringMapValue(raw['env']), entrypoint, + initialPrompt: stringValue(raw['initialPrompt']), projectCwd: path.resolve(projectCwd), activeCwd: path.resolve(activeCwd), includeDirectories: stringArrayValue(raw['includeDirectories']), terminal: terminalValue(raw['terminal']), + }) as AgentViewLaunchFile; +} + +function redactAgentViewLaunch( + launch: AgentViewLaunchFile | undefined, +): AgentViewLaunchFile | undefined { + if (!launch) return undefined; + return { + ...launch, + env: {}, }; } @@ -516,15 +845,50 @@ function normalizeActivity( if (!raw) return undefined; const lastActivityAt = stringValue(raw['lastActivityAt']); if (!lastActivityAt) return undefined; - return { + return stripUndefined({ ...raw, schemaVersion: 1, summary: stringValue(raw['summary']), waitingFor: stringValue(raw['waitingFor']), + inputKind: inputKindValue(raw['inputKind']), lastResult: stringValue(raw['lastResult']), + queuedPromptCount: numberValue(raw['queuedPromptCount']), + queuedPromptPreview: stringValue(raw['queuedPromptPreview']), + queuedPromptId: stringValue(raw['queuedPromptId']), + queuedPromptText: stringValue(raw['queuedPromptText']), + queuedPromptDeliveredAt: stringValue(raw['queuedPromptDeliveredAt']), + lastQueuedPromptAt: stringValue(raw['lastQueuedPromptAt']), lastActivityAt, capabilities: stringArrayValue(raw['capabilities']), - }; + }) as AgentViewActivityFile; +} + +export function redactAgentViewActivity( + activity: AgentViewActivityFile | undefined, +): AgentViewActivityFile | undefined { + if (!activity) return undefined; + return stripUndefined({ + ...activity, + queuedPromptId: undefined, + queuedPromptText: undefined, + queuedPromptDeliveredAt: undefined, + }) as AgentViewActivityFile; +} + +export function redactAgentViewWorker( + worker: AgentViewWorkerFile | undefined, +): AgentViewWorkerFile | undefined { + if (!worker) return undefined; + return stripUndefined({ + ...worker, + hostAuthToken: undefined, + }) as AgentViewWorkerFile; +} + +function stripUndefined(value: JsonRecord): JsonRecord { + return Object.fromEntries( + Object.entries(value).filter((entry) => entry[1] !== undefined), + ); } function normalizeWorker( @@ -539,6 +903,7 @@ function normalizeWorker( endpoint: stringValue(raw['endpoint']), hostEndpoint: stringValue(raw['hostEndpoint']), hostAuthToken: stringValue(raw['hostAuthToken']), + hostId: stringValue(raw['hostId']), tokenDigest: stringValue(raw['tokenDigest']), lastHeartbeatAt: stringValue(raw['lastHeartbeatAt']), protocolVersion: numberValue(raw['protocolVersion']) ?? 1, @@ -580,31 +945,43 @@ function ownershipValue( : 'managed'; } -function sessionStateValue( +export function isAgentViewSessionState( value: unknown, -): AgentViewSessionStateFile['sessionState'] { - return value === 'starting' || +): value is AgentViewSessionStateFile['sessionState'] { + return ( + value === 'starting' || value === 'working' || value === 'needs_input' || value === 'idle' || value === 'completed' || value === 'stopped' || value === 'failed' - ? value - : 'failed'; + ); +} + +function sessionStateValue( + value: unknown, +): AgentViewSessionStateFile['sessionState'] { + return isAgentViewSessionState(value) ? value : 'failed'; } function processStateValue( value: unknown, ): AgentViewSessionStateFile['processState'] { - return value === 'starting' || + return isAgentViewProcessState(value) ? value : 'exited'; +} + +function isAgentViewProcessState( + value: unknown, +): value is AgentViewSessionStateFile['processState'] { + return ( + value === 'starting' || value === 'alive' || value === 'hibernating' || value === 'hibernated' || value === 'restarting' || value === 'exited' - ? value - : 'exited'; + ); } function attachStateValue( @@ -619,6 +996,12 @@ function worktreeModeValue( return value === 'worktree' || value === 'shared-unisolated' ? value : 'none'; } +export function inputKindValue( + value: unknown, +): AgentViewActivityFile['inputKind'] { + return value === 'blocking' || value === 'soft' ? value : undefined; +} + function terminalValue(value: unknown): AgentViewLaunchFile['terminal'] { if (!isRecord(value)) return { columns: 80, rows: 24 }; return { diff --git a/packages/cli/src/agent-view/worker-sideband.test.ts b/packages/cli/src/agent-view/worker-sideband.test.ts new file mode 100644 index 00000000000..eaf0a1ac611 --- /dev/null +++ b/packages/cli/src/agent-view/worker-sideband.test.ts @@ -0,0 +1,691 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { INTERNAL_SECRET_ENV_VARS } from '@qwen-code/qwen-code-core'; +import { + AGENT_VIEW_WORKER_ENV_KEYS, + createAgentViewWorkerSidebandEnv, + isAgentViewWorkerEnv, + QWEN_AGENT_VIEW_ACTIVE_CWD, + QWEN_AGENT_VIEW_SESSION_ID, + QWEN_AGENT_VIEW_SIDEBAND, + QWEN_AGENT_VIEW_TOKEN, + QWEN_AGENT_VIEW_WORKER, + readAgentViewWorkerSidebandEnv, + readAgentViewWorkerControlEvents, + reportAgentViewWorkerState, + resetAgentViewWorkerStateReportForTests, + sendAgentViewWorkerEvent, + startAgentViewWorkerHeartbeat, +} from './worker-sideband.js'; +import { INTERNAL_AGENT_VIEW_SUPERVISOR_ENV } from './supervisor-runner.js'; + +const mockCallAgentViewSupervisor = vi.hoisted(() => + vi.fn(async (): Promise => ({ accepted: true })), +); + +vi.mock('./supervisor-client.js', () => ({ + callAgentViewSupervisor: mockCallAgentViewSupervisor, +})); + +describe('worker sideband env', () => { + beforeEach(() => { + mockCallAgentViewSupervisor.mockClear(); + mockCallAgentViewSupervisor.mockResolvedValue({ accepted: true }); + resetAgentViewWorkerStateReportForTests(); + }); + + it('builds the worker-mode environment variables', () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: 'unix:/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + expect(env).toEqual({ + [QWEN_AGENT_VIEW_WORKER]: '1', + [QWEN_AGENT_VIEW_SESSION_ID]: 'session-1', + [QWEN_AGENT_VIEW_SIDEBAND]: 'unix:/tmp/qwen-agent-view.sock', + [QWEN_AGENT_VIEW_TOKEN]: 'token-1', + [QWEN_AGENT_VIEW_ACTIVE_CWD]: '/repo', + }); + expect(AGENT_VIEW_WORKER_ENV_KEYS).toContain(QWEN_AGENT_VIEW_WORKER); + }); + + it('keeps every worker identity key covered by the child-env denylist', () => { + // sanitizeChildEnv strips INTERNAL_SECRET_ENV_VARS from agent-run + // children; a worker key missing from that list would let a child + // inherit the worker identity and impersonate the sideband. + for (const key of AGENT_VIEW_WORKER_ENV_KEYS) { + expect(INTERNAL_SECRET_ENV_VARS).toContain(key); + } + // The supervisor startup-gate marker must be stripped too, or an + // agent-run child could re-enter supervisor mode. + expect(INTERNAL_SECRET_ENV_VARS).toContain( + INTERNAL_AGENT_VIEW_SUPERVISOR_ENV, + ); + }); + + it('detects worker mode only when explicitly enabled', () => { + expect(isAgentViewWorkerEnv({ [QWEN_AGENT_VIEW_WORKER]: '1' })).toBe(true); + expect(isAgentViewWorkerEnv({ [QWEN_AGENT_VIEW_WORKER]: 'true' })).toBe( + false, + ); + expect(isAgentViewWorkerEnv({})).toBe(false); + }); + + it('reads a complete sideband environment', () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: 'pipe:qwen', + token: 'token-1', + activeCwd: '/repo', + }); + + expect(readAgentViewWorkerSidebandEnv(env)).toEqual({ + sessionId: 'session-1', + sidebandEndpoint: 'pipe:qwen', + token: 'token-1', + activeCwd: '/repo', + }); + }); + + it('returns undefined outside worker mode or when required fields are absent', () => { + expect(readAgentViewWorkerSidebandEnv({})).toBeUndefined(); + for (const missingKey of [ + QWEN_AGENT_VIEW_WORKER, + QWEN_AGENT_VIEW_SESSION_ID, + QWEN_AGENT_VIEW_SIDEBAND, + QWEN_AGENT_VIEW_TOKEN, + QWEN_AGENT_VIEW_ACTIVE_CWD, + ] as const) { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: 'pipe:qwen', + token: 'token-1', + activeCwd: '/repo', + }); + delete env[missingKey]; + expect(readAgentViewWorkerSidebandEnv(env)).toBeUndefined(); + } + }); + + it('sends worker events through the configured sideband endpoint', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await expect( + sendAgentViewWorkerEvent( + { + type: 'ready', + cwd: '/repo', + capabilities: ['ready'], + }, + env, + ), + ).resolves.toEqual({ accepted: true }); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + { + type: 'ready', + cwd: '/repo', + capabilities: ['ready'], + at: expect.any(String), + sessionId: 'session-1', + token: 'token-1', + }, + ); + }); + + it('sends detach requests through the configured sideband endpoint', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await sendAgentViewWorkerEvent({ type: 'detach' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + { + type: 'detach', + at: expect.any(String), + sessionId: 'session-1', + token: 'token-1', + }, + ); + }); + + it('reads worker control events through the configured sideband endpoint', async () => { + mockCallAgentViewSupervisor.mockResolvedValueOnce({ + events: [ + { + type: 'redraw', + sequence: 1, + at: '2026-07-17T00:00:00.000Z', + }, + { + type: 'prompt', + sequence: 2, + promptId: 'prompt-1', + text: 'next step', + at: '2026-07-17T00:00:01.000Z', + }, + { + type: 'answer', + sequence: 3, + text: 'yes', + outcome: 'proceed_once', + payload: { answers: { 0: 'yes' } }, + at: '2026-07-17T00:00:02.000Z', + }, + { + type: 'prompt', + sequence: 4, + at: '2026-07-17T00:00:03.000Z', + }, + ], + }); + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await expect(readAgentViewWorkerControlEvents(env)).resolves.toEqual([ + { + type: 'redraw', + sequence: 1, + at: '2026-07-17T00:00:00.000Z', + }, + { + type: 'prompt', + sequence: 2, + promptId: 'prompt-1', + text: 'next step', + at: '2026-07-17T00:00:01.000Z', + }, + { + type: 'answer', + sequence: 3, + text: 'yes', + outcome: 'proceed_once', + payload: { answers: { 0: 'yes' } }, + at: '2026-07-17T00:00:02.000Z', + }, + ]); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerControl', + { + sessionId: 'session-1', + token: 'token-1', + }, + { timeoutMs: 1000 }, + ); + + mockCallAgentViewSupervisor.mockResolvedValueOnce({ + events: [ + { + type: 'prompt', + sequence: 3, + promptId: 'prompt-1', + text: 'next step', + at: '2026-07-17T00:00:01.000Z', + }, + ], + }); + await expect(readAgentViewWorkerControlEvents(env)).resolves.toEqual([]); + expect(mockCallAgentViewSupervisor).toHaveBeenLastCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerControl', + { + sessionId: 'session-1', + token: 'token-1', + }, + { timeoutMs: 1000 }, + ); + }); + + it('ignores malformed worker control responses', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + for (const response of [null, {}, { events: 'invalid' }]) { + mockCallAgentViewSupervisor.mockResolvedValueOnce(response); + await expect(readAgentViewWorkerControlEvents(env)).resolves.toEqual([]); + } + }); + + it('acknowledges a prompt when the UI is ready to submit it', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + mockCallAgentViewSupervisor.mockResolvedValueOnce({ + events: [ + { + type: 'prompt', + sequence: 1, + promptId: 'prompt-1', + text: 'next step', + at: '2026-07-17T00:00:01.000Z', + }, + ], + }); + await readAgentViewWorkerControlEvents(env); + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + await reportAgentViewWorkerState({ sessionState: 'idle' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenNthCalledWith( + 2, + '/tmp/qwen-agent-view.sock', + 'workerEvent', + expect.not.objectContaining({ promptId: expect.any(String) }), + ); + expect(mockCallAgentViewSupervisor).toHaveBeenNthCalledWith( + 3, + '/tmp/qwen-agent-view.sock', + 'workerEvent', + expect.objectContaining({ + sessionState: 'idle', + promptId: 'prompt-1', + }), + ); + }); + + it('does not deduplicate a prompt acknowledgement against an earlier idle report', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState({ sessionState: 'idle' }, env); + mockCallAgentViewSupervisor.mockResolvedValueOnce({ + events: [ + { + type: 'prompt', + sequence: 1, + promptId: 'prompt-1', + text: 'next step', + at: '2026-07-17T00:00:01.000Z', + }, + ], + }); + await readAgentViewWorkerControlEvents(env); + await reportAgentViewWorkerState({ sessionState: 'idle' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(3); + expect(mockCallAgentViewSupervisor).toHaveBeenLastCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + expect.objectContaining({ + sessionState: 'idle', + promptId: 'prompt-1', + }), + ); + }); + + it('retries a lost correlated state response on the next control poll', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + mockCallAgentViewSupervisor.mockResolvedValueOnce({ + events: [ + { + type: 'prompt', + sequence: 1, + promptId: 'prompt-1', + text: 'next step', + at: '2026-07-17T00:00:01.000Z', + }, + ], + }); + await readAgentViewWorkerControlEvents(env); + mockCallAgentViewSupervisor.mockRejectedValueOnce( + new Error('response lost'), + ); + await reportAgentViewWorkerState({ sessionState: 'idle' }, env); + await reportAgentViewWorkerState({ sessionState: 'idle' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenLastCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + expect.objectContaining({ + sessionState: 'idle', + promptId: 'prompt-1', + }), + ); + }); + + it('reports worker state through the configured sideband endpoint', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState( + { + sessionState: 'needs_input', + cwd: '/repo', + summary: 'Waiting for Bash', + waitingFor: 'Bash', + }, + env, + ); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + { + type: 'state', + sessionState: 'needs_input', + cwd: '/repo', + summary: 'Waiting for Bash', + waitingFor: 'Bash', + at: expect.any(String), + sessionId: 'session-1', + token: 'token-1', + }, + ); + }); + + it('does not resend identical worker state reports', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1); + }); + + it('deduplicates worker state reports per session', async () => { + const firstEnv = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + const secondEnv = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-2', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-2', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState({ sessionState: 'working' }, firstEnv); + await reportAgentViewWorkerState({ sessionState: 'working' }, secondEnv); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2); + }); + + it('sends same-state reports when details change', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState( + { sessionState: 'working', summary: 'Running build' }, + env, + ); + await reportAgentViewWorkerState( + { sessionState: 'working', summary: 'Waiting for approval' }, + env, + ); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2); + }); + + it('defaults worker state report cwd to the sideband active cwd', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + expect.objectContaining({ + cwd: '/repo', + }), + ); + }); + + it('retries identical worker state reports after a send failure', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + mockCallAgentViewSupervisor + .mockRejectedValueOnce(new Error('supervisor unavailable')) + .mockResolvedValueOnce({ accepted: true }); + + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2); + }); + + it('does not deduplicate concurrent state reports before send succeeds', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + let rejectFirst: (error: Error) => void = () => {}; + mockCallAgentViewSupervisor + .mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectFirst = reject; + }), + ) + .mockResolvedValueOnce({ accepted: true }); + + const first = reportAgentViewWorkerState({ sessionState: 'working' }, env); + const second = reportAgentViewWorkerState({ sessionState: 'working' }, env); + rejectFirst(new Error('supervisor unavailable')); + + await Promise.all([first, second]); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2); + }); + + it('serializes concurrent state reports before recording dedupe keys', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + let resolveFirst: (value: unknown) => void = () => {}; + mockCallAgentViewSupervisor + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ accepted: true }) + .mockResolvedValueOnce({ accepted: true }); + + const first = reportAgentViewWorkerState({ sessionState: 'working' }, env); + const second = reportAgentViewWorkerState( + { sessionState: 'needs_input' }, + env, + ); + + await Promise.resolve(); + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1); + + resolveFirst({ accepted: true }); + await Promise.all([first, second]); + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + + expect( + mockCallAgentViewSupervisor.mock.calls.map( + (call) => + ((call as unknown[])[2] as { sessionState?: string } | undefined) + ?.sessionState, + ), + ).toEqual(['working', 'needs_input', 'working']); + }); + + it('skips worker events, control reads, and heartbeats outside worker mode', async () => { + await expect( + sendAgentViewWorkerEvent({ type: 'heartbeat' }, {}), + ).resolves.toBeUndefined(); + await expect(readAgentViewWorkerControlEvents({})).resolves.toEqual([]); + expect(startAgentViewWorkerHeartbeat({})).toBeUndefined(); + + expect(mockCallAgentViewSupervisor).not.toHaveBeenCalled(); + }); + + it('re-sends a state after an intervening failed report', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + mockCallAgentViewSupervisor.mockRejectedValueOnce(new Error('offline')); + await reportAgentViewWorkerState({ sessionState: 'needs_input' }, env); + await reportAgentViewWorkerState({ sessionState: 'working' }, env); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(3); + }); + + it('sends one event for concurrent identical state reports', async () => { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + let release: (value: unknown) => void = () => {}; + mockCallAgentViewSupervisor.mockImplementationOnce( + () => + new Promise((resolve) => { + release = resolve; + }), + ); + + const first = reportAgentViewWorkerState({ sessionState: 'working' }, env); + const second = reportAgentViewWorkerState({ sessionState: 'working' }, env); + release({ accepted: true }); + await Promise.all([first, second]); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1); + }); + + it('skips worker state reports outside worker mode', async () => { + await reportAgentViewWorkerState({ sessionState: 'idle' }, {}); + + expect(mockCallAgentViewSupervisor).not.toHaveBeenCalled(); + }); + + it('sends heartbeat events until disposed', async () => { + vi.useFakeTimers(); + try { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + const heartbeat = startAgentViewWorkerHeartbeat(env, 100); + await vi.advanceTimersByTimeAsync(100); + await vi.advanceTimersByTimeAsync(100); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledWith( + '/tmp/qwen-agent-view.sock', + 'workerEvent', + { + type: 'heartbeat', + at: expect.any(String), + sessionId: 'session-1', + token: 'token-1', + }, + ); + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(2); + + heartbeat?.dispose(); + mockCallAgentViewSupervisor.mockClear(); + await vi.advanceTimersByTimeAsync(100); + expect(mockCallAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it('ignores heartbeat send failures', async () => { + vi.useFakeTimers(); + try { + const env = createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + mockCallAgentViewSupervisor.mockRejectedValueOnce( + new Error('supervisor unavailable'), + ); + + const heartbeat = startAgentViewWorkerHeartbeat(env, 100); + await vi.advanceTimersByTimeAsync(100); + + expect(mockCallAgentViewSupervisor).toHaveBeenCalledTimes(1); + heartbeat?.dispose(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/cli/src/agent-view/worker-sideband.ts b/packages/cli/src/agent-view/worker-sideband.ts new file mode 100644 index 00000000000..73706eb0315 --- /dev/null +++ b/packages/cli/src/agent-view/worker-sideband.ts @@ -0,0 +1,305 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { callAgentViewSupervisor } from './supervisor-client.js'; +import type { + AgentViewInputKind, + AgentViewWorkerControlEvent, + AgentViewSessionState, + AgentViewWorkerEvent, +} from './protocol.js'; + +export const QWEN_AGENT_VIEW_WORKER = 'QWEN_AGENT_VIEW_WORKER'; +export const QWEN_AGENT_VIEW_SESSION_ID = 'QWEN_AGENT_VIEW_SESSION_ID'; +export const QWEN_AGENT_VIEW_SIDEBAND = 'QWEN_AGENT_VIEW_SIDEBAND'; +export const QWEN_AGENT_VIEW_TOKEN = 'QWEN_AGENT_VIEW_TOKEN'; +export const QWEN_AGENT_VIEW_ACTIVE_CWD = 'QWEN_AGENT_VIEW_ACTIVE_CWD'; + +export const AGENT_VIEW_WORKER_ENV_KEYS = [ + QWEN_AGENT_VIEW_WORKER, + QWEN_AGENT_VIEW_SESSION_ID, + QWEN_AGENT_VIEW_SIDEBAND, + QWEN_AGENT_VIEW_TOKEN, + QWEN_AGENT_VIEW_ACTIVE_CWD, +] as const; + +export type AgentViewWorkerEnvKey = (typeof AGENT_VIEW_WORKER_ENV_KEYS)[number]; + +export interface AgentViewWorkerSidebandEnv { + sessionId: string; + sidebandEndpoint: string; + token: string; + activeCwd: string; +} + +type AgentViewWorkerEventWithoutSession = + | Omit, 'sessionId'> + | Omit, 'sessionId'> + | Omit, 'sessionId'> + | Omit, 'sessionId'>; + +export interface AgentViewWorkerStateReport { + sessionState: AgentViewSessionState; + cwd?: string; + summary?: string; + waitingFor?: string; + inputKind?: AgentViewInputKind; + lastResult?: string; +} + +export interface AgentViewWorkerHeartbeat { + dispose(): void; +} + +const lastStateReportKeys = new Map(); +const stateReportChains = new Map>(); +const pendingStateReports = new Map(); +const activePrompts = new Map< + string, + { promptId: string; phase: 'received' | 'ready' | 'accepted' } +>(); + +export function createAgentViewWorkerSidebandEnv( + config: AgentViewWorkerSidebandEnv, +): Record { + return { + [QWEN_AGENT_VIEW_WORKER]: '1', + [QWEN_AGENT_VIEW_SESSION_ID]: config.sessionId, + [QWEN_AGENT_VIEW_SIDEBAND]: config.sidebandEndpoint, + [QWEN_AGENT_VIEW_TOKEN]: config.token, + [QWEN_AGENT_VIEW_ACTIVE_CWD]: config.activeCwd, + }; +} + +export function isAgentViewWorkerEnv( + env: NodeJS.ProcessEnv = process.env, +): boolean { + return env[QWEN_AGENT_VIEW_WORKER] === '1'; +} + +export function readAgentViewWorkerSidebandEnv( + env: NodeJS.ProcessEnv = process.env, +): AgentViewWorkerSidebandEnv | undefined { + if (!isAgentViewWorkerEnv(env)) { + return undefined; + } + + const sessionId = env[QWEN_AGENT_VIEW_SESSION_ID]; + const sidebandEndpoint = env[QWEN_AGENT_VIEW_SIDEBAND]; + const token = env[QWEN_AGENT_VIEW_TOKEN]; + const activeCwd = env[QWEN_AGENT_VIEW_ACTIVE_CWD]; + + if (!sessionId || !sidebandEndpoint || !token || !activeCwd) { + return undefined; + } + + return { + sessionId, + sidebandEndpoint, + token, + activeCwd, + }; +} + +export async function sendAgentViewWorkerEvent( + event: AgentViewWorkerEventWithoutSession, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const sideband = readAgentViewWorkerSidebandEnv(env); + if (!sideband) return undefined; + return callAgentViewSupervisor(sideband.sidebandEndpoint, 'workerEvent', { + ...event, + // Stamp at emit time so the dequeue ordering guard (event.at vs + // lastQueuedPromptAt) protects in production — worker events are + // otherwise built without an `at` field. + at: new Date().toISOString(), + sessionId: sideband.sessionId, + token: sideband.token, + }); +} + +export async function readAgentViewWorkerControlEvents( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const sideband = readAgentViewWorkerSidebandEnv(env); + if (!sideband) return []; + const pendingReport = pendingStateReports.get(sideband.sessionId); + if (pendingReport) { + await reportAgentViewWorkerState(pendingReport, env); + } + + const result = await callAgentViewSupervisor( + sideband.sidebandEndpoint, + 'workerControl', + { + sessionId: sideband.sessionId, + token: sideband.token, + }, + { timeoutMs: 1000 }, + ); + + if (!isRecord(result)) return []; + const events = result['events']; + if (!Array.isArray(events)) return []; + return events.filter(isAgentViewWorkerControlEvent).filter((event) => { + if (event.type !== 'prompt') return true; + const active = activePrompts.get(sideband.sessionId); + if (active?.promptId === event.promptId) return false; + activePrompts.set(sideband.sessionId, { + promptId: event.promptId, + phase: 'received', + }); + return true; + }); +} + +export async function reportAgentViewWorkerState( + report: AgentViewWorkerStateReport, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const sideband = readAgentViewWorkerSidebandEnv(env); + if (!sideband) return; + const activePrompt = activePrompts.get(sideband.sessionId); + if ( + activePrompt && + activePrompt.phase === 'received' && + report.sessionState === 'idle' + ) { + activePrompt.phase = 'ready'; + } + const promptId = + activePrompt && + (activePrompt.phase === 'accepted' || activePrompt.phase === 'ready') + ? activePrompt.promptId + : undefined; + if (promptId && activePrompt?.phase === 'ready') { + activePrompt.phase = 'accepted'; + } + if (promptId) { + pendingStateReports.set(sideband.sessionId, report); + } + + const event = { + type: 'state', + ...report, + ...(promptId ? { promptId } : {}), + // activeCwd is guaranteed by readAgentViewWorkerSidebandEnv and survives + // a deleted cwd, unlike process.cwd(), which throws ENOENT. + cwd: report.cwd ?? sideband.activeCwd, + } as const; + const key = JSON.stringify(event); + const sendAndRecord = async () => { + if (key === lastStateReportKeys.get(sideband.sessionId)) { + if (pendingStateReports.get(sideband.sessionId) === report) { + pendingStateReports.delete(sideband.sessionId); + } + return; + } + + try { + await sendAgentViewWorkerEvent(event, env); + lastStateReportKeys.set(sideband.sessionId, key); + if (pendingStateReports.get(sideband.sessionId) === report) { + pendingStateReports.delete(sideband.sessionId); + } + const currentPrompt = activePrompts.get(sideband.sessionId); + if ( + event.promptId && + currentPrompt?.phase === 'accepted' && + (event.sessionState === 'idle' || + event.sessionState === 'completed' || + event.sessionState === 'stopped' || + event.sessionState === 'failed') + ) { + activePrompts.delete(sideband.sessionId); + } + } catch { + lastStateReportKeys.delete(sideband.sessionId); + } + }; + const previous = stateReportChains.get(sideband.sessionId); + const run = previous + ? previous.catch(() => {}).then(sendAndRecord) + : sendAndRecord(); + stateReportChains.set(sideband.sessionId, run); + try { + await run; + } finally { + if (stateReportChains.get(sideband.sessionId) === run) { + stateReportChains.delete(sideband.sessionId); + } + } +} + +export function startAgentViewWorkerHeartbeat( + env: NodeJS.ProcessEnv = process.env, + intervalMs = 15_000, +): AgentViewWorkerHeartbeat | undefined { + if (!readAgentViewWorkerSidebandEnv(env)) return undefined; + const interval = setInterval(() => { + void sendAgentViewWorkerEvent({ type: 'heartbeat' }, env).catch(() => {}); + }, intervalMs); + interval.unref?.(); + return { + dispose() { + clearInterval(interval); + }, + }; +} + +export function resetAgentViewWorkerStateReportForTests(): void { + lastStateReportKeys.clear(); + stateReportChains.clear(); + pendingStateReports.clear(); + activePrompts.clear(); +} + +function isAgentViewWorkerControlEvent( + value: unknown, +): value is AgentViewWorkerControlEvent { + if ( + !isRecord(value) || + !Number.isInteger(value['sequence']) || + typeof value['at'] !== 'string' + ) { + return false; + } + if (value['type'] === 'redraw') { + return true; + } + if (value['type'] === 'stop') { + return true; + } + if (value['type'] === 'prompt') { + return ( + typeof value['promptId'] === 'string' && typeof value['text'] === 'string' + ); + } + return ( + value['type'] === 'answer' && + (value['text'] === undefined || typeof value['text'] === 'string') && + (value['callId'] === undefined || typeof value['callId'] === 'string') && + (value['outcome'] === undefined || + isAgentViewWorkerAnswerOutcome(value['outcome'])) && + (value['payload'] === undefined || isRecord(value['payload'])) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isAgentViewWorkerAnswerOutcome(value: unknown): boolean { + return ( + value === 'proceed_once' || + value === 'proceed_always' || + value === 'proceed_always_project' || + value === 'proceed_always_user' || + value === 'modify_with_editor' || + value === 'restore_previous' || + value === 'cancel' + ); +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 44bf2b2ba3b..afeda4fb4f8 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -1060,6 +1060,7 @@ describe('bootstrap import boundaries', () => { it('keeps bootstrap top-level help commands aligned with config registrations', () => { const configSource = readFileSync('src/config/config.ts', 'utf8'); const commandNameByIdentifier = new Map([ + ['agentsCommand', 'agents'], ['authCommand', 'auth'], ['channelCommand', 'channel'], ['extensionsCommand', 'extensions'], diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 7e2929bce21..512c181b268 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,6 +30,7 @@ initCpuProfiler(); type BootstrapRoute = 'serve' | 'mcp' | 'help' | 'version' | 'default'; export const TOP_LEVEL_COMMANDS = [ + ['agents ', 'Manage Agent View background agents'], ['auth', 'Configure authentication (removed)'], ['channel ', 'Manage messaging channels (Telegram, Discord, etc.)'], ['extensions ', 'Manage Qwen Code extensions.'], diff --git a/packages/cli/src/commands/agent-daemon.test.ts b/packages/cli/src/commands/agent-daemon.test.ts new file mode 100644 index 00000000000..d2bd0313873 --- /dev/null +++ b/packages/cli/src/commands/agent-daemon.test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import yargs from 'yargs'; +import { agentDaemonCommand } from './agent-daemon.js'; + +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockSupervisor = vi.hoisted(() => ({ + status: vi.fn(async () => ({ + running: true, + pid: 123, + socketPath: '/tmp/qwen.sock', + })), + list: vi.fn(async () => [ + { + state: { + sessionState: 'working', + processState: 'alive', + }, + }, + { + state: { + sessionState: 'completed', + processState: 'exited', + }, + }, + ]), + shutdown: vi.fn( + async (): Promise> => ({ shuttingDown: true }), + ), +})); +const mockEnsureAgentViewSupervisor = vi.hoisted(() => + vi.fn(async () => mockSupervisor), +); +const mockConnectExistingAgentViewSupervisor = vi.hoisted(() => + vi.fn(async (): Promise => mockSupervisor), +); + +vi.mock('../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, +})); + +vi.mock('../agent-view/supervisor-runner.js', () => ({ + ensureAgentViewSupervisor: mockEnsureAgentViewSupervisor, + connectExistingAgentViewSupervisor: mockConnectExistingAgentViewSupervisor, +})); + +describe('agent daemon command', () => { + it('registers the daemon subcommands', () => { + const mockYargs = { + command: vi.fn().mockReturnThis(), + demandCommand: vi.fn().mockReturnThis(), + version: vi.fn().mockReturnThis(), + }; + const builder = agentDaemonCommand.builder; + if (typeof builder !== 'function') { + throw new Error('daemon command builder must be a function'); + } + + builder(mockYargs as never); + + expect(agentDaemonCommand.command).toBe('daemon'); + expect(mockYargs.command).toHaveBeenCalledTimes(2); + expect(mockYargs.command.mock.calls.map((call) => call[0].command)).toEqual( + ['status', 'stop'], + ); + expect(mockYargs.demandCommand).toHaveBeenCalledWith( + 1, + 'You need at least one command before continuing.', + ); + }); + + it('prints daemon status', async () => { + mockWriteStdoutLine.mockClear(); + mockSupervisor.status.mockClear(); + mockSupervisor.list.mockClear(); + + await yargs('daemon status'.split(' ')) + .scriptName('qwen') + .command(agentDaemonCommand) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); + + expect(mockSupervisor.status).toHaveBeenCalledOnce(); + expect(mockSupervisor.list).toHaveBeenCalledOnce(); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(JSON.parse(String(mockWriteStdoutLine.mock.calls[0]?.[0]))).toEqual({ + status: { running: true, pid: 123, socketPath: '/tmp/qwen.sock' }, + sessions: { total: 2, active: 1 }, + }); + }); + + it('prints offline daemon status without starting a supervisor', async () => { + mockWriteStdoutLine.mockClear(); + mockConnectExistingAgentViewSupervisor.mockResolvedValueOnce(undefined); + + await yargs('daemon status'.split(' ')) + .scriptName('qwen') + .command(agentDaemonCommand) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(JSON.parse(String(mockWriteStdoutLine.mock.calls[0]?.[0]))).toEqual({ + status: { running: false }, + sessions: { total: 0, active: 0 }, + }); + }); + + it('requires --any for daemon stop', async () => { + await expect(async () => { + await yargs('daemon stop'.split(' ')) + .scriptName('qwen') + .command(agentDaemonCommand) + .exitProcess(false) + .fail((message, error) => { + if (error instanceof Error) throw error; + throw new Error(message ?? String(error)); + }) + .parseAsync(); + }).rejects.toThrow('qwen agents daemon stop requires --any.'); + }); + + it('accepts daemon stop --any --keep-workers', async () => { + mockWriteStdoutLine.mockClear(); + mockSupervisor.shutdown.mockClear(); + + await yargs('daemon stop --any --keep-workers'.split(' ')) + .scriptName('qwen') + .command(agentDaemonCommand) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); + + expect(mockSupervisor.shutdown).toHaveBeenCalledWith(true); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(JSON.parse(String(mockWriteStdoutLine.mock.calls[0]?.[0]))).toEqual({ + shuttingDown: true, + }); + }); + + it('does not start a supervisor for daemon stop when none is running', async () => { + mockWriteStdoutLine.mockClear(); + mockConnectExistingAgentViewSupervisor.mockResolvedValueOnce(undefined); + + await yargs('daemon stop --any'.split(' ')) + .scriptName('qwen') + .command(agentDaemonCommand) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(JSON.parse(String(mockWriteStdoutLine.mock.calls[0]?.[0]))).toEqual({ + shuttingDown: false, + reason: 'not_running', + }); + }); + + it('sets a failing exit code when worker shutdowns fail', async () => { + process.exitCode = undefined; + mockWriteStdoutLine.mockClear(); + mockSupervisor.shutdown.mockResolvedValueOnce({ + shuttingDown: true, + workersStopped: 1, + workersFailed: [{ sessionId: 'session-2', error: 'shutdown failed' }], + }); + + await yargs('daemon stop --any'.split(' ')) + .scriptName('qwen') + .command(agentDaemonCommand) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); + + expect(process.exitCode).toBe(1); + process.exitCode = undefined; + }); +}); diff --git a/packages/cli/src/commands/agent-daemon.ts b/packages/cli/src/commands/agent-daemon.ts new file mode 100644 index 00000000000..c2db0e2a473 --- /dev/null +++ b/packages/cli/src/commands/agent-daemon.ts @@ -0,0 +1,121 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Argv, CommandModule } from 'yargs'; +import { connectExistingAgentViewSupervisor } from '../agent-view/supervisor-runner.js'; +import { writeStdoutLine } from '../utils/stdioHelpers.js'; + +interface DaemonStopArgs { + any?: boolean; + 'keep-workers'?: boolean; +} + +const daemonStatusCommand: CommandModule = { + command: 'status', + describe: 'Show Agent View daemon status', + handler: async () => { + const supervisor = await connectExistingAgentViewSupervisor(); + if (!supervisor) { + writeStdoutLine( + JSON.stringify( + { + status: { running: false }, + sessions: { total: 0, active: 0 }, + }, + null, + 2, + ), + ); + return; + } + const [status, sessions] = await Promise.all([ + supervisor.status(), + supervisor.list(), + ]); + const sessionList = Array.isArray(sessions) ? sessions : []; + writeStdoutLine( + JSON.stringify( + { + status, + sessions: { + total: sessionList.length, + active: sessionList.filter(isActiveSessionSnapshot).length, + }, + }, + null, + 2, + ), + ); + }, +}; + +const daemonStopCommand: CommandModule = { + command: 'stop', + describe: 'Stop Agent View daemons', + builder: (yargs: Argv) => + yargs + .option('any', { + type: 'boolean', + description: 'Allow stopping a daemon from any workspace', + }) + .option('keep-workers', { + type: 'boolean', + default: false, + description: 'Leave worker processes running when stopping the daemon', + }) + .check((argv) => + argv.any === true ? true : 'qwen agents daemon stop requires --any.', + ), + handler: async (argv) => { + const supervisor = await connectExistingAgentViewSupervisor(); + if (!supervisor) { + writeStdoutLine( + JSON.stringify({ shuttingDown: false, reason: 'not_running' }, null, 2), + ); + return; + } + const result = await supervisor.shutdown(argv['keep-workers']); + writeStdoutLine(JSON.stringify(result, null, 2)); + if (hasWorkerShutdownFailures(result)) { + process.exitCode = 1; + } + }, +}; + +export const agentDaemonCommand: CommandModule = { + command: 'daemon', + describe: 'Manage Agent View daemon', + builder: (yargs: Argv) => + yargs + .command(daemonStatusCommand) + .command(daemonStopCommand) + .demandCommand(1, 'You need at least one command before continuing.') + .version(false), + handler: () => {}, +}; + +function isActiveSessionSnapshot(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false; + const state = 'state' in value ? value.state : value; + if (typeof state !== 'object' || state === null) return false; + if (!('sessionState' in state) || !('processState' in state)) return false; + return ( + state.sessionState !== 'completed' && + state.sessionState !== 'stopped' && + state.sessionState !== 'failed' && + state.processState !== 'exited' + ); +} + +function hasWorkerShutdownFailures(value: unknown): boolean { + return ( + typeof value === 'object' && + value !== null && + 'workersFailed' in value && + Array.isArray(value.workersFailed) && + value.workersFailed.length > 0 + ); +} diff --git a/packages/cli/src/commands/agent-session.test.ts b/packages/cli/src/commands/agent-session.test.ts new file mode 100644 index 00000000000..24007506f96 --- /dev/null +++ b/packages/cli/src/commands/agent-session.test.ts @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import yargs from 'yargs'; +import { AgentViewSupervisorClientError } from '../agent-view/supervisor-client.js'; +import { + attachCommand, + killCommand, + logsCommand, + respawnCommand, + rmCommand, + stopCommand, +} from './agent-session.js'; + +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLineSafe = vi.hoisted(() => vi.fn()); +const mockSupervisor = vi.hoisted(() => ({ + attach: vi.fn(async (id: string) => ({ command: 'attach', id })), + logs: vi.fn(async (id: string) => ({ + command: 'logs', + id, + output: 'hello from worker\n', + })), + stop: vi.fn(async (id: string) => ({ command: 'stop', id })), + kill: vi.fn(async (id: string) => ({ command: 'kill', id })), + respawn: vi.fn( + async (target?: string): Promise> => ({ + command: 'respawn', + target: target ?? 'all', + }), + ), + remove: vi.fn(async (id: string) => ({ command: 'rm', id })), +})); +const mockEnsureAgentViewSupervisor = vi.hoisted(() => + vi.fn(async () => mockSupervisor), +); +const mockRequireAgentViewEnabled = vi.hoisted(() => vi.fn()); + +vi.mock('../utils/stdioHelpers.js', () => ({ + writeStderrLineSafe: mockWriteStderrLineSafe, + writeStdoutLine: mockWriteStdoutLine, +})); + +vi.mock('../agent-view/supervisor-runner.js', () => ({ + ensureAgentViewSupervisor: mockEnsureAgentViewSupervisor, +})); + +vi.mock('../agent-view/feature.js', () => ({ + requireAgentViewEnabled: mockRequireAgentViewEnabled, +})); + +const jsonSessionCommands = [ + { module: stopCommand, command: 'stop ', method: mockSupervisor.stop }, + { module: killCommand, command: 'kill ', method: mockSupervisor.kill }, + { module: rmCommand, command: 'rm ', method: mockSupervisor.remove }, +] as const; + +async function parseCommand(commandLine: string): Promise { + await yargs(commandLine.split(' ')) + .scriptName('qwen') + .command(attachCommand) + .command(logsCommand) + .command(stopCommand) + .command(killCommand) + .command(respawnCommand) + .command(rmCommand) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseAsync(); +} + +function firstJsonOutput(): unknown { + return JSON.parse(String(mockWriteStdoutLine.mock.calls[0]?.[0])); +} + +describe('agent session commands', () => { + beforeEach(() => { + process.exitCode = undefined; + vi.clearAllMocks(); + }); + + it('exports the Agent View session command modules', () => { + expect(attachCommand.command).toBe('attach '); + expect(logsCommand.command).toBe('logs '); + expect(jsonSessionCommands.map((entry) => entry.module.command)).toEqual([ + 'stop ', + 'kill ', + 'rm ', + ]); + expect(respawnCommand.command).toBe('respawn [id]'); + expect(typeof respawnCommand.builder).toBe('function'); + expect(typeof respawnCommand.handler).toBe('function'); + }); + + it('routes attach to the supervisor without printing JSON', async () => { + await parseCommand('attach session-1'); + + expect(mockEnsureAgentViewSupervisor).toHaveBeenCalledOnce(); + expect(mockSupervisor.attach).toHaveBeenCalledWith('session-1'); + expect(mockWriteStdoutLine).not.toHaveBeenCalled(); + expect(process.exitCode).toBeUndefined(); + }); + + it('prints attach failures without throwing a stack trace', async () => { + mockSupervisor.attach.mockRejectedValueOnce(new Error('not found')); + + await parseCommand('attach missing-session'); + + expect(mockWriteStderrLineSafe).toHaveBeenCalledWith('not found'); + expect(process.exitCode).toBe(1); + }); + + it('routes logs to the supervisor and prints raw output', async () => { + await parseCommand('logs session-1'); + + expect(mockEnsureAgentViewSupervisor).toHaveBeenCalledOnce(); + expect(mockSupervisor.logs).toHaveBeenCalledWith('session-1'); + expect(mockWriteStdoutLine).toHaveBeenCalledWith('hello from worker\n'); + }); + + it.each(['logs', 'stop', 'kill', 'rm'])( + 'does not start the supervisor when %s is feature-gated', + async (command) => { + mockRequireAgentViewEnabled.mockImplementationOnce(() => { + throw new Error('Agent View is disabled.'); + }); + + await expect(parseCommand(`${command} session-1`)).rejects.toThrow( + 'Agent View is disabled.', + ); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + }, + ); + + it.each(jsonSessionCommands)( + 'routes $command to the supervisor and prints JSON', + async ({ command, method }) => { + const name = command.split(' ')[0]; + + await parseCommand(`${name} session-1`); + + expect(mockEnsureAgentViewSupervisor).toHaveBeenCalledOnce(); + expect(method).toHaveBeenCalledWith('session-1'); + expect(firstJsonOutput()).toEqual({ + command: name, + id: 'session-1', + }); + }, + ); + + it('routes respawn to the supervisor and prints JSON', async () => { + await parseCommand('respawn session-1'); + + expect(mockSupervisor.respawn).toHaveBeenCalledWith('session-1'); + expect(firstJsonOutput()).toEqual({ + command: 'respawn', + target: 'session-1', + }); + }); + + it('rejects respawn with both and --all', async () => { + await expect(parseCommand('respawn --all session-1')).rejects.toThrow( + 'qwen agents respawn accepts or --all, not both.', + ); + + expect(mockSupervisor.respawn).not.toHaveBeenCalled(); + }); + + it('rejects respawn with neither nor --all', async () => { + await expect(parseCommand('respawn')).rejects.toThrow( + 'qwen agents respawn requires or --all.', + ); + + // The rejection must surface at the yargs check layer, before the + // handler ensures (and may start) the daemon supervisor. + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(mockSupervisor.respawn).not.toHaveBeenCalled(); + }); + + it('rejects respawn with an empty ', async () => { + await expect(parseCommand('respawn ')).rejects.toThrow(); + + expect(mockSupervisor.respawn).not.toHaveBeenCalled(); + }); + + it('keeps all-digit session ids as strings', async () => { + await parseCommand('stop 12345678'); + + expect(mockSupervisor.stop).toHaveBeenCalledWith('12345678'); + + mockSupervisor.respawn.mockClear(); + await parseCommand('respawn 87654321'); + + expect(mockSupervisor.respawn).toHaveBeenCalledWith('87654321'); + + mockSupervisor.remove.mockClear(); + await parseCommand('rm 12345678'); + + expect(mockSupervisor.remove).toHaveBeenCalledWith('12345678'); + + mockSupervisor.attach.mockClear(); + await parseCommand('attach 12345678'); + + expect(mockSupervisor.attach).toHaveBeenCalledWith('12345678'); + }); + + it('routes respawn --all to the supervisor and prints JSON', async () => { + await parseCommand('respawn --all'); + + expect(mockSupervisor.respawn).toHaveBeenCalledWith(); + expect(firstJsonOutput()).toEqual({ + command: 'respawn', + target: 'all', + }); + }); + + it('treats a respawn --all timeout as still in flight', async () => { + mockSupervisor.respawn.mockRejectedValueOnce( + new AgentViewSupervisorClientError( + 'Timed out waiting for Agent View supervisor response.', + 'timeout', + ), + ); + + await parseCommand('respawn --all'); + + expect(mockWriteStderrLineSafe).toHaveBeenCalledWith( + 'Respawn is still running in the supervisor. Check `qwen agents` for session status.', + ); + expect(process.exitCode).toBeUndefined(); + }); + + it('rethrows non-timeout respawn --all failures', async () => { + mockSupervisor.respawn.mockRejectedValueOnce( + new AgentViewSupervisorClientError('daemon gone', 'unavailable'), + ); + + await expect(parseCommand('respawn --all')).rejects.toThrow('daemon gone'); + }); + + it('fails respawn --all when every session was skipped', async () => { + mockSupervisor.respawn.mockResolvedValueOnce({ + all: true, + results: [ + { id: 'session-1', skipped: true, reason: 'state is not exited' }, + { id: 'session-2', skipped: true, reason: 'state is not exited' }, + ], + }); + + await parseCommand('respawn --all'); + + expect(process.exitCode).toBe(1); + }); + + it('succeeds respawn --all when at least one session respawned', async () => { + mockSupervisor.respawn.mockResolvedValueOnce({ + all: true, + results: [ + { id: 'session-1', skipped: true, reason: 'state is not exited' }, + { id: 'session-2', respawned: true }, + ], + }); + + await parseCommand('respawn --all'); + + expect(process.exitCode).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/commands/agent-session.ts b/packages/cli/src/commands/agent-session.ts new file mode 100644 index 00000000000..c5e247026b9 --- /dev/null +++ b/packages/cli/src/commands/agent-session.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Argv, CommandModule } from 'yargs'; +import { AgentViewSupervisorClientError } from '../agent-view/supervisor-client.js'; +import { ensureAgentViewSupervisor } from '../agent-view/supervisor-runner.js'; +import { requireAgentViewEnabled } from '../agent-view/feature.js'; +import { writeStderrLineSafe, writeStdoutLine } from '../utils/stdioHelpers.js'; + +interface SessionArgs { + id: string; +} + +interface RespawnArgs { + id?: string; + all?: boolean; +} + +interface AgentSessionSupervisor { + attach(id: string): Promise; + logs(id: string): Promise; + stop(id: string): Promise; + kill(id: string): Promise; + respawn(id?: string): Promise; + remove(id: string): Promise; +} + +async function getSessionSupervisor(): Promise { + const supervisor = await ensureAgentViewSupervisor(); + return supervisor as unknown as AgentSessionSupervisor; +} + +function writeJsonResult(result: unknown): void { + writeStdoutLine(JSON.stringify(result, null, 2)); +} + +// True when `respawn --all` produced results but respawned nothing, so +// `respawn --all || alert` fires instead of silently succeeding. +function isAllRespawnSkipped(result: unknown): boolean { + if (typeof result !== 'object' || result === null) return false; + const { results } = result as { results?: unknown }; + if (!Array.isArray(results) || results.length === 0) return false; + return results.every( + (entry) => + typeof entry === 'object' && + entry !== null && + (entry as { skipped?: unknown }).skipped === true, + ); +} + +function sessionCommand( + command: string, + describe: string, + method: keyof Omit, + formatResult: (result: unknown) => string = (result) => + JSON.stringify(result, null, 2), +): CommandModule { + return { + command, + describe, + // demandOption keeps the positional typed as `string` (without it yargs + // infers `string | undefined`, which breaks CommandBuilder typing) and + // matches the `` command spelling. + builder: (yargs: Argv) => + yargs.positional('id', { type: 'string', demandOption: true }), + handler: async (argv) => { + requireAgentViewEnabled(); + const supervisor = await getSessionSupervisor(); + writeStdoutLine(formatResult(await supervisor[method](argv['id']))); + }, + }; +} + +function getLogsOutput(result: unknown): string { + if ( + typeof result === 'object' && + result !== null && + 'output' in result && + typeof result.output === 'string' + ) { + return result.output; + } + return String(result ?? ''); +} + +export const attachCommand: CommandModule = { + command: 'attach ', + describe: 'Attach to an Agent View session', + builder: (yargs: Argv) => + yargs.positional('id', { type: 'string', demandOption: true }), + handler: async (argv) => { + requireAgentViewEnabled(); + try { + // Supervisor errors are reported like RPC failures below. + const supervisor = await getSessionSupervisor(); + await supervisor.attach(argv['id']); + } catch (error) { + writeStderrLineSafe( + error instanceof Error ? error.message : String(error), + ); + process.exitCode = 1; + } + }, +}; + +export const logsCommand = sessionCommand( + 'logs ', + 'Show Agent View session logs', + 'logs', + getLogsOutput, +); + +export const stopCommand = sessionCommand( + 'stop ', + 'Stop an Agent View session', + 'stop', +); + +export const killCommand = sessionCommand( + 'kill ', + 'Kill an Agent View session', + 'kill', +); + +export const respawnCommand: CommandModule = { + command: 'respawn [id]', + describe: 'Respawn Agent View session(s)', + builder: (yargs: Argv) => + yargs + // Session short-ids can be all digits; keep them strings so the + // guards below (and the RPC layer) see them consistently. + .positional('id', { type: 'string' }) + .option('all', { + type: 'boolean', + default: false, + description: 'Respawn all Agent View sessions', + }) + .check((argv) => { + const hasId = typeof argv['id'] === 'string' && argv['id'].length > 0; + if (argv.all === true && hasId) { + return 'qwen agents respawn accepts or --all, not both.'; + } + if (argv.all === true || hasId) return true; + return 'qwen agents respawn requires or --all.'; + }), + handler: async (argv) => { + requireAgentViewEnabled(); + const supervisor = await getSessionSupervisor(); + if (argv.all === true) { + try { + const result = await supervisor.respawn(); + writeJsonResult(result); + if (isAllRespawnSkipped(result)) { + process.exitCode = 1; + } + } catch (error) { + // The server fulfills {all: true} as an unbounded sequential loop; + // a timeout means the respawn is still in flight, not that it failed. + if ( + error instanceof AgentViewSupervisorClientError && + error.code === 'timeout' + ) { + writeStderrLineSafe( + 'Respawn is still running in the supervisor. Check `qwen agents` for session status.', + ); + return; + } + throw error; + } + return; + } + if (typeof argv['id'] !== 'string' || argv['id'].length === 0) { + throw new Error('qwen agents respawn requires or --all.'); + } + writeJsonResult(await supervisor.respawn(argv['id'])); + }, +}; + +export const rmCommand: CommandModule = { + command: 'rm ', + describe: 'Remove an Agent View session', + builder: (yargs: Argv) => + yargs.positional('id', { type: 'string', demandOption: true }), + handler: async (argv) => { + requireAgentViewEnabled(); + const supervisor = await getSessionSupervisor(); + writeJsonResult(await supervisor.remove(argv['id'])); + }, +}; diff --git a/packages/cli/src/commands/agents.test.ts b/packages/cli/src/commands/agents.test.ts new file mode 100644 index 00000000000..1248dfa7ec1 --- /dev/null +++ b/packages/cli/src/commands/agents.test.ts @@ -0,0 +1,1093 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import yargs, { type Argv } from 'yargs'; +import * as path from 'node:path'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { Storage } from '@qwen-code/qwen-code-core'; +import type { AgentViewSessionSnapshot } from '../agent-view/protocol.js'; +import type { LoadedSettings } from '../config/settings.js'; +import { + agentsCommand, + agentsInteractiveSession, + agentsListCommand, + handleAgentViewBackgroundPrompt, + runAgentsInteractiveSession, +} from './agents.js'; + +const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); +const mockLoadSettings = vi.hoisted(() => + vi.fn( + () => + ({ + merged: { + security: { auth: { selectedType: 'openai' } }, + model: { name: 'settings-model' }, + modelProviders: { + idealab: [{ id: 'settings-model' }], + }, + env: {}, + }, + }) as unknown as LoadedSettings, + ), +); +const mockGetCliVersion = vi.hoisted(() => vi.fn(async () => 'test-version')); +const mockShowResumeSessionPickerItem = vi.hoisted(() => + vi.fn( + async () => + undefined as + | { + sessionId: string; + cwd: string; + startTime: string; + mtime: number; + prompt: string; + filePath: string; + } + | undefined, + ), +); +const mockSupervisor = vi.hoisted(() => ({ + list: vi.fn(async () => [ + { + sessionId: 'session-1', + state: { + schemaVersion: 1, + sessionId: 'session-1', + ownership: 'managed', + sessionState: 'working', + processState: 'alive', + attachState: 'detached', + projectCwd: '/tmp/workspace', + originalCwd: '/tmp/workspace', + activeCwd: '/tmp/workspace/.qwen/worktrees/fix-tests', + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + worktree: { + mode: 'shared-unisolated', + warning: 'Non-Git directory; sessions share one cwd.', + }, + }, + activity: { + schemaVersion: 1, + summary: 'write tests', + waitingFor: 'permission', + queuedPromptCount: 2, + lastActivityAt: '2026-07-17T09:00:00.000Z', + capabilities: [], + }, + worker: { + schemaVersion: 1, + protocolVersion: 1, + platform: 'darwin', + recentOutputBytes: 0, + lastHeartbeatAt: '2026-07-17T09:00:00.000Z', + }, + rosterEntry: { + sessionId: 'session-1', + projectCwd: '/tmp/workspace', + activeCwd: '/tmp/workspace', + displayName: 'Write Tests', + pinned: true, + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + }, + }, + { + sessionId: 'session-attached', + state: { + schemaVersion: 1, + sessionId: 'session-attached', + ownership: 'managed', + sessionState: 'idle', + processState: 'alive', + attachState: 'attached', + projectCwd: '/tmp/workspace', + originalCwd: '/tmp/workspace', + activeCwd: '/tmp/other-project', + createdAt: '2026-07-17T08:30:00.000Z', + updatedAt: '2026-07-17T08:30:00.000Z', + worktree: { mode: 'none' }, + }, + }, + { + sessionId: 'session-done', + state: { + schemaVersion: 1, + sessionId: 'session-done', + ownership: 'managed', + sessionState: 'completed', + processState: 'exited', + attachState: 'detached', + projectCwd: '/tmp/workspace', + originalCwd: '/tmp/workspace', + activeCwd: '/tmp/workspace', + createdAt: '2026-07-17T08:00:00.000Z', + updatedAt: '2026-07-17T08:00:00.000Z', + worktree: { mode: 'none' }, + }, + }, + ]), + subscribe: vi.fn(() => ({ dispose: vi.fn() })), + dispatch: vi.fn(async () => ({ sessionId: 'session-2', state: 'created' })), + adopt: vi.fn(async () => ({ sessionId: 'session-resume', adopted: true })), + attach: vi.fn(async () => ({ attached: true })), + peek: vi.fn(async () => ({ + sessionId: 'session-1', + state: { + schemaVersion: 1, + sessionId: 'session-1', + ownership: 'managed', + sessionState: 'needs_input', + processState: 'alive', + attachState: 'detached', + projectCwd: '/tmp/workspace', + originalCwd: '/tmp/workspace', + activeCwd: '/tmp/workspace', + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + worktree: { mode: 'none' }, + }, + activity: { + schemaVersion: 1, + waitingFor: 'permission', + summary: 'write tests', + lastActivityAt: '2026-07-17T09:00:00.000Z', + capabilities: [], + }, + worker: { + schemaVersion: 1, + protocolVersion: 1, + platform: 'darwin', + recentOutputBytes: 0, + workerPid: 123, + }, + live: true, + })), + send: vi.fn(async () => ({ sent: true })), + answer: vi.fn(async () => ({ answered: true })), + pin: vi.fn(async () => ({ pinned: true })), + rename: vi.fn(async () => ({ displayName: 'Build Fix' })), + stop: vi.fn(async () => ({ stopped: true })), + remove: vi.fn(async () => ({ removed: true })), +})); +const mockEnsureAgentViewSupervisor = vi.hoisted(() => + vi.fn(async () => mockSupervisor), +); + +vi.mock('../utils/stdioHelpers.js', () => ({ + writeStdoutLine: mockWriteStdoutLine, + writeStderrLine: mockWriteStderrLine, +})); + +vi.mock('../agent-view/supervisor-runner.js', () => ({ + ensureAgentViewSupervisor: mockEnsureAgentViewSupervisor, +})); + +vi.mock('../config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: mockLoadSettings, + }; +}); + +vi.mock('../utils/version.js', () => ({ + getCliVersion: mockGetCliVersion, +})); + +vi.mock('../ui/components/StandaloneSessionPicker.js', () => ({ + showResumeSessionPickerItem: mockShowResumeSessionPickerItem, +})); + +vi.mock('../agent-view/feature.js', () => ({ + requireAgentViewEnabled: vi.fn(), +})); + +interface AgentsArgs { + cwd?: string; + json?: boolean; + all?: boolean; +} + +function buildParser(): Argv { + const builder = agentsListCommand.builder; + if (typeof builder !== 'function') { + throw new Error('agents list command builder must be a function'); + } + return builder( + yargs([]).exitProcess(false).fail(false).locale('en'), + ) as Argv; +} + +describe('agents command', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.exitCode = undefined; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('has the Phase 1 command definition', () => { + expect(agentsCommand.command).toBe('agents'); + expect(agentsCommand.describe).toBe('Manage Agent View background agents'); + expect(typeof agentsCommand.builder).toBe('function'); + expect(typeof agentsCommand.handler).toBe('function'); + }); + + it.each([ + ['routes bare `agents` to the list handler', ''], + ['routes `agents --json` to the list handler', '--json'], + ])('%s', async (_label, flags) => { + const builder = agentsCommand.builder; + if (typeof builder !== 'function') { + throw new Error('agents command builder must be a function'); + } + const parser = await Promise.resolve( + builder( + yargs([]) + .exitProcess(false) + .fail((message, error) => { + throw error ?? new Error(message); + }) + .locale('en'), + ), + ); + + await parser.parseAsync(`agents ${flags}`.trim()); + + expect(mockSupervisor.list).toHaveBeenCalled(); + expect(mockWriteStdoutLine).toHaveBeenCalled(); + }); + + it('registers --cwd, --json, and --all options', () => { + const options = ( + buildParser() as Argv & { + getOptions(): { key: Record }; + } + ).getOptions(); + + expect(options.key['cwd']).toBe(true); + expect(options.key['json']).toBe(true); + expect(options.key['all']).toBe(true); + }); + + it('rejects --all without --json', () => { + expect(() => buildParser().parseSync('--all')).toThrow( + 'qwen agents --all requires --json.', + ); + }); + + it('prints all managed agents as a JSON array without entering interactive helper', async () => { + const runSpy = vi.spyOn(agentsInteractiveSession, 'run'); + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync( + '--cwd /tmp/workspace --json --all', + ) as Parameters[0], + ); + + const payload = JSON.parse( + String(mockWriteStdoutLine.mock.calls[0]?.[0]), + ) as unknown[]; + expect(payload).toEqual([ + expect.objectContaining({ + sessionId: 'session-1', + name: 'Write Tests', + state: 'working', + processState: 'alive', + projectCwd: '/tmp/workspace', + activeCwd: '/tmp/workspace/.qwen/worktrees/fix-tests', + attached: false, + pinned: true, + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + summary: 'write tests', + waitingFor: 'permission', + queuedPromptCount: 2, + }), + expect.objectContaining({ + sessionId: 'session-attached', + state: 'idle', + processState: 'alive', + projectCwd: '/tmp/workspace', + activeCwd: '/tmp/other-project', + attached: true, + pinned: false, + }), + expect.objectContaining({ + sessionId: 'session-done', + state: 'completed', + processState: 'exited', + pinned: false, + attached: false, + }), + ]); + expect(mockSupervisor.list).toHaveBeenCalledWith( + path.resolve('/tmp/workspace'), + ); + expect(runSpy).not.toHaveBeenCalled(); + }); + + it('omits completed agents from JSON unless --all is set', async () => { + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync('--json') as Parameters[0], + ); + + const payload = JSON.parse( + String(mockWriteStdoutLine.mock.calls[0]?.[0]), + ) as Array<{ sessionId: string }>; + expect(payload.map((session) => session.sessionId)).toEqual([ + 'session-1', + 'session-attached', + ]); + }); + + it('lists all projects by default for JSON output', async () => { + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync('--json') as Parameters[0], + ); + + expect(mockSupervisor.list).toHaveBeenCalledWith(undefined); + }); + + it('runs the interactive helper when --json is not set', async () => { + const runSpy = vi + .spyOn(agentsInteractiveSession, 'run') + .mockResolvedValue(undefined); + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync('--cwd /tmp/workspace') as Parameters< + typeof handler + >[0], + ); + + expect(runSpy).toHaveBeenCalledOnce(); + expect(runSpy.mock.calls[0]?.[0]).toEqual({ + cwd: path.resolve('/tmp/workspace'), + listCwd: path.resolve('/tmp/workspace'), + supervisor: mockSupervisor, + renderRoster: expect.any(Function), + header: expect.objectContaining({ + version: 'test-version', + cwd: path.resolve('/tmp/workspace'), + model: 'settings-model', + providerLabel: 'Idealab', + }), + }); + expect(mockSupervisor.list).not.toHaveBeenCalled(); + }); + + it('prints a text roster when --json is not set and stdout is not a TTY', async () => { + const snapshots = structuredClone(await mockSupervisor.list()); + mockSupervisor.list.mockClear(); + snapshots[0]!.state.activeCwd = '\u001b]0;spoof\u0007/tmp/work\nspace'; + snapshots[0]!.activity!.summary = 'write\nmore tests'; + mockSupervisor.list.mockResolvedValueOnce(snapshots); + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync('--cwd /tmp/workspace') as Parameters< + typeof handler + >[0], + ); + + const output = String(mockWriteStdoutLine.mock.calls[0]?.[0]); + const lines = output.split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toMatch( + /^session-1 Working alive \/tmp\/work space \S+ write more tests$/, + ); + expect( + lines.some((line) => line.startsWith('session-attached Idle alive ')), + ).toBe(true); + expect( + lines.some((line) => line.startsWith('session-done Completed offline ')), + ).toBe(true); + expect(output).not.toContain('spoof'); + }); + + it('prints a placeholder when the non-TTY roster is empty', async () => { + vi.mocked(mockSupervisor.list).mockResolvedValueOnce([]); + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync('--cwd /tmp/workspace') as Parameters< + typeof handler + >[0], + ); + + expect(mockWriteStdoutLine).toHaveBeenCalledWith('No background agents.'); + }); + + it('reports non-TTY roster load failures on stderr', async () => { + mockSupervisor.list.mockRejectedValueOnce( + new Error('supervisor unavailable'), + ); + const handler = agentsListCommand.handler; + if (!handler) throw new Error('agents list command handler missing'); + + await handler( + buildParser().parseSync('--cwd /tmp/workspace') as Parameters< + typeof handler + >[0], + ); + + expect(mockWriteStderrLine).toHaveBeenCalledWith('supervisor unavailable'); + expect(mockWriteStdoutLine).not.toHaveBeenCalledWith( + 'No background agents.', + ); + expect(process.exitCode).toBe(1); + }); + + it('builds rows for the roster renderer', async () => { + const renderRoster = vi.fn(); + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster, + }); + + expect(mockSupervisor.list).toHaveBeenCalledWith(undefined); + expect(renderRoster).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + sessionId: 'session-1', + displayName: 'Write Tests', + pinned: true, + stateLabel: 'Working', + cwd: '/tmp/workspace/.qwen/worktrees/fix-tests', + summary: 'write tests', + }), + ]), + expect.objectContaining({ + dispatchPrompt: expect.any(Function), + peekSelected: expect.any(Function), + sendToSession: expect.any(Function), + answerSession: expect.any(Function), + pinSession: expect.any(Function), + renameSession: expect.any(Function), + stopSession: expect.any(Function), + removeSession: expect.any(Function), + loadRows: expect.any(Function), + subscribeToChanges: expect.any(Function), + }), + undefined, + undefined, + ); + }); + + it('reads transcript titles from the configured runtime output directory', async () => { + const root = mkdtempSync(path.join(tmpdir(), 'agent-view-title-')); + const cwd = path.join(root, 'workspace'); + const runtimeOutputDir = path.join(root, 'runtime'); + const snapshots = structuredClone( + await mockSupervisor.list(), + ) as AgentViewSessionSnapshot[]; + snapshots.splice(1); + delete snapshots[0]!.rosterEntry!.displayName; + snapshots[0]!.state.projectCwd = cwd; + snapshots[0]!.state.activeCwd = cwd; + snapshots[0]!.rosterEntry!.projectCwd = cwd; + snapshots[0]!.rosterEntry!.activeCwd = cwd; + const chatsDir = path.join( + new Storage(cwd, runtimeOutputDir).getProjectDir(), + 'chats', + ); + mkdirSync(chatsDir, { recursive: true }); + writeFileSync( + path.join(chatsDir, 'session-1.jsonl'), + '{"type":"system","subtype":"custom_title","customTitle":"Runtime title"}\n', + ); + mockSupervisor.list.mockResolvedValueOnce(snapshots as never); + mockLoadSettings.mockReturnValueOnce({ + merged: { + security: { auth: { selectedType: 'openai' } }, + model: { name: 'settings-model' }, + modelProviders: { + idealab: [{ id: 'settings-model' }], + }, + advanced: { runtimeOutputDir }, + env: {}, + }, + } as unknown as LoadedSettings); + + try { + const renderRoster = vi.fn(); + await runAgentsInteractiveSession({ + cwd, + supervisor: mockSupervisor, + renderRoster, + }); + + expect(renderRoster.mock.calls[0]?.[0]).toEqual([ + expect.objectContaining({ displayName: 'Runtime title' }), + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('filters roster rows when listCwd is provided', async () => { + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + listCwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: vi.fn(), + }); + + expect(mockSupervisor.list).toHaveBeenCalledWith('/tmp/workspace'); + }); + + it('dispatches without attaching inside roster actions', async () => { + const calls: string[] = []; + const supervisor = { + list: vi.fn(async () => []), + subscribe: vi.fn(() => ({ dispose: vi.fn() })), + dispatch: vi.fn(async () => { + calls.push('dispatch'); + return { sessionId: 'new-session' }; + }), + adopt: vi.fn(), + attach: vi.fn(async () => { + calls.push('attach'); + }), + peek: vi.fn(), + send: vi.fn(), + answer: vi.fn(), + pin: vi.fn(), + rename: vi.fn(), + stop: vi.fn(), + remove: vi.fn(), + }; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor, + renderRoster: async (_rows, actions) => { + await actions.dispatchPrompt(' write tests ', true); + }, + }); + + expect(supervisor.dispatch).toHaveBeenCalledWith( + 'write tests', + '/tmp/workspace', + ); + expect(supervisor.attach).not.toHaveBeenCalled(); + expect(calls).toEqual(['dispatch']); + }); + + it('attaches after the roster returns an attach intent', async () => { + const calls: string[] = []; + let renderCount = 0; + const supervisor = { + list: vi.fn(async () => []), + subscribe: vi.fn(() => ({ dispose: vi.fn() })), + dispatch: vi.fn(async () => { + calls.push('dispatch'); + return { sessionId: 'new-session' }; + }), + adopt: vi.fn(), + attach: vi.fn(async () => { + calls.push('attach'); + }), + peek: vi.fn(), + send: vi.fn(), + answer: vi.fn(), + pin: vi.fn(), + rename: vi.fn(), + stop: vi.fn(), + remove: vi.fn(), + }; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor, + renderRoster: async (_rows, actions) => { + renderCount += 1; + if (renderCount > 1) { + return { type: 'exit' }; + } + const result = await actions.dispatchPrompt('write tests', true); + expect(result).toEqual({ sessionId: 'new-session' }); + return { type: 'attach', sessionId: 'new-session' }; + }, + }); + + expect(supervisor.attach).toHaveBeenCalledWith('new-session'); + expect(calls).toEqual(['dispatch', 'attach']); + }); + + it('keeps a foreground subscription alive while attaching', async () => { + const calls: string[] = []; + let renderCount = 0; + const dispose = vi.fn(() => { + calls.push('dispose'); + }); + const supervisor = { + list: vi.fn(async () => []), + subscribe: vi.fn(() => { + calls.push('subscribe'); + return { dispose }; + }), + dispatch: vi.fn(), + adopt: vi.fn(), + attach: vi.fn(async () => { + calls.push('attach'); + expect(dispose).not.toHaveBeenCalled(); + }), + peek: vi.fn(), + send: vi.fn(), + answer: vi.fn(), + pin: vi.fn(), + rename: vi.fn(), + stop: vi.fn(), + remove: vi.fn(), + }; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor, + renderRoster: async () => { + renderCount += 1; + return renderCount === 1 + ? { type: 'attach', sessionId: 'session-1' } + : { type: 'exit' }; + }, + }); + + expect(calls).toEqual(['subscribe', 'attach', 'dispose']); + }); + + it('reopens the roster with an error panel when attach fails', async () => { + let renderCount = 0; + const supervisor = { + list: vi.fn(async () => []), + subscribe: vi.fn(() => ({ dispose: vi.fn() })), + dispatch: vi.fn(), + adopt: vi.fn(), + attach: vi.fn(async () => { + throw new Error('stale PTY host'); + }), + peek: vi.fn(), + send: vi.fn(), + answer: vi.fn(), + pin: vi.fn(), + rename: vi.fn(), + stop: vi.fn(), + remove: vi.fn(), + }; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) { + expect(initialPeekPanel).toBeUndefined(); + return { type: 'attach', sessionId: 'session-1' }; + } + expect(initialPeekPanel).toEqual({ + kind: 'session', + sessionId: 'session-1', + content: 'message', + lines: ['stale PTY host'], + tone: 'error', + }); + return { type: 'exit' }; + }, + }); + + expect(supervisor.attach).toHaveBeenCalledWith('session-1'); + expect(renderCount).toBe(2); + }); + + it('renders an error panel when the initial roster load fails', async () => { + const supervisor = { + ...mockSupervisor, + list: vi.fn(async () => { + throw new Error('supervisor unavailable'); + }), + }; + + await expect( + runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor, + renderRoster: async (rows, _actions, initialPeekPanel) => { + expect(rows).toEqual([]); + expect(initialPeekPanel).toEqual({ + kind: 'message', + title: 'Agent View', + lines: ['supervisor unavailable'], + tone: 'error', + }); + return { type: 'exit' }; + }, + }), + ).resolves.toBeUndefined(); + }); + + it('adopts a picked history session when the roster requests resume', async () => { + let renderCount = 0; + const runtimeOutputDir = '/tmp/custom-agent-runtime'; + const supervisor = { + list: vi.fn(async () => []), + subscribe: vi.fn(() => ({ dispose: vi.fn() })), + dispatch: vi.fn(), + adopt: vi.fn(async () => ({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + adopted: true, + })), + attach: vi.fn(), + peek: vi.fn(async () => { + throw new Error( + 'Agent View session 123e4567-e89b-12d3-a456-426614174000 is not managed.', + ); + }), + send: vi.fn(), + answer: vi.fn(), + pin: vi.fn(), + rename: vi.fn(), + stop: vi.fn(), + remove: vi.fn(), + }; + mockLoadSettings.mockReturnValueOnce({ + merged: { + advanced: { runtimeOutputDir }, + }, + } as unknown as LoadedSettings); + mockShowResumeSessionPickerItem.mockImplementationOnce(async () => { + expect(Storage.getRuntimeBaseDir()).toBe(runtimeOutputDir); + return { + sessionId: '123e4567-e89b-12d3-a456-426614174000', + cwd: '/tmp/history-workspace', + startTime: '2026-07-17T08:00:00.000Z', + mtime: Date.parse('2026-07-17T08:00:00.000Z'), + prompt: 'historical prompt', + filePath: '/tmp/history-workspace/.qwen/chats/session.jsonl', + }; + }); + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) { + return { type: 'resume' }; + } + expect(initialPeekPanel).toEqual({ + kind: 'session', + sessionId: '123e4567-e89b-12d3-a456-426614174000', + content: 'message', + lines: ['Session added to Agent View.'], + }); + return { type: 'exit' }; + }, + }); + + expect(mockShowResumeSessionPickerItem).toHaveBeenCalledWith( + '/tmp/workspace', + undefined, + { + includeAgentViewSessions: false, + allowManagedAgentViewSelection: true, + }, + ); + expect(supervisor.dispatch).not.toHaveBeenCalled(); + expect(supervisor.adopt).toHaveBeenCalledWith({ + sessionId: '123e4567-e89b-12d3-a456-426614174000', + projectCwd: path.resolve('/tmp/history-workspace'), + activeCwd: path.resolve('/tmp/history-workspace'), + terminal: { + columns: expect.any(Number), + rows: expect.any(Number), + }, + }); + }); + + it('does not re-adopt a history session that is already managed', async () => { + mockShowResumeSessionPickerItem.mockResolvedValueOnce({ + sessionId: 'managed-session', + cwd: '/tmp/history-workspace', + startTime: '2026-07-17T08:00:00.000Z', + mtime: Date.parse('2026-07-17T08:00:00.000Z'), + prompt: 'historical prompt', + filePath: '/tmp/history-workspace/.qwen/chats/session.jsonl', + }); + let renderCount = 0; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) return { type: 'resume' }; + expect(initialPeekPanel).toEqual({ + kind: 'session', + sessionId: 'managed-session', + content: 'message', + lines: ['Session is already managed by Agent View.'], + }); + return { type: 'exit' }; + }, + }); + + expect(mockSupervisor.adopt).not.toHaveBeenCalled(); + }); + + it('handles an already-managed result returned by adopt', async () => { + mockShowResumeSessionPickerItem.mockResolvedValueOnce({ + sessionId: 'managed-session', + cwd: '/tmp/history-workspace', + startTime: '2026-07-17T08:00:00.000Z', + mtime: Date.parse('2026-07-17T08:00:00.000Z'), + prompt: 'historical prompt', + filePath: '/tmp/history-workspace/.qwen/chats/session.jsonl', + }); + mockSupervisor.peek.mockRejectedValueOnce( + new Error('Agent View session managed-session is not managed.'), + ); + mockSupervisor.adopt.mockResolvedValueOnce({ + sessionId: 'managed-session', + adopted: false, + alreadyManaged: true, + } as never); + let renderCount = 0; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) return { type: 'resume' }; + expect(initialPeekPanel).toEqual({ + kind: 'session', + sessionId: 'managed-session', + content: 'message', + lines: ['Session is already managed by Agent View.'], + }); + return { type: 'exit' }; + }, + }); + + expect(mockSupervisor.adopt).toHaveBeenCalledOnce(); + }); + + it('does not adopt when peek fails for a transient reason', async () => { + mockShowResumeSessionPickerItem.mockResolvedValueOnce({ + sessionId: 'history-session', + cwd: '/tmp/history-workspace', + startTime: '2026-07-17T08:00:00.000Z', + mtime: Date.parse('2026-07-17T08:00:00.000Z'), + prompt: 'historical prompt', + filePath: '/tmp/history-workspace/.qwen/chats/session.jsonl', + }); + mockSupervisor.peek.mockRejectedValueOnce(new Error('daemon unavailable')); + let renderCount = 0; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) return { type: 'resume' }; + expect(initialPeekPanel).toEqual({ + kind: 'message', + title: 'Resume', + tone: 'error', + lines: ['daemon unavailable'], + }); + return { type: 'exit' }; + }, + }); + + expect(mockSupervisor.adopt).not.toHaveBeenCalled(); + }); + + it('shows adoption failures in a persistent error panel', async () => { + mockShowResumeSessionPickerItem.mockResolvedValueOnce({ + sessionId: 'history-session', + cwd: '/tmp/history-workspace', + startTime: '2026-07-17T08:00:00.000Z', + mtime: Date.parse('2026-07-17T08:00:00.000Z'), + prompt: 'historical prompt', + filePath: '/tmp/history-workspace/.qwen/chats/session.jsonl', + }); + mockSupervisor.peek.mockRejectedValueOnce( + new Error('Agent View session history-session is not managed.'), + ); + mockSupervisor.adopt.mockRejectedValueOnce(new Error('adopt failed')); + let renderCount = 0; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) return { type: 'resume' }; + expect(initialPeekPanel).toEqual({ + kind: 'session', + sessionId: 'history-session', + content: 'message', + lines: ['adopt failed'], + tone: 'error', + }); + return { type: 'exit' }; + }, + }); + }); + + it('shows picker failures in a persistent error panel', async () => { + mockShowResumeSessionPickerItem.mockRejectedValueOnce( + new Error('cannot read session history'), + ); + let renderCount = 0; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, _actions, initialPeekPanel) => { + renderCount += 1; + if (renderCount === 1) return { type: 'resume' }; + expect(initialPeekPanel).toEqual({ + kind: 'message', + title: 'Resume', + lines: ['cannot read session history'], + tone: 'error', + }); + return { type: 'exit' }; + }, + }); + + expect(renderCount).toBe(2); + }); + + it('sends and answers selected sessions through the supervisor', async () => { + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, actions) => { + await actions.sendToSession('idle-session', 'next'); + await actions.answerSession('needs-input-session', 'yes'); + }, + }); + + expect(mockSupervisor.send).toHaveBeenCalledWith('idle-session', 'next'); + expect(mockSupervisor.answer).toHaveBeenCalledWith( + 'needs-input-session', + 'yes', + ); + }); + + it('pins and renames selected sessions through the supervisor', async () => { + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, actions) => { + await actions.pinSession('session-1'); + await actions.renameSession('session-1', 'Build Fix'); + }, + }); + + expect(mockSupervisor.pin).toHaveBeenCalledWith('session-1'); + expect(mockSupervisor.rename).toHaveBeenCalledWith( + 'session-1', + 'Build Fix', + ); + }); + + it('stops and removes selected sessions through the supervisor', async () => { + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, actions) => { + await actions.stopSession('session-1'); + await actions.removeSession('session-1'); + }, + }); + + expect(mockSupervisor.stop).toHaveBeenCalledWith('session-1'); + expect(mockSupervisor.remove).toHaveBeenCalledWith('session-1'); + }); + + it('peeks selected session details through the supervisor', async () => { + let panel; + + await runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, actions) => { + panel = await actions.peekSelected('session-1'); + }, + }); + + expect(mockSupervisor.peek).toHaveBeenCalledWith('session-1'); + expect(panel).toEqual({ + kind: 'session', + sessionId: 'session-1', + content: 'activity', + lines: ['Waiting: permission', 'Summary: write tests'], + }); + }); + + it('rejects blank prompts', async () => { + await expect( + runAgentsInteractiveSession({ + cwd: '/tmp/workspace', + supervisor: mockSupervisor, + renderRoster: async (_rows, actions) => { + await actions.dispatchPrompt(' ', false); + }, + }), + ).rejects.toThrow('Prompt cannot be empty.'); + + expect(mockSupervisor.dispatch).not.toHaveBeenCalled(); + expect(mockSupervisor.attach).not.toHaveBeenCalled(); + }); + + it('dispatches a background prompt through the supervisor', async () => { + await handleAgentViewBackgroundPrompt('write tests'); + + expect(mockSupervisor.dispatch).toHaveBeenCalledWith( + 'write tests', + process.cwd(), + ); + expect(mockWriteStdoutLine.mock.calls.map((call) => call[0])).toEqual([ + 'Started background agent session-2.', + 'Open with qwen agents.', + 'Attach with qwen agents attach session-2.', + 'View logs with qwen agents logs session-2.', + ]); + }); + + it('rejects a whitespace-only background prompt before supervisor startup', async () => { + await expect(handleAgentViewBackgroundPrompt(' ')).rejects.toThrow( + 'Cannot use --bg/--background without a prompt.', + ); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/commands/agents.ts b/packages/cli/src/commands/agents.ts new file mode 100644 index 00000000000..380d37656be --- /dev/null +++ b/packages/cli/src/commands/agents.ts @@ -0,0 +1,806 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import type { Argv, CommandModule } from 'yargs'; +import { + FatalError, + readLastJsonStringFieldSync, + Storage, +} from '@qwen-code/qwen-code-core'; +import { loadSettings, type Settings } from '../config/settings.js'; +import type { + AgentViewActivityFile, + AgentViewLaunchFile, + AgentViewRosterEntry, + AgentViewSessionSnapshot, + AgentViewSessionStateFile, + AgentViewWorkerFile, +} from '../agent-view/protocol.js'; +import type { AgentViewSupervisorClientHandle } from '../agent-view/supervisor-runner.js'; +import { ensureAgentViewSupervisor } from '../agent-view/supervisor-runner.js'; +import { requireAgentViewEnabled } from '../agent-view/feature.js'; +import { runAgentViewRosterApp } from '../ui/agent-view/AgentViewApp.js'; +import type { AgentViewRosterResult } from '../ui/agent-view/AgentViewApp.js'; +import type { + AgentViewHeaderInfo, + AgentViewPanel, + AgentViewSessionPanel, +} from '../ui/agent-view/AgentViewRoster.js'; +import { showResumeSessionPickerItem } from '../ui/components/StandaloneSessionPicker.js'; +import type { AgentRosterRow } from '../ui/agent-view/roster-model.js'; +import { buildAgentRosterRows } from '../ui/agent-view/roster-model.js'; +import { getAuthTypeFromEnv } from '../utils/modelConfigUtils.js'; +import { + cleanSingleLineText, + stripUnsafeCharacters, +} from '../ui/utils/textUtils.js'; +import { writeStderrLine, writeStdoutLine } from '../utils/stdioHelpers.js'; +import { getCliVersion } from '../utils/version.js'; +import { + attachCommand, + killCommand, + logsCommand, + respawnCommand, + rmCommand, + stopCommand, +} from './agent-session.js'; +import { agentDaemonCommand } from './agent-daemon.js'; + +interface AgentsArgs { + cwd?: string; + json?: boolean; + all?: boolean; +} + +type AgentsInteractiveSupervisor = Pick< + AgentViewSupervisorClientHandle, + | 'list' + | 'subscribe' + | 'dispatch' + | 'adopt' + | 'attach' + | 'peek' + | 'send' + | 'answer' + | 'pin' + | 'rename' + | 'stop' + | 'remove' +>; + +export interface AgentsInteractiveActions { + dispatchPrompt(prompt: string, attach: boolean): Promise; + peekSelected(sessionId: string): Promise; + sendToSession(sessionId: string, text: string): Promise; + answerSession(sessionId: string, text: string): Promise; + pinSession(sessionId: string): Promise; + renameSession(sessionId: string, displayName: string): Promise; + stopSession(sessionId: string): Promise; + removeSession(sessionId: string): Promise; + loadRows(): Promise; + subscribeToChanges?(onChange: () => void): { dispose(): void }; +} + +export interface RunAgentsInteractiveSessionOptions { + cwd: string; + listCwd?: string; + supervisor: AgentsInteractiveSupervisor; + renderRoster( + rows: AgentRosterRow[], + actions: AgentsInteractiveActions, + initialPeekPanel?: AgentViewPanel, + header?: AgentViewHeaderInfo, + ): Promise | AgentViewRosterResult | void; + header?: AgentViewHeaderInfo; +} + +export async function handleAgentViewBackgroundPrompt( + prompt: string, + settings?: Settings, +): Promise { + requireAgentViewEnabled(settings); + const normalizedPrompt = prompt.trim(); + if (!normalizedPrompt) { + throw new FatalError('Cannot use --bg/--background without a prompt.', 1); + } + const supervisor = await ensureAgentViewSupervisor(); + const result = await supervisor.dispatch(normalizedPrompt, process.cwd()); + const sessionId = getSessionId(result); + const shortId = formatSessionShortId(sessionId); + writeStdoutLine(`Started background agent ${shortId}.`); + writeStdoutLine(`Open with qwen agents.`); + writeStdoutLine(`Attach with qwen agents attach ${shortId}.`); + writeStdoutLine(`View logs with qwen agents logs ${shortId}.`); +} + +export async function runAgentsInteractiveSession({ + cwd, + listCwd, + supervisor, + renderRoster, + header, +}: RunAgentsInteractiveSessionOptions): Promise { + // Cache only discovered transcript titles; a new session may be titled + // after its first roster poll. + const titleCache = new Map(); + const transcriptStorageCache = new Map(); + const loadRows = async () => + toRosterRows( + toSnapshots(await supervisor.list(listCwd)), + titleCache, + transcriptStorageCache, + ); + const actions: AgentsInteractiveActions = { + dispatchPrompt: async (prompt, _attach) => { + const trimmedPrompt = prompt.trim(); + if (!trimmedPrompt) { + throw new Error('Prompt cannot be empty.'); + } + + return supervisor.dispatch(trimmedPrompt, cwd); + }, + peekSelected: async (sessionId) => + formatPeekPanel(await supervisor.peek(sessionId)), + sendToSession: (sessionId, text) => supervisor.send(sessionId, text), + answerSession: (sessionId, text) => supervisor.answer(sessionId, text), + pinSession: (sessionId) => supervisor.pin(sessionId), + renameSession: (sessionId, displayName) => + supervisor.rename(sessionId, displayName), + stopSession: (sessionId) => supervisor.stop(sessionId), + removeSession: (sessionId) => supervisor.remove(sessionId), + loadRows, + subscribeToChanges: (onChange) => + supervisor.subscribe(() => { + titleCache.clear(); + onChange(); + }), + }; + + let initialPeekPanel: AgentViewPanel | undefined; + const foregroundSubscription = supervisor.subscribe(() => {}); + try { + while (true) { + let rows: AgentRosterRow[] = []; + try { + rows = await loadRows(); + } catch (error) { + initialPeekPanel = { + kind: 'message', + title: 'Agent View', + lines: [error instanceof Error ? error.message : String(error)], + tone: 'error', + }; + } + const result = await renderRoster( + rows, + actions, + initialPeekPanel, + header, + ); + initialPeekPanel = undefined; + if (!result || result.type === 'exit') { + return; + } + if (result.type === 'resume') { + resetTerminalForRoster(); + await waitForTerminalHandoff(); + try { + initialPeekPanel = await adoptResumeSessionFromPicker( + cwd, + supervisor, + ); + } catch (error) { + initialPeekPanel = { + kind: 'message', + title: 'Resume', + lines: [error instanceof Error ? error.message : String(error)], + tone: 'error', + }; + } + resetTerminalForRoster(); + await waitForTerminalHandoff(); + continue; + } + if (result.type === 'attach') { + try { + await supervisor.attach(result.sessionId); + } catch (error) { + initialPeekPanel = { + kind: 'session', + sessionId: result.sessionId, + content: 'message', + lines: [error instanceof Error ? error.message : String(error)], + tone: 'error', + }; + } finally { + resetTerminalForRoster(); + } + } + } + } finally { + foregroundSubscription.dispose(); + } +} + +async function adoptResumeSessionFromPicker( + cwd: string, + supervisor: Pick, +): Promise { + const runtimeOutputDir = loadSettings(cwd, { + skipLoadEnvironment: true, + }).merged.advanced?.runtimeOutputDir; + const session = await Storage.runWithRuntimeBaseDir( + runtimeOutputDir, + cwd, + () => + showResumeSessionPickerItem(cwd, undefined, { + includeAgentViewSessions: false, + allowManagedAgentViewSelection: true, + }), + ); + if (!session) { + return undefined; + } + const { sessionId } = session; + const sessionCwd = path.resolve(session.cwd || cwd); + + try { + await supervisor.peek(sessionId); + return { + kind: 'session', + sessionId, + content: 'message', + lines: ['Session is already managed by Agent View.'], + }; + } catch (error) { + if (!isNotManagedPeekError(error)) throw error; + } + + try { + const result = await supervisor.adopt({ + sessionId, + projectCwd: sessionCwd, + activeCwd: sessionCwd, + terminal: { + columns: process.stdout.columns ?? 80, + rows: process.stdout.rows ?? 24, + }, + }); + if (isAlreadyManagedAdoptResult(result)) { + return { + kind: 'session', + sessionId, + content: 'message', + lines: ['Session is already managed by Agent View.'], + }; + } + return { + kind: 'session', + sessionId, + content: 'message', + lines: ['Session added to Agent View.'], + }; + } catch (error) { + return { + kind: 'session', + sessionId, + content: 'message', + lines: [error instanceof Error ? error.message : String(error)], + tone: 'error', + }; + } +} + +function isNotManagedPeekError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + /^No Agent View session found for .+\.$/.test(message) || + /^Agent View session .+ is not managed\.$/.test(message) + ); +} + +function isAlreadyManagedAdoptResult(value: unknown): boolean { + return ( + isRecord(value) && + (value['alreadyManaged'] === true || value['adopted'] === false) + ); +} + +function resetTerminalForRoster(): void { + if (!process.stdout.isTTY) return; + process.stdout.write('\x1b[0m\x1b[?25h\x1b[?1049l\x1b[2J\x1b[H'); +} + +// One macrotask tick is enough: resetTerminalForRoster writes synchronously +// to stdout, and a single setImmediate lets ink flush its final unmount output +// before the next TUI (session picker) takes over the terminal. +async function waitForTerminalHandoff(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +export const agentsInteractiveSession = { + run: runAgentsInteractiveSession, +}; + +async function defaultRenderAgentsRoster( + rows: AgentRosterRow[], + actions: AgentsInteractiveActions, + initialPeekPanel?: AgentViewPanel, + header?: AgentViewHeaderInfo, +): Promise { + if (process.stdin.isTTY && process.stdout.isTTY) { + return runAgentViewRosterApp(rows, actions, header, initialPeekPanel); + } + if ( + initialPeekPanel?.kind === 'message' && + initialPeekPanel.tone === 'error' + ) { + writeStderrLine(initialPeekPanel.lines.join(' ')); + process.exitCode = 1; + return; + } + writeStdoutLine(formatRosterRowsText(rows)); +} + +function toRosterRows( + snapshots: AgentViewSessionSnapshot[], + titleCache: Map, + transcriptStorageCache: Map, +): AgentRosterRow[] { + if (snapshots.length === 0) { + return []; + } + return buildAgentRosterRows({ + sessions: snapshots.map((snapshot) => snapshot.state), + launches: Object.fromEntries( + snapshots.map((snapshot) => [snapshot.sessionId, snapshot.launch]), + ), + activities: Object.fromEntries( + snapshots.map((snapshot) => [snapshot.sessionId, snapshot.activity]), + ), + workers: Object.fromEntries( + snapshots.map((snapshot) => [snapshot.sessionId, snapshot.worker]), + ), + rosterEntries: snapshots + .map((snapshot) => + getRosterEntryWithTitle(snapshot, titleCache, transcriptStorageCache), + ) + .filter((entry): entry is NonNullable => Boolean(entry)), + }); +} + +function getRosterEntryWithTitle( + snapshot: AgentViewSessionSnapshot, + titleCache: Map, + transcriptStorageCache: Map, +): AgentViewRosterEntry | undefined { + if (snapshot.rosterEntry?.displayName) { + return snapshot.rosterEntry; + } + let title = titleCache.get(snapshot.sessionId); + if (title === undefined) { + title = readTranscriptTitle(snapshot, transcriptStorageCache); + if (title) { + titleCache.set(snapshot.sessionId, title); + } + } + if (!title) { + return snapshot.rosterEntry; + } + return { + sessionId: snapshot.sessionId, + projectCwd: snapshot.rosterEntry?.projectCwd ?? snapshot.state.projectCwd, + activeCwd: snapshot.rosterEntry?.activeCwd ?? snapshot.state.activeCwd, + ...(snapshot.rosterEntry?.pinned ? { pinned: true } : {}), + displayName: title, + createdAt: snapshot.rosterEntry?.createdAt ?? snapshot.state.createdAt, + updatedAt: snapshot.rosterEntry?.updatedAt ?? snapshot.state.updatedAt, + }; +} + +function readTranscriptTitle( + snapshot: AgentViewSessionSnapshot, + storageCache: Map, +): string | undefined { + const cwdCandidates = Array.from( + new Set([snapshot.state.activeCwd, snapshot.state.projectCwd]), + ); + for (const cwd of cwdCandidates) { + try { + const filePath = path.join( + getTranscriptStorage(cwd, storageCache).getProjectDir(), + 'chats', + `${snapshot.sessionId}.jsonl`, + ); + const title = readLastJsonStringFieldSync( + filePath, + 'customTitle', + // Strict marker: a loose 'custom_title' substring would also match + // tool/assistant records that merely mention the marker. + '"subtype":"custom_title"', + )?.trim(); + if (title) return title; + } catch { + // Missing transcripts are fine; new sessions may not have a title yet. + } + } + return undefined; +} + +function getTranscriptStorage( + cwd: string, + cache: Map, +): Storage { + const resolvedCwd = path.resolve(cwd); + const cached = cache.get(resolvedCwd); + if (cached) return cached; + const runtimeOutputDir = loadSettings(resolvedCwd, { + skipLoadEnvironment: true, + }).merged.advanced?.runtimeOutputDir; + const storage = Storage.runWithRuntimeBaseDir( + runtimeOutputDir, + resolvedCwd, + () => new Storage(resolvedCwd), + ); + cache.set(resolvedCwd, storage); + return storage; +} + +function formatRosterRowsText(rows: AgentRosterRow[]): string { + if (rows.length === 0) { + return 'No background agents.'; + } + return rows + .map((row) => { + // Non-TTY output has no ink sanitize-ansi protection, so untrusted + // session text must be stripped here. + const cleanSummary = cleanSingleLineText(row.summary ?? ''); + const summary = cleanSummary ? ` ${cleanSummary}` : ''; + return `${row.sessionId} ${row.stateLabel} ${row.aliveIndicator} ${cleanSingleLineText(row.cwd)} ${row.ageLabel}${summary}`; + }) + .join('\n'); +} + +export const agentsListCommand: CommandModule = { + command: ['$0', 'list'], + describe: 'List background agents', + builder: (yargs: Argv) => + yargs + .option('cwd', { + type: 'string', + description: 'Workspace directory to inspect', + }) + .option('json', { + type: 'boolean', + nargs: 0, + default: false, + description: 'Print machine-readable JSON', + }) + .option('all', { + type: 'boolean', + nargs: 0, + default: false, + description: 'Include completed and stopped agents', + }) + .check((argv) => { + if (argv.all === true && argv.json !== true) { + return 'qwen agents --all requires --json.'; + } + return true; + }) + .version(false), + handler: async (argv) => { + requireAgentViewEnabled(); + const cwd = path.resolve(argv.cwd ?? process.cwd()); + const listCwd = argv.cwd ? path.resolve(argv.cwd) : undefined; + const supervisor = await ensureAgentViewSupervisor(); + if (argv.json) { + const snapshots = toSnapshots(await supervisor.list(listCwd)); + writeStdoutLine(JSON.stringify(formatAgentsJson(snapshots, argv.all))); + return; + } + + await agentsInteractiveSession.run({ + cwd, + ...(listCwd ? { listCwd } : {}), + supervisor, + renderRoster: defaultRenderAgentsRoster, + header: await buildAgentViewHeader(cwd), + }); + }, +}; + +async function buildAgentViewHeader(cwd: string): Promise { + return { + version: await getCliVersion(), + cwd, + ...readConfiguredModelHeader(cwd), + }; +} + +function readConfiguredModelHeader( + cwd: string, +): Pick { + try { + const settings = loadSettings(cwd, { + skipLoadEnvironment: true, + }).merged; + const model = + readConfiguredModelFromSettings(settings) || + process.env['OPENAI_MODEL']?.trim() || + process.env['QWEN_MODEL']?.trim() || + undefined; + return { + authLabel: formatAuthLabel( + settings.security?.auth?.selectedType || getAuthTypeFromEnv(), + ), + ...(model ? { model } : {}), + ...readProviderLabel(settings.modelProviders, model), + }; + } catch { + const model = + process.env['OPENAI_MODEL']?.trim() || process.env['QWEN_MODEL']?.trim(); + return { + authLabel: process.env['OPENAI_API_KEY'] ? 'API Key' : 'Auth', + ...(model ? { model } : {}), + }; + } +} + +function readProviderLabel( + modelProviders: Settings['modelProviders'], + model: string | undefined, +): Pick { + if (!model || !modelProviders) { + return {}; + } + for (const [providerId, models] of Object.entries(modelProviders)) { + if (!Array.isArray(models)) continue; + if (models.some((modelConfig) => modelConfig.id === model)) { + return { providerLabel: formatProviderLabel(providerId) }; + } + } + return {}; +} + +function readConfiguredModelFromSettings( + settings: Settings, +): string | undefined { + return settings.model?.name?.trim() || undefined; +} + +function formatAuthLabel(authType: string | undefined): string { + if (!authType) return 'Auth'; + if (authType === 'qwen-oauth') return 'Qwen OAuth'; + if (authType === 'openai') return 'API Key'; + return formatProviderLabel(authType); +} + +function formatProviderLabel(providerId: string): string { + return providerId + .split(/[-_]/) + .filter(Boolean) + .map((part) => `${part.slice(0, 1).toUpperCase()}${part.slice(1)}`) + .join(''); +} + +export const agentsCommand: CommandModule = { + command: 'agents', + describe: 'Manage Agent View background agents', + builder: (yargs: Argv) => + yargs + .check((argv) => + argv['background'] === true || argv['continue'] === true + ? '`qwen agents` cannot be combined with --bg/--background or --continue/-c.' + : true, + ) + // Hoisted from the list subcommand so the space form + // `agents --cwd ` is consumed at this level instead of failing + // strict mode (the $0 builder only applies once yargs descends). + .option('cwd', { + type: 'string', + description: 'Workspace directory to inspect', + }) + .check((argv) => { + const separatorTail = (argv as { '--'?: unknown })['--']; + return Array.isArray(separatorTail) && separatorTail.length > 0 + ? '`qwen agents` does not accept arguments after `--`.' + : true; + }) + // Session verbs are subcommands of `qwen agents` so they cannot + // hijack natural-language prompts at the top level. + .command(agentsListCommand) + .command(attachCommand) + .command(logsCommand) + .command(stopCommand) + .command(killCommand) + .command(respawnCommand) + .command(rmCommand) + .command(agentDaemonCommand) + .version(false), + handler: () => {}, +}; + +function formatAgentsJson( + snapshots: AgentViewSessionSnapshot[], + includeAll = false, +): Array> { + return snapshots + .filter((snapshot) => includeAll || isActiveAgentSnapshot(snapshot)) + .map((snapshot) => { + const attached = snapshot.state.attachState === 'attached'; + const name = snapshot.rosterEntry?.displayName; + return { + sessionId: snapshot.sessionId, + ...(name ? { name } : {}), + state: snapshot.state.sessionState, + processState: snapshot.state.processState, + projectCwd: snapshot.state.projectCwd, + activeCwd: snapshot.state.activeCwd, + attached, + pinned: Boolean(snapshot.rosterEntry?.pinned), + createdAt: snapshot.state.createdAt, + updatedAt: snapshot.state.updatedAt, + ...(snapshot.activity?.summary + ? { summary: snapshot.activity.summary } + : {}), + ...(snapshot.activity?.waitingFor + ? { waitingFor: snapshot.activity.waitingFor } + : {}), + ...(snapshot.activity?.queuedPromptCount + ? { queuedPromptCount: snapshot.activity.queuedPromptCount } + : {}), + }; + }); +} + +function isActiveAgentSnapshot(snapshot: AgentViewSessionSnapshot): boolean { + if ( + snapshot.state.sessionState === 'completed' || + snapshot.state.sessionState === 'stopped' || + snapshot.state.sessionState === 'failed' + ) { + return false; + } + return snapshot.state.processState !== 'exited'; +} + +function toSnapshots(value: unknown): AgentViewSessionSnapshot[] { + if (!Array.isArray(value)) return []; + return value + .map(toSnapshot) + .filter((snapshot): snapshot is AgentViewSessionSnapshot => + Boolean(snapshot), + ); +} + +function toSnapshot(value: unknown): AgentViewSessionSnapshot | undefined { + if (!isRecord(value)) return undefined; + if (isSessionState(value)) { + return { + sessionId: value.sessionId, + state: value, + }; + } + const state = value['state']; + if (!isSessionState(state)) return undefined; + return { + sessionId: + typeof value['sessionId'] === 'string' + ? value['sessionId'] + : state.sessionId, + state, + ...(isLaunch(value['launch']) ? { launch: value['launch'] } : {}), + ...(isActivity(value['activity']) ? { activity: value['activity'] } : {}), + ...(isWorker(value['worker']) ? { worker: value['worker'] } : {}), + ...(isRosterEntry(value['rosterEntry']) + ? { rosterEntry: value['rosterEntry'] } + : {}), + }; +} + +function isSessionState(value: unknown): value is AgentViewSessionStateFile { + return ( + isRecord(value) && + typeof value['sessionId'] === 'string' && + typeof value['sessionState'] === 'string' && + typeof value['processState'] === 'string' && + typeof value['projectCwd'] === 'string' && + typeof value['activeCwd'] === 'string' && + typeof value['createdAt'] === 'string' && + typeof value['updatedAt'] === 'string' + ); +} + +function isActivity(value: unknown): value is AgentViewActivityFile { + return isRecord(value) && typeof value['lastActivityAt'] === 'string'; +} + +function isLaunch(value: unknown): value is AgentViewLaunchFile { + return ( + isRecord(value) && + typeof value['sessionId'] === 'string' && + Array.isArray(value['argv']) + ); +} + +function isWorker(value: unknown): value is AgentViewWorkerFile { + return isRecord(value) && typeof value['protocolVersion'] === 'number'; +} + +function isRosterEntry( + value: unknown, +): value is AgentViewSessionSnapshot['rosterEntry'] { + return ( + isRecord(value) && + typeof value['sessionId'] === 'string' && + typeof value['projectCwd'] === 'string' && + typeof value['activeCwd'] === 'string' && + typeof value['createdAt'] === 'string' && + typeof value['updatedAt'] === 'string' + ); +} + +function getSessionId(value: unknown): string { + if (isRecord(value) && typeof value['sessionId'] === 'string') { + return value['sessionId']; + } + throw new Error('Agent dispatch did not return a session id.'); +} + +function formatSessionShortId(sessionId: string): string { + if (sessionId.length <= 12) return sessionId; + return sessionId.slice(0, 8); +} + +function formatPeekPanel(value: unknown): AgentViewSessionPanel { + if (!isRecord(value)) { + return { + kind: 'session', + sessionId: 'Agent', + content: 'message', + lines: ['No details available.'], + }; + } + + const sessionId = + typeof value['sessionId'] === 'string' ? value['sessionId'] : 'Agent'; + const activity = isActivity(value['activity']) + ? value['activity'] + : undefined; + // Activity fields carry untrusted worker/model output; strip unsafe + // control sequences before they reach the operator's terminal. + const lines = [ + activity?.waitingFor + ? `Waiting: ${stripUnsafeCharacters(activity.waitingFor)}` + : undefined, + activity?.queuedPromptCount ? formatQueuedPromptLine(activity) : undefined, + activity?.lastResult + ? `Result: ${stripUnsafeCharacters(activity.lastResult)}` + : undefined, + activity?.summary + ? `Summary: ${stripUnsafeCharacters(activity.summary)}` + : undefined, + ].filter((line): line is string => Boolean(line)); + + return { + kind: 'session', + sessionId, + content: 'activity', + lines: lines.length > 0 ? lines : ['No details available.'], + }; +} + +function formatQueuedPromptLine(activity: AgentViewActivityFile): string { + const preview = activity.queuedPromptPreview?.trim(); + const suffix = preview ? `: ${stripUnsafeCharacters(preview)}` : ''; + return `Waiting for response${suffix}`; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/src/commands/review/parse-args.test.ts b/packages/cli/src/commands/review/parse-args.test.ts index e33a129235e..1242fad2d50 100644 --- a/packages/cli/src/commands/review/parse-args.test.ts +++ b/packages/cli/src/commands/review/parse-args.test.ts @@ -897,6 +897,7 @@ describe('parseArgsCommand wiring', () => { async function runCli(tokens: string[]): Promise { await yargs(tokens) + .parserConfiguration({ 'populate--': true }) .command(parseArgsCommand) .strict() .exitProcess(false) @@ -973,6 +974,7 @@ describe('parseArgsCommand wiring', () => { describe('nested under the real review command', () => { async function runNested(tokens: string[]): Promise { await yargs(tokens) + .parserConfiguration({ 'populate--': true }) .command(reviewCommand) .strict() .exitProcess(false) diff --git a/packages/cli/src/commands/review/parse-args.ts b/packages/cli/src/commands/review/parse-args.ts index cf05a13e3a5..50fe96bb43d 100644 --- a/packages/cli/src/commands/review/parse-args.ts +++ b/packages/cli/src/commands/review/parse-args.ts @@ -799,7 +799,10 @@ export const parseArgsCommand: CommandModule = { ) { commandPrefix++; } - const unbound = positionals.slice(commandPrefix); + const separatorTail = Array.isArray(argv['--']) + ? (argv['--'] as unknown[]).map(String) + : []; + const unbound = [...positionals.slice(commandPrefix), ...separatorTail]; if (unbound.length > 0) { throw new Error( `parse-args: unexpected extra argument(s) ${JSON.stringify(unbound)} — a raw string that begins with a flag must be passed via --stdin, not after --`, diff --git a/packages/cli/src/config/config.test.ts b/packages/cli/src/config/config.test.ts index 2022b3156ef..ea1ace1f717 100644 --- a/packages/cli/src/config/config.test.ts +++ b/packages/cli/src/config/config.test.ts @@ -29,6 +29,24 @@ import { resetMcpApprovalsForTesting } from './mcpApprovals.js'; const mockWriteStderrLine = vi.hoisted(() => vi.fn()); const mockWriteStdoutLine = vi.hoisted(() => vi.fn()); const mockUpdateHandler = vi.hoisted(() => vi.fn()); +const mockConnectExistingAgentViewSupervisor = vi.hoisted(() => + vi.fn().mockResolvedValue(undefined), +); +const mockEnsureAgentViewSupervisor = vi.hoisted(() => + vi.fn(async () => ({ + status: vi.fn(async () => ({ pid: 123 })), + list: vi.fn(async () => []), + subscribe: vi.fn(() => ({ dispose: vi.fn() })), + dispatch: vi.fn(async () => ({ sessionId: 'session-1' })), + attach: vi.fn(async () => ({ attached: true })), + logs: vi.fn(async () => ({ output: '' })), + stop: vi.fn(async () => ({ stopped: true })), + kill: vi.fn(async () => ({ killed: true })), + respawn: vi.fn(async () => ({ respawned: true })), + remove: vi.fn(async () => ({ removed: true })), + shutdown: vi.fn(async () => ({ shuttingDown: true })), + })), +); const mockSessionServiceInstance = vi.hoisted(() => ({ loadLastSession: vi.fn(), loadSession: vi.fn(), @@ -44,6 +62,7 @@ const mockConfigConstructorParams = vi.hoisted(() => vi.fn()); vi.mock('../utils/stdioHelpers.js', () => ({ writeStderrLine: mockWriteStderrLine, writeStdoutLine: mockWriteStdoutLine, + drainStdioBeforeExit: vi.fn(async () => {}), clearScreen: vi.fn(), })); @@ -55,6 +74,15 @@ vi.mock('../commands/update.js', () => ({ }, })); +vi.mock('../agent-view/supervisor-runner.js', () => ({ + ensureAgentViewSupervisor: mockEnsureAgentViewSupervisor, + connectExistingAgentViewSupervisor: mockConnectExistingAgentViewSupervisor, +})); + +vi.mock('../agent-view/feature.js', () => ({ + requireAgentViewEnabled: vi.fn(), +})); + const createNativeLspServiceInstance = () => ({ discoverAndPrepare: vi.fn(), start: vi.fn(), @@ -357,6 +385,428 @@ describe('parseArguments', () => { mockExit.mockRestore(); }); + it.each([ + ['agents', ['agents']], + ['agents list', ['agents', 'list']], + ['agents daemon stop --any', ['agents', 'daemon', 'stop', '--any']], + ['agents attach ', ['agents', 'attach', 'session-1']], + ['agents logs ', ['agents', 'logs', 'session-1']], + ['agents stop ', ['agents', 'stop', 'session-1']], + ['agents kill ', ['agents', 'kill', 'session-1']], + ['agents respawn ', ['agents', 'respawn', 'session-1']], + ['agents respawn --all', ['agents', 'respawn', '--all']], + ['agents rm ', ['agents', 'rm', 'session-1']], + ['agents --cwd=', ['agents', '--cwd=/tmp']], + ['agents --cwd ', ['agents', '--cwd', '/tmp']], + ])( + 'exits after `%s` instead of continuing to main CLI flow', + async (_label, args) => { + process.argv = ['node', 'script.js', ...args]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + expect(mockExit).toHaveBeenCalledWith(0); + } finally { + mockExit.mockRestore(); + } + }, + ); + + it('routes `agents --json` through the list command end-to-end', async () => { + process.argv = ['node', 'script.js', 'agents', '--json']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStdoutLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow(/process\.exit/); + + expect(mockExit).toHaveBeenCalledWith(0); + expect(mockWriteStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('['), + ); + } finally { + mockExit.mockRestore(); + } + }); + + it.each([ + ['stop', ['stop', 'the', 'server']], + ['kill', ['kill', 'the', 'server']], + ['rm', ['rm', 'the', 'server']], + ['attach', ['attach', 'the', 'server']], + ['logs', ['logs', 'the', 'server']], + ['respawn', ['respawn', 'the', 'server']], + ])( + 'still parses verb-initial input starting with `%s` as a positional prompt', + async (_verb, args) => { + process.argv = ['node', 'script.js', ...args]; + + const argv = await parseArguments(); + + expect(argv.query).toBe(args.join(' ')); + }, + ); + + it.each([ + ['agents', 'explain', 'this', 'project'], + ['--debug', 'agents', 'fix', 'the', 'bug'], + ])('reserves `agents` as a command word for %j', async (...args) => { + process.argv = ['node', 'script.js', ...args]; + mockEnsureAgentViewSupervisor.mockClear(); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it('fails loudly instead of blocking on `-p agents serve`', async () => { + process.argv = ['node', 'script.js', '-p', 'agents', 'serve']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + } finally { + mockExit.mockRestore(); + } + }); + + it('rejects agents list options with an unexpected positional', async () => { + process.argv = [ + 'node', + 'script.js', + 'agents', + '--cwd', + '/tmp', + 'explain', + 'this', + 'project', + ]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockEnsureAgentViewSupervisor.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it('does not dispatch when --cwd consumes an agents verb', async () => { + process.argv = [ + 'node', + 'script.js', + 'agents', + '--cwd', + 'attach', + 'session-1', + ]; + mockEnsureAgentViewSupervisor.mockClear(); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it('treats tokens after top-level `--` as a positional prompt', async () => { + process.argv = ['node', 'script.js', '--', 'agents', 'stop', 'session-1']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + const argv = await parseArguments(); + expect(argv.query).toBe('agents stop session-1'); + expect(mockExit).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it('keeps option-looking tokens after top-level `--` in the prompt', async () => { + process.argv = [ + 'node', + 'script.js', + '--', + '--json', + 'agents', + 'do', + 'something', + ]; + + const argv = await parseArguments(); + + expect(argv.query).toBe('--json agents do something'); + }); + + it.each([ + ['agents', '--', 'stop', 'session-1'], + ['agents', 'respawn', '--', 'session-1'], + ['agents', 'respawn', '--all', '--', 'stray'], + ])( + 'rejects arguments after `--` inside the agents command: %j', + async (...args) => { + process.argv = ['node', 'script.js', ...args]; + mockEnsureAgentViewSupervisor.mockClear(); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }, + ); + + it.each([ + ['agents', '--json=true'], + ['agents', '--json=1'], + ['agents', '--all=false'], + ['agents', '--all=1'], + ['--json=1', 'agents'], + ])('rejects assigned agents boolean %s', async (...args) => { + process.argv = ['node', 'script.js', ...args]; + mockEnsureAgentViewSupervisor.mockClear(); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(/process\.exit/); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it('does not apply agents boolean validation to another command argument', async () => { + process.argv = [ + 'node', + 'script.js', + 'mcp', + 'approve', + 'agents', + '--all=true', + ]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(0); + expect(mockWriteStderrLine).not.toHaveBeenCalledWith( + expect.stringContaining('does not accept an assigned value'), + ); + } finally { + mockExit.mockRestore(); + } + }); + + it.each([ + '--safe-mode=false', + '--no-safe-mode', + '--insecure=false', + '--no-insecure', + '--openai-logging=false', + '--no-openai-logging', + '--screen-reader=false', + '--no-screen-reader', + '--bare=false', + '--no-bare', + '--debug=false', + '--no-debug', + '-d=false', + '-l=false', + '-sy', + ])('rejects --bg combined with explicitly false %s', async (option) => { + process.argv = ['node', 'script.js', '--bg', 'background task', option]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Cannot use --bg/--background with'), + ); + } finally { + mockExit.mockRestore(); + } + }); + + it('rejects --bg before an agents command instead of treating it as a prompt', async () => { + process.argv = [ + 'node', + 'script.js', + '--bg', + 'agents', + 'attach', + 'session-1', + ]; + mockEnsureAgentViewSupervisor.mockClear(); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it.each([ + ['--bg', 'serve'], + ['--background', 'update'], + ['--continue', 'update'], + ['-c', 'update'], + ])( + 'rejects %s with the %s subcommand before its handler runs', + async (flag, command) => { + process.argv = ['node', 'script.js', flag, command]; + mockUpdateHandler.mockClear(); + mockEnsureAgentViewSupervisor.mockClear(); + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow(); + expect(mockUpdateHandler).not.toHaveBeenCalled(); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }, + ); + + it('allows a --bg prompt matching a subcommand after the separator', async () => { + process.argv = ['node', 'script.js', '--bg', '--', 'serve']; + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + + try { + const argv = await parseArguments(); + expect(argv.background).toBe(true); + expect(argv.query).toBe('serve'); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }); + + it.each(['--continue', '-c'])( + 'rejects %s before an agents command instead of treating agents as a prompt', + async (flag) => { + process.argv = [ + 'node', + 'script.js', + flag, + 'agents', + 'attach', + 'session-1', + ]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow( + 'cannot be combined with a CLI subcommand', + ); + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }, + ); + + it('shows the agents attach help for `agents attach --help`', async () => { + process.argv = ['node', 'script.js', 'agents', 'attach', '--help']; + const chunks: string[] = []; + const outSpy = vi.spyOn(process.stdout, 'write').mockImplementation((( + chunk: unknown, + ) => { + chunks.push(String(chunk)); + return true; + }) as never); + const logSpy = vi.spyOn(console, 'log').mockImplementation((( + ...args: unknown[] + ) => { + chunks.push(args.map(String).join(' ')); + }) as never); + + try { + // yargs prints the subcommand help, then exits 0. + await expect(parseArguments()).rejects.toThrow( + 'process.exit unexpectedly called with "0"', + ); + expect(chunks.join('')).toContain('agents attach '); + } finally { + outSpy.mockRestore(); + logSpy.mockRestore(); + } + }); + + it('runs the agents surface for `agents --version` instead of the probe version', async () => { + process.argv = ['node', 'script.js', 'agents', '--version']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + expect(mockExit).toHaveBeenCalledWith(0); + expect(mockEnsureAgentViewSupervisor).toHaveBeenCalled(); + } finally { + mockExit.mockRestore(); + } + }); + + it('fails loudly on `agents --yolo` instead of prompting "agents"', async () => { + process.argv = ['node', 'script.js', 'agents', '--yolo']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + + try { + await expect(parseArguments()).rejects.toThrow( + 'process.exit unexpectedly called with "1"', + ); + } finally { + mockExit.mockRestore(); + } + }); + it('propagates non-zero exitCode from the update handler', async () => { process.argv = ['node', 'script.js', 'update']; mockUpdateHandler.mockImplementation(() => { @@ -407,6 +857,337 @@ describe('parseArguments', () => { expect(argv.insecure).toBe(true); }); + it.each([ + [ + ['--bg', 'background task', '--prompt', 'hello'], + 'Cannot use --bg/--background with --prompt (-p)', + ], + [ + ['--background'], + 'Cannot use --bg/--background without a positional prompt', + ], + [ + ['--bg', 'background task', '--prompt-interactive', 'hello'], + 'Cannot use --bg/--background with --prompt-interactive (-i)', + ], + [ + ['--bg', 'background task', '--acp'], + 'Cannot use --bg/--background with ACP mode', + ], + [ + ['--bg', 'background task', '--input-format', 'stream-json'], + 'Cannot use --bg/--background with --input-format stream-json', + ], + [ + ['--bg', 'background task', '--output-format', 'json'], + 'Cannot use --bg/--background with JSON output', + ], + [ + ['--bg', 'background task', '--json-schema', '{}'], + 'Cannot use --bg/--background with --json-schema', + ], + [ + ['--bg', 'background task', '-i'], + 'Cannot use --bg/--background with --prompt-interactive (-i)', + ], + [ + ['--bg', 'background task', '--resume', 'some-session-id'], + 'Cannot use --bg/--background with --resume, --continue, or --session-id', + ], + [ + ['--bg', 'background task', '--continue'], + 'Cannot use --bg/--background with --resume, --continue, or --session-id', + ], + [ + ['--bg', 'background task', '--session-id', 'some-session-id'], + 'Cannot use --bg/--background with --resume, --continue, or --session-id', + ], + [ + ['--bg', 'background task', '--worktree', 'my-feature'], + 'Cannot use --bg/--background with --worktree', + ], + [ + ['--bg', 'background task', '--model', 'some-model'], + 'Cannot use --bg/--background with --model', + ], + [ + ['--bg', 'background task', '--approval-mode', 'yolo'], + 'Cannot use --bg/--background with --approval-mode', + ], + [ + ['--bg', 'background task', '--include-directories', '/extra'], + 'Cannot use --bg/--background with --include-directories', + ], + [ + ['--bg', 'background task', '--experimental-acp'], + 'Cannot use --bg/--background with ACP mode', + ], + [ + ['--bg', 'background task', '--output-format', 'stream-json'], + 'Cannot use --bg/--background with JSON output', + ], + // Bare string flags parse to '' from yargs; the gates must key on + // presence, not truthiness. + [ + ['--bg', 'background task', '--worktree'], + 'Cannot use --bg/--background with --worktree', + ], + [ + ['--bg', 'background task', '--model'], + 'Cannot use --bg/--background with --model', + ], + [ + ['--bg', 'background task', '--session-id'], + 'Cannot use --bg/--background with --resume, --continue, or --session-id', + ], + [ + ['--bg', 'background task', '--safe-mode'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--proxy'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--chat-recording'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--chat-recording=false'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--no-chat-recording'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--screen-reader'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--debug'], + 'Cannot use --bg/--background with --safe-mode', + ], + [ + ['--bg', 'background task', '--telemetry'], + 'Cannot use --bg/--background with telemetry flags', + ], + [ + ['--bg', 'background task', '--telemetry=false'], + 'Cannot use --bg/--background with telemetry flags', + ], + [ + ['--bg', 'background task', '--no-telemetry'], + 'Cannot use --bg/--background with telemetry flags', + ], + [ + ['--bg', 'background task', '--telemetry-target', 'local'], + 'Cannot use --bg/--background with telemetry flags', + ], + [ + ['--bg', 'background task', '--list-extensions'], + 'Cannot use --bg/--background with telemetry flags', + ], + [ + ['--bg', 'background task', '--channel', 'CI'], + 'Cannot use --bg/--background with telemetry flags', + ], + ])('rejects %s', async (args, message) => { + process.argv = ['node', 'script.js', ...args]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + + const expectedMessage = + args.length === 1 && args[0] === '--background' + ? message + : 'Cannot use --bg/--background with other CLI options'; + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining(expectedMessage), + ); + } finally { + mockExit.mockRestore(); + } + }); + + it.each(['--bg', '--background'])( + 'parses %s with a background prompt', + async (flag) => { + process.argv = ['node', 'script.js', flag, 'background task']; + + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + try { + const argv = await parseArguments(); + + expect(argv.background).toBe(true); + expect(argv.query).toBe('background task'); + expect(argv.prompt).toBeUndefined(); + expect(argv.promptInteractive).toBeUndefined(); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }, + ); + + it.each(['fix the bug', '--yolo'])( + 'treats top-level `--` tail %j as a background prompt', + async (prompt) => { + process.argv = ['node', 'script.js', '--bg', '--', prompt]; + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + try { + const argv = await parseArguments(); + + expect(argv.background).toBe(true); + expect(argv.query).toBe(prompt); + expect(argv.yolo).toBe(false); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }, + ); + + it('rejects --prompt combined with a top-level `--` tail', async () => { + process.argv = ['node', 'script.js', '--prompt', 'first', '--', 'second']; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use both a positional prompt and the --prompt', + ), + ); + } finally { + mockExit.mockRestore(); + } + }); + + it.each([ + '--safeMode', + '--chatRecording', + '--openaiLogging', + '--screenReader', + '--telemetryLogPrompts', + '--listExtensions', + '--experimentalAcp', + '--experimentalLsp', + ])('rejects --bg combined with camelCase option %s', async (option) => { + process.argv = ['node', 'script.js', '--bg', 'background task', option]; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Cannot use --bg/--background with'), + ); + } finally { + mockExit.mockRestore(); + } + }); + + it('rejects --bg when stdin is piped', async () => { + process.argv = ['node', 'script.js', '--bg', 'background task']; + + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = false; + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining( + 'Cannot use --bg/--background when stdin is not an interactive terminal', + ), + ); + } finally { + mockExit.mockRestore(); + process.stdin.isTTY = originalIsTTY; + } + }); + + it.each([ + ['--yolo', ['--bg', 'background task', '--yolo']], + ['-y', ['--bg', 'background task', '-y']], + ['--sandbox', ['--bg', 'background task', '--sandbox']], + ['-s', ['--bg', 'background task', '-s']], + ['--sandbox-image', ['--bg', 'background task', '--sandbox-image', 'img']], + ['--system-prompt', ['--bg', 'background task', '--system-prompt', 'sp']], + [ + '--append-system-prompt', + ['--bg', 'background task', '--append-system-prompt', 'sp'], + ], + ['--mcp-config', ['--bg', 'background task', '--mcp-config', '{}']], + ['--extensions', ['--bg', 'background task', '--extensions', 'ext']], + ['-e', ['--bg', 'background task', '-e', 'ext']], + ['--allowed-tools', ['--bg', 'background task', '--allowed-tools', 't']], + [ + '--allowed-mcp-server-names', + ['--bg', 'background task', '--allowed-mcp-server-names', 's'], + ], + ['--input-file', ['--bg', 'background task', '--input-file', 'cmds.jsonl']], + [ + '--fallback-model', + ['--bg', 'background task', '--fallback-model', 'qwen-plus'], + ], + ['--core-tools', ['--bg', 'background task', '--core-tools', 'read_file']], + [ + '--exclude-tools', + ['--bg', 'background task', '--exclude-tools', 'run_shell_command'], + ], + [ + '--disabled-slash-commands', + ['--bg', 'background task', '--disabled-slash-commands', '/help'], + ], + ['--auth-type', ['--bg', 'background task', '--auth-type', 'qwen-oauth']], + ['--experimental-lsp', ['--bg', 'background task', '--experimental-lsp']], + ['--json-file', ['--bg', 'background task', '--json-file', 'events.json']], + ['--json-fd', ['--bg', 'background task', '--json-fd', '3']], + ['--max-wall-time', ['--bg', 'background task', '--max-wall-time', '30m']], + [ + '--max-session-turns', + ['--bg', 'background task', '--max-session-turns', '5'], + ], + [ + '--max-tool-calls', + ['--bg', 'background task', '--max-tool-calls', '100'], + ], + [ + '--max-subagent-depth', + ['--bg', 'background task', '--max-subagent-depth', '2'], + ], + ])('rejects --bg combined with %s', async (_label, args) => { + process.argv = ['node', 'script.js', ...args]; + + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + mockWriteStderrLine.mockClear(); + + try { + await expect(parseArguments()).rejects.toThrow('process.exit called'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Cannot use --bg/--background with'), + ); + } finally { + mockExit.mockRestore(); + } + }); + it('rejects --json-schema combined with --acp', async () => { // ACP runs an independent turn loop (runAcpAgent) that doesn't honour // the synthetic structured_output terminal contract. The yargs check @@ -853,6 +1634,30 @@ describe('parseArguments', () => { } }); + it('should accept --json-schema with a prompt after the separator', async () => { + process.argv = [ + 'node', + 'script.js', + '--json-schema', + '{"type":"object"}', + '--', + 'fix', + 'the', + '-p', + 'bug', + ]; + + const originalIsTTY = process.stdin.isTTY; + process.stdin.isTTY = true; + try { + const argv = await parseArguments(); + expect(argv.jsonSchema).toBe('{"type":"object"}'); + expect(argv['--']).toEqual(['fix', 'the', '-p', 'bug']); + } finally { + process.stdin.isTTY = originalIsTTY; + } + }); + it('should throw when --json-schema is combined with --input-format stream-json', async () => { // stream-json input runs through runNonInteractiveStreamJson which // doesn't honor the structured-output single-shot termination @@ -1472,6 +2277,17 @@ describe('loadCliConfig', () => { ); }); + it('should propagate the Agent View gate', async () => { + process.argv = ['node', 'script.js']; + const argv = await parseArguments(); + + await loadCliConfig({ experimental: { agentView: true } }, argv); + + expect(mockConfigConstructorParams).toHaveBeenCalledWith( + expect.objectContaining({ agentViewEnabled: true }), + ); + }); + it('should propagate the session writer lease opt-in', async () => { process.argv = ['node', 'script.js']; const argv = await parseArguments(); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index bf538d25955..5b640b3e55b 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -74,6 +74,8 @@ import { reviewCommand } from '../commands/review.js'; import { serveCommand } from '../commands/serve.js'; import { sessionsCommand } from '../commands/sessions.js'; import { updateCommand } from '../commands/update.js'; +import { agentsCommand } from '../commands/agents.js'; +import { setInvocationScopedEnv } from './invocation-env.js'; import { isValidSessionId } from './session-id.js'; export { isValidSessionId } from './session-id.js'; @@ -81,7 +83,10 @@ export { isValidSessionId } from './session-id.js'; import { isWorkspaceTrusted } from './trustedFolders.js'; import { assembleMcpServers } from './mcpServers.js'; import { getPendingGatedMcpServers } from './mcpApprovals.js'; -import { writeStderrLine } from '../utils/stdioHelpers.js'; +import { + drainStdioBeforeExit, + writeStderrLine, +} from '../utils/stdioHelpers.js'; import { parseDurationSeconds, validateMaxToolCalls, @@ -229,6 +234,7 @@ export interface CliArgs { jsonFile?: string | undefined; jsonSchema?: string | undefined; inputFile?: string | undefined; + background?: boolean | undefined; } /** @@ -544,20 +550,9 @@ function normalizeOutputFormat( return OutputFormat.TEXT; } -export async function parseArguments(): Promise { - let rawArgv = hideBin(process.argv); - - // hack: if the first argument is the CLI entry point, remove it - if ( - rawArgv.length > 0 && - (rawArgv[0].endsWith('/dist/qwen-cli/cli.js') || - rawArgv[0].endsWith('/dist/cli.js') || - rawArgv[0].endsWith('/dist/cli/cli.js')) - ) { - rawArgv = rawArgv.slice(1); - } - - const yargsInstance = yargs(rawArgv) +function buildCliParser(rawArgv: string[]): Argv { + const parser = yargs(rawArgv) + .parserConfiguration({ 'populate--': true }) .locale('en') .scriptName('qwen') .usage( @@ -654,6 +649,19 @@ export async function parseArguments(): Promise { description: 'Enable chat recording to disk. If false, chat history is not saved and --continue/--resume will not work.', }) + .option('background', { + alias: 'bg', + type: 'boolean', + nargs: 0, + description: 'Start a new Agent View background session', + }) + .option('continue', { + alias: 'c', + type: 'boolean', + nargs: 0, + description: 'Resume the most recent session for the current project.', + default: false, + }) .command('$0 [query..]', 'Launch Qwen Code CLI', (yargsInstance: Argv) => yargsInstance .positional('query', { @@ -863,13 +871,6 @@ export async function parseArguments(): Promise { 'File path for receiving remote input commands (bidirectional sync). ' + 'An external process writes JSONL commands; the TUI watches and processes them.', }) - .option('continue', { - alias: 'c', - type: 'boolean', - description: - 'Resume the most recent session for the current project.', - default: false, - }) .option('resume', { alias: 'r', type: 'string', @@ -981,8 +982,22 @@ export async function parseArguments(): Promise { const hasPositionalQuery = Array.isArray(query) ? query.length > 0 : !!query; + const separatorTail = Array.isArray(argv['--']) ? argv['--'] : []; + const hasQuery = hasPositionalQuery || separatorTail.length > 0; - if (argv['prompt'] && hasPositionalQuery) { + if (argv['background']) { + if (!hasQuery) { + return 'Cannot use --bg/--background without a positional prompt'; + } + const backgroundError = validateBackgroundInvocation(rawArgv); + if (backgroundError) return backgroundError; + } + if (argv['background'] && !process.stdin.isTTY) { + // The positional-prompt gate above already ran, so the prompt + // is positional here; the only actionable fix is a TTY. + return 'Cannot use --bg/--background when stdin is not an interactive terminal; run it from a TTY'; + } + if (argv['prompt'] && hasQuery) { return 'Cannot use both a positional prompt and the --prompt (-p) flag together'; } if (argv['prompt'] && argv['promptInteractive']) { @@ -1059,10 +1074,6 @@ export async function parseArguments(): Promise { return '--json-schema cannot be used with --acp; structured output is only honoured by the headless non-interactive flow.'; } const hasPrompt = !!argv['prompt']; - const query = argv['query'] as string | string[] | undefined; - const hasPositionalQuery = Array.isArray(query) - ? query.length > 0 - : !!query; // Allow stdin piping (`echo "..." | qwen --json-schema ...`): // when stdin is not a TTY, the prompt is supplied via the pipe // and headless mode runs normally. Only reject true interactive @@ -1071,13 +1082,14 @@ export async function parseArguments(): Promise { // termination handler in the TUI loop, so silently launching // the TUI would strand the run. const stdinIsPiped = !process.stdin.isTTY; - if (!hasPrompt && !hasPositionalQuery && !stdinIsPiped) { + if (!hasPrompt && !hasQuery && !stdinIsPiped) { return '--json-schema only applies to non-interactive mode; pass a prompt via -p, as a positional argument, or piped via stdin.'; } } return true; }), - ) + ); + parser // Register MCP subcommands .command(mcpCommand) // Register Extension subcommands @@ -1095,6 +1107,60 @@ export async function parseArguments(): Promise { .command(sessionsCommand) // Register update command .command(updateCommand); + return parser; +} + +function validateBackgroundInvocation(rawArgv: string[]): string | undefined { + try { + yargs(rawArgv) + .exitProcess(false) + .help(false) + .version(false) + .parserConfiguration({ 'populate--': true }) + .option('background', { + alias: 'bg', + type: 'boolean', + nargs: 0, + }) + .command('$0 [query..]', false, (parser: Argv) => + parser.positional('query', { type: 'string' }), + ) + .strictOptions() + .fail((message, error) => { + throw error ?? new Error(message); + }) + .parseSync(); + return undefined; + } catch { + return 'Cannot use --bg/--background with other CLI options; pass only a positional prompt or place option-looking prompt text after `--`'; + } +} + +export async function parseArguments(): Promise { + let rawArgv = hideBin(process.argv); + + // hack: if the first argument is the CLI entry point, remove it + if ( + rawArgv.length > 0 && + (rawArgv[0].endsWith('/dist/qwen-cli/cli.js') || + rawArgv[0].endsWith('/dist/cli.js') || + rawArgv[0].endsWith('/dist/cli/cli.js')) + ) { + rawArgv = rawArgv.slice(1); + } + + const yargsInstance = buildCliParser(rawArgv); + yargsInstance.command(agentsCommand); + yargsInstance.middleware((argv) => { + if ( + argv._.length > 0 && + (argv['background'] === true || argv['continue'] === true) + ) { + throw new Error( + '`--bg/--background` and `--continue/-c` cannot be combined with a CLI subcommand. Place `--` before prompt text that matches a command name.', + ); + } + }); yargsInstance .version(await getCliVersion()) // This will enable the --version flag based on package.json @@ -1120,6 +1186,7 @@ export async function parseArguments(): Promise { result._[0] === 'channel' || result._[0] === 'review' || result._[0] === 'sessions' || + result._[0] === 'agents' || result._[0] === 'update') ) { // Note: `serve` is intentionally NOT in this list. Its handler blocks @@ -1129,17 +1196,28 @@ export async function parseArguments(): Promise { // execution and exit. Returning here would let the main interactive // flow run, which would prompt for stdin input despite the user // having already invoked a subcommand. + // Drain first: on POSIX pipes stdout flushes asynchronously, so a bare + // process.exit would discard buffered output beyond the pipe buffer + // (e.g. `qwen agents logs | tee` for a large scrollback). + await drainStdioBeforeExit(); process.exit(process.exitCode ?? 0); } // Normalize query args: handle both quoted "@path file" and unquoted @path file const queryArg = (result as { query?: string | string[] | undefined }).query; - const q: string | undefined = Array.isArray(queryArg) - ? queryArg.join(' ') - : queryArg; + const queryParts = Array.isArray(queryArg) + ? queryArg.map(String) + : queryArg + ? [queryArg] + : []; + const separatorTail = (result as { '--'?: unknown })['--']; + if (Array.isArray(separatorTail)) { + queryParts.push(...separatorTail.map(String)); + } + const q = queryParts.length > 0 ? queryParts.join(' ') : undefined; // Route positional args: explicit -i flag -> interactive; else -> one-shot (even for @commands) - if (q && !result['prompt']) { + if (q && !result['prompt'] && !result['background']) { const hasExplicitInteractive = result['promptInteractive'] === '' || !!result['promptInteractive']; if (hasExplicitInteractive) { @@ -1568,7 +1646,7 @@ export async function loadCliConfig( ): Promise { const debugMode = isDebugMode(argv); if (debugMode && process.env['QWEN_DEBUG_LOG_FILE'] === undefined) { - process.env['QWEN_DEBUG_LOG_FILE'] = '1'; + setInvocationScopedEnv('QWEN_DEBUG_LOG_FILE', '1'); } const bareMode = isBareMode(argv.bare); const safeMode = @@ -1579,7 +1657,7 @@ export async function loadCliConfig( // every content generator and the preconnect path. Resolution there ORs this // with QWEN_TLS_INSECURE / NODE_TLS_REJECT_UNAUTHORIZED=0. if (argv.insecure) { - process.env['QWEN_TLS_INSECURE'] = '1'; + setInvocationScopedEnv('QWEN_TLS_INSECURE', '1'); } // When opting out of TLS verification, also set NODE_TLS_REJECT_UNAUTHORIZED // process-wide. The custom undici dispatcher handles the Node path, but this @@ -1591,7 +1669,7 @@ export async function loadCliConfig( isTlsVerificationDisabled() && process.env['NODE_TLS_REJECT_UNAUTHORIZED'] !== '0' ) { - process.env['NODE_TLS_REJECT_UNAUTHORIZED'] = '0'; + setInvocationScopedEnv('NODE_TLS_REJECT_UNAUTHORIZED', '0'); // The setting is process-wide, so the blast radius is every outbound HTTPS // connection (model API, OAuth, MCP servers, and child processes that // inherit the env), not just model calls. Log to the debug file too, so the @@ -2268,6 +2346,7 @@ export async function loadCliConfig( // Undefined flows through to Config's default (5) and clamp logic. maxSubagentDepth: resolveMaxSubagentDepth(argv, settings), experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, + agentViewEnabled: settings.experimental?.agentView === true, sessionWriterLeaseEnabled: settings.experimental?.sessionWriterLease === true, cronEnabled: settings.experimental?.cron ?? true, diff --git a/packages/cli/src/config/invocation-env.test.ts b/packages/cli/src/config/invocation-env.test.ts new file mode 100644 index 00000000000..df22401daab --- /dev/null +++ b/packages/cli/src/config/invocation-env.test.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it } from 'vitest'; +import { + restoreInvocationScopedEnv, + setInvocationScopedEnv, +} from './invocation-env.js'; + +const TEST_ENV = 'QWEN_TEST_INVOCATION_ENV'; + +describe('invocation-scoped environment', () => { + afterEach(() => { + delete process.env[TEST_ENV]; + }); + + it('removes values introduced by the current invocation', () => { + delete process.env[TEST_ENV]; + setInvocationScopedEnv(TEST_ENV, 'temporary'); + + expect(restoreInvocationScopedEnv(process.env)[TEST_ENV]).toBeUndefined(); + }); + + it('preserves values that existed before the invocation', () => { + const name = `${TEST_ENV}_PRESET`; + process.env[name] = 'operator-value'; + setInvocationScopedEnv(name, 'temporary'); + + expect(restoreInvocationScopedEnv(process.env)[name]).toBe( + 'operator-value', + ); + delete process.env[name]; + }); +}); diff --git a/packages/cli/src/config/invocation-env.ts b/packages/cli/src/config/invocation-env.ts new file mode 100644 index 00000000000..0921e31840c --- /dev/null +++ b/packages/cli/src/config/invocation-env.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +const previousValues = new Map(); + +export function setInvocationScopedEnv(name: string, value: string): void { + if (!previousValues.has(name)) { + previousValues.set(name, process.env[name]); + } + process.env[name] = value; +} + +export function restoreInvocationScopedEnv( + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const restored = { ...env }; + for (const [name, previous] of previousValues) { + if (previous === undefined) { + delete restored[name]; + } else { + restored[name] = previous; + } + } + return restored; +} diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 2b0dbf6ce15..6b8c4df98cd 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -139,6 +139,17 @@ describe('SettingsSchema', () => { }); }); + it('should keep Agent View disabled by default', () => { + expect( + getSettingsSchema().experimental.properties.agentView, + ).toMatchObject({ + type: 'boolean', + default: false, + requiresRestart: true, + showInDialog: true, + }); + }); + it('should expose cumulative tool result threshold in clearContextOnIdle', () => { const threshold = getSettingsSchema().context.properties.clearContextOnIdle.properties diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 68080f27030..fb84ab21cdb 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -3604,6 +3604,15 @@ const SETTINGS_SCHEMA = { description: 'Settings to enable experimental features.', showInDialog: false, properties: { + agentView: { + type: 'boolean', + label: 'Agent View', + category: 'Experimental', + requiresRestart: true, + default: false, + description: 'Enable Agent View background sessions.', + showInDialog: true, + }, liveVoice: { type: 'object', label: 'Live Voice', diff --git a/packages/cli/src/config/shared-env-keys.test.ts b/packages/cli/src/config/shared-env-keys.test.ts index 4199fdff84e..f3ed11cc3bc 100644 --- a/packages/cli/src/config/shared-env-keys.test.ts +++ b/packages/cli/src/config/shared-env-keys.test.ts @@ -22,6 +22,11 @@ import { scrubInheritedLoaderEnv, setLoaderKeyRejectionReporter, } from './shared-env-keys.js'; +import { + PTY_HOST_AUTH_TOKEN_ENV, + PTY_HOST_ID_ENV, +} from '../agent-view/pty-host-env.js'; +import { AGENT_VIEW_WORKER_ENV_KEYS } from '../agent-view/worker-sideband.js'; describe('PROJECT_ENV_HARDCODED_EXCLUSIONS', () => { // Security guard: a project `.env` must never be able to disable TLS @@ -66,6 +71,17 @@ describe('PROJECT_ENV_HARDCODED_EXCLUSIONS', () => { expect(PROJECT_ENV_HARDCODED_EXCLUSIONS).toContain('NODE_EXTRA_CA_CERTS'); }); + it('keeps Agent View process identity out of project env files', () => { + for (const key of [ + ...AGENT_VIEW_WORKER_ENV_KEYS, + 'QWEN_AGENT_VIEW_SUPERVISOR', + PTY_HOST_AUTH_TOKEN_ENV, + PTY_HOST_ID_ENV, + ]) { + expect(PROJECT_ENV_HARDCODED_EXCLUSIONS).toContain(key); + } + }); + // The compile-cache keys stay settable from project files: a // project-configured V8 cache dir is a pinned feature (#7594, tests in // both loaders), and Node validates cache entries against the source, so diff --git a/packages/cli/src/config/shared-env-keys.ts b/packages/cli/src/config/shared-env-keys.ts index 0dd7e641b16..35e52aa68d3 100644 --- a/packages/cli/src/config/shared-env-keys.ts +++ b/packages/cli/src/config/shared-env-keys.ts @@ -7,6 +7,11 @@ import { QWEN_CODE_DESKTOP_ENV, QWEN_CODE_SERVE_ENV, } from './acp-channel-fallback.js'; +import { + PTY_HOST_AUTH_TOKEN_ENV, + PTY_HOST_ID_ENV, +} from '../agent-view/pty-host-env.js'; +import { AGENT_VIEW_WORKER_ENV_KEYS } from '../agent-view/worker-sideband.js'; import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; @@ -173,6 +178,12 @@ export const PROJECT_ENV_HARDCODED_EXCLUSIONS = [ // `cd && qwen serve` into code execution as the daemon // via an attacker-chosen ACP entrypoint, for every workspace's sessions. 'QWEN_CLI_ENTRY', + // Agent View worker, supervisor, and PTY-host identity markers are stamped + // only by trusted launchers; a project .env must not impersonate them. + ...AGENT_VIEW_WORKER_ENV_KEYS, + 'QWEN_AGENT_VIEW_SUPERVISOR', + PTY_HOST_AUTH_TOKEN_ENV, + PTY_HOST_ID_ENV, // QWEN_CDP_MCP_COMMAND is the command the daemon spawns as the // browser-automation MCP adapter, and QWEN_SERVE_CDP_TUNNEL_OVER_WS // switches that tunnel surface on. A project `.env` or settings.env fixing diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 72409fdd80a..06c806382f0 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -29,6 +29,7 @@ import { PRIVATE_EXTERNAL_TOOL_GUARD_PROVIDER_ENV, } from '@qwen-code/acp-bridge/externalToolGuard'; import dns from 'node:dns'; +import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -59,6 +60,7 @@ import { ExtensionRefreshState } from './config/extension-refresh-state.js'; import { initializeI18n, resolveLanguageSetting } from './i18n/index.js'; import { setupStartupWorktree, + discardCreatedStartupWorktree, persistStartupWorktreeSidecar, buildStartupWorktreeNotice, type StartupWorktreeContext, @@ -91,7 +93,11 @@ import { start_sandbox } from './utils/sandbox.js'; import { getStartupWarnings } from './utils/startupWarnings.js'; import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; import { initializeWarningHandler } from './utils/warningHandler.js'; -import { writeStderrLine, writeStderrLineSafe } from './utils/stdioHelpers.js'; +import { + drainStdioBeforeExit, + writeStderrLine, + writeStderrLineSafe, +} from './utils/stdioHelpers.js'; import { sanitizeTerminalText } from './ui/utils/textUtils.js'; import { getHeadlessYoloSafetyWarning } from './utils/headlessSafetyWarnings.js'; import { initializeLlmOutputLanguage } from './utils/languageUtils.js'; @@ -380,6 +386,58 @@ export async function main() { process.env[QWEN_CODE_SIMPLE_ENV_VAR] = '1'; } + if (process.argv.includes('--internal-agent-view-supervisor')) { + // Gate on the supervisor launch env: defaultSpawnSupervisor always sets + // it on the daemon child, while a natural-language prompt mentioning the + // flag never does. Without the gate such a prompt would hijack the + // process into supervisor mode and hang without running the user input. + const { runAgentViewSupervisor, INTERNAL_AGENT_VIEW_SUPERVISOR_ENV } = + await import('./agent-view/supervisor-runner.js'); + if (process.env[INTERNAL_AGENT_VIEW_SUPERVISOR_ENV] === '1') { + await runAgentViewSupervisor(); + process.exit(0); + } + } + + const ptyHostArgIndex = process.argv.indexOf( + '--internal-agent-view-pty-host', + ); + if (ptyHostArgIndex !== -1) { + // Gate on the host auth token: the spawner always sets it in the child + // env, while a natural-language prompt mentioning the flag never does. + // Without the gate such a prompt would hijack the process into PTY-host + // mode and readFile/JSON.parse arbitrary words from the command line. + const { PTY_HOST_AUTH_TOKEN_ENV } = await import( + './agent-view/pty-host-env.js' + ); + if (process.env[PTY_HOST_AUTH_TOKEN_ENV]) { + const launchPath = process.argv[ptyHostArgIndex + 1]; + const socketPath = process.argv[ptyHostArgIndex + 2]; + if (!launchPath || !socketPath) { + throw new Error( + 'Agent View PTY host requires launch and socket paths.', + ); + } + const { runAgentViewPtyHostProcess } = await import( + './agent-view/pty-host-process.js' + ); + const exit = await runAgentViewPtyHostProcess({ launchPath, socketPath }); + if (exit.kind !== 'exited') { + process.exit(exit.kind === 'confirmed-shutdown' ? 0 : 1); + } + // node-pty reports signal-kills as {exitCode: 0, signal}; surface them + // as failures (shell convention) so the supervisor does not record a + // killed worker as successfully completed. + process.exit( + exit.kind === 'exited' + ? exit.exitCode === 0 && exit.signal + ? 128 + exit.signal + : exit.exitCode + : 1, + ); + } + } + // Run before yargs parses subcommands — handlers like `channel status`/`stop` // call `process.exit` before `loadSettings()` would otherwise bootstrap. preResolveHomeEnvOverrides(); @@ -433,6 +491,15 @@ export async function main() { : loadSettings(); markAcpStartup('settingsLoadEnd'); + if (argv.background) { + const { handleAgentViewBackgroundPrompt } = await import( + './commands/agents.js' + ); + await handleAgentViewBackgroundPrompt(argv.query ?? '', settings.merged); + await drainStdioBeforeExit(); + process.exit(0); + } + // Propagate corruption state to child process via env vars so // relaunchAppInChildProcess() doesn't lose the marker. if (settings.corruptedPath) { @@ -488,6 +555,10 @@ export async function main() { const { themeManager, AUTO_THEME_NAME } = await import( './ui/themes/theme-manager.js' ); + const { isAgentViewWorkerEnv } = await import( + './agent-view/worker-sideband.js' + ); + const isAgentViewWorker = isAgentViewWorkerEnv(); // Load custom themes from settings themeManager.loadCustomThemes(settings.merged.ui?.customThemes); @@ -560,7 +631,12 @@ export async function main() { if (sandboxConfig) { const partialConfig = await loadCliConfig( settings.merged, - argv, + { + ...argv, + continue: false, + resume: undefined, + forkSession: false, + }, undefined, [], // Pass separated hooks for proper source attribution @@ -780,16 +856,104 @@ export async function main() { } } - // Handle --resume without a session ID, or with a custom title, by showing - // the session picker. Set the runtime output dir early so the picker can find - // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is - // idempotent and will be called again inside loadCliConfig). - if (argv.resume !== undefined) { + const exitStartup = async ( + code: number, + message?: string, + ): Promise => { + if (message) writeStderrLine(message); + const cleanup = await discardCreatedStartupWorktree(startupWorktreeContext); + if (cleanup.error) { + writeStderrLine(`Failed to clean up startup worktree: ${cleanup.error}`); + code = 1; + } + process.exit(code); + }; + + if (argv.resume !== undefined || argv.continue) { Storage.setRuntimeBaseDir( settings.merged.advanced?.runtimeOutputDir, process.cwd(), ); + } + + if (argv.continue) { + const sessionService = new SessionService(process.cwd()); + const sessionData = await sessionService.loadLastSession(); + if (!sessionData) { + if (argv.forkSession) { + await exitStartup( + 1, + 'Cannot use --fork-session with --continue: no saved session found to fork.', + ); + } + } else { + const sourceSessionId = sessionData.conversation.sessionId; + const { + isManagedAgentViewContinueBlocked, + isManagedAgentViewResumeBlocked, + MANAGED_AGENT_VIEW_RESUME_MESSAGE, + releaseExitedManagedSessionForContinue, + } = await import('./startup/agent-view-resume-guard.js'); + const agentViewEnabled = settings.merged.experimental?.agentView === true; + if ( + !agentViewEnabled && + (await isManagedAgentViewResumeBlocked(sourceSessionId)) + ) { + const { AGENT_VIEW_DISABLED_MESSAGE } = await import( + './agent-view/feature.js' + ); + await exitStartup(1, AGENT_VIEW_DISABLED_MESSAGE); + } + let ownershipReleased = false; + if (await isManagedAgentViewContinueBlocked(sourceSessionId)) { + if (!argv.forkSession) { + ownershipReleased = await releaseExitedManagedSessionForContinue( + sourceSessionId, + process.env, + agentViewEnabled, + ); + } + if (!ownershipReleased) { + await exitStartup(1, MANAGED_AGENT_VIEW_RESUME_MESSAGE); + } + } + if (argv.forkSession) { + const forkedSessionId = randomUUID(); + try { + await sessionService.forkSession(sourceSessionId, forkedSessionId); + } catch (error) { + await exitStartup( + 1, + `Failed to fork session ${sourceSessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + argv = { + ...argv, + continue: false, + resume: forkedSessionId, + forkSession: false, + }; + } else { + if ( + !ownershipReleased && + !(await releaseExitedManagedSessionForContinue( + sourceSessionId, + process.env, + agentViewEnabled, + )) + ) { + await exitStartup(1, MANAGED_AGENT_VIEW_RESUME_MESSAGE); + } + argv = { ...argv, continue: false, resume: sourceSessionId }; + } + } + } + // Handle --resume without a session ID, or with a custom title, by showing + // the session picker. Set the runtime output dir early so the picker can find + // sessions stored under a custom runtimeOutputDir (setRuntimeBaseDir is + // idempotent and will be called again inside loadCliConfig). + if (argv.resume !== undefined) { let resolvedSessionId: string | undefined; if (argv.resume === '') { @@ -825,15 +989,69 @@ export async function main() { } else if (argv.resume === '' || !cliConfig.isValidSessionId(argv.resume)) { // User cancelled the picker or no sessions found for the title if (argv.resume !== '') { - writeStderrLine(`No saved session found with title "${argv.resume}".`); - process.exit(1); + await exitStartup( + 1, + `No saved session found with title "${argv.resume}".`, + ); } else { - process.exit(0); + await exitStartup(0); } } // else: argv.resume is already a valid UUID, pass through to loadCliConfig } + if (argv.resume !== undefined) { + const { routeManagedAgentViewResume } = await import( + './startup/agent-view-resume.js' + ); + const hasOneShotInput = + argv.prompt !== undefined || + argv.promptInteractive !== undefined || + argv.query !== undefined || + argv.inputFile !== undefined || + argv.forkSession === true || + !process.stdin.isTTY; + if ( + await routeManagedAgentViewResume( + argv.resume, + process.env, + hasOneShotInput, + settings.merged.experimental?.agentView === true, + ) + ) { + await exitStartup( + typeof process.exitCode === 'number' ? process.exitCode : 1, + ); + } + } + + if (argv.resume !== undefined) { + const sessionService = new SessionService(process.cwd()); + if (argv.forkSession) { + const sourceSessionId = argv.resume; + const forkedSessionId = randomUUID(); + try { + await sessionService.forkSession(sourceSessionId, forkedSessionId); + } catch (error) { + await exitStartup( + 1, + `Failed to fork session ${sourceSessionId}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + argv = { + ...argv, + resume: forkedSessionId, + continue: false, + forkSession: false, + }; + } else if (!(await sessionService.loadSession(argv.resume))) { + await exitStartup( + 1, + `No saved session found with ID ${argv.resume}. Run \`qwen --resume\` without an ID to choose from existing sessions.`, + ); + } + } + // We are now past the logic handling potentially launching a child process // to run Qwen Code. It is now safe to perform expensive initialization that // may have side effects. @@ -852,25 +1070,68 @@ export async function main() { settingsWatcher?.startWatching(); markAcpStartup('configConstructionStart'); - const config = await loadCliConfig( - settings.merged, - argv.acp || argv.experimentalAcp - ? { ...argv, chatRecording: false } - : argv, - process.cwd(), - argv.extensions, - // Pass separated hooks for proper source attribution - { - userHooks: settings.getUserHooks(), - projectHooks: settings.getProjectHooks(), - }, - buildDisabledSkillNamesProvider(settings), - undefined, - settingsWatcher, - ); + let config: Config; + try { + config = await loadCliConfig( + settings.merged, + argv.acp || argv.experimentalAcp + ? { ...argv, chatRecording: false } + : argv, + process.cwd(), + argv.extensions, + // Pass separated hooks for proper source attribution + { + userHooks: settings.getUserHooks(), + projectHooks: settings.getProjectHooks(), + }, + buildDisabledSkillNamesProvider(settings), + undefined, + settingsWatcher, + ); + } catch (error) { + const cleanup = await discardCreatedStartupWorktree( + startupWorktreeContext, + ); + if (cleanup.error) { + writeStderrLine( + `Failed to clean up startup worktree: ${cleanup.error}`, + ); + } + throw error; + } markAcpStartup('configConstructionEnd'); profileCheckpoint('after_load_cli_config'); + { + const { + readAgentViewWorkerSidebandEnv, + reportAgentViewWorkerState, + sendAgentViewWorkerEvent, + startAgentViewWorkerHeartbeat, + } = await import('./agent-view/worker-sideband.js'); + const sideband = readAgentViewWorkerSidebandEnv(); + if (sideband) { + await sendAgentViewWorkerEvent({ + type: 'ready', + cwd: process.cwd(), + capabilities: ['ready', 'heartbeat', 'state'], + summary: config.getQuestion(), + }).catch((error) => { + debugLogger.debug( + `Agent View worker ready sideband failed: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + }); + await reportAgentViewWorkerState({ + sessionState: 'idle', + cwd: process.cwd(), + summary: config.getQuestion(), + }); + startAgentViewWorkerHeartbeat(); + } + } + const nonInteractiveHousekeeping = !config.isInteractive() || config.getExperimentalZedIntegration() ? await import('./utils/housekeeping/scheduler.js') @@ -965,8 +1226,6 @@ export async function main() { // Persist session usage for cross-session reports (must run before // config.shutdown() which clears telemetry state). - // sessionStartTime is read from uiTelemetryService so it stays correct - // after /clear resets the session (reset() updates the internal timestamp). registerCleanup(() => { try { const metrics = uiTelemetryService.getMetrics(); @@ -1032,7 +1291,10 @@ export async function main() { // the filter in startEarlyInputCapture absorbs the OSC 11 response // bytes so they cannot leak into the TUI input, even though our // probe attaches its own listener to parse the RGB value. - if (!configuredTheme || configuredTheme === AUTO_THEME_NAME) { + if ( + !isAgentViewWorker && + (!configuredTheme || configuredTheme === AUTO_THEME_NAME) + ) { themeAutoDetectionComplete = themeManager .resolveAutoThemeAsync() .catch((err) => { diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index be68d5673d2..e30c9616bec 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -2505,6 +2505,28 @@ export default { // === Core: added from PR #3328 === 'Open the memory manager.': 'Open the memory manager.', + 'Detach the current Agent View session.': + 'Detach the current Agent View session.', + 'Cannot detach Agent View while a question is waiting.': + 'Cannot detach Agent View while a question is waiting.', + 'Cannot detach Agent View while a tool confirmation is pending.': + 'Cannot detach Agent View while a tool confirmation is pending.', + 'Cannot detach Agent View while a command confirmation is pending.': + 'Cannot detach Agent View while a command confirmation is pending.', + 'Cannot detach Agent View while a foreground shell is active.': + 'Cannot detach Agent View while a foreground shell is active.', + 'Cannot detach Agent View while the background tasks dialog is open.': + 'Cannot detach Agent View while the background tasks dialog is open.', + 'Cannot detach Agent View while prompts are queued.': + 'Cannot detach Agent View while prompts are queued.', + 'Cannot detach Agent View while a turn is running.': + 'Cannot detach Agent View while a turn is running.', + 'Cannot detach Agent View before configuration is loaded.': + 'Cannot detach Agent View before configuration is loaded.', + 'Cannot detach Agent View before the session is saved.': + 'Cannot detach Agent View before the session is saved.', + 'Resume is disabled inside an attached background agent. Detach to `qwen agents` and use `/resume` there.': + 'Resume is disabled inside an attached background agent. Detach to `qwen agents` and use `/resume` there.', 'Show current process memory diagnostics': 'Show current process memory diagnostics', 'Record a CPU profile for Chrome DevTools analysis': diff --git a/packages/cli/src/i18n/locales/zh-TW.js b/packages/cli/src/i18n/locales/zh-TW.js index a5d88425ba6..9968f280311 100644 --- a/packages/cli/src/i18n/locales/zh-TW.js +++ b/packages/cli/src/i18n/locales/zh-TW.js @@ -2044,6 +2044,27 @@ export default { // === Core: added from PR #3328 === 'Open the memory manager.': '打開記憶管理器。', + 'Detach the current Agent View session.': '分離目前 Agent View 工作階段。', + 'Cannot detach Agent View while a question is waiting.': + '有提問等待回答時無法分離 Agent View。', + 'Cannot detach Agent View while a tool confirmation is pending.': + '有待確認的工具呼叫時無法分離 Agent View。', + 'Cannot detach Agent View while a command confirmation is pending.': + '有待確認的指令時無法分離 Agent View。', + 'Cannot detach Agent View while a foreground shell is active.': + '前景 shell 執行時無法分離 Agent View。', + 'Cannot detach Agent View while the background tasks dialog is open.': + '背景工作對話框開啟時無法分離 Agent View。', + 'Cannot detach Agent View while prompts are queued.': + '有排隊中的 prompt 時無法分離 Agent View。', + 'Cannot detach Agent View while a turn is running.': + '回合執行中無法分離 Agent View。', + 'Cannot detach Agent View before configuration is loaded.': + '設定載入完成前無法分離 Agent View。', + 'Cannot detach Agent View before the session is saved.': + '工作階段儲存前無法分離 Agent View。', + 'Resume is disabled inside an attached background agent. Detach to `qwen agents` and use `/resume` there.': + '在已 attach 的背景 agent 內停用 resume。請先分離回 `qwen agents`,再在那裡使用 `/resume`。', 'Show current process memory diagnostics': '顯示目前程序的內存診斷。', 'Record a CPU profile for Chrome DevTools analysis': '錄製 CPU 效能分析檔案,用於 Chrome DevTools 分析', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index ace713f98f1..5d0ada22655 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -2286,6 +2286,27 @@ export default { '[{{label}}] failed: {{error}}': '[{{label}}] 失败:{{error}}', 'Loading suggestions...': '正在加载建议...', 'Open the memory manager.': '打开记忆管理器。', + 'Detach the current Agent View session.': '分离当前 Agent View 会话。', + 'Cannot detach Agent View while a question is waiting.': + '有提问等待回答时无法分离 Agent View。', + 'Cannot detach Agent View while a tool confirmation is pending.': + '有待确认的工具调用时无法分离 Agent View。', + 'Cannot detach Agent View while a command confirmation is pending.': + '有待确认的命令时无法分离 Agent View。', + 'Cannot detach Agent View while a foreground shell is active.': + '前台 shell 运行时无法分离 Agent View。', + 'Cannot detach Agent View while the background tasks dialog is open.': + '后台任务对话框打开时无法分离 Agent View。', + 'Cannot detach Agent View while prompts are queued.': + '有排队的 prompt 时无法分离 Agent View。', + 'Cannot detach Agent View while a turn is running.': + '回合运行中无法分离 Agent View。', + 'Cannot detach Agent View before configuration is loaded.': + '配置加载完成前无法分离 Agent View。', + 'Cannot detach Agent View before the session is saved.': + '会话保存前无法分离 Agent View。', + 'Resume is disabled inside an attached background agent. Detach to `qwen agents` and use `/resume` there.': + '在已 attach 的后台 agent 内禁用 resume。请先分离回 `qwen agents`,再在那里使用 `/resume`。', 'Show current process memory diagnostics': '显示当前进程的内存诊断。', 'Record a CPU profile for Chrome DevTools analysis': '录制 CPU 性能分析文件,用于 Chrome DevTools 分析', diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts index 4647c1c6fb7..e709507975d 100644 --- a/packages/cli/src/nonInteractiveCliCommands.ts +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -210,6 +210,13 @@ function handleCommandResult( originalType: 'confirm_action', }; + case 'agent_view_detach': + return { + type: 'unsupported', + reason: 'Agent View detach is only supported in interactive mode.', + originalType: 'agent_view_detach', + }; + default: { // Exhaustiveness check const _exhaustive: never = result; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 79c6ee2bf4b..cd2423b359f 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -14,6 +14,7 @@ import { agentsCommand } from '../ui/commands/agentsCommand.js'; import { arenaCommand } from '../ui/commands/arenaCommand.js'; import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; +import { backgroundCommand } from '../ui/commands/background-command.js'; import { branchCommand } from '../ui/commands/branchCommand.js'; import { btwCommand } from '../ui/commands/btwCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; @@ -119,6 +120,7 @@ export class BuiltinCommandLoader implements ICommandLoader { arenaCommand, approvalModeCommand, authCommand, + backgroundCommand, branchCommand, btwCommand, forkCommand, diff --git a/packages/cli/src/startup/agent-view-resume-guard.test.ts b/packages/cli/src/startup/agent-view-resume-guard.test.ts new file mode 100644 index 00000000000..b20ac3025e5 --- /dev/null +++ b/packages/cli/src/startup/agent-view-resume-guard.test.ts @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { + AgentViewProcessState, + AgentViewSessionStateFile, +} from '../agent-view/protocol.js'; +import { + isManagedAgentViewContinueBlocked, + isManagedAgentViewDeleteBlocked, + isManagedAgentViewResumeBlocked, + releaseExitedManagedSessionForContinue, +} from './agent-view-resume-guard.js'; + +const mockReadAgentViewSessionState = vi.hoisted(() => vi.fn()); +const mockRequireValidWorkerToken = vi.hoisted(() => + vi.fn().mockResolvedValue(undefined), +); +const mockRelease = vi.hoisted(() => + vi.fn().mockResolvedValue({ released: true }), +); + +vi.mock('../agent-view/supervisor-store.js', () => ({ + readAgentViewSessionState: mockReadAgentViewSessionState, + readAgentViewSessionStateStrict: mockReadAgentViewSessionState, + sanitizeSessionId: (sessionId: string) => sessionId.toLowerCase(), +})); + +vi.mock('../agent-view/supervisor-runner.js', () => ({ + ensureAgentViewSupervisor: vi.fn(async () => ({ release: mockRelease })), +})); + +vi.mock('../agent-view/supervisor-process.js', () => ({ + requireValidWorkerToken: mockRequireValidWorkerToken, +})); + +describe('managed Agent View resume guards', () => { + beforeEach(() => { + mockReadAgentViewSessionState.mockReset(); + mockRequireValidWorkerToken.mockReset().mockResolvedValue(undefined); + mockRelease.mockReset().mockResolvedValue({ released: true }); + }); + + it('blocks --resume for managed sessions regardless of liveness', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await expect(isManagedAgentViewResumeBlocked('session-1')).resolves.toBe( + true, + ); + }); + + it('blocks --continue while a managed worker is still alive', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'alive')); + + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + true, + ); + }); + + it('allows --continue once the managed worker has exited', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + false, + ); + }); + + it('allows --continue once the managed worker is hibernated', async () => { + mockReadAgentViewSessionState.mockResolvedValue( + state('managed', 'hibernated'), + ); + + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + false, + ); + }); + + it('allows --continue for unmanaged and unknown sessions', async () => { + mockReadAgentViewSessionState.mockResolvedValue( + state('unmanaged', 'alive'), + ); + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + false, + ); + + mockReadAgentViewSessionState.mockResolvedValue(undefined); + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + false, + ); + }); + + it('never blocks the worker that owns the session', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'alive')); + + await expect( + isManagedAgentViewContinueBlocked('session-1', workerEnv('session-1')), + ).resolves.toBe(false); + + expect(mockRequireValidWorkerToken).toHaveBeenCalledWith( + 'session-1', + { token: 'token-1' }, + {}, + ); + expect(mockReadAgentViewSessionState).not.toHaveBeenCalled(); + }); + + it('normalizes worker and resume session ids before comparing them', async () => { + await expect( + isManagedAgentViewResumeBlocked('SESSION-1', workerEnv('session-1')), + ).resolves.toBe(false); + + expect(mockReadAgentViewSessionState).not.toHaveBeenCalled(); + }); + + it('does not bypass the guard for a two-key marker/session-id env', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'alive')); + + // Marker + matching session id alone (no sideband endpoint/token/cwd) + // must not exempt the process from the guard. + await expect( + isManagedAgentViewResumeBlocked('session-1', { + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_SESSION_ID: 'session-1', + }), + ).resolves.toBe(true); + + await expect( + isManagedAgentViewContinueBlocked('session-1', { + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_SESSION_ID: 'session-1', + }), + ).resolves.toBe(true); + }); + + it('does not bypass the guard for a forged or foreign worker env', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'alive')); + + // Lone QWEN_AGENT_VIEW_WORKER=1 without the sideband session id. + await expect( + isManagedAgentViewResumeBlocked('session-1', { + QWEN_AGENT_VIEW_WORKER: '1', + }), + ).resolves.toBe(true); + + // Worker env claiming a different session. + await expect( + isManagedAgentViewContinueBlocked('session-1', { + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_SESSION_ID: 'other-session', + }), + ).resolves.toBe(true); + }); + + it('does not bypass guards when a complete worker env has an invalid token', async () => { + mockRequireValidWorkerToken.mockRejectedValue( + new Error('Agent View worker token is invalid.'), + ); + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'alive')); + + await expect( + isManagedAgentViewResumeBlocked('session-1', workerEnv('session-1')), + ).resolves.toBe(true); + await expect( + isManagedAgentViewContinueBlocked('session-1', workerEnv('session-1')), + ).resolves.toBe(true); + + expect(mockReadAgentViewSessionState).toHaveBeenCalledTimes(2); + }); + + it('blocks --resume and --continue during the adopting window', async () => { + // /background adopt writes ownership 'adopting' and spawns the worker + // before patching 'managed'; both guards must already block. + mockReadAgentViewSessionState.mockResolvedValue( + state('adopting', 'starting'), + ); + + await expect(isManagedAgentViewResumeBlocked('session-1')).resolves.toBe( + true, + ); + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + true, + ); + await expect(isManagedAgentViewDeleteBlocked('session-1')).resolves.toBe( + true, + ); + }); + + it('fails closed when the delete guard cannot read session state', async () => { + mockReadAgentViewSessionState.mockRejectedValue(new Error('EIO')); + + await expect(isManagedAgentViewDeleteBlocked('session-1')).resolves.toBe( + true, + ); + }); + + it('blocks ordinary resume/continue while allowing an explicit release retry', async () => { + mockReadAgentViewSessionState.mockResolvedValue( + state('removing', 'exited'), + ); + + await expect(isManagedAgentViewResumeBlocked('session-1')).resolves.toBe( + true, + ); + await expect(isManagedAgentViewContinueBlocked('session-1')).resolves.toBe( + true, + ); + await expect(isManagedAgentViewDeleteBlocked('session-1')).resolves.toBe( + true, + ); + await expect( + releaseExitedManagedSessionForContinue('session-1'), + ).resolves.toBe(true); + expect(mockRelease).toHaveBeenCalledWith('session-1'); + }); + + it('does not release ownership during the adopting window', async () => { + mockReadAgentViewSessionState.mockResolvedValue( + state('adopting', 'starting'), + ); + + await expect( + releaseExitedManagedSessionForContinue('session-1'), + ).resolves.toBe(false); + + expect(mockRelease).not.toHaveBeenCalled(); + }); + + it('releases an exited managed session for foreground --continue', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + await expect( + releaseExitedManagedSessionForContinue('session-1'), + ).resolves.toBe(true); + + expect(mockRelease).toHaveBeenCalledWith('session-1'); + }); + + it('releases a hibernated managed session for foreground --continue', async () => { + mockReadAgentViewSessionState.mockResolvedValue( + state('managed', 'hibernated'), + ); + + await releaseExitedManagedSessionForContinue('session-1'); + + expect(mockRelease).toHaveBeenCalledWith('session-1'); + }); + + it('keeps ownership for live or unmanaged sessions', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'alive')); + await expect( + releaseExitedManagedSessionForContinue('session-1'), + ).resolves.toBe(false); + expect(mockRelease).not.toHaveBeenCalled(); + + mockReadAgentViewSessionState.mockResolvedValue( + state('unmanaged', 'exited'), + ); + await expect( + releaseExitedManagedSessionForContinue('session-1'), + ).resolves.toBe(true); + expect(mockRelease).not.toHaveBeenCalled(); + }); + + it('does not release ownership from inside a worker', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await releaseExitedManagedSessionForContinue( + 'session-1', + workerEnv('session-1'), + ); + + expect(mockRelease).not.toHaveBeenCalled(); + }); + + it('does not skip release when a complete worker env has an invalid token', async () => { + mockRequireValidWorkerToken.mockRejectedValue( + new Error('Agent View worker token is invalid.'), + ); + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await releaseExitedManagedSessionForContinue( + 'session-1', + workerEnv('session-1'), + ); + + expect(mockRelease).toHaveBeenCalledWith('session-1'); + }); + + it('releases ownership when the worker env belongs to another session', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await releaseExitedManagedSessionForContinue( + 'session-1', + workerEnv('other-session'), + ); + + expect(mockRelease).toHaveBeenCalledWith('session-1'); + }); + + it('still releases ownership when only a stray worker marker is set', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await releaseExitedManagedSessionForContinue('session-1', { + QWEN_AGENT_VIEW_WORKER: '1', + }); + + expect(mockRelease).toHaveBeenCalledWith('session-1'); + }); + + it('re-blocks continue when the managed session becomes live before release', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + mockRelease.mockRejectedValueOnce(new Error('session became active')); + + await expect( + releaseExitedManagedSessionForContinue('session-1'), + ).resolves.toBe(false); + }); + + it('does not start the supervisor to release ownership when the gate is disabled', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed', 'exited')); + + await expect( + releaseExitedManagedSessionForContinue('session-1', process.env, false), + ).resolves.toBe(false); + + expect(mockRelease).not.toHaveBeenCalled(); + }); +}); + +function workerEnv(sessionId: string): NodeJS.ProcessEnv { + return { + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_SESSION_ID: sessionId, + QWEN_AGENT_VIEW_SIDEBAND: 'unix:/tmp/qwen-agent-view.sock', + QWEN_AGENT_VIEW_TOKEN: 'token-1', + QWEN_AGENT_VIEW_ACTIVE_CWD: '/repo', + }; +} + +function state( + ownership: AgentViewSessionStateFile['ownership'], + processState: AgentViewProcessState, +): AgentViewSessionStateFile { + return { + schemaVersion: 1, + sessionId: 'session-1', + ownership, + sessionState: 'working', + processState, + attachState: 'detached', + projectCwd: '/project', + originalCwd: '/project', + activeCwd: '/project', + createdAt: '2026-07-17T00:00:00.000Z', + updatedAt: '2026-07-17T00:00:00.000Z', + worktree: { mode: 'none' }, + }; +} diff --git a/packages/cli/src/startup/agent-view-resume-guard.ts b/packages/cli/src/startup/agent-view-resume-guard.ts new file mode 100644 index 00000000000..5f0e1df120c --- /dev/null +++ b/packages/cli/src/startup/agent-view-resume-guard.ts @@ -0,0 +1,153 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + readAgentViewSessionState, + readAgentViewSessionStateStrict, + sanitizeSessionId, +} from '../agent-view/supervisor-store.js'; +import { requireValidWorkerToken } from '../agent-view/supervisor-process.js'; +import { ensureAgentViewSupervisor } from '../agent-view/supervisor-runner.js'; +import { readAgentViewWorkerSidebandEnv } from '../agent-view/worker-sideband.js'; + +export const MANAGED_AGENT_VIEW_RESUME_MESSAGE = + 'That session is still running as a background agent. Open `qwen agents` to attach to it, or remove it from Agent View first to resume here.'; + +export const AGENT_VIEW_WORKER_RESUME_MESSAGE = + 'Resume is disabled inside an attached background agent. Detach to `qwen agents` and use `/resume` there.'; + +export const MANAGED_AGENT_VIEW_ONE_SHOT_RESUME_MESSAGE = + 'Cannot use one-shot input (-p/--prompt, -i, --input-file, --fork-session, or piped stdin) with --resume of a session that is still running as a background agent. Use `qwen agents attach ` to interact with it instead.'; + +export const MANAGED_AGENT_VIEW_DELETE_MESSAGE = + 'That session is still running as a background agent. Stop or remove it from `qwen agents` before deleting it here.'; + +async function isSessionWorker( + sessionId: string, + env: NodeJS.ProcessEnv, +): Promise { + const sideband = readAgentViewWorkerSidebandEnv(env); + if ( + !sideband || + sanitizeSessionId(sideband.sessionId) !== sanitizeSessionId(sessionId) + ) { + return false; + } + try { + await requireValidWorkerToken(sessionId, { token: sideband.token }, {}); + return true; + } catch { + return false; + } +} + +export async function isManagedAgentViewResumeBlocked( + sessionId: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (await isSessionWorker(sessionId, env)) return false; + const state = await readAgentViewSessionState(sessionId); + // Block during ownership transitions too: /background adopt writes + // 'adopting', spawns the --resume worker, and only patches 'managed' + // afterwards; 'removing' may still have a live host or durable cleanup to + // finish. A concurrent foreground resume would race either transition. + return ( + state?.ownership === 'managed' || + state?.ownership === 'adopting' || + state?.ownership === 'removing' + ); +} + +/** + * `--continue` releases an exited managed session through the supervisor + * before resuming it in the foreground. Block live sessions and ownership + * transitions; callers may explicitly retry an interrupted removal through + * the release RPC below. + */ +export async function isManagedAgentViewContinueBlocked( + sessionId: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (await isSessionWorker(sessionId, env)) return false; + const state = await readAgentViewSessionState(sessionId); + return ( + (state?.ownership === 'managed' && + state.processState !== 'exited' && + state.processState !== 'hibernated') || + state?.ownership === 'adopting' || + state?.ownership === 'removing' + ); +} + +/** + * `/delete` removes transcripts, archives and file-history backups, so a + * managed session that is still alive must not be deletable mid-run. Ownership + * transitions are blocked because their host liveness is not yet settled. + */ +export async function isManagedAgentViewDeleteBlocked( + sessionId: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + if (await isSessionWorker(sessionId, env)) return false; + try { + const state = await readAgentViewSessionStateStrict(sessionId); + return ( + (state?.ownership === 'managed' && state.processState !== 'exited') || + state?.ownership === 'adopting' || + state?.ownership === 'removing' + ); + } catch { + return true; + } +} + +/** + * When the foreground `--continue` path takes over an exited managed + * session, drop the roster ownership so a later `qwen agents attach` cannot + * respawn a second worker underneath the live foreground runtime. + */ +export async function releaseExitedManagedSessionForContinue( + sessionId: string, + env: NodeJS.ProcessEnv = process.env, + agentViewEnabled = true, +): Promise { + // Same strict predicate as the /resume block: a lone marker must not + // suppress the release in an ordinary foreground session. + if (await isSessionWorker(sessionId, env)) return true; + const state = await readAgentViewSessionState(sessionId); + if (state?.ownership === 'adopting') return false; + if (state?.ownership !== 'managed' && state?.ownership !== 'removing') { + return true; + } + if ( + state.ownership === 'managed' && + state.processState !== 'exited' && + state.processState !== 'hibernated' + ) { + return false; + } + if (!agentViewEnabled) return false; + try { + const result = await (await ensureAgentViewSupervisor()).release(sessionId); + return ( + typeof result === 'object' && + result !== null && + 'released' in result && + result.released === true + ); + } catch { + return false; + } +} + +export function isAgentViewWorkerResumeCommandBlocked( + env: NodeJS.ProcessEnv = process.env, +): boolean { + // Require the full sideband env, not a lone QWEN_AGENT_VIEW_WORKER=1: a + // stray marker (shell-profile export, leftover experiment) must not + // disable /resume in an ordinary foreground session. + return readAgentViewWorkerSidebandEnv(env) !== undefined; +} diff --git a/packages/cli/src/startup/agent-view-resume-sessions.ts b/packages/cli/src/startup/agent-view-resume-sessions.ts new file mode 100644 index 00000000000..bd6bfaf5fdc --- /dev/null +++ b/packages/cli/src/startup/agent-view-resume-sessions.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SessionService, + type SessionListItem, +} from '@qwen-code/qwen-code-core'; +import { + listAgentViewSessionSnapshots, + readAgentViewSessionState, +} from '../agent-view/supervisor-store.js'; +import { readAgentViewWorkerSidebandEnv } from '../agent-view/worker-sideband.js'; + +export type AgentViewResumeSessionListItem = SessionListItem & { + agentViewManaged?: boolean; + agentViewLastResult?: string; +}; + +export async function listManagedAgentViewResumeSessions(): Promise< + AgentViewResumeSessionListItem[] +> { + const snapshots = await listAgentViewSessionSnapshots(); + return snapshots + .filter((snapshot) => snapshot.state.ownership === 'managed') + .map((snapshot) => { + const prompt = + snapshot.rosterEntry?.displayName ?? + snapshot.activity?.summary ?? + snapshot.launch?.initialPrompt ?? + snapshot.sessionId; + return { + sessionId: snapshot.sessionId, + cwd: snapshot.state.projectCwd, + startTime: snapshot.state.createdAt, + mtime: toMtime( + snapshot.activity?.lastActivityAt ?? snapshot.state.updatedAt, + ), + prompt, + filePath: '', + agentViewManaged: true, + ...(snapshot.activity?.lastResult + ? { agentViewLastResult: snapshot.activity.lastResult } + : {}), + ...(snapshot.rosterEntry?.displayName + ? { + customTitle: snapshot.rosterEntry.displayName, + titleSource: 'manual' as const, + } + : {}), + }; + }); +} + +export async function listAgentViewProjectResumeSessions(): Promise< + SessionListItem[] +> { + const sessionService = await getAgentViewProjectSessionService(); + if (!sessionService) return []; + + const result = await sessionService.listSessions({ + size: 100, + }); + return result.items; +} + +export async function getAgentViewProjectSessionService(): Promise< + SessionService | undefined +> { + const worker = readAgentViewWorkerSidebandEnv(); + if (!worker) return undefined; + + const state = await readAgentViewSessionState(worker.sessionId); + if (!state?.projectCwd || state.projectCwd === state.activeCwd) { + return undefined; + } + + return new SessionService(state.projectCwd); +} + +function toMtime(value: string): number { + const time = new Date(value).getTime(); + return Number.isNaN(time) ? Date.now() : time; +} diff --git a/packages/cli/src/startup/agent-view-resume.test.ts b/packages/cli/src/startup/agent-view-resume.test.ts new file mode 100644 index 00000000000..0ed999d1357 --- /dev/null +++ b/packages/cli/src/startup/agent-view-resume.test.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { AgentViewSessionStateFile } from '../agent-view/protocol.js'; +import { createAgentViewWorkerSidebandEnv } from '../agent-view/worker-sideband.js'; +import { routeManagedAgentViewResume } from './agent-view-resume.js'; +import { isAgentViewWorkerResumeCommandBlocked } from './agent-view-resume-guard.js'; + +const mockReadAgentViewSessionState = vi.hoisted(() => vi.fn()); +const mockRequireValidWorkerToken = vi.hoisted(() => + vi.fn().mockResolvedValue(undefined), +); +const mockWriteStderrLine = vi.hoisted(() => vi.fn()); +const mockAttach = vi.hoisted(() => vi.fn()); +const mockEnsureAgentViewSupervisor = vi.hoisted(() => + vi.fn(async () => ({ attach: mockAttach })), +); + +vi.mock('../agent-view/supervisor-store.js', () => ({ + readAgentViewSessionState: mockReadAgentViewSessionState, + sanitizeSessionId: (sessionId: string) => sessionId.toLowerCase(), +})); + +vi.mock('../utils/stdioHelpers.js', () => ({ + writeStderrLineSafe: mockWriteStderrLine, +})); + +vi.mock('../agent-view/supervisor-runner.js', () => ({ + ensureAgentViewSupervisor: mockEnsureAgentViewSupervisor, +})); + +vi.mock('../agent-view/supervisor-process.js', () => ({ + requireValidWorkerToken: mockRequireValidWorkerToken, +})); + +describe('routeManagedAgentViewResume', () => { + beforeEach(() => { + process.exitCode = undefined; + mockReadAgentViewSessionState.mockReset(); + mockRequireValidWorkerToken.mockReset().mockResolvedValue(undefined); + mockWriteStderrLine.mockReset(); + mockAttach.mockReset(); + mockEnsureAgentViewSupervisor.mockClear(); + }); + + it('routes managed Agent View resumes to supervisor attach', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed')); + + await expect(routeManagedAgentViewResume('session-1')).resolves.toBe(true); + + expect(mockReadAgentViewSessionState).toHaveBeenCalledWith('session-1'); + expect(mockEnsureAgentViewSupervisor).toHaveBeenCalledOnce(); + expect(mockAttach).toHaveBeenCalledWith('session-1'); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + 'Session session-1 is managed by Agent View; attaching via supervisor...', + ); + expect(process.exitCode).toBe(0); + }); + + it('rejects one-shot input for managed Agent View resumes', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed')); + + await expect( + routeManagedAgentViewResume('session-1', process.env, true), + ).resolves.toBe(true); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(mockAttach).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Cannot use one-shot input'), + ); + expect(process.exitCode).toBe(1); + }); + + it('blocks managed resumes when Agent View is disabled', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed')); + + await expect( + routeManagedAgentViewResume('session-1', process.env, false, false), + ).resolves.toBe(true); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Agent View is disabled'), + ); + expect(process.exitCode).toBe(1); + }); + + it('reports the disabled gate before one-shot restrictions', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed')); + + await expect( + routeManagedAgentViewResume('session-1', process.env, true, false), + ).resolves.toBe(true); + + expect(mockEnsureAgentViewSupervisor).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Agent View is disabled'), + ); + expect(mockWriteStderrLine).not.toHaveBeenCalledWith( + expect.stringContaining('Cannot use one-shot input'), + ); + expect(process.exitCode).toBe(1); + }); + + it('marks managed Agent View resume failures as unsuccessful', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed')); + mockAttach.mockRejectedValueOnce(new Error('attach failed')); + + await expect(routeManagedAgentViewResume('session-1')).resolves.toBe(true); + + expect(mockWriteStderrLine).toHaveBeenCalledWith('attach failed'); + expect(process.exitCode).toBe(1); + }); + + it('continues native resume when the Agent View session is missing', async () => { + mockReadAgentViewSessionState.mockResolvedValue(undefined); + + await expect(routeManagedAgentViewResume('session-1')).resolves.toBe(false); + + expect(mockWriteStderrLine).not.toHaveBeenCalled(); + }); + + it('continues native resume for unmanaged Agent View sessions', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('unmanaged')); + + await expect(routeManagedAgentViewResume('session-1')).resolves.toBe(false); + + expect(mockWriteStderrLine).not.toHaveBeenCalled(); + }); + + it('continues native resume inside an Agent View worker', async () => { + mockReadAgentViewSessionState.mockResolvedValue(state('managed')); + + await expect( + routeManagedAgentViewResume( + 'session-1', + createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: 'unix:/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }), + ), + ).resolves.toBe(false); + + expect(mockRequireValidWorkerToken).toHaveBeenCalledWith( + 'session-1', + { token: 'token-1' }, + {}, + ); + expect(mockReadAgentViewSessionState).not.toHaveBeenCalled(); + expect(mockWriteStderrLine).not.toHaveBeenCalled(); + }); +}); + +describe('isAgentViewWorkerResumeCommandBlocked', () => { + it('blocks /resume only inside a fully-initialized attached worker', () => { + expect( + isAgentViewWorkerResumeCommandBlocked( + createAgentViewWorkerSidebandEnv({ + sessionId: 'session-1', + sidebandEndpoint: 'unix:/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }), + ), + ).toBe(true); + }); + + it('does not block on a stray worker marker alone', () => { + expect( + isAgentViewWorkerResumeCommandBlocked({ QWEN_AGENT_VIEW_WORKER: '1' }), + ).toBe(false); + expect(isAgentViewWorkerResumeCommandBlocked({})).toBe(false); + }); +}); + +function state( + ownership: AgentViewSessionStateFile['ownership'], +): AgentViewSessionStateFile { + return { + schemaVersion: 1, + sessionId: 'session-1', + ownership, + sessionState: 'working', + processState: 'alive', + attachState: 'detached', + projectCwd: '/project', + originalCwd: '/project', + activeCwd: '/project', + createdAt: '2026-07-17T00:00:00.000Z', + updatedAt: '2026-07-17T00:00:00.000Z', + worktree: { mode: 'none' }, + }; +} diff --git a/packages/cli/src/startup/agent-view-resume.ts b/packages/cli/src/startup/agent-view-resume.ts new file mode 100644 index 00000000000..a9777204df0 --- /dev/null +++ b/packages/cli/src/startup/agent-view-resume.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { writeStderrLineSafe } from '../utils/stdioHelpers.js'; +import { ensureAgentViewSupervisor } from '../agent-view/supervisor-runner.js'; +import { + isManagedAgentViewResumeBlocked, + MANAGED_AGENT_VIEW_ONE_SHOT_RESUME_MESSAGE, +} from './agent-view-resume-guard.js'; +import { AGENT_VIEW_DISABLED_MESSAGE } from '../agent-view/feature.js'; + +export async function routeManagedAgentViewResume( + sessionId: string | undefined, + env: NodeJS.ProcessEnv = process.env, + hasOneShotInput = false, + agentViewEnabled = true, +): Promise { + if (!sessionId) return false; + if (!(await isManagedAgentViewResumeBlocked(sessionId, env))) { + return false; + } + if (!agentViewEnabled) { + writeStderrLineSafe(AGENT_VIEW_DISABLED_MESSAGE); + process.exitCode = 1; + return true; + } + // Attach is an interactive bridge; one-shot input would be silently + // dropped, so reject the combination instead of swallowing it. + if (hasOneShotInput) { + writeStderrLineSafe(MANAGED_AGENT_VIEW_ONE_SHOT_RESUME_MESSAGE); + process.exitCode = 1; + return true; + } + try { + writeStderrLineSafe( + `Session ${sessionId} is managed by Agent View; attaching via supervisor...`, + ); + const supervisor = await ensureAgentViewSupervisor(); + await supervisor.attach(sessionId); + process.exitCode = 0; + } catch (error) { + writeStderrLineSafe(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } + return true; +} diff --git a/packages/cli/src/startup/worktreeStartup.test.ts b/packages/cli/src/startup/worktreeStartup.test.ts index 00762245153..52091f5b4d9 100644 --- a/packages/cli/src/startup/worktreeStartup.test.ts +++ b/packages/cli/src/startup/worktreeStartup.test.ts @@ -13,6 +13,7 @@ import * as path from 'node:path'; import { setupStartupWorktree, + discardCreatedStartupWorktree, buildStartupWorktreeNotice, persistStartupWorktreeSidecar, } from './worktreeStartup.js'; @@ -137,6 +138,169 @@ describe('setupStartupWorktree', () => { } }); + it('discards a newly-created worktree before an early startup exit', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('managed-resume'); + expect(res?.ok).toBe(true); + if (!res?.ok) return; + + await expect(discardCreatedStartupWorktree(res.context)).resolves.toEqual( + {}, + ); + + expect(process.cwd()).toBe(tempRepo); + await expect(fs.stat(res.context.worktreePath)).rejects.toThrow(); + const { stdout } = await exec('git', ['worktree', 'list', '--porcelain'], { + cwd: tempRepo, + }); + expect(stdout).not.toContain(res.context.worktreePath); + const { stdout: branches } = await exec( + 'git', + ['branch', '--list', res.context.branch], + { cwd: tempRepo }, + ); + expect(branches.trim()).toBe(''); + }); + + it('discards a clean worktree with a CLI-created configured symlink', async () => { + tempRepo = await makeTempRepo(); + await fs.writeFile(path.join(tempRepo, '.env.local'), 'secret'); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('managed-resume-symlink', { + symlinkDirectories: ['.env.local'], + }); + expect(res?.ok).toBe(true); + if (!res?.ok) return; + expect(res.context.createdSymlinkPaths).toEqual(['.env.local']); + expect( + ( + await fs.lstat(path.join(res.context.worktreePath, '.env.local')) + ).isSymbolicLink(), + ).toBe(true); + + await expect(discardCreatedStartupWorktree(res.context)).resolves.toEqual( + {}, + ); + + expect(process.cwd()).toBe(tempRepo); + await expect(fs.stat(res.context.worktreePath)).rejects.toThrow(); + }); + + it('does not treat configured symlink names as git pathspec patterns', async () => { + tempRepo = await makeTempRepo(); + await fs.writeFile(path.join(tempRepo, '[secret]'), 'secret'); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('managed-resume-literal-symlink', { + symlinkDirectories: ['[secret]'], + }); + expect(res?.ok).toBe(true); + if (!res?.ok) return; + await fs.writeFile(path.join(res.context.worktreePath, 's'), 'user work'); + + await expect(discardCreatedStartupWorktree(res.context)).resolves.toEqual({ + preserved: expect.stringContaining('uncommitted changes'), + }); + expect((await fs.stat(res.context.worktreePath)).isDirectory()).toBe(true); + }); + + it('preserves a configured symlink replaced with user work', async () => { + tempRepo = await makeTempRepo(); + await fs.writeFile(path.join(tempRepo, '.env.local'), 'secret'); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('managed-resume-replaced-symlink', { + symlinkDirectories: ['.env.local'], + }); + expect(res?.ok).toBe(true); + if (!res?.ok) return; + const worktreeEnv = path.join(res.context.worktreePath, '.env.local'); + await fs.unlink(worktreeEnv); + await fs.writeFile(worktreeEnv, 'user work'); + + await expect(discardCreatedStartupWorktree(res.context)).resolves.toEqual({ + preserved: expect.stringContaining('uncommitted changes'), + }); + expect((await fs.stat(res.context.worktreePath)).isDirectory()).toBe(true); + }); + + it('preserves a newly-created worktree when another process may own work', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const res = await setupStartupWorktree('managed-resume-in-use'); + expect(res?.ok).toBe(true); + if (!res?.ok) return; + const context = res.context; + + const pendingPath = path.join(context.worktreePath, 'pending.txt'); + await fs.writeFile(pendingPath, 'pending'); + await expect(discardCreatedStartupWorktree(context)).resolves.toEqual({ + preserved: expect.stringContaining('uncommitted changes'), + }); + await fs.rm(pendingPath); + + await writeWorktreeSessionMarker(context.worktreePath, 'other-session'); + await expect(discardCreatedStartupWorktree(context)).resolves.toEqual({ + preserved: expect.stringContaining('owned by session other-session'), + }); + await fs.rm(path.join(context.worktreePath, '.qwen-session')); + + await exec('git', ['tag', context.branch, context.originalHeadCommit], { + cwd: tempRepo, + }); + await fs.writeFile( + path.join(context.worktreePath, 'committed.txt'), + 'work', + ); + await exec('git', ['add', 'committed.txt'], { cwd: context.worktreePath }); + await exec('git', ['commit', '-m', 'work from another process'], { + cwd: context.worktreePath, + }); + const { stdout: branchHead } = await exec('git', ['rev-parse', 'HEAD'], { + cwd: context.worktreePath, + }); + await expect(discardCreatedStartupWorktree(context)).resolves.toEqual({ + preserved: expect.stringContaining( + `branch ${context.branch} changed after startup`, + ), + }); + + expect((await fs.stat(context.worktreePath)).isDirectory()).toBe(true); + const { stdout: preservedBranchHead } = await exec( + 'git', + ['rev-parse', `refs/heads/${context.branch}`], + { cwd: tempRepo }, + ); + expect(preservedBranchHead.trim()).toBe(branchHead.trim()); + }); + + it('keeps a reattached worktree on an early startup exit', async () => { + tempRepo = await makeTempRepo(); + process.chdir(tempRepo); + + const first = await setupStartupWorktree('managed-resume-existing'); + expect(first?.ok).toBe(true); + if (!first?.ok) return; + process.chdir(tempRepo); + const second = await setupStartupWorktree('managed-resume-existing'); + expect(second?.ok).toBe(true); + if (!second?.ok) return; + expect(second.context.wasReattached).toBe(true); + + await expect( + discardCreatedStartupWorktree(second.context), + ).resolves.toEqual({}); + + expect(process.cwd()).toBe(second.context.worktreePath); + expect((await fs.stat(second.context.worktreePath)).isDirectory()).toBe( + true, + ); + }); + it('rejects invalid slug characters before any git operation', async () => { tempRepo = await makeTempRepo(); process.chdir(tempRepo); diff --git a/packages/cli/src/startup/worktreeStartup.ts b/packages/cli/src/startup/worktreeStartup.ts index cd40e0d3d56..4b10758fa48 100644 --- a/packages/cli/src/startup/worktreeStartup.ts +++ b/packages/cli/src/startup/worktreeStartup.ts @@ -21,6 +21,7 @@ * those need a constructed `Config` and live in {@link persistStartupWorktreeSidecar}. */ +import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { createDebugLogger, @@ -77,6 +78,7 @@ export interface StartupWorktreeContext { * session's new commits. */ wasReattached: boolean; + createdSymlinkPaths: string[]; } export type SetupStartupWorktreeResult = @@ -252,6 +254,7 @@ export async function setupStartupWorktree( originalHeadCommit: registered.headCommit, isPullRequest, wasReattached: true, + createdSymlinkPaths: [], }, }; } @@ -330,10 +333,76 @@ export async function setupStartupWorktree( : originalHeadCommit, isPullRequest, wasReattached: false, + createdSymlinkPaths: result.createdSymlinkPaths ?? [], }, }; } +export interface DiscardStartupWorktreeResult { + preserved?: string; + error?: string; +} + +export async function discardCreatedStartupWorktree( + context: StartupWorktreeContext | null, +): Promise { + if (context === null || context.wasReattached) return {}; + try { + process.chdir(context.repoRoot); + const service = new GitWorktreeService(context.repoRoot); + const owner = await readWorktreeSessionMarker(context.worktreePath); + if (owner !== null) { + return { + preserved: `worktree ${context.worktreePath} is owned by session ${owner}`, + }; + } + const intactCreatedSymlinkPaths: string[] = []; + for (const entry of context.createdSymlinkPaths) { + try { + const sourcePath = path.join(context.repoRoot, entry); + const worktreePath = path.join(context.worktreePath, entry); + const [stat, sourceTarget, worktreeTarget] = await Promise.all([ + fs.lstat(worktreePath), + fs.realpath(sourcePath), + fs.realpath(worktreePath), + ]); + if (stat.isSymbolicLink() && sourceTarget === worktreeTarget) { + intactCreatedSymlinkPaths.push(entry); + } + } catch { + // A missing or changed link is user-visible work and must stay dirty. + } + } + if ( + await service.hasWorktreeChanges( + context.worktreePath, + intactCreatedSymlinkPaths, + ) + ) { + return { + preserved: `worktree ${context.worktreePath} has uncommitted changes`, + }; + } + const branchHead = await service.resolveRef(`refs/heads/${context.branch}`); + if (branchHead !== context.originalHeadCommit) { + return { + preserved: `worktree branch ${context.branch} changed after startup`, + }; + } + const result = await service.removeUserWorktree(context.slug, { + deleteBranch: true, + forceDeleteBranch: true, + }); + return result.success + ? {} + : { + error: result.error ?? `failed to remove ${context.worktreePath}`, + }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + /** * Result of the post-`loadCliConfig` sidecar persist step. Callers use the * boolean fields to decide whether to surface an INFO line in TUI / a diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 9f9e22be8a5..ae695334288 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -4,14 +4,69 @@ * SPDX-License-Identifier: Apache-2.0 */ -const { writeTerminalTitleSpy, useWakeRepaintMock, buildWakeRepaintSpy } = - vi.hoisted(() => ({ - writeTerminalTitleSpy: vi.fn(), - useWakeRepaintMock: vi.fn(), - buildWakeRepaintSpy: vi.fn((deps: Record) => - vi.fn(() => deps), +import type { AgentViewWorkerSidebandEnv } from '../agent-view/worker-sideband.js'; + +const { + writeTerminalTitleSpy, + useWakeRepaintMock, + buildWakeRepaintSpy, + buildCurrentCliArgvMock, +} = vi.hoisted(() => ({ + writeTerminalTitleSpy: vi.fn(), + useWakeRepaintMock: vi.fn(), + buildWakeRepaintSpy: vi.fn((deps: Record) => + vi.fn(() => deps), + ), + buildCurrentCliArgvMock: vi.fn(), +})); + +const agentViewHandoffMocks = vi.hoisted(() => ({ + detachCurrentSession: vi.fn(async () => ({ sessionId: 'session-1' })), + readWorkerSideband: vi.fn<() => AgentViewWorkerSidebandEnv | undefined>( + () => undefined, + ), + sendWorkerEvent: vi.fn(async () => undefined), + reportWorkerState: vi.fn(async () => undefined), +})); + +const agentViewStateMock = vi.hoisted(() => + vi.fn(() => ({ + activeView: 'main', + agents: new Map(), + agentShellFocused: false as boolean, + })), +); + +vi.mock('../agent-view/managed-detach.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + detachCurrentSessionToAgentView: agentViewHandoffMocks.detachCurrentSession, + }; +}); + +vi.mock('../agent-view/worker-sideband.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + readAgentViewWorkerSidebandEnv: agentViewHandoffMocks.readWorkerSideband, + sendAgentViewWorkerEvent: agentViewHandoffMocks.sendWorkerEvent, + reportAgentViewWorkerState: agentViewHandoffMocks.reportWorkerState, + }; +}); + +vi.mock('../agent-view/current-cli-argv.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + buildCurrentQwenCliArgv: buildCurrentCliArgvMock.mockImplementation( + actual.buildCurrentQwenCliArgv, ), - })); + }; +}); vi.mock('./hooks/use-wake-repaint.js', () => ({ useWakeRepaint: useWakeRepaintMock, @@ -55,6 +110,7 @@ import { isInputActiveForState, isRenderModeToggleKey, mergeStartupWarnings, + runAgentViewRosterCommand, shouldAutoOpenSkillReview, shouldDrainMessageQueue, useQueuedSubmissionDrain, @@ -160,10 +216,7 @@ vi.mock('./hooks/useProviderUpdates.js', () => ({ vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); vi.mock('./contexts/AgentViewContext.js', () => ({ - useAgentViewState: vi.fn(() => ({ - activeView: 'main', - agents: new Map(), - })), + useAgentViewState: agentViewStateMock, useAgentViewActions: vi.fn(() => ({ switchToAgent: vi.fn(), switchToNext: vi.fn(), @@ -211,6 +264,9 @@ import { useKeypress, type Key } from './hooks/useKeypress.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; import { clearCiEnv } from '../test-utils/ci-env.js'; import { restorePromptStash } from '../services/prompt-stash.js'; +import { runExitCleanup } from '../utils/cleanup.js'; + +type SpawnSync = typeof import('node:child_process').spawnSync; describe('AppContainer State Management', () => { let mockConfig: Config; @@ -263,6 +319,17 @@ describe('AppContainer State Management', () => { beforeEach(() => { vi.clearAllMocks(); + agentViewHandoffMocks.detachCurrentSession.mockResolvedValue({ + sessionId: 'session-1', + }); + agentViewHandoffMocks.readWorkerSideband.mockReturnValue(undefined); + agentViewHandoffMocks.sendWorkerEvent.mockResolvedValue(undefined); + agentViewHandoffMocks.reportWorkerState.mockResolvedValue(undefined); + agentViewStateMock.mockReturnValue({ + activeView: 'main', + agents: new Map(), + agentShellFocused: false, + }); restoreCiEnv = clearCiEnv(); vi.stubEnv('TERM', 'xterm-256color'); originalStdoutIsTTY = process.stdout.isTTY; @@ -359,6 +426,7 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -698,7 +766,119 @@ describe('AppContainer State Management', () => { }); }); + it('runs Agent View roster through the current CLI entrypoint', () => { + const spawnSyncSpy = vi.fn(() => ({ status: 0 })); + const originalArgv1 = process.argv[1]; + process.argv[1] = '/workspace/qwen-code/packages/cli/dist/src/cli.js'; + try { + expect( + runAgentViewRosterCommand( + '/workspace/qwen-code', + spawnSyncSpy as unknown as SpawnSync, + ), + ).toBe(0); + } finally { + process.argv[1] = originalArgv1; + } + + expect(spawnSyncSpy).toHaveBeenCalledWith( + process.execPath, + [ + '/workspace/qwen-code/packages/cli/dist/src/cli.js', + 'agents', + '--cwd', + '/workspace/qwen-code', + ], + expect.objectContaining({ + stdio: 'inherit', + env: expect.objectContaining({ + QWEN_CODE_NO_RELAUNCH: '1', + }), + }), + ); + }); + + it('treats signal-killed Agent View roster process as failed', () => { + const spawnSyncSpy = vi.fn(() => ({ status: null, signal: 'SIGTERM' })); + const originalArgv1 = process.argv[1]; + process.argv[1] = '/workspace/qwen-code/packages/cli/dist/src/cli.js'; + try { + expect( + runAgentViewRosterCommand( + '/workspace/qwen-code', + spawnSyncSpy as unknown as SpawnSync, + ), + ).toBe(1); + } finally { + process.argv[1] = originalArgv1; + } + }); + + it('reports spawn failures from the Agent View roster relaunch', () => { + const spawnError = new Error('spawn ENOENT'); + const consoleErrorSpy = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + const spawnSyncSpy = vi.fn(() => ({ + status: null, + signal: null, + error: spawnError, + })); + const originalArgv1 = process.argv[1]; + process.argv[1] = '/workspace/qwen-code/packages/cli/dist/src/cli.js'; + try { + expect( + runAgentViewRosterCommand( + '/workspace/qwen-code', + spawnSyncSpy as unknown as SpawnSync, + ), + ).toBe(1); + expect(spawnSyncSpy).toHaveBeenCalledTimes(1); + expect(consoleErrorSpy).toHaveBeenCalledWith(spawnError); + } finally { + process.argv[1] = originalArgv1; + consoleErrorSpy.mockRestore(); + } + }); + describe('Basic Rendering', () => { + it('reports working after an Agent View worker starts responding', async () => { + agentViewHandoffMocks.readWorkerSideband.mockReturnValue({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/agent-view.sock', + token: 'token', + activeCwd: '/test/workspace', + }); + mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], + streamingState: StreamingState.Responding, + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + clearPendingState: mockClearPendingState, + }); + + render( + , + ); + + await vi.waitFor(() => { + expect(agentViewHandoffMocks.reportWorkerState).toHaveBeenCalledWith({ + sessionState: 'working', + }); + }); + }); + it('continues quitting when cancelling the active request fails', () => { vi.useFakeTimers(); const cancelOngoingRequest = vi.fn(() => { @@ -706,6 +886,7 @@ describe('AppContainer State Management', () => { }); const requestShutdown = vi.fn(); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: StreamingState.Responding, submitQuery: vi.fn(), initError: null, @@ -743,6 +924,128 @@ describe('AppContainer State Management', () => { expect(vi.getTimerCount()).toBe(timerCount + 1); }); + it('exits the foreground runtime after handing it to Agent View', async () => { + buildCurrentCliArgvMock.mockReturnValueOnce([ + process.execPath, + '-e', + 'process.exit(0)', + ]); + const requestShutdown = vi.fn(); + const flush = vi.fn().mockResolvedValue(undefined); + vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({ + requestShutdown, + } as unknown as GeminiClient); + vi.spyOn(mockConfig, 'getChatRecordingService').mockReturnValue({ + flush, + } as unknown as NonNullable< + ReturnType + >); + const exit = vi + .spyOn(process, 'exit') + .mockImplementation((() => undefined) as never); + + try { + render( + , + ); + const actions = mockedUseSlashCommandProcessor.mock.calls.at( + -1, + )?.[12] as { detachAgentViewSession: () => Promise } | undefined; + + await actions?.detachAgentViewSession(); + + expect(agentViewHandoffMocks.detachCurrentSession).toHaveBeenCalledWith( + mockConfig, + { terminal: { columns: 80, rows: 24 } }, + ); + expect(requestShutdown).toHaveBeenCalledOnce(); + expect(runExitCleanup).toHaveBeenCalledOnce(); + expect(exit).toHaveBeenCalledWith(0); + expect(flush).toHaveBeenCalledOnce(); + expect(flush.mock.invocationCallOrder[0]).toBeLessThan( + agentViewHandoffMocks.detachCurrentSession.mock + .invocationCallOrder[0], + ); + expect( + agentViewHandoffMocks.detachCurrentSession.mock + .invocationCallOrder[0], + ).toBeLessThan(requestShutdown.mock.invocationCallOrder[0]); + expect(requestShutdown.mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(runExitCleanup).mock.invocationCallOrder[0], + ); + expect( + vi.mocked(runExitCleanup).mock.invocationCallOrder[0], + ).toBeLessThan(exit.mock.invocationCallOrder[0]); + } finally { + exit.mockRestore(); + } + }); + + it('detaches an attached worker through sideband without adopting it', async () => { + agentViewHandoffMocks.readWorkerSideband.mockReturnValue({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/agent-view.sock', + token: 'token', + activeCwd: '/repo', + }); + const requestShutdown = vi.fn(); + vi.spyOn(mockConfig, 'getGeminiClient').mockReturnValue({ + requestShutdown, + } as unknown as GeminiClient); + render( + , + ); + const actions = mockedUseSlashCommandProcessor.mock.calls.at(-1)?.[12] as + | { detachAgentViewSession: () => Promise } + | undefined; + + await actions?.detachAgentViewSession(); + + expect(agentViewHandoffMocks.sendWorkerEvent).toHaveBeenCalledWith({ + type: 'detach', + }); + expect(agentViewHandoffMocks.detachCurrentSession).not.toHaveBeenCalled(); + expect(requestShutdown).not.toHaveBeenCalled(); + expect(runExitCleanup).not.toHaveBeenCalled(); + }); + + it('surfaces worker detach failures to the command processor', async () => { + agentViewHandoffMocks.readWorkerSideband.mockReturnValue({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/agent-view.sock', + token: 'token', + activeCwd: '/repo', + }); + agentViewHandoffMocks.sendWorkerEvent.mockRejectedValue( + new Error('supervisor unavailable'), + ); + render( + , + ); + const actions = mockedUseSlashCommandProcessor.mock.calls.at(-1)?.[12] as + | { detachAgentViewSession: () => Promise } + | undefined; + + await expect(actions?.detachAgentViewSession()).rejects.toThrow( + 'supervisor unavailable', + ); + }); + it('shows recording failures as warnings and unsubscribes on unmount', async () => { const addItem = vi.fn(); mockedUseHistory.mockReturnValue({ @@ -874,6 +1177,7 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery, initError: null, @@ -1476,6 +1780,7 @@ describe('AppContainer State Management', () => { drainQueue: vi.fn().mockReturnValue([]), }); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery, initError: null, @@ -1781,6 +2086,7 @@ describe('AppContainer State Management', () => { const mockSubmitQuery = vi.fn(); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: mockSubmitQuery, initError: null, @@ -1829,6 +2135,7 @@ describe('AppContainer State Management', () => { const mockQueueMessage = vi.fn(); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: mockSubmitQuery, initError: null, @@ -1880,6 +2187,7 @@ describe('AppContainer State Management', () => { const mockQueueMessage = vi.fn(); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: mockSubmitQuery, initError: null, @@ -1990,6 +2298,7 @@ describe('AppContainer State Management', () => { const mockQueueMessage = vi.fn(); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: mockSubmitQuery, initError: null, @@ -2039,6 +2348,7 @@ describe('AppContainer State Management', () => { .mockReturnValueOnce('Use list_agents to inspect restored agents.') .mockReturnValue(null); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -2644,6 +2954,51 @@ describe('AppContainer State Management', () => { }, ); + it.each(['exit', 'quit'])( + 'delivers roster control prompt "%s" as message content', + (prompt) => { + const mockHandleSlashCommand = vi.fn(); + const mockQueueMessage = vi.fn(); + + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: mockHandleSlashCommand, + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + mockedUseMessageQueue.mockReturnValue({ + removeGoalTurns: vi.fn().mockReturnValue([]), + messageQueue: [], + addMessage: mockQueueMessage, + clearQueue: vi.fn(), + getQueuedMessagesText: vi.fn().mockReturnValue(''), + popAllMessages: vi.fn().mockReturnValue(null), + drainQueue: vi.fn().mockReturnValue([]), + popNextTurn: vi.fn().mockReturnValue(null), + }); + + render( + , + ); + + const controlPromptOptions = { + deferUntilIdle: false, + bypassAgentTabRouting: true, + }; + capturedUIActions.handleFinalSubmit(prompt, controlPromptOptions); + + expect(mockQueueMessage).toHaveBeenCalledWith(prompt, false, undefined); + expect(mockHandleSlashCommand).not.toHaveBeenCalled(); + }, + ); + it.each(['/quit', '/exit'])( 'routes "%s" immediately while responding', (command) => { @@ -2658,6 +3013,7 @@ describe('AppContainer State Management', () => { confirmationRequest: null, }); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: StreamingState.Responding, submitQuery: vi.fn(), initError: null, @@ -2753,6 +3109,7 @@ describe('AppContainer State Management', () => { capturedOnCancelSubmit = candidate as CapturedCancelSubmit; } return { + pendingToolCalls: [], ...streamReturnValue, streamingResponseLengthRef: { current: 0 }, isReceivingContent: false, @@ -4237,6 +4594,133 @@ describe('AppContainer State Management', () => { expect(handleKeypress).toBeDefined(); expect(() => handleKeypress!(optionMKey)).not.toThrow(); }); + + it('does not route empty left arrow to Agent View background handoff outside Agent View workers', async () => { + const mockHandleSlashCommand = vi.fn(); + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: mockHandleSlashCommand, + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + + render( + , + ); + + const keypressHandlers = mockedUseKeypress.mock.calls + .map((call) => call[0]) + .filter( + (handler): handler is (key: Key) => void => + typeof handler === 'function', + ); + expect(keypressHandlers.length).toBeGreaterThan(0); + + const leftKey: Key = { + name: 'left', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\u001b[D', + }; + for (const handleKeypress of keypressHandlers) { + handleKeypress(leftKey); + } + + expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/background'); + }); + + it('does not use left arrow as an Agent View detach shortcut', () => { + agentViewHandoffMocks.readWorkerSideband.mockReturnValue({ + sessionId: 'session-1', + sidebandEndpoint: '/tmp/agent-view.sock', + token: 'token', + activeCwd: '/repo', + }); + + render( + , + ); + + const leftKey: Key = { + name: 'left', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\u001b[D', + }; + for (const handleKeypress of mockedUseKeypress.mock.calls + .map((call) => call[0]) + .filter( + (handler): handler is (key: Key) => void => + typeof handler === 'function', + )) { + handleKeypress(leftKey); + } + + expect(agentViewHandoffMocks.sendWorkerEvent).not.toHaveBeenCalled(); + }); + + it('does not route non-empty left arrow to Agent View background handoff', async () => { + const mockHandleSlashCommand = vi.fn(); + mockedUseSlashCommandProcessor.mockReturnValue({ + handleSlashCommand: mockHandleSlashCommand, + slashCommands: [], + pendingHistoryItems: [], + commandContext: {}, + shellConfirmationRequest: null, + confirmationRequest: null, + }); + mockedUseTextBuffer.mockReturnValue({ + text: 'draft prompt', + setText: vi.fn(), + }); + + render( + , + ); + + const keypressHandlers = mockedUseKeypress.mock.calls + .map((call) => call[0]) + .filter( + (handler): handler is (key: Key) => void => + typeof handler === 'function', + ); + expect(keypressHandlers.length).toBeGreaterThan(0); + + const leftKey: Key = { + name: 'left', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '\u001b[D', + }; + for (const handleKeypress of keypressHandlers) { + handleKeypress(leftKey); + } + + expect(mockHandleSlashCommand).not.toHaveBeenCalledWith('/background'); + }); }); describe('Version Handling', () => { @@ -4421,6 +4905,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Processing request'; mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -4469,6 +4954,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4516,6 +5002,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const thoughtSubject = 'Confirm tool execution'; mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: StreamingState.WaitingForConfirmation, submitQuery: vi.fn(), initError: null, @@ -4565,6 +5052,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought with a short subject const shortTitle = 'Short'; mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -4619,6 +5107,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state and thought const title = 'Test Title'; mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -4670,6 +5159,7 @@ describe('AppContainer State Management', () => { // Mock the streaming state as Idle with no thought mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4738,6 +5228,7 @@ describe('AppContainer State Management', () => { >); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4838,6 +5329,7 @@ describe('AppContainer State Management', () => { >); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -4966,6 +5458,7 @@ describe('AppContainer State Management', () => { mockedMeasureElement.mockReturnValue({ width: 80, height: 10 }); // Footer is taller than the screen mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -5297,6 +5790,7 @@ describe('AppContainer State Management', () => { it('should cancel ongoing request on first Ctrl+C', () => { const mockCancelOngoingRequest = vi.fn(); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'responding', submitQuery: vi.fn(), initError: null, @@ -6127,6 +6621,7 @@ describe('AppContainer State Management', () => { truncateToItem: vi.fn(), }); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -6171,6 +6666,7 @@ describe('AppContainer State Management', () => { truncateToItem: vi.fn(), }); mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [], streamingState: 'idle', submitQuery: vi.fn(), initError: null, @@ -6274,6 +6770,75 @@ describe('AppContainer State Management', () => { ).toBe(false); }); }); + + describe('Agent View idle gate state', () => { + it('passes a populated idle-gate ref to the slash command processor', () => { + mockedUseGeminiStream.mockReturnValue({ + pendingToolCalls: [ + { + status: 'awaiting_approval', + confirmationDetails: { type: 'ask_user_question' }, + }, + { + status: 'awaiting_approval', + confirmationDetails: { type: 'edit' }, + }, + ], + streamingState: 'idle', + submitQuery: vi.fn(), + initError: null, + pendingHistoryItems: [], + thought: null, + cancelOngoingRequest: vi.fn(), + retryLastPrompt: vi.fn(), + streamingResponseLengthRef: { current: 0 }, + isReceivingContent: false, + clearPendingState: vi.fn(), + }); + + render( + , + ); + + const calls = mockedUseSlashCommandProcessor.mock.calls; + expect(calls.length).toBeGreaterThan(0); + const gateRef = calls[calls.length - 1]?.at(-1) as { + current: Record; + }; + expect(gateRef?.current).toMatchObject({ + hasPendingUserQuestion: true, + hasPendingToolConfirmation: true, + }); + }); + + it('treats a focused agent shell as a foreground shell', () => { + agentViewStateMock.mockReturnValue({ + activeView: 'main', + agents: new Map(), + agentShellFocused: true, + }); + + render( + , + ); + + const calls = mockedUseSlashCommandProcessor.mock.calls; + const gateRef = calls[calls.length - 1]?.at(-1) as { + current: Record; + }; + expect(gateRef.current['hasForegroundShell']).toBe(true); + }); + }); }); describe('dedupeNewestFirst', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9a4e1931290..b130f28bfd0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -137,6 +137,7 @@ import { useBranchCommand } from './hooks/useBranchCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useDeleteCommand } from './hooks/useDeleteCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; +import type { AgentViewIdleGateState } from './commands/types.js'; import { useDoublePress } from './hooks/useDoublePress.js'; import { computeApiTruncationIndex, @@ -153,6 +154,7 @@ import { calculatePromptWidths } from './components/InputPrompt.js'; import { useStdin, useStdout } from 'ink'; import ansiEscapes from 'ansi-escapes'; import * as fs from 'node:fs'; +import { spawnSync } from 'node:child_process'; import { basename } from 'node:path'; import { formatSessionWindowTitle, @@ -241,7 +243,22 @@ import { useMemoryDialog } from './hooks/useMemoryDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { buildTerminalNotification } from './hooks/useTerminalNotification.js'; import { useContextualTips } from './hooks/useContextualTips.js'; +import { detachCurrentSessionToAgentView } from '../agent-view/managed-detach.js'; +import { buildCurrentQwenCliArgv } from '../agent-view/current-cli-argv.js'; +import { + readAgentViewWorkerSidebandEnv, + readAgentViewWorkerControlEvents, + reportAgentViewWorkerState, + sendAgentViewWorkerEvent, +} from '../agent-view/worker-sideband.js'; import { getTipHistory } from '../services/tips/index.js'; +import { + applyAgentViewWorkerControlEventForUi, + getAgentViewAnswerableToolCalls, + getAgentViewWorkerStateForUi, + getLastAgentViewModelOutputLine, + retainAnsweredAgentViewSoftQuestion, +} from './agent-view/worker-ui-bridge.js'; import { restorePromptStash } from '../services/prompt-stash.js'; import { useRemoteInput } from '../remoteInput/RemoteInputContext.js'; import { useDualOutput } from '../dualOutput/DualOutputContext.js'; @@ -258,6 +275,9 @@ import { import { MAIN_CONTENT_HEIGHT_RESERVATION } from './utils/layoutUtils.js'; const CTRL_EXIT_PROMPT_DURATION_MS = 1000; +// Stable empty default so the destructured `pendingToolCalls` doesn't get a +// fresh array identity each render, which would re-run dependent effects. +const EMPTY_TOOL_CALLS: WaitingToolCall[] = []; const debugLogger = createDebugLogger('APP_CONTAINER'); export function isRenderModeToggleKey(key: Key): boolean { @@ -676,6 +696,7 @@ export const AppContainer = (props: AppContainerProps) => { ); const [isProcessing, setIsProcessing] = useState(false); const [embeddedShellFocused, setEmbeddedShellFocused] = useState(false); + const agentViewIdleGateStateRef = useRef({}); const [geminiMdFileCount, setGeminiMdFileCount] = useState( initializationResult.geminiMdFileCount, @@ -1727,6 +1748,23 @@ export const AppContainer = (props: AppContainerProps) => { [config, skillReviewPending], ); + const detachAgentViewSession = useCallback(async () => { + if (readAgentViewWorkerSidebandEnv() !== undefined) { + await sendAgentViewWorkerEvent({ type: 'detach' }); + return; + } + await config.getChatRecordingService?.()?.flush?.(); + await detachCurrentSessionToAgentView(config, { + terminal: { + columns: terminalWidth, + rows: terminalHeight, + }, + }); + config.getGeminiClient()?.requestShutdown(); + await runExitCleanup(); + process.exit(runAgentViewRosterCommand(config.getProjectRoot())); + }, [config, terminalHeight, terminalWidth]); + // Subscribe to skill-review task changes and keep skillReviewPending in sync. useEffect(() => { const mgr = config.getMemoryManager(); @@ -1819,6 +1857,7 @@ export const AppContainer = (props: AppContainerProps) => { handleBranch, openDeleteDialog, openHelpDialog, + detachAgentViewSession, clearPendingState: () => clearPendingStateRef.current(), }), [ @@ -1850,6 +1889,7 @@ export const AppContainer = (props: AppContainerProps) => { openDeleteDialog, openHelpDialog, openDiffDialog, + detachAgentViewSession, config, ], ); @@ -1886,6 +1926,7 @@ export const AppContainer = (props: AppContainerProps) => { historyManager.updateItem, setSessionName, extensionRefreshState, + agentViewIdleGateStateRef, ); // onDebugMessage should log to debug logfile, not update footer debugMessage @@ -2137,7 +2178,7 @@ export const AppContainer = (props: AppContainerProps) => { handleApprovalModeChange, activePtyId, loopDetectionConfirmationRequest, - pendingToolCalls, + pendingToolCalls = EMPTY_TOOL_CALLS, streamingResponseLengthRef, isReceivingContent, } = useGeminiStream( @@ -2168,6 +2209,10 @@ export const AppContainer = (props: AppContainerProps) => { goalQueueRef, ); cancelOngoingRequestRef.current = cancelOngoingRequest; + const streamingStateRef = useRef(streamingState); + streamingStateRef.current = streamingState; + const isProcessingRef = useRef(isProcessing); + isProcessingRef.current = isProcessing; clearPendingStateRef.current = clearPendingState; // Now that streamingState is available, keep isIdleRef in sync and @@ -2183,6 +2228,44 @@ export const AppContainer = (props: AppContainerProps) => { } }, [streamingState]); + const agentViewLastResult = getLastAgentViewModelOutputLine([ + ...historyManager.history, + ...pendingGeminiHistoryItems, + ]); + const agentViewLastResultRef = useRef(agentViewLastResult); + agentViewLastResultRef.current = agentViewLastResult; + const answeredAgentViewSoftQuestionRef = useRef( + undefined, + ); + + useEffect(() => { + if (readAgentViewWorkerSidebandEnv() === undefined) return; + answeredAgentViewSoftQuestionRef.current = + retainAnsweredAgentViewSoftQuestion( + answeredAgentViewSoftQuestionRef.current, + agentViewLastResult, + ); + const report = getAgentViewWorkerStateForUi({ + initError, + streamingState, + pendingToolCalls, + lastResult: agentViewLastResult, + answeredSoftQuestion: answeredAgentViewSoftQuestionRef.current, + }); + void reportAgentViewWorkerState({ + ...report, + // Fold the recorded session title into the authoritative report instead + // of emitting a partial one that would clobber waitingFor/lastResult. + ...(report.summary || !sessionName ? {} : { summary: sessionName }), + }); + }, [ + agentViewLastResult, + initError, + pendingToolCalls, + sessionName, + streamingState, + ]); + // Auto-open the skill-review dialog when idle and there are pending skills. // Gated on the live auto-skill flag: after the dialog's turn-off option // (which disables the feature and closes WITHOUT dismissing), the batch must @@ -2240,7 +2323,6 @@ export const AppContainer = (props: AppContainerProps) => { livePanelFocused: bgLivePanelFocused, } = useBackgroundTaskViewState(); const { closeDialog: closeBgTasksDialog } = useBackgroundTaskViewActions(); - // Prompt suggestion state const [promptSuggestion, setPromptSuggestion] = useState(null); const prevStreamingStateRef = useRef(StreamingState.Idle); @@ -2487,9 +2569,13 @@ export const AppContainer = (props: AppContainerProps) => { options?: { deferUntilIdle?: boolean; submittedPrompt?: string; + bypassAgentTabRouting?: boolean; }, ) => { - const consumesComposerState = options !== undefined; + // Control prompts (bypassAgentTabRouting) are not composer + // submissions and must not consume composer restore state. + const consumesComposerState = + options !== undefined && !options.bypassAgentTabRouting; const restoredSubmission = consumesComposerState ? restoredSubmissionRef.current : null; @@ -2517,7 +2603,12 @@ export const AppContainer = (props: AppContainerProps) => { } // Route to active in-process agent if viewing a sub-agent tab. - if (agentViewState.activeView !== 'main') { + // Control prompts from the roster target the main session and must + // not be swallowed by a sub-agent the roster has no visibility into. + if ( + !options?.bypassAgentTabRouting && + agentViewState.activeView !== 'main' + ) { const agent = agentViewState.agents.get(agentViewState.activeView); if (agent) { agent.interactiveAgent.enqueueMessage(submittedValue.trim()); @@ -2530,6 +2621,7 @@ export const AppContainer = (props: AppContainerProps) => { // Quit must bypass reminders and the message queue so it can stop an // active stream without consuming one-shot session state. if ( + !options?.bypassAgentTabRouting && ['/quit', '/exit', 'exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes( userPromptText.trim(), ) @@ -2763,6 +2855,96 @@ export const AppContainer = (props: AppContainerProps) => { ], ); + const pendingAgentViewControlPromptsRef = useRef([]); + // Keep the poll loop mounted across streaming-state transitions; reading + // the submitter through a ref avoids tearing down/restarting the 250 ms + // loop (and its immediate poll RPC) on every state change. + const handleFinalSubmitRef = useRef(handleFinalSubmit); + handleFinalSubmitRef.current = handleFinalSubmit; + useEffect(() => { + if (readAgentViewWorkerSidebandEnv() === undefined) return undefined; + + let disposed = false; + let timer: NodeJS.Timeout | undefined; + const flushPrompt = () => { + if ( + streamingStateRef.current !== StreamingState.Idle || + isProcessingRef.current + ) { + return; + } + const nextPrompt = pendingAgentViewControlPromptsRef.current.shift(); + if (nextPrompt) { + const lastResult = agentViewLastResultRef.current; + answeredAgentViewSoftQuestionRef.current = lastResult; + void reportAgentViewWorkerState({ + sessionState: 'idle', + ...(lastResult ? { lastResult } : {}), + }); + handleFinalSubmitRef.current(nextPrompt, { + bypassAgentTabRouting: true, + }); + } + }; + const poll = async () => { + let stopped = false; + try { + const events = await readAgentViewWorkerControlEvents(); + if (!disposed && events.some((event) => event.type === 'redraw')) { + refreshStatic(); + } + for (const event of events) { + await applyAgentViewWorkerControlEventForUi( + event, + pendingToolCallsRef.current, + (text) => { + pendingAgentViewControlPromptsRef.current.push(text); + }, + () => { + if (streamingStateRef.current === StreamingState.Responding) { + cancelOngoingRequestRef.current(); + } + // Terminal stop: drop queued prompts and shut the worker down + // gracefully (the supervisor's SIGTERM is only a 10 s backstop). + pendingAgentViewControlPromptsRef.current = []; + const shutdown = async () => { + config.getGeminiClient()?.requestShutdown(); + await runExitCleanup(); + process.exit(0); + }; + void reportAgentViewWorkerState({ + sessionState: 'stopped', + lastResult: 'Stopped by user', + }).then(shutdown, shutdown); + }, + ); + if (event.type === 'stop') { + stopped = true; + break; + } + } + } catch { + // Supervisor sideband is best-effort; normal TUI rendering continues. + } finally { + if (!disposed) { + if (!stopped) { + flushPrompt(); + } + timer = setTimeout(poll, 250); + } + } + }; + + flushPrompt(); + void poll(); + return () => { + disposed = true; + if (timer) { + clearTimeout(timer); + } + }; + }, [config, refreshStatic]); + const handleArenaModelsSelected = useCallback( (models: string[]) => { const value = models.join(','); @@ -3360,6 +3542,28 @@ export const AppContainer = (props: AppContainerProps) => { !!(settings.corruptedPath && !settings.corruptionDialogDismissed); dialogsVisibleRef.current = dialogsVisible; + const answerableAgentViewToolCalls = + getAgentViewAnswerableToolCalls(pendingToolCalls); + agentViewIdleGateStateRef.current = { + hasPendingUserQuestion: answerableAgentViewToolCalls.some( + (toolCall) => toolCall.confirmationDetails.type === 'ask_user_question', + ), + hasPendingToolConfirmation: answerableAgentViewToolCalls.some( + (toolCall) => toolCall.confirmationDetails.type !== 'ask_user_question', + ), + hasPendingCommandConfirmation: + !!shellConfirmationRequest || + !!confirmationRequest || + !!loopDetectionConfirmationRequest, + hasForegroundShell: Boolean( + activePtyId || embeddedShellFocused || agentViewState.agentShellFocused, + ), + hasBackgroundFocusDialog: bgTasksDialogOpen || bgLivePanelFocused, + hasQueuedPrompt: + goalQueueRef.current?.hasQueuedUserMessages?.() === true || + (goalQueueRef.current?.getPendingSubmissionCount?.() ?? 0) > 0, + }; + const shouldShowStickyTodos = stickyTodos !== null && !dialogsVisible && @@ -3987,7 +4191,8 @@ export const AppContainer = (props: AppContainerProps) => { return; // Btw cancelled, end processing } - // 4. Cancel ongoing requests + // 4. Cancel ongoing requests (cancel-and-continue: the worker session + // stays alive, so no terminal state is reported) if (streamingState === StreamingState.Responding) { cancelOngoingRequest?.(); return; // Request cancelled, end processing @@ -4108,6 +4313,8 @@ export const AppContainer = (props: AppContainerProps) => { clearTimeout(escapeTimerRef.current); escapeTimerRef.current = null; } + // Cancel-and-continue: the worker session stays alive, so no + // terminal state is reported. cancelOngoingRequest?.(); setEscapePressedOnce(false); return; @@ -4874,3 +5081,39 @@ export const AppContainer = (props: AppContainerProps) => { ); }; + +type SpawnSyncFn = typeof spawnSync; + +// Deliberately synchronous: the roster relaunch is a terminal handoff — the +// current process must exit and hand stdio to the child before returning, so +// an async spawn would leave two processes racing for the same TTY. +export function runAgentViewRosterCommand( + cwd: string, + spawn: SpawnSyncFn = spawnSync, +): number { + let argv: string[]; + try { + argv = buildCurrentQwenCliArgv(['agents', '--cwd', cwd]); + } catch { + return 1; + } + const [command, ...commandArgs] = argv; + if (!command) { + return 1; + } + const result = spawn(command, commandArgs, { + stdio: 'inherit', + env: { + ...process.env, + QWEN_CODE_NO_RELAUNCH: '1', + }, + }); + if (result.error || result.signal) { + if (result.error) { + // eslint-disable-next-line no-console + console.error(result.error); + } + return 1; + } + return result.status ?? 1; +} diff --git a/packages/cli/src/ui/agent-view/AgentViewApp.test.tsx b/packages/cli/src/ui/agent-view/AgentViewApp.test.tsx new file mode 100644 index 00000000000..29b7ef84003 --- /dev/null +++ b/packages/cli/src/ui/agent-view/AgentViewApp.test.tsx @@ -0,0 +1,1727 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cleanup, render as inkRender } from 'ink-testing-library'; +import type { ComponentProps, ReactElement } from 'react'; +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AgentViewApp } from './AgentViewApp.js'; +import type { AgentViewSessionPanel } from './AgentViewRoster.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import type { AgentRosterRow } from './roster-model.js'; + +vi.mock('../../services/BuiltinCommandLoader.js', () => ({ + BuiltinCommandLoader: class { + loadCommands() { + return Promise.resolve([ + { + name: 'model', + description: 'Switch the model for this session', + kind: 'built-in', + action: () => undefined, + }, + { + name: 'login', + description: 'Connect an LLM provider', + kind: 'built-in', + action: () => undefined, + }, + { + name: 'logout', + description: 'Clear provider credentials', + kind: 'built-in', + action: () => undefined, + }, + ]); + } + }, +})); + +describe('AgentViewApp', () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('dispatches the current prompt as a background session', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const loadRows = vi.fn(async () => [row('new-session')]); + const onExit = vi.fn(); + const onAttachRequested = vi.fn(); + const { stdin } = render( + , + ); + + for (const char of 'ship it') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + await flushInk(); + + expect(dispatchPrompt).toHaveBeenCalledWith('ship it', false); + expect(loadRows).toHaveBeenCalled(); + expect(onAttachRequested).not.toHaveBeenCalled(); + expect(onExit).not.toHaveBeenCalled(); + }, 20_000); + + it('requests attach for the selected row on empty Enter', async () => { + const onAttachRequested = vi.fn(); + const onExit = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write('\r'); + await Promise.resolve(); + await Promise.resolve(); + + expect(onAttachRequested).toHaveBeenCalledWith('session-1'); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('keeps the prompt and shows dispatch errors', async () => { + const dispatchPrompt = vi.fn(async () => { + throw new Error('Timed out waiting for Agent View supervisor response'); + }); + const { stdin, lastFrame } = render( + , + ); + + for (const char of 'ship it') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + + expect(dispatchPrompt).toHaveBeenCalledWith('ship it', false); + await waitForFrame(lastFrame, 'Timed out'); + // The failure itself must be visible, not just the restored prompt. + expect(lastFrame()).toContain('Timed out waiting for Agent View'); + // The prompt must actually survive the failed dispatch. + expect(lastFrame()).toContain('> ship it'); + }); + + it('does not overwrite newer input when an earlier dispatch fails', async () => { + let rejectDispatch: ((error: Error) => void) | undefined; + const dispatchPrompt = vi.fn( + () => + new Promise((_, reject) => { + rejectDispatch = reject; + }), + ); + const { stdin, lastFrame } = render( + , + ); + + for (const char of 'first') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + for (const char of 'newer') { + stdin.write(char); + await Promise.resolve(); + } + + await act(async () => { + rejectDispatch?.(new Error('dispatch failed')); + await flushInk(); + }); + + expect(lastFrame()).toContain('> newer'); + expect(lastFrame()).not.toContain('> first'); + }); + + it('keeps a rowless initial error panel visible', () => { + const { lastFrame } = render( + , + ); + + expect(lastFrame()).toContain('adopt failed'); + }); + + it('keeps a successful dispatch when the row refresh fails', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const loadRows = vi.fn(async () => { + throw new Error('daemon unavailable'); + }); + const { stdin, lastFrame } = render( + , + ); + + for (const char of 'ship it') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await waitForFrame(lastFrame, 'Dispatched.'); + + // waitForFrame returns silently on timeout, so pin the wait explicitly. + expect(lastFrame()).toContain('Dispatched.'); + // The prompt must stay cleared; restoring it would let a re-Enter + // duplicate the dispatched session. + expect(lastFrame()).not.toContain('> ship it'); + + // The refresh failure must not mask the successful dispatch: no error + // notice and no restored prompt (a re-Enter would duplicate the session). + expect(dispatchPrompt).toHaveBeenCalledTimes(1); + expect(dispatchPrompt).toHaveBeenCalledWith('ship it', false); + expect(loadRows).toHaveBeenCalled(); + expect(lastFrame()).not.toContain('daemon unavailable'); + }); + + it('shows peek details for the selected row on Space', async () => { + const peekSelected = vi.fn(async () => + sessionPanel('session-1', ['State: idle / alive', 'Summary: done']), + ); + const { stdin } = render( + , + ); + + stdin.write(' '); + await settleInput(); + + expect(peekSelected).toHaveBeenCalledWith('session-1'); + }); + + it('answers a needs-input session from an open peek without exiting', async () => { + const answerSession = vi.fn(async () => ({ answered: true })); + const onExit = vi.fn(); + const { stdin } = render( + + sessionPanel('session-1', ['Waiting: approval']), + })} + onExit={onExit} + />, + ); + + stdin.write(' '); + await settleInput(); + for (const char of 'yes') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + + expect(answerSession).toHaveBeenCalledWith('session-1', 'yes'); + expect(onExit).not.toHaveBeenCalled(); + }); + + it('does not resurrect a closed peek when the reply send fails', async () => { + let rejectSend: ((error: Error) => void) | undefined; + const sendToSession = vi.fn( + () => + new Promise<{ sent: boolean }>((_, reject) => { + rejectSend = reject; + }), + ); + const { stdin, lastFrame } = render( + + sessionPanel('session-1', ['Result: ready']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await settleInput(); + for (const char of 'hello') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + expect(sendToSession).toHaveBeenCalledWith('session-1', 'hello'); + + // Close the peek while the send is still in flight (Space cancels once + // the reply input is inactive; ESC is buffered by the readline layer). + stdin.write(' '); + await settleInput(); + expect(lastFrame()).not.toContain('space to close'); + + await act(async () => { + rejectSend?.(new Error('worker is gone')); + await flushInk(); + }); + + // The failure must not resurrect the panel the user explicitly closed, + // but it still needs to be visible outside the hidden peek input. + expect(lastFrame()).not.toContain('space to close'); + expect(lastFrame()).toContain('Reply was not sent: worker is gone'); + }); + + it('does not let an older reply settle clear a newer peek target', async () => { + let resolveFirstSend: ((value: { sent: boolean }) => void) | undefined; + const sendToSession = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ sent: boolean }>((resolve) => { + resolveFirstSend = resolve; + }), + ) + .mockResolvedValue({ sent: true }); + const { stdin } = render( + { + throw new Error('refresh failed'); + }), + peekSelected: async (sessionId) => + sessionPanel(sessionId, ['Result: ready']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await settleInput(); + for (const char of 'forA') stdin.write(char); + stdin.write('\r'); + await vi.waitFor(() => expect(sendToSession).toHaveBeenCalledOnce()); + + stdin.write(' '); + await settleInput(); + stdin.write('\x1b[B'); + await settleInput(); + stdin.write(' '); + await settleInput(); + for (const char of 'forB') { + stdin.write(char); + await Promise.resolve(); + } + + await act(async () => { + resolveFirstSend?.({ sent: true }); + await flushInk(); + }); + stdin.write('\r'); + await vi.waitFor(() => expect(sendToSession).toHaveBeenCalledTimes(2)); + + expect(sendToSession).toHaveBeenNthCalledWith(2, 'session-B', 'forB'); + }); + + it('keeps a reply error visible when an older peek load finishes', async () => { + let resolvePeek: ((panel: AgentViewSessionPanel) => void) | undefined; + const peekSelected = vi.fn( + () => + new Promise((resolve) => { + resolvePeek = resolve; + }), + ); + const sendToSession = vi.fn(async () => { + throw new Error('worker is gone'); + }); + const { stdin, lastFrame } = render( + , + ); + + stdin.write(' '); + await flushInk(); + for (const char of 'retry') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await waitForFrame(lastFrame, 'worker is gone'); + + await act(async () => { + resolvePeek?.(sessionPanel('session-1', ['late activity'])); + await flushInk(); + }); + + expect(lastFrame()).toContain('worker is gone'); + expect(lastFrame()).not.toContain('late activity'); + }); + + it('clears a stale reply error when the retry succeeds', async () => { + const sendToSession = vi + .fn() + .mockRejectedValueOnce(new Error('worker is gone')) + .mockResolvedValueOnce({ sent: true }); + const { stdin, lastFrame } = render( + + sessionPanel('session-1', ['Result: ready']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await flushInk(); + for (const char of 'hello') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await waitForFrame(lastFrame, 'worker is gone'); + await flushInk(); + expect(lastFrame()).toContain('enter to send'); + expect(lastFrame()?.match(/hello/g)).toHaveLength(2); + + stdin.write('!'); + await Promise.resolve(); + stdin.write('\r'); + await flushInk(); + await vi.waitFor(() => expect(sendToSession).toHaveBeenCalledTimes(2)); + await flushInk(); + + expect(lastFrame()).not.toContain('worker is gone'); + expect(lastFrame()).not.toContain('Prompt: hello'); + }); + + it('shows a submitted reply while delivery is in flight', async () => { + const sendToSession = vi.fn(() => new Promise(() => {})); + const { stdin, lastFrame } = render( + + sessionPanel('session-1', ['Result: ready']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await flushInk(); + for (const char of 'continue') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await vi.waitFor(() => expect(sendToSession).toHaveBeenCalledOnce()); + await waitForFrame(lastFrame, 'Waiting for response: continue'); + + expect(lastFrame()).toContain('Waiting for response: continue'); + expect(lastFrame()).toContain('waiting for response'); + expect(lastFrame()).not.toContain('enter to send'); + }); + + it('dispatches typed text without targeting another session from a stale error peek', async () => { + let notify: (() => void) | undefined; + const sendToSession = vi.fn(async () => { + throw new Error('worker is gone'); + }); + const dispatchPrompt = vi.fn(); + const onAttachRequested = vi.fn(); + const { stdin, lastFrame } = render( + [row('session-2')]), + peekSelected: async () => + sessionPanel('session-1', ['Result: ready']), + subscribeToChanges: (onChange) => { + notify = onChange; + return { dispose: vi.fn() }; + }, + })} + onExit={vi.fn()} + onAttachRequested={onAttachRequested} + />, + ); + + stdin.write(' '); + await settleInput(); + for (const char of 'hello') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await vi.waitFor(() => expect(sendToSession).toHaveBeenCalledOnce()); + await waitForFrame(lastFrame, 'worker is gone'); + expect(lastFrame()).toContain('worker is gone'); + + await act(async () => { + notify?.(); + }); + await flushInk(); + for (const char of 'retry') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(sendToSession).toHaveBeenCalledOnce(); + expect(dispatchPrompt).toHaveBeenCalledWith('retry', false); + expect(onAttachRequested).not.toHaveBeenCalled(); + }); + + it('sends soft needs-input replies as follow-ups', async () => { + const sendToSession = vi.fn(async () => ({ sent: true })); + const answerSession = vi.fn(async () => ({ answered: true })); + const { stdin } = render( + + sessionPanel('session-1', ['Result: What next?']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await settleInput(); + for (const char of 'next') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + + expect(sendToSession).toHaveBeenCalledWith('session-1', 'next'); + expect(answerSession).not.toHaveBeenCalled(); + }); + + it('does not queue another reply behind a pending soft-question prompt', async () => { + const sendToSession = vi.fn(); + const { stdin, lastFrame } = render( + + sessionPanel('session-1', ['Result: What next?']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await waitForFrame(lastFrame, 'Waiting for response: continue'); + + expect(lastFrame()).not.toContain('> reply'); + for (const char of 'again') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(sendToSession).not.toHaveBeenCalled(); + expect(lastFrame()).not.toContain('again'); + }); + + it('sends a follow-up to a completed session from an open peek', async () => { + const sendToSession = vi.fn(async () => ({ sent: true })); + const loadRows = vi.fn(async () => [ + row('session-1', { + state: 'completed', + stateLabel: 'Completed', + queuedPromptCount: 1, + queuedPromptPreview: 'continue', + }), + ]); + const { stdin } = render( + sessionPanel('session-1', ['Result: done']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await flushInk(); + for (const char of 'continue') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + + expect(sendToSession).toHaveBeenCalledWith('session-1', 'continue'); + }, 10_000); + + it('keeps a second peek reply while the first is in flight', async () => { + const answerSession = vi.fn(() => new Promise(() => {})); + const onAttachRequested = vi.fn(); + const { stdin, lastFrame } = render( + + sessionPanel('session-1', ['Waiting: Edit']), + })} + onExit={vi.fn()} + onAttachRequested={onAttachRequested} + />, + ); + + stdin.write(' '); + await flushInk(); + for (const char of 'yes') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await vi.waitFor(() => expect(answerSession).toHaveBeenCalledOnce()); + await waitForFrame(lastFrame, '> reply'); + expect(lastFrame()).toContain('> reply'); + for (const char of 'no wait') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(answerSession).toHaveBeenCalledOnce(); + expect(answerSession).toHaveBeenCalledWith('session-1', 'yes'); + expect(lastFrame()).toContain('no wait'); + expect(lastFrame()).toContain('Reply is still being sent.'); + + stdin.write('\r'); + await flushInk(); + + expect(answerSession).toHaveBeenCalledOnce(); + expect(onAttachRequested).not.toHaveBeenCalled(); + }, 10_000); + + it('clears same-tick peek input instead of exiting', async () => { + const onExit = vi.fn(); + const { stdin, lastFrame } = render( + + sessionPanel('session-1', ['Result: ready']), + })} + onExit={onExit} + />, + ); + + stdin.write(' '); + await flushInk(); + stdin.write('\x03'); + await flushInk(); + stdin.write('a'); + stdin.write('\x03'); + await flushInk(); + + expect(onExit).not.toHaveBeenCalled(); + expect(lastFrame()).not.toContain('> a'); + }); + + it('shows the persisted pending prompt when reopening a peek', async () => { + const sendToSession = vi.fn(); + const { stdin, lastFrame } = render( + sessionPanel('session-1', ['Result: done']), + })} + onExit={vi.fn()} + />, + ); + + stdin.write(' '); + await waitForFrame(lastFrame, 'Waiting for response: continue'); + + expect(lastFrame()).toContain('Waiting for response: continue'); + expect(lastFrame()).not.toContain('> reply'); + for (const char of 'again') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + expect(sendToSession).not.toHaveBeenCalled(); + expect(lastFrame()).not.toContain('again'); + }, 10_000); + + it('refreshes an open peek when a pending reply completes', async () => { + const sendToSession = vi.fn(); + const actionsForTest = actions({ + sendToSession, + peekSelected: async () => + sessionPanel('session-1', ['Result: old output']), + }); + const { stdin, lastFrame, rerender } = render( + , + ); + + stdin.write(' '); + await waitForFrame(lastFrame, 'Waiting for response: continue'); + expect(lastFrame()).toContain('Waiting for response: continue'); + + rerender( + + + , + ); + await flushInk(); + expect(sendToSession).not.toHaveBeenCalled(); + // The open peek must reflect the refreshed row, not stale panel data. + await waitForFrame(lastFrame, 'final question?'); + expect(lastFrame()).toContain('final question?'); + }, 10_000); + + it('does not send a follow-up to a working session from an open peek', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const sendToSession = vi.fn(async () => ({ sent: true })); + const onAttachRequested = vi.fn(); + const { stdin } = render( + + sessionPanel('session-1', ['State: working / alive']), + })} + onExit={vi.fn()} + onAttachRequested={onAttachRequested} + />, + ); + + stdin.write(' '); + await flushInk(); + for (const char of 'continue') { + stdin.write(char); + await Promise.resolve(); + } + await flushInk(); + + expect(sendToSession).not.toHaveBeenCalled(); + expect(dispatchPrompt).not.toHaveBeenCalled(); + expect(onAttachRequested).not.toHaveBeenCalled(); + + // Enter must not deliver the peek reply either: the session cannot + // accept replies while working (canReply: false), so it attaches + // instead of sending. + stdin.write('\r'); + await settleInput(); + expect(sendToSession).not.toHaveBeenCalled(); + expect(dispatchPrompt).not.toHaveBeenCalled(); + expect(onAttachRequested).toHaveBeenCalledWith('session-1'); + }, 10_000); + + it('pins the selected row with Ctrl+T', async () => { + const pinSession = vi.fn(async () => ({ pinned: true })); + const { stdin } = render( + , + ); + + stdin.write('\x14'); + await settleInput(); + + expect(pinSession).toHaveBeenCalledWith('session-1'); + }); + + it('renames the selected row with Ctrl+R using the prompt', async () => { + const renameSession = vi.fn(async () => ({ displayName: 'Build Fix' })); + const { stdin } = render( + , + ); + + for (const char of 'Build Fix') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\x12'); + await settleInput(); + + expect(renameSession).toHaveBeenCalledWith('session-1', 'Build Fix'); + }); + + it('stops and then removes the selected row with Ctrl+X', async () => { + const stopSession = vi.fn(async () => ({ stopped: true })); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin } = render( + , + ); + + stdin.write('\x18'); + await flushInk(); + stdin.write('\x18'); + await settleInput(); + + expect(stopSession).toHaveBeenCalledWith('session-1'); + expect(removeSession).toHaveBeenCalledWith('session-1'); + }); + + it('keeps the remove window when a successful stop refresh fails', async () => { + const stopSession = vi.fn(async () => ({ stopped: true })); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin } = render( + { + throw new Error('refresh failed'); + }), + })} + onExit={vi.fn()} + />, + ); + + stdin.write('\x18'); + await flushInk(); + stdin.write('\x18'); + await flushInk(); + + expect(stopSession).toHaveBeenCalledOnce(); + expect(removeSession).toHaveBeenCalledWith('session-1'); + }); + + it('does not report successful pin and rename actions as refresh failures', async () => { + const pinSession = vi.fn(async () => ({ pinned: true })); + const renameSession = vi.fn(async () => ({ displayName: 'Launchpad' })); + const dispatchPrompt = vi.fn(); + const { stdin, lastFrame } = render( + { + throw new Error('refresh failed'); + }), + })} + onExit={vi.fn()} + />, + ); + + stdin.write('\x14'); + await waitForFrame(lastFrame, 'Pinned.'); + expect(lastFrame()).toContain('Pinned.'); + + for (const char of 'Launchpad') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\x12'); + await waitForFrame(lastFrame, 'Renamed to Launchpad.'); + stdin.write('\r'); + await flushInk(); + + expect(renameSession).toHaveBeenCalledWith('session-1', 'Launchpad'); + expect(dispatchPrompt).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Renamed to Launchpad.'); + }); + + it('composes repeated same-tick selection moves', async () => { + const onAttachRequested = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write('\x1b[B\x1b[B'); + await flushInk(); + stdin.write('\r'); + await flushInk(); + + expect(onAttachRequested).toHaveBeenCalledWith('session-3'); + }); + + it('navigates directory groups in their rendered order', async () => { + const onAttachRequested = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write('\x13'); + await flushInk(); + stdin.write('\x1b[B\r'); + await flushInk(); + + expect(onAttachRequested).toHaveBeenCalledWith('alpha-old'); + }); + + it('stops a non-running session before allowing remove', async () => { + const stopSession = vi.fn(async () => ({ stopped: true })); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin, lastFrame } = render( + , + ); + + stdin.write('\x18'); + await flushInk(); + + expect(stopSession).toHaveBeenCalledWith('session-1'); + expect(removeSession).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Stopped. Press Ctrl+X again to remove.'); + + stdin.write('\x18'); + await settleInput(); + + expect(removeSession).toHaveBeenCalledWith('session-1'); + }); + + it('preserves another session remove window when an earlier stop fails', async () => { + let rejectFirstStop: (error: Error) => void = () => {}; + const rows = [row('session-a'), row('session-b')]; + const stopSession = vi.fn((sessionId: string) => + sessionId === 'session-a' + ? new Promise((_resolve, reject) => { + rejectFirstStop = reject; + }) + : Promise.resolve({ stopped: true }), + ); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin, lastFrame } = render( + rows), + })} + onExit={vi.fn()} + />, + ); + + stdin.write('\x18'); + await settleInput(); + stdin.write('\u001b[B'); + await flushInk(); + stdin.write('\x18'); + await flushInk(); + + await act(async () => { + rejectFirstStop(new Error('session-a stop failed')); + await flushInk(); + }); + expect(lastFrame()).toContain('session-a stop failed'); + stdin.write('\x18'); + await flushInk(); + + expect(stopSession).toHaveBeenCalledWith('session-a'); + expect(stopSession).toHaveBeenCalledWith('session-b'); + expect(removeSession).toHaveBeenCalledWith('session-b'); + }); + + it('expires the Ctrl+X remove confirmation window', async () => { + vi.useFakeTimers(); + const stopSession = vi.fn(async () => ({ stopped: true })); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin, lastFrame } = render( + , + ); + + stdin.write('\x18'); + await flushInk(); + expect(stopSession).toHaveBeenCalledOnce(); + expect(lastFrame()).toContain('Stopped. Press Ctrl+X again to remove.'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + await settleInput(); + expect(lastFrame()).not.toContain('Press Ctrl+X again to remove.'); + + stdin.write('\x18'); + await flushInk(); + + expect(removeSession).not.toHaveBeenCalled(); + expect(stopSession).toHaveBeenCalledTimes(2); + expect(lastFrame()).toContain('Stopped. Press Ctrl+X again to remove.'); + }); + + it('keeps dispatch input active after the Ctrl+X remove hint', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin, lastFrame } = render( + , + ); + + stdin.write('\x18'); + await flushInk(); + expect(lastFrame()).toContain('Stopped. Press Ctrl+X again to remove.'); + + for (const char of 'new task') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(removeSession).not.toHaveBeenCalled(); + expect(dispatchPrompt).toHaveBeenCalledWith('new task', false); + }); + + it('keeps dispatch input active after removing a session', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const removeSession = vi.fn(async () => ({ removed: true })); + const { stdin, lastFrame } = render( + , + ); + + stdin.write('\x18'); + await settleInput(); + stdin.write('\x18'); + await settleInput(); + await flushInk(); + expect(removeSession).toHaveBeenCalledWith('session-1'); + + for (const char of 'next task') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).toHaveBeenCalledWith('next task', false); + expect(lastFrame()).toContain('describe a task for a new session'); + }); + + it('uses s: prompts as filters instead of dispatch prompts', async () => { + const dispatchPrompt = vi.fn(); + const { stdin, lastFrame } = render( + , + ); + + for (const char of 's:idle') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Showing 1 matching session(s).'); + expect(lastFrame()).toContain('idle-session'); + expect(lastFrame()).not.toContain('working-session'); + }); + + it('dispatches prompts whose later words start with s:', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const { stdin, lastFrame } = render( + , + ); + + const prompt = 'fix the s:12:34 timestamp bug'; + for (const char of prompt) { + stdin.write(char); + await Promise.resolve(); + } + await flushInk(); + expect(lastFrame()).toContain('session-1'); + + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).toHaveBeenCalledWith(prompt, false); + }); + + it('keeps rows visible while typing a new-session prompt', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const { stdin, lastFrame } = render( + , + ); + + for (const char of 'launch') { + stdin.write(char); + await Promise.resolve(); + } + await flushInk(); + // Rows must stay rendered while the prompt is being typed. + expect(lastFrame()).toContain('Launchpad'); + + stdin.write('\r'); + await settleInput(); + + expect(dispatchPrompt).toHaveBeenCalledWith('launch', false); + }); + + it('shows shortcut help', async () => { + const { stdin, lastFrame } = render( + , + ); + + stdin.write('?'); + await flushInk(); + + expect(lastFrame()).toContain('Shortcuts'); + expect(lastFrame()).toContain('Ctrl+S: toggle grouping'); + }); + + it('dispatches slash commands as new-session input', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const onAttachRequested = vi.fn(); + const { stdin } = render( + , + ); + + for (const char of '/model') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).toHaveBeenCalledWith('/model', false); + expect(onAttachRequested).not.toHaveBeenCalled(); + }); + + it.each(['/quit', '/exit', '/quit now', '/exit now'] as const)( + 'handles %s locally by exiting the roster', + async (input) => { + const dispatchPrompt = vi.fn(); + const onExit = vi.fn(); + const { stdin } = render( + , + ); + + for (const char of input) { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).not.toHaveBeenCalled(); + expect(onExit).toHaveBeenCalledOnce(); + }, + ); + + it.each(['/resume named-session', '/continue named-session'] as const)( + 'handles %s locally instead of dispatching it', + async (input) => { + const dispatchPrompt = vi.fn(); + const onResumeRequested = vi.fn(); + const { stdin } = render( + , + ); + + for (const char of input) { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).not.toHaveBeenCalled(); + expect(onResumeRequested).toHaveBeenCalledOnce(); + }, + ); + + it.each(['/exit\nfinish the task', '/continue\nfinish the task'] as const)( + 'dispatches the multi-line prompt %j instead of handling it locally', + async (input) => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const onExit = vi.fn(); + const onResumeRequested = vi.fn(); + const { stdin } = render( + , + ); + + for (const char of input) { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).toHaveBeenCalledWith(input, false); + expect(onExit).not.toHaveBeenCalled(); + expect(onResumeRequested).not.toHaveBeenCalled(); + }, + ); + + it('keeps only one dispatch in flight while a new session is starting', async () => { + let resolveDispatch: (value: unknown) => void = () => undefined; + const dispatchPrompt = vi.fn( + () => + new Promise((resolve) => { + resolveDispatch = resolve; + }), + ); + const { stdin, lastFrame } = render( + , + ); + + for (const char of 'slow') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await settleInput(); + for (const char of 'next') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await Promise.resolve(); + await Promise.resolve(); + + expect(dispatchPrompt).toHaveBeenCalledOnce(); + expect(dispatchPrompt).toHaveBeenCalledWith('slow', false); + expect(lastFrame()).toContain('Starting session'); + expect(lastFrame()).toContain('> next'); + + resolveDispatch({ sessionId: 'new-session' }); + await flushInk(); + }); + + it('keeps unknown slash prompts dispatchable for user commands and skills', async () => { + const dispatchPrompt = vi.fn(async () => ({ sessionId: 'new-session' })); + const { stdin } = render( + , + ); + + for (const char of '/zz') { + stdin.write(char); + await Promise.resolve(); + } + stdin.write('\r'); + await flushInk(); + + expect(dispatchPrompt).toHaveBeenCalledWith('/zz', false); + }, 10_000); + + it('clears input on Ctrl+C and exits on a repeated Ctrl+C', async () => { + const onExit = vi.fn(); + const { stdin, lastFrame } = render( + , + ); + + stdin.write('x'); + await flushInk(); + expect(lastFrame()).toContain('> x'); + + stdin.write('\x03'); + await flushInk(); + expect(lastFrame()).not.toContain('> x'); + expect(onExit).not.toHaveBeenCalled(); + + stdin.write('\x03'); + await flushInk(); + expect(onExit).toHaveBeenCalledOnce(); + }); + + it('absorbs Ctrl+C after a draft is edited back to empty', async () => { + const onExit = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write('x'); + await flushInk(); + stdin.write('\x03'); + await flushInk(); + stdin.write('a'); + await flushInk(); + stdin.write('\x7f'); + await flushInk(); + stdin.write('\x03'); + await flushInk(); + + expect(onExit).not.toHaveBeenCalled(); + }); + + it('composes same-tick Ctrl+C presses', async () => { + const onExit = vi.fn(); + const { stdin } = render( + , + ); + + stdin.write('\x03'); + stdin.write('\x03'); + await flushInk(); + + expect(onExit).toHaveBeenCalledOnce(); + }); + + it('refreshes rows on the configured interval', async () => { + vi.useFakeTimers(); + try { + const loadRows = vi.fn(async () => [row('session-2')]); + const { lastFrame } = render( + , + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(10); + }); + await Promise.resolve(); + + expect(loadRows).toHaveBeenCalledOnce(); + expect(lastFrame()).toContain('session-2'); + } finally { + vi.useRealTimers(); + } + }); + + it('refreshes rows when the supervisor subscription reports a change', async () => { + let notify: (() => void) | undefined; + const loadRows = vi.fn(async () => [row('session-2')]); + const { lastFrame } = render( + { + notify = onChange; + return { dispose: vi.fn() }; + }, + })} + onExit={vi.fn()} + />, + ); + + await act(async () => { + notify?.(); + }); + await flushInk(); + + expect(loadRows).toHaveBeenCalledOnce(); + expect(lastFrame()).toContain('session-2'); + }); +}); + +function render(element: ReactElement) { + return inkRender( + {element}, + ); +} + +function actions( + overrides: Partial['actions']> = {}, +): ComponentProps['actions'] { + return { + dispatchPrompt: vi.fn(), + peekSelected: vi.fn(), + sendToSession: vi.fn(), + answerSession: vi.fn(), + pinSession: vi.fn(), + renameSession: vi.fn(), + stopSession: vi.fn(), + removeSession: vi.fn(), + loadRows: vi.fn(async () => [row('session-1')]), + ...overrides, + }; +} + +function sessionPanel( + sessionId: string, + lines: string[], +): AgentViewSessionPanel { + return { + kind: 'session', + sessionId, + content: 'activity', + lines, + }; +} + +async function flushInk(): Promise { + for (let index = 0; index < 5; index++) { + await Promise.resolve(); + await new Promise((resolve) => process.nextTick(resolve)); + } +} + +async function settleInput(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +async function waitForFrame( + lastFrame: () => string | undefined, + text: string, +): Promise { + for (let index = 0; index < 20; index++) { + await flushInk(); + if (lastFrame()?.includes(text)) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +function row( + sessionId: string, + overrides: Partial = {}, +): AgentRosterRow { + return { + sessionId, + displayName: sessionId, + state: 'idle', + stateLabel: 'Idle', + stateGroup: 'done', + taskState: 'ready', + inputState: 'none', + runtimeState: 'alive', + recoverability: 'live', + iconShape: 'alive', + iconTone: 'ready', + title: sessionId, + subtitle: '', + actions: { + canAttach: true, + canPeek: true, + canReply: true, + canStop: false, + canRemove: true, + canRespawn: false, + canHibernate: true, + needsBlockingAnswer: false, + }, + project: 'qwen-code', + projectCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code', + cwd: '/workspace/qwen-code', + ageMs: 60_000, + ageLabel: '1m', + updatedAt: '2026-07-17T10:00:00.000Z', + alive: true, + aliveIndicator: 'alive', + ...overrides, + }; +} diff --git a/packages/cli/src/ui/agent-view/AgentViewApp.tsx b/packages/cli/src/ui/agent-view/AgentViewApp.tsx new file mode 100644 index 00000000000..b4493450e23 --- /dev/null +++ b/packages/cli/src/ui/agent-view/AgentViewApp.tsx @@ -0,0 +1,822 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { render } from 'ink'; +import { clearScreen } from '../../utils/stdioHelpers.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { AgentViewRoster } from './AgentViewRoster.js'; +import type { + AgentViewHeaderInfo, + AgentViewNotice, + AgentViewPanel, + AgentViewSessionPanel, +} from './AgentViewRoster.js'; +import { + filterAgentRosterRows, + isAgentRosterBlockingWait, + orderAgentRosterRows, + type AgentRosterGroupMode, + type AgentRosterRow, +} from './roster-model.js'; + +export interface AgentViewAppActions { + dispatchPrompt(prompt: string, attach: boolean): Promise; + peekSelected(sessionId: string): Promise; + sendToSession(sessionId: string, text: string): Promise; + answerSession(sessionId: string, text: string): Promise; + pinSession(sessionId: string): Promise; + renameSession(sessionId: string, displayName: string): Promise; + stopSession(sessionId: string): Promise; + removeSession(sessionId: string): Promise; + loadRows(): Promise; + subscribeToChanges?(onChange: () => void): { dispose(): void }; +} + +export type AgentViewRosterResult = + | { type: 'exit' } + | { type: 'attach'; sessionId: string } + | { type: 'resume' }; + +export interface AgentViewAppProps { + rows: AgentRosterRow[]; + actions: AgentViewAppActions; + onExit: () => void; + onAttachRequested?: (sessionId: string) => void; + onResumeRequested?: () => void; + header?: AgentViewHeaderInfo; + initialPeekPanel?: AgentViewPanel; + refreshIntervalMs?: number; +} + +interface ReplyTarget { + sessionId: string; + mode: 'answer' | 'send'; +} + +interface PeekSubmittedPreview { + sessionId: string; + prompt: string; +} + +const STOP_REMOVE_CONFIRM_MS = 2000; + +export function AgentViewApp({ + rows, + actions, + onExit, + onAttachRequested, + onResumeRequested, + header, + initialPeekPanel, + refreshIntervalMs, +}: AgentViewAppProps) { + const [currentRows, setCurrentRows] = useState(rows); + const [prompt, setPrompt] = useState(''); + const [promptVersion, setPromptVersion] = useState(0); + const [peekPrompt, setPeekPrompt] = useState(''); + const [selectedSessionId, setSelectedSessionId] = useState< + string | undefined + >(rows[0]?.sessionId); + const [peekPanel, setPeekPanel] = useState( + initialPeekPanel, + ); + const [notice, setNotice] = useState(); + const [peekReplyTarget, setPeekReplyTarget] = useState(); + const [peekSubmittedPreview, setPeekSubmittedPreview] = + useState(); + const [groupMode, setGroupMode] = useState('state'); + const rowsPropRef = useRef(rows); + const lastStopRequestRef = useRef< + | { + sessionId: string; + at: number; + } + | undefined + >(undefined); + const stopRemoveTimerRef = useRef(undefined); + const lastInterruptAtRef = useRef(0); + const dispatchInFlightRef = useRef(false); + const peekSubmitInFlightRef = useRef(false); + const pinInFlightRef = useRef(false); + const promptRevisionRef = useRef(0); + // Invalidates in-flight peek loads on every open/close so a stale response + // can never overwrite a newer panel or resurrect a closed one. + const peekGenerationRef = useRef(0); + const displayFilter = + peekPanel?.kind === 'filter' + ? peekPanel.query + : peekPanel + ? undefined + : getDisplayFilter(prompt); + const visibleRows = useMemo( + () => + orderAgentRosterRows( + filterAgentRosterRows(currentRows, displayFilter), + groupMode, + ), + [currentRows, displayFilter, groupMode], + ); + const selectedIndex = getSelectedIndex(visibleRows, selectedSessionId); + + useEffect(() => { + if (rowsPropRef.current === rows) { + return; + } + rowsPropRef.current = rows; + setCurrentRows(rows); + }, [rows]); + + useEffect(() => { + if (visibleRows.length === 0) { + setSelectedSessionId(undefined); + return; + } + if (!visibleRows.some((row) => row.sessionId === selectedSessionId)) { + setSelectedSessionId(visibleRows[0]?.sessionId); + } + }, [selectedSessionId, visibleRows]); + + useEffect(() => { + if (!peekPanel) return; + if (peekPanel.kind !== 'session') return; + const row = currentRows.find( + (item) => item.sessionId === peekPanel.sessionId, + ); + if (!row) { + setPeekReplyTarget(undefined); + setPeekPrompt(''); + setPeekSubmittedPreview(undefined); + if (peekPanel.tone !== 'error') { + peekGenerationRef.current += 1; + setPeekPanel(undefined); + } + return; + } + setPeekReplyTarget(getReplyTarget(row)); + }, [currentRows, peekPanel]); + + const refreshRows = useCallback(async () => { + const rows = await actions.loadRows(); + setCurrentRows(rows); + return rows; + }, [actions]); + + useEffect(() => { + if (!refreshIntervalMs || refreshIntervalMs <= 0) return undefined; + const interval = setInterval(() => { + void refreshRows().catch(() => {}); + }, refreshIntervalMs); + return () => { + clearInterval(interval); + }; + }, [refreshIntervalMs, refreshRows]); + + useEffect(() => { + const subscription = actions.subscribeToChanges?.(() => { + void refreshRows().catch(() => {}); + }); + return () => { + subscription?.dispose(); + }; + }, [actions, refreshRows]); + + useEffect( + () => () => { + if (stopRemoveTimerRef.current) { + clearTimeout(stopRemoveTimerRef.current); + } + }, + [], + ); + + const moveSelection = useCallback( + (delta: number) => { + if (visibleRows.length === 0) { + setSelectedSessionId(undefined); + return; + } + setSelectedSessionId((currentSessionId) => { + const currentIndex = getSelectedIndex(visibleRows, currentSessionId); + const nextIndex = Math.min( + visibleRows.length - 1, + Math.max(0, currentIndex + delta), + ); + return visibleRows[nextIndex]?.sessionId; + }); + }, + [visibleRows], + ); + + const dispatch = useCallback( + (attach: boolean, promptOverride?: string): boolean => { + if (dispatchInFlightRef.current) { + setNotice({ + lines: ['Starting session...'], + }); + setPeekReplyTarget(undefined); + setPeekPrompt(''); + return false; + } + const promptToSubmit = promptOverride ?? prompt; + + if (isRosterExitCommand(promptToSubmit)) { + onExit(); + return true; + } + + if (isRosterResumeCommand(promptToSubmit)) { + setPrompt(''); + onResumeRequested?.(); + return true; + } + + if (isBlockingFilterPrompt(promptToSubmit)) { + const matchingRows = filterAgentRosterRows(currentRows, promptToSubmit); + setPeekPanel({ + kind: 'filter', + query: promptToSubmit, + lines: [`Showing ${matchingRows.length} matching session(s).`], + }); + setPeekReplyTarget(undefined); + setPeekPrompt(''); + return false; + } + + const submitted = promptToSubmit; + const restoreRevision = promptRevisionRef.current; + dispatchInFlightRef.current = true; + setPrompt(''); + setNotice({ + lines: [`Starting session: ${submitted}`], + }); + peekGenerationRef.current += 1; + setPeekPanel(undefined); + setPeekReplyTarget(undefined); + setPeekPrompt(''); + + void (async () => { + try { + const result = await actions.dispatchPrompt(submitted, attach); + dispatchInFlightRef.current = false; + if (attach) { + const sessionId = getDispatchedSessionId(result); + if (!sessionId) { + if (promptRevisionRef.current === restoreRevision) { + setPrompt(submitted); + setPromptVersion((current) => current + 1); + } + setNotice({ + lines: ['Agent dispatch did not return a session id.'], + }); + setPeekReplyTarget(undefined); + setPeekPrompt(''); + return; + } + onAttachRequested?.(sessionId); + return; + } + // The dispatch itself succeeded; a refresh failure must not present + // it as a failed dispatch (a re-Enter would duplicate the session). + try { + await refreshRows(); + } catch { + // Rows catch up on the next poll tick. + } + setNotice({ + lines: ['Dispatched.'], + }); + setPeekReplyTarget(undefined); + setPeekPrompt(''); + } catch (error) { + dispatchInFlightRef.current = false; + if (promptRevisionRef.current === restoreRevision) { + setPrompt(submitted); + setPromptVersion((current) => current + 1); + } + setNotice({ + lines: [error instanceof Error ? error.message : String(error)], + }); + setPeekReplyTarget(undefined); + setPeekPrompt(''); + } + })(); + return true; + }, + [ + actions, + currentRows, + onAttachRequested, + onExit, + onResumeRequested, + prompt, + refreshRows, + ], + ); + + const submitPeekPrompt = useCallback( + (promptOverride?: string): boolean => { + const promptToSubmit = promptOverride ?? peekPrompt; + if (!peekReplyTarget || !promptToSubmit.trim()) return false; + if (peekSubmitInFlightRef.current) { + setNotice({ lines: ['Reply is still being sent.'] }); + return false; + } + + const currentRow = currentRows.find( + (row) => row.sessionId === peekReplyTarget.sessionId, + ); + const target = currentRow + ? (getReplyTarget(currentRow) ?? peekReplyTarget) + : peekReplyTarget; + const submitted = promptToSubmit; + const generation = peekGenerationRef.current; + setPeekPrompt(''); + setPeekSubmittedPreview({ + sessionId: target.sessionId, + prompt: submitted, + }); + setPeekPanel((current) => { + if ( + current?.kind === 'session' && + current.sessionId === target.sessionId && + current.tone !== 'error' + ) { + return current; + } + return { + kind: 'session', + sessionId: target.sessionId, + content: 'activity', + lines: [], + }; + }); + peekSubmitInFlightRef.current = true; + void (async () => { + try { + if (target.mode === 'answer') { + await actions.answerSession(target.sessionId, submitted); + } else { + await actions.sendToSession(target.sessionId, submitted); + } + // The reply was delivered; only restore it if the send itself + // failed, never on a post-success refresh failure. + if (peekGenerationRef.current === generation) { + try { + const rows = await refreshRows(); + const row = rows.find( + (item) => item.sessionId === target.sessionId, + ); + setPeekReplyTarget(row ? getReplyTarget(row) : undefined); + } catch { + setPeekReplyTarget(undefined); + } + } + setPeekSubmittedPreview(undefined); + } catch (error) { + // Restore the undelivered reply for retry, but never resurrect a + // panel the user closed (or overwrite a newer one) while the send + // was in flight. + setPeekSubmittedPreview(undefined); + if (peekGenerationRef.current === generation) { + peekGenerationRef.current += 1; + setPeekPrompt((current) => current || submitted); + setPeekPanel({ + kind: 'session', + sessionId: target.sessionId, + content: 'message', + lines: [ + `Prompt: ${submitted}`, + error instanceof Error ? error.message : String(error), + ], + tone: 'error', + }); + } else { + setNotice({ + lines: [ + `Reply was not sent: ${ + error instanceof Error ? error.message : String(error) + }`, + ], + }); + } + } finally { + peekSubmitInFlightRef.current = false; + } + })(); + return true; + }, + [actions, currentRows, peekPrompt, peekReplyTarget, refreshRows], + ); + + const attachSession = useCallback( + (sessionId: string) => { + const row = visibleRows.find((item) => item.sessionId === sessionId); + if (!row) return; + if (!row.actions.canAttach) { + setNotice({ + lines: ['This session is not attachable right now.'], + }); + return; + } + setSelectedSessionId(row.sessionId); + onAttachRequested?.(row.sessionId); + }, + [onAttachRequested, visibleRows], + ); + + const peekSession = useCallback( + (sessionId: string) => { + const row = visibleRows.find((item) => item.sessionId === sessionId); + if (!row) return; + setSelectedSessionId(row.sessionId); + setNotice(undefined); + setPeekSubmittedPreview(undefined); + setPeekReplyTarget(getReplyTarget(row)); + setPeekPrompt(''); + const generation = ++peekGenerationRef.current; + setPeekPanel({ + kind: 'session', + sessionId: row.sessionId, + content: 'message', + lines: ['Loading...'], + }); + void Promise.resolve(actions.peekSelected(row.sessionId)).then( + (panel) => { + if (peekGenerationRef.current === generation) { + setPeekPanel(panel); + } + }, + (error) => { + if (peekGenerationRef.current !== generation) return; + setPeekPanel({ + kind: 'session', + sessionId: row.sessionId, + content: 'message', + lines: [error instanceof Error ? error.message : String(error)], + tone: 'error', + }); + }, + ); + }, + [actions, visibleRows], + ); + + const togglePinSession = useCallback( + (sessionId: string) => { + const row = visibleRows.find((item) => item.sessionId === sessionId); + if (!row || pinInFlightRef.current) return; + pinInFlightRef.current = true; + void (async () => { + try { + await actions.pinSession(row.sessionId); + await refreshRows().catch(() => {}); + setNotice({ + lines: [row.pinned ? 'Unpinned.' : 'Pinned.'], + }); + } catch (error) { + setNotice({ + lines: [error instanceof Error ? error.message : String(error)], + }); + } finally { + pinInFlightRef.current = false; + } + })(); + }, + [actions, refreshRows, visibleRows], + ); + + const renameSession = useCallback( + (sessionId: string, displayName: string) => { + const row = visibleRows.find((item) => item.sessionId === sessionId); + if (!row) return; + const previousPrompt = displayName; + const restoreRevision = promptRevisionRef.current; + setPrompt(''); + void (async () => { + try { + await actions.renameSession(row.sessionId, displayName); + await refreshRows().catch(() => {}); + setNotice({ + lines: [ + displayName ? `Renamed to ${displayName}.` : 'Name cleared.', + ], + }); + } catch (error) { + if (promptRevisionRef.current === restoreRevision) { + setPrompt(previousPrompt); + setPromptVersion((current) => current + 1); + } + setNotice({ + lines: [error instanceof Error ? error.message : String(error)], + }); + } + })(); + }, + [actions, refreshRows, visibleRows], + ); + + const stopOrRemoveSession = useCallback( + (sessionId: string) => { + const row = visibleRows.find((item) => item.sessionId === sessionId); + if (!row) return; + const now = Date.now(); + const pendingStop = lastStopRequestRef.current; + const remove = + pendingStop?.sessionId === row.sessionId && + now - pendingStop.at <= STOP_REMOVE_CONFIRM_MS; + const showRemoveHint = (message: string) => { + const hintAt = Date.now(); + const hint: AgentViewNotice = { lines: [message] }; + lastStopRequestRef.current = { sessionId: row.sessionId, at: hintAt }; + if (stopRemoveTimerRef.current) { + clearTimeout(stopRemoveTimerRef.current); + } + stopRemoveTimerRef.current = setTimeout(() => { + const current = lastStopRequestRef.current; + if (current?.sessionId === row.sessionId && current.at === hintAt) { + lastStopRequestRef.current = undefined; + setNotice((currentNotice) => + currentNotice === hint ? undefined : currentNotice, + ); + } + }, STOP_REMOVE_CONFIRM_MS); + setNotice(hint); + }; + lastStopRequestRef.current = remove + ? undefined + : { sessionId: row.sessionId, at: now }; + if (remove && stopRemoveTimerRef.current) { + clearTimeout(stopRemoveTimerRef.current); + stopRemoveTimerRef.current = undefined; + } + if (!remove) { + showRemoveHint('Stopped. Press Ctrl+X again to remove.'); + } + const stopRequest = remove ? undefined : lastStopRequestRef.current; + void (async () => { + try { + if (remove) { + await actions.removeSession(row.sessionId); + } else { + await actions.stopSession(row.sessionId); + } + await refreshRows().catch(() => {}); + if (remove) { + setNotice({ lines: ['Removed.'] }); + } + } catch (error) { + const ownsStopRequest = + remove || lastStopRequestRef.current === stopRequest; + if (!remove && ownsStopRequest) { + lastStopRequestRef.current = undefined; + if (stopRemoveTimerRef.current) { + clearTimeout(stopRemoveTimerRef.current); + stopRemoveTimerRef.current = undefined; + } + } + setNotice({ + lines: [error instanceof Error ? error.message : String(error)], + }); + } + })(); + }, + [actions, refreshRows, visibleRows], + ); + + const toggleGroupMode = useCallback(() => { + setGroupMode((current) => (current === 'state' ? 'directory' : 'state')); + setNotice({ + lines: [ + groupMode === 'state' ? 'Grouped by directory.' : 'Grouped by state.', + ], + }); + }, [groupMode]); + + const showHelp = useCallback(() => { + setNotice({ + title: 'Shortcuts', + lines: [ + 'Enter/Right: attach', + 'Prompt + Enter: dispatch', + 'Shift+Enter: dispatch and attach', + 'Space: peek', + 'Ctrl+S: toggle grouping', + 'Ctrl+T: pin/unpin', + 'Ctrl+R: rename using prompt', + 'Ctrl+X: stop; press again to remove', + 'Esc: close, clear, or exit', + 'Ctrl+C: clear; press again to exit', + ], + }); + }, []); + + const interrupt = useCallback( + (clearedDraft: boolean) => { + const now = Date.now(); + if (clearedDraft) { + lastInterruptAtRef.current = now; + return; + } + if (now - lastInterruptAtRef.current <= 2000) { + onExit(); + return; + } + lastInterruptAtRef.current = now; + setNotice({ + lines: ['Press Ctrl+C again to exit.'], + }); + }, + [onExit], + ); + + const cancel = useCallback(() => { + if (peekPanel) { + peekGenerationRef.current += 1; + setPeekReplyTarget(undefined); + setPeekSubmittedPreview(undefined); + setPeekPrompt(''); + setPeekPanel(undefined); + return; + } + if (notice) { + setNotice(undefined); + return; + } + if (prompt) { + promptRevisionRef.current += 1; + setPrompt(''); + return; + } + onExit(); + }, [notice, onExit, peekPanel, prompt]); + + return ( + { + promptRevisionRef.current += 1; + }} + onPeekPromptChange={setPeekPrompt} + onDispatch={dispatch} + onSubmitPeekPrompt={submitPeekPrompt} + onAttachSession={attachSession} + onPeekSession={peekSession} + onTogglePinSession={togglePinSession} + onRenameSession={renameSession} + onStopOrRemoveSession={stopOrRemoveSession} + onToggleGroupMode={toggleGroupMode} + onShowHelp={showHelp} + onInterrupt={interrupt} + onMoveSelection={moveSelection} + onCancel={cancel} + /> + ); +} + +function getReplyTarget(row: AgentRosterRow): ReplyTarget | undefined { + // canReply and needsBlockingAnswer are mutually exclusive in deriveActions; + // a blocking approval (e.g. 'Waiting: Edit') is only reachable through the + // answer path, so it must not be gated out by canReply. + if (!row.actions.canReply && !row.actions.needsBlockingAnswer) { + return undefined; + } + if ((row.queuedPromptCount ?? 0) > 0 && !isAgentRosterBlockingWait(row)) { + return undefined; + } + return { + sessionId: row.sessionId, + mode: row.actions.needsBlockingAnswer ? 'answer' : 'send', + }; +} + +function getPeekQueuedPrompts( + preview: PeekSubmittedPreview | undefined, + rows: AgentRosterRow[], + panel: AgentViewPanel | undefined, +): string[] | undefined { + const sessionId = panel?.kind === 'session' ? panel.sessionId : undefined; + const row = rows.find((item) => item.sessionId === sessionId); + if (preview && preview.sessionId === sessionId) { + return [preview.prompt]; + } + if (!row || (row.queuedPromptCount ?? 0) <= 0) { + return undefined; + } + const prompt = row.queuedPromptPreview?.trim(); + return prompt ? [prompt] : undefined; +} + +function getSelectedIndex( + rows: AgentRosterRow[], + selectedSessionId: string | undefined, +): number { + if (rows.length === 0) { + return 0; + } + const index = rows.findIndex((row) => row.sessionId === selectedSessionId); + return index >= 0 ? index : 0; +} + +function getDisplayFilter(prompt: string): string | undefined { + const trimmed = prompt.trim(); + if (!trimmed) return undefined; + if (isBlockingFilterPrompt(trimmed)) return trimmed; + return undefined; +} + +function isBlockingFilterPrompt(prompt: string): boolean { + return prompt.trimStart().toLowerCase().split(/\s+/, 1)[0].startsWith('s:'); +} + +function isRosterExitCommand(prompt: string): boolean { + if (/[\r\n]/.test(prompt)) return false; + const command = prompt.trim().toLowerCase().split(/\s+/, 1)[0]; + return command === '/quit' || command === '/exit'; +} + +function isRosterResumeCommand(prompt: string): boolean { + if (/[\r\n]/.test(prompt)) return false; + const command = prompt.trim().toLowerCase().split(/\s+/, 1)[0]; + return command === '/resume' || command === '/continue'; +} + +function getDispatchedSessionId(value: unknown): string | undefined { + if ( + typeof value === 'object' && + value !== null && + 'sessionId' in value && + typeof value.sessionId === 'string' + ) { + return value.sessionId; + } + return undefined; +} + +export async function runAgentViewRosterApp( + rows: AgentRosterRow[], + actions: AgentViewAppActions, + header?: AgentViewHeaderInfo, + initialPeekPanel?: AgentViewPanel, +): Promise { + clearScreen(); + + return new Promise((resolve) => { + let settled = false; + let resultToResolve: AgentViewRosterResult = { type: 'exit' }; + const cleanup: { unmount?: () => void } = {}; + const finish = (result: AgentViewRosterResult = { type: 'exit' }) => { + if (settled) return; + settled = true; + resultToResolve = result; + cleanup.unmount?.(); + }; + const instance = render( + + + finish({ type: 'attach', sessionId }) + } + onResumeRequested={() => finish({ type: 'resume' })} + refreshIntervalMs={1000} + /> + , + { exitOnCtrlC: false }, + ); + cleanup.unmount = instance.unmount; + + void instance.waitUntilExit().then(() => { + clearScreen(); + resolve(resultToResolve); + }); + }); +} diff --git a/packages/cli/src/ui/agent-view/AgentViewRoster.terminal.test.tsx b/packages/cli/src/ui/agent-view/AgentViewRoster.terminal.test.tsx new file mode 100644 index 00000000000..77f22c5bead --- /dev/null +++ b/packages/cli/src/ui/agent-view/AgentViewRoster.terminal.test.tsx @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cleanup, render } from 'ink-testing-library'; +import { act } from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { AgentViewRoster } from './AgentViewRoster.js'; + +vi.mock('../../services/BuiltinCommandLoader.js', () => ({ + BuiltinCommandLoader: class { + loadCommands() { + return Promise.resolve([]); + } + }, +})); + +describe('AgentViewRoster terminal input', () => { + afterEach(cleanup); + + it('drops terminal responses after Ink strips their ESC prefix', async () => { + const onPromptChange = vi.fn(); + const onCancel = vi.fn(); + const onDispatch = vi.fn(() => true); + const { stdin } = render( + + true)} + onAttachSession={vi.fn()} + onPeekSession={vi.fn()} + onTogglePinSession={vi.fn()} + onRenameSession={vi.fn()} + onStopOrRemoveSession={vi.fn()} + onToggleGroupMode={vi.fn()} + onShowHelp={vi.fn()} + onInterrupt={vi.fn()} + onMoveSelection={vi.fn()} + onCancel={onCancel} + /> + , + ); + + stdin.write('\x1b[?1;2c'); + stdin.write('\x1b[27;2;13~'); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + expect(onPromptChange).not.toHaveBeenCalled(); + expect(onDispatch).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/agent-view/AgentViewRoster.test.tsx b/packages/cli/src/ui/agent-view/AgentViewRoster.test.tsx new file mode 100644 index 00000000000..76e7e6f1f69 --- /dev/null +++ b/packages/cli/src/ui/agent-view/AgentViewRoster.test.tsx @@ -0,0 +1,1113 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { cleanup, render } from 'ink-testing-library'; +import { act } from 'react'; +import { afterEach, describe, expect, it, beforeEach, vi } from 'vitest'; +import { + AgentViewRoster, + type AgentViewRosterProps, + type AgentViewSessionPanel, +} from './AgentViewRoster.js'; +import { KeypressProvider } from '../contexts/KeypressContext.js'; +import { CommandKind, type SlashCommand } from '../commands/types.js'; +import type { AgentRosterRow } from './roster-model.js'; + +interface TestKey { + return?: boolean; + shift?: boolean; + escape?: boolean; + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + home?: boolean; + end?: boolean; + tab?: boolean; + backspace?: boolean; + delete?: boolean; + ctrl?: boolean; + meta?: boolean; +} + +type InputHandler = (input: string, key: TestKey) => void; + +const inputState = vi.hoisted(() => ({ + handlers: [] as InputHandler[], +})); + +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useInput: (handler: InputHandler) => { + inputState.handlers.push(handler); + }, + }; +}); + +vi.mock('../hooks/useTerminalSize.js', () => ({ + useTerminalSize: () => ({ columns: 140, rows: 24 }), +})); + +describe('AgentViewRoster', () => { + beforeEach(() => { + inputState.handlers = []; + }); + + afterEach(() => { + cleanup(); + }); + + it('renders roster rows and the prompt', () => { + const { lastFrame } = renderRoster({ + rows: [ + row('alpha', { + displayName: 'Launchpad', + pinned: true, + summary: 'Waiting on approval', + lastResult: 'Ready to continue', + }), + row('beta', { + stateLabel: 'Working', + project: 'core', + ageLabel: '2m', + aliveIndicator: 'hibernating', + waitingFor: 'tests', + lastResult: 'What would you like to test?', + }), + ], + selectedIndex: 1, + prompt: 'check status', + groupMode: 'state', + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('* Launchpad'); + expect(output).toContain('Pinned'); + expect(output).toContain('Ready to continue'); + expect(output).toContain('enter to open'); + expect(output).toContain('space to reply'); + expect(output).toContain('ctrl+x to delete'); + expect(output).not.toContain('Waiting on approval'); + expect(output).toMatch(/> \* beta\s+What would you like to test\? 2m/); + expect(output).toContain('check status'); + }); + + it('renders the Qwen header with model and directory details', () => { + const { lastFrame } = renderRoster({ + header: { + version: '0.20.0', + authLabel: 'API Key', + providerLabel: 'IdealLab', + model: 'qwen3.7-max', + cwd: '/workspace/qwen-code', + }, + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('██╔═══██╗'); + expect(output).toContain('Qwen Code'); + expect(output).toContain('(v0.20.0)'); + expect(output).toContain('API Key | [IdealLab] qwen3.7-max'); + expect(output).toContain('/workspace/qwen-code'); + expect(output).toContain('Tips:'); + }); + + it('dispatches prompt in the background on Enter', () => { + const onDispatch = vi.fn(() => true); + + renderRoster({ + prompt: 'ship it', + onDispatch, + }); + + press('', { return: true }); + + expect(onDispatch).toHaveBeenCalledWith(false, 'ship it'); + }); + + it('dispatches prompt when a PTY sends carriage return', () => { + const onDispatch = vi.fn(() => true); + + renderRoster({ + prompt: 'ship it', + onDispatch, + }); + + press('\r', {}); + + expect(onDispatch).toHaveBeenCalledWith(false, 'ship it'); + }); + + it('dispatches when a PTY sends text and carriage return together', () => { + const onDispatch = vi.fn(() => true); + + renderRoster({ + onDispatch, + }); + + press('ship it\r', {}); + + expect(onDispatch).toHaveBeenCalledWith(false, 'ship it'); + }); + + it.each(['\\\r', '\\\r\n'])( + 'dispatches and attaches legacy VSCode Shift+Enter %j', + (input) => { + const onDispatch = vi.fn(() => true); + + renderRoster({ + prompt: 'ship it', + onDispatch, + }); + + press(input, {}); + + expect(onDispatch).toHaveBeenCalledWith(true, 'ship it'); + }, + ); + + it('attaches the selected session on empty Enter or right arrow', () => { + const onAttachSession = vi.fn(); + + renderRoster({ + prompt: '', + onAttachSession, + }); + + press('', { return: true }); + press('', { rightArrow: true }); + + expect(onAttachSession).toHaveBeenCalledTimes(2); + expect(onAttachSession).toHaveBeenNthCalledWith(1, 'alpha'); + expect(onAttachSession).toHaveBeenNthCalledWith(2, 'alpha'); + }); + + it('moves the cursor right instead of attaching while a prompt is typed', () => { + const onAttachSession = vi.fn(); + const onPromptChange = vi.fn(); + + renderRoster({ + prompt: 'ab', + onAttachSession, + onPromptChange, + }); + + press('', { leftArrow: true }); + press('', { rightArrow: true }); + press('c', {}); + + expect(onAttachSession).not.toHaveBeenCalled(); + expect(onPromptChange).toHaveBeenLastCalledWith('abc'); + }); + + it('supports Home and End in the roster prompt', () => { + const onPromptChange = vi.fn(); + + renderRoster({ + prompt: 'ab', + onPromptChange, + }); + + press('', { home: true }); + press('X', {}); + + expect(onPromptChange).toHaveBeenLastCalledWith('Xab'); + }); + + it('inserts multi-line pastes instead of dropping the tail', () => { + const onDispatch = vi.fn(() => true); + const onPromptChange = vi.fn(); + + renderRoster({ + onDispatch, + onPromptChange, + }); + + press('fix the login bug\nalso update the tests', {}); + + expect(onDispatch).not.toHaveBeenCalled(); + expect(onPromptChange).toHaveBeenLastCalledWith( + 'fix the login bug\nalso update the tests', + ); + }); + + it('keeps a trailing line feed in the prompt without dispatching', () => { + const onDispatch = vi.fn(() => true); + const onPromptChange = vi.fn(); + + renderRoster({ onDispatch, onPromptChange }); + + press('abc\n', {}); + + expect(onDispatch).not.toHaveBeenCalled(); + expect(onPromptChange).toHaveBeenLastCalledWith('abc\n'); + }); + + it('drops terminal responses but keeps bracketed user text', async () => { + const onPromptChange = vi.fn(); + const onCancel = vi.fn(); + + renderRoster({ onPromptChange, onCancel }); + + press('\x1b[I', {}); + press('\x1b[?u', {}); + press('\x1b[10;20R', {}); + press('[?1;2c', {}); + press('[I', {}); + press('[10;20R', {}); + press('\x1b[27;2;13~', {}); + press('[27;2;13~', {}); + press('', { escape: true }); + press('[?u', {}); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + expect(onPromptChange).not.toHaveBeenCalled(); + expect(onCancel).not.toHaveBeenCalled(); + + press('[3]', {}); + expect(onPromptChange).toHaveBeenLastCalledWith('[3]'); + + press('[1;2~]', {}); + expect(onPromptChange).toHaveBeenLastCalledWith('[3][1;2~]'); + + press('[Info] check', {}); + expect(onPromptChange).toHaveBeenLastCalledWith('[3][1;2~][Info] check'); + }); + + it('does not append terminal responses to peek replies', () => { + const onPeekPromptChange = vi.fn(); + + renderRoster({ + peekPanel: sessionPanel(), + peekInputMode: 'send', + onPeekPromptChange, + }); + + press('\x1b', {}); + press('[?u', {}); + + expect(onPeekPromptChange).not.toHaveBeenCalled(); + + press('[404]', {}); + expect(onPeekPromptChange).toHaveBeenLastCalledWith('[404]'); + }); + + it('moves selection and cancels from keyboard shortcuts', async () => { + const onMoveSelection = vi.fn(); + const onCancel = vi.fn(); + + renderRoster({ + onMoveSelection, + onCancel, + }); + + press('', { upArrow: true }); + press('', { downArrow: true }); + press('', { escape: true }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 30)); + }); + + expect(onMoveSelection).toHaveBeenNthCalledWith(1, -1); + expect(onMoveSelection).toHaveBeenNthCalledWith(2, 1); + expect(onCancel).toHaveBeenCalledTimes(1); + }); + + it('renders notices as a bounded single line', () => { + const longNotice = Array.from( + { length: 25 }, + (_, index) => `line-${index + 1}`, + ).join('\n'); + const { lastFrame } = renderRoster({ + notice: { lines: [`Starting session: ${longNotice}`] }, + }); + + const noticeLine = (lastFrame() ?? '') + .split('\n') + .find((line) => line.includes('Starting session:')); + expect(noticeLine).toBeDefined(); + expect(noticeLine).toContain('line-1 line-2'); + expect(noticeLine).toContain('…'); + expect(noticeLine).not.toContain('line-25'); + }); + + it('peeks the selected session on Space when prompt is empty', () => { + const onPeekSession = vi.fn(); + + renderRoster({ + prompt: '', + onPeekSession, + }); + + press(' ', {}); + + expect(onPeekSession).toHaveBeenCalledWith('alpha'); + }); + + it('toggles pin, renames, and stops the selected session from shortcuts', () => { + const onTogglePinSession = vi.fn(); + const onRenameSession = vi.fn(); + const onStopOrRemoveSession = vi.fn(); + + renderRoster({ + prompt: 'Build Fix', + onTogglePinSession, + onRenameSession, + onStopOrRemoveSession, + }); + + press('t', { ctrl: true }); + press('r', { ctrl: true }); + press('x', { ctrl: true }); + + expect(onTogglePinSession).toHaveBeenCalledWith('alpha'); + expect(onRenameSession).toHaveBeenCalledWith('alpha', 'Build Fix'); + expect(onStopOrRemoveSession).toHaveBeenCalledWith('alpha'); + }); + + it('toggles grouping, shows help, and reports Ctrl+C', () => { + const onToggleGroupMode = vi.fn(); + const onShowHelp = vi.fn(); + const onInterrupt = vi.fn(); + + renderRoster({ + onToggleGroupMode, + onShowHelp, + onInterrupt, + }); + + press('s', { ctrl: true }); + press('?', {}); + press('c', { ctrl: true }); + + expect(onToggleGroupMode).toHaveBeenCalledOnce(); + expect(onShowHelp).toHaveBeenCalledOnce(); + expect(onInterrupt).toHaveBeenCalledOnce(); + expect(onInterrupt).toHaveBeenCalledWith(false); + }); + + it('clears the live peek draft before processing more same-tick input', () => { + const onPeekPromptChange = vi.fn(); + const onInterrupt = vi.fn(); + + renderRoster({ + peekPrompt: 'a', + peekPanel: sessionPanel(), + peekInputMode: 'send', + onPeekPromptChange, + onInterrupt, + }); + + pressTogether([ + ['c', { ctrl: true }], + ['b', {}], + ]); + + expect(onInterrupt).toHaveBeenCalledWith(true); + expect(onPeekPromptChange).toHaveBeenNthCalledWith(1, ''); + expect(onPeekPromptChange).toHaveBeenNthCalledWith(2, 'b'); + }); + + it('clears live main input when typing and Ctrl+C arrive together', () => { + const onPromptChange = vi.fn(); + const onInterrupt = vi.fn(); + + renderRoster({ onPromptChange, onInterrupt }); + + pressTogether([ + ['a', {}], + ['c', { ctrl: true }], + ]); + + expect(onPromptChange).toHaveBeenLastCalledWith(''); + expect(onInterrupt).toHaveBeenCalledWith(true); + }); + + it('can group rows by directory', () => { + const { lastFrame } = renderRoster({ + groupMode: 'directory', + rows: [ + row('alpha', { project: 'qwen-code' }), + row('beta', { project: 'other' }), + ], + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('qwen-code'); + expect(output).toContain('other'); + }); + + it('sanitizes directory group labels', () => { + const { lastFrame } = renderRoster({ + groupMode: 'directory', + rows: [ + row('alpha', { + project: 'safe\nInjected\x1b]52;c;Y2xpcGJvYXJk\x07', + }), + ], + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('safe Injected'); + expect(output).not.toContain('52;c'); + }); + + it('targets the moved row when Down and Enter arrive together', () => { + const onAttachSession = vi.fn(); + + renderRoster({ + rows: [row('alpha'), row('beta')], + selectedIndex: 0, + onAttachSession, + }); + + pressTogether([ + ['', { downArrow: true }], + ['', { return: true }], + ]); + + expect(onAttachSession).toHaveBeenCalledWith('beta'); + }); + + it('reports prompt edits for printable input and backspace', async () => { + const onPromptChange = vi.fn(); + + renderRoster({ + prompt: 'ab', + onPromptChange, + }); + + press('c', {}); + await settleCompletion(); + press('', { backspace: true }); + await settleCompletion(); + + expect(onPromptChange).toHaveBeenNthCalledWith(1, 'abc'); + expect(onPromptChange).toHaveBeenNthCalledWith(2, 'ab'); + }); + + it('does not let repeated lagging prompt echoes overwrite newer text', async () => { + const onPromptChange = vi.fn(); + const onDispatch = vi.fn(() => true); + const props: AgentViewRosterProps = { + rows: [row('alpha')], + prompt: '', + selectedIndex: 0, + groupMode: 'state', + onPromptChange, + onPeekPromptChange: vi.fn(), + onDispatch, + onSubmitPeekPrompt: vi.fn(() => true), + onAttachSession: vi.fn(), + onPeekSession: vi.fn(), + onTogglePinSession: vi.fn(), + onRenameSession: vi.fn(), + onStopOrRemoveSession: vi.fn(), + onToggleGroupMode: vi.fn(), + onShowHelp: vi.fn(), + onInterrupt: vi.fn(), + onMoveSelection: vi.fn(), + onCancel: vi.fn(), + }; + const element = (prompt: string) => ( + + + + ); + const { rerender } = render(element('')); + + press('h', {}); + press('e', {}); + press('l', {}); + press('l', {}); + press('', { backspace: true }); + press('p', {}); + const echoes = onPromptChange.mock.calls.map(([value]) => value as string); + expect(echoes).toEqual(['h', 'he', 'hel', 'hell', 'hel', 'help']); + + for (const echo of echoes) { + act(() => rerender(element(echo))); + } + press('', { return: true }); + + expect(onDispatch).toHaveBeenCalledWith(false, 'help'); + }); + + it('routes arrow and tab keys to slash completion while suggestions are visible', async () => { + const onMoveSelection = vi.fn(); + const onPromptChange = vi.fn(); + const { lastFrame } = renderRoster({ + prompt: '/mo', + onMoveSelection, + onPromptChange, + slashCommands: slashCommands([{ name: 'model' }]), + }); + await settleCompletion(); + + const output = lastFrame() ?? ''; + expect(output).toContain('model'); + + press('', { downArrow: true }); + press('', { tab: true }); + await settleCompletion(); + + expect(onMoveSelection).not.toHaveBeenCalled(); + expect(onPromptChange).toHaveBeenLastCalledWith('/model '); + }); + + it('accepts the active slash suggestion on Enter', async () => { + const onDispatch = vi.fn(() => true); + const onPromptChange = vi.fn(); + renderRoster({ + prompt: '/qu', + onDispatch, + onPromptChange, + slashCommands: slashCommands([{ name: 'quit' }]), + }); + await settleCompletion(); + + press('', { return: true }); + await settleCompletion(); + + expect(onPromptChange).toHaveBeenLastCalledWith('/quit '); + expect(onDispatch).not.toHaveBeenCalled(); + }); + + it('accepts a slash suggestion matched through an alias', async () => { + const onDispatch = vi.fn(() => true); + const onPromptChange = vi.fn(); + renderRoster({ + prompt: '/exi', + onDispatch, + onPromptChange, + slashCommands: slashCommands([{ name: 'quit', altNames: ['exit'] }]), + }); + await settleCompletion(); + + press('', { return: true }); + await settleCompletion(); + + expect(onPromptChange).toHaveBeenLastCalledWith('/quit '); + expect(onDispatch).not.toHaveBeenCalled(); + }); + + it('submits content coalesced with Enter instead of accepting completion', async () => { + const onDispatch = vi.fn(() => true); + renderRoster({ + prompt: '/qu', + onDispatch, + slashCommands: slashCommands([{ name: 'quit' }]), + }); + await settleCompletion(); + + press('it\r', {}); + + expect(onDispatch).toHaveBeenCalledWith(false, '/quit'); + }); + + it('edits the peek prompt separately from the main dispatch prompt', () => { + const onPromptChange = vi.fn(); + const onPeekPromptChange = vi.fn(); + const onSubmitPeekPrompt = vi.fn(() => true); + + renderRoster({ + prompt: 'new task', + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onPromptChange, + onPeekPromptChange, + onSubmitPeekPrompt, + }); + + press('x', {}); + press('', { return: true }); + + expect(onPromptChange).not.toHaveBeenCalled(); + expect(onPeekPromptChange).toHaveBeenCalledWith('x'); + // The typed peek text accumulates imperatively, so Enter submits it even + // before React re-renders with the new peekPrompt prop. + expect(onSubmitPeekPrompt).toHaveBeenCalledWith('x'); + }); + + it('does not resurrect a submitted peek reply in the same tick', () => { + const onPeekPromptChange = vi.fn(); + const onSubmitPeekPrompt = vi.fn(() => true); + + renderRoster({ + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onPeekPromptChange, + onSubmitPeekPrompt, + }); + + press('x', {}); + press('', { return: true }); + press('y', {}); + + expect(onSubmitPeekPrompt).toHaveBeenCalledWith('x'); + expect(onPeekPromptChange).toHaveBeenLastCalledWith('y'); + }); + + it('submits a non-empty peek prompt on Enter', () => { + const onSubmitPeekPrompt = vi.fn(() => true); + + renderRoster({ + peekPrompt: 'continue', + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onSubmitPeekPrompt, + }); + + press('', { return: true }); + + expect(onSubmitPeekPrompt).toHaveBeenCalledOnce(); + }); + + it('submits peek input when text and carriage return arrive together', () => { + const onSubmitPeekPrompt = vi.fn(() => true); + + renderRoster({ + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onSubmitPeekPrompt, + }); + + press('continue\r', {}); + + expect(onSubmitPeekPrompt).toHaveBeenCalledWith('continue'); + }); + + it('keeps multi-line peek pastes in the reply instead of submitting early', () => { + const onPeekPromptChange = vi.fn(); + const onSubmitPeekPrompt = vi.fn(() => true); + + renderRoster({ + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onPeekPromptChange, + onSubmitPeekPrompt, + }); + + press('line one\nline two', {}); + + expect(onSubmitPeekPrompt).not.toHaveBeenCalled(); + expect(onPeekPromptChange).toHaveBeenLastCalledWith('line one\nline two'); + }); + + it('deletes a full emoji code point on peek backspace', () => { + const onPeekPromptChange = vi.fn(); + + renderRoster({ + peekPrompt: 'looks good 👍', + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onPeekPromptChange, + }); + + press('', { backspace: true }); + + expect(onPeekPromptChange).toHaveBeenCalledWith('looks good '); + }); + + it('closes an open session peek on Space', () => { + const onCancel = vi.fn(); + + renderRoster({ + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onCancel, + }); + + press(' ', {}); + + expect(onCancel).toHaveBeenCalledOnce(); + }); + + it('keeps a same-tick peek reply when Space follows typed text', () => { + const onCancel = vi.fn(); + const onPeekPromptChange = vi.fn(); + + renderRoster({ + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + onCancel, + onPeekPromptChange, + }); + + press('y', {}); + press(' ', {}); + + expect(onCancel).not.toHaveBeenCalled(); + expect(onPeekPromptChange).toHaveBeenLastCalledWith('y '); + }); + + it('renders peek panel details', () => { + const { lastFrame } = renderRoster({ + rows: [row('alpha', { summary: 'ready' })], + peekPanel: sessionPanel({ + lines: ['State: idle / alive', 'Summary: ready'], + }), + peekInputMode: 'send', + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('ready'); + expect(output).toContain('enter to send'); + expect(output).toContain('space to close'); + expect(output).not.toContain('Summary: ready'); + expect(output).not.toContain('State: idle / alive'); + }); + + it('bounds long and multi-line peek content', () => { + const longLine = 'x'.repeat(3_000); + const { lastFrame } = renderRoster({ + peekPanel: sessionPanel({ + content: 'message', + lines: [longLine, 'two', 'three', 'four', 'five', 'six'], + }), + }); + + const output = lastFrame() ?? ''; + expect(output).not.toContain(longLine); + expect(output).toContain('x…'); + expect(output).toContain('…'); + expect(output).not.toContain('six'); + }); + + it('shows error panel lines over stale row output', () => { + const { lastFrame } = renderRoster({ + rows: [row('alpha', { summary: 'stale result' })], + peekPanel: sessionPanel({ + content: 'message', + tone: 'error', + lines: ['worker is not responding'], + }), + peekInputMode: 'send', + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('worker is not responding'); + expect(output).not.toContain('stale result'); + }); + + it('shows informational panel lines over empty row activity', () => { + const { lastFrame } = renderRoster({ + rows: [row('alpha', { summary: undefined, lastResult: undefined })], + peekPanel: sessionPanel({ + content: 'message', + lines: ['Session added to Agent View.'], + }), + }); + + expect(lastFrame()).toContain('Session added to Agent View.'); + }); + + it('does not attach another row from a stale error panel', () => { + const onAttachSession = vi.fn(); + const onTogglePinSession = vi.fn(); + const onRenameSession = vi.fn(); + const onStopOrRemoveSession = vi.fn(); + renderRoster({ + rows: [row('beta')], + peekPanel: sessionPanel({ + content: 'message', + tone: 'error', + lines: ['worker is gone'], + }), + peekInputMode: 'send', + onAttachSession, + onTogglePinSession, + onRenameSession, + onStopOrRemoveSession, + }); + + press('', { return: true }); + press('', { rightArrow: true }); + press('t', { ctrl: true }); + press('r', { ctrl: true }); + press('x', { ctrl: true }); + + expect(onAttachSession).not.toHaveBeenCalled(); + expect(onTogglePinSession).not.toHaveBeenCalled(); + expect(onRenameSession).not.toHaveBeenCalled(); + expect(onStopOrRemoveSession).not.toHaveBeenCalled(); + }); + + it('keeps blocking answers visible while follow-up prompts are queued', () => { + const { lastFrame } = renderRoster({ + rows: [ + row('alpha', { + waitingFor: undefined, + queuedPromptCount: 1, + actions: { + ...row('alpha').actions, + needsBlockingAnswer: true, + }, + }), + ], + peekPanel: sessionPanel(), + peekPrompt: 'yes', + peekInputMode: 'answer', + peekQueuedPrompts: ['continue'], + }); + + expect(lastFrame()).toContain('> yes'); + expect(lastFrame()).toContain('enter to send'); + }); + + it('renders worker text as sanitized single lines', () => { + const { lastFrame } = renderRoster({ + rows: [ + row('alpha', { + displayName: 'first\u202e\nsecond', + waitingFor: '\u001b]0;spoof\u0007Edit\u2066\nfile', + }), + ], + peekPanel: sessionPanel(), + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('first second'); + expect(output).toContain('Waiting: Edit file'); + expect(output).not.toContain('spoof'); + expect(output).not.toMatch(/[\u202e\u2066]/); + }); + + it('renders notices without hiding the dispatch input', () => { + const { lastFrame } = renderRoster({ + notice: { + lines: ['Press Ctrl+X again to remove.'], + }, + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('Press Ctrl+X again to remove.'); + expect(output).toContain('describe a task for a new session'); + }); + + it('prefers the latest row output over peek summary details', () => { + const { lastFrame } = renderRoster({ + rows: [ + row('alpha', { + lastResult: 'latest model line', + summary: 'session summary', + }), + ], + peekPanel: sessionPanel({ + lines: ['Result: old model line', 'Summary: session summary'], + }), + peekInputMode: 'send', + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('latest model line'); + expect(output).not.toContain('old model line'); + expect(output).not.toContain('session summary'); + }); + + it('keeps the session peek open when selection moves after refresh', () => { + const { lastFrame } = renderRoster({ + rows: [ + row('new-selection', { summary: 'new row' }), + row('alpha', { summary: 'peeked row' }), + ], + selectedIndex: 0, + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + peekInputMode: 'send', + }); + + const output = lastFrame() ?? ''; + expect(output).toContain('peeked row'); + expect(output).toContain('reply'); + expect(output).not.toContain('send follow-up to alpha'); + }); + + it('inserts pasted text that reads like a control key instead of executing it', () => { + const onPromptChange = vi.fn(); + + renderRoster({ + prompt: 'abc', + onPromptChange, + }); + + // A multi-codepoint chunk is a paste; the literal word "delete" must + // land in the buffer instead of deleting a character. + press('delete', {}); + + expect(onPromptChange).toHaveBeenLastCalledWith('abcdelete'); + }); + + it('locks Ctrl+X and Enter to the peeked session while a peek is open', () => { + const onStopOrRemoveSession = vi.fn(); + const onAttachSession = vi.fn(); + + renderRoster({ + rows: [row('beta'), row('alpha')], + selectedIndex: 0, + peekPanel: sessionPanel({ lines: ['Result: ready'] }), + onStopOrRemoveSession, + onAttachSession, + }); + + press('x', { ctrl: true }); + expect(onStopOrRemoveSession).toHaveBeenCalledWith('alpha'); + + press('', { return: true }); + expect(onAttachSession).toHaveBeenCalledWith('alpha'); + }); + + it('locks roster selection and rename shortcuts while a peek is open', () => { + const onMoveSelection = vi.fn(); + const onRenameSession = vi.fn(); + const onTogglePinSession = vi.fn(); + + renderRoster({ + rows: [row('beta'), row('alpha')], + selectedIndex: 0, + prompt: 'hidden name', + peekPanel: sessionPanel(), + peekInputMode: 'send', + onMoveSelection, + onRenameSession, + onTogglePinSession, + }); + + press('', { upArrow: true }); + press('r', { ctrl: true }); + press('t', { ctrl: true }); + + expect(onMoveSelection).not.toHaveBeenCalled(); + expect(onRenameSession).not.toHaveBeenCalled(); + expect(onTogglePinSession).not.toHaveBeenCalled(); + }); +}); + +function renderRoster(overrides: Partial = {}) { + return render( + + true)} + onSubmitPeekPrompt={vi.fn(() => true)} + onAttachSession={vi.fn()} + onPeekSession={vi.fn()} + onTogglePinSession={vi.fn()} + onRenameSession={vi.fn()} + onStopOrRemoveSession={vi.fn()} + onToggleGroupMode={vi.fn()} + onShowHelp={vi.fn()} + onInterrupt={vi.fn()} + onMoveSelection={vi.fn()} + onCancel={vi.fn()} + {...overrides} + /> + , + ); +} + +function sessionPanel( + overrides: Partial = {}, +): AgentViewSessionPanel { + return { + kind: 'session', + sessionId: 'alpha', + content: 'activity', + lines: [], + ...overrides, + }; +} + +function press(input: string, key: TestKey) { + const handler = inputState.handlers.at(-1); + if (!handler) { + throw new Error('AgentViewRoster did not register an input handler'); + } + act(() => { + handler(input, key); + }); +} + +function pressTogether(inputs: Array<[string, TestKey]>) { + const handler = inputState.handlers.at(-1); + if (!handler) { + throw new Error('AgentViewRoster did not register an input handler'); + } + act(() => { + for (const [input, key] of inputs) { + handler(input, key); + } + }); +} + +async function settleCompletion() { + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +function row( + sessionId: string, + overrides: Partial = {}, +): AgentRosterRow { + return { + sessionId, + displayName: sessionId, + state: 'needs_input', + stateLabel: 'Needs Input', + stateGroup: 'needs_input', + taskState: 'waiting', + inputState: 'permission', + runtimeState: 'alive', + recoverability: 'live', + iconShape: 'alive', + iconTone: 'needs_input', + title: sessionId, + subtitle: '', + actions: { + canAttach: true, + canPeek: true, + canReply: false, + canStop: false, + canRemove: true, + canRespawn: false, + canHibernate: false, + needsBlockingAnswer: true, + }, + project: 'qwen-code', + projectCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code', + cwd: '/workspace/qwen-code', + ageMs: 60_000, + ageLabel: '1m', + updatedAt: '2026-07-17T10:00:00.000Z', + alive: true, + aliveIndicator: 'alive', + ...overrides, + }; +} + +function slashCommands( + commands: Array<{ + name: string; + description?: string; + altNames?: string[]; + }>, +): SlashCommand[] { + return commands.map((command) => ({ + name: command.name, + altNames: command.altNames, + description: command.description ?? command.name, + kind: CommandKind.BUILT_IN, + action: () => undefined, + })); +} diff --git a/packages/cli/src/ui/agent-view/AgentViewRoster.tsx b/packages/cli/src/ui/agent-view/AgentViewRoster.tsx new file mode 100644 index 00000000000..325afdc55f6 --- /dev/null +++ b/packages/cli/src/ui/agent-view/AgentViewRoster.tsx @@ -0,0 +1,1238 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import stringWidth from 'string-width'; +import { theme } from '../semantic-colors.js'; +import { Header } from '../components/Header.js'; +import { Tips } from '../components/Tips.js'; +import { BaseTextInput } from '../components/BaseTextInput.js'; +import { SuggestionsDisplay } from '../components/SuggestionsDisplay.js'; +import { useTextBuffer } from '../components/shared/text-buffer.js'; +import { + CompletionMode, + useCommandCompletion, +} from '../hooks/useCommandCompletion.js'; +import type { Key } from '../hooks/useKeypress.js'; +import { + CommandKind, + type CommandContext, + type SlashCommand, +} from '../commands/types.js'; +import type { LoadedSettings } from '../../config/settings.js'; +import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js'; +import { + isAgentRosterBlockingWait, + type AgentRosterGroupMode, + type AgentRosterRow, +} from './roster-model.js'; +import { + cleanSingleLineText, + stripUnsafeCharacters, + truncateToWidth, +} from '../utils/textUtils.js'; + +export interface AgentViewHeaderInfo { + version: string; + cwd: string; + model?: string; + authLabel?: string; + providerLabel?: string; +} + +export interface AgentViewRosterProps { + rows: AgentRosterRow[]; + prompt: string; + promptVersion?: number; + selectedIndex: number; + groupMode: AgentRosterGroupMode; + header?: AgentViewHeaderInfo; + notice?: AgentViewNotice; + peekPanel?: AgentViewPanel; + peekPrompt?: string; + peekInputMode?: 'answer' | 'send'; + peekQueuedPrompts?: string[]; + slashCommands?: readonly SlashCommand[]; + onPromptChange: (prompt: string) => void; + onPromptEdit?: () => void; + onPeekPromptChange: (prompt: string) => void; + onDispatch: (attach: boolean, prompt: string) => boolean; + onSubmitPeekPrompt: (promptOverride?: string) => boolean; + onAttachSession: (sessionId: string) => void; + onPeekSession: (sessionId: string) => void; + onTogglePinSession: (sessionId: string) => void; + onRenameSession: (sessionId: string, displayName: string) => void; + onStopOrRemoveSession: (sessionId: string) => void; + onToggleGroupMode: () => void; + onShowHelp: () => void; + onInterrupt: (clearedDraft: boolean) => void; + onMoveSelection: (delta: number) => void; + onCancel: () => void; +} + +const TERMINAL_ESCAPE_GRACE_MS = 25; + +export type AgentViewPanel = + | AgentViewSessionPanel + | { + kind: 'filter'; + query: string; + lines: string[]; + } + | { + kind: 'message'; + title: string; + tone: 'info' | 'error'; + lines: string[]; + }; + +export interface AgentViewSessionPanel { + kind: 'session'; + sessionId: string; + content: 'activity' | 'message'; + tone?: 'error'; + lines: string[]; +} + +export interface AgentViewNotice { + title?: string; + lines: string[]; +} + +interface AgentViewPromptInput { + buffer: ReturnType; + suggestions: ReturnType['suggestions']; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + isPerfectMatch: boolean; + completionMode: CompletionMode; + suggestionsWidth: number; + dismissCompletion: () => void; + handleCompletionKey: (input: string, key: RosterInputKey) => boolean; + handleBufferKey: (input: string, key: RosterInputKey) => void; +} + +interface RosterInputKey { + upArrow?: boolean; + downArrow?: boolean; + leftArrow?: boolean; + rightArrow?: boolean; + home?: boolean; + end?: boolean; + return?: boolean; + tab?: boolean; + shift?: boolean; + ctrl?: boolean; + meta?: boolean; + backspace?: boolean; + delete?: boolean; + escape?: boolean; +} + +export function AgentViewRoster({ + rows, + prompt, + promptVersion = 0, + selectedIndex, + groupMode, + header, + notice, + peekPanel, + peekPrompt = '', + peekInputMode, + peekQueuedPrompts, + slashCommands = AGENT_VIEW_SLASH_COMMANDS, + onPromptChange, + onPromptEdit, + onPeekPromptChange, + onDispatch, + onSubmitPeekPrompt, + onAttachSession, + onPeekSession, + onTogglePinSession, + onRenameSession, + onStopOrRemoveSession, + onToggleGroupMode, + onShowHelp, + onInterrupt, + onMoveSelection, + onCancel, +}: AgentViewRosterProps) { + const loadedSlashCommands = useAgentViewSlashCommands(slashCommands); + const promptInput = useAgentViewPromptInput({ + prompt, + promptVersion, + slashCommands: loadedSlashCommands, + onPromptChange, + }); + const promptEditedRef = useRef(false); + useEffect(() => { + promptEditedRef.current = false; + }, [promptVersion]); + const peekPromptPending = Boolean(peekQueuedPrompts?.length); + const selectedIndexRef = useRef(selectedIndex); + selectedIndexRef.current = selectedIndex; + const panelSessionId = + peekPanel?.kind === 'session' ? peekPanel.sessionId : undefined; + const peekRow = rows.find((row) => row.sessionId === panelSessionId); + // A blocking approval (e.g. 'Waiting: Edit') must stay answerable even + // while follow-up prompts are queued. + const peekBlockingWait = Boolean( + peekRow && isAgentRosterBlockingWait(peekRow), + ); + const peekInputActive = Boolean( + peekPanel && peekInputMode && (!peekPromptPending || peekBlockingWait), + ); + const sessionPeekActive = Boolean(peekPanel?.kind === 'session' && peekRow); + // Ink can emit multiple input events within one tick before React + // re-renders; an imperative mirror keeps peek accumulation from reading a + // stale prop on the second event. + const peekPromptRef = useRef(peekPrompt); + const terminalEscapeTimerRef = useRef(undefined); + useEffect(() => { + peekPromptRef.current = peekPrompt; + }, [peekPrompt]); + useEffect( + () => () => { + if (terminalEscapeTimerRef.current) { + clearTimeout(terminalEscapeTimerRef.current); + } + }, + [], + ); + + const handleEscape = () => { + if (promptInput.showSuggestions) { + promptInput.dismissCompletion(); + return; + } + if (peekPanel) { + peekPromptRef.current = ''; + onPeekPromptChange(''); + } else { + promptEditedRef.current = false; + } + onCancel(); + }; + + useInput((input, key) => { + const currentPrompt = promptInput.buffer.text; + const hasPrompt = currentPrompt.trim().length > 0; + + if (key.escape) { + if (terminalEscapeTimerRef.current) { + clearTimeout(terminalEscapeTimerRef.current); + } + terminalEscapeTimerRef.current = setTimeout(() => { + terminalEscapeTimerRef.current = undefined; + handleEscape(); + }, TERMINAL_ESCAPE_GRACE_MS); + return; + } + + if (isTerminalControlInput(input)) { + if (terminalEscapeTimerRef.current) { + clearTimeout(terminalEscapeTimerRef.current); + terminalEscapeTimerRef.current = undefined; + } + return; + } + + if (terminalEscapeTimerRef.current) { + clearTimeout(terminalEscapeTimerRef.current); + terminalEscapeTimerRef.current = undefined; + handleEscape(); + return; + } + + const selectedRow = rows[selectedIndexRef.current]; + const actionRow = peekPanel?.kind === 'session' ? peekRow : selectedRow; + + if ( + isCtrlInput(input, key, 't', '\x14') && + actionRow && + !sessionPeekActive + ) { + onTogglePinSession(actionRow.sessionId); + return; + } + + if ( + isCtrlInput(input, key, 'r', '\x12') && + actionRow && + !sessionPeekActive + ) { + const displayName = currentPrompt.trim(); + promptEditedRef.current = false; + promptInput.buffer.setText(''); + onRenameSession(actionRow.sessionId, displayName); + return; + } + + if (isCtrlInput(input, key, 'x', '\x18') && actionRow) { + onStopOrRemoveSession(actionRow.sessionId); + return; + } + + if (isCtrlInput(input, key, 's', '\x13')) { + onToggleGroupMode(); + return; + } + + if (isCtrlInput(input, key, 'c', '\x03')) { + if (peekInputActive && peekPromptRef.current) { + peekPromptRef.current = ''; + onPeekPromptChange(''); + onInterrupt(true); + return; + } + if (!peekInputActive && (hasPrompt || promptEditedRef.current)) { + promptEditedRef.current = false; + onPromptEdit?.(); + promptInput.buffer.setText(''); + onPromptChange(''); + onInterrupt(true); + return; + } + onInterrupt(false); + return; + } + + if (input === '?' && !hasPrompt && !peekInputActive) { + onShowHelp(); + return; + } + + if ( + !peekInputActive && + !( + isReturnInput(input, key) && + isExactSlashCommand(currentPrompt, loadedSlashCommands) + ) && + promptInput.handleCompletionKey(input, key) + ) { + if (key.tab) { + promptEditedRef.current = true; + onPromptEdit?.(); + } + return; + } + + if (key.upArrow && !sessionPeekActive) { + selectedIndexRef.current = Math.max(0, selectedIndexRef.current - 1); + onMoveSelection(-1); + return; + } + + if (key.downArrow && !sessionPeekActive) { + selectedIndexRef.current = Math.max( + 0, + Math.min(rows.length - 1, selectedIndexRef.current + 1), + ); + onMoveSelection(1); + return; + } + + const returnPrefix = getReturnInputPrefix(input, key); + const isReturn = returnPrefix !== undefined; + const legacyShiftEnter = input === '\\\r' || input === '\\\r\n'; + + if ( + isReturn && + peekPanel?.kind === 'session' && + !peekRow && + !`${currentPrompt}${returnPrefix}`.trim() + ) { + return; + } + + if (sessionPeekActive && peekRow && !peekInputActive) { + if (isReturn && rows.length > 0) { + onAttachSession(peekRow.sessionId); + } else if (input === ' ') { + onCancel(); + } + return; + } + + if (isReturn) { + if (peekInputActive) { + const submittedPeekPrompt = `${peekPromptRef.current}${returnPrefix}`; + if (submittedPeekPrompt.trim()) { + if (onSubmitPeekPrompt(submittedPeekPrompt)) { + peekPromptRef.current = ''; + } + } else if (peekRow) { + onAttachSession(peekRow.sessionId); + } + } else { + const submittedPrompt = `${currentPrompt}${returnPrefix}`; + if (submittedPrompt.trim()) { + if ( + onDispatch(Boolean(key.shift || legacyShiftEnter), submittedPrompt) + ) { + promptEditedRef.current = false; + promptInput.buffer.setText(''); + } + } else if (rows.length > 0) { + if (selectedRow) onAttachSession(selectedRow.sessionId); + } + } + return; + } + + if (key.rightArrow && !hasPrompt && !peekInputActive && actionRow) { + // Only consume Right when it actually attaches; otherwise fall through + // so the buffer's cursor-right movement keeps working while a prompt + // is typed. + onAttachSession(actionRow.sessionId); + return; + } + + if ( + input === ' ' && + sessionPeekActive && + !peekPromptRef.current.trim() && + !hasPrompt + ) { + onCancel(); + return; + } + + if (input === ' ' && !hasPrompt && !sessionPeekActive && rows.length > 0) { + if (selectedRow) onPeekSession(selectedRow.sessionId); + return; + } + + if (key.backspace || key.delete) { + if (peekInputActive) { + // Delete one code point so astral characters (emoji) are not split + // into lone surrogates. + const next = Array.from(peekPromptRef.current).slice(0, -1).join(''); + peekPromptRef.current = next; + onPeekPromptChange(next); + } else { + promptEditedRef.current = true; + onPromptEdit?.(); + promptInput.handleBufferKey(input, key); + } + return; + } + + if (input && !key.ctrl && !key.meta) { + if (peekInputActive) { + const next = `${peekPromptRef.current}${input}`; + peekPromptRef.current = next; + onPeekPromptChange(next); + } else { + promptEditedRef.current = true; + onPromptEdit?.(); + promptInput.handleBufferKey(input, key); + } + return; + } + + if (!peekInputActive) { + promptInput.handleBufferKey(input, key); + } + }); + + return ( + + + + {rows.length === 0 ? ( + No sessions + ) : ( + getRosterSections(rows, groupMode).map((section) => ( + + {section.label} + {section.rows.map(({ row, index }) => ( + + ))} + + )) + )} + + {peekPanel ? ( + + {peekPanel.kind === 'session' && sessionPeekActive && peekRow ? ( + + ) : ( + <> + {getPanelTitle(peekPanel)} + {peekPanel.lines.map((line, index) => ( + + {line} + + ))} + + )} + + ) : null} + {notice ? ( + + {notice.title ? ( + {formatNoticeLine(notice.title)} + ) : null} + {notice.lines.map((line, index) => ( + + {formatNoticeLine(line)} + + ))} + + ) : null} + {sessionPeekActive ? null : ( + + )} + + ); +} + +function isReturnInput(input: string, key: RosterInputKey): boolean { + return getReturnInputPrefix(input, key) !== undefined; +} + +const STRIPPED_TERMINAL_CONTROL_PATTERN = + /^(?:\[[?>][\d;]*[uc]|\[\d+(?:;\d+)+R|\[27;\d+;\d+~|\[[IO])$/; + +function isTerminalControlInput(input: string): boolean { + return ( + input.includes('\x1b') || STRIPPED_TERMINAL_CONTROL_PATTERN.test(input) + ); +} + +function formatNoticeLine(line: string): string { + return truncateToWidth( + cleanSingleLineText(line), + Math.max(8, (process.stdout.columns ?? 80) - 4), + ); +} + +function getReturnInputPrefix( + input: string, + key: RosterInputKey, +): string | undefined { + if (key.return) { + return ''; + } + if (input === '\\\r' || input === '\\\r\n') { + return ''; + } + // Ink reports pasted LF chunks without key.return. Keep them in the + // cursor-aware text insertion path instead of treating the paste as a + // submit; PTY Enter and legacy VSCode Shift+Enter use CR. + if (!key.return && input.includes('\n')) { + return undefined; + } + const returnIndex = input.search(/[\r\n]/); + if (returnIndex < 0) { + return undefined; + } + // Content after the newline means a multi-line paste; submitting here + // would silently discard the tail, so let the chunk fall through to the + // text-insert path instead. + if (input.slice(returnIndex + 1).trim() !== '') { + return undefined; + } + return input.slice(0, returnIndex); +} + +// Commands the roster can actually execute locally. Any other built-in +// command offered by typeahead would otherwise be dispatched as the initial +// prompt of a brand-new background session. +const ROSTER_EXECUTABLE_COMMAND_NAMES = new Set([ + 'exit', + 'quit', + 'resume', + 'continue', +]); + +function useAgentViewSlashCommands( + fallbackCommands: readonly SlashCommand[], +): readonly SlashCommand[] { + const [commands, setCommands] = + useState(fallbackCommands); + + useEffect(() => { + if (fallbackCommands !== AGENT_VIEW_SLASH_COMMANDS) { + setCommands(fallbackCommands); + return undefined; + } + let disposed = false; + const abortController = new AbortController(); + void new BuiltinCommandLoader(null) + .loadCommands(abortController.signal) + .then((loadedCommands) => { + if (disposed) return; + // Keep user commands / MCP prompts / skills dispatchable, but limit + // built-ins to the ones the roster handles. + const filtered = loadedCommands.filter( + (command) => + command.kind !== CommandKind.BUILT_IN || + ROSTER_EXECUTABLE_COMMAND_NAMES.has(command.name.toLowerCase()), + ); + if (filtered.length > 0) { + setCommands(filtered); + } + }) + .catch(() => undefined); + return () => { + disposed = true; + abortController.abort(); + }; + }, [fallbackCommands]); + + return commands; +} + +function AgentViewHeader({ + header, + summary, +}: { + header: AgentViewHeaderInfo | undefined; + summary: string; +}) { + const cwd = header?.cwd ?? process.cwd(); + const version = header?.version ?? 'unknown'; + const model = formatHeaderModel(header); + + return ( + +
+ + {summary} + + ); +} + +function useAgentViewPromptInput({ + prompt, + promptVersion, + slashCommands, + onPromptChange, +}: { + prompt: string; + promptVersion: number; + slashCommands: readonly SlashCommand[]; + onPromptChange: (prompt: string) => void; +}): AgentViewPromptInput { + const inputWidth = Math.max(20, Math.min(process.stdout.columns ?? 80, 160)); + const suggestionsWidth = Math.max(20, inputWidth - 4); + const commandContext = useMemo(() => createAgentViewCommandContext(), []); + const lastPromptRef = useRef(prompt); + const lastSeenPromptPropRef = useRef(prompt); + const lastSeenPromptVersionRef = useRef(promptVersion); + // Count emitted values so repeated intermediate states can each consume one + // lagging prop echo without overwriting newer text. + const emittedPromptCountsRef = useRef>(new Map()); + const onChange = useCallback( + (nextPrompt: string) => { + if (nextPrompt === lastPromptRef.current) { + return; + } + lastPromptRef.current = nextPrompt; + const emittedCount = emittedPromptCountsRef.current.get(nextPrompt) ?? 0; + emittedPromptCountsRef.current.set(nextPrompt, emittedCount + 1); + onPromptChange(nextPrompt); + }, + [onPromptChange], + ); + const buffer = useTextBuffer({ + initialText: prompt, + initialCursorOffset: Array.from(prompt).length, + viewport: { height: 3, width: Math.max(10, inputWidth - 4) }, + onChange, + isValidPath: () => false, + }); + const completion = useCommandCompletion( + buffer, + process.cwd(), + slashCommands, + commandContext, + ); + + useEffect(() => { + if (promptVersion !== lastSeenPromptVersionRef.current) { + lastSeenPromptVersionRef.current = promptVersion; + lastSeenPromptPropRef.current = prompt; + lastPromptRef.current = prompt; + emittedPromptCountsRef.current.clear(); + buffer.setText(prompt); + return; + } + if (prompt === lastSeenPromptPropRef.current) { + return; + } + lastSeenPromptPropRef.current = prompt; + if (prompt === lastPromptRef.current) { + // In-order echo of a value we emitted; the buffer already has it. + emittedPromptCountsRef.current.clear(); + return; + } + const emittedCount = emittedPromptCountsRef.current.get(prompt) ?? 0; + const isEcho = emittedCount > 0; + if (emittedCount <= 1) { + emittedPromptCountsRef.current.delete(prompt); + } else { + emittedPromptCountsRef.current.set(prompt, emittedCount - 1); + } + lastPromptRef.current = prompt; + if (prompt === buffer.text) { + return; + } + if (isEcho) { + // A lagging echo of an intermediate typed value must not clobber + // newer text. Genuine external updates always reach the buffer. + return; + } + buffer.setText(prompt); + }, [buffer, prompt, promptVersion]); + + const acceptActiveSuggestion = useCallback((): boolean => { + if (completion.suggestions.length === 0) { + return false; + } + const targetIndex = + completion.activeSuggestionIndex === -1 + ? 0 + : completion.activeSuggestionIndex; + if (targetIndex < 0 || targetIndex >= completion.suggestions.length) { + return false; + } + completion.handleAutocomplete(targetIndex); + return true; + }, [completion]); + + const handleCompletionKey = useCallback( + (_input: string, key: RosterInputKey): boolean => { + if (!completion.showSuggestions) { + return false; + } + if (key.upArrow) { + completion.navigateUp(); + return true; + } + if (key.downArrow) { + completion.navigateDown(); + return true; + } + if ( + getReturnInputPrefix(_input, key) === '' && + completion.completionMode === CompletionMode.SLASH + ) { + const targetIndex = + completion.activeSuggestionIndex === -1 + ? 0 + : completion.activeSuggestionIndex; + const suggestion = completion.suggestions[targetIndex]; + const query = buffer.text.trim().slice(1).split(/\s/, 1)[0] ?? ''; + const normalizedQuery = query.toLowerCase(); + const matchesQuery = [suggestion?.value, suggestion?.matchedAlias] + .filter((value): value is string => value !== undefined) + .some((value) => value.toLowerCase().startsWith(normalizedQuery)); + if ( + !suggestion || + !matchesQuery || + suggestion.value.toLowerCase() === normalizedQuery + ) { + return false; + } + return acceptActiveSuggestion(); + } + if (key.tab) { + acceptActiveSuggestion(); + return true; + } + return false; + }, + [acceptActiveSuggestion, buffer.text, completion], + ); + + const handleBufferKey = useCallback( + (input: string, key: RosterInputKey) => { + buffer.handleInput(toTextBufferKey(input, key)); + }, + [buffer], + ); + + return { + buffer, + suggestions: completion.suggestions, + activeSuggestionIndex: completion.activeSuggestionIndex, + visibleStartIndex: completion.visibleStartIndex, + showSuggestions: completion.showSuggestions, + isLoadingSuggestions: completion.isLoadingSuggestions, + isPerfectMatch: completion.isPerfectMatch, + completionMode: completion.completionMode, + suggestionsWidth, + dismissCompletion: completion.dismissCompletion, + handleCompletionKey, + handleBufferKey, + }; +} + +function toTextBufferKey(input: string, key: RosterInputKey): Key { + return { + name: getInputKeyName(input, key), + ctrl: Boolean(key.ctrl), + meta: Boolean(key.meta), + shift: Boolean(key.shift), + // A multi-codepoint chunk is a paste: route it through the insert path + // so text literally reading "delete"/"backspace"/... is not executed as + // that control key. + paste: Array.from(input).length > 1, + sequence: input, + }; +} + +function getInputKeyName(input: string, key: RosterInputKey): string { + if (key.upArrow) return 'up'; + if (key.downArrow) return 'down'; + if (key.leftArrow) return 'left'; + if (key.rightArrow) return 'right'; + if (key.home) return 'home'; + if (key.end) return 'end'; + if (isReturnInput(input, key)) return 'return'; + if (key.tab || input === '\t') return 'tab'; + if (key.backspace) return 'backspace'; + if (key.delete) return 'delete'; + if (key.escape) return 'escape'; + return input; +} + +function createAgentViewCommandContext(): CommandContext { + const noop = () => undefined; + return { + executionMode: 'interactive', + services: { + config: null, + settings: {} as LoadedSettings, + logger: null, + }, + ui: { + history: [], + addItem: () => 0, + clear: noop, + setDebugMessage: noop, + pendingItem: null, + setPendingItem: noop, + btwItem: null, + setBtwItem: noop, + cancelBtw: noop, + btwAbortControllerRef: { current: null }, + isIdleRef: { current: true }, + loadHistory: noop, + refreshStatic: noop, + toggleVimEnabled: async () => false, + setGeminiMdFileCount: noop, + reloadCommands: noop, + setSessionName: noop, + extensionsUpdateState: new Map(), + dispatchExtensionStateUpdate: noop, + addConfirmUpdateExtensionRequest: noop, + }, + session: { + stats: {} as CommandContext['session']['stats'], + sessionShellAllowlist: new Set(), + }, + }; +} + +function isExactSlashCommand( + prompt: string, + slashCommands: readonly SlashCommand[], +): boolean { + const command = prompt + .trim() + .match(/^\/(\S+)$/)?.[1] + ?.toLowerCase(); + if (!command) { + return false; + } + return slashCommands.some( + (slashCommand) => + slashCommand.name.toLowerCase() === command || + slashCommand.altNames?.some((name) => name.toLowerCase() === command), + ); +} + +const AGENT_VIEW_SLASH_COMMANDS: readonly SlashCommand[] = [ + { + name: 'exit', + altNames: ['quit'], + description: 'Exit Agent View', + kind: CommandKind.BUILT_IN, + action: () => undefined, + }, + { + name: 'quit', + altNames: ['exit'], + description: 'Exit Agent View', + kind: CommandKind.BUILT_IN, + action: () => undefined, + }, + { + name: 'resume', + altNames: ['continue'], + description: 'Resume a previous session', + kind: CommandKind.BUILT_IN, + action: () => undefined, + }, + { + name: 'continue', + altNames: ['resume'], + description: 'Resume a previous session', + kind: CommandKind.BUILT_IN, + action: () => undefined, + }, +]; + +function formatHeaderModel(header: AgentViewHeaderInfo | undefined): string { + const model = header?.model ?? 'unknown model'; + return header?.providerLabel ? `[${header.providerLabel}] ${model}` : model; +} + +function formatRosterSummary(rows: AgentRosterRow[]): string { + const needsInput = rows.filter( + (row) => row.stateGroup === 'needs_input', + ).length; + const working = rows.filter((row) => row.stateGroup === 'working').length; + const completed = rows.filter((row) => row.stateGroup === 'done').length; + return `${needsInput} awaiting input - ${working} working - ${completed} completed`; +} + +function getInputPlaceholder(): string { + return 'describe a task for a new session'; +} + +function getPeekInputPlaceholder(): string { + return 'reply'; +} + +function SessionPeekBox({ + row, + panel, + prompt, + inputMode, + queuedPrompts, +}: { + row: AgentRosterRow; + panel: AgentViewSessionPanel; + prompt: string; + inputMode: 'answer' | 'send' | undefined; + queuedPrompts: string[] | undefined; +}) { + const maxLineWidth = Math.max(1, (process.stdout.columns ?? 80) - 4); + const lines = getSessionPeekLines(row, panel, queuedPrompts, maxLineWidth); + const blockingWait = isAgentRosterBlockingWait(row); + const inputActive = Boolean( + inputMode && (!queuedPrompts?.length || blockingWait), + ); + return ( + + + {formatRowName(row)}{' '} + {row.ageLabel} + + {lines.map((line, index) => ( + ') ? theme.text.primary : theme.text.secondary + } + > + {line} + + ))} + {inputActive ? ( + + {'>'} + {prompt ? ( + {prompt} + ) : ( + + {getPeekInputPlaceholder()} + + )} + + ) : null} + + {getPeekFooter(inputActive ? inputMode : undefined, queuedPrompts)} + + + ); +} + +function getPeekFooter( + inputMode: 'answer' | 'send' | undefined, + queuedPrompts: string[] | undefined, +): string { + if (inputMode) { + return 'enter to send · space to close · ctrl+x to delete'; + } + if (queuedPrompts?.length) { + return 'waiting for response · space to close · ctrl+x to delete'; + } + return 'enter to open · space to close · ctrl+x to delete'; +} + +function AgentViewPromptBox({ + promptInput, + placeholder, +}: { + promptInput: AgentViewPromptInput; + placeholder: string; +}) { + return ( + + undefined} + showCursor + placeholder={placeholder} + isActive={false} + borderColor={theme.border.focused} + /> + {promptInput.showSuggestions ? ( + + + + ) : null} + + {'enter to open · space to reply · ctrl+x to delete ·'} + + + ); +} + +function getSessionPeekLines( + row: AgentRosterRow, + panel: AgentViewSessionPanel, + queuedPrompts: readonly string[] | undefined, + maxWidth: number, +): string[] { + const lines = + panel.content === 'message' + ? panel.lines + : [ + cleanRowText(row.lastResult) ?? cleanRowText(row.summary), + formatWaitingLine(row.waitingFor), + getQueuedPromptLine(queuedPrompts), + ].filter((line): line is string => Boolean(line)); + const normalized = lines + .map((line) => cleanSingleLineText(stripUnsafeCharacters(line))) + .filter(Boolean); + const visible = normalized.slice(0, 5); + if (normalized.length > visible.length) { + visible[visible.length - 1] = '…'; + } + return visible.map((line) => truncateToWidth(line, maxWidth)); +} + +function getPanelTitle(panel: AgentViewPanel): string { + if (panel.kind === 'session') return panel.sessionId; + if (panel.kind === 'filter') return 'Filter'; + return panel.title; +} + +function formatWaitingLine(waitingFor: string | undefined): string | undefined { + if (!waitingFor || waitingFor === 'response') { + return undefined; + } + const text = cleanSingleLineText(waitingFor); + return text ? `Waiting: ${text}` : undefined; +} + +function getQueuedPromptLine( + queuedPrompts: readonly string[] | undefined, +): string | undefined { + if (!queuedPrompts || queuedPrompts.length === 0) { + return undefined; + } + const latest = cleanSingleLineText(queuedPrompts.at(-1) ?? ''); + if (!latest) { + return undefined; + } + return `Waiting for response: ${latest}`; +} + +function getRosterSections( + rows: AgentRosterRow[], + groupMode: AgentRosterGroupMode, +): Array<{ + key: string; + label: string; + rows: Array<{ row: AgentRosterRow; index: number }>; +}> { + const sections = new Map< + string, + Array<{ row: AgentRosterRow; index: number }> + >(); + rows.forEach((row, index) => { + const label = + groupMode === 'directory' ? row.project : getStateGroupLabel(row); + const section = sections.get(label) ?? []; + section.push({ row, index }); + sections.set(label, section); + }); + return Array.from(sections, ([key, sectionRows]) => ({ + key, + label: cleanSingleLineText(key), + rows: sectionRows, + })); +} + +function getStateGroupLabel(row: AgentRosterRow): string { + if (row.pinned) return 'Pinned'; + switch (row.stateGroup) { + case 'needs_input': + return 'Needs input'; + case 'working': + return 'Working'; + case 'done': + return 'Completed'; + default: + return row.stateLabel; + } +} + +function isCtrlInput( + input: string, + key: { ctrl?: boolean }, + letter: string, + code: string, +): boolean { + return (key.ctrl && input.toLowerCase() === letter) || input === code; +} + +function RosterRow({ + row, + selected, +}: { + row: AgentRosterRow; + selected: boolean; +}) { + const prefix = selected ? '>' : ' '; + const marker = row.iconShape === 'alive' ? '*' : '.'; + const name = formatRowName(row); + const output = formatRowOutput(row); + const columns = formatRosterRowColumns({ + name, + output, + ageLabel: row.ageLabel, + }); + + return ( + + + {prefix}{' '} + + {marker} + + {' '} + {columns.name}{' '} + + + {columns.output} {columns.ageLabel} + + + ); +} + +function getRosterMarkerColor(row: AgentRosterRow): string { + switch (row.iconTone) { + case 'needs_input': + return theme.status.warning; + case 'working': + return theme.status.success; + case 'failed': + return theme.status.error; + case 'stopped': + return theme.status.error; + case 'ready': + return theme.text.secondary; + default: + return theme.text.secondary; + } +} + +function formatRowName(row: AgentRosterRow): string { + return ( + cleanRowText(row.displayName) ?? + cleanRowText(row.title) ?? + 'Untitled session' + ); +} + +function formatRowOutput(row: AgentRosterRow): string { + return cleanRowText(row.subtitle) ?? cleanRowText(row.lastResult) ?? ''; +} + +function cleanRowText(value: string | undefined): string | undefined { + // Worker/model output is untrusted; strip unsafe control sequences before + // rendering it into the operator's terminal. + const text = value ? cleanSingleLineText(value) : undefined; + return text ? text : undefined; +} + +function formatRosterRowColumns({ + name, + output, + ageLabel, +}: { + name: string; + output: string; + ageLabel: string; +}): { name: string; output: string; ageLabel: string } { + const width = Math.max(32, Math.min(process.stdout.columns ?? 120, 160)); + const chromeWidth = stringWidth(`> * ${ageLabel}`); + const available = Math.max(12, width - chromeWidth); + const nameWidth = Math.min(Math.max(10, Math.floor(available * 0.38)), 30); + const outputWidth = Math.max(8, available - nameWidth); + return { + name: padEndToWidth(truncateToWidth(name, nameWidth), nameWidth), + output: truncateToWidth(output, outputWidth), + ageLabel, + }; +} + +function padEndToWidth(value: string, width: number): string { + return `${value}${' '.repeat(Math.max(0, width - stringWidth(value)))}`; +} diff --git a/packages/cli/src/ui/agent-view/roster-model.test.ts b/packages/cli/src/ui/agent-view/roster-model.test.ts new file mode 100644 index 00000000000..42dfbd4b375 --- /dev/null +++ b/packages/cli/src/ui/agent-view/roster-model.test.ts @@ -0,0 +1,397 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + buildAgentRosterRows, + isAgentRosterBlockingWait, +} from './roster-model.js'; +import type { + AgentViewActivityFile, + AgentViewLaunchFile, + AgentViewRosterEntry, + AgentViewSessionStateFile, + AgentViewWorkerFile, +} from '../../agent-view/protocol.js'; + +const now = '2026-07-17T10:00:00.000Z'; + +describe('buildAgentRosterRows', () => { + it('projects session state into display rows with activity and worker data', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('alpha', { + activeCwd: '/workspace/qwen-code/packages/cli', + createdAt: '2026-07-17T08:30:00.000Z', + processState: 'alive', + sessionState: 'needs_input', + }), + ], + activities: { + alpha: activity({ + summary: 'Waiting on approval', + waitingFor: 'user', + lastResult: 'edited files', + lastActivityAt: '2026-07-17T09:55:00.000Z', + }), + }, + workers: { + alpha: worker({ + lastHeartbeatAt: '2026-07-17T09:59:59.000Z', + }), + }, + now, + }); + + expect(rows).toEqual([ + expect.objectContaining({ + sessionId: 'alpha', + state: 'needs_input', + stateLabel: 'Needs Input', + stateGroup: 'needs_input', + project: 'qwen-code', + projectCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code/packages/cli', + cwd: '/workspace/qwen-code/packages/cli', + ageMs: 90 * 60 * 1000, + ageLabel: '1h', + alive: true, + aliveIndicator: 'alive', + summary: 'Waiting on approval', + waitingFor: 'user', + lastResult: 'edited files', + lastActivityAt: '2026-07-17T09:55:00.000Z', + lastHeartbeatAt: '2026-07-17T09:59:59.000Z', + }), + ]); + }); + + it('does not classify soft questions as blocking waits', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('explicit-soft', { sessionState: 'needs_input' }), + session('response-soft', { sessionState: 'needs_input' }), + ], + activities: { + 'explicit-soft': activity({ + waitingFor: 'question', + inputKind: 'soft', + }), + 'response-soft': activity({ waitingFor: 'Response' }), + }, + now, + }); + + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.inputState).toBe('soft_question'); + expect(row.actions.needsBlockingAnswer).toBe(false); + expect(isAgentRosterBlockingWait(row)).toBe(false); + } + }); + + it('sorts rows by state groups, then newest first within a group', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('failed-new', { + sessionState: 'failed', + createdAt: '2026-07-17T09:59:00.000Z', + }), + session('idle-old', { + sessionState: 'idle', + createdAt: '2026-07-17T08:00:00.000Z', + }), + session('working-old', { + sessionState: 'working', + createdAt: '2026-07-17T08:30:00.000Z', + }), + session('needs-input', { + sessionState: 'needs_input', + createdAt: '2026-07-17T09:00:00.000Z', + }), + session('working-new', { + sessionState: 'starting', + createdAt: '2026-07-17T09:30:00.000Z', + }), + session('completed', { + sessionState: 'completed', + createdAt: '2026-07-17T09:57:00.000Z', + }), + session('stopped', { + sessionState: 'stopped', + createdAt: '2026-07-17T09:56:00.000Z', + }), + ], + now, + }); + + expect(rows.map((row) => row.sessionId)).toEqual([ + 'needs-input', + 'working-new', + 'working-old', + 'failed-new', + 'completed', + 'stopped', + 'idle-old', + ]); + }); + + it('renders an unparseable createdAt as a zero age instead of ~56 years', () => { + const rows = buildAgentRosterRows({ + sessions: [session('broken', { createdAt: 'not-a-date' })], + now, + }); + + expect(rows[0]).toMatchObject({ ageMs: 0, ageLabel: '0s' }); + }); + + it('filters by text across identity, cwd, and summaries', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('alpha', { + projectCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code/packages/cli', + }), + session('beta', { + projectCwd: '/workspace/other', + activeCwd: '/workspace/other', + }), + ], + activities: { + beta: activity({ summary: 'Fix renderer crash' }), + }, + filter: 'renderer', + now, + }); + + expect(rows.map((row) => row.sessionId)).toEqual(['beta']); + }); + + it('filters by the title and subtitle rendered in the roster', () => { + const byTitle = buildAgentRosterRows({ + sessions: [session('alpha')], + launches: { + alpha: launch('alpha', { initialPrompt: 'refactor auth module' }), + }, + activities: { + alpha: activity({ summary: 'Working' }), + }, + filter: 'auth', + now, + }); + const bySubtitle = buildAgentRosterRows({ + sessions: [session('stopped', { sessionState: 'stopped' })], + filter: 'stopped by user', + now, + }); + + expect(byTitle.map((row) => row.sessionId)).toEqual(['alpha']); + expect(bySubtitle.map((row) => row.sessionId)).toEqual(['stopped']); + }); + + it('combines text filters with s:state filters', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('alpha', { + sessionState: 'working', + projectCwd: '/workspace/qwen-code', + }), + session('beta', { + sessionState: 'idle', + projectCwd: '/workspace/qwen-code', + }), + session('gamma', { + sessionState: 'working', + projectCwd: '/workspace/other', + activeCwd: '/workspace/other', + }), + ], + filter: 'qwen s:working', + now, + }); + + expect(rows.map((row) => row.sessionId)).toEqual(['alpha']); + }); + + it('supports Claude-style s:blocked and s:done state filters', () => { + const sessions = [ + session('blocked', { sessionState: 'needs_input' }), + session('idle', { sessionState: 'idle' }), + session('completed', { sessionState: 'completed' }), + session('stopped', { sessionState: 'stopped' }), + session('failed', { sessionState: 'failed' }), + session('working', { sessionState: 'working' }), + ]; + + expect( + buildAgentRosterRows({ sessions, filter: 's:blocked', now }).map( + (row) => row.sessionId, + ), + ).toEqual(['blocked']); + expect( + buildAgentRosterRows({ sessions, filter: 's:done', now }).map( + (row) => row.sessionId, + ), + ).toEqual(['completed', 'failed', 'idle', 'stopped']); + }); + + it('matches the whole Working group with s:working, including starting', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('working-one', { sessionState: 'working' }), + session('starting-one', { sessionState: 'starting' }), + session('done-one', { sessionState: 'completed' }), + ], + filter: 's:working', + now, + }); + + expect(rows.map((row) => row.sessionId)).toEqual([ + 'starting-one', + 'working-one', + ]); + }); + + it('sorts pinned rows first and searches display names from roster entries', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('alpha', { + sessionState: 'failed', + updatedAt: '2026-07-17T09:00:00.000Z', + }), + session('beta', { + sessionState: 'needs_input', + updatedAt: '2026-07-17T09:59:00.000Z', + }), + ], + rosterEntries: [ + rosterEntry('alpha', { + displayName: 'Launchpad', + pinned: true, + }), + ], + now, + }); + + expect(rows.map((row) => row.sessionId)).toEqual(['alpha', 'beta']); + expect(rows[0]).toMatchObject({ + sessionId: 'alpha', + displayName: 'Launchpad', + pinned: true, + project: 'qwen-code', + }); + + const filtered = buildAgentRosterRows({ + sessions: [ + session('alpha'), + session('beta', { projectCwd: '/workspace/other' }), + ], + rosterEntries: [rosterEntry('alpha', { displayName: 'Launchpad' })], + filter: 'launchpad', + now, + }); + expect(filtered.map((row) => row.sessionId)).toEqual(['alpha']); + }); + + it('reports hibernating and offline process states without a worker summary', () => { + const rows = buildAgentRosterRows({ + sessions: [ + session('sleeping', { processState: 'hibernated' }), + session('gone', { processState: 'exited' }), + ], + now, + }); + + expect(rows).toEqual([ + expect.objectContaining({ + sessionId: 'gone', + alive: false, + aliveIndicator: 'offline', + }), + expect.objectContaining({ + sessionId: 'sleeping', + alive: false, + aliveIndicator: 'hibernating', + }), + ]); + }); +}); + +function session( + sessionId: string, + overrides: Partial = {}, +): AgentViewSessionStateFile { + return { + schemaVersion: 1, + sessionId, + ownership: 'managed', + sessionState: 'working', + processState: 'alive', + attachState: 'detached', + projectCwd: '/workspace/qwen-code', + originalCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code', + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + worktree: { mode: 'none' }, + ...overrides, + }; +} + +function activity( + overrides: Partial = {}, +): AgentViewActivityFile { + return { + schemaVersion: 1, + lastActivityAt: '2026-07-17T09:00:00.000Z', + capabilities: [], + ...overrides, + }; +} + +function launch( + sessionId: string, + overrides: Partial = {}, +): AgentViewLaunchFile { + return { + schemaVersion: 1, + sessionId, + argv: [], + env: {}, + entrypoint: '/tmp/qwen', + projectCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code', + includeDirectories: [], + terminal: { columns: 80, rows: 24 }, + ...overrides, + }; +} + +function worker( + overrides: Partial = {}, +): AgentViewWorkerFile { + return { + schemaVersion: 1, + protocolVersion: 1, + platform: 'darwin', + recentOutputBytes: 0, + ...overrides, + }; +} + +function rosterEntry( + sessionId: string, + overrides: Partial = {}, +): AgentViewRosterEntry { + return { + sessionId, + projectCwd: '/workspace/qwen-code', + activeCwd: '/workspace/qwen-code', + createdAt: '2026-07-17T09:00:00.000Z', + updatedAt: '2026-07-17T09:00:00.000Z', + ...overrides, + }; +} diff --git a/packages/cli/src/ui/agent-view/roster-model.ts b/packages/cli/src/ui/agent-view/roster-model.ts new file mode 100644 index 00000000000..67e7b6f261a --- /dev/null +++ b/packages/cli/src/ui/agent-view/roster-model.ts @@ -0,0 +1,366 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + AgentViewActivityFile, + AgentViewLaunchFile, + AgentViewProcessState, + AgentViewRosterEntry, + AgentViewSessionState, + AgentViewSessionStateFile, + AgentViewWorkerFile, +} from '../../agent-view/protocol.js'; +import type { + AgentViewIconShape, + AgentViewIconTone, + AgentViewInputState, + AgentViewPresentationActions, + AgentViewRecoverability, + AgentViewRuntimeState, + AgentViewTaskState, +} from '../../agent-view/presentation.js'; +import { deriveAgentViewPresentation } from '../../agent-view/presentation.js'; + +export type AgentRosterStateGroup = 'needs_input' | 'working' | 'done'; + +export type AgentRosterAliveIndicator = 'alive' | 'hibernating' | 'offline'; +export type AgentRosterGroupMode = 'state' | 'directory'; + +export interface BuildAgentRosterRowsOptions { + sessions: AgentViewSessionStateFile[]; + rosterEntries?: AgentViewRosterEntry[]; + launches?: Record; + activities?: Record; + workers?: Record; + filter?: string; + now?: Date | string; +} + +export interface AgentRosterRow { + sessionId: string; + displayName?: string; + pinned?: boolean; + state: AgentViewSessionState; + stateLabel: string; + stateGroup: AgentRosterStateGroup; + taskState: AgentViewTaskState; + inputState: AgentViewInputState; + runtimeState: AgentViewRuntimeState; + recoverability: AgentViewRecoverability; + iconShape: AgentViewIconShape; + iconTone: AgentViewIconTone; + title: string; + subtitle: string; + actions: AgentViewPresentationActions; + project: string; + projectCwd: string; + activeCwd: string; + cwd: string; + ageMs: number; + ageLabel: string; + updatedAt: string; + alive: boolean; + aliveIndicator: AgentRosterAliveIndicator; + summary?: string; + waitingFor?: string; + inputKind?: AgentViewActivityFile['inputKind']; + lastResult?: string; + queuedPromptCount?: number; + queuedPromptPreview?: string; + lastActivityAt?: string; + lastHeartbeatAt?: string; +} + +export function isAgentRosterBlockingWait(row: AgentRosterRow): boolean { + return ( + row.actions.needsBlockingAnswer || + Boolean( + row.waitingFor && + row.waitingFor.toLowerCase() !== 'response' && + row.inputKind !== 'soft', + ) + ); +} + +export function buildAgentRosterRows( + options: BuildAgentRosterRowsOptions, +): AgentRosterRow[] { + const now = toTime(options.now ?? new Date()); + const rosterEntries = new Map( + options.rosterEntries?.map((entry) => [entry.sessionId, entry]) ?? [], + ); + const rows = options.sessions.map((session) => + toRosterRow( + session, + rosterEntries.get(session.sessionId), + options.launches?.[session.sessionId], + options.activities?.[session.sessionId], + options.workers?.[session.sessionId], + now, + ), + ); + + return rows + .filter((row) => matchesFilter(row, options.filter)) + .sort(compareRosterRows); +} + +export function filterAgentRosterRows( + rows: AgentRosterRow[], + filter: string | undefined, +): AgentRosterRow[] { + return rows.filter((row) => matchesFilter(row, filter)); +} + +export function orderAgentRosterRows( + rows: AgentRosterRow[], + groupMode: AgentRosterGroupMode, +): AgentRosterRow[] { + if (groupMode === 'state') { + return rows; + } + const groups = new Map(); + for (const row of rows) { + const group = groups.get(row.project) ?? []; + group.push(row); + groups.set(row.project, group); + } + return Array.from(groups.values()).flat(); +} + +function toRosterRow( + session: AgentViewSessionStateFile, + rosterEntry: AgentViewRosterEntry | undefined, + launch: AgentViewLaunchFile | undefined, + activity: AgentViewActivityFile | undefined, + worker: AgentViewWorkerFile | undefined, + now: number, +): AgentRosterRow { + // An unparseable createdAt must not surface as a ~56-year duration. + const createdAt = toTime(session.createdAt); + const ageMs = + Number.isNaN(now) || Number.isNaN(createdAt) + ? 0 + : Math.max(0, now - createdAt); + const stateLabel = formatStateLabel(session.sessionState); + const presentation = deriveAgentViewPresentation({ + state: session, + ...(rosterEntry ? { rosterEntry } : {}), + ...(launch ? { launch } : {}), + ...(activity ? { activity } : {}), + ...(worker ? { worker } : {}), + now: new Date(now).toISOString(), + }); + const aliveIndicator = getAliveIndicator(session.processState); + + return { + sessionId: session.sessionId, + displayName: rosterEntry?.displayName, + pinned: rosterEntry?.pinned, + state: session.sessionState, + stateLabel, + stateGroup: getStateGroup(presentation.group), + taskState: presentation.taskState, + inputState: presentation.inputState, + runtimeState: presentation.runtimeState, + recoverability: presentation.recoverability, + iconShape: presentation.iconShape, + iconTone: presentation.iconTone, + title: presentation.title, + subtitle: presentation.subtitle, + actions: presentation.actions, + project: getProjectName(session.projectCwd), + projectCwd: session.projectCwd, + activeCwd: session.activeCwd, + cwd: session.activeCwd || session.projectCwd, + ageMs, + ageLabel: formatDuration(ageMs), + updatedAt: session.updatedAt, + alive: aliveIndicator === 'alive', + aliveIndicator, + summary: cleanText(activity?.summary) ?? cleanText(launch?.initialPrompt), + waitingFor: activity?.waitingFor, + inputKind: activity?.inputKind, + lastResult: activity?.lastResult, + queuedPromptCount: activity?.queuedPromptCount, + queuedPromptPreview: activity?.queuedPromptPreview, + lastActivityAt: activity?.lastActivityAt, + lastHeartbeatAt: worker?.lastHeartbeatAt, + }; +} + +function cleanText(value: string | undefined): string | undefined { + const text = value?.trim(); + return text ? text : undefined; +} + +function compareRosterRows( + left: AgentRosterRow, + right: AgentRosterRow, +): number { + if (Boolean(left.pinned) !== Boolean(right.pinned)) { + return left.pinned ? -1 : 1; + } + + const groupDelta = + getStateGroupRank(left.stateGroup) - getStateGroupRank(right.stateGroup); + if (groupDelta !== 0) { + return groupDelta; + } + + const ageDelta = left.ageMs - right.ageMs; + if (ageDelta !== 0) { + return ageDelta; + } + + return left.sessionId.localeCompare(right.sessionId); +} + +function matchesFilter( + row: AgentRosterRow, + rawFilter: string | undefined, +): boolean { + const filter = rawFilter?.trim().toLowerCase(); + if (!filter) { + return true; + } + + const terms = filter.split(/\s+/); + return terms.every((term) => { + if (term.startsWith('s:')) { + return matchesStateFilter(row, term.slice(2)); + } + + return getSearchText(row).includes(term); + }); +} + +function matchesStateFilter(row: AgentRosterRow, stateFilter: string): boolean { + switch (stateFilter) { + case 'blocked': + return row.stateGroup === 'needs_input'; + case 'working': + // Group-level alias (like blocked/done) so starting sessions, which + // render in the Working group, are not silently excluded. + return row.stateGroup === 'working'; + case 'done': + return row.stateGroup === 'done'; + default: + return row.state === stateFilter || row.taskState === stateFilter; + } +} + +function getSearchText(row: AgentRosterRow): string { + return [ + row.displayName, + row.title, + row.subtitle, + row.sessionId, + row.state, + row.stateLabel, + row.taskState, + row.inputState, + row.project, + row.projectCwd, + row.activeCwd, + row.cwd, + row.summary, + row.waitingFor, + row.lastResult, + ] + .filter((value): value is string => Boolean(value)) + .join(' ') + .toLowerCase(); +} + +function getStateGroup( + group: ReturnType['group'], +): AgentRosterStateGroup { + switch (group) { + case 'needs_input': + return 'needs_input'; + case 'completed': + return 'done'; + case 'working': + return 'working'; + default: + return assertNever(group); + } +} + +function getStateGroupRank(group: AgentRosterStateGroup): number { + switch (group) { + case 'needs_input': + return 0; + case 'working': + return 1; + case 'done': + return 2; + default: + return assertNever(group); + } +} + +function getAliveIndicator( + state: AgentViewProcessState, +): AgentRosterAliveIndicator { + switch (state) { + case 'starting': + case 'alive': + case 'restarting': + return 'alive'; + case 'hibernating': + case 'hibernated': + return 'hibernating'; + case 'exited': + return 'offline'; + default: + return assertNever(state); + } +} + +function formatStateLabel(state: AgentViewSessionState): string { + return state + .split('_') + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(' '); +} + +function getProjectName(projectCwd: string): string { + const trimmed = projectCwd.replace(/[\\/]+$/, ''); + return trimmed.split(/[\\/]/).pop() || projectCwd; +} + +function formatDuration(durationMs: number): string { + const seconds = Math.floor(durationMs / 1000); + if (seconds < 60) { + return `${seconds}s`; + } + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m`; + } + + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h`; + } + + const days = Math.floor(hours / 24); + return `${days}d`; +} + +function toTime(value: Date | string): number { + const date = value instanceof Date ? value : new Date(value); + // Callers must handle NaN explicitly; coercing here would turn an + // unparseable timestamp into epoch 0 and a bogus ~56-year age. + return date.getTime(); +} + +function assertNever(value: never): never { + throw new Error(`Unexpected value: ${String(value)}`); +} diff --git a/packages/cli/src/ui/agent-view/worker-ui-bridge.test.ts b/packages/cli/src/ui/agent-view/worker-ui-bridge.test.ts new file mode 100644 index 00000000000..2e259267e39 --- /dev/null +++ b/packages/cli/src/ui/agent-view/worker-ui-bridge.test.ts @@ -0,0 +1,479 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi } from 'vitest'; +import { + ToolConfirmationOutcome, + type WaitingToolCall, +} from '@qwen-code/qwen-code-core'; +import { StreamingState } from '../types.js'; +import { + answerAgentViewPendingToolCall, + applyAgentViewWorkerControlEventForUi, + getAgentViewAnswerableToolCalls, + getAgentViewWorkerStateForUi, + getLastAgentViewModelOutputLine, + retainAnsweredAgentViewSoftQuestion, +} from './worker-ui-bridge.js'; + +describe('getAgentViewWorkerStateForUi', () => { + it('selects the newest non-empty model output line', () => { + expect( + getLastAgentViewModelOutputLine([ + { type: 'gemini', text: 'first response' }, + { + type: 'gemini_content', + text: 'opening line\n\nfinal question?', + }, + ]), + ).toBe('final question?'); + }); + + it('maps responding state to working with the last model output', () => { + expect( + getAgentViewWorkerStateForUi({ + initError: null, + streamingState: StreamingState.Responding, + pendingToolCalls: [{ status: 'executing', request: { name: 'Bash' } }], + lastResult: 'Running the requested test file.', + }), + ).toEqual({ + sessionState: 'working', + lastResult: 'Running the requested test file.', + }); + }); + + it('maps confirmation waits to needs_input', () => { + expect( + getAgentViewWorkerStateForUi({ + initError: null, + streamingState: StreamingState.WaitingForConfirmation, + pendingToolCalls: [ + { status: 'awaiting_approval', request: { name: 'Edit' } }, + ], + }), + ).toEqual({ + sessionState: 'needs_input', + waitingFor: 'Edit', + inputKind: 'blocking', + }); + }); + + it('maps nested Agent confirmation waits to needs_input', () => { + expect( + getAgentViewWorkerStateForUi({ + initError: null, + streamingState: StreamingState.WaitingForConfirmation, + pendingToolCalls: [ + { + status: 'executing', + request: { name: 'Agent' }, + liveOutput: { + type: 'task_execution', + pendingConfirmation: { type: 'info' }, + }, + }, + ], + }), + ).toEqual({ + sessionState: 'needs_input', + waitingFor: 'Agent', + inputKind: 'blocking', + }); + }); + + it('maps idle and initialization failures', () => { + expect( + getAgentViewWorkerStateForUi({ + initError: null, + streamingState: StreamingState.Idle, + lastResult: 'Ready for the next step.', + }), + ).toEqual({ + sessionState: 'idle', + lastResult: 'Ready for the next step.', + }); + + expect( + getAgentViewWorkerStateForUi({ + initError: new Error('init failed'), + streamingState: StreamingState.Idle, + }), + ).toEqual({ + sessionState: 'failed', + summary: 'init failed', + }); + }); + + it('maps idle model questions to needs_input', () => { + expect( + getAgentViewWorkerStateForUi({ + initError: null, + streamingState: StreamingState.Idle, + lastResult: + 'What would you like to test? A specific file, the full suite, or something else?', + }), + ).toEqual({ + sessionState: 'needs_input', + waitingFor: 'response', + inputKind: 'soft', + lastResult: + 'What would you like to test? A specific file, the full suite, or something else?', + }); + }); + + it('does not re-open an answered soft question without new model output', () => { + const lastResult = 'What should I do next?'; + expect( + getAgentViewWorkerStateForUi({ + initError: null, + streamingState: StreamingState.Idle, + lastResult, + answeredSoftQuestion: lastResult, + }), + ).toEqual({ sessionState: 'idle', lastResult }); + }); + + it('retains an answered soft question until model output changes', () => { + const question = 'What should I do next?'; + + expect(retainAnsweredAgentViewSoftQuestion(question, question)).toBe( + question, + ); + expect( + retainAnsweredAgentViewSoftQuestion(question, 'Here is the result.'), + ).toBeUndefined(); + }); +}); + +describe('answerAgentViewPendingToolCall', () => { + it('resolves a matching permission confirmation', async () => { + const onConfirm = vi.fn(async () => {}); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-1', name: 'Edit' }, + confirmationDetails: { + type: 'info', + title: 'Allow edit?', + prompt: 'Allow edit?', + onConfirm, + }, + } as unknown as WaitingToolCall; + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + callId: 'call-1', + text: 'yes', + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.ProceedOnce); + }); + + it('passes text answers to AskUserQuestion confirmations', async () => { + const onConfirm = vi.fn(async () => {}); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-2', name: 'AskUserQuestion' }, + confirmationDetails: { + type: 'ask_user_question', + title: 'Choose', + questions: [ + { + question: 'Which path?', + header: 'Path', + options: [], + }, + ], + onConfirm, + }, + } as unknown as WaitingToolCall; + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text: 'src/index.ts', + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'src/index.ts' } }, + ); + }); + + it('applies a text answer to every question in a multi-question confirmation', async () => { + const onConfirm = vi.fn(async () => {}); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-multi', name: 'AskUserQuestion' }, + confirmationDetails: { + type: 'ask_user_question', + title: 'Choose', + questions: [ + { question: 'Which path?', header: 'Path', options: [] }, + { question: 'Which mode?', header: 'Mode', options: [] }, + ], + onConfirm, + }, + } as unknown as WaitingToolCall; + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text: 'src/index.ts', + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'src/index.ts', 1: 'src/index.ts' } }, + ); + }); + + it('maps negative text answers to cancel', async () => { + const onConfirm = vi.fn(async () => {}); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-3', name: 'Bash' }, + confirmationDetails: { + type: 'exec', + title: 'Run command?', + prompt: 'Run command?', + command: 'npm test', + rootCommand: 'npm', + onConfirm, + }, + } as unknown as WaitingToolCall; + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text: 'no', + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + }); + + it('fails closed: only explicit affirmative words approve tool confirmations', async () => { + for (const text of ['stop', "don't delete that", 'yes but wait']) { + const onConfirm = vi.fn(async () => {}); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-4', name: 'Bash' }, + confirmationDetails: { + type: 'exec', + title: 'Run command?', + prompt: 'Run command?', + command: 'rm -rf build', + rootCommand: 'rm', + onConfirm, + }, + } as unknown as WaitingToolCall; + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text, + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + } + }); + + it('answers nested Agent pending confirmations', async () => { + const onConfirm = vi.fn(async () => {}); + const answerable = getAgentViewAnswerableToolCalls([ + { + status: 'executing', + request: { callId: 'agent-call', name: 'Agent' }, + liveOutput: { + type: 'task_execution', + pendingConfirmation: { + type: 'info', + title: 'Allow nested action?', + prompt: 'Allow nested action?', + onConfirm, + }, + }, + }, + ]); + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text: 'yes', + at: '2026-07-17T00:00:00.000Z', + }, + answerable, + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.ProceedOnce); + }); + + it('routes callId-less answers to the displayed awaiting call, not a nested one', async () => { + const nestedOnConfirm = vi.fn(async () => {}); + const editOnConfirm = vi.fn(async () => {}); + const answerable = getAgentViewAnswerableToolCalls([ + { + status: 'executing', + request: { callId: 'agent-call', name: 'Agent' }, + liveOutput: { + type: 'task_execution', + pendingConfirmation: { + type: 'info', + title: 'Allow nested action?', + prompt: 'Allow nested action?', + onConfirm: nestedOnConfirm, + }, + }, + }, + { + status: 'awaiting_approval', + request: { callId: 'edit-call', name: 'Edit' }, + confirmationDetails: { + type: 'info', + title: 'Allow edit?', + prompt: 'Allow edit?', + onConfirm: editOnConfirm, + }, + }, + ]); + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text: 'yes', + at: '2026-07-17T00:00:00.000Z', + }, + answerable, + ), + ).resolves.toBe(true); + + expect(editOnConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + ); + expect(nestedOnConfirm).not.toHaveBeenCalled(); + }); + + it('delivers negative answers to AskUserQuestion instead of refusing', async () => { + const onConfirm = vi.fn(async () => {}); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-q', name: 'AskUserQuestion' }, + confirmationDetails: { + type: 'ask_user_question', + title: 'Choose', + questions: [ + { + question: 'Proceed?', + header: 'Proceed', + options: [], + }, + ], + onConfirm, + }, + } as unknown as WaitingToolCall; + + await expect( + answerAgentViewPendingToolCall( + { + type: 'answer', + sequence: 1, + text: 'no', + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + ), + ).resolves.toBe(true); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'no' } }, + ); + }); +}); + +describe('applyAgentViewWorkerControlEventForUi', () => { + it('drops stale answers when no approval is pending', async () => { + const enqueuePrompt = vi.fn(); + + await applyAgentViewWorkerControlEventForUi( + { + type: 'answer', + sequence: 1, + text: 'run the focused test', + at: '2026-07-17T00:00:00.000Z', + }, + [], + enqueuePrompt, + ); + + expect(enqueuePrompt).not.toHaveBeenCalled(); + }); + + it('does not queue approval answers as prompts', async () => { + const onConfirm = vi.fn(async () => {}); + const enqueuePrompt = vi.fn(); + const pendingCall = { + status: 'awaiting_approval', + request: { callId: 'call-1', name: 'Edit' }, + confirmationDetails: { + type: 'info', + title: 'Allow edit?', + prompt: 'Allow edit?', + onConfirm, + }, + } as unknown as WaitingToolCall; + + await applyAgentViewWorkerControlEventForUi( + { + type: 'answer', + sequence: 1, + callId: 'call-1', + text: 'yes', + at: '2026-07-17T00:00:00.000Z', + }, + [pendingCall], + enqueuePrompt, + ); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.ProceedOnce); + expect(enqueuePrompt).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/agent-view/worker-ui-bridge.ts b/packages/cli/src/ui/agent-view/worker-ui-bridge.ts new file mode 100644 index 00000000000..0512228afc7 --- /dev/null +++ b/packages/cli/src/ui/agent-view/worker-ui-bridge.ts @@ -0,0 +1,328 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ToolConfirmationOutcome, + type ToolConfirmationPayload, + type ToolCallConfirmationDetails, + type WaitingToolCall, +} from '@qwen-code/qwen-code-core'; +import type { + AgentViewSessionState, + AgentViewWorkerAnswerOutcome, + AgentViewWorkerControlEvent, +} from '../../agent-view/protocol.js'; +import { StreamingState, type HistoryItemWithoutId } from '../types.js'; + +interface AgentViewStatusToolCall { + status: string; + request?: { + callId?: string; + name?: string; + }; + liveOutput?: unknown; + confirmationDetails?: ToolCallConfirmationDetails; +} + +export interface AgentViewWorkerUiStateReport { + sessionState: AgentViewSessionState; + summary?: string; + waitingFor?: string; + inputKind?: 'blocking' | 'soft'; + lastResult?: string; +} + +export function retainAnsweredAgentViewSoftQuestion( + answeredQuestion: string | undefined, + lastResult: string | undefined, +): string | undefined { + return answeredQuestion === lastResult ? answeredQuestion : undefined; +} + +export function getAgentViewWorkerStateForUi({ + initError, + streamingState, + pendingToolCalls, + lastResult, + answeredSoftQuestion, +}: { + initError: unknown; + streamingState: StreamingState; + pendingToolCalls?: AgentViewStatusToolCall[]; + lastResult?: string; + answeredSoftQuestion?: string; +}): AgentViewWorkerUiStateReport { + if (initError) { + const summary = + initError instanceof Error ? initError.message : String(initError); + return { sessionState: 'failed', summary }; + } + + const toolCalls = pendingToolCalls ?? []; + const waitingTool = toolCalls.find( + (tool) => tool.status === 'awaiting_approval', + ); + const waitingFor = + waitingTool?.request?.name ?? getNestedAgentViewWaitingFor(toolCalls); + if (streamingState === StreamingState.WaitingForConfirmation) { + return { + sessionState: 'needs_input', + ...(waitingFor ? { waitingFor } : {}), + inputKind: 'blocking', + ...(lastResult ? { lastResult } : {}), + }; + } + + if (streamingState === StreamingState.Responding) { + return { + sessionState: 'working', + ...(lastResult ? { lastResult } : {}), + }; + } + + if ( + lastResult && + lastResult !== answeredSoftQuestion && + looksLikeUserQuestion(lastResult) + ) { + return { + sessionState: 'needs_input', + waitingFor: 'response', + inputKind: 'soft', + lastResult, + }; + } + + return { + sessionState: 'idle', + ...(lastResult ? { lastResult } : {}), + }; +} + +function looksLikeUserQuestion(text: string): boolean { + // Rhetorical questions tend to trail long explanations; a real follow-up + // question is usually a short standalone line. Keep the heuristic soft — + // misclassifying only affects the roster's idle/needs-input hint. + const trimmed = text.trim(); + return trimmed.length <= 120 && /[??]\s*$/.test(trimmed); +} + +export function getLastAgentViewModelOutputLine( + items: readonly HistoryItemWithoutId[], +): string | undefined { + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (!item || (item.type !== 'gemini' && item.type !== 'gemini_content')) { + continue; + } + const lastLine = item.text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .at(-1); + if (lastLine) return lastLine; + } + return undefined; +} + +export async function answerAgentViewPendingToolCall( + event: Extract, + pendingToolCalls: WaitingToolCall[], +): Promise { + const toolCall = pendingToolCalls.find( + (call) => + call.status === 'awaiting_approval' && + (!event.callId || call.request.callId === event.callId), + ); + if (!toolCall?.confirmationDetails?.onConfirm) { + return false; + } + const confirmationDetails = toolCall.confirmationDetails; + + // Questions deliver the text as the answer payload; the negative-text + // heuristic below must not turn a real "no" answer into a refusal. + const isQuestion = confirmationDetails.type === 'ask_user_question'; + const outcome = toToolConfirmationOutcome( + event.outcome, + event.text, + isQuestion, + ); + if (isQuestion) { + await confirmationDetails.onConfirm( + outcome, + getAgentViewAnswerPayload(event, confirmationDetails.questions.length), + ); + return true; + } + + await confirmationDetails.onConfirm(outcome); + return true; +} + +export function getAgentViewAnswerableToolCalls( + pendingToolCalls: readonly unknown[], +): WaitingToolCall[] { + // Two passes so genuinely awaiting calls always precede synthesized + // nested-Agent confirmations — mirroring the display rule, so the call + // that receives the answer is the one the roster shows as waiting. + const awaiting: WaitingToolCall[] = []; + const nested: WaitingToolCall[] = []; + for (const toolCall of pendingToolCalls) { + if (!isRecord(toolCall)) continue; + if ( + toolCall['status'] === 'awaiting_approval' && + isRecord(toolCall['confirmationDetails']) + ) { + awaiting.push(toolCall as unknown as WaitingToolCall); + continue; + } + + const pendingConfirmation = getNestedAgentViewPendingConfirmation( + toolCall['liveOutput'], + ); + if (pendingConfirmation) { + nested.push({ + status: 'awaiting_approval', + request: isRecord(toolCall['request']) + ? { + callId: + typeof toolCall['request']['callId'] === 'string' + ? toolCall['request']['callId'] + : '', + name: + typeof toolCall['request']['name'] === 'string' + ? toolCall['request']['name'] + : 'Agent', + } + : { callId: '', name: 'Agent' }, + confirmationDetails: pendingConfirmation, + } as unknown as WaitingToolCall); + } + } + return [...awaiting, ...nested]; +} + +export async function applyAgentViewWorkerControlEventForUi( + event: AgentViewWorkerControlEvent, + pendingToolCalls: readonly unknown[], + enqueuePrompt: (text: string) => void, + stopCurrentTurn?: () => void, +): Promise { + if (event.type === 'prompt') { + enqueuePrompt(event.text); + return; + } + + if (event.type === 'stop') { + stopCurrentTurn?.(); + return; + } + + if (event.type !== 'answer') { + return; + } + + // An answer without a matching pending confirmation is stale. Soft + // questions are converted to prompt controls by the supervisor before + // reaching this worker-side path. + await answerAgentViewPendingToolCall( + event, + getAgentViewAnswerableToolCalls(pendingToolCalls), + ); +} + +function getNestedAgentViewWaitingFor( + toolCalls: readonly AgentViewStatusToolCall[], +): string { + const nested = toolCalls.find((toolCall) => + Boolean(getNestedAgentViewPendingConfirmation(toolCall.liveOutput)), + ); + return nested?.request?.name ?? 'user input'; +} + +function getNestedAgentViewPendingConfirmation( + liveOutput: unknown, +): ToolCallConfirmationDetails | undefined { + if ( + !isRecord(liveOutput) || + liveOutput['type'] !== 'task_execution' || + !isRecord(liveOutput['pendingConfirmation']) + ) { + return undefined; + } + return liveOutput[ + 'pendingConfirmation' + ] as unknown as ToolCallConfirmationDetails; +} + +function toToolConfirmationOutcome( + outcome: AgentViewWorkerAnswerOutcome | undefined, + text: string | undefined, + isQuestion = false, +): ToolConfirmationOutcome { + switch (outcome) { + case 'proceed_always': + return ToolConfirmationOutcome.ProceedAlways; + case 'proceed_always_project': + return ToolConfirmationOutcome.ProceedAlwaysProject; + case 'proceed_always_user': + return ToolConfirmationOutcome.ProceedAlwaysUser; + case 'modify_with_editor': + return ToolConfirmationOutcome.ModifyWithEditor; + case 'restore_previous': + return ToolConfirmationOutcome.RestorePrevious; + case 'cancel': + return ToolConfirmationOutcome.Cancel; + case 'proceed_once': + return ToolConfirmationOutcome.ProceedOnce; + default: + break; + } + + // For ask_user_question the text is the answer itself (a "no" answer must + // still be delivered), matching AskUserQuestionDialog behavior. + if (isQuestion) { + return ToolConfirmationOutcome.ProceedOnce; + } + + // Fail closed for approval decisions: only explicit positive tokens + // approve, so refusal phrasings outside a fixed vocabulary ("stop", + // "wait", "don't delete that", ...) can never approve the pending action. + const normalized = text?.trim().toLowerCase(); + if ( + normalized === 'y' || + normalized === 'yes' || + normalized === 'ok' || + normalized === 'approve' || + normalized === 'allow' || + normalized === 'proceed' + ) { + return ToolConfirmationOutcome.ProceedOnce; + } + return ToolConfirmationOutcome.Cancel; +} + +function getAgentViewAnswerPayload( + event: Extract, + questionCount: number, +): ToolConfirmationPayload | undefined { + if (isRecord(event.payload)) { + return event.payload as unknown as ToolConfirmationPayload; + } + const text = event.text?.trim(); + if (!text) { + return undefined; + } + return { + answers: Object.fromEntries( + Array.from({ length: questionCount }, (_, index) => [index, text]), + ), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/cli/src/ui/commands/background-command.test.ts b/packages/cli/src/ui/commands/background-command.test.ts new file mode 100644 index 00000000000..5cf7bfc4b07 --- /dev/null +++ b/packages/cli/src/ui/commands/background-command.test.ts @@ -0,0 +1,240 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; +import { backgroundCommand } from './background-command.js'; +import { CommandKind, type AgentViewIdleGateState } from './types.js'; +import type { AgentViewWorkerSidebandEnv } from '../../agent-view/worker-sideband.js'; + +const mockReadAgentViewWorkerSidebandEnv = vi.hoisted(() => + vi.fn<() => AgentViewWorkerSidebandEnv | undefined>(() => undefined), +); + +vi.mock('../../agent-view/worker-sideband.js', async (importOriginal) => ({ + ...(await importOriginal< + typeof import('../../agent-view/worker-sideband.js') + >()), + readAgentViewWorkerSidebandEnv: mockReadAgentViewWorkerSidebandEnv, +})); + +describe('backgroundCommand', () => { + beforeEach(() => { + mockReadAgentViewWorkerSidebandEnv.mockReturnValue(undefined); + }); + + it('has command metadata', () => { + expect(backgroundCommand.name).toBe('background'); + expect(backgroundCommand.altNames).toEqual(['bg']); + expect(backgroundCommand.kind).toBe(CommandKind.BUILT_IN); + expect(backgroundCommand.supportedModes).toEqual(['interactive']); + }); + + it('returns detach action when idle', async () => { + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config: mockConfig({ sessionExists: true }) }, + ui: { isIdleRef: { current: true } }, + }), + '', + ); + + expect(result).toEqual({ type: 'agent_view_detach' }); + }); + + it('rejects while a turn is running', async () => { + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config: mockConfig({ sessionExists: true }) }, + ui: { isIdleRef: { current: false } }, + }), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Cannot detach Agent View while a turn is running.', + }); + }); + + it.each([ + [ + 'a question is waiting', + { hasPendingUserQuestion: true }, + 'Cannot detach Agent View while a question is waiting.', + ], + [ + 'a tool confirmation is pending', + { hasPendingToolConfirmation: true }, + 'Cannot detach Agent View while a tool confirmation is pending.', + ], + [ + 'a command confirmation is pending', + { hasPendingCommandConfirmation: true }, + 'Cannot detach Agent View while a command confirmation is pending.', + ], + [ + 'a foreground shell is active', + { hasForegroundShell: true }, + 'Cannot detach Agent View while a foreground shell is active.', + ], + [ + 'the background tasks dialog is open', + { hasBackgroundFocusDialog: true }, + 'Cannot detach Agent View while the background tasks dialog is open.', + ], + [ + 'prompts are queued', + { hasQueuedPrompt: true }, + 'Cannot detach Agent View while prompts are queued.', + ], + ] satisfies Array<[string, AgentViewIdleGateState, string]>)( + 'rejects while %s', + async (_name, gateState, content) => { + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config: mockConfig({ sessionExists: true }) }, + ui: { + isIdleRef: { current: true }, + agentViewIdleGateStateRef: { current: gateState }, + }, + }), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content, + }); + }, + ); + + it('detaches managed Agent View workers while a turn is running', async () => { + mockReadAgentViewWorkerSidebandEnv.mockReturnValue({ + sessionId: 'session-1', + sidebandEndpoint: 'unix:/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config: null }, + ui: { + isIdleRef: { current: false }, + agentViewIdleGateStateRef: { + current: { + hasPendingToolConfirmation: true, + hasQueuedPrompt: true, + }, + }, + }, + }), + '', + ); + + expect(result).toEqual({ type: 'agent_view_detach' }); + }); + + it('rejects before configuration is loaded', async () => { + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config: null }, + ui: { isIdleRef: { current: true } }, + }), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Cannot detach Agent View before configuration is loaded.', + }); + }); + + it('rejects ordinary sessions when Agent View is disabled', async () => { + const config = mockConfig({ sessionExists: true }); + config.isAgentViewEnabled = () => false; + + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config }, + ui: { isIdleRef: { current: true } }, + }), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'Agent View is disabled. Set `experimental.agentView` to `true` in settings to enable it.', + }); + }); + + it('rejects before the current session can be resumed', async () => { + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config: mockConfig({ sessionExists: false }) }, + ui: { isIdleRef: { current: true } }, + }), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'Cannot detach Agent View before the session is saved.', + }); + }); + + it('rejects while background work is running', async () => { + const config = mockConfig({ sessionExists: true, hasBackgroundWork: true }); + + const result = await backgroundCommand.action?.( + createMockCommandContext({ + services: { config }, + ui: { isIdleRef: { current: true } }, + }), + '', + ); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + "Stop the current session's running background tasks before detaching it.", + }); + }); +}); + +function mockConfig(options: { + sessionExists: boolean; + hasBackgroundWork?: boolean; +}) { + return { + isAgentViewEnabled: () => true, + getSessionId: () => '123e4567-e89b-12d3-a456-426614174000', + getSessionService: () => ({ + sessionExists: vi.fn().mockResolvedValue(options.sessionExists), + }), + getBackgroundTaskRegistry: () => ({ + hasRunningTasks: () => options.hasBackgroundWork === true, + getAll: () => [], + }), + getMonitorRegistry: () => ({ getRunning: () => [] }), + getBackgroundShellRegistry: () => ({ + hasRunningEntries: () => false, + getAll: () => [], + }), + getWorkflowRunRegistry: () => ({ + hasRunningEntries: () => false, + list: () => [], + }), + }; +} diff --git a/packages/cli/src/ui/commands/background-command.ts b/packages/cli/src/ui/commands/background-command.ts new file mode 100644 index 00000000000..dbb5605550f --- /dev/null +++ b/packages/cli/src/ui/commands/background-command.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CommandKind, + type AgentViewDetachActionReturn, + type MessageActionReturn, + type SlashCommand, +} from './types.js'; +import { t } from '../../i18n/index.js'; +import { readAgentViewWorkerSidebandEnv } from '../../agent-view/worker-sideband.js'; +import { AGENT_VIEW_DISABLED_MESSAGE } from '../../agent-view/feature.js'; +import { + buildBackgroundWorkBlockedMessage, + hasBlockingBackgroundWork, +} from '../utils/backgroundWorkUtils.js'; + +export const backgroundCommand: SlashCommand = { + name: 'background', + altNames: ['bg'], + get description() { + return t('Detach the current Agent View session.'); + }, + kind: CommandKind.BUILT_IN, + supportedModes: ['interactive'] as const, + action: async ( + context, + ): Promise => { + if (readAgentViewWorkerSidebandEnv() !== undefined) { + return { type: 'agent_view_detach' }; + } + + const config = context.services.config; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: t('Cannot detach Agent View before configuration is loaded.'), + }; + } + if (!config.isAgentViewEnabled()) { + return { + type: 'message', + messageType: 'error', + content: AGENT_VIEW_DISABLED_MESSAGE, + }; + } + + if (hasBlockingBackgroundWork(config)) { + const message = t( + "Stop the current session's running background tasks before detaching it.", + ); + return { + type: 'message', + messageType: 'error', + content: buildBackgroundWorkBlockedMessage(config, message), + }; + } + + const idleGateState = context.ui.agentViewIdleGateStateRef?.current; + if (idleGateState?.hasPendingUserQuestion) { + return { + type: 'message', + messageType: 'error', + content: t('Cannot detach Agent View while a question is waiting.'), + }; + } + + if (idleGateState?.hasPendingToolConfirmation) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot detach Agent View while a tool confirmation is pending.', + ), + }; + } + + if (idleGateState?.hasPendingCommandConfirmation) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot detach Agent View while a command confirmation is pending.', + ), + }; + } + + if (idleGateState?.hasForegroundShell) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot detach Agent View while a foreground shell is active.', + ), + }; + } + + if (idleGateState?.hasBackgroundFocusDialog) { + return { + type: 'message', + messageType: 'error', + content: t( + 'Cannot detach Agent View while the background tasks dialog is open.', + ), + }; + } + + if (idleGateState?.hasQueuedPrompt) { + return { + type: 'message', + messageType: 'error', + content: t('Cannot detach Agent View while prompts are queued.'), + }; + } + + if (!context.ui.isIdleRef.current) { + return { + type: 'message', + messageType: 'error', + content: t('Cannot detach Agent View while a turn is running.'), + }; + } + + const sessionId = config.getSessionId(); + if (!(await config.getSessionService().sessionExists(sessionId))) { + return { + type: 'message', + messageType: 'error', + content: t('Cannot detach Agent View before the session is saved.'), + }; + } + + return { type: 'agent_view_detach' }; + }, +}; diff --git a/packages/cli/src/ui/commands/quitCommand.test.ts b/packages/cli/src/ui/commands/quitCommand.test.ts index e67723fdf1c..e6610817e33 100644 --- a/packages/cli/src/ui/commands/quitCommand.test.ts +++ b/packages/cli/src/ui/commands/quitCommand.test.ts @@ -8,14 +8,23 @@ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; import { quitCommand } from './quitCommand.js'; import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; import { formatDuration } from '../utils/formatters.js'; +import type { AgentViewWorkerSidebandEnv } from '../../agent-view/worker-sideband.js'; + +const mockReadAgentViewWorkerSidebandEnv = vi.hoisted(() => + vi.fn<() => AgentViewWorkerSidebandEnv | undefined>(() => undefined), +); vi.mock('../utils/formatters.js'); +vi.mock('../../agent-view/worker-sideband.js', () => ({ + readAgentViewWorkerSidebandEnv: mockReadAgentViewWorkerSidebandEnv, +})); describe('quitCommand', () => { beforeEach(() => { vi.useFakeTimers(); vi.setSystemTime(new Date('2025-01-01T01:00:00Z')); vi.mocked(formatDuration).mockReturnValue('1h 0m 0s'); + mockReadAgentViewWorkerSidebandEnv.mockReturnValue(undefined); }); afterEach(() => { @@ -52,4 +61,20 @@ describe('quitCommand', () => { ], }); }); + + it('detaches managed Agent View workers instead of quitting', () => { + mockReadAgentViewWorkerSidebandEnv.mockReturnValue({ + sessionId: 'session-1', + sidebandEndpoint: 'unix:/tmp/qwen-agent-view.sock', + token: 'token-1', + activeCwd: '/repo', + }); + const mockContext = createMockCommandContext(); + + if (!quitCommand.action) throw new Error('Action is not defined'); + const result = quitCommand.action(mockContext, 'exit'); + + expect(result).toEqual({ type: 'agent_view_detach' }); + expect(formatDuration).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/ui/commands/quitCommand.ts b/packages/cli/src/ui/commands/quitCommand.ts index 6d0ef5e6741..156a09ec159 100644 --- a/packages/cli/src/ui/commands/quitCommand.ts +++ b/packages/cli/src/ui/commands/quitCommand.ts @@ -7,6 +7,7 @@ import { formatDuration } from '../utils/formatters.js'; import { CommandKind, type SlashCommand } from './types.js'; import { t } from '../../i18n/index.js'; +import { readAgentViewWorkerSidebandEnv } from '../../agent-view/worker-sideband.js'; export const quitCommand: SlashCommand = { name: 'quit', @@ -17,6 +18,10 @@ export const quitCommand: SlashCommand = { kind: CommandKind.BUILT_IN, supportedModes: ['interactive'] as const, action: (context) => { + if (readAgentViewWorkerSidebandEnv() !== undefined) { + return { type: 'agent_view_detach' }; + } + const now = Date.now(); const { sessionStartTime } = context.session.stats; const wallDuration = now - sessionStartTime.getTime(); diff --git a/packages/cli/src/ui/commands/resumeCommand.test.ts b/packages/cli/src/ui/commands/resumeCommand.test.ts index fed298b8ce0..79a5698d077 100644 --- a/packages/cli/src/ui/commands/resumeCommand.test.ts +++ b/packages/cli/src/ui/commands/resumeCommand.test.ts @@ -17,11 +17,28 @@ vi.mock('../../config/config.js', () => ({ ), })); +const mockIsAgentViewWorkerResumeCommandBlocked = vi.hoisted(() => + vi.fn(() => false), +); +const mockIsManagedAgentViewResumeBlocked = vi.hoisted(() => + vi.fn(async () => false), +); + +vi.mock('../../startup/agent-view-resume-guard.js', () => ({ + isManagedAgentViewResumeBlocked: mockIsManagedAgentViewResumeBlocked, + isAgentViewWorkerResumeCommandBlocked: + mockIsAgentViewWorkerResumeCommandBlocked, + MANAGED_AGENT_VIEW_RESUME_MESSAGE: 'managed session message', + AGENT_VIEW_WORKER_RESUME_MESSAGE: 'worker resume message', +})); + describe('resumeCommand', () => { let mockContext: CommandContext; beforeEach(() => { vi.clearAllMocks(); + mockIsManagedAgentViewResumeBlocked.mockResolvedValue(false); + mockIsAgentViewWorkerResumeCommandBlocked.mockReturnValue(false); mockContext = createMockCommandContext(); }); @@ -43,6 +60,18 @@ describe('resumeCommand', () => { }); }); + it('blocks resume inside an attached background agent', async () => { + mockIsAgentViewWorkerResumeCommandBlocked.mockReturnValue(true); + + const result = await resumeCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'worker resume message', + }); + }); + it('should return error when config is not available and args given', async () => { mockContext.services.config = null; @@ -77,6 +106,28 @@ describe('resumeCommand', () => { }); }); + it('blocks direct resume for a managed Agent View session', async () => { + const sessionId = '550e8400-e29b-41d4-a716-446655440000'; + mockIsManagedAgentViewResumeBlocked.mockResolvedValueOnce(true); + const sessionExists = vi.fn().mockResolvedValue(true); + const mockConfig = { + getSessionService: vi.fn().mockReturnValue({ sessionExists }), + getTargetDir: vi.fn().mockReturnValue('/test'), + }; + mockContext = createMockCommandContext({ + services: { config: mockConfig as never }, + }); + + const result = await resumeCommand.action!(mockContext, sessionId); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'managed session message', + }); + expect(sessionExists).toHaveBeenCalledWith(sessionId); + }); + it('should return error when valid UUID is provided but session does not exist', async () => { const sessionId = '550e8400-e29b-41d4-a716-446655440000'; const mockConfig = { diff --git a/packages/cli/src/ui/commands/resumeCommand.ts b/packages/cli/src/ui/commands/resumeCommand.ts index 4644d33940f..d0742ae63a1 100644 --- a/packages/cli/src/ui/commands/resumeCommand.ts +++ b/packages/cli/src/ui/commands/resumeCommand.ts @@ -8,6 +8,12 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { isValidSessionId } from '../../config/config.js'; import { t } from '../../i18n/index.js'; +import { + AGENT_VIEW_WORKER_RESUME_MESSAGE, + isAgentViewWorkerResumeCommandBlocked, + isManagedAgentViewResumeBlocked, + MANAGED_AGENT_VIEW_RESUME_MESSAGE, +} from '../../startup/agent-view-resume-guard.js'; export const resumeCommand: SlashCommand = { name: 'resume', @@ -18,6 +24,14 @@ export const resumeCommand: SlashCommand = { return t('Resume a previous session'); }, action: async (context, args): Promise => { + if (isAgentViewWorkerResumeCommandBlocked()) { + return { + type: 'message', + messageType: 'error', + content: t(AGENT_VIEW_WORKER_RESUME_MESSAGE), + }; + } + const arg = args.trim(); // No argument — show picker @@ -39,6 +53,13 @@ export const resumeCommand: SlashCommand = { const sessionService = config.getSessionService(); const exists = await sessionService.sessionExists(arg); if (exists) { + if (await isManagedAgentViewResumeBlocked(arg)) { + return { + type: 'message', + messageType: 'error', + content: t(MANAGED_AGENT_VIEW_RESUME_MESSAGE), + }; + } return { type: 'dialog', dialog: 'resume', sessionId: arg }; } return { diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 0f7b3396081..1fdbaa4d05c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -88,6 +88,8 @@ export interface CommandContext { btwAbortControllerRef: MutableRefObject; /** Ref to whether the agent stream is currently idle (no model turn in flight). */ isIdleRef: MutableRefObject; + /** Ref to Agent View detach blockers that are owned by the outer UI. */ + agentViewIdleGateStateRef?: MutableRefObject; /** * Loads a new set of history items, replacing the current history. * @@ -118,6 +120,15 @@ export interface CommandContext { abortSignal?: AbortSignal; } +export interface AgentViewIdleGateState { + hasPendingUserQuestion?: boolean; + hasPendingToolConfirmation?: boolean; + hasPendingCommandConfirmation?: boolean; + hasForegroundShell?: boolean; + hasBackgroundFocusDialog?: boolean; + hasQueuedPrompt?: boolean; +} + /** * The return type for a command action that results in scheduling a tool call. */ @@ -282,6 +293,10 @@ export interface ConfirmActionReturn { }; } +export interface AgentViewDetachActionReturn { + type: 'agent_view_detach'; +} + export type SlashCommandActionReturn = | ToolActionReturn | MessageActionReturn @@ -292,7 +307,8 @@ export type SlashCommandActionReturn = | SubmitPromptActionReturn | GoalControlActionReturn | ConfirmShellCommandsActionReturn - | ConfirmActionReturn; + | ConfirmActionReturn + | AgentViewDetachActionReturn; export enum CommandKind { BUILT_IN = 'built-in', diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index f60a5388a02..b6847b05f70 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -523,6 +523,7 @@ export const DialogManager = ({ onCancel={uiActions.closeResumeDialog} initialSessions={uiState.resumeMatchedSessions} enablePreview + includeAgentViewSessions={uiState.resumeMatchedSessions === undefined} /> ); } diff --git a/packages/cli/src/ui/components/SessionPicker.tsx b/packages/cli/src/ui/components/SessionPicker.tsx index 09935c825c3..8d6110d8cb3 100644 --- a/packages/cli/src/ui/components/SessionPicker.tsx +++ b/packages/cli/src/ui/components/SessionPicker.tsx @@ -4,11 +4,9 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { useEffect, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; -import type { - SessionListItem as SessionData, - SessionService, -} from '@qwen-code/qwen-code-core'; +import type { SessionService } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useSessionPicker } from '../hooks/useSessionPicker.js'; import { formatRelativeTime } from '../utils/formatters.js'; @@ -16,13 +14,27 @@ import { formatMessageCount, truncateText, } from '../utils/sessionPickerUtils.js'; +import { + cleanSingleLineText, + getCachedStringWidth, + truncateToWidth, +} from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { t } from '../../i18n/index.js'; import { SessionPreview } from './SessionPreview.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { + listAgentViewProjectResumeSessions, + listManagedAgentViewResumeSessions, + type AgentViewResumeSessionListItem, +} from '../../startup/agent-view-resume-sessions.js'; +import { MANAGED_AGENT_VIEW_RESUME_MESSAGE } from '../../startup/agent-view-resume-guard.js'; + +type SessionData = AgentViewResumeSessionListItem; export interface SessionPickerProps { sessionService: SessionService | null; - onSelect: (sessionId: string) => void; + onSelect: (sessionId: string, session?: SessionData) => void; onCancel: () => void; currentBranch?: string; @@ -42,6 +54,9 @@ export interface SessionPickerProps { * When provided, skips initial load and disables pagination. */ initialSessions?: SessionData[]; + excludeSessionIds?: readonly string[]; + includeAgentViewSessions?: boolean; + allowManagedAgentViewSelection?: boolean; /** * Enable Space-to-preview. Off by default — preview's Enter shortcut @@ -130,6 +145,26 @@ function SessionListItemView({ typeof session.messageCount === 'number' ? formatMessageCount(session.messageCount) : undefined; + const metadataSuffix = [ + timeAgo, + session.agentViewManaged ? 'bg' : undefined, + messageText, + session.gitBranch, + isDisabled ? disabledHint : undefined, + ] + .filter((value): value is string => Boolean(value)) + .join(' · '); + const agentViewMetaWidth = Math.max( + 0, + maxPromptWidth - getCachedStringWidth(metadataSuffix) - 3, + ); + const agentViewMeta = + session.agentViewManaged && session.agentViewLastResult + ? truncateText( + cleanSingleLineText(session.agentViewLastResult), + agentViewMetaWidth, + ) + : undefined; const showUpIndicator = isFirst && showScrollUp; const showDownIndicator = isLast && showScrollDown; @@ -142,7 +177,9 @@ function SessionListItemView({ ? prefixChars.scrollDown : prefixChars.normal; - const promptText = session.customTitle || session.prompt || '(empty prompt)'; + const promptText = cleanSingleLineText( + session.customTitle || session.prompt || '(empty prompt)', + ); // Reserve space for the checkbox when multi-select is active so the // prompt column doesn't shift between modes. const checkboxWidth = isChecked === undefined ? 0 : 4; // "[x] " @@ -204,10 +241,8 @@ function SessionListItemView({ - {timeAgo} - {messageText !== undefined && ` · ${messageText}`} - {session.gitBranch && ` · ${session.gitBranch}`} - {isDisabled && disabledHint ? ` · ${disabledHint}` : ''} + {agentViewMeta ? `${agentViewMeta} · ` : ''} + {metadataSuffix} @@ -223,13 +258,55 @@ export function SessionPicker(props: SessionPickerProps) { title, centerSelection = true, initialSessions, + excludeSessionIds, enablePreview = false, enableMultiSelect = false, onConfirmMulti, disabledIds, + includeAgentViewSessions = false, + allowManagedAgentViewSelection = false, } = props; + const [agentViewSessions, setAgentViewSessions] = useState([]); + const [managedPreviewSessionId, setManagedPreviewSessionId] = useState< + string | undefined + >(); + const managedSessionIds = useMemo( + () => + new Set([ + ...agentViewSessions + .filter((session) => session.agentViewManaged) + .map((session) => session.sessionId), + ]), + [agentViewSessions], + ); + + useEffect(() => { + if (!includeAgentViewSessions) { + setAgentViewSessions([]); + return; + } + let disposed = false; + void Promise.all([ + listAgentViewProjectResumeSessions().catch(() => []), + listManagedAgentViewResumeSessions().catch(() => []), + ]).then(([projectSessions, managedSessions]) => { + if (!disposed) { + setAgentViewSessions([...projectSessions, ...managedSessions]); + } + }); + return () => { + disposed = true; + }; + }, [includeAgentViewSessions]); const { columns: width, rows: height } = useTerminalSize(); + const handleSelect = (sessionId: string, session?: SessionData) => { + if (!allowManagedAgentViewSelection && managedSessionIds.has(sessionId)) { + setManagedPreviewSessionId(sessionId); + return; + } + onSelect(sessionId, session); + }; // Calculate box width (marginX={2}) const boxWidth = width - 4; @@ -249,18 +326,35 @@ export function SessionPicker(props: SessionPickerProps) { const picker = useSessionPicker({ sessionService, currentBranch, - onSelect, + onSelect: handleSelect, onCancel, maxVisibleItems, centerSelection, initialSessions, - isActive: true, + // Gate picker input while the managed-session preview overlay is open. + isActive: managedPreviewSessionId === undefined, enablePreview, enableMultiSelect, onConfirmMulti, disabledIds, + extraSessions: agentViewSessions, + excludeSessionIds, }); + if (managedPreviewSessionId) { + const session = picker.filteredSessions.find( + (item) => item.sessionId === managedPreviewSessionId, + ); + if (isAgentViewManagedSession(session)) { + return ( + setManagedPreviewSessionId(undefined)} + /> + ); + } + } + if ( enablePreview && picker.viewMode === 'preview' && @@ -270,16 +364,33 @@ export function SessionPicker(props: SessionPickerProps) { const previewed = picker.filteredSessions.find( (s) => s.sessionId === picker.previewSessionId, ); + if (isAgentViewManagedSession(previewed)) { + return ( + + ); + } return ( onSelect(sessionId, previewed)} /> ); } @@ -475,3 +586,56 @@ export function SessionPicker(props: SessionPickerProps) { ); } + +function ManagedAgentViewSessionPreview({ + session, + onExit, +}: { + session: SessionData; + onExit: () => void; +}): React.JSX.Element { + const { columns } = useTerminalSize(); + useKeypress( + (key) => { + if ( + key.name === 'escape' || + key.name === 'return' || + (key.ctrl && key.name === 'c') + ) { + onExit(); + } + }, + { isActive: true }, + ); + + return ( + + + {cleanSingleLineText(session.customTitle ?? session.prompt ?? '')} + + {session.agentViewLastResult ? ( + + {truncateToWidth( + cleanSingleLineText(session.agentViewLastResult), + Math.max(columns - 6, 8), + )} + + ) : null} + + {MANAGED_AGENT_VIEW_RESUME_MESSAGE} + + Esc to return + + ); +} + +function isAgentViewManagedSession( + session: SessionData | undefined, +): session is SessionData { + return Boolean(session?.agentViewManaged); +} diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx index 1671e261c76..494b772a104 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.test.tsx @@ -422,7 +422,10 @@ describe('SessionPicker', () => { stdin.write('\r'); await flush(); - expect(onSelect).toHaveBeenCalledWith('selected-session'); + expect(onSelect).toHaveBeenCalledWith( + 'selected-session', + expect.objectContaining({ sessionId: 'selected-session' }), + ); }); it('should cancel on Escape', async () => { @@ -460,6 +463,27 @@ describe('SessionPicker', () => { }); describe('Display', () => { + it('sanitizes managed session titles before rendering', async () => { + const session = Object.assign(createMockSession(), { + agentViewManaged: true, + customTitle: '\u001b]0;spoof\u0007first\nsecond', + }); + const { lastFrame } = render( + + + , + ); + + await flush(); + + expect(lastFrame()).toContain('first second'); + expect(lastFrame()).not.toContain('spoof'); + }); + it('should show session metadata', async () => { const sessions = [ createMockSession({ @@ -491,6 +515,59 @@ describe('SessionPicker', () => { expect(output).toContain('feature-branch'); }); + it('keeps long Agent View metadata within one item line', async () => { + const session = Object.assign(createMockSession(), { + agentViewManaged: true, + agentViewLastResult: 'x'.repeat(300), + messageCount: undefined, + gitBranch: undefined, + }); + const { lastFrame } = render( + + + , + ); + + await flush(); + + const lines = (lastFrame() ?? '').split('\n'); + const promptLine = lines.findIndex((line) => + line.includes('Test prompt'), + ); + expect(promptLine).toBeGreaterThanOrEqual(0); + expect(lines[promptLine + 1]).toMatch(/just now · bg/); + }); + + it('bounds managed Agent View preview output to one terminal line', async () => { + const session = Object.assign(createMockSession(), { + agentViewManaged: true, + agentViewLastResult: `first line\n${'x'.repeat(5000)}`, + }); + const { stdin, lastFrame } = render( + + + , + ); + + await flush(); + stdin.write(' '); + await flush(); + + const output = lastFrame() ?? ''; + expect(output).toContain('first line x'); + expect(output).toContain('…'); + expect(output).not.toContain('x'.repeat(80)); + }); + it('renders the metadata line cleanly when messageCount is undefined', async () => { // `listSessions()` now omits `messageCount` for perf, so this is the // default production shape. Pin the row's render contract: time and @@ -887,7 +964,10 @@ describe('SessionPicker', () => { await flush(); stdin.write('\r'); // Enter await flush(); - expect(onSelect).toHaveBeenCalledWith('s1'); + expect(onSelect).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ sessionId: 's1' }), + ); }); it('without enablePreview, Space is a no-op and footer omits the hint', async () => { @@ -934,7 +1014,10 @@ describe('SessionPicker', () => { // unchanged), not be eaten by a phantom preview. stdin.write('\r'); await flush(); - expect(onSelect).toHaveBeenCalledWith('s1'); + expect(onSelect).toHaveBeenCalledWith( + 's1', + expect.objectContaining({ sessionId: 's1' }), + ); expect(service.loadSession).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx index 1a245e0ab06..91c4fd697f6 100644 --- a/packages/cli/src/ui/components/StandaloneSessionPicker.tsx +++ b/packages/cli/src/ui/components/StandaloneSessionPicker.tsx @@ -18,6 +18,7 @@ import { SettingsContext } from '../contexts/SettingsContext.js'; import type { LoadedSettings } from '../../config/settings.js'; import { SessionPicker } from './SessionPicker.js'; import { writeStdoutLine } from '../../utils/stdioHelpers.js'; +import { listManagedAgentViewResumeSessions } from '../../startup/agent-view-resume-sessions.js'; /** * `--resume` runs this picker BEFORE `loadCliConfig`, so no real Config / @@ -46,10 +47,13 @@ const PREVIEW_SETTINGS_STUB = { interface StandalonePickerScreenProps { sessionService: SessionService; - onSelect: (sessionId: string) => void; + onSelect: (session: SessionListItem) => void; onCancel: () => void; currentBranch?: string; initialSessions?: SessionListItem[]; + excludeSessionIds?: readonly string[]; + includeAgentViewSessions?: boolean; + allowManagedAgentViewSelection?: boolean; } function StandalonePickerScreen({ @@ -58,6 +62,9 @@ function StandalonePickerScreen({ onCancel, currentBranch, initialSessions, + excludeSessionIds, + includeAgentViewSessions = true, + allowManagedAgentViewSelection, }: StandalonePickerScreenProps): React.JSX.Element { const { exit } = useApp(); const [isExiting, setIsExiting] = useState(false); @@ -76,8 +83,8 @@ function StandalonePickerScreen({ { - onSelect(id); + onSelect={(id, session) => { + onSelect(session ?? makeFallbackSessionItem(id, sessionService)); handleExit(); }} onCancel={() => { @@ -87,7 +94,12 @@ function StandalonePickerScreen({ currentBranch={currentBranch} centerSelection={true} initialSessions={initialSessions} + excludeSessionIds={excludeSessionIds} enablePreview + includeAgentViewSessions={ + includeAgentViewSessions && initialSessions === undefined + } + allowManagedAgentViewSelection={allowManagedAgentViewSelection} /> @@ -98,8 +110,10 @@ function StandalonePickerScreen({ * Clears the terminal screen. */ function clearScreen(): void { - // Move cursor to home position and clear screen - process.stdout.write('\x1b[2J\x1b[H'); + // Reset terminal state before fullscreen picker rendering. The explicit + // alt-screen exit prevents stale alternate-buffer content from surfacing + // when Agent View hands off between Ink apps. + process.stdout.write('\x1b[0m\x1b[?25h\x1b[?1049l\x1b[2J\x1b[H'); } /** @@ -109,10 +123,33 @@ function clearScreen(): void { export async function showResumeSessionPicker( cwd: string = process.cwd(), initialSessions?: SessionListItem[], + options: { + includeAgentViewSessions?: boolean; + allowManagedAgentViewSelection?: boolean; + } = {}, ): Promise { + return (await showResumeSessionPickerItem(cwd, initialSessions, options)) + ?.sessionId; +} + +export async function showResumeSessionPickerItem( + cwd: string = process.cwd(), + initialSessions?: SessionListItem[], + options: { + includeAgentViewSessions?: boolean; + allowManagedAgentViewSelection?: boolean; + } = {}, +): Promise { const sessionService = new SessionService(cwd); - const hasSession = await sessionService.loadLastSession(); - if (!hasSession) { + const hasSession = await hasResumeSession(sessionService, initialSessions); + const includeAgentViewSessions = options.includeAgentViewSessions ?? true; + const managedSessions = await listManagedAgentViewResumeSessions().catch( + () => [], + ); + const displayManagedSessions = includeAgentViewSessions + ? managedSessions + : []; + if (!hasSession && displayManagedSessions.length === 0) { writeStdoutLine('No sessions found. Start a new session with `qwen`.'); return undefined; } @@ -126,8 +163,8 @@ export async function showResumeSessionPicker( process.stdin.setRawMode(true); } - return new Promise((resolve) => { - let selectedId: string | undefined; + return new Promise((resolve) => { + let selectedSession: SessionListItem | undefined; const { unmount, waitUntilExit } = render( { - selectedId = id; + onSelect={(session) => { + selectedSession = session; }} onCancel={() => { - selectedId = undefined; + selectedSession = undefined; }} currentBranch={getGitBranch(cwd)} initialSessions={initialSessions} + excludeSessionIds={ + includeAgentViewSessions + ? undefined + : managedSessions.map((session) => session.sessionId) + } + includeAgentViewSessions={includeAgentViewSessions} + allowManagedAgentViewSelection={ + options.allowManagedAgentViewSelection + } /> , { @@ -160,13 +206,36 @@ export async function showResumeSessionPicker( // Clear the screen after the picker closes for a clean fullscreen experience clearScreen(); - // Restore raw mode state only if we changed it and user cancelled - // (if user selected a session, main app will handle raw mode) - if (process.stdin.isTTY && !wasRaw && !selectedId) { + // Restore raw mode state if this standalone picker changed it. + if (process.stdin.isTTY && !wasRaw) { process.stdin.setRawMode(false); } - resolve(selectedId); + resolve(selectedSession); }); }); } + +async function hasResumeSession( + sessionService: SessionService, + initialSessions: SessionListItem[] | undefined, +): Promise { + if (initialSessions) { + return initialSessions.length > 0; + } + return (await sessionService.listSessions({ size: 1 })).items.length > 0; +} + +function makeFallbackSessionItem( + sessionId: string, + sessionService: SessionService, +): SessionListItem { + return { + sessionId, + cwd: sessionService.getProjectRoot(), + startTime: new Date().toISOString(), + mtime: Date.now(), + prompt: sessionId, + filePath: '', + }; +} diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 828a8af117a..7a534fa4f1c 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -98,6 +98,37 @@ describe('KeypressContext - Kitty Protocol', () => { }); describe('Enter key handling', () => { + it('ignores pure focus event sequences without dropping real keypresses', () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.pressKey({ sequence: '\x1b[I\x1b[O' }); + }); + act(() => { + stdin.pressKey({ + name: 'a', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'a\x1b[I', + }); + }); + + expect(keyHandler).toHaveBeenCalledTimes(1); + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ sequence: 'a\x1b[I' }), + ); + }); + it('preserves typed µ as printable text', () => { const keyHandler = vi.fn(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 26fd5241725..8fa31d67eb7 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -64,6 +64,19 @@ const OPTION_COMPOSED_GLYPHS: Record = { }; export const PASTE_MODE_PREFIX = `${ESC}[200~`; export const PASTE_MODE_SUFFIX = `${ESC}[201~`; +// Built lazily: FOCUS_IN/FOCUS_OUT come from useFocus.ts, which is part of a +// circular import chain (useFocus -> useKeypress -> KeypressContext -> useFocus). +// Evaluating the pattern at module load crashes when the cycle is entered at +// useFocus.ts because the bindings are not initialized yet. +let focusEventPattern: RegExp | undefined; +function getFocusEventPattern(): RegExp { + if (!focusEventPattern) { + focusEventPattern = new RegExp( + `^(?:${escapeRegExp(FOCUS_IN)}|${escapeRegExp(FOCUS_OUT)})+$`, + ); + } + return focusEventPattern; +} export const DRAG_COMPLETION_TIMEOUT_MS = 100; // Broadcast full path after 100ms if no more input // Kitty sequence timeout: 200ms balances between: // - Too short: prematurely clear valid sequences during slow input @@ -79,6 +92,10 @@ export const KITTY_SEQUENCE_TIMEOUT_MS = 200; // chunked pastes on cold terminals yet short enough that users don't // perceive the recovery as a hang. export const PASTE_IDLE_TIMEOUT_MS = 1000; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} export const SINGLE_QUOTE = "'"; export const DOUBLE_QUOTE = '"'; @@ -786,7 +803,7 @@ export function KeypressProvider({ if (TERMINAL_RESPONSE_RE.test(key.sequence)) { return; } - if (key.sequence === FOCUS_IN || key.sequence === FOCUS_OUT) { + if (getFocusEventPattern().test(key.sequence)) { return; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index bab2ad49c5c..579b03b63d2 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -12,6 +12,7 @@ import { type SlashCommandProcessorActions, } from './slashCommandProcessor.js'; import type { + AgentViewIdleGateState, CommandContext, ConfirmActionReturn, ConfirmShellCommandsActionReturn, @@ -234,6 +235,8 @@ describe('useSlashCommandProcessor', () => { settings: LoadedSettings = mockSettings, extensionRefreshState?: ExtensionRefreshState, isIdleRef = { current: true }, + actions: SlashCommandProcessorActions = createMockActions(), + agentViewIdleGateStateRef?: { current: AgentViewIdleGateState }, ) => { mockBuiltinLoadCommands.mockResolvedValue(Object.freeze(builtinCommands)); mockFileLoadCommands.mockResolvedValue(Object.freeze(fileCommands)); @@ -253,13 +256,14 @@ describe('useSlashCommandProcessor', () => { setIsProcessing, isIdleRef, vi.fn(), // setGeminiMdFileCount - createMockActions(), + actions, new Map(), // extensionsUpdateState true, // isConfigInitialized null, // logger mockUpdateItem, undefined, // setSessionName extensionRefreshState, + agentViewIdleGateStateRef, ), ); @@ -277,6 +281,31 @@ describe('useSlashCommandProcessor', () => { expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); }); + it('plumbs Agent View idle gate state into the command context', async () => { + const agentViewIdleGateStateRef = { + current: { hasQueuedPrompt: true }, + }; + const result = setupProcessorHook( + [], + [], + [], + vi.fn(), + mockSettings, + undefined, + { current: true }, + createMockActions(), + agentViewIdleGateStateRef, + ); + + await waitFor(() => { + expect(result.current.slashCommands).toBeDefined(); + }); + + expect(result.current.commandContext.ui.agentViewIdleGateStateRef).toBe( + agentViewIdleGateStateRef, + ); + }); + it('rebuilds commands when an MCP server connects (surfaces MCP prompts in /)', async () => { const result = setupProcessorHook(); await waitFor(() => { @@ -740,6 +769,66 @@ describe('useSlashCommandProcessor', () => { }); describe('Action Result Handling', () => { + it('should handle "agent_view_detach" action', async () => { + const command = createTestCommand({ + name: 'background', + action: vi.fn().mockResolvedValue({ type: 'agent_view_detach' }), + }); + const actions = createMockActions(); + actions.detachAgentViewSession = vi.fn(); + const result = setupProcessorHook( + [command], + [], + [], + vi.fn(), + mockSettings, + undefined, + { current: true }, + actions, + ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/background'); + }); + + expect(actions.detachAgentViewSession).toHaveBeenCalledTimes(1); + }); + + it('shows agent view detach failures in history', async () => { + const command = createTestCommand({ + name: 'background', + action: vi.fn().mockResolvedValue({ type: 'agent_view_detach' }), + }); + const actions = createMockActions(); + actions.detachAgentViewSession = vi + .fn() + .mockRejectedValue(new Error('supervisor unavailable')); + const result = setupProcessorHook( + [command], + [], + [], + vi.fn(), + mockSettings, + undefined, + { current: true }, + actions, + ); + await waitFor(() => expect(result.current.slashCommands).toHaveLength(1)); + + await act(async () => { + await result.current.handleSlashCommand('/background'); + }); + + expect(mockAddItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'supervisor unavailable', + }), + expect.any(Number), + ); + }); + it.each([ ['/auth status', 'auth', ['connect', 'login'], 'auth'], ['/connect', 'auth', ['connect', 'login'], 'auth'], diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 29a1984b83a..1a29fbe074a 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -45,6 +45,7 @@ import { MessageType } from '../types.js'; import type { LoadedSettings } from '../../config/settings.js'; import { CommandKind, + type AgentViewIdleGateState, type CommandContext, type SlashCommand, } from '../commands/types.js'; @@ -215,6 +216,7 @@ export interface SlashCommandProcessorActions { openRewindSelector: () => void; openDiffDialog: () => void; openHelpDialog: () => void; + detachAgentViewSession?: () => Promise; clearPendingState: () => void; } @@ -241,6 +243,7 @@ export const useSlashCommandProcessor = ( updateItem: UseHistoryManagerReturn['updateItem'], setSessionName?: (name: string | null) => void, extensionRefreshState?: ExtensionRefreshState, + agentViewIdleGateStateRef?: MutableRefObject, ) => { const fallbackExtensionRefreshStateRef = useRef( null, @@ -421,12 +424,19 @@ export const useSlashCommandProcessor = ( // AbortController for cancelling async slash commands via ESC const abortControllerRef = useRef(null); + // Agent View adoption (detach) is not abortable — the supervisor spawn + // takes no cancellation signal. While it is in flight, ESC must not report + // a cancellation that does not happen. + const detachInFlightRef = useRef(false); const cancelSlashCommand = useCallback(() => { cancelBtw(); if (!abortControllerRef.current) { return; } + if (detachInFlightRef.current) { + return; + } abortControllerRef.current.abort(); addItem( { @@ -549,6 +559,7 @@ export const useSlashCommandProcessor = ( cancelBtw, btwAbortControllerRef, isIdleRef, + agentViewIdleGateStateRef, toggleVimEnabled, setGeminiMdFileCount, reloadCommands, @@ -588,6 +599,7 @@ export const useSlashCommandProcessor = ( setSessionName, extensionsUpdateState, isIdleRef, + agentViewIdleGateStateRef, activeExtensionRefreshState, ], ); @@ -1137,6 +1149,19 @@ export const useSlashCommandProcessor = ( toolName: result.toolName, toolArgs: result.toolArgs, }; + case 'agent_view_detach': + if (!actions.detachAgentViewSession) { + throw new Error( + 'Agent View detach action is not available.', + ); + } + detachInFlightRef.current = true; + try { + await actions.detachAgentViewSession(); + } finally { + detachInFlightRef.current = false; + } + return { type: 'handled' }; case 'message': // Picker-shaped commands can still reject their arguments // before opening a dialog. Keep those failures paired with diff --git a/packages/cli/src/ui/hooks/useDeleteCommand.test.ts b/packages/cli/src/ui/hooks/useDeleteCommand.test.ts index 415c32d2c4a..d43ea3f9460 100644 --- a/packages/cli/src/ui/hooks/useDeleteCommand.test.ts +++ b/packages/cli/src/ui/hooks/useDeleteCommand.test.ts @@ -8,6 +8,23 @@ import { act, renderHook } from '@testing-library/react'; import { afterEach, describe, it, expect, vi } from 'vitest'; import { useDeleteCommand } from './useDeleteCommand.js'; import type { Config, RemoveSessionsResult } from '@qwen-code/qwen-code-core'; +import { + isManagedAgentViewDeleteBlocked, + MANAGED_AGENT_VIEW_DELETE_MESSAGE, + releaseExitedManagedSessionForContinue, +} from '../../startup/agent-view-resume-guard.js'; + +vi.mock('../../startup/agent-view-resume-guard.js', async (importOriginal) => { + const actual = + await importOriginal< + typeof import('../../startup/agent-view-resume-guard.js') + >(); + return { + ...actual, + isManagedAgentViewDeleteBlocked: vi.fn(async () => false), + releaseExitedManagedSessionForContinue: vi.fn(async () => true), + }; +}); function createConfig(opts: { currentSessionId: string; @@ -63,6 +80,61 @@ describe('useDeleteCommand', () => { }); describe('handleDeleteMany', () => { + it('skips live managed Agent View sessions and deletes the rest', async () => { + const guard = vi.mocked(isManagedAgentViewDeleteBlocked); + guard.mockResolvedValueOnce(true); // managed-id + guard.mockResolvedValueOnce(false); // a + const removeSessions = vi.fn().mockResolvedValue({ + removed: ['a'], + notFound: [], + errors: [], + }); + const { config } = createConfig({ + currentSessionId: 'current', + removeSessions, + }); + const addItem = vi.fn(); + const { result } = renderHook(() => + useDeleteCommand({ config, addItem }), + ); + + await act(async () => { + result.current.handleDeleteMany(['managed-id', 'a']); + await flushAsync(); + }); + + expect(removeSessions).toHaveBeenCalledWith(['a']); + expect(releaseExitedManagedSessionForContinue).toHaveBeenCalledWith('a'); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: MANAGED_AGENT_VIEW_DELETE_MESSAGE, + }), + expect.any(Number), + ); + }); + + it('skips a batch item when Agent View ownership cannot be released', async () => { + vi.mocked(releaseExitedManagedSessionForContinue).mockResolvedValueOnce( + false, + ); + const removeSessions = vi.fn(); + const { config } = createConfig({ + currentSessionId: 'current', + removeSessions, + }); + const { result } = renderHook(() => + useDeleteCommand({ config, addItem: vi.fn() }), + ); + + await act(async () => { + result.current.handleDeleteMany(['managed-id']); + await flushAsync(); + }); + + expect(removeSessions).not.toHaveBeenCalled(); + }); + it('removes sessions and reports the count on success', async () => { const removeSessions = vi.fn().mockResolvedValue({ removed: ['a', 'b'], @@ -588,6 +660,31 @@ describe('useDeleteCommand', () => { }); describe('handleDelete', () => { + it('refuses to delete a live managed Agent View session', async () => { + vi.mocked(isManagedAgentViewDeleteBlocked).mockResolvedValueOnce(true); + const { config, sessionService } = createConfig({ + currentSessionId: 'current', + }); + const addItem = vi.fn(); + const { result } = renderHook(() => + useDeleteCommand({ config, addItem }), + ); + + await act(async () => { + result.current.handleDelete('managed-id'); + await flushAsync(); + }); + + expect(sessionService.removeSession).not.toHaveBeenCalled(); + expect(addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'info', + text: MANAGED_AGENT_VIEW_DELETE_MESSAGE, + }), + expect.any(Number), + ); + }); + it('fires after a successful deletion', async () => { const { config, hookSystem } = createConfig({ currentSessionId: 'current', @@ -605,6 +702,28 @@ describe('useDeleteCommand', () => { expect(hookSystem.fireSessionDeleteEvent).toHaveBeenCalledWith( 'deleted-id', ); + expect(releaseExitedManagedSessionForContinue).toHaveBeenCalledWith( + 'deleted-id', + ); + }); + + it('does not delete when Agent View ownership cannot be released', async () => { + vi.mocked(releaseExitedManagedSessionForContinue).mockResolvedValueOnce( + false, + ); + const { config, sessionService } = createConfig({ + currentSessionId: 'current', + }); + const { result } = renderHook(() => + useDeleteCommand({ config, addItem: vi.fn() }), + ); + + await act(async () => { + result.current.handleDelete('managed-id'); + await flushAsync(); + }); + + expect(sessionService.removeSession).not.toHaveBeenCalled(); }); it('does not fire when the session was not removed', async () => { diff --git a/packages/cli/src/ui/hooks/useDeleteCommand.ts b/packages/cli/src/ui/hooks/useDeleteCommand.ts index 3183f80f3b4..e048d7d6904 100644 --- a/packages/cli/src/ui/hooks/useDeleteCommand.ts +++ b/packages/cli/src/ui/hooks/useDeleteCommand.ts @@ -9,6 +9,11 @@ import type { Config } from '@qwen-code/qwen-code-core'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { t } from '../../i18n/index.js'; import { fireSessionDeleteHook } from '../../hooks/session-delete-hook.js'; +import { + isManagedAgentViewDeleteBlocked, + MANAGED_AGENT_VIEW_DELETE_MESSAGE, + releaseExitedManagedSessionForContinue, +} from '../../startup/agent-view-resume-guard.js'; export interface UseDeleteCommandOptions { config: Config | null; @@ -71,6 +76,29 @@ export function useDeleteCommand( return; } + // A live managed Agent View session's transcript is being written by + // its worker; deleting it mid-run destroys the running agent's state. + if (await isManagedAgentViewDeleteBlocked(sessionId)) { + addItem?.( + { + type: 'info', + text: MANAGED_AGENT_VIEW_DELETE_MESSAGE, + }, + Date.now(), + ); + return; + } + if (!(await releaseExitedManagedSessionForContinue(sessionId))) { + addItem?.( + { + type: 'error', + text: t('Failed to release Agent View session before deletion.'), + }, + Date.now(), + ); + return; + } + try { const sessionService = config.getSessionService(); const success = await sessionService.removeSession(sessionId); @@ -151,18 +179,45 @@ export function useDeleteCommand( ); } + // Skip live managed Agent View sessions: their transcripts are + // being written by running workers and must not be removed mid-run. + const deletable: string[] = []; + let blockedManaged = 0; + for (const id of filtered) { + if ( + (await isManagedAgentViewDeleteBlocked(id)) || + !(await releaseExitedManagedSessionForContinue(id)) + ) { + blockedManaged++; + } else { + deletable.push(id); + } + } + if (blockedManaged > 0) { + addItem?.( + { + type: 'info', + text: MANAGED_AGENT_VIEW_DELETE_MESSAGE, + }, + Date.now(), + ); + } + if (deletable.length === 0) { + return; + } + addItem?.( { type: 'info', text: t('Deleting {{count}} session(s)...', { - count: String(filtered.length), + count: String(deletable.length), }), }, Date.now(), ); const sessionService = config.getSessionService(); - const result = await sessionService.removeSessions(filtered); + const result = await sessionService.removeSessions(deletable); for (const sessionId of result.removed) { fireSessionDeleteHook(config, sessionId); diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index 8bfe6a67858..be431af0539 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -82,6 +82,21 @@ vi.mock('../utils/resumeHistoryUtils.js', async (importOriginal) => { }; }); +vi.mock('../utils/restoreGoal.js', () => ({ + restoreGoalFromHistory: vi.fn(() => ({ restored: false })), +})); + +const mockIsManagedAgentViewResumeBlocked = vi.hoisted(() => + vi.fn(async () => false), +); + +vi.mock('../../startup/agent-view-resume-guard.js', () => ({ + isManagedAgentViewResumeBlocked: mockIsManagedAgentViewResumeBlocked, + isAgentViewWorkerResumeCommandBlocked: vi.fn(() => false), + MANAGED_AGENT_VIEW_RESUME_MESSAGE: 'managed session message', + AGENT_VIEW_WORKER_RESUME_MESSAGE: 'worker resume message', +})); + vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => { const original = await importOriginal(); @@ -667,6 +682,41 @@ describe('useResumeCommand', () => { expect(blockedItem.text).toContain('[bg_ab12cd34]'); }); + it('blocks picker resume for a managed Agent View session', async () => { + mockIsManagedAgentViewResumeBlocked.mockResolvedValueOnce(true); + const historyManager = { + addItem: vi.fn(), + clearItems: vi.fn(), + loadHistory: vi.fn(), + }; + const startNewSession = vi.fn(); + const config = { + getBackgroundTaskRegistry: () => ({ hasRunningTasks: () => false }), + getBackgroundShellRegistry: () => ({ hasRunningEntries: () => false }), + getMonitorRegistry: () => ({ getRunning: () => [] }), + getWorkflowRunRegistry: () => ({ hasRunningEntries: () => false }), + getTargetDir: () => '/tmp', + } as unknown as import('@qwen-code/qwen-code-core').Config; + const { result } = renderHook(() => + useResumeCommand({ + config, + settings: mockSettings, + historyManager, + startNewSession, + }), + ); + + await act(async () => { + await result.current.handleResume('managed-session'); + }); + + expect(startNewSession).not.toHaveBeenCalled(); + expect(historyManager.addItem).toHaveBeenCalledWith( + { type: 'error', text: 'managed session message' }, + expect.any(Number), + ); + }); + it('blocks resume when the current session still has a running monitor', async () => { const historyManager = { addItem: vi.fn(), diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index c85314524a4..b9ec7f42767 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -23,6 +23,12 @@ import { resetBackgroundStateForSessionSwitch, } from '../utils/backgroundWorkUtils.js'; import type { LoadedSettings } from '../../config/settings.js'; +import { + AGENT_VIEW_WORKER_RESUME_MESSAGE, + isAgentViewWorkerResumeCommandBlocked, + isManagedAgentViewResumeBlocked, + MANAGED_AGENT_VIEW_RESUME_MESSAGE, +} from '../../startup/agent-view-resume-guard.js'; import { waitForGoalRuntime } from '../utils/goal-runtime.js'; export interface UseResumeCommandOptions { @@ -94,6 +100,18 @@ export function useResumeCommand( return; } + if (isAgentViewWorkerResumeCommandBlocked()) { + addItem( + { + type: MessageType.ERROR, + text: AGENT_VIEW_WORKER_RESUME_MESSAGE, + } as HistoryItemWithoutId, + Date.now(), + ); + closeResumeDialog(); + return; + } + if (hasBlockingBackgroundWork(config)) { const blockedMessage: HistoryItemWithoutId = { type: MessageType.ERROR, @@ -110,14 +128,24 @@ export function useResumeCommand( // Close dialog immediately to prevent input capture during async operations. closeResumeDialog(); + if (await isManagedAgentViewResumeBlocked(sessionId)) { + addItem( + { + type: MessageType.ERROR, + text: MANAGED_AGENT_VIEW_RESUME_MESSAGE, + } as HistoryItemWithoutId, + Date.now(), + ); + return; + } + const oldSessionId = config.getSessionId(); let coreSwapped = false; let uiSwapped = false; let recoveredBackgroundAgentsNotice: string | null = null; try { - const cwd = config.getTargetDir(); - const sessionService = new SessionService(cwd); + const sessionService = new SessionService(config.getTargetDir()); const sessionData = await sessionService.loadSession(sessionId); if (!sessionData) { diff --git a/packages/cli/src/ui/hooks/useSessionPicker.test.tsx b/packages/cli/src/ui/hooks/useSessionPicker.test.tsx index 4dc00487aaa..1e20a2bf02b 100644 --- a/packages/cli/src/ui/hooks/useSessionPicker.test.tsx +++ b/packages/cli/src/ui/hooks/useSessionPicker.test.tsx @@ -43,6 +43,24 @@ function pressKey(key: Partial) { }); } +function pressKeys(keys: Array>) { + const handler = keypressState.handlers.at(-1); + expect(handler).toBeDefined(); + act(() => { + for (const key of keys) { + handler?.({ + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + ...key, + }); + } + }); +} + const sessions = [ { sessionId: 's1', @@ -226,7 +244,7 @@ describe('useSessionPicker multi-select state', () => { pressKey({ name: 'return', sequence: '\r' }); - expect(onSelect).toHaveBeenCalledWith('s1'); + expect(onSelect).toHaveBeenCalledWith('s1', sessions[0]); expect(onConfirmMulti).not.toHaveBeenCalled(); }); @@ -276,3 +294,123 @@ describe('useSessionPicker multi-select state', () => { expect(onSelect).not.toHaveBeenCalled(); }); }); + +describe('useSessionPicker filtering', () => { + it('composes same-tick navigation before selecting', () => { + const onSelect = vi.fn(); + renderHook( + () => + useSessionPicker({ + sessionService: null, + onSelect, + onCancel: vi.fn(), + maxVisibleItems: 5, + initialSessions: [ + ...sessions, + { ...sessions[0], sessionId: 's3', prompt: 'three' }, + ], + }), + { wrapper }, + ); + + pressKeys([ + { name: 'down', sequence: '\x1b[B' }, + { name: 'down', sequence: '\x1b[B' }, + { name: 'return', sequence: '\r' }, + ]); + + expect(onSelect).toHaveBeenCalledWith( + 's3', + expect.objectContaining({ sessionId: 's3' }), + ); + }); + + it('keeps selection on the same session after an async re-sort', () => { + const onSelect = vi.fn(); + const { rerender } = renderHook( + ({ extraSessions }) => + useSessionPicker({ + sessionService: null, + onSelect, + onCancel: vi.fn(), + maxVisibleItems: 5, + initialSessions: sessions, + extraSessions, + }), + { + wrapper, + initialProps: { extraSessions: [] as typeof sessions }, + }, + ); + + pressKey({ name: 'down', sequence: '\x1b[B' }); + rerender({ + extraSessions: [ + { + ...sessions[0], + sessionId: 'newer', + prompt: 'newer', + filePath: '/tmp/newer.json', + mtime: 10, + }, + ], + }); + pressKey({ name: 'return', sequence: '\r' }); + + expect(onSelect).toHaveBeenCalledWith('s2', sessions[1]); + }); + + it('prefers a managed session manual title over its transcript title', () => { + const { result } = renderHook( + () => + useSessionPicker({ + sessionService: null, + onSelect: vi.fn(), + onCancel: vi.fn(), + maxVisibleItems: 5, + initialSessions: [ + { + ...sessions[0], + customTitle: 'Generated title', + titleSource: 'auto', + }, + ], + extraSessions: [ + { + ...sessions[0], + customTitle: 'Launchpad', + titleSource: 'manual', + }, + ], + }), + { wrapper }, + ); + + expect(result.current.filteredSessions[0]).toMatchObject({ + customTitle: 'Launchpad', + titleSource: 'manual', + }); + }); + + it('excludes sessions by id before search and selection', () => { + const { result } = renderHook( + () => + useSessionPicker({ + sessionService: null, + onSelect: vi.fn(), + onCancel: vi.fn(), + maxVisibleItems: 5, + initialSessions: sessions, + excludeSessionIds: ['s1'], + }), + { wrapper }, + ); + + expect( + result.current.filteredSessions.map((session) => session.sessionId), + ).toEqual(['s2']); + expect( + result.current.visibleSessions.map((session) => session.sessionId), + ).toEqual(['s2']); + }); +}); diff --git a/packages/cli/src/ui/hooks/useSessionPicker.ts b/packages/cli/src/ui/hooks/useSessionPicker.ts index 0966ca08e92..a8eae254887 100644 --- a/packages/cli/src/ui/hooks/useSessionPicker.ts +++ b/packages/cli/src/ui/hooks/useSessionPicker.ts @@ -33,7 +33,7 @@ import { export interface UseSessionPickerOptions { sessionService: SessionService | null; currentBranch?: string; - onSelect: (sessionId: string) => void; + onSelect: (sessionId: string, session?: SessionListItem) => void; onCancel: () => void; maxVisibleItems: number; /** @@ -48,6 +48,8 @@ export interface UseSessionPickerOptions { * match the given title. */ initialSessions?: SessionListItem[]; + extraSessions?: SessionListItem[]; + excludeSessionIds?: readonly string[]; /** * Enable/disable input handling. */ @@ -127,6 +129,8 @@ export function useSessionPicker({ maxVisibleItems, centerSelection = false, initialSessions, + extraSessions, + excludeSessionIds, isActive = true, enablePreview = false, enableMultiSelect = false, @@ -152,7 +156,8 @@ export function useSessionPicker({ } const hasInitialSessions = initialSessions !== undefined; - const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedSessionId, setSelectedSessionId] = useState(); + const selectedSessionIdRef = useRef(undefined); const [sessionState, setSessionState] = useState( hasInitialSessions ? { sessions: initialSessions, hasMore: false, nextCursor: undefined } @@ -181,6 +186,10 @@ export function useSessionPicker({ () => new Set(disabledIds ?? []), [disabledIds], ); + const excludeSessionIdSet = useMemo( + () => new Set(excludeSessionIds ?? []), + [excludeSessionIds], + ); const toggleChecked = useCallback( (sessionId: string) => { @@ -214,16 +223,22 @@ export function useSessionPicker({ useSessionSearchInput({ onExitToList }); const isLoadingMoreRef = useRef(false); + const allSessions = useMemo( + () => + mergeSessionItems(sessionState.sessions, extraSessions ?? []).filter( + (session) => !excludeSessionIdSet.has(session.sessionId), + ), + [sessionState.sessions, extraSessions, excludeSessionIdSet], + ); const filteredSessions = useMemo( () => - filterSessions( - sessionState.sessions, - filterByBranch, - currentBranch, - searchQuery, - ), - [sessionState.sessions, filterByBranch, currentBranch, searchQuery], + filterSessions(allSessions, filterByBranch, currentBranch, searchQuery), + [allSessions, filterByBranch, currentBranch, searchQuery], + ); + const selectedIndex = useMemo( + () => getSelectedSessionIndex(filteredSessions, selectedSessionId), + [filteredSessions, selectedSessionId], ); const scrollOffset = useMemo(() => { @@ -301,19 +316,30 @@ export function useSessionPicker({ // Reset selection when any filter changes (branch toggle or text query). useEffect(() => { - setSelectedIndex(0); + selectedSessionIdRef.current = undefined; + setSelectedSessionId(undefined); setFollowScrollOffset(0); }, [filterByBranch, searchQuery]); - // Ensure selectedIndex is valid when filtered sessions change + // Anchor the cursor to a session id so async merges and mtime re-sorts do + // not move a different row under the user's selection. useEffect(() => { + if (filteredSessions.length === 0) { + selectedSessionIdRef.current = undefined; + setSelectedSessionId(undefined); + return; + } if ( - selectedIndex >= filteredSessions.length && - filteredSessions.length > 0 + !selectedSessionId || + !filteredSessions.some( + (session) => session.sessionId === selectedSessionId, + ) ) { - setSelectedIndex(filteredSessions.length - 1); + const firstSessionId = filteredSessions[0]?.sessionId; + selectedSessionIdRef.current = firstSessionId; + setSelectedSessionId(firstSessionId); } - }, [filteredSessions.length, selectedIndex]); + }, [filteredSessions, selectedSessionId]); // Auto-load more when centered mode hits the sentinel or list is empty. useEffect(() => { @@ -351,33 +377,32 @@ export function useSessionPicker({ // about — share the early-return so a future tweak in either // branch can't drift past length 0. if (filteredSessions.length === 0) return; - if (delta === -1) { - setSelectedIndex((prev) => { - const newIndex = Math.max(0, prev - 1); - if (!centerSelection && newIndex < followScrollOffset) { - setFollowScrollOffset(newIndex); - } - return newIndex; - }); - return; + const currentIndex = getSelectedSessionIndex( + filteredSessions, + selectedSessionIdRef.current, + ); + const newIndex = Math.min( + filteredSessions.length - 1, + Math.max(0, currentIndex + delta), + ); + if (!centerSelection && newIndex < followScrollOffset) { + setFollowScrollOffset(newIndex); + } else if ( + !centerSelection && + newIndex >= followScrollOffset + maxVisibleItems + ) { + setFollowScrollOffset(newIndex - maxVisibleItems + 1); } - setSelectedIndex((prev) => { - const newIndex = Math.min(filteredSessions.length - 1, prev + 1); - if ( - !centerSelection && - newIndex >= followScrollOffset + maxVisibleItems - ) { - setFollowScrollOffset(newIndex - maxVisibleItems + 1); - } - if (!centerSelection && newIndex >= filteredSessions.length - 3) { - void loadMoreSessions(); - } - return newIndex; - }); + if (!centerSelection && newIndex >= filteredSessions.length - 3) { + void loadMoreSessions(); + } + const nextSessionId = filteredSessions[newIndex]?.sessionId; + selectedSessionIdRef.current = nextSessionId; + setSelectedSessionId(nextSessionId); }, [ centerSelection, - filteredSessions.length, + filteredSessions, followScrollOffset, loadMoreSessions, maxVisibleItems, @@ -390,6 +415,10 @@ export function useSessionPicker({ // callback only runs in list/search modes — no inline guard // needed. const { name, sequence, ctrl } = key; + const currentSelectedIndex = getSelectedSessionIndex( + filteredSessions, + selectedSessionIdRef.current, + ); if (ctrl && name === 'c') { onCancel(); @@ -416,7 +445,7 @@ export function useSessionPicker({ // Order by the full session list so the receiver can present // "Deleted N sessions" feedback in display order, even for // items that were filtered out at commit time. - const orderedIds = sessionState.sessions + const orderedIds = allSessions .map((s) => s.sessionId) .filter((id) => checkedIds.has(id) && !disabledIdSet.has(id)); if (orderedIds.length > 0) { @@ -429,12 +458,12 @@ export function useSessionPicker({ // footer's "N selected" hint promised. return; } - const session = filteredSessions[selectedIndex]; + const session = filteredSessions[currentSelectedIndex]; // Disabled rows render dimmed with a "cannot delete" hint; honor // that here so a stray Enter on the active session doesn't close // the dialog and leave the receiver to bounce back with an error. if (session && !disabledIdSet.has(session.sessionId)) { - onSelect(session.sessionId); + onSelect(session.sessionId, session); } return; } @@ -459,7 +488,7 @@ export function useSessionPicker({ if ( delta === -1 && filteredSessions.length > 0 && - selectedIndex === 0 + currentSelectedIndex === 0 ) { setViewMode('search'); return; @@ -507,14 +536,14 @@ export function useSessionPicker({ if (name === 'space') { // The constructor invariant ensures at most one of these is on. if (enableMultiSelect) { - const session = filteredSessions[selectedIndex]; + const session = filteredSessions[currentSelectedIndex]; if (session) { toggleChecked(session.sessionId); } return; } if (enablePreview) { - const session = filteredSessions[selectedIndex]; + const session = filteredSessions[currentSelectedIndex]; if (session) { setPreviewSessionId(session.sessionId); setViewMode('preview'); @@ -570,3 +599,64 @@ export function useSessionPicker({ isSearchActive: viewMode === 'search', }; } + +function getSelectedSessionIndex( + sessions: SessionListItem[], + sessionId: string | undefined, +): number { + if (sessions.length === 0) return 0; + const index = sessions.findIndex( + (session) => session.sessionId === sessionId, + ); + return index >= 0 ? index : 0; +} + +function mergeSessionItems( + primary: SessionListItem[], + extra: SessionListItem[], +): SessionListItem[] { + if (extra.length === 0) return primary; + const byId = new Map(primary.map((session) => [session.sessionId, session])); + for (const session of extra) { + const existing = byId.get(session.sessionId); + byId.set( + session.sessionId, + existing ? mergeSessionItem(existing, session) : session, + ); + } + return Array.from(byId.values()).sort( + (left, right) => right.mtime - left.mtime, + ); +} + +function mergeSessionItem( + existing: SessionListItem, + incoming: SessionListItem, +): SessionListItem { + const manualTitleItem = + existing.titleSource === 'manual' + ? existing + : incoming.titleSource === 'manual' + ? incoming + : undefined; + const merged = { + ...existing, + ...incoming, + prompt: existing.prompt || incoming.prompt, + customTitle: + manualTitleItem?.customTitle ?? + existing.customTitle ?? + incoming.customTitle, + titleSource: + manualTitleItem?.titleSource ?? + existing.titleSource ?? + incoming.titleSource, + gitBranch: existing.gitBranch ?? incoming.gitBranch, + filePath: existing.filePath || incoming.filePath, + messageCount: existing.messageCount ?? incoming.messageCount, + parentSessionId: existing.parentSessionId ?? incoming.parentSessionId, + isArchived: existing.isArchived ?? incoming.isArchived, + mtime: Math.max(existing.mtime, incoming.mtime), + }; + return merged; +} diff --git a/packages/cli/src/ui/utils/textUtils.test.ts b/packages/cli/src/ui/utils/textUtils.test.ts index addfd4fab4a..13a4b103055 100644 --- a/packages/cli/src/ui/utils/textUtils.test.ts +++ b/packages/cli/src/ui/utils/textUtils.test.ts @@ -10,15 +10,33 @@ import type { ToolEditConfirmationDetails, } from '@qwen-code/qwen-code-core'; import { + cleanSingleLineText, escapeAnsiCtrlCodes, sanitizeFilenameForDisplay, sanitizeMultilineForDisplay, sanitizeSensitiveText, sliceTextByVisualHeight, + stripUnsafeCharacters, truncateToWidth, } from './textUtils.js'; describe('textUtils', () => { + describe('cleanSingleLineText', () => { + it('strips terminal controls and flattens whitespace', () => { + expect( + cleanSingleLineText('\u001b]0;spoof\u0007first\nsecond\tline'), + ).toBe('first second line'); + }); + + it('strips Unicode bidi controls from terminal text', () => { + const text = + 'a\u200eb\u200fc\u202ad\u202be\u202cf\u202dg\u202eh\u2066i\u2067j\u2068k\u2069l'; + + expect(stripUnsafeCharacters(text)).toBe('abcdefghijkl'); + expect(cleanSingleLineText(text)).toBe('abcdefghijkl'); + }); + }); + describe('sliceTextByVisualHeight', () => { it('returns the original text when maxHeight is undefined', () => { const sliced = sliceTextByVisualHeight('a\nb\nc', undefined, 10); diff --git a/packages/cli/src/ui/utils/textUtils.ts b/packages/cli/src/ui/utils/textUtils.ts index bbcff6e35eb..19849c882d8 100644 --- a/packages/cli/src/ui/utils/textUtils.ts +++ b/packages/cli/src/ui/utils/textUtils.ts @@ -77,6 +77,10 @@ export function cpSlice(str: string, start: number, end?: number): string { return arr.join(''); } +// Unicode bidirectional override / isolate characters (the "Trojan Source" +// attack class, CVE-2021-42572) that can visually reorder rendered text. +const BIDI_OVERRIDE_CHARS_REGEX = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; + /** * Strip characters that can break terminal rendering. * @@ -88,9 +92,10 @@ export function cpSlice(str: string, start: number, end?: number): string { * - VT control sequences (via Node.js util.stripVTControlCharacters) * - C0 control chars (0x00-0x1F) except TAB/CR/LF which are handled elsewhere * - C1 control chars (0x80-0x9F) that can cause display issues + * - Unicode bidirectional override and isolate characters * * Characters preserved: - * - All printable Unicode including emojis + * - Printable Unicode including emojis, except bidirectional controls * - DEL (0x7F) - handled functionally by applyOperations, not a display issue * - TAB (0x09) - needed for pasted tab-separated data (e.g. from spreadsheets) * - CR/LF (0x0D/0x0A) - needed for line breaks @@ -120,7 +125,12 @@ export function stripUnsafeCharacters(str: string): string { // Preserve all other characters including Unicode/emojis return true; }) - .join(''); + .join('') + .replace(BIDI_OVERRIDE_CHARS_REGEX, ''); +} + +export function cleanSingleLineText(str: string): string { + return stripUnsafeCharacters(str).replace(/\s+/g, ' ').trim(); } // String width caching for performance optimization @@ -291,10 +301,6 @@ const regex = ansiRegex(); // eslint-disable-next-line no-control-regex const BARE_C0_CONTROL_CHARS_REGEX = /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g; -// Unicode bidirectional override / isolate characters (the "Trojan Source" -// attack class, CVE-2021-42572) that can visually reorder rendered text. -const BIDI_OVERRIDE_CHARS_REGEX = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; - /** * Full sanitization for raw, untrusted text about to be rendered into a * terminal `` (e.g. tool output in the Ctrl+O transcript, or a caught diff --git a/packages/cli/src/utils/sandbox.test.ts b/packages/cli/src/utils/sandbox.test.ts index d0139d5029e..5e64ee91d27 100644 --- a/packages/cli/src/utils/sandbox.test.ts +++ b/packages/cli/src/utils/sandbox.test.ts @@ -210,6 +210,29 @@ describe('getSandboxPassthroughEnvArgs', () => { 'QWEN_CODE_DESKTOP=1', ]); }); + + it('forwards Agent View worker identity into container sandboxes', () => { + expect( + getSandboxPassthroughEnvArgs({ + QWEN_AGENT_VIEW_WORKER: '1', + QWEN_AGENT_VIEW_SESSION_ID: 'session-1', + QWEN_AGENT_VIEW_SIDEBAND: '/tmp/sideband.sock', + QWEN_AGENT_VIEW_TOKEN: 'token', + QWEN_AGENT_VIEW_ACTIVE_CWD: '/project', + }), + ).toEqual([ + '--env', + 'QWEN_AGENT_VIEW_WORKER=1', + '--env', + 'QWEN_AGENT_VIEW_SESSION_ID=session-1', + '--env', + 'QWEN_AGENT_VIEW_SIDEBAND=/tmp/sideband.sock', + '--env', + 'QWEN_AGENT_VIEW_TOKEN=token', + '--env', + 'QWEN_AGENT_VIEW_ACTIVE_CWD=/project', + ]); + }); }); describe('isContainerPathWithinWorkdir', () => { diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts index 706ef1dec42..d5d5ec7c257 100644 --- a/packages/cli/src/utils/sandbox.ts +++ b/packages/cli/src/utils/sandbox.ts @@ -23,6 +23,7 @@ import { } from '@qwen-code/qwen-code-core'; import { randomBytes } from 'node:crypto'; import { writeStderrLine } from './stdioHelpers.js'; +import { AGENT_VIEW_WORKER_ENV_KEYS } from '../agent-view/worker-sideband.js'; import { parseSandboxImageName } from './sandboxImageName.js'; import { isContainerPathWithinWorkdir } from './sandbox-path.js'; import { parseSandboxMountSpec } from './sandboxMounts.js'; @@ -81,6 +82,10 @@ export function getSandboxPassthroughEnvArgs( HOST_UPDATE_RELAUNCH_ENV_VAR, QWEN_CODE_SERVE_ENV, QWEN_CODE_DESKTOP_ENV, + // Agent View worker identity: startup routing, the resume/continue + // guards and the sideband ready/heartbeat all run after the container + // hop and read these keys, so a managed worker mis-starts without them. + ...AGENT_VIEW_WORKER_ENV_KEYS, ].flatMap((envVar) => env[envVar] === undefined ? [] : ['--env', `${envVar}=${env[envVar]}`], ); diff --git a/packages/cli/src/utils/stdioHelpers.ts b/packages/cli/src/utils/stdioHelpers.ts index 587837945b0..7762bc3eb37 100644 --- a/packages/cli/src/utils/stdioHelpers.ts +++ b/packages/cli/src/utils/stdioHelpers.ts @@ -66,6 +66,42 @@ export const writeStderrLineSafe = (message: string): void => { } }; +/** + * Wait until any pending stdout/stderr writes have flushed. + * + * On POSIX pipes `process.stdout.write` flushes asynchronously, so a + * `process.exit()` right after writing silently discards buffered output + * (beyond the ~80KB pipe buffer). Call this before a deliberate early exit + * that follows user-facing writes (e.g. `qwen agents` subcommands, `--bg`). + * + * A pipe consumer that exits early (`qwen agents logs | head`) turns + * the queued writes into EPIPE errors, and one that holds the pipe open + * without reading would block the drain forever — so errors settle the + * drain immediately and a timeout caps the wait. + */ +export const drainStdioBeforeExit = (timeoutMs = 5000): Promise => + new Promise((resolve) => { + let settled = false; + const settle = (): void => { + if (!settled) { + settled = true; + resolve(); + } + }; + const onError = (): void => settle(); + // Persistent listeners: Node emits stream errors asynchronously, so an + // EPIPE from a queued write can arrive after a successful drain removed + // one-shot listeners, crashing the process with an unhandled 'error'. + // The process exits right after the drain, so keeping them is harmless. + process.stdout.on('error', onError); + process.stderr.on('error', onError); + const timer = setTimeout(settle, timeoutMs); + timer.unref?.(); + process.stdout.write('', () => { + process.stderr.write('', settle); + }); + }); + /** * Clears the terminal screen. * Use instead of console.clear() to satisfy no-console lint rules. diff --git a/packages/core/src/config/config.test.ts b/packages/core/src/config/config.test.ts index cc95c2d53e1..07f5d3ecf9b 100644 --- a/packages/core/src/config/config.test.ts +++ b/packages/core/src/config/config.test.ts @@ -3933,6 +3933,16 @@ describe('Server Config (config.ts)', () => { expect(registeredNames).not.toContain(ToolNames.LOOP_WAKEUP); }); + it('keeps Agent View opt-in', () => { + expect(new Config({ ...baseParams }).isAgentViewEnabled()).toBe(false); + expect( + new Config({ + ...baseParams, + agentViewEnabled: true, + }).isAgentViewEnabled(), + ).toBe(true); + }); + it('registers read_mcp_resource so the model can read MCP resources', async () => { const config = new Config({ ...baseParams }); await config.initialize(); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 9698a8e478a..4e8ff736575 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -1123,6 +1123,7 @@ export interface ConfigParameters { clearContextOnIdle?: ClearContextOnIdleSettings; sessionTokenLimit?: number; experimentalZedIntegration?: boolean; + agentViewEnabled?: boolean; sessionWriterLeaseEnabled?: boolean; cronEnabled?: boolean; /** @@ -2017,6 +2018,7 @@ export class Config { private readonly cliVersion?: string; private runtimeStatusEnabled = false; private readonly experimentalZedIntegration: boolean = false; + private readonly agentViewEnabled: boolean = false; private readonly sessionWriterLeaseEnabled: boolean = false; private readonly cronEnabled: boolean = true; /** Recurring cron max age in days, resolved once at construction @@ -2299,6 +2301,7 @@ export class Config { this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.experimentalZedIntegration = params.experimentalZedIntegration ?? false; + this.agentViewEnabled = params.agentViewEnabled ?? false; this.sessionWriterLeaseEnabled = this.experimentalZedIntegration === true && params.sessionWriterLeaseEnabled === true; @@ -6771,6 +6774,10 @@ export class Config { return this.cronEnabled; } + isAgentViewEnabled(): boolean { + return this.agentViewEnabled; + } + isAgentTeamEnabled(): boolean { // Agent team is experimental and opt-in: enabled via settings or env var if (process.env['QWEN_CODE_ENABLE_AGENT_TEAM'] === '1') return true; diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index dc8c17b0a4c..289e24a27e0 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -189,6 +189,7 @@ export interface WorktreeSetupConfig { export interface CreateWorktreeResult { success: boolean; worktree?: WorktreeInfo; + createdSymlinkPaths?: string[]; error?: string; } @@ -1598,14 +1599,16 @@ export class GitWorktreeService { // run tests / builds without a fresh install. Same fail-open // policy as hooksPath — failures log and continue. const symlinkPaths = options?.symlinkDirectories ?? []; + let createdSymlinkPaths: string[] = []; if (symlinkPaths.length > 0) { - await this.symlinkConfiguredDirectories( + createdSymlinkPaths = await this.symlinkConfiguredDirectories( worktreePath, symlinkPaths, ).catch((error) => { debugLogger.warn( `createUserWorktree: symlinkConfiguredDirectories failed for ${slug}: ${error}`, ); + return []; }); } @@ -1617,7 +1620,7 @@ export class GitWorktreeService { isActive: true, createdAt: Date.now(), }; - return { success: true, worktree }; + return { success: true, worktree, createdSymlinkPaths }; } catch (error) { const message = `Failed to create worktree "${slug}": ${error instanceof Error ? error.message : 'Unknown error'}`; debugLogger.warn(`createUserWorktree: ${message}`); @@ -1739,7 +1742,7 @@ export class GitWorktreeService { private async symlinkConfiguredDirectories( worktreePath: string, configured: readonly string[], - ): Promise { + ): Promise { // Loop-invariant canonical paths, hoisted out of the per-entry loop. // // We must `fs.realpath` the repo root (rather than `path.resolve`, @@ -1764,7 +1767,7 @@ export class GitWorktreeService { debugLogger.warn( `symlinkConfiguredDirectories: cannot realpath sourceRepoPath "${this.sourceRepoPath}", skipping all entries`, ); - return; + return []; } const gitDirAbs = path.join(repoRootAbs, '.git'); const qwenDirAbs = path.join(repoRootAbs, '.qwen'); @@ -1776,6 +1779,7 @@ export class GitWorktreeService { .realpath(worktreePath) .catch(() => worktreePath); + const createdPaths: string[] = []; for (const raw of configured) { if (typeof raw !== 'string' || raw.length === 0) { debugLogger.warn( @@ -1966,6 +1970,7 @@ export class GitWorktreeService { // `sourceAbs` so the new link is one-hop and doesn't preserve // the chain we just validated. await fs.symlink(realSource, destAbs, symlinkType); + createdPaths.push(raw); debugLogger.debug( `symlinkConfiguredDirectories: linked ${destAbs} → ${realSource} (${symlinkType})`, ); @@ -1981,6 +1986,7 @@ export class GitWorktreeService { } } } + return createdPaths; } /** @@ -2178,11 +2184,26 @@ export class GitWorktreeService { * Fail-closed: returns `true` on any git error so the caller assumes the * worktree is dirty rather than risking data loss. */ - async hasWorktreeChanges(worktreePath: string): Promise { + async hasWorktreeChanges( + worktreePath: string, + ignoredPaths: readonly string[] = [], + ): Promise { try { const { simpleGit } = await loadSimpleGit(); const wtGit = simpleGit(worktreePath); - const status = await wtGit.status(); + const status = await wtGit.status( + ignoredPaths.length > 0 + ? [ + '--untracked-files=all', + '--', + '.', + ...ignoredPaths.map( + (entry) => + `:(exclude,literal)${entry.replaceAll(path.sep, '/')}`, + ), + ] + : undefined, + ); // Defensive: `status.isClean()` reads several status arrays, but // we OR with `conflicted.length` explicitly so future simple-git // versions that change the bookkeeping cannot silently let a diff --git a/packages/core/src/utils/ripgrepUtils.ts b/packages/core/src/utils/ripgrepUtils.ts index c02215905b5..ef85c4698de 100644 --- a/packages/core/src/utils/ripgrepUtils.ts +++ b/packages/core/src/utils/ripgrepUtils.ts @@ -10,6 +10,8 @@ import { execFile } from 'node:child_process'; import type { ChildProcess } from 'node:child_process'; import { resolveBundleDir } from './bundlePaths.js'; import { fileExists } from './fileUtils.js'; +import { sanitizeChildEnv } from './sanitize-child-env.js'; +import { normalizePathEnvForWindows } from './windowsPath.js'; import { execCommand, isCommandAvailable } from './shell-utils.js'; import { createDebugLogger } from './debugLogger.js'; @@ -233,6 +235,9 @@ export async function ensureRipgrepHealthy( ['--version'], { timeout: RIPGREP_TEST_TIMEOUT_MS, + // Same env scrub as the search invocation itself, so the health + // probe never bypasses the child-env sanitization. + env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)), }, ); probeOutput = stdout; @@ -496,6 +501,9 @@ async function runRipgrepOnce( maxBuffer: RIPGREP_BUFFER_LIMIT, timeout: wslTimeout(), signal, + // Agent-reachable spawn: scrub Qwen-internal secrets (including + // the Agent View worker identity) from the inherited env. + env: normalizePathEnvForWindows(sanitizeChildEnv(process.env)), }, (error, stdout = '', stderr = '') => { const stdoutText = stdout.toString(); diff --git a/packages/core/src/utils/sanitize-child-env.test.ts b/packages/core/src/utils/sanitize-child-env.test.ts index a311e5bf6a8..99b47e3fd43 100644 --- a/packages/core/src/utils/sanitize-child-env.test.ts +++ b/packages/core/src/utils/sanitize-child-env.test.ts @@ -16,11 +16,15 @@ describe('sanitizeChildEnv', () => { QWEN_SERVER_TOKEN: 'super-secret', QWEN_DAEMON_TOKEN: 'also-secret', QWEN_CODE_PRIVATE_ACP_CAPABILITY: 'private-capability', + QWEN_AGENT_VIEW_PTY_HOST_TOKEN: 'host-token', + QWEN_AGENT_VIEW_PTY_HOST_ID: 'host-id', PATH: '/usr/bin', }); expect(result['QWEN_SERVER_TOKEN']).toBeUndefined(); expect(result['QWEN_DAEMON_TOKEN']).toBeUndefined(); expect(result['QWEN_CODE_PRIVATE_ACP_CAPABILITY']).toBeUndefined(); + expect(result['QWEN_AGENT_VIEW_PTY_HOST_TOKEN']).toBeUndefined(); + expect(result['QWEN_AGENT_VIEW_PTY_HOST_ID']).toBeUndefined(); }); it('preserves benign vars and third-party credentials that shell workflows need', () => { @@ -67,6 +71,14 @@ describe('sanitizeChildEnv', () => { // Guardrail: this list must not grow to include third-party credentials, // which the shell tool legitimately inherits (see #6601 discussion). expect([...INTERNAL_SECRET_ENV_VARS].sort()).toEqual([ + 'QWEN_AGENT_VIEW_ACTIVE_CWD', + 'QWEN_AGENT_VIEW_PTY_HOST_ID', + 'QWEN_AGENT_VIEW_PTY_HOST_TOKEN', + 'QWEN_AGENT_VIEW_SESSION_ID', + 'QWEN_AGENT_VIEW_SIDEBAND', + 'QWEN_AGENT_VIEW_SUPERVISOR', + 'QWEN_AGENT_VIEW_TOKEN', + 'QWEN_AGENT_VIEW_WORKER', 'QWEN_CODE_PRIVATE_ACP_CAPABILITY', 'QWEN_DAEMON_TOKEN', 'QWEN_SERVER_TOKEN', diff --git a/packages/core/src/utils/sanitize-child-env.ts b/packages/core/src/utils/sanitize-child-env.ts index 6bacbd1173f..2d2b24b715e 100644 --- a/packages/core/src/utils/sanitize-child-env.ts +++ b/packages/core/src/utils/sanitize-child-env.ts @@ -16,8 +16,11 @@ import { PRIVATE_ACP_CAPABILITY_ENV } from './invocation-context.js'; * `QWEN_SERVER_TOKEN` is the serve-daemon bearer token, `QWEN_DAEMON_TOKEN` * is the channel-daemon worker token, and * `QWEN_CODE_PRIVATE_ACP_CAPABILITY` authenticates the daemon-spawned ACP - * child. Their direct consumers already scrub them from `process.env` after - * reading them. + * child. The `QWEN_AGENT_VIEW_*` keys carry Agent View worker or PTY-host + * identity and authentication data; an agent-run child inheriting them could + * impersonate an internal process. The worker re-reads its sideband keys for + * its whole lifetime, so this denylist is their child-process isolation + * boundary. * * This denylist is intentionally NARROW: it strips only Qwen-internal secrets, * NOT third-party credentials such as `GH_TOKEN`, `AWS_*`, or `NPM_TOKEN`. @@ -29,6 +32,17 @@ import { PRIVATE_ACP_CAPABILITY_ENV } from './invocation-context.js'; export const INTERNAL_SECRET_ENV_VARS: readonly string[] = [ 'QWEN_SERVER_TOKEN', 'QWEN_DAEMON_TOKEN', + 'QWEN_AGENT_VIEW_WORKER', + 'QWEN_AGENT_VIEW_SESSION_ID', + 'QWEN_AGENT_VIEW_SIDEBAND', + 'QWEN_AGENT_VIEW_TOKEN', + 'QWEN_AGENT_VIEW_ACTIVE_CWD', + // The Agent View supervisor startup-gate marker: an agent-run child that + // inherits it could re-enter supervisor mode when its argv mentions the + // internal flag, so it never leaves the daemon process tree. + 'QWEN_AGENT_VIEW_SUPERVISOR', + 'QWEN_AGENT_VIEW_PTY_HOST_TOKEN', + 'QWEN_AGENT_VIEW_PTY_HOST_ID', PRIVATE_ACP_CAPABILITY_ENV, ]; diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 95befe03305..a82b2178744 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -3377,6 +3377,11 @@ "description": "Settings to enable experimental features.", "type": "object", "properties": { + "agentView": { + "description": "Enable Agent View background sessions.", + "type": "boolean", + "default": false + }, "liveVoice": { "description": "Experimental realtime voice conversations through Qwen Live Host on macOS WebShell.", "type": "object",