@crouton-kit/crouter 0.3.80 → 0.3.82
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/cli.js +9 -0
- package/dist/clients/attach/attach-cmd.js +897 -898
- package/dist/clients/attach/chat-view.js +7 -3
- package/dist/clients/attach/slash-commands.js +9 -1
- package/dist/core/canvas/canvas.d.ts +7 -0
- package/dist/core/canvas/canvas.js +15 -0
- package/dist/core/runtime/broker.js +14 -0
- package/dist/core/runtime/front-door.js +50 -4
- package/dist/core/runtime/package-health.d.ts +28 -0
- package/dist/core/runtime/package-health.js +94 -0
- package/dist/core/runtime/resume.d.ts +6 -0
- package/dist/core/runtime/resume.js +275 -0
- package/package.json +1 -1
|
@@ -201,7 +201,7 @@ export class ChatView {
|
|
|
201
201
|
// renderedPendingTools → pendingTools, where live tool events find them.)
|
|
202
202
|
if (message.stopReason === 'aborted' || message.stopReason === 'error') {
|
|
203
203
|
const errorMessage = message.stopReason === 'aborted'
|
|
204
|
-
? '
|
|
204
|
+
? 'Interrupted'
|
|
205
205
|
: (message.errorMessage ?? 'Error');
|
|
206
206
|
component.updateResult({ content: [{ type: 'text', text: errorMessage }], isError: true });
|
|
207
207
|
}
|
|
@@ -333,8 +333,12 @@ export class ChatView {
|
|
|
333
333
|
if (this.streamingComponent) {
|
|
334
334
|
const stopReason = event.message.stopReason;
|
|
335
335
|
let errorMessage = event.message.errorMessage;
|
|
336
|
-
|
|
337
|
-
|
|
336
|
+
// An aborted turn is a user interrupt (Esc). The provider tends to
|
|
337
|
+
// attach its own generic text ("Request was aborted."), so override it
|
|
338
|
+
// outright — don't gate on the absence of an errorMessage — with a
|
|
339
|
+
// plain, human label.
|
|
340
|
+
if (stopReason === 'aborted') {
|
|
341
|
+
errorMessage = 'Interrupted';
|
|
338
342
|
// Surface the abort on the assistant bubble itself, mirroring pi
|
|
339
343
|
// (interactive-mode.js:2280 sets streamingMessage.errorMessage before
|
|
340
344
|
// updateContent) so the rendered message shows the annotation.
|
|
@@ -32,6 +32,7 @@ import { surfaceTmuxStyleArgs } from '../../core/runtime/surface-bg.js';
|
|
|
32
32
|
* `/graph` toggles the in-process overlay. Exported so the editor folds these
|
|
33
33
|
* into the slash autocomplete list. */
|
|
34
34
|
export const CANVAS_SLASH_COMMANDS = [
|
|
35
|
+
{ name: 'clear', description: 'Clear the conversation and free context — a fresh, empty session (like /new)' },
|
|
35
36
|
{ name: 'graph', description: 'Toggle the canvas GRAPH view (your local subscription tree)' },
|
|
36
37
|
{ name: 'promote', description: 'Promote this node to an orchestrator — /promote, or /promote <kind> to specialize' },
|
|
37
38
|
{ name: 'resume-node', description: 'Open the canvas navigator (search/scope/sort/tree) and resume the chosen node' },
|
|
@@ -48,7 +49,7 @@ const CANVAS_NAMES = new Set(CANVAS_SLASH_COMMANDS.map((c) => c.name));
|
|
|
48
49
|
* local node, `/context` opens local `crtr node inspect context`. `/graph`
|
|
49
50
|
* (the in-process GRAPH overlay, already remote-safe — see graph-overlay.ts)
|
|
50
51
|
* and `/view` (a self-contained popup unrelated to `nodeId`) stay available. */
|
|
51
|
-
const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context', 'rename']);
|
|
52
|
+
const REMOTE_UNSAFE_CANVAS_NAMES = new Set(['promote', 'resume-node', 'context', 'rename', 'clear']);
|
|
52
53
|
/** tmux named colours accepted by `/color` (plus `colourN`/`#rrggbb`, validated
|
|
53
54
|
* by pattern). Mirrors tmux's colour-name table so a typo is rejected with the
|
|
54
55
|
* options rather than silently ignored by tmux. */
|
|
@@ -279,6 +280,13 @@ function dispatchCanvasCommand(name, arg, ctx) {
|
|
|
279
280
|
return true;
|
|
280
281
|
}
|
|
281
282
|
switch (name) {
|
|
283
|
+
case 'clear':
|
|
284
|
+
// Claude-Code ergonomics: `/clear` == `/new` — drop the conversation and
|
|
285
|
+
// start an empty session (context freed). The broker's new_session re-
|
|
286
|
+
// emits a `welcome`, whose applySnapshot resetChat()s the pane, so the
|
|
287
|
+
// transcript is wiped too. Aliased here so muscle memory works.
|
|
288
|
+
ctx.send({ type: 'new_session' });
|
|
289
|
+
return true;
|
|
282
290
|
case 'graph':
|
|
283
291
|
// `/graph` opens the attach viewer's in-process GRAPH overlay, not a tmux
|
|
284
292
|
// popup. Dispatch reaches it through the viewer integration's `onGraph` hook.
|
|
@@ -54,6 +54,13 @@ export declare function clearPid(nodeId: string): void;
|
|
|
54
54
|
export declare function listNodes(filter?: {
|
|
55
55
|
status?: NodeStatus | NodeStatus[];
|
|
56
56
|
}): NodeRow[];
|
|
57
|
+
/** Resumable ROOT conversations pinned to `cwd`, most-recently-created first.
|
|
58
|
+
* A "root" is the top of a spine (`parent IS NULL`) that is a real
|
|
59
|
+
* conversation (not a `kind:'human'` ask) and still resumable (`active`/`idle`,
|
|
60
|
+
* never a `done`/`dead`/`canceled` node). This backs the front-door
|
|
61
|
+
* `crtr -c`/`-r` resume: cwd is the key (the Claude-Code mental model — "my
|
|
62
|
+
* last session HERE"), so it never teleports across projects. */
|
|
63
|
+
export declare function listResumableRoots(cwd: string): NodeRow[];
|
|
57
64
|
/** Record `A subscribes_to B` — A receives B's output. active=true wakes A on
|
|
58
65
|
* emit; passive accumulates pointers without a wake. Mutable; callable by anyone. */
|
|
59
66
|
export declare function subscribe(subscriber: string, publisher: string, active?: boolean): void;
|
|
@@ -288,6 +288,21 @@ export function listNodes(filter) {
|
|
|
288
288
|
}
|
|
289
289
|
return rows.map(rowFrom);
|
|
290
290
|
}
|
|
291
|
+
/** Resumable ROOT conversations pinned to `cwd`, most-recently-created first.
|
|
292
|
+
* A "root" is the top of a spine (`parent IS NULL`) that is a real
|
|
293
|
+
* conversation (not a `kind:'human'` ask) and still resumable (`active`/`idle`,
|
|
294
|
+
* never a `done`/`dead`/`canceled` node). This backs the front-door
|
|
295
|
+
* `crtr -c`/`-r` resume: cwd is the key (the Claude-Code mental model — "my
|
|
296
|
+
* last session HERE"), so it never teleports across projects. */
|
|
297
|
+
export function listResumableRoots(cwd) {
|
|
298
|
+
const rows = openDb()
|
|
299
|
+
.prepare(`SELECT * FROM nodes
|
|
300
|
+
WHERE parent IS NULL AND kind != 'human'
|
|
301
|
+
AND status IN ('active','idle') AND cwd = ?
|
|
302
|
+
ORDER BY created DESC`)
|
|
303
|
+
.all(cwd);
|
|
304
|
+
return rows.map(rowFrom);
|
|
305
|
+
}
|
|
291
306
|
// ---------------------------------------------------------------------------
|
|
292
307
|
// Edges
|
|
293
308
|
// ---------------------------------------------------------------------------
|
|
@@ -23,6 +23,7 @@ import { join } from 'node:path';
|
|
|
23
23
|
import { randomUUID } from 'node:crypto';
|
|
24
24
|
import { getAgentDir, initTheme, } from '@earendil-works/pi-coding-agent';
|
|
25
25
|
import { jobDir, nodeDir, sessionListCachePath, viewSocketPath } from '../canvas/paths.js';
|
|
26
|
+
import { missingPackageWarning } from './package-health.js';
|
|
26
27
|
import { SessionListCache } from './session-list-cache.js';
|
|
27
28
|
import { getNode, updateNode } from '../canvas/index.js';
|
|
28
29
|
import { FRONT_DOOR_ENV } from './front-door.js';
|
|
@@ -180,6 +181,19 @@ export async function runBroker(nodeId) {
|
|
|
180
181
|
catch (err) {
|
|
181
182
|
process.stderr.write(`[broker] WARNING: initTheme failed: ${String(err)}\n`);
|
|
182
183
|
}
|
|
184
|
+
// Diagnose a stale local-path pi package (e.g. a moved crouter repo whose
|
|
185
|
+
// relative `pi-crtr-extensions` path in settings.json no longer resolves). pi
|
|
186
|
+
// discards a missing package silently, dropping its whole feature (the Claude
|
|
187
|
+
// command/marketplace bridge) with no trace; log it loudly so broker.log shows
|
|
188
|
+
// the cause. Fix: `crtr sys setup`.
|
|
189
|
+
try {
|
|
190
|
+
const warn = missingPackageWarning(getAgentDir());
|
|
191
|
+
if (warn !== null)
|
|
192
|
+
process.stderr.write(`[broker] WARNING: ${warn}\n`);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
/* best-effort */
|
|
196
|
+
}
|
|
183
197
|
// N3: the node's display name is re-applied inside rebindSession (below) so it
|
|
184
198
|
// survives a session replacement (new_session/switch_session/fork), not only at
|
|
185
199
|
// boot.
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// crtr → boot a root in this terminal (no prompt)
|
|
4
4
|
// crtr [dir] → root pinned to dir
|
|
5
5
|
// crtr [dir] ["prompt"] → root with a starter prompt
|
|
6
|
+
// crtr -c | --continue → resume the MRU root pinned to cwd (no picker)
|
|
7
|
+
// crtr -r | --resume → resume a root pinned to cwd, chosen from a menu
|
|
6
8
|
// crtr --name NAME ... → named root
|
|
7
9
|
// crtr --profile <id-or-name> → root under an explicit profile (else the
|
|
8
10
|
// startup selector: a per-cwd pinned default,
|
|
@@ -18,7 +20,17 @@
|
|
|
18
20
|
// over the terminal and the process never returns.
|
|
19
21
|
import { existsSync, statSync } from 'node:fs';
|
|
20
22
|
import { resolve as resolvePath } from 'node:path';
|
|
21
|
-
|
|
23
|
+
// NOTE: the boot-path deps — ./spawn.js (bootRoot), ./resume.js (resumeRoot),
|
|
24
|
+
// ./package-health.js (missingPackageWarning + piAgentDir) — are DYNAMICALLY
|
|
25
|
+
// imported inside maybeBootRoot, NOT statically here. `cli.ts` calls
|
|
26
|
+
// maybeBootRoot on every invocation, and a static import chain that reaches the
|
|
27
|
+
// pi engine costs ~365ms of cold start that every leaf subcommand (`crtr memory
|
|
28
|
+
// list`, etc.) would pay despite never booting a root. The dynamic imports below
|
|
29
|
+
// are reached only after the early-return guards, i.e. only on a genuine
|
|
30
|
+
// front-door boot. Note we DELIBERATELY do NOT import `@earendil-works/pi-coding-agent`
|
|
31
|
+
// even on the boot path — the one thing we needed from it (getAgentDir) is
|
|
32
|
+
// reimplemented as package-health's `piAgentDir()` so the whole pi engine never
|
|
33
|
+
// loads in the FOREGROUND boot process (the broker loads it in parallel).
|
|
22
34
|
function isDir(p) {
|
|
23
35
|
try {
|
|
24
36
|
return existsSync(p) && statSync(p).isDirectory();
|
|
@@ -31,7 +43,7 @@ function isDir(p) {
|
|
|
31
43
|
* positional dir/prompt). A leading token in this set still boots a root —
|
|
32
44
|
* without it, `crtr --name X` would fall through to the dispatcher and error as
|
|
33
45
|
* an unknown subcommand. */
|
|
34
|
-
const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--profile', '--pick-profile']);
|
|
46
|
+
const FRONT_DOOR_FLAGS = new Set(['--name', '--kind', '--profile', '--pick-profile', '--continue', '-c', '--resume', '-r']);
|
|
35
47
|
/** Parse `[dir] [prompt]` positionals + the front-door flags out of the leftover
|
|
36
48
|
* tokens after the bare `crtr`. */
|
|
37
49
|
function parseRootArgs(tokens) {
|
|
@@ -40,6 +52,7 @@ function parseRootArgs(tokens) {
|
|
|
40
52
|
let kind;
|
|
41
53
|
let profile;
|
|
42
54
|
let pickProfile = false;
|
|
55
|
+
let resume;
|
|
43
56
|
const positionals = [];
|
|
44
57
|
for (let i = 0; i < tokens.length; i++) {
|
|
45
58
|
const t = tokens[i];
|
|
@@ -55,6 +68,12 @@ function parseRootArgs(tokens) {
|
|
|
55
68
|
else if (t === '--pick-profile') {
|
|
56
69
|
pickProfile = true;
|
|
57
70
|
}
|
|
71
|
+
else if (t === '--continue' || t === '-c') {
|
|
72
|
+
resume = 'continue';
|
|
73
|
+
}
|
|
74
|
+
else if (t === '--resume' || t === '-r') {
|
|
75
|
+
resume = 'pick';
|
|
76
|
+
}
|
|
58
77
|
else if (t.startsWith('--')) {
|
|
59
78
|
// ignore unknown flags for the front door
|
|
60
79
|
}
|
|
@@ -67,7 +86,7 @@ function parseRootArgs(tokens) {
|
|
|
67
86
|
cwd = resolvePath(positionals.shift());
|
|
68
87
|
}
|
|
69
88
|
const prompt = positionals.length > 0 ? positionals.join(' ') : undefined;
|
|
70
|
-
return { cwd, prompt, name, kind, profile, pickProfile };
|
|
89
|
+
return { cwd, prompt, name, kind, profile, pickProfile, resume };
|
|
71
90
|
}
|
|
72
91
|
/** Env marker set on every pi the front door boots. Its presence means we are
|
|
73
92
|
* already inside a front-door-booted root, so a nested front-door launch must
|
|
@@ -111,8 +130,35 @@ export async function maybeBootRoot(root, argv) {
|
|
|
111
130
|
return false;
|
|
112
131
|
}
|
|
113
132
|
// Unambiguous front-door launch → boot a resident root inline (exec pi in
|
|
114
|
-
// this terminal). Does not return.
|
|
133
|
+
// this terminal). Does not return. Load the heavy boot-path deps lazily now
|
|
134
|
+
// that we've committed to booting (see the import note at the top of file).
|
|
135
|
+
const [{ bootRoot }, { resumeRoot }, { missingPackageWarning, piAgentDir }] = await Promise.all([
|
|
136
|
+
import('./spawn.js'),
|
|
137
|
+
import('./resume.js'),
|
|
138
|
+
import('./package-health.js'),
|
|
139
|
+
]);
|
|
115
140
|
const args = parseRootArgs(tokens);
|
|
141
|
+
// Surface any stale/missing local-path pi package BEFORE the viewer takes over
|
|
142
|
+
// the terminal — pi discards a missing package silently, so this is the only
|
|
143
|
+
// moment the user sees why (e.g.) their ~/.claude commands vanished.
|
|
144
|
+
try {
|
|
145
|
+
const warn = missingPackageWarning(piAgentDir());
|
|
146
|
+
if (warn !== null)
|
|
147
|
+
process.stderr.write(`\n${warn}\n\n`);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* best-effort diagnostic — never block a boot */
|
|
151
|
+
}
|
|
152
|
+
// `-c`/`-r`: resume the last root pinned to this cwd (MRU, or via picker) by
|
|
153
|
+
// taking over THIS terminal as its viewer. Falls through to a fresh boot when
|
|
154
|
+
// nothing here is resumable, or when the picker was aborted.
|
|
155
|
+
if (args.resume !== undefined) {
|
|
156
|
+
const outcome = await resumeRoot(args.cwd, args.resume === 'pick');
|
|
157
|
+
// 'empty' → nothing to resume here, fall through to a fresh boot.
|
|
158
|
+
// 'aborted' → the user dismissed the picker; exit without surprise-booting.
|
|
159
|
+
if (outcome === 'aborted')
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
116
162
|
await bootRoot({ ...args });
|
|
117
163
|
return true;
|
|
118
164
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface MissingPackage {
|
|
2
|
+
/** The raw `packages[]` entry as stored in settings.json. */
|
|
3
|
+
source: string;
|
|
4
|
+
/** Absolute path it resolved to (which does not exist). */
|
|
5
|
+
resolved: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The pi agent config dir (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`) — a local
|
|
9
|
+
* reimplementation of pi's `getAgentDir()`. Reimplemented (not imported) ON
|
|
10
|
+
* PURPOSE: `getAgentDir` lives in `@earendil-works/pi-coding-agent`, and
|
|
11
|
+
* importing that package pulls in the ENTIRE ~360ms pi engine module graph. The
|
|
12
|
+
* front door calls this on every bare-`crtr` boot BEFORE spawning the broker, so
|
|
13
|
+
* a static/dynamic pi import here adds ~316ms of serialized dead-time in the
|
|
14
|
+
* foreground before the broker (which loads pi itself) even starts. This mirrors
|
|
15
|
+
* pi's contract exactly: `$PI_CODING_AGENT_DIR` (tilde-expanded) else
|
|
16
|
+
* `~/.pi/agent`. Same precedent as `bearings.ts` (keep the pi engine out of the
|
|
17
|
+
* hot/foreground path). If pi ever rebrands its configDir/env, update here.
|
|
18
|
+
*/
|
|
19
|
+
export declare function piAgentDir(homeDir?: string): string;
|
|
20
|
+
/**
|
|
21
|
+
* Return every local-path package in `<agentDir>/settings.json` whose resolved
|
|
22
|
+
* path is missing. Empty when settings is absent/unreadable or all paths exist.
|
|
23
|
+
* Mirrors pi's own resolution: `resolve(agentDir, expandTilde(source))`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function findMissingPackagePaths(agentDir: string, homeDir?: string): MissingPackage[];
|
|
26
|
+
/** A human-readable, actionable warning for a set of missing packages, or
|
|
27
|
+
* `null` when nothing is missing. Names `crtr sys setup` as the fix. */
|
|
28
|
+
export declare function missingPackageWarning(agentDir: string, homeDir?: string): string | null;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// package-health: detect pi packages configured in `<agentDir>/settings.json`
|
|
2
|
+
// whose LOCAL-PATH source no longer exists on disk.
|
|
3
|
+
//
|
|
4
|
+
// Why this exists: pi installs a local-path package (e.g. crouter's bundled
|
|
5
|
+
// `pi-crtr-extensions`, which surfaces `~/.claude` commands/skills/plugins as
|
|
6
|
+
// slash commands) by storing its path in settings.json RELATIVE to the settings
|
|
7
|
+
// dir. Moving the crouter repo silently invalidates that relative path — and pi
|
|
8
|
+
// DISCARDS a missing package with no error, so the extension just never loads
|
|
9
|
+
// and its whole feature (here: every Claude command/marketplace command) simply
|
|
10
|
+
// vanishes with no diagnostic. This turns that silent failure into a loud,
|
|
11
|
+
// actionable warning. The fix it points at is `crtr sys setup`, which re-resolves
|
|
12
|
+
// package paths to the repo's current location.
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
/** Expand a leading `~` against `homeDir` (pi's own convention). */
|
|
17
|
+
function expandTilde(pathValue, homeDir) {
|
|
18
|
+
if (pathValue === '~')
|
|
19
|
+
return homeDir;
|
|
20
|
+
if (pathValue.startsWith('~/'))
|
|
21
|
+
return join(homeDir, pathValue.slice(2));
|
|
22
|
+
return pathValue;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The pi agent config dir (`~/.pi/agent`, or `$PI_CODING_AGENT_DIR`) — a local
|
|
26
|
+
* reimplementation of pi's `getAgentDir()`. Reimplemented (not imported) ON
|
|
27
|
+
* PURPOSE: `getAgentDir` lives in `@earendil-works/pi-coding-agent`, and
|
|
28
|
+
* importing that package pulls in the ENTIRE ~360ms pi engine module graph. The
|
|
29
|
+
* front door calls this on every bare-`crtr` boot BEFORE spawning the broker, so
|
|
30
|
+
* a static/dynamic pi import here adds ~316ms of serialized dead-time in the
|
|
31
|
+
* foreground before the broker (which loads pi itself) even starts. This mirrors
|
|
32
|
+
* pi's contract exactly: `$PI_CODING_AGENT_DIR` (tilde-expanded) else
|
|
33
|
+
* `~/.pi/agent`. Same precedent as `bearings.ts` (keep the pi engine out of the
|
|
34
|
+
* hot/foreground path). If pi ever rebrands its configDir/env, update here.
|
|
35
|
+
*/
|
|
36
|
+
export function piAgentDir(homeDir = homedir()) {
|
|
37
|
+
const envDir = process.env['PI_CODING_AGENT_DIR'];
|
|
38
|
+
if (envDir !== undefined && envDir !== '')
|
|
39
|
+
return expandTilde(envDir, homeDir);
|
|
40
|
+
return join(homeDir, '.pi', 'agent');
|
|
41
|
+
}
|
|
42
|
+
/** A `packages[]` entry is a LOCAL PATH (not an npm ref) — the only kind that
|
|
43
|
+
* can go stale on a repo move. `npm:...` and bare registry names resolve
|
|
44
|
+
* through node/npm, not the filesystem, so they are out of scope here. */
|
|
45
|
+
function isLocalPathSource(source) {
|
|
46
|
+
if (source.startsWith('npm:'))
|
|
47
|
+
return false;
|
|
48
|
+
return source.startsWith('.') || source.startsWith('/') || source.startsWith('~');
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Return every local-path package in `<agentDir>/settings.json` whose resolved
|
|
52
|
+
* path is missing. Empty when settings is absent/unreadable or all paths exist.
|
|
53
|
+
* Mirrors pi's own resolution: `resolve(agentDir, expandTilde(source))`.
|
|
54
|
+
*/
|
|
55
|
+
export function findMissingPackagePaths(agentDir, homeDir = homedir()) {
|
|
56
|
+
let raw;
|
|
57
|
+
try {
|
|
58
|
+
raw = readFileSync(join(agentDir, 'settings.json'), 'utf8');
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
let packages;
|
|
64
|
+
try {
|
|
65
|
+
packages = JSON.parse(raw).packages;
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(packages))
|
|
71
|
+
return [];
|
|
72
|
+
const missing = [];
|
|
73
|
+
for (const entry of packages) {
|
|
74
|
+
if (typeof entry !== 'string' || !isLocalPathSource(entry))
|
|
75
|
+
continue;
|
|
76
|
+
const resolved = resolve(agentDir, expandTilde(entry, homeDir));
|
|
77
|
+
if (!existsSync(resolved))
|
|
78
|
+
missing.push({ source: entry, resolved });
|
|
79
|
+
}
|
|
80
|
+
return missing;
|
|
81
|
+
}
|
|
82
|
+
/** A human-readable, actionable warning for a set of missing packages, or
|
|
83
|
+
* `null` when nothing is missing. Names `crtr sys setup` as the fix. */
|
|
84
|
+
export function missingPackageWarning(agentDir, homeDir = homedir()) {
|
|
85
|
+
const missing = findMissingPackagePaths(agentDir, homeDir);
|
|
86
|
+
if (missing.length === 0)
|
|
87
|
+
return null;
|
|
88
|
+
const lines = missing.map((m) => ` • ${m.source}\n → ${m.resolved} (missing)`);
|
|
89
|
+
return (`crtr: ${missing.length} configured pi package path${missing.length === 1 ? '' : 's'} ` +
|
|
90
|
+
`no longer exist${missing.length === 1 ? 's' : ''} — pi silently skips these, so their ` +
|
|
91
|
+
`slash commands (e.g. ~/.claude commands & marketplace plugins) will not load.\n` +
|
|
92
|
+
lines.join('\n') +
|
|
93
|
+
`\n Fix: re-run \`crtr sys setup\` (re-resolves package paths to this repo's current location).`);
|
|
94
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Resolve a front-door resume: pick a target root (MRU or via picker), then
|
|
2
|
+
* take over this terminal to view it. On success it execs the viewer inline and
|
|
3
|
+
* never returns. Otherwise reports why it did not: `'empty'` (no resumable root
|
|
4
|
+
* here → caller boots a fresh one) or `'aborted'` (the picker was dismissed →
|
|
5
|
+
* caller exits without surprise-booting). */
|
|
6
|
+
export declare function resumeRoot(cwd: string, pick: boolean): Promise<'empty' | 'aborted'>;
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// Front-door resume — `crtr -c` / `crtr -r`.
|
|
2
|
+
//
|
|
3
|
+
// crtr -c | --continue → revive + attach the MRU root pinned to this cwd,
|
|
4
|
+
// inline in THIS terminal (no picker). If no root is
|
|
5
|
+
// resumable here, boot a fresh one.
|
|
6
|
+
// crtr -r | --resume → same, but choose from a keypress menu of the roots
|
|
7
|
+
// pinned to this cwd first.
|
|
8
|
+
//
|
|
9
|
+
// A "session" in the Claude-Code sense is a crtr ROOT node: a durable pi
|
|
10
|
+
// conversation pinned to a cwd that never dies, only goes dormant. So resuming
|
|
11
|
+
// is reviving the last root here and making THIS pane its controller-viewer —
|
|
12
|
+
// mirroring the front-door boot tail (register the pane as the viewer focus,
|
|
13
|
+
// then exec `crtr surface attach` inline). cwd is the key so it never teleports
|
|
14
|
+
// across projects; see `listResumableRoots`.
|
|
15
|
+
import { spawn } from 'node:child_process';
|
|
16
|
+
import { emitKeypressEvents } from 'node:readline';
|
|
17
|
+
import { getNode, listResumableRoots } from '../canvas/index.js';
|
|
18
|
+
import { navLabel } from '../canvas/nav-model.js';
|
|
19
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
20
|
+
import { readGoal } from './kickoff.js';
|
|
21
|
+
import { reviveNode } from './revive.js';
|
|
22
|
+
import { installTmuxBindings } from './tmux-chrome.js';
|
|
23
|
+
import { registerViewerFocus, waitForBrokerViewSocket, viewerSplitEnv, currentTmux, inTmux, } from './placement.js';
|
|
24
|
+
import { getFocusByNode, closeFocusRow } from '../canvas/focuses.js';
|
|
25
|
+
import { ensureDaemon } from '../../daemon/manage.js';
|
|
26
|
+
import { stdoutColor } from '../output.js';
|
|
27
|
+
const dim = stdoutColor.dim;
|
|
28
|
+
const bold = stdoutColor.bold;
|
|
29
|
+
const accent = stdoutColor.cyan;
|
|
30
|
+
/** Resolve a front-door resume: pick a target root (MRU or via picker), then
|
|
31
|
+
* take over this terminal to view it. On success it execs the viewer inline and
|
|
32
|
+
* never returns. Otherwise reports why it did not: `'empty'` (no resumable root
|
|
33
|
+
* here → caller boots a fresh one) or `'aborted'` (the picker was dismissed →
|
|
34
|
+
* caller exits without surprise-booting). */
|
|
35
|
+
export async function resumeRoot(cwd, pick) {
|
|
36
|
+
const roots = listResumableRoots(cwd);
|
|
37
|
+
if (roots.length === 0)
|
|
38
|
+
return 'empty';
|
|
39
|
+
let target;
|
|
40
|
+
if (pick && roots.length > 1) {
|
|
41
|
+
target = await pickRoot(roots, cwd);
|
|
42
|
+
if (target === null)
|
|
43
|
+
return 'aborted';
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
target = roots[0]; // MRU (also the sole root under `-r` with one candidate)
|
|
47
|
+
}
|
|
48
|
+
await attachInline(target.node_id, cwd);
|
|
49
|
+
return 'aborted'; // unreachable — attachInline execs and exits
|
|
50
|
+
}
|
|
51
|
+
/** Revive the target root's broker if dormant, register THIS pane as its one
|
|
52
|
+
* viewer, and exec `crtr surface attach` inline so this terminal becomes the
|
|
53
|
+
* controller-viewer. Mirrors the front-door boot tail. Does not return. */
|
|
54
|
+
async function attachInline(nodeId, cwd) {
|
|
55
|
+
void cwd;
|
|
56
|
+
if (!inTmux()) {
|
|
57
|
+
throw new Error('crtr must be resumed from inside a tmux session — start tmux first (e.g. `tmux new -s work`), then run `crtr -c` there.');
|
|
58
|
+
}
|
|
59
|
+
const meta = getNode(nodeId);
|
|
60
|
+
if (meta === null)
|
|
61
|
+
throw new Error(`resume: unknown node ${nodeId}`);
|
|
62
|
+
try {
|
|
63
|
+
ensureDaemon();
|
|
64
|
+
}
|
|
65
|
+
catch { /* daemon is best-effort */ }
|
|
66
|
+
// Revive the broker engine if dormant. reviveNode double-launch-guards, so a
|
|
67
|
+
// still-alive broker no-ops and returns no launch handle; only a fresh launch
|
|
68
|
+
// needs the view-socket wait below.
|
|
69
|
+
const wasAlive = isPidAlive(meta.pi_pid ?? null);
|
|
70
|
+
const revived = reviveNode(nodeId, { resume: true });
|
|
71
|
+
try {
|
|
72
|
+
installTmuxBindings();
|
|
73
|
+
}
|
|
74
|
+
catch { /* best-effort */ }
|
|
75
|
+
// Make THIS pane the node's one viewer. A dormant root's prior focus row
|
|
76
|
+
// points at a stale/detached pane (the front door keeps the row across a
|
|
77
|
+
// clean detach), so drop it and claim the current pane — the exact --new-pane
|
|
78
|
+
// discipline from placement.focus (UNIQUE(node_id): one viewer per node).
|
|
79
|
+
const here = currentTmux();
|
|
80
|
+
if (here !== null) {
|
|
81
|
+
const prior = getFocusByNode(nodeId);
|
|
82
|
+
if (prior !== null) {
|
|
83
|
+
try {
|
|
84
|
+
closeFocusRow(prior.focus_id);
|
|
85
|
+
}
|
|
86
|
+
catch { /* best-effort */ }
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
registerViewerFocus(nodeId, here.pane, here.session, here.window);
|
|
90
|
+
}
|
|
91
|
+
catch { /* best-effort */ }
|
|
92
|
+
}
|
|
93
|
+
// A fresh launch must bind its view socket before we attach; an already-alive
|
|
94
|
+
// broker already has one.
|
|
95
|
+
if (!wasAlive) {
|
|
96
|
+
const ok = await waitForBrokerViewSocket(nodeId, revived.launch?.exited);
|
|
97
|
+
if (!ok)
|
|
98
|
+
throw new Error(`the resumed root broker ${nodeId} never bound its view socket — cannot attach.`);
|
|
99
|
+
}
|
|
100
|
+
const attachEnv = { ...process.env, ...viewerSplitEnv(), CRTR_ATTACH_WAIT_MS: '30000' };
|
|
101
|
+
const child = spawn('crtr', ['surface', 'attach', 'to', nodeId], { stdio: 'inherit', env: attachEnv });
|
|
102
|
+
const code = await new Promise((res) => {
|
|
103
|
+
child.once('exit', (c) => res(c ?? 0));
|
|
104
|
+
child.once('error', () => res(1));
|
|
105
|
+
});
|
|
106
|
+
process.exit(code);
|
|
107
|
+
}
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// `-r` picker — a minimal raw-mode keypress menu (pre-boot, we own the TTY).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
const HILITE = accent('\u25b8');
|
|
112
|
+
const cursorUp = (n) => (n > 0 ? `\u001b[${n}A\r` : '');
|
|
113
|
+
const CLEAR_DOWN = '\u001b[0J';
|
|
114
|
+
const ANSI_RE = /\u001b\[[0-9;]*m/g;
|
|
115
|
+
/** Terminal rows a just-written block occupies — counts each '\n' AND the extra
|
|
116
|
+
* rows a long line WRAPS into at the current terminal width (ANSI color codes
|
|
117
|
+
* stripped so they don't inflate the width). A plain newline count undershoots
|
|
118
|
+
* in a narrow pane, so redraws stack instead of clearing. Mirrors the
|
|
119
|
+
* profile-menu blockRows. */
|
|
120
|
+
function blockRows(block) {
|
|
121
|
+
const cols = process.stdout.columns ?? 80;
|
|
122
|
+
const segs = block.split('\n');
|
|
123
|
+
const newlines = segs.length - 1; // block ends with '\n' → last seg is ''
|
|
124
|
+
let extra = 0;
|
|
125
|
+
for (let i = 0; i < newlines; i++) {
|
|
126
|
+
const w = segs[i].replace(ANSI_RE, '').length;
|
|
127
|
+
if (cols > 0 && w > cols)
|
|
128
|
+
extra += Math.ceil(w / cols) - 1;
|
|
129
|
+
}
|
|
130
|
+
return newlines + extra;
|
|
131
|
+
}
|
|
132
|
+
/** Truncate to a visible-column budget, preserving ANSI escapes (they cost no
|
|
133
|
+
* width). Appends '…' when it cuts. Keeps a styled row on one terminal line so
|
|
134
|
+
* nothing wraps. */
|
|
135
|
+
function fitWidth(s, width) {
|
|
136
|
+
if (width <= 0)
|
|
137
|
+
return s;
|
|
138
|
+
let out = '';
|
|
139
|
+
let vis = 0;
|
|
140
|
+
for (let i = 0; i < s.length;) {
|
|
141
|
+
if (s[i] === '\u001b') {
|
|
142
|
+
const m = /^\u001b\[[0-9;]*m/.exec(s.slice(i));
|
|
143
|
+
if (m !== null) {
|
|
144
|
+
out += m[0];
|
|
145
|
+
i += m[0].length;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (vis >= width - 1 && s.replace(ANSI_RE, '').length > width) {
|
|
150
|
+
out += '\u2026';
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
out += s[i];
|
|
154
|
+
vis++;
|
|
155
|
+
i++;
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
function label(r) {
|
|
160
|
+
// Liveness is the broker pid, not the row status: a dormant resident root
|
|
161
|
+
// keeps status='active' while its engine is down.
|
|
162
|
+
const live = isPidAlive(r.pi_pid ?? null) ? accent('\u25cf live') : dim('\u25cb dormant');
|
|
163
|
+
// The SAME handle the Alt-G graph shows: navLabel = name → description → kind.
|
|
164
|
+
// getNode carries `description` (NodeRow from the list query does not).
|
|
165
|
+
const node = getNode(r.node_id);
|
|
166
|
+
let name = navLabel(node, r.node_id);
|
|
167
|
+
// navLabel falls back to the bare kind for a never-named root — useless in a
|
|
168
|
+
// roster where every root is 'general'. Rescue it with the goal's first line
|
|
169
|
+
// (the spawning prompt), so unnamed convos are still tellable apart.
|
|
170
|
+
if (node !== null && name === node.kind) {
|
|
171
|
+
const goal = firstLine(readGoal(r.node_id));
|
|
172
|
+
if (goal !== '')
|
|
173
|
+
name = goal;
|
|
174
|
+
}
|
|
175
|
+
return `${name} ${live}`;
|
|
176
|
+
}
|
|
177
|
+
/** First non-empty line of a goal body, trimmed and length-capped. */
|
|
178
|
+
function firstLine(text) {
|
|
179
|
+
if (text === null)
|
|
180
|
+
return '';
|
|
181
|
+
const line = text.split('\n').map((l) => l.trim()).find((l) => l !== '') ?? '';
|
|
182
|
+
return line.length > 72 ? line.slice(0, 71) + '\u2026' : line;
|
|
183
|
+
}
|
|
184
|
+
function detail(r, cwd) {
|
|
185
|
+
const when = ago(r.created);
|
|
186
|
+
const where = r.cwd === cwd ? '' : ` ${r.cwd}`;
|
|
187
|
+
// Short id keeps the row on one line; the full id is rarely needed to pick.
|
|
188
|
+
return `${when}${where} ${r.node_id.slice(0, 8)}`;
|
|
189
|
+
}
|
|
190
|
+
function ago(iso) {
|
|
191
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
192
|
+
if (!Number.isFinite(ms) || ms < 0)
|
|
193
|
+
return '';
|
|
194
|
+
const m = Math.floor(ms / 60000);
|
|
195
|
+
if (m < 1)
|
|
196
|
+
return 'just now';
|
|
197
|
+
if (m < 60)
|
|
198
|
+
return `${m}m ago`;
|
|
199
|
+
const h = Math.floor(m / 60);
|
|
200
|
+
if (h < 24)
|
|
201
|
+
return `${h}h ago`;
|
|
202
|
+
return `${Math.floor(h / 24)}d ago`;
|
|
203
|
+
}
|
|
204
|
+
/** Drive a highlighted keypress menu of resumable roots. ↑/↓ or j/k move,
|
|
205
|
+
* digits jump, Enter picks, q/Esc/Ctrl-C abort → null. Restores the TTY on
|
|
206
|
+
* every exit path so the inline viewer gets a clean terminal. */
|
|
207
|
+
function pickRoot(roots, cwd) {
|
|
208
|
+
const title = 'Resume a session here';
|
|
209
|
+
const subtitle = `${roots.length} roots pinned to ${cwd}`;
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
const out = process.stdout;
|
|
212
|
+
const input = process.stdin;
|
|
213
|
+
let sel = 0;
|
|
214
|
+
let prevLines = 0;
|
|
215
|
+
const render = () => {
|
|
216
|
+
const cols = process.stdout.columns ?? 80;
|
|
217
|
+
let s = `\n ${bold(title)}\n ${dim(subtitle)}\n\n`;
|
|
218
|
+
roots.forEach((r, i) => {
|
|
219
|
+
const mark = i === sel ? HILITE : ' ';
|
|
220
|
+
const slot = i < 9 ? key(`${i + 1}`) : ' ';
|
|
221
|
+
const row = ` ${mark} ${slot} ${label(r)} ${dim(detail(r, cwd))}`;
|
|
222
|
+
s += fitWidth(row, cols) + '\n';
|
|
223
|
+
});
|
|
224
|
+
s += `\n ${dim('↑/↓ move enter resume q quit')}\n`;
|
|
225
|
+
return s;
|
|
226
|
+
};
|
|
227
|
+
const draw = () => {
|
|
228
|
+
const block = render();
|
|
229
|
+
out.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
|
|
230
|
+
prevLines = blockRows(block);
|
|
231
|
+
};
|
|
232
|
+
emitKeypressEvents(input);
|
|
233
|
+
const wasRaw = input.isRaw ?? false;
|
|
234
|
+
if (input.isTTY)
|
|
235
|
+
input.setRawMode(true);
|
|
236
|
+
input.resume();
|
|
237
|
+
const finish = (result) => {
|
|
238
|
+
input.removeListener('keypress', onKey);
|
|
239
|
+
if (input.isTTY)
|
|
240
|
+
input.setRawMode(wasRaw);
|
|
241
|
+
input.pause();
|
|
242
|
+
out.write(cursorUp(prevLines) + CLEAR_DOWN);
|
|
243
|
+
resolve(result);
|
|
244
|
+
};
|
|
245
|
+
const onKey = (_str, k) => {
|
|
246
|
+
const name = k.name ?? '';
|
|
247
|
+
if (k.ctrl && name === 'c') {
|
|
248
|
+
finish(null);
|
|
249
|
+
process.exit(130);
|
|
250
|
+
}
|
|
251
|
+
if (name === 'q' || name === 'escape')
|
|
252
|
+
return finish(null);
|
|
253
|
+
if (name === 'up' || name === 'k') {
|
|
254
|
+
sel = (sel - 1 + roots.length) % roots.length;
|
|
255
|
+
return draw();
|
|
256
|
+
}
|
|
257
|
+
if (name === 'down' || name === 'j') {
|
|
258
|
+
sel = (sel + 1) % roots.length;
|
|
259
|
+
return draw();
|
|
260
|
+
}
|
|
261
|
+
if (name === 'return' || name === 'enter')
|
|
262
|
+
return finish(roots[sel]);
|
|
263
|
+
const digit = Number.parseInt(k.sequence ?? '', 10);
|
|
264
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(9, roots.length)) {
|
|
265
|
+
sel = digit - 1;
|
|
266
|
+
return finish(roots[sel]);
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
input.on('keypress', onKey);
|
|
270
|
+
draw();
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
function key(s) {
|
|
274
|
+
return accent(s);
|
|
275
|
+
}
|