소스 검색

Refactor background job session reuse

Alvin Unreal 2 달 전
부모
커밋
d6fa618c73

+ 5 - 5
codemap.md

@@ -7,7 +7,7 @@
 - define orchestrator and specialist agents,
 - load layered plugin configuration and per-agent permissions,
 - expose additional tools and MCP integrations,
-- manage delegated/resumable session orchestration and terminal multiplexer visualization,
+- manage background job-board orchestration and terminal multiplexer visualization,
 - inject workflow-enforcement hooks plus runtime command handlers,
 - ship install-time skills and a bootstrap CLI.
 
@@ -18,7 +18,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 | Path | Role |
 |---|---|
 | `package.json` | Package manifest, dependency graph, release scripts, published file list. |
-| `src/index.ts` | Main plugin bootstrap: wires agents, tools, MCPs, hooks, council/session managers, multiplexer session mirroring, interview/preset managers, task-session tracking, and config merge behavior. |
+| `src/index.ts` | Main plugin bootstrap: wires agents, tools, MCPs, hooks, council managers, shared background job board, multiplexer session mirroring, interview/preset managers, task-session tracking, and config merge behavior. |
 | `src/cli/index.ts` | CLI entrypoint for installation/bootstrap workflows. |
 | `src/config/schema.ts` | Source-of-truth runtime config schema used by validation and schema generation. |
 | `scripts/generate-schema.ts` | Generates `oh-my-opencode-slim.schema.json` from the Zod config schema. |
@@ -74,8 +74,8 @@ This codemap intentionally covers the plugin repository itself and excludes the
    - Hooks can transform prompts/messages, normalize system message arrays, repair tool failures, or intercept runtime commands before/after execution.
 
 3. **Delegated execution**
-   - OpenCode child sessions are created by delegation/council flows and tracked by plugin utilities.
-   - `src/hooks/task-session-manager/` remembers reusable child sessions and injects short aliases into the orchestrator prompt.
+   - Native OpenCode background tasks are parsed from `task`/`task_status` output and tracked in the shared background job board.
+   - `src/hooks/task-session-manager/` updates job-board state, resolves short aliases, and injects background/reusable job context into the orchestrator prompt.
    - `src/multiplexer/` optionally mirrors those sessions into tmux/zellij panes.
    - Results flow back into the parent session through notifications/output polling.
 
@@ -92,7 +92,7 @@ This codemap intentionally covers the plugin repository itself and excludes the
 - Session/delegation utilities depend on `src/multiplexer/` and cooperate with helpers in `src/utils/` for depth tracking, result extraction, task output parsing, and alias state.
 - `src/tools/council.ts` delegates into `src/council/`.
 - `src/tools/preset-manager.ts` hooks command execution and updates runtime agent models from configured presets.
-- `src/hooks/task-session-manager/` depends on `src/utils/session-manager.ts` and `src/utils/task.ts` to support child-session reuse.
+- `src/hooks/task-session-manager/` depends on `src/utils/background-job-board.ts` and `src/utils/task.ts` to support background task tracking, task output parsing, and safe alias reuse.
 - `src/hooks/filter-available-skills/` and agent permission logic rely on shared skill names from the CLI/config layer.
 - `src/interview/` hooks into plugin command/event surfaces exposed by `src/index.ts`.
 

+ 7 - 7
docs/configuration.md

@@ -119,9 +119,9 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `tmux.enabled` | boolean | `false` | Legacy alias for `multiplexer.type = "tmux"` |
 | `tmux.layout` | string | `"main-vertical"` | Legacy alias for `multiplexer.layout` |
 | `tmux.main_pane_size` | number | `60` | Legacy alias for `multiplexer.main_pane_size` |
-| `sessionManager.maxSessionsPerAgent` | integer | `2` | Maximum remembered resumable child sessions per specialist type in the current orchestrator session (1–10). See [Session Management](session-management.md) |
-| `sessionManager.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in resumable-session context (0–1000) |
-| `sessionManager.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per remembered child session (0–50) |
+| `backgroundJobs.maxSessionsPerAgent` | integer | `2` | Maximum completed/reconciled reusable child sessions per specialist type in the current orchestrator session (1–10). See [Session Management](session-management.md) |
+| `backgroundJobs.readContextMinLines` | integer | `10` | Minimum number of lines read from a file before it appears in reusable background-job context (0–1000) |
+| `backgroundJobs.readContextMaxFiles` | integer | `8` | Maximum number of recent read-context files shown per reusable child session (0–50) |
 | `disabled_mcps` | string[] | `[]` | MCP server IDs to disable globally |
 | `fallback.enabled` | boolean | `false` | Enable model failover on timeout/error |
 | `fallback.timeoutMs` | number | `15000` | Time before aborting and trying next model |
@@ -238,11 +238,11 @@ To override a GIF, use either a bundled filename or an absolute path:
 }
 ```
 
-### Session Management
+### Background Job Management
 
-Session management is enabled by default and does not need to be present in the
-starter config. Add `sessionManager` only if you want to tune how many resumable
-child-agent sessions are remembered or how much read context is shown. See
+Background job management is enabled by default and does not need to be present
+in the starter config. Add `backgroundJobs` only if you want to tune how many
+completed/reconciled child-agent sessions are reusable or how much read context is shown. See
 [Session Management](session-management.md) for the concept, defaults, and
 examples.
 

+ 29 - 22
docs/session-management.md

@@ -1,8 +1,8 @@
 # Session Management
 
-Session management lets the orchestrator keep track of recent delegated child
-sessions so follow-up work can continue in the right specialist context instead
-of starting from scratch every time.
+Background job management lets the orchestrator track native background tasks,
+poll active work, and reuse completed/reconciled child sessions when follow-up
+work matches the same specialist context.
 
 It is enabled by default. You do not need to add anything to your config unless
 you want to change how many sessions are remembered.
@@ -27,7 +27,7 @@ management, the orchestrator can reuse recent child sessions when it makes sense
 
 ## How It Feels in Practice
 
-When a child task runs, the plugin remembers it under a short alias such as:
+When a child task runs, the plugin tracks it under a short alias such as:
 
 ```text
 exp-1
@@ -38,10 +38,16 @@ fix-2
 The orchestrator sees a compact reminder in its system context, for example:
 
 ```text
-### Resumable Sessions
-- explorer: exp-1 Search routing files
-  Context read by exp-1: src/router.ts (120 lines), src/routes/api.ts (74 lines)
-- oracle: ora-1 Review auth architecture
+### Background Job Board
+SENTINEL: background-job-board-v2
+
+#### Active / Unreconciled
+- exp-1 / child-1 / explorer / running
+  Objective: Search routing files
+
+#### Reusable Sessions
+- ora-1 / child-2 / oracle / completed, reconciled
+  Objective: Review auth architecture
 ```
 
 When a child session reads files through OpenCode's `read` tool, the reminder can
@@ -52,9 +58,9 @@ To keep the prompt small, read context only shows files where at least 10 lines
 were read, includes line counts, and caps each remembered session to the most
 recent 8 files by default. Both thresholds are configurable.
 
-On a related follow-up, the orchestrator can reuse that session instead of
-launching a fresh one. If the remembered child session no longer exists, the
-plugin drops the stale entry and falls back to a new session automatically.
+On a related follow-up, the orchestrator can reuse a completed/reconciled session
+instead of launching a fresh one. Running jobs must be polled with `task_status`;
+terminal jobs must be reconciled before dependent work or a final response.
 
 ---
 
@@ -78,7 +84,8 @@ long-lived global state.
 
 ## Default Behavior
 
-By default, the plugin remembers **2 recent child sessions per specialist type**.
+By default, the plugin keeps **2 reusable completed child sessions per specialist
+type** while active/unreconciled jobs remain visible until resolved.
 
 That means the generated starter config can stay clean:
 
