pi-crew 0.9.26 → 0.9.27

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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/NOTICE.md +14 -0
  3. package/assets/crew-vibes.ttf +0 -0
  4. package/assets/runner-spritesheet.png +0 -0
  5. package/dist/build-meta.json +255 -40
  6. package/dist/index.mjs +1564 -241
  7. package/dist/index.mjs.map +4 -4
  8. package/docs/perf/optimization-plan-2026-07-verified.md +396 -0
  9. package/package.json +9 -2
  10. package/scripts/bench-check.mjs +96 -0
  11. package/scripts/bench-cold-start.mjs +216 -0
  12. package/scripts/build-bundle.mjs +84 -0
  13. package/scripts/build-crew-vibes-font.py +328 -0
  14. package/scripts/check-all-skills.ts +294 -0
  15. package/scripts/check-bundle-staleness.mjs +97 -0
  16. package/scripts/check-conflict-markers.mjs +91 -0
  17. package/scripts/check-lazy-imports.mjs +28 -0
  18. package/scripts/install-crew-vibes-font.mjs +91 -0
  19. package/scripts/postinstall.mjs +47 -0
  20. package/scripts/profile-startup.mjs +125 -0
  21. package/scripts/release-smoke.mjs +74 -0
  22. package/scripts/run-bench.mjs +47 -0
  23. package/scripts/run-real-chain.ts +41 -0
  24. package/scripts/test-issue-29-crash.ts +111 -0
  25. package/scripts/test-issue-29-e2e.ts +183 -0
  26. package/scripts/test-issue-29-real-runtime.ts +330 -0
  27. package/scripts/test-issue-29-real-tasks.ts +387 -0
  28. package/scripts/test-issue-29-team-tool.ts +105 -0
  29. package/scripts/test-runner.mjs +74 -0
  30. package/scripts/verify-flicker-fix.ts +109 -0
  31. package/scripts/verify-skill.ts +550 -0
  32. package/scripts/watch-bundle.mjs +259 -0
  33. package/scripts/watchdog-harness.ts +119 -0
  34. package/src/agents/discover-agents.ts +6 -1
  35. package/src/config/defaults.ts +6 -0
  36. package/src/extension/crew-vibes/cat-frames.ts +18 -0
  37. package/src/extension/crew-vibes/config.ts +195 -0
  38. package/src/extension/crew-vibes/figures.ts +94 -0
  39. package/src/extension/crew-vibes/font-detect.ts +58 -0
  40. package/src/extension/crew-vibes/index.ts +396 -0
  41. package/src/extension/crew-vibes/provider-usage.ts +330 -0
  42. package/src/extension/crew-vibes/render.ts +206 -0
  43. package/src/extension/crew-vibes/speed.ts +286 -0
  44. package/src/extension/knowledge-injection.ts +12 -3
  45. package/src/extension/management.ts +23 -3
  46. package/src/extension/register.ts +7 -0
  47. package/src/runtime/child-pi.ts +117 -10
  48. package/src/runtime/crew-agent-records.ts +10 -3
  49. package/src/runtime/retry-executor.ts +4 -1
  50. package/src/runtime/task-runner/state-helpers.ts +9 -30
  51. package/src/runtime/team-runner.ts +5 -3
  52. package/src/state/atomic-write.ts +153 -49
  53. package/src/state/event-log.ts +16 -10
  54. package/src/state/locks.ts +7 -8
  55. package/src/state/mailbox.ts +15 -4
  56. package/src/state/state-store.ts +39 -10
  57. package/src/teams/discover-teams.ts +56 -1
  58. package/src/utils/safe-paths.ts +2 -1
  59. package/src/workflows/discover-workflows.ts +72 -1
