diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 43412b30..b1936eb1 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -215,6 +215,13 @@ export type AgentConfig = { orchestrator?: import('@lib/agent/runner/sequence/orchestrator/queue-tools').OrchestratorToolsContext; }; +export type AgentNudge = { id: number; message: string }; + +export type AgentNudgeSource = ( + afterId?: number, + signal?: AbortSignal, +) => Promise; + /** * Stop hook return type: either allow stop or block with a reason. */ @@ -779,6 +786,13 @@ export async function runAgent( * aborted` events (e.g. the orchestrator's task type and id). */ analyticsProperties?: Record; + /** + * Optional live user guidance channel. TUI implementations resolve + * whenever the operator asks the agent to move on; non-interactive hosts + * omit it. Kept on the execution call, not initializeAgent(), because it is + * an interactive transport concern rather than agent bootstrap config. + */ + waitForAgentNudge?: AgentNudgeSource; }, middleware?: { onMessage(message: any): void; @@ -833,6 +847,30 @@ export async function runAgent( message: { role: 'user', content: prompt }, parent_tool_use_id: null, }; + let lastNudgeId = 0; + while (config?.waitForAgentNudge) { + const nudgeAbort = new AbortController(); + const nextNudge = config.waitForAgentNudge( + lastNudgeId, + nudgeAbort.signal, + ); + const next = await Promise.race([ + resultReceived.then(() => { + nudgeAbort.abort(); + return null; + }), + nextNudge, + ]); + if (!next) return; + lastNudgeId = next.id; + logToFile(`[agent] user nudge sent: ${next.message}`); + yield { + type: 'user', + session_id: '', + message: { role: 'user', content: next.message }, + parent_tool_use_id: null, + }; + } await resultReceived; }; diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 01dcd101..f80c2361 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -42,6 +42,7 @@ export const anthropicBackend: AgentHarness = { askBridge, middleware, model, + waitForAgentNudge, } = inputs; const { skillsBaseUrl, @@ -93,6 +94,7 @@ export const anthropicBackend: AgentHarness = { additionalFeatureQueue: config.additionalFeatureQueue ?? [], abortCases: config.abortCases, emitStepEvents: config.trackStepProgress ?? false, + waitForAgentNudge, }, middleware, ); @@ -115,6 +117,7 @@ export const anthropicBackend: AgentHarness = { additionalFeatureQueue, requestRemark, analyticsProperties, + waitForAgentNudge, } = inputs; const options = sessionToOptions(session); @@ -149,6 +152,7 @@ export const anthropicBackend: AgentHarness = { additionalFeatureQueue, requestRemark, analyticsProperties, + waitForAgentNudge, }, ); }, diff --git a/src/lib/agent/runner/harness/pi/index.ts b/src/lib/agent/runner/harness/pi/index.ts index b7afb44a..cffa288e 100644 --- a/src/lib/agent/runner/harness/pi/index.ts +++ b/src/lib/agent/runner/harness/pi/index.ts @@ -439,8 +439,38 @@ export const piBackend: AgentHarness = { try { // Non-streaming: resolves when the agent run completes. Throws if no - // model/api key, or on a transport error. - await agentSession.prompt(prompt); + // model/api key, or on a transport error. While it runs, forward user + // nudges through pi's steering queue so the shared TUI control has the + // same behavior as the Anthropic prompt stream. + const promptRun = agentSession.prompt(prompt); + const nudgeAbort = new AbortController(); + const nudgeLoop = inputs.waitForAgentNudge + ? (async () => { + let lastNudgeId = 0; + while (inputs.waitForAgentNudge) { + const next = await inputs.waitForAgentNudge( + lastNudgeId, + nudgeAbort.signal, + ); + if (!next) return; + lastNudgeId = next.id; + logToFile(`[pi] user nudge sent: ${next.message}`); + try { + await agentSession.steer(next.message); + } catch (err) { + logToFile(`[pi] user nudge ignored: ${String(err)}`); + } + } + })() + : undefined; + try { + await promptRun; + } finally { + nudgeAbort.abort(); + await nudgeLoop?.catch((err) => { + logToFile(`[pi] nudge loop stopped: ${String(err)}`); + }); + } } finally { unsubscribe(); mcpCleanup?.(); diff --git a/src/lib/agent/runner/harness/types.ts b/src/lib/agent/runner/harness/types.ts index 09480341..12d800e5 100644 --- a/src/lib/agent/runner/harness/types.ts +++ b/src/lib/agent/runner/harness/types.ts @@ -21,7 +21,10 @@ import type { Harness } from '@lib/constants'; import type { ProgramConfig } from '@lib/programs/program-step'; import type { SpinnerHandle } from '@ui'; import type { WizardAskBridge } from '@lib/wizard-ask-bridge'; -import type { AgentErrorType } from '@lib/agent/agent-interface'; +import type { + AgentErrorType, + AgentNudgeSource, +} from '@lib/agent/agent-interface'; import type { OrchestratorToolsContext } from '@lib/agent/runner/sequence/orchestrator/queue-tools'; import type { ProgramRun, @@ -56,6 +59,8 @@ export interface BackendRunInputs { middleware?: RunMiddleware; /** Gateway model id resolved from the (runner, model) pair. */ model: string; + /** Optional live user guidance source for harnesses that support mid-run input. */ + waitForAgentNudge?: AgentNudgeSource; } /** What a runner reports back: an error classification, or nothing on success. */ @@ -91,6 +96,8 @@ export interface TaskRunInputs { requestRemark: boolean; /** Per-call analytics properties merged into `agent completed` / `agent aborted` events. */ analyticsProperties: Record; + /** Optional live user guidance source for harnesses that support mid-run input. */ + waitForAgentNudge?: AgentNudgeSource; } /** A drop-in agent runner: consumes a fully-assembled run, returns a result. */ diff --git a/src/lib/agent/runner/sequence/linear.ts b/src/lib/agent/runner/sequence/linear.ts index d851a210..d0aacdbd 100644 --- a/src/lib/agent/runner/sequence/linear.ts +++ b/src/lib/agent/runner/sequence/linear.ts @@ -142,6 +142,8 @@ export async function runLinearProgram( askBridge, middleware, model: pick.model, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), }); // 9. Error handling (full set from both runners) diff --git a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts index b735d2e4..9ee1e995 100644 --- a/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts +++ b/src/lib/agent/runner/sequence/orchestrator/orchestrator-runner.ts @@ -292,6 +292,8 @@ export async function runOrchestrator( additionalFeatureQueue: [], requestRemark: false, analyticsProperties: { task_type: 'seed', harness: seedPick.harness }, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), }); if (seedResult.error) { logToFile( @@ -376,6 +378,8 @@ export async function runOrchestrator( task_id: task.id, harness: taskPick.harness, }, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), }); } finally { renderQueue(); diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index b3206f64..b94ad45c 100644 --- a/src/ui/logging-ui.ts +++ b/src/ui/logging-ui.ts @@ -93,6 +93,16 @@ export class LoggingUI implements WizardUI { console.log(`◇ ${message}`); } + waitForAgentNudge( + _afterId?: number, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null> { + if (signal?.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + signal?.addEventListener('abort', () => resolve(null), { once: true }); + }); + } + setDetectedFramework(label: string): void { console.log(`✔ Framework: ${label}`); } diff --git a/src/ui/tui/__tests__/store.test.ts b/src/ui/tui/__tests__/store.test.ts index a146f0ef..0760b645 100644 --- a/src/ui/tui/__tests__/store.test.ts +++ b/src/ui/tui/__tests__/store.test.ts @@ -794,6 +794,47 @@ describe('WizardStore', () => { }); }); + describe('agent nudges', () => { + it('records a nudge with a monotonic id and status message', () => { + const store = createStore(); + + store.nudgeAgent('Move to the next task.'); + + expect(store.agentNudge).toEqual({ + id: 1, + message: 'Move to the next task.', + }); + expect(store.statusMessages).toEqual(['Sent a nudge to the agent.']); + expect(wizardCaptureMock).toHaveBeenCalledWith( + 'agent nudged', + expect.objectContaining({ program_id: Program.PostHogIntegration }), + ); + }); + + it('waits for the next nudge after the given id', async () => { + const store = createStore(); + store.nudgeAgent('First nudge'); + + const next = store.waitForAgentNudge(1); + store.nudgeAgent('Second nudge'); + + await expect(next).resolves.toEqual({ + id: 2, + message: 'Second nudge', + }); + }); + + it('resolves null when the wait is aborted', async () => { + const store = createStore(); + const controller = new AbortController(); + + const next = store.waitForAgentNudge(0, controller.signal); + controller.abort(); + + await expect(next).resolves.toBeNull(); + }); + }); + describe('tasks', () => { it('setTasks replaces the task list', () => { const store = createStore(); diff --git a/src/ui/tui/ink-ui.ts b/src/ui/tui/ink-ui.ts index 598d66cc..96545743 100644 --- a/src/ui/tui/ink-ui.ts +++ b/src/ui/tui/ink-ui.ts @@ -223,6 +223,13 @@ export class InkUI implements WizardUI { this.store.pushStatus(message); } + waitForAgentNudge( + afterId?: number, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null> { + return this.store.waitForAgentNudge(afterId, signal); + } + syncTodos( todos: Array<{ content: string; status: string; activeForm?: string }>, ): void { diff --git a/src/ui/tui/primitives/TabContainer.tsx b/src/ui/tui/primitives/TabContainer.tsx index 1b5a0ed6..9d113b7b 100644 --- a/src/ui/tui/primitives/TabContainer.tsx +++ b/src/ui/tui/primitives/TabContainer.tsx @@ -33,6 +33,7 @@ interface TabContainerProps { expandableStatus?: boolean; /** Store reference — required when expandableStatus is true so status state is shared. */ store?: WizardStore; + extraBindings?: KeyBinding[]; } export const TabContainer = ({ @@ -40,6 +41,7 @@ export const TabContainer = ({ statusMessage, expandableStatus = false, store, + extraBindings = [], }: TabContainerProps) => { const [activeTab, setActiveTab] = useState(0); // Fallback to local state when no store is provided @@ -78,8 +80,8 @@ export const TabContainer = ({ }, }); } - return b; - }, [tabs.length, expandableStatus, store]); + return [...b, ...extraBindings]; + }, [tabs.length, expandableStatus, store, extraBindings]); useKeyBindings('tab-container', bindings); diff --git a/src/ui/tui/screens/RunScreen.tsx b/src/ui/tui/screens/RunScreen.tsx index da78ce37..6f7e5dec 100644 --- a/src/ui/tui/screens/RunScreen.tsx +++ b/src/ui/tui/screens/RunScreen.tsx @@ -18,6 +18,7 @@ import { EventPlanViewer, HNViewer, } from '@ui/tui/primitives/index'; +import type { KeyBinding } from '@ui/tui/hooks/useKeyBindings'; import type { ProgressItem } from '@ui/tui/primitives/index'; import { ADDITIONAL_FEATURE_LABELS } from '@lib/wizard-session'; import { LearnCard } from '@ui/tui/components/LearnCard'; @@ -98,6 +99,18 @@ export const RunScreen = ({ store }: RunScreenProps) => { // Program-supplied tips for the right pane; undefined falls back to // DEFAULT_TIPS inside TipsCard, so non-self-driving programs are unaffected. const programTips = getProgramConfig(activeProgram).getTips?.(store); + const nudgeBindings = useMemo( + () => [ + { + match: 'n', + label: 'n', + action: 'nudge agent', + priority: 20, + handler: () => store.nudgeAgent(), + }, + ], + [store], + ); const leftPane = store.learnCardComplete ? ( @@ -145,6 +158,7 @@ export const RunScreen = ({ store }: RunScreenProps) => { tabs={tabs} statusMessage={statuses} expandableStatus + extraBindings={nudgeBindings} store={store} /> ); diff --git a/src/ui/tui/screens/audit/AuditRunScreen.tsx b/src/ui/tui/screens/audit/AuditRunScreen.tsx index 1f5e22b7..436e5fde 100644 --- a/src/ui/tui/screens/audit/AuditRunScreen.tsx +++ b/src/ui/tui/screens/audit/AuditRunScreen.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore } from 'react'; +import { useMemo, useSyncExternalStore } from 'react'; import { join } from 'node:path'; import { Box } from 'ink'; import type { WizardStore } from '@ui/tui/store'; @@ -8,6 +8,7 @@ import { LogViewer, HNViewer, } from '@ui/tui/primitives/index'; +import type { KeyBinding } from '@ui/tui/hooks/useKeyBindings'; import { useStdoutDimensions } from '@ui/tui/hooks/useStdoutDimensions'; import { useFileWatcher } from '@ui/tui/hooks/file-watcher'; import { AuditChecksViewer } from './AuditChecksViewer/AuditChecksViewer.js'; @@ -63,6 +64,18 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { notebookUrl={store.session.notebookUrl} /> ); + const nudgeBindings = useMemo( + () => [ + { + match: 'n', + label: 'n', + action: 'nudge agent', + priority: 20, + handler: () => store.nudgeAgent(), + }, + ], + [store], + ); // Narrow terminals: drop the area pane. const statusComponent = @@ -94,6 +107,7 @@ export const AuditRunScreen = ({ store }: AuditRunScreenProps) => { tabs={tabs} statusMessage={statuses} expandableStatus + extraBindings={nudgeBindings} store={store} /> ); diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index 181818ab..2aed3335 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -191,6 +191,7 @@ export class WizardStore { private $currentStage = atom<{ stage: string; startedAt: number } | null>( null, ); + private $agentNudge = atom<{ id: number; message: string } | null>(null); private $tokenUsage = atom(EMPTY_TOKEN_USAGE); // Defaults on for local/dev/test runs (tsx, `pnpm try`, vitest) so // contributors see it without needing to know the shortcut; defaults off @@ -199,6 +200,7 @@ export class WizardStore { private $tokenHudVisible = atom(IS_DEV); private _onTasksChanged: (() => void) | null = null; + private _nudgeSeq = 0; /** Last screen seen — used to detect screen transitions for analytics. */ private _lastScreen: ScreenName | null = null; @@ -390,6 +392,10 @@ export class WizardStore { return this.$currentStage.get(); } + get agentNudge(): { id: number; message: string } | null { + return this.$agentNudge.get(); + } + /** No-op when the stage hasn't changed, so `startedAt` survives across * re-renders and tab switches and measures real stage time. */ setCurrentStage(stage: string): void { @@ -910,6 +916,44 @@ export class WizardStore { this.emitChange(); } + nudgeAgent( + message = 'Please finish the current line of work and move on if you are stuck.', + ): void { + this._nudgeSeq += 1; + this.$agentNudge.set({ id: this._nudgeSeq, message }); + this.emitChange(); + this.pushStatus('Sent a nudge to the agent.'); + analytics.wizardCapture('agent nudged', { + program_id: this.router.activeProgram, + ...sessionProperties(this.session), + }); + } + + waitForAgentNudge( + afterId = 0, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null> { + const current = this.$agentNudge.get(); + if (current && current.id > afterId) return Promise.resolve(current); + if (signal?.aborted) return Promise.resolve(null); + return new Promise((resolve) => { + let unsub: (() => void) | null = null; + const onAbort = () => { + unsub?.(); + resolve(null); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + unsub = this.subscribe(() => { + const next = this.$agentNudge.get(); + if (next && next.id > afterId) { + unsub?.(); + signal?.removeEventListener('abort', onAbort); + resolve(next); + } + }); + }); + } + get tokenUsage(): TokenUsageSnapshot { return this.$tokenUsage.get(); } diff --git a/src/ui/wizard-ui.ts b/src/ui/wizard-ui.ts index 155a41f5..6903887f 100644 --- a/src/ui/wizard-ui.ts +++ b/src/ui/wizard-ui.ts @@ -107,6 +107,14 @@ export interface WizardUI { note(message: string): void; pushStatus(message: string): void; + /** User-triggered guidance for an active agent run. TUI-only callers emit it + * into the live prompt stream; non-interactive implementations may never + * resolve. */ + waitForAgentNudge( + afterId?: number, + signal?: AbortSignal, + ): Promise<{ id: number; message: string } | null>; + // ── Spinner ─────────────────────────────────────────────────────── spinner(): SpinnerHandle;