command-code 1.82.5 → 1.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,70 @@
1
+ // Example mod: filter the model's answer as it streams, and stream a process's output.
2
+ //
3
+ // hooks.transformTextDelta sees every chunk of the answer before it is shown. Return the chunk
4
+ // to show (rewritten or not), or '' to hold it back. When the answer is complete it is called
5
+ // once more with {delta: '', done: true}: return whatever you held back. The transcript keeps
6
+ // exactly what was shown. It must be synchronous and fast: it runs inside the stream.
7
+ //
8
+ // A key can arrive split across chunks ("sk-ab" then "cdef..."), so this filter holds back a
9
+ // tail that might be the start of one and decides when the next chunk (or the end) arrives.
10
+ //
11
+ // cmd.process.spawn runs a command and hands each stdout/stderr chunk to onOutput as it is
12
+ // produced; cmd.exec only answers once the process exits.
13
+
14
+ import type {ModApi} from '@commandcode/harness';
15
+
16
+ const KEY_PATTERN = /\bsk-[A-Za-z0-9_-]{16,}/g;
17
+ // A tail that could still grow into a key: "s", "sk", "sk-", "sk-abc...".
18
+ const MAYBE_KEY_TAIL = /(?:\bs|\bsk|\bsk-[A-Za-z0-9_-]*)$/;
19
+
20
+ function redact(text: string): string {
21
+ return text.replace(KEY_PATTERN, '[REDACTED]');
22
+ }
23
+
24
+ export default function (cmd: ModApi): void {
25
+ // Held-back text, for the stream it belongs to. A retry or a new turn starts a new stream.
26
+ let held = '';
27
+ let stream = '';
28
+
29
+ cmd.hooks({
30
+ transformTextDelta: ({delta, done, turnNumber, attempt}) => {
31
+ const id = `${turnNumber}:${attempt}`;
32
+ if (id !== stream) {
33
+ stream = id;
34
+ held = '';
35
+ }
36
+ const text = held + delta;
37
+ if (done) {
38
+ held = '';
39
+ return redact(text);
40
+ }
41
+ const tail = MAYBE_KEY_TAIL.exec(text)?.[0] ?? '';
42
+ held = tail;
43
+ return redact(text.slice(0, text.length - tail.length));
44
+ },
45
+ });
46
+
47
+ cmd.addCommand({
48
+ name: 'stream',
49
+ description: 'Run a command and show its latest output line as it runs',
50
+ argumentHint: '<command>',
51
+ handler: async ({args}) => {
52
+ if (!args.trim()) return {message: 'usage: /stream <command>'};
53
+ let lines = 0;
54
+ cmd.ui.setStatus(`running ${args}`);
55
+ try {
56
+ const {code} = await cmd.process.spawn({
57
+ command: args,
58
+ onOutput: (chunk, from) => {
59
+ const last = chunk.trimEnd().split('\n').at(-1) ?? '';
60
+ lines += chunk.split('\n').length - 1;
61
+ if (last) cmd.ui.setStatus(`${from}: ${last}`);
62
+ },
63
+ });
64
+ return {message: `${args} exited ${code} after ${lines} lines`};
65
+ } finally {
66
+ cmd.ui.setStatus(null);
67
+ }
68
+ },
69
+ });
70
+ }
@@ -12,8 +12,15 @@
12
12
  | `cmd.cwd` | `string` | workspace root |
13
13
  | `cmd.session` | `ModSessionApi \| undefined` | persistence seam; undefined until bound |
14
14
  | `cmd.events` | `ModEventBus` | `emit(channel, data?)` / `on(channel, handler)` cross-mod bus |
15
- | `cmd.ui` | `ModUi` | `notify` / `confirm` / `select` / `input` / `setStatus` / `widget` / `refreshWidgets` / `capabilities` |
15
+ | `cmd.ui` | `ModUi` | `notify` / `confirm` / `select` / `input` / `setStatus` / `widget` / `refreshWidgets` / `pane` / `openPane` / `closePane` / `toast` / `copy` / `override` / `setTitle` / `capabilities` |
16
16
  | `cmd.sessions` | `ModSessionControls` | `compact` / `tree` / `leafId` / `navigateTree` / `setLabel` |
17
+ | `cmd.state` | `ModStateApi` | `get` / `set` / `subscribe` - session values that survive a hot reload |
18
+ | `cmd.store` | `ModStoreApi` | `get` / `set` / `delete` - values kept across sessions (`~/.commandcode/mods/.store/<mod>.json`) |
19
+ | `cmd.clock` | `ModClockApi` | `every` / `after` - timers cancelled with the mod |
20
+ | `cmd.model` | `ModModelApi` | `complete` - one prompt on the session's model client |
21
+ | `cmd.prompt` | `ModPromptApi` | `submit` - start a turn of its own once the session is idle |
22
+ | `cmd.process` | `ModProcessApi` | `spawn` - run a process and stream its output as it arrives |
23
+ | `cmd.audio` | `ModAudioApi` | `play` / `bell` - sound files shipped with the mod, and the terminal bell |
17
24
 
