Browse Source

Implement background subagent scheduler

alvinreal 3 months ago
parent
commit
87e419f2e9

+ 1 - 0
README.md

@@ -500,6 +500,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | Doc | What it covers |
 | Doc | What it covers |
 |-----|----------------|
 |-----|----------------|
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
+| **[V2 Background Orchestration](docs/v2-background-orchestration.md)** | Scheduler-first orchestrator model built around native background subagents |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |
 | **[Session Goal](docs/session-goal.md)** | Pin a session objective with `/goal` so todos, delegation, and verification stay aligned |
 | **[Session Goal](docs/session-goal.md)** | Pin a session objective with `/goal` so todos, delegation, and verification stay aligned |

+ 1 - 0
docs/quick-reference.md

@@ -13,6 +13,7 @@
 | Doc | Contents |
 | Doc | Contents |
 |-----|----------|
 |-----|----------|
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
 | [Council Agent](council.md) | Multi-LLM consensus, presets, role prompts, timeouts |
+| [V2 Background Orchestration](v2-background-orchestration.md) | Scheduler-first orchestrator model for native background subagents |
 | [Interview](interview.md) | `/interview` command, browser UI, dashboard mode, multi-session coordination |
 | [Interview](interview.md) | `/interview` command, browser UI, dashboard mode, multi-session coordination |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [Todo Continuation](todo-continuation.md) | `auto_continue`, `/auto-continue`, cooldowns, safety gates |
 | [Todo Continuation](todo-continuation.md) | `auto_continue`, `/auto-continue`, cooldowns, safety gates |

+ 368 - 0
docs/v2-background-orchestration.md

@@ -0,0 +1,368 @@
+# V2 Background Orchestration
+
+V2 is the next orchestration model for oh-my-opencode-slim. It assumes native
+OpenCode background subagents are available and changes the orchestrator from a
+primary worker into a scheduler.
+
+The old model was:
+
+```text
+orchestrator works directly → delegates when useful → waits for result
+```
+
+The V2 model is:
+
+```text
+orchestrator plans → dispatches background specialists → monitors → reconciles → verifies
+```
+
+This is a clean rebuild, not a compatibility layer over the old blocking model.
+
+---
+
+## Runtime Requirement
+
+V2 requires OpenCode with native background subagents enabled:
+
+```bash
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
+```
+
+The required native tools are:
+
+| Tool | Purpose |
+|------|---------|
+| `task(..., background: true)` | Start a specialist in the background and immediately return a task ID |
+| `task_status` | Poll or wait for a background task result |
+
+If these are not available, V2 should fail loudly instead of falling back to the
+legacy blocking orchestration model.
+
+---
+
+## Core Principle
+
+The orchestrator is not the default implementation worker.
+
+Its job is to:
+
+- understand the user request,
+- break work into dependent and independent units,
+- choose the right specialist for each unit,
+- schedule background work,
+- track task IDs and states,
+- avoid conflicting writes,
+- integrate specialist results,
+- run or route final verification,
+- communicate concise progress and outcomes to the user.
+
+Specialists do the work. The orchestrator manages the work.
+
+---
+
+## Execution Loop
+
+Every non-trivial request follows this loop:
+
+```text
+Understand
+  ↓
+Plan dependency graph
+  ↓
+Dispatch independent specialists in background
+  ↓
+Track task IDs and ownership
+  ↓
+Continue only independent coordination work
+  ↓
+Poll / wait with task_status
+  ↓
+Reconcile results and resolve conflicts
+  ↓
+Dispatch follow-up work if needed
+  ↓
+Verify
+  ↓
+Final response
+```
+
+The orchestrator should not act on assumptions from a still-running task. It can
+continue scheduling independent work, but dependent work waits for terminal task
+results.
+
+---
+
+## Scheduler Responsibilities
+
+### 1. Build a dependency graph
+
+Before dispatching agents, the orchestrator identifies:
+
+- which questions must be answered before implementation,
+- which tasks can run in parallel,
+- which tasks must be sequential,
+- which files or subsystems each writer owns,
+- which outputs are needed for final verification.
+
+This does not need to be a long plan. It should be just enough structure to
+avoid wasted work and conflicting edits.
+
+### 2. Dispatch background specialists
+
+Independent work should be launched with background tasks:
+
+```text
+task(
+  description="Search auth flow",
+  subagent_type="explorer",
+  background=true,
+  prompt="Find the auth entry points, session storage, and login callback paths. Return file paths and a concise map. Do not edit files."
+)
+```
+
+The orchestrator records the returned task ID and keeps working only on safe,
+independent coordination.
+
+### 3. Track ownership
+
+The scheduler must prevent write conflicts.
+
+Rules:
+
+- Only one write-capable specialist owns a file at a time.
+- Do not run two `fixer` tasks against overlapping folders unless ownership is
+  explicit.
+- UI work that touches shared components should not run beside implementation
+  work that edits the same components.
+- Review tasks can run in parallel with read-only discovery, but not with edits
+  they are supposed to review.
+
+### 4. Poll and reconcile
+
+Background tasks are not complete until `task_status` says they are terminal.
+
+The orchestrator should use `task_status` to:
+
+- wait for dependent results,
+- check long-running tasks,
+- collect outputs before final response,
+- surface failures or blocked tasks clearly.
+
+Specialist outputs are inputs, not final truth. The orchestrator reconciles them
+against each other and the original user goal.
+
+### 5. Verify
+
+Verification remains orchestrator-owned, but not necessarily orchestrator-run.
+
+Examples:
+
+- route UI review to `designer`,
+- route code review to `oracle`,
+- route test writing or test updates to `fixer`,
+- run final shell checks directly only when appropriate.
+
+The final response should only happen after relevant background work is terminal
+and reconciled.
+
+---
+
+## Specialist Roles In V2
+
+### Explorer
+
+Read-only reconnaissance and codebase mapping. Usually the first background task
+for unfamiliar work.
+
+### Librarian
+
+External docs, version-specific API behavior, and real-world examples. Runs in
+parallel with Explorer when implementation depends on current library behavior.
+
+### Fixer
+
+Bounded implementation worker. Receives a clear objective, file ownership,
+constraints, and validation expectations.
+
+### Designer
+
+User-facing UI/UX implementation and review. Owns visual polish, responsive
+layout, interaction quality, and design consistency.
+
+### Oracle
+
+Architecture, code review, simplification, risk analysis, and high-stakes
+debugging. Often used after implementation or before risky refactors.
+
+### Council
+
+Multi-model decision support for critical trade-offs. It is not a worker pool;
+it is for judgment where disagreement is useful.
+
+### Observer
+
+Visual/media analysis isolated from the orchestrator context.
+
+---
+
+## Direct Work Boundary
+
+V2 removes the orchestrator-as-worker default.
+
+The orchestrator may directly:
+
+- ask clarifying questions,
+- read minimal context needed to route work,
+- create and update todos,
+- launch and monitor tasks,
+- synthesize results,
+- run final checks when that is cheaper than delegating.
+
+The orchestrator should delegate:
+
+- broad code search,
+- unfamiliar library research,
+- implementation,
+- test creation or updates,
+- UI polish,
+- architecture review,
+- visual/media analysis.
+
+This keeps the main context focused on coordination instead of filling it with
+worker detail.
+
+---
+
+## Task Prompt Contract
+
+Every delegated task should be self-contained.
+
+Include:
+
+- objective,
+- constraints,
+- relevant files or search scope,
+- ownership boundaries,
+- expected output format,
+- whether edits are allowed,
+- validation to run or report,
+- what not to do.
+
+Good background task prompt:
+
+```text
+Investigate src/hooks/task-session-manager for assumptions that a task tool
+result means the child task has finished. Do not edit files. Return:
+1. exact files/functions involved,
+2. which assumptions break with background tasks,
+3. recommended code changes,
+4. tests that should be added.
+```
+
+Bad background task prompt:
+
+```text
+Look into background tasks.
+```
+
+---
+
+## State The Orchestrator Must Track
+
+V2 prompt/runtime should treat background tasks as a small job board:
+
+| Field | Meaning |
+|-------|---------|
+| task ID | Native OpenCode background task/session ID |
+| specialist | Agent type assigned |
+| objective | What the task is responsible for |
+| state | running, completed, error, cancelled, timed out |
+| ownership | Files/folders/subsystems the task may edit |
+| dependencies | Tasks that must complete first |
+| result | Final task output once terminal |
+
+The current todo list can represent user-visible work, but task IDs and file
+ownership need to be explicit in the orchestrator's working context.
+
+---
+
+## Plugin Changes Needed
+
+V2 is more than a prompt rewrite. The plugin should become aware that a task
+tool return can mean "background job launched" rather than "work complete".
+
+Important areas:
+
+- `src/agents/orchestrator.ts` — replace blocking delegation language with the
+  scheduler contract.
+- `src/config/constants.ts` — update phase reminders so they reinforce scheduler
+  behavior instead of old delegation behavior.
+- `src/hooks/task-session-manager/` — track running background task IDs and
+  update aliases from `task_status` results.
+- `src/index.ts` task hooks — separate "task launched" from "task finished" for
+  notifications, Divoom, multiplexer, and cleanup behavior.
+- `src/multiplexer/` — verify panes stay attached to running background child
+  sessions while the parent continues.
+- `src/hooks/todo-continuation/` — avoid marking workflows complete before
+  relevant background tasks have terminal results.
+
+---
+
+## V2 Startup Behavior
+
+V2 should be strict.
+
+If background subagents are unavailable, the plugin should not silently behave
+like V1. It should tell the user exactly what is missing:
+
+```text
+V2 orchestration requires OpenCode background subagents.
+Start OpenCode with:
+
+OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS=true opencode
+```
+
+No legacy fallback keeps the mental model clean.
+
+---
+
+## Example V2 Flow
+
+User asks:
+
+```text
+Make background subagents first-class in this plugin.
+```
+
+The orchestrator should do something like:
+
+1. Create todos for discovery, design, implementation, docs, tests, review.
+2. Launch Explorer in background to map task-session hooks and task lifecycle.
+3. Launch Oracle in background to review architecture risks.
+4. Continue by preparing the dependency graph and file ownership plan.
+5. Wait for Explorer and Oracle with `task_status`.
+6. Dispatch Fixer to implement prompt/config/hook changes with clear ownership.
+7. Dispatch a second Fixer for tests if file ownership is separate.
+8. Wait for implementation results.
+9. Dispatch Oracle for final review.
+10. Run final checks.
+11. Report final state.
+
+At no point does the orchestrator become the main implementer.
+
+---
+
+## Success Criteria
+
+V2 is working when:
+
+- the orchestrator launches independent specialists in background by default,
+- task IDs are tracked until terminal state,
+- dependent work waits for real task results,
+- file ownership prevents concurrent write conflicts,
+- final responses only happen after reconciliation and verification,
+- users see faster progress on multi-step work,
+- the orchestrator context stays focused on decisions instead of worker detail.
+
+V2 is not just "parallel agents." It is a scheduler-centered operating model for
+OpenCode's native background subagents.

+ 637 - 0
docs/v2_core.md

