pi-crew 0.6.1 → 0.6.3

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 (102) hide show
  1. package/CHANGELOG.md +160 -0
  2. package/README.md +70 -33
  3. package/docs/issue-29-analysis.md +189 -0
  4. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +1268 -0
  5. package/package.json +2 -2
  6. package/src/config/config.ts +106 -15
  7. package/src/errors.ts +107 -0
  8. package/src/extension/async-notifier.ts +6 -2
  9. package/src/extension/crew-cleanup.ts +8 -5
  10. package/src/extension/management.ts +464 -109
  11. package/src/extension/register.ts +194 -34
  12. package/src/extension/registration/commands.ts +2 -2
  13. package/src/extension/registration/subagent-helpers.ts +2 -2
  14. package/src/extension/registration/subagent-tools.ts +1 -1
  15. package/src/extension/registration/team-tool.ts +1 -1
  16. package/src/extension/registration/viewers.ts +2 -2
  17. package/src/extension/team-tool/api.ts +3 -3
  18. package/src/extension/team-tool/cancel.ts +3 -3
  19. package/src/extension/team-tool/explain.ts +1 -1
  20. package/src/extension/team-tool/handle-schedule.ts +40 -0
  21. package/src/extension/team-tool/inspect.ts +3 -3
  22. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  23. package/src/extension/team-tool/respond.ts +2 -2
  24. package/src/extension/team-tool/run.ts +60 -11
  25. package/src/extension/team-tool/status.ts +1 -1
  26. package/src/extension/team-tool.ts +173 -46
  27. package/src/hooks/registry.ts +77 -13
  28. package/src/hooks/types.ts +9 -0
  29. package/src/plugins/plugin-define.ts +6 -0
  30. package/src/plugins/plugin-registry.ts +32 -0
  31. package/src/plugins/plugins/index.ts +3 -0
  32. package/src/plugins/plugins/nextjs.ts +19 -0
  33. package/src/plugins/plugins/vite.ts +14 -0
  34. package/src/plugins/plugins/vitest.ts +14 -0
  35. package/src/runtime/async-runner.ts +35 -7
  36. package/src/runtime/background-runner.ts +529 -144
  37. package/src/runtime/chain-parser.ts +5 -1
  38. package/src/runtime/chain-runner.ts +9 -3
  39. package/src/runtime/checkpoint.ts +262 -180
  40. package/src/runtime/child-pi.ts +164 -37
  41. package/src/runtime/crash-recovery.ts +25 -14
  42. package/src/runtime/crew-agent-records.ts +2 -0
  43. package/src/runtime/diagnostic-export.ts +1 -1
  44. package/src/runtime/dynamic-script-runner.ts +10 -7
  45. package/src/runtime/foreground-watchdog.ts +1 -1
  46. package/src/runtime/heartbeat-watcher.ts +19 -2
  47. package/src/runtime/intercom-bridge.ts +7 -0
  48. package/src/runtime/iteration-hooks.ts +1 -1
  49. package/src/runtime/manifest-cache.ts +4 -0
  50. package/src/runtime/orphan-worker-registry.ts +444 -0
  51. package/src/runtime/parent-guard.ts +70 -16
  52. package/src/runtime/pi-args.ts +437 -14
  53. package/src/runtime/pi-spawn.ts +1 -0
  54. package/src/runtime/post-checks.ts +1 -1
  55. package/src/runtime/retry-runner.ts +9 -3
  56. package/src/runtime/run-tracker.ts +38 -10
  57. package/src/runtime/sandbox.ts +17 -2
  58. package/src/runtime/scheduler.ts +25 -2
  59. package/src/runtime/skill-effectiveness.ts +105 -62
  60. package/src/runtime/skill-instructions.ts +110 -32
  61. package/src/runtime/stale-reconciler.ts +310 -69
  62. package/src/runtime/subagent-manager.ts +251 -57
  63. package/src/runtime/task-health.ts +76 -0
  64. package/src/runtime/task-id.ts +20 -0
  65. package/src/runtime/task-runner/live-executor.ts +1 -1
  66. package/src/runtime/task-runner/progress.ts +1 -1
  67. package/src/runtime/task-runner/state-helpers.ts +110 -6
  68. package/src/runtime/task-runner.ts +92 -70
  69. package/src/runtime/team-runner.ts +186 -20
  70. package/src/schema/team-tool-schema.ts +27 -9
  71. package/src/skills/discover-skills.ts +9 -3
  72. package/src/state/active-run-registry.ts +170 -38
  73. package/src/state/artifact-store.ts +25 -13
  74. package/src/state/atomic-write-v2.ts +86 -0
  75. package/src/state/atomic-write.ts +346 -55
  76. package/src/state/blob-store.ts +178 -10
  77. package/src/state/crew-init.ts +161 -28
  78. package/src/state/decision-ledger.ts +172 -111
  79. package/src/state/event-log-rotation.ts +82 -52
  80. package/src/state/event-log.ts +254 -70
  81. package/src/state/health-store.ts +71 -0
  82. package/src/state/locks.ts +102 -20
  83. package/src/state/mailbox.ts +45 -7
  84. package/src/state/observation-store.ts +4 -1
  85. package/src/state/run-graph.ts +141 -130
  86. package/src/state/run-metrics.ts +24 -8
  87. package/src/state/state-store.ts +330 -43
  88. package/src/ui/live-run-sidebar.ts +1 -1
  89. package/src/ui/loaders.ts +4 -0
  90. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  91. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  92. package/src/ui/run-action-dispatcher.ts +2 -2
  93. package/src/ui/run-snapshot-cache.ts +1 -1
  94. package/src/utils/env-filter.ts +66 -0
  95. package/src/utils/file-coalescer.ts +9 -1
  96. package/src/utils/paths.ts +80 -5
  97. package/src/utils/redaction.ts +6 -1
  98. package/src/utils/safe-paths.ts +281 -10
  99. package/src/worktree/cleanup.ts +112 -24
  100. package/src/worktree/worktree-manager.ts +128 -15
  101. package/test-bugs-all.mjs +10 -6
  102. package/test-integration-check.ts +4 -0
