pi-crew 0.6.0 → 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 (135) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/README.md +70 -31
  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/agents/agent-config.ts +2 -1
  7. package/src/benchmark/feedback-loop.ts +4 -2
  8. package/src/config/config.ts +106 -15
  9. package/src/errors.ts +107 -0
  10. package/src/extension/async-notifier.ts +6 -2
  11. package/src/extension/crew-cleanup.ts +8 -5
  12. package/src/extension/cross-extension-rpc.ts +48 -0
  13. package/src/extension/management.ts +464 -109
  14. package/src/extension/register.ts +194 -34
  15. package/src/extension/registration/commands.ts +4 -3
  16. package/src/extension/registration/subagent-helpers.ts +2 -2
  17. package/src/extension/registration/subagent-tools.ts +3 -1
  18. package/src/extension/registration/team-tool.ts +3 -1
  19. package/src/extension/registration/viewers.ts +3 -2
  20. package/src/extension/run-export.ts +16 -1
  21. package/src/extension/run-import.ts +16 -0
  22. package/src/extension/team-tool/anchor.ts +5 -1
  23. package/src/extension/team-tool/api.ts +12 -7
  24. package/src/extension/team-tool/cancel.ts +3 -3
  25. package/src/extension/team-tool/config-patch.ts +15 -1
  26. package/src/extension/team-tool/explain.ts +1 -1
  27. package/src/extension/team-tool/handle-schedule.ts +40 -0
  28. package/src/extension/team-tool/inspect.ts +3 -3
  29. package/src/extension/team-tool/lifecycle-actions.ts +4 -4
  30. package/src/extension/team-tool/respond.ts +2 -2
  31. package/src/extension/team-tool/run.ts +60 -11
  32. package/src/extension/team-tool/status.ts +1 -1
  33. package/src/extension/team-tool.ts +175 -47
  34. package/src/hooks/registry.ts +84 -12
  35. package/src/hooks/types.ts +12 -3
  36. package/src/i18n.ts +15 -2
  37. package/src/observability/exporters/otlp-exporter.ts +73 -0
  38. package/src/plugins/plugin-define.ts +6 -0
  39. package/src/plugins/plugin-registry.ts +32 -0
  40. package/src/plugins/plugins/index.ts +3 -0
  41. package/src/plugins/plugins/nextjs.ts +19 -0
  42. package/src/plugins/plugins/vite.ts +14 -0
  43. package/src/plugins/plugins/vitest.ts +14 -0
  44. package/src/runtime/adaptive-plan.ts +24 -0
  45. package/src/runtime/agent-control.ts +6 -3
  46. package/src/runtime/async-runner.ts +86 -3
  47. package/src/runtime/background-runner.ts +529 -144
  48. package/src/runtime/chain-parser.ts +5 -1
  49. package/src/runtime/chain-runner.ts +67 -3
  50. package/src/runtime/checkpoint.ts +262 -180
  51. package/src/runtime/child-pi.ts +165 -38
  52. package/src/runtime/crash-recovery.ts +25 -14
  53. package/src/runtime/crew-agent-records.ts +6 -3
  54. package/src/runtime/cross-extension-rpc.ts +34 -8
  55. package/src/runtime/diagnostic-export.ts +4 -5
  56. package/src/runtime/dynamic-script-runner.ts +9 -6
  57. package/src/runtime/foreground-watchdog.ts +3 -3
  58. package/src/runtime/heartbeat-watcher.ts +19 -2
  59. package/src/runtime/intercom-bridge.ts +7 -0
  60. package/src/runtime/iteration-hooks.ts +1 -1
  61. package/src/runtime/live-agent-manager.ts +6 -3
  62. package/src/runtime/live-irc.ts +4 -2
  63. package/src/runtime/manifest-cache.ts +4 -0
  64. package/src/runtime/orphan-worker-registry.ts +444 -0
  65. package/src/runtime/parallel-utils.ts +2 -1
  66. package/src/runtime/parent-guard.ts +70 -16
  67. package/src/runtime/pi-args.ts +437 -14
  68. package/src/runtime/pi-spawn.ts +1 -0
  69. package/src/runtime/post-checks.ts +11 -4
  70. package/src/runtime/retry-runner.ts +9 -3
  71. package/src/runtime/{drift-detectors.ts → run-drift.ts} +1 -1
  72. package/src/runtime/run-tracker.ts +38 -10
  73. package/src/runtime/sandbox.ts +42 -21
  74. package/src/runtime/scheduler.ts +25 -2
  75. package/src/runtime/semaphore.ts +2 -1
  76. package/src/runtime/settings-store.ts +14 -2
  77. package/src/runtime/skill-effectiveness.ts +109 -64
  78. package/src/runtime/skill-instructions.ts +114 -33
  79. package/src/runtime/stale-reconciler.ts +310 -69
  80. package/src/runtime/subagent-manager.ts +265 -53
  81. package/src/runtime/subprocess-tool-registry.ts +2 -2
  82. package/src/runtime/task-health.ts +76 -0
  83. package/src/runtime/task-id.ts +20 -0
  84. package/src/runtime/task-packet.ts +13 -1
  85. package/src/runtime/task-runner/live-executor.ts +1 -1
  86. package/src/runtime/task-runner/progress.ts +1 -1
  87. package/src/runtime/task-runner/state-helpers.ts +110 -6
  88. package/src/runtime/task-runner.ts +98 -67
  89. package/src/runtime/team-runner.ts +186 -20
  90. package/src/runtime/usage-tracker.ts +4 -2
  91. package/src/runtime/verification-gates.ts +36 -9
  92. package/src/schema/team-tool-schema.ts +27 -9
  93. package/src/skills/discover-skills.ts +9 -3
  94. package/src/state/active-run-registry.ts +170 -38
  95. package/src/state/artifact-store.ts +25 -13
  96. package/src/state/atomic-write-v2.ts +86 -0
  97. package/src/state/atomic-write.ts +346 -55
  98. package/src/state/blob-store.ts +178 -10
  99. package/src/state/contracts.ts +2 -1
  100. package/src/state/crew-init.ts +161 -28
  101. package/src/state/decision-ledger.ts +172 -111
  102. package/src/state/event-log-rotation.ts +82 -52
  103. package/src/state/event-log.ts +270 -75
  104. package/src/state/health-store.ts +71 -0
  105. package/src/state/hook-instinct-bridge.ts +2 -1
  106. package/src/state/locks.ts +109 -20
  107. package/src/state/mailbox.ts +45 -7
  108. package/src/state/observation-store.ts +4 -1
  109. package/src/state/run-graph.ts +141 -130
  110. package/src/state/run-metrics.ts +24 -8
  111. package/src/state/state-store.ts +333 -44
  112. package/src/state/task-claims.ts +9 -2
  113. package/src/tools/safe-bash.ts +69 -20
  114. package/src/types/new-api-types.ts +10 -5
  115. package/src/ui/keybinding-map.ts +2 -1
  116. package/src/ui/live-run-sidebar.ts +1 -1
  117. package/src/ui/loaders.ts +4 -0
  118. package/src/ui/overlays/agent-picker-overlay.ts +1 -1
  119. package/src/ui/overlays/mailbox-detail-overlay.ts +1 -1
  120. package/src/ui/run-action-dispatcher.ts +4 -3
  121. package/src/ui/run-snapshot-cache.ts +1 -1
  122. package/src/ui/status-colors.ts +2 -1
  123. package/src/ui/syntax-highlight.ts +2 -1
  124. package/src/ui/tool-render.ts +13 -3
  125. package/src/utils/env-filter.ts +66 -0
  126. package/src/utils/file-coalescer.ts +9 -1
  127. package/src/utils/fs-watch.ts +4 -2
  128. package/src/utils/gh-protocol.ts +2 -1
  129. package/src/utils/paths.ts +80 -5
  130. package/src/utils/redaction.ts +6 -1
  131. package/src/utils/safe-paths.ts +287 -10
  132. package/src/worktree/cleanup.ts +117 -26
  133. package/src/worktree/worktree-manager.ts +128 -15
  134. package/test-bugs-all.mjs +10 -6
  135. 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.
