@crouton-kit/crouter 0.3.81 → 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.
@@ -20,10 +20,17 @@
20
20
  // over the terminal and the process never returns.
21
21
  import { existsSync, statSync } from 'node:fs';
22
22
  import { resolve as resolvePath } from 'node:path';
23
- import { getAgentDir } from '@earendil-works/pi-coding-agent';
24
- import { bootRoot } from './spawn.js';
25
- import { resumeRoot } from './resume.js';
26
- import { missingPackageWarning } from './package-health.js';
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).
27
34
  function isDir(p) {
28
35
  try {
29
36
  return existsSync(p) && statSync(p).isDirectory();
@@ -123,13 +130,19 @@ export async function maybeBootRoot(root, argv) {
123
130
  return false;
124
131
  }
125
132
  // Unambiguous front-door launch → boot a resident root inline (exec pi in
126
- // 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
+ ]);
127
140
  const args = parseRootArgs(tokens);
128
141
  // Surface any stale/missing local-path pi package BEFORE the viewer takes over
129
142
  // the terminal — pi discards a missing package silently, so this is the only
130
143
  // moment the user sees why (e.g.) their ~/.claude commands vanished.
131
144
  try {
132
- const warn = missingPackageWarning(getAgentDir());
145
+ const warn = missingPackageWarning(piAgentDir());
133
146
  if (warn !== null)
134
147
  process.stderr.write(`\n${warn}\n\n`);
135
148
  }
@@ -4,6 +4,19 @@ export interface MissingPackage {
4
4
  /** Absolute path it resolved to (which does not exist). */
5
5
  resolved: string;
6
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;
7
20
  /**
8
21
  * Return every local-path package in `<agentDir>/settings.json` whose resolved
9
22
  * path is missing. Empty when settings is absent/unreadable or all paths exist.
@@ -21,6 +21,24 @@ function expandTilde(pathValue, homeDir) {
21
21
  return join(homeDir, pathValue.slice(2));
22
22
  return pathValue;
23
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
+ }
24
42
  /** A `packages[]` entry is a LOCAL PATH (not an npm ref) — the only kind that
25
43
  * can go stale on a repo move. `npm:...` and bare registry names resolve
26
44
  * through node/npm, not the filesystem, so they are out of scope here. */
@@ -15,7 +15,9 @@
15
15
  import { spawn } from 'node:child_process';
16
16
  import { emitKeypressEvents } from 'node:readline';
17
17
  import { getNode, listResumableRoots } from '../canvas/index.js';
18
+ import { navLabel } from '../canvas/nav-model.js';
18
19
  import { isPidAlive } from '../canvas/pid.js';
20
+ import { readGoal } from './kickoff.js';
19
21
  import { reviveNode } from './revive.js';
20
22
  import { installTmuxBindings } from './tmux-chrome.js';
21
23
  import { registerViewerFocus, waitForBrokerViewSocket, viewerSplitEnv, currentTmux, inTmux, } from './placement.js';
@@ -109,17 +111,81 @@ async function attachInline(nodeId, cwd) {
109
111
  const HILITE = accent('\u25b8');
110
112
  const cursorUp = (n) => (n > 0 ? `\u001b[${n}A\r` : '');
111
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
+ }
112
159
  function label(r) {
113
160
  // Liveness is the broker pid, not the row status: a dormant resident root
114
161
  // keeps status='active' while its engine is down.
115
162
  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`;
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
+ }
117
175
  return `${name} ${live}`;
118
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
+ }
119
184
  function detail(r, cwd) {
120
185
  const when = ago(r.created);
121
186
  const where = r.cwd === cwd ? '' : ` ${r.cwd}`;
122
- return `${r.node_id} ${when}${where}`;
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)}`;
123
189
  }
124
190
  function ago(iso) {
125
191
  const ms = Date.now() - new Date(iso).getTime();
@@ -147,11 +213,13 @@ function pickRoot(roots, cwd) {
147
213
  let sel = 0;
148
214
  let prevLines = 0;
149
215
  const render = () => {
216
+ const cols = process.stdout.columns ?? 80;
150
217
  let s = `\n ${bold(title)}\n ${dim(subtitle)}\n\n`;
151
218
  roots.forEach((r, i) => {
152
219
  const mark = i === sel ? HILITE : ' ';
153
220
  const slot = i < 9 ? key(`${i + 1}`) : ' ';
154
- s += ` ${mark} ${slot} ${label(r)} ${dim(detail(r, cwd))}\n`;
221
+ const row = ` ${mark} ${slot} ${label(r)} ${dim(detail(r, cwd))}`;
222
+ s += fitWidth(row, cols) + '\n';
155
223
  });
156
224
  s += `\n ${dim('↑/↓ move enter resume q quit')}\n`;
157
225
  return s;
@@ -159,7 +227,7 @@ function pickRoot(roots, cwd) {
159
227
  const draw = () => {
160
228
  const block = render();
161
229
  out.write((prevLines > 0 ? cursorUp(prevLines) + CLEAR_DOWN : '') + block);
162
- prevLines = block.split('\n').length - 1;
230
+ prevLines = blockRows(block);
163
231
  };
164
232
  emitKeypressEvents(input);
165
233
  const wasRaw = input.isRaw ?? false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/crouter",
3
- "version": "0.3.81",
3
+ "version": "0.3.82",
4
4
  "description": "crtr — agent runtime with memory, plugins, and marketplaces",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",