18
25
  ## Registration (factory-time; each returns `Disposable`)
19
26
 
@@ -28,6 +35,8 @@
28
35
  | `cmd.addShortcut` | `(keys: string, {description?; handler}) => Disposable` |
29
36
  | `cmd.addSkillsPath` | `(path: string) => Disposable` |
30
37
  | `cmd.on` | `(event: AgentEventType \| 'session_start' \| 'session_shutdown', handler) => Disposable` |
38
+ | `cmd.addAgent` | `(definition: {name; description; systemPrompt; tools?; disallowedTools?; model?; maxTurns?}) => Disposable` |
39
+ | `cmd.expose` | `(name, fn) => Disposable` |
31
40
 
32
41
  What each does:
33
42
 
@@ -38,6 +47,8 @@ What each does:
38
47
  - `cmd.on(event, handler)` - observe any `AgentEvent` plus the host lifecycle events `session_start` / `session_shutdown` (fired when a harness binds the host and when a session switch or dispose replaces it). Handlers are isolated: a throw becomes a `mod_error {hook: 'on:<event>'}` event, never a crash.
39
48
  - `cmd.addProvider(providerModule)` - register a model provider through the same `ProviderModule` seam the built-ins use (id, transport, auth hooks, model list). The host appends mod providers to its set - mods extend the provider set, never replace it.
40
49
  - `cmd.addRenderer(customType, data => lines)` - a renderer for a custom entry type; returns the lines to print (style them with ansi escapes - picocolors, `@commandcode/tui` helpers, or raw codes). First registration per type wins across mods.
