pi-crew 0.9.68 → 0.10.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 (253) hide show
  1. package/CHANGELOG.md +222 -0
  2. package/NOTICE.md +21 -0
  3. package/README.md +44 -2
  4. package/agents/analyst.md +1 -1
  5. package/agents/cold-verifier.md +3 -1
  6. package/agents/critic.md +1 -1
  7. package/agents/executor.md +1 -1
  8. package/agents/explorer.md +1 -1
  9. package/agents/planner.md +1 -1
  10. package/agents/reviewer.md +1 -1
  11. package/agents/security-reviewer.md +1 -1
  12. package/agents/test-engineer.md +1 -1
  13. package/agents/verifier.md +1 -1
  14. package/agents/writer.md +1 -1
  15. package/dist/index.mjs +68113 -60774
  16. package/docs/README.md +2 -0
  17. package/docs/actions-reference.md +31 -0
  18. package/docs/commands-reference.md +17 -6
  19. package/docs/resource-formats.md +13 -0
  20. package/package.json +4 -2
  21. package/schema.json +503 -91
  22. package/scripts/resource-sampler.mjs +36 -2
  23. package/skills/requirements-to-task-packet/SKILL.md +26 -0
  24. package/skills/widget-rendering/SKILL.md +7 -7
  25. package/src/agents/agent-config.ts +2 -1
  26. package/src/agents/discover-agents.ts +23 -14
  27. package/src/config/config-merge.ts +183 -0
  28. package/src/config/config-validation.ts +687 -0
  29. package/src/config/config.ts +22 -864
  30. package/src/config/defaults.ts +43 -2
  31. package/src/config/drift-detector.ts +1 -1
  32. package/src/config/env-vars.ts +691 -0
  33. package/src/config/role-tools.ts +11 -9
  34. package/src/config/sanitize-project-config.ts +172 -0
  35. package/src/config/types.ts +49 -1
  36. package/src/extension/async-notifier.ts +25 -2
  37. package/src/extension/crew-cleanup.ts +13 -0
  38. package/src/extension/crew-vibes/config.ts +2 -1
  39. package/src/extension/crew-vibes/footer.ts +19 -0
  40. package/src/extension/crew-vibes/index.ts +11 -1
  41. package/src/extension/plan-orchestrate.ts +132 -0
  42. package/src/extension/register.ts +8 -0
  43. package/src/extension/registration/command-registration.ts +1 -0
  44. package/src/extension/registration/commands/dashboard.ts +158 -0
  45. package/src/extension/registration/commands/index.ts +35 -0
  46. package/src/extension/registration/commands/manage.ts +303 -0
  47. package/src/extension/registration/commands/run.ts +228 -0
  48. package/src/extension/registration/commands/shared.ts +639 -0
  49. package/src/extension/registration/commands/status.ts +60 -0
  50. package/src/extension/registration/commands.ts +13 -1224
  51. package/src/extension/registration/foreground-run-controller.ts +10 -2
  52. package/src/extension/registration/lifecycle-handlers.ts +178 -17
  53. package/src/extension/registration/runtime-cleanup.ts +23 -5
  54. package/src/extension/registration/subagent-tools.ts +218 -9
  55. package/src/extension/registration/team-tool.ts +5 -1
  56. package/src/extension/registration/ui.ts +5 -4
  57. package/src/extension/rpc-hmac.ts +5 -3
  58. package/src/extension/team-tool/api/heartbeat.ts +47 -10
  59. package/src/extension/team-tool/api/plan-approval.ts +9 -0
  60. package/src/extension/team-tool/api/task-claims.ts +109 -40
  61. package/src/extension/team-tool/cancel.ts +84 -50
  62. package/src/extension/team-tool/dispatch/index.ts +1 -0
  63. package/src/extension/team-tool/dispatch/run.ts +4 -1
  64. package/src/extension/team-tool/doctor.ts +103 -1
  65. package/src/extension/team-tool/orchestrate.ts +66 -1
  66. package/src/extension/team-tool/plans.ts +192 -0
  67. package/src/extension/team-tool/respond.ts +197 -65
  68. package/src/extension/team-tool/run-deadline.ts +35 -3
  69. package/src/extension/team-tool/run-intent.ts +63 -0
  70. package/src/extension/team-tool/run.ts +74 -20
  71. package/src/extension/team-tool/status.ts +84 -26
  72. package/src/extension/team-tool.ts +11 -2
  73. package/src/hooks/registry.ts +1 -6
  74. package/src/i18n.ts +9 -0
  75. package/src/prompt/prompt-runtime.ts +521 -2
  76. package/src/prompt/worker-events-channel.ts +173 -0
  77. package/src/runtime/README.md +8 -8
  78. package/src/runtime/async-runner.ts +7 -3
  79. package/src/runtime/background-runner.ts +42 -14
  80. package/src/runtime/broker/broker-issuer.ts +9 -2
  81. package/src/runtime/broker/crew-broker-tokens.ts +43 -6
  82. package/src/runtime/broker/crew-broker.ts +838 -10
  83. package/src/runtime/broker/wait-status-cache.ts +157 -0
  84. package/src/runtime/budget-enforcement.ts +281 -0
  85. package/src/runtime/child-pi/child-pi-constants.ts +8 -0
  86. package/src/runtime/child-pi/child-pi-spawn.ts +60 -14
  87. package/src/runtime/child-pi/child-pi-streams.ts +21 -1
  88. package/src/runtime/child-pi/child-pi-timers.ts +324 -0
  89. package/src/runtime/child-pi/child-pi.ts +97 -201
  90. package/src/runtime/child-pi/mock-fixtures.ts +16 -2
  91. package/src/runtime/crew-agent-records.ts +259 -14
  92. package/src/runtime/delegate-spawn.ts +148 -0
  93. package/src/runtime/detached-run-results.ts +90 -0
  94. package/src/runtime/deterministic-ast.ts +2 -1
  95. package/src/runtime/dispatch-batch.ts +945 -0
  96. package/src/runtime/finalize-run.ts +557 -0
  97. package/src/runtime/goal-workflow/adaptive-plan.ts +116 -15
  98. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +8 -3
  99. package/src/runtime/goal-workflow/goal-state-store.ts +1 -1
  100. package/src/runtime/group-join.ts +11 -125
  101. package/src/runtime/live-session/live-session-runtime.ts +26 -1
  102. package/src/runtime/merge-gate.ts +32 -10
  103. package/src/runtime/merge-loop.ts +130 -0
  104. package/src/runtime/model/model-budget-summary.ts +53 -0
  105. package/src/runtime/model/model-fallback.ts +36 -2
  106. package/src/runtime/model/pi-args.ts +10 -0
  107. package/src/runtime/model/provider-extensions.ts +10 -0
  108. package/src/runtime/orphan-worker-registry.ts +1 -1
  109. package/src/runtime/output/output-validator.ts +45 -0
  110. package/src/runtime/parent-guard.ts +3 -1
  111. package/src/runtime/peer-dep.ts +2 -1
  112. package/src/runtime/per-write-validator.ts +0 -5
  113. package/src/runtime/pi-spawn.ts +61 -15
  114. package/src/runtime/plan-approval.ts +125 -0
  115. package/src/runtime/plan-replan.ts +151 -0
  116. package/src/runtime/process-status.ts +16 -1
  117. package/src/runtime/recovery/checkpoint.ts +0 -18
  118. package/src/runtime/recovery/crash-recovery.ts +111 -46
  119. package/src/runtime/run-tracker.ts +77 -10
  120. package/src/runtime/scheduler-context.ts +98 -0
  121. package/src/runtime/scheduling/coalesce-tasks.ts +5 -0
  122. package/src/runtime/scheduling/global-worker-cap.ts +2 -1
  123. package/src/runtime/scheduling/nested-slots.ts +70 -0
  124. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -13
  125. package/src/runtime/scheduling/task-graph-scheduler.ts +0 -10
  126. package/src/runtime/settings-store.ts +219 -0
  127. package/src/runtime/spawn-policy.ts +217 -0
  128. package/src/runtime/stale-reconciler.ts +87 -6
  129. package/src/runtime/subagent-manager.ts +25 -1
  130. package/src/runtime/task-output-context.ts +230 -9
  131. package/src/runtime/task-packet.ts +23 -1
  132. package/src/runtime/task-runner/child-executor.ts +106 -7
  133. package/src/runtime/task-runner/post-execution.ts +125 -1
  134. package/src/runtime/task-runner/pre-execution.ts +39 -1
  135. package/src/runtime/task-runner/prompt-builder.ts +51 -1
  136. package/src/runtime/task-runner/retrieval-orchestrator.ts +72 -18
  137. package/src/runtime/task-runner/spec-evidence.ts +403 -0
  138. package/src/runtime/task-runner/state-helpers.ts +26 -24
  139. package/src/runtime/task-runner.ts +11 -0
  140. package/src/runtime/team-runner.ts +132 -1673
  141. package/src/runtime/verification/spec-sandbox.ts +255 -0
  142. package/src/runtime/verification/verification-gates.ts +3 -2
  143. package/src/runtime/verification/verification-worktree.ts +2 -1
  144. package/src/runtime/workflow-phase-advance.ts +100 -0
  145. package/src/runtime/workspace-tree.ts +9 -0
  146. package/src/schema/config-schema.ts +66 -26
  147. package/src/schema/sensitive-config-paths.ts +64 -0
  148. package/src/schema/team-tool-schema.ts +13 -3
  149. package/src/state/README.md +4 -10
  150. package/src/state/atomic-write.ts +20 -3
  151. package/src/state/contracts.ts +38 -0
  152. package/src/state/coordination/mailbox.ts +12 -2
  153. package/src/state/event-log/cursor.ts +223 -0
  154. package/src/state/event-log/event-log-rotation.ts +12 -4
  155. package/src/state/event-log/event-log.ts +152 -369
  156. package/src/state/event-log/sequence-cache.ts +373 -0
  157. package/src/state/event-log/worker-atomic-writer.ts +2 -1
  158. package/src/state/stores/active-run-registry.ts +3 -2
  159. package/src/state/stores/manifest-io.ts +237 -0
  160. package/src/state/stores/ownership-map.ts +162 -0
  161. package/src/state/stores/plan-store.ts +241 -0
  162. package/src/state/stores/run-cache.ts +0 -90
  163. package/src/state/stores/spec-store.ts +189 -0
  164. package/src/state/stores/state-store.ts +139 -232
  165. package/src/state/types.ts +199 -0
  166. package/src/ui/dashboard-panes/plan-pane.ts +136 -0
  167. package/src/ui/dashboard-panes/progress-pane.ts +6 -0
  168. package/src/ui/dashboard-panes/transcript-pane.ts +31 -0
  169. package/src/ui/dock-footer.ts +49 -0
  170. package/src/ui/heartbeat-aggregator.ts +9 -1
  171. package/src/ui/inline-panel/agent-pane.ts +375 -0
  172. package/src/ui/inline-panel/agent-transcript.ts +338 -0
  173. package/src/ui/inline-panel/agent-view-overlay.ts +225 -0
  174. package/src/ui/inline-panel/crew-editor.ts +192 -0
  175. package/src/ui/inline-panel/index.ts +290 -0
  176. package/src/ui/inline-panel/panel-rows.ts +37 -0
  177. package/src/ui/inline-panel/panel-selection.ts +157 -0
  178. package/src/ui/inline-panel/panel-store.ts +111 -0
  179. package/src/ui/inline-panel/view-session-store.ts +36 -0
  180. package/src/ui/keybinding-map.ts +54 -13
  181. package/src/ui/pi-ui-compat.ts +9 -0
  182. package/src/ui/powerbar-publisher.ts +52 -1
  183. package/src/ui/run-dashboard.ts +31 -5
  184. package/src/ui/run-snapshot-cache.ts +57 -30
  185. package/src/ui/snapshot-types.ts +6 -1
  186. package/src/ui/widget/index.ts +176 -22
  187. package/src/ui/widget/task-list.ts +198 -0
  188. package/src/ui/widget/widget-formatters.ts +240 -4
  189. package/src/ui/widget/widget-renderer.ts +243 -38
  190. package/src/ui/widget/widget-types.ts +11 -0
  191. package/src/utils/child-process-shield.ts +106 -0
  192. package/src/utils/file-coalescer.ts +0 -4
  193. package/src/utils/fs-errno.ts +66 -0
  194. package/src/utils/fs-watch.ts +1 -1
  195. package/src/utils/internal-error.ts +3 -1
  196. package/src/utils/paths.ts +11 -3
  197. package/src/utils/redaction.ts +7 -0
  198. package/src/utils/safe-abort.ts +45 -0
  199. package/src/utils/task-name-generator.ts +1 -8
  200. package/src/workflows/discover-workflows.ts +20 -2
  201. package/src/workflows/validate-workflow.ts +7 -1
  202. package/src/workflows/workflow-config.ts +17 -0
  203. package/src/workflows/workflow-serializer.ts +3 -0
  204. package/src/worktree/worktree-manager.ts +22 -0
  205. package/workflows/default.workflow.md +36 -26
  206. package/workflows/strict-fast-fix.workflow.md +26 -0
  207. package/src/agents/agent-search.ts +0 -98
  208. package/src/benchmark/benchmark-runner.ts +0 -313
  209. package/src/benchmark/feedback-loop.ts +0 -73
  210. package/src/config/resilient-parser.ts +0 -117
  211. package/src/extension/crew-vibes/cat-frames.ts +0 -18
  212. package/src/extension/result-watcher.ts +0 -139
  213. package/src/observability/exporters/prometheus-exporter.ts +0 -54
  214. package/src/observability/metric-retention.ts +0 -64
  215. package/src/runtime/compaction/compaction-summary.ts +0 -278
  216. package/src/runtime/errors/crew-errors.ts +0 -162
  217. package/src/runtime/live-session/intercom-bridge.ts +0 -187
  218. package/src/runtime/loop-gates.ts +0 -128
  219. package/src/runtime/metric-parser.ts +0 -36
  220. package/src/runtime/output/stream-preview.ts +0 -184
  221. package/src/runtime/output/tool-progress.ts +0 -278
  222. package/src/runtime/phase-tracker.ts +0 -385
  223. package/src/runtime/pipeline-runner.ts +0 -523
  224. package/src/runtime/process/process-lifecycle.ts +0 -491
  225. package/src/runtime/recovery/retry-runner.ts +0 -330
  226. package/src/runtime/run-drift.ts +0 -219
  227. package/src/runtime/task-quality.ts +0 -199
  228. package/src/runtime/task-runner/run-projection.ts +0 -128
  229. package/src/runtime/verification/post-checks.ts +0 -142
  230. package/src/state/coordination/schedule.ts +0 -166
  231. package/src/state/event-log/jsonl-writer.ts +0 -115
  232. package/src/state/hook-instinct-bridge.ts +0 -94
  233. package/src/state/hook-integrations.ts +0 -51
  234. package/src/state/session-state-map.ts +0 -51
  235. package/src/state/stores/blob-store.ts +0 -308
  236. package/src/state/stores/instinct-store.ts +0 -275
  237. package/src/state/stores/observation-store.ts +0 -176
  238. package/src/state/tiered-eval.ts +0 -480
  239. package/src/state/types-eval.ts +0 -58
  240. package/src/tools/safe-bash-extension.ts +0 -54
  241. package/src/tools/safe-bash.ts +0 -505
  242. package/src/ui/agent-management-overlay.ts +0 -160
  243. package/src/ui/crew-footer.ts +0 -102
  244. package/src/ui/crew-select-list.ts +0 -114
  245. package/src/ui/dashboard-panes/capability-pane.ts +0 -77
  246. package/src/ui/transcript-entries.ts +0 -256
  247. package/src/utils/conflict-detect.ts +0 -721
  248. package/src/utils/fingerprint.ts +0 -180
  249. package/src/utils/gh-protocol.ts +0 -556
  250. package/src/utils/project-detector.ts +0 -160
  251. package/src/utils/sse-parser.ts +0 -131
  252. package/src/workflows/cost-estimator.ts +0 -34
  253. package/src/workflows/intermediate-store.ts +0 -166
