Эх сурвалжийг харах

feat(v2): adapt to current opencode2 builds and port orchestrator-wake to v2

Refresh the v2 host adapter against opencode2 beta-19365 and port the
orchestrator wake scheduler to v2 in a children-driven degraded mode.

v2 adapter:
- session.delete now delegates to session.remove (smartfetch temp
  sessions no longer leak); session.list delegates for real (parentID
  and root-only filters pass through)
- execute.after honors status discrimination: error status synthesizes
  the v1 output from the error text instead of surfacing empty content
- event adapter reads live wire payloads under `data` (properties kept
  as legacy fallback); synthesizes v1 lifecycle from
  session.execution.started/succeeded/failed/interrupted now that hosts
  no longer publish session.status busy/idle
- bridges the v2 Form flow (form.created/replied/cancelled) to v1
  question.asked/replied/rejected so wait_for_user/input-wait tracking
  works again; maps permission.asked fields to v1 names
- registers the native session.prompt hook (messageID dedupe, agent
  learned from context events) with the context-hook emulation kept as
  fallback for older hosts
- stamps options:{codemode:false} on every tool registration: without
  it v2's CodeMode split confines plugin tools to the execute-tool
  runtime and catalogs yield `Unknown tool`
- enriches bridged transcript user messages with sessionID/agent
  (metadata-only, absence-gated) to restore phase-reminder, job-board
  and post-file-tool-nudge injection gating
- tags v2-injected parts with cache:{type:"ephemeral"} (optional spec
  field; v1 payloads stay byte-identical); synthetic gains
  resume/delivery (interview notify uses resume:false)
- capability-gates runtime-status reconciliation (no more 5s
  uncertainty spam when session.status is absent)
- carries the internal-initiator marker through prompt metadata so the
  two-wake no-progress cap survives on v2

orchestrator-wake:
- children-driven degraded mode (orchestratorWake.mode: auto|todo|
  children): wakes when a child session lacks a terminal outcome or a
  stopped job needs recovery; enumeration via session.list({parentID})
  with an event-tracked fallback; staleness bound 3x interval; wake
  delivered with delivery:"queue" (v1 promptAsync keeps no delivery
  key); v1 gate and wake behavior unchanged

docs/schema: opencode-v2-compatibility.md refreshed (verified against
opencode2 beta-19365, bridges:11, 2026-09-09 live mock-driven runs),
hook name fix (session.model.request), new config documented; JSON
schema regenerated; codemaps updated.

v1 byte-identity: cache-safety snapshot/property/tripwire suites pass
with zero snapshot updates; all pre-existing scheduler tests unmodified.
GoldJohnKing 6 өдөр өмнө
parent
commit
77d0c7d0c5

+ 5 - 3
README.md

@@ -137,9 +137,11 @@ bun run build
 The same package runs on both OpenCode v1 and v2. On v2 you get the full
 agent pantheon, delegation through the host `subagent` tool (bridged into the
 background job board), all built-in tools and slash commands, auto-registered
-MCPs, `/preset` in the TUI, webfetch secondary-model summaries, and
-rate-limit model fallback. Multiplexer panes and the orchestrator-wake
-scheduler stay v1-only by design (v2 renders and notifies subagents natively).
+MCPs, `/preset` in the TUI, webfetch secondary-model summaries, rate-limit
+model fallback, and the orchestrator-wake scheduler in children-driven
+degraded mode (a periodic watchdog over stuck background children and
+unreconciled jobs). Multiplexer panes stay v1-only by design (v2 renders and
+notifies subagents natively).
 
 v2 auto-refreshes unpinned plugins on startup, so pin an exact version while
 both v2 and this adapter evolve quickly:

+ 5 - 2
codemap.md

@@ -43,7 +43,7 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 | `src/hooks/post-file-tool-nudge/` | Post-read/write reminder path that nudges delegation-aware next steps. | [View Map](src/hooks/post-file-tool-nudge/codemap.md) |
 | `src/hooks/task-session-manager/` | Resumable `task` session tracking: job-board injection, short alias resolution, cache-safe prompt injection, idle/stop-confirmation reconciliation, live runtime-status reads, HITL wait gating, and revived-run tracking. | [View Map](src/hooks/task-session-manager/codemap.md) |
 | `src/hooks/cache-monitor/` | Observation-only runtime watchdog over provider cache telemetry (`tokens.cache.read/write`) that warns on prompt-cache busts and frozen-prefix plateaus. | [View Map](src/hooks/cache-monitor/codemap.md) |
-| `src/hooks/orchestrator-wake/` | Periodic orchestrator wake scheduler: after continuous parent idle, sends a static internal wake prompt when incomplete TODOs remain; process-global one-flight/no-progress gate. | [View Map](src/hooks/orchestrator-wake/codemap.md) |
+| `src/hooks/orchestrator-wake/` | Periodic orchestrator wake scheduler: after continuous parent idle, sends a static internal wake prompt when incomplete TODOs remain (v1) or when background children lack a terminal outcome (v2 children-driven degraded mode); process-global one-flight/no-progress gate. | [View Map](src/hooks/orchestrator-wake/codemap.md) |
 | `src/hooks/loop-command/` | `/loop` runtime command: extracts goal/successCriteria/maxAttempts and drives an iterative retry loop with a per-run history directory. | [View Map](src/hooks/loop-command/codemap.md) |
 | `src/interview/` | `/interview` feature: per-session and dashboard prompt/state orchestration, persistence, local UI, and cross-process coordination. | [View Map](src/interview/codemap.md) |
 | `src/mcp/` | Built-in MCP registry and per-provider MCP definitions. | [View Map](src/mcp/codemap.md) |
@@ -104,7 +104,10 @@ This codemap covers the plugin repository itself and excludes the nested `openco
 - `src/tools/preset-switch.ts` + `src/tui-preset.ts` implement `/preset` switching: the preset name (or preset edits) is persisted to the user config file and takes effect on the next reload; the agent registry is never hot-swapped mid-session.
 - `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts`, `background-job-store.ts`, `background-job-coordinator.ts`, `background-job-supervisor.ts`, `session-runtime-status.ts`, and `task.ts`, and injects prompt content only through `src/hooks/cache-safe-injection.ts`.
 - `src/hooks/cache-monitor/` watches `message.updated` cache telemetry across all sessions and logs prompt-cache-bust/plateau warnings; it is observation-only and never mutates messages.
-- `src/hooks/orchestrator-wake/` reads host todo/children/status APIs, gates on the task-session-manager's `hasInputWait` and continuation-model seams, and shares one-flight/no-progress state via a process-global wake gate.
+- `src/hooks/orchestrator-wake/` reads host todo/children/status APIs (v1)
+  or `session.list({parentID})` + event tracking (v2 degraded mode), gates on
+  the task-session-manager's `hasInputWait` and continuation-model seams, and
+  shares one-flight/no-progress state via a process-global wake gate.
 - `src/v2/` wraps the v1 factory for the v2 host: `setup(ctx)` shims a v1 `PluginInput`, runs the v1 `config()` hook, and adapts agent/tool/command/hook registrations into v2 domains.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
 - `src/interview/` hooks into plugin command/event surfaces exposed by `src/index.ts`.

+ 21 - 5
docs/background-orchestration.md

@@ -359,15 +359,19 @@ periodic internal wake prompt so incomplete TODOs are not abandoned. This is
   "backgroundJobs": {
     "orchestratorWake": {
       "enabled": true,
-      "intervalMs": 300000
+      "intervalMs": 300000,
+      "mode": "auto"
     }
   }
 }
 ```
 
 `intervalMs` must be an integer from `60000` to `2147483647`. `0` is invalid.
-Set `enabled: false` to disable wakes while keeping idle reconciliation and
-background-job orchestration.
+`mode` selects the wake condition: `"auto"` (default) uses todo-gating on v1
+hosts and children-driven mode on v2 hosts; `"todo"` and `"children"` pin one
+mode (an explicit `"todo"` degrades to children on hosts without the todo
+API). Set `enabled: false` to disable wakes while keeping idle reconciliation
+and background-job orchestration.
 
 Behavior:
 
@@ -406,8 +410,20 @@ The scheduler does **not** perform automatic cancellation and does not rely on
 the local job board. When no incomplete TODOs remain, it ends the current idle
 spell and stops polling until new activity.
 
