pi-crew 0.11.21 → 0.11.23

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 (89) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/index.mjs +542 -531
  3. package/package.json +2 -1
  4. package/src/errors.ts +14 -1
  5. package/src/extension/async-notifier.ts +42 -17
  6. package/src/extension/cross-extension-rpc.ts +42 -5
  7. package/src/extension/notification-router.ts +15 -2
  8. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  9. package/src/extension/registration/runtime-cleanup.ts +17 -0
  10. package/src/extension/registration/wire-cross-extension.ts +26 -0
  11. package/src/extension/rpc-hmac.ts +53 -2
  12. package/src/extension/team-tool/cancel.ts +34 -21
  13. package/src/extension/team-tool/dispatch/index.ts +6 -5
  14. package/src/extension/team-tool/dispatch/status.ts +42 -2
  15. package/src/extension/team-tool/respond.ts +8 -6
  16. package/src/extension/team-tool/run.ts +4 -4
  17. package/src/extension/team-tool.ts +24 -2
  18. package/src/prompt/inbox-poll.ts +77 -25
  19. package/src/prompt/message-tool.ts +62 -4
  20. package/src/prompt/prompt-runtime.ts +82 -32
  21. package/src/prompt/surface-worker.ts +13 -1
  22. package/src/runtime/broker/crew-broker-client.ts +74 -16
  23. package/src/runtime/broker/crew-broker.ts +120 -97
  24. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  25. package/src/runtime/broker/protocol/request-parsers.ts +68 -0
  26. package/src/runtime/broker/wait-status-cache.ts +14 -5
  27. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  28. package/src/runtime/child-pi/child-pi.ts +20 -2
  29. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  30. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  31. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  32. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  33. package/src/runtime/crew-agent-records.ts +32 -14
  34. package/src/runtime/delivery-coordinator.ts +30 -0
  35. package/src/runtime/dispatch-batch.ts +9 -6
  36. package/src/runtime/dwf-state-store.ts +41 -7
  37. package/src/runtime/finalize-run.ts +13 -2
  38. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  39. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  40. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  41. package/src/runtime/live-session/embedded-tier.ts +3 -1
  42. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  43. package/src/runtime/model/model-fallback.ts +23 -1
  44. package/src/runtime/output/pi-json-output.ts +26 -1
  45. package/src/runtime/policy-engine.ts +18 -0
  46. package/src/runtime/process/zombie-scanner.ts +84 -16
  47. package/src/runtime/process-status.ts +6 -1
  48. package/src/runtime/recovery/crash-recovery.ts +170 -28
  49. package/src/runtime/rpc/rpc-worker.ts +44 -1
  50. package/src/runtime/scheduling/run-coalesced-task-group.ts +73 -6
  51. package/src/runtime/scheduling/scheduler.ts +74 -11
  52. package/src/runtime/scratchpad/engine.ts +26 -3
  53. package/src/runtime/settings-store.ts +22 -2
  54. package/src/runtime/stale-reconciler.ts +4 -1
  55. package/src/runtime/surface/tmux-provider.ts +24 -3
  56. package/src/runtime/task-health.ts +16 -1
  57. package/src/runtime/task-output-context.ts +143 -71
  58. package/src/runtime/task-packet.ts +70 -17
  59. package/src/runtime/task-runner/child-executor.ts +27 -0
  60. package/src/runtime/task-runner/output-splitter.ts +79 -16
  61. package/src/runtime/task-runner/post-execution.ts +75 -15
  62. package/src/runtime/task-runner/state-helpers.ts +92 -2
  63. package/src/runtime/usage-tracker.ts +11 -0
  64. package/src/runtime/verification/green-contract.ts +17 -0
  65. package/src/runtime/verification/verification-gates.ts +24 -6
  66. package/src/runtime/workspace-lock.ts +53 -0
  67. package/src/schema/team-tool-schema.ts +10 -4
  68. package/src/state/coordination/mailbox.ts +133 -38
  69. package/src/state/decision-ledger.ts +56 -8
  70. package/src/state/event-log/cursor.ts +14 -2
  71. package/src/state/event-log/event-log-rotation.ts +83 -5
  72. package/src/state/event-log/event-log.ts +181 -12
  73. package/src/state/stores/active-run-registry.ts +10 -3
  74. package/src/state/types.ts +9 -1
  75. package/src/ui/agents-jobs-browser.ts +22 -8
  76. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  77. package/src/ui/events-state-source.ts +67 -28
  78. package/src/ui/inline-panel/agent-pane.ts +13 -6
  79. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  80. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  81. package/src/ui/inline-panel/index.ts +21 -1
  82. package/src/ui/live-run-sidebar.ts +14 -3
  83. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  84. package/src/ui/powerbar-publisher.ts +6 -3
  85. package/src/ui/rail.ts +7 -1
  86. package/src/ui/render-scheduler.ts +53 -1
  87. package/src/ui/run-action-dispatcher.ts +68 -20
  88. package/src/ui/status-colors.ts +9 -0
  89. package/src/utils/env-filter.ts +8 -0
@@ -21,7 +21,7 @@ import * as fs from "node:fs";
21
21
  import * as path from "node:path";
22
22
  import { childPiBaseAllowlist } from "../../config/env-allowlist-registry.ts";