@@ -0,0 +1,286 @@
1
+ import type { SpeedConfig } from "./config.ts";
2
+
3
+ /**
4
+ * Token-speed engine ported from pi-speeed (MIT, attribution in NOTICE.md).
5
+ * Tracks output tok/s with a sliding window and suppresses unreliable
6
+ * burst-only readings. No stats/font handling — just the live + session avg.
7
+ */
8
+
9
+ export type TokenEvent = { time: number; tokens: number };
10
+
11
+ const COMPACTION_THRESHOLD = 5000;
12
+
13
+ export type EngineConfig = Pick<SpeedConfig, "slidingWindowMs" | "minReliableDurationMs" | "maxDisplayTokS">;
14
+
15
+ export function estimateTokensFromDelta(text: string): number {
16
+ if (!text) return 0;
17
+ const matches = text.match(/\w+|[^\s\w]/g);
18
+ return matches ? matches.length : 0;
19
+ }
20
+
21
+ export class TokenSpeedEngine {
22
+ private _isStreaming = false;
23
+ private _tokenCount = 0;
24
+ private _startTime = 0;
25
+ private _endTime = 0;
26
+ private _events: TokenEvent[] = [];
27
+ private _windowStartIndex = 0;
28
+ private _lastStableTokS = 0;
29
+ private _lastUsageOutput = 0;
30
+ private _config: EngineConfig;
31
+
32
+ constructor(config: EngineConfig) {
33
+ this._config = config;
34
+ }
35
+
36
+ updateConfig(config: EngineConfig): void {
37
+ this._config = config;
38
+ }
39
+
40
+ get isStreaming(): boolean {
41
+ return this._isStreaming;
42
+ }
43
+
44
+ get tokenCount(): number {
45
+ return this._tokenCount;
46
+ }
47
+
48
+ get elapsedMs(): number {
49
+ if (this._startTime === 0) return 0;
50
+ return this._isStreaming ? Date.now() - this._startTime : this._endTime - this._startTime;
51
+ }
52
+
53
+ get avgTokS(): number {
54
+ const elapsedSec = this.elapsedMs / 1000;
55
+ if (elapsedSec <= 0) return 0;
56
+ return this._tokenCount / elapsedSec;
57
+ }
58
+
59
+ sanitizeTokS(value: number | null, durationMs = this.elapsedMs): number | null {
60
+ if (value === null || !Number.isFinite(value) || value <= 0) return null;
61
+ if (durationMs < this._config.minReliableDurationMs) return null;
62
+ if (value > this._config.maxDisplayTokS) return null;
63
+ return value;
64
+ }
65
+
66
+ get tokS(): number {
67
+ const candidate = this.rawTokS;
68
+ const stable = this.sanitizeTokS(candidate);
69
+ if (stable !== null) this._lastStableTokS = stable;
70
+ return this._lastStableTokS;
71
+ }
72
+
73
+ get rawTokS(): number {
74
+ if (this.elapsedMs < this._config.slidingWindowMs) return this.avgTokS;
75
+ if (!this._isStreaming) return this.avgTokS;
76
+
77
+ const now = Date.now();
78
+ const windowStart = now - this._config.slidingWindowMs;
79
+
80
+ while (this._windowStartIndex < this._events.length && this._events[this._windowStartIndex].time < windowStart) {
81
+ this._windowStartIndex++;
82
+ }
83
+
84
+ if (this._windowStartIndex >= this._events.length) return this.avgTokS;
85
+
86
+ let windowTokenCount = 0;
87
+ for (let i = this._windowStartIndex; i < this._events.length; i++) {
88
+ windowTokenCount += this._events[i].tokens;
89
+ }
90
+ if (windowTokenCount === 0) return this.avgTokS;
91
+
92
+ const windowDuration = (now - this._events[this._windowStartIndex].time) / 1000;
93
+ if (windowDuration <= 0) return 0;
94
+
95
+ return windowTokenCount / windowDuration;
96
+ }
97
+
98
+ start(): void {
99
+ this._tokenCount = 0;
100
+ this._isStreaming = true;
101
+ this._startTime = Date.now();
102
+ this._endTime = this._startTime;
103
+ this._events = [];
104
+ this._windowStartIndex = 0;
105
+ this._lastStableTokS = 0;
106
+ this._lastUsageOutput = 0;
107
+ }
108
+
109
+ stop(): void {
110
+ this._isStreaming = false;
111
+ this._endTime = Date.now();
112
+ this._events = [];
113
+ this._windowStartIndex = 0;
114
+ }
115
+
116
+ recordDelta(delta: string, usageOutput?: number): void {
117
+ if (!this._isStreaming) return;
118
+ if (usageOutput !== undefined && usageOutput > 0) {
119
+ // usageOutput may be cumulative across message_update events. Track the
120
+ // delta to avoid inflating _tokenCount by N× the true total.
121
+ const increment = usageOutput - this._lastUsageOutput;
122
+ this._lastUsageOutput = usageOutput;
123
+ this.recordTokens(Math.max(0, increment));
124
+ return;
125
+ }
126
+ this.recordTokens(estimateTokensFromDelta(delta));
127
+ }
128
+
129
+ reconcileTotal(tokens: number): void {
130
+ if (tokens > 0) this._tokenCount = tokens;
131
+ }
132
+
133
+ recordTokens(tokens: number): void {
134
+ if (!this._isStreaming || tokens <= 0) return;
135
+ this._tokenCount += tokens;
136
+ this._events.push({ time: Date.now(), tokens });
137
+ if (this._windowStartIndex >= COMPACTION_THRESHOLD) this.compact();
138
+ }
139
+
140
+ private compact(): void {
141
+ if (this._windowStartIndex === 0) return;
142
+ this._events = this._events.slice(this._windowStartIndex);
143
+ this._windowStartIndex = 0;
144
+ }
145
+ }
146
+
147
+ export type CompletedMessageSpeed = {
148
+ outputTokens: number;
149
+ durationMs: number;
150
+ tokS: number | null;
151
+ };
152
+
153
+ function isSuccessfulStop(stopReason: string | undefined): boolean {
154
+ return stopReason !== "error" && stopReason !== "aborted";
155
+ }
156
+
157
+ export class SpeedTracker {
158
+ private readonly engine: TokenSpeedEngine;
159
+ private lastStableTokS: number | null = null;
160
+ private sessionOutputTokens = 0;
161
+ private sessionDurationMs = 0;
162
+
163
+ constructor(config: SpeedConfig) {
164
+ this.engine = new TokenSpeedEngine(config);
165
+ }
166
+
167
+ updateConfig(config: SpeedConfig): void {
168
+ this.engine.updateConfig(config);
169
+ }
170
+
171
+ get isStreaming(): boolean {
172
+ return this.engine.isStreaming;
173
+ }
174
+
175
+ get lastTokS(): number | null {
176
+ return this.lastStableTokS;
177
+ }
178
+
179
+ resetSession(): void {
180
+ this.sessionOutputTokens = 0;
181
+ this.sessionDurationMs = 0;
182
+ }
183
+
184
+ startMessage(): void {
185
+ this.engine.start();
186
+ }
187
+
188
+ recordDelta(delta: string, usageOutput?: number): void {
189
+ this.engine.recordDelta(delta, usageOutput);
190
+ }
191
+
192
+ stopMessage(): void {
193
+ if (this.engine.isStreaming) this.engine.stop();
194
+ }
195
+
196
+ liveTokS(): number | null {
197
+ const speed = this.engine.tokS;
198
+ return speed > 0 ? speed : this.lastStableTokS;
199
+ }
200
+
201
+ sessionAvgTokS(): number | null {
202
+ return this.sessionDurationMs > 0 ? this.sessionOutputTokens / (this.sessionDurationMs / 1000) : null;
203
+ }
204
+
205
+ finishMessage(outputTokens: number, stopReason: string | undefined): CompletedMessageSpeed | null {
206
+ if (!this.engine.isStreaming) return null;
207
+
208
+ this.engine.reconcileTotal(outputTokens);
209
+ const durationMs = this.engine.elapsedMs;
210
+ const tokens = this.engine.tokenCount;
211
+ const rawAvgTokS = durationMs > 0 ? tokens / (durationMs / 1000) : null;
212
+ const tokS = this.engine.sanitizeTokS(rawAvgTokS, durationMs);
213
+ this.lastStableTokS = tokS;
214
+ this.engine.stop();
215
+
216
+ if (tokS !== null && isSuccessfulStop(stopReason)) {
217
+ this.sessionOutputTokens += tokens;
218
+ this.sessionDurationMs += durationMs;
219
+ }
220
+
221
+ return { outputTokens: tokens, durationMs, tokS };
222
+ }
223
+ }
224
+
225
+ export class SpeedAnimator {
226
+ private from: number | null = null;
227
+ private target: number | null = null;
228
+ private startedAt = 0;
229
+ private durationMs: number;
230
+
231
+ constructor(durationMs: number) {
232
+ this.durationMs = durationMs;
233
+ }
234
+
235
+ updateDuration(durationMs: number): void {
236
+ this.durationMs = durationMs;
237
+ }
238
+
239
+ reset(value: number | null = null, now = Date.now()): void {
240
+ this.from = value;
241
+ this.target = value;
242
+ this.startedAt = now;
243
+ }
244
+
245
+ setTarget(target: number | null, now = Date.now()): number | null {
246
+ if (target === null) {
247
+ this.reset(null, now);
248
+ return null;
249
+ }
250
+
251
+ const current = this.value(now);
252
+ if (current === null) {
253
+ this.reset(target, now);
254
+ return target;
255
+ }
256
+
257
+ if (this.target !== null && Math.abs(target - this.target) < 0.05) return current;
258
+
259
+ this.from = current;
260
+ this.target = target;
261
+ this.startedAt = now;
262
+ return current;
263
+ }
264
+
265
+ value(now = Date.now()): number | null {
266
+ if (this.target === null) return null;
267
+ if (this.from === null || this.durationMs <= 0) return this.target;
268
+
269
+ const progress = Math.max(0, Math.min(1, (now - this.startedAt) / this.durationMs));
270
+ if (progress >= 1) {
271
+ this.from = this.target;
272
+ return this.target;
273
+ }
274
+
275
+ return this.from + (this.target - this.from) * progress;
276
+ }
277
+
278
+ isAnimating(now = Date.now()): boolean {
279
+ return (
280
+ this.target !== null &&
281
+ this.from !== null &&
282
+ Math.abs(this.target - this.from) >= 0.05 &&
283
+ now - this.startedAt < this.durationMs
284
+ );
285
+ }
286
+ }
@@ -22,6 +22,7 @@
22
22
  */
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
+ import { logInternalError } from "../utils/internal-error.ts";
25
26
  import { projectCrewRoot } from "../utils/paths.ts";