@@ -95,18 +102,18 @@ That means the generated starter config can stay clean:
 }
 ```
 
-Session management still works because the runtime falls back to the built-in
+Background job management still works because the runtime falls back to the built-in
 default.
 
 ---
 
 ## Configuration
 
-Only add `sessionManager` if you want to change the default limits:
+Only add `backgroundJobs` if you want to change the default limits:
 
 ```jsonc
 {
-  "sessionManager": {
+  "backgroundJobs": {
     "maxSessionsPerAgent": 2,
     "readContextMinLines": 10,
     "readContextMaxFiles": 8
@@ -114,22 +121,22 @@ Only add `sessionManager` if you want to change the default limits:
 }
 ```
 
-### `sessionManager.maxSessionsPerAgent`
+### `backgroundJobs.maxSessionsPerAgent`
 
 | Type | Default | Range | Meaning |
 |------|---------|-------|---------|
-| integer | `2` | `1`–`10` | Number of recent resumable child sessions remembered per specialist type in the current parent session |
+| integer | `2` | `1`–`10` | Number of completed/reconciled reusable child sessions retained per specialist type in the current parent session |
 
-### `sessionManager.readContextMinLines`
+### `backgroundJobs.readContextMinLines`
 
 | Type | Default | Range | Meaning |
 |------|---------|-------|---------|
-| integer | `10` | `0`–`1000` | Minimum number of lines read from a file before it appears in resumable-session context |
+| integer | `10` | `0`–`1000` | Minimum number of lines read from a file before it appears in reusable job context |
 
 Set this lower if you want short config files to appear. Set it higher to keep
 the prompt focused on substantial file reads.
 
-### `sessionManager.readContextMaxFiles`
+### `backgroundJobs.readContextMaxFiles`
 
 | Type | Default | Range | Meaning |
 |------|---------|-------|---------|
@@ -158,7 +165,7 @@ Example with a smaller memory window:
 
 ```jsonc
 {
-  "sessionManager": {
+  "backgroundJobs": {
     "maxSessionsPerAgent": 1,
     "readContextMaxFiles": 4
   }
@@ -169,7 +176,7 @@ Example with a larger memory window:
 
 ```jsonc
 {
-  "sessionManager": {
+  "backgroundJobs": {
     "maxSessionsPerAgent": 4,
     "readContextMinLines": 5
   }

+ 21 - 9
docs/v2_core.md

@@ -310,14 +310,25 @@ Primary files:
 
 - `src/hooks/task-session-manager/index.ts`
 - `src/utils/task.ts`
-- `src/utils/session-manager.ts`
+- `src/utils/background-job-board.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.
+- `src/index.ts` creates one shared `BackgroundJobBoard` using
+  `backgroundJobs` caps/context config and passes it to task-session-manager,
+  todo-continuation, cancel-task, and multiplexer integration.
+- `tool.execute.before(task)` validates `subagent_type`, strips stale/invalid
+  `task_id` aliases when they cannot safely resolve, and only resolves reusable
+  aliases for matching completed/reconciled jobs.
+- `tool.execute.before(task_status)` resolves job-board aliases for polling
+  running or terminal tasks.
+- `tool.execute.after(task)` parses native launch output and records running
+  jobs in the shared board; it does not treat launch as completion.
+- `tool.execute.after(task_status)` and synthetic completion messages parse
+  status output into running/terminal job-board state.
+- Prompt injection is owned by the job board: running and terminal unreconciled
+  jobs appear under `### Background Job Board`; completed/reconciled jobs appear
+  only in the reusable section.
 
 V2 behavior:
 
@@ -336,7 +347,7 @@ V2 behavior:
    parseTaskStatusOutput(output) → { taskID, state, result? }
    ```
 
-2. Store background job records in a shared scheduler/job-board module scoped by
+2. Store background job records in `src/utils/background-job-board.ts`, scoped by
    parent orchestrator session.
 
 3. Update `tool.execute.after` for `task`:
@@ -353,13 +364,13 @@ V2 behavior:
 
 5. Update system-context injection:
 
-   - replace or augment `### Resumable Sessions` with `### Background Job Board`,
+   - inject the unified `### 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.
+   reconciled sessions should enter the reusable section.
 
 ---
 
@@ -614,7 +625,8 @@ Start here:
    - phase reminder rewrite.
 
 7. `src/multiplexer/session-manager.test.ts`
-   - add V2 lifecycle tests once behavior is understood.
+   - keep multiplexer lifecycle coverage aligned with background-job-board-owned
+     task state.
 
 ---
 

+ 1 - 1
oh-my-opencode-slim.schema.json

@@ -486,7 +486,7 @@
         }
       }
     },
-    "sessionManager": {
+    "backgroundJobs": {
       "type": "object",
       "properties": {
         "maxSessionsPerAgent": {

+ 2 - 2
src/agents/index.test.ts

@@ -761,9 +761,9 @@ describe('PluginConfigSchema custom-agent-only prompt fields', () => {
     expect(result.success).toBe(true);
   });
 
-  test('accepts sessionManager config', () => {
+  test('accepts backgroundJobs config', () => {
     const result = PluginConfigSchema.safeParse({
-      sessionManager: {
+      backgroundJobs: {
         maxSessionsPerAgent: 2,
         readContextMinLines: 10,
         readContextMaxFiles: 8,

+ 5 - 5
src/codemap.md

@@ -4,13 +4,13 @@
 
 - `src/index.ts` delivers the plugin assembly layer: it loads configuration, resolves agent definitions, precomputes runtime model fallback chains, wires multiplexer/session orchestration, registers tools/MCPs/hooks, and returns the OpenCode plugin registration object.
 - `config/`, `agents/`, `tools/`, `multiplexer/`, `hooks/`, and `utils/` contain the reusable building blocks (loader/schema/constants, agent factories/permission helpers, tool factories, session mirroring managers, hook implementations, and runtime utilities) that power that entry point.
-- `hooks/task-session-manager` is now part of the core plugin flow to support resumable child task sessions with concise aliases and reminder injection for orchestrator calls.
+- `hooks/task-session-manager` is now part of the core plugin flow to support background job-board tracking, concise aliases, and reminder injection for orchestrator calls.
 - `cli/` remains the installer surface (argument parsing, interactive prompts, config edits, skill/provider installation).
 
 ## Design
 
 - Agent creation follows explicit factories (`agents/index.ts`, per-agent creators under `agents/`) with override/permission helpers (`config/schema.ts`, `cli/skills.ts`, `config/agent-mcps.ts`) so defaults live in `config/constants.ts`, prompts can be swapped via `config/loader.ts`, and variant labels propagate through `utils/agent-variant.ts`.
-- Session orchestration combines `SubagentDepthTracker`, `MultiplexerSessionManager`, `CouncilManager`, and `ForegroundFallbackManager`; these coordinate subagent depth limits, pane lifecycle, council session creation, and foreground model failover.
+- Session orchestration combines `SubagentDepthTracker`, `BackgroundJobBoard`, `MultiplexerSessionManager`, `CouncilManager`, and `ForegroundFallbackManager`; these coordinate subagent depth limits, background task state, pane lifecycle, council session creation, and foreground model failover.
 - Hook composition is centralized in `src/index.ts`: lifecycle event handlers and tool transform handlers fan out to specialized hooks, then some hooks post-process system messages in-place for provider compatibility.
 - Supplemental tools bundle AST-grep search/replace, council orchestration, and web fetching behind the OpenCode `tool` interface and are mounted in `index.ts` alongside hooks and MCP helpers.
 
@@ -20,7 +20,7 @@
   - `loadPluginConfig` builds effective config from user/project presets.
   - `createAgents` + `getAgentConfigs` construct final agent registry and resolved prompts.
   - Runtime model chains are built from configured arrays plus fallback chains.
-  - `SubagentDepthTracker`, `MultiplexerSessionManager`, `CouncilManager`, `ForegroundFallbackManager`, and hook factories are initialized before registration.
+  - `SubagentDepthTracker`, shared `BackgroundJobBoard`, `MultiplexerSessionManager`, `CouncilManager`, `ForegroundFallbackManager`, and hook factories are initialized before registration.
 - Plugin registration: `index.ts` merges/overlays agent configs into OpenCode's config, registers tools (`council`, `webfetch`, `ast_grep_*`, todo tools), MCPs (`createBuiltinMcps`), and all hook handlers (`event`, `tool.execute.before/after`, `experimental.chat.system/messages.transform`, `command.execute.before`, etc.).
 - Runtime event flow (`event`): updates depth tree, multiplexer pane state, auto-update checks, interview/preset state, and task-session cleanup for deleted sessions.
 - `experimental.chat.system.transform` pipeline:
@@ -35,7 +35,7 @@
 - Connects directly to `@opencode-ai/plugin`: returns the plugin object, mutates runtime agent configuration, handles event hooks, and routes RPC via `ctx.client`/`ctx.client.session`.
 - Integrates with host multiplexer backends through `src/multiplexer`, and with session lifecycle constraints through `SubagentDepthTracker`.
 - Hook integration points now include:
-  - `createTaskSessionManagerHook` for resumable Task sessions,
+  - `createTaskSessionManagerHook` for V2 background job board state,
   - `createTodoContinuationHook`, `createPhaseReminderHook`, `createFilterAvailableSkillsHook`, and `createPostFileToolNudgeHook` for chat/tool behavior,
   - `createInterviewManager` / `createPresetManager` command handlers.
-- Utility integration is visible at runtime through `utils/session-manager.ts` + `utils/task.ts` (task resume support), `utils/system-collapse.ts` (system message normalization), and legacy utility support (`logger`, `env`, `polling`, `session`, etc.).
+- Utility integration is visible at runtime through `utils/background-job-board.ts` + `utils/task.ts` (background task state, prompt formatting, and task output parsing), `utils/system-collapse.ts` (system message normalization), and legacy utility support (`logger`, `env`, `polling`, `session`, etc.).

+ 1 - 1
src/config/codemap.md

@@ -30,7 +30,7 @@ resolution, and helper APIs used by agents, council, and runtime subsystems.
 3. Validate with schema. Invalid/malformed files are warned and ignored by
    returning `null` for that file.
 4. Merge user+project configs where project takes precedence:
-   nested merges for `agents`, `tmux`, `multiplexer`, `interview`, `sessionManager`,
+   nested merges for `agents`, `tmux`, `multiplexer`, `interview`, `backgroundJobs`,
    `fallback`, `council`.
    top-level arrays/values are overridden.
 5. If `tmux` is enabled and no explicit `multiplexer` is configured,

+ 1 - 1
src/config/loader.ts

@@ -200,7 +200,7 @@ export function mergePluginConfigs(
     tmux: deepMerge(base.tmux, override.tmux),
     multiplexer: deepMerge(base.multiplexer, override.multiplexer),
     interview: deepMerge(base.interview, override.interview),
-    sessionManager: deepMerge(base.sessionManager, override.sessionManager),
+    backgroundJobs: deepMerge(base.backgroundJobs, override.backgroundJobs),
     divoom: deepMerge(base.divoom, override.divoom),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),

+ 3 - 3
src/config/schema.ts

@@ -181,13 +181,13 @@ export const InterviewConfigSchema = z.object({
 
 export type InterviewConfig = z.infer<typeof InterviewConfigSchema>;
 
-export const SessionManagerConfigSchema = z.object({
+export const BackgroundJobsConfigSchema = z.object({
   maxSessionsPerAgent: z.number().int().min(1).max(10).default(2),
   readContextMinLines: z.number().int().min(0).max(1000).default(10),
   readContextMaxFiles: z.number().int().min(0).max(50).default(8),
 });
 
-export type SessionManagerConfig = z.infer<typeof SessionManagerConfigSchema>;
+export type BackgroundJobsConfig = z.infer<typeof BackgroundJobsConfigSchema>;
 
 export const DivoomConfigSchema = z.object({
   enabled: z.boolean().default(false),
@@ -332,7 +332,7 @@ export const PluginConfigSchema = z
     tmux: TmuxConfigSchema.optional(),
     websearch: WebsearchConfigSchema.optional(),
     interview: InterviewConfigSchema.optional(),
-    sessionManager: SessionManagerConfigSchema.optional(),
+    backgroundJobs: BackgroundJobsConfigSchema.optional(),
     divoom: DivoomConfigSchema.optional(),
     todoContinuation: TodoContinuationConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),

+ 1 - 1
src/hooks/codemap.md

@@ -68,7 +68,7 @@ and managers for all hook-based runtime behaviors used by
   system transform, command interception, tool-after, and events. It owns
   auto-injection state, cooldown, suppress windows, and orchestration session
   tracking.
-- `createTaskSessionManagerHook` tracks task sessions for resumability: generates
+- `createTaskSessionManagerHook` tracks V2 background jobs and reusable completed sessions: generates
   user-facing aliases, resolves alias/task IDs before delegation, remembers fresh
   task IDs after completion, and drops stale entries on missing-session failure,
   renamed task IDs, or session deletion.

+ 30 - 42
src/hooks/task-session-manager/codemap.md

@@ -2,59 +2,47 @@
 
 ## Responsibility
 
-Provides resumable-task state for `task` tool calls so orchestrator users can
-resume work in a parent session by using short aliases (`exp-1`, `ora-2`) instead
-of raw child session IDs.
+Provides V2 background job-board state for `task`/`task_status` calls so the
+orchestrator can poll active jobs and reuse only completed, reconciled child
+sessions by short aliases (`exp-1`, `ora-2`).
 
 ## Design
 
 - `createTaskSessionManagerHook(ctx, options)` returns handlers for:
   - `tool.execute.before`
   - `tool.execute.after`
-  - `experimental.chat.system.transform`
+  - `experimental.chat.messages.transform`
   - `event`
-- Internally uses `SessionManager` from `src/utils/session-manager.ts` to store
-  remembered task sessions with bounded per-agent history.
+- Uses `BackgroundJobBoard` from `src/utils/background-job-board.ts` as the
+  single source of truth for active jobs, terminal unreconciled jobs, reusable
+  completed sessions, aliases, read context, and LRU caps.
 - Task labels are derived from `description`/`prompt` via
-  `deriveTaskSessionLabel` and converted to compact aliases by `SessionManager`.
-- In-flight calls are tracked by `callID` in a capped ordered map (`MAX_PENDING_TASK_CALLS`)
-  to rewrite inputs and correlate outputs safely.
-- Session governance is feature-gated by `shouldManageSession(sessionID)`, allowing
-  the hook to run only for orchestrator-managed sessions.
+  `deriveTaskSessionLabel` and stored on job-board records.
+- In-flight calls are tracked by `callID` in a capped ordered map
+  (`MAX_PENDING_TASK_CALLS`) to correlate launch output safely.
 
 ## Flow
 
-1. `tool.execute.before` receives a `task` call.
-2. If `subagent_type` is a recognized agent, it derives a short label.
-3. When `task_id` is provided, it attempts resolution against remembered aliases
-   for the current parent session/agent.
-4. On success, `args.task_id` is rewritten to the real task ID; on miss it is
-   removed to force fresh task creation.
-5. The call metadata is stored in the pending-call map to correlate the
-   subsequent post-tool event.
-6. `tool.execute.after` reads the output task ID from `task` output text.
-7. On first successful parse, it `remember()`s the task entry and associates it
-   with the alias map.
-8. If this call was a resume attempt, and the returned ID changed, the stale
-   predecessor alias is dropped.
-9. If resume returns an error like `[ERROR] Session not found`/`Session no
-   session`, the predecessor alias is dropped so future commands fall back to
-   fresh execution.
-10. `experimental.chat.system.transform` injects a rendered block from
-    `SessionManager.formatForPrompt` under `### Resumable Sessions`.
-11. On `session.deleted`, the hook clears all task state for that parent session
-    and removes any pending task call records for that parent.
+1. `tool.execute.before` receives `task` or `task_status` calls.
+2. `task_status.task_id` aliases resolve against any parent-scoped job; unknown
+   raw IDs are left unchanged.
+3. `task.task_id` aliases resolve only to completed/reconciled jobs for the same
+   specialist; misses remove `task_id` to force fresh task creation.
+4. `tool.execute.after` registers launches and status transitions from native V2
+   output; bare task IDs without state do not create reusable jobs.
+5. Read context from child sessions is attached to board records with line-count
+   and file caps.
+6. `experimental.chat.messages.transform` injects one `### Background Job Board`
+   section with Active / Unreconciled and Reusable Sessions subsections.
+7. Parent idle events reconcile terminal jobs only after they have been injected
+   into the prompt.
+8. `session.deleted` drops a child job or clears all parent jobs and pending call
+   records.
 
 ## Integration
 
-- Wired in `src/index.ts`:
-  - invoked in `tool.execute.before`
-  - invoked in `tool.execute.after`
-  - injected into `experimental.chat.system.transform`
-  - cleaned up in `event` on `session.deleted`
-- Exposes no side effects outside hook handling and `SessionManager`.
-- Depends on:
-  - `SessionManager` and `deriveTaskSessionLabel` (from `src/utils/session-manager.ts`)
-  - `parseTaskIdFromTaskOutput` (from `src/utils/task.ts`)
-  - plugin configuration (`maxSessionsPerAgent`) and runtime session filtering from
-    `src/index.ts` (`shouldManageSession`).
+- Wired in `src/index.ts` for before/after tool hooks, message transforms, and
+  lifecycle events.
+- Depends on `BackgroundJobBoard`, task-output parsing utilities, plugin
+  configuration (`backgroundJobs` caps), and runtime session filtering from
+  `src/index.ts` (`shouldManageSession`).

+ 280 - 702
src/hooks/task-session-manager/index.test.ts

@@ -819,7 +819,9 @@ describe('task-session-manager hook', () => {
 
     const nextMessages = createMessages('parent-1', 'continue again');
     await hook['experimental.chat.messages.transform']({}, nextMessages);
-    expect(nextMessages.messages[0].parts[0].text).toBe('continue again');
+    expect(nextMessages.messages[0].parts[0].text).toContain(
+      'Reusable Sessions',
+    );
   });
 
   test('does not reconcile terminal jobs before they are injected into a prompt', async () => {
@@ -884,711 +886,348 @@ describe('task-session-manager hook', () => {
     });
   });
 
-  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,
-    );
+  test('completed reconciled job appears reusable and resumes via task', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
 
-    expect(next.args.task_id).toBeUndefined();
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map config schema',
+    });
+    board.updateStatus({
+      taskID: 'child-1',
+      state: 'completed',
+      resultSummary: 'schema mapped',
+    });
 
-    const messages = createMessages('parent-1', 'do something');
+    const messages = createMessages('parent-1', 'continue');
     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();
-
-    await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-1',
-      },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'config schema',
-          prompt: 'inspect config schema',
-        },
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
       },