23
23
  import { isScratchpadEnabledForRole } from "../../config/role-tools.ts";
24
- import { buildScopedAllowList, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
24
+ import { buildScopedAllowList, providerEnvKeys, sanitizeEnvSecrets } from "../../utils/env-filter.ts";
25
25
  import { logInternalError } from "../../utils/internal-error.ts";
26
26
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
27
27
  import { buildPiWorkerArgs, createSafeTempDir, getPiTempBase } from "../model/pi-args.ts";
@@ -48,8 +48,23 @@ export const BASE_ALLOWLIST: string[] = childPiBaseAllowlist();
48
48
  * 4. Add PI_CREW_PARENT_PID for the child-side parent-guard (consumed by the
49
49
  * reactive zombie-scanner + background-runner parent-guard; unused only by
50
50
  * the pi dist binary — see note at the PI_CREW_PARENT_PID assignment below).
51
+ *
52
+ * R6-8 (DR-03 round-6): `modelChain` threads the task's model CANDIDATE CHAIN
53
+ * (primary + fallbacks, e.g. modelRouting.candidates) so the allowlist is
54
+ * scoped to the UNION of the chain's providers. Previously only the single
55
+ * current model was passed: a bare-id primary (no "provider/" prefix)
56
+ * resolved to ZERO provider env keys → the child failed auth with a
57
+ * non-retryable /api key/i error → the loop broke even though qualified
58
+ * fallbacks were queued (the env-filter.ts doc contract already promised
59
+ * "primary + fallbacks"). Scoping is NEVER weakened by this: keys stay
60
+ * limited to the chain's providers ONLY — never all-providers.
51
61
  */
52
- export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, model?: string): SpawnOptions {
62
+ export function buildChildPiSpawnOptions(
63
+ cwd: string,
64
+ env: NodeJS.ProcessEnv,
65
+ model?: string,
66
+ modelChain?: (string | undefined)[],
67
+ ): SpawnOptions {
53
68
  // SECURITY FIX (Issue #1): Validate cwd before passing to spawn.
54
69
  // If cwd comes from an untrusted source (user input, workspace config), a malicious cwd
55
70
  // could cause the child process to operate in an attacker-controlled directory,
@@ -81,9 +96,33 @@ export function buildChildPiSpawnOptions(cwd: string, env: NodeJS.ProcessEnv, mo
81
96
  // Bug #12 fix: essential env vars (PATH, HOME, etc.) are always preserved so child can find npm/node.
82
97
  //
83
98
  // PER-TASK KEY SCOPING: when a model is provided, only the env keys for that
84
- // provider are injected (via buildScopedAllowList). When no model is given,
85
- // only BASE_ALLOWLIST system vars pass through — no provider keys leak.
86
- const allowList = model ? buildScopedAllowList(BASE_ALLOWLIST, [model]) : BASE_ALLOWLIST;
99
+ // provider are injected (via buildScopedAllowList). R6-8: when the caller
100
+ // threads the model CANDIDATE CHAIN (primary + fallbacks), keys are scoped
101
+ // to the UNION of the chain's providers so a fallback attempt on a second
102
+ // provider does not spawn keyless. When no model is given, only
103
+ // BASE_ALLOWLIST system vars pass through — no provider keys leak.
104
+ const scopeModels: (string | undefined)[] = modelChain && modelChain.length > 0 ? modelChain : model !== undefined ? [model] : [];
105
+ const allowList = scopeModels.length > 0 ? buildScopedAllowList(BASE_ALLOWLIST, scopeModels) : BASE_ALLOWLIST;
106
+ // R6-8: warn when the scoped models resolve to ZERO provider env keys AND at
107
+ // least one is a truly-bare id (no "provider/" prefix) — env-key auth + bare
108
+ // id means the child worker will very likely fail authentication (observed as
109
+ // the R6-8 stall: bare id → zero keys → /api key/i non-retryable → chain break
110
+ // even though qualified fallbacks were queued). NOT an error, and it never
111
+ // widens the allowlist: users authenticating via auth.json (or custom
112
+ // providers with non-env credentials) are unaffected — the child reads
113
+ // auth.json credentials exactly like the parent process does. Qualified
114
+ // unknown providers ("custom/foo") do not warn: that is a deliberate
115
+ // custom-provider choice, not a likely misconfiguration.
116
+ const anyProviderKeys = scopeModels.some((scoped) => providerEnvKeys(scoped).length > 0);
117
+ const anyBareId = scopeModels.some((scoped) => typeof scoped === "string" && !scoped.includes("/"));
118
+ if (scopeModels.length > 0 && anyBareId && !anyProviderKeys) {
119
+ logInternalError(
120
+ "child-pi-spawn.bare-model-no-provider-keys",
121
+ undefined,
122
+ `model "${model ?? scopeModels.find((scoped) => typeof scoped === "string")}" is a bare id (no provider prefix) and resolved zero provider env keys — the child worker gets no provider API key via env; env-key users should qualify the model id (e.g. "openai/<id>") or configure a fallback chain (auth.json users are unaffected)`,
123
+ "warn",
124
+ );
125
+ }
87
126
  const filteredEnv = sanitizeEnvSecrets(env, { allowList });
88
127
  // FIX: Removed delete workarounds — with explicit allowlist, these vars
89
128
  // are no longer auto-leaked. The wildcard approach was fragile.
@@ -192,12 +231,14 @@ export function buildFinalChildPiSpawnOptions(
192
231
  mergedEnv: NodeJS.ProcessEnv,
193
232
  builtEnv: Record<string, string | undefined>,
194
233
  model?: string,
234
+ modelChain?: (string | undefined)[],
195
235
  ): SpawnOptions {
196
236
  // (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
197
237
  assertOnlyControlEnvKeys(builtEnv);
198
238
  // (b) Build the allowlist-filtered base SpawnOptions (cwd validation, env
199
239
  // filtering, provider-key scoping when model is set, NODE_PATH guard).
200
- const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model);
240
+ // R6-8: modelChain threads the candidate chain for provider-key scoping.
241
+ const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model, modelChain);
201
242
  // (c) Spread builtEnv back on top — the allowlist in step (b) intentionally
202
243
  // strips PI_CREW_*/PI_TEAMS_* keys, so without this spread the child
203
244
  // process would never see steering file, kind, role, or broker creds.
@@ -395,6 +436,3 @@ export function prepareSpawnContext(
395
436
  },
396
437
  };
397
438
  }