26
27
  import type { BeforeAgentStartEvent, ExtensionAPI } from "./pi-api.ts";
27
28
 
@@ -259,6 +260,13 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
259
260
  knowledgeCache.delete(p);
260
261
  return "";
261
262
  }
263
+ // Security: reject symlinks to prevent information disclosure.
264
+ // An attacker could replace knowledge.md with a symlink to /etc/shadow
265
+ // or other sensitive files.
266
+ if (stat.isSymbolicLink) {
267
+ logInternalError("knowledge-injection.symlink", new Error(`Refusing to read symlink: ${p}`));
268
+ return "";
269
+ }
262
270
  // P5 (Round 15): mtime+size cache. readKnowledge fires on every agent
263
271
  // start (main session + every worker), re-reading the file each time.
264
272
  // For a run with N workers this is N redundant readFileSync of the same
@@ -322,10 +330,11 @@ export function readKnowledge(cwd: string, query?: KnowledgeQuery): string {
322
330
  }
323
331
 
324
332
  /** Stat helper returning undefined on error (file missing, perms, etc.). */
325
- function tryStat(p: string): { mtimeMs: number; size: number } | undefined {
333
+ function tryStat(p: string): { mtimeMs: number; size: number; isSymbolicLink: boolean } | undefined {
326
334
  try {
327
- const s = fs.statSync(p);
328
- return { mtimeMs: s.mtimeMs, size: s.size };
335
+ // Use lstatSync to detect symlinks (statSync follows them).
336
+ const s = fs.lstatSync(p);
337
+ return { mtimeMs: s.mtimeMs, size: s.size, isSymbolicLink: s.isSymbolicLink() };
329
338
  } catch {
330
339
  return undefined;
331
340
  }
@@ -3,21 +3,32 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentConfig, ResourceSource, RoutingMetadata } from "../agents/agent-config.ts";
5
5
  import { serializeAgent } from "../agents/agent-serializer.ts";
6
- import { discoverAgents } from "../agents/discover-agents.ts";
6
+ import { discoverAgents, invalidateAgentDiscoveryCache } from "../agents/discover-agents.ts";
7
7
  import type { PiTeamsConfig } from "../config/config.ts";
8
8
  import type { TeamToolParamsValue } from "../schema/team-tool-schema.ts";
9
- import { allTeams, discoverTeams } from "../teams/discover-teams.ts";
9
+ import { allTeams, discoverTeams, invalidateTeamDiscoveryCache } from "../teams/discover-teams.ts";
10
10
  import type { TeamConfig, TeamRole } from "../teams/team-config.ts";
11
11
  import { serializeTeam } from "../teams/team-serializer.ts";
12
12
  import { hasOwn, parseConfigObject, requireString, sanitizeName } from "../utils/names.ts";
13
13
  import { projectCrewRoot, userPiRoot } from "../utils/paths.ts";
14
- import { allWorkflows, discoverWorkflows } from "../workflows/discover-workflows.ts";
14
+ import { allWorkflows, discoverWorkflows, invalidateWorkflowDiscoveryCache } from "../workflows/discover-workflows.ts";
15
15
  import type { WorkflowConfig, WorkflowStep } from "../workflows/workflow-config.ts";
16
16
  import { serializeWorkflow } from "../workflows/workflow-serializer.ts";
17
17
  import { enforceDestructiveIntent } from "./team-tool/intent-policy.ts";
18
18
  import type { TeamToolDetails } from "./team-tool-types.ts";
19
19
  import { type PiTeamsToolResult, toolResult } from "./tool-result.ts";
20
20
 
21
+ /**
22
+ * Invalidate all discovery caches that may have read the on-disk resource
23
+ * file we are about to (or just did) write. Called once per create/update/delete
24
+ * to keep the 5 s TTL caches in sync without forcing a per-render full rescan.
25
+ */
26
+ function invalidateResourceCaches(): void {
27
+ invalidateAgentDiscoveryCache();
28
+ invalidateTeamDiscoveryCache();
29
+ invalidateWorkflowDiscoveryCache();
30
+ }
31
+
21
32
  interface ManagementContext {
22
33
  cwd: string;
23
34
  config?: PiTeamsConfig;
@@ -385,6 +396,9 @@ export function handleCreate(params: TeamToolParamsValue, ctx: ManagementContext
385
396
  true,
386
397
  );
387
398
  }
399
+ // F17/F15: bust the discovery caches so future powerbar/scheduler reads see
400
+ // the new file immediately (instead of after the 5 s TTL expires).
401
+ invalidateResourceCaches();
388
402
  return result(`Created ${params.resource} '${name}' at ${filePath}.`);
389
403
  }