-    );
+    });
 
-    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 nextMessages = createMessages('parent-1', 'reuse');
+    await hook['experimental.chat.messages.transform']({}, nextMessages);
+    expect(nextMessages.messages[0].parts[0].text).toContain(
+      '#### Reusable Sessions',
     );
-
-    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('<resumable_sessions>');
-    expect(userMessage.parts[0].text).toContain('### Resumable Sessions');
-    expect(userMessage.parts[0].text).toContain(
-      'explorer: exp-1 config schema',
+    expect(nextMessages.messages[0].parts[0].text).toContain(
+      'exp-1 / child-1 / explorer / completed, reconciled',
     );
-    expect(userMessage.parts[0].text).toContain('</resumable_sessions>');
-  });
-
-  test('does not expose a system transform for resumable sessions', async () => {
-    const { hook } = createHook();
-    expect('experimental.chat.system.transform' in hook).toBe(false);
-  });
-
-  test('resolves remembered aliases to real task ids before execution', async () => {
-    const { hook } = createHook();
-
-    await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-1',
-      },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'config schema',
-          prompt: 'inspect config schema',
-        },
-      },
+    expect(nextMessages.messages[0].parts[0].text).not.toContain(
+      ['<resumable', '_sessions>'].join(''),
     );
-    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)',
-      },
+    expect(nextMessages.messages[0].parts[0].text).not.toContain(
+      ['### Resumable', 'Sessions'].join(' '),
     );
 
-    const next = {
+    const resume = {
       args: {
         subagent_type: 'explorer',
-        description: 'continue schema work',
+        description: 'continue config schema',
         task_id: 'exp-1',
       },
     };
     await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      next,
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume-1' },
+      resume,
     );
-
-    expect(next.args.task_id).toBe('child-1');
+    expect(resume.args.task_id).toBe('child-1');
   });
 
-  test('tracks files read by child sessions in resumable message context', async () => {
-    const { hook } = createHook();
+  test('unreconciled or failed jobs do not resolve as reusable task sessions', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
 
-    await hook.event({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
-      },
+    board.registerLaunch({
+      taskID: 'done-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'review plan',
+    });
+    board.updateStatus({ taskID: 'done-1', state: 'completed' });
+    board.registerLaunch({
+      taskID: 'err-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'bad review',
     });
+    board.updateStatus({ taskID: 'err-1', state: 'error' });
+    board.markReconciled('err-1');
 
-    await hook['tool.execute.after'](
-      {
-        tool: 'read',
-        sessionID: 'child-1',
-        callID: 'read-1',
-      },
-      {
-        output: [
-          '<path>/tmp/src/index.ts</path>',
-          '<type>file</type>',
-          '<content>',
-          ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
-          '</content>',
-        ].join('\n'),
-        metadata: {
-          loaded: ['/tmp/AGENTS.md'],
-        },
-      },
+    const unreconciled = {
+      args: { subagent_type: 'oracle', task_id: 'ora-1' },
+    };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      unreconciled,
     );
+    expect(unreconciled.args.task_id).toBeUndefined();
 
+    const failed = { args: { subagent_type: 'oracle', task_id: 'ora-2' } };
     await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-1',
-      },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'session files',
-        },
-      },
-    );
-    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)',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-2' },
+      failed,
     );
+    expect(failed.args.task_id).toBeUndefined();
 
-    const messages = createMessages('parent-1', 'do something');
+    const messages = createMessages('parent-1', 'continue');
     await hook['experimental.chat.messages.transform']({}, messages);
-
-    const userMessage = messages.messages[0];
-    expect(userMessage.parts[0].text).toContain('exp-1 session files');
-    expect(userMessage.parts[0].text).toContain(
-      'Context read by exp-1: src/index.ts (12 lines)',
+    expect(messages.messages[0].parts[0].text).not.toContain(
+      'err-1 / oracle / completed, reconciled',
     );
   });
 
-  test('accumulates multiple reads and hides tiny read context', async () => {
-    const { hook } = createHook();
-
-    await hook.event({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
-      },
+  test('running alias is polled through task_status but not resumed by task', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
     });
 
-    await hook['tool.execute.after'](
-      { tool: 'read', sessionID: 'child-1', callID: 'read-1' },
-      {
-        output: [
-          '<path>/tmp/src/small.ts</path>',
-          '<content>',
-          ...Array.from({ length: 4 }, (_, index) => `${index + 1}: line`),
-          '</content>',
-        ].join('\n'),
-      },
-    );
-    await hook['tool.execute.after'](
-      { tool: 'read', sessionID: 'child-1', callID: 'read-2' },
-      {
-        output: [
-          '<path>/tmp/src/large.ts</path>',
-          '<content>',
-          ...Array.from({ length: 7 }, (_, index) => `${index + 1}: line`),
-          '</content>',
-        ].join('\n'),
-      },
-    );
-    await hook['tool.execute.after'](
-      { tool: 'read', sessionID: 'child-1', callID: 'read-3' },
-      {
-        output: [
-          '<path>/tmp/src/large.ts</path>',
-          '<content>',
-          ...Array.from({ length: 5 }, (_, index) => `${index + 8}: line`),
-          '</content>',
-        ].join('\n'),
-      },
-    );
-
+    const resume = { args: { subagent_type: 'explorer', task_id: 'exp-1' } };
     await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
-      { args: { subagent_type: 'explorer', description: 'line counts' } },
-    );
-    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)',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+      resume,
     );
+    expect(resume.args.task_id).toBeUndefined();
 
-    const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
-
-    const prompt = messages.messages[0].parts[0].text;
-    expect(prompt).not.toContain('small.ts');
-    expect(prompt).toContain('src/large.ts (12 lines)');
-  });
-
-  test('counts overlapping repeated reads once per unique line', async () => {
-    const { hook } = createHook();
-
-    await hook.event({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
-      },
-    });
-    for (const call of ['read-1', 'read-2']) {
-      await hook['tool.execute.after'](
-        { tool: 'read', sessionID: 'child-1', callID: call },
-        {
-          output: [
-            '<path>/tmp/src/repeat.ts</path>',
-            '<content>',
-            ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
-            '</content>',
-          ].join('\n'),
-        },
-      );
-    }
-
+    const poll = { args: { task_id: 'exp-1' } };
     await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
-      { args: { subagent_type: 'explorer', description: 'repeat reads' } },
-    );
-    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)',
-      },
+      { tool: 'task_status', sessionID: 'parent-1', callID: 'poll' },
+      poll,
     );
-
-    const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
-
-    const prompt = messages.messages[0].parts[0].text;
-    expect(prompt).toContain('src/repeat.ts (12 lines)');
-    expect(prompt).not.toContain('src/repeat.ts (24 lines)');
+    expect(poll.args.task_id).toBe('child-1');
   });
 
-  test('uses configured read context thresholds', async () => {
-    const { hook } = createHook({
-      readContextMinLines: 5,
-      readContextMaxFiles: 1,
-    });
-
-    await hook.event({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
-      },
+  test('task alias is dropped when subagent_type is missing', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
     });
-    for (const [file, lines] of [
-      ['small.ts', 4],
-      ['medium.ts', 5],
-      ['large.ts', 12],
-    ] as const) {
-      await hook['tool.execute.after'](
-        { tool: 'read', sessionID: 'child-1', callID: `read-${file}` },
-        {
-          output: [
-            `<path>/tmp/src/${file}</path>`,
-            '<content>',
-            ...Array.from({ length: lines }, (_, line) => `${line + 1}: line`),
-            '</content>',
-          ].join('\n'),
-        },
-      );
-    }
 
+    const resume = { args: { task_id: 'exp-1' } };
     await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
-      { args: { subagent_type: 'explorer', description: 'configured caps' } },
-    );
-    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)',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+      resume,
     );
 
-    const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
-
-    const prompt = messages.messages[0].parts[0].text;
-    expect(prompt).not.toContain('small.ts');
-    expect(prompt).toContain('Context read by exp-1:');
-    expect(prompt).toContain('(+1 more)');
+    expect(resume.args.task_id).toBeUndefined();
   });
 