398
-
399
- // Silence unused-import warning for logInternalError if not consumed by future helpers.
400
- void logInternalError;
@@ -170,6 +170,18 @@ export interface ChildPiRunInput {
170
170
  task: string;
171
171
  agent: AgentConfig;
172
172
  model?: string;
173
+ /**
174
+ * R6-8 (DR-03 round-6): the task's model CANDIDATE CHAIN (primary +
175
+ * fallbacks, e.g. modelRouting.candidates / the executor's attemptModels)
176
+ * for provider env-key scoping in buildChildPiSpawnOptions. Scopes the
177
+ * allowlist to the UNION of the chain's providers — never all-providers —
178
+ * so a fallback attempt on a second provider does not spawn keyless, and
179
+ * a bare-id primary no longer means zero provider keys when a qualified
180
+ * fallback exists. Absent/empty → single-model scoping (previous
181
+ * behavior); a truly-bare single model then warns (env-key auth + bare id
182
+ * is a likely misconfiguration; auth.json users are unaffected).
183
+ */
184
+ modelChain?: (string | undefined)[];
173
185
  skillPaths?: string[];
174
186
  signal?: AbortSignal;
175
187
  transcriptPath?: string;
@@ -474,7 +486,12 @@ async function trySurfaceBranch(
474
486
  // child's built env whose PI_CREW_DEPTH is parentDepth+1 — layer-1
475
487
  // guard would misfire on our own tier-1 workers.
476
488
  env: depthEnv ?? process.env,
477
- workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model).env as Record<string, string>,
489
+ // R6-8: thread the model candidate chain so the surface pane's worker env
490
+ // is provider-key scoped to primary + fallbacks (same as the headless path).
491
+ workerEnv: buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain).env as Record<
492
+ string,
493
+ string
494
+ >,
478
495
  config: surfaceConfig,
479
496
  role: input.role ?? input.agent.name,
480
497
  livePaneCount: degradeController ? degradeController.livePaneCount() : (surfaceOpts?.livePaneCount ?? 0),