390
404
 
@@ -507,9 +521,13 @@ export function handleUpdate(params: TeamToolParamsValue, ctx: ManagementContext
507
521
  true,
508
522
  );
509
523
  }
524
+ // F17/F15: bust the discovery caches so the renamed/updated resource is
525
+ // visible immediately. updateReferencesForRename may have rewritten peer
526
+ // files too, so we invalidate AFTER that pass completes.
510
527
  const updatedRefs = params.updateReferences
511
528
  ? updateReferencesForRename(ctx, params.resource!, current.name, nextName, source, false)
512
529
  : [];
530
+ invalidateResourceCaches();
513
531
  return result(
514
532
  [
515
533
  `Updated ${params.resource} at ${nextPath}. Backup: ${backupPath}.`,
@@ -546,5 +564,7 @@ export function handleDelete(params: TeamToolParamsValue, ctx: ManagementContext
546
564
  true,
547
565
  );
548
566
  }
567
+ // F17/F15: bust the discovery caches so the deletion is visible immediately.
568
+ invalidateResourceCaches();
549
569
  return result(`Deleted ${params.resource} at ${resolved.resource!.filePath}. Backup: ${backupPath}.`);
550
570
  }
@@ -64,6 +64,7 @@ import { registerCrewAutocomplete } from "./crew-autocomplete.ts";
64
64
  import { registerCleanupHandler } from "./crew-cleanup.ts";