-**v2 availability:** the v2 shim lacks the required session APIs, so this
-capability-gated feature remains inactive there.
+**v2 hosts (children-driven degraded mode):** v2 has no todo/children/status
+surfaces, so with `mode: "auto"` the scheduler runs in children-driven mode.
+The wake condition becomes "children without a terminal `outcome`" — v2
+records an outcome (succeeded|failed|interrupted) only on terminal transition —
+plus pending stopped-job recovery. Children are enumerated via
+`session.list({parentID})` (event-tracked fallback from `session.created`
+links when the listing is unavailable), scoped to the session's directory, and
+a child with no fresh update evidence (host `time.updated` or a tracked status
+change within 3× the interval) counts as inactive. The wake prompt asks the
+orchestrator to check on unfinished background child sessions and unreconciled
+jobs, is delivered with `queue` semantics (like v1's queued prompt_async), and
+the children-only fingerprint keeps the two-wake no-progress cap bounding
+cost. v2's native subagent completion nudges still cover the happy path; this
+watchdog covers stuck children and unreconciled jobs.
 
 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

+ 6 - 3
docs/configuration.md

@@ -157,8 +157,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.maxRetainedSnapshots` | integer | `20` | Maximum board snapshots retained per checkpoint cache epoch (1–100). Adding a snapshot beyond the limit starts a new epoch with only the current snapshot, intentionally creating one cache miss See [Background Job Management](#background-job-management). |
 | `backgroundJobs.strategy` | `"latest"` \| `"checkpoint-compatible"` | `"latest"` | Board injection strategy. `latest` preserves the current strip-and-replace behavior; `checkpoint-compatible` appends only when the formatted board changes and uses `backgroundJobs.maxRetainedSnapshots` per cache epoch. Cache state resets on compaction/session boundaries and is lost on plugin restart See [Background Job Management](#background-job-management). |
-| `backgroundJobs.orchestratorWake.enabled` | boolean | `true` | When true, idle orchestrator sessions with incomplete todos may receive periodic internal wake prompts (default every 5 minutes of continuous parent idle). Requires host session APIs; inactive on the v2 shim. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.orchestratorWake.enabled` | boolean | `true` | When true, idle orchestrator sessions with incomplete todos may receive periodic internal wake prompts (default every 5 minutes of continuous parent idle). Requires host session APIs. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
 | `backgroundJobs.orchestratorWake.intervalMs` | integer | `300000` | Continuous parent-idle interval between wake evaluations (`60000`–`2147483647` ms). `0` is invalid. See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler) See [Background Job Management](#background-job-management). |
+| `backgroundJobs.orchestratorWake.mode` | string | `"auto"` | Wake-condition source: `"auto"` uses todo-gating on OpenCode v1 and children-driven degraded mode on v2 hosts; `"todo"`/`"children"` pin one mode (explicit `"todo"` degrades to children where no todo API exists). See [Background Orchestration](background-orchestration.md#orchestrator-wake-scheduler). |
 | `backgroundJobs.wallClockTimeoutMs` | integer | `0` | **Opt-in wall-clock supervisor.** `0` disables it. Otherwise, only native `task(..., background: true)` child sessions are supervised; accepted values are `60000`–`2147483647` milliseconds See [Background Job Management](#background-job-management). |
 | `backgroundJobs.abortGraceMs` | integer | `10000` | Grace period after a wall-clock deadline for a terminal confirmation. Accepted values are `1000`–`60000` milliseconds; a hanging or failed abort does not extend this grace See [Background Job Management](#background-job-management). |
 | `backgroundJobs.concurrency.defaultConcurrency` | integer | `0` | Maximum concurrently running native background tasks. `0` means unlimited; accepted values are `0`–`1000` See [Background Job Management](#background-job-management). |
@@ -318,7 +319,8 @@ The wall-clock supervisor is separately opt-in and remains disabled unless
     "maxRetainedSnapshots": 10,
     "orchestratorWake": {
       "enabled": true,
-      "intervalMs": 300000
+      "intervalMs": 300000,
+      "mode": "auto"
     },
     "wallClockTimeoutMs": 900000,
     "abortGraceMs": 10000,
@@ -335,7 +337,8 @@ The wall-clock supervisor is separately opt-in and remains disabled unless
 }
 ```
 
-`orchestratorWake` defaults to enabled with a 5-minute continuous-idle interval.
+`orchestratorWake` defaults to enabled with a 5-minute continuous-idle
+interval and `"auto"` mode (todo-gated on v1 hosts, children-driven on v2).
 Set `enabled: false` to keep idle reconciliation and background-job orchestration
 without periodic wake prompts. See the
 [Background Orchestration](background-orchestration.md) guide for the concept,

+ 185 - 39
docs/opencode-v2-compatibility.md

@@ -61,11 +61,14 @@ entrypoint v2 loads when the `dist/server` directory is registered directly
 (see [Installing on v2](#installing-on-v2)); the release artifact check
 requires it. v1 uses the main entry.
 
-Verified against opencode2 `beta-18743` (all bridges green — health check
-`bridges:10`). Every v2 API the adapter touches is capability-probed at
-runtime (`typeof ctx.mcp?.transform === 'function'`, `s.switchModel`,
-`ctx.generate`, …), so a host lacking one capability degrades that single
-feature with a log line instead of breaking the load.
+Verified against opencode2 `beta-19365` (all bridges green — health check
+`bridges:11`; live mock-driven re-verification on 2026-09-09 exercised the
+event-stream bridge end-to-end, including the orchestrator-wake
+children-driven degraded mode firing a queued wake after 60 s of parent
+idle with a stalled background child). Every v2 API the adapter touches is
+capability-probed at runtime (`typeof ctx.mcp?.transform === 'function'`,
+`s.switchModel`, `ctx.generate`, …), so a host lacking one capability
+degrades that single feature with a log line instead of breaking the load.
 
 ## The v2 adapter (`src/v2/setup.ts`)
 
@@ -76,7 +79,12 @@ feature with a log line instead of breaking the load.
    and a shim `client` that **really delegates** the v1 SDK call shapes to
    v2 flat session calls — `session.get`, `session.abort`→`interrupt`,
    `session.messages`→`context`, `session.prompt` (as `delivery: "steer"`),
-   and `session.update`→`rename`. The shim marks the input
+   `session.update`→`rename`, `session.delete`→`remove` (same
+   `DELETE /api/session/:id`; stops the smartfetch secondary-model temp
+   sessions leaking), and `session.list` (v2 `Session.Info` page → the v1
+   `{data}` envelope with `directory` derived from `location` and `outcome`
+   mapped, used by the interview dashboard's session scan and the
+   orchestrator-wake children enumeration). The shim marks the input
    `hostFlavor: 'v2'` and never fakes success shapes: methods the host
    lacks degrade with an honest log (or are omitted entirely, as with
    `session.get`, so capability probes see the truth).
@@ -88,7 +96,9 @@ feature with a log line instead of breaking the load.
    - `agent` → `ctx.agent.transform` (model/prompt/permission adaptation +
      `subagent`/`execute` permission mapping + prompt rewrite `task`→`subagent`
      + `draft.default("orchestrator")`)
-   - `tool` → `ctx.tool.transform` (zod shape → JSON schema; execute shimmed)
+    - `tool` → `ctx.tool.transform` (zod shape → JSON schema; execute
+      shimmed; every registration carries `options: {codemode: false}` —
+      see the feature matrix note below)
    - `mcp` → `ctx.mcp.transform` (`draft.set(name, adaptMcpServer(cfg))` for
      the built-in MCPs)
    - `command` → `ctx.command.transform` — v2 command drafts are add-only:
@@ -96,26 +106,64 @@ feature with a log line instead of breaking the load.
      `<omos-cmd-command data-name="...">` marker as a user prompt; the
      session context hook recovers it and dispatches to the v1
      `command.execute.before` hook (deepwork/reflect/loop)
-   - a single `ctx.session.hook("context")` handles the system/messages
-     transforms (SystemPart[]/Message.content shape conversion),
-     `chat.message` agent tracking, and interview + generic command marker
-     dispatch — mutating only the trailing message so earlier content stays
-     byte-identical (provider prompt-cache prefix reuse)
-   - `tool.execute.before/after` → `ctx.tool.hook` via
-     `createToolExecuteBridges` (`src/v2/setup.ts`): the host `subagent`
-     tool is normalized to v1 `task` semantics (name mapping, `agent`→
-     `subagent_type`, `sessionID`→`task_id`, and back after the hook so v2
-     executes the repaired input). A throwing `execute.before`
-     **rethrows** — v2 rejects the tool call, which is how the v1
-     anti-duplicate / relaunch-lease guards enforce on v2
-   - `event` → `ctx.event.subscribe()` loop feeding `mapV2EventToV1`
-     (`src/v2/event-adapter.ts`): additive synthesis only — the raw v2 event
-     is always dispatched first (the interview bridge depends on it), then
-     synthesized v1 shapes: idle `session.status` → `session.idle`, flat
-     child `session.created` → v1 early-registration
-     `{info: {id, parentID, agent?}}`, and usage telemetry
-     (`session.usage.updated`/`session.step.ended`) → a deduplicated
-     completed-assistant `message.updated` for the cache monitor
+    - a single `ctx.session.hook("context")` handles the system/messages
+      transforms (SystemPart[]/Message.content shape conversion),
+      `chat.message` agent tracking, and interview + generic command marker
+      dispatch — mutating only the trailing message so earlier content stays
+      byte-identical (provider prompt-cache prefix reuse). While the
+      bridged messages transform runs, parts injected through
+      `cache-safe-injection` carry a v2 `ContentPart.cache`
+      `{type: "ephemeral"}` hint (CacheHint tagging) so providers that
+      honor manual breakpoints cap the injected zone's cache contribution;
+      the hint is scoped to the v2 bridge, so v1 payload bytes never
+      change.
+    - a native `ctx.session.hook("prompt")` registration (capability-
+      guarded): the v2 prompt hook fires **once per admitted input** with
+      the eventual inbox User `messageID`, giving the v1 `chat.message`
+      consumers (task-session-manager / orchestrator-wake
+      `observeChatMessage`, `toolLoopGuard.observeNewUserMessage`) true
+      once-per-admission fidelity with prompt parts. When it registers,
+      the context hook's per-request `chat.message` emulation narrows to
+      agent/model discovery; hosts that reject the hook name keep the
+      full emulation as fallback.
+    - `tool.execute.before/after` → `ctx.tool.hook` via
+      `createToolExecuteBridges` (`src/v2/setup.ts`): the host `subagent`
+      tool is normalized to v1 `task` semantics (name mapping, `agent`→
+      `subagent_type`, `sessionID`→`task_id`, and back after the hook so
+      v2 executes the repaired input). A throwing `execute.before`
+      **rethrows** — v2 rejects the tool call, which is how the v1
+      anti-duplicate / relaunch-lease guards enforce on v2. The after
+      bridge honors v2's status discrimination: `error` events synthesize
+      the v1 after-hook output from the error text (so json-error-recovery
+      still appends its reminder to a failed call's output), and an
+      errored call never presents its result content as a success.
+    - `event` → `ctx.event.subscribe()` loop feeding `mapV2EventToV1`
+      (`src/v2/event-adapter.ts`): additive synthesis only — the raw v2 event
+      is always dispatched first (the interview bridge depends on it), then
+      synthesized v1 shapes: idle `session.status` → `session.idle`, flat
+      child `session.created` → v1 early-registration
+      `{info: {id, parentID, agent?}}`, usage telemetry
+      (`session.usage.updated`/`session.step.ended`) → a deduplicated
+      completed-assistant `message.updated` for the cache monitor, the Form
+      flow (`form.created`/`form.replied`/`form.cancelled`) → v1
+      `question.asked`/`question.replied`/`question.rejected`
+      (`form.id` → the question request id; forms owned by the `"global"`
+      sentinel are skipped), and `permission.asked` field mapping to the v1
+      names (`permission` ← `action`, `patterns` ← `resources`;
+      `permission.replied` passes through raw — v2's shape already matches
+      the v1 event). Newer hosts (verified live on beta-19365/beta-19378)
+      publish durable `session.execution.started/succeeded/failed/
+      interrupted` and no longer stream busy/idle `session.status` on the
+      event flow; those events are synthesized into the same v1 lifecycle
+      shapes (`started` → busy `session.status`; terminal subtypes → idle
+      `session.status` + `session.idle`; `failed` additionally emits a v1
+      `session.error` with the host error payload before the idle pair).
+      The `session.status` → `session.idle` path is retained for older
+      builds — hosts emitting both simply deliver idle repeatedly, which
+      the double-idle invariant tolerates. This is what keeps the
+      companion's waiting-input indicator, the task-session-manager
+      input-wait gate, orchestrator-wake suppression/arm scheduling, and
+      the foreground fallback working on v2 hosts.
    - `generate.text` → one-shot generation channel probed on `ctx.generate`
      and threaded as `experimental_v2.generateText`, powering the webfetch
      secondary-model summaries without a temp session
@@ -130,7 +178,7 @@ the rest, and a zero-registration load logs a loud health-check warning.
 |---|---|---|---|
 | Orchestrator + specialist agents, prompts & permission mapping | ✅ | ✅ `ctx.agent.transform` | — |
 | Delegation + background job board + `task_*` tools | ✅ `task` tool | ✅ host `subagent` (auto-bridged: name/args normalization in `src/v2/delegation.ts`, output parsing in the execute bridges) | — |
-| Tools (ast-grep, webfetch, task_message/task_cancel/task_revive, wait_for_user, acp_run) | ✅ | ✅ `ctx.tool.transform` | ast-grep needs its CLI binary (package, system, or lazy download); webfetch needs `jsdom` resolvable |
+| Tools (ast-grep, webfetch, task_message/task_cancel/task_revive, wait_for_user, acp_run) | ✅ | ✅ `ctx.tool.transform` | v2 requires `options: {codemode: false}` on each registration (CodeMode split): without it a tool registers cleanly but is confined to the `execute` tool's JS runtime and session catalogs yield `Unknown tool: <name>`. The plugin stamps it on every adapted tool (`adaptTool` in `src/v2/adapters.ts`; additive field, older hosts ignore it). ast-grep needs its CLI binary (package, system, or lazy download); webfetch needs `jsdom` resolvable |
 | Slash commands `/deepwork` `/reflect` `/loop` | ✅ | ✅ marker round-trip | — |
 | `/interview` | ✅ | ✅ marker command + trailing-message context bridge | — |
 | Message transforms (phase reminder, skills filter, image routing, display-name rewrite) | ✅ | ✅ via the single context hook | — |
@@ -142,8 +190,8 @@ the rest, and a zero-registration load logs a loud health-check warning.
 | `/preset` (interactive switcher) | ✅ | ✅ TUI plugin entry (`./tui` → `dist/tui2.js`): sidebar + `/preset` dialog or `/preset <name>` fast path | TUI host needs `keymap.layer` + `ui.dialog.select`; config-file `preset` still applies at load |
 | TUI default agent | ✅ orchestrator | ✅ orchestrator — `draft.default("orchestrator")`; the v2 TUI honors `default_agent` and hoists the default to the head of the agent list | — |
 | Multiplexer (tmux/zellij/herdr/cmux panes) | ✅ | ❌ host-gated off (`hostFlavor: 'v2'` → `shouldEnableMultiplexer` returns false and the session manager is forced to `type: "none"`) | by design — v2 renders subagents natively |
-| Orchestrator-wake scheduler | ✅ | ❌ intentionally not ported | v2's built-in `subagent` tool posts completion notifications to the parent natively, covering the scheduler's job |
-| `chat.headers` (custom request headers) | ✅ | ❌ unbridged | low value: v2 exposes an HTTP request hook (`session.hook("http.request")`) — will bridge only if asked for |
+| Orchestrator-wake scheduler | ✅ todo-gated (host `todo`/`children`/`status` APIs) | ✅ children-driven degraded mode (`backgroundJobs.orchestratorWake.mode`) | v2 wake enumerates children via `session.list({parentID})` with an event-tracked fallback, gates on children without a terminal `outcome` (staleness-bounded), and delivers with `queue`; v2's native subagent completion nudges still cover the happy path — the port adds a periodic watchdog for stuck children and unreconciled jobs |
+| `chat.headers` (custom request headers) | ✅ | ❌ unbridged | low value: v2 exposes a model request hook (`session.hook("model.request")`, with mutable `headers`) — will bridge only if asked for |
 | Companion app | ✅ | ⚠️ unverified | independent desktop app; test separately against v2 |
 
 ## Upstream behaviors to know
@@ -151,11 +199,69 @@ the rest, and a zero-registration load logs a loud health-check warning.
 Behaviors of v2 itself that plugin authors should know about — none
 currently break this plugin:
 
+- **Event payloads ride under `data`, not `properties`.** The v2
+  event stream (SSE and `ctx.event.subscribe()`) frames each event as
+  `{id, created, type, location?, durable?, data}` — the payload is the
+  `data` record, unlike the v1 SDK's `properties` (verified live on
+  beta-19365: every observed event keyed exactly
+  `["id","created","type","durable","data"]`). The adapter reads `data`
+  first with `properties` as a legacy fallback and always writes
+  `properties` on the synthesized v1 shapes, because that is the key the
+  v1 consumers read.
+- **Lifecycle keys on `session.execution.*` on newer hosts.** Verified
+  live hosts (beta-19365/beta-19378) publish durable
+  `session.execution.started/succeeded/failed/interrupted` events
+  (`{sessionID}`, plus `error` on `.failed` and `reason` on
+  `.interrupted`) and no longer publish busy/idle `session.status` on
+  the SSE event stream (`session.status` remains only in the schema).
+  The adapter synthesizes the v1 lifecycle shapes from the execution
+  events (`started` → busy `session.status`; terminal subtypes → idle
+  `session.status` + `session.idle`; `failed` → a v1 `session.error`
+  with the host error payload passed through best-effort, emitted before
+  the idle pair so the error-then-idle flow the event-router expects is
+  preserved). The `session.status` mapping is retained for older builds;
+  a host emitting both delivers idle repeatedly — the double-idle
+  invariant above covers it. Without this synthesis the
+  orchestrator-wake scheduler never arms on live v2 hosts.
+- **Transcript user messages carry no identity.** Context-hook
+  transcript user messages on live v2 hosts carry `{id, time, text,
+  type}` only — no `agent`, no `sessionID`. The v1 injection gates
+  (phase-reminder, background-job-board, post-file-tool-nudge) key on
+  user-message `info.agent`/`info.sessionID`, so every injection would
+  skip. The v2 context bridge stamps the context event's `sessionID` and
+  the session's known agent (from the event, falling back to the
+  session-prompt bridge's learned state) onto transcript user messages
+  before the bridged messages transform runs — metadata-only envelope
+  enrichment, strictly absence-gated (host-provided values never
+  overwritten), parts/content bytes untouched, idempotent across
+  context events. This also makes the CacheHint-tagged injected parts
+  observable on live v2 hosts.
+- **Runtime status reconciliation is capability-gated.** v2 has no
+  equivalent of the v1 live session-status map (`client.session.status`
+  is not a function on v2 hosts), so the task-session-manager's
+  runtime-status reconciliation poll is disabled entirely on hosts
+  without the method — a single per-instance log line notes the
+  disabled reconciliation instead of logging uncertainty every ~5s poll.
+  v1 hosts expose the method and keep the exact historical polling
+  behavior. Background job stop-confirmation was never obtainable from
+  the v2 poll anyway (the lookup failed every time).
 - **Duplicate idle delivery.** v2 favors `session.status` over
   `session.idle`; the adapter synthesizes `session.idle` additively, so a
   consumer watching both events sees idle twice per session. Current
   consumers are idempotent per session (idle-reconciliation's per-session
   timer guards); new idle consumers must tolerate duplicate delivery.
+- **Duplicate `permission.asked` delivery.** The adapter appends a
+  v1-field-mapped copy after the raw v2 `permission.asked` event (raw
+  first is a load-bearing invariant for v2-native handlers). Consumers
+  watching both see the ask twice with the same request id — safe because
+  every ask consumer is idempotent per request id (the input-wait
+  tracker's Set, the companion's status setters, wake suppression); new
+  ask consumers must tolerate it, like idle.
+- **Question flow is Form-based.** v2 replaced `question.*` with the Form
+  flow; the adapter synthesizes `question.asked/replied/rejected` from
+  `form.created/replied/cancelled` so v1 consumers keep working. Forms
+  owned by the `"global"` sentinel session (MCP elicitation) are not
+  synthesized — v1 question events are session-scoped.
 - **MCP tool-name namespaces are host-generated.** This plugin never
   matches raw MCP tool names: MCP access is granted per server name
   (`"mcps": ["context7", "!gh_grep"]` in agent config), and registration
@@ -227,7 +333,10 @@ session's model (`session.switchModel`) and steers the re-prompt through
 `/interview` is supported on v2 through a marker command and a
 trailing-message context bridge. The bridge keeps an in-memory transcript
 projection from v2 context and streamed text events, and uses the v2 session
-methods for prompts, notifications, and renames. The markdown document
+methods for prompts, notifications, and renames. Interview notifications
+admit the synthetic input with `resume: false` — the interview URL lands in
+the session without waking an agent turn (the v1 `noReply` prompt
+equivalent). The markdown document
 remains the durable source of truth; completion responses without
 `<interview_state>` rewrite the current spec while retaining frontmatter and
 Q&A history.
@@ -238,13 +347,45 @@ Q&A history.
   v2 renders subagents natively, so the multiplexer is host-gated off on v2
   (`shouldEnableMultiplexer` / `sessionManagerMultiplexerConfig` in
   `src/index.ts`).
-- **Orchestrator-wake scheduler** (`backgroundJobs.orchestratorWake`).
-  Intentionally not ported: v2's built-in `subagent` tool already nudges an
-  idle parent with unfinished work by posting completion notifications
-  natively. The capability also depends on host `todo`/`children` surfaces
-  the v2 shim does not provide.
-- **`chat.headers`.** Not bridged (low value on v2 — an HTTP request hook
-  exists if demand appears).
+- **`chat.headers`.** Not bridged (low value on v2 — a model request hook
+  exists, `session.hook("model.request")` with mutable `headers`, if
+  demand appears).
+
+### Orchestrator-wake on v2 (children-driven degraded mode)
+
+The wake scheduler is **active on v2** in a degraded mode, configured with
+`backgroundJobs.orchestratorWake.mode` (`"auto"` | `"todo"` | `"children"`,
+default `"auto"`: todo-gating on v1, children-driven on v2; an explicit
+`"todo"` degrades to children because v2 has no todo surface — logged once).
+
+How it differs from the v1 path:
+
+- **Gate:** v2 requires only the shim's `session.list` + `promptAsync`
+  (`session.get` is optional model enrichment). v1 keeps its exact
+  historical probe set (`get`/`todo`/`children`/`status`/`promptAsync`).
+- **Children enumeration:** `session.list({ parentID })` through the shim
+  (v2 `Session.Info` → v1 envelope; `outcome` and `time.updated` mapped).
+  When the listing is unavailable (missing/erroring/empty), an event-tracked
+  fallback uses the adapter-synthesized `session.created` parentID links plus
+  tracked busy/idle statuses. Results are scoped to the session's directory
+  when the host reports one.
+- **Wake condition:** children with `outcome === undefined` (v2 records an
+  outcome only on terminal transition: succeeded|failed|interrupted) that
+  still have fresh update evidence — host `time.updated` or a tracked status
+  change newer than 3× the wake interval (staleness bound for children that
+  crash mid-run without recording an outcome). Stopped-job recovery wakes
+  bypass the condition, as on v1.
+- **Wake delivery:** `delivery: "queue"` — v1 `prompt_async` queued, and a
+  v2 `steer` would hijack an in-flight run. The shim's `promptAsync` keeps
+  `steer` as the default so the foreground-fallback replay is unchanged.
+- **Fingerprint:** children-only (id + outcome + tracked status + update
+  evidence); the two-wake no-progress cap still bounds cost.
+
+v2's built-in `subagent` tool still posts completion notifications to the
+parent natively — that covers the happy path. What the port adds is a
+periodic watchdog: an idle parent with a stuck or unreconciled child (or a
+job that stopped without a terminal result) gets woken to assess, cancel, or
+respawn, bounded by the same no-progress cap as v1.
 
 ### Environment caveats
 
@@ -270,4 +411,9 @@ Q&A history.
   pipeline under the same cache-safety contract: only trailing messages are
   mutated, earlier content stays byte-identical, and the v1 enforcement
   suite (`src/hooks/cache-safety.property.test.ts` and friends) covers the
-  shared transform code the v2 context hook invokes.
+  shared transform code the v2 context hook invokes. The one v2-only
+  addition is CacheHint tagging: parts injected through
+  `cache-safe-injection` while the v2 context bridge runs carry
+  `cache: {type: "ephemeral"}` (v2 `ContentPart.cache`). The hint is
+  applied via a scoped default inside the v2 bridge only — v1 callers
+  never set it, so the v1 payload (and its snapshots) stay byte-identical.

+ 13 - 2
oh-my-opencode-slim.schema.json

@@ -1115,9 +1115,10 @@
         "orchestratorWake": {
           "default": {
             "enabled": true,
-            "intervalMs": 300000
+            "intervalMs": 300000,
+            "mode": "auto"
           },
-          "description": "Periodic orchestrator wake scheduler for idle sessions with incomplete todos. Default enabled at a 5-minute interval. Requires host session APIs (session.get, todo, children, status, promptAsync); inactive on the v2 shim.",
+          "description": "Periodic orchestrator wake scheduler for idle sessions. v1: requires host session APIs (session.get, todo, children, status, promptAsync) and wakes while incomplete todos remain. v2: runs in children-driven degraded mode (requires session.list + promptAsync) and wakes while un-finished child sessions remain. Default enabled at a 5-minute interval.",
           "type": "object",
           "properties": {
             "enabled": {
@@ -1131,6 +1132,16 @@
               "type": "integer",
               "minimum": 60000,
               "maximum": 2147483647
+            },
+            "mode": {
+              "default": "auto",
+              "description": "Wake-condition source. \"auto\" uses todo-gating on v1 hosts and children-driven degraded mode on v2 hosts (no todo surface there); \"todo\" or \"children\" pin one mode, degrading to children when the host lacks the todo API. Default \"auto\".",
+              "type": "string",
+              "enum": [
+                "auto",
+                "todo",
+                "children"
+              ]
             }
           }
         },

+ 4 - 0
src/cache-safety-tripwire.test.ts

@@ -74,6 +74,10 @@ const ALLOWLIST = new Map<string, string>([
     'hooks/image-hook.ts',
     'Date.now() throttles temp-image cleanup; extracted image paths are deterministic per part id.',
   ],
+  [
+    'hooks/orchestrator-wake/index.ts',
+    'Date.now() timestamps event-tracked child/status bookkeeping for wake decisions (staleness bound, busy-set); the wake prompt text is a static constant and never derives from them.',
+  ],
   [
     'hooks/auto-update-checker/cache.ts',
     'Date.now() and process.pid name an on-disk quarantine directory during the atomic publish transaction; the path is filesystem bookkeeping, never serialized into prompt content.',

+ 1 - 0
src/config/loader.test.ts

@@ -674,6 +674,7 @@ describe('onWarning callback', () => {
     expect(config.backgroundJobs?.orchestratorWake).toEqual({
       enabled: false,
       intervalMs: 120_000,
+      mode: 'auto',
     });
     expect(config.backgroundJobs).not.toHaveProperty('continueOnIdle');
     expect(config.autoUpdate).toBe(false);

+ 6 - 1
src/config/runtime.test.ts

@@ -131,7 +131,12 @@ describe('RuntimeConfig', () => {
       providerConcurrency: {},
       modelConcurrency: {},
     });
-    expect(runtime.fallback).toEqual({ enabled: true, maxRetries: 3 });
+    expect(runtime.fallback).toEqual({
+      enabled: true,
+      maxRetries: 3,
+      initialRetryDelayMs: 0,
+      retryDelayMs: 500,
+    });
     expect(runtime.webfetch.enabled).toBe(true);
     expect(runtime.acpAgents).toEqual({});
     expect(runtime.companion).toBeUndefined();

+ 1 - 1
src/config/runtime.ts

@@ -79,7 +79,7 @@ const DEFAULT_BACKGROUND_JOBS: BackgroundJobsConfig = {
   readContextMinLines: DEFAULT_READ_CONTEXT_MIN_LINES,
   readContextMaxFiles: DEFAULT_READ_CONTEXT_MAX_FILES,
   maxRetainedSnapshots: DEFAULT_MAX_RETAINED_SNAPSHOTS,
-  orchestratorWake: { enabled: true, intervalMs: 300_000 },
+  orchestratorWake: { enabled: true, intervalMs: 300_000, mode: 'auto' },
   wallClockTimeoutMs: 0,
   abortGraceMs: 10_000,
   concurrency: {

+ 25 - 1
src/config/schema.test.ts

@@ -198,7 +198,7 @@ describe('PluginConfigSchema backgroundJobs', () => {
     }
   });
 
-  it('defaults orchestratorWake to enabled with a 5-minute interval', () => {
+  it('defaults orchestratorWake to enabled with a 5-minute interval and auto mode', () => {
     const result = PluginConfigSchema.safeParse({ backgroundJobs: {} });
 
     expect(result.success).toBe(true);
@@ -206,6 +206,7 @@ describe('PluginConfigSchema backgroundJobs', () => {
       expect(result.data.backgroundJobs?.orchestratorWake).toEqual({
         enabled: true,
         intervalMs: 300_000,
+        mode: 'auto',
       });
     }
   });
@@ -222,10 +223,33 @@ describe('PluginConfigSchema backgroundJobs', () => {
       expect(result.data.backgroundJobs?.orchestratorWake).toEqual({
         enabled: false,
         intervalMs: 120_000,
+        mode: 'auto',
       });
     }
   });
 
+  it('accepts explicit orchestratorWake.mode values', () => {
+    for (const mode of ['auto', 'todo', 'children'] as const) {
+      const result = PluginConfigSchema.safeParse({
+        backgroundJobs: { orchestratorWake: { mode } },
+      });
+      expect(result.success).toBe(true);
+      if (result.success) {
+        expect(result.data.backgroundJobs?.orchestratorWake?.mode).toBe(mode);
+      }
+    }
+  });
+
+  it('rejects unknown orchestratorWake.mode values', () => {
+    for (const mode of ['child', 'todos', 'AUTO', '', null]) {
+      expect(
+        PluginConfigSchema.safeParse({
+          backgroundJobs: { orchestratorWake: { mode } },
+        }).success,
+      ).toBe(false);
+    }
+  });
+
   it('rejects orchestratorWake.intervalMs below 60_000 including 0', () => {
     for (const intervalMs of [0, 1, 59_999, 60_000.5, -1]) {
       expect(

+ 8 - 2
src/config/schema.ts

@@ -244,10 +244,16 @@ export const BackgroundJobsConfigSchema = z.object({
         .describe(
           'Continuous parent-idle interval between orchestrator wake evaluations (60,000–2,147,483,647ms). Default 300,000 (5 minutes). 0 is invalid.',
         ),
+      mode: z
+        .enum(['auto', 'todo', 'children'])
+        .default('auto')
+        .describe(
+          'Wake-condition source. "auto" uses todo-gating on v1 hosts and children-driven degraded mode on v2 hosts (no todo surface there); "todo" or "children" pin one mode, degrading to children when the host lacks the todo API. Default "auto".',
+        ),
     })
-    .default({ enabled: true, intervalMs: 300_000 })
+    .default({ enabled: true, intervalMs: 300_000, mode: 'auto' })
     .describe(
-      'Periodic orchestrator wake scheduler for idle sessions with incomplete todos. Default enabled at a 5-minute interval. Requires host session APIs (session.get, todo, children, status, promptAsync); inactive on the v2 shim.',
+      'Periodic orchestrator wake scheduler for idle sessions. v1: requires host session APIs (session.get, todo, children, status, promptAsync) and wakes while incomplete todos remain. v2: runs in children-driven degraded mode (requires session.list + promptAsync) and wakes while un-finished child sessions remain. Default enabled at a 5-minute interval.',
     ),
   wallClockTimeoutMs: z
     .union([z.literal(0), z.number().int().min(60_000).max(2_147_483_647)])

+ 67 - 0
src/hooks/cache-safe-injection.test.ts

@@ -6,6 +6,7 @@ import {
   hasTaggedPart,
   isTaggedPart,
   isVolatileTaggedMessage,
+  setDefaultSyntheticPartCacheHint,
   stripTaggedContent,
 } from './cache-safe-injection';
 import type { MessageWithParts } from './types';
@@ -34,6 +35,72 @@ describe('createTaggedSyntheticPart', () => {
       metadata: { other: 1, [KEY]: true },
     });
   });
+
+  test('omits cache entirely when neither spec nor default provides it (v1 bytes)', () => {
+    const part = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+    });
+    expect(part).toEqual({
+      type: 'text',
+      synthetic: true,
+      text: 'hello',
+      metadata: { [KEY]: true },
+    });
+    expect('cache' in part).toBe(false);
+  });
+
+  test('copies an explicit spec cache hint (ttl included when set)', () => {
+    const part = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+      cache: { type: 'ephemeral' },
+    });
+    expect(part.cache).toEqual({ type: 'ephemeral' });
+
+    const ttl = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+      cache: { type: 'persistent', ttlSeconds: 300 },
+    });
+    expect(ttl.cache).toEqual({ type: 'persistent', ttlSeconds: 300 });
+  });
+
+  test('scoped default applies while set and restores after', () => {
+    const restore = setDefaultSyntheticPartCacheHint({ type: 'ephemeral' });
+    try {
+      const inside = createTaggedSyntheticPart({
+        text: 'hello',
+        metadataKey: KEY,
+      });
+      expect(inside.cache).toEqual({ type: 'ephemeral' });
+      // An explicit spec hint always wins over the default.
+      const explicit = createTaggedSyntheticPart({
+        text: 'hello',
+        metadataKey: KEY,
+        cache: { type: 'persistent' },
+      });
+      expect(explicit.cache).toEqual({ type: 'persistent' });
+    } finally {
+      restore();
+    }
+    const outside = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+    });
+    expect('cache' in outside).toBe(false);
+  });
+
+  test('the created hint is a copy, not a shared reference', () => {
+    const hint = { type: 'ephemeral' as const };
+    const part = createTaggedSyntheticPart({
+      text: 'hello',
+      metadataKey: KEY,
+      cache: hint,
+    });
+    expect(part.cache).not.toBe(hint);
+    expect(part.cache).toEqual(hint);
+  });
 });
 
 describe('isTaggedPart / hasTaggedPart', () => {

+ 53 - 0
src/hooks/cache-safe-injection.ts

@@ -32,6 +32,16 @@ import {
   type MessageWithParts,
 } from './types';
 
+/**
+ * Cache hint mirrored from the v2 LLM `ContentPart.cache` (`LLM.CacheHint`).
+ * Honored by anthropic-messages / google-vertex / bedrock-converse /
+ * openrouter as a manual cache-breakpoint placement; a no-op elsewhere.
+ */
+export interface SyntheticPartCacheHint {
+  type: 'ephemeral' | 'persistent';
+  ttlSeconds?: number;
+}
+
 export interface TaggedSyntheticPartSpec {
   /** Text content of the injected part. */
   text: string;
@@ -42,17 +52,60 @@ export interface TaggedSyntheticPartSpec {
   metadataKey: string;
   /** Additional metadata merged into the part (the tag key always wins). */
   extraMetadata?: Record<string, unknown>;
+  /**
+   * Optional cache hint copied onto the created part. v1 callers never
+   * pass it, so the v1 payload stays byte-identical; the v2 context
+   * bridge scopes a process default via `setDefaultSyntheticPartCacheHint`
+   * so every part injected on v2 carries it.
+   */
+  cache?: SyntheticPartCacheHint;
+}
+
+/**
+ * Current scoped default applied to parts whose spec omits `cache`.
+ * ONLY the v2 context bridge may set it (set → run bridged transform →
+ * restore); the v1 pipeline never executes inside that wrapper, so v1
+ * bytes never change.
+ */
+let currentDefaultCacheHint: SyntheticPartCacheHint | undefined;
+
+/**
+ * Set the scoped default cache hint for parts created while the returned
+ * restore function has not been called. Returns a restore closure that
+ * reinstates the previous default (call it in a `finally`).
+ */
+export function setDefaultSyntheticPartCacheHint(
+  hint: SyntheticPartCacheHint | undefined,
+): () => void {
+  const previous = currentDefaultCacheHint;
+  currentDefaultCacheHint = hint;
+  return () => {
+    currentDefaultCacheHint = previous;
+  };
 }
 
 /** Build a synthetic text part tagged with the given metadata key. */
 export function createTaggedSyntheticPart(
   spec: TaggedSyntheticPartSpec,
 ): MessagePart {
+  const cache = spec.cache ?? currentDefaultCacheHint;
   return {
     type: 'text',
     synthetic: true,
     text: spec.text,
     metadata: { ...(spec.extraMetadata ?? {}), [spec.metadataKey]: true },
+    // Copied (never shared) so later mutation of the spec/default cannot
+    // drift an already-created part.
+    ...(cache
+      ? {
+          cache: {
+            type: cache.type,
+            ...(cache.ttlSeconds !== undefined
+              ? { ttlSeconds: cache.ttlSeconds }
+              : {}),
+          },
+        }
+      : {}),
   };
 }
 

+ 8 - 2
src/hooks/foreground-fallback/index.ts

@@ -316,7 +316,10 @@ export class ForegroundFallbackManager {
   private readonly sessionRetries = new Map<string, number>();
   /** sessionID -> pending initial delay timeout handle.
    *  Cleared on recovery or session deletion. */
-  private readonly pendingInitialDelay = new Map<string, ReturnType<typeof setTimeout>>();
+  private readonly pendingInitialDelay = new Map<
+    string,
+    ReturnType<typeof setTimeout>
+  >();
   /** sessionID -> timestamp of last fallback attempt.
    *  Used to enforce retryDelayMs between consecutive attempts. */
   private readonly lastFallbackTime = new Map<string, number>();
@@ -616,7 +619,10 @@ export class ForegroundFallbackManager {
 
   /** Intervene immediately on first occurrence (tried === 0), otherwise
    *  delegate to retry budget. Used by all three event paths. */
-  private shouldTriggerFallback(sessionID: string, needsAbort = false): boolean {
+  private shouldTriggerFallback(
+    sessionID: string,
+    needsAbort = false,
+  ): boolean {
     const tried = this.sessionRetries.get(sessionID) ?? 0;
     if (tried === 0) {
       if (this.initialRetryDelayMs > 0) {

+ 1 - 0
src/hooks/index.ts

@@ -28,6 +28,7 @@ export { createJsonErrorRecoveryHook } from './json-error-recovery/hook';
 export { createLoopCommandHook } from './loop-command';
 export {
   createOrchestratorWakeScheduler,
+  ORCHESTRATOR_CHILDREN_WAKE_TEXT,
   ORCHESTRATOR_WAKE_TEXT,
   ORCHESTRATOR_WAKE_UNCHANGED_CAP,
 } from './orchestrator-wake';

+ 55 - 17
src/hooks/orchestrator-wake/codemap.md

@@ -4,29 +4,56 @@
 
 Periodic orchestrator wake scheduler. After continuous parent-idle time,
 capability-gated host session APIs may receive a static internal wake prompt
-when incomplete TODOs remain (or when a background job stopped without a
+when incomplete todos remain (or when a background job stopped without a
 terminal result). Active children do not suppress wakes; host responses are
 authoritative and the local job board is never consulted. Progress/reservation
 state is process-global so independently created hook instances share
 one-flight and the two-wake no-progress cap.
 
+On v2 hosts (hostFlavor 'v2' from the client shim) the scheduler runs in a
+children-driven degraded mode: no todo/children/status surfaces exist there,
+so children are enumerated via `session.list({parentID})` (event-tracked
+fallback), the wake condition is children without a terminal `outcome`
+(staleness-bounded at 3× the interval), and the wake prompt is delivered with
+`delivery: 'queue'`. Config: `orchestratorWake.mode` ('auto' | 'todo' |
+'children', default auto). The v1 code path is unchanged.
+
 ## Design
 
 - **Scheduler** (`index.ts`): `createOrchestratorWakeScheduler(ctx, options)`
   returns `{ event, observeChatMessage, triggerStoppedJobRecovery, suppress }`.
   - Tracks per-session local state (`generation` symbol, timer, continuous
     idle flag) only; progress lives in the process gate.
-  - Gates (`canSchedule`): config enabled, required session APIs present
-    (`get`/`todo`/`children`/`status`/`promptAsync`), managed session,
-    no input wait (`hasInputWait`), no fallback in progress, gate not stopped.
-  - Reads a host snapshot (todos + children + status map + session model) and
-    computes a fingerprint; unchanged fingerprints across wake attempts hit
-    `ORCHESTRATOR_WAKE_UNCHANGED_CAP` (2) and stop.
+  - Capability record (`probeSessionApis`): v1 keeps exactly the historical
+    probe set (get/todo/children/status/promptAsync); v2 requires only
+    list+promptAsync (get optional). `resolveWakeMode` maps the configured
+    mode to todo/children per flavor and logs one degradation note when v2
+    lacks the todo API.
+  - Gates (`canSchedule`): config enabled, capability gate ready, managed
+    session, no input wait (`hasInputWait`), no fallback in progress, gate
+    not stopped.
+  - Reads a host snapshot (todo mode: todos + children + status map +
+    session model; children mode: children list + event-tracked parent
+    status + optional model) and computes a fingerprint; unchanged
+    fingerprints across wake attempts hit `ORCHESTRATOR_WAKE_UNCHANGED_CAP`
+    (2) and stop.
+  - Checkpoint classification (`classifyTodoSnapshot` /
+    `classifyChildrenSnapshot` → `applySnapshotVerdict`): identical v1
+    check order (parent-active → active-child suppression → todo
+    condition); children mode uses the event-tracked parent race guard
+    (fail-open) and outcome-based child activity as the wake condition.
+  - Event bookkeeping: `lastStatusBySession` (busy-set + race guard),
+    `childSessions`/`childEvidence` from `session.created` parentID links
+    (both v1-shape and flat v2 events), all bounded at 512 entries FIFO and
+    cleared on `session.deleted`/dispose.
   - Wakes via `promptAsync` with a static `<system-reminder>` text
-    (`ORCHESTRATOR_WAKE_TEXT` or `ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT`),
-    reserving the wake before prompt so a failed call cannot storm retries.
+    (`ORCHESTRATOR_WAKE_TEXT`, `ORCHESTRATOR_CHILDREN_WAKE_TEXT`, or
+    `ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT`), reserving the wake before prompt
+    so a failed call cannot storm retries. v2 children mode passes
+    `delivery: 'queue'` (v1 call shape unchanged).
   - `triggerStoppedJobRecovery`: immediate recovery wake for jobs that stopped
-    without a native terminal result (separate from the periodic TODO wake).
+    without a native terminal result (separate from the periodic TODO wake;
+    bypasses the wake condition, as on v1).
   - `observeChatMessage`: real external user activity rearms the no-progress
     cap and records the observed model for continuation prompts.
 - **Gate** (`wake-gate.ts`): Process-local reservation/progress store shared
@@ -49,13 +76,15 @@ session.idle / session.status(idle)
 beginContinuousIdle() → arm interval timer
 evaluate() (one-flight via gate)
-    ├─ read host snapshot (todo/children/status)
+    ├─ read host snapshot (todo mode: todo/children/status;
+    │  children mode: list/event-tracked children + parent status)
     ├─ active status? → end idle spell
-    ├─ no incomplete todos (and not recovery)? → end idle spell
+    ├─ todo mode: active child? → schedule later; no incomplete todos? → end
+    ├─ children mode: no active (outcome-less, fresh) child? → end
     ├─ fingerprint unchanged ≥ cap? → stop
     ├─ recheck immediately before promptAsync
     ├─ commitWakeReservation
-    └─ promptAsync(internal wake reminder)
+    └─ promptAsync(internal wake reminder; v2 children mode: delivery 'queue')
 busy (wake-initiated) → endIdleSpell(rearm=false)   [cap survives]
 busy (external) / errors / user activity → rearm cap
@@ -66,12 +95,19 @@ busy (external) / errors / user activity → rearm cap
 - **Consumer**: `src/index.ts` creates the scheduler and routes `event`,
   `chat.message` (`observeChatMessage`), `wait_for_user` (`suppress`), and
   job-stopped recovery triggers to it; config comes from
-  `runtime.backgroundJobs.orchestratorWake` (`{ enabled, intervalMs }`).
+  `runtime.backgroundJobs.orchestratorWake`
+  (`{ enabled, intervalMs, mode }`).
 - **Task-session-manager seams**: `hasInputWait` (input-wait-tracker) and
   `parseContinuationModelSelection` (continuation-model-selection) gate and
   parameterize wake prompts.
 - **SessionLifecycle**: registers `session.deleted` cleanup via the
   coordinator.
+- **v2 adapter**: the client shim's `session.list` (parentID filter, v1
+  envelope with mapped `outcome`/`time.updated`/`directory`) and the
+  `promptAsync` `delivery` parameter ('queue' from the wake path; 'steer'
+  default for foreground-fallback); `src/v2/setup.ts`'s cleanup invokes the
+  v1 `dispose` hook, which synthesizes `server.instance.disposed` into the
+  scheduler.
 - **Dependencies**: `createInternalAgentTextPart` /
   `isInternalInitiatorPart` (`src/utils/internal-initiator.ts`), `log`,
   `isRecord`, `SessionLifecycle`, and the task-session-manager status/selection
@@ -83,15 +119,17 @@ busy (external) / errors / user activity → rearm cap
 
 - SDK failures during evaluation suppress the wake (reservation already
   committed), clear the expecting-busy marker, and log; the timer re-arms via
-  the finally block unless stopped.
+  the finally block unless stopped. Children-mode enumeration failures fall
+  back to event tracking instead of suppressing.
 - `server.instance.disposed` clears timers, releases owners, and drops pending
-  recovery state.
+  recovery + event-tracking state.
 - Model enrichment from `session.get` is fail-soft.
 
 ## Performance Considerations
 
 - One unref'd timer per continuously-idle managed session; timers are cleared
   on any busy/error/wait/deletion.
-- All process-global state is bounded and evicted LRU-style.
+- All process-global state is bounded and evicted LRU-style; event-tracking
+  maps are bounded at 512 entries FIFO.
 - Host snapshot reads are `Promise.all`-parallel and only happen inside the
   one-flight evaluation.

+ 685 - 0
src/hooks/orchestrator-wake/index.test.ts

@@ -3,11 +3,18 @@ import { createInternalAgentTextPart } from '../../utils';
 import { SessionLifecycle } from '../session-lifecycle';
 import { resetUserWaitGateForTests } from '../task-session-manager/user-wait-gate';
 import {
+  buildChildrenWakeFingerprint,
   buildOrchestratorWakeFingerprint,
+  CHILD_STALENESS_INTERVALS,
+  childUpdateEvidenceMs,
   createOrchestratorWakeScheduler,
+  isWakeChildActive,
+  mapWakeChild,
+  ORCHESTRATOR_CHILDREN_WAKE_TEXT,
   ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT,
   ORCHESTRATOR_WAKE_TEXT,
   ORCHESTRATOR_WAKE_UNCHANGED_CAP,
+  resolveWakeMode,
 } from './index';
 import {
   getWakeProgress,
@@ -19,6 +26,7 @@ type SessionClient = {
   todo?: ReturnType<typeof mock>;
   children?: ReturnType<typeof mock>;
   status?: ReturnType<typeof mock>;
+  list?: ReturnType<typeof mock>;
   promptAsync?: ReturnType<typeof mock>;
 };
 
@@ -114,6 +122,8 @@ function makeClient(overrides?: SessionClientFactory): SessionClient {
 function createScheduler(options?: {
   enabled?: boolean;
   intervalMs?: number;
+  mode?: 'auto' | 'todo' | 'children';
+  hostFlavor?: string;
   sessionClient?: SessionClient | null;
   shouldManageSession?: (id: string) => boolean;
   hasInputWait?: (id: string) => boolean;
@@ -126,12 +136,14 @@ function createScheduler(options?: {
   const ctx = {
     directory: options?.directory ?? '/project',
     client: { session },
+    ...(options?.hostFlavor ? { hostFlavor: options.hostFlavor } : {}),
   } as never;
 
   const scheduler = createOrchestratorWakeScheduler(ctx, {
     config: {
       enabled: options?.enabled ?? true,
       intervalMs: options?.intervalMs ?? 60_000,
+      ...(options?.mode ? { mode: options.mode } : {}),
     },
     intervalMs: options?.intervalMs ?? 60_000,
     shouldManageSession: options?.shouldManageSession ?? (() => true),
@@ -143,6 +155,26 @@ function createScheduler(options?: {
   return { scheduler, session: session as SessionClient | undefined };
 }
 
+/** v2-flavored session surface: list + promptAsync (get optional). */
+function makeV2Client(overrides?: {
+  listChildren?: Array<Record<string, unknown>>;
+  listImpl?: ReturnType<typeof mock>;
+  promptAsync?: ReturnType<typeof mock>;
+  get?: ReturnType<typeof mock>;
+  omitList?: boolean;
+}): SessionClient {
+  const client: SessionClient = {
+    promptAsync: overrides?.promptAsync ?? mock(async () => ({})),
+  };
+  if (!overrides?.omitList) {
+    client.list =
+      overrides?.listImpl ??
+      mock(async () => ({ data: overrides?.listChildren ?? [] }));
+  }
+  if (overrides?.get) client.get = overrides.get;
+  return client;
+}
+
 const originalSetTimeout = globalThis.setTimeout;
 const originalClearTimeout = globalThis.clearTimeout;
 let clock = createClock();
@@ -947,3 +979,656 @@ describe('orchestrator wake scheduler', () => {
     expect(promptAsync).toHaveBeenCalledTimes(1);
   });
 });
+
+describe('session API capability probe', () => {
+  test('v1 requires the exact historical probe set (get/todo/children/status/promptAsync)', () => {
+    const base = makeClient() as Record<string, unknown>;
+    expect(
+      createScheduler({
+        sessionClient: base as SessionClient,
+      }).scheduler._test.hasRequiredSessionApis(),
+    ).toBe(true); // list is NOT required on v1
+
+    for (const key of ['get', 'todo', 'children', 'status', 'promptAsync']) {
+      const partial = { ...base };
+      delete partial[key];
+      expect(
+        createScheduler({
+          sessionClient: partial as SessionClient,
+        }).scheduler._test.hasRequiredSessionApis(),
+      ).toBe(false);
+    }
+  });
+
+  test('v2 requires only list + promptAsync; get is optional', () => {
+    expect(
+      createScheduler({
+        hostFlavor: 'v2',
+        sessionClient: makeV2Client(),
+      }).scheduler._test.hasRequiredSessionApis(),
+    ).toBe(true);
+    expect(
+      createScheduler({
+        hostFlavor: 'v2',
+        sessionClient: makeV2Client({ omitList: true }),
+      }).scheduler._test.hasRequiredSessionApis(),
+    ).toBe(false);
+    expect(
+      createScheduler({
+        hostFlavor: 'v2',
+        sessionClient: { list: mock(async () => ({ data: [] })) },
+      }).scheduler._test.hasRequiredSessionApis(),
+    ).toBe(false);
+  });
+
+  test('v2 without todo resolves auto to children-driven mode', () => {
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      sessionClient: makeV2Client(),
+    });
+    expect(scheduler._test.wakeMode()).toBe('children');
+    expect(scheduler._test.capabilities().flavor).toBe('v2');
+  });
+
+  test('explicit todo mode on v2 degrades to children; children pins children', () => {
+    expect(
+      createScheduler({
+        hostFlavor: 'v2',
+        mode: 'todo',
+        sessionClient: makeV2Client(),
+      }).scheduler._test.wakeMode(),
+    ).toBe('children');
+    expect(
+      createScheduler({
+        hostFlavor: 'v2',
+        mode: 'children',
+        sessionClient: makeV2Client(),
+      }).scheduler._test.wakeMode(),
+    ).toBe('children');
+    // v1 with a todo API keeps explicit todo mode.
+    expect(createScheduler({ mode: 'todo' }).scheduler._test.wakeMode()).toBe(
+      'todo',
+    );
+    expect(
+      createScheduler({ mode: 'children' }).scheduler._test.wakeMode(),
+    ).toBe('children');
+    expect(createScheduler().scheduler._test.wakeMode()).toBe('todo');
+  });
+
+  test('resolveWakeMode: auto maps per flavor; todo degrades without the todo API', () => {
+    expect(resolveWakeMode('auto', { flavor: 'v1', hasTodo: true })).toBe(
+      'todo',
+    );
+    expect(resolveWakeMode('auto', { flavor: 'v2', hasTodo: false })).toBe(
+      'children',
+    );
+    expect(resolveWakeMode(undefined, { flavor: 'v1', hasTodo: true })).toBe(
+      'todo',
+    );
+    expect(resolveWakeMode('todo', { flavor: 'v1', hasTodo: false })).toBe(
+      'children',
+    );
+    expect(resolveWakeMode('children', { flavor: 'v1', hasTodo: true })).toBe(
+      'children',
+    );
+  });
+});
+
+describe('children-driven degraded mode (v2)', () => {
+  test('wakes with active children using the children wake text and queue delivery', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler, session } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [
+          {
+            id: 'child-1',
+            parentID: 'p1',
+            directory: '/project',
+            time: { updated: Date.now() },
+          },
+        ],
+      }),
+    });
+
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[Record<string, unknown>]>
+    )[0]?.[0] as {
+      path: { id: string };
+      query: { directory: string };
+      delivery?: string;
+      body: { agent: string; parts: Array<{ text: string }> };
+    };
+    expect(call.path).toEqual({ id: 'p1' });
+    expect(call.query).toEqual({ directory: '/project' });
+    expect(call.delivery).toBe('queue');
+    expect(call.body.agent).toBe('orchestrator');
+    expect(call.body.parts[0]?.text).toBe(
+      `${ORCHESTRATOR_CHILDREN_WAKE_TEXT}\n<!-- SLIM_INTERNAL_INITIATOR -->`,
+    );
+    expect(session?.list).toHaveBeenCalledWith(
+      expect.objectContaining({
+        query: { parentID: 'p1', directory: '/project' },
+      }),
+    );
+  });
+
+  test('does not wake when every child has a terminal outcome', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [
+          { id: 'c1', outcome: 'succeeded', time: { updated: Date.now() } },
+          { id: 'c2', outcome: 'failed', time: { updated: Date.now() } },
+          { id: 'c3', outcome: 'interrupted', time: { updated: Date.now() } },
+        ],
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('treats a child as inactive once its update evidence is stale', async () => {
+    const promptAsync = mock(async () => ({}));
+    const stalenessMs = 60_000 * CHILD_STALENESS_INTERVALS;
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [
+          { id: 'c1', time: { updated: Date.now() - stalenessMs - 1 } },
+        ],
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('scopes children to the session workspace via reported directory', async () => {
+    const promptAsyncLocal = mock(async () => ({}));
+    const fresh = () => Date.now();
+    const local = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync: promptAsyncLocal,
+        listChildren: [
+          { id: 'c-local', directory: '/project', time: { updated: fresh() } },
+          {
+            id: 'c-other',
+            directory: '/elsewhere',
+            time: { updated: fresh() },
+          },
+        ],
+      }),
+    });
+    await local.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsyncLocal).toHaveBeenCalledTimes(1); // local child qualifies
+
+    // Only a foreign-directory child: scoped out → no wake, spell ends.
+    const promptAsyncOther = mock(async () => ({}));
+    const other = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync: promptAsyncOther,
+        listChildren: [
+          {
+            id: 'c-other',
+            directory: '/elsewhere',
+            time: { updated: fresh() },
+          },
+        ],
+      }),
+    });
+    await other.scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p2' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsyncOther).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('stops after the unchanged cap when children make no progress', async () => {
+    const promptAsync = mock(async () => ({}));
+    const frozen = Date.now();
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1', time: { updated: frozen } }],
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(ORCHESTRATOR_WAKE_UNCHANGED_CAP);
+    expect(getWakeProgress('p1').stopped).toBe(true);
+    await clock.advance(180_000);
+    expect(promptAsync).toHaveBeenCalledTimes(ORCHESTRATOR_WAKE_UNCHANGED_CAP);
+  });
+
+  test('child update progress resets the unchanged cap', async () => {
+    const promptAsync = mock(async () => ({}));
+    let updated = Date.now();
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listImpl: mock(async () => ({
+          data: [{ id: 'c1', time: { updated } }],
+        })),
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    // Each interval the host reports fresh child progress: every wake sees a
+    // new fingerprint, so the two-wake cap keeps resetting.
+    updated += 5_000;
+    await clock.advance(60_000);
+    updated += 5_000;
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(3);
+    expect(getWakeProgress('p1').stopped).toBe(false);
+    expect(getWakeProgress('p1').unchangedWakeCount).toBe(1);
+  });
+
+  test('recovery wake bypasses the children condition', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({ promptAsync, listChildren: [] }),
+    });
+    scheduler.triggerStoppedJobRecovery('p1');
+    await clock.advance(0);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<
+        [{ body: { parts: Array<{ text: string }> }; delivery?: string }]
+      >
+    )[0]?.[0];
+    expect(call?.body.parts[0]?.text).toBe(
+      `${ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT}\n<!-- SLIM_INTERNAL_INITIATOR -->`,
+    );
+    expect(call?.delivery).toBe('queue');
+  });
+
+  test('parent-active race guard: tracked busy parent blocks the wake', async () => {
+    const promptAsync = mock(async () => ({}));
+    let managed = false;
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      shouldManageSession: (id) => managed && id === 'p1',
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1', time: { updated: Date.now() } }],
+      }),
+    });
+    // Busy while unmanaged: endIdleSpell does not run, but the status is
+    // tracked (the race-guard source on hosts without a status map).
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'p1', status: { type: 'busy' } },
+      },
+    });
+    managed = true;
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+    expect(scheduler._test.lastStatusBySession.get('p1')?.status).toBe('busy');
+  });
+
+  test('event-tracked busy-set marks a child active without list evidence', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1' }], // no time fields at all
+      }),
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'c1', status: { type: 'busy' } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('stale tracked busy child is bounded by the staleness window', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listChildren: [{ id: 'c1' }],
+      }),
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'c1', status: { type: 'busy' } },
+      },
+    });
+    // Backdate the tracked evidence past the staleness bound.
+    scheduler._test.lastStatusBySession.set('c1', {
+      status: 'busy',
+      at: Date.now() - 60_000 * CHILD_STALENESS_INTERVALS - 1,
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+});
+
+describe('children enumeration fallback (v2)', () => {
+  test('falls back to event-tracked bookkeeping when the list yields nothing', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({ promptAsync, listChildren: [] }),
+    });
+    // Synthesized v1-shape session.created carrying parentID.
+    await scheduler.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('accepts the raw flat v2 session.created shape too', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({ promptAsync, listChildren: [] }),
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.created',
+        properties: { sessionID: 'c1', parentID: 'p1' },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('falls back when session.list rejects', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({
+        promptAsync,
+        listImpl: mock(async () => {
+          throw new Error('list unavailable');
+        }),
+      }),
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+  });
+
+  test('event child gone stale no longer wakes', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({ promptAsync, listChildren: [] }),
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      },
+    });
+    scheduler._test.childEvidence.set(
+      'c1',
+      Date.now() - 60_000 * CHILD_STALENESS_INTERVALS - 1,
+    );
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+
+  test('session.deleted forgets event-tracked children', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      hostFlavor: 'v2',
+      intervalMs: 60_000,
+      sessionClient: makeV2Client({ promptAsync, listChildren: [] }),
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'c1', parentID: 'p1' } },
+      },
+    });
+    await scheduler.event({
+      event: {
+        type: 'session.deleted',
+        properties: { info: { id: 'c1' } },
+      },
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(scheduler._test.childEvidence.has('c1')).toBe(false);
+  });
+});
+
+describe('children mode on v1 (explicit opt-in)', () => {
+  test('enumerates via session.children and keeps the v1 promptAsync call shape', async () => {
+    const promptAsync = mock(async () => ({}));
+    const children = mock(async () => ({
+      data: [{ id: 'c1', time: { updated: Date.now() } }],
+    }));
+    const status = mock(async () => ({ data: {} }));
+    const { scheduler } = createScheduler({
+      mode: 'children',
+      intervalMs: 60_000,
+      sessionClient: makeClient({ promptAsync, children, status }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).toHaveBeenCalledTimes(1);
+    const call = (
+      promptAsync.mock.calls as unknown as Array<[Record<string, unknown>]>
+    )[0]?.[0] as {
+      delivery?: string;
+      body: { parts: Array<{ text: string }> };
+    };
+    expect(call.delivery).toBeUndefined(); // v1 call shape unchanged
+    expect(call.body.parts[0]?.text).toBe(
+      `${ORCHESTRATOR_CHILDREN_WAKE_TEXT}\n<!-- SLIM_INTERNAL_INITIATOR -->`,
+    );
+  });
+
+  test('v1 status-map parent activity ends the idle spell', async () => {
+    const promptAsync = mock(async () => ({}));
+    const { scheduler } = createScheduler({
+      mode: 'children',
+      intervalMs: 60_000,
+      sessionClient: makeClient({
+        promptAsync,
+        childrenData: [{ id: 'c1', time: { updated: Date.now() } }],
+        statusData: { p1: { type: 'busy' } },
+      }),
+    });
+    await scheduler.event({
+      event: { type: 'session.idle', properties: { sessionID: 'p1' } },
+    });
+    await clock.advance(60_000);
+    expect(promptAsync).not.toHaveBeenCalled();
+    expect(clock.pendingCount()).toBe(0);
+  });
+});
+
+describe('children-mode helpers', () => {
+  test('childUpdateEvidenceMs follows the update-evidence cascade numerically', () => {
+    expect(
+      childUpdateEvidenceMs({ id: 'c', time: { updated: 42, created: 1 } }),
+    ).toBe(42);
+    expect(childUpdateEvidenceMs({ id: 'c', updatedAt: 7 })).toBe(7);
+    expect(childUpdateEvidenceMs({ id: 'c', time: { created: 3 } })).toBe(3);
+    expect(childUpdateEvidenceMs({ id: 'c', time: { updated: 'x' } })).toBe(
+      undefined,
+    );
+    expect(childUpdateEvidenceMs({ id: 'c' })).toBe(undefined);
+  });
+
+  test('mapWakeChild copies id/outcome/directory/evidence and drops unknowns', () => {
+    expect(
+      mapWakeChild({
+        id: 'c1',
+        outcome: 'succeeded',
+        directory: '/project',
+        time: { updated: 10 },
+      }),
+    ).toEqual({
+      id: 'c1',
+      outcome: 'succeeded',
+      directory: '/project',
+      evidenceAt: 10,
+    });
+    expect(mapWakeChild({ nope: 1 })).toBeUndefined();
+    expect(mapWakeChild({ id: '' })).toBeUndefined();
+  });
+
+  test('isWakeChildActive: outcome wins, freshness bounds both branches', () => {
+    const now = 1_000_000;
+    const staleness = 180_000;
+    expect(
+      isWakeChildActive(
+        { id: 'c', outcome: 'failed', evidenceAt: now },
+        undefined,
+        now,
+        staleness,
+      ),
+    ).toBe(false);
+    expect(
+      isWakeChildActive(
+        { id: 'c', evidenceAt: now - staleness },
+        undefined,
+        now,
+        staleness,
+      ),
+    ).toBe(true);
+    expect(
+      isWakeChildActive(
+        { id: 'c', evidenceAt: now - staleness - 1 },
+        undefined,
+        now,
+        staleness,
+      ),
+    ).toBe(false);
+    // Busy-set with no list evidence.
+    expect(
+      isWakeChildActive(
+        { id: 'c' },
+        { status: 'busy', at: now },
+        now,
+        staleness,
+      ),
+    ).toBe(true);
+    // Stale busy-set is bounded.
+    expect(
+      isWakeChildActive(
+        { id: 'c' },
+        { status: 'busy', at: now - staleness - 1 },
+        now,
+        staleness,
+      ),
+    ).toBe(false);
+    // No evidence at all → inactive.
+    expect(isWakeChildActive({ id: 'c' }, undefined, now, staleness)).toBe(
+      false,
+    );
+  });
+
+  test('buildChildrenWakeFingerprint includes outcome, tracked status, and evidence', () => {
+    const tracked = new Map([['c1', { status: 'busy' as const, at: 5 }]]);
+    const fp = buildChildrenWakeFingerprint(
+      [
+        { id: 'c1', evidenceAt: 42 },
+        { id: 'c2', outcome: 'succeeded' },
+      ],
+      tracked,
+    );
+    expect(fp).toContain('c1::busy:42');
+    expect(fp).toContain('c2:succeeded::');
+    expect(buildChildrenWakeFingerprint([], tracked)).toBe('');
+  });
+});

+ 573 - 97
src/hooks/orchestrator-wake/index.ts

@@ -7,6 +7,16 @@
  * job board is never consulted. Progress/reservation state is process-global
  * so independently created hook instances share one-flight and the two-wake
  * no-progress cap.
+ *
+ * v2 hosts (hostFlavor 'v2', stamped by the client shim) have no todo/
+ * children/status surfaces, so the scheduler runs there in a children-driven
+ * degraded mode: children are enumerated via `session.list({parentID})`
+ * (event-tracked fallback when the listing is unavailable), the wake
+ * condition is "children without a terminal outcome" plus stopped-job
+ * recovery, and the wake prompt is delivered with `delivery: 'queue'`
+ * (v1 prompt_async queued; v2 steer would hijack an in-flight run). All new
+ * behavior is behind the host-flavor/capability probe — the v1 code path is
+ * unchanged.
  */
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { OpencodeClient } from '@opencode-ai/sdk';
@@ -43,9 +53,23 @@ export const ORCHESTRATOR_WAKE_TEXT =
 export const ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT =
   '<system-reminder>\nA background job stopped without a terminal result. Consult the Background Job Board, recover or reroute the work as needed, and do not wait for that job as if it were still running. Do not respond to this reminder.\n</system-reminder>';
 
+/** Children-mode variant (v2 degraded mode): watchdog over background
+ * children and unreconciled jobs instead of the todo list. */
+export const ORCHESTRATOR_CHILDREN_WAKE_TEXT =
+  '<system-reminder>\nCheck on unfinished background child sessions and unreconciled jobs. Await running agents; if one appears stuck, assess it and cancel/respawn only when justified. Do not respond to this reminder.\n</system-reminder>';
+
 /** After this many successful wakes with an unchanged fingerprint, stop. */
 export const ORCHESTRATOR_WAKE_UNCHANGED_CAP = 2;
 
+/**
+ * Children-driven mode: a child with `outcome === undefined` counts as
+ * inactive once its newest update evidence (host `time.updated` or a
+ * tracked status change) is older than this multiple of the wake interval.
+ * Bounds wakes when a child crashes mid-run without recording an outcome;
+ * stopped-job recovery remains the explicit path for such children.
+ */
+export const CHILD_STALENESS_INTERVALS = 3;
+
 const SUPPORTED_TODO_STATUSES = new Set([
   'pending',
   'in_progress',
@@ -55,6 +79,30 @@ const SUPPORTED_TODO_STATUSES = new Set([
 
 type SessionClient = OpencodeClient['session'];
 
+/** Todo-mode host snapshot (v1): todos + children + live status map. */
+type TodoModeSnapshot = {
+  kind: 'todo';
+  todos: Array<Record<string, unknown>>;
+  children: Array<Record<string, unknown>>;
+  status: Record<string, unknown>;
+  model?: ContinuationModelSelection;
+};
+
+/** Children-mode snapshot (v2 degraded mode / explicit 'children'). */
+type ChildrenModeSnapshot = {
+  kind: 'children';
+  children: Array<WakeChildInfo>;
+  /** v1 status-map parent activity (v2 has no status map; the event-tracked
+   * race guard covers it). */
+  hostParentActive: boolean;
+  model?: ContinuationModelSelection;
+};
+
+type WakeSnapshot = TodoModeSnapshot | ChildrenModeSnapshot;
+
+/** Checkpoint verdict shared by both wake modes. */
+type SnapshotVerdict = 'parent-active' | 'children-active' | 'no-work' | 'wake';
+
 type LocalSessionState = {
   /** Invalidates local timers/async work for this hook instance. */
   generation: symbol;
@@ -65,6 +113,10 @@ type LocalSessionState = {
 export type OrchestratorWakeConfig = {
   enabled: boolean;
   intervalMs: number;
+  /** Wake-condition source; resolved against host capabilities (see
+   * `resolveWakeMode`). Optional for callers built before the field
+   * existed — absent means 'auto'. */
+  mode?: 'auto' | 'todo' | 'children';
 };
 
 export type OrchestratorWakeOptions = {
@@ -77,22 +129,159 @@ export type OrchestratorWakeOptions = {
   intervalMs?: number;
 };
 
-function hasRequiredSessionApis(
+/**
+ * Capability record for the host session surface. The v1 branch keeps
+ * exactly the historical probe set (get/todo/children/status/promptAsync);
+ * the v2 branch (hostFlavor 'v2', stamped by the client shim) requires only
+ * list+promptAsync — `get` is optional enrichment and todo/children/status
+ * have no v2 equivalent (children-driven degraded mode covers them).
+ */
+export type WakeSessionApis = {
+  flavor: 'v1' | 'v2';
+  hasGet: boolean;
+  hasTodo: boolean;
+  hasChildren: boolean;
+  hasStatus: boolean;
+  hasList: boolean;
+  hasPromptAsync: boolean;
+  /** True when the scheduler can operate against this host surface. */
+  ready: boolean;
+};
+
+function probeSessionApis(
   session: SessionClient | undefined,
-): session is SessionClient & {
-  get: NonNullable<SessionClient['get']>;
-  todo: NonNullable<SessionClient['todo']>;
-  children: NonNullable<SessionClient['children']>;
-  status: NonNullable<SessionClient['status']>;
-  promptAsync: NonNullable<SessionClient['promptAsync']>;
-} {
-  return (
-    typeof session?.get === 'function' &&
-    typeof session.todo === 'function' &&
-    typeof session.children === 'function' &&
-    typeof session.status === 'function' &&
-    typeof session.promptAsync === 'function'
-  );
+  hostFlavor: string | undefined,
+): WakeSessionApis {
+  const flavor = hostFlavor === 'v2' ? ('v2' as const) : ('v1' as const);
+  const caps = {
+    flavor,
+    hasGet: typeof session?.get === 'function',
+    hasTodo: typeof session?.todo === 'function',
+    hasChildren: typeof session?.children === 'function',
+    hasStatus: typeof session?.status === 'function',
+    hasList: typeof session?.list === 'function',
+    hasPromptAsync: typeof session?.promptAsync === 'function',
+  } as WakeSessionApis;
+  caps.ready =
+    flavor === 'v2'
+      ? caps.hasList && caps.hasPromptAsync
+      : caps.hasGet &&
+        caps.hasTodo &&
+        caps.hasChildren &&
+        caps.hasStatus &&
+        caps.hasPromptAsync;
+  return caps;
+}
+
+export type ResolvedWakeMode = 'todo' | 'children';
+
+/**
+ * Resolve the configured wake mode against host capabilities: 'auto' uses
+ * todo-gating on v1 and children-driven degraded mode on v2; an explicit
+ * 'todo' degrades to children on hosts without the todo API (v2).
+ */
+export function resolveWakeMode(
+  configured: 'auto' | 'todo' | 'children' | undefined,
+  caps: Pick<WakeSessionApis, 'flavor' | 'hasTodo'>,
+): ResolvedWakeMode {
+  if (configured === 'children') return 'children';
+  if (configured === 'todo') {
+    return caps.flavor === 'v2' || !caps.hasTodo ? 'children' : 'todo';
+  }
+  return caps.flavor === 'v2' ? 'children' : 'todo';
+}
+
+/** Normalized child view for children-driven wake decisions. */
+export type WakeChildInfo = {
+  id: string;
+  /** v2 Session.Info.outcome — present only on terminal transition
+   * (succeeded|failed|interrupted). */
+  outcome?: string;
+  /** Workspace directory when the host reports it (scope filter). */
+  directory?: string;
+  /** Newest update-evidence timestamp (epoch ms) when known. */
+  evidenceAt?: number;
+};
+
+/** Event-tracked session status (busy-set + parent-active race guard). */
+export type TrackedSessionStatus = { status: 'busy' | 'idle'; at: number };
+
+/** Numeric variant of the update-evidence cascade (staleness bound). */
+export function childUpdateEvidenceMs(
+  child: Record<string, unknown>,
+): number | undefined {
+  const time = isObjectRecord(child.time) ? child.time : undefined;
+  const candidates = [
+    time?.updated,
+    time?.completed,
+    child.updatedAt,
+    child.updated,
+    time?.created,
+    child.createdAt,
+  ];
+  for (const value of candidates) {
+    if (typeof value === 'number' && Number.isFinite(value)) {
+      return value;
+    }
+  }
+  return undefined;
+}
+
+/** Map one host child/list entry to the normalized children-mode view. */
+export function mapWakeChild(
+  child: Record<string, unknown>,
+): WakeChildInfo | undefined {
+  if (typeof child.id !== 'string' || !child.id) return undefined;
+  const info: WakeChildInfo = { id: child.id };
+  if (typeof child.outcome === 'string' && child.outcome) {
+    info.outcome = child.outcome;
+  }
+  if (typeof child.directory === 'string' && child.directory) {
+    info.directory = child.directory;
+  }
+  const evidence = childUpdateEvidenceMs(child);
+  if (evidence !== undefined) {
+    info.evidenceAt = evidence;
+  }
+  return info;
+}
+
+/**
+ * Active-child determination for children-driven mode: a terminal outcome
+ * always wins; otherwise the child is active while its newest evidence —
+ * host update time OR tracked status change (the event busy-set) — is
+ * fresher than the staleness bound. Children with no evidence at all are
+ * inactive (cannot be proven active).
+ */
+export function isWakeChildActive(
+  child: WakeChildInfo,
+  tracked: TrackedSessionStatus | undefined,
+  now: number,
+  stalenessMs: number,
+): boolean {
+  if (child.outcome !== undefined) return false;
+  const evidenceAt = Math.max(child.evidenceAt ?? 0, tracked?.at ?? 0);
+  if (evidenceAt <= 0) return false;
+  return now - evidenceAt <= stalenessMs;
+}
+
+/** Children-mode fingerprint: id + outcome + tracked status + evidence. */
+export function buildChildrenWakeFingerprint(
+  children: Array<WakeChildInfo>,
+  trackedStatuses: ReadonlyMap<string, TrackedSessionStatus>,
+): string {
+  return children
+    .map((child) => {
+      const tracked = trackedStatuses.get(child.id);
+      return [
+        child.id,
+        child.outcome ?? '',
+        tracked?.status ?? '',
+        String(child.evidenceAt ?? ''),
+      ].join(':');
+    })
+    .sort()
+    .join('\n');
 }
 
 function isIncompleteTodoStatus(status: string): boolean {
@@ -225,13 +414,81 @@ export function createOrchestratorWakeScheduler(
   const directory = ctx.directory;
   const sessionSdk = (ctx.client as OpencodeClient).session;
 
+  /** Static host-surface capability record (the client never changes). */
+  const capabilities = probeSessionApis(
+    sessionSdk,
+    (ctx as PluginInput & { hostFlavor?: string }).hostFlavor,
+  );
+  const wakeMode = resolveWakeMode(options.config.mode, capabilities);
+  if (enabled && capabilities.flavor === 'v2' && !capabilities.hasTodo) {
+    log(
+      '[orchestrator-wake] host provides no session todo API; running in children-driven degraded mode',
+      { directory },
+    );
+  }
+
   /** Local timer/generation state only; progress lives in the process gate. */
   const localSessions = new Map<string, LocalSessionState>();
   /** Reservations this hook owns and must release when it is disposed. */
   const localWakeOwners = new Map<string, symbol>();
   const pendingStoppedRecoveries = new Set<string>();
+  /** Event-tracked session statuses (busy-set + parent race guard). */
+  const lastStatusBySession = new Map<string, TrackedSessionStatus>();
+  /** parentID → child session ids observed via session.created events. */
+  const childSessions = new Map<string, Set<string>>();
+  /** Newest event evidence (created/status change) per child, epoch ms. */
+  const childEvidence = new Map<string, number>();
   let disposed = false;
 
+  /** Bound for the event-tracked bookkeeping maps (FIFO eviction). */
+  const MAX_EVENT_TRACKED_SESSIONS = 512;
+
+  function boundTrackedMap<T>(map: Map<string, T>): void {
+    while (map.size > MAX_EVENT_TRACKED_SESSIONS) {
+      const oldest = map.keys().next().value;
+      if (oldest === undefined) break;
+      map.delete(oldest);
+    }
+  }
+
+  function recordTrackedStatus(
+    sessionID: string,
+    status: 'busy' | 'idle',
+  ): void {
+    const now = Date.now();
+    lastStatusBySession.set(sessionID, { status, at: now });
+    boundTrackedMap(lastStatusBySession);
+    if (childEvidence.has(sessionID)) {
+      childEvidence.set(sessionID, now);
+      boundTrackedMap(childEvidence);
+    }
+  }
+
+  function recordChildSession(parentID: string, childID: string): void {
+    let kids = childSessions.get(parentID);
+    if (!kids) {
+      kids = new Set();
+      childSessions.set(parentID, kids);
+      boundTrackedMap(childSessions);
+    }
+    kids.add(childID);
+    childEvidence.set(childID, Date.now());
+    boundTrackedMap(childEvidence);
+  }
+
+  function forgetSessionEvents(sessionID: string): void {
+    lastStatusBySession.delete(sessionID);
+    childEvidence.delete(sessionID);
+    childSessions.delete(sessionID);
+    for (const kids of childSessions.values()) {
+      kids.delete(sessionID);
+    }
+  }
+
+  function isParentActiveByEvents(sessionID: string): boolean {
+    return lastStatusBySession.get(sessionID)?.status === 'busy';
+  }
+
   function touchLocal(sessionID: string): LocalSessionState {
     const existing = localSessions.get(sessionID);
     if (existing) return existing;
@@ -308,7 +565,7 @@ export function createOrchestratorWakeScheduler(
 
   function canSchedule(sessionID: string): boolean {
     if (!enabled) return false;
-    if (!hasRequiredSessionApis(sessionSdk)) return false;
+    if (!capabilities.ready) return false;
     if (!options.shouldManageSession(sessionID)) return false;
     if (options.hasInputWait(sessionID)) return false;
     if (options.isFallbackInProgress?.(sessionID)) return false;
@@ -341,16 +598,34 @@ export function createOrchestratorWakeScheduler(
     if (state.timer === undefined) schedule(sessionID);
   }
 
-  async function readHostSnapshot(sessionID: string): Promise<
-    | {
-        todos: Array<Record<string, unknown>>;
-        children: Array<Record<string, unknown>>;
-        status: Record<string, unknown>;
-        model?: ContinuationModelSelection;
-      }
-    | undefined
-  > {
-    if (!hasRequiredSessionApis(sessionSdk)) return undefined;
+  /** Fail-soft session-model enrichment (v2 `get` is optional). */
+  async function readSessionModel(
+    sessionID: string,
+  ): Promise<ContinuationModelSelection | undefined> {
+    if (typeof sessionSdk?.get !== 'function') return undefined;
+    try {
+      const sessionResponse = await sessionSdk.get({
+        path: { id: sessionID },
+        query: { directory },
+        throwOnError: true,
+      });
+      // Session.model is version-dependent; read via record shape.
+      const session = isObjectRecord(sessionResponse?.data)
+        ? sessionResponse.data
+        : undefined;
+      return parseContinuationModelSelection(
+        session ? (session as Record<string, unknown>).model : undefined,
+      );
+    } catch {
+      // Model enrichment is fail-soft.
+      return undefined;
+    }
+  }
+
+  async function readHostSnapshot(
+    sessionID: string,
+  ): Promise<TodoModeSnapshot | undefined> {
+    if (!capabilities.ready) return undefined;
 
     const dirQuery = { directory };
     const [todoResponse, childrenResponse, statusResponse] = await Promise.all([
@@ -394,25 +669,10 @@ export function createOrchestratorWakeScheduler(
       return undefined;
     }
 
-    let model: ContinuationModelSelection | undefined;
-    try {
-      const sessionResponse = await sessionSdk.get({
-        path: { id: sessionID },
-        query: dirQuery,
-        throwOnError: true,
-      });
-      // Session.model is version-dependent; read via record shape.
-      const session = isObjectRecord(sessionResponse?.data)
-        ? sessionResponse.data
-        : undefined;
-      model = parseContinuationModelSelection(
-        session ? (session as Record<string, unknown>).model : undefined,
-      );
-    } catch {
-      // Model enrichment is fail-soft.
-    }
+    const model = await readSessionModel(sessionID);
 
     return {
+      kind: 'todo',
       todos: todos as Array<Record<string, unknown>>,
       children: children as Array<Record<string, unknown>>,
       status,
@@ -420,6 +680,188 @@ export function createOrchestratorWakeScheduler(
     };
   }
 
+  /**
+   * Children-driven degraded mode snapshot (v2, or explicit 'children' on
+   * v1). Children are enumerated via `session.list({parentID})` through the
+   * shim; when the listing is unavailable (missing/erroring/empty) the
+   * event-tracked bookkeeping (session.created parentID links + tracked
+   * statuses) is the fallback. Results are scoped to this workspace and
+   * enriched with the session model (fail-soft).
+   */
+  async function readChildrenSnapshot(
+    sessionID: string,
+  ): Promise<ChildrenModeSnapshot | undefined> {
+    if (!capabilities.ready) return undefined;
+
+    let children: Array<WakeChildInfo> | undefined;
+    let hostParentActive = false;
+
+    if (capabilities.flavor === 'v2') {
+      try {
+        const response = (await sessionSdk.list({
+          query: { parentID: sessionID, directory },
+        } as Parameters<SessionClient['list']>[0])) as { data?: unknown };
+        if (Array.isArray(response?.data)) {
+          children = response.data
+            .filter(isObjectRecord)
+            .map(mapWakeChild)
+            .filter((child): child is WakeChildInfo => child !== undefined);
+        }
+      } catch (error) {
+        log(
+          '[orchestrator-wake] session.list child enumeration failed; using event-tracked fallback',
+          {
+            sessionID,
+            error: error instanceof Error ? error.message : String(error),
+          },
+        );
+      }
+    } else {
+      const dirQuery = { directory };
+      const [childrenResponse, statusResponse] = await Promise.all([
+        sessionSdk.children({
+          path: { id: sessionID },
+          query: dirQuery,
+          throwOnError: true,
+        }),
+        sessionSdk.status({
+          query: dirQuery,
+          throwOnError: true,
+        }),
+      ]);
+      if (
+        !Array.isArray(childrenResponse.data) ||
+        !isObjectRecord(statusResponse.data)
+      ) {
+        return undefined;
+      }
+      if (
+        !childrenResponse.data.every(
+          (child) => isObjectRecord(child) && typeof child.id === 'string',
+        )
+      ) {
+        return undefined;
+      }
+      children = childrenResponse.data
+        .filter(isObjectRecord)
+        .map(mapWakeChild)
+        .filter((child): child is WakeChildInfo => child !== undefined);
+      hostParentActive = isActiveStatus(statusResponse.data, sessionID);
+    }
+
+    if (children === undefined || children.length === 0) {
+      const trackedKids = childSessions.get(sessionID);
+      children = trackedKids
+        ? [...trackedKids].map((id) => {
+            const evidenceAt = childEvidence.get(id);
+            return evidenceAt === undefined ? { id } : { id, evidenceAt };
+          })
+        : [];
+    }
+
+    // Workspace scoping: drop children the host reports under another
+    // directory (only when the info is available).
+    children = children.filter(
+      (child) => child.directory === undefined || child.directory === directory,
+    );
+
+    const model = await readSessionModel(sessionID);
+
+    return { kind: 'children', children, hostParentActive, model };
+  }
+
+  /** Active-child check for children-driven mode (see isWakeChildActive). */
+  function hasActiveWakeChild(children: Array<WakeChildInfo>): boolean {
+    const now = Date.now();
+    const stalenessMs = intervalMs * CHILD_STALENESS_INTERVALS;
+    return children.some((child) =>
+      isWakeChildActive(
+        child,
+        lastStatusBySession.get(child.id),
+        now,
+        stalenessMs,
+      ),
+    );
+  }
+
+  /**
+   * Classify a snapshot at a wake checkpoint. The todo branch is the exact
+   * v1 check sequence (host status map → active-child suppression →
+   * incomplete-todo condition); children mode replaces the status-map
+   * lookups with the event-tracked parent guard and the outcome-based child
+   * check (active children ARE the wake condition there — recovery wakes
+   * bypass it, as on v1).
+   */
+  function classifyTodoSnapshot(
+    snapshot: TodoModeSnapshot,
+    sessionID: string,
+    recoveryWake: boolean,
+  ): SnapshotVerdict {
+    if (isActiveStatus(snapshot.status, sessionID)) return 'parent-active';
+    if (!recoveryWake && hasActiveChild(snapshot.children, snapshot.status)) {
+      return 'children-active';
+    }
+    if (!recoveryWake && !hasIncompleteTodos(snapshot.todos)) {
+      return 'no-work';
+    }
+    return 'wake';
+  }
+
+  function classifyChildrenSnapshot(
+    snapshot: ChildrenModeSnapshot,
+    sessionID: string,
+    recoveryWake: boolean,
+  ): SnapshotVerdict {
+    if (snapshot.hostParentActive || isParentActiveByEvents(sessionID)) {
+      return 'parent-active';
+    }
+    if (!recoveryWake && !hasActiveWakeChild(snapshot.children)) {
+      return 'no-work';
+    }
+    return 'wake';
+  }
+
+  function classifySnapshot(
+    snapshot: WakeSnapshot,
+    sessionID: string,
+    recoveryWake: boolean,
+  ): SnapshotVerdict {
+    return snapshot.kind === 'children'
+      ? classifyChildrenSnapshot(snapshot, sessionID, recoveryWake)
+      : classifyTodoSnapshot(snapshot, sessionID, recoveryWake);
+  }
+
+  function buildSnapshotFingerprint(snapshot: WakeSnapshot): string {
+    return snapshot.kind === 'children'
+      ? buildChildrenWakeFingerprint(snapshot.children, lastStatusBySession)
+      : buildOrchestratorWakeFingerprint(
+          snapshot.todos,
+          snapshot.children,
+          snapshot.status,
+        );
+  }
+
+  /** Apply a checkpoint verdict; false means the evaluation ended. */
+  function applySnapshotVerdict(
+    sessionID: string,
+    verdict: SnapshotVerdict,
+  ): boolean {
+    if (verdict === 'parent-active') {
+      endIdleSpell(sessionID, true);
+      return false;
+    }
+    if (verdict === 'children-active') {
+      schedule(sessionID);
+      return false;
+    }
+    if (verdict === 'no-work') {
+      // No incomplete work: end the spell; do not keep polling.
+      endIdleSpell(sessionID, false);
+      return false;
+    }
+    return true;
+  }
+
   async function evaluate(
     sessionID: string,
     generation: symbol,
@@ -450,7 +892,10 @@ export function createOrchestratorWakeScheduler(
     localWakeOwners.set(sessionID, owner);
 
     try {
-      const snapshot = await readHostSnapshot(sessionID);
+      const snapshot =
+        wakeMode === 'children'
+          ? await readChildrenSnapshot(sessionID)
+          : await readHostSnapshot(sessionID);
       if (!snapshot || state.generation !== generation) return;
       if (!state.continuousIdle) return;
       if (!canSchedule(sessionID)) {
@@ -458,25 +903,16 @@ export function createOrchestratorWakeScheduler(
         return;
       }
 
-      if (isActiveStatus(snapshot.status, sessionID)) {
-        endIdleSpell(sessionID, true);
-        return;
-      }
-      if (!recoveryWake && hasActiveChild(snapshot.children, snapshot.status)) {
-        schedule(sessionID);
-        return;
-      }
-      if (!recoveryWake && !hasIncompleteTodos(snapshot.todos)) {
-        // No incomplete work: end the spell; do not keep polling.
-        endIdleSpell(sessionID, false);
+      if (
+        !applySnapshotVerdict(
+          sessionID,
+          classifySnapshot(snapshot, sessionID, recoveryWake),
+        )
+      ) {
         return;
       }
 
-      const fingerprint = buildOrchestratorWakeFingerprint(
-        snapshot.todos,
-        snapshot.children,
-        snapshot.status,
-      );
+      const fingerprint = buildSnapshotFingerprint(snapshot);
       noteHostProgress(sessionID, fingerprint);
 
       const progress = getWakeProgress(sessionID);
@@ -491,31 +927,26 @@ export function createOrchestratorWakeScheduler(
       }
 
       // Recheck host status/waits immediately before promptAsync.
-      const latest = await readHostSnapshot(sessionID);
+      const latest =
+        wakeMode === 'children'
+          ? await readChildrenSnapshot(sessionID)
+          : await readHostSnapshot(sessionID);
       if (!latest || state.generation !== generation) return;
       if (!state.continuousIdle) return;
       if (!canSchedule(sessionID)) {
         suppress(sessionID);
         return;
       }
-      if (isActiveStatus(latest.status, sessionID)) {
-        endIdleSpell(sessionID, true);
-        return;
-      }
-      if (!recoveryWake && hasActiveChild(latest.children, latest.status)) {
-        schedule(sessionID);
-        return;
-      }
-      if (!recoveryWake && !hasIncompleteTodos(latest.todos)) {
-        endIdleSpell(sessionID, false);
+      if (
+        !applySnapshotVerdict(
+          sessionID,
+          classifySnapshot(latest, sessionID, recoveryWake),
+        )
+      ) {
         return;
       }
 
-      const latestFingerprint = buildOrchestratorWakeFingerprint(
-        latest.todos,
-        latest.children,
-        latest.status,
-      );
+      const latestFingerprint = buildSnapshotFingerprint(latest);
       noteHostProgress(sessionID, latestFingerprint);
 
       const latestProgress = getWakeProgress(sessionID);
@@ -537,24 +968,40 @@ export function createOrchestratorWakeScheduler(
         return;
       }
 
-      if (!hasRequiredSessionApis(sessionSdk)) return;
-
-      await sessionSdk.promptAsync({
-        path: { id: sessionID },
-        query: { directory },
-        body: {
-          agent: 'orchestrator',
-          ...(modelSelection ? { model: modelSelection.model } : {}),
-          parts: [
-            createInternalAgentTextPart(
-              recoveryWake
-                ? ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT
-                : ORCHESTRATOR_WAKE_TEXT,
-            ),
-          ],
-        },
-        throwOnError: true,
-      });
+      if (!capabilities.ready) return;
+
+      const wakeText = recoveryWake
+        ? ORCHESTRATOR_STOPPED_JOB_WAKE_TEXT
+        : wakeMode === 'children'
+          ? ORCHESTRATOR_CHILDREN_WAKE_TEXT
+          : ORCHESTRATOR_WAKE_TEXT;
+      const body = {
+        agent: 'orchestrator',
+        ...(modelSelection ? { model: modelSelection.model } : {}),
+        parts: [createInternalAgentTextPart(wakeText)],
+      };
+      if (wakeMode === 'children' && capabilities.flavor === 'v2') {
+        // v1 prompt_async queued; 'queue' preserves that on v2 ('steer'
+        // would hijack an in-flight run).
+        await (
+          sessionSdk.promptAsync as (
+            args: Record<string, unknown>,
+          ) => Promise<unknown>
+        )({
+          path: { id: sessionID },
+          query: { directory },
+          body,
+          delivery: 'queue',
+          throwOnError: true,
+        });
+      } else {
+        await sessionSdk.promptAsync({
+          path: { id: sessionID },
+          query: { directory },
+          body,
+          throwOnError: true,
+        });
+      }
       if (recoveryWake) pendingStoppedRecoveries.delete(sessionID);
     } catch (error) {
       // Failed promptAsync already reserved; clear expecting-busy so a later
@@ -649,7 +1096,7 @@ export function createOrchestratorWakeScheduler(
     if (
       disposed ||
       !enabled ||
-      !hasRequiredSessionApis(sessionSdk) ||
+      !capabilities.ready ||
       !options.shouldManageSession(sessionID)
     ) {
       return;
@@ -669,8 +1116,9 @@ export function createOrchestratorWakeScheduler(
     event: {
       type: string;
       properties?: {
-        info?: { id?: string };
+        info?: { id?: string; parentID?: string };
         sessionID?: string;
+        parentID?: string;
         status?: { type?: string };
       };
     };
@@ -680,6 +1128,9 @@ export function createOrchestratorWakeScheduler(
     if (type === 'server.instance.disposed') {
       disposed = true;
       pendingStoppedRecoveries.clear();
+      lastStatusBySession.clear();
+      childSessions.clear();
+      childEvidence.clear();
       for (const sessionID of [...localWakeOwners.keys()]) {
         releaseLocalWakeOwner(sessionID);
       }
@@ -692,7 +1143,27 @@ export function createOrchestratorWakeScheduler(
     const sessionID = extractSessionID(input.event);
     if (!sessionID) return;
 
+    // Event bookkeeping (children-driven mode + parent-active race guard).
+    // Status tracking covers ALL sessions: child entries feed the busy-set
+    // and update evidence, the parent entry is the race guard on hosts
+    // without a live status map (v2).
+    if (type === 'session.status') {
+      const statusType = properties?.status?.type;
+      if (statusType === 'busy' || statusType === 'idle') {
+        recordTrackedStatus(sessionID, statusType);
+      }
+    } else if (type === 'session.created') {
+      const parentID =
+        typeof properties?.info?.parentID === 'string'
+          ? properties.info.parentID
+          : typeof properties?.parentID === 'string'
+            ? properties.parentID
+            : undefined;
+      if (parentID) recordChildSession(parentID, sessionID);
+    }
+
     if (type === 'session.deleted') {
+      forgetSessionEvents(sessionID);
       clearSession(sessionID);
       return;
     }
@@ -758,7 +1229,12 @@ export function createOrchestratorWakeScheduler(
       localSessions,
       intervalMs,
       enabled,
-      hasRequiredSessionApis: () => hasRequiredSessionApis(sessionSdk),
+      hasRequiredSessionApis: () => capabilities.ready,
+      capabilities: () => capabilities,
+      wakeMode: () => wakeMode,
+      lastStatusBySession,
+      childEvidence,
+      childSessions,
     },
   };
 }

+ 192 - 8
src/hooks/task-session-manager/runtime-status-reconciliation.test.ts

@@ -1,4 +1,8 @@
 import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { pathToFileURL } from 'node:url';
 import { BackgroundJobBoard } from '../../utils';
 import { buildPluginInput } from '../../v2/client-shim';
 import { createRuntimeStatusReconciler } from './runtime-status-reconciliation';
@@ -431,10 +435,11 @@ describe('runtime status reconciliation', () => {
   });
 
   test('v2 shim client (no session.status) never confirms a stop', async () => {
-    // The v2 client shim deliberately omits session.status: an empty-but-
-    // valid status map from a fake stub would let stop-confirmation mark a
-    // still-running job `stopped` after the grace. Omission must surface
-    // as snapshot.error → markStatusUncertain, even far beyond the grace.
+    // Capability gate: without client.session.status the reconciler skips
+    // entirely (single disable notice) instead of marking every running
+    // job uncertain every poll. Skipping is strictly safer than the old
+    // snapshot.error path for stop-confirmation: no lookup ever runs, so
+    // nothing can terminalize a still-running job.
     const board = new BackgroundJobBoard();
     const contextFilesForPrompt = mock(() => []);
     const prune = mock(() => {});
@@ -461,19 +466,198 @@ describe('runtime status reconciliation', () => {
     const listener = mock(() => {});
     board.addTerminalStateListener(listener);
 
+    reconciler.schedule();
     await reconciler.reconcile();
     await reconciler.reconcile();
 
     expect(board.get('child-1')).toMatchObject({
       state: 'running',
-      statusUncertain: true,
+      statusUncertain: false,
     });
-    expect(board.get('child-1')?.lastStatusError).toContain(
-      'Runtime status lookup failed',
-    );
+    expect(board.get('child-1')?.lastStatusError).toBeUndefined();
     expect(listener).not.toHaveBeenCalled();
     expect(contextFilesForPrompt).not.toHaveBeenCalled();
     expect(prune).not.toHaveBeenCalled();
     reconciler.dispose();
   });
+
+  test('v2 host: polling loop never arms, no uncertainty marks, and the disable notice logs exactly once', async () => {
+    // Board behavior is asserted in-process (no logger involved): the
+    // loop never arms and nothing is ever marked uncertain.
+    const board = new BackgroundJobBoard();
+    const reconciler = createRuntimeStatusReconciler({
+      // v2 shape: the session domain exists but has NO status method.
+      input: {
+        directory: '/test/project',
+        client: { session: {} },
+      } as never,
+      backgroundJobBoard: board,
+      delayMs: 1,
+      taskContextTracker: {
+        pendingManagedTaskIds: new Set(),
+        contextFilesForPrompt: () => [],
+        prune: () => {},
+      },
+    });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'gate check',
+      now: 0,
+    });
+
+    // Repeated scheduling attempts (the event hook fires schedule()
+    // after every event) must arm nothing.
+    for (let index = 0; index < 5; index += 1) {
+      reconciler.schedule();
+      await new Promise((resolve) => setTimeout(resolve, 3));
+    }
+    await reconciler.reconcile();
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: false,
+    });
+    reconciler.dispose();
+
+    // Log-file assertions run in a subprocess: other test files
+    // mock.module('../../utils/logger') globally in shared-process runs,
+    // so the real logger (and its file sink) is only observable with a
+    // pristine module registry.
+    const logDir = await fs.mkdtemp(
+      path.join(os.tmpdir(), 'omos-reconcile-log-'),
+    );
+    const workerSource = `
+      const { createRuntimeStatusReconciler } = await import(
+        process.env.RECONCILER_MODULE_URL
+      );
+      const { BackgroundJobBoard } = await import(
+        process.env.BOARD_MODULE_URL
+      );
+      const { initLogger, flushLoggerForTesting } = await import(
+        process.env.LOGGER_MODULE_URL
+      );
+      const { readFileSync } = await import('node:fs');
+      initLogger('reconcile-v2-gate');
+      const board = new BackgroundJobBoard();
+      const reconciler = createRuntimeStatusReconciler({
+        input: {
+          directory: '/test/project',
+          client: { session: {} },
+        },
+        backgroundJobBoard: board,
+        delayMs: 1,
+        taskContextTracker: {
+          pendingManagedTaskIds: new Set(),
+          contextFilesForPrompt: () => [],
+          prune: () => {},
+        },
+      });
+      board.registerLaunch({
+        taskID: 'child-1',
+        parentSessionID: 'parent-1',
+        agent: 'fixer',
+        description: 'worker gate check',
+        now: 0,
+      });
+      for (let index = 0; index < 5; index += 1) {
+        reconciler.schedule();
+        await new Promise((resolve) => setTimeout(resolve, 3));
+      }
+      await reconciler.reconcile();
+      reconciler.dispose();
+      await flushLoggerForTesting();
+      const contents = readFileSync(
+        process.env.LOG_FILE_PATH,
+        'utf8',
+      );
+      const lines = contents.split('\\n');
+      console.log(
+        JSON.stringify({
+          disableNotices: lines.filter((line) =>
+            line.includes('runtime status reconciliation disabled'),
+          ).length,
+          uncertainLines: lines.filter((line) =>
+            line.includes('reconciliation uncertain'),
+          ).length,
+        }),
+      );
+    `;
+    const proc = Bun.spawn([process.execPath, '-e', workerSource], {
+      cwd: import.meta.dir,
+      env: {
+        ...process.env,
+        OPENCODE_LOG_DIR: logDir,
+        RECONCILER_MODULE_URL: pathToFileURL(
+          path.join(import.meta.dir, 'runtime-status-reconciliation.ts'),
+        ).href,
+        BOARD_MODULE_URL: pathToFileURL(
+          path.join(import.meta.dir, '../../utils/index.ts'),
+        ).href,
+        LOGGER_MODULE_URL: pathToFileURL(
+          path.join(import.meta.dir, '../../utils/logger.ts'),
+        ).href,
+        LOG_FILE_PATH: path.join(
+          logDir,
+          'oh-my-opencode-slim.reconcile-v2-gate.log',
+        ),
+      },
+      stdout: 'pipe',
+      stderr: 'pipe',
+    });
+    const [stdout, stderr, exitCode] = await Promise.all([
+      new Response(proc.stdout).text(),
+      new Response(proc.stderr).text(),
+      proc.exited,
+    ]);
+    await fs.rm(logDir, { recursive: true, force: true });
+    if (exitCode !== 0) {
+      console.error(stderr);
+      expect(exitCode).toBe(0);
+    }
+    const counts = JSON.parse(stdout.trim()) as {
+      disableNotices: number;
+      uncertainLines: number;
+    };
+    expect(counts.disableNotices).toBe(1);
+    expect(counts.uncertainLines).toBe(0);
+  });
+
+  test('v1 host (status fn present): schedule() arms the loop exactly as before', async () => {
+    const board = new BackgroundJobBoard();
+    const status = mock(async () => ({
+      data: { 'child-1': { type: 'busy' } },
+    }));
+    const reconciler = createRuntimeStatusReconciler({
+      input: {
+        directory: '/test/project',
+        client: { session: { status } },
+      } as never,
+      backgroundJobBoard: board,
+      delayMs: 1,
+      taskContextTracker: {
+        pendingManagedTaskIds: new Set(['child-1']),
+        contextFilesForPrompt: () => [],
+        prune: () => {},
+      },
+    });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'v1 loop regression',
+      now: 0,
+    });
+
+    reconciler.schedule();
+    await new Promise((resolve) => setTimeout(resolve, 10));
+
+    expect(status).toHaveBeenCalled();
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      statusUncertain: false,
+    });
+    reconciler.dispose();
+  });
 });

+ 30 - 0
src/hooks/task-session-manager/runtime-status-reconciliation.ts

@@ -30,8 +30,34 @@ export function createRuntimeStatusReconciler(options: {
   let activeReconcile: Promise<void> | undefined;
   let rerunRequested = false;
 
+  // Capability gate: hosts without `client.session.status` (live v2 —
+  // verified beta-19365/beta-19378) can never produce a status snapshot;
+  // every poll would throw "client.session.status is not a function" and
+  // log reconciliation uncertainty (~5s of pure noise). Skip the loop
+  // entirely with a single per-instance disable notice instead. v1 hosts
+  // expose the method and keep the exact historical behavior.
+  let capability: 'unknown' | 'supported' | 'unsupported' = 'unknown';
+  function reconciliationSupported(): boolean {
+    if (capability === 'unknown') {
+      const client = options.input?.client as
+        | { session?: { status?: unknown } }
+        | undefined;
+      capability =
+        client && typeof client.session?.status === 'function'
+          ? 'supported'
+          : 'unsupported';
+      if (capability === 'unsupported') {
+        log(
+          '[task-session-manager] runtime status reconciliation disabled on this host (client.session.status unavailable)',
+        );
+      }
+    }
+    return capability === 'supported';
+  }
+
   function schedule(): void {
     if (disposed) return;
+    if (!reconciliationSupported()) return;
     if (activeReconcile) {
       rerunRequested = true;
       return;
@@ -51,6 +77,10 @@ export function createRuntimeStatusReconciler(options: {
 
   async function reconcilePass(): Promise<void> {
     if (disposed) return;
+    // Same capability gate as schedule(): a direct reconcile() (rehydrate
+    // path) on a host without the status method must stay silent instead
+    // of marking every running job uncertain.
+    if (!reconciliationSupported()) return;
     const running = options.backgroundJobBoard
       .list()
       .filter((job) => job.state === 'running');

+ 9 - 2
src/v2/adapters.ts

@@ -10,7 +10,7 @@
  */
 
 import { log } from '../utils/logger';
-import type { ModelRef, V2AgentDraft } from './types';
+import type { ModelRef, V2AgentDraft, V2ToolDefinition } from './types';
 
 /** Parse a v1 "provider/model" string into a v2 Model.Ref. */
 export function parseModelRef(model: unknown): ModelRef | undefined {
@@ -104,7 +104,7 @@ export function adaptTool(
   v1Tool: Record<string, unknown>,
   directory: string,
   inputSchema: unknown,
-): Record<string, unknown> {
+): V2ToolDefinition {
   const description =
     (v1Tool.description as string | undefined) ?? `Tool ${name}`;
 
@@ -116,6 +116,13 @@ export function adaptTool(
     name,
     description,
     input: inputSchema,
+    // CodeMode opt-out (official plugin pattern, packages/plugin README):
+    // v2's Tool.snapshot() only turns `codemode: false` tools into direct
+    // model-visible tool definitions. Without this flag the tool registers
+    // cleanly but is confined to the `execute` tool's JS runtime — session
+    // tool catalogs then yield `Unknown tool: <name>`. Additive field;
+    // older hosts ignore it.
+    options: { codemode: false },
     execute: async (input: unknown, context: unknown) => {
       if (!execute) return { output: {} };
       const ctx = context as {

+ 267 - 0
src/v2/client-shim.test.ts

@@ -146,6 +146,111 @@ describe('v2 client shim delegation', () => {
     });
   });
 
+  test('promptAsync threads an optional queue delivery (orchestrator-wake) and keeps switchModel ordering', async () => {
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const input = buildPluginInput(
+      makeCtx({
+        switchModel: async (i: unknown) => {
+          seq.push({ m: 'switchModel', i });
+        },
+        prompt: async (i: unknown) => {
+          seq.push({ m: 'prompt', i });
+          return {};
+        },
+      } as never),
+    );
+    const promptAsync = (
+      input.client as {
+        session: {
+          promptAsync: (
+            a: Record<string, unknown> & { delivery?: 'steer' | 'queue' },
+          ) => Promise<unknown>;
+        };
+      }
+    ).session.promptAsync;
+    // Wake call shape: model selection + delivery 'queue'.
+    await promptAsync({
+      path: { id: 'ses_1' },
+      query: { directory: '/proj' },
+      body: {
+        agent: 'orchestrator',
+        model: { providerID: 'test', modelID: 'model-a' },
+        parts: [{ type: 'text', text: 'wake reminder' }],
+      },
+      delivery: 'queue',
+      throwOnError: true,
+    });
+    expect(seq).toHaveLength(2);
+    expect(seq[0]).toMatchObject({ m: 'switchModel' });
+    expect(seq[1]).toMatchObject({
+      m: 'prompt',
+      i: { sessionID: 'ses_1', delivery: 'queue', text: 'wake reminder' },
+    });
+    // Regression: without the delivery argument the default stays 'steer'
+    // (foreground-fallback depends on steering an in-flight run).
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: { parts: [{ type: 'text', text: 'fallback replay' }] },
+    });
+    expect(seq[2]).toMatchObject({
+      m: 'prompt',
+      i: { sessionID: 'ses_1', delivery: 'steer', text: 'fallback replay' },
+    });
+  });
+
+  test('promptAsync carries the internal-initiator marker as prompt metadata (wake cap survival)', async () => {
+    // The v1 wake prompt's part metadata cannot survive the text-only v2
+    // translation; the marker must travel as prompt `metadata` (accepted
+    // and propagated by the v2 session.prompt endpoint + hook) so the
+    // session-prompt bridge can restore it and observeChatMessage does
+    // NOT classify the wake admission as external user activity.
+    const seq: Array<{ m: string; i: unknown }> = [];
+    const input = buildPluginInput(
+      makeCtx({
+        prompt: async (i: unknown) => {
+          seq.push({ m: 'prompt', i });
+          return {};
+        },
+      } as never),
+    );
+    const promptAsync = (
+      input.client as {
+        session: {
+          promptAsync: (
+            a: Record<string, unknown> & { delivery?: 'steer' | 'queue' },
+          ) => Promise<unknown>;
+        };
+      }
+    ).session.promptAsync;
+    // Internal wake prompt (real ORCHESTRATOR_CHILDREN_WAKE_TEXT part shape).
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: {
+        agent: 'orchestrator',
+        parts: [createInternalAgentTextPart('wake reminder')],
+      },
+      delivery: 'queue',
+      throwOnError: true,
+    });
+    expect(seq[0]).toMatchObject({
+      m: 'prompt',
+      i: {
+        sessionID: 'ses_1',
+        delivery: 'queue',
+        metadata: { 'oh-my-opencode-slim.internalInitiator': true },
+      },
+    });
+    expect((seq[0].i as { text: string }).text).toContain(
+      'SLIM_INTERNAL_INITIATOR',
+    );
+    // Plain external prompts carry no metadata key.
+    await promptAsync({
+      path: { id: 'ses_1' },
+      body: { parts: [{ type: 'text', text: 'user says hi' }] },
+    });
+    expect(seq[1].i).not.toHaveProperty('metadata');
+  });
+
   test('abort delegates to interrupt', async () => {
     const calls: unknown[] = [];
     const input = buildPluginInput(
@@ -185,6 +290,168 @@ describe('v2 client shim delegation', () => {
     expect(res.data).toEqual({ id: 'ses_1', parentID: 'ses_0', title: 't' });
   });
 
+  test('delete delegates to session.remove with the flat {sessionID}', async () => {
+    const calls: unknown[] = [];
+    const input = buildPluginInput(
+      makeCtx({
+        remove: async (i: { sessionID: string }) => {
+          calls.push(i);
+        },
+      } as never),
+    );
+    await (
+      input.client as {
+        session: { delete: (a: unknown) => Promise<unknown> };
+      }
+    ).session.delete({ path: { id: 'ses_tmp' }, query: { directory: '/d' } });
+    // The smartfetch secondary-model cleanup shape (path.id) must resolve
+    // to the flat v2 {sessionID} — no temp-session leak.
+    expect(calls).toEqual([{ sessionID: 'ses_tmp' }]);
+  });
+
+  test('delete without remove resolves with an honest log (no fake throw)', async () => {
+    const input = buildPluginInput(makeCtx({}));
+    await expect(
+      (
+        input.client as {
+          session: { delete: (a: unknown) => Promise<unknown> };
+        }
+      ).session.delete({ path: { id: 'ses_tmp' } }),
+    ).resolves.toBeUndefined();
+  });
+
+  test('list delegates to session.list and maps to the v1 {data} envelope', async () => {
+    const calls: unknown[] = [];
+    const input = buildPluginInput(
+      makeCtx({
+        list: async (i: unknown) => {
+          calls.push(i);
+          return {
+            data: [
+              {
+                id: 'ses_1',
+                parentID: 'ses_0',
+                projectID: 'proj_1',
+                title: 'Interview thing',
+                time: { created: 1, updated: 2, idle: 3 },
+                location: { directory: '/w/alpha' },
+                agent: 'orchestrator',
+              },
+              { id: 'ses_2', time: { created: 5 } },
+            ],
+            cursor: {},
+          };
+        },
+      } as never),
+    );
+    const res = await (
+      input.client as {
+        session: {
+          list: (a: unknown) => Promise<{ data: unknown[] }>;
+        };
+      }
+    ).session.list({ query: {} });
+    expect(calls).toEqual([{}]);
+    // v1-shape mapping the interview dashboard reads: directory (from v2
+    // location.ref) + time.updated for the scan cutoff; identity fields
+    // pass through, nothing fabricated.
+    expect(res.data).toEqual([
+      {
+        id: 'ses_1',
+        parentID: 'ses_0',
+        projectID: 'proj_1',
+        title: 'Interview thing',
+        agent: 'orchestrator',
+        directory: '/w/alpha',
+        time: { created: 1, updated: 2, idle: 3 },
+      },
+      { id: 'ses_2', time: { created: 5 } },
+    ]);
+  });
+
+  test('list maps terminal outcome for the wake scheduler children view', async () => {
+    const input = buildPluginInput(
+      makeCtx({
+        list: async () => ({
+          data: [
+            {
+              id: 'kid_active',
+              parentID: 'ses_0',
+              time: { updated: 10 },
+              location: { directory: '/proj' },
+            },
+            {
+              id: 'kid_done',
+              parentID: 'ses_0',
+              outcome: 'succeeded',
+              time: { updated: 20 },
+              location: { directory: '/proj' },
+            },
+          ],
+        }),
+      } as never),
+    );
+    const res = await (
+      input.client as {
+        session: { list: (a: unknown) => Promise<{ data: unknown[] }> };
+      }
+    ).session.list({ query: { parentID: 'ses_0' } });
+    expect(res.data).toEqual([
+      {
+        id: 'kid_active',
+        parentID: 'ses_0',
+        directory: '/proj',
+        time: { updated: 10 },
+        // outcome intentionally absent until terminal transition
+      },
+      {
+        id: 'kid_done',
+        parentID: 'ses_0',
+        outcome: 'succeeded',
+        directory: '/proj',
+        time: { updated: 20 },
+      },
+    ]);
+  });
+
+  test('list passes through directory and parentID filters (null → "null")', async () => {
+    const calls: unknown[] = [];
+    const input = buildPluginInput(
+      makeCtx({
+        list: async (i: unknown) => {
+          calls.push(i);
+          return { data: [] };
+        },
+      } as never),
+    );
+    const list = (
+      input.client as {
+        session: { list: (a: unknown) => Promise<{ data: unknown[] }> };
+      }
+    ).session.list;
+    await list({ query: { directory: '/w' } });
+    await list({ query: { parentID: 'ses_parent' } });
+    await list({ query: { parentID: null } });
+    await list({ query: { parentID: 'null' } });
+    expect(calls).toEqual([
+      { directory: '/w' },
+      { parentID: 'ses_parent' },
+      { parentID: 'null' }, // root-only sentinel on the wire
+      { parentID: 'null' },
+    ]);
+  });
+
+  test('list without session.list keeps the v1-parity empty page', async () => {
+    const input = buildPluginInput(makeCtx({}));
+    await expect(
+      (
+        input.client as {
+          session: { list: (a: unknown) => Promise<{ data: unknown[] }> };
+        }
+      ).session.list({ query: {} }),
+    ).resolves.toEqual({ data: [] });
+  });
+
   test('unavailable methods fail explicitly, never fake success', async () => {
     const input = buildPluginInput(makeCtx({}));
     const session = (

+ 135 - 12
src/v2/client-shim.ts

@@ -5,10 +5,10 @@
  * project metadata, and a shell. v2's plugin context exposes none of these,
  * so this shim builds a v1-shaped input whose `client` translates the v1
  * SDK call shapes (Hono-style `{path, body}` or flat `{sessionID}`) into
- * v2 flat session calls (`get`/`interrupt`/`switchModel`/`prompt`/
- * `context`). Delegation is real where the v2 host provides the method and
- * explicitly fails or degrades with a log where it does not — the shim
- * never fakes success shapes.
+ * v2 flat session calls (`get`/`remove`/`list`/`interrupt`/`switchModel`/
+ * `prompt`/`context`). Delegation is real where the v2 host provides the
+ * method and explicitly fails or degrades with a log where it does not —
+ * the shim never fakes success shapes.
  *
  * The v2 model-switch semantics (prompts carry no model; `switchModel`
  * must precede the prompt) are encapsulated in the `promptAsync`
@@ -16,6 +16,11 @@
  * unmodified on v2.
  */
 
+import { isRecord } from '../utils/guards';
+import {
+  INTERNAL_INITIATOR_METADATA_KEY,
+  isInternalInitiatorPart,
+} from '../utils/internal-initiator';
 import { log } from '../utils/logger';
 import type { V2Context } from './types';
 
@@ -119,6 +124,106 @@ function modelRefFromBody(body: {
   return id && providerID ? { id, providerID } : undefined;
 }
 
+/**
+ * Internal-initiator marker for v2 prompts: the v1 part metadata is lost
+ * in the text-only v2 translation, so the marker travels as prompt
+ * `metadata` (accepted and propagated by the v2 session.prompt endpoint
+ * and its hook). The session-prompt bridge restores it onto the rebuilt
+ * v1 parts view so `isInternalInitiatorPart` consumers — notably
+ * orchestrator-wake's `observeChatMessage`, which must NOT treat a wake
+ * admission as external user activity (the two-wake no-progress cap
+ * depends on that) — keep working on v2.
+ */
+function internalInitiatorMetadataFromBody(
+  args: Record<string, unknown>,
+): Record<string, unknown> | undefined {
+  const body = (args?.body ?? {}) as {
+    parts?: Array<Record<string, unknown>>;
+  };
+  const parts = Array.isArray(body.parts) ? body.parts : [];
+  return parts.some((part) => isInternalInitiatorPart(part))
+    ? { [INTERNAL_INITIATOR_METADATA_KEY]: true }
+    : undefined;
+}
+
+/**
+ * Map one v2 `Session.Info` to the v1 list shape the shim's consumers
+ * read (interview dashboard directory discovery: `directory`,
+ * `time.updated`; identity fields for any future consumer). Only fields
+ * with the right type are copied — nothing is fabricated (no invented
+ * `version`/`title` defaults).
+ */
+function toV1SessionInfo(
+  info: Record<string, unknown>,
+): Record<string, unknown> {
+  const out: Record<string, unknown> = {};
+  if (typeof info.id === 'string') out.id = info.id;
+  if (typeof info.parentID === 'string') out.parentID = info.parentID;
+  if (typeof info.projectID === 'string') out.projectID = info.projectID;
+  if (typeof info.title === 'string') out.title = info.title;
+  if (typeof info.agent === 'string') out.agent = info.agent;
+  // v2 Session.Info.outcome appears only on terminal transition
+  // (succeeded|failed|interrupted); orchestrator-wake's children-driven
+  // mode reads it as the terminal signal.
+  if (typeof info.outcome === 'string') out.outcome = info.outcome;
+  if (isRecord(info.model)) out.model = info.model;
+  if (isRecord(info.metadata)) out.metadata = info.metadata;
+  // v2 carries the directory on `location` (Location.Ref); v1 had it flat.
+  if (isRecord(info.location)) {
+    if (typeof info.location.directory === 'string') {
+      out.directory = info.location.directory;
+    }
+  }
+  if (typeof info.directory === 'string') out.directory = info.directory;
+  if (isRecord(info.time)) {
+    const time: Record<string, unknown> = {};
+    if (typeof info.time.created === 'number') time.created = info.time.created;
+    if (typeof info.time.updated === 'number') time.updated = info.time.updated;
+    if (typeof info.time.idle === 'number') time.idle = info.time.idle;
+    if (Object.keys(time).length > 0) out.time = time;
+  }
+  return out;
+}
+
+/**
+ * v1 `client.session.list` over v2 `session.list`. Accepts the v1
+ * `{query}` call shape (or a flat query object); passes through the
+ * filters shim callers use — `directory` and the `parentID` filter
+ * (session id or root-only: the literal `"null"` string, with a real
+ * `null` normalized to it) — and wraps the mapped page in the v1
+ * `{data}` envelope. Hosts without `session.list` keep the v1-parity
+ * empty page (honest absence, not a fake success).
+ */
+export function createSessionListShim(
+  s: V2Context['session'],
+): (args: Record<string, unknown>) => Promise<{ data: unknown[] }> {
+  return async (args) => {
+    if (typeof s.list !== 'function') return { data: [] };
+    const query = ((args?.query as Record<string, unknown> | undefined) ??
+      (args as Record<string, unknown> | undefined) ??
+      {}) as Record<string, unknown>;
+    const input: Record<string, unknown> = {};
+    if (typeof query.directory === 'string' && query.directory) {
+      input.directory = query.directory;
+    }
+    if (query.parentID === null) {
+      input.parentID = 'null'; // root-only sentinel on the wire
+    } else if (typeof query.parentID === 'string' && query.parentID !== '') {
+      input.parentID = query.parentID;
+    }
+    const output = (await s.list(input)) as
+      | { data?: unknown }
+      | Array<Record<string, unknown>>
+      | undefined;
+    const infos = Array.isArray(output)
+      ? output
+      : isRecord(output) && Array.isArray(output.data)
+        ? (output.data as Array<Record<string, unknown>>)
+        : [];
+    return { data: infos.filter(isRecord).map(toV1SessionInfo) };
+  };
+}
+
 /** Build a v1-compatible PluginInput from the v2 context. The optional
  * `extras` threads probed v2 capabilities (e.g. one-shot generation)
  * through as `experimental_v2`; when absent no `experimental_v2` key is
@@ -170,7 +275,7 @@ export function buildPluginInput(
       // after the grace (false terminalization). With the method absent,
       // the lookup throws → snapshot.error → the reconciler's safe
       // markStatusUncertain branch.
-      list: async () => ({ data: [] }),
+      list: createSessionListShim(s),
       prompt: s.prompt
         ? async (args: Record<string, unknown>) => {
             const files = filesFromBody(args);
@@ -184,10 +289,17 @@ export function buildPluginInput(
         : async () => {
             throw new Error('[v2] session.prompt unavailable');
           },
-      promptAsync: async (args: Record<string, unknown>) => {
+      // v1 prompt_async QUEUED its prompt. The optional `delivery` argument
+      // lets callers preserve that on v2 ('queue' — orchestrator-wake);
+      // the default stays 'steer' because the foreground-fallback replay
+      // must steer an in-flight run.
+      promptAsync: async (
+        args: Record<string, unknown> & { delivery?: 'steer' | 'queue' },
+      ) => {
         if (!s.prompt) {
           throw new Error('[v2] session.prompt unavailable for promptAsync');
         }
+        const delivery = args?.delivery === 'queue' ? 'queue' : 'steer';
         const body = (args?.body ?? {}) as Parameters<
           typeof modelRefFromBody
         >[0] & { parts?: Array<{ type?: string; text?: string }> };
@@ -203,11 +315,13 @@ export function buildPluginInput(
           }
         }
         const files = filesFromBody(args);
+        const metadata = internalInitiatorMetadataFromBody(args);
         return s.prompt({
           sessionID: sessionIDOf(args),
           text: textFromBody(args),
-          delivery: 'steer',
+          delivery,
           ...(files.length > 0 ? { files } : {}),
+          ...(metadata ? { metadata } : {}),
         });
       },
       update: s.rename
@@ -223,11 +337,20 @@ export function buildPluginInput(
               id: sessionIDOf(args),
             });
           },
-      delete: async (args: Record<string, unknown>) => {
-        log('[v2][shim] session.delete unavailable (v2 has no delete)', {
-          id: sessionIDOf(args),
-        });
-      },
+      // v2 removed the delete endpoint in name only: `session.remove` is
+      // the same DELETE /api/session/:id. Capability-probed like `get`
+      // above — smartfetch's secondary-model cleanup (the real caller)
+      // relies on this to not leak temp sessions on v2. Hosts without
+      // `remove` degrade with the honest log below (no fake success).
+      delete: s.remove
+        ? async (args: Record<string, unknown>) => {
+            await s.remove?.({ sessionID: sessionIDOf(args) });
+          }
+        : async (args: Record<string, unknown>) => {
+            log('[v2][shim] session.remove unavailable; delete is a no-op', {
+              id: sessionIDOf(args),
+            });
+          },
     },
     app: {
       log: async (args?: Record<string, unknown>) => {

+ 12 - 8
src/v2/codemap.md

@@ -18,9 +18,9 @@ v2 registrations. v1 behavior is unchanged.
 | `setup.ts` | `createV2Setup()` → the `setup(ctx)` orchestrator v2 calls. Capability-guards reduced/TUI-side hosts (no `agent.transform`). Registers agents, tools, MCPs, commands, the merged context hook, tool-execute bridges, and the event pump — each independently try/catch-guarded with a zero-registration health check. Exports the pure command-marker helpers (`wrapCommandMarker`/`parseCommandMarker`/`stripCommandMarker`), `createCommandRegistration`, `applyCommandMarkerToContext`, the merged context-hook builder `createSessionContextHandler`, the tool-execute bridge factory `createToolExecuteBridges`, and `adaptMcpServer`. |
 | `types.ts` | v2 plugin context surface (`V2Context` + draft/event types), mirrored locally (v2 plugin package is not a build-time dependency). Runtime-probed session methods (`get`/`interrupt`/`switchModel`/`context`/`prompt`/`synthetic`/`rename`/`switchAgent`) and the optional `mcp` domain are declared optional with probe notes. |
 | `session-submit.ts` | Shared `createSessionSubmit` (prompt-only user-prompt submit via `ctx.session.prompt`) + `textFromContent`; used by both the generic command bridge and the interview bridge to avoid a setup↔bridge import cycle. |
-| `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` with a **real-delegation** client — v1 SDK call shapes translate to v2 flat session calls (`get`, `interrupt`, `context`, `prompt` with `delivery:"steer"`, `rename`), with honest degradation (log or omit) where the host lacks the method. `resolveV2Directory` prefers `ctx.location.directory` (#45403+) with a `process.cwd()` fallback. `promptAsync` encapsulates the v2 model-switch semantics (`switchModel` before the prompt) that power the v1 foreground-fallback pipeline. Marks the input `hostFlavor: 'v2'` (multiplexer gating in `src/index.ts`) and threads the probed `generate.text` channel as `experimental_v2`. Never fakes success shapes (no invented `serverUrl`). |
+| `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` with a **real-delegation** client — v1 SDK call shapes translate to v2 flat session calls (`get`, `interrupt`, `context`, `prompt` with `delivery:"steer"`, `rename`), with honest degradation (log or omit) where the host lacks the method. `resolveV2Directory` prefers `ctx.location.directory` (#45403+) with a `process.cwd()` fallback. `promptAsync` encapsulates the v2 model-switch semantics (`switchModel` before the prompt) and accepts an optional `delivery` argument (default `"steer"` for the foreground-fallback replay; the orchestrator-wake scheduler passes `"queue"` to match v1's queued prompt_async); internal-initiator body parts (wake prompts) map to prompt `metadata` so the session-prompt bridge can restore the v1 part marker. `session.list` maps v2 `Session.Info` to the v1 `{data}` envelope including `outcome`/`time.updated`/`directory` (interview dashboard scan + orchestrator-wake children enumeration). Marks the input `hostFlavor: 'v2'` (multiplexer gating and wake-mode resolution in `src/index.ts` / `src/hooks/orchestrator-wake/`) and threads the probed `generate.text` channel as `experimental_v2`. Never fakes success shapes (no invented `serverUrl`). |
 | `delegation.ts` | v2↔v1 delegation tool normalization: `toolNameToV1` (`subagent`→`task`), `subagentArgsToV1` (`agent`→`subagent_type`, `sessionID`→`task_id`), `v1ArgsToSubagent` (reverse). Lets the whole v1 pipeline (task-session-manager, job board, `task_*` tools) run on v2's host `subagent` tool with zero changes. |
-| `event-adapter.ts` | `mapV2EventToV1`: additive-only v2→v1 event synthesis for the event pump. Raw event always first (interview bridge consumes it); then idle `session.status` → `session.idle`, flat child `session.created` → v1 early-registration `{info:{id,parentID,agent?}}`, usage telemetry (`session.usage.updated`/`session.step.ended`) → deduplicated completed-assistant `message.updated` (deterministic fingerprint id; no wall-clock/randomness). |
+| `event-adapter.ts` | `mapV2EventToV1`: additive-only v2→v1 event synthesis for the event pump. Raw event always first (interview bridge consumes it); payload is read from the live wire key `data` (`{id, created, type, location?, durable?, data}` — verified live on beta-19365) with `properties` as the legacy/test fallback, while every synthesized shape writes `properties` (what the v1 consumers read). Syntheses: idle `session.status` → `session.idle`, `session.execution.*` → v1 busy/idle/error lifecycle shapes, flat child `session.created` → v1 early-registration `{info:{id,parentID,agent?}}`, usage telemetry (`session.usage.updated`/`session.step.ended`) → deduplicated completed-assistant `message.updated` (deterministic fingerprint id; no wall-clock/randomness). |
 | `tui.ts` | v2 TUI plugin entry (`./tui` export → `dist/tui2.js`): re-exports the v1 dual-contract TUI (`../tui`) and extends its v2 `setup` with the `/preset` keymap flow (`ui.dialog.select` + toast; persists via `switchPresetOnDisk`; `/preset <name>` fast path). Capability-guarded: builds without `keymap.layer`/`ui.dialog.select` keep the sidebar and lose only `/preset`. |
 | `adapters.ts` | Shape adapters: `parseModelRef`, `adaptPermissions` (v1 map → v2 Rule[] + v2 permissive base + `task`→`subagent`/`bash`→`execute` mapping), `rewritePromptForV2` (`task(`→`subagent(`), `adaptTool`, `applyAgentToDraft`. |
 | `interview-bridge.ts` | v2-only `/interview` marker command, trailing-message context bridge, v2 interview runtime, and per-session transcript projections. |
@@ -131,9 +131,13 @@ expanding the global v2 client surface.
 
 ## Limitations (see `docs/opencode-v2-compatibility.md`)
 
-Multiplexer and orchestrator-wake are v1-only by design (v2 renders subagents
-natively; the host `subagent` tool notifies the parent itself). MCP
-registration needs `ctx.mcp.transform` ≥ #45408 (older builds: config-only
-snippet). Model switching needs `session.switchModel` ≥ #43718; directory
-needs `ctx.location` ≥ #45403 (older builds: cwd). Companion is unverified on
-v2. Prompt-cache safety rules are unchanged (trailing-message-only mutation).
+Multiplexer is v1-only by design (v2 renders subagents natively). The
+orchestrator-wake scheduler runs on v2 in children-driven degraded mode
+(list+promptAsync gate, `session.list({parentID})` enumeration with the
+event-tracked fallback, outcome-based condition with a 3×-interval staleness
+bound, `queue` delivery — see `src/hooks/orchestrator-wake/codemap.md`).
+MCP registration needs `ctx.mcp.transform` ≥ #45408 (older builds:
+config-only snippet). Model switching needs `session.switchModel` ≥ #43718;
+directory needs `ctx.location` ≥ #45403 (older builds: cwd). Companion is
+unverified on v2. Prompt-cache safety rules are unchanged
+(trailing-message-only mutation).

+ 703 - 0
src/v2/event-adapter.test.ts

@@ -227,3 +227,706 @@ describe('mapV2EventToV1', () => {
     expect(warnings[0]).toContain('prompt-cache bust');
   });
 });
+
+describe('mapV2EventToV1 session.execution.* lifecycle synthesis', () => {
+  // Newer v2 hosts (verified live beta-19365/beta-19378) publish durable
+  // session.execution.* events and no longer stream busy/idle
+  // session.status — these tests pin the synthesized v1 lifecycle shapes
+  // the wake scheduler / task-session-manager / fallback consumers read.
+  test('execution.started synthesizes v1 busy session.status', () => {
+    const ev = deepFreeze({
+      type: 'session.execution.started',
+      properties: { sessionID: 'ses_exec' },
+    });
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(2);
+    expect(out[0]).toBe(ev);
+    expect(out[1]).toEqual({
+      type: 'session.status',
+      properties: { sessionID: 'ses_exec', status: { type: 'busy' } },
+    });
+  });
+
+  test('execution.succeeded synthesizes idle session.status + session.idle', () => {
+    const ev = deepFreeze({
+      type: 'session.execution.succeeded',
+      properties: { sessionID: 'ses_exec' },
+    });
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(3);
+    expect(out[0]).toBe(ev);
+    expect(out[1]).toEqual({
+      type: 'session.status',
+      properties: { sessionID: 'ses_exec', status: { type: 'idle' } },
+    });
+    expect(out[2]).toEqual({
+      type: 'session.idle',
+      properties: { sessionID: 'ses_exec' },
+    });
+  });
+
+  test('execution.interrupted synthesizes the same idle pair as succeeded', () => {
+    const out = mapV2EventToV1({
+      type: 'session.execution.interrupted',
+      properties: { sessionID: 'ses_exec', reason: 'user cancel' },
+    });
+    expect(out).toHaveLength(3);
+    expect(out.slice(1)).toEqual([
+      {
+        type: 'session.status',
+        properties: { sessionID: 'ses_exec', status: { type: 'idle' } },
+      },
+      { type: 'session.idle', properties: { sessionID: 'ses_exec' } },
+    ]);
+  });
+
+  test('execution.failed emits v1 session.error (host error verbatim) before the idle pair', () => {
+    const ev = deepFreeze({
+      type: 'session.execution.failed',
+      properties: {
+        sessionID: 'ses_exec',
+        error: { message: 'rate limited', statusCode: 429 },
+      },
+    });
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(4);
+    expect(out[0]).toBe(ev);
+    // Error-before-idle: the event-router's deferred-inline-error flow
+    // expects the error first and terminalizes on the following idle.
+    expect(out[1]).toEqual({
+      type: 'session.error',
+      properties: {
+        sessionID: 'ses_exec',
+        error: { message: 'rate limited', statusCode: 429 },
+      },
+    });
+    expect(out[2]).toEqual({
+      type: 'session.status',
+      properties: { sessionID: 'ses_exec', status: { type: 'idle' } },
+    });
+    expect(out[3]).toEqual({
+      type: 'session.idle',
+      properties: { sessionID: 'ses_exec' },
+    });
+  });
+
+  test('execution.failed without an error field degrades to a best-effort message', async () => {
+    const out = mapV2EventToV1({
+      type: 'session.execution.failed',
+      properties: { sessionID: 'ses_exec' },
+    });
+    expect(out[1]).toEqual({
+      type: 'session.error',
+      properties: {
+        sessionID: 'ses_exec',
+        error: { message: 'v2 session execution failed' },
+      },
+    });
+    // The fallback message must NOT classify as a failover error.
+    const { isFailoverError } = await import('../hooks/foreground-fallback');
+    expect(
+      isFailoverError(
+        (out[1] as { properties: { error: unknown } }).properties.error,
+      ),
+    ).toBe(false);
+  });
+
+  test('execution events without a sessionID synthesize nothing', () => {
+    for (const type of [
+      'session.execution.started',
+      'session.execution.succeeded',
+      'session.execution.failed',
+      'session.execution.interrupted',
+    ]) {
+      expect(mapV2EventToV1({ type, properties: {} })).toHaveLength(1);
+    }
+  });
+
+  test('unrelated session.execution.* subtypes stay passthrough-only', () => {
+    // Only the four verified subtypes map; an unknown variant must not
+    // guess a lifecycle meaning.
+    const ev = deepFreeze({
+      type: 'session.execution.resumed',
+      properties: { sessionID: 'ses_exec' },
+    });
+    expect(mapV2EventToV1(ev)).toEqual([ev]);
+  });
+
+  test('older host emitting BOTH session.status idle and execution.succeeded keeps single logical idle (double-idle invariant)', () => {
+    // Composition is per-event and additive; a consumer watching idle
+    // across both deliveries sees idle four times (raw idle
+    // session.status + its synthesized session.idle; succeeded's
+    // synthesized idle session.status + session.idle) — all for the
+    // same single logical idle transition, tolerated because every
+    // idle consumer is idempotent per session (the documented invariant
+    // beginContinuousIdle / idle-reconciliation rely on).
+    const idleDeliveries: string[] = [];
+    for (const ev of [
+      {
+        type: 'session.status',
+        properties: { sessionID: 's', status: { type: 'idle' } },
+      },
+      { type: 'session.execution.succeeded', properties: { sessionID: 's' } },
+    ]) {
+      for (const mapped of mapV2EventToV1(ev)) {
+        if (mapped.type === 'session.idle') {
+          idleDeliveries.push(
+            (mapped.properties as { sessionID: string }).sessionID,
+          );
+        }
+        if (
+          mapped.type === 'session.status' &&
+          (mapped.properties as { status?: { type?: string } }).status?.type ===
+            'idle'
+        ) {
+          idleDeliveries.push(
+            (mapped.properties as { sessionID: string }).sessionID,
+          );
+        }
+      }
+    }
+    expect(idleDeliveries).toEqual(['s', 's', 's', 's']);
+  });
+
+  test('synthesized lifecycle pair feeds the real task-session-manager busy path', async () => {
+    // End-to-end against the consumer: the synthesized busy status must
+    // mark a tracked child running-from-live-session on the board.
+    const { createTaskSessionManagerHook } = await import(
+      '../hooks/task-session-manager'
+    );
+    const { BackgroundJobBoard } = await import('../utils');
+    const board = new BackgroundJobBoard();
+    const hook = createTaskSessionManagerHook({} as never, {
+      maxSessionsPerAgent: 2,
+      maxRetainedSnapshots: 2,
+      backgroundJobBoard: board,
+      shouldManageSession: (id: string) => id === 'parent-1',
+    });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'exec lifecycle e2e',
+      now: 0,
+    });
+    for (const mapped of mapV2EventToV1({
+      type: 'session.execution.started',
+      properties: { sessionID: 'child-1' },
+    })) {
+      await hook.event({ event: mapped });
+    }
+    // Busy observation landed: lastLiveBusyAt recorded on the running job.
+    expect(board.get('child-1')).toMatchObject({ state: 'running' });
+    expect(board.get('child-1')?.lastLiveBusyAt).toBeDefined();
+  });
+});
+
+describe('mapV2EventToV1 live wire shape (payload under `data`)', () => {
+  // Live v2 hosts deliver plugin/SSE events as
+  // `{id, created, type, location?, durable?, data}` — the OpenCodeEvent
+  // wire shape (verified live on beta-19365: every event keys observed as
+  // ["id","created","type","durable","data"] with no `properties` key).
+  // The `properties` spelling used by the tests above is the legacy
+  // fallback. These tests pin the live-observed shapes end-to-end through
+  // the exact wake-arming chain: execution.succeeded → idle pair →
+  // beginContinuousIdle, and session.created → child registration.
+  function liveEvent(
+    type: string,
+    data: Record<string, unknown>,
+  ): Record<string, unknown> {
+    return deepFreeze({
+      id: `evt_${type.replace(/\./g, '_')}`,
+      created: 1_788_961_637_000,
+      type,
+      durable: { aggregateID: data.sessionID ?? 'agg', seq: 1, version: 1 },
+      data,
+    });
+  }
+
+  test('live execution.succeeded synthesizes the v1 idle pair from `data`', () => {
+    const ev = liveEvent('session.execution.succeeded', {
+      sessionID: 'ses_live',
+    });
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(3);
+    expect(out[0]).toBe(ev);
+    expect(out.slice(1)).toEqual([
+      {
+        type: 'session.status',
+        properties: { sessionID: 'ses_live', status: { type: 'idle' } },
+      },
+      { type: 'session.idle', properties: { sessionID: 'ses_live' } },
+    ]);
+  });
+
+  test('live execution.started synthesizes v1 busy from `data`', () => {
+    const out = mapV2EventToV1(
+      liveEvent('session.execution.started', { sessionID: 'ses_live' }),
+    );
+    expect(out.slice(1)).toEqual([
+      {
+        type: 'session.status',
+        properties: { sessionID: 'ses_live', status: { type: 'busy' } },
+      },
+    ]);
+  });
+
+  test('live session.created (flat `data` fields incl. parentID) maps to the v1 early-registration shape', () => {
+    // Shape captured from the live beta-19365 host: a subagent child
+    // session.created with data.parentID linking it to the orchestrator.
+    const out = mapV2EventToV1(
+      liveEvent('session.created', {
+        sessionID: 'ses_child',
+        projectID: 'prj_1',
+        location: { directory: '/tmp/proj' },
+        subpath: '',
+        parentID: 'ses_parent',
+        slug: 'playful-orchid',
+        title: 'live child detection',
+        agent: 'general',
+        version: '0.0.0-beta-19365',
+      }),
+    );
+    expect(out).toHaveLength(2);
+    expect(out[1]).toEqual({
+      type: 'session.created',
+      properties: {
+        info: {
+          id: 'ses_child',
+          parentID: 'ses_parent',
+          title: 'live child detection',
+          agent: 'general',
+        },
+      },
+    });
+  });
+
+  test('live usage.updated telemetry maps to message.updated from `data`', () => {
+    const out = mapV2EventToV1(
+      liveEvent('session.usage.updated', {
+        sessionID: 'ses_live',
+        cost: 0,
+        tokens: {
+          input: 1,
+          output: 1,
+          reasoning: 0,
+          cache: { read: 0, write: 0 },
+        },
+      }),
+    );
+    expect(out).toHaveLength(2);
+    expect(out[1]).toEqual({
+      type: 'message.updated',
+      properties: {
+        info: {
+          id: 'v2-usage:ses_live:1:1:0:0',
+          role: 'assistant',
+          sessionID: 'ses_live',
+          time: { completed: 0 },
+          tokens: {
+            input: 1,
+            output: 1,
+            reasoning: 0,
+            cache: { read: 0, write: 0 },
+          },
+        },
+      },
+    });
+  });
+
+  test('live idle session.status synthesizes session.idle from `data`', () => {
+    const out = mapV2EventToV1(
+      liveEvent('session.status', {
+        sessionID: 'ses_live',
+        status: { type: 'idle' },
+      }),
+    );
+    expect(out.slice(1)).toEqual([
+      { type: 'session.idle', properties: { sessionID: 'ses_live' } },
+    ]);
+  });
+
+  test('live-shape wake chain end-to-end: idle arms the real wake scheduler with a tracked child', async () => {
+    // The exact live-observed break: raw `data`-keyed events must reach
+    // the v1 consumers. Arm the real scheduler with the synthesized idle
+    // for a managed orchestrator whose child was registered from a live
+    // session.created, then advance past the interval and require the
+    // queued v2 wake prompt.
+    const { createOrchestratorWakeScheduler } = await import(
+      '../hooks/orchestrator-wake'
+    );
+    const prompted: Array<Record<string, unknown>> = [];
+    const sessionSdk = {
+      list: async (args: Record<string, unknown>) => {
+        // In-process v2 hosts expose no session.list → shim-level honest
+        // empty page; the event-tracked fallback must cover enumeration.
+        void args;
+        return { data: [] };
+      },
+      promptAsync: async (args: Record<string, unknown>) => {
+        prompted.push(args);
+        return {};
+      },
+    };
+    const scheduler = createOrchestratorWakeScheduler(
+      {
+        client: { session: sessionSdk },
+        directory: '/tmp/proj',
+        hostFlavor: 'v2',
+      } as never,
+      {
+        config: { enabled: true, intervalMs: 10 },
+        shouldManageSession: (id: string) => id === 'ses_parent',
+        hasInputWait: () => false,
+        intervalMs: 10,
+      },
+    );
+    await scheduler.event({
+      event: mapV2EventToV1(
+        liveEvent('session.created', {
+          sessionID: 'ses_child',
+          parentID: 'ses_parent',
+          slug: 's',
+          version: 'v',
+        }),
+      )[1],
+    });
+    await scheduler.event({
+      event: mapV2EventToV1(
+        liveEvent('session.execution.started', { sessionID: 'ses_child' }),
+      )[1],
+    });
+    await scheduler.event({
+      event: mapV2EventToV1(
+        liveEvent('session.execution.succeeded', { sessionID: 'ses_parent' }),
+      )[2],
+    });
+    await new Promise((resolve) => setTimeout(resolve, 60));
+    // The live-shape chain armed and fired the queued v2 wake. With the
+    // 10ms test interval the two-wake no-progress cap is the stop bound
+    // (the cap itself is pinned by the dedicated wake-scheduler tests).
+    expect(prompted.length).toBeGreaterThanOrEqual(1);
+    expect(prompted.length).toBeLessThanOrEqual(2);
+    expect(prompted[0]?.delivery).toBe('queue');
+    const body = prompted[0]?.body as {
+      parts: Array<{ type: string; text: string }>;
+    };
+    expect(body.parts[0]?.type).toBe('text');
+    expect(body.parts[0]?.text).toContain(
+      'unfinished background child sessions',
+    );
+  });
+});
+
+describe('mapV2EventToV1 form → question bridge', () => {
+  test('form.created synthesizes v1 question.asked with QuestionV1 shape', () => {
+    const ev = deepFreeze({
+      type: 'form.created',
+      properties: {
+        form: {
+          id: 'frm_1',
+          sessionID: 'ses_q',
+          title: 'Pick one',
+          fields: [
+            {
+              key: 'flavor',
+              type: 'string',
+              title: 'Which flavor?',
+              options: [
+                { value: 'a', label: 'Vanilla', description: 'plain' },
+                { value: 'b', label: 'Chocolate' },
+              ],
+            },
+            {
+              key: 'extras',
+              type: 'multiselect',
+              title: 'Extras',
+              options: [{ value: 'x', label: 'Sprinkles' }],
+            },
+          ],
+        },
+      },
+    });
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(2);
+    expect(out[0]).toBe(ev);
+    // Exact fields the v1 consumers read (input-wait tracker: id +
+    // sessionID as the ask requestID) plus the v1 QuestionV1 questions
+    // contract (question/header ≤30 chars/options/multiple).
+    expect(out[1]).toEqual({
+      type: 'question.asked',
+      properties: {
+        id: 'frm_1',
+        sessionID: 'ses_q',
+        questions: [
+          {
+            question: 'Which flavor?',
+            header: 'Which flavor?',
+            options: [
+              { label: 'Vanilla', description: 'plain' },
+              { label: 'Chocolate', description: '' },
+            ],
+          },
+          {
+            question: 'Extras',
+            header: 'Extras',
+            options: [{ label: 'Sprinkles', description: '' }],
+            multiple: true,
+          },
+        ],
+      },
+    });
+  });
+
+  test('form.created falls back to the field key and caps the header', () => {
+    const out = mapV2EventToV1({
+      type: 'form.created',
+      properties: {
+        form: {
+          id: 'frm_2',
+          sessionID: 'ses_q',
+          title: 't',
+          fields: [
+            {
+              key: 'a-very-long-identifier-key-name-over-limit',
+              type: 'string',
+            },
+          ],
+        },
+      },
+    });
+    const question = (
+      out[1] as {
+        properties: { questions: Array<{ header: string; question: string }> };
+      }
+    ).properties.questions[0];
+    expect(question.question).toBe(
+      'a-very-long-identifier-key-name-over-limit',
+    );
+    expect(question.header).toHaveLength(30);
+  });
+
+  test('"global"-owned forms (MCP elicitation) synthesize nothing', () => {
+    expect(
+      mapV2EventToV1({
+        type: 'form.created',
+        properties: {
+          form: { id: 'frm_g', sessionID: 'global', title: 't', fields: [] },
+        },
+      }),
+    ).toHaveLength(1);
+    expect(
+      mapV2EventToV1({
+        type: 'form.replied',
+        properties: { id: 'frm_g', sessionID: 'global', answer: {} },
+      }),
+    ).toHaveLength(1);
+    expect(
+      mapV2EventToV1({
+        type: 'form.cancelled',
+        properties: { id: 'frm_g', sessionID: 'global' },
+      }),
+    ).toHaveLength(1);
+  });
+
+  test('form.replied synthesizes v1 question.replied with requestID + answers', () => {
+    const out = mapV2EventToV1({
+      type: 'form.replied',
+      properties: {
+        id: 'frm_1',
+        sessionID: 'ses_q',
+        answer: {
+          flavor: 'Vanilla',
+          count: 2,
+          approved: true,
+          extras: ['Sprinkles', 'Fudge'],
+        },
+      },
+    });
+    expect(out).toHaveLength(2);
+    // input-wait-tracker resolves the ask keyed `question:<id>` by reading
+    // properties.requestID — must be the form id, not a fresh value.
+    expect(out[1]).toEqual({
+      type: 'question.replied',
+      properties: {
+        sessionID: 'ses_q',
+        requestID: 'frm_1',
+        answers: [['Vanilla'], ['2'], ['true'], ['Sprinkles', 'Fudge']],
+      },
+    });
+  });
+
+  test('form.cancelled synthesizes v1 question.rejected', () => {
+    const out = mapV2EventToV1({
+      type: 'form.cancelled',
+      properties: { id: 'frm_1', sessionID: 'ses_q' },
+    });
+    expect(out).toHaveLength(2);
+    expect(out[1]).toEqual({
+      type: 'question.rejected',
+      properties: { sessionID: 'ses_q', requestID: 'frm_1' },
+    });
+  });
+
+  test('malformed form events synthesize nothing (fail-open)', () => {
+    expect(
+      mapV2EventToV1({ type: 'form.created', properties: {} }),
+    ).toHaveLength(1);
+    expect(
+      mapV2EventToV1({
+        type: 'form.created',
+        properties: { form: { sessionID: 'ses_q' } }, // no id
+      }),
+    ).toHaveLength(1);
+    expect(
+      mapV2EventToV1({
+        type: 'form.replied',
+        properties: { sessionID: 'ses_q', answer: {} }, // no id
+      }),
+    ).toHaveLength(1);
+  });
+
+  test('ask/reply round-trip arms then resolves the real input-wait tracker', async () => {
+    // End-to-end against the consumer that matters: the task-session-
+    // manager input-wait gate must arm on the synthesized ask and clear
+    // on the synthesized reply.
+    const { createTaskSessionManagerHook } = await import(
+      '../hooks/task-session-manager'
+    );
+    const hook = createTaskSessionManagerHook({} as never, {
+      maxSessionsPerAgent: 2,
+      maxRetainedSnapshots: 2,
+      shouldManageSession: (id: string) => id === 'parent-1',
+    });
+    const asked = mapV2EventToV1({
+      type: 'form.created',
+      properties: {
+        form: {
+          id: 'frm_w',
+          sessionID: 'parent-1',
+          title: 't',
+          fields: [{ key: 'k', type: 'string' }],
+        },
+      },
+    })[1];
+    await hook.event({ event: asked });
+    expect(hook.hasInputWait('parent-1')).toBe(true);
+    const replied = mapV2EventToV1({
+      type: 'form.replied',
+      properties: { id: 'frm_w', sessionID: 'parent-1', answer: { k: 'v' } },
+    })[1];
+    await hook.event({ event: replied });
+    expect(hook.hasInputWait('parent-1')).toBe(false);
+  });
+});
+
+describe('mapV2EventToV1 permission field mapping', () => {
+  test('permission.asked synthesizes the v1 field names after the raw event', () => {
+    const ev = deepFreeze({
+      type: 'permission.asked',
+      properties: {
+        id: 'per_1',
+        sessionID: 'ses_p',
+        action: 'execute',
+        resources: ['bash'],
+        metadata: { callID: 'call_1' },
+      },
+    });
+    const out = mapV2EventToV1(ev);
+    expect(out).toHaveLength(2);
+    expect(out[0]).toBe(ev);
+    // v1 PermissionV1 names: permission ← action, patterns ← resources.
+    // The repo consumers (input-wait tracker, wake scheduler, companion)
+    // read {id, sessionID} — present on both shapes.
+    expect(out[1]).toEqual({
+      type: 'permission.asked',
+      properties: {
+        id: 'per_1',
+        sessionID: 'ses_p',
+        permission: 'execute',
+        patterns: ['bash'],
+        metadata: { callID: 'call_1' },
+        always: [],
+      },
+    });
+  });
+
+  test('permission.asked maps save → always and degrades missing fields', () => {
+    const out = mapV2EventToV1({
+      type: 'permission.asked',
+      properties: {
+        id: 'per_2',
+        sessionID: 'ses_p',
+        action: 'edit',
+        resources: ['file:///a', 42],
+        save: ['file:///a'],
+      },
+    });
+    expect(out[1]).toEqual({
+      type: 'permission.asked',
+      properties: {
+        id: 'per_2',
+        sessionID: 'ses_p',
+        permission: 'edit',
+        patterns: ['file:///a'],
+        metadata: {},
+        always: ['file:///a'],
+      },
+    });
+  });
+
+  test('permission.asked without id/sessionID stays passthrough-only', () => {
+    expect(
+      mapV2EventToV1({
+        type: 'permission.asked',
+        properties: { sessionID: 'ses_p' },
+      }),
+    ).toHaveLength(1);
+    expect(
+      mapV2EventToV1({ type: 'permission.asked', properties: {} }),
+    ).toHaveLength(1);
+  });
+
+  test('permission.replied passes through raw — v2 shape already matches v1', () => {
+    // v2 replied {sessionID, requestID, reply} IS the v1 PermissionV1
+    // event shape; the consumers read sessionID + requestID directly.
+    const ev = deepFreeze({
+      type: 'permission.replied',
+      properties: { sessionID: 'ses_p', requestID: 'per_1', reply: 'once' },
+    });
+    expect(mapV2EventToV1(ev)).toEqual([ev]);
+  });
+
+  test('synthesized ask/replied pair arms then resolves the input-wait tracker', async () => {
+    const { createTaskSessionManagerHook } = await import(
+      '../hooks/task-session-manager'
+    );
+    const hook = createTaskSessionManagerHook({} as never, {
+      maxSessionsPerAgent: 2,
+      maxRetainedSnapshots: 2,
+      shouldManageSession: (id: string) => id === 'parent-1',
+    });
+    const asked = mapV2EventToV1({
+      type: 'permission.asked',
+      properties: {
+        id: 'per_w',
+        sessionID: 'parent-1',
+        action: 'execute',
+        resources: [],
+      },
+    })[1];
+    await hook.event({ event: asked });
+    expect(hook.hasInputWait('parent-1')).toBe(true);
+    await hook.event({
+      event: {
+        type: 'permission.replied',
+        properties: {
+          sessionID: 'parent-1',
+          requestID: 'per_w',
+          reply: 'once',
+        },
+      },
+    });
+    expect(hook.hasInputWait('parent-1')).toBe(false);
+  });
+});

+ 263 - 2
src/v2/event-adapter.ts

@@ -8,12 +8,28 @@
  * - token/cache telemetry moved to `session.usage.updated` /
  *   `session.step.ended` (v2 has no `message.updated`).
  *
+ * Payload key: live v2 hosts deliver the event payload under `data`
+ * (`{id, created, type, location?, durable?, data}` — the wire/plugin
+ * `OpenCodeEvent` shape, verified live on beta-19365); the `properties`
+ * spelling is accepted as a legacy/test fallback. The v1 consumers the
+ * synthesized shapes target all read `properties`, so every synthesized
+ * event below writes `properties` regardless of the source key.
+ *
  * `mapV2EventToV1` is additive synthesis only: the first element of the
  * returned array is ALWAYS the raw input event, unmodified (byte-identical
  * reference), so v2-native handlers (interview bridge) and any v1 handler
  * already tolerant of the v2 shape keep seeing it. Synthesized v1-shape
  * events are appended after it.
  *
+ * Lifecycle note: newer v2 hosts (verified live on beta-19365/beta-19378)
+ * publish durable `session.execution.started/succeeded/failed/interrupted`
+ * events and no longer stream busy/idle `session.status` on the SSE event
+ * flow (`session.status` remains only in the schema). The execution events
+ * are synthesized into the v1 lifecycle shapes below; the idle
+ * `session.status` → `session.idle` path is retained for older hosts, and
+ * hosts emitting both simply deliver idle more than once — tolerated by
+ * the documented double-idle invariant.
+ *
  * The synthesized shapes are pinned to what the v1 consumers actually read:
  * - `session.created` early registration (task-session-manager
  *   event-router): `properties.info.{id,parentID,agent?}` — plugin
@@ -22,6 +38,17 @@
  *   parseCompletedAssistantMessage): `properties.info.{role:'assistant',
  *   sessionID, id, time.completed, tokens.input, tokens.cache.read,
  *   tokens.cache.write}`.
+ * - `question.asked/replied/rejected` (companionManager,
+ *   task-session-manager input-wait tracker, orchestrator-wake): the v1
+ *   QuestionV1 shapes `{id, sessionID, questions}` /
+ *   `{sessionID, requestID, answers}` / `{sessionID, requestID}`,
+ *   synthesized from the v2 Form flow (form.created/replied/cancelled).
+ *   Forms owned by the `"global"` sentinel session stay unsynthesized.
+ * - `permission.asked` field mapping (same consumers): v1 names
+ *   `{id, sessionID, permission, patterns, metadata, always}` ← v2
+ *   `{id, sessionID, action, resources, metadata?, save?}`.
+ *   `permission.replied` passes through raw — v2's shape already matches
+ *   the v1 event.
  */
 
 import { isRecord } from '../utils/guards';
@@ -32,6 +59,16 @@ function finiteNumber(value: unknown): number | undefined {
     : undefined;
 }
 
+/**
+ * Resolve the v2 event payload: live hosts carry it under `data` (the
+ * `OpenCodeEvent` wire shape); `properties` is the legacy/test spelling.
+ */
+function payloadOf(event: Record<string, unknown>): Record<string, unknown> {
+  if (isRecord(event.data)) return event.data;
+  if (isRecord(event.properties)) return event.properties;
+  return {};
+}
+
 /**
  * v2 usage telemetry (`session.usage.updated` / `session.step.ended`)
  * → v1 completed-assistant `message.updated`.
@@ -89,15 +126,145 @@ function usageToMessageUpdated(
   };
 }
 
+/**
+ * v2 Form field → v1 QuestionV1.Info (`{question, header, options,
+ * multiple?}`). Only fields with a usable title/key survive; option
+ * labels/descriptions pass through with v1's required-string shape.
+ */
+function formFieldToQuestion(
+  field: Record<string, unknown>,
+): Record<string, unknown> | undefined {
+  const title =
+    typeof field.title === 'string' && field.title
+      ? field.title
+      : typeof field.key === 'string' && field.key
+        ? field.key
+        : undefined;
+  if (!title) return undefined;
+  const options = Array.isArray(field.options)
+    ? field.options.filter(isRecord).map((option) => ({
+        label: typeof option.label === 'string' ? option.label : '',
+        description:
+          typeof option.description === 'string' ? option.description : '',
+      }))
+    : [];
+  const question: Record<string, unknown> = {
+    question: title,
+    // v1 QuestionV1 caps the header at 30 chars.
+    header: title.slice(0, 30),
+    options,
+  };
+  if (field.type === 'multiselect') question.multiple = true;
+  return question;
+}
+
+/** v1 QuestionV1 answers are `string[][]` (selected labels per question,
+ * in question order); map the v2 `Record<key, Value>` preserving the
+ * record's insertion order. */
+function formAnswerToV1Answers(answer: unknown): Array<Array<string>> {
+  if (!isRecord(answer)) return [];
+  return Object.values(answer).map((value) =>
+    Array.isArray(value)
+      ? value.filter((entry): entry is string => typeof entry === 'string')
+      : [String(value)],
+  );
+}
+
+/**
+ * v2 `form.created` → v1 `question.asked`. v2 replaced the v1
+ * question.* events with the Form flow; the v1 consumers
+ * (companionManager, task-session-manager input-wait tracker,
+ * orchestrator-wake) key on `{id, sessionID}` for asks and
+ * `{sessionID, requestID}` for resolutions — the shapes below follow the
+ * v1 QuestionV1 event schema so the full contract stays intact.
+ *
+ * Forms owned by the `"global"` sentinel (MCP elicitation) are skipped:
+ * v1 question.* are session-scoped, and the tracker gates on
+ * `shouldManageSession(sessionID)` (a real orchestrator session).
+ */
+function formCreatedToQuestionAsked(
+  props: Record<string, unknown>,
+): Record<string, unknown> | undefined {
+  const form = isRecord(props.form) ? props.form : undefined;
+  if (!form) return undefined;
+  const id = typeof form.id === 'string' ? form.id : undefined;
+  const sessionID =
+    typeof form.sessionID === 'string' ? form.sessionID : undefined;
+  if (!id || !sessionID || sessionID === 'global') return undefined;
+  const questions = Array.isArray(form.fields)
+    ? form.fields
+        .filter(isRecord)
+        .map(formFieldToQuestion)
+        .filter((q): q is Record<string, unknown> => q !== undefined)
+    : [];
+  return {
+    type: 'question.asked',
+    properties: { id, sessionID, questions },
+  };
+}
+
+/**
+ * v2 `permission.asked` → v1 field names. The repo consumers read
+ * `{id, sessionID}` (input-wait tracker requestID, wake-scheduler
+ * suppress) — both already present on the raw v2 event, which stays
+ * first in the output. The synthesized copy restores the v1
+ * PermissionV1 names (`permission` ← `action`, `patterns` ←
+ * `resources`, `always` ← `save`) so shape-sensitive v1 consumers keep
+ * working. Duplicate ask delivery (raw + synthesized, same `id`) is safe:
+ * every ask consumer is idempotent per request id (Set-based tracker,
+ * status setters, timer suppression) — same invariant as the double-idle
+ * note below.
+ */
+function permissionAskedToV1(
+  props: Record<string, unknown>,
+): Record<string, unknown> | undefined {
+  const id = props.id;
+  const sessionID = props.sessionID;
+  if (typeof id !== 'string' || typeof sessionID !== 'string') {
+    return undefined;
+  }
+  return {
+    type: 'permission.asked',
+    properties: {
+      id,
+      sessionID,
+      permission: typeof props.action === 'string' ? props.action : '',
+      patterns: Array.isArray(props.resources)
+        ? props.resources.filter(
+            (resource): resource is string => typeof resource === 'string',
+          )
+        : [],
+      metadata: isRecord(props.metadata) ? props.metadata : {},
+      always: Array.isArray(props.save)
+        ? props.save.filter(
+            (entry): entry is string => typeof entry === 'string',
+          )
+        : [],
+    },
+  };
+}
+
 /**
  * Map one v2 server event into zero or more v1-shape events.
  *
  * Returns `[rawEvent, ...synthesizedV1Shapes]` — the raw event is always
  * first and never mutated. Synthesis:
  * - idle `session.status` → v1 `session.idle` `{sessionID}`;
+ * - `session.execution.*` (newer hosts; verified live beta-19365/19378)
+ *   → the v1 lifecycle shapes: `started` → `session.status`
+ *   `{status:{type:'busy'}}`; `succeeded`/`interrupted` → `session.status`
+ *   idle + `session.idle`; `failed` → a v1 `session.error` (host error
+ *   payload passed through best-effort) followed by the same idle pair —
+ *   error-before-idle preserves the error-then-idle flow the
+ *   task-session-manager event-router expects (deferred inline errors
+ *   are terminalized by the following idle);
  * - child `session.created` (parentID present) → v1 early-registration
  *   shape `{info: {id, parentID, title?, agent?}}`;
- * - usage telemetry → v1 completed-assistant `message.updated`.
+ * - usage telemetry → v1 completed-assistant `message.updated`;
+ * - `form.created/replied/cancelled` → v1 `question.asked/replied/
+ *   rejected` (QuestionV1 shapes; "global"-owned forms skipped);
+ * - `permission.asked` → v1 field names (permission ← action, patterns ←
+ *   resources). `permission.replied` needs no mapping (shapes match).
  *
  * `interviewBridge.handleEvent` keeps receiving the RAW v2 event (the
  * setup pump dispatches it before iterating this array).
@@ -107,7 +274,7 @@ export function mapV2EventToV1(
 ): Array<Record<string, unknown>> {
   const out: Array<Record<string, unknown>> = [event];
   const type = typeof event.type === 'string' ? event.type : '';
-  const props = isRecord(event.properties) ? event.properties : {};
+  const props = payloadOf(event);
 
   if (type === 'session.status') {
     const statusType = isRecord(props.status)
@@ -129,6 +296,63 @@ export function mapV2EventToV1(
         properties: { sessionID: props.sessionID },
       });
     }
+  } else if (
+    type === 'session.execution.started' ||
+    type === 'session.execution.succeeded' ||
+    type === 'session.execution.failed' ||
+    type === 'session.execution.interrupted'
+  ) {
+    // Newer v2 hosts publish durable `session.execution.*` and no longer
+    // stream busy/idle `session.status` (verified live beta-19365/19378).
+    // Synthesize the v1 lifecycle shapes the wake scheduler, the
+    // task-session-manager, and the foreground fallback key on. The
+    // `session.status` path above still covers older hosts; a host
+    // emitting both delivers idle repeatedly, which the documented
+    // double-idle invariant already tolerates. Only the four verified
+    // subtypes map — unknown `session.execution.*` variants stay
+    // passthrough-only rather than guessing a lifecycle meaning.
+    if (typeof props.sessionID === 'string') {
+      const sessionID = props.sessionID;
+      if (type === 'session.execution.started') {
+        out.push({
+          type: 'session.status',
+          properties: { sessionID, status: { type: 'busy' } },
+        });
+      } else {
+        if (type === 'session.execution.failed') {
+          // The v1 `session.error` consumers classify the payload
+          // themselves (foreground-fallback `isFailoverError`,
+          // event-router failover deferral), so the host's error field
+          // passes through verbatim; a missing error degrades to a
+          // best-effort message that classifies as non-failover. Emitted
+          // BEFORE the idle pair so the error-then-idle ordering the
+          // event-router expects is preserved on the synthesized stream.
+          out.push({
+            type: 'session.error',
+            properties: {
+              sessionID,
+              error:
+                props.error !== undefined
+                  ? props.error
+                  : { message: 'v2 session execution failed' },
+            },
+          });
+        }
+        // succeeded / interrupted / failed all terminate the run. Idle
+        // (not error/retry) is the correct terminal state for wake
+        // arming: `beginContinuousIdle` is idempotent per session, so
+        // the synthesized status+idle pair arming twice matches the
+        // double-idle invariant.
+        out.push({
+          type: 'session.status',
+          properties: { sessionID, status: { type: 'idle' } },
+        });
+        out.push({
+          type: 'session.idle',
+          properties: { sessionID },
+        });
+      }
+    }
   } else if (type === 'session.created') {
     // Only child sessions are plugin-relevant: event-router gates early
     // board registration on `info.parentID` + shouldManageSession(parent).
@@ -153,7 +377,44 @@ export function mapV2EventToV1(
   ) {
     const mapped = usageToMessageUpdated(props);
     if (mapped) out.push(mapped);
+  } else if (type === 'form.created') {
+    const mapped = formCreatedToQuestionAsked(props);
+    if (mapped) out.push(mapped);
+  } else if (type === 'form.replied') {
+    // {id, sessionID, answer} → v1 question.replied {sessionID, requestID,
+    // answers}. Consumers read sessionID + requestID.
+    if (
+      typeof props.id === 'string' &&
+      typeof props.sessionID === 'string' &&
+      props.sessionID !== 'global'
+    ) {
+      out.push({
+        type: 'question.replied',
+        properties: {
+          sessionID: props.sessionID,
+          requestID: props.id,
+          answers: formAnswerToV1Answers(props.answer),
+        },
+      });
+    }
+  } else if (type === 'form.cancelled') {
+    if (
+      typeof props.id === 'string' &&
+      typeof props.sessionID === 'string' &&
+      props.sessionID !== 'global'
+    ) {
+      out.push({
+        type: 'question.rejected',
+        properties: { sessionID: props.sessionID, requestID: props.id },
+      });
+    }
+  } else if (type === 'permission.asked') {
+    const mapped = permissionAskedToV1(props);
+    if (mapped) out.push(mapped);
   }
+  // `permission.replied` needs no synthesis: v2's shape
+  // {sessionID, requestID, reply} IS the v1 PermissionV1 event shape, and
+  // the raw event is always dispatched first above.
 
   return out;
 }

+ 2 - 0
src/v2/index.ts

@@ -29,5 +29,7 @@ export type {
   V2SessionContextEvent,
   V2ToolAfterEvent,
   V2ToolBeforeEvent,
+  V2ToolDefinition,
   V2ToolDraft,
+  V2ToolOptions,
 } from './types';

+ 3 - 1
src/v2/interview-bridge.test.ts

@@ -172,7 +172,9 @@ describe('v2 interview bridge', () => {
     await bridge.runtime.notify('ses_n', 'ready');
     expect(calls).toContainEqual({
       method: 'synthetic',
-      input: { sessionID: 'ses_n', text: 'ready' },
+      // resume:false = admit the interview URL without waking the session
+      // (v1's noReply prompt equivalent; no agent turn, no double-send).
+      input: { sessionID: 'ses_n', text: 'ready', resume: false },
     });
 
     await bridge.runtime.continue('ses_c', 'go on');

+ 9 - 4
src/v2/interview-bridge.ts

@@ -7,6 +7,7 @@ import { createInterviewServer } from '../interview/server';
 import { createInterviewService } from '../interview/service';
 import type { InterviewMessage } from '../interview/types';
 import { log } from '../utils/logger';
+import { createSessionListShim } from './client-shim';
 import { createSessionSubmit, textFromContent } from './session-submit';
 import type {
   V2CommandDraft,
@@ -90,14 +91,15 @@ export function createV2InterviewBridge(
   const runtime: InterviewSessionRuntime = {
     messages: async (sessionID) => transcripts.get(sessionID) ?? [],
     notify: async (sessionID, text) => {
-      // synthetic only — no prompt fallback: synthetic avoids triggering an
-      // agent turn; a prompt fallback would double-send and wake the loop.
+      // synthetic only — no prompt fallback: `resume: false` admits the
+      // input WITHOUT waking the session, mirroring the v1 noReply prompt
+      // (a prompt fallback would double-send and wake the loop).
       if (typeof methods.synthetic !== 'function') {
         log('[v2][interview] synthetic unavailable for notify', { sessionID });
         return;
       }
       try {
-        await methods.synthetic({ sessionID, text });
+        await methods.synthetic({ sessionID, text, resume: false });
       } catch (err) {
         log('[v2][interview] synthetic notify failed', {
           sessionID,
@@ -147,8 +149,11 @@ export function createV2InterviewBridge(
         outputFolder,
         {
           runtime,
+          // v1-shaped list over v2 session.list (directory discovery for
+          // the dashboard's session scan); empty page when the host lacks
+          // the method.
           sessionClient: {
-            list: async () => ({ data: [] }),
+            list: createSessionListShim(methods),
           } as never,
           server: options.server,
         },

+ 595 - 0
src/v2/setup-command.test.ts

@@ -1,5 +1,11 @@
 import { describe, expect, mock, test } from 'bun:test';
 import * as fs from 'node:fs/promises';
+import { appendTaggedSyntheticPart } from '../hooks/cache-safe-injection';
+import { createJsonErrorRecoveryHook } from '../hooks/json-error-recovery/hook';
+import {
+  createPhaseReminderHook,
+  PHASE_REMINDER_METADATA_KEY,
+} from '../hooks/phase-reminder';
 import {
   createToolLoopGuardHook,
   LOOP_GUARD_WARNING,
@@ -10,6 +16,7 @@ import {
   applyCommandMarkerToContext,
   createCommandRegistration,
   createSessionContextHandler,
+  createSessionPromptBridge,
   createToolExecuteBridges,
   parseCommandMarker,
   registerSynthCommands,
@@ -21,6 +28,7 @@ import type {
   V2CommandDefinition,
   V2CommandDraft,
   V2SessionContextEvent,
+  V2SessionPromptEvent,
 } from './types';
 
 function makeEvent(
@@ -538,6 +546,153 @@ describe('createSessionContextHandler (merged context hook seam)', () => {
   });
 });
 
+describe('context bridge: transcript user-message identity enrichment', () => {
+  // Live v2 hosts (verified beta-19365/beta-19378) carry only
+  // {id, time, text, type} on transcript user messages; the v1 injection
+  // gates (phase-reminder, board, nudge) key on info.sessionID/agent.
+  test('user messages gain sessionID/agent when absent; content bytes untouched', async () => {
+    const user = {
+      id: 'u1',
+      role: 'user',
+      time: 123,
+      content: [{ type: 'text', text: 'hello' }],
+    };
+    const contentBefore = structuredClone(user.content);
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async () => {},
+    });
+
+    await handler(makeEvent([user]));
+
+    expect(user.sessionID).toBe('ses_cmd');
+    expect(user.agent).toBe('orchestrator');
+    expect(user.content).toEqual(contentBefore);
+
+    // Idempotent + never overwrites: a later context event for a
+    // different session/agent must not restamp the enriched message.
+    await handler(
+      makeEvent([user], { sessionID: 'ses_other', agent: 'fixer' }),
+    );
+    expect(user.sessionID).toBe('ses_cmd');
+    expect(user.agent).toBe('orchestrator');
+  });
+
+  test('host-provided sessionID/agent values are preserved', async () => {
+    const user = {
+      id: 'u1',
+      role: 'user',
+      sessionID: 'host-ses',
+      agent: 'planner',
+      content: [{ type: 'text', text: 'hi' }],
+    };
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async () => {},
+    });
+
+    await handler(makeEvent([user]));
+
+    expect(user.sessionID).toBe('host-ses');
+    expect(user.agent).toBe('planner');
+  });
+
+  test('assistant messages are left untouched', async () => {
+    const assistant = {
+      id: 'a1',
+      role: 'assistant',
+      time: 456,
+      content: [{ type: 'text', text: 'response' }],
+    };
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async () => {},
+    });
+
+    await handler(makeEvent([assistant]));
+
+    expect(assistant.sessionID).toBeUndefined();
+    expect(assistant.agent).toBeUndefined();
+  });
+
+  test('agent falls back to the prompt-bridge learned state when the event carries none', async () => {
+    const user = {
+      id: 'u1',
+      role: 'user',
+      content: [{ type: 'text', text: 'hi' }],
+    };
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async () => {},
+      knownAgentForSession: (sessionID) =>
+        sessionID === 'ses_cmd' ? 'oracle' : undefined,
+    });
+
+    await handler(makeEvent([user], { agent: '' }));
+
+    expect(user.sessionID).toBe('ses_cmd');
+    expect(user.agent).toBe('oracle');
+  });
+
+  test('no enrichment without a messagesTransform dep (mutation stays scoped)', async () => {
+    const user = {
+      id: 'u1',
+      role: 'user',
+      content: [{ type: 'text', text: 'hi' }],
+    };
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+    });
+
+    await handler(makeEvent([user]));
+
+    expect(user.sessionID).toBeUndefined();
+    expect(user.agent).toBeUndefined();
+  });
+
+  test('end-to-end: a recognized agent now performs the phase-reminder injection it previously skipped', async () => {
+    const phaseReminder = createPhaseReminderHook({
+      shouldInject: () => true,
+    });
+
+    // The pre-fix behavior: the raw v2 transcript message (no
+    // sessionID/agent) fails the gate inside the real v1 hook.
+    const unenriched = {
+      info: { role: 'user' },
+      parts: [{ type: 'text', text: 'do the work' }],
+    };
+    await phaseReminder['experimental.chat.messages.transform'](
+      {},
+      { messages: [unenriched] },
+    );
+    expect(unenriched.parts).toHaveLength(1);
+
+    // Through the v2 context bridge: identity enrichment makes the same
+    // gate pass and the reminder lands as a tagged synthetic part.
+    const message = {
+      id: 'u1',
+      role: 'user',
+      time: 123,
+      content: [{ type: 'text', text: 'do the work' }],
+    };
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: phaseReminder['experimental.chat.messages.transform'],
+    });
+
+    await handler(makeEvent([message]));
+
+    expect(message.content).toHaveLength(2);
+    const injected = message.content[1] as Record<string, unknown>;
+    expect(injected.synthetic).toBe(true);
+    expect(
+      (injected.metadata as Record<string, unknown>)[
+        PHASE_REMINDER_METADATA_KEY
+      ],
+    ).toBe(true);
+  });
+});
+
 describe('tool execute bridge normalization', () => {
   test('before bridge maps subagent call into v1 task shape and writes back', async () => {
     const seen: Array<{ tool: string; args: unknown }> = [];
@@ -835,3 +990,443 @@ describe('tool execute bridge normalization', () => {
     expect(nextTurn).not.toContain(LOOP_GUARD_WARNING);
   });
 });
+
+describe('tool execute bridge status discrimination', () => {
+  test('error status synthesizes the v1 output from the error text', async () => {
+    const seen: Array<{ tool: string; output: unknown }> = [];
+    const after = async (_i: unknown, o: { output: unknown }) => {
+      seen.push({ tool: 'task', output: o.output });
+    };
+    const { afterBridge } = createToolExecuteBridges(undefined, after);
+    const event = {
+      tool: 'subagent',
+      sessionID: 's',
+      agent: 'a',
+      messageID: 'm',
+      id: 'c',
+      input: {},
+      status: 'error' as const,
+      error: 'invalid JSON in tool arguments',
+      // Stale result content that must NOT be presented as success.
+      result: { content: 'previous successful output' },
+    };
+    await afterBridge(event);
+    expect(seen[0]?.output).toBe('invalid JSON in tool arguments');
+    // Hook saw the error text; the stale content was never surfaced.
+  });
+
+  test('error status with an Error-like payload uses message', async () => {
+    const seen: unknown[] = [];
+    const { afterBridge } = createToolExecuteBridges(
+      undefined,
+      async (_i, o: { output: unknown }) => {
+        seen.push(o.output);
+      },
+    );
+    await afterBridge({
+      tool: 'read',
+      sessionID: 's',
+      agent: 'a',
+      messageID: 'm',
+      id: 'c',
+      input: {},
+      status: 'error',
+      error: { message: 'file not found' },
+      result: undefined,
+    });
+    expect(seen).toEqual(['file not found']);
+  });
+
+  test('errored output still feeds json-error-recovery (reminder appended)', async () => {
+    const recovery = createJsonErrorRecoveryHook({} as never);
+    const event = {
+      tool: 'task',
+      sessionID: 's',
+      agent: 'a',
+      messageID: 'm',
+      id: 'c',
+      input: {},
+      status: 'error' as const,
+      error: 'SyntaxError: Unexpected token in JSON',
+      result: {},
+    };
+    const { afterBridge } = createToolExecuteBridges(
+      undefined,
+      recovery['tool.execute.after'],
+    );
+    await afterBridge(event);
+    // The reminder the recovery hook appended to output.output must land
+    // in the model-visible content field of the errored result.
+    expect(event.result.content).toContain('invalid JSON arguments');
+    expect(event.result.content).toContain('SyntaxError');
+  });
+
+  test('error status with no error field never presents result content as success', async () => {
+    const seen: unknown[] = [];
+    const { afterBridge } = createToolExecuteBridges(
+      undefined,
+      async (_i, o: { output: unknown }) => {
+        seen.push(o.output);
+      },
+    );
+    const event = {
+      tool: 'bash',
+      sessionID: 's',
+      agent: 'a',
+      messageID: 'm',
+      id: 'c',
+      input: {},
+      status: 'error' as const,
+      result: { content: [], output: { stale: true } },
+    };
+    await afterBridge(event);
+    // Neither the empty content nor the structured stale output is
+    // rendered as a successful output.
+    expect(seen).toEqual(['']);
+  });
+
+  test('absent status (older hosts) keeps the completed path', async () => {
+    const seen: unknown[] = [];
+    const event = {
+      tool: 'subagent',
+      sessionID: 's',
+      agent: 'a',
+      messageID: 'm',
+      id: 'c',
+      input: {},
+      result: { content: 'plain output' },
+    } as Record<string, unknown> & { result?: unknown };
+    const { afterBridge } = createToolExecuteBridges(
+      undefined,
+      async (_i, o: { output: unknown }) => {
+        seen.push(o.output);
+      },
+    );
+    await afterBridge(event);
+    expect(seen).toEqual(['plain output']);
+  });
+});
+
+describe('createSessionPromptBridge (native session.prompt hook)', () => {
+  function makePromptEvent(
+    overrides: Partial<V2SessionPromptEvent> = {},
+  ): V2SessionPromptEvent {
+    return {
+      sessionID: 'ses_p',
+      messageID: 'msg_1',
+      prompt: { text: 'do the thing' },
+      delivery: 'steer',
+      ...overrides,
+    };
+  }
+
+  test('handlePrompt delivers chat.message once per admission with parts', async () => {
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.handlePrompt(makePromptEvent());
+    expect(calls).toEqual([
+      {
+        sessionID: 'ses_p',
+        messageID: 'msg_1',
+        parts: [{ type: 'text', text: 'do the thing' }],
+      },
+    ]);
+
+    // Re-fired admission with the same messageID: still once.
+    await bridge.handlePrompt(makePromptEvent());
+    expect(calls).toHaveLength(1);
+
+    // A new admission: fires again.
+    await bridge.handlePrompt(makePromptEvent({ messageID: 'msg_2' }));
+    expect(calls).toHaveLength(2);
+  });
+
+  test('handlePrompt maps prompt files into v1 file parts', async () => {
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.handlePrompt(
+      makePromptEvent({
+        prompt: {
+          text: '',
+          files: [{ uri: 'file:///a.txt', name: 'a.txt' }],
+        },
+      }),
+    );
+    // observeChatMessage (task-session-manager + wake scheduler) gates on
+    // a non-synthetic text/file part — the file part keeps that gate
+    // passable for attachment-only prompts.
+    expect(calls[0]?.parts).toEqual([
+      { type: 'file', uri: 'file:///a.txt', name: 'a.txt' },
+    ]);
+  });
+
+  test('observeContext forwards newly learned agent/model, then goes quiet', async () => {
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    const contextEvent = makeEvent(
+      [
+        {
+          id: 'msg_1',
+          role: 'user',
+          content: [{ type: 'text', text: 'hi' }],
+        },
+      ],
+      {
+        sessionID: 'ses_p',
+        agent: 'orchestrator',
+        model: { id: 'claude-x', providerID: 'anthropic' },
+      },
+    );
+    await bridge.observeContext(contextEvent);
+    expect(calls).toEqual([
+      {
+        sessionID: 'ses_p',
+        agent: 'orchestrator',
+        model: { providerID: 'anthropic', modelID: 'claude-x' },
+        messageID: 'msg_1',
+      },
+    ]);
+
+    // Repeated context events with the same agent/model: no more calls
+    // (once-per-admission fidelity — message-scoped delivery belongs to
+    // the prompt hook).
+    await bridge.observeContext(contextEvent);
+    await bridge.observeContext(contextEvent);
+    expect(calls).toHaveLength(1);
+
+    // A model change is newly learned state → one forwarded call.
+    await bridge.observeContext(
+      makeEvent(
+        [{ id: 'msg_2', role: 'user', content: [{ type: 'text', text: 'x' }] }],
+        {
+          sessionID: 'ses_p',
+          agent: 'orchestrator',
+          model: { id: 'claude-fallback', providerID: 'anthropic' },
+        },
+      ),
+    );
+    expect(calls).toHaveLength(2);
+    expect(calls[1]?.model).toEqual({
+      providerID: 'anthropic',
+      modelID: 'claude-fallback',
+    });
+  });
+
+  test('agent learned from context is carried by the next admission', async () => {
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.observeContext(
+      makeEvent(
+        [
+          {
+            id: 'msg_1',
+            role: 'user',
+            content: [{ type: 'text', text: 'hi' }],
+          },
+        ],
+        { sessionID: 'ses_p', agent: 'orchestrator' },
+      ),
+    );
+    await bridge.handlePrompt(makePromptEvent({ messageID: 'msg_2' }));
+    expect(calls.at(-1)).toMatchObject({
+      sessionID: 'ses_p',
+      messageID: 'msg_2',
+      agent: 'orchestrator',
+    });
+  });
+
+  test('handlePrompt feeds the real observeChatMessage consumers', async () => {
+    // The v1 observeChatMessage gate that never passed via the context
+    // emulation (no parts) must pass via the prompt hook: a non-synthetic
+    // text part + messageID present.
+    const observed: Array<{ sessionID: string; messageID?: string }> = [];
+    const bridge = createSessionPromptBridge((input) => {
+      observed.push({
+        sessionID: input.sessionID,
+        messageID: input.messageID,
+      });
+      return Promise.resolve();
+    });
+    await bridge.handlePrompt(makePromptEvent());
+    expect(observed).toEqual([{ sessionID: 'ses_p', messageID: 'msg_1' }]);
+  });
+
+  test('handlePrompt restores the internal-initiator marker from prompt metadata (wake admissions stay internal)', async () => {
+    // The v2 orchestrator-wake queue prompt arrives with the marker as
+    // prompt metadata (part metadata cannot survive the text-only v2
+    // translation). The rebuilt parts view must carry the v1 part marker
+    // so isInternalInitiatorPart consumers — orchestrator-wake's
+    // observeChatMessage in particular — treat the admission as internal
+    // (no no-progress rearm, no timer clear).
+    const calls: Array<Record<string, unknown>> = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input as Record<string, unknown>);
+    });
+    await bridge.handlePrompt(
+      makePromptEvent({
+        prompt: { text: '<system-reminder>wake</system-reminder>' },
+        metadata: { 'oh-my-opencode-slim.internalInitiator': true },
+      }),
+    );
+    expect(calls[0]?.parts).toEqual([
+      {
+        type: 'text',
+        text: '<system-reminder>wake</system-reminder>',
+        synthetic: true,
+        metadata: { 'oh-my-opencode-slim.internalInitiator': true },
+      },
+    ]);
+    // The restored marker must satisfy the real v1 gate.
+    const { isInternalInitiatorPart } = await import(
+      '../utils/internal-initiator'
+    );
+    const parts = calls[0]?.parts as unknown[];
+    expect(parts.some((part) => isInternalInitiatorPart(part))).toBe(true);
+
+    // Without the metadata flag the parts view stays plain (external).
+    const plainCalls: Array<Record<string, unknown>> = [];
+    const bridge2 = createSessionPromptBridge(async (input) => {
+      plainCalls.push(input as Record<string, unknown>);
+    });
+    await bridge2.handlePrompt(
+      makePromptEvent({ prompt: { text: 'user text' } }),
+    );
+    expect(plainCalls[0]?.parts).toEqual([{ type: 'text', text: 'user text' }]);
+  });
+
+  test('malformed prompt events are ignored without throwing', async () => {
+    const calls: unknown[] = [];
+    const bridge = createSessionPromptBridge(async (input) => {
+      calls.push(input);
+    });
+    await expect(
+      bridge.handlePrompt({
+        sessionID: '',
+        messageID: 'm',
+        prompt: { text: 't' },
+      }),
+    ).resolves.toBeUndefined();
+    await expect(
+      bridge.handlePrompt({
+        sessionID: 's',
+        messageID: '',
+        prompt: { text: 't' },
+      }),
+    ).resolves.toBeUndefined();
+    expect(calls).toEqual([]);
+  });
+});
+
+describe('context handler: native prompt mode + CacheHint', () => {
+  test('observeContextAgent runs and the per-request emulation is skipped', async () => {
+    const observed: string[] = [];
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      // Native prompt-hook mode: no chatMessage emulation dep — agent
+      // tracking flows through observeContextAgent instead.
+      observeContextAgent: async (event) => {
+        observed.push(event.sessionID);
+      },
+    });
+    await handler(makeEvent([{ id: 'u', role: 'user', content: [] }]));
+    expect(observed).toEqual(['ses_cmd']);
+  });
+
+  test('v2-injected parts carry cache:{type:"ephemeral"} via the bridge', async () => {
+    const message = {
+      id: 'u',
+      role: 'user',
+      content: [{ type: 'text', text: 'hi' }],
+    };
+    const event = makeEvent([message]);
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async (_input, output) => {
+        const target = output.messages.at(-1);
+        if (!target) throw new Error('no message');
+        appendTaggedSyntheticPart(target, {
+          text: 'INJECTED REMINDER',
+          metadataKey: 'omos_test_tag',
+        });
+      },
+      syntheticPartCacheHint: { type: 'ephemeral' },
+    });
+
+    await handler(event);
+
+    const injected = message.content.at(-1) as Record<string, unknown>;
+    expect(injected.cache).toEqual({ type: 'ephemeral' });
+  });
+
+  test('without the hint dep injected parts stay byte-identical to v1', async () => {
+    const message = {
+      id: 'u',
+      role: 'user',
+      content: [{ type: 'text', text: 'hi' }],
+    };
+    const event = makeEvent([message]);
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async (_input, output) => {
+        const target = output.messages.at(-1);
+        if (!target) throw new Error('no message');
+        appendTaggedSyntheticPart(target, {
+          text: 'INJECTED REMINDER',
+          metadataKey: 'omos_test_tag',
+        });
+      },
+    });
+
+    await handler(event);
+
+    const injected = message.content.at(-1) as Record<string, unknown>;
+    expect(injected.cache).toBeUndefined();
+    expect(injected).toEqual({
+      type: 'text',
+      synthetic: true,
+      text: 'INJECTED REMINDER',
+      metadata: { omos_test_tag: true },
+    });
+  });
+
+  test('hint scoping restores the default after the transform', async () => {
+    // Outside the bridged transform, injection must not carry the hint —
+    // proves the set/restore wrapper cannot leak into v1-path calls.
+    const probe: Array<Record<string, unknown>> = [];
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      messagesTransform: async (_input, output) => {
+        const target = output.messages.at(-1);
+        if (!target) throw new Error('no message');
+        appendTaggedSyntheticPart(target, {
+          text: 'inside',
+          metadataKey: 'omos_test_tag',
+        });
+      },
+      syntheticPartCacheHint: { type: 'ephemeral' },
+    });
+    await handler(makeEvent([{ id: 'u', role: 'user', content: [] }]));
+    appendTaggedSyntheticPart(
+      {
+        info: { role: 'user' },
+        get parts() {
+          return probe;
+        },
+        set parts(value) {
+          probe.push(...(value as Array<Record<string, unknown>>));
+        },
+      } as never,
+      { text: 'outside', metadataKey: 'omos_test_tag' },
+    );
+    const outside = { ...probe.at(-1) } as Record<string, unknown>;
+    expect(outside.cache).toBeUndefined();
+  });
+});

+ 24 - 0
src/v2/setup.e2e.test.ts

@@ -30,6 +30,7 @@ import type { V2Context } from './types';
 
 type CapturedTool = {
   name: string;
+  options?: { codemode?: boolean };
   execute: (input: unknown, context: unknown) => Promise<unknown>;
 };
 
@@ -314,6 +315,13 @@ describe('createV2Setup e2e', () => {
     expect(calls.agentUpdates.map((u) => u.id)).toContain('orchestrator');
     expect(calls.agentDefault).toBe('orchestrator');
     expect(calls.toolAdds.length).toBeGreaterThan(0);
+    // CodeMode split (upstream Tool.snapshot): every registered tool must
+    // carry `options: { codemode: false }` or it never becomes a direct
+    // model-visible tool definition — it lands in the `execute` tool's
+    // confined JS runtime and session catalogs yield `Unknown tool: ...`.
+    for (const tool of calls.toolAdds) {
+      expect(tool.options).toEqual({ codemode: false });
+    }
     expect(calls.commandAdds.map((c) => c.name)).toContain('deepwork');
     expect(calls.mcpSets.map((m) => m.name)).toEqual(['context7', 'gh_grep']);
     expect(calls.mcpSets.map((m) => m.config)).toEqual([
@@ -321,6 +329,7 @@ describe('createV2Setup e2e', () => {
       expect.objectContaining({ type: 'remote' }),
     ]);
     expect(calls.hooks).toContain('session:context');
+    expect(calls.hooks).toContain('session:prompt');
     expect(calls.hooks).toContain('tool:execute.before');
     expect(calls.hooks).toContain('tool:execute.after');
     expect(calls.contextHookCb).toBeFunction();
@@ -485,4 +494,19 @@ describe('createV2Setup e2e', () => {
     expect(bustWarningsAfter).toBe(bustWarningsAtDispose);
     expect(logAfterDispose).not.toContain('ses_after');
   }, 20_000);
+
+  test('dispose runs the v1 dispose hook (server.instance.disposed synthesis for wake timers)', async () => {
+    const { ctx } = makeMockV2Context(projectDir);
+    const cleanup = await createV2Setup()(ctx);
+    await cleanup();
+    await flushLoggerForTesting();
+
+    // The v1 dispose hook synthesizes `server.instance.disposed` into the
+    // v1 event consumers — orchestrator-wake scheduler timers/state,
+    // task-session manager. Without this wiring, host teardown would leak
+    // the scheduler's unref'd wake timers.
+    const logText = readPluginLog();
+    expect(logText).toContain('[v2] v1 dispose hook invoked');
+    expect(logText).not.toContain('[v2] v1 dispose failed');
+  }, 20_000);
 });

+ 351 - 19
src/v2/setup.ts

@@ -5,14 +5,21 @@
  * wraps the existing v1 factory (reusing ALL build logic) and translates the
  * returned v1 `Hooks` into v2 registrations: agent/tool/command transforms,
  * a single session context hook (system/messages transforms, chat.message
- * tracking, and interview + generic command marker dispatch), tool execute
- * hooks, and the event stream. Each bridge is independently try/catch-guarded.
+ * tracking, and interview + generic command marker dispatch), the native
+ * `session.prompt` hook (once-per-admission chat.message fidelity, with a
+ * context-hook fallback on older hosts), tool execute hooks, and the event
+ * stream. Each bridge is independently try/catch-guarded.
  */
 
 import { loadPluginConfig } from '../config/loader';
 import { InterviewConfigSchema } from '../config/schema';
+import {
+  type SyntheticPartCacheHint,
+  setDefaultSyntheticPartCacheHint,
+} from '../hooks/cache-safe-injection';
 import { OhMyOpenCodeLite } from '../index';
 import type { McpConfig } from '../mcp/types';
+import { INTERNAL_INITIATOR_METADATA_KEY } from '../utils/internal-initiator';
 import { initLogger, log } from '../utils/logger';
 import { adaptTool, applyAgentToDraft } from './adapters';
 import { buildPluginInput, resolveV2Directory } from './client-shim';
@@ -30,6 +37,7 @@ import type {
   V2CommandDraft,
   V2Context,
   V2SessionContextEvent,
+  V2SessionPromptEvent,
   V2ToolAfterEvent,
   V2ToolBeforeEvent,
 } from './types';
@@ -178,6 +186,16 @@ export async function applyCommandMarkerToContext(
   trailing.content = [{ type: 'text', text: stripCommandMarker(text) }];
 }
 
+/** Payload the v1 `chat.message` bridge feeds its consumers (a subset of
+ * the real v1 hook input — see src/index.ts wiring). */
+export type V1ChatMessageInput = {
+  sessionID: string;
+  agent?: string;
+  model?: { providerID: string; modelID: string; variant?: string };
+  messageID?: string;
+  parts?: unknown[];
+};
+
 /** Deps injected into the single session context hook. */
 export interface V2SessionContextHandlerDeps {
   /** Interview bridge handleContext (transcript projection + /interview
@@ -185,11 +203,18 @@ export interface V2SessionContextHandlerDeps {
   interviewHandleContext: (event: V2SessionContextEvent) => Promise<void>;
   /** v1 `command.execute.before` hook (generic command marker dispatch). */
   commandBefore?: V1CommandBeforeHook;
-  /** v1 `chat.message` hook (agent tracking). */
-  chatMessage?: (
-    input: { sessionID: string; agent?: string; messageID?: string },
-    output: unknown,
-  ) => Promise<void>;
+  /** v1 `chat.message` hook (per-request context emulation). Omitted when
+   * the native v2 `session.prompt` hook owns message-scoped delivery. */
+  chatMessage?: (input: V1ChatMessageInput, output: unknown) => Promise<void>;
+  /** Native prompt-hook mode: records per-session agent/model from
+   * context events and forwards newly learned state to the v1
+   * `chat.message` hook (see createSessionPromptBridge). */
+  observeContextAgent?: (event: V2SessionContextEvent) => Promise<void>;
+  /** Agent known for a session, from the agent-learned state the
+   * session-prompt bridge / context events maintain. Used to enrich
+   * transcript user messages the v1 injection gates key on when the
+   * context event itself carries no agent. */
+  knownAgentForSession?: (sessionID: string) => string | undefined;
   /** v1 `experimental.chat.system.transform` hook. */
   systemTransform?: (
     input: unknown,
@@ -202,6 +227,10 @@ export interface V2SessionContextHandlerDeps {
       messages: Array<{ info: { role: string }; parts: unknown[] }>;
     },
   ) => Promise<void>;
+  /** CacheHint stamped on parts injected while the bridged messages
+   * transform runs (v2 ContentPart.cache; v1 bytes never change — see
+   * cache-safe-injection). */
+  syntheticPartCacheHint?: SyntheticPartCacheHint;
 }
 
 /** Build the single `ctx.session.hook("context")` handler: interview marker
@@ -225,7 +254,19 @@ export function createSessionContextHandler(
         log('[v2] command context bridge failed', String(err));
       }
     }
-    // Agent tracking (chat.message equivalent).
+    // Agent/model discovery (native prompt-hook mode): the prompt hook
+    // fires before the first context event, so first-admission agent/model
+    // must be discovered here and forwarded to the v1 chat.message hook
+    // (once per newly learned state, not per request).
+    if (deps.observeContextAgent) {
+      try {
+        await deps.observeContextAgent(event);
+      } catch (err) {
+        log('[v2] chat.message agent-discovery bridge failed', String(err));
+      }
+    }
+    // Agent tracking (chat.message equivalent, per-request emulation —
+    // only when the native prompt hook did NOT take over).
     if (deps.chatMessage) {
       try {
         const userMessage = [...event.messages]
@@ -267,6 +308,37 @@ export function createSessionContextHandler(
     // injection does), so rebuild event.messages from the transformed
     // v1messages rather than index-based content copy-back.
     if (deps.messagesTransform && Array.isArray(event.messages)) {
+      // Transcript identity enrichment (v2-only): live v2 hosts carry
+      // only {id, time, text, type} on transcript user messages, but the
+      // bridged v1 injection gates (phase-reminder, background-job-board,
+      // post-file-tool-nudge) key on user-message info.sessionID /
+      // info.agent — without this stamp every injection skips on v2.
+      // Metadata-only (envelope fields; parts/content bytes untouched)
+      // and strictly absence-gated: host-provided values always win.
+      // Idempotent across context events — a message stamped once never
+      // qualifies for stamping again.
+      const knownAgent =
+        typeof event.agent === 'string' && event.agent
+          ? event.agent
+          : deps.knownAgentForSession?.(event.sessionID);
+      for (const message of event.messages) {
+        if (message.role !== 'user') continue;
+        if (message.sessionID === undefined) {
+          message.sessionID = event.sessionID;
+        }
+        if (message.agent === undefined && knownAgent) {
+          message.agent = knownAgent;
+        }
+      }
+      // CacheHint tagging (v2-only): parts injected through
+      // cache-safe-injection while the bridged transform runs carry an
+      // ephemeral cache hint (v2 ContentPart.cache), so providers cap the
+      // injected zone's cache contribution. Scoped set/restore — the v1
+      // pipeline never executes inside this wrapper, so v1 payload bytes
+      // never change (pinned by the v1 snapshot/property suites).
+      const restoreCacheHint = deps.syntheticPartCacheHint
+        ? setDefaultSyntheticPartCacheHint(deps.syntheticPartCacheHint)
+        : undefined;
       try {
         const v1messages = event.messages.map((m) => ({
           info: m,
@@ -280,11 +352,203 @@ export function createSessionContextHandler(
         }) as V2SessionContextEvent['messages'];
       } catch (err) {
         log('[v2] messages transform bridge failed', String(err));
+      } finally {
+        restoreCacheHint?.();
       }
     }
   };
 }
 
+/** Cap on per-session bookkeeping maps (FIFO eviction) — mirrors the
+ * tool-loop guard's MAX_TRACKED_SESSIONS rationale. */
+const MAX_PROMPT_BRIDGE_SESSIONS = 1024;
+
+function pruneSessionMap<T>(map: Map<string, T>): void {
+  while (map.size > MAX_PROMPT_BRIDGE_SESSIONS) {
+    const oldest = map.keys().next().value as string | undefined;
+    if (oldest === undefined) break;
+    map.delete(oldest);
+  }
+}
+
+/** v2 Model.Ref from a context event (`{id, providerID, variant?}`) →
+ * v1 chat.message model (`{providerID, modelID, variant?}`). */
+function v1ModelFromContext(
+  model: Record<string, unknown> | undefined,
+): { providerID: string; modelID: string; variant?: string } | undefined {
+  if (!model) return undefined;
+  const id = model.id;
+  const providerID = model.providerID;
+  if (typeof id !== 'string' || typeof providerID !== 'string') {
+    return undefined;
+  }
+  return {
+    providerID,
+    modelID: id,
+    ...(typeof model.variant === 'string' ? { variant: model.variant } : {}),
+  };
+}
+
+export interface V2SessionPromptBridge {
+  /** `ctx.session.hook("prompt")` handler — one v1 chat.message delivery
+   * per admitted input (dedupe by messageID). */
+  handlePrompt(event: V2SessionPromptEvent): Promise<void>;
+  /** Record per-session agent/model from context events; forward NEWLY
+   * learned state to the v1 chat.message hook. */
+  observeContext(event: V2SessionContextEvent): Promise<void>;
+  /** Latest agent known for a session from the learned state above (the
+   * identity source for transcript user-message enrichment). */
+  agentForSession(sessionID: string): string | undefined;
+}
+
+/**
+ * Native `session.prompt` hook → v1 `chat.message` bridge.
+ *
+ * v2's prompt hook fires ONCE per admitted input — endpoint prompts AND
+ * subagent-tool child prompts (synthetic/shell/compaction inputs skip
+ * it) — with the eventual inbox User `messageID`, the exact identity the
+ * v1 chat.message consumers key on (task-session-manager +
+ * orchestrator-wake `observeChatMessage`, toolLoopGuard
+ * `observeNewUserMessage`). The context-hook emulation cannot provide
+ * this: it fires per LLM request and has no prompt parts, so
+ * `observeChatMessage`'s non-synthetic-part gate never passed on v2.
+ *
+ * The prompt payload carries NO agent/model, so `observeContext` learns
+ * them from the (immediately following) context events and forwards
+ * first-seen/changed state — preserving the v1 timing where the session
+ * agent is known before the first tool call of a turn.
+ *
+ * Child-session filtering: none, deliberately — the context-hook
+ * emulation never filtered child sessions either, and every consumer
+ * gates itself (e.g. `shouldManageSession`).
+ */
+export function createSessionPromptBridge(
+  chatMessage: (input: V1ChatMessageInput, output: unknown) => Promise<void>,
+): V2SessionPromptBridge {
+  /** Last admitted messageID per session (once-per-admission dedupe). */
+  const seenAdmissions = new Map<string, string>();
+  /** Latest known agent/model per session (learned from context). */
+  const sessionState = new Map<
+    string,
+    { agent?: string; model?: { providerID: string; modelID: string } }
+  >();
+
+  function trailingUserId(event: V2SessionContextEvent): string | undefined {
+    const id = [...event.messages]
+      .reverse()
+      .find((message) => message.role === 'user')?.id;
+    return typeof id === 'string' && id ? id : undefined;
+  }
+
+  return {
+    async handlePrompt(event) {
+      if (!event || typeof event !== 'object') return;
+      const sessionID = event.sessionID;
+      const messageID = event.messageID;
+      if (typeof sessionID !== 'string' || !sessionID) return;
+      if (typeof messageID !== 'string' || !messageID) return;
+      if (seenAdmissions.get(sessionID) === messageID) return;
+      seenAdmissions.set(sessionID, messageID);
+      pruneSessionMap(seenAdmissions);
+
+      const state = sessionState.get(sessionID);
+      const prompt: Record<string, unknown> = isRecord(event.prompt)
+        ? event.prompt
+        : {};
+      // Internal-initiator admissions (v2 orchestrator-wake queue prompts)
+      // arrive as prompt `metadata` — the part metadata cannot survive the
+      // text-only v2 translation (see client-shim). Restore it onto the
+      // text part so isInternalInitiatorPart consumers classify the
+      // admission as internal (wake admissions must not rearm the
+      // no-progress cap or clear wake timers as user activity would).
+      const internalInitiator =
+        isRecord(event.metadata) &&
+        event.metadata[INTERNAL_INITIATOR_METADATA_KEY] === true;
+      // Rebuild the v1 parts view: observeChatMessage gates on a
+      // non-synthetic text/file part being present.
+      const parts: Array<Record<string, unknown>> = [];
+      if (typeof prompt.text === 'string' && prompt.text) {
+        parts.push(
+          internalInitiator
+            ? {
+                type: 'text',
+                text: prompt.text,
+                synthetic: true,
+                metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
+              }
+            : { type: 'text', text: prompt.text },
+        );
+      }
+      if (Array.isArray(prompt.files)) {
+        for (const file of prompt.files) {
+          if (isRecord(file)) parts.push({ type: 'file', ...file });
+        }
+      }
+      try {
+        await chatMessage(
+          {
+            sessionID,
+            messageID,
+            ...(state?.agent ? { agent: state.agent } : {}),
+            ...(state?.model ? { model: state.model } : {}),
+            ...(parts.length > 0 ? { parts } : {}),
+          },
+          undefined,
+        );
+      } catch (err) {
+        log('[v2] prompt-hook chat.message bridge failed', String(err));
+      }
+    },
+
+    async observeContext(event) {
+      if (!event || typeof event !== 'object') return;
+      const sessionID = event.sessionID;
+      if (typeof sessionID !== 'string' || !sessionID) return;
+      const agent =
+        typeof event.agent === 'string' && event.agent
+          ? event.agent
+          : undefined;
+      const model = v1ModelFromContext(event.model);
+      const previous = sessionState.get(sessionID);
+      if (
+        previous &&
+        previous.agent === agent &&
+        ((previous.model === undefined && model === undefined) ||
+          (previous.model !== undefined &&
+            model !== undefined &&
+            previous.model.providerID === model.providerID &&
+            previous.model.modelID === model.modelID))
+      ) {
+        return; // nothing newly learned — once-per-admission fidelity holds
+      }
+      sessionState.set(sessionID, {
+        ...(agent ? { agent } : {}),
+        ...(model ? { model } : {}),
+      });
+      pruneSessionMap(sessionState);
+      try {
+        await chatMessage(
+          {
+            sessionID,
+            ...(agent ? { agent } : {}),
+            ...(model ? { model } : {}),
+            ...(trailingUserId(event)
+              ? { messageID: trailingUserId(event) }
+              : {}),
+          },
+          undefined,
+        );
+      } catch (err) {
+        log('[v2] agent-discovery chat.message bridge failed', String(err));
+      }
+    },
+
+    agentForSession(sessionID) {
+      return sessionState.get(sessionID)?.agent;
+    },
+  };
+}
+
 /** The v2→v1 tool.execute bridge pair produced by
  * `createToolExecuteBridges`. */
 export interface V2ToolBridgeEvents {
@@ -321,6 +585,17 @@ function renderOutput(value: unknown): string {
   }
 }
 
+/** Formatted error text from a v2 execute.after `error` payload (string,
+ * Error-like `{message}`, or structured record). Empty string when the
+ * host provided nothing. */
+function errorTextOf(error: unknown): string {
+  if (typeof error === 'string') return error;
+  if (isRecord(error) && typeof error.message === 'string' && error.message) {
+    return error.message;
+  }
+  return renderOutput(error);
+}
+
 /**
  * Copy a v1 after-hook's string output back into v2 without changing the
  * representation chosen by the v2 tool. In particular, image/file parts
@@ -405,6 +680,15 @@ export function createToolExecuteBridges(
     if (!after) return;
     const e = event as unknown as V2ToolAfterEvent;
     const isDelegation = e.tool.toLowerCase() === 'subagent';
+    // v2 execute.after is status-discriminated: `completed` → mutable
+    // result; `error` → `error` payload (result may be absent or stale).
+    // Absent status (older hosts) keeps the completed path. On error the
+    // v1 output is synthesized from the error text — that is exactly the
+    // v1 shape, where a failed tool's model-visible output WAS the error
+    // message — so error-recovery consumers (json-error-recovery appends
+    // its reminder to output.output) still run meaningfully. An errored
+    // call never presents its result content as a successful output.
+    const errored = e.status === 'error';
     // Map v2 Tool.Result.content (string | Content[]) -> v1 output.output
     // string; the v1 after-hooks (postFileToolNudge, jsonErrorRecovery,
     // taskSessionManagerAfter) read output.output to decide nudges.
@@ -421,9 +705,11 @@ export function createToolExecuteBridges(
       (typeof rawContent === 'string' ||
         (Array.isArray(rawContent) && rawContent.length > 0));
     const rawOutput = result?.output;
-    const content = hasRenderableContent
-      ? textContent(rawContent)
-      : renderOutput(rawOutput);
+    const content = errored
+      ? errorTextOf(e.error)
+      : hasRenderableContent
+        ? textContent(rawContent)
+        : renderOutput(rawOutput);
     const originalMetadata = result?.metadata;
     const initialTitle =
       isRecord(result?.metadata) && typeof result.metadata.title === 'string'
@@ -454,7 +740,14 @@ export function createToolExecuteBridges(
           ? output.output
           : renderOutput(output.output);
       if (updatedText !== content) {
-        if (hasRenderableContent) {
+        if (errored) {
+          // Errored call: the model-visible content is the synthesized
+          // error text plus whatever the hook appended (e.g. the
+          // json-error-recovery reminder). Written as plain string
+          // content — never keep a stale/empty result content looking
+          // like a successful output.
+          result.content = updatedText;
+        } else if (hasRenderableContent) {
           result.content = updateToolResultContent(
             rawContent,
             content,
@@ -666,6 +959,10 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
         const reg = await ctx.tool.transform((draft) => {
           for (const [name, def] of toolEntries) {
             try {
+              // adaptTool stamps `options: { codemode: false }` on every
+              // registration (CodeMode opt-out) — without it v2's
+              // Tool.snapshot() confines the tool to the `execute` tool's
+              // JS runtime instead of the model-visible tool catalog.
               draft.add(adaptTool(name, def, directory, schemaFor(def)));
             } catch (err) {
               log('[v2] tool adapt failed', { name, err: String(err) });
@@ -738,8 +1035,9 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
 
     // ── Session context hook: command markers + system/messages transforms ──
     // One registration handles: the interview marker bridge, generic command
-    // marker dispatch (deepwork/reflect/loop), chat.message agent tracking,
-    // and the v1 system/messages transforms.
+    // marker dispatch (deepwork/reflect/loop), chat.message agent tracking
+    // (or agent/model discovery when the native prompt hook is active), and
+    // the v1 system/messages transforms.
     try {
       const commandBefore = v1Hooks['command.execute.before'] as
         | V1CommandBeforeHook
@@ -758,18 +1056,48 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
           ) => Promise<void>)
         | undefined;
       const chatMessage = v1Hooks['chat.message'] as
-        | ((
-            i: { sessionID: string; agent?: string },
-            o: unknown,
-          ) => Promise<void>)
+        | ((i: V1ChatMessageInput, o: unknown) => Promise<void>)
         | undefined;
 
+      // Native per-admission prompt hook (v2): `session.prompt` fires once
+      // per admitted input with the eventual inbox User messageID — the
+      // identity v1 chat.message consumers key on. When the host supports
+      // it, the context hook's per-request chat.message emulation narrows
+      // to agent/model discovery; older v2 hosts (hook name rejected)
+      // keep the full emulation.
+      let promptBridge: V2SessionPromptBridge | undefined;
+      if (chatMessage) {
+        const bridge = createSessionPromptBridge(chatMessage);
+        try {
+          const promptReg = await ctx.session.hook(
+            'prompt',
+            bridge.handlePrompt,
+          );
+          disposers.push(() => promptReg.dispose());
+          promptBridge = bridge;
+          log('[v2] native session prompt hook registered');
+        } catch (err) {
+          log(
+            '[v2] session.hook(prompt) unavailable; keeping chat.message context emulation',
+            String(err),
+          );
+        }
+      }
+
       const handler = createSessionContextHandler({
         interviewHandleContext: (event) => interviewBridge.handleContext(event),
         commandBefore,
-        chatMessage,
+        chatMessage: promptBridge ? undefined : chatMessage,
+        observeContextAgent: promptBridge?.observeContext,
+        // Transcript user-message enrichment falls back to the agent the
+        // prompt bridge learned when the context event carries none.
+        knownAgentForSession: (sessionID) =>
+          promptBridge?.agentForSession(sessionID),
         systemTransform,
         messagesTransform,
+        // v2 ContentPart cache hint for parts injected by the bridged
+        // transforms (v1 bytes never change — see the handler).
+        syntheticPartCacheHint: { type: 'ephemeral' },
       });
       const reg = await ctx.session.hook('context', handler);
       disposers.push(() => reg.dispose());
@@ -882,7 +1210,11 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
           log('[v2] disposer failed', String(err));
         }
       }
+      // v1 dispose synthesizes `server.instance.disposed` into the v1 event
+      // consumers (orchestrator-wake scheduler timers/state, task-session
+      // manager) — without it, host teardown would leak wake timers.
       try {
+        log('[v2] v1 dispose hook invoked');
         await dispose?.();
       } catch (err) {
         log('[v2] v1 dispose failed', String(err));

+ 75 - 3
src/v2/types.ts

@@ -14,8 +14,29 @@ export interface V2AgentDraft {
   update(id: string, update: (agent: Record<string, unknown>) => void): void;
   remove(id: string): void;
 }
+/** v2 Tool.Options registration flags (upstream `Tool.Options` subset).
+ * `codemode: false` is the CodeMode opt-out: upstream `Tool.snapshot()`
+ * only promotes `codemode === false` tools to direct model-visible tool
+ * definitions — everything else is reachable only inside the `execute`
+ * tool's confined JS runtime, so session tool catalogs yield
+ * `Unknown tool: <name>` even though registration succeeded. The field
+ * is additive: older hosts ignore it. */
+export interface V2ToolOptions {
+  codemode?: boolean;
+  namespace?: string;
+  permission?: string;
+}
+/** v2 tool payload accepted by `tool.transform` drafts (the Tool.Info
+ * subset this adapter produces). */
+export interface V2ToolDefinition {
+  name: string;
+  description: string;
+  input: unknown;
+  options?: V2ToolOptions;
+  execute: (input: unknown, context: unknown) => Promise<unknown>;
+}
 export interface V2ToolDraft {
-  add(tool: Record<string, unknown>): void;
+  add(tool: V2ToolDefinition): void;
 }
 /** A v2 command definition passed to `command.transform` drafts. The command
  * body runs `execute` directly (no template field). */
@@ -47,9 +68,34 @@ export interface V2SessionContextEvent {
     id?: string;
     role: string;
     content: Array<Record<string, unknown>>;
+    /** Session identity on the message envelope. Live v2 hosts carry only
+     * `{id, time, text, type}` on transcript user messages; the v2 context
+     * bridge stamps these absence-gated so the bridged v1 injection gates
+     * (phase-reminder, board, nudge) keep working. */
+    sessionID?: string;
+    /** Agent that handled the message (same enrichment contract). */
+    agent?: string;
   }>;
   tools: Record<string, unknown>;
 }
+/**
+ * v2 `session.prompt` hook payload: fires ONCE per admitted input (endpoint
+ * prompts AND subagent-tool child prompts; synthetic/shell/compaction
+ * inputs skip it). `messageID` is the eventual inbox User id — the v1
+ * `chat.message` dedupe key.
+ */
+export interface V2SessionPromptEvent {
+  readonly sessionID: string;
+  readonly messageID: string;
+  prompt: {
+    text: string;
+    files?: Array<Record<string, unknown>>;
+    agents?: Array<Record<string, unknown>>;
+    skills?: Array<Record<string, unknown>>;
+  };
+  metadata?: Record<string, unknown>;
+  readonly delivery?: unknown;
+}
 export interface V2ToolBeforeEvent {
   readonly tool: string;
   readonly sessionID: string;
@@ -112,8 +158,24 @@ export interface V2Context {
       name: 'context',
       cb: (event: V2SessionContextEvent) => Promise<void>,
     ): Promise<V2Registration>;
+    /** v2 session.prompt hook — once per admitted input (see
+     * V2SessionPromptEvent). Older v2 hosts reject the name; callers must
+     * keep a fallback path. */
+    hook(
+      name: 'prompt',
+      cb: (event: V2SessionPromptEvent) => Promise<void>,
+    ): Promise<V2Registration>;
     /** v2 session.get — SessionInfo by id (runtime-probed). */
     get?(input: { sessionID: string }): Promise<unknown>;
+    /** v2 session.remove — DELETE /api/session/:id (runtime-probed). */
+    remove?(input: { sessionID: string }): Promise<unknown>;
+    /** v2 session.list — query-filtered listing (runtime-probed).
+     * `parentID` accepts a session id or `null`/the literal `"null"`
+     * string for root-only listing. */
+    list?(input: {
+      directory?: string;
+      parentID?: string | null;
+    }): Promise<unknown>;
     /** v2 session.interrupt — `continue: false` aborts the active run. */
     interrupt?(input: {
       sessionID: string;
@@ -132,8 +194,18 @@ export interface V2Context {
     /** v2 session.prompt — flat PromptInput ({sessionID, text, files?,
      * agents?, skills?, metadata?, delivery?, resume?}). */
     prompt?(input: Record<string, unknown>): Promise<unknown>;
-    /** v2 session.synthetic — like prompt but not persisted as user input. */
-    synthetic?(input: Record<string, unknown>): Promise<unknown>;
+    /** v2 session.synthetic — like prompt but not persisted as user
+     * input. `delivery` routes the inbox entry ("steer" | "queue");
+     * `resume: false` admits the input WITHOUT waking the session. */
+    synthetic?(input: {
+      sessionID: string;
+      id?: string;
+      text: string;
+      description?: string;
+      metadata?: Record<string, unknown>;
+      delivery?: 'steer' | 'queue';
+      resume?: boolean;
+    }): Promise<unknown>;
     /** v2 session.rename ({sessionID, title}). */
     rename?(input: Record<string, unknown>): Promise<unknown>;
     /** v2 session.switchAgent ({sessionID, agent}). */