command-code 1.82.5 → 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 +12 -0
- package/dist/bundled/command-code-knowledge/live-models.ts +73 -16
- package/dist/bundled/command-code-knowledge/reference/custom-slash-commands.md +1 -0
- package/dist/bundled/command-code-knowledge/reference/product-help.md +2 -0
- package/dist/bundled/mod-builder/SKILL.md +1 -0
- package/dist/bundled/mod-builder/examples/model-router.ts +43 -0
- package/dist/bundled/mod-builder/examples/pane-and-buttons.ts +60 -0
- package/dist/bundled/mod-builder/examples/pane-form.ts +68 -0
- package/dist/bundled/mod-builder/examples/permission-policy.ts +34 -0
- package/dist/bundled/mod-builder/examples/redact-secrets.ts +46 -0
- package/dist/bundled/mod-builder/examples/spinner-count.ts +47 -0
- package/dist/bundled/mod-builder/examples/stream-filter.ts +70 -0
- package/dist/bundled/mod-builder/reference/api.md +40 -5
- package/dist/bundled/mod-builder/reference/hooks-and-events.md +37 -1
- package/dist/bundled/mod-builder/reference/overview.md +19 -4
- package/dist/bundled/mod-builder/reference/packaging.md +14 -1
- package/dist/bundled/mod-builder/reference/ui.md +191 -19
- package/dist/bundled/mod-builder/reference/verify.md +7 -3
- package/dist/cli.mjs +5 -5
- package/package.json +1 -1
- package/vsix/commandcode-vscode.vsix +0 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 1.82.5
|
|
4
16
|
|
|
5
17
|
### Patch Changes
|
|
@@ -81,7 +81,39 @@ function changedSinceBuild(model: CatalogModelV1): boolean {
|
|
|
81
81
|
);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
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
|
-
|
|
101
|
-
|
|
132
|
+
const row = servedRow({model, sessionModel});
|
|
133
|
+
if (!knownSet.has(row.id)) {
|
|
134
|
+
added.push(row);
|
|
102
135
|
continue;
|
|
103
136
|
}
|
|
104
|
-
if (changedSinceBuild(
|
|
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(
|
|
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 `
|
|
171
|
-
* pin came from the live catalog, else the newest catalog
|
|
172
|
-
* tool, a `/command-code-knowledge` turn, an
|
|
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
|
-
|
|
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 =
|
|
232
|
+
const {catalog, sessionModel} = getParams();
|
|
183
233
|
if (!catalog) return '';
|
|
184
|
-
if (
|
|
185
|
-
cached
|
|
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
|
|
|
@@ -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)
|
|
@@ -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
|
+
}
|