# senpi-task - Senpi Task State Machine + Tool Surface

**Generated:** 2026-07-17 / 7d664b96b

## OVERVIEW

The Senpi-coupled engine behind the `omo-senpi` task component: a durable task state machine, a persistent record store, two child runners (in-process and RPC process), a residency/TTL/reconcile lifecycle, an exactly-once completion notifier, a steering engine, a named-team runtime, and the 4 task + 7 lead-team `ToolDefinition`s. Package: `@oh-my-opencode/senpi-task` (private, `sideEffects: false`). `@code-yeongyu/senpi` and `typebox` are optional peers (`package.json:25`) so pure state/store/schema code stays runnable without a live Senpi import; runner and tool code that needs the Senpi surface is isolated. Do not import `packages/omo-opencode` from here.

## ANATOMY

| Area | Path | Purpose |
|------|------|---------|
| State machine | `src/state/` | `TaskStatus` (7: `pending`/`running`/`completed`/`error`/`cancelled`/`interrupted`/`lost`) and `ResidencyState` (5) enums, `TaskRecord`, and `transitionTaskRecord` with late/invalid-transition audits (`state/types.ts`, `state/transitions.ts`). |
| Store | `src/store/` | `createTaskRecordStore` JSONL record store with an in-memory read cache (mtime+size validated; `list()` prunes entries whose files vanished on disk) and a capped (16) LRU append-fd pool reusing open JSONL log handles; `resolveStateDir` (`<project_dir>/.omo/senpi-task` default, `store/state-dir.ts:6`), redaction, and the security test. |
| Runners | `src/runners/` | `InProcessRunner` (shares parent tool closures) and `RpcProcessRunner` (spawns a child Senpi process with JSON-RPC steer/abort/prompt). RPC internals under `src/runners/rpc/`. |
| Manager | `src/manager/` | `createTaskManager` wiring runners, concurrency, name registry, depth policy, execution-mode resolution, and transcript logging. |
| Lifecycle | `src/lifecycle/` | `createTaskLifecycle` - residency admission (`residency.ts`), session-shutdown suspension (`shutdown.ts`), crash reconcile and scoped resume revival (`reconcile.ts` + `reconcile-revival.ts`, batch admission under the fenced lease in `admission-lease.ts`), and the two-phase TTL sweep (`ttl.ts`, tombstones inside the record lock so deletion cannot orphan a live handle or a fresh revival claim). See SESSION SUSPEND AND RESUME REVIVAL. |
| Completion | `src/completion/` | `createCompletionNotifier` + `routeCompletion` - the exactly-once wake/deliver/buffer/queue routing table (`completion/routing.ts`). |
| Steering | `src/steering/` | `createSteeringEngine` - send / interrupt / cancel against a live or resident child. |
| Team | `src/team/` | Named-team registry, normalize/validate, durable mailboxes with injection-driven delivery, lead poller, member self-polling extension, tasklist, shutdown handshake, and runtime (`team/runtime.ts`). |
| Tools | `src/tools/` | `task/` (single or `tasks:[...]` batch spawn), `control/` (`task_send`/`task_cancel`), `output/` (`task_output`), `team/` (the 6 lead-only team tools). |
| Agents | `src/agents/` | `loadAgents` + `mapOmoConfigAgents` - omo.json agent definitions to task-tool targets - plus the builtin curated agents (`agents/builtin/`) and `resolveAgent` agent-aware model/persona resolution. |
| Category | `src/category/` | `resolveCategory` + per-provider builtin category tables (anthropic/openai/google/kimi), including the `requiresModel` activation gate. |
| Adversarial | `src/__adversarial__/` | Seeded 200-iteration chaos bench asserting the four W1 invariants (`chaos-bench.test.ts`). |

## PUBLIC API (`src/index.ts` barrel)

### Task tools (4, names as registered)

| Tool | Factory | File |
|------|---------|------|
| `task` | `createTaskTool` | `tools/task/tool.ts:9` (`TASK_TOOL_NAME`) |
| `task_send` | `createTaskSendTool` | `tools/control/send.ts` |
| `task_cancel` | `createTaskCancelTool` | `tools/control/cancel.ts:61` |
| `task_output` | `createTaskOutputTool` | `tools/output/output.ts` |

`task` is spawn-only. It accepts either one `prompt` or a non-empty `tasks:[...]` batch; synchronous batches aggregate every child result, while background batches return item ids and queue positions. Steer, resident-session revival, team messaging, and shutdown approval traffic goes through `task_send`; child output and single-child status/transcript peeks go through `task_output`.

### Team tools (6, lead-only)

