@crouton-kit/crouter 0.3.79 → 0.3.81

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.
@@ -0,0 +1,15 @@
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
+ * Return every local-path package in `<agentDir>/settings.json` whose resolved
9
+ * path is missing. Empty when settings is absent/unreadable or all paths exist.
10
+ * Mirrors pi's own resolution: `resolve(agentDir, expandTilde(source))`.
11
+ */
12
+ export declare function findMissingPackagePaths(agentDir: string, homeDir?: string): MissingPackage[];
13
+ /** A human-readable, actionable warning for a set of missing packages, or
14
+ * `null` when nothing is missing. Names `crtr sys setup` as the fix. */
15
+ export declare function missingPackageWarning(agentDir: string, homeDir?: string): string | null;
@@ -0,0 +1,76 @@
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
+ /** A `packages[]` entry is a LOCAL PATH (not an npm ref) — the only kind that
25
+ * can go stale on a repo move. `npm:...` and bare registry names resolve
26
+ * through node/npm, not the filesystem, so they are out of scope here. */
27
+ function isLocalPathSource(source) {
28
+ if (source.startsWith('npm:'))
29
+ return false;
30
+ return source.startsWith('.') || source.startsWith('/') || source.startsWith('~');
31
+ }
32
+ /**
33
+ * Return every local-path package in `<agentDir>/settings.json` whose resolved
34
+ * path is missing. Empty when settings is absent/unreadable or all paths exist.
35
+ * Mirrors pi's own resolution: `resolve(agentDir, expandTilde(source))`.
36
+ */
37
+ export function findMissingPackagePaths(agentDir, homeDir = homedir()) {
38
+ let raw;
39
+ try {
40
+ raw = readFileSync(join(agentDir, 'settings.json'), 'utf8');
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ let packages;
46
+ try {
47
+ packages = JSON.parse(raw).packages;
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ if (!Array.isArray(packages))
53
+ return [];
54
+ const missing = [];
55
+ for (const entry of packages) {
56
+ if (typeof entry !== 'string' || !isLocalPathSource(entry))
57
+ continue;
58
+ const resolved = resolve(agentDir, expandTilde(entry, homeDir));
59
+ if (!existsSync(resolved))
60
+ missing.push({ source: entry, resolved });
61
+ }
62
+ return missing;
63
+ }
64
+ /** A human-readable, actionable warning for a set of missing packages, or
65
+ * `null` when nothing is missing. Names `crtr sys setup` as the fix. */
66
+ export function missingPackageWarning(agentDir, homeDir = homedir()) {
67
+ const missing = findMissingPackagePaths(agentDir, homeDir);
68
+ if (missing.length === 0)
69
+ return null;
70
+ const lines = missing.map((m) => ` • ${m.source}\n → ${m.resolved} (missing)`);
71
+ return (`crtr: ${missing.length} configured pi package path${missing.length === 1 ? '' : 's'} ` +
72
+ `no longer exist${missing.length === 1 ? 's' : ''} — pi silently skips these, so their ` +
73
+ `slash commands (e.g. ~/.claude commands & marketplace plugins) will not load.\n` +
74
+ lines.join('\n') +
75
+ `\n Fix: re-run \`crtr sys setup\` (re-resolves package paths to this repo's current location).`);
76
+ }
@@ -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,207 @@
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 { isPidAlive } from '../canvas/pid.js';
19
+ import { reviveNode } from './revive.js';
20
+ import { installTmuxBindings } from './tmux-chrome.js';
21
+ import { registerViewerFocus, waitForBrokerViewSocket, viewerSplitEnv, currentTmux, inTmux, } from './placement.js';
22
+ import { getFocusByNode, closeFocusRow } from '../canvas/focuses.js';
23
+ import { ensureDaemon } from '../../daemon/manage.js';
24
+ import { stdoutColor } from '../output.js';
25
+ const dim = stdoutColor.dim;
26
+ const bold = stdoutColor.bold;
27
+ const accent = stdoutColor.cyan;
28
+ /** Resolve a front-door resume: pick a target root (MRU or via picker), then
29
+ * take over this terminal to view it. On success it execs the viewer inline and
30
+ * never returns. Otherwise reports why it did not: `'empty'` (no resumable root
31
+ * here → caller boots a fresh one) or `'aborted'` (the picker was dismissed →
32
+ * caller exits without surprise-booting). */
33
+ export async function resumeRoot(cwd, pick) {
34
+ const roots = listResumableRoots(cwd);
35
+ if (roots.length === 0)
36
+ return 'empty';
37
+ let target;
38
+ if (pick && roots.length > 1) {
39
+ target = await pickRoot(roots, cwd);
40
+ if (target === null)
41
+ return 'aborted';
42
+ }
43
+ else {
44
+ target = roots[0]; // MRU (also the sole root under `-r` with one candidate)
45
+ }
46
+ await attachInline(target.node_id, cwd);
47
+ return 'aborted'; // unreachable — attachInline execs and exits
48
+ }
49
+ /** Revive the target root's broker if dormant, register THIS pane as its one
50
+ * viewer, and exec `crtr surface attach` inline so this terminal becomes the
51
+ * controller-viewer. Mirrors the front-door boot tail. Does not return. */
52
+ async function attachInline(nodeId, cwd) {
53
+ void cwd;
54
+ if (!inTmux()) {
55
+ 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.');
56
+ }
57
+ const meta = getNode(nodeId);
58
+ if (meta === null)
59
+ throw new Error(`resume: unknown node ${nodeId}`);
60
+ try {
61
+ ensureDaemon();
62
+ }
63
+ catch { /* daemon is best-effort */ }
64
+ // Revive the broker engine if dormant. reviveNode double-launch-guards, so a
65
+ // still-alive broker no-ops and returns no launch handle; only a fresh launch
66
+ // needs the view-socket wait below.
67
+ const wasAlive = isPidAlive(meta.pi_pid ?? null);
68
+ const revived = reviveNode(nodeId, { resume: true });
69
+ try {
70
+ installTmuxBindings();
71
+ }
72
+ catch { /* best-effort */ }
73
+ // Make THIS pane the node's one viewer. A dormant root's prior focus row
74
+ // points at a stale/detached pane (the front door keeps the row across a
75
+ // clean detach), so drop it and claim the current pane — the exact --new-pane
76
+ // discipline from placement.focus (UNIQUE(node_id): one viewer per node).
77
+ const here = currentTmux();
78
+ if (here !== null) {
79
+ const prior = getFocusByNode(nodeId);
80
+ if (prior !== null) {
81
+ try {
82
+ closeFocusRow(prior.focus_id);
83
+ }
84
+ catch { /* best-effort */ }
85
+ }
86
+ try {
87
+ registerViewerFocus(nodeId, here.pane, here.session, here.window);
88
+ }
89
+ catch { /* best-effort */ }
90
+ }
91
+ // A fresh launch must bind its view socket before we attach; an already-alive
92
+ // broker already has one.
93
+ if (!wasAlive) {
94
+ const ok = await waitForBrokerViewSocket(nodeId, revived.launch?.exited);
95
+ if (!ok)
96
+ throw new Error(`the resumed root broker ${nodeId} never bound its view socket — cannot attach.`);
97
+ }
98
+ const attachEnv = { ...process.env, ...viewerSplitEnv(), CRTR_ATTACH_WAIT_MS: '30000' };
99
+ const child = spawn('crtr', ['surface', 'attach', 'to', nodeId], { stdio: 'inherit', env: attachEnv });
100
+ const code = await new Promise((res) => {
101
+ child.once('exit', (c) => res(c ?? 0));
102
+ child.once('error', () => res(1));
103
+ });
104
+ process.exit(code);
105
+ }
106
+ // ---------------------------------------------------------------------------
107
+ // `-r` picker — a minimal raw-mode keypress menu (pre-boot, we own the TTY).
108
+ // ---------------------------------------------------------------------------
109
+ const HILITE = accent('\u25b8');
110
+ const cursorUp = (n) => (n > 0 ? `\u001b[${n}A\r` : '');
111
+ const CLEAR_DOWN = '\u001b[0J';
112
+ function label(r) {
113
+ // Liveness is the broker pid, not the row status: a dormant resident root
114
+ // keeps status='active' while its engine is down.
115
+ const live = isPidAlive(r.pi_pid ?? null) ? accent('\u25cf live') : dim('\u25cb dormant');
116
+ const name = r.name && r.name !== r.kind ? r.name : `${r.kind} root`;
117
+ return `${name} ${live}`;
118
+ }
119
+ function detail(r, cwd) {
120
+ const when = ago(r.created);
121
+ const where = r.cwd === cwd ? '' : ` ${r.cwd}`;
122
+ return `${r.node_id} ${when}${where}`;
123
+ }
124
+ function ago(iso) {
125
+ const ms = Date.now() - new Date(iso).getTime();
126
+ if (!Number.isFinite(ms) || ms < 0)
127
+ return '';
128
+ const m = Math.floor(ms / 60000);
129
+ if (m < 1)
130
+ return 'just now';
131
+ if (m < 60)
132
+ return `${m}m ago`;
133
+ const h = Math.floor(m / 60);
134
+ if (h < 24)
135
+ return `${h}h ago`;
136
+ return `${Math.floor(h / 24)}d ago`;
137
+ }
138
+ /** Drive a highlighted keypress menu of resumable roots. ↑/↓ or j/k move,
139
+ * digits jump, Enter picks, q/Esc/Ctrl-C abort → null. Restores the TTY on
140
+ * every exit path so the inline viewer gets a clean terminal. */
141
+ function pickRoot(roots, cwd) {
142
+ const title = 'Resume a session here';
143
+ const subtitle = `${roots.length} roots pinned to ${cwd}`;
144
+ return new Promise((resolve) => {
145
+ const out = process.stdout;
146
+ const input = process.stdin;
147
+ let sel = 0;
148
+ let prevLines = 0;
149
+ const render = () => {
150
+ let s = `\n ${bold(title)}\n ${dim(subtitle)}\n\n`;
151
+ roots.forEach((r, i) => {
152
+ const mark = i === sel ? HILITE : ' ';
153
+ const slot = i < 9 ? key(`${i + 1}`) : ' ';
154
+ s += ` ${mark} ${slot} ${label(r)} ${dim(detail(r, cwd))}\n`;
155
+ });
156
+ s += `\n ${dim('↑/↓ move enter resume q quit')}\n`;
157
+ return s;
158
+ };
159
+ const draw = () => {
160
+ const block = render();
161
+ out.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
162
+ prevLines = block.split('\n').length - 1;
163
+ };
164
+ emitKeypressEvents(input);
165
+ const wasRaw = input.isRaw ?? false;
166
+ if (input.isTTY)
167
+ input.setRawMode(true);
168
+ input.resume();
169
+ const finish = (result) => {
170
+ input.removeListener('keypress', onKey);
171
+ if (input.isTTY)
172
+ input.setRawMode(wasRaw);
173
+ input.pause();
174
+ out.write(cursorUp(prevLines) + CLEAR_DOWN);
175
+ resolve(result);
176
+ };
177
+ const onKey = (_str, k) => {
178
+ const name = k.name ?? '';
179
+ if (k.ctrl && name === 'c') {
180
+ finish(null);
181
+ process.exit(130);
182
+ }
183
+ if (name === 'q' || name === 'escape')
184
+ return finish(null);
185
+ if (name === 'up' || name === 'k') {
186
+ sel = (sel - 1 + roots.length) % roots.length;
187
+ return draw();
188
+ }
189
+ if (name === 'down' || name === 'j') {
190
+ sel = (sel + 1) % roots.length;
191
+ return draw();
192
+ }
193
+ if (name === 'return' || name === 'enter')
194
+ return finish(roots[sel]);
195
+ const digit = Number.parseInt(k.sequence ?? '', 10);
196
+ if (Number.isInteger(digit) && digit >= 1 && digit <= Math.min(9, roots.length)) {
197
+ sel = digit - 1;
198
+ return finish(roots[sel]);
199
+ }
200
+ };
201
+ input.on('keypress', onKey);
202
+ draw();
203
+ });
204
+ }
205
+ function key(s) {
206
+ return accent(s);
207
+ }
@@ -72,10 +72,10 @@ export async function bootRoot(opts) {
72
72
  // covering cwd > a synchronous create-or-root-profile prompt (the stable root
73
73
  // profile, headless).
74
74
  const profileId = await selectProfileForCwd(opts.cwd, opts.profile, opts.pickProfile ?? false);
75
- // Immediate feedback: the broker + viewer boot take ~1s and the profile
76
- // selector has just released the terminal a silent cursor reads as a hang.
77
- if (process.stdout.isTTY)
78
- process.stdout.write('\u001b[2m › starting…\u001b[0m\r\n');
75
+ // No boot spinner here: the attach viewer paints the crouton banner the moment
76
+ // the engine attaches (~1s), which is the real "we're up" signal. A separate
77
+ // `› starting…` line would just linger above it in scrollback (the viewer
78
+ // appends, it doesn't clear the screen), so it's redundant.
79
79
  // A born-resident root starts in base mode; it earns the orchestrator persona
80
80
  // the first time it delegates (or on promotion). Resident lifecycle either way.
81
81
  const { launch } = buildLaunchSpec(kind, 'base', { lifecycle: 'resident', hasManager: false, cwd: opts.cwd, profileId });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.79",
3
+ "version": "0.3.81",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",