65
65
  import { registerCrewInputRouter } from "./crew-input-router.ts";
66
66
  import { registerCrewShortcuts } from "./crew-shortcuts.ts";
67
+ import { registerCrewVibes } from "./crew-vibes/index.ts";
67
68
  import { type PiCrewRpcHandle, registerPiCrewRpc } from "./cross-extension-rpc.ts";
68
69
  import { registerKnowledgeInjection } from "./knowledge-injection.ts";
69
70
  import { registerCrewMessageRenderers } from "./message-renderers.ts";
@@ -1545,4 +1546,10 @@ export function registerPiTeams(pi: ExtensionAPI): void {
1545
1546
  registerContextStatusInjection(pi, {
1546
1547
  enabled: loadConfig(process.cwd()).config.reliability?.ambientStatusInjection !== false,
1547
1548
  });
1549
+
1550
+ try {
1551
+ registerCrewVibes(pi);
1552
+ } catch (err) {
1553
+ console.warn("[pi-crew] crew-vibes initialization failed:", err instanceof Error ? err.message : err);
1554
+ }
1548
1555
  }
@@ -31,6 +31,18 @@ const MAX_COMPACT_CONTENT_CHARS = DEFAULT_CHILD_PI.maxCompactContentChars;
31
31
  const activeChildProcesses = new Map<number, ChildProcess>();
32
32
  const childHardKillTimers = new Map<number, NodeJS.Timeout>();
33
33
 
