pi-crew 0.11.0 → 0.11.2

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.
Files changed (174) hide show
  1. package/CHANGELOG.md +155 -9
  2. package/README.md +161 -1037
  3. package/agents/verifier.md +18 -7
  4. package/dist/index.mjs +744 -90644
  5. package/docs/README.md +57 -46
  6. package/docs/architecture.md +87 -33
  7. package/docs/commands-reference.md +9 -5
  8. package/docs/troubleshooting.md +3 -2
  9. package/package.json +1 -3
  10. package/schema.json +39 -0
  11. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +2 -0
  12. package/skills/real-test-pi-crew/SKILL.md +371 -34
  13. package/src/agents/agent-config.ts +1 -1
  14. package/src/agents/discover-agents.ts +1 -1
  15. package/src/config/config-validation.ts +15 -2
  16. package/src/config/config.ts +47 -13
  17. package/src/config/defaults.ts +0 -1
  18. package/src/config/env-vars.ts +35 -0
  19. package/src/config/types.ts +19 -5
  20. package/src/errors.ts +2 -2
  21. package/src/extension/async-notifier.ts +23 -0
  22. package/src/extension/crew-vibes/config.ts +0 -21
  23. package/src/extension/crew-vibes/index.ts +0 -2
  24. package/src/extension/crew-vibes/render.ts +1 -50
  25. package/src/extension/help.ts +21 -12
  26. package/src/extension/knowledge-injection.ts +2 -1
  27. package/src/extension/management.ts +8 -3
  28. package/src/extension/notification-sink.ts +17 -0
  29. package/src/extension/register.ts +7 -2
  30. package/src/extension/registration/command-utils.ts +28 -2
  31. package/src/extension/registration/commands/dashboard.ts +11 -1
  32. package/src/extension/registration/commands/manage.ts +36 -19
  33. package/src/extension/registration/commands/run.ts +24 -2
  34. package/src/extension/registration/commands/shared.ts +23 -1
  35. package/src/extension/registration/commands/status.ts +25 -2
  36. package/src/extension/registration/context-builder.ts +8 -2
  37. package/src/extension/registration/health-notify-policy.ts +100 -0
  38. package/src/extension/registration/lazy-configurers.ts +35 -0
  39. package/src/extension/registration/lifecycle-handlers.ts +91 -30
  40. package/src/extension/registration/lifecycle.ts +75 -10
  41. package/src/extension/registration/observability.ts +98 -35
  42. package/src/extension/registration/registration-types.ts +7 -5
  43. package/src/extension/registration/runtime-cleanup.ts +9 -3
  44. package/src/extension/registration/subagent-helpers.ts +38 -0
  45. package/src/extension/registration/subagent-tools.ts +16 -6
  46. package/src/extension/registration/team-tool.ts +10 -3
  47. package/src/extension/registration/terminal-status-wiring.ts +172 -0
  48. package/src/extension/registration/viewers.ts +6 -0
  49. package/src/extension/registration/wire-cross-extension.ts +28 -0
  50. package/src/extension/run-compare.ts +220 -0
  51. package/src/extension/run-export.ts +37 -5
  52. package/src/extension/run-maintenance.ts +155 -5
  53. package/src/extension/team-tool/dispatch/index.ts +3 -2
  54. package/src/extension/team-tool/dispatch/manage.ts +5 -2
  55. package/src/extension/team-tool/goal.ts +4 -1
  56. package/src/extension/team-tool/handle-settings.ts +33 -4
  57. package/src/extension/team-tool/health-monitor.ts +21 -7
  58. package/src/extension/team-tool/lifecycle-actions.ts +49 -1
  59. package/src/extension/team-tool/plan.ts +10 -0
  60. package/src/extension/team-tool/routing-hint.ts +63 -0
  61. package/src/extension/team-tool/status.ts +4 -0
  62. package/src/extension/team-tool.ts +52 -6
  63. package/src/extension/webhook-notify.ts +382 -0
  64. package/src/observability/metric-sink.ts +12 -2
  65. package/src/prompt/prompt-runtime.ts +82 -31
  66. package/src/prompt/worker-events-channel.ts +12 -0
  67. package/src/runtime/README.md +1 -1
  68. package/src/runtime/async-runner.ts +87 -1
  69. package/src/runtime/background-runner.ts +313 -234
  70. package/src/runtime/broker/crew-broker.ts +17 -11
  71. package/src/runtime/broker/delegate/shadow-lifecycle.ts +92 -0
  72. package/src/runtime/broker/wait-status-cache.ts +1 -1
  73. package/src/runtime/child-pi/child-pi-timers.ts +1 -1
  74. package/src/runtime/child-pi/mock-fixtures.ts +48 -0
  75. package/src/runtime/crew-agent-records.ts +337 -45
  76. package/src/runtime/deadletter.ts +43 -1
  77. package/src/runtime/delegate-spawn.ts +5 -1
  78. package/src/runtime/dispatch-batch.ts +72 -5
  79. package/src/runtime/goal-workflow/goal-loop-runner.ts +73 -4
  80. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  81. package/src/runtime/model/model-fallback.ts +21 -1
  82. package/src/runtime/model/pi-args.ts +8 -10
  83. package/src/runtime/recovery/crash-recovery.ts +25 -1
  84. package/src/runtime/run-worker.ts +12 -1
  85. package/src/runtime/scheduling/global-worker-cap.ts +13 -6
  86. package/src/runtime/scheduling/run-coalesced-task-group.ts +27 -1
  87. package/src/runtime/scheduling/scheduler.ts +49 -13
  88. package/src/runtime/scheduling/semaphore.ts +148 -20
  89. package/src/runtime/scratchpad/README.md +1 -1
  90. package/src/runtime/scratchpad/protocol.ts +1 -1
  91. package/src/runtime/settings-store.ts +1 -1
  92. package/src/runtime/skill-instructions.ts +22 -0
  93. package/src/runtime/stale-reconciler.ts +85 -13
  94. package/src/runtime/task-display.ts +1 -1
  95. package/src/runtime/task-runner/pre-execution.ts +26 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +142 -45
  97. package/src/runtime/task-runner.ts +21 -1
  98. package/src/runtime/team-runner.ts +38 -1
  99. package/src/runtime/workspace-lock.ts +4 -1
  100. package/src/schema/config-schema.ts +18 -0
  101. package/src/schema/team-tool-schema.ts +17 -0
  102. package/src/state/atomic-write.ts +53 -0
  103. package/src/state/contracts.ts +109 -0
  104. package/src/state/coordination/locks.ts +191 -33
  105. package/src/state/coordination/mailbox.ts +140 -15
  106. package/src/state/crew-init.ts +87 -12
  107. package/src/state/event-log/cursor.ts +37 -1
  108. package/src/state/event-log/event-log-rotation.ts +72 -7
  109. package/src/state/stores/active-run-registry.ts +13 -1
  110. package/src/state/stores/state-store.ts +112 -22
  111. package/src/state/types.ts +4 -0
  112. package/src/ui/adaptive-card.ts +65 -0
  113. package/src/ui/agents-jobs-browser.ts +70 -64
  114. package/src/ui/card-colors.ts +36 -7
  115. package/src/ui/dashboard-panes/agents-pane.ts +55 -14
  116. package/src/ui/dashboard-panes/cancellation-pane.ts +0 -42
  117. package/src/ui/dashboard-panes/health-pane.ts +7 -5
  118. package/src/ui/dashboard-panes/mailbox-pane.ts +22 -6
  119. package/src/ui/dashboard-panes/metrics-pane.ts +15 -7
  120. package/src/ui/dashboard-panes/pane-theme.ts +21 -0
  121. package/src/ui/dashboard-panes/plan-pane.ts +63 -30
  122. package/src/ui/dashboard-panes/progress-pane.ts +3 -2
  123. package/src/ui/dashboard-panes/schedules-pane.ts +44 -21
  124. package/src/ui/dashboard-panes/transcript-pane.ts +11 -5
  125. package/src/ui/dwf-phase-display.ts +3 -20
  126. package/src/ui/format-helpers.ts +22 -0
  127. package/src/ui/heartbeat-aggregator.ts +34 -0
  128. package/src/ui/inline-panel/crew-editor.ts +13 -3
  129. package/src/ui/inline-panel/index.ts +60 -4
  130. package/src/ui/keybinding-map.ts +251 -35
  131. package/src/ui/live-conversation-overlay.ts +180 -47
  132. package/src/ui/live-run-sidebar.ts +134 -55
  133. package/src/ui/mascot.ts +32 -16
  134. package/src/ui/overlays/agent-picker-overlay.ts +81 -26
  135. package/src/ui/overlays/confirm-overlay.ts +55 -29
  136. package/src/ui/overlays/help-overlay.ts +108 -53
  137. package/src/ui/overlays/mailbox-compose-overlay.ts +89 -50
  138. package/src/ui/overlays/mailbox-detail-overlay.ts +137 -57
  139. package/src/ui/powerbar-publisher.ts +0 -1
  140. package/src/ui/rail.ts +333 -0
  141. package/src/ui/run-dashboard.ts +193 -79
  142. package/src/ui/run-snapshot-cache.ts +18 -1
  143. package/src/ui/settings-overlay.ts +81 -39
  144. package/src/ui/spinner.ts +26 -2
  145. package/src/ui/terminal-status.ts +7 -1
  146. package/src/ui/theme-adapter.ts +0 -45
  147. package/src/ui/theme-discovery.ts +12 -6
  148. package/src/ui/tool-progress-formatter.ts +128 -9
  149. package/src/ui/tool-renderers/brief-mode.ts +10 -67
  150. package/src/ui/tool-renderers/index.ts +374 -523
  151. package/src/ui/transcript-viewer.ts +30 -12
  152. package/src/ui/widget/index.ts +32 -52
  153. package/src/ui/widget/task-list.ts +64 -32
  154. package/src/ui/widget/widget-formatters.ts +3 -402
  155. package/src/ui/widget/widget-model.ts +28 -7
  156. package/src/ui/widget/widget-renderer.ts +201 -128
  157. package/src/ui/widget/widget-types.ts +0 -2
  158. package/src/utils/incremental-reader.ts +11 -3
  159. package/src/utils/paths.ts +94 -12
  160. package/src/utils/project-markers.ts +40 -0
  161. package/src/utils/visual.ts +0 -4
  162. package/src/worktree/worktree-manager.ts +206 -26
  163. package/workflows/distill.workflow.md +3 -3
  164. package/workflows/fast-fix.workflow.md +1 -1
  165. package/workflows/plan-execute.workflow.md +1 -1
  166. package/workflows/review.workflow.md +1 -1
  167. package/workflows/strict-fast-fix.workflow.md +1 -1
  168. package/docs/migration-v0.4-v0.5.md +0 -208
  169. package/docs/runtime-flow.md +0 -148
  170. package/src/extension/crew-vibes/figures.ts +0 -22
  171. package/src/extension/crew-vibes/font-detect.ts +0 -71
  172. package/src/ui/dynamic-border.ts +0 -35
  173. package/src/ui/loaders.ts +0 -6
  174. package/src/ui/overlay-stack.ts +0 -148