@@ -801,7 +818,8 @@ export async function runChildPi(input: ChildPiRunInput): Promise<ChildPiRunResu
801
818
  return await new Promise<ChildPiRunResult>((resolve) => {
802
819
  // Compose the final SpawnOptions: canary + filter + spread are now
803
820
  // owned by buildFinalChildPiSpawnOptions (see child-pi-spawn.ts, BLOCKER 2 / S5).
804
- const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model);
821
+ // R6-8: modelChain threads the candidate chain for provider-key scoping.
822
+ const spawnOptions = buildFinalChildPiSpawnOptions(input.cwd, mergedEnv, builtEnv, input.model, input.modelChain);
805
823
  const child = spawn(spawnSpec.command, spawnSpec.args, spawnOptions);
806
824
  if (child.pid) {
807
825
  registerActiveChild(child.pid, child);
@@ -68,6 +68,29 @@ export async function runMockChildPi(
68
68
  return { exitCode: 0, stdout, stderr: "" };
69
69
  }
70
70
 
71
+ if (mock === "json-429-empty-settle") {
72
+ // DR-01 P0 test seam: reproduces the live 429-exhaustion shape (runs
73
+ // team_20261014082741 / team_20261014093334): opening assistant text +
74
+ // tool results, then EMPTY assistant message_end events between
75
+ // auto-retry cycles, ending agent_settled — exit 0, NO stopReason error,
76
+ // and finalText keeps the early opening sentence ("finalText lies").
77
+ const opening = JSON.stringify({
78
+ type: "message_end",
79
+ message: { role: "assistant", content: [{ type: "text", text: `[MOCK] Starting work for ${input.agent.name}` }] },
80
+ });
81
+ const toolResult = JSON.stringify({
82
+ type: "message_end",
83
+ message: { role: "toolResult", content: [{ type: "text", text: "[MOCK] tool output" }] },
84
+ });
85
+ const emptyAssistant = JSON.stringify({
86
+ type: "message_end",
87
+ message: { role: "assistant", content: [], usage: { input: 0, output: 0 } },
88
+ });
89
+ const stdout = [opening, toolResult, emptyAssistant, emptyAssistant, JSON.stringify({ type: "agent_settled" })].join("\n") + "\n";
90
+ await observe(input, stdout);
91
+ return { exitCode: 0, stdout, stderr: "" };
92
+ }
93
+
71
94
  if (mock === "json-success" || mock === "adaptive-plan") {
72
95
  const text =
73
96
  mock === "adaptive-plan" && effectiveTask.includes("ADAPTIVE_PLAN_JSON_START")
@@ -12,6 +12,17 @@
12
12
  * their distinct separators. Defaults match `compactString`'s pre-P0-A output
13
13
  * exactly so that callers that do not opt into additional stages get
14
14
  * bit-identical output (L4 backward-compat safety).
15
+ *
16
+ * K6 (DR-03 round-6): budget accounting. The kept important lines are part
17
+ * of the OUTPUT, so their joined length (+ the "\n" seam) is reserved OUT
18
+ * of the head+tail budget (`splitWithImportantLines` `reserveChars`). The
19
+ * composed result therefore fits `maxChars` + {@link TRUNCATION_MARKER_ALLOWANCE}
20
+ * (marker budget) instead of the pre-K6 ~1.15×maxChars, where head+tail
21
+ * alone consumed the full budget and the important-line slack stacked on
22
+ * top unbudgeted. When no important lines are kept, sizing is bit-identical
23
+ * to the legacy behavior (no reserve). The stage's own monotonic-shrink
24
+ * invariant is preserved: if the composed result would be ≥ the input, the
25
+ * input is returned unchanged.
15
26
  */
16
27
  import type { ICompactStage } from "../compact-pipeline.ts";
17
28
  import { splitWithImportantLines } from "../important-line-classifier.ts";
@@ -34,6 +45,15 @@ const DEFAULT_MARKER: TruncationMarkerConfig = {
34
45
  tailSeparator: "\n",
35
46
  };
36
47
 
48
+ /**
49
+ * K6 "small constant" = the marker budget: the longest marker template
50
+ * (with-important-lines variant, 6-digit counters) plus separators plus
51
+ * digit drift. The head+tail budget absorbs the kept-important-line content
52
+ * (joined length + seam), so the composed result exceeds `maxChars` by at
53
+ * most this marker budget — never by the old unbudgeted ~15% slack.
54
+ */
55
+ export const TRUNCATION_MARKER_ALLOWANCE = 160;
56
+
37
57
  export class TruncationStage implements ICompactStage {
38
58
  readonly id = "truncation";
39
59
  private readonly maxChars: number;
@@ -55,19 +75,41 @@ export class TruncationStage implements ICompactStage {
55
75
  }
56
76
  apply(text: string): string {
57
77
  if (text.length <= this.maxChars) return text;
58
- const { head, tail, importantLines, baseDropped } = splitWithImportantLines(text, this.maxChars, {
78
+ // K6: two-pass budget accounting.
79
+ // Pass 1 (today's semantics, full-budget split) learns which important
80
+ // lines are kept — their joined length is part of the OUTPUT and must be
81
+ // reserved out of the head+tail budget instead of stacking on top.
82
+ const pass1 = splitWithImportantLines(text, this.maxChars, {
59
83
  preserveImportant: this.preserveImportant,
60
84
  });
85
+ const joined = pass1.importantLines.join("\n");
86
+ // Reserve exactly the kept content (+ the "\n" seam between marker and
87
+ // joined lines). The marker text + separators stay as the tolerated
88
+ // TRUNCATION_MARKER_ALLOWANCE overhead; when no important lines are
89
+ // kept the reserve is 0 and sizing is bit-identical to legacy.
90
+ const reserve = joined.length > 0 ? joined.length + 1 : 0;
91
+ // Pass 2 re-slices head+tail against the shrunken budget. It is a pure
92
+ // budgeted slice: preserveImportant:false skips the (already done)
93
+ // scan, and pass-1's lines are guaranteed to sit inside pass-2's
94
+ // dropped middle — the pass-2 middle is a superset of pass-1's because
95
+ // the shrunken head/tail regions are subsets of pass-1's.
96
+ const { head, tail, baseDropped } = splitWithImportantLines(text, this.maxChars, {
97
+ preserveImportant: false,
98
+ reserveChars: reserve,
99
+ });
100
+ const importantLines = pass1.importantLines;
61
101
  let result: string;
62
102
  if (importantLines.length === 0) {
63
103
  result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail preserved]...${this.marker.tailSeparator}${tail}`;
64
104
  } else {
65
- const joined = importantLines.join("\n");
66
105
  const remaining = text.length - head.length - tail.length - joined.length;
67
106
  result = `${head}${this.marker.headSeparator}...[pi-crew ${this.marker.verb} ${baseDropped} ${this.marker.unit}, head+tail + ${importantLines.length} important lines preserved, ${remaining} ${this.marker.unit} remaining dropped]...\n${joined}${this.marker.tailSeparator}${tail}`;
68
107
  }
69
108
  // Defense-in-depth: this stage's own monotonic-shrink invariant. The
70
- // pipeline gate is a SECOND line of defense.
109
+ // pipeline gate is a SECOND line of defense. With the K6 budget the
110
+ // composed result is ≤ maxChars + marker budget, so this guard only
111
+ // fires for inputs barely over maxChars (where returning the input
112
+ // verbatim is still within the allowance) or arithmetic bugs.
71
113
  if (result.length >= text.length) return text;
72
114
  return result;
73
115
  }
@@ -20,7 +20,55 @@
20
20
  * "compacted ... chars, head+tail preserved") and `readIfSmall` (marker
21
21
  * "truncated ... bytes, head+tail preserved") backward-compatible when no
22
22
  * important lines are present.
23
+ *
24
+ * K6/K7 (DR-03 round-6):
25
+ * - `reserveChars` lets the caller (TruncationStage) subtract the composed
26
+ * marker + separator + joined-important-line overhead from the head+tail
27
+ * budget, so the FINAL composed result fits `maxChars` + small constant
28
+ * instead of stacking unbudgeted overhead on top (~1.15×maxChars).
29
+ * - Head/tail/middle boundaries are surrogate-pair-safe: a code-unit index
30
+ * that would split an astral pair (emoji, CJK extensions) is backed off
31
+ * so every emitted part contains only whole code points.
32
+ */
33
+
34
+ /** Default fraction of `maxChars` available for important-line content. */
35
+ export const DEFAULT_SLACK_FACTOR = 0.15;
36
+ /** Default cap on candidate lines before slack-budget selection. */
37
+ export const DEFAULT_MAX_IMPORTANT_LINES = 30;
38
+
39
+ function isHighSurrogateUnit(codeUnit: number): boolean {
40
+ return codeUnit >= 0xd800 && codeUnit <= 0xdbff;
41
+ }
42
+
43
+ function isLowSurrogateUnit(codeUnit: number): boolean {
44
+ return codeUnit >= 0xdc00 && codeUnit <= 0xdfff;
45
+ }
46
+
47
+ /**
48
+ * K7: back a head-end boundary off while it would split a surrogate pair.
49
+ * `value.slice(0, boundary)` must never end on a lone high surrogate — if the
50
+ * code unit at `boundary - 1` is a high surrogate, its low partner lives at
51
+ * `boundary` and slicing there would orphan both halves; the boundary walks
52
+ * back so the whole pair falls into the (dropped) middle instead.
23
53
  */
54
+ export function safeHeadBoundary(value: string, end: number): number {
55
+ let boundary = Math.max(0, Math.min(end, value.length));
56
+ while (boundary > 0 && isHighSurrogateUnit(value.charCodeAt(boundary - 1))) boundary -= 1;
57
+ return boundary;
58
+ }
59
+
60
+ /**
61
+ * K7: advance a tail-start boundary while it would split a surrogate pair.
62
+ * `value.slice(boundary)` must never start on a lone low surrogate — if the
63
+ * code unit at `boundary` is a low surrogate, its high partner lives at
64
+ * `boundary - 1` and slicing there would orphan both halves; the boundary
65
+ * walks forward so the whole pair stays in the (dropped) middle instead.
66
+ */
67
+ export function safeTailBoundary(value: string, start: number): number {
68
+ let boundary = Math.max(0, Math.min(start, value.length));
69
+ while (boundary < value.length && isLowSurrogateUnit(value.charCodeAt(boundary))) boundary += 1;
70
+ return boundary;
71
+ }
24
72
 
25
73
  /** Diagnostic patterns. Anchored where safe to avoid matching noise. */
26
74
  export const IMPORTANT_LINE_PATTERNS: readonly RegExp[] = [
@@ -75,7 +123,9 @@ export interface TruncationSplit {
75
123
  * fit the slack budget.
76
124
  */
77
125
  importantLines: string[];
78
- /** `value.length - maxChars` — chars dropped if no important lines preserved. */
126
+ /** `value.length - headTailBudget` — chars dropped if no important lines
127
+ * preserved. `headTailBudget` is `maxChars - reserveChars`; with no
128
+ * reserve (the default) this equals `value.length - maxChars`. */
79
129
  baseDropped: number;
80
130
  }
81
131
 
@@ -84,8 +134,19 @@ export interface SplitOptions {
84
134
  preserveImportant?: boolean;
85
135
  /** Hard cap on candidate lines before slack-budget selection. Default 30. */
86
136
  maxImportantLines?: number;
87
- /** Fraction of `maxChars` available for important-line content. Default 0.15. */
137
+ /**
138
+ * Fraction of `maxChars` available for important-line content. Default 0.15
139
+ * ({@link DEFAULT_SLACK_FACTOR}).
140
+ */
88
141
  slackFactor?: number;
142
+ /**
143
+ * K6: code units the CALLER will compose AROUND head+tail (marker text,
144
+ * separators, joined important lines). The head+tail budget shrinks by
145
+ * this reserve so the caller's final composed result fits `maxChars` +
146
+ * small constant instead of stacking unbudgeted overhead on top. Default
147
+ * 0 — legacy semantics where head+tail alone consume the full maxChars.
148
+ */
149
+ reserveChars?: number;
89
150
  }
90
151
 
91
152
  /**
@@ -101,24 +162,37 @@ export function splitWithImportantLines(value: string, maxChars: number, opts: S
101
162
  if (value.length <= maxChars) {
102
163
  return { head: value, tail: "", importantLines: [], baseDropped: 0 };
103
164
  }
104
- const headLen = Math.floor(maxChars * 0.75);
105
- const tailLen = maxChars - headLen;
106
- const head = value.slice(0, headLen);
107
- const tail = value.slice(value.length - tailLen);
165
+ // K6: reserve the caller's composed overhead (marker + separators +
166
+ // joined important lines) out of the head+tail budget. Clamped so at
167
+ // least one code unit of budget survives even when the reserve exceeds
168
+ // maxChars (pathologically small budgets — the marker alone dominates).
169
+ const reserve = Math.max(0, Math.min(Math.floor(opts.reserveChars ?? 0), maxChars - 1));
170
+ const headTailBudget = Math.max(1, maxChars - reserve);
171
+ const headLen = Math.floor(headTailBudget * 0.75);
172
+ const tailLen = headTailBudget - headLen;
173
+ // K7: surrogate-safe boundaries. Raw code-unit slices previously split
174
+ // astral pairs at the 75% head boundary and the tail start, emitting
175
+ // lone surrogates (mojibake) into the compacted output. Back off while
176
+ // the boundary would split a pair — the whole pair then falls into the
177
+ // middle (dropped) region instead of being broken across head/tail.
178
+ const headEnd = safeHeadBoundary(value, headLen);
179
+ const tailStart = safeTailBoundary(value, value.length - tailLen);
180
+ const head = value.slice(0, headEnd);
181
+ const tail = value.slice(tailStart);
108
182
 
109
183
  if (opts.preserveImportant === false) {
110
184
  return {
111
185
  head,
112
186
  tail,
113
187
  importantLines: [],
114
- baseDropped: value.length - maxChars,
188
+ baseDropped: value.length - headTailBudget,
115
189
  };
116
190
  }
117
191
 
118
- const slackFactor = opts.slackFactor ?? 0.15;
192
+ const slackFactor = opts.slackFactor ?? DEFAULT_SLACK_FACTOR;
119
193
  const slackChars = Math.max(0, Math.floor(maxChars * slackFactor));
120
- const maxCandidates = opts.maxImportantLines ?? 30;
121
- const middle = value.slice(headLen, value.length - tailLen);
194
+ const maxCandidates = opts.maxImportantLines ?? DEFAULT_MAX_IMPORTANT_LINES;
195
+ const middle = value.slice(headEnd, tailStart);
122
196
  const candidates = extractImportantLines(middle, maxCandidates);
123
197
 
124
198
  // Greedily pick whole lines that fit in the slack budget.
@@ -135,6 +209,6 @@ export function splitWithImportantLines(value: string, maxChars: number, opts: S
135
209
  head,
136
210
  tail,
137
211
  importantLines: chosen,
138
- baseDropped: value.length - maxChars,
212
+ baseDropped: value.length - headTailBudget,
139
213
  };
140
214
  }
@@ -39,6 +39,14 @@ export interface ToolResultEntry {
39
39
  content: string;
40
40
  /** Whether the tool result represents an error. */
41
41
  isError?: boolean;
42
+ /**
43
+ * K8: the tool's REAL exit code when the original result carries one
44
+ * (e.g. a bash exit status). Passed through to the digest verbatim
45
+ * instead of the historical `isError ? 1 : 0` guess — exit 127 digests
46
+ * as `exit=127`. Optional: callers whose source shape has no exit code
47
+ * omit it and keep the legacy guess.
48
+ */
49
+ exitCode?: number;
42
50
  }
43
51
 
44
52
  /** A file mutation event (edit/write) that can invalidate earlier reads. */
@@ -102,11 +110,23 @@ function estimateTokens(text: string): number {
102
110
 
103
111
  const DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER = 1.25;
104
112
 
105
- function firstErrorLine(text: string): string | undefined {
106
- return text
107
- .split(/\r?\n/)
108
- .find((line) => /error|failed|exception|panic/i.test(line))
109
- ?.trim();
113
+ function firstErrorLine(text: string, isError = false): string | undefined {
114
+ const lines = text.split(/\r?\n/);
115
+ const matched = lines.find((line) => /error|failed|exception|panic/i.test(line))?.trim();
116
+ if (matched !== undefined) return matched;
117
+ // K8: multi-line stderr/stack whose HEAD carries the diagnostic but no
118
+ // line matches the keyword patterns (localized messages, shell errors,
119
+ // stack frames without a keyword). Fall back to the first 3 non-empty
120
+ // lines of the content head — the head names the failing frame; the old
121
+ // digest kept only the TAIL line, which for a stack trace is the least
122
+ // informative frame. Gated on isError so success output never grows a
123
+ // bogus error= part.
124
+ if (!isError) return undefined;
125
+ const head = lines
126
+ .map((line) => line.trim())
127
+ .filter((line) => line.length > 0)
128
+ .slice(0, 3);
129
+ return head.length > 0 ? head.join(" | ") : undefined;
110
130
  }
111
131
 
112
132
  function truncateField(value: string, maxLength: number): string {
@@ -119,22 +139,26 @@ function truncateField(value: string, maxLength: number): string {
119
139
  * Generate a compact digest of a tool result for the digest notice.
120
140
  * Supports bash (exit code + tail line), grep/search (match/file counts),
121
141
  * and falls back to undefined for tools without a known digest format.
142
+ *
143
+ * K8: `exitCode` passes the REAL exit status through when the original
144
+ * result carries one (exit 127 → `exit=127`, not the hardcoded `exit=1`);
145
+ * when absent the legacy `isError ? 1 : 0` guess is kept.
122
146
  */
123
- export function resultDigest(toolName: string, content: string, isError?: boolean): string | undefined {
147
+ export function resultDigest(toolName: string, content: string, isError?: boolean, exitCode?: number): string | undefined {
124
148
  const name = toolName.toLowerCase();
125
149
  const text = content ?? "";
126
150
  if (name === "bash") {
127
- const exitCode = isError ? 1 : 0;
151
+ const exitCodeValue = typeof exitCode === "number" && Number.isFinite(exitCode) ? Math.trunc(exitCode) : isError ? 1 : 0;
128
152
  const tail = text.trim().split(/\r?\n/).filter(Boolean).at(-1) ?? "";
129
- const error = firstErrorLine(text);
130
- return [`exit=${exitCode}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
153
+ const error = firstErrorLine(text, isError);
154
+ return [`exit=${exitCodeValue}`, tail ? `tail=${tail}` : undefined, error ? `error=${error}` : undefined]
131
155
  .filter((part): part is string => part !== undefined)
132
156
  .join("; ");
133
157
  }
134
158
  if (name === "search" || name === "grep") {
135
159
  const match = text.match(/(\d+)\s+matches?/i) ?? text.match(/totalMatches["']?:\s*(\d+)/i);
136
160
  const files = text.match(/(\d+)\s+files?/i) ?? text.match(/filesWithMatches["']?:\s*(\d+)/i);
137
- const error = firstErrorLine(text);
161
+ const error = firstErrorLine(text, isError);
138
162
  return (
139
163
  [match ? `matches=${match[1]}` : undefined, files ? `files=${files[1]}` : undefined, error ? `error=${error}` : undefined]
140
164
  .filter((part): part is string => part !== undefined)
@@ -146,7 +170,7 @@ export function resultDigest(toolName: string, content: string, isError?: boolea
146
170
 
147
171
  function createPrunedNotice(tokens: number, entry: ToolResultEntry): string {
148
172
  const generic = `[Output pruned — ${tokens} tokens]`;
149
- const digest = resultDigest(entry.toolName, entry.content, entry.isError);
173
+ const digest = resultDigest(entry.toolName, entry.content, entry.isError, entry.exitCode);
150
174
  if (!digest) return generic;
151
175
  const genericTokens = countTokens(generic);
152
176
  const maxTokens = Math.max(genericTokens, Math.floor(genericTokens * DIGEST_NOTICE_TOKEN_CAP_MULTIPLIER));
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import {
5
+ atomicWriteFile,
5
6
  atomicWriteJson,
6
7
  atomicWriteJsonCoalesced,
7
8
  cancelPendingCoalescedWriteForPath,
@@ -686,12 +687,32 @@ function readSeqFromSidecar(filePath: string): number | undefined {
686
687
 
687
688
  function writeSeqToSidecar(filePath: string, seq: number): void {
688
689
  try {
689
- fs.writeFileSync(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq));
690
+ // D6 (DR-02): tmp+rename so a concurrent reader (nextAgentEventSeq on
691
+ // another thread/process) never observes a torn or empty .seq.
692
+ // best-effort durability per F4: the sidecar is only an O(1) lookup
693
+ // optimization — losing it on a crash costs the O(n) scan fallback,
694
+ // never correctness. Path and content format unchanged (String(seq)).
695
+ atomicWriteFile(`${filePath}.${AGENT_EVENT_SEQ_SIDECAR}`, String(seq), { durability: "best-effort" });
690
696
  } catch (error) {
691
697
  logInternalError("crew-agent-records.seq-sidecar", error, `filePath=${filePath}`);
692
698
  }
693
699
  }
694
700
 
701
+ function scanMaxAgentEventSeq(filePath: string): number {
702
+ let max = 0;
703
+ for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
704
+ if (!line.trim()) continue;
705
+ try {
706
+ const parsed = JSON.parse(line) as { seq?: unknown };
707
+ if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
708
+ else max += 1;
709
+ } catch {
710
+ max += 1;
711
+ }
712
+ }
713
+ return max;
714
+ }
715
+
695
716
  function nextAgentEventSeq(filePath: string): number {
696
717
  if (!fs.existsSync(filePath)) {
697
718
  // Clean up stale sidecar when main file is gone.
@@ -708,24 +729,21 @@ function nextAgentEventSeq(filePath: string): number {
708
729
  // FIX: Try sidecar file for O(1) lookup before falling back to O(n) scan.
709
730
  const sidecarSeq = readSeqFromSidecar(filePath);
710
731
  if (sidecarSeq !== undefined) {
732
+ // FS1 (fix, EL-1 port from event-log sequence-cache.ts): a REGRESSED
733
+ // sidecar (crash between append and sidecar advance, or lost best-effort
734
+ // durability) must never re-mint used seqs — trust it only as
735
+ // max(sidecar, tail-scan), else cursor consumers (seq > sinceSeq)
736
+ // permanently drop the duplicate-seq events.
737
+ const safeSeq = Math.max(sidecarSeq, scanMaxAgentEventSeq(filePath));
711
738
  setAgentEventSeqCache(filePath, {
712
739
  size: stat.size,
713
740
  mtimeMs: stat.mtimeMs,
714
- seq: sidecarSeq,
741
+ seq: safeSeq,
715
742
  });
716
- return sidecarSeq + 1;
717
- }
718
- let max = 0;
719
- for (const line of fs.readFileSync(filePath, "utf-8").split(/\r?\n/)) {
720
- if (!line.trim()) continue;
721
- try {
722
- const parsed = JSON.parse(line) as { seq?: unknown };
723
- if (typeof parsed.seq === "number" && Number.isFinite(parsed.seq)) max = Math.max(max, parsed.seq);
724
- else max += 1;
725
- } catch {
726
- max += 1;
727
- }
743
+ if (safeSeq !== sidecarSeq) writeSeqToSidecar(filePath, safeSeq);
744
+ return safeSeq + 1;
728
745
  }
746
+ const max = scanMaxAgentEventSeq(filePath);
729
747
  setAgentEventSeqCache(filePath, {
730
748
  size: stat.size,
731
749
  mtimeMs: stat.mtimeMs,
@@ -26,6 +26,12 @@ export interface DeliveryCoordinatorDeps {
26
26
  const PENDING_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
27
27
 
28
28
  export class DeliveryCoordinator {
29
+ // DR-02 C3: the queue was TTL-bounded ONLY (24h) — an extension that stays
30
+ // sessionless while runs keep completing could grow `pending` unboundedly.
31
+ // Hard count cap with drop-oldest (oldest delivery is the least likely to
32
+ // still matter); the TTL eviction below stays unchanged. Static (not
33
+ // module-const) so tests can read/patch it like Semaphore.MAX_QUEUE.
34
+ static readonly MAX_PENDING = 1_000;
29
35
  private active = false;
30
36
  private generation = 0;
31
37
  private pending: PendingDelivery[] = [];
@@ -36,6 +42,9 @@ export class DeliveryCoordinator {
36
42
  /** The session id passed to the most recent activate(); used by flushQueuedResults
37
43
  * to park deliveries owned by a different session (vector #12). */
38
44
  private activeSessionId?: string;
45
+ /** DR-02 C3: set after the first warn of an overflow episode; cleared once
46
+ * the queue is back at/below the cap so the NEXT episode warns again. */
47
+ private pendingOverflowWarned = false;
39
48
 
40
49
  constructor(deps: DeliveryCoordinatorDeps) {
41
50
  this.deps = deps;
@@ -215,6 +224,27 @@ export class DeliveryCoordinator {
215
224
  this.ttlTimer.unref();
216
225
  }
217
226
  this.pending.push({ ...delivery, generation: this.generation });
227
+ // DR-02 C3: count cap — drop-oldest while over cap, warn ONCE per
228
+ // overflow episode (flag resets once the queue is back under cap, so a
229
+ // later separate overflow is visible again).
230
+ if (this.pending.length <= DeliveryCoordinator.MAX_PENDING) {
231
+ this.pendingOverflowWarned = false;
232
+ return;
233
+ }
234
+ let dropped = 0;
235
+ while (this.pending.length > DeliveryCoordinator.MAX_PENDING) {
236
+ if (this.pending.shift() === undefined) break;
237
+ dropped++;
238
+ }
239
+ if (dropped > 0 && !this.pendingOverflowWarned) {
240
+ this.pendingOverflowWarned = true;
241
+ logInternalError(
242
+ "delivery-coordinator.pending-overflow",
243
+ undefined,
244
+ `dropped=${dropped} max=${DeliveryCoordinator.MAX_PENDING} remaining=${this.pending.length} runId=${delivery.runId}`,
245
+ "warn",
246
+ );
247
+ }
218
248
  }
219
249
 
220
250
  private evictExpired(): void {
@@ -202,12 +202,15 @@ export async function sweepExpiredWaitingTasks(
202
202
  }
203
203
  try {
204
204
  const existingRuntimes = new Map(readCrewAgents(fresh.manifest).map((a) => [a.taskId, a.runtime]));
205
- saveCrewAgents(
206
- fresh.manifest,
207
- tasks
208
- .filter((t) => requeuedTaskIds.includes(t.id))
209
- .map((t) => recordFromTask(fresh.manifest, t, existingRuntimes.get(t.id) ?? "child-process")),
210
- );
205
+ // R4-A1 (fix): agents.json is a full-index overwrite — merge the requeued
206
+ // records into the existing set (team-runner.ts:525 pattern) instead of
207
+ // persisting the subset alone (siblings incl. RUNNING agents were wiped).
208
+ const mergedById = new Map(readCrewAgents(fresh.manifest).map((record) => [record.id, record] as const));
209
+ for (const task of tasks.filter((t) => requeuedTaskIds.includes(t.id))) {
210
+ const record = recordFromTask(fresh.manifest, task, existingRuntimes.get(task.id) ?? "child-process");
211
+ mergedById.set(record.id, record);
212
+ }
213
+ saveCrewAgents(fresh.manifest, [...mergedById.values()]);
211
214
  } catch (error) {
212
215
  logInternalError("dispatch-batch.ask-timeout.crewAgents", error, `runId=${runId}`);
213
216
  }