@north-light/crouter 0.3.158 → 0.3.160
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/dist/api/dto/nodes.d.ts +3 -0
- package/dist/builtin-memory/internal/INDEX.md +1 -0
- package/dist/builtin-memory/internal/storage-tiers.md +2 -0
- package/dist/builtin-memory/internal/workflow-codification.md +82 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/bash-call-width.test.js +38 -0
- package/dist/clients/attach/__tests__/chat-view-snapshot-ordering.test.js +20 -0
- package/dist/clients/attach/__tests__/crtr-output-coverage.test.js +3 -1
- package/dist/clients/attach/chrome/bash-jobs.d.ts +5 -8
- package/dist/clients/attach/chrome/bash-jobs.js +20 -32
- package/dist/clients/attach/chrome/canvas-panels.js +10 -3
- package/dist/clients/attach/chrome/roster.d.ts +10 -4
- package/dist/clients/attach/chrome/roster.js +96 -36
- package/dist/clients/attach/input/controller.d.ts +3 -0
- package/dist/clients/attach/input/controller.js +1 -0
- package/dist/clients/attach/render/assistant-message.d.ts +13 -0
- package/dist/clients/attach/render/assistant-message.js +55 -0
- package/dist/clients/attach/render/chat-view.d.ts +29 -0
- package/dist/clients/attach/render/chat-view.js +233 -12
- package/dist/clients/attach/render/context-message.js +1 -1
- package/dist/clients/attach/render/edit-diff.js +20 -1
- package/dist/clients/attach/render/tool-calls.d.ts +32 -0
- package/dist/clients/attach/render/tool-calls.js +251 -0
- package/dist/clients/attach/session/input-wiring.d.ts +3 -0
- package/dist/clients/attach/session/input-wiring.js +3 -0
- package/dist/clients/attach/slash/dispatch.d.ts +4 -0
- package/dist/clients/attach/slash/dispatch.js +8 -0
- package/dist/clients/attach/viewer.js +568 -566
- package/dist/commands/cron.js +2 -2
- package/dist/commands/node.js +141 -3
- package/dist/commands/search.js +1 -1
- package/dist/commands/surface-inspect.js +4 -3
- package/dist/commands/sys/config.js +10 -2
- package/dist/commands/sys/doctor.js +22 -1
- package/dist/commands/sys/setup-core.d.ts +5 -1
- package/dist/commands/sys/setup-core.js +16 -2
- package/dist/commands/sys/setup-wizard.d.ts +11 -0
- package/dist/commands/sys/setup-wizard.js +111 -6
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +5 -3
- package/dist/core/__tests__/tmux-surface.test.js +5 -2
- package/dist/core/bash-jobs.d.ts +11 -0
- package/dist/core/bash-jobs.js +41 -1
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/labels.d.ts +1 -0
- package/dist/core/canvas/labels.js +3 -1
- package/dist/core/canvas/nav-render.d.ts +6 -0
- package/dist/core/canvas/nav-render.js +8 -0
- package/dist/core/canvas/types.d.ts +6 -0
- package/dist/core/config.js +3 -2
- package/dist/core/feed/inbox.d.ts +3 -3
- package/dist/core/feed/inbox.js +10 -6
- package/dist/core/inspector/core.d.ts +17 -2
- package/dist/core/inspector/core.js +172 -23
- package/dist/core/inspector/model.d.ts +30 -1
- package/dist/core/inspector/model.js +39 -0
- package/dist/core/inspector/text.js +14 -1
- package/dist/core/inspector/tui.js +71 -3
- package/dist/core/keybindings/__tests__/resolve.test.js +2 -2
- package/dist/core/keybindings/catalog.d.ts +3 -3
- package/dist/core/keybindings/catalog.js +5 -2
- package/dist/core/preview-registry.d.ts +5 -0
- package/dist/core/preview-registry.js +10 -0
- package/dist/core/runtime/canvas-extensions.d.ts +4 -0
- package/dist/core/runtime/canvas-extensions.js +4 -0
- package/dist/core/runtime/naming-persist.d.ts +4 -3
- package/dist/core/runtime/naming-persist.js +10 -9
- package/dist/core/runtime/naming.d.ts +28 -8
- package/dist/core/runtime/naming.js +168 -32
- package/dist/core/runtime/nerd-font.d.ts +28 -0
- package/dist/core/runtime/nerd-font.js +127 -0
- package/dist/core/runtime/tmux.js +16 -0
- package/dist/core/tui/host.js +21 -6
- package/dist/daemon/api/map.js +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +5 -0
- package/dist/pi-extensions/naming-tool.d.ts +24 -0
- package/dist/pi-extensions/naming-tool.js +67 -0
- package/dist/types.d.ts +5 -0
- package/dist/types.js +1 -0
- package/dist/web-client/assets/index-B76ZKfT_.js +79 -0
- package/dist/web-client/assets/{index-CpEl9LTS.css → index-CqLKj8Xu.css} +1 -1
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
- package/dist/web-client/assets/index-CsuwzlcQ.js +0 -79
|
@@ -6,6 +6,12 @@
|
|
|
6
6
|
// the first prompt by asking pi headlessly (`pi -p`), persisted on the node's
|
|
7
7
|
// meta so it survives revives and updates the live session name.
|
|
8
8
|
//
|
|
9
|
+
// The namer returns a STRUCTURED result — {name, icon} — produced by constrained
|
|
10
|
+
// sampling against the `name_session` tool in `pi-extensions/naming-tool.ts`,
|
|
11
|
+
// not by parsing prose. The icon is a Nerd Font glyph the model picks freely
|
|
12
|
+
// (guided by example blocks, not a closed enum) and is stored as its own meta
|
|
13
|
+
// field, so each surface decides whether to render it.
|
|
14
|
+
//
|
|
9
15
|
// One entry point: generateAndPersistName — async (execFile, non-blocking).
|
|
10
16
|
// Naming happens INSIDE the named node's own pi process, off the first real
|
|
11
17
|
// message (the kickoff task or a human's first line), never on the spawn path:
|
|
@@ -16,23 +22,59 @@
|
|
|
16
22
|
// Best-effort: a failed/slow/garbled pi call falls back to a local slug of the
|
|
17
23
|
// prompt, so a node always gets a sane name.
|
|
18
24
|
import { execFile } from 'node:child_process';
|
|
25
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { join } from 'node:path';
|
|
19
28
|
import { bundledPiSubprocessEnv, resolveBundledPiCliPath } from './pi-cli.js';
|
|
29
|
+
import { NAMING_TOOL_PATH } from './canvas-extensions.js';
|
|
30
|
+
import { defaultProvider, modelLadders, stripThinkingSuffix } from './model-selection.js';
|
|
20
31
|
/** Cap on prompt text fed to the namer — a name needs only the gist. */
|
|
21
32
|
const PROMPT_CAP = 2000;
|
|
22
|
-
/** Wall-clock budget for the headless pi call before we fall back to a slug.
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
/** Wall-clock budget for the headless pi call before we fall back to a slug.
|
|
34
|
+
* Generous because the structured path costs a tool round-trip on top of the
|
|
35
|
+
* provider's own cold start; naming is async and off the spawn path, so a slow
|
|
36
|
+
* call delays only the label, never the node. */
|
|
37
|
+
const NAME_TIMEOUT_MS = 45_000;
|
|
38
|
+
/** Nerd Font glyphs offered to the namer as EXAMPLES, never as a closed set —
|
|
39
|
+
* the model may pick any Nerd Font character it thinks fits better. Grouped by
|
|
40
|
+
* the kind of work they read as, so the model generalizes from the grouping
|
|
41
|
+
* instead of pattern-matching a lookup table. */
|
|
42
|
+
const ICON_EXAMPLES = [
|
|
43
|
+
'fixing/diagnosis: U+F188 bug, U+F0F1 stethoscope, U+F06D fire, U+F071 warning',
|
|
44
|
+
'building/changing code: U+F0AD wrench, U+F067 plus, U+F121 code, U+F085 cogs, U+F021 refresh',
|
|
45
|
+
'testing/verifying: U+F0C3 flask, U+F00C check, U+F24E balance',
|
|
46
|
+
'writing/docs/research: U+F02D book, U+F15C document, U+F002 magnifier, U+F0EB lightbulb',
|
|
47
|
+
'data/infra: U+F1C0 database, U+F0C2 cloud, U+F120 terminal, U+F013 gear, U+F1B3 packages',
|
|
48
|
+
'ship/perf/scale: U+F135 rocket, U+F0E7 bolt, U+F080 chart, U+F201 trend line',
|
|
49
|
+
'security/access: U+F132 shield, U+F023 lock, U+F084 key',
|
|
50
|
+
'interface/design: U+F1FC paintbrush, U+F03E image, U+F108 display',
|
|
51
|
+
'communication/coordination: U+F1D8 paper plane, U+F0E6 comments, U+F0E8 sitemap, U+F0C1 link',
|
|
52
|
+
].join('\n');
|
|
53
|
+
const NAME_SYSTEM_PROMPT = 'You name coding-agent work sessions. The name is a label used to identify the ' +
|
|
25
54
|
'session at a glance among many other concurrent programming sessions, so it must ' +
|
|
26
|
-
'describe what the task is about
|
|
27
|
-
'
|
|
28
|
-
'`
|
|
29
|
-
'
|
|
55
|
+
'describe what the task is about.\n\n' +
|
|
56
|
+
'Call the `name_session` tool exactly once with:\n' +
|
|
57
|
+
'- `name`: a concise 3-8 word kebab-case name — lowercase words joined by single ' +
|
|
58
|
+
'hyphens (e.g. `refactor-auth-token-flow`, `add-csv-export-endpoint`). No punctuation, ' +
|
|
59
|
+
'quotes, or prose.\n' +
|
|
60
|
+
'- `icon`: ONE Nerd Font glyph, written as its codepoint in `U+XXXX` form (e.g. ' +
|
|
61
|
+
'`U+F188`). ANY Nerd Font codepoint is allowed — the whole Font Awesome, Material, ' +
|
|
62
|
+
'Devicons, Octicons and Codicons ranges are available, so reach for the glyph that ' +
|
|
63
|
+
'actually depicts this work rather than settling for one of the examples below. ' +
|
|
64
|
+
'Emit a codepoint, not a name like `nf-fa-bug`, and not an emoji.\n\n' +
|
|
65
|
+
'Pick the glyph whose depicted object a person would associate with this work at a ' +
|
|
66
|
+
'glance. Prefer the concrete subject (a database, a lock, a browser, a specific ' +
|
|
67
|
+
"technology's logo) over a generic verb glyph when the task has one; fall back to the " +
|
|
68
|
+
'activity (fixing, building, testing) otherwise. Never reuse a generic gear for ' +
|
|
69
|
+
'everything.\n\n' +
|
|
70
|
+
'Examples of what reads well, by kind of work:\n' +
|
|
71
|
+
ICON_EXAMPLES;
|
|
30
72
|
/** Put the raw task text FIRST in a delimited block, then the instruction, so the
|
|
31
73
|
* model reads the content before being told what to do and never mistakes the
|
|
32
74
|
* prompt's own text for the instruction. The prompt is capped first, so the
|
|
33
75
|
* closing tag is always present. */
|
|
34
76
|
function nameUserPrompt(prompt) {
|
|
35
|
-
return `<prompt>\n${prompt.slice(0, PROMPT_CAP)}\n</prompt>\n\nName this session based on the task above
|
|
77
|
+
return `<prompt>\n${prompt.slice(0, PROMPT_CAP)}\n</prompt>\n\nName this session based on the task above, and pick an icon depicting it. Call \`name_session\` once, then stop.`;
|
|
36
78
|
}
|
|
37
79
|
/** A short stop-word set so the local-slug fallback skips filler words. */
|
|
38
80
|
const STOPWORDS = new Set([
|
|
@@ -64,18 +106,24 @@ export function slugFromPrompt(prompt) {
|
|
|
64
106
|
.slice(0, 3);
|
|
65
107
|
return sanitizeSessionName(picked.join('-')) || 'session';
|
|
66
108
|
}
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
*
|
|
73
|
-
|
|
74
|
-
* classification that never needs a reasoning budget. Override the model with
|
|
75
|
-
* CRTR_NAME_MODEL. */
|
|
76
|
-
function piTextArgs(systemPrompt, userPrompt) {
|
|
109
|
+
/** The namer's model: the `light` rung of the DEFAULT provider's ladder, with
|
|
110
|
+
* any thinking suffix stripped (we pass `--thinking off` explicitly — naming is
|
|
111
|
+
* a trivial classification that never needs a reasoning budget). Reading the
|
|
112
|
+
* ladder rather than pinning a concrete model id keeps naming working for a
|
|
113
|
+
* user who never configured Anthropic: an OpenAI default provider resolves to
|
|
114
|
+
* its own light rung. `CRTR_NAME_MODEL` still overrides. */
|
|
115
|
+
function nameModel() {
|
|
77
116
|
const override = process.env['CRTR_NAME_MODEL'];
|
|
78
|
-
|
|
117
|
+
if (override !== undefined && override.trim() !== '')
|
|
118
|
+
return override.trim();
|
|
119
|
+
const ladders = modelLadders();
|
|
120
|
+
return stripThinkingSuffix(ladders[defaultProvider(ladders)].light);
|
|
121
|
+
}
|
|
122
|
+
/** The pi argv for a headless TEXT request (issue titles). Stripped down (no
|
|
123
|
+
* tools, session, context files, extensions, skills, templates, themes) so
|
|
124
|
+
* it's fast and side-effect free. */
|
|
125
|
+
function piTextArgs(systemPrompt, userPrompt) {
|
|
126
|
+
const model = nameModel();
|
|
79
127
|
return [
|
|
80
128
|
'-p',
|
|
81
129
|
'--no-session',
|
|
@@ -93,32 +141,120 @@ function piTextArgs(systemPrompt, userPrompt) {
|
|
|
93
141
|
userPrompt,
|
|
94
142
|
];
|
|
95
143
|
}
|
|
144
|
+
/** The pi argv for a headless STRUCTURED name request: same stripped-down run,
|
|
145
|
+
* but with the naming tool loaded explicitly (`-e` still works under
|
|
146
|
+
* `--no-extensions`) and the tool allowlist narrowed to it, so the model's only
|
|
147
|
+
* possible action is one constrained-schema emission. */
|
|
96
148
|
function nameArgs(prompt) {
|
|
97
|
-
return
|
|
149
|
+
return [
|
|
150
|
+
'-p',
|
|
151
|
+
'--no-session',
|
|
152
|
+
'--no-context-files',
|
|
153
|
+
'--no-extensions',
|
|
154
|
+
'--no-skills',
|
|
155
|
+
'--no-prompt-templates',
|
|
156
|
+
'--no-themes',
|
|
157
|
+
'-e', NAMING_TOOL_PATH,
|
|
158
|
+
'--tools', 'name_session',
|
|
159
|
+
'--mode', 'text',
|
|
160
|
+
// A trivial one-shot classification — no thinking budget, ever.
|
|
161
|
+
'--thinking', 'off',
|
|
162
|
+
'--model', nameModel(),
|
|
163
|
+
'--system-prompt', NAME_SYSTEM_PROMPT,
|
|
164
|
+
nameUserPrompt(prompt),
|
|
165
|
+
];
|
|
98
166
|
}
|
|
99
|
-
/**
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
167
|
+
/** Normalize a model-supplied icon to a single renderable glyph, or '' when
|
|
168
|
+
* nothing usable came back.
|
|
169
|
+
*
|
|
170
|
+
* The model is asked for a `U+XXXX` codepoint rather than the literal
|
|
171
|
+
* character: Nerd Font glyphs sit in the private-use areas, which models
|
|
172
|
+
* reproduce unreliably as literal text (they tend to substitute an emoji), but
|
|
173
|
+
* name accurately as codepoints. A literal glyph is still accepted — including
|
|
174
|
+
* an emoji, which renders fine even if it is not the house style — so a model
|
|
175
|
+
* that ignores the format still contributes. Rejects a spelled-out
|
|
176
|
+
* `nf-fa-bug`, a bare ASCII letter, and anything multi-glyph, all of which
|
|
177
|
+
* would render as noise or tofu in a label. */
|
|
178
|
+
export function sanitizeIcon(raw) {
|
|
179
|
+
const trimmed = (raw ?? '').trim();
|
|
180
|
+
if (trimmed === '')
|
|
181
|
+
return '';
|
|
182
|
+
const codepoint = /^(?:u\+|0x|\\u\{?|&#x)?([0-9a-f]{4,6})\}?;?$/i.exec(trimmed);
|
|
183
|
+
if (codepoint !== null) {
|
|
184
|
+
const value = Number.parseInt(codepoint[1], 16);
|
|
185
|
+
// Anything below the symbol blocks is a letter/digit/control — not an icon.
|
|
186
|
+
if (value >= 0x2000 && value <= 0x10ffff) {
|
|
187
|
+
try {
|
|
188
|
+
return String.fromCodePoint(value);
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return '';
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return '';
|
|
195
|
+
}
|
|
196
|
+
const points = [...trimmed];
|
|
197
|
+
// A glyph, plus at most a variation selector.
|
|
198
|
+
if (points.length > 2)
|
|
199
|
+
return '';
|
|
200
|
+
return points[0].codePointAt(0) >= 0x2000 ? trimmed : '';
|
|
201
|
+
}
|
|
202
|
+
/** Ask pi headlessly for a structured {name, icon} for `body`, async. Resolves
|
|
203
|
+
* to a sanitized SessionName, or `{name:'',icon:''}` on any failure (non-zero
|
|
204
|
+
* exit, timeout, no tool call, garbled emission) so the caller can fall back to
|
|
205
|
+
* a local slug. Owns the subprocess mechanics — crucially it hands pi an
|
|
206
|
+
* immediate stdin EOF: `pi -p` reads stdin, and execFile's default stdin is an
|
|
207
|
+
* OPEN pipe that never closes, so without this pi blocks waiting for EOF and
|
|
208
|
+
* the call exits non-zero (the regression that silently lost every LLM name to
|
|
209
|
+
* the slug fallback).
|
|
106
210
|
*
|
|
107
211
|
* Exported (not just for tests): the canvas-coupled persistence wrapper
|
|
108
212
|
* generateAndPersistName lives in naming-persist.ts to keep this module
|
|
109
213
|
* canvas-free, and imports this pure helper. */
|
|
110
214
|
export function headlessName(body) {
|
|
215
|
+
const empty = { name: '', icon: '' };
|
|
111
216
|
return new Promise((resolve) => {
|
|
217
|
+
let dir = null;
|
|
112
218
|
try {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
219
|
+
dir = mkdtempSync(join(tmpdir(), 'crtr-name-'));
|
|
220
|
+
const resultFile = join(dir, 'name.json');
|
|
221
|
+
const done = (value) => {
|
|
222
|
+
if (dir !== null) {
|
|
223
|
+
try {
|
|
224
|
+
rmSync(dir, { recursive: true, force: true });
|
|
225
|
+
}
|
|
226
|
+
catch { /* temp */ }
|
|
227
|
+
}
|
|
228
|
+
resolve(value);
|
|
229
|
+
};
|
|
230
|
+
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...nameArgs(body)], {
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
timeout: NAME_TIMEOUT_MS,
|
|
233
|
+
env: { ...bundledPiSubprocessEnv(), CRTR_NAME_RESULT_FILE: resultFile },
|
|
234
|
+
}, () => {
|
|
235
|
+
// The tool call is the ONLY channel: a non-zero exit still counts when
|
|
236
|
+
// the emission already landed (pi print-mode can exit oddly after a
|
|
237
|
+
// terminating tool), and a clean exit without the file is a failure.
|
|
238
|
+
try {
|
|
239
|
+
const parsed = JSON.parse(readFileSync(resultFile, 'utf8'));
|
|
240
|
+
const name = sanitizeSessionName(typeof parsed['name'] === 'string' ? parsed['name'] : '');
|
|
241
|
+
const icon = sanitizeIcon(typeof parsed['icon'] === 'string' ? parsed['icon'] : '');
|
|
242
|
+
done(name !== '' ? { name, icon } : empty);
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
done(empty);
|
|
246
|
+
}
|
|
117
247
|
});
|
|
118
248
|
child.stdin?.end(); // immediate EOF — see the doc above
|
|
119
249
|
}
|
|
120
250
|
catch {
|
|
121
|
-
|
|
251
|
+
if (dir !== null) {
|
|
252
|
+
try {
|
|
253
|
+
rmSync(dir, { recursive: true, force: true });
|
|
254
|
+
}
|
|
255
|
+
catch { /* temp */ }
|
|
256
|
+
}
|
|
257
|
+
resolve(empty);
|
|
122
258
|
}
|
|
123
259
|
});
|
|
124
260
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface NerdFontDetection {
|
|
2
|
+
/** A Nerd Font face is installed somewhere on this system. */
|
|
3
|
+
installed: boolean;
|
|
4
|
+
/** The family or filename that matched, for the doctor message. */
|
|
5
|
+
evidence: string | null;
|
|
6
|
+
/** How the match was found — fontconfig query, or a font-directory scan. */
|
|
7
|
+
source: 'fc-list' | 'font-dir' | null;
|
|
8
|
+
}
|
|
9
|
+
export declare function detectNerdFont(platform?: NodeJS.Platform, home?: string): NerdFontDetection;
|
|
10
|
+
/** Download the upstream symbols-only patch into the user's font dir. Works on
|
|
11
|
+
* every Linux regardless of package manager — Debian/Ubuntu and Fedora ship no
|
|
12
|
+
* Nerd Font package, so there is nothing native to install there. Needs no
|
|
13
|
+
* sudo: it writes to `~/.local/share/fonts`. */
|
|
14
|
+
export declare const NERD_FONT_MANUAL_LINUX_COMMAND: string;
|
|
15
|
+
export interface NerdFontInstallCommand {
|
|
16
|
+
command: string | null;
|
|
17
|
+
hint: string;
|
|
18
|
+
autoInstall: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** The install command for this platform/package manager. `packageManager`
|
|
21
|
+
* matches setup's detection: only brew and pacman have a real Nerd Font
|
|
22
|
+
* package, everything else on Linux gets the upstream download. A non-brew
|
|
23
|
+
* package manager only ever exists on Linux, so it settles the platform on its
|
|
24
|
+
* own and `platform` decides only the `manual` case. */
|
|
25
|
+
export declare function nerdFontInstallCommand(packageManager: 'brew' | 'apt-get' | 'dnf' | 'pacman' | 'manual', platform?: NodeJS.Platform): NerdFontInstallCommand;
|
|
26
|
+
/** Every install path ends the same way: the font is on disk but the terminal
|
|
27
|
+
* emulator still has to be pointed at it, and crtr cannot do that. */
|
|
28
|
+
export declare const NERD_FONT_SELECT_HINT = "then set your terminal profile's font to a Nerd Font (e.g. add \"Symbols Nerd Font Mono\" as the non-ASCII/fallback font) \u2014 installing it does not select it";
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readdirSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
/** crtr's viewer renders tool calls, command icons, node badges and the
|
|
6
|
+
* inspector with Nerd Font private-use-area codepoints (see
|
|
7
|
+
* `core/preview-registry.ts` and `clients/attach/render/tool-calls.ts`), so a
|
|
8
|
+
* terminal without a Nerd Font shows tofu boxes for every icon. Nothing about
|
|
9
|
+
* the font is auto-detectable from inside the process — the terminal emulator
|
|
10
|
+
* picks the face — so the best we can do is detect an INSTALLED Nerd Font on
|
|
11
|
+
* the system and tell the user to point their terminal profile at it. */
|
|
12
|
+
/** Font-family and filename fragments that only appear in Nerd Font builds.
|
|
13
|
+
* `symbols` alone would false-positive on system symbol faces, so the
|
|
14
|
+
* symbols-only patch is matched with its full distributed name. */
|
|
15
|
+
const NERD_FONT_PATTERN = /nerd[ _-]?font|symbolsnerdfont|symbols-only/i;
|
|
16
|
+
/** Font directories searched when fontconfig is unavailable (always the case
|
|
17
|
+
* on macOS). User-level dirs first: an install crtr suggested lands there. */
|
|
18
|
+
function fontDirs(platform, home) {
|
|
19
|
+
if (platform === 'darwin') {
|
|
20
|
+
return [join(home, 'Library', 'Fonts'), '/Library/Fonts', '/System/Library/Fonts'];
|
|
21
|
+
}
|
|
22
|
+
return [
|
|
23
|
+
join(home, '.local', 'share', 'fonts'),
|
|
24
|
+
join(home, '.fonts'),
|
|
25
|
+
'/usr/local/share/fonts',
|
|
26
|
+
'/usr/share/fonts',
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
/** Shallow recursive filename scan. Font trees nest a couple of levels
|
|
30
|
+
* (`/usr/share/fonts/truetype/<family>/`), so depth 3 reaches every real
|
|
31
|
+
* install without walking an unbounded tree. */
|
|
32
|
+
function scanForNerdFont(dir, depth) {
|
|
33
|
+
if (depth < 0)
|
|
34
|
+
return null;
|
|
35
|
+
let entries;
|
|
36
|
+
try {
|
|
37
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return null; // absent or unreadable directory is simply not a match
|
|
41
|
+
}
|
|
42
|
+
for (const entry of entries) {
|
|
43
|
+
if (entry.isDirectory()) {
|
|
44
|
+
const nested = scanForNerdFont(join(dir, entry.name), depth - 1);
|
|
45
|
+
if (nested !== null)
|
|
46
|
+
return nested;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!/\.(ttf|otf|ttc)$/i.test(entry.name))
|
|
50
|
+
continue;
|
|
51
|
+
if (NERD_FONT_PATTERN.test(entry.name))
|
|
52
|
+
return entry.name;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/** Ask fontconfig for installed families. Present on Linux, absent on macOS. */
|
|
57
|
+
function queryFontconfig() {
|
|
58
|
+
const probe = spawnSync('sh', ['-lc', 'command -v fc-list >/dev/null 2>&1'], { stdio: 'ignore' });
|
|
59
|
+
if (probe.status !== 0)
|
|
60
|
+
return null;
|
|
61
|
+
const res = spawnSync('fc-list', [':', 'family'], { encoding: 'utf8', timeout: 5000 });
|
|
62
|
+
if (res.status !== 0 || typeof res.stdout !== 'string')
|
|
63
|
+
return null;
|
|
64
|
+
for (const line of res.stdout.split('\n')) {
|
|
65
|
+
if (NERD_FONT_PATTERN.test(line))
|
|
66
|
+
return line.split(',')[0].trim();
|
|
67
|
+
}
|
|
68
|
+
return ''; // fontconfig answered and knows of no Nerd Font — authoritative miss
|
|
69
|
+
}
|
|
70
|
+
export function detectNerdFont(platform = process.platform, home = homedir()) {
|
|
71
|
+
if (platform !== 'darwin') {
|
|
72
|
+
const family = queryFontconfig();
|
|
73
|
+
if (family !== null && family !== '')
|
|
74
|
+
return { installed: true, evidence: family, source: 'fc-list' };
|
|
75
|
+
}
|
|
76
|
+
for (const dir of fontDirs(platform, home)) {
|
|
77
|
+
const file = scanForNerdFont(dir, 3);
|
|
78
|
+
if (file !== null)
|
|
79
|
+
return { installed: true, evidence: file, source: 'font-dir' };
|
|
80
|
+
}
|
|
81
|
+
return { installed: false, evidence: null, source: null };
|
|
82
|
+
}
|
|
83
|
+
/** Download the upstream symbols-only patch into the user's font dir. Works on
|
|
84
|
+
* every Linux regardless of package manager — Debian/Ubuntu and Fedora ship no
|
|
85
|
+
* Nerd Font package, so there is nothing native to install there. Needs no
|
|
86
|
+
* sudo: it writes to `~/.local/share/fonts`. */
|
|
87
|
+
export const NERD_FONT_MANUAL_LINUX_COMMAND = 'mkdir -p ~/.local/share/fonts && '
|
|
88
|
+
+ 'curl -fsSL -o /tmp/NerdFontsSymbolsOnly.zip '
|
|
89
|
+
+ 'https://github.com/ryanoasis/nerd-fonts/releases/latest/download/NerdFontsSymbolsOnly.zip && '
|
|
90
|
+
+ 'unzip -oq /tmp/NerdFontsSymbolsOnly.zip -d ~/.local/share/fonts && fc-cache -f';
|
|
91
|
+
/** The install command for this platform/package manager. `packageManager`
|
|
92
|
+
* matches setup's detection: only brew and pacman have a real Nerd Font
|
|
93
|
+
* package, everything else on Linux gets the upstream download. A non-brew
|
|
94
|
+
* package manager only ever exists on Linux, so it settles the platform on its
|
|
95
|
+
* own and `platform` decides only the `manual` case. */
|
|
96
|
+
export function nerdFontInstallCommand(packageManager, platform = process.platform) {
|
|
97
|
+
const onLinux = platform !== 'darwin' || packageManager === 'apt-get' || packageManager === 'dnf' || packageManager === 'pacman';
|
|
98
|
+
if (packageManager === 'brew') {
|
|
99
|
+
return {
|
|
100
|
+
command: 'brew install --cask font-symbols-only-nerd-font',
|
|
101
|
+
hint: 'Homebrew cask',
|
|
102
|
+
autoInstall: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
if (packageManager === 'pacman') {
|
|
106
|
+
return {
|
|
107
|
+
command: 'sudo pacman -S --noconfirm ttf-nerd-fonts-symbols',
|
|
108
|
+
hint: 'pacman install',
|
|
109
|
+
autoInstall: true,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (onLinux) {
|
|
113
|
+
return {
|
|
114
|
+
command: NERD_FONT_MANUAL_LINUX_COMMAND,
|
|
115
|
+
hint: 'download symbols-only patch to ~/.local/share/fonts (needs curl + unzip)',
|
|
116
|
+
autoInstall: true,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
command: null,
|
|
121
|
+
hint: 'install Homebrew, or download a Nerd Font from https://nerdfonts.com and add it in Font Book',
|
|
122
|
+
autoInstall: false,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** Every install path ends the same way: the font is on disk but the terminal
|
|
126
|
+
* emulator still has to be pointed at it, and crtr cannot do that. */
|
|
127
|
+
export const NERD_FONT_SELECT_HINT = 'then set your terminal profile\'s font to a Nerd Font (e.g. add "Symbols Nerd Font Mono" as the non-ASCII/fallback font) — installing it does not select it';
|
|
@@ -483,6 +483,8 @@ const MENU_ACTIONS = {
|
|
|
483
483
|
// M-S-r carries shift+alt and lands as the same chord the keyboard produces.
|
|
484
484
|
'crtr.tmux.menu.file-review': { description: 'review a file', action: { kind: 'raw-keys', keys: 'M-S-r' } },
|
|
485
485
|
'crtr.tmux.menu.metadata': { description: 'metadata', action: { kind: 'keys', keys: '/node-metadata' } },
|
|
486
|
+
'crtr.tmux.menu.fold-tools': { description: 'fold finished tool calls', action: { kind: 'keys', keys: '/fold-tools' } },
|
|
487
|
+
'crtr.tmux.menu.view-settings': undefined,
|
|
486
488
|
'crtr.tmux.menu.context': { description: 'context + reports', action: { kind: 'keys', keys: '/context' } },
|
|
487
489
|
'crtr.tmux.menu.providers': { description: 'settings', action: { kind: 'popup', run: 'sys setup' } },
|
|
488
490
|
'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
|
|
@@ -627,6 +629,11 @@ function menuItems(bindings) {
|
|
|
627
629
|
'crtr.tmux.menu.metadata',
|
|
628
630
|
'crtr.tmux.menu.context',
|
|
629
631
|
]);
|
|
632
|
+
// How this viewer PAINTS the transcript, as opposed to what it shows. Each item
|
|
633
|
+
// is a toggle the viewer applies to its own render state (a `/`-command sent to
|
|
634
|
+
// the pane); a tmux menu is built once and cannot show live state, so the item
|
|
635
|
+
// reads as the action and the viewer answers with a notice.
|
|
636
|
+
const viewSettingsItems = boundItems(['crtr.tmux.menu.fold-tools']);
|
|
630
637
|
const exploreItems = boundItems([
|
|
631
638
|
'crtr.tmux.menu.resume',
|
|
632
639
|
'crtr.tmux.menu.graph',
|
|
@@ -641,6 +648,15 @@ function menuItems(bindings) {
|
|
|
641
648
|
});
|
|
642
649
|
}
|
|
643
650
|
}
|
|
651
|
+
if (viewSettingsItems.length > 0) {
|
|
652
|
+
for (const gesture of bindings.gestures('crtr.tmux.menu.view-settings')) {
|
|
653
|
+
exploreItems.push({
|
|
654
|
+
description: 'view settings ›',
|
|
655
|
+
selector: menuSelector(gesture),
|
|
656
|
+
action: { kind: 'submenu', title: ' view settings ', items: viewSettingsItems },
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
644
660
|
// Explore changes what is in view or opens a canvas-level browser. Current
|
|
645
661
|
// node acts on the agent in this pane without changing its lifecycle.
|
|
646
662
|
addGroup('Explore', exploreItems);
|
package/dist/core/tui/host.js
CHANGED
|
@@ -501,9 +501,12 @@ export async function runCoreView(core, tui, text, opts = {}) {
|
|
|
501
501
|
setMode(mode) { chrome.mode = mode; },
|
|
502
502
|
quit() { finish(); },
|
|
503
503
|
};
|
|
504
|
-
// The single-flight busy lane lives in the external FIFO.
|
|
505
|
-
//
|
|
506
|
-
//
|
|
504
|
+
// The single-flight busy lane lives in the external FIFO. Refresh timers skip
|
|
505
|
+
// while busy; ctx.dispatch from inside an intent runs inline so chained intents
|
|
506
|
+
// do not deadlock. Keystrokes QUEUE behind an in-flight intent (up to a small
|
|
507
|
+
// depth) rather than vanishing: a view whose auto-refresh shells out is busy a
|
|
508
|
+
// real fraction of the time, and silently swallowing a `y` confirmation or a
|
|
509
|
+
// cursor move is the difference between a live surface and a deaf one.
|
|
507
510
|
const streams = new Set();
|
|
508
511
|
let busy = false;
|
|
509
512
|
let dispatching = false;
|
|
@@ -627,6 +630,9 @@ export async function runCoreView(core, tui, text, opts = {}) {
|
|
|
627
630
|
render();
|
|
628
631
|
if (core.intents['refresh'])
|
|
629
632
|
await enqueueDispatch('refresh');
|
|
633
|
+
// Keystroke-dispatch backlog depth (see onData).
|
|
634
|
+
const MAX_QUEUED_KEYS = 2;
|
|
635
|
+
let queuedKeys = 0;
|
|
630
636
|
// Capture-mode buffer: the host's line-editor draft while a `capture` binding's
|
|
631
637
|
// when(state) is true; reset to '' whenever no capture binding is active.
|
|
632
638
|
let captureBuf = '';
|
|
@@ -650,8 +656,10 @@ export async function runCoreView(core, tui, text, opts = {}) {
|
|
|
650
656
|
finish();
|
|
651
657
|
return;
|
|
652
658
|
}
|
|
653
|
-
//
|
|
654
|
-
|
|
659
|
+
// Queue behind an in-flight intent, but shallowly: past a couple of pending
|
|
660
|
+
// keys the user is hammering a stuck view, and replaying the backlog later
|
|
661
|
+
// would act on a screen they never saw.
|
|
662
|
+
if (busy && queuedKeys >= MAX_QUEUED_KEYS)
|
|
655
663
|
return;
|
|
656
664
|
// Text-capture: while a capture binding is active, printable/backspace edit
|
|
657
665
|
// the host buffer and dispatch capture(nextDraft); other keys (return/escape)
|
|
@@ -673,8 +681,15 @@ export async function runCoreView(core, tui, text, opts = {}) {
|
|
|
673
681
|
captureBuf = '';
|
|
674
682
|
}
|
|
675
683
|
const m = matchViewBinding(tui.keymap, bindings, input, key, state, raw);
|
|
676
|
-
if (m)
|
|
684
|
+
if (!m)
|
|
685
|
+
return;
|
|
686
|
+
queuedKeys += 1;
|
|
687
|
+
try {
|
|
677
688
|
await enqueueDispatch(m.intent, m.payload);
|
|
689
|
+
}
|
|
690
|
+
finally {
|
|
691
|
+
queuedKeys -= 1;
|
|
692
|
+
}
|
|
678
693
|
};
|
|
679
694
|
process.stdin.on('data', (d) => { void onData(d); });
|
|
680
695
|
// Resize → repaint from current state (never re-enter an in-flight intent).
|
package/dist/daemon/api/map.js
CHANGED
|
@@ -105,6 +105,8 @@ export function toNodeDetailDTO(meta, edges) {
|
|
|
105
105
|
};
|
|
106
106
|
if (meta.description !== undefined)
|
|
107
107
|
dto.description = meta.description;
|
|
108
|
+
if (meta.icon !== undefined)
|
|
109
|
+
dto.icon = meta.icon;
|
|
108
110
|
if (meta.cycles !== undefined)
|
|
109
111
|
dto.cycles = meta.cycles;
|
|
110
112
|
dto.context_tokens = readContextTokens(meta.node_id);
|
|
@@ -244,6 +244,11 @@ export function createValveOperations(nodeId, contextDir) {
|
|
|
244
244
|
finish(() => reject(err));
|
|
245
245
|
});
|
|
246
246
|
pgid = child.pid;
|
|
247
|
+
// The detached supervisor IS the group leader, so its pid is the pgid.
|
|
248
|
+
// Persisting it is what lets a human stop the job from the Inspector
|
|
249
|
+
// later; the agent's own handoff notice quotes the same number.
|
|
250
|
+
if (pgid !== undefined)
|
|
251
|
+
writeFileSync(paths.jobPgid, String(pgid));
|
|
247
252
|
child.unref();
|
|
248
253
|
interval = setInterval(() => {
|
|
249
254
|
offset = tailOnce(paths.jobLog, offset, onData);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
interface PiLike {
|
|
2
|
+
registerTool: (definition: Record<string, unknown>) => void;
|
|
3
|
+
}
|
|
4
|
+
/** The naming schema — the ONE definition of what a generated session name is.
|
|
5
|
+
* Both fields are required: a name without an icon renders a bare label, and
|
|
6
|
+
* an icon without a name is useless, so the model is made to decide both in a
|
|
7
|
+
* single constrained emission. */
|
|
8
|
+
export declare const NAME_SESSION_SCHEMA: {
|
|
9
|
+
readonly type: "object";
|
|
10
|
+
readonly properties: {
|
|
11
|
+
readonly name: {
|
|
12
|
+
readonly type: "string";
|
|
13
|
+
readonly description: "A 3-8 word kebab-case session name: lowercase words joined by single hyphens, e.g. refactor-auth-token-flow. No punctuation, quotes, or prose.";
|
|
14
|
+
};
|
|
15
|
+
readonly icon: {
|
|
16
|
+
readonly type: "string";
|
|
17
|
+
readonly description: "The Nerd Font glyph depicting this session, as a codepoint in U+XXXX form (e.g. U+F188).";
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
readonly required: readonly ["name", "icon"];
|
|
21
|
+
readonly additionalProperties: false;
|
|
22
|
+
};
|
|
23
|
+
export declare function registerNamingTool(pi: PiLike): void;
|
|
24
|
+
export default registerNamingTool;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// naming-tool.ts — the structured-output seam for session naming.
|
|
2
|
+
//
|
|
3
|
+
// Loaded ONLY by the headless namer subprocess (`runtime/naming.ts` passes it
|
|
4
|
+
// with `-e` alongside `--no-extensions`), never by a canvas node. It registers a
|
|
5
|
+
// single `name_session` tool whose parameters ARE the naming schema, with
|
|
6
|
+
// pi's constrained sampling turned on, so the light model emits a real typed
|
|
7
|
+
// object instead of prose we have to parse out of a text reply.
|
|
8
|
+
//
|
|
9
|
+
// The result travels back to the parent over a file: the parent creates a temp
|
|
10
|
+
// path, passes it as CRTR_NAME_RESULT_FILE, and reads it after the subprocess
|
|
11
|
+
// exits. stdout is unusable for this — pi print-mode writes its own chatter
|
|
12
|
+
// there.
|
|
13
|
+
//
|
|
14
|
+
// Inert (registers nothing) when CRTR_NAME_RESULT_FILE is absent, so an
|
|
15
|
+
// accidental load in any other context is a no-op.
|
|
16
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname } from 'node:path';
|
|
18
|
+
/** The naming schema — the ONE definition of what a generated session name is.
|
|
19
|
+
* Both fields are required: a name without an icon renders a bare label, and
|
|
20
|
+
* an icon without a name is useless, so the model is made to decide both in a
|
|
21
|
+
* single constrained emission. */
|
|
22
|
+
export const NAME_SESSION_SCHEMA = {
|
|
23
|
+
type: 'object',
|
|
24
|
+
properties: {
|
|
25
|
+
name: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'A 3-8 word kebab-case session name: lowercase words joined by single hyphens, e.g. refactor-auth-token-flow. No punctuation, quotes, or prose.',
|
|
28
|
+
},
|
|
29
|
+
icon: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'The Nerd Font glyph depicting this session, as a codepoint in U+XXXX form (e.g. U+F188).',
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ['name', 'icon'],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
};
|
|
37
|
+
export function registerNamingTool(pi) {
|
|
38
|
+
const out = process.env['CRTR_NAME_RESULT_FILE'];
|
|
39
|
+
if (out === undefined || out.trim() === '')
|
|
40
|
+
return;
|
|
41
|
+
pi.registerTool({
|
|
42
|
+
name: 'name_session',
|
|
43
|
+
label: 'Name session',
|
|
44
|
+
description: 'Record the name and icon for this coding session. Call exactly once, then stop.',
|
|
45
|
+
parameters: NAME_SESSION_SCHEMA,
|
|
46
|
+
// Ask the provider to constrain sampling to the schema. `prefer` degrades
|
|
47
|
+
// silently on a model without strict tool support (`require` would fail the
|
|
48
|
+
// whole request); the parent validates and falls back to a local slug
|
|
49
|
+
// either way, so this only makes a malformed emission rarer.
|
|
50
|
+
constrainedSampling: { type: 'json_schema', strict: 'prefer' },
|
|
51
|
+
execute(_id, params) {
|
|
52
|
+
try {
|
|
53
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
54
|
+
writeFileSync(out, JSON.stringify(params ?? {}), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// The parent falls back to a local slug when the file never lands.
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
content: [{ type: 'text', text: 'Recorded.' }],
|
|
61
|
+
details: { recorded: true },
|
|
62
|
+
terminate: true,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export default registerNamingTool;
|
package/dist/types.d.ts
CHANGED
|
@@ -152,6 +152,11 @@ export interface ScopeConfig {
|
|
|
152
152
|
* `both` keeps both. Tool calls and results are never kept. Full detail for
|
|
153
153
|
* every cycle stays in the session file. */
|
|
154
154
|
condensed_history: CondensedHistoryMode;
|
|
155
|
+
/** Fold every SETTLED tool call in the attach viewer down to its single call
|
|
156
|
+
* line (`/fold-tools`, Alt+C → w → f). A tool still running is never folded —
|
|
157
|
+
* it folds itself once it settles. Toggling it in a viewer writes back here,
|
|
158
|
+
* so the choice carries into later chats and later viewers. Default false. */
|
|
159
|
+
fold_finished_tools: boolean;
|
|
155
160
|
keybindings: KeybindingOverrides;
|
|
156
161
|
modelLadders: ModelLaddersConfig;
|
|
157
162
|
/** The kind registry (spec §1.5): kind existence + launch knobs, keyed by
|
package/dist/types.js
CHANGED
|
@@ -38,6 +38,7 @@ export function defaultScopeConfig() {
|
|
|
38
38
|
completion_bell: true,
|
|
39
39
|
live_cycles: DEFAULT_LIVE_CYCLES,
|
|
40
40
|
condensed_history: DEFAULT_CONDENSED_HISTORY,
|
|
41
|
+
fold_finished_tools: false,
|
|
41
42
|
keybindings: {},
|
|
42
43
|
modelLadders: defaultModelLaddersConfig(),
|
|
43
44
|
kinds: defaultKindsConfig(),
|