command-code 1.82.4 → 1.83.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # command-code
2
2
 
3
+ ## 1.83.0
4
+
5
+ ### Minor Changes
6
+
7
+ - feat: Planning and Implementation models follow the live model catalog
8
+ - feat: mods get panes, widgets, toasts, hot reload, permission answers, transcript redaction, per-request model routing
9
+ - feat: mods can use `cmd.state` / `cmd.store` / `cmd.clock` / `cmd.model` / `cmd.prompt`, sub-agent types, `/mods`, `cmd mods inspect`, and `--safe-mode`
10
+ - feat: panes host `cmd.ui` input, select, and markdown elements with a keyboard focus model (Alt+↑, Tab, Esc), docking beside the prompt on wide terminals
11
+ - feat: mods can copy to the clipboard with `cmd.ui.copy` and play sounds with `cmd.audio.play` / `cmd.audio.bell`
12
+ - feat: mods can rewrite the model's answer as it streams via `hooks.transformTextDelta` (committed text matches what was shown) and stream a child process's output with `cmd.process.spawn`
13
+ - feat: mods can restyle built-in TUI rows with `cmd.ui.override` (spinner, tool rows, user messages, command output), never the permission prompt
14
+
15
+ ## 1.82.5
16
+
17
+ ### Patch Changes
18
+
19
+ - feat: credit errors say when your renewal payment is still processing
20
+
3
21
  ## 1.82.4
4
22
 
5
23
  ### Patch Changes
@@ -81,7 +81,39 @@ function changedSinceBuild(model: CatalogModelV1): boolean {
81
81
  );
82
82
  }
83
83
 
