Browse Source

fix: add explicit user wait for text-only HITL

nettee 2 weeks ago
parent
commit
556ff3439f

+ 25 - 10
docs/background-orchestration.md

@@ -37,6 +37,7 @@ The required native/background-control tools are:
 | `task(..., background: true)` | Start a specialist in the background and immediately return a task ID |
 | hook-driven completion | OpenCode injects terminal background task results automatically |
 | `cancel_task` | Plugin-provided tool to cancel a tracked background task by task ID or Background Job Board alias |
+| `wait_for_user` | Plugin-provided orchestrator tool that pauses automatic continuation while the user performs external manual work |
 
 If these are not available, the scheduler cannot use the default background
 workflow. Configure the environment variable through the installer or use the
@@ -345,18 +346,32 @@ prompts and todo updates do not rearm.
 Continuation is suppressed when the SDK reports the parent or any direct child
 as active, when a terminal child result has not yet been reconciled, during
 foreground fallback, while OpenCode is waiting for a question or permission
-response, or whenever SDK data is unavailable or malformed. A matching reply, or
-a rejected question, clears that wait but does not itself inject a nudge; the
-normal session lifecycle decides whether a later nudge is needed.
+response, after the orchestrator calls `wait_for_user`, or whenever SDK data is
+unavailable or malformed. A matching reply, or a rejected question, clears its
+tool-backed wait but does not itself inject a nudge; the normal session lifecycle
+decides whether a later nudge is needed.
+
+For external manual work, the orchestrator first gives the user concrete steps,
+then calls `wait_for_user` as its final tool action. This explicit signal covers
+text-only HITL turns without attempting to infer intent from assistant prose. The
+wait remains armed across hook/plugin recreation in the same process and is
+cleared only by a distinct real external user message or genuine session
+deletion. Re-observing the user message that preceded the wait, synthetic/internal
+messages (including foreground-fallback replays), fallback teardown, session
+errors, and idle/busy events do not clear it. Immediate choices, clarifications,
+and pasted command output continue to use the `question` tool. If
+`wait_for_user` is intentionally listed in `disabled_tools`, the orchestrator
+uses the `question` tool as the blocking boundary instead.
 
 This is a best-effort runtime check, not a scheduler or persisted state. The
-one-attempt guard is process-local (shared across hook instances in the same JS
-process via an internal gate). It does not survive process restart or
-cross-process boundaries. Recreating a hook/plugin instance inside the same
-process does **not** rearm a consumed epoch; only a real external user message
-(or genuine session deletion) does. After a process restart, the in-memory job
-board cannot establish prior result reconciliation, and the SDK's current
-session/todo status remains the liveness authority.
+one-attempt guard and explicit `wait_for_user` state are process-local (shared
+across hook instances in the same JS process via an internal gate). They do not
+survive process restart or cross-process boundaries. Recreating a hook/plugin
+instance inside the same process does **not** rearm a consumed or waiting epoch;
+only a distinct real external user message (or genuine session deletion) does.
+After a process restart, the in-memory job board cannot establish prior result
+reconciliation, and the SDK's current session/todo status remains the liveness
+authority.
 
 ### Background Job Board Injection
 

+ 9 - 1
docs/tools.md

@@ -39,15 +39,23 @@ Fast, structural code search and refactoring - more powerful than plain text gre
 | Tool | Description |
 |------|-------------|
 | `cancel_task` | Cancel a tracked background specialist task by native task ID or Background Job Board alias |
+| `wait_for_user` | Pause automatic incomplete-todo continuation until the next distinct external user message |
 
 `cancel_task` is orchestrator-only. It only cancels background tasks tracked for
 the current orchestrator session, and it does not roll back partial edits. After
 cancelling a write-capable task, inspect and reconcile file changes before
 launching replacement work.
 
+`wait_for_user` is also orchestrator-only. The orchestrator uses it as the final
+tool action after providing concrete instructions for external manual work. Its
+`reason` is diagnostic text only; the plugin does not parse assistant prose to
+decide whether a turn is HITL. A new real user text/file/image message clears the
+wait. Synthetic/internal messages and duplicate delivery of the user message
+that preceded the wait do not.
+
 See the background orchestration concepts in
 [Background Orchestration](background-orchestration.md) for the session
-lifecycle and cancellation edge cases behind this tool.
+lifecycle, cancellation, and explicit-wait edge cases behind these tools.
 
 ---
 

+ 21 - 0
src/agents/index.test.ts

@@ -192,6 +192,15 @@ describe('orchestrator agent', () => {
     ).toBe('allow');
   });
 