-  test('ignores reads from unmanaged child sessions', async () => {
-    const { hook } = createHook({
-      shouldManageSession: (sessionID) => sessionID === 'parent-1',
-    });
-
-    await hook.event({
-      event: {
-        type: 'session.created',
-        properties: { info: { id: 'child-1', parentID: 'other-parent' } },
-      },
+  test('task alias is dropped when subagent_type is invalid', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
     });
-    await hook['tool.execute.after'](
-      { tool: 'read', sessionID: 'child-1', callID: 'read-1' },
-      {
-        output: [
-          '<path>/tmp/src/index.ts</path>',
-          '<content>',
-          ...Array.from({ length: 12 }, (_, index) => `${index + 1}: line`),
-          '</content>',
-        ].join('\n'),
-      },
-    );
-
-    await hook['tool.execute.before'](
-      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
-      { args: { subagent_type: 'explorer', description: 'unmanaged read' } },
-    );
-    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 messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
-
-    const prompt = messages.messages[0].parts[0].text;
-    expect(prompt).toContain('exp-1 unmanaged read');
-    expect(prompt).not.toContain('Context read by exp-1');
-  });
-
-  test('prunes read context when remembered sessions are evicted', async () => {
-    const { hook } = createHook();
-
-    for (const index of [1, 2, 3]) {
-      await hook.event({
-        event: {
-          type: 'session.created',
-          properties: {
-            info: { id: `child-${index}`, parentID: 'parent-1' },
-          },
-        },
-      });
-      await hook['tool.execute.after'](
-        { tool: 'read', sessionID: `child-${index}`, callID: `read-${index}` },
-        {
-          output: [
-            `<path>/tmp/src/file-${index}.ts</path>`,
-            '<content>',
-            ...Array.from({ length: 12 }, (_, line) => `${line + 1}: line`),
-            '</content>',
-          ].join('\n'),
-        },
-      );
-      await hook['tool.execute.before'](
-        { tool: 'task', sessionID: 'parent-1', callID: `call-${index}` },
-        { args: { subagent_type: 'explorer', description: `thread ${index}` } },
-      );
-      await hook['tool.execute.after'](
-        { tool: 'task', sessionID: 'parent-1', callID: `call-${index}` },
-        {
-          output: `task_id: child-${index} (for resuming to continue this task if needed)`,
-        },
-      );
-    }
-
-    const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
-
-    const prompt = messages.messages[0].parts[0].text;
-    expect(prompt).not.toContain('exp-1 thread 1');
-    expect(prompt).not.toContain('file-1.ts');
-    expect(prompt).toContain('exp-2 thread 2');
-    expect(prompt).toContain('file-2.ts (12 lines)');
-    expect(prompt).toContain('exp-3 thread 3');
-    expect(prompt).toContain('file-3.ts (12 lines)');
-  });
-
-  test('drops stale remembered sessions and falls back to fresh', 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 next = {
-      args: {
-        subagent_type: 'explorer',
-        description: 'continue schema work',
-        task_id: 'exp-1',
-      },
+    const resume = {
+      args: { subagent_type: 'not-an-agent', task_id: 'exp-1' },
     };
     await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      next,
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+      resume,
     );
 
-    expect(next.args.task_id).toBe('child-1');
+    expect(resume.args.task_id).toBeUndefined();
+  });
 
-    await hook['tool.execute.after'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      {
-        output: '[ERROR] Session not found',
-      },
+  test('wrong parent or wrong agent alias does not resolve', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+    board.markReconciled('child-1');
+
+    const wrongAgent = { args: { subagent_type: 'oracle', task_id: 'exp-1' } };
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'agent' },
+      wrongAgent,
     );
+    expect(wrongAgent.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).not.toContain('exp-1');
+    const wrongParent = { args: { task_id: 'exp-1' } };
+    await hook['tool.execute.before'](
+      { tool: 'task_status', sessionID: 'parent-2', callID: 'parent' },
+      wrongParent,
+    );
+    expect(wrongParent.args.task_id).toBe('exp-1');
   });
 
-  test('drops resumed predecessor when success returns a new task id', async () => {
+  test('unknown raw task_status id remains unchanged', async () => {
     const { hook } = createHook();
-
+    const poll = { args: { task_id: 'raw-unknown' } };
     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)',
-      },
+      { tool: 'task_status', sessionID: 'parent-1', callID: 'poll' },
+      poll,
     );
+    expect(poll.args.task_id).toBe('raw-unknown');
+  });
+
+  test('resuming reusable job relaunches running and removes reusable entry', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'map hooks',
+    });
+    board.updateStatus({ taskID: 'child-1', state: 'completed' });
+    board.markReconciled('child-1');
 
+    const resume = { args: { subagent_type: 'explorer', task_id: 'exp-1' } };
     await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'continue schema work',
-          task_id: 'exp-1',
-        },
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+      resume,
     );
     await hook['tool.execute.after'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      {
-        output:
-          'task_id: child-2 (for resuming to continue this task if needed)',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'resume' },
+      { output: ['task_id: child-1', 'state: running'].join('\n') },
     );
 
-    const messages = createMessages('parent-1', 'do something');
+    const messages = createMessages('parent-1', 'continue');
     await hook['experimental.chat.messages.transform']({}, messages);
-
-    const prompt = messages.messages[0].parts[0].text;
-    expect(prompt).toContain('continue schema work');
-    expect(prompt).not.toContain('config schema');
+    expect(messages.messages[0].parts[0].text).toContain(
+      'exp-1 / child-1 / explorer / running',
+    );
+    expect(messages.messages[0].parts[0].text).toContain(
+      '#### Reusable Sessions\n- none',
+    );
   });
 
-  test('does not drop remembered session on non-runtime session text', async () => {
+  test('bare task id output without state does not create reusable job', async () => {
     const { hook } = createHook();
-
     await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-1',
-      },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'config schema',
-        },
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'legacy output' } },
     );
     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)',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: 'task_id: child-1 (for resuming to continue this task)' },
     );
 
-    await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
+    const messages = createMessages('parent-1', 'continue');
+    await hook['experimental.chat.messages.transform']({}, messages);
+    expect(messages.messages[0].parts[0].text).toBe('continue');
+  });
+
+  test('reads before and after launch attach with unique-line counts and caps', async () => {
+    const { hook } = createHook({
+      readContextMinLines: 5,
+      readContextMaxFiles: 1,
+    });
+    await hook.event({
+      event: {
+        type: 'session.created',
+        properties: { info: { id: 'child-1', parentID: 'parent-1' } },
       },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'continue schema work',
-          task_id: 'exp-1',
+    });
+    for (const [file, start, count] of [
+      ['small.ts', 1, 4],
+      ['large.ts', 1, 12],
+      ['large.ts', 7, 6],
+      ['medium.ts', 1, 5],
+    ] as const) {
+      await hook['tool.execute.after'](
+        { tool: 'read', sessionID: 'child-1', callID: `read-${file}-${start}` },
+        {
+          output: [
+            `<path>/tmp/src/${file}</path>`,
+            '<content>',
+            ...Array.from(
+              { length: count },
+              (_, index) => `${start + index}: line`,
+            ),
+            '</content>',
+          ].join('\n'),
         },
-      },
+      );
+    }
+    await hook['tool.execute.before'](
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'explorer', description: 'context caps' } },
     );
     await hook['tool.execute.after'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      {
-        output: 'Found no session cookies in fixtures, continuing analysis.',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: ['task_id: child-1', 'state: running'].join('\n') },
     );
-
-    const messages = createMessages('parent-1', 'do something');
+    await hook['tool.execute.after'](
+      { tool: 'task_status', sessionID: 'parent-1', callID: 'status-1' },
+      { output: ['task_id: child-1', 'state: completed'].join('\n') },
+    );
+    const messages = createMessages('parent-1', 'continue');
     await hook['experimental.chat.messages.transform']({}, messages);
+    await hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 'parent-1', status: { type: 'idle' } },
+      },
+    });
+    const next = createMessages('parent-1', 'reuse');
+    await hook['experimental.chat.messages.transform']({}, next);
+    const prompt = next.messages[0].parts[0].text;
+    expect(prompt).not.toContain('small.ts');
+    expect(prompt).toContain('src/large.ts (12 lines)');
+    expect(prompt).not.toContain('src/large.ts (18 lines)');
+    expect(prompt).toContain('(+1 more)');
+  });
+
+  test('reusable cap evicts only old reusable jobs, not active jobs', async () => {
+    const board = new BackgroundJobBoard({ maxReusablePerAgent: 2 });
+    for (const index of [1, 2, 3]) {
+      board.registerLaunch({
+        taskID: `done-${index}`,
+        parentSessionID: 'parent-1',
+        agent: 'explorer',
+        description: `done ${index}`,
+        now: index,
+      });
+      board.updateStatus({
+        taskID: `done-${index}`,
+        state: 'completed',
+        now: index,
+      });
+      board.markReconciled(`done-${index}`, index);
+    }
+    board.registerLaunch({
+      taskID: 'running-1',
+      parentSessionID: 'parent-1',
+      agent: 'explorer',
+      description: 'active',
+      now: 4,
+    });
 
-    expect(messages.messages[0].parts[0].text).toContain('exp-1 config schema');
+    expect(board.get('done-1')).toBeUndefined();
+    expect(board.get('done-2')).toBeDefined();
+    expect(board.get('done-3')).toBeDefined();
+    expect(board.get('running-1')).toBeDefined();
+  });
+
+  test('does not expose a system transform for resumable sessions', async () => {
+    const { hook } = createHook();
+    expect('experimental.chat.system.transform' in hook).toBe(false);
   });
 
   test('ignores sessions that are not orchestrator-managed', async () => {
@@ -1626,7 +1265,7 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toBe('do something');
   });
 
-  test('cleans up remembered sessions when parent or child is deleted', async () => {
+  test('cleans up background jobs when parent or child is deleted', async () => {
     const { hook } = createHook();
 
     await hook['tool.execute.before'](
@@ -1710,89 +1349,28 @@ describe('task-session-manager hook', () => {
     expect(messages.messages[0].parts[0].text).toBe('do something');
   });
 
-  test('deduplicates pending call order when a resume call is recorded twice', 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)',
-      },
-    );
-
+  test('parent deletion clears jobs and pending calls', async () => {
+    const board = new BackgroundJobBoard();
+    const { hook } = createHook({ backgroundJobBoard: board });
     await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      {
-        args: {
-          subagent_type: 'explorer',
-          description: 'continue schema work',
-          task_id: 'exp-1',
-        },
-      },
-    );
-    await hook['tool.execute.after'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-2',
-      },
-      {
-        output: '[ERROR] Session not found',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { args: { subagent_type: 'oracle', description: 'architecture review' } },
     );
+    board.registerLaunch({
+      taskID: 'child-1',
+      parentSessionID: 'parent-1',
+      agent: 'oracle',
+      description: 'architecture review',
+    });
 
-    await hook['tool.execute.before'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-3',
-      },
-      {
-        args: {
-          subagent_type: 'oracle',
-          description: 'architecture review',
-        },
-      },
-    );
+    await hook.event({
+      event: { type: 'session.deleted', properties: { sessionID: 'parent-1' } },
+    });
     await hook['tool.execute.after'](
-      {
-        tool: 'task',
-        sessionID: 'parent-1',
-        callID: 'call-3',
-      },
-      {
-        output:
-          'task_id: child-3 (for resuming to continue this task if needed)',
-      },
+      { tool: 'task', sessionID: 'parent-1', callID: 'call-1' },
+      { output: ['task_id: child-2', 'state: running'].join('\n') },
     );
 
-    const messages = createMessages('parent-1', 'do something');
-    await hook['experimental.chat.messages.transform']({}, messages);
-
-    expect(messages.messages[0].parts[0].text).toContain(
-      'oracle: ora-1 architecture review',
-    );
+    expect(board.list('parent-1')).toHaveLength(0);
   });
 });

+ 53 - 56
src/hooks/task-session-manager/index.ts

@@ -9,7 +9,6 @@ import {
   parseTaskIdFromTaskOutput,
   parseTaskLaunchOutput,
   parseTaskStatusOutput,
-  SessionManager,
   SLIM_INTERNAL_INITIATOR_MARKER,
 } from '../../utils';
 