50
+ - `cmd.addAgent(definition)` - a sub-agent type the model can delegate to through the `task` tool, next to the bundled and on-disk agents. `tools` defaults to `'*'`. An agent name that already exists (bundled, project, user, or another mod's) is skipped with a warning. Read live, so a reload's agents show up on the next turn.
51
+ - `cmd.expose(name, fn)` / `cmd.use(mod, name)` - share a function with other mods and call one another mod shared. `use` answers `undefined` until that mod has loaded and exposed the name, so call it from a hook or command, not only at factory time.
41
52
  - `cmd.addShortcut(keys, {description, handler})` - a keyboard shortcut in the interactive TUI. `keys` is a `modifier+key` chord that **must include Ctrl or Alt** (`alt+r`, `ctrl+r`); a modifier-less or Shift-only chord (`p`, `enter`, `shift+p`) is rejected with a warning, because it would also type into the prompt, and so is a `Ctrl+Shift` chord whose key reaches the terminal as a bare control byte — letters, digits, Enter, Tab, Backspace (`ctrl+shift+p`, `ctrl+shift+enter`) — where the Shift never arrives (arrow / navigation keys keep their modifiers, so `ctrl+shift+up` is fine). `ctrl+m`, `ctrl+i`, `ctrl+h` and `ctrl+[` are rejected too: terminals send them as Enter, Tab, Backspace and Escape. A chord a built-in already owns (`ctrl+c`, `ctrl+o`, an editor key like `ctrl+k`) is rejected the same way — the host's own bindings run first, so it would never fire — and so are the keys the prompt handles itself (`alt+w`, `ctrl+z`, `ctrl+g`, `ctrl+s`, `ctrl+v`, `alt+v`), which stay taken even when rebound. The handler gets the same `{args, ui, cwd, exec}` context a command handler does and may return the same `{message}` / `{prompt}` result. The host dispatches mod shortcuts AFTER its own bindings, so a mod can add keys but never shadow interrupt, exit, mode cycling, or the transcript toggles; `Esc` stays host-owned. First registration per chord wins across mods; `/hotkeys` lists them under **Mods**. Headless hosts never fire shortcuts.
42
53
  - `cmd.addSkillsPath(path)` - an extra skills location (one skill directory, or a directory of skills) for this session's skill discovery. Relative paths resolve against the mod's own file, so a package can point at the `./skills` it ships. Collected at factory time. A package can declare the same thing without code through its manifest - see [What a package ships](./packaging.md#what-a-package-ships).
43
54
 
@@ -50,6 +61,7 @@ What each does:
50
61
  | `cmd.queueMessage` | `({content, deliverAs?: 'steer' \| 'follow-up'}) => void` |
51
62
  | `cmd.sendUserMessage` | `(content, {deliverAs?}) => void` |
52
63
  | `cmd.exec` | `({command, args?, cwd?, signal?}) => Promise<{stdout, stderr, code}>` |
64
+ | `cmd.process.spawn` | `({command, args?, cwd?, signal?, onOutput: (chunk, stream) => void}) => Promise<{code}>` |
53
65
  | `cmd.setSessionName` / `setModel` / `setEffort` | `(value) => void` (buffered pre-bind) |
54
66
  | `cmd.getSessionName` / `getModel` / `getEffort` | `() => value \| undefined` (undefined pre-bind) |
55
67
  | `cmd.getContextUsage` | `() => {tokens, contextWindow, percent} \| undefined` |
@@ -59,10 +71,19 @@ What each does:
59
71
  | `cmd.getAllTools` / `getActiveTools` | `() => readonly string[]` |
60
72
  | `cmd.getToolInfo` | `() => readonly {name, description, readOnly, active}[]` |
61
73
  | `cmd.setActiveTools` | `(names: readonly string[]) => void` |
74
+ | `cmd.use` | `(mod, name) => fn \| undefined` |
75
+ | `cmd.state.get` / `set` / `subscribe` | `(key) => T \| undefined` / `(key, value) => void` / `(key, listener) => Disposable` |
76
+ | `cmd.store.get` / `set` / `delete` | `(key) => T \| undefined` / `(key, value) => Promise<void>` / `(key) => Promise<void>` |
77
+ | `cmd.clock.every` / `after` | `(ms, callback) => Disposable` |
78
+ | `cmd.model.complete` | `({prompt, system?, model?, maxOutputTokens?, signal?}) => Promise<ModModelCompleteResult>` |
79
+ | `cmd.prompt.submit` | `(text) => void` |
80
+ | `cmd.ui.copy` | `(text) => Promise<ModCopyResult>` |
81
+ | `cmd.audio.play` | `({file, timeoutMs?}) => Promise<ModAudioResult>` |
82
+ | `cmd.audio.bell` | `() => ModAudioResult` |
62
83
 
63
84
  Details:
64
85
 
65
- - `cmd.ui` - `notify` (a `notice` feed row), `confirm` / `select` / `input` (the Interaction question modal in the TUI; deterministic defaults headless: false/undefined), `setStatus`, `widget`, `refreshWidgets`, `setTitle`. The full dialog/status/widget contract is in [UI surface](./ui.md#ui-surface).
86
+ - `cmd.ui` - `notify` (a `notice` feed row), `confirm` / `select` / `input` (the Interaction question modal in the TUI; deterministic defaults headless: false/undefined), `setStatus`, `widget`, `refreshWidgets`, `pane` + `openPane(id, {focus?})` / `closePane`, `toast`, `override`, `setTitle`. The full dialog/status/widget/override contract is in [UI surface](./ui.md#ui-surface).
66
87
  - `cmd.queueMessage({content, deliverAs})` - `steer` lands after the current tool batch (behind any queued user messages in the same poll), `follow-up` only when the run would stop (ahead of continuation nudges, behind queued user input). Queued messages steer a run that is already going; they never START one.
67
88
  - `cmd.sendUserMessage(content, {deliverAs})` - a REAL user message: echoed in the feed like typed input and, when the agent is idle, starting a turn right away. While a run is going, `steer` (default) queues it behind the active turn exactly as typing would, and `follow-up` holds it until the run would stop. Headless there is no submit path, so it falls back to `queueMessage` with the same `deliverAs`.
68
89
  - `cmd.getSessionName()` / `getModel()` / `getEffort()` - the live values behind the setters; `undefined` before a session binds.
@@ -75,14 +96,27 @@ Details:
75
96
  - `cmd.showEntry(customType, data)` - render a custom entry into the live feed through the renderer registered for that type (unrendered types pretty-print as JSON). The TUI wires the sink; headless runs drop entries. Pair with `cmd.session.appendCustomEntry` when the data should also persist.
76
97
  - `cmd.sessions` - live session controls: `compact()`, `tree()` (the session branch tree as stable `{id, label, children}` nodes), `leafId()`, `navigateTree({targetId, summarize?, customInstructions?})`, and `setLabel({targetId, label})`. These read live harness state, so unlike the buffered setters they throw with a clear message when no session is bound yet.
77
98
  - `cmd.exec({command, args})` - run a process through the harness Runtime (args are shell-quoted).
99
+ - `cmd.process.spawn({command, args, onOutput})` - the same command line as `cmd.exec`, but every stdout/stderr chunk reaches `onOutput(chunk, stream)` as the process produces it (`stream` is `'stdout'` or `'stderr'`), so a mod can show progress, tail a log, or react to a line without waiting for the exit. Resolves `{code}` when the process exits; aborting `signal` kills the process tree and rejects. A throwing `onOutput` becomes `mod_error {hook: 'on:process'}` and the process keeps running. Output is not buffered for you: keep what you need from the chunks.
78
100
  - `cmd.getAllTools()` / `cmd.getActiveTools()` / `cmd.setActiveTools(names)` - a mod-managed tool filter: disabled tools vanish from the model's schemas and refuse execution.
79
101
  - `cmd.events` - a tiny cross-mod pub/sub bus (`emit` / `on`).
102
+ - `cmd.state` - values the host holds for the session, namespaced per mod. They survive a hot reload (module variables start over), and `set` repaints every widget and pane, so a drawing that reads state is always current. `subscribe(key, listener)` hears this mod's writes to that key.
103
+ - `cmd.store` - values kept across sessions, one JSON file per mod. `get` is synchronous (the file is read before the factory runs); `set` and `delete` write through.
104
+ - `cmd.clock.every(ms, fn)` / `after(ms, fn)` - timers owned by the mod: cancelled by their `Disposable`, and all of them when the mod reloads. They never hold the process open, and a throwing callback becomes `mod_error` while the timer keeps its schedule.
105
+ - `cmd.model.complete({prompt})` - one text completion on the session's model client, with no transcript and no tools, billed and attributed like the session. It always resolves: `{isAnswered: true, text, model}` or `{isAnswered: false, reason: 'unavailable' | 'error' | 'aborted'}` (unavailable before a session binds).
106
+ - `cmd.prompt.submit(text)` - submit a prompt as if typed: in the TUI it starts a turn once the session is idle and queues behind a running one. Headless, it rides the follow-up queue.
107
+ - `cmd.ui.copy(text)` - write text to the system clipboard with the same helper `/copy` uses. See [Clipboard and sound](./ui.md#clipboard-and-sound).
108
+ - `cmd.audio.play({file})` / `cmd.audio.bell()` - play a sound file shipped with the mod, or ring the terminal bell. See [Clipboard and sound](./ui.md#clipboard-and-sound).
80
109
 
81
110
  ## `ModHooks` (the mutating lifecycle - all optional, all composable)
82
111
 
83
- `transformContext`, `appendSystemPrompt`, `beforeToolCall`, `afterToolCall`, `onTurnStart`, `onTurnEnd`, `shouldStopAfterTurn`, `prepareNextTurn`, `onRunEnd`, `onStop`, plus the host-level `transformInput`, `onSessionStart`, and `onSessionEnd`.
112
+ `transformContext`, `appendSystemPrompt`, `transformSystemPrompt`, `transformMessage`, `transformTextDelta`, `beforeModelRequest`, `beforeToolCall`, `afterToolCall`, `onTurnStart`, `onTurnEnd`, `shouldStopAfterTurn`, `prepareNextTurn`, `onRunEnd`, `onStop`, plus the host-level `transformInput`, `onPermissionRequest`, `onSessionStart`, and `onSessionEnd`.
84
113
 
85
- - `beforeToolCall` returns `{block?, input?, additionalContext?, terminate?}`.
114
+ - `beforeToolCall` returns `{block?, input?, additionalContext?, terminate?, result?}`. `result` answers the call without running the tool.
115
+ - `beforeModelRequest` returns `{model?}` for one request.
116
+ - `transformSystemPrompt` returns the full system prompt to send.
117
+ - `transformMessage` returns the message the transcript keeps.
118
+ - `transformTextDelta` returns the answer chunk to show (synchronously), or `''` to drop or hold it back.
119
+ - `onPermissionRequest` returns `'allow' | 'deny' | undefined`.
86
120
  - `afterToolCall` receives an `isError` param (PostToolUse vs PostToolUseFailure) and returns `{content?, isError?, additionalContext?, terminate?, modState?}`.
87
121
  - `onStop` receives `{state, stopReason, turnNumber, lastAssistantText}` and returns `{continue?, reason?}`.
88
122
  - `transformInput` returns `{action:'continue'} \| {action:'transform', text} \| {action:'handled', message?}`.
@@ -92,7 +126,8 @@ The per-hook contracts, ordering guarantees, and error policy are in [the hook c
92
126
 
93
127
  - `transformInput({text})` intercepts typed user prompts before they reach the model (the mods' **UserPromptSubmit** hook): return `{action: 'transform', text}` to rewrite (handlers chain - the next sees the rewrite), `{action: 'handled', message?}` to consume the prompt entirely (an optional info row renders in its place), or `undefined`/`{action: 'continue'}` to pass through. Only real typed input is intercepted - automated turns, meta messages, image-carrying prompts, and slash commands never route through it; a throwing handler is skipped (`mod_error {hook: 'transformInput'}`), never a swallowed prompt. It lives in `hooks` because it MUTATES what the agent sees.
94
128
  - `onStop({state, stopReason, turnNumber, lastAssistantText})` is the **Stop** hook - return `{continue: true, reason?}` to force a run that would otherwise finish to keep going (the reason rides an automated turn). Fires only on natural completion, any-mod wins, and the loop caps consecutive continuations.
95
- - `onSessionStart({source})` / `onSessionEnd({reason})` are the once-per-session **SessionStart** / **SessionEnd** hooks - fired when the host binds to / tears down a session (`source: 'startup' | 'resume'`, `reason: 'shutdown' | 'replaced'`). Act through the mod's captured `cmd`; they run fire-and-forget (a throw becomes `mod_error`), so a session hook never blocks bind/dispose. Pure observation is also available via `cmd.on('session_start' | 'session_shutdown')` - these named hooks add the typed metadata.
129
+ - `onPermissionRequest({toolName, input, description, isSafetyPrompt})` answers a permission prompt before the person sees it. It fires only where the engine would ask a human, so deny rules, plan mode, and dont-ask have already decided everything they cover. Every hook is asked, and **deny wins**: any `'deny'` answers the prompt, and `'allow'` holds only when no hook denies, so a mod that blocks something cannot be overridden by another that allows it. A throwing hook is skipped (`mod_error`). On a safety-forced prompt (`isSafetyPrompt`: a sensitive write, a risky command) `'deny'` works but `'allow'` is ignored and the person is asked.
130
+ - `onSessionStart({source})` / `onSessionEnd({reason})` are the once-per-session **SessionStart** / **SessionEnd** hooks - fired when the host binds to / tears down a session (`source: 'startup' | 'resume' | 'reload'`, `reason: 'shutdown' | 'replaced' | 'reload'`). `reload` is this mod's own hot reload, in the same session: the old code ends with it and the new code starts with it. Act through the mod's captured `cmd`; they run fire-and-forget (a throw becomes `mod_error`), so a session hook never blocks bind/dispose. Pure observation is also available via `cmd.on('session_start' | 'session_shutdown')` - these named hooks add the typed metadata.
96
131
 
97
132
  ## `on` event types
98
133
 
@@ -22,9 +22,12 @@ run({state, userInput?, config})
22
22
  ◆ onTurnStart (each mod, in order; returns new state)
23
23
  → turn_start {turnNumber}
24
24
  read live permission mode ONCE - pinned for this round
25
+ ◆ beforeModelRequest (each mod; later wins) → the model for THIS request; the
26
+ deferred-tools prompt and tool list below are built for it
25
27
  resolve systemPrompt (string | builder({sessionId, state, permissionMode}))
26
28
  ◆ appendSystemPrompt (each mod; non-empty returns joined with '\n\n', in
27
29
  registration order, and appended AFTER the base prompt)
30
+ ◆ transformSystemPrompt (each mod; the FULL prompt threaded mod→mod)
28
31
  ◆ transformContext (each mod; messages threaded mod→mod; result is EPHEMERAL -
29
32
  used for this call only, never written back to state.messages)
30
33
  prepareForSend (core wire-validity pass: orphan heal, merge, strip meta)
@@ -33,8 +36,14 @@ run({state, userInput?, config})
33
36
  modelClient.complete(...) (streams → text_delta / thinking_* /
34
37
  message_update / continuation_recovery /
35
38
  tool_input_coerced; retry → api_retry)
39
+ ◆ transformTextDelta on every streamed text chunk (each mod, threaded;
40
+ sync), BEFORE its → text_delta; a retry starts the chunk log over
36
41
  → model_request_end {model, usage, stopReason}
37
- → message_end {content}
42
+ ◆ transformTextDelta {delta: '', done: true} (flush; output → text_delta)
43
+ assistant text := the rewritten chunks, joined, per text block
44
+ → message_end {content} (the rewritten content)
45
+ ◆ transformMessage on every message before it is committed
46
+ (user input, assistant reply, tool results, steering, follow-ups)
38
47
  working += assistant message (provider-executed blocks filtered out)
39
48
  if client tool calls:
40
49
  runTools(...) (see tool-dispatch order below)
@@ -73,6 +82,7 @@ phase 2 - execution (sequential, or parallel when config.toolExecution='parallel
73
82
  execution uses the LAST mod's input; a mod may also set `terminate`)
74
83
  block ⇒ → tool_hook_blocked {hookOutput}; the block reason becomes the
75
84
  tool_result text; NO tool_running / tool_completed / tool_errored fires
85
+ result ⇒ the tool does NOT run; the answer is its (successful) output
76
86
  → tool_running {toolCallId, toolName, description}
77
87
  toolRunner.execute({toolName, input: <possibly rewritten>, signal, onUpdate})
78
88
  ◆ afterToolCall (each mod, in order; sees current content, may replace it,
@@ -124,6 +134,32 @@ Fires per tool call, after the permission check passed, before execution.
124
134
  - `terminate: true` - stops the run after this tool batch finishes. ANY-semantics (one hook, on one call, in one batch, is enough). Combines with `block` on the same return value.
125
135
  - `undefined` - no opinion.
126
136
 
137
+ - `result` - answer the call WITHOUT running the tool: this content is the tool's successful output (unlike `block`, which is an error). Later mods' `beforeToolCall` for this call do not run; `afterToolCall` still does, so a post-hook sees the answer like any real result. `block` wins when both are set.
138
+
139
+ ### `beforeModelRequest({state, turnNumber, model}, ctx?) → {model?} | undefined`
140
+
141
+ Fires once per model request, at the start of the round, before the round's prompt, context, and tools are built. The tool list and the deferred-tools prompt are then built for the model the request goes to, so a model that cannot load deferred tools gets every tool inline, the same as picking it with `/model`. A returned `model` sends **this request only** to that model; the next request goes back to the run's model (use `prepareNextTurn` to switch for the rest of the run). Later mods win. A swapped request drops the run's explicit BYOK route, which belongs to the run's model. `model_request_start` / `model_request_end` report the model the request actually went to.
142
+
143
+ The other hooks of the round have not run yet, so a value one of them stores is from the previous round. To route on context size, measure `state.messages` inside `beforeModelRequest` itself.
144
+
145
+ ### `transformSystemPrompt({system, state}, ctx?) → string`
146
+
147
+ Fires once per round, after `appendSystemPrompt`: receives the round's **full** system prompt (base + every append) and returns the prompt to send, so a mod can replace, edit, or drop parts of it. Threaded mod to mod. Returning the input unchanged keeps the structured, cache-friendly sections; any change sends one flat string, so keep the result byte-stable across rounds or the prompt-prefix cache misses every turn.
148
+
149
+ ### `transformMessage({message, state}, ctx?) → AgentMessage`
150
+
151
+ Fires for every message the loop commits: the person's input, each model reply, each tool-result batch, steering and follow-up messages. The returned message is what the transcript stores **and** what every later request sends, which makes it the place for secret redaction and PII masking. Threaded mod to mod. The role and every `tool_use` / `tool_result` id must survive: a rewrite that changes them is rejected with `mod_error {hook: 'transformMessage'}` and the previous message is kept. Live streaming events (`text_delta`, `message_end`) still show the model's raw output; the transcript holds the rewrite. To change what is shown as well, use `transformTextDelta`.
152
+
153
+ ### `transformTextDelta({delta, done, turnNumber, attempt, state}, ctx?) → string`
154
+
155
+ Fires for every chunk of the model's answer text as it streams, **before** the `text_delta` event for it goes out. Return the chunk to show (rewritten or as is), or `''` to drop it. Threaded mod to mod; a dropped chunk stops there, so later mods never see it. What the feed shows and what the transcript keeps are the same text: the committed assistant message (and `message_end`, and the live `message_update`) carries exactly the rewritten chunks, joined, each in the text block it streamed into. Thinking and tool calls are untouched.
156
+
157
+ - **Synchronous and fast.** It runs inside the model stream. A hook that throws, or returns a promise or a non-string, is skipped for that chunk with `mod_error {hook: 'transformTextDelta'}`, and the chunk passes on as the previous mod handed it.
158
+ - **The final flush.** When the response is complete, every mod is called once more with `{delta: '', done: true}`. Return anything you held back (the start of a key split across two chunks) and it streams as one last `text_delta` and lands at the end of the last text block.
159
+ - **Retries.** `attempt` counts this request's attempts from 0. A mid-stream retry throws the abandoned attempt's chunks away (the feed marks the seam with `stream_restart`), so a mod that keeps state across chunks should start over when `turnNumber` or `attempt` changes.
160
+ - **Non-streaming clients.** A response that arrived with no text deltas still goes through the hook, one chunk per text block, with no `text_delta` events invented for it.
161
+ - Runs **before** `transformMessage`, which still sees the (rewritten) assistant message on commit.
162
+
127
163
  ### `afterToolCall({toolCallId, toolName, input, result, isError, state}, ctx?) → {content?, terminate?, additionalContext?, isError?, modState?} | undefined`
128
164
 
129
165
  Fires per tool call after execution (also after an execution error - `result` is then the error text content). `input` is the FINAL input the tool actually ran with (after any `beforeToolCall` rewrites).
@@ -4,7 +4,7 @@
4
4
 
5
5
  > **Experimental.** The `ModApi` is new and still taking shape. Surfaces and signatures can change as we learn from how mods get built - so pin what you ship, and tell us what's missing. Your feedback is what stabilizes it.
6
6
 
7
- Mods are TypeScript packages that let you modify almost any behavior of Command Code. They're far more powerful than the plugin or extension APIs you'll find in other agents - though you can absolutely use one to build a plugin or an extension. Each mod is written against the `ModApi`: a plain TypeScript file that Command Code discovers on disk, loads at startup, and compiles onto its agent loop. One file can add tools the model calls, slash commands, mutating lifecycle hooks, event observers, typed-input interception, custom feed rendering, configurable flags, and model providers. Command Code's own built-in features - providers, session titling, the update notice - are built as mods against this very same API. If Command Code can do it, a mod can change it.
7
+ Mods are TypeScript packages that let you modify almost any behavior of Command Code. They're far more powerful than the plugin or extension APIs you'll find in other agents - though you can absolutely use one to build a plugin or an extension. Each mod is written against the `ModApi`: a plain TypeScript file that Command Code discovers on disk, loads at startup, and compiles onto its agent loop. One file can add tools the model calls, slash commands, mutating lifecycle hooks, event observers, typed-input interception, permission answers, transcript redaction, per-request model routing, panes and widgets with buttons, toasts, sub-agent types, configurable flags, and model providers. Mods hot-reload when you save them. Command Code's own built-in features - providers, session titling, the update notice - are built as mods against this very same API. If Command Code can do it, a mod can change it.
8
8
 
9
9
  A loadable mod IS an `AgentMod` once loaded; the mod host just builds it from a factory file instead of a code import:
10
10
 
@@ -27,6 +27,10 @@ hooks.transformInput ──► typed-prompt
27
27
  addProvider ──► ProviderModule
28
28
  addRenderer ──► custom feed entries
29
29
  queueMessage ──► steering drains
30
+ ui.widget / ui.pane ──► bands, panes, buttons
31
+ (element tree, any host)
32
+ addAgent ──► task tool agent types
33
+ prompt.submit ──► a turn of its own
30
34
  ```
31
35
 
32
36
  The factory receives the API bound as `cmd`. Registration verbs are all `add*` and each returns a `Disposable` - call `.dispose()` to undo exactly that one registration.
@@ -169,11 +173,13 @@ Dot-entries and `node_modules` are never scanned (the package registry lives und
169
173
  {"mods": {"disabled": ["review-guard"]}}
170
174
  ```
171
175
 
176
+ Turn off every installed mod at once with `{"mods": {"disableAll": true}}` (any settings file; a disable wins), or for one session with `cmd --safe-mode`. Both still load the built-in mods, which are Command Code itself; safe mode also skips `--mod` paths.
177
+
172
178
  ---
173
179
 
174
180
  ## The one rule: hooks mutate, `on` observes
175
181
 
176
- - **`cmd.hooks({...})`** is the only place that can change behavior - block a tool (`beforeToolCall`), rewrite a result (`afterToolCall`), add to the prompt (`appendSystemPrompt`), rewrite typed input (`transformInput`), force a finished run to keep going (`onStop`), react to session start/end (`onSessionStart`/`onSessionEnd`), or run post-turn work (`onRunEnd`). Multiple `hooks()` calls compose in registration order.
182
+ - **`cmd.hooks({...})`** is the only place that can change behavior - block, rewrite, or answer a tool call (`beforeToolCall`), rewrite a result (`afterToolCall`), add to or edit the system prompt (`appendSystemPrompt`, `transformSystemPrompt`), redact what the transcript keeps (`transformMessage`), rewrite the answer as it streams (`transformTextDelta`), send one request to another model (`beforeModelRequest`), answer a permission prompt (`onPermissionRequest`), rewrite typed input (`transformInput`), force a finished run to keep going (`onStop`), react to session start/end (`onSessionStart`/`onSessionEnd`), or run post-turn work (`onRunEnd`). Multiple `hooks()` calls compose in registration order.
177
183
  - **`cmd.on(event, ...)`** only observes - it cannot block or rewrite. Handlers are isolated (a throw becomes a `mod_error` event, never a crash).
178
184
 
179
185
  If you find yourself wanting an `on` handler to stop a tool, you want a hook instead. The full mutating surface is in [Hooks and events](./hooks-and-events.md#hooks-and-events); the full registration/live surface is the [ModApi reference](./api.md#mod-api-reference).
@@ -222,6 +228,13 @@ Ask for any of these by name or by what you want:
222
228
  | `custom-entry-renderer.ts` | `addRenderer` + `showEntry` - styled feed rows |
223
229
  | `flags-and-options.ts` | `addFlag` / `getFlag` + `--mod-option` |
224
230
  | `lifecycle-hooks.ts` | `hooks.onStop` (Stop) + `onSessionStart`/`onSessionEnd` + `afterToolCall` `isError` + `on('subagent_start'/'subagent_stop')` |
231
+ | `pane-and-buttons.ts` | `ui.pane` + `ui.widget` + buttons + `cmd.state` + `ui.toast` |
232
+ | `pane-form.ts` | a focused form pane: `input` + `select` + `markdown` elements, `openPane(id, {focus: true})` |
233
+ | `spinner-count.ts` | `ui.override` - a tool-call count beside the spinner, compact shell rows |
234
+ | `redact-secrets.ts` | `hooks.transformMessage` - mask keys before the transcript keeps them |
235
+ | `permission-policy.ts` | `hooks.onPermissionRequest` - answer prompts with a team policy |
236
+ | `model-router.ts` | `hooks.beforeModelRequest` + `cmd.model.complete` |
237
+ | `stream-filter.ts` | `hooks.transformTextDelta` - redact keys as the answer streams + `cmd.process.spawn` |
225
238
  | `kitchen-sink.ts` | every capability in one annotated file |
226
239
 
227
240
  ---
@@ -232,8 +245,10 @@ Ask for any of these by name or by what you want:
232
245
  - **Project mods are trust-gated** like project skills: they load only after the workspace trust prompt, because a mod is arbitrary code. User-scope and `--mod` mods always load. There is no sandbox - install packages you trust. Package installs run npm with `--ignore-scripts` (mods are jiti-loaded TypeScript; they need no build step, so lifecycle scripts are pure attack surface).
233
246
  - **Print mode loads user-scope and `--mod` mods only**, with the ui bridge degraded to headless defaults (confirm → false, select/input → undefined - never auto-approved; `setStatus`/`widget` render nowhere, and `cmd.ui.capabilities.status` is false). Project mods stay out of headless runs because print never shows a trust prompt; pass `--dangerously-skip-permissions` to opt a repo's own mods into a headless run (CI).
234
247
  - **Mod-queued messages don't echo in the feed** the way typed input does - they land in the transcript and steer the model, but the visible record is the model's response.
235
- - **Rendering is line-based, not component-based.** `cmd.addRenderer` returns styled text lines the host prints as feed rows; mods do not mount React components into the TUI. That keeps renderers host-agnostic (the same mod renders in any future host) and a crashing renderer degrades to a warning notice, never a broken screen.
236
- - **Reload is the `/reload` path.** Mods load once per process; `/reload` restarts the process, which re-discovers and re-imports every mod (jiti caches nothing between loads). There is no in-place hot swap.
248
+ - **Rendering is data, not components.** Widgets, panes, and feed renderers return strings or a small element tree (text, row, box, button, progress, divider, input, select, markdown) that each host draws; mods never mount React components. That keeps a mod host-agnostic, and a crashing render degrades to a one-line warning, never a broken screen.
249
+ - **Mods hot-reload on save.** In the interactive TUI every file-backed mod is watched: saving it re-imports the file and swaps the mod in place. The old code gets `onSessionEnd` with `reason: 'reload'` and every registration it made is disposed, except tools added with `addTool`, which keep the old code until the next session. `cmd.state` is kept, and the new code's `onSessionStart` fires with `source: 'reload'`. A save that throws while loading keeps the old code running, and the feed says why. `/mods reload [name]` does the same on demand; `COMMANDCODE_MODS_HOT_RELOAD=0` turns the watcher off. Tools a mod adds take effect on the next session, because the tool set is fixed per session. Built-in mods never reload.
250
+ - **Mods restyle rows, never the permission prompt.** `cmd.ui.override` covers the spinner, tool rows, user messages, and command output. What a person is asked to approve is always drawn by Command Code, so the permission prompt (and every other approval dialog) is not on the list and never will be.
251
+ - **Permission answers stop at the prompt.** `onPermissionRequest` only sees prompts the engine was about to show you: deny rules, plan mode, and dont-ask always win, and a safety-forced prompt can be denied by a mod but never allowed. In a headless run, where the prompt would otherwise be auto-answered, the mod's answer is used too.
237
252
  - **Session controls stop at the harness surface.** `cmd.sessions` covers what the live harness owns (compact, tree, navigate, labels); creating/switching/forking sessions is host lifecycle, not harness state, and stays with the host UI.
238
253
 
239
254
  Embedders wire the same machinery directly: `createModHost` + `loadMods` + `createHarness({modHost})` (or `createNodeHarnessSession({modHost})`), all exported from `@commandcode/harness`.
@@ -19,6 +19,7 @@ cmd mods remove fun-stuff # by the name `mods list` prints
19
19
  cmd mods enable <name> # enable a mod; -g for user level
20
20
  cmd mods disable <name> # disable a mod; -g for user level
21
21
  cmd mods open # open the mods dir (project); --user for ~
22
+ cmd mods inspect ./some-mod # what it hooks, registers, and reaches (never runs it)
22
23
  ```
23
24
 
24
25
  `cmd mods list` prints one line per mod - name, scope, origin - project mods first, then user, then package-provided:
@@ -146,7 +147,19 @@ A disable in `settings.local.json` has to be removed by hand; no command writes
146
147
 
147
148
  ## Trust and safety
148
149
 
149
- - **There is no sandbox** - a mod is arbitrary code; install packages you trust.
150
+ - **There is no sandbox** - a mod is arbitrary code that runs with your permissions; install packages you trust.
151
+ - **Inspect before you trust** - `cmd mods inspect <path>` reads a mod's source (it never imports it) and lists the hooks it registers, what it adds, and what it can reach: processes, the network, the filesystem, the environment, permission answers, transcript rewrites. It is a lexical scan, so code a mod imports or names it builds at runtime can reach further, and the report says so.
152
+
153
+ ```bash
154
+ $ cmd mods inspect ./permission-policy.ts
155
+ files: 1
156
+ hooks: onPermissionRequest
157
+ registers: none
158
+ reaches: answers permission prompts (onPermissionRequest)
159
+ imports: none
160
+ ```
161
+
162
+ - **Kill switches** - `cmd --safe-mode` loads built-in mods only for one session (no installed mods, no `--mod`); `{"mods": {"disableAll": true}}` in any settings file does the same for every session until removed.
150
163
  - **Project mods are trust-gated** like project skills: they load only after the workspace trust prompt. User-scope and `--mod` mods always load.
151
164
  - **Package installs run npm with `--ignore-scripts`** - mods are jiti-loaded TypeScript; they need no build step, so lifecycle scripts are pure attack surface.
152
165
  - **Print mode loads user-scope and `--mod` mods only.** Project mods stay out of headless runs because print never shows a trust prompt; pass `--dangerously-skip-permissions` to opt a repo's own mods into a headless run (CI).