diff --git a/src/agent-pty.ts b/src/agent-pty.ts index ffdaee6..8a1472c 100644 --- a/src/agent-pty.ts +++ b/src/agent-pty.ts @@ -134,6 +134,13 @@ export interface AgentTerminalSnapshot { exitCode?: number | null /** Exit signal name if the process was killed by a signal; null otherwise. */ exitSignal?: string | null + + /** + * The model's active `terminal_wait_for` on this terminal (the sidebar + * renders the wait banner from it). Present only while a wait is + * registered; carries the LATEST wait when several overlap. + */ + waiting?: { needle: string; since: number } } /** Map a POSIX signal number to its conventional name (best-effort). */ @@ -223,6 +230,8 @@ export interface AgentTerminalHandle { exitCode?: number | null /** Exit signal number once known (POSIX only; undefined on Windows). */ exitSignal?: number | null + /** Active wait_for registrations (skip bookkeeping; empty while idle). */ + waits: AgentTerminalActiveWait[] } /** Read result shape (mirrors the official tool-pty terminal_read contract). */ @@ -237,6 +246,16 @@ export interface AgentTerminalReadResult { lineEnd: number } +/** One active wait_for registration on a handle (banner + skip bookkeeping). */ +export interface AgentTerminalActiveWait { + /** The needle being awaited (shown on the sidebar wait banner). */ + needle: string + /** Epoch ms when the wait registered (age display / debugging). */ + since: number + /** Flipped by `skipWait()`; the waiting poll returns `skipped` within one tick. */ + skipped: boolean +} + /** Outcome of {@link AgentPtyRegistry.waitFor}. */ export type AgentTerminalWaitResult = | { @@ -276,6 +295,12 @@ export type AgentTerminalWaitResult = /** The exit signal name, if the process was killed by a signal. */ exitSignal?: string | null } + | { + /** The user skipped the wait from the sidebar banner. */ + kind: 'skipped' + /** The needle that was awaited. */ + needle: string + } /** Snapshot projection of a handle (drops the pty reference and transcript). */ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { @@ -289,6 +314,8 @@ export function snapshotOf(handle: AgentTerminalHandle): AgentTerminalSnapshot { out.exitCode = handle.exitCode ?? null out.exitSignal = signalNameOf(handle.exitSignal) } + const active = handle.waits.at(-1) + if (active !== undefined) out.waiting = { needle: active.needle, since: active.since } return out } @@ -349,6 +376,7 @@ export class AgentPtyRegistry { pty, transcript: '', exited: false, + waits: [], } pty.onData((data) => { handle.transcript += data @@ -503,7 +531,8 @@ export class AgentPtyRegistry { * can tell which outcome hit. * @param timeoutMs - max wait; default 10000 (10s). Clamped to ≥100ms. * @param signal - caller-owned cancellation; aborts the wait re-throwing. - * @returns one of `found` / `timeout` / `exited`. + * A wait can also be skipped by the user from the sidebar banner (`skipWait`), which resolves it with `{kind:'skipped'}`. + * @returns one of `found` / `timeout` / `exited` / `skipped`. */ async waitFor( uuid: string, @@ -531,29 +560,62 @@ export class AgentPtyRegistry { if (firstHit !== undefined) { return { kind: 'found', needle, line: firstHit.line, column: firstHit.column, match: firstHit.match, elapsedMs: Date.now() - start } } - // Poll loop: check the transcript every 50ms, exit on match / exit / - // abort / timeout. The handle is read live each iteration (its transcript - // and exited fields mutate as the pty produces output). - while (true) { - if (signal?.aborted) signal.throwIfAborted() - if (handle.exited) { - return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } + // Register the active wait so the sidebar can show the wait banner (the + // snapshot's `waiting` field rides the agent-terminals push). Registered + // only after the fast paths: a wait that resolves instantly never + // flashes the banner. The notify() makes every push subscriber (the + // sidebar view) converge on the new waiting state immediately. + const record: AgentTerminalActiveWait = { needle, since: start, skipped: false } + handle.waits.push(record) + this.notify() + try { + while (true) { + if (signal?.aborted) signal.throwIfAborted() + if (handle.exited) { + return { kind: 'exited', needle, exitCode: handle.exitCode ?? null, exitSignal: signalNameOf(handle.exitSignal) } + } + if (record.skipped) { + return { kind: 'skipped', needle } + } + const hit = locateNeedle(handle.transcript, needle, re) + if (hit !== undefined) { + return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start } + } + if (Date.now() >= deadline) { + return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length } + } + await new Promise(resolve => { + const t = setTimeout(resolve, 50) + // Allow the Node process to exit even if the timer is pending. + if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref() + }) } - const hit = locateNeedle(handle.transcript, needle, re) - if (hit !== undefined) { - return { kind: 'found', needle, line: hit.line, column: hit.column, match: hit.match, elapsedMs: Date.now() - start } - } - if (Date.now() >= deadline) { - return { kind: 'timeout', needle, timeoutMs: timeout, totalLines: handle.transcript.split('\n').length } - } - await new Promise(resolve => { - const t = setTimeout(resolve, 50) - // Allow the Node process to exit even if the timer is pending. - if (typeof t === 'object' && 'unref' in t) (t as { unref: () => void }).unref() - }) + } finally { + const index = handle.waits.indexOf(record) + if (index !== -1) handle.waits.splice(index, 1) + this.notify() } } + /** + * Mark every active wait on one terminal as skipped (the sidebar banner's + * skip button). Each waiting poll loop observes its record's flag within + * one 50ms tick and returns `{kind:'skipped'}`. Idempotent: 0 when nothing + * is waiting (a stale banner racing a wait that already resolved). + * @returns the number of waits that transitioned to skipped. + */ + skipWait(uuid: string): number { + const handle = this.expect(uuid) + let count = 0 + for (const record of handle.waits) { + if (!record.skipped) { + record.skipped = true + count += 1 + } + } + return count + } + /** * Send a POSIX signal to a terminal's foreground process. * diff --git a/tests/agent-pty.spec.ts b/tests/agent-pty.spec.ts index b5b67ec..6e4bdf4 100644 --- a/tests/agent-pty.spec.ts +++ b/tests/agent-pty.spec.ts @@ -393,6 +393,71 @@ describe('AgentPtyRegistry', () => { await expect(registry.waitFor('nonexistent-uuid', 'foo', 500)).rejects.toThrow() }) + it('waitFor returns skipped when the user skips from the sidebar', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'skip-test', 'echo skip-ready', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'skip-ready') + // waitFor registers its record synchronously (before the first poll + // await), so the skip can fire immediately after the call. + const waitPromise = registry.waitFor(uuid, 'NEVER_APPEARS_XYZ', 30_000) + expect(await registry.skipWait(uuid)).toBe(1) + const result = await waitPromise + expect(result.kind).toBe('skipped') + if (result.kind === 'skipped') expect(result.needle).toBe('NEVER_APPEARS_XYZ') + // Idempotent: nothing left to skip once the wait resolved. + expect(registry.skipWait(uuid)).toBe(0) + } finally { + registry.disposeAll() + } + }) + + it('snapshot exposes waiting while a wait is active and clears after it ends', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'wait-snap', 'echo snap-ready', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'snap-ready') + const waitPromise = registry.waitFor(uuid, 'LATER_MARK_9', 30_000) + // Registration happens synchronously before waitFor's first await. + expect(registry.list('s1')[0]?.waiting?.needle).toBe('LATER_MARK_9') + expect(typeof registry.list('s1')[0]?.waiting?.since).toBe('number') + expect(registry.skipWait(uuid)).toBe(1) + const result = await waitPromise + expect(result.kind).toBe('skipped') + expect(registry.list('s1')[0]?.waiting).toBeUndefined() + } finally { + registry.disposeAll() + } + }) + + it('fires change listeners when a wait starts and ends', async () => { + const registry = new AgentPtyRegistry(testShell()) + try { + const uuid = registry.create('s1', 'watched-wait', 'echo notify-ready', process.cwd(), 80, 24) + await waitForTranscript(registry, uuid, 'notify-ready') + let changes = 0 + const unsubscribe = registry.subscribe(() => { changes += 1 }) + const waitPromise = registry.waitFor(uuid, 'NEVER_NOTIFY_1', 30_000) + const afterStart = changes + expect(afterStart).toBeGreaterThanOrEqual(1) + expect(await registry.skipWait(uuid)).toBe(1) + expect(await waitPromise).toEqual({ kind: 'skipped', needle: 'NEVER_NOTIFY_1' }) + expect(changes).toBeGreaterThan(afterStart) + unsubscribe() + } finally { + registry.disposeAll() + } + }) + + it('skipWait rejects an unknown uuid with not-found', () => { + const registry = new AgentPtyRegistry(testShell()) + try { + expect(() => registry.skipWait('missing-uuid')).toThrow(/not found/) + } finally { + registry.disposeAll() + } + }) + it('delivers SIGINT and SIGTSTP by writing control characters (cross-platform)', () => { const registry = new AgentPtyRegistry(testShell()) try {