@@ -88,17 +109,81 @@ export function savePersistedSubagentRecord(cwd: string, record: SubagentRecord)
88
109
  }
89
110
  }
90
111
 
91
- export function readPersistedSubagentRecord(cwd: string, id: string): SubagentRecord | undefined {
112
+ const ALLOWED_RECORD_FIELDS = new Set([
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",
138
+ ]);
139
+
140
+ function sanitizePersistedRecord(raw: unknown): SubagentRecord | undefined {
141
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
142
+ const obj = raw as Record<string, unknown>;
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
+ }
151
+ for (const key of Object.keys(obj)) {
152
+ if (
153
+ ALLOWED_RECORD_FIELDS.has(key) &&
154
+ (typeof obj[key] === "string" ||
155
+ typeof obj[key] === "number" ||
156
+ typeof obj[key] === "boolean")
157
+ ) {
158
+ clean[key] = obj[key];
159
+ }
160
+ }
161
+ // Re-validate `id` to prevent prototype/constructor smuggling
162
+ if (typeof clean.id !== "string" || clean.id.length === 0) return undefined;
163
+ return clean as unknown as SubagentRecord;
164
+ }
165
+
166
+ export function readPersistedSubagentRecord(
167
+ cwd: string,
168
+ id: string,
169
+ ): SubagentRecord | undefined {
92
170
  try {
93
- const parsed = JSON.parse(fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"));
94
- return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as SubagentRecord : undefined;
171
+ const raw = JSON.parse(
172
+ fs.readFileSync(persistedSubagentPath(cwd, id), "utf-8"),
173
+ );
174
+ return sanitizePersistedRecord(raw);
95
175
  } catch {
96
176
  return undefined;
97
177
  }
98
178
  }
99
179
 
100
180
  function resultText(result: PiTeamsToolResult): string {
101
- 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
+ );
102
187
  }