@@ -1,14 +1,21 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { loadRunManifestById } from "../state/state-store.ts";
3
+ import { DEFAULT_PATHS, DEFAULT_SUBAGENT } from "../config/defaults.ts";
4
4
  import type { PiTeamsToolResult } from "../extension/tool-result.ts";
5
- import { DEFAULT_SUBAGENT } from "../config/defaults.ts";
6
- import { projectCrewRoot } from "../utils/paths.ts";
7
- import { DEFAULT_PATHS } from "../config/defaults.ts";
5
+ import { loadRunManifestById } from "../state/state-store.ts";
8
6
  import { logInternalError } from "../utils/internal-error.ts";
7
+ import { projectCrewRoot } from "../utils/paths.ts";
9
8
  import { redactSecrets } from "../utils/redaction.ts";
10
9
 
11
- export type SubagentStatus = "queued" | "running" | "completed" | "failed" | "cancelled" | "error" | "blocked" | "stopped";
10
+ export type SubagentStatus =
11
+ | "queued"
12
+ | "running"
13
+ | "completed"
14
+ | "failed"
15
+ | "cancelled"
16
+ | "error"
17
+ | "blocked"
18
+ | "stopped";
12
19
 
13
20
  export interface SubagentSpawnOptions {
14
21
  cwd: string;
@@ -49,7 +56,10 @@ export interface SubagentRecord {
49
56
  lifetimeUsage?: { input: number; output: number; cacheWrite: number };
50
57
  }
51
58
 
52
- type SpawnRunner = (options: SubagentSpawnOptions, signal?: AbortSignal) => Promise<PiTeamsToolResult>;
59
+ type SpawnRunner = (
60
+ options: SubagentSpawnOptions,
61
+ signal?: AbortSignal,
62
+ ) => Promise<PiTeamsToolResult>;
53
63
  type Notify = (record: SubagentRecord) => void;
54
64
  type NotifyEvent = (type: string, data: Record<string, unknown>) => void;
55
65
 
@@ -66,7 +76,11 @@ function isValidSubagentId(id: string): boolean {
66
76
 
67
77
  function persistedSubagentPath(cwd: string, id: string): string {
68
78
  if (!isValidSubagentId(id)) throw new Error(`Invalid subagent id: ${id}`);
69
- return path.join(projectCrewRoot(cwd), DEFAULT_PATHS.state.subagentsSubdir, `${id}.json`);
79
+ return path.join(
80
+ projectCrewRoot(cwd),
81
+ DEFAULT_PATHS.state.subagentsSubdir,
82
+ `${id}.json`,
83
+ );
70
84
  }
71
85
 
72
86
  function serializableRecord(record: SubagentRecord): SubagentRecord {
@@ -74,11 +88,18 @@ function serializableRecord(record: SubagentRecord): SubagentRecord {
74
88
  return rest;
75
89
  }
76
90
 
77
- export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord): void {
91
+ export function savePersistedSubagentRecord(
92
+ cwd: string,
93
+ record: SubagentRecord,
94
+ ): void {
78
95
  try {
79
96
  const filePath = persistedSubagentPath(cwd, record.id);
80
97
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
81
- fs.writeFileSync(filePath, `${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`, "utf-8");
98
+ fs.writeFileSync(
99
+ filePath,
100
+ `${JSON.stringify(redactSecrets(serializableRecord(record)), null, 2)}\n`,
101
+ "utf-8",
102
+ );
82
103
  // SECURITY: Restrict permissions to owner-only (rw-------).
83
104
  // On multi-user systems, other users must not read task prompts,
84
105
  // agent descriptions, and run IDs from subagent record files.
@@ -89,26 +110,67 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
89
110
  }
90
111
 
91
112
  const ALLOWED_RECORD_FIELDS = new Set([
92
- "agentId", "agentName", "subagentType", "status", "spawnedAt",
93
- "completedAt", "model", "runId", "cwd", "taskId", "taskId",
113
+ "id",
114
+ "agentId",
115
+ "agentName",
116
+ "subagentType",
117
+ "type",
118
+ "description",
119
+ "prompt",
120
+ "status",
121
+ "startedAt",
122
+ "completedAt",
123
+ "spawnedAt",
124
+ "model",
125
+ "runId",
126
+ "cwd",
127
+ "taskId",
128
+ "result",
129
+ "error",
130
+ "resultConsumed",
131
+ "background",
132
+ "ownerSessionGeneration",
133
+ "stuckNotified",
134
+ "blockedAt",
135
+ "turnCount",
136
+ "terminated",
137
+ "durationMs",
94
138
  ]);
95
139
 
96
140
  function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
97
141
  if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
98
142
  const obj = raw as Record<string, unknown>;
99
- if (typeof obj.agentId !== "string" || !obj.agentId) return undefined;
100
- const clean: Record<string, unknown> = { agentId: obj.agentId };
143
+ // Accept either `id` (public SubagentRecord field) or `agentId` (legacy).
144
+ // The saved JSON uses `id` (from serializableRecord → JSON.stringify of record).
145
+ const idValue = obj.id ?? obj.agentId;
146
+ if (typeof idValue !== "string" || !idValue) return undefined;
147
+ const clean: Record<string, unknown> = { id: idValue };
148
+ if (obj.agentId && typeof obj.agentId === "string") {
149
+ clean.agentId = obj.agentId;
150
+ }
101
151
  for (const key of Object.keys(obj)) {
102
- if (ALLOWED_RECORD_FIELDS.has(key) && (typeof obj[key] === "string" || typeof obj[key] === "number" || typeof obj[key] === "boolean")) {
152
+ if (
153
+ ALLOWED_RECORD_FIELDS.has(key) &&
154
+ (typeof obj[key] === "string" ||
155
+ typeof obj[key] === "number" ||
156
+ typeof obj[key] === "boolean")
157
+ ) {
103
158
  clean[key] = obj[key];
104
159
  }
105
160
  }
161
+ // Re-validate `id` to prevent prototype/constructor smuggling
162
+ if (typeof clean.id !== "string" || clean.id.length === 0) return undefined;
106
163
  return clean as unknown as SubagentRecord;
107
164
  }
108
165
 
109
- export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRecord | undefined {
166
+ export function readPersistedSubagentRecord(
167
+ cwd: string,
168
+ id: string,
169
+ ): SubagentRecord | undefined {
110
170
  try {
111
- const raw = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
171
+ const raw = JSON.parse(
172
+ fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"),
173
+ );
112
174
  return sanitizePersistedRecord(raw);
113
175
  } catch {
114
176
  return undefined;
@@ -116,7 +178,12 @@ export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRe
116
178
  }
117
179
 
118
180
  function resultText(result: PiTeamsToolResult): string {
119
- return result.content?.map((item) => item.type === "text" ? item.text : "").filter(Boolean).join("\n") ?? "";
181
+ return (
182
+ result.content
183
+ ?.map((item) => (item.type === "text" ? item.text : ""))
184
+ .filter(Boolean)
185
+ .join("\n") ?? ""
186
+ );
120
187
  }
121
188
 
122
189
  function detailsRunId(result: PiTeamsToolResult): string | undefined {
@@ -124,9 +191,12 @@ function detailsRunId(result: PiTeamsToolResult): string | undefined {
124
191
  return typeof details?.runId === "string" ? details.runId : undefined;
125
192
  }
126
193
 
127
- function totalRunTurns(cwd: string, runId: string | undefined): number | undefined {
194
+ function totalRunTurns(
195
+ cwd: string,
196
+ runId: string | undefined,
197
+ ): number | undefined {
128
198
  if (!runId) return undefined;
129
- const loaded = loadRunManifestById(cwd, runId);
199
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
130
200
  if (!loaded) return undefined;
131
201
  let total = 0;
132
202
  let hasTurns = false;
@@ -153,20 +223,33 @@ export class SubagentManager {
153
223
  private readonly onEvent?: NotifyEvent;
154
224
  private readonly pollIntervalMs: number;
155
225
 
156
- constructor(maxConcurrent = 4, onComplete?: Notify, pollIntervalMs = 1000, onEvent?: NotifyEvent) {
226
+ constructor(
227
+ maxConcurrent = 4,
228
+ onComplete?: Notify,
229
+ pollIntervalMs = 1000,
230
+ onEvent?: NotifyEvent,
231
+ ) {
157
232
  this.maxConcurrent = maxConcurrent;
158
233
  this.onComplete = onComplete;
159
234
  this.onEvent = onEvent;
160
235
  this.pollIntervalMs = pollIntervalMs;
161
236
  }
162
237
 
163
- spawn(options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): SubagentRecord {
238
+ spawn(
239
+ options: SubagentSpawnOptions,
240
+ runner: SpawnRunner,
241
+ signal?: AbortSignal,
242
+ ): SubagentRecord {
164
243
  const record: SubagentRecord = {
165
244
  id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
166
245
  type: options.type,
167
246
  description: options.description,
168
247
  prompt: options.prompt,
169
- status: options.background && this.runningBackground >= this.maxConcurrent ? "queued" : "running",
248
+ status:
249
+ options.background &&
250
+ this.runningBackground >= this.maxConcurrent
251
+ ? "queued"
252
+ : "running",
170
253
  startedAt: Date.now(),
171
254
  model: options.model,
172
255
  skill: options.skill,
@@ -189,7 +272,9 @@ export class SubagentManager {
189
272
  }
190
273
 
191
274
  listAgents(): SubagentRecord[] {
192
- return [...this.records.values()].sort((a, b) => b.startedAt - a.startedAt);
275
+ return [...this.records.values()].sort(
276
+ (a, b) => b.startedAt - a.startedAt,
277
+ );
193
278
  }
194
279
 
195
280
  abort(id: string, reason?: string): boolean {
@@ -200,7 +285,8 @@ export class SubagentManager {
200
285
  this.markStopped(record, reason ?? "Aborted by caller.");
201
286
  return true;
202
287
  }
203
- if (record.status !== "running" && record.status !== "blocked") return false;
288
+ if (record.status !== "running" && record.status !== "blocked")
289
+ return false;
204
290
  this.controllers.get(id)?.abort();
205
291
  this.markStopped(record, reason ?? "Aborted by caller.");
206
292
  return true;
@@ -227,7 +313,16 @@ export class SubagentManager {
227
313
  async waitForAll(): Promise<void> {
228
314
  while (true) {
229
315
  this.drainQueue();
230
- const pending = this.listAgents().filter((record) => record.status === "running" || record.status === "queued").map((record) => record.promise).filter((promise): promise is Promise<void> => Boolean(promise));
316
+ const pending = this.listAgents()
317
+ .filter(
318
+ (record) =>
319
+ record.status === "running" ||
320
+ record.status === "queued",
321
+ )
322
+ .map((record) => record.promise)
323
+ .filter((promise): promise is Promise<void> =>
324
+ Boolean(promise),
325
+ );
231
326
  if (!pending.length) break;
232
327
  await Promise.allSettled(pending);
233
328
  }
@@ -237,8 +332,16 @@ export class SubagentManager {
237
332
  while (true) {
238
333
  const record = this.records.get(id);
239
334
  if (!record) return undefined;
240
- if (record.status !== "running" && record.status !== "queued") return record;
241
- if (record.promise) await record.promise.catch((error) => { logInternalError("subagent-manager.waitForRecord", error, `id=${id}`); });
335
+ if (record.status !== "running" && record.status !== "queued")
336
+ return record;
337
+ if (record.promise)
338
+ await record.promise.catch((error) => {
339
+ logInternalError(
340
+ "subagent-manager.waitForRecord",
341
+ error,
342
+ `id=${id}`,
343
+ );
344
+ });
242
345
  else await new Promise((resolve) => setTimeout(resolve, 100));
243
346
  }
244
347
  }
@@ -248,7 +351,12 @@ export class SubagentManager {
248
351
  this.drainQueue();
249
352
  }
250
353
 
251
- private start(record: SubagentRecord, options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): void {
354
+ private start(
355
+ record: SubagentRecord,
356
+ options: SubagentSpawnOptions,
357
+ runner: SpawnRunner,
358
+ signal?: AbortSignal,
359
+ ): void {
252
360
  if (options.background) this.runningBackground++;
253
361
  record.status = "running";
254
362
  record.startedAt = Date.now();
@@ -267,33 +375,72 @@ export class SubagentManager {
267
375
  record.error = record.result;
268
376
  throw new Error(record.error);
269
377
  }
270
- if (record.runId) await this.pollRunToTerminal(options.cwd, record);
378
+ if (record.runId)
379
+ await this.pollRunToTerminal(options.cwd, record);
271
380
  else record.status = "completed";
272
381
  } catch (error) {
273
382
  if (record.status === "stopped" || runSignal.aborted) {
274
- const abortReason = runSignal.aborted ? "Signal aborted — agent cancelled by parent (session switch, user cancel, or tool timeout)." : undefined;
383
+ const abortReason = runSignal.aborted
384
+ ? "Signal aborted — agent cancelled by parent (session switch, user cancel, or tool timeout)."
385
+ : undefined;
275
386
  record.status = "stopped";
276
- if (!record.error) record.error = abortReason ?? (error instanceof Error ? error.message : String(error));
387
+ if (!record.error)
388
+ record.error =
389
+ abortReason ??
390
+ (error instanceof Error
391
+ ? error.message
392
+ : String(error));
277
393
  return;
278
394
  }
279
395
  record.status = "error";
280
- record.error = error instanceof Error ? error.message : String(error);
396
+ record.error =
397
+ error instanceof Error ? error.message : String(error);
281
398
  throw error; // H4: Propagate rejection so callers awaiting record.promise see the error
282
399
  } finally {
283
400
  this.cleanupRunSignal(record.id);
284
- if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
285
- if (record.status !== "blocked") record.completedAt = record.completedAt ?? Date.now();
401
+ if (options.background)
402
+ this.runningBackground = Math.max(
403
+ 0,
404
+ this.runningBackground - 1,
405
+ );
406
+ if (record.status !== "blocked")
407
+ record.completedAt = record.completedAt ?? Date.now();
286
408
  savePersistedSubagentRecord(options.cwd, record);
287
- if (record.status === "completed" || record.status === "failed" || record.status === "cancelled" || record.status === "error" || record.status === "stopped") {
409
+ if (
410
+ record.status === "completed" ||
411
+ record.status === "failed" ||
412
+ record.status === "cancelled" ||
413
+ record.status === "error" ||
414
+ record.status === "stopped"
415
+ ) {
288
416
  // Phase 1.6: Populate telemetry fields
289
- record.turnCount = record.turnCount ?? totalRunTurns(options.cwd, record.runId);
290
- record.durationMs = record.completedAt ? Math.max(0, record.completedAt - record.startedAt) : undefined;
417
+ record.turnCount =
418
+ record.turnCount ??
419
+ totalRunTurns(options.cwd, record.runId);
420
+ record.durationMs = record.completedAt
421
+ ? Math.max(0, record.completedAt - record.startedAt)
422
+ : undefined;
291
423
  savePersistedSubagentRecord(options.cwd, record);
292
424
  this.onComplete?.(record);
293
425
  }
294
426
  this.drainQueue();
295
427
  }
296
428
  })();
429
+ // Defense in depth (issue #29): a subagent failure should never crash
430
+ // the host pi process. The IIFE above can reject (e.g. when a run
431
+ // lookup fails) and the re-throw at line 281 propagates to
432
+ // `record.promise`. If no caller awaits the promise, that rejection
433
+ // would become `unhandledRejection` → `uncaughtException` → pi exits.
434
+ // Attaching a no-op catch here keeps the contract for callers that
435
+ // DO await (they still see the rejection) while preventing the
436
+ // harness-killing failure mode for callers that don't.
437
+ record.promise.catch((error) => {
438
+ logInternalError(
439
+ "subagent-manager.start.unhandled",
440
+ error,
441
+ `id=${record.id}`,
442
+ );
443
+ });
297
444
  }
298
445
 
299
446
  private markStopped(record: SubagentRecord, reason?: string): void {
@@ -314,7 +461,9 @@ export class SubagentManager {
314
461
  if (signal) {
315
462
  const abort = (): void => controller.abort();
316
463
  signal.addEventListener("abort", abort, { once: true });
317
- this.controllerCleanup.set(id, () => signal.removeEventListener("abort", abort));
464
+ this.controllerCleanup.set(id, () =>
465
+ signal.removeEventListener("abort", abort),
466
+ );
318
467
  }
319
468
  return controller.signal;
320
469
  }
@@ -326,32 +475,48 @@ export class SubagentManager {
326
475
  }
327
476
 
328
477
  private drainQueue(): void {
329
- while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
478
+ while (
479
+ this.queue.length > 0 &&
480
+ this.runningBackground < this.maxConcurrent
481
+ ) {
330
482
  const next = this.queue.shift();
331
483
  if (!next || next.record.status !== "queued") continue;
332
484
  this.start(next.record, next.options, next.runner, next.signal);
333
485
  }
334
486
  }
335
487
 
336
- private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
337
- while (record.runId && (record.status === "running" || record.status === "blocked")) {
338
- const loaded = loadRunManifestById(cwd, record.runId);
488
+ private async pollRunToTerminal(
489
+ cwd: string,
490
+ record: SubagentRecord,
491
+ ): Promise<void> {
492
+ while (
493
+ record.runId &&
494
+ (record.status === "running" || record.status === "blocked")
495
+ ) {
496
+ const loaded = loadRunManifestById(cwd, record.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
339
497
  if (!loaded) {
340
- await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
498
+ await new Promise((resolve) =>
499
+ setTimeout(resolve, this.pollIntervalMs),
500
+ );
341
501
  continue;
342
502
  }
343
503
  if (loaded.manifest.status === "completed") {
344
504
  record.status = "completed";
345
505
  record.error = undefined;
346
- record.turnCount = record.turnCount ?? totalRunTurns(cwd, record.runId);
506
+ record.turnCount =
507
+ record.turnCount ?? totalRunTurns(cwd, record.runId);
347
508
  record.completedAt = Date.now();
348
509
  savePersistedSubagentRecord(cwd, record);
349
510
  return;
350
511
  }
351
- if (loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled") {
512
+ if (
513
+ loaded.manifest.status === "failed" ||
514
+ loaded.manifest.status === "cancelled"
515
+ ) {
352
516
  record.status = loaded.manifest.status;
353
517
  record.error = loaded.manifest.summary;
354
- record.turnCount = record.turnCount ?? totalRunTurns(cwd, record.runId);
518
+ record.turnCount =
519
+ record.turnCount ?? totalRunTurns(cwd, record.runId);
355
520
  record.completedAt = Date.now();
356
521
  savePersistedSubagentRecord(cwd, record);
357
522
  return;
@@ -370,32 +535,52 @@ export class SubagentManager {
370
535
  savePersistedSubagentRecord(cwd, record);
371
536
  return;
372
537
  }
373
- await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
538
+ await new Promise((resolve) =>
539
+ setTimeout(resolve, this.pollIntervalMs),
540
+ );
374
541
  }
375
542
  }
376
543
 
377
- private scheduleBlockedTerminalPoll(cwd: string, record: SubagentRecord): void {
544
+ private scheduleBlockedTerminalPoll(
545
+ cwd: string,
546
+ record: SubagentRecord,
547
+ ): void {
378
548
  const poll = (): void => {
379
549
  const current = this.records.get(record.id);
380
- if (!current || current.status !== "blocked" || !current.runId) return;
381
- const loaded = loadRunManifestById(cwd, current.runId);
382
- if (!loaded || loaded.manifest.status === "blocked" || loaded.manifest.status === "running" || loaded.manifest.status === "planning" || loaded.manifest.status === "queued") {
550
+ if (!current || current.status !== "blocked" || !current.runId)
551
+ return;
552
+ const loaded = loadRunManifestById(cwd, current.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
553
+ if (
554
+ !loaded ||
555
+ loaded.manifest.status === "blocked" ||
556
+ loaded.manifest.status === "running" ||
557
+ loaded.manifest.status === "planning" ||
558
+ loaded.manifest.status === "queued"
559
+ ) {
383
560
  const timer = setTimeout(poll, this.pollIntervalMs);
384
561
  timer.unref();
385
562
  return;
386
563
  }
387
564
  const persisted = readPersistedSubagentRecord(cwd, current.id);
388
- current.resultConsumed = current.resultConsumed || persisted?.resultConsumed;
565
+ current.resultConsumed =
566
+ current.resultConsumed || persisted?.resultConsumed;
389
567
  if (loaded.manifest.status === "completed") {
390
568
  current.status = "completed";
391
569
  current.error = undefined;
392
- } else if (loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled") {
570
+ } else if (
571
+ loaded.manifest.status === "failed" ||
572
+ loaded.manifest.status === "cancelled"
573
+ ) {
393
574
  current.status = loaded.manifest.status;
394
575
  current.error = loaded.manifest.summary;
395
576
  } else return;
396
577
  current.completedAt = Date.now();
397
- current.turnCount = current.turnCount ?? totalRunTurns(cwd, current.runId);
398
- current.durationMs = Math.max(0, current.completedAt - current.startedAt);
578
+ current.turnCount =
579
+ current.turnCount ?? totalRunTurns(cwd, current.runId);
580
+ current.durationMs = Math.max(
581
+ 0,
582
+ current.completedAt - current.startedAt,
583
+ );
399
584
  savePersistedSubagentRecord(cwd, current);
400
585
  this.onComplete?.(current);
401
586
  };
@@ -403,11 +588,20 @@ export class SubagentManager {
403
588
  timer.unref();
404
589
  }
405
590
 
406
- private scheduleStuckBlockedNotify(cwd: string, record: SubagentRecord): void {
591
+ private scheduleStuckBlockedNotify(
592
+ cwd: string,
593
+ record: SubagentRecord,
594
+ ): void {
407
595
  const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
408
596
  const fire = (): void => {
409
597
  const current = this.records.get(record.id);
410
- if (!current || current.status !== "blocked" || !current.blockedAt || current.stuckNotified) return;
598
+ if (
599
+ !current ||
600
+ current.status !== "blocked" ||
601
+ !current.blockedAt ||
602
+ current.stuckNotified
603
+ )
604
+ return;
411
605
  current.stuckNotified = true;
412
606
  this.onEvent?.("subagent.stuck-blocked", {
413
607
  event: "subagent.stuck-blocked",
@@ -0,0 +1,76 @@
1
+ export type HealthGrade = "A" | "B" | "C" | "D" | "F";
2
+
3
+ export interface HealthPenalty {
4
+ reason: string;
5
+ deduction: number;
6
+ }
7
+
8
+ export interface HealthDelta {
9
+ metric: string;
10
+ delta: number;
11
+ trend: "improving" | "degrading" | "stable";
12
+ }
13
+
14
+ export interface RunHealth {
15
+ score: number;
16
+ grade: HealthGrade;
17
+ penalties: HealthPenalty[];
18
+ deltas: HealthDelta[];
19
+ }
20
+
21
+ const STALLED_THRESHOLD_MS = 5 * 60 * 1000;
22
+
23
+ export function scoreToGrade(score: number): HealthGrade {
24
+ if (score >= 90) return "A";
25
+ if (score >= 70) return "B";
26
+ if (score >= 50) return "C";
27
+ if (score >= 30) return "D";
28
+ return "F";
29
+ }
30
+
31
+ export interface TaskSummary {
32
+ id: string;
33
+ status: string;
34
+ stalledSince?: number;
35
+ }
36
+
37
+ export interface ManifestSummary {
38
+ runId: string;
39
+ tasks: TaskSummary[];
40
+ createdAt: string;
41
+ }
42
+
43
+ export function computeRunHealth(manifest: ManifestSummary): RunHealth {
44
+ const penalties: HealthPenalty[] = [];
45
+ const tasks = manifest.tasks;
46
+ const taskCount = tasks.length;
47
+ if (taskCount === 0) return { score: 100, grade: "A", penalties: [], deltas: [] };
48
+
49
+ const failedCount = tasks.filter(t => t.status === "failed").length;
50
+ const stalledCount = tasks.filter(t =>
51
+ t.stalledSince !== undefined && (Date.now() - t.stalledSince) > STALLED_THRESHOLD_MS
52
+ ).length;
53
+
54
+ const failureRate = failedCount / taskCount;
55
+ if (failureRate > 0.2) {
56
+ penalties.push({ reason: "high-failure-rate", deduction: Math.round(failureRate * 50) });
57
+ }
58
+
59
+ if (stalledCount > 0) {
60
+ penalties.push({ reason: "stalled-tasks", deduction: Math.min(15, stalledCount * 5) });
61
+ }
62
+
63
+ if (taskCount > 20) {
64
+ penalties.push({ reason: "large-task-count", deduction: Math.min(10, Math.floor((taskCount - 20) / 10)) });
65
+ }
66
+
67
+ const totalDeduction = penalties.reduce((sum, p) => sum + p.deduction, 0);
68
+ const score = Math.max(0, Math.min(100, 100 - totalDeduction));
69
+
70
+ return {
71
+ score,
72
+ grade: scoreToGrade(score),
73
+ penalties,
74
+ deltas: [],
75
+ };
76
+ }
@@ -146,3 +146,23 @@ export type DependencyType =
146
146
  | "duplicates" // A duplicates B
147
147
  | "delegated-from" // A was delegated from B
148
148
  | "validates"; // A validates B's output
149
+
150
+ // ── Stable IDs (full hash, for cross-run references) ──────────────────────
151
+
152
+ /**
153
+ * Generate a stable, collision-resistant ID from arbitrary content.
154
+ * Uses full SHA-256 hash (not adaptive length) for maximum stability.
155
+ * Format: {prefix}-{first12charsOfBase36hash}
156
+ *
157
+ * Use for: run-level IDs, artifact keys, cross-run references
158
+ * where determinism and uniqueness matter more than short length.
159
+ */
160
+ export function stableIdFromContent(content: string, prefix = "id"): string {
161
+ const hash = createHash("sha256").update(content).digest("hex");
162
+ const hashChars = "0123456789abcdefghijklmnopqrstuvwxyz";
163
+ let b36 = "";
164
+ for (let i = 0; i < 16 && i < hash.length; i++) {
165
+ b36 += hashChars[parseInt(hash[i]!, 16)] ?? "0";
166
+ }
167
+ return `${prefix}-${b36.slice(0, 12)}`;
168
+ }
@@ -65,7 +65,7 @@ export async function runLiveTask(input: RunLiveTaskInput): Promise<RunLiveTaskO
65
65
  const transcriptPath = `${manifest.artifactsRoot}/transcripts/${task.id}.jsonl`;
66
66
  const isCurrent = input.isCurrent ?? (() => {
67
67
  if (input.signal?.aborted) return false;
68
- const loaded = loadRunManifestById(manifest.cwd, manifest.runId);
68
+ const loaded = loadRunManifestById(manifest.cwd, manifest.runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency;
69
69
  const currentTask = loaded?.tasks.find((item) => item.id === task.id);
70
70
  if (loaded?.manifest.status && loaded.manifest.status !== "running") return false;
71
71
  return currentTask ? currentTask.status === "running" : true;
@@ -77,7 +77,7 @@ export function applyUsageToProgress(progress: CrewAgentProgress | undefined, us
77
77
 
78
78
  export function shouldFlushProgressEvent(event: unknown): boolean {
79
79
  const type = asRecord(event)?.type;
80
- return type === "tool_execution_start" || type === "tool_execution_end" || type === "message_end" || type === "tool_result_end";
80
+ return type === "tool_execution_start" || type === "tool_execution_end" || type === "message_start" || type === "message_end" || type === "tool_result_end";
81
81
  }
82
82
 
83
83
  export function progressEventSummary(task: TeamTaskState, event: unknown): ProgressEventSummary {