34
+ // Periodic cleanup of dead child process entries to prevent memory leaks.
35
+ // If a child process never emits exit/close (zombie), the entry would leak.
36
+ setInterval(() => {
37
+ for (const [pid, child] of activeChildProcesses) {
38
+ try {
39
+ process.kill(pid, 0); // Throws ESRCH if dead
40
+ } catch {
41
+ activeChildProcesses.delete(pid);
42
+ }
43
+ }
44
+ }, 60_000).unref();
45
+
34
46
  /**
35
47
  * SEC-1: Extract a redacted stderr/stdout excerpt for embedding in lifecycle
36
48
  * events and error messages. The in-memory stdout/stderr accumulators receive
@@ -176,6 +188,10 @@ export interface ChildPiLifecycleEvent {
176
188
  stderrExcerpt?: string;
177
189
  /** Timestamp (ISO). */
178
190
  ts: string;
191
+ /** F12: optional cause for `final_drain` events. `"stdout-quiet"` indicates
192
+ * the drain was triggered by the quiet-window early-exit rather than the
193
+ * default 5 s ceiling. Other drain reasons (default) leave this undefined. */
194
+ reason?: "stdout-quiet";
179
195
  /** Phase-0 diagnostic (HB-003a): the signal that killed the child (when
180
196
  * available). Was previously discarded after building the error string. */
181
197
  signal?: string;
