pi-crew 0.9.64 → 0.9.66

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 (43) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +46 -1
  3. package/dist/index.mjs +423 -330
  4. package/package.json +3 -2
  5. package/scripts/pty_probe.py +10 -8
  6. package/skills/real-test-pi-crew/SKILL.md +6 -6
  7. package/src/config/config.ts +19 -3
  8. package/src/config/types.ts +2 -0
  9. package/src/extension/team-tool/cancel.ts +34 -0
  10. package/src/extension/team-tool/dispatch/manage.ts +7 -4
  11. package/src/extension/team-tool/explain.ts +3 -1
  12. package/src/extension/team-tool/lifecycle-actions.ts +4 -1
  13. package/src/extension/team-tool-types.ts +2 -0
  14. package/src/observability/event-to-metric.ts +29 -0
  15. package/src/observability/metrics-primitives.ts +41 -3
  16. package/src/runtime/README.md +1 -1
  17. package/src/runtime/broker/crew-broker.ts +0 -16
  18. package/src/runtime/effectiveness.ts +23 -1
  19. package/src/runtime/merge-gate.ts +202 -0
  20. package/src/runtime/model/model-fallback.ts +11 -0
  21. package/src/runtime/model/provider-extensions.ts +31 -12
  22. package/src/runtime/output/output-validator.ts +34 -6
  23. package/src/runtime/output/progress-tracker.ts +3 -33
  24. package/src/runtime/scheduling/scheduler.ts +67 -19
  25. package/src/runtime/scratchpad/engine.ts +40 -2
  26. package/src/runtime/scratchpad/snapshot-hmac.ts +161 -0
  27. package/src/runtime/team-runner.ts +128 -203
  28. package/src/schema/team-tool-schema.ts +2 -0
  29. package/src/teams/discover-teams.ts +2 -0
  30. package/src/teams/team-config.ts +7 -0
  31. package/src/teams/team-serializer.ts +1 -0
  32. package/src/ui/mascot.ts +1 -14
  33. package/teams/default.team.md +1 -0
  34. package/teams/fast-fix.team.md +1 -0
  35. package/src/observability/event-bus.ts +0 -86
  36. package/src/plugins/plugin-define.ts +0 -6
  37. package/src/plugins/plugin-registry.ts +0 -32
  38. package/src/plugins/plugins/index.ts +0 -3
  39. package/src/plugins/plugins/nextjs.ts +0 -19
  40. package/src/plugins/plugins/vite.ts +0 -10
  41. package/src/plugins/plugins/vitest.ts +0 -9
  42. package/src/runtime/child-pi/child-pi-pool.ts +0 -68
  43. package/src/runtime/iteration-hooks.ts +0 -305
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Monotonic merge gate for parallel task updates.
3
+ *
4
+ * Extracted from team-runner.ts (2026-08-10, improvement-plan Tier 2
5
+ * "team-runner split" — self-contained portion). The merge gate protects
6
+ * terminal task states from being regressed by stale parallel worker
7
+ * snapshots: every terminal->non-terminal transition is rejected, plus a
8
+ * small set of bespoke policies (P2 completed integrity, P3
9
+ * waiting->running stale-snapshot regression) that are stricter than the
10
+ * lifecycle table on the parallel-merge path.
11
+ *
12
+ * Exhaustively tested in test/unit/team-runner-should-merge-table.test.ts.
13
+ */
14
+ import { TEAM_TASK_STATUSES, TEAM_TERMINAL_TASK_STATUSES, type TeamTaskStatus } from "../state/contracts.ts";
15
+ import type { TeamTaskState } from "../state/types.ts";
16
+ import { refreshTaskGraphQueues } from "./scheduling/task-graph-scheduler.ts";
17
+
18
+ export function isNonTerminalTaskStatus(status: TeamTaskState["status"]): boolean {
19
+ return status === "queued" || status === "running" || status === "waiting";
20
+ }
21
+
22
+ export function safeFinishedAt(task: TeamTaskState): number {
23
+ if (!task.finishedAt) return -Infinity;
24
+ const ms = new Date(task.finishedAt).getTime();
25
+ return Number.isNaN(ms) ? Infinity : ms;
26
+ }
27
+
28
+ /**
29
+ * Returns true when the current task has a malformed finishedAt (NaN/Infinity)
30
+ * and the updated task has a valid finite finishedAt. Malformed finishedAt
31
+ * should be replaced rather than persisting corruption.
32
+ */
33
+ export function isMalformedFinishedAtReplacement(currentTime: number, updatedTime: number): boolean {
34
+ return !Number.isFinite(currentTime) && Number.isFinite(updatedTime);
35
+ }
36
+
37
+ /**
38
+ * RT-16: status-level gate for shouldMergeTaskUpdate. Returns the stable
39
+ * "from->to" key used by REJECTED_STATUS_MERGE_TRANSITIONS.
40
+ */
41
+ export function statusMergeKey(from: TeamTaskStatus, to: TeamTaskStatus): string {
42
+ return `${from}->${to}`;
43
+ }
44
+
45
+ /**
46
+ * RT-16 — derived merge-gate transition table.
47
+ *
48
+ * The set of old->new status pairs that shouldMergeTaskUpdate must REJECT based
49
+ * solely on the status transition (before any field-level comparison). It
50
+ * replaces the former 13 hand-written status guards that hand-duplicated the
51
+ * lifecycle table. Built once from the single source-of-truth table
52
+ * (TEAM_TASK_STATUSES + TEAM_TERMINAL_TASK_STATUSES — the terminal half of
53
+ * TEAM_TASK_STATUS_TRANSITIONS) plus two merge-specific policies that are
54
+ * STRICTER than the lifecycle table on the parallel-merge path:
55
+ *
56
+ * P1 Terminal preservation — every terminal->non-terminal pair is rejected.
57
+ * The lifecycle table permits retries (e.g. completed->queued), but a stale
58
+ * worker snapshot must never resurrect a settled task.
59
+ * P2 Completed integrity — five terminal->terminal flips that touch the
60
+ * "completed" success terminal are rejected: completed->failed,
61
+ * completed->needs_attention, failed->completed, cancelled->completed,
62
+ * needs_attention->completed. (completed may still move to
63
+ * cancelled/skipped; that is intentionally allowed, so these are NOT simply
64
+ * "every illegal terminal->terminal flip".)
65
+ * P3 waiting->running regression — the single stale-snapshot case.
66
+ *
67
+ * The decision for every old->new pair is byte-for-byte identical to the former
68
+ * 7 status guards (verified exhaustively in
69
+ * test/unit/team-runner-should-merge-table.test.ts).
70
+ */
71
+ export const REJECTED_STATUS_MERGE_TRANSITIONS: ReadonlySet<string> = (() => {
72
+ const rejected = new Set<string>();
73
+ // P1 — terminal preservation: reject every terminal->non-terminal pair.
74
+ for (const from of TEAM_TASK_STATUSES) {
75
+ if (!TEAM_TERMINAL_TASK_STATUSES.has(from)) continue;
76
+ for (const to of TEAM_TASK_STATUSES) {
77
+ if (!TEAM_TERMINAL_TASK_STATUSES.has(to)) rejected.add(statusMergeKey(from, to));
78
+ }
79
+ }
80
+ // P3 — waiting->running stale-snapshot regression.
81
+ rejected.add(statusMergeKey("waiting", "running"));
82
+ // P2 — completed integrity flips (bespoke terminal->terminal policy).
83
+ const completedIntegrityFlips: ReadonlyArray<[TeamTaskStatus, TeamTaskStatus]> = [
84
+ ["completed", "failed"],
85
+ ["completed", "needs_attention"],
86
+ ["failed", "completed"],
87
+ ["cancelled", "completed"],
88
+ ["needs_attention", "completed"],
89
+ ];
90
+ for (const [from, to] of completedIntegrityFlips) rejected.add(statusMergeKey(from, to));
91
+ return rejected;
92
+ })();
93
+
94
+ export function shouldMergeTaskUpdate(current: TeamTaskState, updated: TeamTaskState): boolean {
95
+ // RT-16: status-level gate — reject stale/dangerous transitions via the
96
+ // derived transition table (REJECTED_STATUS_MERGE_TRANSITIONS) instead of
97
+ // hand-written guards. Parallel workers receive the same input snapshot; a
98
+ // later result may still carry stale copies. The table encodes three
99
+ // merge-specific policies stricter than the lifecycle table: terminal
100
+ // preservation (no terminal->non-terminal resurrection), completed integrity
101
+ // (no flipping the "completed" success terminal to/from failed or
102
+ // needs_attention), and the waiting->running stale-snapshot regression.
103
+ if (REJECTED_STATUS_MERGE_TRANSITIONS.has(statusMergeKey(current.status, updated.status))) return false;
104
+ // Guard: when current is "running" but has resultArtifact (another worker already
105
+ // completed it), a stale updated with status="running" and no resultArtifact
106
+ // must not overwrite the actual completed state.
107
+ if (current.status === updated.status && updated.status === "running" && current.resultArtifact && !updated.resultArtifact)
108
+ return false;
109
+ // Guard: when current is "completed" and has resultArtifact but updated is also
110
+ // "completed" without resultArtifact, block the stale update from overwriting
111
+ // a task that successfully produced output.
112
+ if (current.status === updated.status && current.status === "completed" && current.resultArtifact && !updated.resultArtifact)
113
+ return false;
114
+ // Prevent a stale completed task from overwriting a fresher one.
115
+ // Restructure to handle undefined current.finishedAt as a special case:
116
+ // - undefined current + valid updated: allow the update
117
+ // - valid current + undefined updated: block the update (don't lose completion time)
118
+ // - both undefined: finishedAt guard does not apply, fall through to heartbeat check
119
+ // - both valid: compare timestamps as before
120
+ if (current.finishedAt !== undefined && updated.finishedAt !== undefined) {
121
+ const currentTime = safeFinishedAt(current);
122
+ const updatedTime = safeFinishedAt(updated);
123
+ // Malformed finishedAt (NaN) is treated as Infinity — invalid state should be
124
+ // replaced rather than persisting corruption. Log warning for visibility.
125
+ if (!Number.isFinite(currentTime)) {
126
+ console.warn(`[merge-gate] Task ${current.id} has malformed finishedAt: ${current.finishedAt}`);
127
+ }
128
+ if (isMalformedFinishedAtReplacement(currentTime, updatedTime)) {
129
+ return true;
130
+ }
131
+ if (updatedTime < currentTime) return false;
132
+ }
133
+ // Block if updated is trying to establish a terminal status without a finishedAt
134
+ // timestamp. Heartbeat-only updates (status='running', no finishedAt) are
135
+ // allowed if heartbeat has changed (checked separately in hasMeaningfulUpdate).
136
+ if (!updated.finishedAt && !isNonTerminalTaskStatus(updated.status)) return false;
137
+ // Explicitly enumerate all fields that constitute a meaningful update so that
138
+ // adding a new important field requires updating this list (rather than silently
139
+ // losing data if a field is forgotten in the boolean OR chain below).
140
+ const hasMeaningfulUpdate =
141
+ updated.status !== current.status ||
142
+ updated.finishedAt !== current.finishedAt ||
143
+ updated.startedAt !== current.startedAt ||
144
+ Boolean(updated.resultArtifact) !== Boolean(current.resultArtifact) ||
145
+ (Boolean(updated.resultArtifact) && updated.resultArtifact !== current.resultArtifact) ||
146
+ Boolean(updated.error) ||
147
+ Boolean(updated.modelAttempts?.length) ||
148
+ Boolean(updated.usage) ||
149
+ Boolean(updated.attempts?.length) ||
150
+ updated.heartbeat?.lastSeenAt !== current.heartbeat?.lastSeenAt ||
151
+ updated.jsonEvents !== current.jsonEvents ||
152
+ updated.agentProgress?.lastActivityAt !== current.agentProgress?.lastActivityAt;
153
+ return hasMeaningfulUpdate;
154
+ }
155
+ /** Exposed for the exhaustive status-merge table test (RT-16). */
156
+ export const __test__shouldMergeTaskUpdate = shouldMergeTaskUpdate;
157
+
158
+ // H4 fix: rename to descriptive name. Kept __test__ as alias for backward
159
+ // compat test imports.
160
+ // FIX (perf P10): replace O(N×M) .find() + .map() inside nested loops with a
161
+ // single-pass Map-based merge. Build an index of `merged` once, then for each
162
+ // incoming updated task do O(1) lookup; the final pass reassembles `merged`
163
+ // preserving original order. For a 20-task run × 5-batch merger with
164
+ // ~10 updates per result, this reduces from O(50×20) = 1000 ops to O(120).
165
+ // Behavior is unchanged: skipped updates (shouldMergeTaskUpdate=false) still
166
+ // leave the existing task in place.
167
+ export function mergeTaskUpdatesPreservingTerminal(base: TeamTaskState[], results: Array<{ tasks: TeamTaskState[] }>): TeamTaskState[] {
168
+ // Index current merged state by id for O(1) lookup during the merge pass.
169
+ const indexById = new Map<string, TeamTaskState>();
170
+ for (const task of base) indexById.set(task.id, task);
171
+
172
+ let skipped = 0;
173
+ for (const result of results) {
174
+ for (const updated of result.tasks) {
175
+ const current = indexById.get(updated.id);
176
+ if (!current) continue;
177
+ if (!shouldMergeTaskUpdate(current, updated)) {
178
+ // Log skipped merges for visibility into rejected parallel updates.
179
+ // In distributed systems with parallel workers, rejected merges may
180
+ // indicate bugs (wrong status, timestamp corruption) if they accumulate.
181
+ console.debug("[merge-gate] Skipping stale merge for task", updated.id, {
182
+ currentStatus: current.status,
183
+ updatedStatus: updated.status,
184
+ currentFinishedAt: current.finishedAt,
185
+ updatedFinishedAt: updated.finishedAt,
186
+ });
187
+ skipped += 1;
188
+ continue;
189
+ }
190
+ indexById.set(updated.id, updated);
191
+ }
192
+ }
193
+ // Reassemble in original `base` order so downstream snapshots stay stable.
194
+ const merged = base.map((task) => indexById.get(task.id) ?? task);
195
+ // `skipped` is intentional visibility — currently no caller reads it but
196
+ // we'd rather leave the count available for future instrumentation than
197
+ // remove the cumulative silent-rejection signal it provides.
198
+ void skipped;
199
+ return refreshTaskGraphQueues(merged);
200
+ }
201
+ /** @deprecated Use mergeTaskUpdatesPreservingTerminal. Kept for backward test import compat. */
202
+ export const __test__mergeTaskUpdates = mergeTaskUpdatesPreservingTerminal;
@@ -365,6 +365,17 @@ const RETRYABLE_MODEL_FAILURE_PATTERNS = [
365
365
  /safety/i,
366
366
  /is[_ ]?overloaded/i,
367
367
  /\b408\b/,
368
+ //
369
+ // EPIPE / broken-pipe. In the child-pi worker path this typically means
370
+ // the child `pi` process exited (crash or early exit) while the parent
371
+ // was still writing to its stdin — spawning a fresh child on the next
372
+ // model in the fallback chain usually recovers. In the network path it
373
+ // is a transient pipe close. Both are retryable on a different model.
374
+ // See docs/failure-mode-inventory.md EPIPE gap; NON_RETRYABLE patterns
375
+ // (auth/billing) are checked first, so an auth error mentioning EPIPE
376
+ // stays non-retryable.
377
+ /epipe/i,
378
+ /broken pipe/i,
368
379
  ];