@@ -6,30 +6,20 @@
6
6
 
7
7
  import { getCrewScheduler, getScheduledJobs, getScheduledJobsHiddenCountView } from "../../extension/team-tool/handle-schedule.ts";
8
8
  import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
9
- import type { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
10
9
  import { isPlanApprovalStatePending } from "../../runtime/plan-approval.ts";
11
10
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
12
11
  import type { ScheduledJob } from "../../runtime/scheduling/scheduler.ts";
13
- import type { TeamRunManifest } from "../../state/types.ts";
14
12
  import { formatRelativeTime } from "../../utils/relative-time.ts";
15
- import { truncate } from "../../utils/visual.ts";
13
+ import { truncate, visibleWidth } from "../../utils/visual.ts";
16
14
  import { Box, Text } from "../layout-primitives.ts";
15
+ import { ACTIVE, RAIL, type RailSlot, railLeaders, railRaw, shortId, statusSlot } from "../rail.ts";
17
16
  import { spinnerFrame } from "../spinner.ts";
18
17
  import { colorizeStatusGlyphs } from "../status-colors.ts";
19
- import type { CrewTheme } from "../theme-adapter.ts";
20
- import {
21
- agentActivity,
22
- budgetedRow,
23
- dockElapsed,
24
- dockStatusIcon,
25
- dockStatusLabel,
26
- dockUsageText,
27
- notificationBadge,
28
- } from "./widget-formatters.ts";
18
+ import { asCrewTheme, type CrewTheme } from "../theme-adapter.ts";
19
+ import { notificationBadge } from "./widget-formatters.ts";
29
20
  import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
30
21
  import type { WidgetRun } from "./widget-types.ts";
31
22
 
32
- export const MAX_AGENTS_DISPLAY = 3;
33
23
  const FINISHED_LINGER_MAX_AGE = 1;
34
24
  /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
35
25
  * (the same default used elsewhere in pi-crew tool renderers) so we never paint
@@ -41,32 +31,158 @@ export const TASK_DESC_MAX = 60;
41
31
  const ERROR_LINGER_MAX_AGE = 2;
42
32
  const ERROR_STATUSES = new Set(["failed", "cancelled", "stopped", "needs_attention"]);
43
33
 
34
+ // ── RAIL dock composition (design system §2.B) ─────────────────────────
35
+ //
36
+ // The dock is the ONE surface that must never grow a second row: it speaks the
37
+ // rail grammar with the BODY glyph (`┃`) only — never `┏`/`┗`, which imply a
38
+ // multi-line card. The builders below return PLAIN text (that is the exported
39
+ // contract: `buildWidgetLines` is the raw row, `colorWidgetLine` the paint
40
+ // pass), but they COMPOSE through the shared rail helpers with a no-op theme
41
+ // so the glyph/leader vocabulary lives in `rail.ts` alone.
42
+ const PLAIN_THEME = asCrewTheme(undefined);
43
+
44
+ /** Identity word of the dock + status bar. */
45
+ const DOCK_WORD = "CREW";
46
+
47
+ /** The dock's tail: four leaders + the one-key entry hint. */
48
+ const DOCK_HINT_RIGHT = "↓·enter";
49
+ const DOCK_HINT = `···· ${DOCK_HINT_RIGHT}`;
50
+
51
+ /** The dock's FOCUS marker (RAIL §2.B: "focused row keeps the `❯ ` prefix").
52
+ * It is deliberately not `rail.ts`'s `CURSOR` (`›`) — that glyph marks the
53
+ * selected row of a LIST; the dock is a single line, and `❯` is the marker
54
+ * the prompt area has always acknowledged the ↓ keystroke with. */
55
+ const FOCUS_MARKER = "❯";
56
+
57
+ /**
58
+ * `···· ↓·enter` appended to a dock row. Composed through `railLeaders` with a
59
+ * budget that pins the leader run at exactly four dots — the dock is a
60
+ * left-aligned single row, so the leaders must NOT stretch to the terminal
61
+ * width the way a card's metrics→elapsed leaders do.
62
+ */
63
+ function dockTail(left: string, theme: CrewTheme, maxWidth?: number): string {
64
+ const pinned = visibleWidth(left) + visibleWidth(DOCK_HINT_RIGHT) + 6;
65
+ // The pin gives a fixed four-dot leader on a wide terminal. On a NARROW one
66
+ // the budget shrinks instead, so `railLeaders` trims the LEFT segment (with
67
+ // `…`) and the actionable `↓·enter` hint never gets clipped away — the live
68
+ // row used to end in `↓…` at 50 columns.
69
+ const budget = maxWidth === undefined ? pinned : Math.min(pinned, maxWidth - 2);
70
+ return railLeaders(left, DOCK_HINT_RIGHT, budget, theme);
71
+ }
72
+
73
+ /** `CREW ▸ <subject>` — the dock identity (colour is applied on the paint pass). */
74
+ function dockIdentity(subject: string): string {
75
+ return subject ? `${DOCK_WORD} ${ACTIVE} ${subject}` : DOCK_WORD;
76
+ }
77
+
78
+ /** `┃ <content>` — the dock rail, unpadded (`truncate` stays the single clip). */
79
+ function dockLine(content: string, theme: CrewTheme, slot: RailSlot): string {
80
+ return railRaw(RAIL.body, slot, content, theme);
81
+ }
82
+
83
+ /**
84
+ * Aggregate run status → the dock's rail colour (§2.B: rail colour = state).
85
+ * A failure outranks live work; live work outranks idle. Zero runs is idle.
86
+ */
87
+ export function widgetRailSlot(runs: readonly WidgetRun[]): RailSlot {
88
+ if (runs.length === 0) return "border";
89
+ const slots = runs.map((entry) => statusSlot(entry.run.status));
90
+ if (slots.includes("error")) return "error";
91
+ if (slots.includes("borderAccent")) return "borderAccent";
92
+ return slots[0] ?? "border";
93
+ }
94
+
95
+ /** `<team>` (or `team/workflow` when they differ); several live runs collapse
96
+ * to `<n> runs` — the counts on the row stay aggregate. */
97
+ function dockSubject(runs: WidgetRun[]): string {
98
+ if (runs.length > 1) return `${runs.length} runs`;
99
+ const first = runs[0];
100
+ return first ? shortRunLabel(first.run) : "idle";
101
+ }
102
+
103
+ /**
104
+ * The zero-run dock row: `┃ CREW ▸ idle · ⏰ 1 sched ···· ↓·enter`.
105
+ *
106
+ * Returns `undefined` when nothing schedules-related paints. The keep-alive
107
+ * path MUST then render NOTHING (`[]`) — a bare hint, and above all the
108
+ * literal `undefined — ↓·enter`, is the live regression this guards against.
109
+ */
110
+ export function idleWidgetLine(schedLine: string | undefined, focused = false, maxWidth?: number): string | undefined {
111
+ if (!schedLine) return undefined;
112
+ const left = `${dockIdentity("idle")} · ${schedLine}`;
113
+ const line = dockLine(dockTail(left, PLAIN_THEME, maxWidth), PLAIN_THEME, "border");
114
+ return focused ? `${FOCUS_MARKER} ${line}` : line;
115
+ }
116
+
117
+ /**
118
+ * The dock's leading activity glyph: a braille spinner ONLY while something is
119
+ * actually running, otherwise the outcome glyph of the aggregate state.
120
+ *
121
+ * Live-run bug (2026-09-16): the dock spun forever because `buildWidgetLines`
122
+ * always passed `spinnerFrame("widget-header")` — a finished run painted
123
+ * `┃ ⠹ CREW ▸ fast-fix · 0 running · 3/3 done`, i.e. a spinner with nothing to
124
+ * spin for. `✓`/`✗` are used (not `●`/`✖`) because they are in
125
+ * `STATUS_GLYPH_CHARS`, so the shared colorizer paints them.
126
+ */
127
+ export function widgetActivityGlyph(runs: readonly WidgetRun[]): string {
128
+ const anyAgentRunning = runs.some((entry) => entry.agents.some((agent) => agent.status === "running"));
129
+ const anyRunRunning = runs.some((entry) => entry.run.status === "running");
130
+ if (anyAgentRunning || anyRunRunning) return spinnerFrame("widget-header");
131
+ const slot = widgetRailSlot(runs);
132
+ if (slot === "error") return "✗";
133
+ if (slot === "success") return "✓";
134
+ return "";
135
+ }
136
+
44
137
  // ── Header ────────────────────────────────────────────────────────────
45
138
 
46
- export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines = 20, notificationCount = 0, schedSegment?: string): string {
139
+ /**
140
+ * The dock's ONE row (RAIL §2.B):
141
+ *
142
+ * `┃ ⠧ CREW ▸ fast-fix · 2 running · 3/5 done · ⏰ 1 sched ···· ↓·enter`
143
+ *
144
+ * Zero runs paint `┃ CREW ▸ idle · …` (see `idleWidgetLine`) instead. Returns
145
+ * PLAIN text — the component colorizes index 0 with `colorWidgetLine`.
146
+ */
147
+ export function widgetHeader(
148
+ runs: WidgetRun[],
149
+ runningGlyph: string,
150
+ maxLines = 20,
151
+ notificationCount = 0,
152
+ schedSegment?: string,
153
+ maxWidth?: number,
154
+ ): string {
47
155
  const agents = runs.flatMap((item) => item.agents);
48
- const runningAgents = agents.filter((a) => a.status === "running").length;
49
- const queuedAgents = agents.filter((a) => a.status === "queued").length;
50
- const waitingAgents = agents.filter((a) => a.status === "waiting").length;
51
- const completedAgents = agents.filter((a) => a.status === "completed").length;
52
- const parts = [`${runningAgents} running`];
53
- if (queuedAgents) parts.push(`${queuedAgents} queued`);
54
- if (waitingAgents) parts.push(`${waitingAgents} waiting`);
55
- if (completedAgents) parts.push(`${completedAgents}/${agents.length} done`);
56
- // WP-3 on the single line (2026-09-14 round 2): a run parked awaiting plan
57
- // approval surfaces as a `⚠ plan:<run8>` segment — the row-level badge is
58
- // gone with the run tree, so the count row carries the signal.
59
- const planPending = runs.find((item) => isPlanApprovalStatePending(item.run.planApproval));
60
- if (planPending) parts.push(`⚠ plan:${planPending.run.runId.slice(-8)}`);
61
- // Tier C (merged 2026-09-14): the header is the widget's ONE compact status
62
- // row in detailed mode — agent stats + the schedules segment on a single
63
- // line, with the `/team-dashboard` hint still trailing so it stays
64
- // reachable. `schedSegment` is the ALREADY-BUILT `⏰ …` string (jobs, hidden
65
- // count, and clock are all injected upstream — buildWidgetLines); undefined
66
- // means nothing schedules-related paints and the header stays
67
- // byte-identical to the pre-merge format.
68
- const sched = schedSegment ? ` · ${schedSegment}` : "";
69
- return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} · ${parts.join(" · ")}${sched} — ↓·enter`;
156
+ const segments: string[] = [];
157
+ if (runs.length > 0) {
158
+ const runningAgents = agents.filter((a) => a.status === "running").length;
159
+ const queuedAgents = agents.filter((a) => a.status === "queued").length;
160
+ const waitingAgents = agents.filter((a) => a.status === "waiting").length;
161
+ const completedAgents = agents.filter((a) => a.status === "completed").length;
162
+ // Zero counts are noise on a one-line dock: a finished run reads
163
+ // `3/3 done`, not `0 running · 3/3 done`.
164
+ if (runningAgents) segments.push(`${runningAgents} running`);
165
+ if (queuedAgents) segments.push(`${queuedAgents} queued`);
166
+ if (waitingAgents) segments.push(`${waitingAgents} waiting`);
167
+ if (completedAgents) segments.push(`${completedAgents}/${agents.length} done`);
168
+ // WP-3 on the single line (2026-09-14 round 2): a run parked awaiting plan
169
+ // approval surfaces as a `⚠ plan:<run8>` segment — the row-level badge is
170
+ // gone with the run tree, so the count row carries the signal.
171
+ const planPending = runs.find((item) => isPlanApprovalStatePending(item.run.planApproval));
172
+ if (planPending) segments.push(`⚠ plan:${shortId(planPending.run.runId)}`);
173
+ }
174
+ // Tier C: `schedSegment` is the ALREADY-BUILT `⏰ …` string (jobs, hidden
175
+ // count and clock are all injected upstream — buildWidgetLines); undefined
176
+ // means nothing schedules-related paints.
177
+ if (schedSegment) segments.push(schedSegment);
178
+ // Bug 021: the alerts badge is one more segment (no 🔔, capped at 99+).
179
+ const badge = notificationBadge(notificationCount)
180
+ .replace(/^\s*·\s*/, "")
181
+ .trim();
182
+ if (badge) segments.push(badge);
183
+ const head = runningGlyph ? `${runningGlyph} ${dockIdentity(dockSubject(runs))}` : dockIdentity(dockSubject(runs));
184
+ const left = segments.length > 0 ? `${head} · ${segments.join(" · ")}` : head;
185
+ return dockLine(dockTail(left, PLAIN_THEME, maxWidth), PLAIN_THEME, "border");
70
186
  }
71
187
 
72
188
  // ── Agent ordering (shared with the inline panel) ──────────────────────
@@ -113,25 +229,15 @@ export function orderWidgetAgents(entry: WidgetRun, now = Date.now()): { active:
113
229
 
114
230
  // ── Line builder ──────────────────────────────────────────────────────
115
231
 
116
- /**
117
- * Row layout for the per-agent lines.
118
- *
119
- * - `detailed` — the historical two-line tree (name row + `⊶ activity` row).
120
- * - `compact` — one width-budgeted line per agent, so a wide terminal shows the
121
- * full description instead of the same clip a narrow one gets.
122
- */
123
- export type WidgetRowStyle = "compact" | "detailed";
124
-
125
232
  export interface WidgetRenderOptions {
126
- rowStyle?: WidgetRowStyle;
127
233
  /** Task id under the inline panel cursor, if any. */
128
234
  selectedTaskId?: string;
129
235
  /** Task id whose transcript pane is open, if any. */
130
236
  viewedTaskId?: string;
131
237
  /**
132
- * True while the inline panel holds the cursor. Every agent is then listed
133
- * (no MAX_AGENTS_DISPLAY cap) so keyboard navigation can reach all of them;
134
- * the idle widget stays capped to keep the prompt area small.
238
+ * True while the inline panel holds the cursor. The inline panel then
239
+ * gets a visible cursor acknowledgement; the idle widget stays compact
240
+ * to keep the prompt area small.
135
241
  */
136
242
  focused?: boolean;
137
243
  /**
@@ -142,58 +248,6 @@ export interface WidgetRenderOptions {
142
248
  now?: Date;
143
249
  }
144
250
 
145
- /** Short display form of a model id: `zai/glm-5.3` → `glm-5.3`. */
146
- function shortModelLabel(agent: CrewAgentRecord, run: TeamRunManifest): string | undefined {
147
- const model = agent.model ?? run.modelContext?.parentModel ?? run.modelContext?.override;
148
- if (typeof model !== "string" || !model) return undefined;
149
- return model.split("/").at(-1) ?? model;
150
- }
151
-
152
- /** One flat dock row (pi-subtask style) for an agent — active or finished. */
153
- function compactAgentRow(
154
- run: TeamRunManifest,
155
- agent: CrewAgentRecord,
156
- finished: boolean,
157
- runs: readonly WidgetRun[],
158
- options: WidgetRenderOptions,
159
- width: number,
160
- liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined,
161
- nowMs: number,
162
- ): string {
163
- const marker = options.selectedTaskId === agent.taskId ? "❯" : " ";
164
- const dockGlyph = options.viewedTaskId === agent.taskId ? "⏺" : dockStatusIcon(agent.status);
165
- const name = liveHandle?.agent ?? agent.agent;
166
- const label = liveHandle?.description ?? agent.role ?? "";
167
- // Task-first: the agent exists to run its task, so the row names the task
168
- // right after the agent. With multiple runs, prefix each row with its run
169
- // label so the flat dock still says which run an agent belongs to.
170
- const runTag = runs.length > 1 ? `${shortRunLabel(run)} · ` : "";
171
- const taskTag = agent.taskId ? ` · ${agent.taskId}` : "";
172
- const roleTag = label && label !== agent.taskId && label !== name ? ` · ${label}` : "";
173
- const nameText = runTag + name + taskTag + roleTag;
174
- // pi-subtask activity: the worker's latest line while running, otherwise
175
- // the status word.
176
- const liveLine = liveHandle?.activity?.responseText
177
- ?.split("\n")
178
- .find((line) => line.trim())
179
- ?.trim();
180
- const activity =
181
- !finished && liveHandle?.status === "running" && liveLine
182
- ? liveLine.length > 60
183
- ? `${liveLine.slice(0, 60)}…`
184
- : liveLine
185
- : finished
186
- ? dockStatusLabel(agent.status)
187
- : agentActivity(agent, liveHandle);
188
- const usage = dockUsageText(agent, liveHandle, { viewed: options.viewedTaskId === agent.taskId, nowMs });
189
- const ageText = dockElapsed(agent.completedAt ?? agent.startedAt);
190
- const model = shortModelLabel(agent, run);
191
- // Stats tail: `· glm-5.3 · ↑1.2k ↓350 · 41s` — the model the worker is
192
- // actually on first, then usage, then elapsed.
193
- const suffix = `${model ? ` · ${model}` : ""}${usage ? ` · ${usage}` : ""}${ageText ? ` · ${ageText}` : ""}`;
194
- return budgetedRow({ lead: `${marker} ${dockGlyph} `, name: nameText, activity, suffix }, width);
195
- }
196
-
197
251
  // ── Schedules line (Tier C) ───────────────────────────────────────────
198
252
 
199
253
  /**
@@ -294,48 +348,67 @@ export function buildWidgetLines(
294
348
  width = DEFAULT_WIDGET_WIDTH,
295
349
  options: WidgetRenderOptions = {},
296
350
  ): string[] {
297
- // SINGLE-LINE WIDGET (maintainer design 2026-09-14, round 2): the dock
298
- // paints EXACTLY ONE row — counts only (running/queued/waiting/done
299
- // agents + the schedules segment + the ↓·enter interaction hint). No
300
- // per-agent rows, no run tree, no visible "main" row, no scroll window —
301
- // ALL browsing lives in the Agents & Jobs browser, opened by ↓·enter from
302
- // THIS line (crew-editor: idle enter at the line target = browser).
351
+ // SINGLE-LINE DOCK (design system §2.B, maintainer design 2026-09-14): the
352
+ // dock paints EXACTLY ONE row — `┃ <identity> · counts · schedules ····
353
+ // ↓·enter`. No per-agent rows, no run tree, no scroll window; ALL browsing
354
+ // lives in the Agents & Jobs browser, opened by ↓·enter from THIS line
355
+ // (crew-editor: idle enter at the line target = browser). The rail glyph is
356
+ // always `┃` — `┏`/`┗` would imply a multi-line card.
303
357
  //
304
358
  // Focused (the ↓ cursor sits ON this line): prefix a ❯ marker so the
305
- // keystroke is visibly acknowledged — the line itself is the cursor
306
- // target; there is no second row to land on. maxLines/frame remain in the
307
- // signature for call-site compatibility; a single line is always within
308
- // budget.
359
+ // keystroke is visibly acknowledged — the line itself is the cursor target;
360
+ // there is no second row to land on. maxLines/frame remain in the signature
361
+ // for call-site compatibility; a single line is always within budget.
309
362
  const schedLine = schedulesWidgetLine(cwd, options.now ?? new Date());
310
363
  const runs = providedRuns ?? activeWidgetRuns(cwd);
364
+ const focused = options.focused === true;
311
365
  if (!runs.length) {
312
366
  // Zero runs keep-alive (Tier C): jobs are exactly what run while no
313
- // interactive run is active. The ⏰ segment stands alone as the row.
314
- const zero = schedLine ?? null;
315
- if (!zero) return [];
316
- const base = `${zero} — ↓·enter`;
317
- return [truncate(options.focused ? `❯ ${base}` : base, width)];
367
+ // interactive run is active. The ⏰ segment stands alone as the row; with
368
+ // no schedules either there is NOTHING to paint — `[]`, never a bare
369
+ // hint and never the literal `undefined — ↓·enter` (live bug 2026-09-16).
370
+ const idle = idleWidgetLine(schedLine, focused);
371
+ return idle ? [truncate(idle, width)] : [];
318
372
  }
319
- const runningGlyph = spinnerFrame("widget-header");
320
- const base = widgetHeader(runs, runningGlyph, maxLines, notificationCount, schedLine);
321
- return [truncate(options.focused ? `❯ ${base}` : base, width)];
373
+ const base = widgetHeader(runs, widgetActivityGlyph(runs), maxLines, notificationCount, schedLine, width);
374
+ return [truncate(focused ? `${FOCUS_MARKER} ${base}` : base, width)];
322
375
  }
