# Technical guide

This guide describes the design and operation of `pi-codemode`.
It targets maintainers, integrators, and operators.

The package is a source-only pi extension. It targets pi 0.83.0 and Node.js
24.11 or newer. The public contract is in [`README.md`](./README.md).

This guide uses ASD-STE100 principles. It uses short sentences, active voice,
and controlled terms. It does not claim ASD certification.

## Package contract

The package exposes `src/index.ts` as its extension entry point.
The package does not build JavaScript before load. pi loads the TypeScript source.

The package requires these host surfaces:

- `@earendil-works/pi-coding-agent` 0.83.0 or a compatible 0.83 release.
- Node.js 24.11 or newer.
- TypeBox 1.3.7 at the host boundary.

The package uses `@earendil-works/pi-tui` only for tests and QA.
It does not use that package at runtime.

The package publishes these files:

- `src/`
- `README.md`
- `TECHNICAL.md`
- `LICENSE`

## Extension lifecycle

The extension creates a baseline `eval` definition during load.
This definition lets pi discover the tool before session settings resolve.

The extension then performs these actions:

1. Read settings during `session_start`.
2. Detect available interpreters.
3. Resolve active task-tool names.
4. Create the session manager and language kernels.
5. Replace the baseline `eval` definition.
6. Re-register `eval` when the active model changes.
7. Close kernels during `session_shutdown` or session replacement.

The prompt builder selects a dialect from the active model identifier.
GPT models receive a terse dialect with detached-cell guidance.

A session generation owns its kernels, bridge, callbacks, and artifact root.
A new generation retires the old generation before it releases ownership.
Retired callbacks cannot write into the new session.

## Execution model

The `eval` schema supports two request types.

| Request | Required fields | Result |
| --- | --- | --- |
| Run | `language`, `code` | Execute one cell in a persistent kernel. |
| Control | `action`, `cell_id` | Peek or stop a detached cell. |

The run request also accepts `title`, `timeout`, `on_timeout`, and `reset`.
The control request accepts `peek` or `stop`.

Each cell follows this flow:

1. Validate the request.
2. Select the language kernel.
3. Create one cell state object.
4. Bind callbacks for this cell.
5. Start the timeout and bridge signal.
6. Stream kernel messages into the cell state.
7. Wait for bridge calls to settle.
8. Build the result and render details.
9. Retire child work and settle the cell once.

The cell settles once for every terminal outcome. The outcomes include success,
error, timeout, abort, bridge failure, and kernel crash.

The host pauses the cell's idle timeout only while it is itself executing a
bridge call (tool dispatch, completion, reserved tools). The preludes still
send `timeout-pause`/`timeout-resume` status operations around bridge calls,
but the host ignores kernel-emitted pause frames: a cell cannot extend its
own deadline.

### Timeout and detach behavior

Interactive, RPC, and app-server sessions detach on timeout by default.
Print and JSON sessions return an error by default.
You can set `on_timeout` for one request.

Total cell wall time is roughly the timeout plus the summed durations of host
bridge calls: the watchdog is suspended only while a bridge call is in flight
and re-arms a fresh full timeout when it returns. In error mode the kernel
also receives the timeout and arms its own hard timer, so a defeated host
watchdog still settles the cell; detached cells get no kernel timer because
they must be allowed to outlive the timeout.

A detached cell keeps its language kernel busy. A later call in that language
returns the cell identifier and an output tail. A call in another language runs.

`peek` returns running or terminal state. It includes code, title, output, phase,
status events, tool-call summaries, duration, and structured display state.

`stop` cancels the cell. The kernel then reports whether variables survived.
The result states whether the kernel remained available or restarted.

### Generation and ownership rules

- Keep one persistent kernel per enabled language.
- Rebind per-cell callbacks for every execution.
- Retire child bridge work before releasing cell ownership.
- Emit events only from the active generation.
- Reuse a detached language kernel only after it settles.

## Kernel architecture

The kernel manager selects one implementation for each language.

| Language | Implementation | State behavior |
| --- | --- | --- |
| JavaScript | Node worker with inline fallback, or `--jitless` child process | State survives cells. A restart loses state. |
| Python | Managed `python3` or `python` process | State survives cells when an interrupt preserves the process. |
| Ruby | Shared subprocess transport | State survives cells while the process remains active. |

