From 09be8e6f78d5feff0f00eea71c379c6642688b80 Mon Sep 17 00:00:00 2001 From: BakedSoups Date: Fri, 3 Jul 2026 12:27:29 -0700 Subject: [PATCH 1/2] Add agent nudge control --- src/lib/agent/agent-interface.ts | 37 ++++++++++++++++ .../agent/runner/harness/anthropic/index.ts | 4 ++ src/ui/logging-ui.ts | 10 +++++ src/ui/tui/__tests__/store.test.ts | 41 +++++++++++++++++ src/ui/tui/ink-ui.ts | 7 +++ src/ui/tui/primitives/TabContainer.tsx | 6 ++- src/ui/tui/screens/RunScreen.tsx | 14 ++++++ src/ui/tui/screens/audit/AuditRunScreen.tsx | 16 ++++++- src/ui/tui/store.ts | 44 +++++++++++++++++++ src/ui/wizard-ui.ts | 8 ++++ 10 files changed, 184 insertions(+), 3 deletions(-) diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 43412b305..6ae7cbb27 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -207,6 +207,14 @@ export type AgentConfig = { getPendingQuestion?: () => | import('@lib/wizard-session').PendingQuestion | null; + /** + * Optional live user guidance channel. TUI implementations resolve whenever + * the operator asks the agent to move on; non-interactive hosts omit it. + */ + waitForAgentNudge?: ( + afterId?: number, + signal?: AbortSignal, + ) => Promise<{ id: number; message: string } | null>; /** * Orchestrator queue context. Present only when the `wizard-orchestrator` * flag routes the run here; threaded into wizard-tools so the orchestrator @@ -299,6 +307,10 @@ type AgentRunConfig = { getPendingQuestion?: () => | import('@lib/wizard-session').PendingQuestion | null; + waitForAgentNudge?: ( + afterId?: number, + signal?: AbortSignal, + ) => Promise<{ id: number; message: string } | null>; }; /** @@ -712,6 +724,7 @@ export async function initializeAgent( allowedTools: config.allowedTools, disallowedTools: config.disallowedTools, getPendingQuestion: config.getPendingQuestion, + waitForAgentNudge: config.waitForAgentNudge, }; logToFile('Agent config:', { @@ -833,6 +846,30 @@ export async function runAgent( message: { role: 'user', content: prompt }, parent_tool_use_id: null, }; + let lastNudgeId = 0; + while (agentConfig.waitForAgentNudge) { + const nudgeAbort = new AbortController(); + const nextNudge = agentConfig.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 01dcd101b..190cde9a6 100644 --- a/src/lib/agent/runner/harness/anthropic/index.ts +++ b/src/lib/agent/runner/harness/anthropic/index.ts @@ -71,6 +71,8 @@ export const anthropicBackend: AgentHarness = { allowedTools: programConfig.allowedTools, disallowedTools: programConfig.disallowedTools, getPendingQuestion: () => session.pendingQuestion, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), modelOverride: model, }, sessionToOptions(session), @@ -133,6 +135,8 @@ export const anthropicBackend: AgentHarness = { wizardMetadata: boot.wizardMetadata, integrationLabel: programConfig.id, orchestrator, + waitForAgentNudge: (afterId, signal) => + getUI().waitForAgentNudge(afterId, signal), }, options, ); diff --git a/src/ui/logging-ui.ts b/src/ui/logging-ui.ts index b3206f64c..b94ad45cc 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 a146f0ef7..0760b6455 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 598d66cca..965457437 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 1b5a0ed69..9d113b7bf 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 da78ce378..6f7e5dec7 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 1f5e22b7f..436e5fde3 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 181818ab9..2aed3335b 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 155a41f50..6903887f4 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; From 29e1f5368946ac0260cd963317d44853380159aa Mon Sep 17 00:00:00 2001 From: BakedSoups Date: Thu, 9 Jul 2026 23:20:52 -0700 Subject: [PATCH 2/2] Support agent nudges across runner harnesses Move the live nudge channel out of Anthropic agent initialization and into the shared harness execution inputs. Linear and orchestrator flows now pass the TUI nudge source to the resolved harness. Anthropic continues to deliver nudges through its prompt stream, and Pi forwards them through agentSession.steer(). --- src/lib/agent/agent-interface.ts | 31 +++++++++-------- .../agent/runner/harness/anthropic/index.ts | 8 ++--- src/lib/agent/runner/harness/pi/index.ts | 34 +++++++++++++++++-- src/lib/agent/runner/harness/types.ts | 9 ++++- src/lib/agent/runner/sequence/linear.ts | 2 ++ .../orchestrator/orchestrator-runner.ts | 4 +++ 6 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/lib/agent/agent-interface.ts b/src/lib/agent/agent-interface.ts index 6ae7cbb27..b1936eb19 100644 --- a/src/lib/agent/agent-interface.ts +++ b/src/lib/agent/agent-interface.ts @@ -207,14 +207,6 @@ export type AgentConfig = { getPendingQuestion?: () => | import('@lib/wizard-session').PendingQuestion | null; - /** - * Optional live user guidance channel. TUI implementations resolve whenever - * the operator asks the agent to move on; non-interactive hosts omit it. - */ - waitForAgentNudge?: ( - afterId?: number, - signal?: AbortSignal, - ) => Promise<{ id: number; message: string } | null>; /** * Orchestrator queue context. Present only when the `wizard-orchestrator` * flag routes the run here; threaded into wizard-tools so the orchestrator @@ -223,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. */ @@ -307,10 +306,6 @@ type AgentRunConfig = { getPendingQuestion?: () => | import('@lib/wizard-session').PendingQuestion | null; - waitForAgentNudge?: ( - afterId?: number, - signal?: AbortSignal, - ) => Promise<{ id: number; message: string } | null>; }; /** @@ -724,7 +719,6 @@ export async function initializeAgent( allowedTools: config.allowedTools, disallowedTools: config.disallowedTools, getPendingQuestion: config.getPendingQuestion, - waitForAgentNudge: config.waitForAgentNudge, }; logToFile('Agent config:', { @@ -792,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; @@ -847,9 +848,9 @@ export async function runAgent( parent_tool_use_id: null, }; let lastNudgeId = 0; - while (agentConfig.waitForAgentNudge) { + while (config?.waitForAgentNudge) { const nudgeAbort = new AbortController(); - const nextNudge = agentConfig.waitForAgentNudge( + const nextNudge = config.waitForAgentNudge( lastNudgeId, nudgeAbort.signal, ); diff --git a/src/lib/agent/runner/harness/anthropic/index.ts b/src/lib/agent/runner/harness/anthropic/index.ts index 190cde9a6..f80c23618 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, @@ -71,8 +72,6 @@ export const anthropicBackend: AgentHarness = { allowedTools: programConfig.allowedTools, disallowedTools: programConfig.disallowedTools, getPendingQuestion: () => session.pendingQuestion, - waitForAgentNudge: (afterId, signal) => - getUI().waitForAgentNudge(afterId, signal), modelOverride: model, }, sessionToOptions(session), @@ -95,6 +94,7 @@ export const anthropicBackend: AgentHarness = { additionalFeatureQueue: config.additionalFeatureQueue ?? [], abortCases: config.abortCases, emitStepEvents: config.trackStepProgress ?? false, + waitForAgentNudge, }, middleware, ); @@ -117,6 +117,7 @@ export const anthropicBackend: AgentHarness = { additionalFeatureQueue, requestRemark, analyticsProperties, + waitForAgentNudge, } = inputs; const options = sessionToOptions(session); @@ -135,8 +136,6 @@ export const anthropicBackend: AgentHarness = { wizardMetadata: boot.wizardMetadata, integrationLabel: programConfig.id, orchestrator, - waitForAgentNudge: (afterId, signal) => - getUI().waitForAgentNudge(afterId, signal), }, options, ); @@ -153,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 b7afb44a1..cffa288e6 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 094803412..12d800e5e 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 d851a210b..d0aacdbd1 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 b735d2e41..9ee1e9952 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();