@@ -0,0 +1,637 @@
+# V2 Core Refactor Plan
+
+This document is the implementation plan for the V2 orchestration core.
+
+Scope for this pass:
+
+- core prompts,
+- scheduler/job-board behavior,
+- `task` and `task_status` integration,
+- task-session-manager changes,
+- tmux/zellij multiplexer compatibility,
+- todo-continuation guardrails.
+
+Out of scope for this pass:
+
+- Divoom integration,
+- install/startup flag checks,
+- README/index documentation updates,
+- legacy fallback behavior.
+
+V2 assumes native OpenCode background subagents are available and enabled.
+
+---
+
+## Core Thesis
+
+V2 changes the orchestrator from a worker-with-delegation into a scheduler.
+
+V1 mental model:
+
+```text
+orchestrator works directly → delegates when useful → waits for result
+```
+
+V2 mental model:
+
+```text
+orchestrator plans → dispatches background specialists → monitors jobs
+→ reconciles terminal results → verifies final state
+```
+
+The orchestrator should not be the default implementation worker. Specialists do
+the work; the orchestrator manages the work.
+
+---
+
+## Native Background Task Lifecycle
+
+OpenCode background task semantics are the foundation:
+
+```text
+task(background: true)
+  → returns immediately with task_id
+  → child session continues elsewhere
+  → task_status(task_id) reports running or terminal state
+  → orchestrator consumes terminal result
+```
+
+Important distinction:
+
+- `task` result means **launched**.
+- `task_status` terminal result means **finished**.
+- Finished is not the same as reconciled.
+
+V2 must model these as separate states.
+
+---
+
+## Core State Model
+
+Introduce a small scheduler/job-board model for background delegates.
+
+Suggested state shape:
+
+```ts
+type BackgroundJobState =
+  | 'launched'
+  | 'running'
+  | 'completed'
+  | 'error'
+  | 'cancelled'
+  | 'reconciled';
+
+interface BackgroundJobRecord {
+  taskID: string;
+  parentSessionID: string;
+  agent: string;
+  description: string;
+  objective: string;
+  ownership?: string[];
+  dependencies?: string[];
+  state: BackgroundJobState;
+  launchedAt: number;
+  updatedAt: number;
+  completedAt?: number;
+  timedOut?: boolean;
+  terminalUnreconciled?: boolean;
+  resultSummary?: string;
+}
+```
+
+Native `task_status` states are `running`, `completed`, `error`, and
+`cancelled`. A wait timeout is not a terminal native state; represent it as a
+`timedOut` overlay while the job remains `running`.
+
+This does not need to be persisted initially. Start in memory, scoped to the
+parent orchestrator session.
+
+Start with minimal reliable fields:
+
+```ts
+{
+  taskID,
+  parentSessionID,
+  agent,
+  description,
+  objective,
+  state,
+  timedOut,
+  terminalUnreconciled,
+  launchedAt,
+  updatedAt,
+  completedAt,
+  resultSummary,
+}
+```
+
+Keep `ownership` and `dependencies` advisory until there is a reliable data
+source. Native `task` arguments do not contain those fields, so initial V2 should
+not pretend the plugin can infer them perfectly.
+
+### Shared scheduler module
+
+Do not bury this state inside `task-session-manager`.
+
+Create a small shared utility, for example:
+
+- `src/utils/background-job-board.ts`, or
+- `src/hooks/scheduler-state/` if it grows into a hook-owned subsystem.
+
+It should expose methods such as:
+
+```ts
+registerLaunch(record)
+updateStatus(taskID, status)
+markReconciled(taskID)
+hasRunning(parentSessionID)
+hasTerminalUnreconciled(parentSessionID)
+formatForPrompt(parentSessionID)
+```
+
+Then pass the shared state into:
+
+- task-session-manager,
+- todo-continuation,
+- any future prompt/system-context hook that needs scheduler state.
+
+### Reconciliation rule
+
+The plugin needs one concrete reconciliation transition.
+
+Initial rule:
+
+1. `task_status` or an auto-injected completion message marks a job terminal and
+   `terminalUnreconciled: true`.
+2. The next orchestrator assistant turn after that terminal result is treated as
+   the reconciliation turn for all terminal unreconciled jobs visible in context.
+3. On orchestrator assistant turn completion, when the parent session returns to
+   idle after that assistant response, mark the terminal unreconciled jobs that
+   were injected into that turn's prompt as `reconciled`.
+
+This is intentionally simple. It avoids terminal jobs living forever while still
+forcing at least one orchestrator turn to see and account for each result.
+
+Initial V2 should not try to infer from free text whether the orchestrator
+mentioned, ignored, blocked, or failed a job. If a more precise protocol is
+needed later, add an explicit marker/tool for reconciliation.
+
+---
+
+## Prompt Refactor
+
+Primary file:
+
+- `src/agents/orchestrator.ts`
+
+Related reminder file:
+
+- `src/config/constants.ts`
+
+### Role rewrite
+
+Replace the current role framing with scheduler-first language:
+
+```text
+You are a workflow manager for coding work. Your job is to plan, schedule,
+delegate, monitor, reconcile, and verify specialist-agent work. You are not the
+default implementation worker.
+```
+
+The orchestrator may directly:
+
+- ask clarifying questions,
+- read minimal context required to route work,
+- manage todos,
+- dispatch specialists,
+- poll task status,
+- synthesize results,
+- run final checks when that is the simplest verification path.
+
+The orchestrator should delegate:
+
+- broad search,
+- external docs/API research,
+- implementation,
+- test writing or test updates,
+- UI polish,
+- architecture review,
+- visual/media analysis.
+
+### Replace blocking execution section
+
+Remove the V1 text that says delegated specialists block the parent until result.
+
+New execution model:
+
+```text
+### OpenCode V2 scheduler model
+- Delegated specialists should be launched as background tasks whenever work can
+  run independently using `task(..., background: true)`.
+- A dispatch returns a task/session ID immediately; it does not mean completion.
+- Track each task ID with specialist, objective, state, and any advisory
+  ownership/dependency labels available from the dispatch plan.
+- Continue orchestration while tasks run: planning, scheduling independent lanes,
+  preparing synthesis, and asking needed user questions.
+- Poll or wait with `task_status(wait: true, timeout_ms: ...)` before consuming
+  outputs or starting dependent work.
+- Parallel background tasks are allowed only when their write scopes do not
+  conflict.
+- Final response requires relevant tasks to be terminal and reconciled.
+```
+
+### Replace execute workflow
+
+V2 workflow should be:
+
+```text
+## Dispatch
+1. Split work into independent and dependency-ordered lanes.
+2. Plan advisory ownership for write-capable lanes.
+3. Dispatch independent specialists as background tasks.
+4. Record task IDs, state, and advisory ownership/dependency labels.
+5. Continue only independent orchestration while jobs run.
+6. Poll/wait for terminal results with task_status.
+7. Reconcile results, resolve conflicts, and gate dependent lanes.
+8. Dispatch follow-up jobs if needed.
+9. Verify final state.
+```
+
+### Phase reminder rewrite
+
+Update `PHASE_REMINDER_TEXT` so it reinforces scheduler behavior:
+
+```text
+Build a short work graph with independent lanes, dependencies, and advisory
+ownership.
+Dispatch independent specialists as background tasks, record task/session IDs,
+then continue orchestration. Poll task_status and only consume outputs or advance
+dependent work when results are terminal.
+```
+
+---
+
+## Task Prompt Contract
+
+Each background task prompt should be self-contained and bounded.
+
+Include:
+
+- objective,
+- constraints,
+- relevant files or search scope,
+- ownership boundaries,
+- whether edits are allowed,
+- expected output format,
+- validation expectations,
+- what not to do.
+
+Good prompt:
+
+```text
+Inspect src/hooks/task-session-manager for assumptions that a task result means
+child work is finished. Do not edit files. Return exact files/functions,
+background-task risks, and recommended changes.
+```
+
+Bad prompt:
+
+```text
+Look into background tasks.
+```
+
+---
+
+## Task Session Manager Refactor
+
+Primary files:
+
+- `src/hooks/task-session-manager/index.ts`
+- `src/utils/task.ts`
+- `src/utils/session-manager.ts`
+
+Current behavior:
+
+- `tool.execute.before` tracks `task` calls.
+- `tool.execute.after` parses a `task_id` from output.
+- the parsed ID is immediately remembered as a resumable session.
+- there is no terminal/non-terminal distinction.
+
+V2 behavior:
+
+- `task` tool output creates or updates a job as `launched` or `running`.
+- `task_status` output updates the job to `running`, `completed`, `error`, or
+  `cancelled`; timeout is metadata while the job remains `running`.
+- only terminal jobs become ready for reconciliation.
+- only reconciled/appropriate sessions should be offered for reuse.
+
+### Required changes
+
+1. Split parsing helpers:
+
+   ```ts
+   parseTaskLaunchOutput(output) → { taskID, state: 'running' | ... }
+   parseTaskStatusOutput(output) → { taskID, state, result? }
+   ```
+
+2. Store background job records in a shared scheduler/job-board module scoped by
+   parent orchestrator session.
+
+3. Update `tool.execute.after` for `task`:
+
+   - parse launch output,
+   - register job as launched/running,
+   - do not treat it as completed.
+
+4. Add handling for `task_status`:
+
+   - parse status output,
+   - update job state,
+   - attach result summary for terminal states.
+
+5. Update system-context injection:
+
+   - replace or augment `### Resumable Sessions` with `### Background Job Board`,
+   - include compact running/terminal unreconciled jobs,
+   - keep aliases short.
+
+6. Do not expose running background jobs as resumable sessions. A running job
+   alias should nudge `task_status`, not `task(task_id=...)`. Only completed and
+   reconciled sessions should enter the old resumable-session pool.
+
+---
+
+## Background Job Board Prompt Context
+
+The orchestrator needs a compact view of active work.
+
+Target injected shape:
+
+```text
+### Background Job Board
+Use task_status before consuming running jobs. Reconcile terminal jobs before
+final response.
+
+- exp-4 / ses_abc / explorer / running
+  Objective: Map multiplexer flow
+  Ownership: read-only
+  Dependencies: none
+
+- fix-2 / ses_def / fixer / completed, unreconciled
+  Objective: Update task-session-manager task_status handling
+  Ownership: src/hooks/task-session-manager/**
+```
+
+Keep this small. The point is scheduling state, not full task transcripts.
+
+---
+
+## `task_status` Integration
+
+Primary files:
+
+- `src/index.ts`
+- `src/hooks/task-session-manager/index.ts`
+
+Add hook support for the native `task_status` tool.
+
+Target flow:
+
+```text
+tool.execute.after(task_status)
+  → parse task_id + state
+  → update job board
+  → if terminal, attach compact result summary
+  → mark as terminal/unreconciled
+```
+
+The orchestrator prompt should then see terminal jobs and reconcile them before
+continuing dependent work.
+
+Do not rely only on OpenCode auto-resume notifications. The plugin should build
+its own compact scheduler state from tool results and events.
+
+### Auto-injected completion path
+
+Native background tasks can also complete through an OpenCode-injected parent
+message instead of an explicit `task_status` call. V2 must ingest that path too.
+
+Parse this in the chat/message transform path that already inspects parent
+conversation messages, most likely `experimental.chat.messages.transform` in the
+same hook family as task-session-manager context injection. If native OpenCode
+adds a dedicated event later, move the parser to that event path.
+
+Add parsing for synthetic completion content containing fields like:
+
+```text
+Background task completed: <description>
+task_id: <id>
+state: completed | error
+
+<task_result>
+...
+</task_result>
+```
+
+That path should update the same shared job-board state as `task_status`.
+Initially parse verified auto-message states only. `cancelled` can still be
+handled through explicit `task_status` output unless verified in auto-injected
+messages.
+
+```text
+auto-injected completion message
+  → parse task_id + state + result
+  → update job board
+  → mark terminal/unreconciled
+```
+
+---
+
+## Multiplexer Integration
+
+Primary files:
+
+- `src/multiplexer/session-manager.ts`
+- `src/multiplexer/tmux/index.ts`
+- `src/multiplexer/zellij/index.ts`
+- `src/index.ts`
+
+Current multiplexer behavior is already close to V2:
+
+- child session created → spawn pane,
+- child session busy → ensure pane exists,
+- child session idle/deleted → close pane,
+- fallback polling checks `/session/status`.
+
+V2 requirements:
+
+1. Panes represent child sessions, not parent blocking state.
+2. Parent may continue while panes run.
+3. Pane title should make background work understandable.
+4. Cleanup should be tied to actual child session idle/deleted state, not parent
+   task-tool return.
+5. Tests should cover long-running background children and delayed completion.
+
+Likely first implementation can keep close-on-idle if native child sessions emit
+accurate idle events. Verify with real background tasks before changing cleanup
+semantics.
+
+Potential later improvement:
+
+```text
+[BG explorer] exp-4 Map multiplexer flow
+[BG fixer] fix-2 task-session-manager
+```
+
+---
+
+## Todo Continuation Guardrails
+
+Primary file:
+
+- `src/hooks/todo-continuation/index.ts`
+
+Risk:
+
+- parent orchestrator becomes idle while background jobs are still running,
+- auto-continuation assumes the workflow can proceed or finish,
+- dependent work advances too early.
+
+V2 rule:
+
+```text
+If relevant background jobs are running, continuation should poll/reconcile them
+instead of treating the workflow as complete.
+```
+
+Implementation direction:
+
+- expose a `hasRunningBackgroundJobs(parentSessionID)` query from the scheduler
+  state,
+- expose `hasTerminalUnreconciledJobs(parentSessionID)`,
+- have continuation reminders nudge toward `task_status` and reconciliation.
+
+---
+
+## Agent Lane Reframing
+
+V2 should describe specialists as execution lanes, not optional helpers.
+
+- Explorer: discovery lane.
+- Librarian: external knowledge lane.
+- Fixer: implementation lane.
+- Designer: UI/UX lane.
+- Oracle: review/risk/architecture lane.
+- Council: high-stakes decision lane.
+- Observer: visual/media lane.
+
+The orchestrator schedules lanes according to dependency and ownership.
+
+---
+
+## Implementation Phases
+
+### Phase 0 — Pre-Prompt Groundwork
+
+Before changing the prompt, build enough parser/job-board behavior that the
+prompt can rely on visible scheduler state.
+
+### Phase 1 — Parser And Job Board Core
+
+- add task launch/status parsers,
+- parse only `task` output with `state: running` as a background launch,
+- add shared in-memory scheduler state,
+- keep timeout as an overlay on `running`, not a native state,
+- keep ownership/dependencies advisory until reliable.
+
+### Phase 2 — Prompt Core
+
+- rewrite orchestrator role,
+- rewrite execution model,
+- rewrite dispatch workflow,
+- update phase reminder,
+- reframe specialists as lanes.
+
+### Phase 3 — Prompt Job Board Injection
+
+- inject compact job board into orchestrator context.
+
+### Phase 4 — `task_status` Handling
+
+- hook `task_status`,
+- update job states from status output,
+- mark terminal jobs as unreconciled,
+- keep running jobs visible.
+
+### Phase 5 — Auto-Injected Completion Handling
+
+- parse OpenCode background completion messages,
+- update the same shared job board,
+- prevent jobs from staying stale when the parent auto-resumes.
+
+### Phase 6 — Reconciliation Transition
+
+- mark terminal jobs injected into a prompt as reconciled after the next
+  orchestrator assistant turn completes and the parent session returns idle,
+- test this transition directly.
+
+### Phase 7 — Session/Mux Safety
+
+- verify tmux/zellij pane lifecycle with real background tasks,
+- add tests for delayed completion,
+- adjust close-on-idle only if native events prove insufficient.
+
+### Phase 8 — Todo Continuation Safety
+
+- prevent auto-continuation from finalizing while jobs run,
+- nudge the orchestrator to poll terminal states and reconcile.
+
+---
+
+## First Code Targets
+
+Start here:
+
+1. `src/utils/task.ts`
+   - task launch/status parsing helpers.
+
+2. `src/utils/background-job-board.ts` or equivalent shared scheduler module
+   - background job state, queries, reconciliation marking, prompt formatting.
+
+3. `src/hooks/task-session-manager/index.ts`
+   - register launches/statuses with the shared job board and avoid exposing
+     running jobs as resumable sessions.
+
+4. `src/index.ts`
+   - route `task_status` after-hooks into the task-session-manager hook.
+
+5. `src/agents/orchestrator.ts`
+   - prompt role and workflow rewrite after scheduler state exists.
+
+6. `src/config/constants.ts`
+   - phase reminder rewrite.
+
+7. `src/multiplexer/session-manager.test.ts`
+   - add V2 lifecycle tests once behavior is understood.
+
+---
+
+## Success Criteria For Core V2
+
+Core V2 is working when:
+
+- orchestrator prompt consistently schedules rather than implements,
+- background `task` output registers running jobs,
+- `task_status` terminal output updates job board state,
+- orchestrator context shows running and terminal unreconciled jobs,
+- dependent work waits for terminal results,
+- prompt-level advisory ownership reduces conflicting background workers,
+- multiplexer panes show background child sessions while parent continues,
+- todo-continuation does not finalize with unresolved background jobs.
+
+The core invariant:
+
+```text
+task creates jobs; task_status or auto-completion finishes jobs; orchestrator
+reconciles jobs.
+```

