# extension

Senpi ExtensionAPI composition layer: validates the host surface, registers components in a fixed order, and owns the shared cross-component seams. Component prose and registration-order rationale live in `../../AGENTS.md`; this file covers what exists only here.

## Anatomy

| Path | Purpose |
|------|---------|
| `types.ts` | Structural host ports: `SenpiExtensionAPI`, `ComponentContext`, `ComponentLogger`, `OmoSenpiComponent`. The sanctioned description of the host surface; components import these, never concrete senpi types. Optional members (`rpc`, `events`, `cwd`, `appendEntry`, `registerMcpServer`, ...) stay optional so older hosts still load. |
| `compose.ts` | `composeOmoSenpiExtension` - the activation sequence: publish `OMO_DAG_SDK_ROOT` and `OMO_AGENT_TOOLKIT_SDK_ROOT` BEFORE any component registers, and default `PYTHONPYCACHEPREFIX` to the OmO user cache folder (`python-cache-env.ts`; skipped when the user set either `PYTHONDONTWRITEBYTECODE` or `PYTHONPYCACHEPREFIX`, even empty) because every bash/terminal child inherits `process.env`: a bundled skill's Python must never write `__pycache__` into the signed desktop app (#11373), while user projects keep cached bytecode; capability mismatch logs one warning and disables the extension (never throws); registers the global `omo-senpi-disabled` flag plus one `omo-senpi-<name>-disabled` flag per component; installs the capture registry and idle coordinator before the component loop; hooks `session_shutdown` to retire that coordinator (senpi emits it on the OLD runner before invalidating the generation, so this is what keeps an armed batch-window flush from touching a stale `pi.sendMessage`); each `register` is individually try/caught so one failing component never blocks the rest. |
| `component-list.ts` | `createOmoSenpiComponents(taskComponent)`: the 30-component registration array; the task component is injected by the entry file. Order is load-bearing (documented in `../../AGENTS.md`). |
| `index.ts` | Source/dev entry: composes with the eager `createTaskComponent()` and default-exports the extension. |
| `bundled-index.ts` | Built-artifact entry: swaps in a lazy task shim that `await import("#omo-task-runtime")`; the alias is created by `plugin/scripts/build-extension.mjs`. |
| `idle-injection-coordinator.ts` | Shared idle-edge arbiter: components enqueue key-deduped, source-tagged injections; one flush per idle tick emits a single hidden `omo-senpi:wake` message wrapping all pending custom payloads. Production defers via a 200 ms `setTimeout` batch window (injectable `scheduleFlush` for tests) so notifications becoming ready together collapse into ONE steer injection. Passive entries (`passive: true`, used by kibitzer nudges) ride a flush that carries at least one non-passive entry and never cause a flush by themselves: a queue holding only passive entries flushes 0. Receipts are a contract: an ACCEPTED injection (`enqueue` returned true) always gets exactly one of `onFlushed` / `onDeliveryFailed`, a REFUSED one (returned false, coordinator retired) gets none and stays the caller's problem. `retire()` (compose calls it on `session_shutdown`) cancels the armed batch-window timer, hands every still-queued entry to `onDeliveryFailed` (`IdleInjectionRetiredError`) and clears the queue, and refuses every later `enqueue` - that emptied-and-unrefillable queue is the SINGLE mechanism making post-reload flushes harmless, so the flush path itself carries no retirement guard. Nothing is dropped in silence: task completions route the receipt to `CompletionNotifier.recordDeliveryFailure` (redelivered by the next `session_start` reconcile), team liveness retries, the lead poller releases its delivery reservation, and derived continuations (ulw, kibitzer) log and are re-derived. |
| `tool-capture-registry.ts` | Wraps `pi.registerTool` to capture full ToolDefinitions WITH live execute closures (`pi.getAllTools()` returns metadata without closures - plan-reviewer fix). Must wrap before any component registers; lsp registers earlier than task. |
| `tool-hook-status.ts` | `reportToolHookStatus(eventContext, message)` - shared "(OmO) Checking ..." tool-hook status reporting; silently no-ops without the host method. |
| `sdk-root-provisioning.ts` / `dag-sdk-root-provisioning.ts` | Publish existing SDK directories only. The DAG compatibility wrapper preserves its packaged/source-tree resolution and `DAG_SDK_ROOT_ENV` export; compose also publishes the agent toolkit SDK root. |
| `agent-toolkit-sdk.ts` | Self-contained eval SDK entry built to `plugin/runtime/agent-toolkit-sdk/sdk.js`, also mapped by `#omo-agent-toolkit-sdk` for host status reads. Exports the per-call session-bound singleton, explicit-context factory, env binder, manifest, operations, version and source-only types. |
| `toolkit-path-provisioning.ts` | Prepends `OMO_AGENT_TOOLKIT_BIN` / PATH for the packaged `../runtime/agent-toolkit` so component spawns resolve it without global bins. |
| `omo-task.ts` | Re-export shim for `createTaskComponent` (build-graph entry). |

## Conventions

- Components reach the shared seams only through the injected `ComponentContext` (`logger`, `config.getFlag`, `getCapturedTools`, `idleCoordinator`) - never module singletons.
- New cross-component seams are added here, not duplicated inside components.
- `session-start-ordering.test.ts` pins registration order; `dag-sdk.test.ts` / `toolkit-path-provisioning.test.ts` pin the env provisioning seams.

## Anti-patterns

- Don't import `@code-yeongyu/senpi` runtime values from components; type-only through `types.ts` ports (bundle purity depends on it).
- Don't add an eager task-engine import to `bundled-index.ts`; the built artifact lazy-loads `#omo-task-runtime` deliberately.
- Don't register components outside `component-list.ts`, and don't reorder it without checking inter-component dependencies.