103
188
 
104
189
  function detailsRunId(result: PiTeamsToolResult): string | undefined {
@@ -106,9 +191,12 @@ function detailsRunId(result: PiTeamsToolResult): string | undefined {
106
191
  return typeof details?.runId === "string" ? details.runId : undefined;
107
192
  }
108
193
 
109
- function totalRunTurns(cwd: string, runId: string | undefined): number | undefined {
194
+ function totalRunTurns(
195
+ cwd: string,
196
+ runId: string | undefined,
197
+ ): number | undefined {
110
198
  if (!runId) return undefined;
111
- const loaded = loadRunManifestById(cwd, runId);
199
+ const loaded = loadRunManifestById(cwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
112
200
  if (!loaded) return undefined;
113
201
  let total = 0;
114
202
  let hasTurns = false;
@@ -135,20 +223,33 @@ export class SubagentManager {
135
223
  private readonly onEvent?: NotifyEvent;
136
224
  private readonly pollIntervalMs: number;
137
225
 
138
- constructor(maxConcurrent = 4, onComplete?: Notify, pollIntervalMs = 1000, onEvent?: NotifyEvent) {
226
+ constructor(
227
+ maxConcurrent = 4,
228
+ onComplete?: Notify,
229
+ pollIntervalMs = 1000,
230
+ onEvent?: NotifyEvent,
231
+ ) {
139
232
  this.maxConcurrent = maxConcurrent;
140
233
  this.onComplete = onComplete;
141
234
  this.onEvent = onEvent;
142
235
  this.pollIntervalMs = pollIntervalMs;
143
236
  }
144
237
 
145
- spawn(options: SubagentSpawnOptions, runner: SpawnRunner, signal?: AbortSignal): SubagentRecord {
238
+ spawn(
239
+ options: SubagentSpawnOptions,
240
+ runner: SpawnRunner,
241
+ signal?: AbortSignal,
242
+ ): SubagentRecord {
146
243
  const record: SubagentRecord = {
147
244
  id: `agent_${Date.now().toString(36)}_${(++this.counter).toString(36)}`,
148
245
  type: options.type,
149
246
  description: options.description,
150
247
  prompt: options.prompt,
151
- status: options.background && this.runningBackground >= this.maxConcurrent ? "queued" : "running",
248
+ status:
249
+ options.background &&
250
+ this.runningBackground >= this.maxConcurrent
251
+ ? "queued"
252
+ : "running",
152
253
  startedAt: Date.now(),
153
254
  model: options.model,
154
255
  skill: options.skill,
@@ -171,7 +272,9 @@ export class SubagentManager {
171
272
  }
172
273
 
173
274
  listAgents(): SubagentRecord[] {
174
- 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
+ );
175
278
  }
176
279
 
177
280
  abort(id: string, reason?: string): boolean {
@@ -182,7 +285,8 @@ export class SubagentManager {
182
285
  this.markStopped(record, reason ?? "Aborted by caller.");
183
286
  return true;
184
287
  }
185
- if (record.status !== "running" && record.status !== "blocked") return false;
288
+ if (record.status !== "running" && record.status !== "blocked")
289
+ return false;
186
290
  this.controllers.get(id)?.abort();
187
291
  this.markStopped(record, reason ?? "Aborted by caller.");
188
292
  return true;
@@ -209,7 +313,16 @@ export class SubagentManager {
209
313
  async waitForAll(): Promise<void> {
210
314
  while (true) {
211
315
  this.drainQueue();
212
- 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
+ );
213
326
  if (!pending.length) break;
214
327
  await Promise.allSettled(pending);
215
328
  }
@@ -219,8 +332,16 @@ export class SubagentManager {
219
332
  while (true) {
220
333
  const record = this.records.get(id);
221
334
  if (!record) return undefined;
222
- if (record.status !== "running" && record.status !== "queued") return record;
223
- 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
+ });
224
345
  else await new Promise((resolve) => setTimeout(resolve, 100));
225
346
  }
226
347
  }
@@ -230,7 +351,12 @@ export class SubagentManager {
230
351
  this.drainQueue();
231
352
  }
232
353
 
233
- 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 {
234
360
  if (options.background) this.runningBackground++;
235
361
  record.status = "running";
236
362
  record.startedAt = Date.now();
@@ -249,33 +375,72 @@ export class SubagentManager {
249
375
  record.error = record.result;
250
376
  throw new Error(record.error);
251
377
  }
252
- if (record.runId) await this.pollRunToTerminal(options.cwd, record);
378
+ if (record.runId)
379
+ await this.pollRunToTerminal(options.cwd, record);
253
380
  else record.status = "completed";
254
381
  } catch (error) {
255
382
  if (record.status === "stopped" || runSignal.aborted) {
256
- 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;
257
386
  record.status = "stopped";
258
- 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));
259
393
  return;
260
394
  }
261
395
  record.status = "error";
262
- record.error = error instanceof Error ? error.message : String(error);
396
+ record.error =
397
+ error instanceof Error ? error.message : String(error);
263
398
  throw error; // H4: Propagate rejection so callers awaiting record.promise see the error
264
399
  } finally {
265
400
  this.cleanupRunSignal(record.id);
266
- if (options.background) this.runningBackground = Math.max(0, this.runningBackground - 1);
267
- 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();
268
408
  savePersistedSubagentRecord(options.cwd, record);
269
- 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
+ ) {
270
416
  // Phase 1.6: Populate telemetry fields
271
- record.turnCount = record.turnCount ?? totalRunTurns(options.cwd, record.runId);
272
- 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;
273
423
  savePersistedSubagentRecord(options.cwd, record);
274
424
  this.onComplete?.(record);
275
425
  }
276
426
  this.drainQueue();
277
427
  }
278
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
+ });
279
444
  }
280
445
 
281
446
  private markStopped(record: SubagentRecord, reason?: string): void {
@@ -296,7 +461,9 @@ export class SubagentManager {
296
461
  if (signal) {
297
462
  const abort = (): void => controller.abort();
298
463
  signal.addEventListener("abort", abort, { once: true });
299
- this.controllerCleanup.set(id, () => signal.removeEventListener("abort", abort));
464
+ this.controllerCleanup.set(id, () =>
465
+ signal.removeEventListener("abort", abort),
466
+ );
300
467
  }
301
468
  return controller.signal;
302
469
  }
@@ -308,32 +475,48 @@ export class SubagentManager {
308
475
  }
309
476
 
310
477
  private drainQueue(): void {
311
- while (this.queue.length > 0 && this.runningBackground < this.maxConcurrent) {
478
+ while (
479
+ this.queue.length > 0 &&
480
+ this.runningBackground < this.maxConcurrent
481
+ ) {
312
482
  const next = this.queue.shift();
313
483
  if (!next || next.record.status !== "queued") continue;
314
484
  this.start(next.record, next.options, next.runner, next.signal);
315
485
  }
316
486
  }
317
487
 
318
- private async pollRunToTerminal(cwd: string, record: SubagentRecord): Promise<void> {
319
- while (record.runId && (record.status === "running" || record.status === "blocked")) {
320
- 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
321
497
  if (!loaded) {
322
- await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
498
+ await new Promise((resolve) =>
499
+ setTimeout(resolve, this.pollIntervalMs),
500
+ );
323
501
  continue;
324
502
  }
325
503
  if (loaded.manifest.status === "completed") {
326
504
  record.status = "completed";
327
505
  record.error = undefined;
328
- record.turnCount = record.turnCount ?? totalRunTurns(cwd, record.runId);
506
+ record.turnCount =
507
+ record.turnCount ?? totalRunTurns(cwd, record.runId);
329
508
  record.completedAt = Date.now();
330
509
  savePersistedSubagentRecord(cwd, record);
331
510
  return;
332
511
  }
333
- if (loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled") {
512
+ if (
513
+ loaded.manifest.status === "failed" ||
514
+ loaded.manifest.status === "cancelled"
515
+ ) {
334
516
  record.status = loaded.manifest.status;
335
517
  record.error = loaded.manifest.summary;
336
- record.turnCount = record.turnCount ?? totalRunTurns(cwd, record.runId);
518
+ record.turnCount =
519
+ record.turnCount ?? totalRunTurns(cwd, record.runId);
337
520
  record.completedAt = Date.now();
338
521
  savePersistedSubagentRecord(cwd, record);
339
522
  return;
@@ -352,32 +535,52 @@ export class SubagentManager {
352
535
  savePersistedSubagentRecord(cwd, record);
353
536
  return;
354
537
  }
355
- await new Promise((resolve) => setTimeout(resolve, this.pollIntervalMs));
538
+ await new Promise((resolve) =>
539
+ setTimeout(resolve, this.pollIntervalMs),
540
+ );
356
541
  }
357
542
  }
358
543
 
359
- private scheduleBlockedTerminalPoll(cwd: string, record: SubagentRecord): void {
544
+ private scheduleBlockedTerminalPoll(
545
+ cwd: string,
546
+ record: SubagentRecord,
547
+ ): void {
360
548
  const poll = (): void => {
361
549
  const current = this.records.get(record.id);
362
- if (!current || current.status !== "blocked" || !current.runId) return;
363
- const loaded = loadRunManifestById(cwd, current.runId);
364
- 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
+ ) {
365
560
  const timer = setTimeout(poll, this.pollIntervalMs);
366
561
  timer.unref();
367
562
  return;
368
563
  }
369
564
  const persisted = readPersistedSubagentRecord(cwd, current.id);
370
- current.resultConsumed = current.resultConsumed || persisted?.resultConsumed;
565
+ current.resultConsumed =
566
+ current.resultConsumed || persisted?.resultConsumed;
371
567
  if (loaded.manifest.status === "completed") {
372
568
  current.status = "completed";
373
569
  current.error = undefined;
374
- } else if (loaded.manifest.status === "failed" || loaded.manifest.status === "cancelled") {
570
+ } else if (
571
+ loaded.manifest.status === "failed" ||
572
+ loaded.manifest.status === "cancelled"
573
+ ) {
375
574
  current.status = loaded.manifest.status;
376
575
  current.error = loaded.manifest.summary;
377
576
  } else return;
378
577
  current.completedAt = Date.now();
379
- current.turnCount = current.turnCount ?? totalRunTurns(cwd, current.runId);
380
- 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
+ );
381
584
  savePersistedSubagentRecord(cwd, current);
382
585
  this.onComplete?.(current);
383
586
  };
@@ -385,11 +588,20 @@ export class SubagentManager {
385
588
  timer.unref();
386
589
  }
387
590
 
388
- private scheduleStuckBlockedNotify(cwd: string, record: SubagentRecord): void {
591
+ private scheduleStuckBlockedNotify(
592
+ cwd: string,
593
+ record: SubagentRecord,
594
+ ): void {
389
595
  const threshold = DEFAULT_SUBAGENT.stuckBlockedNotifyMs;
390
596
  const fire = (): void => {
391
597
  const current = this.records.get(record.id);
392
- 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;
393
605
  current.stuckNotified = true;
394
606
  this.onEvent?.("subagent.stuck-blocked", {
395
607
  event: "subagent.stuck-blocked",
@@ -61,7 +61,7 @@ class SubprocessToolRegistryImpl implements SubprocessToolRegistry {
61
61
 
62
62
  export const subprocessToolRegistry: SubprocessToolRegistry = new SubprocessToolRegistryImpl();
63
63
 
64
- /** H3: Reset the global singleton registry (for test isolation). */
65
- export function resetSubprocessToolRegistry(): void {
64
+ /** @internal Reset the global singleton registry (for test isolation). */
65
+ function resetSubprocessToolRegistry(): void {
66
66
  subprocessToolRegistry.clear();
67
67
  }
@@ -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
+ }
@@ -1,6 +1,7 @@
1
1
  import * as path from "node:path";
2
2
  import type { TeamRunManifest, TaskPacket, TaskScope, VerificationContract } from "../state/types.ts";
3
3
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
4
+ import { generateTaskHashId } from "./task-id.ts";
4
5
 
5
6
  // ═══════════════════════════════════════════════════════════════════════════
6
7
  // SEC-007 Fix: Workflow Step Task Sanitization
@@ -81,8 +82,19 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
81
82
  const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
82
83
  // SEC-007: Sanitize task text before inserting into task packet
83
84
  const sanitizedTask = sanitizeTaskText(input.step.task);
85
+ const sanitizedGoal = sanitizeTaskText(input.manifest.goal);
86
+
87
+ // Generate a deterministic hash-based task ID for traceability and logging.
88
+ // Uses goal + step ID + run ID as content parts.
89
+ // TODO: Once TaskPacket type gains a hashId field, include this in the packet.
90
+ const _taskHashId = generateTaskHashId([
91
+ input.manifest.goal,
92
+ input.step.id,
93
+ input.manifest.runId,
94
+ ]);
95
+
84
96
  return {
85
- objective: sanitizedTask.replaceAll("{goal}", input.manifest.goal),
97
+ objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
86
98
  scope,
87
99
  scopePath,
88
100
  repo: path.basename(input.manifest.cwd) || input.manifest.cwd,
@@ -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 {