+ 53 - 26
src/agents/orchestrator.ts

@@ -27,6 +27,7 @@ export function resolvePrompt(
 // Agent descriptions for the orchestrator prompt
 // Agent descriptions for the orchestrator prompt
 const AGENT_DESCRIPTIONS: Record<string, string> = {
 const AGENT_DESCRIPTIONS: Record<string, string> = {
   explorer: `@explorer
   explorer: `@explorer
+- Lane: Codebase discovery and reconnaissance
 - Role: Parallel search specialist for discovering unknowns across the codebase
 - Role: Parallel search specialist for discovering unknowns across the codebase
 - Permissions: Read files
 - Permissions: Read files
 - Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
 - Stats: 2x faster codebase search than orchestrator, 1/2 cost of orchestrator
@@ -35,42 +36,47 @@ const AGENT_DESCRIPTIONS: Record<string, string> = {
 - **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file`,
 - **Don't delegate when:** Know the path and need actual content • Need full file anyway • Single specific lookup • About to edit the file`,
 
 
   librarian: `@librarian
   librarian: `@librarian
+- Lane: External knowledge and library research
 - Role: Authoritative source for current library docs and API references
 - Role: Authoritative source for current library docs and API references
 - Permissions: External docs/search MCPs; no file edits
 - Permissions: External docs/search MCPs; no file edits
 - Stats: 10x better finding up-to-date library docs than orchestrator, 1/2 cost of orchestrator
 - Stats: 10x better finding up-to-date library docs than orchestrator, 1/2 cost of orchestrator
 - Capabilities: Fetches latest official docs, examples, API signatures, version-specific behavior via grep_app MCP
 - Capabilities: Fetches latest official docs, examples, API signatures, version-specific behavior via grep_app MCP
 - **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices
 - **Delegate when:** Libraries with frequent API changes (React, Next.js, AI SDKs) • Complex APIs needing official examples (ORMs, auth) • Version-specific behavior matters • Unfamiliar library • Edge cases or advanced features • Nuanced best practices
 - **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
 - **Don't delegate when:** Standard usage you're confident • Simple stable APIs • General programming knowledge • Info already in conversation • Built-in language features
-- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → yourself.`,
+- **Rule of thumb:** "How does this library work?" → @librarian. "How does programming work?" → answer directly.`,
 
 
   oracle: `@oracle
   oracle: `@oracle
+- Lane: Architecture, risk, debugging strategy, and review
 - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
 - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
 - Permissions: Read files
 - Permissions: Read files
 - Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
 - Stats: 5x better decision maker, problem solver, investigator than orchestrator, 0.8x speed of orchestrator, same cost.
 - Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
 - Capabilities: Deep architectural reasoning, system-level trade-offs, complex debugging, code review, simplification, maintainability review
 - **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • When a workflow calls for a **reviewer** subagent • Code needs simplification or YAGNI scrutiny
 - **Delegate when:** Major architectural decisions with long-term impact • Problems persisting after 2+ fix attempts • High-risk multi-system refactors • Costly trade-offs (performance vs maintainability) • Complex debugging with unclear root cause • Security/scalability/data integrity decisions • Genuinely uncertain and cost of wrong choice is high • When a workflow calls for a **reviewer** subagent • Code needs simplification or YAGNI scrutiny
 - **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
 - **Don't delegate when:** Routine decisions you're confident about • First bug fix attempt • Straightforward trade-offs • Tactical "how" vs strategic "should" • Time-sensitive good-enough decisions • Quick research/testing can answer
-- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Just do it and PR? → yourself.`,
+- **Rule of thumb:** Need senior architect review? → @oracle. Need code review or simplification? → @oracle. Routine coordination or final synthesis? → handle directly.`,
 
 
   designer: `@designer
   designer: `@designer
+- Lane: User-facing UI/UX design, polish, and review
 - Role: UI/UX specialist for intentional, polished experiences
 - Role: UI/UX specialist for intentional, polished experiences
 - Permissions: Read/write files
 - Permissions: Read/write files
 - Stats: 10x better UI/UX than orchestrator
 - Stats: 10x better UI/UX than orchestrator
 - Capabilities: Visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
 - Capabilities: Visual relevant edits, interactions, responsive layouts, design systems with aesthetic intent, deep UI/UX knowledge.
 - **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
 - **Delegate when:** User-facing interfaces needing polish • Responsive layouts • UX-critical components (forms, nav, dashboards) • Visual consistency systems • Animations/micro-interactions • Landing/marketing pages • Refining functional→delightful • Reviewing existing UI/UX quality
 - **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet
 - **Don't delegate when:** Backend/logic with no visual • Quick prototypes where design doesn't matter yet
-- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional? → yourself.`,
+- **Rule of thumb:** Users see it and polish matters? → @designer. Headless/functional implementation? → schedule @fixer.`,
 
 
   fixer: `@fixer
   fixer: `@fixer
+- Lane: Bounded implementation and test execution
 - Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
 - Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
 - Permissions: Read/write files
 - Permissions: Read/write files
 - Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
 - Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
 - Tools/Constraints: Execution-focused—no research, no architectural decisions
 - Tools/Constraints: Execution-focused—no research, no architectural decisions
 - **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Writing or updating tests • Tasks that touch test files, fixtures, mocks, or test helpers. Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
 - **Delegate when:** For implementation work, think and triage first. If the change is non-trivial or multi-file, hand bounded execution to @fixer • Writing or updating tests • Tasks that touch test files, fixtures, mocks, or test helpers. Parallelization benefits: Task involves multiple folders and multiple files modification, scoping work per folder and spawning parallel @fixers for each folder.
 - **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Sequential dependencies
 - **Don't delegate when:** Needs discovery/research/decisions • Single small change (<20 lines, one file) • Unclear requirements needing iteration • Explaining to fixer > doing • Tight integration with your current work • Sequential dependencies
-- **Rule of thumb:** Explaining > doing? → yourself. Test file modifications and bounded implementation work usually go to @fixer. Bigger or lots of edits, splitting makes sense, parallelized by spawning @fixers per certain scope.`,
+- **Rule of thumb:** If implementation or tests are needed, schedule @fixer with clear scope. Bigger or lots of edits should be split by ownership and dispatched as parallel background fixer lanes when safe.`,
 
 
   council: `@council
   council: `@council
+- Lane: High-stakes multi-model decision support
 - Role: Multi-LLM consensus engine that runs several councillors, synthesizes their views, and returns a structured council report.
 - Role: Multi-LLM consensus engine that runs several councillors, synthesizes their views, and returns a structured council report.
 - Permissions: Read files
 - Permissions: Read files
 - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
 - Stats: 3x slower than orchestrator, 3x or more cost of orchestrator
@@ -79,16 +85,17 @@ const AGENT_DESCRIPTIONS: Record<string, string> = {
 - **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
 - **Don't delegate when:** Straightforward tasks you're confident about • Speed matters more than confidence • Routine implementation/debugging • A single specialist is clearly the right tool • You only need current docs/search/code review rather than multi-model consensus.
 - **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
 - **How to call:** Send the full question/task and relevant context. Be explicit about what decision, trade-off, or answer the council should resolve. Do not ask council to do routine code edits.
 - **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
 - **Result handling:** Council returns a structured response that may include: synthesized Council Response, individual Councillor Details, and Council Summary/confidence. Preserve that structure when the user asked for council output. Do not pretend the council only returned a final answer. If you need to act on the council result, first briefly state the council's recommendation, then proceed.
-- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert agent or direct execution? → use the specialist or yourself.`,
+- **Rule of thumb:** Need second/third opinions from different models? → @council. Need one expert lane? → use the specialist. Need final synthesis? → handle directly.`,
 
 
   observer: `@observer
   observer: `@observer
+- Lane: Visual/media analysis isolated from orchestrator context
 - Role: Visual analysis specialist for images, PDFs, and diagrams
 - Role: Visual analysis specialist for images, PDFs, and diagrams
 - Permissions: Read files
 - Permissions: Read files
 - Stats: Saves main context tokens — Observer processes raw files, returns structured observations
 - Stats: Saves main context tokens — Observer processes raw files, returns structured observations
 - Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
 - Capabilities: Interprets images, screenshots, PDFs, and diagrams via native read tool; extracts UI elements, layouts, text, relationships
 - **Delegate when:** Need to analyze a multimedia file• Extract information
 - **Delegate when:** Need to analyze a multimedia file• Extract information
 - **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
 - **Don't delegate when:** Plain text files that Read can handle directly • Files that need editing afterward (need literal content from Read)
-- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer — it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for editing? → Read it yourself.
+- **Rule of thumb:** Even if your model supports vision, delegate visual analysis to @observer — it isolates large image/PDF bytes from your context window, returning only concise structured text. Need exact file contents for routing? → Read only the minimal context yourself.
 - **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png — describe the UI elements and error messages."`,
 - **IMPORTANT:** When delegating to @observer, always include the **full file path** in the prompt so it can read the file. Example: "Analyze the screenshot at /path/to/file.png — describe the UI elements and error messages."`,
 };
 };
 
 
@@ -138,7 +145,9 @@ export function buildOrchestratorPrompt(disabledAgents?: Set<string>): string {
   ).join('\n');
   ).join('\n');
 
 
   return `<Role>
   return `<Role>
-You are an AI coding orchestrator that optimizes for quality, speed, cost, and reliability by delegating to specialists when it provides net efficiency gains.
+You are a workflow manager for coding work. Your job is to plan, schedule, delegate, monitor, reconcile, and verify specialist-agent work. You are not the default implementation worker.
+
+Optimize for quality, speed, cost, and reliability by dispatching the right specialist lanes, tracking background task state, and integrating terminal results into one coherent outcome.
 </Role>
 </Role>
 
 
 <Agents>
 <Agents>
@@ -156,22 +165,31 @@ Parse request: explicit requirements + implicit needs.
 Evaluate approach by: quality, speed, cost, reliability.
 Evaluate approach by: quality, speed, cost, reliability.
 Choose the path that optimizes all four.
 Choose the path that optimizes all four.
 
 
+Classify work into lanes: discovery, external knowledge, implementation, UI/UX, review/risk, visual analysis, and final verification.
+
 ## 3. Delegation Check
 ## 3. Delegation Check
 **STOP. Review specialists before acting.**
 **STOP. Review specialists before acting.**
 
 
-!!! Review available agents and delegation rules. Decide whether to delegate or do it yourself. !!!
+!!! Review available agents and lane rules. Decide what to schedule, what depends on what, and what minimal direct coordination is needed. !!!
 
 
-**Delegation efficiency:**
+**Dispatch efficiency:**
 - Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
 - Reference paths/lines, don't paste files (\`src/app.ts:42\` not full contents)
 - Provide context summaries, let specialists read what they need
 - Provide context summaries, let specialists read what they need
 - Brief user on delegation goal before each call
 - Brief user on delegation goal before each call
-- Skip delegation if overhead ≥ doing it yourself
+- Keep direct work limited to clarification, minimal routing context, todos, synthesis, and final checks
+- For trivial conversational answers or tiny mechanical edits, direct execution is allowed when scheduling overhead would clearly dominate
+
+## 4. Plan and Parallelize
+Build a short work graph before dispatching:
+- Independent lanes that can run now
+- Dependency-ordered lanes that must wait
+- Advisory ownership for write-capable lanes
+- Verification/review lanes that run after implementation
 
 
-## 4. Split and Parallelize
-Can tasks be split into subtasks and run in parallel?
+Can tasks be split into background specialist work?
 ${enabledParallelExamples}
 ${enabledParallelExamples}
 
 
-Balance: respect dependencies, avoid parallelizing what must be sequential.
+Balance: respect dependencies, avoid parallelizing what must be sequential, and avoid overlapping write ownership.
 
 
 ### Context Isolation
 ### Context Isolation
 If no specialist delegation is needed, consider \`subtask\` before doing
 If no specialist delegation is needed, consider \`subtask\` before doing
@@ -184,6 +202,8 @@ compact outcome.
 Use \`subtask\` for focused investigation, bounded analysis, cleanup, or
 Use \`subtask\` for focused investigation, bounded analysis, cleanup, or
 verification across files/logs/messages.
 verification across files/logs/messages.
 
 
+Prefer native background \`task(..., background: true)\` plus \`task_status\` for independent specialist lanes. Use \`subtask\` only for bounded parent-local context isolation when native background specialist scheduling is not the right fit.
+
 Do not use \`subtask\` for tiny tasks, open-ended work, interactive decisions,
 Do not use \`subtask\` for tiny tasks, open-ended work, interactive decisions,
 work better handled by a named specialist, or cases where the parent must reason
 work better handled by a named specialist, or cases where the parent must reason
 over the details.
 over the details.
@@ -192,18 +212,25 @@ When calling \`subtask\`, give a self-contained prompt with objective,
 constraints, relevant context, deliverable, and validation. Pass only clearly
 constraints, relevant context, deliverable, and validation. Pass only clearly
 relevant files. Wait for the summary, then integrate and verify it.
 relevant files. Wait for the summary, then integrate and verify it.
 
 
-### OpenCode subagent execution model
-- A delegated specialist runs in a separate child session.
-- Delegation is blocking for the parent at that point: send work out, then continue that line after results return.
-- Parallel delegation means launching multiple independent child-session branches.
-- Only parallelize branches that are truly independent; reconcile dependent steps after delegated results come back.
-
-## 5. Execute
-1. Break complex tasks into todos
-2. Fire parallel research/implementation
-3. Delegate to specialists or do it yourself based on step 3
-4. Integrate results
-5. Adjust if needed
+### OpenCode scheduler model
+- Delegated specialists should be launched as background tasks whenever work can run independently: use \`task(..., background: true)\`.
+- A dispatch returns a task/session ID immediately; it does not mean completion.
+- Track each task ID with specialist, objective, state, and any advisory ownership/dependency labels from the dispatch plan.
+- Continue orchestration while tasks run: planning, scheduling independent lanes, preparing synthesis, and asking needed user questions.
+- Poll or wait with \`task_status(wait: true, timeout_ms: ...)\` before consuming outputs or starting dependent work.
+- Parallel background tasks are allowed only when their write scopes do not conflict.
+- Final response requires relevant tasks to be terminal and reconciled.
+
+## 5. Dispatch
+1. Split work into independent and dependency-ordered lanes
+2. Plan advisory ownership for write-capable lanes
+3. Dispatch independent specialists as background tasks
+4. Record task IDs, state, and advisory ownership/dependency labels
+5. Continue only independent orchestration while jobs run
+6. Poll/wait for terminal results with \`task_status(wait: true, timeout_ms: ...)\`
+7. Reconcile results, resolve conflicts, and gate dependent lanes
+8. Dispatch follow-up jobs if needed
+9. Verify final state
 
 
 ### Session Reuse
 ### Session Reuse
 - Smartly reuse an available specialist session - context reuse saves time and tokens
 - Smartly reuse an available specialist session - context reuse saves time and tokens
@@ -258,7 +285,7 @@ When user's approach seems problematic:
 **Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
 **Bad:** "Great question! Let me think about the best approach here. I'm going to delegate to @librarian to check the latest Next.js documentation for the App Router, and then I'll implement the solution for you."
 
 
 **Good:** "Checking Next.js App Router docs via @librarian..."
 **Good:** "Checking Next.js App Router docs via @librarian..."
-[proceeds with implementation]
+[continues scheduling or integration]
 
 
 </Communication>
 </Communication>
 `;
 `;

+ 2 - 2
src/config/constants.ts

@@ -94,8 +94,8 @@ export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
 
 
 // Workflow reminders
 // Workflow reminders
 export const PHASE_REMINDER_TEXT = `!IMPORTANT! Recall the workflow rules:
 export const PHASE_REMINDER_TEXT = `!IMPORTANT! Recall the workflow rules:
-Understand → choose the best parallelized path based on your capabilities and agents delegation rules → recall session reuse rules → execute → verify.
-If delegating, launch the specialist in the same turn you mention it !END!`;
+Understand → build a short work graph with independent lanes, dependencies, and advisory ownership → dispatch independent specialists as background tasks → record task/session IDs → continue orchestration → poll task_status for terminal results → reconcile → verify.
+Only consume outputs or advance dependent work when background results are terminal. !END!`;
 
 
 // Tmux pane spawn delay (ms) — gives TmuxSessionManager time to create pane
 // Tmux pane spawn delay (ms) — gives TmuxSessionManager time to create pane
 export const TMUX_SPAWN_DELAY_MS = 500;
 export const TMUX_SPAWN_DELAY_MS = 500;

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

@@ -1,10 +1,12 @@
 import { describe, expect, mock, test } from 'bun:test';
 import { describe, expect, mock, test } from 'bun:test';
+import { BackgroundJobBoard } from '../../utils';
 import { createTaskSessionManagerHook } from './index';
 import { createTaskSessionManagerHook } from './index';
 
 
 function createHook(options?: {
 function createHook(options?: {
   shouldManageSession?: (sessionID: string) => boolean;
   shouldManageSession?: (sessionID: string) => boolean;
   readContextMinLines?: number;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
   readContextMaxFiles?: number;
+  backgroundJobBoard?: BackgroundJobBoard;
 }) {
 }) {
   const hook = createTaskSessionManagerHook(
   const hook = createTaskSessionManagerHook(
     {
     {
@@ -16,6 +18,7 @@ function createHook(options?: {
       maxSessionsPerAgent: 2,
       maxSessionsPerAgent: 2,
       readContextMinLines: options?.readContextMinLines,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
       readContextMaxFiles: options?.readContextMaxFiles,
+      backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
       shouldManageSession: options?.shouldManageSession ?? (() => true),
     },
     },
   );
   );
@@ -35,6 +38,524 @@ function createMessages(sessionID: string, text = 'user message') {
 }
 }
 
 
 describe('task-session-manager hook', () => {
 describe('task-session-manager hook', () => {
+  test('stores background task launches in job board prompt context', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'map scheduler hooks',
+          prompt: 'inspect scheduler hooks',
+        },
+      },
+    );
+
+    await hook['tool.execute.after'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      {
+        output: [
+          'task_id: child-1 (for polling this task with task_status)',
+          'state: running',
+          '',
+          '<task_result>',
+          'Background task started.',
+          '</task_result>',
+        ].join('\n'),
+      },
+    );
+
+    const messages = createMessages('parent-1', 'do something');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    const userMessage = messages.messages[0];
+    expect(userMessage.parts[0].text).toContain('### Background Job Board');
+    expect(userMessage.parts[0].text).toContain(
+      'exp-1 / child-1 / explorer / running',
+    );
+    expect(userMessage.parts[0].text).toContain(
+      'Objective: map scheduler hooks',
+    );
+  });
+
+  test('updates background job board from task_status output', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'oracle',
+          description: 'review scheduler plan',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: [
+          'task_id: child-1 (for polling this task with task_status)',
+          'state: running',
+        ].join('\n'),
+      },
+    );
+
+    await hook['tool.execute.after'](
+      { tool: 'task_status', sessionID: 'parent-1', callID: 'call-2' },
+      {
+        output: [
+          'task_id: child-1',
+          'state: completed',
+          '',
+          '<task_result>',
+          'plan is sound',
+          '</task_result>',
+        ].join('\n'),
+      },
+    );
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'plan is sound',
+    });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(messages.messages[0].parts[0].text).toContain(
+      'ora-1 / child-1 / oracle / completed, unreconciled',
+    );
+    expect(messages.messages[0].parts[0].text).toContain(
+      'Result: plan is sound',
+    );
+  });
+
+  test('keeps task_status timeout as a running timed-out job', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'fixer',
+          description: 'implement scheduler wiring',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: [
+          'task_id: child-1 (for polling this task with task_status)',
+          'state: running',
+        ].join('\n'),
+      },
+    );
+
+    await hook['tool.execute.after'](
+      { tool: 'task_status', sessionID: 'parent-1', callID: 'call-2' },
+      {
+        output: [
+          'task_id: child-1',
+          'state: running',
+          '',
+          '<task_result>',
+          'Timed out after 120000ms while waiting for task completion.',
+          '</task_result>',
+        ].join('\n'),
+      },
+    );
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      timedOut: true,
+      terminalUnreconciled: false,
+    });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(messages.messages[0].parts[0].text).toContain(
+      'fix-1 / child-1 / fixer / running, timed out',
+    );
+  });
+
+  test('updates background job board from injected completion messages', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'map hooks',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output: [
+          'task_id: child-1 (for polling this task with task_status)',
+          'state: running',
+        ].join('\n'),
+      },
+    );
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'found hook flow',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'found hook flow',
+    });
+    expect(messages.messages[0].parts[0].text).toContain(
+      'exp-1 / child-1 / explorer / completed, unreconciled',
+    );
+  });
+
+  test('ignores non-synthetic user text that resembles task status', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    const messages = createMessages(
+      'parent-1',
+      [
+        'please note this text:',
+        'task_id: child-1',
+        'state: completed',
+        '<task_result>',
+        'spoofed',
+        '</task_result>',
+      ].join('\n'),
+    );
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('does not replay old injected completion after same task id relaunches', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+
+    const messages = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 'parent-1' },
+          parts: [
+            {
+              type: 'text',
+              synthetic: true,
+              text: [
+                'Background task completed: map hooks',
+                'task_id: child-1',
+                'state: completed',
+                '',
+                '<task_result>',
+                'old result',
+                '</task_result>',
+              ].join('\n'),
+            },
+          ],
+        },
+      ],
+    };
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      resultSummary: 'old result',
+    });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks again',
+    });
+
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'running',
+      terminalUnreconciled: false,
+      resultSummary: undefined,
+    });
+  });
+
+  test('marks terminal jobs reconciled after injected prompt reaches idle', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'approved',
+    });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+    expect(messages.messages[0].parts[0].text).toContain(
+      'ora-1 / child-1 / oracle / completed, unreconciled',
+    );
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+    });
+
+    const nextMessages = createMessages('parent-1', 'continue again');
+    await hook['experimental.chat.messages.transform']({}, nextMessages);
+    expect(nextMessages.messages[0].parts[0].text).toBe('continue again');
+  });
+
+  test('does not reconcile terminal jobs before they are injected into a prompt', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
+  test('does not reconcile injected terminal jobs after session error', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+
+    await hook.event({
+      event: {
+        type: 'session.error',
+        properties: {
+          sessionID: 'parent-1',
+          error: { name: 'MessageAbortedError' },
+        },
+      },
+    });
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(board.get('child-1')).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+    });
+  });
+
+  test('does not expose running background jobs as resumable sessions', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'background config schema',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-1',
+      },
+      {
+        output: [
+          'task_id: child-1 (for polling this task with task_status)',
+          'state: running',
+        ].join('\n'),
+      },
+    );
+
+    const next = {
+      args: {
+        subagent_type: 'explorer',
+        description: 'continue background work',
+        task_id: 'exp-1',
+      },
+    };
+    await hook['tool.execute.before'](
+      {
+        tool: 'task',
+        sessionID: 'parent-1',
+        callID: 'call-2',
+      },
+      next,
+    );
+
+    expect(next.args.task_id).toBeUndefined();
+  });
+
+  test('drops remembered alias when resumed session is relaunched in background', async () => {
+    const { hook } = createHook();
+
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        args: {
+          subagent_type: 'explorer',
+          description: 'config schema',
+        },
+      },
+    );
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      {
+        output:
+          'task_id: child-1 (for resuming to continue this task if needed)',
+      },
+    );
+
+    const resumed = {
+      args: {
+        subagent_type: 'explorer',
+        description: 'continue config schema',
+        task_id: 'exp-1',
+      },
+    };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      resumed,
+    );
+    expect(resumed.args.task_id).toBe('child-1');
+
+    await hook['tool.execute.after'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      {
+        output: [
+          'task_id: child-1 (for polling this task with task_status)',
+          'state: running',
+        ].join('\n'),
+      },
+    );
+
+    const next = {
+      args: {
+        subagent_type: 'explorer',
+        description: 'try stale alias',
+        task_id: 'exp-1',
+      },
+    };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-3' },
+      next,
+    );
+
+    expect(next.args.task_id).toBeUndefined();
+
+    const messages = createMessages('parent-1', 'do something');
+    await hook['experimental.chat.messages.transform']({}, messages);
+    expect(messages.messages[0].parts[0].text).toContain(
+      'exp-1 / child-1 / explorer / running',
+    );
+    expect(messages.messages[0].parts[0].text).not.toContain(
+      'explorer: exp-1 config schema',
+    );
+  });
+
   test('stores task sessions and injects resumable-session block into user message', async () => {
   test('stores task sessions and injects resumable-session block into user message', async () => {
     const { hook } = createHook();
     const { hook } = createHook();
 
 

+ 179 - 3
src/hooks/task-session-manager/index.ts

@@ -2,9 +2,13 @@ import path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { AgentName } from '../../config';
 import type { AgentName } from '../../config';
 import {
 import {
+  BackgroundJobBoard,
+  type BackgroundJobRecord,
   type ContextFile,
   type ContextFile,
   deriveTaskSessionLabel,
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
   parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskStatusOutput,
   SessionManager,
   SessionManager,
   SLIM_INTERNAL_INITIATOR_MARKER,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
 } from '../../utils';
@@ -61,6 +65,8 @@ interface ChatMessage {
 
 
 const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
 const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
 const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
 const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
+const BACKGROUND_COMPLETION_PREFIX = /^Background task (completed|failed): /;
+const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
 
 
 function isAgentName(value: unknown): value is AgentName {
 function isAgentName(value: unknown): value is AgentName {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
@@ -115,6 +121,7 @@ export function createTaskSessionManagerHook(
     maxSessionsPerAgent: number;
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
     readContextMaxFiles?: number;
+    backgroundJobBoard?: BackgroundJobBoard;
     shouldManageSession: (sessionID: string) => boolean;
     shouldManageSession: (sessionID: string) => boolean;
   },
   },
 ) {
 ) {
@@ -122,10 +129,15 @@ export function createTaskSessionManagerHook(
     readContextMinLines: options.readContextMinLines,
     readContextMinLines: options.readContextMinLines,
     readContextMaxFiles: options.readContextMaxFiles,
     readContextMaxFiles: options.readContextMaxFiles,
   });
   });
+  const backgroundJobBoard =
+    options.backgroundJobBoard ?? new BackgroundJobBoard();
   const pendingCalls = new Map<string, PendingTaskCall>();
   const pendingCalls = new Map<string, PendingTaskCall>();
   const pendingCallOrder: string[] = [];
   const pendingCallOrder: string[] = [];
   const contextByTask = new Map<string, Map<string, PendingContextFile>>();
   const contextByTask = new Map<string, Map<string, PendingContextFile>>();
   const pendingManagedTaskIds = new Set<string>();
   const pendingManagedTaskIds = new Set<string>();
+  const terminalJobsInjectedByParent = new Map<string, Set<string>>();
+  const processedInjectedCompletions = new Set<string>();
+  const processedInjectedCompletionOrder: string[] = [];
   let anonymousPendingCallId = 0;
   let anonymousPendingCallId = 0;
 
 
   function addTaskContext(taskId: string, files: ContextFile[]): void {
   function addTaskContext(taskId: string, files: ContextFile[]): void {
@@ -178,6 +190,70 @@ export function createTaskSessionManagerHook(
     }
     }
   }
   }
 
 
+  function updateBackgroundJobFromOutput(
+    output: unknown,
+  ): BackgroundJobRecord | undefined {
+    if (typeof output !== 'string') return undefined;
+
+    const status = parseTaskStatusOutput(output);
+    if (!status) return undefined;
+
+    const updated = backgroundJobBoard.updateStatus({
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      resultSummary: status.result,
+    });
+    if (!updated) return undefined;
+
+    if (updated.terminalUnreconciled) {
+      pendingManagedTaskIds.delete(updated.taskID);
+      contextByTask.delete(updated.taskID);
+      pruneContext();
+    }
+
+    return updated;
+  }
+
+  function updateFromInjectedCompletion(
+    part: ChatMessagePart,
+  ): BackgroundJobRecord | undefined {
+    if (
+      part.type !== 'text' ||
+      typeof part.text !== 'string' ||
+      part.synthetic !== true ||
+      !BACKGROUND_COMPLETION_PREFIX.test(part.text)
+    ) {
+      return undefined;
+    }
+
+    const status = parseTaskStatusOutput(part.text);
+    if (!status) return undefined;
+
+    const signature = `${status.taskID}:${status.state}:${status.result ?? ''}`;
+    if (processedInjectedCompletions.has(signature)) return undefined;
+
+    const updated = updateBackgroundJobFromOutput(part.text);
+    if (!updated) return undefined;
+
+    rememberProcessedInjectedCompletion(signature);
+    return updated;
+  }
+
+  function rememberProcessedInjectedCompletion(signature: string): void {
+    processedInjectedCompletions.add(signature);
+    processedInjectedCompletionOrder.push(signature);
+
+    while (
+      processedInjectedCompletionOrder.length >
+      MAX_PROCESSED_INJECTED_COMPLETIONS
+    ) {
+      const evicted = processedInjectedCompletionOrder.shift();
+      if (!evicted) break;
+      processedInjectedCompletions.delete(evicted);
+    }
+  }
+
   function isMissingRememberedSessionError(output: string): boolean {
   function isMissingRememberedSessionError(output: string): boolean {
     const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
     const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
     return (
     return (
@@ -242,6 +318,31 @@ export function createTaskSessionManagerHook(
     );
     );
   }
   }
 
 
+  function rememberInjectedTerminalJobs(parentSessionID: string): void {
+    const taskIDs = backgroundJobBoard
+      .list(parentSessionID)
+      .filter((job) => job.terminalUnreconciled)
+      .map((job) => job.taskID);
+    if (taskIDs.length === 0) return;
+
+    const existing =
+      terminalJobsInjectedByParent.get(parentSessionID) ?? new Set<string>();
+    for (const taskID of taskIDs) {
+      existing.add(taskID);
+    }
+    terminalJobsInjectedByParent.set(parentSessionID, existing);
+  }
+
+  function reconcileInjectedTerminalJobs(parentSessionID: string): void {
+    const taskIDs = terminalJobsInjectedByParent.get(parentSessionID);
+    if (!taskIDs) return;
+
+    for (const taskID of taskIDs) {
+      backgroundJobBoard.markReconciled(taskID);
+    }
+    terminalJobsInjectedByParent.delete(parentSessionID);
+  }
+
   return {
   return {
     'tool.execute.before': async (
     'tool.execute.before': async (
       input: { tool: string; sessionID?: string; callID?: string },
       input: { tool: string; sessionID?: string; callID?: string },
@@ -315,11 +416,37 @@ export function createTaskSessionManagerHook(
         return;
         return;
       }
       }
 
 
+      if (input.tool.toLowerCase() === 'task_status') {
+        if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
+          return;
+        }
+        updateBackgroundJobFromOutput(output.output);
+        return;
+      }
+
       if (input.tool.toLowerCase() !== 'task') return;
       if (input.tool.toLowerCase() !== 'task') return;
 
 
       const pending = takePendingCall(input.callID, input.sessionID);
       const pending = takePendingCall(input.callID, input.sessionID);
 
 
       if (!pending || typeof output.output !== 'string') return;
       if (!pending || typeof output.output !== 'string') return;
+      const launch = parseTaskLaunchOutput(output.output);
+      if (launch) {
+        backgroundJobBoard.registerLaunch({
+          taskID: launch.taskID,
+          parentSessionID: pending.parentSessionId,
+          agent: pending.agentType,
+          description: pending.label,
+          objective: pending.label,
+        });
+        sessionManager.drop(
+          pending.parentSessionId,
+          pending.agentType,
+          pending.resumedTaskId ?? launch.taskID,
+        );
+        pendingManagedTaskIds.add(launch.taskID);
+        return;
+      }
+
       const taskId = parseTaskIdFromTaskOutput(output.output);
       const taskId = parseTaskIdFromTaskOutput(output.output);
       if (!taskId) {
       if (!taskId) {
         if (
         if (
@@ -359,6 +486,23 @@ export function createTaskSessionManagerHook(
       _input: Record<string, never>,
       _input: Record<string, never>,
       output: { messages: ChatMessage[] },
       output: { messages: ChatMessage[] },
     ): Promise<void> => {
     ): Promise<void> => {
+      for (const message of output.messages) {
+        if (message.info.role !== 'user') continue;
+        if (message.info.agent && message.info.agent !== 'orchestrator') {
+          continue;
+        }
+        if (
+          !message.info.sessionID ||
+          !options.shouldManageSession(message.info.sessionID)
+        ) {
+          continue;
+        }
+
+        for (const part of message.parts) {
+          updateFromInjectedCompletion(part);
+        }
+      }
+
       for (let i = output.messages.length - 1; i >= 0; i -= 1) {
       for (let i = output.messages.length - 1; i >= 0; i -= 1) {
         const message = output.messages[i];
         const message = output.messages[i];
         if (message.info.role !== 'user') continue;
         if (message.info.role !== 'user') continue;
@@ -370,8 +514,11 @@ export function createTaskSessionManagerHook(
           return;
           return;
         }
         }
 
 
-        const reminder = sessionManager.formatForPrompt(message.info.sessionID);
-        if (!reminder) return;
+        const reminders = [
+          backgroundJobBoard.formatForPrompt(message.info.sessionID),
+          sessionManager.formatForPrompt(message.info.sessionID),
+        ].filter((item): item is string => Boolean(item));
+        if (reminders.length === 0) return;
 
 
         const textPart = message.parts.find(
         const textPart = message.parts.find(
           (part) => part.type === 'text' && typeof part.text === 'string',
           (part) => part.type === 'text' && typeof part.text === 'string',
@@ -380,11 +527,12 @@ export function createTaskSessionManagerHook(
         if (textPart.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER)) return;
         if (textPart.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER)) return;
         if (textPart.text?.includes(RESUMABLE_SESSIONS_START)) return;
         if (textPart.text?.includes(RESUMABLE_SESSIONS_START)) return;
 
 
+        rememberInjectedTerminalJobs(message.info.sessionID);
         textPart.text = [
         textPart.text = [
           textPart.text ?? '',
           textPart.text ?? '',
           '',
           '',
           RESUMABLE_SESSIONS_START,
           RESUMABLE_SESSIONS_START,
-          reminder,
+          reminders.join('\n\n'),
           RESUMABLE_SESSIONS_END,
           RESUMABLE_SESSIONS_END,
         ].join('\n');
         ].join('\n');
         return;
         return;
@@ -397,6 +545,8 @@ export function createTaskSessionManagerHook(
         properties?: {
         properties?: {
           info?: { id?: string; parentID?: string };
           info?: { id?: string; parentID?: string };
           sessionID?: string;
           sessionID?: string;
+          status?: { type?: string };
+          error?: { name?: string };
         };
         };
       };
       };
     }): Promise<void> => {
     }): Promise<void> => {
@@ -412,13 +562,39 @@ export function createTaskSessionManagerHook(
         return;
         return;
       }
       }
 
 
+      if (
+        input.event.type === 'session.idle' ||
+        (input.event.type === 'session.status' &&
+          (input.event.properties as { status?: { type?: string } } | undefined)
+            ?.status?.type === 'idle')
+      ) {
+        const sessionId =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        if (sessionId && options.shouldManageSession(sessionId)) {
+          reconcileInjectedTerminalJobs(sessionId);
+        }
+        return;
+      }
+
+      if (input.event.type === 'session.error') {
+        const sessionId =
+          input.event.properties?.info?.id ?? input.event.properties?.sessionID;
+        if (sessionId && options.shouldManageSession(sessionId)) {
+          terminalJobsInjectedByParent.delete(sessionId);
+        }
+        return;
+      }
+
       if (input.event.type !== 'session.deleted') return;
       if (input.event.type !== 'session.deleted') return;
       const sessionId =
       const sessionId =
         input.event.properties?.info?.id ?? input.event.properties?.sessionID;
         input.event.properties?.info?.id ?? input.event.properties?.sessionID;
       if (!sessionId) return;
       if (!sessionId) return;
 
 
       sessionManager.dropTask(sessionId);
       sessionManager.dropTask(sessionId);
+      backgroundJobBoard.drop(sessionId);
       sessionManager.clearParent(sessionId);
       sessionManager.clearParent(sessionId);
+      backgroundJobBoard.clearParent(sessionId);
+      terminalJobsInjectedByParent.delete(sessionId);
       contextByTask.delete(sessionId);
       contextByTask.delete(sessionId);
       pendingManagedTaskIds.delete(sessionId);
       pendingManagedTaskIds.delete(sessionId);
       pruneContext();
       pruneContext();

+ 85 - 1
src/hooks/todo-continuation/index.test.ts

@@ -1,5 +1,8 @@
 import { describe, expect, mock, test } from 'bun:test';
 import { describe, expect, mock, test } from 'bun:test';
-import { SLIM_INTERNAL_INITIATOR_MARKER } from '../../utils';
+import {
+  BackgroundJobBoard,
+  SLIM_INTERNAL_INITIATOR_MARKER,
+} from '../../utils';
 import { createTodoContinuationHook } from './index';
 import { createTodoContinuationHook } from './index';
 import {
 import {
   TODO_FINAL_ACTIVE_REMINDER,
   TODO_FINAL_ACTIVE_REMINDER,
@@ -649,6 +652,44 @@ describe('createTodoContinuationHook', () => {
       expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
       expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_REMINDER);
       expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
       expect(allMessageText(output)).not.toContain(TODO_HYGIENE_REMINDER);
     });
     });
+
+    test('todo hygiene reminder includes unreconciled background results', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            {
+              id: '1',
+              content: 'todo1',
+              status: 'in_progress',
+              priority: 'high',
+            },
+          ],
+        },
+      });
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'child-1',
+        parentSessionID: 'main1',
+        agent: 'oracle',
+        description: 'review plan',
+      });
+      board.updateStatus({ taskID: 'child-1', state: 'completed' });
+      const hook = createTodoContinuationHook(ctx, {
+        backgroundJobBoard: board,
+      });
+      const output = userMessages('haz esto', 'main1', 'orchestrator');
+
+      await hook.handleMessagesTransform(output);
+      await hook.handleToolExecuteAfter({
+        tool: 'todowrite',
+        sessionID: 'main1',
+      });
+      await hook.handleMessagesTransform(output);
+
+      expect(allMessageText(output)).toContain(
+        'Background jobs have terminal results: reconcile the Background Job Board results before finalizing.',
+      );
+    });
   });
   });
 
 
   describe('continuation scheduling', () => {
   describe('continuation scheduling', () => {
@@ -700,6 +741,49 @@ describe('createTodoContinuationHook', () => {
       );
       );
     });
     });
 
 
+    test('continuation prompt nudges toward running background job status', async () => {
+      const ctx = createMockContext({
+        todoResult: {
+          data: [
+            { id: '1', content: 'todo1', status: 'pending', priority: 'high' },
+          ],
+        },
+        messagesResult: {
+          data: [
+            {
+              info: { role: 'assistant' },
+              parts: [{ type: 'text', text: 'Continuing' }],
+            },
+          ],
+        },
+      });
+      const board = new BackgroundJobBoard();
+      board.registerLaunch({
+        taskID: 'child-1',
+        parentSessionID: 'session-123',
+        agent: 'fixer',
+        description: 'implement change',
+      });
+      const hook = createTodoContinuationHook(ctx, {
+        cooldownMs: 50,
+        backgroundJobBoard: board,
+      });
+
+      await hook.tool.auto_continue.execute({ enabled: true });
+      await hook.handleEvent({
+        event: {
+          type: 'session.idle',
+          properties: { sessionID: 'session-123' },
+        },
+      });
+      await delay(60);
+
+      const promptCall = contCall(ctx.client.session.prompt);
+      expect(promptCall[0].body.parts[0].text).toContain(
+        'Background jobs are still running: call task_status',
+      );
+    });
+
     test('disabled → no continuation', async () => {
     test('disabled → no continuation', async () => {
       const ctx = createMockContext({
       const ctx = createMockContext({
         todoResult: {
         todoResult: {

+ 38 - 4
src/hooks/todo-continuation/index.ts

@@ -1,6 +1,7 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
 import { tool } from '@opencode-ai/plugin';
 import { tool } from '@opencode-ai/plugin';
 import {
 import {
+  type BackgroundJobBoard,
   createInternalAgentTextPart,
   createInternalAgentTextPart,
   log,
   log,
   SLIM_INTERNAL_INITIATOR_MARKER,
   SLIM_INTERNAL_INITIATOR_MARKER,
@@ -169,6 +170,7 @@ export function createTodoContinuationHook(
     cooldownMs?: number;
     cooldownMs?: number;
     autoEnable?: boolean;
     autoEnable?: boolean;
     autoEnableThreshold?: number;
     autoEnableThreshold?: number;
+    backgroundJobBoard?: BackgroundJobBoard;
   },
   },
 ): {
 ): {
   tool: Record<string, unknown>;
   tool: Record<string, unknown>;
@@ -199,6 +201,7 @@ export function createTodoContinuationHook(
   const cooldownMs = config?.cooldownMs ?? 3000;
   const cooldownMs = config?.cooldownMs ?? 3000;
   const autoEnable = config?.autoEnable ?? false;
   const autoEnable = config?.autoEnable ?? false;
   const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
   const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
+  const backgroundJobBoard = config?.backgroundJobBoard;
   const requestSignatureBySession = new Map<string, string>();
   const requestSignatureBySession = new Map<string, string>();
 
 
   const state: ContinuationState = {
   const state: ContinuationState = {
@@ -373,8 +376,12 @@ export function createTodoContinuationHook(
       lastUserMessage.signature
       lastUserMessage.signature
     ) {
     ) {
       const reminder = hygiene.getPendingReminder(lastUserMessage.sessionID);
       const reminder = hygiene.getPendingReminder(lastUserMessage.sessionID);
-      if (reminder) {
-        appendTodoHygieneInstruction(lastUserMessage.message, reminder);
+      const guardrail = backgroundGuardrail(lastUserMessage.sessionID);
+      const combinedReminder = [reminder, guardrail]
+        .filter((item): item is string => Boolean(item))
+        .join(' ');
+      if (combinedReminder) {
+        appendTodoHygieneInstruction(lastUserMessage.message, combinedReminder);
       } else {
       } else {
         stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
         stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
       }
       }
@@ -427,6 +434,31 @@ export function createTodoContinuationHook(
     state.orchestratorSessionIds.add(sessionID);
     state.orchestratorSessionIds.add(sessionID);
   }
   }
 
 
+  function backgroundGuardrail(sessionID: string): string | undefined {
+    if (!backgroundJobBoard) return undefined;
+
+    const hasRunning = backgroundJobBoard.hasRunning(sessionID);
+    const hasTerminal = backgroundJobBoard.hasTerminalUnreconciled(sessionID);
+    if (hasRunning && hasTerminal) {
+      return 'Background jobs are still unresolved: call task_status for running jobs and reconcile terminal Background Job Board results before dependent work or finalizing.';
+    }
+    if (hasTerminal) {
+      return 'Background jobs have terminal results: reconcile the Background Job Board results before finalizing.';
+    }
+    if (hasRunning) {
+      return 'Background jobs are still running: call task_status before dependent work or finalizing.';
+    }
+
+    return undefined;
+  }
+
+  function continuationPrompt(sessionID: string): string {
+    const guardrail = backgroundGuardrail(sessionID);
+    if (!guardrail) return CONTINUATION_PROMPT;
+
+    return `${CONTINUATION_PROMPT} ${guardrail}`;
+  }
+
   function handleChatMessage(input: {
   function handleChatMessage(input: {
     sessionID: string;
     sessionID: string;
     agent?: string;
     agent?: string;
@@ -689,7 +721,9 @@ export function createTodoContinuationHook(
           await ctx.client.session.prompt({
           await ctx.client.session.prompt({
             path: { id: sessionID },
             path: { id: sessionID },
             body: {
             body: {
-              parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
+              parts: [
+                createInternalAgentTextPart(continuationPrompt(sessionID)),
+              ],
             },
             },
           });
           });
           state.consecutiveContinuations++;
           state.consecutiveContinuations++;
@@ -856,7 +890,7 @@ export function createTodoContinuationHook(
     if (hasIncompleteTodos) {
     if (hasIncompleteTodos) {
       output.parts.push(
       output.parts.push(
         createInternalAgentTextPart(
         createInternalAgentTextPart(
-          `${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
+          `${continuationPrompt(input.sessionID)} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
         ),
         ),
       );
       );
     } else {
     } else {

+ 6 - 0
src/index.ts

@@ -51,6 +51,7 @@ import {
 } from './tools';
 } from './tools';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import { recordTuiAgentModel, recordTuiAgentModels } from './tui-state';
 import {
 import {
+  BackgroundJobBoard,
   createDisplayNameMentionRewriter,
   createDisplayNameMentionRewriter,
   resolveRuntimeAgentName,
   resolveRuntimeAgentName,
 } from './utils';
 } from './utils';
@@ -140,6 +141,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
   let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
   let sessionGoalHook: ReturnType<typeof createSessionGoalHook>;
   let sessionGoalHook: ReturnType<typeof createSessionGoalHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
+  let backgroundJobBoard: BackgroundJobBoard;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
   let divoomManager: ReturnType<typeof createDivoomManager>;
   let divoomManager: ReturnType<typeof createDivoomManager>;
@@ -301,6 +303,8 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         Object.keys(runtimeChains).length > 0,
         Object.keys(runtimeChains).length > 0,
     );
     );
 
 
+    backgroundJobBoard = new BackgroundJobBoard();
+
     // Initialize todo-continuation hook (opt-in auto-continue for
     // Initialize todo-continuation hook (opt-in auto-continue for
     // incomplete todos)
     // incomplete todos)
     todoContinuationHook = createTodoContinuationHook(ctx, {
     todoContinuationHook = createTodoContinuationHook(ctx, {
@@ -308,6 +312,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
       cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
       autoEnable: config.todoContinuation?.autoEnable ?? false,
       autoEnable: config.todoContinuation?.autoEnable ?? false,
       autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
       autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
+      backgroundJobBoard,
     });
     });
     sessionGoalHook = createSessionGoalHook(ctx, config, {
     sessionGoalHook = createSessionGoalHook(ctx, config, {
       getAgentName: (sessionID) => sessionAgentMap.get(sessionID),
       getAgentName: (sessionID) => sessionAgentMap.get(sessionID),
@@ -316,6 +321,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
       maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
       readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
       readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
       readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
+      backgroundJobBoard,
       shouldManageSession: (sessionID) =>
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',
         sessionAgentMap.get(sessionID) === 'orchestrator',
     });
     });

+ 40 - 0
src/multiplexer/session-manager.test.ts

@@ -415,6 +415,46 @@ describe('MultiplexerSessionManager', () => {
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
       expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
     });
     });
 
 
+    test('keeps background child pane open while status is running until deleted', async () => {
+      const ctx = createMockContext();
+      const manager = new MultiplexerSessionManager(
+        ctx,
+        defaultMultiplexerConfig,
+      );
+
+      mockMultiplexer.spawnPane.mockResolvedValueOnce({
+        success: true,
+        paneId: 'p-background-child',
+      });
+
+      await manager.onSessionCreated({
+        type: 'session.created',
+        properties: {
+          info: {
+            id: 'background-child',
+            parentID: 'parent-1',
+            title: 'Background Worker',
+          },
+        },
+      });
+
+      setMockSessionStatuses({ 'background-child': { type: 'running' } });
+      await (manager as any).pollSessions();
+      await (manager as any).pollSessions();
+
+      expect(mockMultiplexer.closePane).not.toHaveBeenCalled();
+
+      await manager.onSessionDeleted({
+        type: 'session.deleted',
+        properties: { info: { id: 'background-child' } },
+      });
+
+      expect(mockMultiplexer.closePane).toHaveBeenCalledTimes(1);
+      expect(mockMultiplexer.closePane).toHaveBeenCalledWith(
+        'p-background-child',
+      );
+    });
+
     test('keeps missing cleanup for sessions previously seen in status', async () => {
     test('keeps missing cleanup for sessions previously seen in status', async () => {
       const ctx = createMockContext();
       const ctx = createMockContext();
       mockMultiplexer.spawnPane.mockResolvedValue({
       mockMultiplexer.spawnPane.mockResolvedValue({

+ 223 - 0
src/utils/background-job-board.test.ts

@@ -0,0 +1,223 @@
+import { describe, expect, test } from 'bun:test';
+import { BackgroundJobBoard } from './background-job-board';
+
+describe('BackgroundJobBoard', () => {
+  test('registers background launches as running jobs with aliases', () => {
+    const board = new BackgroundJobBoard();
+
+    const job = board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map config',
+      now: 100,
+    });
+
+    expect(job).toMatchObject({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map config',
+      state: 'running',
+      alias: 'exp-1',
+      terminalUnreconciled: false,
+    });
+    expect(board.hasRunning('parent-1')).toBe(true);
+  });
+
+  test('updates terminal task_status results as unreconciled', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+      now: 100,
+    });
+
+    const updated = board.updateStatus({
+      taskID: 'ses_1',
+      state: 'completed',
+      resultSummary: 'looks good',
+      now: 200,
+    });
+
+    expect(updated).toMatchObject({
+      state: 'completed',
+      terminalUnreconciled: true,
+      completedAt: 200,
+      resultSummary: 'looks good',
+    });
+    expect(board.hasTerminalUnreconciled('parent-1')).toBe(true);
+  });
+
+  test('keeps timeout status running with timedOut overlay', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'implement parser',
+    });
+
+    board.updateStatus({
+      taskID: 'ses_1',
+      state: 'running',
+      timedOut: true,
+    });
+
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'running',
+      timedOut: true,
+      terminalUnreconciled: false,
+    });
+  });
+
+  test('formats running and terminal unreconciled jobs for prompt', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map config',
+    });
+    board.registerLaunch({
+      taskID: 'ses_2',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({
+      taskID: 'ses_2',
+      state: 'completed',
+      resultSummary: 'plan is sound',
+    });
+
+    const prompt = board.formatForPrompt('parent-1');
+
+    expect(prompt).toContain('### Background Job Board');
+    expect(prompt).toContain('exp-1 / ses_1 / explorer / running');
+    expect(prompt).toContain(
+      'ora-1 / ses_2 / oracle / completed, unreconciled',
+    );
+    expect(prompt).toContain('Result: plan is sound');
+  });
+
+  test('marks terminal jobs as reconciled and hides them from prompt', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({ taskID: 'ses_1', state: 'completed' });
+    board.markReconciled('ses_1', 300);
+
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'reconciled',
+      terminalUnreconciled: false,
+      updatedAt: 300,
+    });
+    expect(board.formatForPrompt('parent-1')).toBeUndefined();
+  });
+
+  test('does not reconcile running jobs', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'still running',
+    });
+
+    expect(board.markReconciled('ses_1')).toBeUndefined();
+    expect(board.get('ses_1')).toMatchObject({ state: 'running' });
+  });
+
+  test('resets terminal state when an existing task id is relaunched', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'first run',
+      now: 100,
+    });
+    board.updateStatus({
+      taskID: 'ses_1',
+      state: 'completed',
+      resultSummary: 'old result',
+      now: 200,
+    });
+
+    const relaunched = board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'fixer',
+      description: 'second run',
+      now: 300,
+    });
+
+    expect(relaunched).toMatchObject({
+      state: 'running',
+      timedOut: false,
+      terminalUnreconciled: false,
+      completedAt: undefined,
+      resultSummary: undefined,
+      updatedAt: 300,
+    });
+  });
+
+  test('updates status from native task_status output', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map files',
+    });
+
+    board.updateFromStatusOutput(
+      [
+        'task_id: ses_1',
+        'state: error',
+        '<task_result>',
+        'failed',
+        '</task_result>',
+      ].join('\n'),
+    );
+
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'error',
+      terminalUnreconciled: true,
+      resultSummary: 'failed',
+    });
+  });
+
+  test('updates error summary from task_error output', () => {
+    const board = new BackgroundJobBoard();
+    board.registerLaunch({
+      taskID: 'ses_1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map files',
+    });
+
+    board.updateFromStatusOutput(
+      [
+        'task_id: ses_1',
+        'state: cancelled',
+        '<task_error>',
+        'cancelled by user',
+        '</task_error>',
+      ].join('\n'),
+    );
+
+    expect(board.get('ses_1')).toMatchObject({
+      state: 'cancelled',
+      terminalUnreconciled: true,
+      resultSummary: 'cancelled by user',
+    });
+  });
+});

+ 235 - 0
src/utils/background-job-board.ts

@@ -0,0 +1,235 @@
+import { parseTaskStatusOutput, type TaskOutputState } from './task';
+
+export type BackgroundJobState = TaskOutputState | 'launched' | 'reconciled';
+
+export interface BackgroundJobRecord {
+  taskID: string;
+  parentSessionID: string;
+  agent: string;
+  description: string;
+  objective?: string;
+  state: BackgroundJobState;
+  timedOut: boolean;
+  terminalUnreconciled: boolean;
+  launchedAt: number;
+  updatedAt: number;
+  completedAt?: number;
+  resultSummary?: string;
+  alias: string;
+}
+
+export interface BackgroundJobLaunchInput {
+  taskID: string;
+  parentSessionID: string;
+  agent: string;
+  description?: string;
+  objective?: string;
+  now?: number;
+}
+
+export interface BackgroundJobStatusInput {
+  taskID: string;
+  state: TaskOutputState;
+  timedOut?: boolean;
+  resultSummary?: string;
+  now?: number;
+}
+
+const TERMINAL_STATES = new Set<BackgroundJobState>([
+  'completed',
+  'error',
+  'cancelled',
+]);
+
+const AGENT_PREFIX: Record<string, string> = {
+  council: 'cou',
+  designer: 'des',
+  explorer: 'exp',
+  fixer: 'fix',
+  librarian: 'lib',
+  observer: 'obs',
+  oracle: 'ora',
+};
+
+export class BackgroundJobBoard {
+  private readonly jobs = new Map<string, BackgroundJobRecord>();
+  private readonly counters = new Map<string, number>();
+
+  registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
+    const now = input.now ?? Date.now();
+    const existing = this.jobs.get(input.taskID);
+
+    if (existing) {
+      const updated = {
+        ...existing,
+        agent: input.agent || existing.agent,
+        description: input.description || existing.description,
+        objective: input.objective ?? existing.objective,
+        state: 'running',
+        timedOut: false,
+        terminalUnreconciled: false,
+        completedAt: undefined,
+        resultSummary: undefined,
+        updatedAt: now,
+      } satisfies BackgroundJobRecord;
+      this.jobs.set(input.taskID, updated);
+      return updated;
+    }
+
+    const record: BackgroundJobRecord = {
+      taskID: input.taskID,
+      parentSessionID: input.parentSessionID,
+      agent: input.agent,
+      description: input.description || `background ${input.agent} task`,
+      objective: input.objective,
+      state: 'running',
+      timedOut: false,
+      terminalUnreconciled: false,
+      launchedAt: now,
+      updatedAt: now,
+      alias: this.nextAlias(input.parentSessionID, input.agent),
+    };
+
+    this.jobs.set(input.taskID, record);
+    return record;
+  }
+
+  updateStatus(
+    input: BackgroundJobStatusInput,
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(input.taskID);
+    if (!existing) return undefined;
+
+    const now = input.now ?? Date.now();
+    const terminal = TERMINAL_STATES.has(input.state);
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      state: input.state,
+      timedOut: input.timedOut ?? false,
+      terminalUnreconciled: terminal ? true : existing.terminalUnreconciled,
+      updatedAt: now,
+      completedAt: terminal
+        ? (existing.completedAt ?? now)
+        : existing.completedAt,
+      resultSummary: input.resultSummary ?? existing.resultSummary,
+    };
+
+    this.jobs.set(input.taskID, updated);
+    return updated;
+  }
+
+  updateFromStatusOutput(output: string): BackgroundJobRecord | undefined {
+    const status = parseTaskStatusOutput(output);
+    if (!status) return undefined;
+
+    return this.updateStatus({
+      taskID: status.taskID,
+      state: status.state,
+      timedOut: status.timedOut,
+      resultSummary: status.result,
+    });
+  }
+
+  markReconciled(
+    taskID: string,
+    now = Date.now(),
+  ): BackgroundJobRecord | undefined {
+    const existing = this.jobs.get(taskID);
+    if (!existing) return undefined;
+    if (
+      !existing.terminalUnreconciled &&
+      !TERMINAL_STATES.has(existing.state)
+    ) {
+      return undefined;
+    }
+
+    const updated: BackgroundJobRecord = {
+      ...existing,
+      state: 'reconciled',
+      terminalUnreconciled: false,
+      updatedAt: now,
+    };
+
+    this.jobs.set(taskID, updated);
+    return updated;
+  }
+
+  get(taskID: string): BackgroundJobRecord | undefined {
+    return this.jobs.get(taskID);
+  }
+
+  list(parentSessionID?: string): BackgroundJobRecord[] {
+    const jobs = [...this.jobs.values()];
+    const filtered = parentSessionID
+      ? jobs.filter((job) => job.parentSessionID === parentSessionID)
+      : jobs;
+
+    return filtered.sort((a, b) => a.launchedAt - b.launchedAt);
+  }
+
+  hasRunning(parentSessionID: string): boolean {
+    return this.list(parentSessionID).some((job) => job.state === 'running');
+  }
+
+  hasTerminalUnreconciled(parentSessionID: string): boolean {
+    return this.list(parentSessionID).some((job) => job.terminalUnreconciled);
+  }
+
+  formatForPrompt(parentSessionID: string): string | undefined {
+    const jobs = this.list(parentSessionID).filter(
+      (job) => job.state === 'running' || job.terminalUnreconciled,
+    );
+
+    if (jobs.length === 0) return undefined;
+
+    return [
+      '### Background Job Board',
+      'Use task_status before consuming running jobs. Reconcile terminal jobs before final response.',
+      '',
+      ...jobs.map(formatJob),
+    ].join('\n');
+  }
+
+  clearParent(parentSessionID: string): void {
+    for (const job of this.list(parentSessionID)) {
+      this.jobs.delete(job.taskID);
+    }
+  }
+
+  drop(taskID: string): void {
+    this.jobs.delete(taskID);
+  }
+
+  private nextAlias(parentSessionID: string, agent: string): string {
+    const prefix = AGENT_PREFIX[agent] ?? (agent.slice(0, 3) || 'job');
+    const key = `${parentSessionID}:${prefix}`;
+    const next = (this.counters.get(key) ?? 0) + 1;
+    this.counters.set(key, next);
+
+    return `${prefix}-${next}`;
+  }
+}
+
+function formatJob(job: BackgroundJobRecord): string {
+  const status = job.terminalUnreconciled
+    ? `${job.state}, unreconciled`
+    : job.timedOut
+      ? `${job.state}, timed out`
+      : job.state;
+  const lines = [
+    `- ${job.alias} / ${job.taskID} / ${job.agent} / ${status}`,
+    `  Objective: ${job.objective || job.description}`,
+  ];
+
+  if (job.resultSummary && job.terminalUnreconciled) {
+    lines.push(`  Result: ${singleLine(job.resultSummary)}`);
+  }
+
+  return lines.join('\n');
+}
+
+function singleLine(value: string): string {
+  const normalized = value.replace(/\s+/g, ' ').trim();
+  if (normalized.length <= 160) return normalized;
+  return `${normalized.slice(0, 157)}...`;
+}

+ 1 - 0
src/utils/index.ts

@@ -1,4 +1,5 @@
 export * from './agent-variant';
 export * from './agent-variant';
+export * from './background-job-board';
 export * from './env';
 export * from './env';
 export * from './internal-initiator';
 export * from './internal-initiator';
 export { getLogDir, initLogger, log, resetLogger } from './logger';
 export { getLogDir, initLogger, log, resetLogger } from './logger';

+ 145 - 1
src/utils/task.test.ts

@@ -1,5 +1,10 @@
 import { describe, expect, test } from 'bun:test';
 import { describe, expect, test } from 'bun:test';
-import { parseTaskIdFromTaskOutput } from './task';
+import {
+  parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskResultFromOutput,
+  parseTaskStatusOutput,
+} from './task';
 
 
 describe('parseTaskIdFromTaskOutput', () => {
 describe('parseTaskIdFromTaskOutput', () => {
   test('parses task_id line from successful task tool output', () => {
   test('parses task_id line from successful task tool output', () => {
@@ -22,3 +27,142 @@ describe('parseTaskIdFromTaskOutput', () => {
     expect(parseTaskIdFromTaskOutput(output)).toBeUndefined();
     expect(parseTaskIdFromTaskOutput(output)).toBeUndefined();
   });
   });
 });
 });
+
+describe('parseTaskLaunchOutput', () => {
+  test('parses background task launch output only when state is running', () => {
+    const output = [
+      'task_id: ses_123 (for polling this task with task_status)',
+      'state: running',
+      '',
+      '<task_result>',
+      'Background task started.',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseTaskLaunchOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'running',
+      result: 'Background task started.',
+    });
+  });
+
+  test('ignores blocking task output without running state', () => {
+    const output = [
+      'task_id: ses_123 (for resuming to continue this task if needed)',
+      '',
+      '<task_result>',
+      'completed result',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseTaskLaunchOutput(output)).toBeUndefined();
+  });
+
+  test('ignores state lines inside task result body', () => {
+    const output = [
+      'task_id: ses_123 (for resuming to continue this task if needed)',
+      '',
+      '<task_result>',
+      'state: running',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseTaskLaunchOutput(output)).toBeUndefined();
+  });
+});
+
+describe('parseTaskStatusOutput', () => {
+  test('parses completed status output with task result', () => {
+    const output = [
+      'task_id: ses_123',
+      'state: completed',
+      '',
+      '<task_result>',
+      'done',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseTaskStatusOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'completed',
+      timedOut: false,
+      result: 'done',
+    });
+  });
+
+  test('parses error status output with task_error', () => {
+    const output = [
+      'task_id: ses_123',
+      'state: error',
+      '',
+      '<task_error>',
+      'failed hard',
+      '</task_error>',
+    ].join('\n');
+
+    expect(parseTaskStatusOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'error',
+      timedOut: false,
+      result: 'failed hard',
+    });
+  });
+
+  test('parses cancelled status output with task_error', () => {
+    const output = [
+      'task_id: ses_123',
+      'state: cancelled',
+      '',
+      '<task_error>',
+      'cancelled by user',
+      '</task_error>',
+    ].join('\n');
+
+    expect(parseTaskStatusOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'cancelled',
+      timedOut: false,
+      result: 'cancelled by user',
+    });
+  });
+
+  test('keeps timeout as running with timedOut overlay', () => {
+    const output = [
+      'task_id: ses_123',
+      'state: running',
+      '',
+      '<task_result>',
+      'Timed out after 120000ms while waiting for task completion.',
+      '</task_result>',
+    ].join('\n');
+
+    expect(parseTaskStatusOutput(output)).toEqual({
+      taskID: 'ses_123',
+      state: 'running',
+      timedOut: true,
+      result: 'Timed out after 120000ms while waiting for task completion.',
+    });
+  });
+
+  test('returns undefined when state is absent', () => {
+    expect(parseTaskStatusOutput('task_id: ses_123')).toBeUndefined();
+  });
+});
+
+describe('parseTaskResultFromOutput', () => {
+  test('extracts trimmed task result block', () => {
+    expect(
+      parseTaskResultFromOutput(
+        ['<task_result>', '  hello  ', '</task_result>'].join('\n'),
+      ),
+    ).toBe('hello');
+  });
+
+  test('extracts task error block', () => {
+    expect(
+      parseTaskResultFromOutput(
+        ['<task_error>', '  broken  ', '</task_error>'].join('\n'),
+      ),
+    ).toBe('broken');
+  });
+});

+ 76 - 0
src/utils/task.ts

@@ -2,6 +2,21 @@
  * Parse Task tool output to recover a session/task ID for resumption.
  * Parse Task tool output to recover a session/task ID for resumption.
  */
  */
 
 
+export type TaskOutputState = 'running' | 'completed' | 'error' | 'cancelled';
+
+export interface TaskLaunchOutput {
+  taskID: string;
+  state: 'running';
+  result?: string;
+}
+
+export interface TaskStatusOutput {
+  taskID: string;
+  state: TaskOutputState;
+  timedOut: boolean;
+  result?: string;
+}
+
 export function parseTaskIdFromTaskOutput(output: string): string | undefined {
 export function parseTaskIdFromTaskOutput(output: string): string | undefined {
   const lines = output.split(/\r?\n/);
   const lines = output.split(/\r?\n/);
 
 
@@ -18,3 +33,64 @@ export function parseTaskIdFromTaskOutput(output: string): string | undefined {
 
 
   return undefined;
   return undefined;
 }
 }
+
+export function parseTaskLaunchOutput(
+  output: string,
+): TaskLaunchOutput | undefined {
+  const taskID = parseTaskIdFromTaskOutput(output);
+  const state = parseTaskStateFromOutput(output);
+
+  if (!taskID || state !== 'running') return undefined;
+
+  return {
+    taskID,
+    state,
+    result: parseTaskResultFromOutput(output),
+  };
+}
+
+export function parseTaskStatusOutput(
+  output: string,
+): TaskStatusOutput | undefined {
+  const taskID = parseTaskIdFromTaskOutput(output);
+  const state = parseTaskStateFromOutput(output);
+
+  if (!taskID || !state) return undefined;
+
+  return {
+    taskID,
+    state,
+    timedOut: state === 'running' && /Timed out after \d+ms/i.test(output),
+    result: parseTaskResultFromOutput(output),
+  };
+}
+
+export function parseTaskStateFromOutput(
+  output: string,
+): TaskOutputState | undefined {
+  for (const line of getTaskHeader(output).split(/\r?\n/)) {
+    const match = /^state:\s*(running|completed|error|cancelled)\s*$/i.exec(
+      line.trim(),
+    );
+
+    if (match) return match[1].toLowerCase() as TaskOutputState;
+  }
+
+  return undefined;
+}
+
+export function parseTaskResultFromOutput(output: string): string | undefined {
+  const match =
+    /<task_(?:result|error)>\s*([\s\S]*?)\s*<\/task_(?:result|error)>/m.exec(
+      output,
+    );
+  const result = match?.[1]?.trim();
+
+  return result || undefined;
+}
+
+function getTaskHeader(output: string): string {
+  const resultIndex = output.search(/<task_(?:result|error)>/);
+  if (resultIndex === -1) return output;
+  return output.slice(0, resultIndex);
+}