`buildLeadTeamTools(deps)` returns them in canonical order (`tools/team/index.ts`): `team_create`, `team_delete`, `task_create`, `task_get`, `task_list`, `task_update`. Child/member sessions never receive the lead family. Each process member loads the bundled member extension in-child and receives only team-scoped `task_send`; lead mail is steered into the resident member's running turn. It never receives lead lifecycle or tasklist tools.

`packages/omo-opencode` is a separate build that still uses its prior task/team names; cross-edition parity is a deliberate follow-up outside this package.

### Engine primitives

### Category activation gating

A builtin category may declare `requiresModel` (a bare model id) in its `BuiltinCategoryDefinition`. `resolveCategory` treats such a category as unavailable - `model_unavailable`, excluded from `availableCategories`, and never routed through its fallback chain - unless the gate model is present in the live senpi registry. ANY explicit `omo.json` `categories.<name>` entry bypasses the gate, even a description-only one, mirroring `hasExplicitUserConfig` in `packages/omo-opencode/src/tools/delegate-task/categories.ts`. Gateway-transformed registry ids (`vercel/openai/gpt-5.6-sol`) satisfy a gate on their last path segment. Three builtins are gated today: `architect` on `claude-fable-5`, `ultrabrain` on `gpt-5.6-sol`, and `deep` on `gpt-5.6-sol`, each with a fallback chain trimmed to its own model family so the gate cannot be bypassed by a cross-family rung.

A **second, independent gate** runs alongside `requiresModel`: `isCategoryChainViable` (`category/builtins.ts`) drops any builtin whose fallback chain has zero rungs resolvable against the live registry. This applies to ALL builtins, not just the three with `requiresModel` - against a registry holding only an unrelated model, `resolveAvailableCategoryNames` returns `[]`. `resolveAvailableCategoryNames(config, registry)` (`category/resolver.ts`, re-exported from the barrel) is the public entry for the gated list; it degrades to the ungated list if the registry call throws.

The task tool description cannot consult the registry - it is built at tool registration, before the model registry is captured - so `listTaskCategories` keeps gated builtins listed and appends a ` (requires <model>)` annotation instead. A category carrying an explicit `omo.json` entry is listed without the annotation. Spawn-time `resolveCategory` remains the sole authoritative gate.

`createTaskManager`, `createTaskLifecycle`, `createCompletionNotifier` / `routeCompletion` / `shouldNotifyStatus`, `createSteeringEngine`, `InProcessRunner`, `RpcProcessRunner`, `createTaskRecordStore` / `resolveStateDir`, `transitionTaskRecord` / `createTaskRecord`, `resolveCategory`, `loadAgents` / `mapOmoConfigAgents`, `resolveAgent` / `BUILTIN_AGENTS` / `BUILTIN_AGENT_DEFAULTS` / `CURATED_READONLY_AGENT_NAMES`, plus the team runtime (`createTeam`, `deleteTeam`, `sendTeamMessage`, `createLeadPoller`, `WaitRegistry`, `resolveMemberExtensionEntryPath`, `createTeamTask`, `requestShutdown`/`approveShutdown`/`rejectShutdown`, ...) and their typed errors (`SenpiTeamSpecError`, `SenpiTeamRuntimeError`, `SenpiShutdownError`, `RunnerError`, `TaskRecordCollisionError`).

### Builtin curated agents

`agents/builtin/` ships four read-only curated subagents - `explore`, `librarian`, `metis`, `momus` - as `BUILTIN_AGENTS` / `BUILTIN_AGENT_DEFAULTS`, each pinned to `executionMode: "in-process"` with a senpi-adapted persona prompt, a 9-name tool allowlist (`read`, `find`, `grep`, `ls`, `bash`, `lsp_diagnostics`, `lsp_goto_definition`, `lsp_find_references`, `lsp_symbols`), and a mirrored per-agent fallback chain in `agents/builtin/fallback-chains.ts` (hand-mirrored from `packages/model-core/src/agent-model-requirements.ts`, same convention as `category/fallback-chains.ts`; no model-core dependency). For curated in-process children, `runners/in-process/curated-readonly-bash.ts` replaces Senpi's general shell with a same-name structured broker that directly executes only validated read-only GitHub queries and HTTPS retrievals; direct edit/write and mutating LSP tools remain excluded. `CURATED_READONLY_AGENT_NAMES` feeds `team/member-validator.ts`, which rejects a curated name in a team member spec because process-mode spawns (mandatory for members) drop persona instructions and the tool allowlist. `resolveAgent(name, agents, registry, options?)` resolves one merged agent definition into the persona (`instructions`, `toolAllowlist`, `agentType`, `agentExecutionMode`, `allowedSubagents`, `maxDepth`) plus a model, trying `def.model`, then each `def.models` entry, then the agent fallback chain; `disable: true` resolves `not_found`, and an explicit `options.modelOverride` skips registry access entirely so active headless explicit-model spawns keep working. The omo-senpi engine ignores `execution_mode` overrides for these four names so the boundary cannot be routed through the process runner; user-defined agents remain configurable. A successful resolution records `resolved_model.source: "agent"` (added to `RESOLVED_MODEL_SOURCES` in `state/types.ts` and parsed by `store/record-parse.ts`), alongside `"category"` and `"explicit"`.