JavaScript uses a worker protocol and a run queue. The host can restart a crashed
worker. The host rebinds message callbacks for each cell. The kernel has three
spawn modes: a worker thread (default), the inline fallback, and a `--jitless`
child process (opt-in via the `jitless` setting). V8 flags are fixed at process
start, so only the child process can run without a JIT.

JavaScript is the only hardened kernel. With the `hardenedCells` setting, the
runtime removes `console`, `Atomics`, `SharedArrayBuffer`, and `WebAssembly`
from the cell scope for each cell and restores them afterwards. The host
helpers (`print`, `display`, `read`, `write`, `tool`, and the rest) stay
available inside hardened cells.

Python uses a JSONL transport and an interrupt sequence. The sequence escalates
only when a cooperative interrupt does not settle.

Ruby uses the shared subprocess lifecycle. Missing interpreters produce
a capability gap. They do not produce an installation error.

The language preludes provide these helpers:

- `display`, `print`, `read`, `write`, and `env`.
- `tool.<name>` and `tool_schema`.
- `completion`, `agent`, and `output`.
- `parallel` and `pipeline`.
- `log` and `phase`.

`local://` resolves below the extension-owned artifact root. Containment is enforced both
lexically (traversal and absolute-path checks) and physically (`realpath` re-checks that
reject symlink escapes), but kernels still run with pi's process permissions — `local://`
is a confinement boundary, not a sandbox.
Spill notices use absolute paths instead of a custom URI.

## Host bridges

### Kernel bridge

The bridge binds to loopback only. It uses a random bearer token per session.
It accepts bounded JSONL request and response frames.

The bridge applies these controls:

- Reject requests with an invalid bearer token.
- Reject request bodies above the configured limit.
- Abort in-flight work when the client disconnects.
- Close the server during session replacement and shutdown.

Bridge tool calls run under a read timeout (`bridgeTimeoutSeconds`, default 120).
On timeout the kernel raises a cell error that names the tool and the cap, but
it does NOT close the socket. Orphaned host-side tool work therefore keeps
running until it finishes; the bridge aborts in-flight work only on socket
disconnect, which surfaces as a closed response (`response-close`). A follow-up
could send an explicit abort frame on timeout instead.

### Tool execution

pi 0.83.0 does not expose `pi.executeTool`.
`src/host/tool-execution.ts` provides `ToolExecutionShim`.

The shim executes tools registered by this package.
It validates parameters before it calls a tool.
It returns `unknown_tool` or `inactive_tool` for other tools.

`getActiveTools()` is the host's complete active-tool listing, not the shim's
callable registry. Therefore a tool can be known and active (for example,
`bash`) and still be refused because its executable definition belongs to
another extension. The refusal names this distinction explicitly.

Cooperative peers: extensions that OPT IN can expose their tools to eval cells
over pi's shared extension event bus (`pi.events`). The client probes once per
session; a bound tool is dispatched by the peer (running the owner's own
validation and context) after the approval gate, and an unbound tool keeps the
ordinary refusal. See `src/host/peer/` and the exported
`pi-codemode/peer-server` helper for extension authors.

The shim keeps the same availability error shape that the bridges expect.
The long-term replacement is a public pi host dispatcher; see
`plans/003-cross-extension-tool-execution.md`. Do not infer executability from
`getAllTools()`/`getActiveTools()` metadata or reach into pi's private runner.

The extension registers the `exec` error stub.
The stub explains that pi-codemode uses `eval` instead.
The `wait` tool is a real tool: `src/tool/wait-tool.ts` resumes detached eval
cells. It is registered host-only (not through the shim), so cells cannot call
`wait` from inside a cell — the same rule Codex applies to its wait tool.

### Agent, output, and completion bridges

`agent()` calls the configured active task tool.
`output()` calls the configured active task-output tool.
Both helpers use the same execute-tool path as `tool.<name>`.

`completion()` calls the pi-ai completion surface once.
A supplied schema requests structured output.

`tool_schema()` lists active tool definitions or returns one schema.
A failed tool call can receive a bounded schema hint.

The eval tool excludes itself from the nested tool namespace.
This rule prevents recursive eval calls.

## Output and rendering

The output layer separates collection from rendering.
It does not import TUI modules.

The output sink performs these actions:

1. Collect output during execution.
2. Keep bounded head and tail text.
3. Clamp rendered columns.
4. Write large output to the session artifact root.
5. Add truncation metadata and a plain absolute path.

