@crouton-kit/crouter 0.3.78 → 0.3.79
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/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1228 -12
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +3 -3
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -787
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +8 -3
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +9 -1
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +849 -803
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +37 -0
- package/dist/commands/sys/setup-core.js +138 -1
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +915 -171
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +71 -41
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +2 -5
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- 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 +2 -1
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/broker.js +7 -6
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/recap.d.ts +1 -1
- package/dist/core/runtime/recap.js +50 -25
- package/dist/core/runtime/session-list-cache.d.ts +10 -0
- package/dist/core/runtime/session-list-cache.js +94 -26
- package/dist/core/runtime/spawn.js +1 -13
- package/dist/core/runtime/tmux.js +2 -1
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/canvas-recap.js +43 -17
- package/dist/types.d.ts +6 -13
- package/dist/types.js +0 -3
- package/package.json +7 -3
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// One entry point: generateRecap — async (execFile, non-blocking), mirroring
|
|
5
5
|
// naming.ts's headless-`pi -p` pattern exactly. It runs the live conversation's
|
|
6
6
|
// working model over the literal back-and-forth (the caller concatenates user and
|
|
7
|
-
// assistant text) with a stripped-
|
|
7
|
+
// assistant text, plus the node's roadmap when one exists) with a stripped-
|
|
8
8
|
// down pi invocation (no tools/session/context/extensions/skills), so it's fast
|
|
9
9
|
// and side-effect free and can never recurse into another spawn.
|
|
10
10
|
//
|
|
@@ -24,30 +24,49 @@ const RECAP_TIMEOUT_MS = 25_000;
|
|
|
24
24
|
* turn, avoiding a dead hard-pinned provider. CRTR_RECAP_MODEL overrides both. */
|
|
25
25
|
const DEFAULT_RECAP_MODEL = 'anthropic/claude-haiku-4-5';
|
|
26
26
|
const RECAP_SYSTEM_PROMPT = 'You brief a developer returning to a paused coding-agent conversation. They run ' +
|
|
27
|
-
'many agent sessions in parallel and have lost this one\'s thread
|
|
28
|
-
'
|
|
29
|
-
'
|
|
30
|
-
'
|
|
27
|
+
'many agent sessions in parallel and have lost this one\'s thread: they no longer ' +
|
|
28
|
+
'know what this conversation is about or what they originally asked for. The ' +
|
|
29
|
+
'brief exists to solve exactly that — pinned above their input box, it must tell ' +
|
|
30
|
+
'them at a glance what this conversation is FOR and where it actually stands. ' +
|
|
31
|
+
'You are given the first and most recent messages of the literal user/agent ' +
|
|
32
|
+
'back-and-forth (tool output omitted), and the agent\'s roadmap file when one ' +
|
|
33
|
+
'exists.\n' +
|
|
31
34
|
'\n' +
|
|
32
|
-
'Output EXACTLY
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
'
|
|
36
|
-
'
|
|
37
|
-
'
|
|
38
|
-
'
|
|
39
|
-
'
|
|
40
|
-
'
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
35
|
+
'Output EXACTLY two plain-text lines, nothing else:\n' +
|
|
36
|
+
'Goal: <what was requested — the original ask / what this conversation is for>\n' +
|
|
37
|
+
'State: <catch-them-up: what is ACTUALLY going on right now>\n' +
|
|
38
|
+
'\n' +
|
|
39
|
+
'Both lines are extremely short — telegraphic fragments, roughly 8-15 words, no ' +
|
|
40
|
+
'filler. The State line must NOT restate the most recent message — the last ' +
|
|
41
|
+
'message is often mid-progress, a question, or noise; instead synthesize the ' +
|
|
42
|
+
'real position: what has landed, what is in flight, and the live blocker or ' +
|
|
43
|
+
'needed answer. If the agent is waiting on the developer, say exactly what for. ' +
|
|
44
|
+
'Be concrete (real file names, branches, SHAs, error messages — one specific ' +
|
|
45
|
+
'anchor beats abstractions). Never narrate turn by turn, hedge, or pad. If the ' +
|
|
46
|
+
'conversation has barely started, still emit your best two lines.\n' +
|
|
47
|
+
'\n' +
|
|
48
|
+
'Examples of the shape (from other conversations):\n' +
|
|
49
|
+
'Goal: Validate + commit the Unit 3 attach-keybinding migration in this worktree.\n' +
|
|
50
|
+
'State: Done — 17/17 tests green, committed and rebased onto main.\n' +
|
|
51
|
+
'\n' +
|
|
52
|
+
'Goal: Build the trimmed-v1 command-plugin seam (orchestrating phases).\n' +
|
|
53
|
+
'State: Phase 1 landed; waiting on the core-seam worker\'s final.\n' +
|
|
54
|
+
'\n' +
|
|
55
|
+
'Goal: Standing personal assistant — iMessage wakes, Reddit scans, small tasks.\n' +
|
|
56
|
+
'State: Idle — waiting on you: hook pick, map export, Omi timing.';
|
|
57
|
+
/** Put the content FIRST in delimited blocks (roadmap, then conversation), then
|
|
58
|
+
* the instruction, so the model reads the content before being told what to do. */
|
|
59
|
+
function recapUserPrompt(conversation, roadmap) {
|
|
60
|
+
const rm = roadmap !== undefined && roadmap.trim() !== ''
|
|
61
|
+
? `<roadmap>\n${roadmap.trim()}\n</roadmap>\n\n`
|
|
62
|
+
: '';
|
|
63
|
+
return `${rm}<conversation>\n${conversation}\n</conversation>\n\nWrite the two-line Goal:/State: briefing for the conversation above. Output JUST the two lines.`;
|
|
45
64
|
}
|
|
46
65
|
/** The pi argv for a headless recap request. Stripped down (no tools, session,
|
|
47
66
|
* context files, extensions, skills, templates, themes) so it's fast and
|
|
48
67
|
* side-effect free. Thinking stays off; model precedence is explicit env
|
|
49
68
|
* override → the conversation's live working model → the small fallback. */
|
|
50
|
-
function recapArgs(conversation, liveModel) {
|
|
69
|
+
function recapArgs(conversation, liveModel, roadmap) {
|
|
51
70
|
const override = process.env['CRTR_RECAP_MODEL'];
|
|
52
71
|
// An explicit operator override wins. Otherwise prefer the live conversation's
|
|
53
72
|
// current model: it just completed a successful turn, while a hard-pinned
|
|
@@ -71,14 +90,20 @@ function recapArgs(conversation, liveModel) {
|
|
|
71
90
|
'--thinking', 'off',
|
|
72
91
|
'--model', model,
|
|
73
92
|
'--system-prompt', RECAP_SYSTEM_PROMPT,
|
|
74
|
-
recapUserPrompt(conversation),
|
|
93
|
+
recapUserPrompt(conversation, roadmap),
|
|
75
94
|
];
|
|
76
95
|
return argv;
|
|
77
96
|
}
|
|
78
|
-
/** Normalize model stdout to
|
|
79
|
-
*
|
|
97
|
+
/** Normalize model stdout to the two-line `Goal:`/`State:` briefing. Collapses
|
|
98
|
+
* intra-line whitespace and drops blank lines; the viewer paints each line
|
|
99
|
+
* (wrapping to its width). A garbled shape still returns whatever lines came
|
|
100
|
+
* back — the widget is best-effort chrome, not a contract. */
|
|
80
101
|
function parseRecap(stdout) {
|
|
81
|
-
return stdout
|
|
102
|
+
return stdout
|
|
103
|
+
.split('\n')
|
|
104
|
+
.map((l) => l.replace(/\s+/g, ' ').trim())
|
|
105
|
+
.filter((l) => l !== '')
|
|
106
|
+
.join('\n');
|
|
82
107
|
}
|
|
83
108
|
/** Ask pi headlessly for a concise briefing of `conversation`, async. Invokes
|
|
84
109
|
* `onRecap` with the parsed string on success, or never (silent) on any
|
|
@@ -87,12 +112,12 @@ function parseRecap(stdout) {
|
|
|
87
112
|
* and execFile's default stdin is an OPEN pipe that never closes, so without
|
|
88
113
|
* this pi blocks waiting for EOF and the call exits non-zero (the same gotcha
|
|
89
114
|
* naming.ts documents). */
|
|
90
|
-
export function generateRecap(conversation, onRecap, liveModel) {
|
|
115
|
+
export function generateRecap(conversation, onRecap, liveModel, roadmap) {
|
|
91
116
|
const body = (conversation ?? '').trim();
|
|
92
117
|
if (body === '')
|
|
93
118
|
return;
|
|
94
119
|
try {
|
|
95
|
-
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body, liveModel)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
|
|
120
|
+
const child = execFile(process.execPath, [resolveBundledPiCliPath(), ...recapArgs(body, liveModel, roadmap)], { encoding: 'utf8', timeout: RECAP_TIMEOUT_MS, env: bundledPiSubprocessEnv() }, (err, stdout) => {
|
|
96
121
|
if (err || typeof stdout !== 'string')
|
|
97
122
|
return; // silent — no recap
|
|
98
123
|
const briefing = parseRecap(stdout);
|
|
@@ -10,13 +10,23 @@ export declare class SessionListCache {
|
|
|
10
10
|
private loaded;
|
|
11
11
|
private persisting;
|
|
12
12
|
private persistAgain;
|
|
13
|
+
private persistTimer;
|
|
13
14
|
constructor(cacheFile: string);
|
|
15
|
+
/** Fire-and-forget warm of the cwd scope at broker start, so the first
|
|
16
|
+
* `/resume` open after this broker launches is already warm. The cold scan
|
|
17
|
+
* streams each file via readline (yields per chunk), so it never blocks the
|
|
18
|
+
* broker loop — pre-warming beats worker threads here. Errors are swallowed
|
|
19
|
+
* by the same paths that guard a normal open. */
|
|
20
|
+
prewarm(sessionDir: string): void;
|
|
14
21
|
/** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
|
|
15
22
|
listDir(sessionDir: string): Promise<WireSessionInfo[]>;
|
|
16
23
|
/** Sessions across every cwd's dir under the sessions root (`all` scope). */
|
|
17
24
|
listAll(sessionsRoot: string): Promise<WireSessionInfo[]>;
|
|
18
25
|
private build;
|
|
19
26
|
private ensureLoaded;
|
|
27
|
+
/** Coalesce persists onto a single trailing timer so the tens-of-MB
|
|
28
|
+
* `JSON.stringify` never runs on the reply path and rapid opens write once. */
|
|
29
|
+
private schedulePersist;
|
|
20
30
|
/** Atomic write (temp + rename) so a concurrent reader never sees a partial
|
|
21
31
|
* file. Coalesces overlapping writes into one trailing flush. */
|
|
22
32
|
private persist;
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
// file's info without re-reading the whole corpus is to reproduce its parse.
|
|
18
18
|
// Keep it in sync with pi's session-file format (CURRENT_SESSION_VERSION).
|
|
19
19
|
import { createReadStream } from 'node:fs';
|
|
20
|
-
import { readFile, readdir, rename, stat, writeFile } from 'node:fs/promises';
|
|
20
|
+
import { readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises';
|
|
21
21
|
import { createInterface } from 'node:readline';
|
|
22
|
-
import { join } from 'node:path';
|
|
22
|
+
import { dirname, join } from 'node:path';
|
|
23
23
|
/** Search-text bound: `allMessagesText` is searched on every query edit; pi's
|
|
24
24
|
* unbounded value retains gigabytes in the viewer and turns key-repeat into GC
|
|
25
25
|
* jitter. The opening and latest text keep search useful. */
|
|
@@ -31,6 +31,29 @@ export function compactSessionSearchText(text) {
|
|
|
31
31
|
return `${text.slice(0, half)} … ${text.slice(-half)}`;
|
|
32
32
|
}
|
|
33
33
|
const MAX_CONCURRENT_INFO_LOADS = 10;
|
|
34
|
+
/** Persisted-file schema version. Bump whenever `CacheEntry`'s shape (or
|
|
35
|
+
* `WireSessionInfo`'s) changes: on a version mismatch the whole file is
|
|
36
|
+
* discarded and rebuilt from disk — no per-entry migration, no lenient
|
|
37
|
+
* recovery (the cache is fully derivable). */
|
|
38
|
+
const CACHE_SCHEMA_VERSION = 1;
|
|
39
|
+
/** The full map is tens of MB (~15 MB for one cwd, ~46 MB for `all`); a
|
|
40
|
+
* synchronous `JSON.stringify` of it must never sit on the reply path. Persists
|
|
41
|
+
* are coalesced onto this trailing timer so rapid opens write at most once per
|
|
42
|
+
* window, off the hot path. */
|
|
43
|
+
const PERSIST_DEBOUNCE_MS = 2000;
|
|
44
|
+
/** Shape-check a loaded entry — a well-formed-JSON file with the right version
|
|
45
|
+
* but a stale entry shape (e.g. missing `info`) would otherwise push undefined
|
|
46
|
+
* into the sort and throw. Any failure discards the whole file (rebuild). */
|
|
47
|
+
function isValidEntry(e) {
|
|
48
|
+
if (typeof e !== 'object' || e === null)
|
|
49
|
+
return false;
|
|
50
|
+
const c = e;
|
|
51
|
+
return (typeof c['mtimeMs'] === 'number' &&
|
|
52
|
+
typeof c['size'] === 'number' &&
|
|
53
|
+
typeof c['info'] === 'object' &&
|
|
54
|
+
c['info'] !== null &&
|
|
55
|
+
typeof c['info']['modified'] === 'string');
|
|
56
|
+
}
|
|
34
57
|
// ---- faithful mirror of pi's private buildSessionInfo -----------------------
|
|
35
58
|
function parseLine(line) {
|
|
36
59
|
if (!line.trim())
|
|
@@ -152,12 +175,21 @@ export class SessionListCache {
|
|
|
152
175
|
loaded = false;
|
|
153
176
|
persisting = false;
|
|
154
177
|
persistAgain = false;
|
|
178
|
+
persistTimer = null;
|
|
155
179
|
constructor(cacheFile) {
|
|
156
180
|
this.cacheFile = cacheFile;
|
|
157
181
|
}
|
|
182
|
+
/** Fire-and-forget warm of the cwd scope at broker start, so the first
|
|
183
|
+
* `/resume` open after this broker launches is already warm. The cold scan
|
|
184
|
+
* streams each file via readline (yields per chunk), so it never blocks the
|
|
185
|
+
* broker loop — pre-warming beats worker threads here. Errors are swallowed
|
|
186
|
+
* by the same paths that guard a normal open. */
|
|
187
|
+
prewarm(sessionDir) {
|
|
188
|
+
void this.listDir(sessionDir);
|
|
189
|
+
}
|
|
158
190
|
/** Sessions for one cwd's session dir (the picker's default `cwd` scope). */
|
|
159
191
|
async listDir(sessionDir) {
|
|
160
|
-
return this.build(await jsonlFiles(sessionDir));
|
|
192
|
+
return this.build(await jsonlFiles(sessionDir), new Set([sessionDir]));
|
|
161
193
|
}
|
|
162
194
|
/** Sessions across every cwd's dir under the sessions root (`all` scope). */
|
|
163
195
|
async listAll(sessionsRoot) {
|
|
@@ -173,32 +205,44 @@ export class SessionListCache {
|
|
|
173
205
|
const files = [];
|
|
174
206
|
for (const dir of dirEntries)
|
|
175
207
|
files.push(...(await jsonlFiles(dir)));
|
|
176
|
-
return this.build(files);
|
|
208
|
+
return this.build(files, new Set(dirEntries));
|
|
177
209
|
}
|
|
178
|
-
async build(files) {
|
|
210
|
+
async build(files, scannedDirs) {
|
|
179
211
|
await this.ensureLoaded();
|
|
180
212
|
const present = new Set(files);
|
|
213
|
+
let changed = false;
|
|
214
|
+
// Prune ONLY keys under the dirs we actually scanned. A cwd-scope open must
|
|
215
|
+
// not evict other cwds' cached entries: they weren't rescanned, so their
|
|
216
|
+
// absence from `files` says nothing about whether they still exist on disk.
|
|
217
|
+
// (Pruning the whole map here would self-destruct the global/`all` cache on
|
|
218
|
+
// every cwd open.)
|
|
181
219
|
for (const key of this.cache.keys()) {
|
|
182
|
-
if (!present.has(key))
|
|
220
|
+
if (!present.has(key) && scannedDirs.has(dirname(key))) {
|
|
183
221
|
this.cache.delete(key);
|
|
222
|
+
changed = true;
|
|
223
|
+
}
|
|
184
224
|
}
|
|
185
|
-
|
|
186
|
-
|
|
225
|
+
// Positioned by file index so equal-`modified` ties keep readdir order
|
|
226
|
+
// deterministically (workers finish out of order); a stable sort preserves it.
|
|
227
|
+
const infos = new Array(files.length);
|
|
187
228
|
let next = 0;
|
|
188
229
|
const worker = async () => {
|
|
189
|
-
|
|
190
|
-
const
|
|
230
|
+
for (;;) {
|
|
231
|
+
const i = next++;
|
|
232
|
+
if (i >= files.length)
|
|
233
|
+
return;
|
|
234
|
+
const file = files[i];
|
|
191
235
|
if (file === undefined)
|
|
192
236
|
return;
|
|
193
237
|
const st = await stat(file).catch(() => null);
|
|
194
238
|
if (!st) {
|
|
195
|
-
this.cache.delete(file)
|
|
196
|
-
|
|
239
|
+
if (this.cache.delete(file))
|
|
240
|
+
changed = true;
|
|
197
241
|
continue;
|
|
198
242
|
}
|
|
199
243
|
const cached = this.cache.get(file);
|
|
200
244
|
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
201
|
-
infos
|
|
245
|
+
infos[i] = cached.info;
|
|
202
246
|
continue;
|
|
203
247
|
}
|
|
204
248
|
const info = await buildWireSessionInfo(file);
|
|
@@ -208,14 +252,15 @@ export class SessionListCache {
|
|
|
208
252
|
continue;
|
|
209
253
|
}
|
|
210
254
|
this.cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, info });
|
|
211
|
-
infos
|
|
255
|
+
infos[i] = info;
|
|
212
256
|
}
|
|
213
257
|
};
|
|
214
258
|
await Promise.all(Array.from({ length: Math.min(MAX_CONCURRENT_INFO_LOADS, files.length) }, () => worker()));
|
|
215
259
|
if (changed)
|
|
216
|
-
|
|
217
|
-
infos.
|
|
218
|
-
|
|
260
|
+
this.schedulePersist();
|
|
261
|
+
const result = infos.filter((x) => x !== undefined);
|
|
262
|
+
result.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
|
263
|
+
return result;
|
|
219
264
|
}
|
|
220
265
|
async ensureLoaded() {
|
|
221
266
|
if (this.loaded)
|
|
@@ -223,15 +268,35 @@ export class SessionListCache {
|
|
|
223
268
|
this.loaded = true;
|
|
224
269
|
try {
|
|
225
270
|
const raw = JSON.parse(await readFile(this.cacheFile, 'utf8'));
|
|
226
|
-
|
|
271
|
+
// Version mismatch or shape drift → discard and rebuild. No lenient
|
|
272
|
+
// multi-path recovery: the cache is derivable, so a bad file is just
|
|
273
|
+
// overwritten on the next persist.
|
|
274
|
+
if (raw?.version !== CACHE_SCHEMA_VERSION || typeof raw.entries !== 'object')
|
|
275
|
+
return;
|
|
276
|
+
for (const [path, entry] of Object.entries(raw.entries)) {
|
|
277
|
+
if (!isValidEntry(entry)) {
|
|
278
|
+
this.cache.clear();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
227
281
|
this.cache.set(path, entry);
|
|
282
|
+
}
|
|
228
283
|
}
|
|
229
284
|
catch {
|
|
230
|
-
// Missing or corrupt cache file → start empty and rebuild.
|
|
231
|
-
|
|
232
|
-
// overwritten on the next persist.
|
|
285
|
+
// Missing or corrupt cache file → start empty and rebuild.
|
|
286
|
+
this.cache.clear();
|
|
233
287
|
}
|
|
234
288
|
}
|
|
289
|
+
/** Coalesce persists onto a single trailing timer so the tens-of-MB
|
|
290
|
+
* `JSON.stringify` never runs on the reply path and rapid opens write once. */
|
|
291
|
+
schedulePersist() {
|
|
292
|
+
if (this.persistTimer)
|
|
293
|
+
return;
|
|
294
|
+
this.persistTimer = setTimeout(() => {
|
|
295
|
+
this.persistTimer = null;
|
|
296
|
+
void this.persist();
|
|
297
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
298
|
+
this.persistTimer.unref?.();
|
|
299
|
+
}
|
|
235
300
|
/** Atomic write (temp + rename) so a concurrent reader never sees a partial
|
|
236
301
|
* file. Coalesces overlapping writes into one trailing flush. */
|
|
237
302
|
async persist() {
|
|
@@ -240,16 +305,19 @@ export class SessionListCache {
|
|
|
240
305
|
return;
|
|
241
306
|
}
|
|
242
307
|
this.persisting = true;
|
|
308
|
+
const tmp = `${this.cacheFile}.${process.pid}.tmp`;
|
|
243
309
|
try {
|
|
244
|
-
const
|
|
310
|
+
const entries = {};
|
|
245
311
|
for (const [path, entry] of this.cache)
|
|
246
|
-
|
|
247
|
-
const
|
|
248
|
-
await writeFile(tmp, JSON.stringify(
|
|
312
|
+
entries[path] = entry;
|
|
313
|
+
const payload = { version: CACHE_SCHEMA_VERSION, entries };
|
|
314
|
+
await writeFile(tmp, JSON.stringify(payload));
|
|
249
315
|
await rename(tmp, this.cacheFile);
|
|
250
316
|
}
|
|
251
317
|
catch {
|
|
252
|
-
// Best-effort: a failed persist only costs a rebuild next open.
|
|
318
|
+
// Best-effort: a failed persist only costs a rebuild next open. Clean up
|
|
319
|
+
// the temp file so a failed write never leaks it.
|
|
320
|
+
await unlink(tmp).catch(() => { });
|
|
253
321
|
}
|
|
254
322
|
finally {
|
|
255
323
|
this.persisting = false;
|
|
@@ -12,7 +12,6 @@
|
|
|
12
12
|
import { spawn } from 'node:child_process';
|
|
13
13
|
import { readdirSync, existsSync } from 'node:fs';
|
|
14
14
|
import { isAbsolute, resolve, join } from 'node:path';
|
|
15
|
-
import { homedir } from 'node:os';
|
|
16
15
|
import { spawnNode, currentNodeContext, rootOfSpine, newNodeId } from './nodes.js';
|
|
17
16
|
import { loadProfileManifest } from '../profiles/manifest.js';
|
|
18
17
|
import { selectProfileForCwd } from '../profiles/select.js';
|
|
@@ -28,6 +27,7 @@ import { installTmuxBindings } from './tmux-chrome.js';
|
|
|
28
27
|
import { getNode, fullName, recordPid } from '../canvas/index.js';
|
|
29
28
|
import { registerViewerFocus, openViewerWindow, waitForBrokerViewSocket, viewerSplitEnv, windowOfPane, currentTmux, inTmux, focusWindow, } from './placement.js';
|
|
30
29
|
import { transition } from './lifecycle.js';
|
|
30
|
+
import { piSessionsRoot } from './pi-vendored.js';
|
|
31
31
|
import { headlessBrokerHost } from './host.js';
|
|
32
32
|
import { ensureDaemon } from '../../daemon/manage.js';
|
|
33
33
|
// ---------------------------------------------------------------------------
|
|
@@ -186,18 +186,6 @@ export async function bootRoot(opts) {
|
|
|
186
186
|
}
|
|
187
187
|
process.exit(await attachExit);
|
|
188
188
|
}
|
|
189
|
-
/** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
|
|
190
|
-
* sessions`). pi's package `exports` map is `.`-only, so config.js can't be
|
|
191
|
-
* deep-imported, and a ROOT import of `getAgentDir` would eager-load the entire
|
|
192
|
-
* heavy pi SDK index on crtr's front-door hot path (the reason broker-sdk.ts
|
|
193
|
-
* dynamic-imports the engine). Mirrors pi: `PI_CODING_AGENT_DIR` env, else
|
|
194
|
-
* `~/.pi/agent` (APP_NAME defaults to 'pi'). Re-sync on a pi SDK bump that moves
|
|
195
|
-
* the sessions dir — same vendoring rationale as pi-vendored.ts. */
|
|
196
|
-
function piSessionsRoot() {
|
|
197
|
-
const env = process.env['PI_CODING_AGENT_DIR'];
|
|
198
|
-
const agentDir = env !== undefined && env !== '' ? env : join(homedir(), '.pi', 'agent');
|
|
199
|
-
return join(agentDir, 'sessions');
|
|
200
|
-
}
|
|
201
189
|
/** Resolve a bare/partial pi session uuid to its ABSOLUTE `.jsonl` path by
|
|
202
190
|
* scanning pi's sessions store the way pi's own CLI does (exact id, else a
|
|
203
191
|
* unique prefix). The broker fork seam (`SessionManager.forkFrom`) loads a FILE
|
|
@@ -419,6 +419,7 @@ const MENU_ACTIONS = {
|
|
|
419
419
|
'crtr.tmux.menu.detach': { description: 'detach to background', action: { kind: 'command', run: 'node lifecycle demote --pane {pane} --detach' } },
|
|
420
420
|
'crtr.tmux.menu.close-subtree': { description: 'close agent + subtree', action: { kind: 'command', run: 'node lifecycle close --pane {pane}' } },
|
|
421
421
|
'crtr.tmux.menu.context': { description: 'browse context dirs', action: { kind: 'keys', keys: '/context' } },
|
|
422
|
+
'crtr.tmux.menu.providers': { description: 'manage providers', action: { kind: 'popup', run: 'sys setup' } },
|
|
422
423
|
'crtr.tmux.menu.graph': { description: 'graph view', action: { kind: 'keys', keys: '/graph' } },
|
|
423
424
|
'crtr.tmux.menu.focus-manager': { description: 'focus manager', action: { kind: 'command', run: 'node focus {manager}' } },
|
|
424
425
|
'crtr.tmux.menu.open': undefined,
|
|
@@ -501,7 +502,7 @@ function menuItems(bindings) {
|
|
|
501
502
|
for (const id of [
|
|
502
503
|
'crtr.tmux.menu.promote', 'crtr.tmux.menu.resume', 'crtr.tmux.menu.demote',
|
|
503
504
|
'crtr.tmux.menu.detach', 'crtr.tmux.menu.close-subtree', 'crtr.tmux.menu.context',
|
|
504
|
-
'crtr.tmux.menu.graph', 'crtr.tmux.menu.focus-manager', 'crtr.tmux.menu.issues',
|
|
505
|
+
'crtr.tmux.menu.providers', 'crtr.tmux.menu.graph', 'crtr.tmux.menu.focus-manager', 'crtr.tmux.menu.issues',
|
|
505
506
|
]) {
|
|
506
507
|
const entry = id === 'crtr.tmux.menu.issues'
|
|
507
508
|
? { description: 'new issue / todo', action: { kind: 'submenu', title: 'new issue / todo', items: issueItems } }
|
package/dist/core/scope.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
|
-
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { existsSync, statSync, realpathSync } from 'node:fs';
|
|
3
3
|
import { join, resolve, dirname } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { CRTR_DIR_NAME } from '../types.js';
|
|
@@ -48,6 +48,14 @@ export function builtinPiPackageDir(name) {
|
|
|
48
48
|
export function userScopeRoot() {
|
|
49
49
|
return join(homedir(), CRTR_DIR_NAME);
|
|
50
50
|
}
|
|
51
|
+
function realpathOrSelf(p) {
|
|
52
|
+
try {
|
|
53
|
+
return realpathSync(p);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return p;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
51
59
|
function isProjectScopeDir(candidate, userRoot) {
|
|
52
60
|
if (candidate === userRoot || !existsSync(candidate))
|
|
53
61
|
return false;
|
|
@@ -61,16 +69,31 @@ function isProjectScopeDir(candidate, userRoot) {
|
|
|
61
69
|
export function findProjectScopeRoot(startDir = process.cwd()) {
|
|
62
70
|
return findProjectScopeRoots(startDir)[0] ?? null;
|
|
63
71
|
}
|
|
64
|
-
/** Walk `startDir` upward
|
|
65
|
-
*
|
|
66
|
-
*
|
|
72
|
+
/** Walk `startDir` upward collecting every ancestor `.crouter/` dir (nearest
|
|
73
|
+
* first). The user-global `~/.crouter/` is excluded because it is the separate
|
|
74
|
+
* user scope, not a project ancestor.
|
|
75
|
+
*
|
|
76
|
+
* The walk is FENCED at `homedir()`: it must never climb at or above HOME.
|
|
77
|
+
* Home's own `.crouter/` is the user scope (already excluded), and nothing at
|
|
78
|
+
* or above HOME is a project ancestor. Without this fence, a relocated HOME
|
|
79
|
+
* nested under a directory tree that itself contains a `.crouter/` (e.g. a
|
|
80
|
+
* mock-VM home under the real host home) would sweep the host's `~/.crouter`
|
|
81
|
+
* (and any other above-HOME `.crouter`) in as a bogus project ancestor,
|
|
82
|
+
* leaking one machine's user-global memory into a nested-HOME node. Mirrors
|
|
83
|
+
* the on-read positional-walk fence (src/core/substrate/on-read.ts). A read
|
|
84
|
+
* whose start dir is outside HOME (e.g. a production VM with HOME=/root and no
|
|
85
|
+
* ancestor `.crouter`) never reaches home and walks to the filesystem root, as
|
|
86
|
+
* before. */
|
|
67
87
|
function collectAncestorScopeRoots(startDir, userRoot) {
|
|
68
88
|
const roots = [];
|
|
89
|
+
const home = realpathOrSelf(homedir());
|
|
69
90
|
let dir = resolve(startDir);
|
|
70
91
|
while (true) {
|
|
71
92
|
const candidate = join(dir, CRTR_DIR_NAME);
|
|
72
93
|
if (isProjectScopeDir(candidate, userRoot))
|
|
73
94
|
roots.push(candidate);
|
|
95
|
+
if (realpathOrSelf(dir) === home)
|
|
96
|
+
return roots;
|
|
74
97
|
const parent = dirname(dir);
|
|
75
98
|
if (parent === dir)
|
|
76
99
|
return roots;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
export declare const ANTHROPIC_PROVIDER_ID = "anthropic";
|
|
4
|
+
export declare const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
|
|
5
|
+
export type ManagedProviderId = typeof ANTHROPIC_PROVIDER_ID | typeof OPENAI_CODEX_PROVIDER_ID;
|
|
6
|
+
export type LadderProviderId = "anthropic" | "openai";
|
|
7
|
+
export type ModelStrength = "ultra" | "strong" | "medium" | "light";
|
|
8
|
+
export type SubscriptionCredential = {
|
|
9
|
+
label: string;
|
|
10
|
+
refresh?: string;
|
|
11
|
+
access?: string;
|
|
12
|
+
expires?: number;
|
|
13
|
+
rateLimitedUntil: number;
|
|
14
|
+
lastAttemptAt: number;
|
|
15
|
+
lastRateLimitedAt: number;
|
|
16
|
+
accountId?: string;
|
|
17
|
+
};
|
|
18
|
+
export type ModelRef = {
|
|
19
|
+
providerId: ManagedProviderId;
|
|
20
|
+
modelId: string;
|
|
21
|
+
};
|
|
22
|
+
export type RotationConfig = {
|
|
23
|
+
defaultFallbackStrength?: ModelStrength;
|
|
24
|
+
revertWhenAvailable?: boolean;
|
|
25
|
+
preferredModel?: ModelRef;
|
|
26
|
+
};
|
|
27
|
+
type RotationConfigUpdate = {
|
|
28
|
+
defaultFallbackStrength?: ModelStrength;
|
|
29
|
+
revertWhenAvailable?: boolean;
|
|
30
|
+
preferredModel?: ModelRef | null;
|
|
31
|
+
};
|
|
32
|
+
export type FallbackTarget = {
|
|
33
|
+
providerId: ManagedProviderId;
|
|
34
|
+
modelId: string;
|
|
35
|
+
label: string;
|
|
36
|
+
strength: ModelStrength;
|
|
37
|
+
thinkingLevel?: ThinkingLevel;
|
|
38
|
+
};
|
|
39
|
+
export declare function isManagedProvider(providerId: string | undefined): providerId is ManagedProviderId;
|
|
40
|
+
/** The `modelLadders` CONFIG key for a runtime provider id -- `openai-codex`
|
|
41
|
+
* (the runtime/auth id) maps to the `openai` ladder key used in
|
|
42
|
+
* ~/.crouter/config.json. Callers building a repair command that a user can
|
|
43
|
+
* paste into `crtr sys config set modelLadders.<key>...` must use THIS key,
|
|
44
|
+
* not the runtime provider id. */
|
|
45
|
+
export declare function getLadderProviderId(providerId: ManagedProviderId): LadderProviderId;
|
|
46
|
+
export declare function getProviderLabel(providerId: ManagedProviderId): string;
|
|
47
|
+
export declare function mutateSubscriptionPool(providerId: ManagedProviderId, fn: (pool: SubscriptionCredential[]) => SubscriptionCredential[] | undefined): SubscriptionCredential[];
|
|
48
|
+
export declare function readSubscriptionPool(providerId: ManagedProviderId): SubscriptionCredential[];
|
|
49
|
+
export declare function writeSubscriptionPool(providerId: ManagedProviderId, next: SubscriptionCredential[]): SubscriptionCredential[];
|
|
50
|
+
export declare function upsertSubscription(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[];
|
|
51
|
+
export declare class DuplicateSubscriptionError extends Error {
|
|
52
|
+
constructor(message: string);
|
|
53
|
+
}
|
|
54
|
+
export declare function addSubscription(providerId: ManagedProviderId, entry: SubscriptionCredential): SubscriptionCredential[];
|
|
55
|
+
export declare function upsertSubscriptionWithUniqueAccount(providerId: ManagedProviderId, next: SubscriptionCredential): SubscriptionCredential[];
|
|
56
|
+
export type DefaultSlotLogin = {
|
|
57
|
+
refresh: string;
|
|
58
|
+
access: string;
|
|
59
|
+
expires: number;
|
|
60
|
+
accountId?: string;
|
|
61
|
+
lastRateLimitedAt: number;
|
|
62
|
+
};
|
|
63
|
+
export declare function commitDefaultIdentity(providerId: ManagedProviderId, login: DefaultSlotLogin): SubscriptionCredential[];
|
|
64
|
+
export declare function renameSubscription(providerId: ManagedProviderId, ref: string, nextLabel: string): SubscriptionCredential[];
|
|
65
|
+
export declare function removeManagedAccount(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
|
|
66
|
+
export declare function commitManagedLogin(providerId: ManagedProviderId, label: string, credential: {
|
|
67
|
+
refresh: string;
|
|
68
|
+
access: string;
|
|
69
|
+
expires: number;
|
|
70
|
+
accountId?: string;
|
|
71
|
+
defaultLastRateLimitedAt: number;
|
|
72
|
+
}): SubscriptionCredential[];
|
|
73
|
+
export declare function removeManagedProvider(providerId: ManagedProviderId, label?: string): SubscriptionCredential[];
|
|
74
|
+
export declare function removeSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
|
|
75
|
+
export declare function promoteSubscription(providerId: ManagedProviderId, ref: string): SubscriptionCredential[];
|
|
76
|
+
export declare function markSubscriptionAttempt(providerId: ManagedProviderId, ref: string, attemptAt?: number): SubscriptionCredential[];
|
|
77
|
+
export declare function markSubscriptionRateLimited(providerId: ManagedProviderId, ref: string, retryAfterMs: number, attemptAt?: number, rateLimitedAt?: number): SubscriptionCredential[];
|
|
78
|
+
export declare function markSubscriptionSuccess(providerId: ManagedProviderId, ref: string, attemptAt?: number): SubscriptionCredential[];
|
|
79
|
+
export declare function findAvailableSubscription(providerId: ManagedProviderId, now?: number): SubscriptionCredential | undefined;
|
|
80
|
+
export declare function parseRetryAfterHeader(value: string | undefined, now?: number): number | undefined;
|
|
81
|
+
export declare function readRotationConfig(): RotationConfig;
|
|
82
|
+
export declare function writeRotationConfig(next: RotationConfigUpdate): RotationConfig;
|
|
83
|
+
export declare function clearPreferredModel(): RotationConfig;
|
|
84
|
+
export declare function rememberPreferredModel(model: ModelRef): RotationConfig;
|
|
85
|
+
export declare function resolveFallbackTarget(currentProviderId: ManagedProviderId, currentModelId?: string, config?: RotationConfig, currentThinkingLevel?: string): FallbackTarget | undefined;
|
|
86
|
+
export declare function shouldRestorePreferredModel(config?: RotationConfig, now?: number): boolean;
|
|
87
|
+
export declare function restorePreferredModelIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">): Promise<boolean>;
|
|
88
|
+
export declare function switchToFallbackIfPossible(piApi: ExtensionAPI, ctx: Pick<ExtensionContext, "model" | "modelRegistry">, preferredModel?: ModelRef | undefined): Promise<FallbackTarget | undefined>;
|
|
89
|
+
export declare function formatStatusLine(providerId: ManagedProviderId, now?: number): string;
|
|
90
|
+
export {};
|