pi-crew 0.11.8 → 0.11.9

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.
@@ -51,28 +51,133 @@ const DOCK_WORD = "CREW";
51
51
  const DOCK_HINT_RIGHT = "↓·enter";
52
52
  const DOCK_HINT = `···· ${DOCK_HINT_RIGHT}`;
53
53
 
54
+ /** The ` · ` the dock row joins its canopy and segments with. */
55
+ const DOCK_SEPARATOR = " · ";
56
+
57
+ /** Content-budget floor: a degenerate width still paints a (clipped) rail row. */
58
+ const MIN_DOCK_WIDTH = 8;
59
+
60
+ /** One ` · `-separated piece of the dock row, with its budget priority. */
61
+ interface DockPiece {
62
+ text: string;
63
+ /**
64
+ * L9 (ui-instability review 2026-10-07, T13 evidence: the 40-column dock
65
+ * rendered `1 runnin…`): `status` pieces (`2 running`, `3/5 done`, `⚠
66
+ * plan:<id>`) are ATOMIC — they render whole or drop as a unit, never
67
+ * split mid-word; `meta` pieces (`⏰ …`, alerts badge) are dropped first
68
+ * under width pressure. The subject (run id / label) clips LAST, with `…`
69
+ * at its own boundary — never a blind truncate() across a concatenation
70
+ * that mixes a must-survive token with a droppable one.
71
+ */
72
+ kind: "status" | "meta";
73
+ }
74
+
75
+ /** Clip `text` at WORD boundaries (trailing words drop, `…` marks the
76
+ * elision) — the finest clip the dock ever applies inside a piece. */
77
+ function clipWords(text: string, budget: number): string {
78
+ if (visibleWidth(text) <= budget) return text;
79
+ const words = text.split(" ");
80
+ let kept = words.length;
81
+ while (kept > 1 && visibleWidth(words.slice(0, kept).join(" ")) > budget - 1) kept--;
82
+ const base = words.slice(0, kept).join(" ");
83
+ return visibleWidth(base) <= budget - 1 ? `${base}…` : truncate(text, budget);
84
+ }
85
+
86
+ /**
87
+ * Compose the dock row's left side under a width budget, by explicit
88
+ * priority (L9/T13): meta pieces drop first (right-to-left), then the subject
89
+ * (id) clips with `…` at its own boundary, then the `↓·enter` hint yields,
90
+ * and only then does a piece drop as a WHOLE unit. A status word is therefore
91
+ * never split — the ellipsis only ever lands at a segment boundary.
92
+ */
93
+ function budgetDockLeft(
94
+ buildLeft: (subject: string, pieces: readonly DockPiece[]) => string,
95
+ subject: string,
96
+ pieces: readonly DockPiece[],
97
+ /** Left budget while the hint rides (hint + the 2-space collapsed gap). */
98
+ withHint: number,
99
+ /** Left budget once the hint is dropped. */
100
+ full: number,
101
+ ): { left: string; hint: boolean } {
102
+ let subjectText = subject;
103
+ let kept = pieces;
104
+ const current = (): string => buildLeft(subjectText, kept);
105
+ const fits = (limit: number): boolean => visibleWidth(current()) <= limit;
106
+
107
+ if (fits(withHint)) return { left: current(), hint: true };
108
+
109
+ // (1) meta drops first, right-to-left — but never the row's LAST piece: an
110
+ // idle row whose only piece is the schedule must clip it, not lose it (a
111
+ // content-free row is indistinguishable from no row at all).
112
+ while (kept.length > 1 && kept.at(-1)?.kind === "meta" && !fits(withHint)) kept = kept.slice(0, -1);
113
+ if (fits(withHint)) return { left: current(), hint: true };
114
+
115
+ // (2) the subject (id) clips — the lowest-priority clippable piece. `…`
116
+ // lands at the subject's own boundary; the pieces stay untouched.
117
+ const room = withHint - visibleWidth(buildLeft("", kept));
118
+ subjectText = room >= 4 ? truncate(subject, room - 3) : "";
119
+ if (fits(withHint)) return { left: current(), hint: true };
120
+
121
+ // (3) the hint yields before ANY piece is dropped or mangled: status
122
+ // outranks the affordance — `↓·enter` may survive per budget, never at the
123
+ // cost of `1 running`.
124
+ if (fits(full)) return { left: current(), hint: false };
125
+
126
+ // (4) whole pieces drop right-to-left — status included, but as a UNIT.
127
+ while (kept.length > 1 && !fits(full)) kept = kept.slice(0, -1);
128
+ if (fits(full)) return { left: current(), hint: false };
129
+
130
+ // (5) one piece left: meta clips word-wise (the idle schedule); a lone
131
+ // status piece drops whole rather than split.
132
+ const last = kept.at(-1);
133
+ if (last?.kind === "meta") {
134
+ const pieceBudget = full - visibleWidth(buildLeft(subjectText, [])) - visibleWidth(DOCK_SEPARATOR);
135
+ kept = [{ kind: "meta", text: clipWords(last.text, Math.max(pieceBudget, 1)) }];
136
+ if (fits(full)) return { left: current(), hint: false };
137
+ }
138
+ // (6) pathological width (< ~12 cols): the width invariant wins.
139
+ return { left: truncate(buildLeft(subjectText, []), Math.max(full, 1)), hint: false };
140
+ }
141
+
142
+ /**
143
+ * The dock's one row, composed under the segment-priority budget and laid
144
+ * out with the `↓·enter` tail. The pin gives a fixed four-dot leader on a
145
+ * wide terminal (`railLeaders`); on a NARROW one the budget shrinks instead,
146
+ * so `budgetDockLeft` sacrifices meta → subject → hint → whole pieces — in
147
+ * that order — and the actionable hint never gets clipped mid-glyph (the
148
+ * live row used to end in `↓…` at 50 columns, and later in `1 runnin…` at
149
+ * 40). The exact-fit gap is laid out HERE rather than through `railLeaders`:
150
+ * its 0-slack branch cuts ONE visible char off the left side, which is
151
+ * exactly the mid-token regression this composer exists to kill.
152
+ */
153
+ function dockRow(args: {
154
+ glyph: string;
155
+ subject: string;
156
+ subjectUrl: string | undefined;
157
+ pieces: readonly DockPiece[];
158
+ maxWidth?: number;
159
+ }): string {
160
+ const { glyph, subjectUrl, pieces } = args;
161
+ const buildLeft = (subjectText: string, kept: readonly DockPiece[]): string => {
162
+ const identity = dockIdentity(linkify(subjectText, subjectUrl));
163
+ const head = glyph ? `${glyph} ${identity}` : identity;
164
+ return kept.length > 0 ? `${head}${DOCK_SEPARATOR}${kept.map((piece) => piece.text).join(DOCK_SEPARATOR)}` : head;
165
+ };
166
+ const pinned = visibleWidth(buildLeft(args.subject, pieces)) + visibleWidth(DOCK_HINT_RIGHT) + 6;
167
+ const budget = args.maxWidth === undefined ? pinned : Math.min(pinned, Math.max(args.maxWidth, MIN_DOCK_WIDTH) - 2);
168
+ const outcome = budgetDockLeft(buildLeft, args.subject, pieces, budget - visibleWidth(DOCK_HINT_RIGHT) - 2, budget);
169
+ if (!outcome.hint) return outcome.left;
170
+ const slack = budget - visibleWidth(outcome.left) - visibleWidth(DOCK_HINT_RIGHT) - 2;
171
+ if (slack >= 3) return railLeaders(outcome.left, DOCK_HINT_RIGHT, budget, PLAIN_THEME);
172
+ return `${outcome.left} ${DOCK_HINT_RIGHT}`;
173
+ }
174
+
54
175
  /** The dock's FOCUS marker (RAIL §2.B: "focused row keeps the `❯ ` prefix").
55
176
  * It is deliberately not `rail.ts`'s `CURSOR` (`›`) — that glyph marks the
56
177
  * selected row of a LIST; the dock is a single line, and `❯` is the marker
57
178
  * the prompt area has always acknowledged the ↓ keystroke with. */