84
- function bucketModels(effective: EffectiveModelCatalog): ModelBuckets {
84
+ export interface LiveModelSessionModel {
85
+ readonly id: string;
86
+ // The catalog the session model runs on: the session pin, which may be older than the section's.
87
+ readonly catalog: EffectiveModelCatalog;
88
+ }
89
+
90
+ // A row answers to its id and its aliases, as the catalog resolves ids.
91
+ function rowNames(row: CatalogModelV1, id: string): boolean {
92
+ return row.id === id || row.aliases.includes(id);
93
+ }
94
+
95
+ // The session model's row from the catalog it runs on, so the section never tells the model a
96
+ // window or effort levels the session is not running on; any other model's row as served.
97
+ interface ServedRowParams {
98
+ readonly model: CatalogModelV1;
99
+ readonly sessionModel?: LiveModelSessionModel;
100
+ }
101
+
102
+ function servedRow(params: ServedRowParams): CatalogModelV1 {
103
+ const {model, sessionModel} = params;
104
+ if (!sessionModel || !rowNames(model, sessionModel.id)) return model;
105
+ return (
106
+ sessionModel.catalog.catalog.models.find(row =>
107
+ rowNames(row, sessionModel.id),
108
+ ) ?? model
109
+ );
110
+ }
111
+
112
+ function bucketModels(params: {
113
+ readonly effective: EffectiveModelCatalog;
114
+ readonly sessionModel?: LiveModelSessionModel;
115
+ }): ModelBuckets {
116
+ const {effective, sessionModel} = params;
85
117
  const known = listSelectableModelIds({catalog: builtCatalog()});
86
118
  const knownSet = new Set(known);
87
119
  const added: CatalogModelV1[] = [];
@@ -97,11 +129,12 @@ function bucketModels(effective: EffectiveModelCatalog): ModelBuckets {
97
129
  continue;
98
130
  }
99
131
  if (state !== 'selectable') continue;
100
- if (!knownSet.has(model.id)) {
101
- added.push(model);
132
+ const row = servedRow({model, sessionModel});
133
+ if (!knownSet.has(row.id)) {
134
+ added.push(row);
102
135
  continue;
103
136
  }
104
- if (changedSinceBuild(model)) changed.push(model);
137
+ if (changedSinceBuild(row)) changed.push(row);
105
138
  }
106
139
  // A shipped id the catalog no longer carries at all (no row, no alias), or now marks update
107
140
  // required, is refused by /model and sub-agents too.
@@ -119,6 +152,8 @@ function bucketModels(effective: EffectiveModelCatalog): ModelBuckets {
119
152
 
120
153
  export interface BuildLiveModelSectionParams {
121
154
  readonly catalog?: EffectiveModelCatalog;
155
+ // The session's model and its pin, when the pin knows it: that row wins over `catalog`'s.
156
+ readonly sessionModel?: LiveModelSessionModel;
122
157
  }
123
158
 
124
159
  /**
@@ -132,7 +167,10 @@ export function buildLiveModelSection(
132
167
  const effective =
133
168
  params.catalog ?? getInstalledModelCatalogManager()?.getEffective();
134
169
  if (!effective) return '';
135
- const {added, hidden, outdated, changed} = bucketModels(effective);
170
+ const {added, hidden, outdated, changed} = bucketModels({
171
+ effective,
172
+ sessionModel: params.sessionModel,
173
+ });
136
174
  if (added.length + hidden.length + outdated.length + changed.length === 0)
137
175
  return '';
138
176
  const parts = [
@@ -166,23 +204,42 @@ export function buildLiveModelSection(
166
204
  return parts.join('\n\n');
167
205
  }
168
206
 
207
+ // One built section, keyed by the inputs it was built from.
208
+ interface LiveModelSectionMemo {
209
+ readonly catalog: EffectiveModelCatalog;
210
+ readonly sessionModel: LiveModelSessionModel | undefined;
211
+ readonly text: string;
212
+ }
213
+
214
+ function sameSessionModel(
215
+ left: LiveModelSessionModel | undefined,
216
+ right: LiveModelSessionModel | undefined,
217
+ ): boolean {
218
+ return left?.id === right?.id && left?.catalog === right?.catalog;
219
+ }
220
+
169
221
  /**
170
- * The section built once per catalog `getCatalog` answers with. A session hands in its pin when the
171
- * pin came from the live catalog, else the newest catalog — so every activation (the activate_skill
172
- * tool, a `/command-code-knowledge` turn, an inline mention) reads the same text until a catalog
173
- * lands or /model re-pins.
222
+ * The section built once per catalog and session model `getParams` answers with. A session hands in
223
+ * its pin when the pin came from the live catalog, else the newest catalog, plus its own model held
224
+ * to the pin — so every activation (the activate_skill tool, a `/command-code-knowledge` turn, an
225
+ * inline mention) reads the same text until a catalog lands or /model re-pins.
174
226
  */
175
227
  export function createLiveModelSectionCache(
176
- getCatalog: () => EffectiveModelCatalog | undefined,
228
+ getParams: () => BuildLiveModelSectionParams,
177
229
  ): () => string {
178
- let cached:
179
- | {readonly catalog: EffectiveModelCatalog; readonly text: string}
180
- | undefined;
230
+ let cached: LiveModelSectionMemo | undefined;
181
231
  return () => {
182
- const catalog = getCatalog();
232
+ const {catalog, sessionModel} = getParams();
183
233
  if (!catalog) return '';
184
- if (cached?.catalog !== catalog) {
185
- cached = {catalog, text: buildLiveModelSection({catalog})};
234
+ if (
235
+ cached?.catalog !== catalog ||
236
+ !sameSessionModel(cached.sessionModel, sessionModel)
237
+ ) {
238
+ cached = {
239
+ catalog,
240
+ sessionModel,
241
+ text: buildLiveModelSection({catalog, sessionModel}),
242
+ };
186
243
  }
187
244
  return cached.text;
188
245
  };
@@ -38,6 +38,7 @@ Every built-in command, grouped by area. Aliases are shown in parentheses on the
38
38
  | `/unshare` | Stop sharing conversation |
39
39
  | `/exit` (`/quit`) | Exit Command Code |
40
40
  | `/reload` | Restart Command Code and resume this session (applies a staged update) |
41
+ | `/mods` | List loaded [mods](https://commandcode.ai/docs/mods) and panes; `/mods reload [name]` hot-reloads, `/mods open\|close <pane>` |
41
42
 
42
43
  ### Context & compaction
43
44
 
@@ -52,10 +52,10 @@ The model catalog — every id `/model`, `--model`, `model:effort` shorthand, an
52
52
  | `tencent/hy3-paid` | Tencent Hy3 | 262K | — | $0.14/$0.58 · cache $0.035 | Go and above | sparse-MoE reasoning & agentic tool use |
53
53
  | `tencent/hy4-preview` | Tencent Hy4 Preview | 1.05M | low, medium, high | $0.834/$2.501 · cache $0.042 | Go and above | agentic coding & sustained multi-step tool use |
54
54
  | `nvidia/nemotron-3-ultra-550b-a55b` | Nemotron 3 Ultra | 1M | — | $0.6/$2.4 · cache $0.12 | Go and above | open reasoning model for long-horizon autonomous agents |
55
+ | `nvidia/nemotron-3.5-super-vl` | Nemotron 3.5 Super VL | 262K | low, medium, high | $0.2/$0.5 · cache $0.04 | Go and above | open vision-language model for coding and documents |
55
56
  | `thinkingmachines/inkling` | Inkling | 256K | — | $1/$4.05 · cache $0.17 | Go and above | multimodal MoE reasoning |
56
57
  | `thinkingmachines/inkling-small` | Inkling Small | 1M | — | $0.5/$1.2 · cache $0.1 | Go and above | lightweight MoE reasoning at lower cost and latency |
57
58
  | `poolside/laguna-s-2.1-free` | Laguna S 2.1 | 256K | — | $0/$0 · cache $0 | Go and above | open-weight agentic coding and long-horizon work |
58
- | `inclusionai/ling-3.0-flash-sante:free` | Ling 3.0 Flash Sante | 262K | — | $0/$0 · cache $0 | Go and above | health & medicine tuned lightweight-MoE, still strong on code |
59
59
  | `inclusionai/ling-3.1-flash` | Ling 3.1 Flash | 262K | low, medium, high | $0.3/$0.9 · cache $0.06 | Go and above | hybrid-reasoning MoE for coding & tool-using agents |
60
60
  | `mistral/mistral-large-4` | Mistral Large 4 | 524K | low, medium, high | $1.36/$4.18 · cache $0.14 | Go and above | frontier open-weight multimodal model for coding & analysis |
61
61
  ## Stealth
@@ -63,6 +63,7 @@ The model catalog — every id `/model`, `--model`, `model:effort` shorthand, an
63
63
  | Id (use EXACTLY this) | Name | Context | Efforts | $/1M in/out · cache read | Min plan | Best for |
64
64
  |---|---|---|---|---|---|---|
65
65
  | `stealth/glyph-cluster:free` | Glyph Cluster | 256K | low, medium, high, xhigh | $0/$0 · cache $0 | Go and above | stealth reasoning model in early access |
66
+ | `stealth/cat-jam:free` | Cat Jam | 1.05M | low, medium, high, xhigh, max | $0/$0 · cache $0 | Go and above | stealth reasoning model for coding and agentic work |
66
67
  ## Anthropic
67
68
 
68
69
  | Id (use EXACTLY this) | Name | Context | Efforts | $/1M in/out · cache read | Min plan | Best for |
@@ -118,6 +119,7 @@ The model catalog — every id `/model`, `--model`, `model:effort` shorthand, an
118
119
  | `meta/muse-spark-1.2-contributor` | Muse Spark 1.2 Contributor | 1.05M | low, medium, high, xhigh | $0.1/$0.2 · cache $0.002 | Go and above | Muse Spark 1.2 at ~95% off |
119
120
  | `meta/muse-spark-1.3` | Muse Spark 1.3 | 1.05M | low, medium, high, xhigh, max | $1.25/$4.25 · cache $0.15 | GOAT and above | multimodal reasoning for long-horizon agentic and coding workflows |
120
121
  | `meta/muse-spark-1.3-contributor` | Muse Spark 1.3 Contributor | 1.05M | low, medium, high, xhigh | $0.1/$0.2 · cache $0.002 | Go and above | Muse Spark 1.3 at up to 95% off |
122
+ | `meta/muse-spark-open` | Muse Spark Open | 1.05M | low, medium, high | $1.25/$4.25 · cache $0.15 | Go and above | open-weight Muse Spark for coding and agentic workflows |
121
123
  | `meta/muse-wonder-1.0` | Muse Wonder | 1.05M | low, medium, high, xhigh | $4/$20 · cache $0.2 | Max | frontier coding, long-running agents and advanced computer use |
122
124
  ## xAI
123
125
 
@@ -73,6 +73,7 @@
73
73
  - /extra: Buy extra usage at API cost, zero markup
74
74
  - /update: Update Command Code to the latest version
75
75
  - /reload: Restart Command Code and resume this session (applies a staged update)
76
+ - /mods: List loaded mods and panes; /mods reload [name] hot-reloads, /mods open|close <pane>
76
77
  - /help: Display help information
77
78
  - /exit: Exit Command Code
78
79
  - /quit: Exit Command Code (alias of /exit)
@@ -139,6 +140,7 @@
139
140
  - --base <branch>: Base branch for a new cloud session
140
141
  - --mod <path>: Load a mod file or directory for this session (repeatable)
141
142
  - --mod-option <name=value>: Set a mod-declared flag value (repeatable)
143
+ - --safe-mode: Load built-in mods only for this session (no installed mods, no --mod)
142
144
  - --skill <path>: Load extra skills from a path (a skill directory or a directory of skills); repeatable
143
145
  - --no-skills: Skip skill discovery; paths given via --skill still load
144
146
  - --skip-onboarding: Skip taste onboarding (for automated runs)
@@ -329,17 +331,19 @@ Valid model ids (the /model catalog — use these EXACT ids):
329
331
  - google/gemini-3.1-flash-lite
330
332
  - sakana/fugu-ultra
331
333
  - nvidia/nemotron-3-ultra-550b-a55b
334
+ - nvidia/nemotron-3.5-super-vl
332
335
  - thinkingmachines/inkling
333
336
  - thinkingmachines/inkling-small
334
337
  - stealth/glyph-cluster:free
338
+ - stealth/cat-jam:free
335
339
  - poolside/laguna-s-2.1-free
336
- - inclusionai/ling-3.0-flash-sante:free
337
340
  - inclusionai/ling-3.1-flash
338
341
  - meta/muse-spark-1.1
339
342
  - meta/muse-spark-1.2
340
343
  - meta/muse-spark-1.2-contributor
341
344
  - meta/muse-spark-1.3
342
345
  - meta/muse-spark-1.3-contributor
346
+ - meta/muse-spark-open
343
347
  - meta/muse-wonder-1.0
344
348
  - xai/grok-4.5
345
349
  - xai/grok-4.6
@@ -59,6 +59,7 @@ the job to the seam, then open the matching example file:
59
59
  | react to what happens | `cmd.on(event, handler)` | `examples/observe-events.ts` |
60
60
  | print custom styled feed rows | `cmd.addRenderer` + `cmd.showEntry` | `examples/custom-entry-renderer.ts` |
61
61
  | take configurable options | `cmd.addFlag` + `cmd.getFlag` | `examples/flags-and-options.ts` |
62
+ | rewrite the answer as it streams / stream a process's output | `cmd.hooks({transformTextDelta})` + `cmd.process.spawn` | `examples/stream-filter.ts` |
62
63
  | everything at once (a tour) | — | `examples/kitchen-sink.ts` |
63
64
 
64
65
  ## 3. The one rule: hooks mutate, `on` observes
@@ -0,0 +1,43 @@
1
+ // Example mod: route requests between models, and call a model yourself.
2
+ //
3
+ // hooks.beforeModelRequest runs at the start of each request's round, so its tools and prompt are
4
+ // built for the model it picks. Returning {model} sends THAT request to another model; the next request goes back to the session's model (use
5
+ // prepareNextTurn to switch for the rest of a run). Here: the first request of each run, which
6
+ // usually just reads the prompt and picks tools, goes to a cheaper model.
7
+ //
8
+ // cmd.model.complete runs one prompt on the session's own client, with no transcript and no
9
+ // tools. It always resolves: {isAnswered: true, text} or {isAnswered: false, reason}.
10
+
11
+ import type {ModApi} from '@commandcode/harness';
12
+
13
+ export default function (cmd: ModApi): void {
14
+ cmd.addFlag('router-model', {
15
+ type: 'string',
16
+ description: 'Model for the first request of each run',
17
+ });
18
+
19
+ cmd.hooks({
20
+ beforeModelRequest: async ({turnNumber}) => {
21
+ const cheap = cmd.getFlag('router-model');
22
+ if (turnNumber !== 1 || typeof cheap !== 'string' || !cheap)
23
+ return undefined;
24
+ return {model: cheap};
25
+ },
26
+ });
27
+
28
+ cmd.addCommand({
29
+ name: 'haiku',
30
+ description:
31
+ 'Write a haiku about the argument with a one-shot model call',
32
+ argumentHint: '<topic>',
33
+ handler: async ({args}) => {
34
+ const result = await cmd.model.complete({
35
+ prompt: `Write a haiku about: ${args || 'code review'}`,
36
+ maxOutputTokens: 200,
37
+ });
38
+ if (!result.isAnswered)
39
+ return {message: `No haiku (${result.reason}).`};
40
+ return {message: result.text};
41
+ },
42
+ });
43
+ }
@@ -0,0 +1,60 @@
1
+ // Example mod: a live pane with buttons, a band above the prompt, and a toast.
2
+ //
3
+ // cmd.ui.pane draws a titled panel the person opens and closes; cmd.ui.widget draws a band of
4
+ // lines around the prompt. Both take a `render` that returns plain data: strings, or elements
5
+ // ({type: 'text' | 'row' | 'box' | 'button' | 'progress' | 'divider'}). Never React components,
6
+ // so the same mod draws in any host.
7
+ //
8
+ // Values a drawing reads live in cmd.state: host-held, so they survive a hot reload, and
9
+ // setting one repaints every widget and pane. While the pane is open, Alt+<hotkey> presses its
10
+ // buttons and Alt+0 closes it.
11
+
12
+ import type {ModApi} from '@commandcode/harness';
13
+
14
+ export default function (cmd: ModApi): void {
15
+ const calls = (): number => cmd.state.get<number>('calls') ?? 0;
16
+
17
+ cmd.on('tool_completed', () => {
18
+ cmd.state.set('calls', calls() + 1);
19
+ });
20
+
21
+ // The band above the prompt: one line, always visible.
22
+ cmd.ui.widget({
23
+ placement: 'above-editor',
24
+ render: () => [
25
+ {type: 'text', text: `tool calls: ${calls()}`, dim: true},
26
+ ],
27
+ });
28
+
29
+ cmd.ui.pane({
30
+ id: 'calls',
31
+ title: 'Tool calls',
32
+ render: () => [
33
+ {type: 'progress', label: 'budget', value: calls() / 50, width: 20},
34
+ {
35
+ type: 'row',
36
+ children: [
37
+ {type: 'text', text: `${calls()} of 50`, bold: true},
38
+ {
39
+ type: 'button',
40
+ label: 'Reset',
41
+ hotkey: 'r',
42
+ onPress: () => {
43
+ cmd.state.set('calls', 0);
44
+ cmd.ui.toast('Counter reset', {level: 'success'});
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ ],
50
+ });
51
+
52
+ // /calls opens the pane.
53
+ cmd.addCommand({
54
+ name: 'calls',
55
+ description: 'Show the tool-call counter pane',
56
+ handler: () => {
57
+ cmd.ui.openPane('calls');
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,68 @@
1
+ // Example mod: a small form in a pane, with a text input, a select, markdown, and a button.
2
+ //
3
+ // `input` and `select` elements only take keys while their pane has focus: `/standup` opens the
4
+ // pane with focus (dialog-like), and Alt+↑ focuses the newest open pane from the prompt. While
5
+ // focused, Tab walks the fields, ←/→ choose an option, Enter submits or picks, and Esc goes
6
+ // back to the prompt. Typing never reaches a pane that does not have focus.
7
+ //
8
+ // The values live in cmd.state, so they survive a hot reload and setting one repaints the pane.
9
+
10
+ import type {ModApi} from '@commandcode/harness';
11
+
12
+ const MOODS = [
13
+ {label: 'Great', value: 'great'},
14
+ {label: 'Okay', value: 'okay'},
15
+ {label: 'Blocked', value: 'blocked'},
16
+ ] as const;
17
+
18
+ export default function (cmd: ModApi): void {
19
+ const note = (): string => cmd.state.get<string>('note') ?? '';
20
+ const mood = (): string => cmd.state.get<string>('mood') ?? 'okay';
21
+
22
+ cmd.ui.pane({
23
+ id: 'standup',
24
+ title: 'Standup',
25
+ render: () => [
26
+ {
27
+ type: 'markdown',
28
+ text: '**Today**: one line, then pick how it is going.',
29
+ },
30
+ {
31
+ type: 'input',
32
+ key: 'note',
33
+ placeholder: 'what are you working on?',
34
+ value: note(),
35
+ onSubmit: text => cmd.state.set('note', text.trim()),
36
+ },
37
+ {
38
+ type: 'select',
39
+ key: 'mood',
40
+ options: MOODS,
41
+ value: mood(),
42
+ onSelect: value => cmd.state.set('mood', value),
43
+ },
44
+ {
45
+ type: 'button',
46
+ label: 'Post',
47
+ hotkey: 's',
48
+ onPress: () => {
49
+ if (!note()) {
50
+ cmd.ui.toast('Type a note first', {level: 'warning'});
51
+ return;
52
+ }
53
+ cmd.ui.notify(`standup (${mood()}): ${note()}`);
54
+ cmd.ui.closePane('standup');
55
+ },
56
+ },
57
+ ],
58
+ });
59
+
60
+ // /standup opens the form with the keyboard in it.
61
+ cmd.addCommand({
62
+ name: 'standup',
63
+ description: 'Post a one-line standup from a form pane',
64
+ handler: () => {
65
+ cmd.ui.openPane('standup', {focus: true});
66
+ },
67
+ });
68
+ }
@@ -0,0 +1,34 @@
1
+ // Example mod: answer permission prompts with a team policy.
2
+ //
3
+ // hooks.onPermissionRequest fires ONLY where Command Code was about to ask you. Deny rules,
4
+ // plan mode, and dont-ask have already run, so a mod can never undo them. Return 'allow' or
5
+ // 'deny' to answer for the person, or undefined to let them decide. A prompt a safety check
6
+ // forced (isSafetyPrompt: a sensitive file, a risky command) can be denied but never allowed:
7
+ // an 'allow' there is ignored and you are asked as usual.
8
+
9
+ import type {ModApi} from '@commandcode/harness';
10
+
11
+ // Anything that can chain or redirect a second command goes to the person: without this,
12
+ // `git status && npm publish` would pass as a safe `git status`.
13
+ const SHELL_CONTROL = /[;&|`<>\n]|\$\(/;
14
+
15
+ // Anchored at both ends, so the whole command must be one of these.
16
+ const SAFE_SHELL = [
17
+ /^git (status|diff|log)( .*)?$/,
18
+ /^pnpm (test|lint|typecheck)( .*)?$/,
19
+ ];
20
+
21
+ export default function (cmd: ModApi): void {
22
+ cmd.hooks({
23
+ onPermissionRequest: ({toolName, input}) => {
24
+ if (toolName !== 'shell_command') return undefined;
25
+ const command =
26
+ typeof input.command === 'string' ? input.command : '';
27
+ if (/\bcurl\b.*\|\s*(ba)?sh\b/.test(command)) return 'deny';
28
+ if (SHELL_CONTROL.test(command)) return undefined;
29
+ if (SAFE_SHELL.some(pattern => pattern.test(command)))
30
+ return 'allow';
31
+ return undefined;
32
+ },
33
+ });
34
+ }
@@ -0,0 +1,46 @@
1
+ // Example mod: redact secrets from everything the transcript keeps.
2
+ //
3
+ // hooks.transformMessage sees every message before it is committed: your prompt, each model
4
+ // reply, every tool result, steering and follow-up messages. What it returns is what the
5
+ // transcript stores AND what every later request sends, so a key a tool printed never reaches
6
+ // the model on the next turn. The role and every tool_use / tool_result id must stay as they
7
+ // were; a rewrite that changes them is rejected and the message is kept.
8
+
9
+ import type {ModApi} from '@commandcode/harness';
10
+
11
+ const SECRET_PATTERNS = [
12
+ /\bsk-[A-Za-z0-9_-]{16,}\b/g, // API keys
13
+ /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, // GitHub tokens
14
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key ids
15
+ ];
16
+
17
+ function redact(text: string): string {
18
+ return SECRET_PATTERNS.reduce(
19
+ (current, pattern) => current.replace(pattern, '[REDACTED]'),
20
+ text,
21
+ );
22
+ }
23
+
24
+ export default function (cmd: ModApi): void {
25
+ cmd.hooks({
26
+ transformMessage: ({message}) => {
27
+ if (message.role === 'assistant') return message;
28
+ return {
29
+ ...message,
30
+ content: message.content.map(block => {
31
+ if (block.type === 'text')
32
+ return {...block, text: redact(block.text)};
33
+ if (block.type !== 'tool_result') return block;
34
+ return {
35
+ ...block,
36
+ content: block.content.map(part =>
37
+ part.type === 'text'
38
+ ? {...part, text: redact(part.text)}
39
+ : part,
40
+ ),
41
+ };
42
+ }),
43
+ };
44
+ },
45
+ });
46
+ }
@@ -0,0 +1,47 @@
1
+ // Example mod: restyle built-in rows with cmd.ui.override.
2
+ //
3
+ // `spinner` appends nodes after the working status line, so "tool calls: 3" sits beside the
4
+ // spinner while the agent works. `tool_row` draws a tool's row instead of the built-in one;
5
+ // returning undefined keeps the built-in row, and `next()` is what mods loaded after this one
6
+ // drew (undefined when nobody did). A throwing override falls back to the built-in drawing.
7
+ //
8
+ // The permission prompt is never overridable: what a person approves is always drawn by the host.
9
+
10
+ import type {ModApi, ModUiOverrideProps} from '@commandcode/harness';
11
+
12
+ function statusMark(status: ModUiOverrideProps['tool_row']['status']): string {
13
+ if (status === 'error') return '✗';
14
+ if (status === 'pending') return '…';
15
+ return '✓';
16
+ }
17
+
18
+ export default function (cmd: ModApi): void {
19
+ const calls = (): number => cmd.state.get<number>('calls') ?? 0;
20
+
21
+ cmd.on('tool_completed', () => {
22
+ cmd.state.set('calls', calls() + 1);
23
+ });
24
+ // One count per run: reset when the agent starts working on a new prompt.
25
+ cmd.on('run_start', () => {
26
+ cmd.state.set('calls', 0);
27
+ });
28
+
29
+ cmd.ui.override('spinner', (_props, next) => [
30
+ {type: 'text', text: `tool calls: ${calls()}`, color: 'cyan'},
31
+ ...(next() ?? []),
32
+ ]);
33
+
34
+ // Shell rows as one compact line; every other tool keeps its built-in row.
35
+ cmd.ui.override('tool_row', ({toolName, input, status}) => {
36
+ if (toolName !== 'shell_command') return undefined;
37
+ return [
38
+ {
39
+ type: 'row',
40
+ children: [
41
+ {type: 'text', text: statusMark(status), bold: true},
42
+ {type: 'text', text: `$ ${input}`, dim: true},
43
+ ],
44
+ },
45
+ ];
46
+ });
47
+ }
@@ -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
+ }