@@ -4,18 +4,30 @@
4
4
  * Extracted from crew-widget.ts.
5
5
  */
6
6
 
7
+ import type { CrewAgentRecord } from "../../runtime/crew-agent-runtime.ts";
7
8
  import { listLiveAgents } from "../../runtime/live-session/live-agent-manager.ts";
9
+ import { isPlanApprovalStatePending } from "../../runtime/plan-approval.ts";
8
10
  import { isFinishedRunStatus } from "../../runtime/process-status.ts";
11
+ import type { TeamRunManifest } from "../../state/types.ts";
9
12
  import { truncate } from "../../utils/visual.ts";
10
13
  import { Box, Text } from "../layout-primitives.ts";
11
14
  import { spinnerFrame } from "../spinner.ts";
12
15
  import { colorizeStatusGlyphs, iconForStatus } from "../status-colors.ts";
13
16
  import type { CrewTheme } from "../theme-adapter.ts";
14
- import { agentActivity, agentStats, notificationBadge } from "./widget-formatters.ts";
17
+ import {
18
+ agentActivity,
19
+ agentStats,
20
+ budgetedRow,
21
+ dockElapsed,
22
+ dockStatusIcon,
23
+ dockStatusLabel,
24
+ dockUsageText,
25
+ notificationBadge,
26
+ } from "./widget-formatters.ts";
15
27
  import { activeWidgetRuns, shortRunLabel } from "./widget-model.ts";