### Plan-gated agents

`agents/invocation-guard.ts` classifies `metis` and `momus` as the plan-gated tier (`AGENT_INVOCATION_CONDITIONS`, `PLAN_GATED_AGENT_NAMES`): plan-review specialists spawnable only when the USER explicitly requested the `ulw-plan` workflow in this session (`hasUserRequested` - raw user input; a model-initiated SKILL.md read never satisfies it), a `.omo/plans/*.md` plan artifact was touched in-session at any root including worktrees (`hasPlanArtifact`), and `start-work` was never invoked (`hasInvoked`, any channel). `evaluateInvocationGuard(agentName, SkillInvocationState)` is the pure verdict - forbidden skills are checked before missing requirements because a post-`start-work` denial is terminal, and denial messages deliberately avoid naming any mechanical unlock step. The session-scoped state arrives through the optional `TaskToolDeps.resolveSkillInvocations(sessionId)`; an unwired dep fails CLOSED (no state, no proof of a user request). The task tool consults the gate in `tools/task/invocation-gate.ts` before `manager.start` on both the single-spawn path (result status `denied`) and the batch path (the item fails as `plan_unresolved`/`invalid_target` with the gate message), and `buildTaskToolDescription` partitions gated names into their own `Plan-gated agents (...)` line instead of the flat `Available agents` list. omo-senpi supplies the live session state; any other host wiring this engine must provide its own resolver or accept the closed default.

## TEAM DELIVERY MODEL

Team messaging is injection-driven over durable mailboxes. A send writes a durable unread JSON file and returns; delivery steers the message into the recipient's running turn without queuing an editable follow-up. The current lead owns one `createLeadPoller` per team whose durable `leadSessionId` matches the current session. The adapter ticks owned lead pollers on `session_start` and every second, but suspends ticks during compaction, session switching, and shutdown. Member inboxes are never polled by the adapter: each process member loads `member-extension/`, which owns that member's poller and scoped tools inside the child process.

Delivery is reservation-based: unread `<messageId>.json` becomes `.delivering-<messageId>.json`, then commits to `processed/<messageId>.json` only after the message is observed in the recipient session (the pre-injection `team_wait` claim path was removed). The processed file is the durable exactly-once ledger.

Persistence of the delivered `peer_message` envelope in the lead's session JSONL is checked by `createSessionMarkerIndex` (`team/messaging/session-marker-index.ts`): a per-path incremental byte-offset index that reads only bytes appended since the last check, so the many `messageId` lookups per tick are O(1) instead of re-reading and re-parsing the whole file. It handles file truncation/rotation by rescanning from zero, and reads nothing when the file has not grown.