The renderer displays these details:

- Syntax-highlighted code.
- Text, Markdown, JSON, and image values.
- Status events and task progress.
- Tool-call summaries.
- Truncation notices.
- Detached and timeout states.

Each cell keeps at most 100 status rows. After overflow, one omitted-count row
precedes the latest 99 events. Agent progress events use coalescing.

pi calls the call and result renderers as separate lanes inside one container.
The call lane shows a compact header after execution starts.
The result lane owns the pending, running, and terminal frame.

The result renderer stores its spinner frame in `context.state`.
It starts a 100 millisecond interval during partial output.
It clears the interval during the terminal render.
A long-tick backstop prevents a discarded row from keeping a timer.

Nested tool widgets are rendering-only. They do not change session state.
The capture limits are fixed:

- 30 enriched calls per cell.
- 4,096 serialized argument characters.
- 160 result code points.

Each widget also records call duration. It sanitizes the result preview.

The same renderer runs through `session.exportToHtml()`.
The HTML export driver verifies the custom eval call and result payload.

## Configuration model

`src/config/settings.ts` loads settings from the working directory and home,
**merging per key**: `~/.pi/agent/codemode.json` (global) overrides defaults,
`.pi/codemode.json` (project) overrides global, and `PI_CODEMODE_*`
environment variables override the matching language flag on top. The
loader returns the resolved settings plus a per-setting `sources` map
(`default` / `global` / `project`) so the `/codemode` menu can show where
every value came from.

The loader validates each file's JSON shape against the TypeBox schema.
Invalid data keeps the file's valid keys (if any) and emits a warning.

Environment variables override one language flag at a time.
The loader accepts only `1`, `true`, `0`, and `false` as override values.

The session manager receives the resolved settings.
It does not read settings from a kernel process.

### Runtime promotion

The `before_agent_start` hook (`src/index.ts`) appends a bounded eval/wait
guidance block (`src/extension/promotion.ts`) to the system prompt when
eval/wait are active and `promotion.enabled` is not `false`. The toggle is
part of the settings schema and the `/codemode` menu.

### /codemode menu

`src/config/codemode-menu.ts` implements the `/codemode` command: a dialog
menu (ctx.ui.select/confirm/input) over the effective merged configuration
with per-setting sources, language toggles with live interpreter detection
(`py`/`js`/`rb`), validated saves to `.pi/codemode.json` or
`~/.pi/agent/codemode.json`, and headless degradation that prints the
effective settings as JSON.

## Host differences

The port keeps these deliberate differences from senpi-codemode:

| Area | pi-codemode behavior | Cause |
| --- | --- | --- |
| Tool execution | The shim executes package tools. Other tools return availability errors, even when active in the host listing. | pi 0.83.0 has no `pi.executeTool`. |
| Removed tools | `exec` is an error stub. | pi 0.83.0 has no removed-tool hint API. |
| Rendering | The result lane owns the active frame and spinner. | pi lacks `hasResult`, `spinnerFrame`, and `imageProtocol`. |
| Names | Use `.pi/codemode.json` and `PI_CODEMODE_*`. | pi uses the `.pi` configuration directory. |
| `-ne` loading | Use an explicit `-e` path. | pi has no bundled extension slot. |
| Transcript output | Use `raw` or `tail`. | The port does not provide a budget helper or `artifact://`. |

The completion bridge uses the requested model as-is.
pi-ai 0.83.0 has no `upstreamModelId` field.
The bridge drops `extraBody` because the host type has no such field.

## Source map for maintainers

Use these paths when you need to inspect a behavior:

| Area | Main paths |
| --- | --- |
| Extension registration | `src/index.ts`, `src/extension/` |
| Settings | `src/config/` |
| Eval schema and execution | `src/tool/eval-tool.ts`, `src/tool/cell-handler.ts` |
| Detached cells | `src/tool/detached-*.ts` |
| Kernels | `src/kernels/` |
| Bridges | `src/bridge/`, `src/bridges/` |
| Tool shim | `src/host/tool-execution.ts` |
| Output | `src/output/` |
| Rendering | `src/tool/render.ts`, `src/tool/tool-widgets.ts` |
| Completion | `src/completion/` |
| Live QA | `scripts/qa-*.ts` |
| Contracts | `test/` |

