pi-crew 0.9.41 → 0.9.44

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 (170) hide show
  1. package/CHANGELOG.md +253 -0
  2. package/README.md +14 -0
  3. package/dist/build-meta.json +6450 -12419
  4. package/dist/index.mjs +57998 -68417
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/migration/atomic-write-v2-migration.md +2 -0
  7. package/docs/superpowers/plans/2026-06-09-fallow-patterns-adoption.md +2 -0
  8. package/package.json +4 -2
  9. package/scripts/README.md +6 -0
  10. package/scripts/build-bundle.mjs +2 -1
  11. package/scripts/check-all-skills.ts +3 -3
  12. package/scripts/dev-runner.mjs +106 -0
  13. package/scripts/postinstall.mjs +6 -2
  14. package/scripts/verify-skill.ts +2 -2
  15. package/src/agents/agent-config.ts +1 -1
  16. package/src/config/config.ts +20 -1
  17. package/src/config/drift-detector.ts +97 -15
  18. package/src/config/resilient-parser.ts +4 -4
  19. package/src/config/types.ts +0 -2
  20. package/src/extension/crew-cleanup.ts +17 -3
  21. package/src/extension/crew-vibes/config.ts +1 -1
  22. package/src/extension/crew-vibes/font-detect.ts +16 -3
  23. package/src/extension/crew-vibes/index.ts +1 -1
  24. package/src/extension/cross-extension-rpc.ts +26 -9
  25. package/src/extension/management.ts +6 -5
  26. package/src/extension/register.ts +58 -1573
  27. package/src/extension/registration/command-registration.ts +57 -0
  28. package/src/extension/registration/commands.ts +7 -2
  29. package/src/extension/registration/context-builder.ts +131 -0
  30. package/src/extension/registration/crash-recovery-cache.ts +54 -0
  31. package/src/extension/registration/foreground-run-controller.ts +238 -0
  32. package/src/extension/registration/hook-registration.ts +119 -0
  33. package/src/extension/registration/lazy-configurers.ts +124 -0
  34. package/src/extension/registration/lifecycle-handlers.ts +731 -0
  35. package/src/extension/registration/observability.ts +33 -4
  36. package/src/extension/registration/registration-types.ts +149 -0
  37. package/src/extension/registration/runtime-cleanup.ts +230 -0
  38. package/src/extension/registration/subagent-manager-setup.ts +211 -0
  39. package/src/extension/registration/tool-registration.ts +50 -0
  40. package/src/extension/registration/wire-cross-extension.ts +37 -0
  41. package/src/extension/rpc-hmac.ts +21 -1
  42. package/src/extension/run-import.ts +3 -3
  43. package/src/extension/team-tool/auto-summarize.ts +4 -4
  44. package/src/extension/team-tool/cancel.ts +5 -5
  45. package/src/extension/team-tool/chain-executor.ts +5 -4
  46. package/src/extension/team-tool/context.ts +11 -1
  47. package/src/extension/team-tool/doctor.ts +2 -1
  48. package/src/extension/team-tool/goal-wrap.ts +2 -2
  49. package/src/extension/team-tool/goal.ts +1 -1
  50. package/src/extension/team-tool/handle-schedule.ts +25 -36
  51. package/src/extension/team-tool/orchestrate.ts +0 -1
  52. package/src/extension/team-tool/parallel-dispatch.ts +5 -0
  53. package/src/extension/team-tool/run.ts +220 -214
  54. package/src/extension/team-tool/workflow-manage.ts +1 -1
  55. package/src/extension/team-tool.ts +69 -16
  56. package/src/hooks/registry.ts +7 -2
  57. package/src/observability/correlation.ts +4 -3
  58. package/src/observability/event-bus.ts +10 -1
  59. package/src/observability/event-to-metric.ts +8 -7
  60. package/src/observability/exporters/adapter.ts +6 -9
  61. package/src/observability/exporters/otlp-exporter.ts +81 -0
  62. package/src/observability/metric-retention.ts +4 -0
  63. package/src/observability/metric-sink.ts +14 -4
  64. package/src/prompt/prompt-runtime.ts +15 -0
  65. package/src/runtime/adaptive-plan.ts +1 -1
  66. package/src/runtime/async-runner.ts +13 -4
  67. package/src/runtime/background-runner.ts +32 -23
  68. package/src/runtime/capability-inventory.ts +1 -1
  69. package/src/runtime/chain-runner.ts +5 -5
  70. package/src/runtime/checkpoint.ts +6 -16
  71. package/src/runtime/child-pi-constants.ts +42 -0
  72. package/src/runtime/child-pi-kill.ts +180 -0
  73. package/src/runtime/child-pi-spawn.ts +234 -0
  74. package/src/runtime/child-pi-steering.ts +128 -0
  75. package/src/runtime/child-pi-streams.ts +296 -0
  76. package/src/runtime/child-pi-transcript.ts +169 -0
  77. package/src/runtime/child-pi.ts +80 -822
  78. package/src/runtime/coalesce-tasks.ts +1 -1
  79. package/src/runtime/compact-stages/tail-capture-stage.ts +1 -1
  80. package/src/runtime/crash-recovery.ts +63 -47
  81. package/src/runtime/delivery-coordinator.ts +1 -1
  82. package/src/runtime/deterministic-ast.ts +2 -2
  83. package/src/runtime/diagnostic-export.ts +2 -1
  84. package/src/runtime/dwf-state-store.ts +5 -0
  85. package/src/runtime/dynamic-workflow-context.ts +78 -18
  86. package/src/runtime/dynamic-workflow-runner.ts +18 -2
  87. package/src/runtime/foreground-control.ts +3 -2
  88. package/src/runtime/goal-evaluator.ts +59 -27
  89. package/src/runtime/goal-loop-runner.ts +46 -13
  90. package/src/runtime/goal-state-store.ts +1 -1
  91. package/src/runtime/handoff-manager.ts +0 -2
  92. package/src/runtime/intercom-bridge.ts +0 -2
  93. package/src/runtime/live-agent-manager.ts +30 -5
  94. package/src/runtime/live-extension-bridge.ts +0 -2
  95. package/src/runtime/live-session-runtime.ts +65 -57
  96. package/src/runtime/manifest-cache.ts +59 -0
  97. package/src/runtime/mcp-proxy.ts +1 -1
  98. package/src/runtime/orphan-worker-registry.ts +3 -3
  99. package/src/runtime/output-validator.ts +1 -1
  100. package/src/runtime/path-overlap.ts +1 -1
  101. package/src/runtime/pi-args.ts +3 -3
  102. package/src/runtime/pi-json-output.ts +4 -3
  103. package/src/runtime/pi-spawn.ts +7 -1
  104. package/src/runtime/pipeline-runner.ts +1 -4
  105. package/src/runtime/post-checks.ts +0 -1
  106. package/src/runtime/retry-runner.ts +1 -1
  107. package/src/runtime/run-coalesced-task-group.ts +18 -23
  108. package/src/runtime/scheduler.ts +2 -0
  109. package/src/runtime/session-resources.ts +1 -1
  110. package/src/runtime/settings-store.ts +31 -1
  111. package/src/runtime/skill-effectiveness.ts +2 -2
  112. package/src/runtime/stale-reconciler.ts +23 -53
  113. package/src/runtime/subagent-manager.ts +19 -3
  114. package/src/runtime/task-output-context.ts +2 -1
  115. package/src/runtime/task-runner/prompt-builder.ts +3 -3
  116. package/src/runtime/task-runner/run-projection.ts +3 -1
  117. package/src/runtime/task-runner/state-helpers.ts +13 -1
  118. package/src/runtime/task-runner.ts +31 -6
  119. package/src/runtime/team-runner.ts +8 -2
  120. package/src/runtime/verification-gates.ts +54 -5
  121. package/src/schema/config-schema.ts +19 -1
  122. package/src/schema/team-tool-schema.ts +2 -0
  123. package/src/skills/discover-skills.ts +25 -15
  124. package/src/state/active-run-registry.ts +0 -1
  125. package/src/state/atomic-write.ts +37 -11
  126. package/src/state/blob-store.ts +3 -9
  127. package/src/state/contracts.ts +5 -1
  128. package/src/state/crew-init.ts +3 -2
  129. package/src/state/decision-ledger.ts +2 -2
  130. package/src/state/event-log-rotation.ts +1 -1
  131. package/src/state/event-log.ts +4 -3
  132. package/src/state/gitignore-manager.ts +2 -1
  133. package/src/state/health-store.ts +5 -2
  134. package/src/state/instinct-store.ts +11 -2
  135. package/src/state/locks.ts +132 -49
  136. package/src/state/mailbox.ts +3 -4
  137. package/src/state/observation-store.ts +1 -13
  138. package/src/state/run-cache.ts +12 -12
  139. package/src/state/run-graph.ts +2 -1
  140. package/src/state/run-metrics.ts +1 -1
  141. package/src/state/schedule.ts +7 -1
  142. package/src/state/state-store.ts +9 -3
  143. package/src/state/tiered-eval.ts +1 -1
  144. package/src/state/worker-atomic-writer.ts +1 -0
  145. package/src/ui/dynamic-border.ts +13 -3
  146. package/src/ui/live-conversation-overlay.ts +7 -27
  147. package/src/ui/live-duration.ts +2 -2
  148. package/src/ui/live-run-sidebar.ts +56 -32
  149. package/src/ui/mascot.ts +19 -13
  150. package/src/ui/powerbar-publisher.ts +244 -178
  151. package/src/ui/render-coalescer.ts +44 -3
  152. package/src/ui/render-diff.ts +2 -2
  153. package/src/ui/run-dashboard.ts +122 -21
  154. package/src/ui/run-event-bus.ts +66 -0
  155. package/src/ui/run-snapshot-cache.ts +84 -81
  156. package/src/ui/settings-overlay.ts +3 -3
  157. package/src/ui/status-colors.ts +24 -0
  158. package/src/ui/tool-progress-formatter.ts +1 -1
  159. package/src/ui/tool-render.ts +3 -3
  160. package/src/ui/transcript-cache.ts +5 -10
  161. package/src/ui/transcript-entries.ts +68 -72
  162. package/src/ui/transcript-viewer.ts +54 -14
  163. package/src/ui/widget/index.ts +26 -15
  164. package/src/ui/widget/widget-model.ts +18 -9
  165. package/src/ui/widget/widget-renderer.ts +2 -5
  166. package/src/utils/fingerprint.ts +1 -1
  167. package/src/utils/paths.ts +1 -1
  168. package/src/utils/project-detector.ts +1 -1
  169. package/src/worktree/worktree-manager.ts +47 -19
  170. package/src/state/atomic-write-v2.ts +0 -108