369
380
 
370
381
  // These patterns indicate auth/key/billing issues that will never succeed on retry.
@@ -22,7 +22,7 @@
22
22
  */
23
23
  import * as fs from "node:fs";
24
24
  import * as path from "node:path";
25
- import { userPiRoot } from "../../utils/paths.ts";
25
+ import { packageRoot, userPiRoot } from "../../utils/paths.ts";
26
26
 
27
27
  export interface DiscoveredProviderExtension {
28
28
  /** Package specifier as written in settings.json packages (e.g. "npm:pi-commandcode-provider"). */
@@ -95,12 +95,17 @@ function resolvePackageEntry(pkgDir: string): string | undefined {
95
95
 
96
96
  /**
97
97
  * Discover provider extension entry points from Pi's installed package registry.
98
- * Reads `~/.pi/agent/settings.json` → `packages` (npm: specs only — local
99
- * relative paths are the pi-crew extension itself and other local work, which
100
- * children already handle) and resolves each against `~/.pi/agent/npm/node_modules/`.
98
+ * Reads `~/.pi/agent/settings.json` → `packages` and resolves each spec:
99
+ * - `npm:<name>` → `~/.pi/agent/npm/node_modules/<name>/`
100
+ * - local path spec → resolved relative to the settings.json dir (the way
101
+ * `pi install <local-path>` records them), e.g. "../../src/foo"
102
+ * - git:/file: specs → skipped (not resolvable on disk)
101
103
  *
102
- * Non-npm specs (local paths, git URLs) are skipped: they are not part of the
103
- * npm registry dir and resolving them is outside this module's scope.
104
+ * Both npm: and local-path specs are SANCTIONED channels — the user wrote them
105
+ * into settings.json (directly or via `pi install`), so they are trusted at the
106
+ * same level. This is distinct from project-sourced AGENT extensions
107
+ * (`.crew/agents/*.md` `extensions:` frontmatter), which are repo-adjacent
108
+ * untrusted data and stay gated by SEC-1 in discover-agents.ts.
104
109
  */
105
110
  export function discoverProviderExtensions(settingsPath?: string): DiscoveredProviderExtension[] {
106
111
  const root = userPiRoot();
@@ -125,13 +130,27 @@ export function discoverProviderExtensions(settingsPath?: string): DiscoveredPro
125
130
  const npmBase = path.join(baseDir, "npm", "node_modules");
126
131
  for (const spec of settings.packages ?? []) {
127
132
  if (typeof spec !== "string") continue;
128
- // Only npm: specs resolve into the npm registry dir. Skip local path
129
- // packages (e.g. "../../source/my_pi/pi-crew") and git specs.
130
- if (!spec.startsWith("npm:")) continue;
131
- const pkgName = spec.slice(4);
132
- // Scoped packages: "@scope/name" → "@scope/name"; plain: "name".
133
- const pkgDir = path.join(npmBase, pkgName);
133
+ let pkgDir: string;
134
+ if (spec.startsWith("npm:")) {
135
+ // Scoped packages: "@scope/name" → "@scope/name"; plain: "name".
136
+ pkgDir = path.join(npmBase, spec.slice(4));
137
+ } else if (spec.startsWith("./") || spec.startsWith("../") || path.isAbsolute(spec)) {
138
+ // Local path spec — resolve relative to the settings.json dir, matching
139
+ // how `pi install <local-path>` records it. Same trust level as npm:
140
+ // (user wrote it into settings.json). Not to be confused with project
141
+ // AGENT extensions (.crew/agents/* frontmatter) — those stay SEC-1 gated.
142
+ pkgDir = path.resolve(baseDir, spec);
143
+ } else {
144
+ // git:/file:/http: specs etc. — not resolvable on disk, skip.
145
+ continue;
146
+ }
134
147
  if (!fs.existsSync(pkgDir)) continue;
148
+ // Skip self: pi-crew's own package is a settings package (the orchestrator
149
+ // extension the parent loads), but a child WORKER must not re-load it — it
150
+ // would register the team tool / observability / MCP wiring intended for
151
+ // the orchestrator process, not a worker. Provider + adapter extensions
152
+ // (pi-other-provider, pi-mcp-adapter, pi-rlm, ...) stay.
153
+ if (path.resolve(pkgDir) === path.resolve(packageRoot())) continue;
135
154
  const entryPath = resolvePackageEntry(pkgDir);
136
155
  if (entryPath) out.push({ spec, entryPath });
137
156
  }
@@ -9,13 +9,41 @@
9
9
  * (headings, code blocks, URLs) after compression.
10
10
  */
11
11
 
12
- /** Role-specific output format patterns — constructed fresh per call to avoid /g lastIndex leak */
12
+ /**
13
+ * Why relax: real worker LLMs emit markdown handoffs (`## Handoff`,
14
+ * `### Summary`, `## Follow-ups`, `- bullet`, `**bold**`) instead of the
15
+ * caveman formats (`file:line — text`, `PASS:`/`FAIL:`, emoji findings)
16
+ * these patterns originally required. Each role pattern below ORs its
17
+ * strict contract with a markdown-structured alternation so structured
18
+ * handoffs validate while empty/garbage output still fails.
19
+ *
20
+ * What changed: added the MARKDOWN_STRUCTURED alternation to every role.
21
+ * What is preserved: the strict patterns still match verbatim, and the
22
+ * structural-preservation checks (code blocks, URLs, headings) in
23
+ * validateWorkerOutput are UNCHANGED.
24
+ */
25
+
26
+ /** Accepts atx headings (`## X`), bold (`**x**`), bullets (`- x` / `* x`), and numbered lists (`1. x`) */
27
+ const MARKDOWN_STRUCTURED = /^(?:#{1,6}\s|\*\*|[-*]\s|\d+\.\s)/m;
28
+
29
+ /** Strict per-role contract patterns (kept verbatim; `.source` is embedded in the alternations below) */
30
+ const STRICT_ROLE_PATTERNS: Record<string, RegExp> = {
31
+ explorer: /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
32
+ executor: /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
33
+ reviewer: /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
34
+ "security-reviewer": /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
35
+ verifier: /^(PASS:|FAIL:)/m,
36
+ };
37
+
38
+ /** Role-specific output format patterns — constructed fresh per call to avoid /g lastIndex leak.
39
+ * Each factory: strict contract OR markdown-structured alternation. */
13
40
  const ROLE_PATTERN_DEFS: Record<string, () => RegExp> = {
14
- explorer: () => /^(\S+:\d+|Defs:|Refs:|Callers:|Tests:|Sites:|No match\.|totals:)/m,
15
- executor: () => /^(\S+:\d+(-\d+)? — .{1,80}\.|verified:|too-big\.|needs-confirm\.|ambiguous\.|regressed\.)/m,
16
- reviewer: () => /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
17
- "security-reviewer": () => /^([^:\s]+:\d+:\s+\p{Emoji_Presentation}|No issues\.|totals:)/mu,
18
- verifier: () => /^(PASS:|FAIL:)/m,
41
+ explorer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.explorer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
42
+ executor: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.executor.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
43
+ reviewer: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.reviewer.source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
44
+ "security-reviewer": () =>
45
+ new RegExp(`(?:${STRICT_ROLE_PATTERNS["security-reviewer"].source})|(?:${MARKDOWN_STRUCTURED.source})`, "mu"),
46
+ verifier: () => new RegExp(`(?:${STRICT_ROLE_PATTERNS.verifier.source})|(?:${MARKDOWN_STRUCTURED.source})`, "m"),
19
47
  };
20
48
 
21
49
  /** Fresh RegExp factories for structural preservation checks (avoids /g lastIndex leak) */
@@ -1,5 +1,4 @@
1
1
  import type { AgentSessionEvent } from "@earendil-works/pi-coding-agent";
2
- import { crewEventBus } from "../../observability/event-bus.ts";
3
2
 
4
3
  export interface AgentProgress {
5
4
  toolCalls: number;
@@ -25,7 +24,7 @@ export class ProgressTracker {
25
24
  subscribe: (listener: (event: AgentSessionEvent) => void) => () => void;
26
25
  },
27
26
  agentId: string,
28
- runId: string,
27
+ _runId: string,
29
28
  ): AgentProgress {
30
29
  if (this.sessions.has(agentId)) {
31
30
  return this.sessions.get(agentId)!.progress;
@@ -42,26 +41,19 @@ export class ProgressTracker {
42
41
  };
43
42
 
44
43
  const unsubscribe = session.subscribe((event: AgentSessionEvent) => {
45
- this.handleEvent(event, progress, agentId, runId);
44
+ this.handleEvent(event, progress);
46
45
  });
47
46
 
48
47
  this.sessions.set(agentId, { unsubscribe, progress });
49
48
  return progress;
50
49
  }
51
50
 
52
- private handleEvent(event: AgentSessionEvent, progress: AgentProgress, agentId: string, runId: string): void {
51
+ private handleEvent(event: AgentSessionEvent, progress: AgentProgress): void {
53
52
  switch (event.type) {
54
53
  case "tool_execution_start":
55
54
  progress.toolCalls++;
56
55
  progress.currentTool = event.toolName;
57
56
  progress.toolStartTime = Date.now();
58
- crewEventBus.emit({
59
- type: "agent:progress",
60
- runId,
61
- agentId,
62
- payload: { ...progress },
63
- timestamp: Date.now(),
64
- });
65
57
  break;
66
58
 
67
59
  case "tool_execution_end":
@@ -69,21 +61,7 @@ export class ProgressTracker {
69
61
  progress.toolStartTime = null;
70
62
  if (event.isError) {
71
63
  progress.errors.push(String(event.result ?? "Unknown error"));
72
- crewEventBus.emit({
73
- type: "agent:error",
74
- runId,
75
- agentId,
76
- payload: String(event.result ?? "Unknown error"),
77
- timestamp: Date.now(),
78
- });
79
64
  }
80
- crewEventBus.emit({
81
- type: "agent:progress",
82
- runId,
83
- agentId,
84
- payload: { ...progress },
85
- timestamp: Date.now(),
86
- });
87
65
  break;
88
66
 
89
67
  case "turn_start":
@@ -92,13 +70,6 @@ export class ProgressTracker {
92
70
 
93
71
  case "agent_end":
94
72
  progress.status = "completed";
95
- crewEventBus.emit({
96
- type: "agent:complete",
97
- runId,
98
- agentId,
99
- payload: { ...progress },
100
- timestamp: Date.now(),
101
- });
102
73
  break;
103
74
 
104
75
  case "agent_start":
@@ -120,5 +91,4 @@ export class ProgressTracker {
120
91
  }
121
92
  }
122
93
 
123
- // Export singleton instance
124
94
  export const globalProgressTracker = new ProgressTracker();
@@ -229,24 +229,72 @@ function parseIntervalMs(s: string): number | undefined {
229
229
  return ms;
230
230
  }
231
231
 
232
- function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
233
- const parts = spec.split(/\s+/);
234
- if (parts.length < 5) return { error: "Invalid cron expression" };
235
- const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
232
+ /** Named-token maps for cron DOW (SUN=0..SAT=6) and month (JAN=1..DEC=12). */
233
+ const CRON_DOW_NAMES: Record<string, number> = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 };
234
+ const CRON_MONTH_NAMES: Record<string, number> = {
235
+ JAN: 1,
236
+ FEB: 2,
237
+ MAR: 3,
238
+ APR: 4,
239
+ MAY: 5,
240
+ JUN: 6,
241
+ JUL: 7,
242
+ AUG: 8,
243
+ SEP: 9,
244
+ OCT: 10,
245
+ NOV: 11,
246
+ DEC: 12,
247
+ };
236
248
 
237
- function matchField(value: number, str: string, min: number, max: number): boolean {
238
- if (str === "*") return true;
239
- const n = parseInt(str, 10);
240
- if (!Number.isNaN(n) && n >= min && n <= max && n === value) return true;
241
- if (/^\d+-\d+$/.test(str)) {
242
- const [a, b] = str.split("-").map(Number);
243
- return value >= a && value <= b;
249
+ /**
250
+ * Match a single cron field value against a cron field expression.
251
+ * Supports the standard cron grammar: wildcard, single N, range a-b, list a,b,c,
252
+ * step syntax (wildcard-step, range-step, from-step), and named tokens
253
+ * (MON, JAN) when a `names` map is passed. Fixes the prior matcher that
254
+ * rejected step values and named DOW (parseInt returned NaN).
255
+ */
256
+ function cronFieldMatches(value: number, field: string, min: number, max: number, names?: Record<string, number>): boolean {
257
+ let normalized = field.trim().toUpperCase();
258
+ if (names) {
259
+ for (const name of Object.keys(names).sort((a, b) => b.length - a.length)) {
260
+ normalized = normalized.split(name).join(String(names[name]));
261
+ }
262
+ }
263
+ // Cron permits 7 for Sunday in the DOW field — normalize to 0.
264
+ if (min === 0 && max === 6) normalized = normalized.replace(/\b7\b/g, "0");
265
+ const matched = new Set<number>();
266
+ for (const rawPart of normalized.split(",")) {
267
+ const part = rawPart.trim();
268
+ if (part === "") return false;
269
+ const stepMatch = part.match(/^(.*)\/(\d+)$/);
270
+ const step = stepMatch ? Number.parseInt(stepMatch[2], 10) : 1;
271
+ if (!Number.isFinite(step) || step < 1) return false;
272
+ const rangeStr = stepMatch ? stepMatch[1] : part;
273
+ let lo: number;
274
+ let hi: number;
275
+ if (rangeStr === "*") {
276
+ lo = min;
277
+ hi = max;
278
+ } else if (/^\d+$/.test(rangeStr)) {
279
+ lo = Number.parseInt(rangeStr, 10);
280
+ hi = stepMatch ? max : lo; // bare `N` = single value; `N/S` = N..max step S
281
+ } else {
282
+ const rm = rangeStr.match(/^(\d+)-(\d+)$/);
283
+ if (!rm) return false;
284
+ lo = Number.parseInt(rm[1], 10);
285
+ hi = Number.parseInt(rm[2], 10);
244
286
  }
245
- if (str.includes(",")) {
246
- return str.split(",").some((part) => matchField(value, part.trim(), min, max));
287
+ for (let v = lo; v <= hi; v += step) {
288
+ if (v >= min && v <= max) matched.add(v);
247
289
  }
248
- return false;
249
290
  }
291
+ return matched.has(value);
292
+ }
293
+
294
+ function nextCronDate(spec: string, from: Date): Date | { error: string } | null {
295
+ const parts = spec.split(/\s+/);
296
+ if (parts.length < 5) return { error: "Invalid cron expression" };
297
+ const [minStr, hourStr, domStr, monthStr, dowStr] = parts;
250
298
 
251
299
  let cursor = new Date(from.getTime());
252
300
  cursor.setSeconds(0, 0);
@@ -261,11 +309,11 @@ function nextCronDate(spec: string, from: Date): Date | { error: string } | null
261
309
  const dow = cursor.getUTCDay();
262
310
 
263
311
  if (
264
- matchField(min, minStr, 0, 59) &&
265
- matchField(hour, hourStr, 0, 23) &&
266
- matchField(dom, domStr, 1, 31) &&
267
- matchField(month, monthStr, 1, 12) &&
268
- matchField(dow, dowStr, 0, 6)
312
+ cronFieldMatches(min, minStr, 0, 59) &&
313
+ cronFieldMatches(hour, hourStr, 0, 23) &&
314
+ cronFieldMatches(dom, domStr, 1, 31) &&
315
+ cronFieldMatches(month, monthStr, 1, 12, CRON_MONTH_NAMES) &&
316
+ cronFieldMatches(dow, dowStr, 0, 6, CRON_DOW_NAMES)
269
317
  ) {
270
318
  return cursor;
271
319
  }
@@ -17,7 +17,7 @@
17
17
  * grace, truncateWithMarker, the childClosed race guard — is ported 1:1.
18
18
  */
19
19
 
20
- import { type ChildProcess, spawn } from "node:child_process";
20
+ import { type ChildProcess, spawn, spawnSync } from "node:child_process";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { closeSync, constants as fsConstants, fstatSync, mkdirSync, openSync, readFileSync, renameSync, writeFileSync } from "node:fs";
23
23
  import { dirname } from "node:path";
@@ -198,6 +198,15 @@ export class EngineManager {
198
198
  ...(this.options.env ?? {}),
199
199
  [NONCE_ENV]: this.nonce,
200
200
  },
201
+ // Run the guest as its own session leader so descendants
202
+ // (cell-spawned subprocesses) share its process group. teardown
203
+ // then uses `process.kill(-pid, ...)` (POSIX) or `taskkill /T`
204
+ // (Windows) to clean up the whole tree — closing the
205
+ // "cell-subprocess orphan on session_shutdown" gap declared in
206
+ // docs/failure-mode-inventory.md (D.4 narrowed).
207
+ // stdio pipes are still owned by the parent; `detached` only
208
+ // affects process-group / kill-on-parent-exit semantics.
209
+ detached: true,
201
210
  // fd 3 carries protocol traffic so stdout/stderr stay pure user output.
202
211
  stdio: ["pipe", "pipe", "pipe", "pipe"],
203
212
  });
@@ -303,7 +312,36 @@ export class EngineManager {
303
312
  this.engineState = "shutdown";
304
313
  liveEngines.delete(this);
305
314
  this.failAllPending(new Error("Engine has been shut down"));
306
- this.child?.kill("SIGKILL");
315
+ // Kill the whole process group / job tree so cell-spawned
316
+ // subprocesses (grandchildren of the host) do not orphan when the
317
+ // session shuts down. Best-effort: fall back to a single-pid kill
318
+ // if the group/tree kill fails (already dead, EPERM, etc.).
319
+ const child = this.child;
320
+ const pid = child?.pid;
321
+ if (pid !== undefined && pid > 0) {
322
+ try {
323
+ if (process.platform === "win32") {
324
+ // taskkill /T /F kills the entire process tree rooted at pid.
325
+ spawnSync("taskkill", ["/PID", String(pid), "/T", "/F"], { stdio: "ignore" });
326
+ } else {
327
+ // Negative pid = signal the entire process group.
328
+ // Requires the guest to be its own session leader (detached:true above).
329
+ process.kill(-pid, "SIGKILL");
330
+ }
331
+ } catch {
332
+ try {
333
+ child?.kill("SIGKILL");
334
+ } catch {
335
+ /* already gone — nothing to clean up */
336
+ }
337
+ }
338
+ } else if (child) {
339
+ try {
340
+ child.kill("SIGKILL");
341
+ } catch {
342
+ /* already gone */
343
+ }
344
+ }
307
345
  this.child = undefined;
308
346
  this.protocolReader?.close();
309
347
  this.protocolReader = undefined;