@@ -64,8 +63,7 @@ interface ChatMessage {
   parts: ChatMessagePart[];
 }
 
-const RESUMABLE_SESSIONS_START = '<resumable_sessions>';
-const RESUMABLE_SESSIONS_END = '</resumable_sessions>';
+const BACKGROUND_JOB_BOARD_SENTINEL = 'SENTINEL: background-job-board-v2';
 const BACKGROUND_COMPLETION_COMPLETED = /^Background task completed: /;
 const BACKGROUND_COMPLETION_FAILED = /^Background task failed: /;
 const MAX_PROCESSED_INJECTED_COMPLETIONS = 500;
@@ -179,12 +177,13 @@ export function createTaskSessionManagerHook(
     shouldManageSession: (sessionID: string) => boolean;
   },
 ) {
-  const sessionManager = new SessionManager(options.maxSessionsPerAgent, {
-    readContextMinLines: options.readContextMinLines,
-    readContextMaxFiles: options.readContextMaxFiles,
-  });
   const backgroundJobBoard =
-    options.backgroundJobBoard ?? new BackgroundJobBoard();
+    options.backgroundJobBoard ??
+    new BackgroundJobBoard({
+      maxReusablePerAgent: options.maxSessionsPerAgent,
+      readContextMinLines: options.readContextMinLines,
+      readContextMaxFiles: options.readContextMaxFiles,
+    });
   const pendingCalls = new Map<string, PendingTaskCall>();
   const pendingCallOrder: string[] = [];
   const contextByTask = new Map<string, Map<string, PendingContextFile>>();
@@ -215,7 +214,7 @@ export function createTaskSessionManagerHook(
       context.set(file.path, pending);
     }
 
-    sessionManager.addContext(taskId, contextFilesForPrompt(context));
+    backgroundJobBoard.addContext(taskId, contextFilesForPrompt(context));
   }
 
   function contextFilesForPrompt(
@@ -231,12 +230,13 @@ export function createTaskSessionManagerHook(
 
   function canTrackTaskContext(taskId: string): boolean {
     return (
-      pendingManagedTaskIds.has(taskId) || sessionManager.taskIds().has(taskId)
+      pendingManagedTaskIds.has(taskId) ||
+      backgroundJobBoard.taskIDs().has(taskId)
     );
   }
 
   function pruneContext(): void {
-    const remembered = sessionManager.taskIds();
+    const remembered = backgroundJobBoard.taskIDs();
     for (const taskId of contextByTask.keys()) {
       if (!pendingManagedTaskIds.has(taskId) && !remembered.has(taskId)) {
         contextByTask.delete(taskId);
@@ -262,7 +262,10 @@ export function createTaskSessionManagerHook(
 
     if (updated.terminalUnreconciled) {
       pendingManagedTaskIds.delete(updated.taskID);
-      contextByTask.delete(updated.taskID);
+      backgroundJobBoard.addContext(
+        updated.taskID,
+        contextFilesForPrompt(contextByTask.get(updated.taskID)),
+      );
       pruneContext();
     }
 
@@ -415,14 +418,33 @@ export function createTaskSessionManagerHook(
       input: { tool: string; sessionID?: string; callID?: string },
       output: { args?: unknown },
     ): Promise<void> => {
-      if (input.tool.toLowerCase() !== 'task') return;
+      const toolName = input.tool.toLowerCase();
+      if (toolName !== 'task' && toolName !== 'task_status') return;
       if (!input.sessionID || !options.shouldManageSession(input.sessionID)) {
         return;
       }
       if (!isObjectRecord(output.args)) return;
 
+      if (toolName === 'task_status') {
+        const args = output.args as { task_id?: unknown };
+        if (typeof args.task_id !== 'string' || args.task_id.trim() === '') {
+          return;
+        }
+        const resolved = backgroundJobBoard.resolveForStatus(
+          input.sessionID,
+          args.task_id.trim(),
+        );
+        if (resolved) args.task_id = resolved.taskID;
+        return;
+      }
+
       const args = output.args as TaskArgs;
-      if (!isAgentName(args.subagent_type)) return;
+      if (!isAgentName(args.subagent_type)) {
+        if (typeof args.task_id === 'string' && args.task_id.trim() !== '') {
+          delete args.task_id;
+        }
+        return;
+      }
 
       const label = deriveTaskSessionLabel({
         description:
@@ -447,10 +469,10 @@ export function createTaskSessionManagerHook(
       }
 
       const requested = args.task_id.trim();
-      const remembered = sessionManager.resolve(
+      const remembered = backgroundJobBoard.resolveReusable(
         input.sessionID,
-        args.subagent_type,
         requested,
+        args.subagent_type,
       );
 
       if (!remembered) {
@@ -458,14 +480,10 @@ export function createTaskSessionManagerHook(
         return;
       }
 
-      args.task_id = remembered.taskId;
-      pendingManagedTaskIds.add(remembered.taskId);
-      sessionManager.markUsed(
-        input.sessionID,
-        args.subagent_type,
-        remembered.taskId,
-      );
-      pendingCall.resumedTaskId = remembered.taskId;
+      args.task_id = remembered.taskID;
+      pendingManagedTaskIds.add(remembered.taskID);
+      backgroundJobBoard.markUsed(input.sessionID, remembered.taskID);
+      pendingCall.resumedTaskId = remembered.taskID;
       rememberPendingCall(pendingCall);
     },
 
@@ -505,10 +523,9 @@ export function createTaskSessionManagerHook(
           description: pending.label,
           objective: pending.label,
         });
-        sessionManager.drop(
-          pending.parentSessionId,
-          pending.agentType,
-          pending.resumedTaskId ?? launch.taskID,
+        backgroundJobBoard.addContext(
+          launch.taskID,
+          contextFilesForPrompt(contextByTask.get(launch.taskID)),
         );
         pendingManagedTaskIds.add(launch.taskID);
         return;
@@ -520,32 +537,18 @@ export function createTaskSessionManagerHook(
           pending.resumedTaskId &&
           isMissingRememberedSessionError(output.output)
         ) {
-          sessionManager.drop(
-            pending.parentSessionId,
-            pending.agentType,
-            pending.resumedTaskId,
-          );
+          backgroundJobBoard.drop(pending.resumedTaskId);
         }
         return;
       }
 
       if (pending.resumedTaskId && pending.resumedTaskId !== taskId) {
-        sessionManager.drop(
-          pending.parentSessionId,
-          pending.agentType,
-          pending.resumedTaskId,
-        );
+        backgroundJobBoard.drop(pending.resumedTaskId);
       }
 
-      sessionManager.remember({
-        parentSessionId: pending.parentSessionId,
-        taskId,
-        agentType: pending.agentType,
-        label: pending.label,
-      });
       pendingManagedTaskIds.delete(taskId);
       const contextFiles = contextFilesForPrompt(contextByTask.get(taskId));
-      sessionManager.addContext(taskId, contextFiles);
+      backgroundJobBoard.addContext(taskId, contextFiles);
       pruneContext();
     },
 
@@ -583,7 +586,6 @@ export function createTaskSessionManagerHook(
 
         const reminders = [
           backgroundJobBoard.formatForPrompt(message.info.sessionID),
-          sessionManager.formatForPrompt(message.info.sessionID),
         ].filter((item): item is string => Boolean(item));
         if (reminders.length === 0) return;
 
@@ -592,16 +594,12 @@ export function createTaskSessionManagerHook(
         );
         if (!textPart) return;
         if (textPart.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER)) return;
-        if (textPart.text?.includes(RESUMABLE_SESSIONS_START)) return;
+        if (textPart.text?.includes(BACKGROUND_JOB_BOARD_SENTINEL)) return;
 
         rememberInjectedTerminalJobs(message.info.sessionID);
-        textPart.text = [
-          textPart.text ?? '',
-          '',
-          RESUMABLE_SESSIONS_START,
-          reminders.join('\n\n'),
-          RESUMABLE_SESSIONS_END,
-        ].join('\n');
+        textPart.text = [textPart.text ?? '', '', reminders.join('\n\n')].join(
+          '\n',
+        );
         return;
       }
     },
@@ -657,8 +655,7 @@ export function createTaskSessionManagerHook(
         input.event.properties?.info?.id ?? input.event.properties?.sessionID;
       if (!sessionId) return;
 
-      sessionManager.dropTask(sessionId);
-      sessionManager.clearParent(sessionId);
+      backgroundJobBoard.drop(sessionId);
       backgroundJobBoard.clearParent(sessionId);
       terminalJobsInjectedByParent.delete(sessionId);
       contextByTask.delete(sessionId);

+ 8 - 4
src/index.ts

@@ -254,7 +254,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
     mcps = createBuiltinMcps(config.disabled_mcps, config.websearch);
     webfetch = createWebfetchTool(ctx);
-    backgroundJobBoard = new BackgroundJobBoard();
+    backgroundJobBoard = new BackgroundJobBoard({
+      maxReusablePerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
+      readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
+      readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
+    });
 
     // Initialize MultiplexerSessionManager to handle OpenCode's built-in
     // Task tool sessions
@@ -312,9 +316,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
     deepworkCommandHook = createDeepworkCommandHook();
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
-      maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
-      readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
-      readContextMaxFiles: config.sessionManager?.readContextMaxFiles ?? 8,
+      maxSessionsPerAgent: config.backgroundJobs?.maxSessionsPerAgent ?? 2,
+      readContextMinLines: config.backgroundJobs?.readContextMinLines ?? 10,
+      readContextMaxFiles: config.backgroundJobs?.readContextMaxFiles ?? 8,
       backgroundJobBoard,
       shouldManageSession: (sessionID) =>
         sessionAgentMap.get(sessionID) === 'orchestrator',

+ 2 - 3
src/utils/background-job-board.test.ts

@@ -119,7 +119,7 @@ describe('BackgroundJobBoard', () => {
       terminalUnreconciled: false,
       updatedAt: 300,
     });
-    expect(board.formatForPrompt('parent-1')).toBeUndefined();
+    expect(board.formatForPrompt('parent-1')).toContain('Reusable Sessions');
   });
 
   test('does not reconcile running jobs', () => {
@@ -381,8 +381,7 @@ describe('BackgroundJobBoard', () => {
       terminalUnreconciled: false,
     });
 
-    // Job should remain hidden from prompt
-    expect(board.formatForPrompt('parent-1')).toBeUndefined();
+    expect(board.formatForPrompt('parent-1')).toContain('Reusable Sessions');
   });
 
   test('annotates just-launched running jobs with age in the prompt', () => {

+ 167 - 4
src/utils/background-job-board.ts

@@ -1,5 +1,12 @@
 import { parseTaskStatusOutput, type TaskOutputState } from './task';
 
+export interface ContextFile {
+  path: string;
+  lineCount: number;
+  lineNumbers?: number[];
+  lastReadAt: number;
+}
+
 export type BackgroundJobState = TaskOutputState | 'reconciled';
 
 export interface BackgroundJobRecord {
@@ -17,6 +24,15 @@ export interface BackgroundJobRecord {
   completedAt?: number;
   resultSummary?: string;
   alias: string;
+  lastUsedAt: number;
+  terminalState?: TaskOutputState;
+  contextFiles: ContextFile[];
+}
+
+export interface BackgroundJobBoardOptions {
+  maxReusablePerAgent?: number;
+  readContextMinLines?: number;
+  readContextMaxFiles?: number;
 }
 
 export interface BackgroundJobLaunchInput {
@@ -56,6 +72,16 @@ export class BackgroundJobBoard {
   private readonly jobs = new Map<string, BackgroundJobRecord>();
   private readonly counters = new Map<string, number>();
 
+  private readonly maxReusablePerAgent: number;
+  private readonly readContextMinLines: number;
+  private readonly readContextMaxFiles: number;
+
+  constructor(options: BackgroundJobBoardOptions = {}) {
+    this.maxReusablePerAgent = options.maxReusablePerAgent ?? 2;
+    this.readContextMinLines = options.readContextMinLines ?? 10;
+    this.readContextMaxFiles = options.readContextMaxFiles ?? 8;
+  }
+
   registerLaunch(input: BackgroundJobLaunchInput): BackgroundJobRecord {
     const now = input.now ?? Date.now();
     const existing = this.jobs.get(input.taskID);
@@ -71,7 +97,9 @@ export class BackgroundJobBoard {
         terminalUnreconciled: false,
         completedAt: undefined,
         resultSummary: undefined,
+        terminalState: undefined,
         lastLaunchedAt: now,
+        lastUsedAt: now,
         updatedAt: now,
       } satisfies BackgroundJobRecord;
       this.jobs.set(input.taskID, updated);
@@ -89,8 +117,10 @@ export class BackgroundJobBoard {
       terminalUnreconciled: false,
       launchedAt: now,
       lastLaunchedAt: now,
+      lastUsedAt: now,
       updatedAt: now,
       alias: this.nextAlias(input.parentSessionID, input.agent),
+      contextFiles: [],
     };
 
     this.jobs.set(input.taskID, record);
@@ -123,10 +153,12 @@ export class BackgroundJobBoard {
       completedAt: terminal
         ? (existing.completedAt ?? now)
         : existing.completedAt,
+      terminalState: terminal ? input.state : existing.terminalState,
       resultSummary: input.resultSummary ?? existing.resultSummary,
     };
 
     this.jobs.set(input.taskID, updated);
+    this.trimReusable(input.taskID);
     return updated;
   }
 
@@ -160,9 +192,12 @@ export class BackgroundJobBoard {
       state: 'reconciled',
       terminalUnreconciled: false,
       updatedAt: now,
+      lastUsedAt: now,
+      terminalState: existing.terminalState ?? terminalStateOf(existing.state),
     };
 
     this.jobs.set(taskID, updated);
+    this.trimReusable(taskID);
     return updated;
   }
 
@@ -184,6 +219,7 @@ export class BackgroundJobBoard {
       terminalUnreconciled: true,
       updatedAt: now,
       completedAt: existing.completedAt ?? now,
+      terminalState: 'cancelled',
       resultSummary: summary,
     };
 
@@ -205,6 +241,55 @@ export class BackgroundJobBoard {
     );
   }
 
+  resolveForStatus(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+  ): BackgroundJobRecord | undefined {
+    return this.resolve(parentSessionID, taskIDOrAlias);
+  }
+
+  resolveReusable(
+    parentSessionID: string,
+    taskIDOrAlias: string,
+    agent?: string,
+  ): BackgroundJobRecord | undefined {
+    const job = this.resolve(parentSessionID, taskIDOrAlias);
+    if (!job || !isReusable(job)) return undefined;
+    if (agent && job.agent !== agent) return undefined;
+    return job;
+  }
+
+  markUsed(parentSessionID: string, key: string, now = Date.now()): void {
+    const job = this.resolve(parentSessionID, key);
+    if (!job) return;
+    this.jobs.set(job.taskID, { ...job, lastUsedAt: now, updatedAt: now });
+  }
+
+  taskIDs(): Set<string> {
+    return new Set(this.jobs.keys());
+  }
+
+  addContext(taskID: string, files: ContextFile[]): void {
+    if (files.length === 0) return;
+    const job = this.jobs.get(taskID);
+    if (!job) return;
+    const existing = new Map(job.contextFiles.map((file) => [file.path, file]));
+    for (const file of files) {
+      const previous = existing.get(file.path);
+      if (previous) {
+        previous.lineCount = Math.max(previous.lineCount, file.lineCount);
+        previous.lastReadAt = Math.max(previous.lastReadAt, file.lastReadAt);
+      } else {
+        existing.set(file.path, { ...file });
+      }
+    }
+    const contextFiles = [...existing.values()]
+      .filter((file) => file.lineCount >= this.readContextMinLines)
+      .sort((a, b) => b.lastReadAt - a.lastReadAt)
+      .slice(0, this.readContextMaxFiles + 1);
+    this.jobs.set(taskID, { ...job, contextFiles });
+  }
+
   list(parentSessionID?: string): BackgroundJobRecord[] {
     const jobs = [...this.jobs.values()];
     const filtered = parentSessionID
@@ -226,17 +311,27 @@ export class BackgroundJobBoard {
     parentSessionID: string,
     now = Date.now(),
   ): string | undefined {
-    const jobs = this.list(parentSessionID).filter(
+    const active = this.list(parentSessionID).filter(
       (job) => job.state === 'running' || job.terminalUnreconciled,
     );
+    const reusable = this.list(parentSessionID).filter(isReusable);
 
-    if (jobs.length === 0) return undefined;
+    if (active.length === 0 && reusable.length === 0) return undefined;
 
     return [
       '### Background Job Board',
-      'Use task_status before consuming running jobs. Reconcile terminal jobs before final response.',
+      'SENTINEL: background-job-board-v2',
+      'Use task_status for running jobs. Reconcile terminal jobs before final response. Reuse only completed/reconciled sessions for the same specialist/context.',
       '',
-      ...jobs.map((job) => formatJob(job, now)),
+      '#### Active / Unreconciled',
+      ...(active.length > 0
+        ? active.map((job) => formatJob(job, now))
+        : ['- none']),
+      '',
+      '#### Reusable Sessions',
+      ...(reusable.length > 0
+        ? reusable.map((job) => this.formatReusableJob(job))
+        : ['- none']),
     ].join('\n');
   }
 
@@ -250,6 +345,32 @@ export class BackgroundJobBoard {
     this.jobs.delete(taskID);
   }
 
+  private trimReusable(taskID: string): void {
+    const job = this.jobs.get(taskID);
+    if (!job || !isReusable(job)) return;
+    const reusable = this.list(job.parentSessionID)
+      .filter(
+        (candidate) => candidate.agent === job.agent && isReusable(candidate),
+      )
+      .sort((a, b) => b.lastUsedAt - a.lastUsedAt);
+    for (const stale of reusable.slice(this.maxReusablePerAgent)) {
+      this.jobs.delete(stale.taskID);
+    }
+  }
+
+  private formatReusableJob(job: BackgroundJobRecord): string {
+    const lines = [
+      `- ${job.alias} / ${job.taskID} / ${job.agent} / completed, reconciled`,
+      `  Objective: ${job.objective || job.description}`,
+    ];
+    const context = formatContextFiles(
+      job.contextFiles,
+      this.readContextMaxFiles,
+    );
+    if (context) lines.push(`  Context read by ${job.alias}: ${context}`);
+    return lines.join('\n');
+  }
+
   private nextAlias(parentSessionID: string, agent: string): string {
     const prefix = AGENT_PREFIX[agent] ?? (agent.slice(0, 3) || 'job');
     const key = `${parentSessionID}:${prefix}`;
@@ -260,6 +381,48 @@ export class BackgroundJobBoard {
   }
 }
 
+export function deriveTaskSessionLabel(input: {
+  description?: string;
+  prompt?: string;
+  agentType: string;
+}): string {
+  const preferred = normalizeWhitespace(input.description ?? '');
+  if (preferred) return preferred.slice(0, 48);
+  const firstPromptLine = (input.prompt ?? '')
+    .split(/\r?\n/)
+    .map((line) => normalizeWhitespace(line))
+    .find(Boolean);
+  return firstPromptLine
+    ? firstPromptLine.slice(0, 48)
+    : `recent ${input.agentType} task`;
+}
+
+function isReusable(job: BackgroundJobRecord): boolean {
+  return job.state === 'reconciled' && job.terminalState === 'completed';
+}
+
+function terminalStateOf(
+  state: BackgroundJobState,
+): TaskOutputState | undefined {
+  return state === 'completed' || state === 'error' || state === 'cancelled'
+    ? state
+    : undefined;
+}
+
+function formatContextFiles(files: ContextFile[], maxFiles: number): string {
+  if (maxFiles === 0) return '';
+  const shown = files.slice(0, maxFiles);
+  const rest = files.length - shown.length;
+  const rendered = shown.map(
+    (file) => `${file.path} (${file.lineCount} lines)`,
+  );
+  return `${rendered.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`;
+}
+
+function normalizeWhitespace(value: string): string {
+  return value.replace(/\s+/g, ' ').trim();
+}
+
 function formatJob(job: BackgroundJobRecord, now = Date.now()): string {
   const ageMs = now - job.lastLaunchedAt;
   const isResume = job.lastLaunchedAt !== job.launchedAt;

+ 73 - 46
src/utils/codemap.md

@@ -4,82 +4,109 @@ Cross-cutting runtime utilities used by orchestration, hooks, and plugin I/O.
 
 ## Responsibility
 
-- **tmux.ts**: Multiplexer-safe pane lifecycle helpers (`spawnPane`, `closePane`) used by tmux and zellij adapters.
-- **subagent-depth.ts**: Tracks delegated session depth and enforces max nested delegation depth.
-- **agent-variant.ts**: Normalizes agent names and applies optional variant labels without overriding existing body configuration.
-- **env.ts**: Unified environment lookup across Bun/Node with empty-string filtering.
-- **session-manager.ts**: Tracks resumable `task` tool sessions by parent session + agent type, normalizes user labels, assigns stable short aliases, and exposes prompt rendering/eviction behavior.
-- **session.ts**: Session extraction helpers for multi-turn synthesis and prompt/result post-processing.
-- **polling.ts**: Shared polling with stability thresholds and abort-signal support.
-- **zip-extractor.ts**: Cross-platform zip/tar extraction with Windows fallback tooling.
-- **task.ts**: Parses `task` tool CLI output to recover `task_id` for resumption.
-- **system-collapse.ts**: Collapses multiple system prompt fragments into one array element while mutating the original array reference.
+- **background-job-board.ts**: Tracks V2 background jobs by parent session,
+  assigns aliases, records read context, and exposes reusable completed /
+  reconciled sessions with prompt rendering and reusable LRU caps.
+- **tmux.ts**: Multiplexer-safe pane lifecycle helpers (`spawnPane`, `closePane`)
+  used by tmux and zellij adapters.
+- **subagent-depth.ts**: Tracks delegated session depth and enforces max nested
+  delegation depth.
+- **agent-variant.ts**: Normalizes agent names and applies optional variant
+  labels without overriding existing body configuration.
+- **env.ts**: Unified environment lookup across Bun/Node with empty-string
+  filtering.
+- **session.ts**: Session extraction helpers for multi-turn synthesis and
+  prompt/result post-processing.
+- **polling.ts**: Shared polling with stability thresholds and abort-signal
+  support.
+- **zip-extractor.ts**: Cross-platform zip/tar extraction with Windows fallback
+  tooling.
+- **task.ts**: Parses `task` and `task_status` tool output.
+- **system-collapse.ts**: Collapses multiple system prompt fragments into one
+  array element while mutating the original array reference.
 - **logger.ts**: Structured JSON logging to temporary files.
-- **internal-initiator.ts**: Marker utilities for internal orchestrator text-part tagging.
+- **internal-initiator.ts**: Marker utilities for internal orchestrator text-part
+  tagging.
 - **compat.ts**: Backward compatibility helpers.
 - **index.ts**: Public re-export barrel for utility modules.
 
 ## Design
 
-- **Deterministic lifecycle tracking**: `SubagentDepthTracker` maps session IDs → depth and is cleaned on session deletion.
-- **Parent-scoped resumable session store**: `SessionManager` groups tasks by `{parentSessionId, agentType}` and maintains LRU-ish ordering by last-used counter so active resumable sessions stay in memory.
-- **Provider-safe env access**: `getEnv` falls back from `Bun.env` to `process.env` and normalizes blank values.
-- **Graceful shutdown protocol**: Multiplexer pane close path sends Ctrl+C before kill, then rebalances layout state.
-- **Session extraction model**: `extractSessionResult`/`parseModelReference` style helpers are centralized under `session.ts`.
-- **In-place system normalization**: `collapseSystemInPlace` purposely mutates `system` array to preserve references held by OpenCode internals.
-- **Resilient polling**: `pollUntilStable` requires consecutive confirmations before success.
+- **Parent-scoped background job board**: `BackgroundJobBoard` tracks
+  active/unreconciled jobs separately from completed/reconciled reusable
+  sessions; reusable entries are LRU-capped per parent+agent.
+- **Deterministic lifecycle tracking**: `SubagentDepthTracker` maps session IDs to
+  depth and is cleaned on session deletion.
+- **Provider-safe env access**: `getEnv` falls back from `Bun.env` to
+  `process.env` and normalizes blank values.
+- **Graceful shutdown protocol**: Multiplexer pane close path sends Ctrl+C before
+  kill, then rebalances layout state.
+- **Session extraction model**: `extractSessionResult`/`parseModelReference`
+  helpers are centralized under `session.ts`.
+- **In-place system normalization**: `collapseSystemInPlace` mutates `system` to
+  preserve references held by OpenCode internals.
+- **Resilient polling**: `pollUntilStable` requires consecutive confirmations
+  before success.
 
 ## Flow
 
-### `subagent-depth.ts`
+### `background-job-board.ts`
 
-- `registerChild(parentSessionId, childSessionId)` computes `childDepth = parentDepth + 1`.
-- Blocks registration when depth exceeds `DEFAULT_MAX_SUBAGENT_DEPTH`.
-- `cleanup(sessionId)` and `cleanupAll()` remove depth state for terminated sessions.
-
-### `session-manager.ts`
-
-- `deriveTaskSessionLabel` computes a deterministic prompt hint:
-  - uses `description` if provided,
-  - falls back to first non-empty normalized line of `prompt`,
-  - else returns `recent {agentType} task`.
-- `remember` creates/reuses entries keyed by `{parentSessionId, agentType}` and enforces a per-agent max via `trimGroup`.
-- Alias generation is monotonic within each parent+agent (`exp-1`, `lib-2`, etc.).
-- `markUsed`, `resolve`, `drop`, `dropTask`, `clearParent` keep the store consistent on reuse and teardown.
-- `formatForPrompt` returns grouped and ranked prompt text (`### Resumable Sessions ...`) for use in system transforms.
+- `deriveTaskSessionLabel` computes a deterministic prompt hint from
+  `description`, the first non-empty `prompt` line, or a fallback agent label.
+- `registerLaunch` creates/reopens running jobs and assigns monotonic aliases
+  within each parent+agent (`exp-1`, `lib-2`, etc.).
+- `updateStatus` marks terminal jobs unreconciled; `markReconciled` makes only
+  completed terminal jobs reusable.
+- `resolveForStatus`, `resolveReusable`, `markUsed`, `drop`, and `clearParent`
+  keep job aliases consistent on polling, reuse, and teardown.
+- `formatForPrompt` returns the unified `### Background Job Board` prompt section
+  with Active / Unreconciled and Reusable Sessions subsections.
 
 ### `tmux.ts`
 
-- `spawnPane` flow: validate enabled state → check multiplexer availability → resolve binary → execute attach command with layout handling.
-- `closePane` flow: send SIGINT-equivalent key sequence → delay → terminate pane → rebalance layout if needed.
+- `spawnPane` flow: validate enabled state → check multiplexer availability →
+  resolve binary → execute attach command with layout handling.
+- `closePane` flow: send SIGINT-equivalent key sequence → delay → terminate pane
+  → rebalance layout if needed.
 - `isServerRunning` flow: bounded `/health` checks with retries and caching.
 
 ### `polling.ts`
 
-- `pollUntilStable(fn, options)` repeatedly calls async predicate and tracks consecutive true states.
-- Returns once stable threshold is met, timeout elapses, or abort signal is raised.
+- `pollUntilStable(fn, options)` repeatedly calls async predicate and tracks
+  consecutive true states.
+- Returns once stable threshold is met, timeout elapses, or abort signal is
+  raised.
 
 ### `session.ts`
 
-- Composes prompt parts and extracts normalized session output for text/call/result flows.
-- Hosts shared parsing/formatting utilities used by council and tool execution layers.
+- Composes prompt parts and extracts normalized session output for text/call/result
+  flows.
+- Hosts shared parsing/formatting utilities used by council and tool execution
+  layers.
 
 ### `task.ts`
 
-- Scans task output line-by-line and extracts `task_id` from `task_id: <id>` format.
+- Scans task output line-by-line and extracts `task_id`, state, timeout, and
+  result summary fields.
 
 ### `system-collapse.ts`
 
-- `collapseSystemInPlace(system: string[])` joins all system entries using `\n\n`, clears and repopulates the same array reference, and preserves empty-array behavior.
+- `collapseSystemInPlace(system: string[])` joins system entries with `\n\n`,
+  clears and repopulates the same array reference, and preserves empty-array
+  behavior.
 
 ## Integration
 
 - **Consumers**
   - `src/multiplexer/*`: `SubagentDepthTracker` and `tmux.ts` integration.
-  - `src/council/council-manager.ts`: depth control and session extraction helpers.
+  - `src/council/council-manager.ts`: depth control and session extraction
+    helpers.
   - `src/hooks/*`: marker detection, polling, and session-aware state helpers.
-  - `src/hooks/task-session-manager`: `SessionManager`, `parseTaskIdFromTaskOutput`, and `deriveTaskSessionLabel` provide resumable-session workflow; the plugin’s system-transform passes the hook output through `collapseSystemInPlace` after this manager injects prompts.
-
+  - `src/hooks/task-session-manager`: `BackgroundJobBoard`, task-output parsing,
+    and `deriveTaskSessionLabel` provide V2 background job polling/reuse workflow
+    through message-transform prompt injection.
 - **Dependencies**
-  - Pulls constants from `../config` (`DEFAULT_MAX_SUBAGENT_DEPTH`, polling intervals/timeouts).
-  - `index.ts` re-exports utility API (`agent-variant`, `env`, `polling`, `logger`, `session`, `subagent-depth`, etc.).
+  - Pulls constants from `../config` (`DEFAULT_MAX_SUBAGENT_DEPTH`, polling
+    intervals/timeouts).
+  - `index.ts` re-exports utility API.

+ 0 - 1
src/utils/index.ts

@@ -5,6 +5,5 @@ export * from './internal-initiator';
 export { getLogDir, initLogger, log, resetLogger } from './logger';
 export * from './polling';
 export * from './session';
-export * from './session-manager';
 export * from './task';
 export { extractZip } from './zip-extractor';

+ 0 - 180
src/utils/session-manager.test.ts

@@ -1,180 +0,0 @@
-import { describe, expect, test } from 'bun:test';
-import { deriveTaskSessionLabel, SessionManager } from './session-manager';
-
-describe('SessionManager', () => {
-  test('keeps most recently used sessions within limit', () => {
-    const manager = new SessionManager(2);
-
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-1',
-      agentType: 'explorer',
-      label: 'first thread',
-    });
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-2',
-      agentType: 'explorer',
-      label: 'second thread',
-    });
-    manager.markUsed('parent-1', 'explorer', 'task-1');
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-3',
-      agentType: 'explorer',
-      label: 'third thread',
-    });
-
-    const prompt = manager.formatForPrompt('parent-1');
-    expect(prompt).toContain('completed/reconciled threads');
-    expect(prompt).toContain('Background Job Board');
-    expect(prompt).toContain('exp-1 first thread');
-    expect(prompt).toContain('exp-3 third thread');
-    expect(prompt).not.toContain('exp-2 second thread');
-  });
-
-  test('clears parent-scoped sessions', () => {
-    const manager = new SessionManager(2);
-
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-1',
-      agentType: 'oracle',
-      label: 'architecture',
-    });
-
-    manager.clearParent('parent-1');
-
-    expect(manager.formatForPrompt('parent-1')).toBeUndefined();
-  });
-
-  test('includes read context for remembered sessions', () => {
-    const manager = new SessionManager(2);
-
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-1',
-      agentType: 'explorer',
-      label: 'session manager',
-    });
-    manager.addContext('task-1', [
-      { path: 'src/index.ts', lineCount: 42, lastReadAt: 1 },
-      {
-        path: 'src/multiplexer/session-manager.ts',
-        lineCount: 24,
-        lastReadAt: 2,
-      },
-    ]);
-
-    const prompt = manager.formatForPrompt('parent-1');
-    expect(prompt).toContain('exp-1 session manager');
-    expect(prompt).toContain(
-      'Context read by exp-1: src/multiplexer/session-manager.ts (24 lines), src/index.ts (42 lines)',
-    );
-  });
-
-  test('filters tiny reads and caps read context files', () => {
-    const manager = new SessionManager(2);
-
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-1',
-      agentType: 'explorer',
-      label: 'large context',
-    });
-    manager.addContext(
-      'task-1',
-      Array.from({ length: 10 }, (_, index) => ({
-        path: `file-${index}.ts`,
-        lineCount: index === 0 ? 9 : 20 + index,
-        lastReadAt: index,
-      })),
-    );
-
-    const prompt = manager.formatForPrompt('parent-1') ?? '';
-    expect(prompt).not.toContain('file-0.ts');
-    expect(prompt).toContain('file-9.ts (29 lines)');
-    expect(prompt).toContain('(+1 more)');
-  });
-
-  test('uses configurable read context thresholds', () => {
-    const manager = new SessionManager(2, {
-      readContextMinLines: 5,
-      readContextMaxFiles: 1,
-    });
-
-    manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-1',
-      agentType: 'explorer',
-      label: 'custom thresholds',
-    });
-    manager.addContext('task-1', [
-      { path: 'small.ts', lineCount: 4, lastReadAt: 1 },
-      { path: 'medium.ts', lineCount: 5, lastReadAt: 2 },
-      { path: 'large.ts', lineCount: 12, lastReadAt: 3 },
-    ]);
-
-    const prompt = manager.formatForPrompt('parent-1') ?? '';
-    expect(prompt).not.toContain('small.ts');
-    expect(prompt).toContain('large.ts (12 lines)');
-    expect(prompt).not.toContain('medium.ts');
-    expect(prompt).toContain('(+1 more)');
-  });
-
-  test('bounds stored read context files to the render cap plus overflow marker', () => {
-    const manager = new SessionManager(2, {
-      readContextMinLines: 1,
-      readContextMaxFiles: 2,
-    });
-
-    const remembered = manager.remember({
-      parentSessionId: 'parent-1',
-      taskId: 'task-1',
-      agentType: 'explorer',
-      label: 'bounded context',
-    });
-    manager.addContext(
-      'task-1',
-      Array.from({ length: 10 }, (_, index) => ({
-        path: `file-${index}.ts`,
-        lineCount: 10,
-        lastReadAt: index,
-      })),
-    );
-
-    expect(remembered.contextFiles).toHaveLength(3);
-    const prompt = manager.formatForPrompt('parent-1') ?? '';
-    expect(prompt).toContain('file-9.ts (10 lines)');
-    expect(prompt).toContain('file-8.ts (10 lines)');
-    expect(prompt).toContain('(+1 more)');
-    expect(prompt).not.toContain('file-0.ts');
-  });
-});
-
-describe('deriveTaskSessionLabel', () => {
-  test('prefers description over prompt', () => {
-    expect(
-      deriveTaskSessionLabel({
-        description: 'config schema lookup',
-        prompt: 'ignored prompt line',
-        agentType: 'explorer',
-      }),
-    ).toBe('config schema lookup');
-  });
-
-  test('falls back to prompt then generic label', () => {
-    expect(
-      deriveTaskSessionLabel({
-        prompt: '\n  inspect task resumption support  \nmore context',
-        agentType: 'explorer',
-      }),
-    ).toBe('inspect task resumption support');
-
-    expect(
-      deriveTaskSessionLabel({
-        agentType: 'fixer',
-      }),
-    ).toBe('recent fixer task');
-  });
-});