58
179
  const FOCUS_MARKER = "❯";
59
180
 
60
- /**
61
- * `···· ↓·enter` appended to a dock row. Composed through `railLeaders` with a
62
- * budget that pins the leader run at exactly four dots — the dock is a
63
- * left-aligned single row, so the leaders must NOT stretch to the terminal
64
- * width the way a card's metrics→elapsed leaders do.
65
- */
66
- function dockTail(left: string, theme: CrewTheme, maxWidth?: number): string {
67
- const pinned = visibleWidth(left) + visibleWidth(DOCK_HINT_RIGHT) + 6;
68
- // The pin gives a fixed four-dot leader on a wide terminal. On a NARROW one
69
- // the budget shrinks instead, so `railLeaders` trims the LEFT segment (with
70
- // `…`) and the actionable `↓·enter` hint never gets clipped away — the live
71
- // row used to end in `↓…` at 50 columns.
72
- const budget = maxWidth === undefined ? pinned : Math.min(pinned, maxWidth - 2);
73
- return railLeaders(left, DOCK_HINT_RIGHT, budget, theme);
74
- }
75
-
76
181
  /** `CREW ▸ <subject>` — the dock identity (colour is applied on the paint pass). */
77
182
  function dockIdentity(subject: string): string {
78
183
  return subject ? `${DOCK_WORD} ${ACTIVE} ${subject}` : DOCK_WORD;
@@ -96,14 +201,12 @@ export function widgetRailSlot(runs: readonly WidgetRun[]): RailSlot {
96
201
  }
97
202
 
98
203
  /** `<team>` (or `team/workflow` when they differ); several live runs collapse
99
- * to `<n> runs` — the counts on the row stay aggregate. */
100
- function dockSubject(runs: WidgetRun[]): string {
204
+ * to `<n> runs` — the counts on the row stay aggregate. Returns the RAW
205
+ * label: the row budget clips it BEFORE the OSC-8 wrap, so a hyperlink
206
+ * sequence can never be sliced open (the link target survives whole). */
207
+ function dockSubjectLabel(runs: WidgetRun[]): string {
101
208
  if (runs.length === 0) return "idle";
102
- const label = runs.length > 1 ? `${runs.length} runs` : shortRunLabel(runs[0]!.run);
103
- // R3-9: the dock's ONE clickable action — the subject opens the LATEST
104
- // run's artifacts directory (`file://` OSC-8 link). Terminals without
105
- // OSC-8 support ignore the escapes and render the plain label.
106
- return linkify(label, latestArtifactsUrl(runs));
209
+ return runs.length > 1 ? `${runs.length} runs` : shortRunLabel(runs[0]!.run);
107
210
  }
108
211
 
109
212
  // ── R3-9: OSC-8 dock link ─────────────────────────────────────────────
@@ -155,8 +258,20 @@ function linkify(text: string, url: string | undefined): string {
155
258
  */
156
259
  export function idleWidgetLine(schedLine: string | undefined, focused = false, maxWidth?: number): string | undefined {
157
260
  if (!schedLine) return undefined;
158
- const left = `${dockIdentity("idle")} · ${schedLine}`;
159
- const line = dockLine(dockTail(left, PLAIN_THEME, maxWidth), PLAIN_THEME, "border");
261
+ const line = dockLine(
262
+ dockRow({
263
+ glyph: "",
264
+ subject: "idle",
265
+ subjectUrl: undefined,
266
+ pieces: [{ text: schedLine, kind: "meta" }],
267
+ // The focused ❯ prefix eats two columns of the same terminal row —
268
+ // budget for it here so the final truncate stays a safety net, not
269
+ // the clip.
270
+ maxWidth: maxWidth === undefined ? undefined : Math.max(maxWidth - (focused ? 2 : 0), MIN_DOCK_WIDTH),
271
+ }),
272
+ PLAIN_THEME,
273
+ "border",
274
+ );
160
275
  return focused ? `${FOCUS_MARKER} ${line}` : line;
161
276
  }
162
277
 
@@ -199,7 +314,7 @@ export function widgetHeader(
199
314
  maxWidth?: number,
200
315
  ): string {
201
316
  const agents = runs.flatMap((item) => item.agents);
202
- const segments: string[] = [];
317
+ const pieces: DockPiece[] = [];
203
318
  if (runs.length > 0) {
204
319
  const runningAgents = agents.filter((a) => a.status === "running").length;
205
320
  const queuedAgents = agents.filter((a) => a.status === "queued").length;
@@ -207,28 +322,36 @@ export function widgetHeader(
207
322
  const completedAgents = agents.filter((a) => a.status === "completed").length;
208
323
  // Zero counts are noise on a one-line dock: a finished run reads
209
324
  // `3/3 done`, not `0 running · 3/3 done`.
210
- if (runningAgents) segments.push(`${runningAgents} running`);
211
- if (queuedAgents) segments.push(`${queuedAgents} queued`);
212
- if (waitingAgents) segments.push(`${waitingAgents} waiting`);
213
- if (completedAgents) segments.push(`${completedAgents}/${agents.length} done`);
325
+ if (runningAgents) pieces.push({ text: `${runningAgents} running`, kind: "status" });
326
+ if (queuedAgents) pieces.push({ text: `${queuedAgents} queued`, kind: "status" });
327
+ if (waitingAgents) pieces.push({ text: `${waitingAgents} waiting`, kind: "status" });
328
+ if (completedAgents) pieces.push({ text: `${completedAgents}/${agents.length} done`, kind: "status" });
214
329
  // WP-3 on the single line (2026-09-14 round 2): a run parked awaiting plan
215
330
  // approval surfaces as a `⚠ plan:<run8>` segment — the row-level badge is
216
331
  // gone with the run tree, so the count row carries the signal.
217
332
  const planPending = runs.find((item) => isPlanApprovalStatePending(item.run.planApproval));
218
- if (planPending) segments.push(`⚠ plan:${shortId(planPending.run.runId)}`);
333
+ if (planPending) pieces.push({ text: `⚠ plan:${shortId(planPending.run.runId)}`, kind: "status" });
219
334
  }
220
335
  // Tier C: `schedSegment` is the ALREADY-BUILT `⏰ …` string (jobs, hidden
221
336
  // count and clock are all injected upstream — buildWidgetLines); undefined
222
337
  // means nothing schedules-related paints.
223
- if (schedSegment) segments.push(schedSegment);
338
+ if (schedSegment) pieces.push({ text: schedSegment, kind: "meta" });
224
339
  // Bug 021: the alerts badge is one more segment (no 🔔, capped at 99+).
225
340
  const badge = notificationBadge(notificationCount)
226
341
  .replace(/^\s*·\s*/, "")
227
342
  .trim();
228
- if (badge) segments.push(badge);
229
- const head = runningGlyph ? `${runningGlyph} ${dockIdentity(dockSubject(runs))}` : dockIdentity(dockSubject(runs));
230
- const left = segments.length > 0 ? `${head} · ${segments.join(" · ")}` : head;
231
- return dockLine(dockTail(left, PLAIN_THEME, maxWidth), PLAIN_THEME, "border");
343
+ if (badge) pieces.push({ text: badge, kind: "meta" });
344
+ return dockLine(
345
+ dockRow({
346
+ glyph: runningGlyph,
347
+ subject: dockSubjectLabel(runs),
348
+ subjectUrl: latestArtifactsUrl(runs),
349
+ pieces,
350
+ maxWidth,
351
+ }),
352
+ PLAIN_THEME,
353
+ "border",
354
+ );
232
355
  }
233
356
 
234
357
  // ── Agent ordering (shared with the inline panel) ──────────────────────
@@ -413,10 +536,13 @@ export function buildWidgetLines(
413
536
  // interactive run is active. The ⏰ segment stands alone as the row; with
414
537
  // no schedules either there is NOTHING to paint — `[]`, never a bare
415
538
  // hint and never the literal `undefined — ↓·enter` (live bug 2026-09-16).
416
- const idle = idleWidgetLine(schedLine, focused);
539
+ const idle = idleWidgetLine(schedLine, focused, width);
417
540
  return idle ? [truncate(idle, width)] : [];
418
541
  }
419
- const base = widgetHeader(runs, widgetActivityGlyph(runs), maxLines, notificationCount, schedLine, width);
542
+ // The focused ❯ prefix eats two columns of the same terminal row: budget
543
+ // for it here so the final truncate stays a safety net, never the clip.
544
+ const headerWidth = focused ? Math.max(width - 2, MIN_DOCK_WIDTH) : width;
545
+ const base = widgetHeader(runs, widgetActivityGlyph(runs), maxLines, notificationCount, schedLine, headerWidth);
420
546
  return [truncate(focused ? `${FOCUS_MARKER} ${base}` : base, width)];
421
547
  }
422
548
 
@@ -63,7 +63,18 @@ export function getPerUserSocketDir(platform: NodeJS.Platform = process.platform
63
63
  if (platform === "win32") return "";
64
64
  const base = process.env.XDG_RUNTIME_DIR || os.tmpdir();
65
65
  const uid = getCurrentUid();
66
- return path.join(base, `pi-crew-${uid}`);
66
+ // F-BAT1 (2026-10-08, live-caught P1): when XDG_RUNTIME_DIR is ABSENT the
67
+ // base is /tmp — and THREE workspace sweeps scan /tmp for dirs named
68
+ // `pi-crew-*` (cleanupLegacyOrphanTempDirs, orphan temp reconciler, health
69
+ // zombie scan). A broker dir at /tmp/pi-crew-<uid> collides with that
70
+ // namespace and gets rmSync-ed as "debris" — killing the LIVE broker socket
71
+ // of every session launched without XDG_RUNTIME_DIR (live evidence:
72
+ // ask/message/delegate ENOENT in both sync and async runs while runs
73
+ // stayed green). The no-XDG fallback therefore uses a DOT-prefixed name
74
+ // (`.pi-crew-broker-<uid>`) which no `pi-crew-*` sweep matches. The XDG
75
+ // path keeps the original `pi-crew-<uid>` name (/run/user is never swept).
76
+ const leaf = process.env.XDG_RUNTIME_DIR ? `pi-crew-${uid}` : `.pi-crew-broker-${uid}`;
77
+ return path.join(base, leaf);
67
78
  }
68
79
 
69
80
  /** Resolve the broker endpoint for the given session.
@@ -122,6 +133,10 @@ export async function prepareBrokerSocketDir(sockPath: string): Promise<void> {
122
133
  export async function removeStaleBrokerSocket(
123
134
  sockPath: string,
124
135
  probeTimeoutMs: number = 250,
136
+ // Injectable for deterministic tests (the CrewBroker options.netModule
137
+ // precedent): a fake whose createConnection() never connects/errors pins
138
+ // the timeout branch without racing a real localhost connect.
139
+ netModule?: { createConnection: (path: string) => { once: (ev: string, fn: (arg?: unknown) => void) => void; destroy: () => void } },
125
140
  ): Promise<"removed" | "kept" | "absent" | "refused"> {
126
141
  // Reject symlinks outright.
127
142
  let st: Awaited<ReturnType<typeof fsp.lstat>>;
@@ -133,11 +148,29 @@ export async function removeStaleBrokerSocket(
133
148
  throw e;
134
149
  }
135
150
  if (st.isSymbolicLink()) return "refused";
151
+ // A REGULAR FILE (or any non-socket entry) can never be a live endpoint —
152
+ // deterministically stale, remove it WITHOUT the connect probe. This is
153
+ // cross-platform: connect() to a non-socket path is undefined (Linux yields
154
+ // ECONNREFUSED, macOS yields a different errno), so relying on the probe
155
+ // here broke the CrewBroker stale-replace flow on BSD/macOS (CI 2026-10-08:
156
+ // crew-broker-stale-socket "refused/nonexistent stale endpoint is replaced
157
+ // once" + "recording-owned-path unlink only on stop()" — both pre-create a
158
+ // plain file as the corpse). Sockets keep the probe path below.
159
+ if (!st.isSocket()) {
160
+ try {
161
+ await fsp.unlink(sockPath);
162
+ return "removed";
163
+ } catch (e) {
164
+ const code = (e as NodeJS.ErrnoException).code;
165
+ if (code === "ENOENT") return "absent";
166
+ throw e;
167
+ }
168
+ }
136
169
  // Bound the probe: connect with a short timeout. If anything answers, treat as live.
137
- const live = await new Promise<boolean>((resolve) => {
170
+ const live = await new Promise<boolean | "refused">((resolve) => {
138
171
  let settled = false;
139
- const sock = net.createConnection(sockPath);
140
- const finish = (v: boolean) => {
172
+ const sock = (netModule ?? net).createConnection(sockPath);
173
+ const finish = (v: boolean | "refused") => {
141
174
  if (settled) return;
142
175
  settled = true;
143
176
  try {
@@ -148,11 +181,18 @@ export async function removeStaleBrokerSocket(
148
181
  resolve(v);
149
182
  };
150
183
  sock.once("connect", () => finish(true));
151
- sock.once("error", () => finish(false));
152
- setTimeout(() => finish(false), probeTimeoutMs);
184
+ // F-BAT1 companion (2026-10-08): unlink is safe ONLY on a definitive
185
+ // ECONNREFUSED — the kernel rejects connect() on a socket whose
186
+ // listener is gone (owner died => fd closed => refused), so "refused"
187
+ // reliably means stale. A TIMEOUT (or any other error) means the
188
+ // listener is alive but slow to accept (busy event loop, backlog) —
189
+ // the previous code treated that as stale and UNLINKED A LIVE BROKER,
190
+ // leaving every later client with ENOENT forever.
191
+ sock.once("error", (err) => finish((err as NodeJS.ErrnoException).code === "ECONNREFUSED" ? "refused" : true));
192
+ setTimeout(() => finish(true), probeTimeoutMs);
153
193
  });
154
- if (live) return "kept";
155
- // Stale: remove.
194
+ if (live !== "refused") return "kept";
195
+ // Stale (definitive ECONNREFUSED): remove.
156
196
  try {
157
197
  await fsp.unlink(sockPath);
158
198
  return "removed";