323
376
 
324
377
  // ── Colorization ──────────────────────────────────────────────────────
325
378
 
326
- export function colorWidgetLine(line: string, index: number, theme: CrewTheme): string {
379
+ /**
380
+ * Paint pass for a PLAIN dock/plan line (`index 0` = the identity row).
381
+ *
382
+ * The builders above return plain text, so this adds the identity/rail/chrome
383
+ * colours: the leading `┃` (or plan `┏`) takes the rail slot — `statusSlot` of
384
+ * the aggregate run status, injected by the component — the identity word goes
385
+ * accent+bold, and the tail hint is dimmed. Lines that were ALREADY built with
386
+ * a real theme (the task-list variant builds through `rail.ts` directly) carry
387
+ * escapes and are passed through untouched, apart from the shared glyph
388
+ * colorizer below.
389
+ */
390
+ export function colorWidgetLine(line: string, index: number, theme: CrewTheme, slot: RailSlot = "border"): string {
327
391
  let result = line;
328
- if (index === 0) {
329
- result = result.replace("Crew agents", theme.bold(theme.fg("accent", "Crew agents")));
392
+ if (index === 0 && !result.includes("\u001b")) {
393
+ // `┏|┃ <WORD> ▸ <subject>`: rail glyph takes the state slot, the identity
394
+ // word accent+bold, the subject toolTitle+bold. The subject is bounded by
395
+ // the first ` ·` so a count segment can never be swallowed.
396
+ result = result.replace(
397
+ /^([❯] )?([┃┏]) (?:(\S+) )?((?:CREW|PLAN)(?: ▸ [^·]+?)?)(?= ·|$)/,
398
+ (_match, cursor: string | undefined, glyph: string, spinner: string | undefined, identity: string) => {
399
+ const [word = "", subject] = identity.split(" ▸ ");
400
+ const tail = subject ? ` ${theme.fg("dim", ACTIVE)} ${theme.fg("toolTitle", theme.bold(subject))}` : "";
401
+ // The spinner is re-emitted raw: the shared glyph colorizer below
402
+ // paints the braille range accent.
403
+ return `${cursor ?? ""}${theme.fg(slot, glyph)} ${spinner ? `${spinner} ` : ""}${theme.fg("accent", theme.bold(word))}${tail}`;
404
+ },
405
+ );
406
+ result = result.replace(DOCK_HINT, theme.fg("dim", DOCK_HINT));
330
407
  }
331
408
  // Shared glyph colorizer covers ALL status glyphs — including ⏳ (waiting),
332
409
  // ⚠ (needs_attention), and the braille spinner range ⠁-⣿ (running) — which the
333
410
  // previous local statusGlyphColor map + regex omitted (F-1, V-3).
334
- result = colorizeStatusGlyphs(result, theme);
335
- if (index === 0) {
336
- result = theme.fg("accent", result);
337
- }
338
- return result;
411
+ return colorizeStatusGlyphs(result, theme);
339
412
  }
340
413
 
341
414
  export function renderLines(lines: string[], width: number): string[] {
@@ -24,8 +24,6 @@ export interface CrewWidgetModel {
24
24
  /** P3 (#9): workspace/session ID for filtering runs by ownerSessionId
25
25
  * on every render, not just the first frame. */
26
26
  workspaceId?: string;
27
- /** Per-agent row layout; defaults to the historical two-line tree. */
28
- rowStyle?: import("./widget-renderer.ts").WidgetRowStyle;
29
27
  }
30
28
 
31
29
  export interface CrewWidgetState {
@@ -157,7 +157,7 @@ export function readLinesSince(filePath: string, state: IncrementalReadState): I
157
157
  totalRead += bytesRead;
158
158
  }
159
159
 
160
- const content = buf.toString("utf-8", 0, totalRead);
160
+ const content = buf.subarray(0, totalRead);
161
161
  const lines: string[] = [];
162
162
  let lineCount = state.lineCount;
163
163
  let committedOffset = state.byteOffset;
@@ -165,8 +165,16 @@ export function readLinesSince(filePath: string, state: IncrementalReadState): I
165
165
  let searchFrom = 0;
166
166
  let newlineIdx: number;
167
167
 
168
- while ((newlineIdx = content.indexOf("\n", searchFrom)) !== -1) {
169
- const lineText = content.slice(searchFrom, newlineIdx);
168
+ // F07 / RR-017: scan the BUFFER, not a decoded string. A string index is a
169
+ // CHARACTER index, so using it as a byte offset silently mis-commits the
170
+ // watermark on any multi-byte UTF-8 line (Vietnamese diacritics, emoji,
171
+ // CJK): the next read then restarts mid-line and produces a corrupt line
172
+ // (measured: a delta read of a 4-line file returned one `raw` garbage
173
+ // event plus the three real ones). Byte offsets are what the API promises,
174
+ // so `Buffer.indexOf(0x0a)` is the correct scan and decoding happens only
175
+ // per complete line.
176
+ while ((newlineIdx = content.indexOf(0x0a, searchFrom)) !== -1) {
177
+ const lineText = content.toString("utf-8", searchFrom, newlineIdx);
170
178
  committedOffset = state.byteOffset + newlineIdx + 1;
171
179
  searchFrom = newlineIdx + 1;
172
180
  if (lineText.length > 0) {
@@ -3,6 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { getCrewEnv } from "../config/env-vars.ts";
6
+ import { PROJECT_DIR_MARKERS, PROJECT_FILE_MARKERS } from "./project-markers.ts";
6
7
 
7
8
  // NEW-P1/NEW-P2 (perf): packageRoot() and userPiRoot() are invariant for a
8
9
  // process lifetime but each call did statSync/readFileSync/lstatSync (10-15+
@@ -111,17 +112,9 @@ export function userPiRoot(): string {
111
112
  return resolved;
112
113
  }
113
114
 
114
- const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
115
- const PROJECT_FILE_MARKERS = [
116
- "package.json",
117
- "pyproject.toml",
118
- "Cargo.toml",
119
- "go.mod",
120
- "pom.xml",
121
- "composer.json",
122
- "build.gradle",
123
- "build.gradle.kts",
124
- ];
115
+ // Marker lists live in ./project-markers.ts (RR-020 Fix 1) so that
116
+ // src/state/crew-init.ts:findProjectRoot() cannot drift from this resolver
117
+ // again — the drift produced two different project roots for one cwd.
125
118
 
126
119
  // 2.10 — cache findRepoRoot results so repeated lookups during render ticks
127
120
  // (loadConfig, state-store helpers, powerbar, snapshot-cache, ...) skip the
@@ -180,7 +173,14 @@ export function findRepoRoot(cwd: string): string | undefined {
180
173
  // newly-created test directories and shouldn't propagate as a crash.
181
174
  let startKey: string;
182
175
  try {
183
- startKey = fs.realpathSync(cwd);
176
+ // Canonicalize with .native (long-name form on Windows). The home/temp
177
+ // boundaries in computeRepoRoot are canonicalized with realpathSync.native
178
+ // too — a non-native start preserves 8.3 short names (RUNNER~1) on win32,
179
+ // so the walk chain NEVER textually matches the long-name boundary and
180
+ // escapes the sandbox, latching onto an ancestor marker (live CI: runs
181
+ // created under a sandboxed home landed in the REAL home's .pi/teams).
182
+ // On Linux/macOS native and non-native resolve identically.
183
+ startKey = fs.realpathSync.native(cwd);
184
184
  } catch (error) {
185
185
  if ((error as NodeJS.ErrnoException).code === "ENOENT") {
186
186
  startKey = path.resolve(cwd);
@@ -252,3 +252,85 @@ export function projectCrewRoot(cwd: string): string {
252
252
  export function userCrewRoot(): string {
253
253
  return path.join(userPiRoot(), "extensions", "pi-crew");
254
254
  }
255
+
256
+ /**
257
+ * Relative path of the run-state directory under a pi-crew root, per layout:
258
+ * - `.crew` → `<root>/.crew/state/runs` (classic layout)
259
+ * - `.pi/teams` → `<root>/.pi/teams/state/runs` (`.pi`-based layout)
260
+ * Mirrors `projectCrewRoot`'s layout choice, so scanners can recognise the
261
+ * SAME two layouts the rest of pi-crew writes.
262
+ */
263
+ export const RUN_STATE_RUNS_SUBPATHS = [path.join(".crew", "state", "runs"), path.join(".pi", "teams", "state", "runs")];
264
+
265
+ /** Layout dirs derived from RUN_STATE_RUNS_SUBPATHS — the single list (a
266
+ * literal copy here is exactly the drift Fix 1 killed for project markers). */
267
+ const RUN_STATE_LAYOUT_DIRS = RUN_STATE_RUNS_SUBPATHS.map((rel) => path.dirname(path.dirname(rel)));
268
+
269
+ /** Layout dirs split into components for the per-component symlink walk
270
+ * (cold-verify F-1: lstat only sees the FINAL path component, so for the
271
+ * `.pi/teams` layout the intermediate `.pi` was never checked). */
272
+ const RUN_STATE_LAYOUT_SEGMENTS = RUN_STATE_LAYOUT_DIRS.map((layout) => layout.split(/[\\/]+/).filter(Boolean));
273
+
274
+ /** True when `p` exists and is NOT a symlink (dir-or-file). A planted symlink
275
+ * never passes: scanners must reject rather than trust it. */
276
+ function existsNoSymlink(p: string): boolean {
277
+ try {
278
+ return !fs.lstatSync(p).isSymbolicLink();
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+
284
+ /** True when EVERY component of `path.join(dir, ...segments)` exists and is
285
+ * not a symlink. lstat rejects only the FINAL component, so intermediate
286
+ * symlinks must be walked component-by-component — cold-verify F-1 reproduced
287
+ * out-of-tree read AND write through `ws/.pi -> /outside` because `.pi` was
288
+ * intermediate in all three lstat calls of the earlier fix. */
289
+ function existsSymlinkFreePath(dir: string, segments: string[]): boolean {
290
+ let current = dir;
291
+ for (const seg of segments) {
292
+ current = path.join(current, seg);
293
+ if (!existsNoSymlink(current)) return false;
294
+ }
295
+ return true;
296
+ }
297
+
298
+ /**
299
+ * True when `dir` holds pi-crew run state under EITHER supported layout
300
+ * (`.crew/` or `.pi/teams/`) — used by /tmp debris scanners so a temp
301
+ * workspace with live `.pi/teams/state/runs` state is not deleted.
302
+ *
303
+ * A SYMLINKED layout dir never counts: scanners must be able to reject a
304
+ * planted symlink instead of treating it as protection.
305
+ */
306
+ export function hasRunStateLayout(dir: string): boolean {
307
+ for (const segments of RUN_STATE_LAYOUT_SEGMENTS) {
308
+ if (existsSymlinkFreePath(dir, segments)) return true;
309
+ }
310
+ return false;
311
+ }
312
+
313
+ /**
314
+ * Resolve the run-state dir (`<layout>/state/runs`) that actually exists under
315
+ * `dir`, checking BOTH supported layouts. Returns undefined when neither
316
+ * layout has a runs dir (i.e. `dir` holds no pi-crew run state).
317
+ *
318
+ * Symlink policy matches `hasRunStateLayout` (cold-verify correction: the
319
+ * first version used existsSync, which FOLLOWS symlinks — a planted
320
+ * `.crew`/`.pi/teams`/`state/runs` symlink let stale-reconciler and
321
+ * health-monitor read — and reconcile — run manifests OUTSIDE the scanned
322
+ * tree). Both the layout dir and the runs dir itself must be symlink-free.
323
+ */
324
+ export function findRunStateDir(dir: string): string | undefined {
325
+ for (const segments of RUN_STATE_LAYOUT_SEGMENTS) {
326
+ // Cold-verify F-1/F-A: EVERY component must be symlink-free — lstat only
327
+ // sees the final path component, so intermediate symlinks (`.pi` of the
328
+ // `.pi/teams` layout, the middle `state`, …) were traversed transparently
329
+ // and let scanners READ and WRITE run manifests OUTSIDE the scanned tree
330
+ // (reproduced: reconcileOrphanedTempWorkspaces flipped an outside manifest
331
+ // running→cancelled through both a `.crew/state` and a `.pi` symlink).
332
+ if (!existsSymlinkFreePath(dir, [...segments, "state", "runs"])) continue;
333
+ return path.join(dir, ...segments, "state", "runs");
334
+ }
335
+ return undefined;
336
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * project-markers.ts — SINGLE source of truth for the project-root marker lists.
3
+ *
4
+ * Why this module exists (RR-020 Fix 1): the marker lists were duplicated and
5
+ * had DRIFTED apart:
6
+ * - `src/utils/paths.ts` (computeRepoRoot) used 15 markers (.pi/.crew/
7
+ * .factory/.omc + 8 build-file markers)
8
+ * - `src/state/crew-init.ts` (findProjectRoot) used 7 markers (.git/.hg/.svn
9
+ * + package.json/pyproject.toml/Cargo.toml/go.mod)
10
+ * For cwd=`parent/subproject` where `parent/.git` and `parent/subproject/.pi`
11
+ * exist, paths.ts resolved the root to `subproject` (→ `subproject/.pi/teams`)
12
+ * while crew-init.ts walked past `.pi` up to `parent/.git` (→ `parent/.crew`),
13
+ * so ONE run could end up with TWO roots (`run-intent.ts` → ensureCrewDirectory).
14
+ * Both resolvers now read the lists from here.
15
+ *
16
+ * CONTRACT — do not break:
17
+ * - NO imports at all (plain string[] literals only). `crew-init.ts` is
18
+ * dynamically `import()`'d from concurrent child Pi subprocesses and under
19
+ * load jiti's ESM/CJS interop can leave namespace bindings `undefined`
20
+ * (issue #28). Importing anything here (even `node:path`) would re-introduce
21
+ * that race for the very module that was hardened against it.
22
+ * - Values must stay IDENTICAL to the historical `paths.ts` lists — they are
23
+ * the widest set, so unifying on them never resolves a root LOWER than
24
+ * before (the direction that would break existing projects).
25
+ */
26
+
27
+ /** Directory names that mark a project root. */
28
+ export const PROJECT_DIR_MARKERS = [".git", ".pi", ".crew", ".hg", ".svn", ".factory", ".omc"];
29
+
30
+ /** Build-system file names that mark a project root. */
31
+ export const PROJECT_FILE_MARKERS = [
32
+ "package.json",
33
+ "pyproject.toml",
34
+ "Cargo.toml",
35
+ "go.mod",
36
+ "pom.xml",
37
+ "composer.json",
38
+ "build.gradle",
39
+ "build.gradle.kts",
40
+ ];
@@ -151,10 +151,6 @@ export function pad(value: string, width: number): string {
151
151
  return `${value}${" ".repeat(width - current)}`;
152
152
  }
153
153
 
154
- export function boxLine(text: string, innerWidth: number): string {
155
- return `│ ${truncate(text, innerWidth - 4)} │`;
156
- }
157
-
158
154
  function readAnsiCode(input: string, index: number): string | undefined {
159
155
  const ansiLength = consumeAnsi(input, index);
160
156
  if (ansiLength > 0) return input.slice(index, index + ansiLength);