فهرست منبع

Implement background subagent scheduler

alvinreal 2 ماه پیش
والد
کامیت
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 |
 |-----|----------------|
 | **[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 |
 | **[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 |

+ 1 - 0
docs/quick-reference.md

@@ -13,6 +13,7 @@
 | Doc | Contents |
 |-----|----------|
 | [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 |
 | [Multiplexer Integration](multiplexer-integration.md) | Real-time pane monitoring, layouts, troubleshooting |
 | [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
 const AGENT_DESCRIPTIONS: Record<string, string> = {
   explorer: `@explorer
+- Lane: Codebase discovery and reconnaissance
 - Role: Parallel search specialist for discovering unknowns across the codebase
 - Permissions: Read files
 - 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`,
 
   librarian: `@librarian
+- Lane: External knowledge and library research
 - Role: Authoritative source for current library docs and API references
 - Permissions: External docs/search MCPs; no file edits
 - 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
 - **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
-- **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
+- Lane: Architecture, risk, debugging strategy, and review
 - Role: Strategic advisor for high-stakes decisions and persistent problems, code reviewer
 - Permissions: Read files
 - 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
 - **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
-- **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
+- Lane: User-facing UI/UX design, polish, and review
 - Role: UI/UX specialist for intentional, polished experiences
 - Permissions: Read/write files
 - Stats: 10x better UI/UX than orchestrator
 - 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
 - **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
+- Lane: Bounded implementation and test execution
 - Role: Fast execution specialist for well-defined tasks, which empowers orchestrator with parallel, speedy executions
 - Permissions: Read/write files
 - Stats: 2x faster code edits, 1/2 cost of orchestrator, 0.8x quality of orchestrator
 - 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.
 - **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
+- 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.
 - Permissions: Read files
 - 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.
 - **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.
-- **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
+- Lane: Visual/media analysis isolated from orchestrator context
 - Role: Visual analysis specialist for images, PDFs, and diagrams
 - Permissions: Read files
 - 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
 - **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)
-- **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."`,
 };
 
@@ -138,7 +145,9 @@ export function buildOrchestratorPrompt(disabledAgents?: Set<string>): string {
   ).join('\n');
 
   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>
 
 <Agents>
@@ -156,22 +165,31 @@ Parse request: explicit requirements + implicit needs.
 Evaluate approach by: quality, speed, cost, reliability.
 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
 **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)
 - Provide context summaries, let specialists read what they need
 - 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}
 
-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
 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
 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,
 work better handled by a named specialist, or cases where the parent must reason
 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
 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
 - 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."
 
 **Good:** "Checking Next.js App Router docs via @librarian..."
-[proceeds with implementation]
+[continues scheduling or integration]
 
 </Communication>
 `;

+ 2 - 2
src/config/constants.ts

@@ -94,8 +94,8 @@ export const DEFAULT_MAX_SUBAGENT_DEPTH = 3;
 
 // Workflow reminders
 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
 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 { BackgroundJobBoard } from '../../utils';
 import { createTaskSessionManagerHook } from './index';
 
 function createHook(options?: {
   shouldManageSession?: (sessionID: string) => boolean;
   readContextMinLines?: number;
   readContextMaxFiles?: number;
+  backgroundJobBoard?: BackgroundJobBoard;
 }) {
   const hook = createTaskSessionManagerHook(
     {
@@ -16,6 +18,7 @@ function createHook(options?: {
       maxSessionsPerAgent: 2,
       readContextMinLines: options?.readContextMinLines,
       readContextMaxFiles: options?.readContextMaxFiles,
+      backgroundJobBoard: options?.backgroundJobBoard,
       shouldManageSession: options?.shouldManageSession ?? (() => true),
     },
   );
@@ -35,6 +38,524 @@ function createMessages(sessionID: string, text = 'user message') {
 }
 
 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 () => {
     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 { AgentName } from '../../config';
 import {
+  BackgroundJobBoard,
+  type BackgroundJobRecord,
   type ContextFile,
   deriveTaskSessionLabel,
   parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskStatusOutput,
   SessionManager,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
@@ -61,6 +65,8 @@ interface ChatMessage {
 
 const RESUMABLE_SESSIONS_START = '<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 {
   return typeof value === 'string' && AGENT_NAME_SET.has(value as AgentName);
@@ -115,6 +121,7 @@ export function createTaskSessionManagerHook(
     maxSessionsPerAgent: number;
     readContextMinLines?: number;
     readContextMaxFiles?: number;
+    backgroundJobBoard?: BackgroundJobBoard;
     shouldManageSession: (sessionID: string) => boolean;
   },
 ) {
@@ -122,10 +129,15 @@ export function createTaskSessionManagerHook(
     readContextMinLines: options.readContextMinLines,
     readContextMaxFiles: options.readContextMaxFiles,
   });
+  const backgroundJobBoard =
+    options.backgroundJobBoard ?? new BackgroundJobBoard();
   const pendingCalls = new Map<string, PendingTaskCall>();
   const pendingCallOrder: string[] = [];
   const contextByTask = new Map<string, Map<string, PendingContextFile>>();
   const pendingManagedTaskIds = new Set<string>();
+  const terminalJobsInjectedByParent = new Map<string, Set<string>>();
+  const processedInjectedCompletions = new Set<string>();
+  const processedInjectedCompletionOrder: string[] = [];
   let anonymousPendingCallId = 0;
 
   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 {
     const firstLine = output.split(/\r?\n/, 1)[0]?.trim().toLowerCase() ?? '';
     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 {
     'tool.execute.before': async (
       input: { tool: string; sessionID?: string; callID?: string },
@@ -315,11 +416,37 @@ export function createTaskSessionManagerHook(
         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;
 
       const pending = takePendingCall(input.callID, input.sessionID);
 
       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);
       if (!taskId) {
         if (
@@ -359,6 +486,23 @@ export function createTaskSessionManagerHook(
       _input: Record<string, never>,
       output: { messages: ChatMessage[] },
     ): 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) {
         const message = output.messages[i];
         if (message.info.role !== 'user') continue;
@@ -370,8 +514,11 @@ export function createTaskSessionManagerHook(
           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(
           (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(RESUMABLE_SESSIONS_START)) return;
 
+        rememberInjectedTerminalJobs(message.info.sessionID);
         textPart.text = [
           textPart.text ?? '',
           '',
           RESUMABLE_SESSIONS_START,
-          reminder,
+          reminders.join('\n\n'),
           RESUMABLE_SESSIONS_END,
         ].join('\n');
         return;
@@ -397,6 +545,8 @@ export function createTaskSessionManagerHook(
         properties?: {
           info?: { id?: string; parentID?: string };
           sessionID?: string;
+          status?: { type?: string };
+          error?: { name?: string };
         };
       };
     }): Promise<void> => {
@@ -412,13 +562,39 @@ export function createTaskSessionManagerHook(
         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;
       const sessionId =
         input.event.properties?.info?.id ?? input.event.properties?.sessionID;
       if (!sessionId) return;
 
       sessionManager.dropTask(sessionId);
+      backgroundJobBoard.drop(sessionId);
       sessionManager.clearParent(sessionId);
+      backgroundJobBoard.clearParent(sessionId);
+      terminalJobsInjectedByParent.delete(sessionId);
       contextByTask.delete(sessionId);
       pendingManagedTaskIds.delete(sessionId);
       pruneContext();

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

@@ -1,5 +1,8 @@
 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 {
   TODO_FINAL_ACTIVE_REMINDER,
@@ -649,6 +652,44 @@ describe('createTodoContinuationHook', () => {
       expect(allMessageText(output)).toContain(TODO_FINAL_ACTIVE_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', () => {
@@ -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 () => {
       const ctx = createMockContext({
         todoResult: {

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

@@ -1,6 +1,7 @@
 import type { PluginInput } from '@opencode-ai/plugin';
 import { tool } from '@opencode-ai/plugin';
 import {
+  type BackgroundJobBoard,
   createInternalAgentTextPart,
   log,
   SLIM_INTERNAL_INITIATOR_MARKER,
@@ -169,6 +170,7 @@ export function createTodoContinuationHook(
     cooldownMs?: number;
     autoEnable?: boolean;
     autoEnableThreshold?: number;
+    backgroundJobBoard?: BackgroundJobBoard;
   },
 ): {
   tool: Record<string, unknown>;
@@ -199,6 +201,7 @@ export function createTodoContinuationHook(
   const cooldownMs = config?.cooldownMs ?? 3000;
   const autoEnable = config?.autoEnable ?? false;
   const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
+  const backgroundJobBoard = config?.backgroundJobBoard;
   const requestSignatureBySession = new Map<string, string>();
 
   const state: ContinuationState = {
@@ -373,8 +376,12 @@ export function createTodoContinuationHook(
       lastUserMessage.signature
     ) {
       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 {
         stripTodoHygieneInstructionFromMessage(lastUserMessage.message);
       }
@@ -427,6 +434,31 @@ export function createTodoContinuationHook(
     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: {
     sessionID: string;
     agent?: string;
@@ -689,7 +721,9 @@ export function createTodoContinuationHook(
           await ctx.client.session.prompt({
             path: { id: sessionID },
             body: {
-              parts: [createInternalAgentTextPart(CONTINUATION_PROMPT)],
+              parts: [
+                createInternalAgentTextPart(continuationPrompt(sessionID)),
+              ],
             },
           });
           state.consecutiveContinuations++;
@@ -856,7 +890,7 @@ export function createTodoContinuationHook(
     if (hasIncompleteTodos) {
       output.parts.push(
         createInternalAgentTextPart(
-          `${CONTINUATION_PROMPT} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
+          `${continuationPrompt(input.sessionID)} [Auto-continue enabled: up to ${maxContinuations} continuations.]`,
         ),
       );
     } else {

+ 6 - 0
src/index.ts

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

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

@@ -415,6 +415,46 @@ describe('MultiplexerSessionManager', () => {
       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 () => {
       const ctx = createMockContext();
       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 './background-job-board';
 export * from './env';
 export * from './internal-initiator';
 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 { parseTaskIdFromTaskOutput } from './task';
+import {
+  parseTaskIdFromTaskOutput,
+  parseTaskLaunchOutput,
+  parseTaskResultFromOutput,
+  parseTaskStatusOutput,
+} from './task';
 
 describe('parseTaskIdFromTaskOutput', () => {
   test('parses task_id line from successful task tool output', () => {
@@ -22,3 +27,142 @@ describe('parseTaskIdFromTaskOutput', () => {
     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.
  */
 
+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 {
   const lines = output.split(/\r?\n/);
 
@@ -18,3 +33,64 @@ export function parseTaskIdFromTaskOutput(output: string): string | 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);
+}