16
28
  import type { WidgetRun } from "./widget-types.ts";
17
29
 
18
- const MAX_AGENTS_DISPLAY = 3;
30
+ export const MAX_AGENTS_DISPLAY = 3;
19
31
  const FINISHED_LINGER_MAX_AGE = 1;
20
32
  /** Default terminal width when caller doesn't pass one explicitly. Keep <= 116
21
33
  * (the same default used elsewhere in pi-crew tool renderers) so we never paint
@@ -42,8 +54,192 @@ export function widgetHeader(runs: WidgetRun[], runningGlyph: string, maxLines =
42
54
  return `${runningGlyph} Crew agents${notificationBadge(notificationCount)} · ${parts.join(" · ")} · /team-dashboard`;
43
55
  }
44
56
 
57
+ // ── Agent ordering (shared with the inline panel) ──────────────────────
58
+
59
+ /**
60
+ * L-4: prioritize RUNNING > QUEUED > WAITING so the most relevant live workers
61
+ * are always shown first. Finished rows fill only the leftover budget and never
62
+ * steal a slot from an active agent.
63
+ */
64
+ const ACTIVE_PRIORITY: Record<string, number> = { running: 0, queued: 1, waiting: 2 };
65
+
66
+ function isActiveStatus(status: string): boolean {
67
+ return status === "running" || status === "queued" || status === "waiting";
68
+ }
69
+
70
+ /**
71
+ * The agent order the widget paints, split into its two sections.
72
+ *
73
+ * Exported because the inline panel navigates the same list: if the panel
74
+ * derived its own order, the cursor index would drift from the rendered rows.
75
+ * One function, one order.
76
+ */
77
+ export function orderWidgetAgents(entry: WidgetRun, now = Date.now()): { active: CrewAgentRecord[]; finished: CrewAgentRecord[] } {
78
+ const runDone = isFinishedRunStatus(entry.run.status);
79
+ const active = entry.agents.filter((agent) => isActiveStatus(agent.status));
80
+ const finished = entry.agents.filter((agent) => {
81
+ if (isActiveStatus(agent.status)) return false;
82
+ if (!agent.completedAt) return false;
83
+ // Mid-run, finished agents are the run's HISTORY: they must stay in
84
+ // the dock until the RUN itself is done, not age out after a minute
85
+ // while later phases are still working. The linger windows below only
86
+ // apply once the run reached a terminal status (and the run-level
87
+ // visibility grace then decides how much longer the dock shows at all).
88
+ if (!runDone) return true;
89
+ const maxAgeMs = (ERROR_STATUSES.has(agent.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
90
+ const age = now - new Date(agent.completedAt).getTime();
91
+ return Number.isFinite(age) && age < maxAgeMs;
92
+ });
93
+ return {
94
+ active: [...active].sort((a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9)),
95
+ finished,
96
+ };
97
+ }
98
+
45
99
  // ── Line builder ──────────────────────────────────────────────────────
46
100
 
101
+ /**
102
+ * Row layout for the per-agent lines.
103
+ *
104
+ * - `detailed` — the historical two-line tree (name row + `⊶ activity` row).
105
+ * - `compact` — one width-budgeted line per agent, so a wide terminal shows the
106
+ * full description instead of the same clip a narrow one gets.
107
+ */
108
+ export type WidgetRowStyle = "compact" | "detailed";
109
+
110
+ export interface WidgetRenderOptions {
111
+ rowStyle?: WidgetRowStyle;
112
+ /** Task id under the inline panel cursor, if any. */
113
+ selectedTaskId?: string;
114
+ /** Task id whose transcript pane is open, if any. */
115
+ viewedTaskId?: string;
116
+ /**
117
+ * True while the inline panel holds the cursor. Every agent is then listed
118
+ * (no MAX_AGENTS_DISPLAY cap) so keyboard navigation can reach all of them;
119
+ * the idle widget stays capped to keep the prompt area small.
120
+ */
121
+ focused?: boolean;
122
+ }
123
+
124
+ /** Short display form of a model id: `zai/glm-5.3` → `glm-5.3`. */
125
+ function shortModelLabel(agent: CrewAgentRecord, run: TeamRunManifest): string | undefined {
126
+ const model = agent.model ?? run.modelContext?.parentModel ?? run.modelContext?.override;
127
+ if (typeof model !== "string" || !model) return undefined;
128
+ return model.split("/").at(-1) ?? model;
129
+ }
130
+
131
+ /** One flat dock row (pi-subtask style) for an agent — active or finished. */
132
+ function compactAgentRow(
133
+ run: TeamRunManifest,
134
+ agent: CrewAgentRecord,
135
+ finished: boolean,
136
+ runs: readonly WidgetRun[],
137
+ options: WidgetRenderOptions,
138
+ width: number,
139
+ liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined,
140
+ ): string {
141
+ const marker = options.selectedTaskId === agent.taskId ? "❯" : " ";
142
+ const dockGlyph = options.viewedTaskId === agent.taskId ? "⏺" : dockStatusIcon(agent.status);
143
+ const name = liveHandle?.agent ?? agent.agent;
144
+ const label = liveHandle?.description ?? agent.role ?? "";
145
+ // Task-first: the agent exists to run its task, so the row names the task
146
+ // right after the agent. With multiple runs, prefix each row with its run
147
+ // label so the flat dock still says which run an agent belongs to.
148
+ const runTag = runs.length > 1 ? `${shortRunLabel(run)} · ` : "";
149
+ const taskTag = agent.taskId ? ` · ${agent.taskId}` : "";
150
+ const roleTag = label && label !== agent.taskId && label !== name ? ` · ${label}` : "";
151
+ const nameText = runTag + name + taskTag + roleTag;
152
+ // pi-subtask activity: the worker's latest line while running, otherwise
153
+ // the status word.
154
+ const liveLine = liveHandle?.activity?.responseText
155
+ ?.split("\n")
156
+ .find((line) => line.trim())
157
+ ?.trim();
158
+ const activity =
159
+ !finished && liveHandle?.status === "running" && liveLine
160
+ ? liveLine.length > 60
161
+ ? `${liveLine.slice(0, 60)}…`
162
+ : liveLine
163
+ : finished
164
+ ? dockStatusLabel(agent.status)
165
+ : agentActivity(agent, liveHandle);
166
+ const usage = dockUsageText(agent, liveHandle, { viewed: options.viewedTaskId === agent.taskId });
167
+ const ageText = dockElapsed(agent.completedAt ?? agent.startedAt);
168
+ const model = shortModelLabel(agent, run);
169
+ // Stats tail: `· glm-5.3 · ↑1.2k ↓350 · 41s` — the model the worker is
170
+ // actually on first, then usage, then elapsed.
171
+ const suffix = `${model ? ` · ${model}` : ""}${usage ? ` · ${usage}` : ""}${ageText ? ` · ${ageText}` : ""}`;
172
+ return budgetedRow({ lead: `${marker} ${dockGlyph} `, name: nameText, activity, suffix }, width);
173
+ }
174
+
175
+ /**
176
+ * The compact dock: hint line, the `main` conversation row, then a
177
+ * MAX_AGENTS_DISPLAY-row SCROLL WINDOW over the flat agent list (exactly the
178
+ * order the inline panel navigates — `panelRowsFromRuns` parity). The window
179
+ * follows the panel selection bottom-pinned: moving the cursor past the
180
+ * window scrolls one row at a time and the ❯ marker is always painted; idle
181
+ * (no selection) shows the top of the list, which the shared ordering puts at
182
+ * the highest-priority live agents. Hidden rows surface as `… ↑N earlier` /
183
+ * `… +N more` indicators.
184
+ */
185
+ function compactDockLines(
186
+ runs: WidgetRun[],
187
+ options: WidgetRenderOptions,
188
+ width: number,
189
+ maxLines: number,
190
+ notificationCount: number,
191
+ runningGlyph: string,
192
+ ): string[] {
193
+ const now = Date.now();
194
+ const flat: Array<{
195
+ run: TeamRunManifest;
196
+ agent: CrewAgentRecord;
197
+ finished: boolean;
198
+ liveHandle: ReturnType<typeof listLiveAgents>[number] | undefined;
199
+ }> = [];
200
+ for (const entry of runs) {
201
+ const { active, finished } = orderWidgetAgents(entry, now);
202
+ const liveForRun = listLiveAgents().filter((a) => a.runId === entry.run.runId);
203
+ for (const agent of active) {
204
+ flat.push({ run: entry.run, agent, finished: false, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
205
+ }
206
+ for (const agent of finished) {
207
+ flat.push({ run: entry.run, agent, finished: true, liveHandle: liveForRun.find((h) => h.taskId === agent.taskId) });
208
+ }
209
+ }
210
+
211
+ // No agents at all: fall back to the legacy header so the space under the
212
+ // editor is never just blank.
213
+ if (flat.length === 0) return [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
214
+
215
+ const lines: string[] = [];
216
+ let hint: string;
217
+ if (options.viewedTaskId) {
218
+ const viewedName = flat.find((row) => row.agent.taskId === options.viewedTaskId)?.agent.agent ?? "agent";
219
+ hint = `viewing @${viewedName} — typing goes to the agent · ↓ switch · esc back`;
220
+ } else if (options.focused) {
221
+ hint = "enter to view · x to stop/cancel · esc back";
222
+ } else {
223
+ hint = `agents (${flat.length}) — ↓ to select`;
224
+ }
225
+ lines.push(truncate(hint, width));
226
+ // Filled ● = you're on the main conversation; hollow ◯ = an agent view is
227
+ // open (same convention as pi-subtask's main row).
228
+ const mainMarker = options.focused && !options.selectedTaskId ? "❯" : " ";
229
+ const mainIcon = options.viewedTaskId ? "◯" : "●";
230
+ lines.push(truncate(`${mainMarker} ${mainIcon} main`, width));
231
+
232
+ const selectedIndex = options.selectedTaskId ? flat.findIndex((row) => row.agent.taskId === options.selectedTaskId) : -1;
233
+ const windowStart = selectedIndex >= 0 ? Math.max(0, selectedIndex - MAX_AGENTS_DISPLAY + 1) : 0;
234
+ const windowEnd = Math.min(flat.length, windowStart + MAX_AGENTS_DISPLAY);
235
+ if (windowStart > 0) lines.push(truncate(` … ↑${windowStart} earlier (↑ to scroll)`, width));
236
+ for (const row of flat.slice(windowStart, windowEnd)) {
237
+ lines.push(compactAgentRow(row.run, row.agent, row.finished, runs, options, width, row.liveHandle));
238
+ }
239
+ if (windowEnd < flat.length) lines.push(truncate(` … +${flat.length - windowEnd} more (↓ to scroll)`, width));
240
+ return lines;
241
+ }
242
+
47
243
  export function buildWidgetLines(
48
244
  cwd: string,
49
245
  frame = 0,
@@ -51,7 +247,10 @@ export function buildWidgetLines(
51
247
  providedRuns?: WidgetRun[],
52
248
  notificationCount = 0,
53
249
  width = DEFAULT_WIDGET_WIDTH,
250
+ options: WidgetRenderOptions = {},
54
251
  ): string[] {
252
+ const rowStyle: WidgetRowStyle = options.rowStyle ?? "detailed";
253
+ const focused = options.focused === true;
55
254
  // Match the legacy `buildCrewWidgetLines` API: when no runs are supplied,
56
255
  // auto-fetch via activeWidgetRuns(cwd). Otherwise widgets calling with
57
256
  // only `(cwd, frame)` would render an empty line set (regression vs. the
@@ -60,20 +259,29 @@ export function buildWidgetLines(
60
259
  if (!runs.length) return [];
61
260
 
62
261
  const runningGlyph = spinnerFrame("widget-header");
262
+
263
+ // Compact = pi-subtask's dock: NO "Crew agents" header, NO tree — hint
264
+ // line, `main` row, then a 3-row scroll window over the flat agent list.
265
+ if (rowStyle === "compact") {
266
+ const lines = compactDockLines(runs, options, width, maxLines, notificationCount, runningGlyph);
267
+ return focused ? lines : lines.slice(0, maxLines);
268
+ }
269
+
63
270
  const lines: string[] = [widgetHeader(runs, runningGlyph, maxLines, notificationCount)];
64
271
 
65
- for (const { run, agents, snapshot } of runs) {
66
- const activeAgents = agents.filter((a) => a.status === "running" || a.status === "queued" || a.status === "waiting");
272
+ for (const entry of runs) {
273
+ const { run, agents } = entry;
67
274
  const now = Date.now();
68
- const finishedAgents = agents.filter((item) => {
69
- if (item.status === "running" || item.status === "queued" || item.status === "waiting") return false;
70
- if (!item.completedAt) return false;
71
- const maxAgeMs = (ERROR_STATUSES.has(item.status) ? ERROR_LINGER_MAX_AGE : FINISHED_LINGER_MAX_AGE) * 60_000;
72
- const age = now - new Date(item.completedAt).getTime();
73
- return Number.isFinite(age) && age < maxAgeMs;
74
- });
275
+ const { active: activeAgents, finished: finishedAgents } = orderWidgetAgents(entry, now);
75
276
  const completed = agents.filter((a) => a.status === "completed").length;
76
- const runGlyph = iconForStatus(run.status, { runningGlyph });
277
+ // WP-3 (H4): while a run is parked awaiting plan approval, the spinner
278
+ // glyph is replaced by a `⚠ plan:<last-8 runId>` badge. Plain-unicode ⚠
279
+ // (the same glyph needs_attention already uses) stays legible in no-color
280
+ // mode and is colorized by the shared colorizeStatusGlyphs pass. In-place
281
+ // glyph swap only — no extra line, so the MAX_AGENTS_DISPLAY / maxLines
282
+ // budget and the truncate(width) rule are untouched.
283
+ const planPending = isPlanApprovalStatePending(run.planApproval);
284
+ const runGlyph = planPending ? `⚠ plan:${run.runId.slice(-8)}` : iconForStatus(run.status, { runningGlyph });
77
285
  const isTerminal = isFinishedRunStatus(run.status);
78
286
  // Run progress line. v1–v3 flickered on snapshot.tasks state, v4 was
79
287
  // too minimal (`0/1 agents` only), v5 duplicated the worker activity
@@ -82,11 +290,6 @@ export function buildWidgetLines(
82
290
  // activity line) and is GUARANTEED stable across ticks:
83
291
  // - agents count — from `agents` array, always populated, never empty.
84
292
  // - run elapsed — from `run.createdAt`, always set on manifest.
85
- // Both come from sources with no race window — `agents` is read from
86
- // snapshot.agents OR agentsFor(run) (both always return same length
87
- // for a healthy run), and `run.createdAt` is immutable. The format
88
- // shape `"X/Y agents · Ns"` is therefore truly invariant: same number
89
- // of `·`-separated fields, same field meanings, every render tick.
90
293
  //
91
294
  // Bug 022 (timer-fix + label): for TERMINAL runs (failed/cancelled/
92
295
  // completed) the elapsed counter previously kept ticking up forever
@@ -104,56 +307,58 @@ export function buildWidgetLines(
104
307
 
105
308
  const liveForRun = listLiveAgents().filter((a) => a.runId === run.runId);
106
309
 
107
- // L-4: prioritize RUNNING > QUEUED > WAITING within the visible window so the
108
- // most relevant live workers are always shown. Finished rows fill only the
109
- // leftover budget and never steal slots from running agents.
110
- const ACTIVE_PRIORITY: Record<string, number> = {
111
- running: 0,
112
- queued: 1,
113
- waiting: 2,
114
- };
115
- const prioritizedActive = [...activeAgents].sort((a, b) => (ACTIVE_PRIORITY[a.status] ?? 9) - (ACTIVE_PRIORITY[b.status] ?? 9));
310
+ // Focused: list every agent so the keyboard cursor can reach it. Idle: keep
311
+ // the historical cap so the prompt area stays small.
312
+ const activeCap = focused ? activeAgents.length : MAX_AGENTS_DISPLAY;
116
313
  // Finished rows only appear in slots not used by active agents (max 2). When
117
314
  // there are >= MAX_AGENTS_DISPLAY live workers, finished rows are suppressed
118
315
  // entirely so they cannot push a live agent's activity line off-screen.
119
- const finishedSlots = Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
316
+ const finishedSlots = focused ? finishedAgents.length : Math.max(0, Math.min(2, MAX_AGENTS_DISPLAY - activeAgents.length));
317
+
318
+ /** Cursor marker column, mirroring the inline panel's selection. */
319
+ const markerFor = (taskId: string): string => (options.selectedTaskId === taskId ? "❯" : " ");
120
320
 
121
- const visibleAgents = prioritizedActive.slice(0, MAX_AGENTS_DISPLAY);
321
+ const visibleAgents = activeAgents.slice(0, activeCap);
122
322
  for (const [index, agent] of visibleAgents.entries()) {
123
- const last = index === visibleAgents.length - 1 && activeAgents.length <= MAX_AGENTS_DISPLAY && finishedSlots === 0;
323
+ const last = index === visibleAgents.length - 1 && activeAgents.length <= activeCap && finishedSlots === 0;
124
324
  const branch = last ? "└─" : "├─";
125
- const agentGlyph = iconForStatus(agent.status, { runningGlyph });
126
325
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
326
+ const legacyGlyph = options.viewedTaskId === agent.taskId ? "◉" : iconForStatus(agent.status, { runningGlyph });
127
327
  const stats = agentStats(agent, liveHandle);
128
328
  const name = liveHandle?.agent ?? agent.agent;
329
+ const activity = agentActivity(agent, liveHandle);
129
330
  const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
130
- const _activeMain = truncate(`│ ${branch} ${agentGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
331
+ const _activeMain = truncate(`│ ${branch} ${legacyGlyph} ${name}${desc ? ` · ${desc}` : ` · ${agent.role}`}`, width);
131
332
  lines.push(_activeMain);
132
- const _activity = truncate(`│ ⊶ ${agentActivity(agent, liveHandle)}${stats ? ` · ${stats}` : ""}`, width);
333
+ const _activity = truncate(`│ ⊶ ${activity}${stats ? ` · ${stats}` : ""}`, width);
133
334
  lines.push(_activity);
134
335
  }
135
336
 
136
- if (activeAgents.length > MAX_AGENTS_DISPLAY) {
137
- lines.push(truncate(`│ └─ … +${activeAgents.length - MAX_AGENTS_DISPLAY} more agents`, width));
337
+ if (activeAgents.length > activeCap) {
338
+ lines.push(truncate(`│ └─ … +${activeAgents.length - activeCap} more agents`, width));
138
339
  }
139
340
 
140
341
  for (const [index, agent] of finishedAgents.slice(0, finishedSlots).entries()) {
141
342
  const liveHandle = liveForRun.find((h) => h.taskId === agent.taskId);
142
343
  const name = liveHandle?.agent ?? agent.agent;
143
- const icon =
344
+ const legacyIcon =
144
345
  agent.status === "completed" ? "✓" : agent.status === "failed" ? "✗" : agent.status === "needs_attention" ? "⚠" : "▪";
145
346
  const stats = agentStats(agent, liveHandle);
146
347
  const desc = truncate(liveHandle?.description ?? agent.role ?? "", TASK_DESC_MAX);
147
348
  const isLastFinished = index === Math.min(finishedAgents.length, finishedSlots) - 1;
148
349
  const branch = isLastFinished ? "└─" : "├─";
149
- const _finished = truncate(`│ ${branch} ${icon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
350
+ const _finished = truncate(`│ ${branch} ${legacyIcon} ${name} · ${desc}${stats ? ` · ${stats}` : ""}`, width);
150
351
  lines.push(_finished);
151
352
  }
152
353
 
153
- if (lines.length >= maxLines) break;
354
+ // Focused (inline panel cursor active): the keyboard can reach every
355
+ // agent, so the renderer must list them ALL — clipping here would put
356
+ // the cursor marker (❯) on rows that are never painted. The idle
357
+ // widget keeps its historical cap to hold the prompt area small.
358
+ if (lines.length >= maxLines && !focused) break;
154
359
  }
155
360
 
156
- return lines.slice(0, maxLines);
361
+ return focused ? lines : lines.slice(0, maxLines);
157
362
  }
158
363
 
159
364
  // ── Colorization ──────────────────────────────────────────────────────
@@ -24,16 +24,27 @@ 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;
27
29
  }
28
30
 
29
31
  export interface CrewWidgetState {
30
32
  frame: number;
31
33
  lastPlacement?: string;
32
34
  lastVisibility?: "hidden" | "visible";
35
+ /** Whether the aboveEditor task-list widget (pi-crew-tasks) is mounted. */
36
+ lastTasksVisibility?: "hidden" | "visible";
33
37
  lastKey?: string;
34
38
  lastMaxLines?: number;
35
39
  lastCwd?: string;
36
40
  legacyCleared?: boolean;
37
41
  model?: CrewWidgetModel;
38
42
  notificationCount?: number;
43
+ /**
44
+ * Non-serializable dock host used when `widgetPlacement` is `"bottom"`:
45
+ * the dock renders through the crew-vibes footer instead of a pi widget
46
+ * slot. Kept off `model` so serialization/persistence of the rest of the
47
+ * state stays unaffected.
48
+ */
49
+ footerDock?: { render(width: number): string[]; dispose(): void };
39
50
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * child-process-shield.ts — stop abort-driven child-process errors from
3
+ * killing the host pi process.
4
+ *
5
+ * THE CRASH
6
+ * ---------
7
+ * Node spawns a child with `options.signal` and the signal fires while that
8
+ * child is still tracked (before its 'exit' event has dispatched), Node runs:
9
+ *
10
+ * abortChildProcess(child, killSignal, reason)
11
+ * try { if (child.kill(killSignal)) {
12
+ * child.emit("error", new AbortError(...)) // node:child_process
13
+ * } } catch (err) { child.emit("error", err); }
14
+ *
15
+ * `child.emit("error", ...)` THROWS synchronously when the ChildProcess has
16
+ * no 'error' listener (EventEmitter 'error'-event semantics). That throw is
17
+ * captured by the AbortSignal dispatch machinery and re-thrown on the next
18
+ * tick (`process.nextTick(() => { throw err; })`, node:internal/event_target)
19
+ * — OUTSIDE any try/catch, so it surfaces as `uncaughtException` and pi dies
20
+ * (`pi exiting due to uncaughtException: AbortError: The operation was
21
+ * aborted`). This is exactly the crash seen on /crew-view session switches
22
+ * mid-foreground-run: the run's signal propagates into the vendored pi SDK's
23
+ * session machinery, an internal controller abort reaches a signal-spawned
24
+ * child with no error listener, and the terminal dies.
25
+ *
26
+ * IMPORTANT: because the rethrow is asynchronous (next tick), wrapping the
27
+ * AbortController.abort() call in try/catch (see safe-abort.ts) CANNOT stop
28
+ * it. The only reliable fix is to make sure the spawned child ALWAYS has an
29
+ * 'error' listener before an abort can fire, so child.emit("error", ...)
30
+ * dispatches instead of throwing.
31
+ *
32
+ * THE SHIELD
33
+ * ----------
34
+ * 1. `ChildProcess.prototype.kill` — every abort path in node runs through
35
+ * `child.kill()` FIRST (abortChildProcess calls it before emitting), so a
36
+ * no-op 'error' listener attached inside kill() protects every signal-
37
+ * spawned child IN THE PROCESS, regardless of when the spawning module
38
+ * captured its `spawn` reference (the vendored SDK may bind `spawn` at a
39
+ * point we cannot observe).
40
+ * 2. `child_process.spawn` wrapper — attaches the same no-op 'error' listener
41
+ * at spawn time when `options.signal` is present, so the listener exists
42
+ * before Node's pre-aborted-signal nextTick abort and before spawn-failure
43
+ * errors (ENOENT) are emitted.
44
+ *
45
+ * The no-op listener never swallows anything a consumer cares about: 'error'
46
+ * events still reach every other listener (multiple listeners run), and code
47
+ * that previously died on an unhandled 'error' event keeps its semantics for
48
+ * genuinely broken spawns — it just no longer takes down the whole pi process
49
+ * for the abort-after-exit case, which is a semantic no-op by definition (the
50
+ * child was already gone).
51
+ */
52
+ import type { ChildProcess } from "node:child_process";
53
+ import { createRequire } from "node:module";
54
+
55
+ const require = createRequire(import.meta.url);
56
+ const childProcessModule = require("node:child_process") as {
57
+ spawn: (file: string, args?: readonly string[] | undefined, options?: object) => ChildProcess;
58
+ ChildProcess: { prototype: { kill: (signal?: number | string) => boolean } };
59
+ };
60
+
61
+ const SHIELD_MARKER = Symbol.for("pi-crew.childProcessAbortShieldInstalled");
62
+
63
+ /** No-op 'error' listener: prevents the EventEmitter throw, never filters data. */
64
+ function shieldErrorListener(): void {
65
+ /* deliberately empty — see module docstring */
66
+ }
67
+
68
+ /**
69
+ * Install the child-process abort shield. Idempotent: subsequent calls are
70
+ * no-ops. MUST be called at extension registration, before any team run can
71
+ * start (the vendored SDK is imported lazily later, so any spawn it binds
72
+ * afterwards already sees the shielded spawn).
73
+ */
74
+ export function installChildProcessAbortShield(): void {
75
+ const proto = childProcessModule.ChildProcess.prototype as typeof childProcessModule.ChildProcess.prototype & {
76
+ [SHIELD_MARKER]?: boolean;
77
+ };
78
+ if (proto[SHIELD_MARKER]) return;
79
+
80
+ const originalKill = proto.kill;
81
+ proto.kill = function shieldedKill(this: ChildProcess, signal?: number | string): boolean {
82
+ // A signal-spawned child may be aborted synchronously by node's
83
+ // abortChildProcess right after this kill() returns; guarantee a
84
+ // listener is present so child.emit("error", AbortError) dispatches
85
+ // instead of throwing.
86
+ if (this.listenerCount("error") === 0) this.on("error", shieldErrorListener);
87
+ return originalKill.call(this, signal);
88
+ };
89
+
90
+ const moduleObject = childProcessModule as {
91
+ spawn: typeof childProcessModule.spawn;
92
+ };
93
+ const originalSpawn = moduleObject.spawn;
94
+ moduleObject.spawn = function shieldedSpawn(this: unknown, ...args: Parameters<typeof childProcessModule.spawn>) {
95
+ const child = originalSpawn.apply(this, args);
96
+ // Attach at spawn time: covers pre-aborted signals (node defers the
97
+ // abort to the next tick) and spawn-failure errors (ENOENT etc.)
98
+ // before any abort could fire.
99
+ if (child && (args[2] as { signal?: unknown } | undefined)?.signal !== undefined) {
100
+ child.on("error", shieldErrorListener);
101
+ }
102
+ return child;
103
+ };
104
+
105
+ proto[SHIELD_MARKER] = true;
106
+ }
@@ -63,10 +63,6 @@ function evictOldestCacheEntry(): void {
63
63
  if (oldestKey !== undefined) readCache.delete(oldestKey);
64
64
  }
65
65
 
66
- export function clearReadCache(): void {
67
- readCache.clear();
68
- }
69
-
70
66
  export function readJsonFileCoalesced<T>(filePath: string, ttlMs: number, read: () => T): T {
71
67
  const now = Date.now();
72
68
  const stat = (() => {
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Fatal-filesystem errno classification (bug-026 sub-issue B).
3
+ *
4
+ * During the 2026-08-15 disk-full window, workers died with generic
5
+ * "unresponsive"/ETIMEDOUT diagnostics — the ENOSPC errno only surfaced
6
+ * inside stderr-tail strings embedded in the E007 ChildTimeout message
7
+ * (`errors.ts` childTimeout appends "Stderr tail: ..."), never as a raw
8
+ * errno object in the parent process. The classifier therefore matches BOTH
9
+ * shapes:
10
+ * - (a) `err.code` (NodeJS.ErrnoException) case-insensitively, and
11
+ * - (b) the error's message text — or any plain string, e.g. a stderr tail.
12
+ *
13
+ * Deliberately NOT placed in state/atomic-write.ts: that module has
14
+ * process.on side effects at module load and imports worker-atomic-writer,
15
+ * and it already throws raw errno errors — classification belongs at catch
16
+ * sites. This module is dependency-free so state/types.ts (type-only) and
17
+ * the team-tool run/doctor surfaces can import it cheaply.
18
+ */
19
+
20
+ export type FatalFsCause = "enospc" | "edquot" | "emfile" | "enfile";
21
+
22
+ const FATAL_FS_CODES: ReadonlySet<string> = new Set(["enospc", "edquot", "emfile", "enfile"]);
23
+
24
+ /** Message-text fallback: catches errno codes embedded in stderr tails /
25
+ * synthesized error messages where no structured `.code` exists. */
26
+ const FATAL_FS_TEXT = /\b(ENOSPC|EDQUOT|EMFILE|ENFILE)\b/;
27
+
28
+ function classifyCode(code: unknown): FatalFsCause | undefined {
29
+ if (typeof code !== "string") return undefined;
30
+ const normalized = code.toLowerCase();
31
+ return FATAL_FS_CODES.has(normalized) ? (normalized as FatalFsCause) : undefined;
32
+ }
33
+
34
+ function classifyText(text: unknown): FatalFsCause | undefined {
35
+ if (typeof text !== "string") return undefined;
36
+ const match = FATAL_FS_TEXT.exec(text);
37
+ return match ? (match[1]?.toLowerCase() as FatalFsCause) : undefined;
38
+ }
39
+
40
+ /**
41
+ * Classify an unknown error (or a raw string, e.g. a child stderr tail) as a
42
+ * fatal filesystem cause. Returns undefined for non-fs errors. Pure and total:
43
+ * never throws on non-object inputs.
44
+ */
45
+ export function classifyFatalFsError(err: unknown): FatalFsCause | undefined {
46
+ if (typeof err === "string") return classifyText(err);
47
+ if (err === null || err === undefined) return undefined;
48
+ const code = classifyCode((err as NodeJS.ErrnoException).code);
49
+ if (code) return code;
50
+ return classifyText((err as { message?: unknown }).message);
51
+ }
52
+
53
+ /**
54
+ * Combined classification for a finished attempt (bug-026 sub-issue B):
55
+ * the assembled attempt error first — a structured errno or a message that
56
+ * embeds one (E007 stderr tails) — then the child's stderr tail. Extracted
57
+ * as a pure seam so the child-executor hook is directly unit-testable.
58
+ */
59
+ export function failureCauseForAttempt(error: string | undefined, stderr: string | undefined): FatalFsCause | undefined {
60
+ return classifyFatalFsError(error) ?? classifyFatalFsError(stderr);
61
+ }
62
+
63
+ /** Human label for a fatal fs cause. */
64
+ export function fsFailureLabel(cause: FatalFsCause): string {
65
+ return cause === "enospc" || cause === "edquot" ? "disk full" : "too many open files";
66
+ }
@@ -10,7 +10,7 @@ import * as fs from "node:fs";
10
10
  * caused a permanent interactive-session busy-loop. The bounded
11
11
  * {@link RunWatcherRegistry} (one non-recursive watcher per ACTIVE run) now
12
12
  * replaces them. Only the two primitives below survive — they are still used by
13
- * manifest-cache, result-watcher, and run-watcher-registry.
13
+ * manifest-cache and run-watcher-registry.
14
14
  */
15
15
 
16
16
  export function closeWatcher(watcher: FSWatcher | null | undefined): void {
@@ -1,7 +1,9 @@
1
+ import { getCrewEnv } from "../config/env-vars.ts";
2
+
1
3
  export function logInternalError(scope: string, error: unknown, details?: string, severity?: "error" | "warn" | "debug"): void {
2
4
  // "error" and "warn" always emit; "debug" (default) is gated behind PI_TEAMS_DEBUG
3
5
  if (!severity || severity === "debug") {
4
- if (!process.env.PI_TEAMS_DEBUG) return;
6
+ if (!getCrewEnv("PI_TEAMS_DEBUG")) return;
5
7
  }
6
8
  const message =
7
9
  error instanceof Error ? error.message : typeof error === "object" && error !== null ? JSON.stringify(error) : String(error);
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
+ import { getCrewEnv } from "../config/env-vars.ts";
5
6
 
6
7
  // NEW-P1/NEW-P2 (perf): packageRoot() and userPiRoot() are invariant for a
7
8
  // process lifetime but each call did statSync/readFileSync/lstatSync (10-15+
@@ -55,7 +56,7 @@ export function userPiRoot(): string {
55
56
  // "undefined/.pi/agent" relative to cwd and silently create a junk "undefined/"
56
57
  // tree. Treat the literal "undefined" (and empty) as unset and fall back to
57
58
  // os.homedir().
58
- const rawHome = (process.env.PI_TEAMS_HOME ?? process.env.PI_CREW_HOME)?.trim();
59
+ const rawHome = getCrewEnv("PI_CREW_HOME")?.trim();
59
60
  const home = rawHome && rawHome !== "undefined" ? rawHome : os.homedir();
60
61
  // NEW-P2: memoize but KEY ON HOME. Tests (withIsolatedHome / isolateHome)
61
62
  // change PI_TEAMS_HOME mid-process; an unkeyed cache would leak the first
@@ -203,8 +204,15 @@ export function findRepoRoot(cwd: string): string | undefined {
203
204
  function computeRepoRoot(start: string): string | undefined {
204
205
  let current = start;
205
206
  const root = path.parse(current).root;
206
- const home = path.resolve(os.homedir());
207
- const tempRoot = path.resolve(os.tmpdir());
207
+ // Canonicalize the boundaries to match the walk chain: findRepoRoot
208
+ // resolves the start dir via realpathSync, so every step of the walk is a
209
+ // canonical path. Comparing against the LEXICAL home/tmpdir lets symlinked
210
+ // temp dirs (macOS: /var -> /private/var, TMPDIR=/var/folders/.../T) never
211
+ // match, so the walk escapes the sandbox and can latch onto an unrelated
212
+ // ancestor marker (seen live: GH macOS runners have one) — state then
213
+ // resolves to a repo root far outside the temp tree (bug-029).
214
+ const home = canonicalizePath(path.resolve(os.homedir()));
215
+ const tempRoot = canonicalizePath(path.resolve(os.tmpdir()));
208
216
  while (current !== root) {
209
217
  // Stop walking before checking markers at home or temp root
210
218
  if (current === home || current === tempRoot) return undefined;
@@ -34,7 +34,14 @@ export const PEM_PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]
34
34
  // keyword-scan matches '_token' in 'prompt_tokens' (underscore + "token"),
35
35
  // falsely classifying usage counts as secrets. See performance/quality
36
36
  // assessment fix #5.
37
+ //
38
+ // `tokens` alone must stay too: pi-crew state records use
39
+ // `progress.tokens` / `status.tokens` as the per-task usage total; the
40
+ // camelCase fallback scan in isSecretKey() matches the bare keyword at
41
+ // start+end of the name and would otherwise rewrite `15` to the literal
42
+ // "***" on disk, which the widget then renders verbatim as `*** tok`.
37
43
  const TOKEN_COUNT_KEYS = new Set([
44
+ "tokens",
38
45
  "prompt_tokens",
39
46
  "completion_tokens",
40
47
  "total_tokens",