@@ -205,6 +221,9 @@ export interface ChildPiRunInput {
205
221
  onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
206
222
  maxDepth?: number;
207
223
  finalDrainMs?: number;
224
+ /** F12: early-exit the drain when stdout has been silent for this many ms
225
+ * after the final assistant event. Set to ≥ finalDrainMs to disable. */
226
+ finalDrainQuietMs?: number;
208
227
  hardKillMs?: number;
209
228
  responseTimeoutMs?: number;
210
229
  /** Soft limit on assistant turns — inject steer at this count. */
@@ -569,17 +588,22 @@ function compactChildPiLine(line: string): {
569
588
  export class ChildPiLineObserver {
570
589
  private buffer = "";
571
590
  private readonly input: ChildPiRunInput;
572
- /** RAW (uncapped) assistant-text fragments, accumulated in arrival order.
573
- * Mirrors {@link parsePiJsonOutput}'s textEvents/finalText extraction but
574
- * operates on the RAW event stream instead of the 16K-compacted transcript.
575
- * This is the source of the AUTHORITATIVE result.txt; the transcript stays
576
- * compacted (memory bound). */
591
+ /** F9: bounded ring buffer for RAW assistant-text fragments. Consumers
592
+ * (getRawFinalText) only read the last element, but the legacy implementation
593
+ * accumulated every fragment unconditionally, which let a verbose/long-running
594
+ * worker grow this array linearly with output. We retain the last 2 entries:
595
+ * the consumer needs the last; we keep the second-to-last only as a defensive
596
+ * fence against a race where a final event arrives just after the consumer
597
+ * read (the previous "last" is still the most-recent pre-final text in that
598
+ * window). 2 is well below any plausible consumer's "tail-only" need while
599
+ * bounding memory. */
600
+ private static readonly MAX_RAW_TEXT_EVENTS = 2;
577
601
  private readonly rawTextEvents: string[] = [];
578
- /** #7 hardening: bounded digest of intermediate findings. When a worker spends
579
- * its entire budget on tool calls (never emits a final assistant text),
580
- * getRawFinalText() returns undefined but this digest captures the last
581
- * display lines (tool results, stdout fragments) before budget exhaustion.
582
- * Capped at MAX_INTERMEDIATE_DIGEST_LINES so result artifacts stay bounded. */
602
+ /** F9: bounded ring buffer for intermediate findings. The downstream digest
603
+ * (getIntermediateFindings) slices the last 20, but the array previously grew
604
+ * to 1000s of entries. We keep MAX_INTERMEDIATE_DIGEST_LINES + headroom so
605
+ * the public API behaviour is preserved (still returns "last 20 lines"). */
606
+ private static readonly MAX_INTERMEDIATE_FINDINGS = 32;
583
607
  private readonly intermediateFindings: string[] = [];
584
608
 
585
609
  constructor(input: ChildPiRunInput) {
@@ -633,12 +657,19 @@ export class ChildPiLineObserver {
633
657
  const rawParsed = JSON.parse(line);
634
658
  const rawTexts = extractText(rawParsed);
635
659
  if (rawTexts.length > 0) {
660
+ // F9: trim from the front if the push would exceed the cap. Slice's
661
+ // second arg excludes the index, so this drops the oldest entries
662
+ // while keeping the freshly pushed tail.
636
663
  this.rawTextEvents.push(...rawTexts);
664
+ const rawOverflow = this.rawTextEvents.length - ChildPiLineObserver.MAX_RAW_TEXT_EVENTS;
665
+ if (rawOverflow > 0) this.rawTextEvents.splice(0, rawOverflow);
637
666
  // Also capture raw assistant text as intermediate findings — the last raw
638
667
  // text may be a partial answer before the worker ran out of budget.
639
668
  const last = rawTexts[rawTexts.length - 1];
640
669
  if (last.trim().length > 0) {
641
670
  this.intermediateFindings.push(last.trim());
671
+ const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
672
+ if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
642
673
  }
643
674
  }
644
675
  } catch {
@@ -663,6 +694,8 @@ export class ChildPiLineObserver {
663
694
  // findings. This ensures we capture tool output even when no assistant text
664
695
  // is emitted (budget exhausted on tool calls).
665
696
  this.intermediateFindings.push(compact.displayLine!.trim());
697
+ const findingsOverflow = this.intermediateFindings.length - ChildPiLineObserver.MAX_INTERMEDIATE_FINDINGS;
698
+ if (findingsOverflow > 0) this.intermediateFindings.splice(0, findingsOverflow);
666
699
  }
667
700
  }
668
701
  }
@@ -921,6 +954,10 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
921
954
  // handler know a drain timer existed even after clearFinalDrainTimers() ran;
922
955
  // spawnMonotonicMs gives us relative timing to distinguish a race from a crash.
923
956
  let finalDrainArmed = false;
957
+ // F12: monotonic timestamp of the last stdout JSON event (any event —
958
+ // we want to know when stdout *stopped*, not when the final assistant
959
+ // event arrived). Updated on every onJsonEvent dispatch.
960
+ let lastStdoutActivityMonotonicMs = performance.now();
924
961
  let finalDrainFiredMonotonicMs: number | undefined;
925
962
  const spawnMonotonicMs = performance.now();
926
963
  let finalAssistantEventMonotonicMs: number | undefined;
@@ -1141,10 +1178,80 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
1141
1178
  completeOperation(eventOpId);
1142
1179
  throw err;
1143
1180
  }
1181
+ // F12: capture monotonic timestamp BEFORE dispatching — any stdout
1182
+ // JSON event counts as activity. This lets the quiet-window
1183
+ // detection measure "time since last byte of stdout" accurately
1184
+ // regardless of what onJsonEvent does.
1185
+ lastStdoutActivityMonotonicMs = performance.now();
1144
1186
  input.onJsonEvent?.(event);
1145
1187
  if (!isFinalAssistantEvent(event) || childExited || settled || finalDrainTimer) return;
1146
1188
  finalAssistantEventMonotonicMs = performance.now();
1147
1189
  finalDrainArmed = true; // Phase-0 diagnostic: track that a drain timer was created.
1190
+ // F12: alongside the 5 s ceiling timer, start a polling watcher
1191
+ // that fires the drain early if stdout goes quiet for `quietMs`
1192
+ // after the final assistant event. Heavy children that emit a
1193
+ // stopReason=stop message_end and then sit idle will exit in
1194
+ // ~quietMs (default 800 ms) instead of up to up to 5 s. unref() so
1195
+ // the poller never holds the event loop on shutdown.
1196
+ // NOTE: The polling watcher is NOT explicitly cleared on process exit.
1197
+ // This is safe because: (1) it's unref()'d, so it won't prevent exit;
1198
+ // (2) the `settled || childExited` guard at the top prevents firing
1199
+ // after the child has exited; (3) sending SIGTERM to an already-
1200
+ // exiting process is harmless. The `finalDrainQuietMs` config allows
1201
+ // disabling this behavior (set >= finalDrainMs, e.g., 10000).
1202
+ const quietMs = input.finalDrainQuietMs ?? DEFAULT_CHILD_PI.finalDrainQuietMs;
1203
+ if (quietMs < (input.finalDrainMs ?? DEFAULT_CHILD_PI.finalDrainMs)) {
1204
+ const pollHandle = setInterval(() => {
1205
+ if (settled || childExited) {
1206
+ clearInterval(pollHandle);
1207
+ pollHandle.unref();
1208
+ return;
1209
+ }
1210
+ const sinceLast = performance.now() - lastStdoutActivityMonotonicMs;
1211
+ if (sinceLast >= quietMs) {
1212
+ clearInterval(pollHandle);
1213
+ pollHandle.unref();
1214
+ // Trigger the same drain path as the 5 s timer:
1215
+ // mark forced, fire final_drain lifecycle, SIGTERM.
1216
+ forcedFinalDrain = true;
1217
+ finalDrainFiredMonotonicMs = performance.now();
1218
+ input.onLifecycleEvent?.({
1219
+ type: "final_drain",
1220
+ pid: child.pid,
1221
+ ts: new Date().toISOString(),
1222
+ reason: "stdout-quiet",
1223
+ });
1224
+ try {
1225
+ child.kill(process.platform === "win32" ? undefined : "SIGTERM");
1226
+ } catch (error) {
1227
+ logInternalError("child-pi.quiet-drain-term", error, `pid=${child.pid}`);
1228
+ }
1229
+ // Mark for hard kill fallback so the existing timer is
1230
+ // still reaped if it ever fires later.
1231
+ hardKillTimer = setTimeout(() => {
1232
+ if (settled || childExited) return;
1233
+ try {
1234
+ hardKilled = true;
1235
+ input.onLifecycleEvent?.({
1236
+ type: "hard_kill",
1237
+ pid: child.pid,
1238
+ ts: new Date().toISOString(),
1239
+ });
1240
+ child.kill(process.platform === "win32" ? undefined : "SIGKILL");
1241
+ } catch (error) {
1242
+ logInternalError("child-pi.quiet-drain-hard-kill", error, `pid=${child.pid}`);
1243
+ }
1244
+ }, hardKillMs);
1245
+ hardKillTimer.unref();
1246
+ // Cancel the 5 s ceiling so we don't double-fire.
1247
+ if (finalDrainTimer) {
1248
+ clearTimeout(finalDrainTimer);
1249
+ finalDrainTimer = undefined;
1250
+ }
1251
+ }
1252
+ }, 200);
1253
+ pollHandle.unref();
1254
+ }
1148
1255
  finalDrainTimer = setTimeout(() => {
1149
1256
  if (settled || childExited) return;
1150
1257
  forcedFinalDrain = true;
@@ -338,7 +338,9 @@ export function upsertCrewAgent(manifest: TeamRunManifest, record: CrewAgentReco
338
338
 
339
339
  export function writeCrewAgentStatus(manifest: TeamRunManifest, record: CrewAgentRecord): void {
340
340
  ensureAgentStateDir(manifest, record.taskId);
341
- atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record));
341
+ // F4: terminal agent status (completed/failed/cancelled/blocked) — keep full
342
+ // durability so the notifier/dashboard health sees the final state immediately.
343
+ atomicWriteJson(agentStatusPath(manifest, record.taskId), redactSecrets(record), { durability: "full" });
342
344
  }
343
345
 
344
346
  // 2.5 — coalesced variants. Buffer per-agent record + aggregate writes for
@@ -350,14 +352,19 @@ const AGENT_COALESCE_MS = 250;
350
352
  export function saveCrewAgentsCoalesced(manifest: TeamRunManifest, records: CrewAgentRecord[]): void {
351
353
  const filePath = agentsPath(manifest);
352
354
  fs.mkdirSync(manifest.stateRoot, { recursive: true });
353
- atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS);
355
+ // F4: progress write — best-effort is safe because the terminal
356
+ // writeCrewAgentStatus above remains durable and the notifier watches the
357
+ // events.jsonl independently of these JSON files.
358
+ atomicWriteJsonCoalesced(filePath, redactSecrets(records), AGENT_COALESCE_MS, { durability: "best-effort" });
354
359
  asyncAgentReaderCache.delete(filePath);
355
360
  for (const record of records) writeCrewAgentStatusCoalesced(manifest, record);
356
361
  }
357
362
 
358
363
  export function writeCrewAgentStatusCoalesced(manifest: TeamRunManifest, record: CrewAgentRecord): void {
359
364
  ensureAgentStateDir(manifest, record.taskId);
360
- atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS);
365
+ atomicWriteJsonCoalesced(agentStatusPath(manifest, record.taskId), redactSecrets(record), AGENT_COALESCE_MS, {
366
+ durability: "best-effort",
367
+ });
361
368
  }
362
369
 
363
370
  /** @internal Flush all coalesced agent writes synchronously. Hook into cleanup paths. */
@@ -33,7 +33,10 @@ function asError(error: unknown): Error {
33
33
  }
34
34
 
35
35
  function globToRegex(pattern: string): RegExp {
36
- const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
36
+ const escaped = pattern
37
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
38
+ .replace(/\*/g, ".*")
39
+ .replace(/\?/g, ".");
37
40
  return new RegExp(`^${escaped}$`, "i");
38
41
  }
39
42