Keep renderer imports out of `src/output/`.
Keep TypeScript strict and erasable.
Keep `any`, assertions, ignored diagnostics, and non-null assertions out
of the code.
Use Node-standard APIs and imports from this repository only.

## Operations

### Start pi with the extension

Run:

```bash
pi -ne -e ./src/index.ts
```

Confirm that `/tools` lists `eval`, `wait`, and `exec`.
Run a JavaScript cell. Confirm that it returns a value.

### Check a print run

Run:

```bash
pi -ne -e ./src/index.ts -p "eval js: 2+2"
```

The expected output is `4`.

### Check the package

Run the one-command gate:

```bash
npm run qa
```

This gate runs the typecheck, the unit tests, and every QA driver. It prints
`QA: all N checks passed` when everything passes. It skips drivers whose
runtimes are missing. Ruby is optional.

Run the individual checks when you need them:

```bash
npx tsc --noEmit
npm test
node scripts/qa-e2e-eval.ts
node scripts/qa-e2e-eval.ts --abort-scenario
node scripts/qa-detached-peek.ts
node scripts/qa-reserved-bridge.ts
node scripts/qa-timeout-state.ts
node scripts/qa-render-dump.ts --fixture success
node scripts/qa-html-export.ts
node scripts/qa-js-cell.ts --code "1+1"
node scripts/qa-py-cell.ts --code "1+1"
node scripts/qa-rb-cell.ts --code "1+1"   # requires Ruby
pi -ne -e ./src/index.ts -p "eval js: 2+2"
```

The verified run produced these results:

- TypeScript diagnostics: none.
- Vitest: 653 passed and 9 skipped.
- E2E: `LANGS: py,js`, `TRUNCATED: true`, `SPILL_EXISTS: true`, `RB_REJECTED: true`.
- Abort: `CANCELLED: true`, `STATE: 42`.
- Detached cells: running and terminal peek checks passed.
- Reserved bridge: task and task-output calls passed.
- Timeout: state readback returned `42`.
- HTML export: `HTML_EXPORT: true`, `EVAL_RENDERED: true`.

Ruby tests passed when Ruby was available.

### Troubleshooting

| Symptom | Action |
| --- | --- |
| `eval` is missing with `-ne` | Add the explicit `-e ./src/index.ts` path. |
| A language is missing | Install its interpreter or enable another language. |
| A cell reports a busy kernel | Peek or stop the detached cell. Do not run it again. |
| Output shows a full-output path | Read the absolute path in the session artifact root. |
| A tool returns `unknown_tool` | Check that pi or this package registered the tool. |
| A config file has no effect | Check JSON syntax and the settings search order. |

## Known advisories

- **GHSA-mh99-v99m-4gvg (high)** — `brace-expansion@5.0.7` is present inside
  `@earendil-works/pi-coding-agent`'s shrinkwrapped subtree (via
  `minimatch@10.2.5`). The root `overrides` block pins `brace-expansion@^5.0.8`,
  but npm does not apply root overrides inside shrinkwrapped dependency
  subtrees, so the override stays latent until upstream drops or re-pins its
  shrinkwrap. The advisory is dev-only: the vulnerable chain lives under the
  `devDependencies` copy of pi-coding-agent and never reaches the published
  extension runtime (reachability analysis, 2026-08-01: the published
  dependencies — pi-ai, typebox, and the babel packages — never include
  minimatch or brace-expansion). Re-check `npm audit` on each release; once pi
  upstream bumps minimatch, remove the override (one-line change).

## Release checklist

Before release, run these checks:

1. Run `npx tsc --noEmit`.
2. Run `npm test`.
3. Run the JavaScript and Python QA drivers.
4. Run the E2E, detach, timeout, render, and HTML export drivers.
5. Run the pi print-mode check.
6. Check the package file list.
7. Check that the README and this guide describe the current contract.
8. Remove temporary probes and output files.
9. Check for credentials, bearer tokens, and environment dumps.

The current implementation passed this checklist on pi 0.83.0.

### Check host modes

Run these commands to check pi mode handling:

```bash
pi -ne -e ./src/index.ts --mode json "eval js: 2+2"
pi -ne -e ./src/index.ts --mode rpc -p "eval js: 2+2"
pi -ne -p "list your tools"
```

The JSON and RPC commands run without renderer errors.
The last command does not load `eval` because it has no explicit `-e` path.