+ 0 - 357
src/utils/session-manager.ts

@@ -1,357 +0,0 @@
-import type { AgentName } from '../config';
-
-export interface ContextFile {
-  path: string;
-  lineCount: number;
-  lineNumbers?: number[];
-  lastReadAt: number;
-}
-
-export interface RememberedTaskSession {
-  alias: string;
-  taskId: string;
-  agentType: AgentName;
-  label: string;
-  contextFiles: ContextFile[];
-  createdAt: number;
-  lastUsedAt: number;
-}
-
-type SessionGroupMap = Map<AgentName, RememberedTaskSession[]>;
-
-const MIN_CONTEXT_FILE_LINES = 10;
-const MAX_CONTEXT_FILES_PER_SESSION = 8;
-
-interface SessionManagerOptions {
-  readContextMinLines?: number;
-  readContextMaxFiles?: number;
-}
-
-function aliasPrefix(agentType: AgentName): string {
-  switch (agentType) {
-    case 'explorer':
-      return 'exp';
-    case 'librarian':
-      return 'lib';
-    case 'oracle':
-      return 'ora';
-    case 'designer':
-      return 'des';
-    case 'fixer':
-      return 'fix';
-    case 'observer':
-      return 'obs';
-    case 'council':
-      return 'cnc';
-    case 'councillor':
-      return 'clr';
-    case 'orchestrator':
-      return 'orc';
-  }
-}
-
-function normalizeWhitespace(value: string): string {
-  return value.replace(/\s+/g, ' ').trim();
-}
-
-export function deriveTaskSessionLabel(input: {
-  description?: string;
-  prompt?: string;
-  agentType: AgentName;
-}): string {
-  const preferred = normalizeWhitespace(input.description ?? '');
-  if (preferred) {
-    return preferred.slice(0, 48);
-  }
-
-  const firstPromptLine = (input.prompt ?? '')
-    .split(/\r?\n/)
-    .map((line) => normalizeWhitespace(line))
-    .find(Boolean);
-
-  if (firstPromptLine) {
-    return firstPromptLine.slice(0, 48);
-  }
-
-  return `recent ${input.agentType} task`;
-}
-
-export class SessionManager {
-  private readonly maxSessionsPerAgent: number;
-  private readonly readContextMinLines: number;
-  private readonly readContextMaxFiles: number;
-  private readonly sessionsByParent = new Map<string, SessionGroupMap>();
-  private readonly nextAliasIndexByParent = new Map<
-    string,
-    Map<AgentName, number>
-  >();
-  private orderCounter = 0;
-
-  constructor(
-    maxSessionsPerAgent: number,
-    options: SessionManagerOptions = {},
-  ) {
-    this.maxSessionsPerAgent = maxSessionsPerAgent;
-    this.readContextMinLines =
-      options.readContextMinLines ?? MIN_CONTEXT_FILE_LINES;
-    this.readContextMaxFiles =
-      options.readContextMaxFiles ?? MAX_CONTEXT_FILES_PER_SESSION;
-  }
-
-  remember(input: {
-    parentSessionId: string;
-    taskId: string;
-    agentType: AgentName;
-    label: string;
-  }): RememberedTaskSession {
-    const now = this.nextOrder();
-    const group = this.getAgentGroup(
-      input.parentSessionId,
-      input.agentType,
-      true,
-    );
-    if (!group) {
-      throw new Error('Failed to initialize session group');
-    }
-    const existing = group.find((entry) => entry.taskId === input.taskId);
-
-    if (existing) {
-      existing.label = input.label;
-      existing.lastUsedAt = this.nextOrder();
-      return existing;
-    }
-
-    const remembered: RememberedTaskSession = {
-      alias: this.nextAlias(input.parentSessionId, input.agentType),
-      taskId: input.taskId,
-      agentType: input.agentType,
-      label: input.label,
-      contextFiles: [],
-      createdAt: now,
-      lastUsedAt: now,
-    };
-
-    group.push(remembered);
-    this.trimGroup(group);
-    return remembered;
-  }
-
-  markUsed(parentSessionId: string, agentType: AgentName, key: string): void {
-    const group = this.getAgentGroup(parentSessionId, agentType, false);
-    const match = group?.find(
-      (entry) => entry.alias === key || entry.taskId === key,
-    );
-
-    if (match) {
-      match.lastUsedAt = this.nextOrder();
-    }
-  }
-
-  resolve(parentSessionId: string, agentType: AgentName, key: string) {
-    const group = this.getAgentGroup(parentSessionId, agentType, false);
-    return group?.find((entry) => entry.alias === key || entry.taskId === key);
-  }
-
-  drop(parentSessionId: string, agentType: AgentName, key: string): void {
-    const group = this.getAgentGroup(parentSessionId, agentType, false);
-    if (!group) return;
-
-    const next = group.filter(
-      (entry) => entry.alias !== key && entry.taskId !== key,
-    );
-    this.setAgentGroup(parentSessionId, agentType, next);
-  }
-
-  dropTask(taskId: string): void {
-    for (const [parentSessionId, groups] of this.sessionsByParent.entries()) {
-      for (const [agentType, group] of groups.entries()) {
-        const next = group.filter((entry) => entry.taskId !== taskId);
-        this.setAgentGroup(parentSessionId, agentType, next);
-      }
-    }
-  }
-
-  taskIds(): Set<string> {
-    const ids = new Set<string>();
-    for (const groups of this.sessionsByParent.values()) {
-      for (const group of groups.values()) {
-        for (const entry of group) {
-          ids.add(entry.taskId);
-        }
-      }
-    }
-    return ids;
-  }
-
-  addContext(taskId: string, files: ContextFile[]): void {
-    if (files.length === 0) return;
-
-    for (const groups of this.sessionsByParent.values()) {
-      for (const group of groups.values()) {
-        const match = group.find((entry) => entry.taskId === taskId);
-        if (!match) continue;
-
-        const existing = new Map(
-          match.contextFiles.map((file) => [file.path, file]),
-        );
-        for (const file of files) {
-          const previous = existing.get(file.path);
-          if (previous) {
-            previous.lineCount = Math.max(previous.lineCount, file.lineCount);
-            previous.lastReadAt = Math.max(
-              previous.lastReadAt,
-              file.lastReadAt,
-            );
-            continue;
-          }
-          match.contextFiles.push({ ...file });
-        }
-        this.trimContextFiles(match);
-      }
-    }
-  }
-
-  clearParent(parentSessionId: string): void {
-    this.sessionsByParent.delete(parentSessionId);
-    this.nextAliasIndexByParent.delete(parentSessionId);
-  }
-
-  formatForPrompt(parentSessionId: string): string | undefined {
-    const groups = this.sessionsByParent.get(parentSessionId);
-    if (!groups || groups.size === 0) return undefined;
-
-    const lines = [...groups.entries()]
-      .map(
-        ([agentType, entries]) =>
-          [
-            agentType,
-            [...entries].sort((a, b) => b.lastUsedAt - a.lastUsedAt),
-          ] as const,
-      )
-      .filter(([, entries]) => entries.length > 0)
-      .sort((a, b) => b[1][0].lastUsedAt - a[1][0].lastUsedAt)
-      .map(([agentType, entries]) =>
-        [
-          `- ${agentType}: ${entries
-            .map((entry) => `${entry.alias} ${entry.label}`)
-            .join('; ')}`,
-          ...entries
-            .map(
-              (entry) =>
-                [
-                  entry,
-                  formatContextFiles(entry.contextFiles, {
-                    minLines: this.readContextMinLines,
-                    maxFiles: this.readContextMaxFiles,
-                  }),
-                ] as const,
-            )
-            .filter(([, context]) => context.length > 0)
-            .map(
-              ([entry, context]) =>
-                `  Context read by ${entry.alias}: ${context}`,
-            ),
-        ].join('\n'),
-      );
-
-    if (lines.length === 0) return undefined;
-
-    return [
-      '### Resumable Sessions',
-      'Reuse only completed/reconciled threads. Poll running jobs from Background Job Board.',
-      '',
-      ...lines,
-    ].join('\n');
-  }
-
-  private getAgentGroup(
-    parentSessionId: string,
-    agentType: AgentName,
-    create: boolean,
-  ): RememberedTaskSession[] | undefined {
-    let groups = this.sessionsByParent.get(parentSessionId);
-    if (!groups && create) {
-      groups = new Map();
-      this.sessionsByParent.set(parentSessionId, groups);
-    }
-
-    let group = groups?.get(agentType);
-    if (!group && create && groups) {
-      group = [];
-      groups.set(agentType, group);
-    }
-
-    return group;
-  }
-
-  private setAgentGroup(
-    parentSessionId: string,
-    agentType: AgentName,
-    entries: RememberedTaskSession[],
-  ): void {
-    const groups = this.sessionsByParent.get(parentSessionId);
-    if (!groups) return;
-
-    if (entries.length === 0) {
-      groups.delete(agentType);
-      if (groups.size === 0) {
-        this.sessionsByParent.delete(parentSessionId);
-        this.nextAliasIndexByParent.delete(parentSessionId);
-      }
-      return;
-    }
-
-    groups.set(agentType, entries);
-  }
-
-  private nextAlias(parentSessionId: string, agentType: AgentName): string {
-    let counters = this.nextAliasIndexByParent.get(parentSessionId);
-    if (!counters) {
-      counters = new Map();
-      this.nextAliasIndexByParent.set(parentSessionId, counters);
-    }
-
-    const next = (counters.get(agentType) ?? 0) + 1;
-    counters.set(agentType, next);
-    return `${aliasPrefix(agentType)}-${next}`;
-  }
-
-  private trimGroup(group: RememberedTaskSession[]): void {
-    group.sort((a, b) => b.lastUsedAt - a.lastUsedAt);
-    if (group.length > this.maxSessionsPerAgent) {
-      group.length = this.maxSessionsPerAgent;
-    }
-  }
-
-  private trimContextFiles(entry: RememberedTaskSession): void {
-    if (this.readContextMaxFiles === 0) {
-      entry.contextFiles = [];
-      return;
-    }
-
-    entry.contextFiles = entry.contextFiles
-      .filter((file) => file.lineCount >= this.readContextMinLines)
-      .sort((a, b) => b.lastReadAt - a.lastReadAt)
-      .slice(0, this.readContextMaxFiles + 1);
-  }
-
-  private nextOrder(): number {
-    this.orderCounter += 1;
-    return this.orderCounter;
-  }
-}
-
-function formatContextFiles(
-  files: ContextFile[],
-  options: { minLines: number; maxFiles: number },
-): string {
-  const eligible = files
-    .filter((file) => file.lineCount >= options.minLines)
-    .sort((a, b) => b.lastReadAt - a.lastReadAt);
-  const shown = eligible.slice(0, options.maxFiles);
-  const rest = eligible.length - shown.length;
-  const rendered = shown.map(
-    (file) => `${file.path} (${file.lineCount} lines)`,
-  );
-  return `${rendered.join(', ')}${rest > 0 ? ` (+${rest} more)` : ''}`;
-}