+  test('orchestrator is allowed to invoke wait_for_user', () => {
+    const agents = createAgents();
+    const orchestrator = agents.find((a) => a.name === 'orchestrator');
+    expect(
+      (orchestrator as { config: { permission: Record<string, unknown> } })
+        .config.permission.wait_for_user,
+    ).toBe('allow');
+  });
+
   test('orchestrator accepts overrides', () => {
     const config: PluginConfig = {
       agents: {
@@ -363,6 +372,18 @@ describe('tool permissions', () => {
     ).toBe('deny');
   });
 
+  test('subagents are denied access to wait_for_user', () => {
+    const agents = createAgents();
+
+    for (const name of ['oracle', 'explorer', 'fixer']) {
+      const agent = agents.find((candidate) => candidate.name === name);
+      expect(
+        (agent as { config: { permission: Record<string, unknown> } }).config
+          .permission.wait_for_user,
+      ).toBe('deny');
+    }
+  });
+
   test('council agent has synthesis-only (deny-all) permissions', () => {
     const agents = createAgents({
       council: councilConfig(),

+ 6 - 0
src/agents/index.ts

@@ -303,11 +303,16 @@ function applyDefaultPermissions(
   const cancelTaskPerm = CANCEL_TASK_ALLOWED_AGENTS.has(agent.name)
     ? (existing.cancel_task ?? 'allow')
     : 'deny';
+  const waitForUserPerm =
+    agent.name === 'orchestrator'
+      ? (existing.wait_for_user ?? 'allow')
+      : 'deny';
 
   agent.config.permission = {
     ...existing,
     question: questionPerm,
     cancel_task: cancelTaskPerm,
+    wait_for_user: waitForUserPerm,
     // Apply skill permissions as nested object under 'skill' key
     skill: {
       ...(typeof existing.skill === 'object' ? existing.skill : {}),
@@ -536,6 +541,7 @@ export function createAgents(
     undefined,
     disabled,
     councillorAgents.length > 0 ? ['council'] : undefined,
+    !config?.disabled_tools?.includes('wait_for_user'),
   );
 
   const inlineOrchestratorPrompt = orchestratorOverride?.prompt;

+ 21 - 0
src/agents/orchestrator.test.ts

@@ -11,4 +11,25 @@ describe('orchestrator prompt', () => {
     expect(prompt).toContain('small bounded set of options');
     expect(prompt).toContain('ordinary dialogue that does not block work');
   });
+
+  test('requires wait_for_user for external manual work', () => {
+    const prompt = buildOrchestratorPrompt();
+
+    expect(prompt).toContain('call `wait_for_user` as your final tool action');
+    expect(prompt).toContain('give the user concrete manual steps');
+    expect(prompt).toContain('end the turn');
+    expect(prompt).toContain('Do not rely on ordinary text alone');
+  });
+
+  test('falls back to question when wait_for_user is disabled', () => {
+    const prompt = buildOrchestratorPrompt(undefined, undefined, false);
+
+    expect(prompt).not.toContain(
+      'call `wait_for_user` as your final tool action',
+    );
+    expect(prompt).toContain('`wait_for_user` is disabled');
+    expect(prompt).toContain(
+      'use the `question` tool as the blocking boundary',
+    );
+  });
 });

+ 10 - 1
src/agents/orchestrator.ts

@@ -113,11 +113,13 @@ const PARALLEL_DELEGATION_EXAMPLES = [
 /**
  * Build the orchestrator prompt with dynamic agent filtering.
  * @param disabledAgents - Set of disabled agent names to exclude from the prompt
+ * @param waitForUserEnabled - Whether explicit text-only HITL waiting is available
  * @returns The complete orchestrator prompt string
  */
 export function buildOrchestratorPrompt(
   disabledAgents?: Set<string>,
   excludeDescriptions?: string[],
+  waitForUserEnabled = true,
 ): string {
   // Filter agent descriptions
   const enabledAgents = Object.entries(AGENT_DESCRIPTIONS)
@@ -135,6 +137,10 @@ export function buildOrchestratorPrompt(
     },
   ).join('\n');
 
+  const externalManualWaitInstruction = waitForUserEnabled
+    ? '- When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then call `wait_for_user` as your final tool action and end the turn. Do not rely on ordinary text alone to mark this waiting state, and do not call more tools after `wait_for_user`.'
+    : '- When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then use the `question` tool as the blocking boundary and ask them to respond when finished. `wait_for_user` is disabled, so do not reference or call it.';
+
   return `<Role>
 You are a workflow manager for coding work. Your job is to plan, schedule, delegate, monitor, reconcile, and verify specialist-agent work. You are not the default implementation worker.
 
@@ -241,7 +247,8 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
 - Don't guess at critical details (file paths, API choices, architectural decisions)
 - Do make reasonable assumptions for minor details and state them briefly
-- When user input is required before work can continue—including clarification, permission, or command output—use the \`question\` tool rather than leaving an ordinary assistant prompt waiting. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+- When user input is required before work can continue and the user can answer immediately—including clarification, permission, a choice, or pasted command output—use the \`question\` tool. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+${externalManualWaitInstruction}
 - For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
 
 ## Concise Execution
@@ -278,10 +285,12 @@ export function createOrchestratorAgent(
   customAppendPrompt?: string,
   disabledAgents?: Set<string>,
   excludeDescriptions?: string[],
+  waitForUserEnabled = true,
 ): AgentDefinition {
   const basePrompt = buildOrchestratorPrompt(
     disabledAgents,
     excludeDescriptions,
+    waitForUserEnabled,
   );
   const prompt = resolvePrompt(basePrompt, customPrompt, customAppendPrompt);
 

+ 2 - 1
src/hooks/__snapshots__/cache-payload.snapshot.test.ts.snap

@@ -192,7 +192,8 @@ Balance: respect dependencies, avoid parallelizing what must be sequential, and
 - If request is vague or has multiple valid interpretations, ask a targeted question before proceeding
 - Don't guess at critical details (file paths, API choices, architectural decisions)
 - Do make reasonable assumptions for minor details and state them briefly
-- When user input is required before work can continue—including clarification, permission, or command output—use the \`question\` tool rather than leaving an ordinary assistant prompt waiting. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+- When user input is required before work can continue and the user can answer immediately—including clarification, permission, a choice, or pasted command output—use the \`question\` tool. Enable custom input, request a concise pasted response or command output, and provide a small bounded set of options whenever the tool schema requires options.
+- When work must pause while the user completes an external manual operation, first give the user concrete manual steps, then call \`wait_for_user\` as your final tool action and end the turn. Do not rely on ordinary text alone to mark this waiting state, and do not call more tools after \`wait_for_user\`.
 - For ordinary dialogue that does not block work, answer normally and do not use the question tool gratuitously.
 
 ## Concise Execution

+ 5 - 5
src/hooks/foreground-fallback/codemap.md

@@ -11,12 +11,12 @@ Runtime model fallback system for foreground (interactive) agent sessions. When
 ## Design
 
 ### Core Abstraction
-- **ForegroundFallbackManager**: Singleton class instantiated at plugin initialization
+- **ForegroundFallbackManager**: Class instantiated at plugin initialization; process-local fallback progress is shared across replacement instances
 - Maintains per-session state tracking:
   - `sessionModel`: Maps sessionID → current model string ("providerID/modelID")
   - `sessionAgent`: Maps sessionID → agent name
   - `sessionTried`: Maps sessionID → Set of models already attempted
-  - `inProgress`: Set of sessions with active fallback in flight
+  - `inProgress`: Process-global Set of sessions with active fallback in flight, shared via `globalThis` + `Symbol.for`
   - `lastTrigger`: Maps sessionID → timestamp for deduplication
 
 ### Fallback Chain Resolution
@@ -37,7 +37,7 @@ Runtime model fallback system for foreground (interactive) agent sessions. When
 ### State Management
 - **Deduplication window**: 5-second cooldown (`DEDUP_WINDOW_MS`) to prevent multiple triggers for same rate-limit event
 - **Session cleanup**: `session.deleted` event handler removes all per-session state to prevent memory leaks
-- **In-progress tracking**: Prevents concurrent fallback attempts on same session
+- **In-progress tracking**: Prevents concurrent fallback attempts on the same session across plugin-manager recreation
 
 ## Flow
 
@@ -68,7 +68,7 @@ Log fallback event
 1. **Abort with timeout**: `abortSessionWithTimeout()` sends Ctrl+C to pane then kills it after 250ms delay
 2. **Message retrieval**: Queries session messages via `client.session.messages()` and finds last user message
 3. **Model switching**: Uses `parseModelReference()` to extract providerID/modelID from chain entry
-4. **Re-prompting**: Calls `promptAsync()` which queues prompt and returns immediately (non-blocking)
+4. **Re-prompting**: Calls `promptAsync()` which queues prompt and returns immediately (non-blocking); appends trusted internal-initiator provenance so the replay is not mistaken for new external user input
 
 ## Integration
 
@@ -107,4 +107,4 @@ Fallback chains are provided as `Record<string, string[]>` where:
 - **Validation**: Checks for `promptAsync` availability before attempting re-prompt
 - **Fallback exhaustion**: Logs when entire chain has been attempted without success
 - **Invalid model format**: Skips malformed model references
-- **Missing user message**: Aborts fallback attempt if no user message found in history
+- **Missing user message**: Aborts fallback attempt if no user message found in history

+ 47 - 0
src/hooks/foreground-fallback/index.test.ts

@@ -1,4 +1,5 @@
 import { beforeEach, describe, expect, mock, test } from 'bun:test';
+import { isInternalInitiatorPart } from '../../utils';
 import { SessionLifecycle } from '../session-lifecycle';
 import {
   ForegroundFallbackManager,
@@ -245,6 +246,33 @@ describe('ForegroundFallbackManager session.error', () => {
     expect(call[0].body.model.modelID).toBe('gpt-4o');
   });
 
+  test('marks the replayed user prompt as an internal initiator', async () => {
+    await mgr.handleEvent({
+      type: 'message.updated',
+      properties: {
+        info: {
+          sessionID: 'sess-1',
+          providerID: 'anthropic',
+          modelID: 'claude-opus-4-5',
+          role: 'assistant',
+        },
+      },
+    });
+
+    await mgr.handleEvent({
+      type: 'session.error',
+      properties: {
+        sessionID: 'sess-1',
+        error: { message: 'Rate limit exceeded' },
+      },
+    });
+
+    const call = mocks.promptAsync.mock.calls[0] as [
+      { body: { parts: unknown[] } },
+    ];
+    expect(call[0].body.parts.some(isInternalInitiatorPart)).toBe(true);
+  });
+
   test('skips malformed messages without info when locating the last user message', async () => {
     // OpenCode may return partial/streaming messages whose `info` is undefined;
     // the fallback must ignore those rather than crash, and still re-submit the
@@ -1296,6 +1324,25 @@ describe('ForegroundFallbackManager session.deleted', () => {
     // inProgress must survive — the finally block of tryFallback/WithAbort
     // manages it, not the session.deleted callback
     expect(mgr.isFallbackInProgress(sessionID)).toBe(true);
+    (mgr as any).inProgress.delete(sessionID);
+  });
+
+  test('shares fallback progress across plugin manager instances', () => {
+    const first = new ForegroundFallbackManager(
+      createMockClient().client,
+      makeChains(),
+      true,
+    );
+    const replacement = new ForegroundFallbackManager(
+      createMockClient().client,
+      makeChains(),
+      true,
+    );
+    const sessionID = 'sess-shared-in-progress';
+
+    (first as any).inProgress.add(sessionID);
+    expect(replacement.isFallbackInProgress(sessionID)).toBe(true);
+    (first as any).inProgress.delete(sessionID);
   });
 });
 

+ 18 - 3
src/hooks/foreground-fallback/index.ts

@@ -18,6 +18,7 @@
  */
 
 import type { PluginInput } from '@opencode-ai/plugin';
+import { createInternalAgentTextPart } from '../../utils/internal-initiator';
 import { log } from '../../utils/logger';
 import {
   abortSessionWithTimeout,
@@ -183,6 +184,17 @@ export function isRateLimitError(error: unknown): boolean {
 /** Prevent re-triggering within this window for the same session. */
 const DEDUP_WINDOW_MS = 5_000;
 const REPROMPT_DELAY_MS = 500;
+const FALLBACK_IN_PROGRESS_KEY = Symbol.for(
+  'oh-my-opencode-slim.foreground-fallback.in-progress',
+);
+
+function getProcessFallbacksInProgress(): Set<string> {
+  const globalWithStore = globalThis as typeof globalThis & {
+    [FALLBACK_IN_PROGRESS_KEY]?: Set<string>;
+  };
+  globalWithStore[FALLBACK_IN_PROGRESS_KEY] ??= new Set();
+  return globalWithStore[FALLBACK_IN_PROGRESS_KEY];
+}
 
 // ---------------------------------------------------------------------------
 // Manager
@@ -201,8 +213,8 @@ export class ForegroundFallbackManager {
   private readonly sessionAgent = new Map<string, string>();
   /** sessionID → set of models already attempted this session */
   private readonly sessionTried = new Map<string, Set<string>>();
-  /** Sessions with an active fallback switch in flight */
-  private readonly inProgress = new Set<string>();
+  /** Process-local sessions with an active fallback switch in flight. */
+  private readonly inProgress = getProcessFallbacksInProgress();
   /** sessionID → timestamp of last trigger (for deduplication) */
   private readonly lastTrigger = new Map<string, number>();
   /** sessionID → model in use when lastTrigger was set; dedup is bypassed
@@ -633,7 +645,10 @@ export class ForegroundFallbackManager {
       }
 
       const promptBody = {
-        parts: lastUser.parts,
+        parts: [
+          ...lastUser.parts,
+          createInternalAgentTextPart('Foreground fallback replay.'),
+        ],
         model: ref,
         ...(agentName ? { agent: agentName } : {}),
       };

+ 12 - 3
src/hooks/task-session-manager/codemap.md

@@ -2,13 +2,15 @@
 
 ## Responsibility
 
-Manages V2 background job-board state for task execution and injected completion messages, enabling the orchestrator to track active jobs and reuse only completed, reconciled child sessions by short aliases (e.g., `exp-1`, `ora-2`). This module was recently split into three focused submodules to improve separation of concerns and maintainability.
+Manages V2 background job-board state for task execution and injected completion messages, enabling the orchestrator to track active jobs and reuse only completed, reconciled child sessions by short aliases (e.g., `exp-1`, `ora-2`). The implementation is split into focused submodules to improve separation of concerns and maintainability.
 
 ## Design
 
-The directory follows a **Facade + Strategy** pattern where `index.ts` acts as the facade that composes and orchestrates behavior across three specialized strategy modules:
+The directory follows a **Facade + Strategy** pattern where `index.ts` acts as the facade that composes and orchestrates behavior across specialized strategy modules:
 
-- **index.ts**: Main facade that wires hooks into OpenCode's lifecycle and coordinates between the job board, pending calls, and task context tracking. Implements the plugin hook interface (`tool.execute.before`, `tool.execute.after`, `experimental.chat.messages.transform`, `event`).
+- **index.ts**: Main facade that wires hooks into OpenCode's lifecycle and coordinates between the job board, pending calls, task context tracking, and explicit user waits. Implements the plugin hook interface (`tool.execute.before`, `tool.execute.after`, `experimental.chat.messages.transform`, `event`) and exposes `beginUserWait()` to the `wait_for_user` tool.
+- **input-wait-tracker.ts**: Provides the single `hasInputWait()` seam used by idle reconciliation and continuation evaluation. It combines local question/permission waits with the process-global explicit user-wait latch.
+- **continuation-attempt-gate.ts**: Owns process-global continuation epochs, reservations, and explicit user waits across hook recreation. The wait is encoded as an `attempts` sentinel so pre-upgrade #856 hooks sharing the store also fail closed. Distinct external user-message identity rearms both states.
 - **pending-call-tracker.ts**: Tracks in-flight task calls using a capped ordered map (`MAX_PENDING_TASK_CALLS`) to correlate launch output safely. Provides call ID generation, storage, retrieval, and cleanup for pending task invocations.
 - **task-context-tracker.ts**: Manages read context from child sessions with line-count and file caps. Stores context per task ID and provides pruning to prevent unbounded growth.
 
@@ -19,6 +21,7 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
 - **BackgroundJobBoard**: Central state store for task sessions (active, reusable, terminal unreconciled).
 - **PendingTaskCall**: Tracks in-flight task invocations with call ID, parent session ID, agent type, label, and optional resumed task ID.
 - **ContextFile**: Represents read context from child sessions with path, line numbers, and last-read timestamp.
+- **User wait**: Explicit text-only HITL latch armed by `wait_for_user` and released by a distinct real external user message.
 
 ## Flow
 
@@ -55,6 +58,12 @@ All modules depend on `BackgroundJobBoard` from `src/utils/background-job-board.
    - `session.status` (busy): Marks sessions as running from live session state
    - `session.deleted`: Clears job state, child jobs, and pending call records for the session
 
+6. **Human-in-the-loop Waits**
+   - `wait_for_user` calls the facade's `beginUserWait()` only after tool validation
+   - The shared latch cancels pending continuation timers/reservations
+   - Foreground-fallback replay provenance and shared fallback teardown state preserve the latch across plugin-manager recreation
+   - Idle continuation remains suppressed until a distinct real user message arrives
+
 ### Data & Control Flow
 
 ```

+ 55 - 0
src/hooks/task-session-manager/continuation-attempt-gate.test.ts

@@ -0,0 +1,55 @@
+import { beforeEach, describe, expect, test } from 'bun:test';
+import {
+  beginUserWait,
+  resetContinuationAttemptGateForTests,
+} from './continuation-attempt-gate';
+
+type LegacyAttemptState =
+  | { status: 'reserved'; owner: symbol }
+  | { status: 'consumed' };
+
+type LegacyStore = {
+  attempts: Map<string, LegacyAttemptState>;
+};
+
+const STORE_KEY = Symbol.for('oh-my-opencode-slim.continuation-attempt-gate');
+
+function getLegacyStore(): LegacyStore {
+  return (
+    globalThis as typeof globalThis & {
+      [STORE_KEY]: LegacyStore;
+    }
+  )[STORE_KEY];
+}
+
+function legacyTryReserve(sessionID: string): symbol | null {
+  const { attempts } = getLegacyStore();
+  if (attempts.has(sessionID)) return null;
+  const owner = Symbol(sessionID);
+  attempts.set(sessionID, { status: 'reserved', owner });
+  return owner;
+}
+
+function legacyCommit(sessionID: string, owner: symbol): boolean {
+  const { attempts } = getLegacyStore();
+  const state = attempts.get(sessionID);
+  if (state?.status !== 'reserved' || state.owner !== owner) return false;
+  attempts.set(sessionID, { status: 'consumed' });
+  return true;
+}
+
+describe('continuation attempt gate compatibility', () => {
+  beforeEach(() => {
+    resetContinuationAttemptGateForTests();
+  });
+
+  test('wait_for_user blocks a pre-upgrade hook sharing the global store', () => {
+    const staleOwner = legacyTryReserve('parent-1');
+    expect(staleOwner).not.toBeNull();
+
+    beginUserWait('parent-1');
+
+    expect(legacyTryReserve('parent-1')).toBeNull();
+    expect(legacyCommit('parent-1', staleOwner as symbol)).toBe(false);
+  });
+});

+ 17 - 1
src/hooks/task-session-manager/continuation-attempt-gate.ts

@@ -8,7 +8,8 @@
 
 type AttemptState =
   | { status: 'reserved'; owner: symbol }
-  | { status: 'consumed' };
+  | { status: 'consumed' }
+  | { status: 'waiting-for-user' };
 
 type RearmIdentity = string | symbol;
 
@@ -37,6 +38,21 @@ function getStore(): ContinuationAttemptStore {
   return globalWithStore[STORE_KEY];
 }
 
+/**
+ * Block continuation for a text-only HITL boundary until a distinct real
+ * external user message opens the next continuation epoch.
+ *
+ * Deleting an existing attempt also revokes an in-flight reservation: its
+ * owner can no longer commit a prompt after this wait begins.
+ */
+export function beginUserWait(sessionID: string): void {
+  getStore().attempts.set(sessionID, { status: 'waiting-for-user' });
+}
+
+export function hasUserWait(sessionID: string): boolean {
+  return getStore().attempts.get(sessionID)?.status === 'waiting-for-user';
+}
+
 function resolveRearmIdentity(identity: string | object): RearmIdentity {
   if (typeof identity === 'string') return identity;
   const store = getStore();

+ 374 - 0
src/hooks/task-session-manager/index.test.ts

@@ -4042,6 +4042,380 @@ describe('task-session-manager hook', () => {
     expect(promptAsync).not.toHaveBeenCalled();
   });
 
+  test('does not evaluate or nudge after wait_for_user requests text-only HITL', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    hook.beginUserWait('parent-1');
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('a distinct external user message releases wait_for_user', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    hook.beginUserWait('parent-1');
+    hook.observeChatMessage(
+      { sessionID: 'parent-1', messageID: 'msg-user-resumes' },
+      {
+        message: {
+          id: 'msg-user-resumes',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
+        parts: [{ type: 'text', text: 'The manual step is complete.' }],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('a duplicate external message cannot clear a newer user wait', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+    const previousUserMessage = {
+      input: { sessionID: 'parent-1', messageID: 'msg-before-wait' },
+      output: {
+        message: {
+          id: 'msg-before-wait',
+          role: 'user' as const,
+          sessionID: 'parent-1',
+        },
+        parts: [{ type: 'text', text: 'Start the long task.' }],
+      },
+    };
+
+    hook.observeChatMessage(
+      previousUserMessage.input,
+      previousUserMessage.output,
+    );
+    hook.beginUserWait('parent-1');
+    hook.observeChatMessage(
+      previousUserMessage.input,
+      previousUserMessage.output,
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('synthetic and internal messages do not clear wait_for_user', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    hook.beginUserWait('parent-1');
+    hook.observeChatMessage(
+      { sessionID: 'parent-1', messageID: 'msg-internal' },
+      {
+        message: {
+          id: 'msg-internal',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
+        parts: [
+          { type: 'text', synthetic: true, text: 'synthetic continuation' },
+          createInternalAgentTextPart('internal continuation'),
+        ],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('a foreground-fallback replay marker does not clear wait_for_user', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    hook.beginUserWait('parent-1');
+    hook.observeChatMessage(
+      { sessionID: 'parent-1', messageID: 'msg-fallback-replay' },
+      {
+        message: {
+          id: 'msg-fallback-replay',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
+        parts: [
+          { type: 'text', text: 'Start the long task.' },
+          createInternalAgentTextPart('foreground fallback replay'),
+        ],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('wait_for_user cancels a scheduled continuation before SDK reads', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const children = mock(async () => ({ data: [] }));
+    const status = mock(async () => ({ data: {} }));
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: { todo, children, status, promptAsync },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    hook.beginUserWait('parent-1');
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(children).not.toHaveBeenCalled();
+    expect(status).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('wait_for_user invalidates an in-flight continuation evaluation', async () => {
+    let resolveTodo!: (value: { data: { status: string }[] }) => void;
+    const todo = mock(
+      () =>
+        new Promise<{ data: { status: string }[] }>((resolveTodoRequest) => {
+          resolveTodo = resolveTodoRequest;
+        }),
+    );
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(todo).toHaveBeenCalledTimes(1);
+
+    hook.beginUserWait('parent-1');
+    resolveTodo({ data: [{ status: 'pending' }] });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('a user wait from another hook revokes an in-flight shared reservation', async () => {
+    let resolveTodo!: (value: { data: { status: string }[] }) => void;
+    const todo = mock(
+      () =>
+        new Promise<{ data: { status: string }[] }>((resolveTodoRequest) => {
+          resolveTodo = resolveTodoRequest;
+        }),
+    );
+    const promptAsync = mock(async () => ({}));
+    const sessionClient = {
+      todo,
+      children: mock(async () => ({ data: [] })),
+      status: mock(async () => ({ data: {} })),
+      promptAsync,
+    };
+    const owner = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient,
+    }).hook;
+    const waiter = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient,
+    }).hook;
+
+    await owner.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(todo).toHaveBeenCalledTimes(1);
+
+    waiter.beginUserWait('parent-1');
+    resolveTodo({ data: [{ status: 'pending' }] });
+    await flushContinuation();
+
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
+  test('external user input clears only the explicit wait while a question remains', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      sessionClient: {
+        todo: mock(async () => ({ data: [{ status: 'pending' }] })),
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    hook.beginUserWait('parent-1');
+    await hook.event({
+      event: {
+        type: 'question.asked',
+        properties: { sessionID: 'parent-1', id: 'question-1' },
+      },
+    });
+    hook.observeChatMessage(
+      { sessionID: 'parent-1', messageID: 'msg-user-replied' },
+      {
+        message: {
+          id: 'msg-user-replied',
+          role: 'user',
+          sessionID: 'parent-1',
+        },
+        parts: [{ type: 'text', text: 'Manual work is done.' }],
+      },
+    );
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await hook.event({
+      event: {
+        type: 'question.replied',
+        properties: { sessionID: 'parent-1', requestID: 'question-1' },
+      },
+    });
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('user waits survive hook disposal and clear on genuine deletion', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    const sessionClient = {
+      todo,
+      children: mock(async () => ({ data: [] })),
+      status: mock(async () => ({ data: {} })),
+      promptAsync,
+    };
+    const makeHook = () =>
+      createHook({ idleReconcileDelayMs: 0, sessionClient }).hook;
+    const owner = makeHook();
+
+    owner.beginUserWait('parent-1');
+    await owner.event({ event: { type: 'server.instance.disposed' } });
+    const replacement = makeHook();
+    await replacement.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+
+    await replacement.event({
+      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
+    });
+    const afterDeletion = makeHook();
+    await afterDeletion.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('fallback session deletion preserves wait_for_user', async () => {
+    const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
+    const promptAsync = mock(async () => ({}));
+    let fallbackInProgress = true;
+    const { hook } = createHook({
+      idleReconcileDelayMs: 0,
+      isFallbackInProgress: () => fallbackInProgress,
+      sessionClient: {
+        todo,
+        children: mock(async () => ({ data: [] })),
+        status: mock(async () => ({ data: {} })),
+        promptAsync,
+      },
+    });
+
+    hook.beginUserWait('parent-1');
+    await hook.event({
+      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
+    });
+    fallbackInProgress = false;
+    await hook.event({
+      event: { type: 'session.idle', properties: { sessionID: 'parent-1' } },
+    });
+    await flushContinuation();
+
+    expect(todo).not.toHaveBeenCalled();
+    expect(promptAsync).not.toHaveBeenCalled();
+  });
+
   test('cancels a scheduled continuation when an input wait arrives before its timer fires', async () => {
     const todo = mock(async () => ({ data: [{ status: 'pending' }] }));
     const children = mock(async () => ({ data: [] }));

+ 5 - 0
src/hooks/task-session-manager/index.ts

@@ -192,6 +192,10 @@ export function createTaskSessionManagerHook(
   };
 
   return {
+    beginUserWait: (sessionID: string): void => {
+      inputWaits.beginUserWait(sessionID);
+    },
+
     observeChatMessage: (input: unknown, output: unknown): void => {
       const inputMessage = isObjectRecord(input) ? input : undefined;
       const outputRecord = isObjectRecord(output) ? output : undefined;
@@ -223,6 +227,7 @@ export function createTaskSessionManagerHook(
           outputMessage.role !== 'user') ||
         !options.shouldManageSession(sessionID) ||
         !Array.isArray(parts) ||
+        parts.some(isInternalInitiatorPart) ||
         !parts.some(
           (part) =>
             isObjectRecord(part) &&

+ 20 - 1
src/hooks/task-session-manager/input-wait-tracker.ts

@@ -1,3 +1,8 @@
+import {
+  beginUserWait as beginSharedUserWait,
+  hasUserWait,
+} from './continuation-attempt-gate';
+
 const IDLESS_INPUT_WAIT = Symbol('idless-input-wait');
 const INPUT_WAIT_ASK_EVENTS = {
   'permission.asked': 'permission',
@@ -32,7 +37,20 @@ export function createInputWaitTracker(options: {
   const inputWaitsByParent = new Map<string, Set<string | symbol>>();
 
   function hasInputWait(sessionID: string): boolean {
-    return (inputWaitsByParent.get(sessionID)?.size ?? 0) > 0;
+    return (
+      hasUserWait(sessionID) ||
+      (inputWaitsByParent.get(sessionID)?.size ?? 0) > 0
+    );
+  }
+
+  function beginUserWait(sessionID: string): void {
+    if (!options.shouldManageSession(sessionID)) {
+      throw new Error(
+        'wait_for_user can only begin in an orchestrator session',
+      );
+    }
+    beginSharedUserWait(sessionID);
+    options.invalidateContinuation(sessionID);
   }
 
   function clearInputWaits(sessionID: string): void {
@@ -79,6 +97,7 @@ export function createInputWaitTracker(options: {
   }
 
   return {
+    beginUserWait,
     trackInputWait,
     hasInputWait,
     clearInputWaits,

+ 69 - 1
src/index.test.ts

@@ -1,5 +1,16 @@
 import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
-import plugin from './index';
+import plugin, { minimumExpectedToolCount } from './index';
+
+describe('plugin health thresholds', () => {
+  test('accounts only for intentionally disabled baseline tools', () => {
+    expect(minimumExpectedToolCount()).toBe(5);
+    expect(minimumExpectedToolCount(['wait_for_user'])).toBe(4);
+    expect(minimumExpectedToolCount(['wait_for_user', 'wait_for_user'])).toBe(
+      4,
+    );
+    expect(minimumExpectedToolCount(['unknown_tool'])).toBe(5);
+  });
+});
 
 describe('plugin env disable', () => {
   let originalEnv: typeof process.env;
@@ -32,3 +43,60 @@ describe('plugin env disable', () => {
     expect(hooks.tool).toBeUndefined();
   });
 });
+
+describe('plugin tool registration', () => {
+  let originalEnv: typeof process.env;
+
+  beforeEach(() => {
+    originalEnv = { ...process.env };
+    delete process.env.OH_MY_OPENCODE_SLIM_DISABLE;
+    process.env.OPENCODE_CONFIG_DIR =
+      '/private/tmp/oh-my-opencode-slim-hitl-empty-config';
+    process.env.XDG_CONFIG_HOME =
+      '/private/tmp/oh-my-opencode-slim-hitl-empty-xdg';
+    process.env.XDG_DATA_HOME =
+      '/private/tmp/oh-my-opencode-slim-hitl-empty-data';
+    process.env.XDG_CACHE_HOME =
+      '/private/tmp/oh-my-opencode-slim-hitl-empty-cache';
+    process.env.OPENCODE_LOG_DIR =
+      '/private/tmp/oh-my-opencode-slim-hitl-empty-logs';
+  });
+
+  afterEach(() => {
+    process.env = originalEnv;
+  });
+
+  test('registers wait_for_user and recovers a stale orchestrator session mapping', async () => {
+    const noop = async () => ({});
+    const session = new Proxy({}, { get: () => noop }) as Record<
+      string,
+      unknown
+    >;
+    const client = new Proxy(
+      { app: { log: noop }, session },
+      {
+        get(target, property) {
+          if (property in target) {
+            return target[property as keyof typeof target];
+          }
+          return new Proxy({}, { get: () => noop });
+        },
+      },
+    );
+
+    const hooks = await plugin({
+      client,
+      directory: '/private/tmp/oh-my-opencode-slim-hitl-project',
+      worktree: '/private/tmp/oh-my-opencode-slim-hitl-project',
+      serverUrl: new URL('http://127.0.0.1:4096'),
+    } as never);
+
+    expect(hooks.tool?.wait_for_user).toBeDefined();
+    await expect(
+      hooks.tool?.wait_for_user?.execute(
+        { reason: 'Complete the external approval.' },
+        { sessionID: 'parent-after-reload', agent: 'orchestrator' } as never,
+      ),
+    ).resolves.toContain('state: waiting_for_user');
+  });
+});

+ 36 - 4
src/index.ts

@@ -61,6 +61,7 @@ import {
   ast_grep_search,
   createAcpRunTool,
   createCancelTaskTool,
+  createWaitForUserTool,
   createWebfetchTool,
 } from './tools';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
@@ -101,11 +102,29 @@ async function appLog(
 const HEALTH_CHECK = {
   minAgents: 5,
   // Default tool set when council and ACP agents are not configured:
-  // cancel_task, webfetch, ast_grep_search, ast_grep_replace.
-  minTools: 4,
+  // cancel_task, wait_for_user, webfetch, ast_grep_search, ast_grep_replace.
+  minTools: 5,
   minMcps: 1,
 } as const;
 
+const BASELINE_TOOL_NAMES = new Set([
+  'cancel_task',
+  'wait_for_user',
+  'webfetch',
+  'ast_grep_search',
+  'ast_grep_replace',
+]);
+
+/** @internal Exposed for deterministic health-threshold tests. */
+export function minimumExpectedToolCount(
+  disabledTools: readonly string[] = [],
+): number {
+  const disabledBaselineTools = new Set(
+    disabledTools.filter((toolName) => BASELINE_TOOL_NAMES.has(toolName)),
+  );
+  return HEALTH_CHECK.minTools - disabledBaselineTools.size;
+}
+
 /**
  * Probe jsdom at init time so the first webfetch call doesn't fail
  * silently. Logs a warning if jsdom can't be imported or instantiated,
@@ -179,6 +198,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let companionManager: CompanionManager;
   let cancelTaskTools: ReturnType<typeof createCancelTaskTool>;
+  let waitForUserTools: ReturnType<typeof createWaitForUserTool>;
   let acpRunTools: Record<string, ReturnType<typeof createAcpRunTool>>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
   let tools: Record<string, ToolDefinition>;
@@ -420,9 +440,20 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
+    waitForUserTools = createWaitForUserTool({
+      shouldManageSession: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+      resolveAgentName: (agent) => resolveRuntimeAgentName(config, agent),
+      registerSessionAsOrchestrator: (sessionID) => {
+        sessionAgentMap.set(sessionID, 'orchestrator');
+      },
+      beginUserWait: (sessionID) =>
+        taskSessionManagerHook.beginUserWait(sessionID),
+    });
 
     tools = {
       ...cancelTaskTools,
+      ...waitForUserTools,
       ...acpRunTools,
       webfetch,
       ast_grep_search,
@@ -456,16 +487,17 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     config.disabled_mcps && config.disabled_mcps.length > 0
       ? 0
       : HEALTH_CHECK.minMcps;
+  const toolThreshold = minimumExpectedToolCount(config.disabled_tools);
 
   if (
     agentCount < HEALTH_CHECK.minAgents ||
-    toolCount < HEALTH_CHECK.minTools ||
+    toolCount < toolThreshold ||
     mcpCount < mcpThreshold
   ) {
     const msg = [
       'Health check: registrations suspiciously low.',
       `  agents: ${agentCount} (expected >=${HEALTH_CHECK.minAgents})`,
-      `  tools:  ${toolCount} (expected >=${HEALTH_CHECK.minTools})`,
+      `  tools:  ${toolCount} (expected >=${toolThreshold})`,
       `  mcps:   ${mcpCount} (expected >=${mcpThreshold})`,
       'This usually means a dependency failed to resolve (jsdom, etc).',
       'If you recently updated opencode, see:',

+ 13 - 1
src/tools/codemap.md

@@ -27,7 +27,7 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
 | Tool Family | Purpose | Key Components |
 |------------|---------|----------------|
 | **Council** | Multi-LLM consensus synthesis (orchestrator dispatches councillors as subagents) | `agents/council.ts`, `agents/index.ts` |
-| **Task Management** | Background task lifecycle control | `cancel-task.ts`, `background-job-board.ts` |
+| **Task Management** | Background task lifecycle and HITL continuation control | `cancel-task.ts`, `wait-for-user.ts`, `background-job-board.ts` |
 | **ACP Integration** | External agent protocol execution | `acp-run.ts`, ACP client implementation |
 | **Code Intelligence** | AST-based code manipulation | `ast-grep/` directory, `tools.ts` |
 | **Web Fetching** | Intelligent web content retrieval | `smartfetch/` directory, `tool.ts` |
@@ -80,6 +80,17 @@ Each tool is implemented as a factory function that returns a `ToolDefinition` r
    └─> Returns cancellation confirmation
 ```
 
+### Explicit User-Wait Flow
+
+```
+1. Orchestrator gives the user concrete manual steps
+   └─> Invokes wait_for_user as its final tool action
+       ├─> Validates session ID, agent identity, and managed-session ownership
+       ├─> Arms task-session-manager.beginUserWait()
+       ├─> Revokes pending automatic-continuation reservations
+       └─> Returns the versioned waiting_for_user protocol marker
+```
+
 ### ACP Agent Execution Flow
 
 ```
@@ -193,6 +204,7 @@ export { ast_grep_replace, ast_grep_search } from './ast-grep';
 
 // Task management
 export { createCancelTaskTool } from './cancel-task';
+export { createWaitForUserTool } from './wait-for-user';
 
 // Preset management
 export type { PresetManager } from './preset-manager';

+ 1 - 0
src/tools/index.ts

@@ -3,3 +3,4 @@ export { createAcpRunTool } from './acp-run';
 export { ast_grep_replace, ast_grep_search } from './ast-grep';
 export { createCancelTaskTool } from './cancel-task';
 export { createWebfetchTool } from './smartfetch';
+export { createWaitForUserTool } from './wait-for-user';

+ 82 - 0
src/tools/wait-for-user.test.ts

@@ -0,0 +1,82 @@
+import { describe, expect, mock, test } from 'bun:test';
+import { createWaitForUserTool } from './wait-for-user';
+
+describe('wait_for_user tool', () => {
+  test('arms the session after validation and tells the orchestrator to end the turn', async () => {
+    const beginUserWait = mock((_sessionID: string) => {});
+    const waitForUser = createWaitForUserTool({
+      shouldManageSession: () => true,
+      beginUserWait,
+    }).wait_for_user;
+
+    const output = await waitForUser.execute(
+      { reason: 'Run the deployment steps, then report back.' },
+      { sessionID: 'parent-1', agent: 'orchestrator' } as never,
+    );
+
+    expect(beginUserWait).toHaveBeenCalledWith('parent-1');
+    expect(String(output)).toContain('state: waiting_for_user');
+    expect(String(output)).toContain(
+      'protocol: oh-my-opencode-slim.wait_for_user.v1',
+    );
+    expect(String(output)).toContain('End this turn now');
+  });
+
+  test('recovers a display-named orchestrator when the session map is stale', async () => {
+    const agentMap = new Map<string, string>();
+    const beginUserWait = mock((_sessionID: string) => {});
+    const waitForUser = createWaitForUserTool({
+      shouldManageSession: (sessionID) =>
+        agentMap.get(sessionID) === 'orchestrator',
+      resolveAgentName: (agent) =>
+        agent === 'engineer' ? 'orchestrator' : agent,
+      registerSessionAsOrchestrator: (sessionID) => {
+        agentMap.set(sessionID, 'orchestrator');
+      },
+      beginUserWait,
+    }).wait_for_user;
+
+    await waitForUser.execute({ reason: 'Complete the external approval.' }, {
+      sessionID: 'parent-1',
+      agent: 'engineer',
+    } as never);
+
+    expect(beginUserWait).toHaveBeenCalledWith('parent-1');
+  });
+
+  test('does not arm rejected invocations', async () => {
+    const beginUserWait = mock((_sessionID: string) => {});
+    const unmanaged = createWaitForUserTool({
+      shouldManageSession: () => false,
+      beginUserWait,
+    }).wait_for_user;
+    const managed = createWaitForUserTool({
+      shouldManageSession: (sessionID) => sessionID === 'parent-1',
+      beginUserWait,
+    }).wait_for_user;
+
+    await expect(
+      managed.execute({ reason: 'wait' }, { agent: 'orchestrator' } as never),
+    ).rejects.toThrow('requires sessionID');
+    await expect(
+      managed.execute({ reason: 'wait' }, {
+        sessionID: 'child-1',
+        agent: 'fixer',
+      } as never),
+    ).rejects.toThrow('orchestrator');
+    await expect(
+      unmanaged.execute({ reason: 'wait' }, {
+        sessionID: 'parent-1',
+        agent: 'orchestrator',
+      } as never),
+    ).rejects.toThrow('orchestrator sessions');
+    await expect(
+      managed.execute({ reason: '   ' }, {
+        sessionID: 'parent-1',
+        agent: 'orchestrator',
+      } as never),
+    ).rejects.toThrow('non-empty reason');
+
+    expect(beginUserWait).not.toHaveBeenCalled();
+  });
+});

+ 65 - 0
src/tools/wait-for-user.ts

@@ -0,0 +1,65 @@
+import { type ToolDefinition, tool } from '@opencode-ai/plugin';
+
+const z = tool.schema;
+
+interface WaitForUserToolOptions {
+  shouldManageSession: (sessionID: string) => boolean;
+  resolveAgentName?: (agent: string) => string;
+  registerSessionAsOrchestrator?: (sessionID: string) => void;
+  beginUserWait: (sessionID: string) => void;
+}
+
+export function createWaitForUserTool(
+  options: WaitForUserToolOptions,
+): Record<'wait_for_user', ToolDefinition> {
+  const wait_for_user = tool({
+    description: `Pause automatic continuation while waiting for external human action.
+
+Use this only as the final tool action after you have already given the user concrete manual steps. The next distinct external user message resumes normal continuation. For an immediate answer, choice, clarification, or pasted output, use the question tool instead.`,
+    args: {
+      reason: z
+        .string()
+        .min(1)
+        .max(500)
+        .describe(
+          'Short description of the external human action being awaited',
+        ),
+    },
+    async execute(args, toolContext) {
+      const sessionID = toolContext?.sessionID;
+      if (!sessionID) throw new Error('wait_for_user requires sessionID');
+      const rawAgent = toolContext?.agent;
+      const agent =
+        typeof rawAgent === 'string'
+          ? (options.resolveAgentName?.(rawAgent) ?? rawAgent)
+          : undefined;
+      if (agent && agent !== 'orchestrator') {
+        throw new Error('wait_for_user can only be used by orchestrator');
+      }
+      if (!options.shouldManageSession(sessionID)) {
+        if (agent === 'orchestrator') {
+          options.registerSessionAsOrchestrator?.(sessionID);
+        }
+      }
+      if (!options.shouldManageSession(sessionID)) {
+        throw new Error(
+          'wait_for_user can only be used in orchestrator sessions',
+        );
+      }
+
+      const reason = args.reason.replace(/\s+/g, ' ').trim();
+      if (!reason) throw new Error('wait_for_user requires a non-empty reason');
+
+      options.beginUserWait(sessionID);
+      return [
+        'state: waiting_for_user',
+        'protocol: oh-my-opencode-slim.wait_for_user.v1',
+        `reason: ${reason}`,
+        '',
+        'End this turn now. Do not call more tools until the user responds.',
+      ].join('\n');
+    },
+  });
+
+  return { wait_for_user };
+}