@@ -1,4 +1,5 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomBytes } from "node:crypto";
2
3
 
3
4
  export interface CorrelationContext {
4
5
  traceId: string;
@@ -7,7 +8,6 @@ export interface CorrelationContext {
7
8
  }
8
9
 
9
10
  const storage = new AsyncLocalStorage<CorrelationContext>();
10
- let spanCounter = 0;
11
11
 
12
12
  export function withCorrelation<T>(ctx: CorrelationContext, fn: () => T): T {
13
13
  return storage.run(ctx, fn);
@@ -18,8 +18,9 @@ export function getCurrentContext(): CorrelationContext | undefined {
18
18
  }
19
19
 
20
20
  export function newSpanId(runId: string, taskId = "main"): string {
21
- spanCounter += 1;
22
- return `${runId}:${taskId}:${spanCounter}`;
21
+ // OBS-4: random 8-hex-char suffix — bounded length, collision-resistant across
22
+ // module reloads (the old monotonic counter collided after HMR and grew unboundedly).
23
+ return `${runId}:${taskId}:${randomBytes(4).toString("hex")}`;
23
24
  }
24
25
 
25
26
  export function childCorrelation(runId: string, taskId: string): CorrelationContext {
@@ -14,6 +14,7 @@ export interface CrewEvent {
14
14
  type CrewEventListener = (event: CrewEvent) => void;
15
15
 
16
16
  class EventBus {
17
+ private static readonly MAX_LISTENERS_PER_EVENT = 100;
17
18
  private listeners = new Map<CrewEventType, Set<CrewEventListener>>();
18
19
  private static _instance?: EventBus;
19
20
 
@@ -54,7 +55,15 @@ class EventBus {
54
55
  if (!this.listeners.has(type)) {
55
56
  this.listeners.set(type, new Set());
56
57
  }
57
- this.listeners.get(type)!.add(listener);
58
+ const set = this.listeners.get(type)!;
59
+ if (set.size >= EventBus.MAX_LISTENERS_PER_EVENT) {
60
+ logInternalError(
61
+ "event-bus.listener-cap",
62
+ new Error(`Max ${EventBus.MAX_LISTENERS_PER_EVENT} listeners reached for event type '${type}'`),
63
+ `type=${type} currentCount=${set.size}`,
64
+ );
65
+ }
66
+ set.add(listener);
58
67
 
59
68
  return () => {
60
69
  this.listeners.get(type)?.delete(listener);
@@ -37,7 +37,7 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
37
37
  const taskCount = registry.counter("crew.task.count", "Total tasks by status");
38
38
  const subagentCount = registry.counter("crew.subagent.count", "Total subagent records by status");
39
39
  const mailboxCount = registry.counter("crew.mailbox.count", "Total mailbox messages by direction");
40
- const retryAttemptCount = registry.counter("crew.task.retry_attempt_total", "Retry attempts by run and task");
40
+ const retryAttemptCount = registry.counter("crew.task.retry_attempt_total", "Total retry attempts");
41
41
  const deadletterCount = registry.counter("crew.task.deadletter_total", "Deadletter triggers by reason");
42
42
  const overflowCount = registry.counter("crew.task.overflow_phase_total", "Overflow recovery phase transitions");
43
43
  const supervisorContactCount = registry.counter("crew.task.supervisor_contact_total", "Supervisor contact requests by reason");
@@ -89,12 +89,12 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
89
89
  [
90
90
  "crew.task.retry_attempt",
91
91
  (data) => {
92
- const item = recordValue(data);
93
92
  taskCount.inc({ status: "retry" });
94
- retryAttemptCount.inc({
95
- runId: stringValue(item.runId, "unknown"),
96
- taskId: stringValue(item.taskId, "unknown"),
97
- });
93
+ // Per-run/task detail lives in the event log (crew.task.retry_attempt).
94
+ // Using runId/taskId as METRIC labels would blow up cardinality (each unique
95
+ // run+task pair = a new series, silently LRU-evicted at MAX_LABEL_COMBINATIONS
96
+ // = 10_000), making the counter unreliable for aggregation.
97
+ retryAttemptCount.inc({});
98
98
  },
99
99
  ],
100
100
  [
@@ -121,8 +121,9 @@ export function wireEventToMetrics(events: ExtensionAPI["events"] | undefined, r
121
121
  (data) => {
122
122
  const item = recordValue(data);
123
123
  supervisorContactCount.inc({
124
+ // reason is low-cardinality; taskId is NOT (unique per task) — dropped to
125
+ // avoid cardinality blowup (OBS-1). Per-task detail is in the event log.
124
126
  reason: stringValue(item.reason, "unknown"),
125
- taskId: stringValue(item.taskId, "unknown"),
126
127
  });
127
128
  },
128
129
  ],
@@ -3,7 +3,7 @@ import type { MetricSnapshot } from "../metrics-primitives.ts";
3
3
  export interface MetricExporter {
4
4
  name: string;
5
5
  push(snapshots: MetricSnapshot[]): Promise<void>;
6
- dispose(): void;
6
+ dispose(): Promise<void>;
7
7
  }
8
8
 
9
9
  export class CompositeExporter implements MetricExporter {
@@ -18,13 +18,10 @@ export class CompositeExporter implements MetricExporter {
18
18
  await Promise.allSettled(this.exporters.map((exporter) => exporter.push(snapshots)));
19
19
  }
20
20
 
21
- dispose(): void {
22
- for (const exporter of this.exporters) {
23
- try {
24
- exporter.dispose();
25
- } catch {
26
- // Best-effort cleanup; one exporter failing shouldn't prevent others.
27
- }
28
- }
21
+ async dispose(): Promise<void> {
22
+ // OBS-3: await allSettled so an async exporter cleanup (e.g. OTLPExporter awaiting
23
+ // an in-flight HTTP push) isn't cut short. Interface contract changed from `void`
24
+ // to `Promise<void>` because exporters may have async teardown.
25
+ await Promise.allSettled(this.exporters.map((exporter) => exporter.dispose()));
29
26
  }
30
27
  }
@@ -1,3 +1,4 @@
1
+ import { lookup } from "node:dns/promises";
1
2
  import { promisify } from "node:util";
2
3
  import { gzip } from "node:zlib";
3
4
  import { logInternalError } from "../../utils/internal-error.ts";
@@ -100,6 +101,76 @@ export function validateEndpoint(endpoint: string): void {
100
101
  }
101
102
  }
102
103
 
104
+ /**
105
+ * CFG-6: classify a single IP address (IPv4 or IPv6) as private / reserved /
106
+ * loopback / link-local. Used by the runtime DNS-rebinding guard below to
107
+ * verify the addresses the endpoint hostname resolves to. Returns true for
108
+ * addresses that must never receive a metrics push. The full literal checks
109
+ * in `validateEndpoint` use the same predicates for direct IP targets.
110
+ */
111
+ export function isPrivateIpAddress(address: string): boolean {
112
+ const lower = address.toLowerCase();
113
+ // IPv4 literal
114
+ const ipv4 = lower.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
115
+ if (ipv4) {
116
+ const o0 = Number(ipv4[1]);
117
+ const o1 = Number(ipv4[2]);
118
+ if (o0 === 127 || o0 === 0) return true;
119
+ if (o0 === 10) return true;
120
+ if (o0 === 172 && o1 >= 16 && o1 <= 31) return true;
121
+ if (o0 === 192 && o1 === 168) return true;
122
+ if (o0 === 169 && o1 === 254) return true;
123
+ return false;
124
+ }
125
+ // IPv6 (without surrounding brackets, as `dns.lookup` returns them)
126
+ if (lower === "::1" || lower === "::") return true;
127
+ if (lower.startsWith("fd") || lower.startsWith("fc")) return true;
128
+ if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true;
129
+ if (lower.startsWith("fec") || lower.startsWith("fed") || lower.startsWith("fee") || lower.startsWith("fef")) return true;
130
+ if (lower.startsWith("ff")) return true;
131
+ if (lower.startsWith("::ffff:")) return true;
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * CFG-6: DNS-rebinding guard. `validateEndpoint` runs once at exporter
137
+ * construction and only inspects the literal hostname; an attacker who
138
+ * controls the DNS for a public hostname can rebind it to a private IP
139
+ * (e.g. 169.254.169.254 cloud metadata) between construction and the actual
140
+ * fetch. Re-resolve the hostname at push time and reject if any returned
141
+ * address falls into a private/reserved range. Resolution failures are
142
+ * allowed to fall through so the underlying `fetch` surfaces the error.
143
+ */
144
+ export async function assertResolvedAddressSafe(endpoint: string): Promise<void> {
145
+ let url: URL;
146
+ try {
147
+ url = new URL(endpoint);
148
+ } catch {
149
+ return;
150
+ }
151
+ if (url.protocol !== "http:" && url.protocol !== "https:") return;
152
+ const hostname = url.hostname.toLowerCase();
153
+ if (!hostname) return;
154
+ // Locals and literal IPs are covered by `validateEndpoint`; skip the DNS
155
+ // round-trip so we don't double-report errors the sync check already
156
+ // produces.
157
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) return;
158
+ if (hostname.startsWith("[")) return;
159
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return;
160
+ let addresses: { address: string }[];
161
+ try {
162
+ addresses = await lookup(hostname, { all: true, verbatim: true });
163
+ } catch {
164
+ // Let the fetch fail with a clearer error from the network layer.
165
+ return;
166
+ }
167
+ for (const { address } of addresses) {
168
+ if (isPrivateIpAddress(address)) {
169
+ throw new Error(`OTLP endpoint resolves to private/reserved address ${address} (DNS rebinding blocked): ${endpoint}`);
170
+ }
171
+ }
172
+ }
173
+
103
174
  // FIX (Round 15): Cap the number of snapshots per push to prevent OOM when
104
175
  // the metric registry has grown large. The OTLP HTTP spec allows many metrics
105
176
  // in one payload, but a single push > 10_000 metrics would balloon the
@@ -210,6 +281,16 @@ export class OTLPExporter implements MetricExporter {
210
281
 
211
282
  async push(snapshots: MetricSnapshot[]): Promise<void> {
212
283
  try {
284
+ // CFG-6: re-validate the endpoint after DNS resolution to block
285
+ // rebinding from a public hostname to a private/metadata IP
286
+ // between construction time and the actual fetch. Failures are
287
+ // logged and the push is skipped — no metrics reach the bad host.
288
+ try {
289
+ await assertResolvedAddressSafe(this.opts.endpoint);
290
+ } catch (error) {
291
+ logInternalError("otlp-export-ssrf", error);
292
+ return;
293
+ }
213
294
  // FIX (Round 15): Cap snapshots to a safe size to avoid OOM and
214
295
  // oversized HTTP payloads. Log a warning if we are truncating.
215
296
  let toSend = snapshots;
@@ -29,6 +29,10 @@ export class TimeWindowedCounter {
29
29
  this.prune();
30
30
  }
31
31
 
32
+ // PERF (OBS-5): This method is O(N) per call because it linearly scans
33
+ // all events and calls labelKey() (which uses JSON.stringify) on each.
34
+ // A more efficient aggregation (e.g., pre-aggregated label buckets or a
35
+ // sliding-window histogram) is planned to replace this hot path.
32
36
  count(labels: MetricLabels = {}, durationMs = this.windowMs): number {
33
37
  const now = this.now();
34
38
  this.pruneAt(now);
@@ -6,7 +6,7 @@ import type { MetricRegistry } from "./metric-registry.ts";
6
6
  import type { MetricSnapshot } from "./metrics-primitives.ts";
7
7
 
8
8
  export interface MetricSink {
9
- writeSnapshot(snapshots: MetricSnapshot[]): void;
9
+ writeSnapshot(snapshots: MetricSnapshot[]): void | Promise<void>;
10
10
  dispose(): void;
11
11
  }
12
12
 
@@ -54,19 +54,29 @@ export function createMetricFileSink(opts: MetricFileSinkOptions): MetricSink {
54
54
  fdDate = date;
55
55
  return fd;
56
56
  };
57
- const writeSnapshot = (snapshots: MetricSnapshot[]): void => {
57
+ const writeSnapshot = (snapshots: MetricSnapshot[]): Promise<void> => {
58
58
  try {
59
59
  const now = new Date();
60
60
  const date = now.toISOString().slice(0, 10);
61
61
  const redacted = redactSecrets(snapshots);
62
62
  if (!Array.isArray(redacted)) {
63
63
  logInternalError("metric-sink.type", new Error("redactSecrets did not return an array"), `got=${typeof redacted}`);
64
- return;
64
+ return Promise.resolve();
65
65
  }
66
66
  const target = ensureFd(date);
67
- fs.writeSync(target, `${JSON.stringify({ exportedAt: now.toISOString(), snapshots: redacted as MetricSnapshot[] })}\n`);
67
+ const line = `${JSON.stringify({ exportedAt: now.toISOString(), snapshots: redacted as MetricSnapshot[] })}\n`;
68
+ // Async write to avoid blocking the main thread on the 60s tick.
69
+ // Return a promise that resolves once the write callback fires so
70
+ // tests (and any callers awaiting) can synchronize on completion.
71
+ return new Promise<void>((resolve) => {
72
+ fs.write(target, line, (err) => {
73
+ if (err) logInternalError("metric-sink.asyncWrite", err);
74
+ resolve();
75
+ });
76
+ });
68
77
  } catch (error) {
69
78
  logInternalError("metric-sink.write", error);
79
+ return Promise.resolve();
70
80
  }
71
81
  };
72
82
  const timer = setInterval(() => writeSnapshot(opts.registry.snapshot()), opts.intervalMs ?? 60_000);
@@ -170,6 +170,21 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
170
170
  if (typeof payload.max_tokens === "number" && payload.max_tokens > maxTokensCap) {
171
171
  payload.max_tokens = maxTokensCap;
172
172
  }
173
+ // Also cap newer field names used by some providers
174
+ if (typeof payload.max_completion_tokens === "number" && payload.max_completion_tokens > maxTokensCap) {
175
+ payload.max_completion_tokens = maxTokensCap;
176
+ }
177
+ if (typeof payload.max_output_tokens === "number" && payload.max_output_tokens > maxTokensCap) {
178
+ payload.max_output_tokens = maxTokensCap;
179
+ }
180
+ const generationConfig = payload.generationConfig as Record<string, unknown> | undefined;
181
+ if (
182
+ generationConfig &&
183
+ typeof generationConfig.max_output_tokens === "number" &&
184
+ generationConfig.max_output_tokens > maxTokensCap
185
+ ) {
186
+ generationConfig.max_output_tokens = maxTokensCap;
187
+ }
173
188
  });
174
189
  }
175
190
 
@@ -21,7 +21,7 @@ import { saveRunManifestAsync } from "../state/state-store.ts";
21
21
  import type { TeamRunManifest, TeamTaskState } from "../state/types.ts";
22
22
  import type { TeamConfig } from "../teams/team-config.ts";
23
23
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
24
- import { getPlanTemplate, listPlanTemplates, renderPlanTemplate } from "./plan-templates.ts";
24
+ import { getPlanTemplate, renderPlanTemplate } from "./plan-templates.ts";
25
25
  import { refreshTaskGraphQueues } from "./task-graph-scheduler.ts";
26
26
 
27
27
  export interface AdaptivePlanTask {
@@ -1,9 +1,9 @@
1
- import { type SpawnOptions, spawn } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import { createRequire } from "node:module";
4
4
  import * as path from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { appendEvent, appendEventAsync } from "../state/event-log.ts";
6
+ import { appendEventAsync } from "../state/event-log.ts";
7
7
  import type { TeamRunManifest } from "../state/types.ts";
8
8
  import { WINDOWS_ESSENTIAL_ENV_VARS } from "../utils/env-allowlist.ts";
9
9
  import { sanitizeEnvSecrets } from "../utils/env-filter.ts";
@@ -356,8 +356,17 @@ export async function spawnBackgroundTeamRun(manifest: TeamRunManifest): Promise
356
356
  );
357
357
  // Best-effort: unregister when child exits. Background-runner writes
358
358
  // the marker file before it dies, so we unregister on the next
359
- // cleanup tick. But the child "exit" event won't fire because we
360
- // unref'd and the stdio is piped + ignored.
359
+ // cleanup tick. The child "exit" event may fire after unref() when
360
+ // stdio is piped; register handler defensively.
361
+ if (child.pid) {
362
+ child.on("exit", () => {
363
+ try {
364
+ unregisterWorker(child.pid!);
365
+ } catch {
366
+ /* best-effort */
367
+ }
368
+ });
369
+ }
361
370
  }
362
371
 
363
372
  return { pid: child.pid, logPath };
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { allAgents, discoverAgents } from "../agents/discover-agents.ts";
4
4
  import { loadConfig } from "../config/config.ts";
5
+ import { atomicWriteFile } from "../state/atomic-write.ts";
5
6
  import { appendEvent } from "../state/event-log.ts";
6
7
  import { withRunLockSync } from "../state/locks.ts";
7
8
  import { createRunPaths, loadRunManifestById, saveRunManifestAsync, updateRunStatus } from "../state/state-store.ts";
@@ -70,7 +71,7 @@ function startHeartbeat(stateRoot: string, eventsPath: string, runId: string): (
70
71
  const writeHeartbeat = (): void => {
71
72
  try {
72
73
  const mem = process.memoryUsage();
73
- fs.writeFileSync(
74
+ atomicWriteFile(
74
75
  heartbeatPath,
75
76
  JSON.stringify({
76
77
  pid: process.pid,
@@ -81,7 +82,6 @@ function startHeartbeat(stateRoot: string, eventsPath: string, runId: string): (
81
82
  rssMb: Math.round(mem.rss / 1024 / 1024),
82
83
  },
83
84
  }),
84
- "utf-8",
85
85
  );
86
86
  } catch {
87
87
  /* ignore — best-effort */
@@ -454,27 +454,36 @@ async function main(): Promise<void> {
454
454
  }
455
455
  }
456
456
  // Hook Node.js abort — if process.exit is called with code 1 (uncaught exception, assert failure)
457
- // we log it before exiting so it appears in background.log
458
- const origExit = process.exit.bind(process);
459
- // Intercept all exit(code) calls to log them as async.exit events before exiting.
460
- // This surfaces uncaught exceptions / early exits that would otherwise vanish silently.
461
- process.exit = ((code?: number | string): never => {
462
- const runId2 = argValue("--run-id");
463
- const codeStr = code === undefined ? "<none>" : String(code);
464
- if (runId2 && manifest.eventsPath) {
465
- try {
466
- appendEvent(manifest.eventsPath, {
467
- type: "async.exit",
468
- runId: runId2,
469
- message: `Background runner exit(${codeStr}) pid=${process.pid}`,
470
- data: { code, pid: process.pid },
471
- });
472
- } catch {
473
- /* best-effort */
457
+ // we log it before exiting so it appears in background.log.
458
+ // NOTE: process.exit is monkey-patched here (not using process.on('exit')) because
459
+ // we need the log write to happen synchronously before the process actually exits.
460
+ // process.on('exit') handlers run too late for I/O to complete reliably.
461
+ // Wrapped in try/catch to guard against failures in the patch itself.
462
+ try {
463
+ const origExit = process.exit.bind(process);
464
+ // Intercept all exit(code) calls to log them as async.exit events before exiting.
465
+ // This surfaces uncaught exceptions / early exits that would otherwise vanish silently.
466
+ process.exit = ((code?: number | string): never => {
467
+ const runId2 = argValue("--run-id");
468
+ const codeStr = code === undefined ? "<none>" : String(code);
469
+ if (runId2 && manifest.eventsPath) {
470
+ try {
471
+ appendEvent(manifest.eventsPath, {
472
+ type: "async.exit",
473
+ runId: runId2,
474
+ message: `Background runner exit(${codeStr}) pid=${process.pid}`,
475
+ data: { code, pid: process.pid },
476
+ });
477
+ } catch {
478
+ /* best-effort */
479
+ }
474
480
  }
475
- }
476
- return origExit(code);
477
- }) as typeof process.exit;
481
+ return origExit(code);
482
+ }) as typeof process.exit;
483
+ } catch {
484
+ // If patching process.exit fails (e.g. frozen process object), continue
485
+ // without the exit log — the run will still proceed normally.
486
+ }
478
487
 
479
488
  // Setup unhandled rejection guard FIRST — must be before any async operations
480
489
  // that might produce unhandled rejections during cleanup. Without this, any unhandled
@@ -632,7 +641,7 @@ async function main(): Promise<void> {
632
641
  // LAZY: defer dynamic import of ../workflows/discover-workflows.ts to its call site.
633
642
  const { allWorkflows, discoverWorkflows } = await import("../workflows/discover-workflows.ts");
634
643
  const wf = allWorkflows(discoverWorkflows(manifest.cwd)).find((w) => w.name === manifest.workflow);
635
- if (!wf || wf.runtime !== "dynamic" || !wf.dynamicScript)
644
+ if (wf?.runtime !== "dynamic" || !wf.dynamicScript)
636
645
  throw new Error(
637
646
  `runKind="dynamic-workflow" but workflow '${manifest.workflow}' is not dynamic (runId=${manifest.runId})`,
638
647
  );
@@ -1,4 +1,4 @@
1
- import type { AgentConfig, ResourceSource } from "../agents/agent-config.ts";
1
+ import type { AgentConfig } from "../agents/agent-config.ts";
2
2
  import { discoverAgents } from "../agents/discover-agents.ts";
3
3
  import type { PiTeamsConfig } from "../config/config.ts";
4
4
  import { discoverSkills, getLastDiscoveryDiagnostics } from "../skills/discover-skills.ts";
@@ -301,16 +301,16 @@ export class ChainRunner {
301
301
  };
302
302
 
303
303
  // Set step type based on matching pattern with type safety
304
- if (teamMatch && teamMatch[1]) {
304
+ if (teamMatch?.[1]) {
305
305
  parsed.team = this.sanitizeIdentifier(teamMatch[1]);
306
306
  }
307
- if (workflowMatch && workflowMatch[1]) {
307
+ if (workflowMatch?.[1]) {
308
308
  parsed.workflow = this.sanitizeIdentifier(workflowMatch[1]);
309
309
  }
310
- if (templateMatch && templateMatch[1]) {
310
+ if (templateMatch?.[1]) {
311
311
  parsed.template = this.sanitizeIdentifier(templateMatch[1]);
312
312
  }
313
- if (inlineMatch && inlineMatch[1]) {
313
+ if (inlineMatch?.[1]) {
314
314
  parsed.inlineGoal = this.sanitizeInlineGoal(inlineMatch[1]);
315
315
  }
316
316
 
@@ -334,7 +334,7 @@ export class ChainRunner {
334
334
  const timeoutStr = this.extractFlag(step, "timeout");
335
335
  if (timeoutStr) {
336
336
  const timeoutMs = parseInt(timeoutStr, 10);
337
- if (!isNaN(timeoutMs) && timeoutMs > 0 && timeoutMs <= 86400000) {
337
+ if (!Number.isNaN(timeoutMs) && timeoutMs > 0 && timeoutMs <= 86400000) {
338
338
  parsed.timeout = timeoutMs * 1000; // Convert seconds to ms
339
339
  }
340
340
  }
@@ -1,5 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
+ import { atomicWriteFile } from "../state/atomic-write.ts";
3
4
  import { logInternalError } from "../utils/internal-error.ts";
4
5
  import { projectCrewRoot } from "../utils/paths.ts";
5
6
  import { assertSafePathId } from "../utils/safe-paths.ts";
@@ -73,22 +74,11 @@ export class FileCheckpointStore implements CheckpointStore {
73
74
  // other's data (silent corruption) and the second rename hits ENOENT
74
75
  // (silent data loss). Including taskId + pid + timestamp guarantees
75
76
  // uniqueness across processes and across tasks.
76
- const tmp = path.join(
77
- this.checkpointDir(),
78
- `.tmp.${checkpoint.taskId}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`,
79
- );
80
- fs.writeFileSync(tmp, JSON.stringify(checkpoint, null, 2), "utf-8");
81
- fs.renameSync(tmp, p);
82
- // fsync parent directory to ensure the rename is durable
83
- const dirFd = fs.openSync(this.checkpointDir(), "r");
84
- try {
85
- fs.fsyncSync(dirFd);
86
- } catch {
87
- // EPERM on Windows: opening a directory and fsync-ing it is not supported
88
- // on all Windows configurations. Best-effort — the rename is still atomic.
89
- } finally {
90
- fs.closeSync(dirFd);
91
- }
77
+ // atomicWriteFile handles temp + write + fsync(data) + rename + fsync(parent)
78
+ // with a UUID-based unique temp name (cross-process safe). The previous
79
+ // hand-rolled version missed data-fsync (ST-5), risking empty/stale content
80
+ // after power loss.
81
+ atomicWriteFile(p, JSON.stringify(checkpoint, null, 2));
92
82
  }
93
83
 
94
84
  load(runId: string, taskId: string): Checkpoint | null {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * child-pi-constants.ts — Shared timing and capture constants for child-pi runtime.
3
+ *
4
+ * Extracted from child-pi.ts (H-7 decomposition, step 3). Zero behavior change.
5
+ *
6
+ * These constants are shared between runChildPi (in child-pi.ts) and the kill
7
+ * helpers (in child-pi-kill.ts). Centralizing them here avoids circular imports
8
+ * and makes the timing budget configurable from one place.
9
+ */
10
+
11
+ import { DEFAULT_CHILD_PI } from "../config/defaults.ts";
12
+
13
+ /** Post-exit window during which stdio is guarded against late writes. */
14
+ export const POST_EXIT_STDIO_GUARD_MS = DEFAULT_CHILD_PI.postExitStdioGuardMs;
15
+
16
+ /** Maximum time to wait for a final assistant event after the last stdout byte. */
17
+ export const FINAL_DRAIN_MS = DEFAULT_CHILD_PI.finalDrainMs;
18
+
19
+ /** Time after SIGTERM to escalate to SIGKILL. */
20
+ export const HARD_KILL_MS = DEFAULT_CHILD_PI.hardKillMs;
21
+
22
+ /** Maximum time with no output before the child is considered unresponsive. */
23
+ export const RESPONSE_TIMEOUT_MS = DEFAULT_CHILD_PI.responseTimeoutMs;
24
+
25
+ /**
26
+ * Maximum size (bytes) for the ChildPiLineObserver's line accumulation buffer.
27
+ * When exceeded, the buffer is force-flushed to prevent unbounded memory growth
28
+ * from chatty child processes that produce output without newlines.
29
+ */
30
+ export const MAX_LINE_BUFFER_BYTES = 1024 * 1024; // 1 MB
31
+
32
+ /** Maximum characters for assistant text fragments in compacted events. */
33
+ export const MAX_ASSISTANT_TEXT_CHARS = DEFAULT_CHILD_PI.maxAssistantTextChars;
34
+
35
+ /** Maximum characters for tool-result fragments in compacted events. */
36
+ export const MAX_TOOL_RESULT_CHARS = DEFAULT_CHILD_PI.maxToolResultChars;
37
+
38
+ /** Maximum characters for tool-input fragments in compacted events. */
39
+ export const MAX_TOOL_INPUT_CHARS = DEFAULT_CHILD_PI.maxToolInputChars;
40
+
41
+ /** Maximum characters for general compactable content (used by TruncationStage). */
42
+ export const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;