Every `session_start` runs the durable recovery chain in order: flush or drop buffered completions, reconcile (revive the resumed session's suspended children; reattach durable process members), re-observe owned-member liveness, reclaim stale member and owned-lead reservations, redeliver unnotified completion notifications, await TTL cleanup, then poll owned leads and sync status. Dead process members with a persisted session are respawned without replaying their original prompt and rebound with `switch_session`; set `task.reattach_on_reconcile: false` only to retain the old lost-task behavior.

### Completion routing table (`completion/routing.ts`)

`shouldNotifyStatus` fires only for externally-caused terminals `completed`/`error`/`lost` (`routing.ts:4`); parent-initiated cancel/interrupt return synchronously in the tool result and never push. `routeCompletion` maps parent state to an action: `idle` -> `wake` and `streaming` -> `deliver_streaming`, both delivered unconditionally (no setting may suppress, delay, or split them - the omo-senpi coordinator batches every notification ready in the same window into ONE injection steered into the running turn at the next tool-call boundary), and `compacting`/`session_switching`/`session_shutdown` -> `buffer` until the parent settles (`routing.ts:12`).

## EXECUTION MODES

- **in-process (default)**: `InProcessRunner` runs the child through the SAME parent tool closures (`filterSharedParentTools` + `mergeChildCustomTools`), so a child sees the parent's live custom tools minus the `task_*`/`team_*` family. Child conversations persist under the same `<stateDir>/children/<taskId>/sessions/` layout as process mode, so a clean quit and a crash recover identically. Proven by the marker-tool test (`src/runners/in-process/marker-suppression.test.ts`).
- **process**: `RpcProcessRunner` spawns a child Senpi process; steering (`steer`/`abort`/`prompt`) crosses a JSON-RPC boundary (`src/runners/rpc/protocol-client.ts`), child transcripts land under `<stateDir>/children/<taskId>/sessions/<taskId>/`, and session-start reconciliation can respawn and `switch_session` to the newest persisted JSONL. Team members always use this mode so the member extension and durable inbox poller live inside the child.

Mode is chosen by `resolveExecutionMode` from the omo.json `task.default_execution_mode` and per-agent `execution_mode` (`src/manager/execution-mode.ts`).

## SESSION SUSPEND AND RESUME REVIVAL

Shutdown suspends instead of disposing. `suspendOnSessionShutdown({parentSessionId, reason})` (`lifecycle/shutdown.ts`) runs on every `session_shutdown` reason (`quit`/`reload`/`new`/`resume`/`fork`): live handles are still torn down (no-orphan law) but the records survive as `persisted_only` (in-process; clears `host_pid` and the last `pid`) or `rpc_detached` (process; keeps the last `pid` for orphan detection), with status, epochs, and run stats preserved and a `suspended` event appended carrying the reason. The scan covers TWO populations - every registry handle AND this session's `pending` records (a queued child has no handle and no session file yet, so a handle-only scan would strand it; pending records are dequeued and parked `persisted_only`) - matched on `parent_session_id === parentSessionId && host_pid === context.hostPid`. A deliberately-stopped child (`killed === true`, a durable record fact independent of status, or a `cancelled`/`lost` status) routes through the destruction port with the `cancel` cause and never revives. Per handle the order is: registry forget FIRST (stale outcome tracking loses ownership before `abort()` settles the turn, so no `cancelled`/`error` terminal can land on the suspended record), best-effort abort, rpc terminate, dispose, residency transition. `task.resume_children: false` runs the pre-feature dispose-all path unchanged.

Revival state lives on the record. `spawn_spec` is a union (`state/types.ts`): the legacy `{cwd}` process shape (persisted `extensions`/`member_env` are discarded as untrusted launch inputs) still feeds RPC respawn, while `SpawnSpecV1` (`{version: 1, cwd, prompt, instructions?, member_scoped_tool_names?}`, guard `isSpawnSpecV1`) carries only plain-data launch facts - never executable tools, auth, or registries - and is REQUIRED for in-process rebuild (`spawn_spec_unavailable` otherwise). `notify_on_terminal` persists the intent to notify the parent at a terminal state (set from `run_in_background` at spawn, persisted by `promoteToBackground`). `pending_steering` is the durable prelaunch queue, drained in persisted order when the child starts; `task_send` to a suspended child returns `not_continuable` ("suspended - resumes when its session is resumed") because messaging a suspended child must not wake it - resume its session instead.

`reconcileOnSessionStart(parentSessionId)` revives ONLY that session's children; called with `undefined` it runs just the legacy global crash sweep, and legacy resident orphans of OTHER sessions keep the pre-feature reattach/lost behavior either way. A foreign-live-owner guard runs for every status before anything mutates. Candidates split into two disjoint populations (`lifecycle/reconcile-revival.ts`, `lifecycle/residency.ts`). RECLAMATION covers orphaned `resident` records (dead owner, or `host_pid === self` with no live handle after a same-process switch): they already occupy a slot, so they bypass the capacity gate and are claimed by a per-record expected-owner CAS (`reclaimOrphanedResident`), and a `killed: true` orphan is disposed so it releases its slot. ADMISSION covers `persisted_only`/`rpc_detached` records needing a NEW slot, claimed as a batch under the per-parent-session admission lease with deterministic selection (non-terminal first, then terminal by `updated_at` DESC, tie-break `task_id` ASC) up to the free `residency_max_children` slots. Overflow stays suspended with `deferred/capacity` - never evicted, never lost.

The admission lease (`lifecycle/admission-lease.ts`, `<stateDir>/locks/session-<parentSessionId>.lock`) is a renewable owner-token lease `{pid, token, renewed_at}`. The holder refreshes `renewed_at` every second; takeover of a lease stale past three refresh intervals is a token CAS under the short record mutex (re-validating token AND staleness, never delete-then-create); the holder re-checks `isOwner()` before every mutation; release removes the file only while the token is still its own. A bounded 5s acquisition yields `deferred/lock_contended` for the whole batch instead of aborting session start. The critical section holds only record reads and `store.mutate` claims; all respawn I/O runs after release.

Outcomes add a `deferred` kind with reason codes `capacity`/`lock_contended`/`foreign_live_owner`/`model_unavailable`/`tools_unavailable`/`session_unavailable`/`spawn_spec_unavailable`/`team_inactive`/`reattach_disabled`/`rollback_failed` (`lifecycle/types.ts`). A retryable respawn failure rolls the claim back to the prior suspended residency and defers; an unrecoverable one disposes a terminal record (persisted result preserved) or marks a non-terminal one `lost`. Suspension never bumps `run_epoch`; a non-terminal reattach bumps it exactly once, a terminal reattach never. Respawn dispatches on `execution_mode` (`manager/manager-respawn.ts`): process children ride the existing RPC respawn, in-process children go through `InProcessRunner.resume` over the persisted session, gated by a fail-closed resolver that matches the persisted `resolved_model` provider + model_id exactly and otherwise yields `deferred/model_unavailable` - never a substitute model. No-rerun rules: a terminal record without a transcript is disposed, never relaunched; a non-terminal record without a session file relaunches exactly once from its persisted v1 prompt (senpi writes no JSONL before the first assistant message, so no work is re-run) and then drains `pending_steering`; only a record with neither file nor v1 spec falls to `lost`. An orphaned rpc record still carrying a live pid is terminated and proven dead before its replacement spawns. After revival only a child interrupted mid-turn receives the continuation nudge: the tail check (`manager/interrupted-turn.ts`) reads the ACTUAL last JSONL record (growing past the 64 KiB window when the final line is oversized), counts an assistant `stopReason: "aborted"` as interrupted, and never fires on a terminal record. Status surfaces label both suspended residencies `suspended` (`task_output` adds "suspended (resumes with session)").

Completion recovery rides the same session start: `reconcileUnnotifiedNotifications({sessionId, parentState})` (`completion/notifier.ts`, kept aliased as `reconcileFailedNotifications`) redelivers every terminal child of the resumed session that still owes a notification - the `notify_on_terminal` population (`notified_epoch < run_epoch`) AND legacy records whose only marker is a `notification_failed_epoch` - through the normal delivery path, dedupe identity `(task_id, run_epoch)`. Notification bookkeeping patches ONLY the epochs through a conditional `store.mutate`, so a concurrent residency/`host_pid` claim is never clobbered.

TTL deletion is a two-phase tombstone (`lifecycle/ttl.ts`): phase 1 inside the record lock re-validates the retention predicate against a fresh read and renames the record to `<taskId>.json.expunging` (invisible to `load`/`list`, so no revival claim can take it); phase 2 outside the lock destroys a live orphan rpc pid (`ttl` cause), deletes the children dir, spill, and log, then drops the tombstone. Every sweep first completes phase 2 for tombstones a crashed sweep left behind - a tombstoned record is committed to deletion and never resurrected. The retention predicate keeps records with a live registry handle, a live host claim (a foreign owner OR this process's fresh claim inside the claim/respawn window), any non-terminal status (a suspended child in flight is never TTL-deleted), an undelivered terminal notification from either population above, and `lost` process records until their pid is proven dead.

## QA

```sh
tsgo --noEmit -p packages/senpi-task/tsconfig.json
bun test packages/senpi-task
```

- Co-located `*.test.ts` throughout use given/when/then. The seeded chaos bench (`src/__adversarial__/chaos-bench.test.ts`, 200 iterations, `SEED=<label>` to rerun a seed) asserts: (1) exactly-once notification per `(task_id, run_epoch)`, (2) terminal idempotence, (3) no concurrency slot leak, (4) no unhandled rejection.
- Standalone manual QA scripts write a disposable fixture tree and never touch repo state: `bun packages/senpi-task/scripts/manual-qa.ts <evidence-dir>` (store + transitions), plus `manual-category-qa.ts`, `manual-agents-qa.ts`, `manual-output-qa.ts`.
- Live end-to-end proof runs through the `omo-senpi` task component drivers, not this package alone. `task-e2e.mjs` proves single and `tasks:[...]` batch delegation; `team-e2e.mjs` proves injection-driven delivery, reservation reclaim, and kill-between-inject-and-commit restart deduplication. See [`packages/omo-senpi/AGENTS.md`](../omo-senpi/AGENTS.md).

Parent: [`packages/AGENTS.md`](../AGENTS.md).
