pi-crew 0.11.21 → 0.11.22

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 (82) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/index.mjs +537 -526
  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.ts +24 -2
  16. package/src/prompt/inbox-poll.ts +77 -25
  17. package/src/prompt/message-tool.ts +62 -4
  18. package/src/prompt/prompt-runtime.ts +82 -32
  19. package/src/runtime/broker/crew-broker-client.ts +74 -16
  20. package/src/runtime/broker/crew-broker.ts +111 -77
  21. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  22. package/src/runtime/broker/wait-status-cache.ts +14 -5
  23. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  24. package/src/runtime/child-pi/child-pi.ts +20 -2
  25. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  26. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  27. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  28. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  29. package/src/runtime/crew-agent-records.ts +7 -1
  30. package/src/runtime/delivery-coordinator.ts +30 -0
  31. package/src/runtime/dwf-state-store.ts +41 -7
  32. package/src/runtime/finalize-run.ts +13 -2
  33. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  34. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  35. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  36. package/src/runtime/live-session/embedded-tier.ts +3 -1
  37. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  38. package/src/runtime/model/model-fallback.ts +23 -1
  39. package/src/runtime/output/pi-json-output.ts +26 -1
  40. package/src/runtime/policy-engine.ts +18 -0
  41. package/src/runtime/process/zombie-scanner.ts +78 -14
  42. package/src/runtime/process-status.ts +6 -1
  43. package/src/runtime/recovery/crash-recovery.ts +170 -28
  44. package/src/runtime/rpc/rpc-worker.ts +44 -1
  45. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  46. package/src/runtime/scheduling/scheduler.ts +74 -11
  47. package/src/runtime/scratchpad/engine.ts +26 -3
  48. package/src/runtime/settings-store.ts +22 -2
  49. package/src/runtime/surface/tmux-provider.ts +24 -3
  50. package/src/runtime/task-health.ts +16 -1
  51. package/src/runtime/task-output-context.ts +143 -71
  52. package/src/runtime/task-packet.ts +70 -17
  53. package/src/runtime/task-runner/child-executor.ts +27 -0
  54. package/src/runtime/task-runner/output-splitter.ts +79 -16
  55. package/src/runtime/task-runner/post-execution.ts +75 -15
  56. package/src/runtime/task-runner/state-helpers.ts +92 -2
  57. package/src/runtime/usage-tracker.ts +11 -0
  58. package/src/runtime/verification/green-contract.ts +17 -0
  59. package/src/runtime/verification/verification-gates.ts +24 -6
  60. package/src/runtime/workspace-lock.ts +53 -0
  61. package/src/schema/team-tool-schema.ts +10 -4
  62. package/src/state/coordination/mailbox.ts +133 -38
  63. package/src/state/decision-ledger.ts +56 -8
  64. package/src/state/event-log/event-log-rotation.ts +83 -5
  65. package/src/state/event-log/event-log.ts +181 -12
  66. package/src/state/stores/active-run-registry.ts +10 -3
  67. package/src/state/types.ts +9 -1
  68. package/src/ui/agents-jobs-browser.ts +22 -8
  69. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  70. package/src/ui/events-state-source.ts +67 -28
  71. package/src/ui/inline-panel/agent-pane.ts +13 -6
  72. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  73. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  74. package/src/ui/inline-panel/index.ts +21 -1
  75. package/src/ui/live-run-sidebar.ts +14 -3
  76. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  77. package/src/ui/powerbar-publisher.ts +6 -3
  78. package/src/ui/rail.ts +7 -1
  79. package/src/ui/render-scheduler.ts +53 -1
  80. package/src/ui/run-action-dispatcher.ts +68 -20
  81. package/src/ui/status-colors.ts +9 -0
  82. package/src/utils/env-filter.ts +8 -0
@@ -39,6 +39,25 @@ function validateScheduledJob(job: unknown): boolean {
39
39
  return typeof obj.id === "string" && obj.id.length > 0 && typeof obj.scheduleType === "string" && typeof obj.enabled === "boolean";
40
40
  }
41
41
 
42
+ /**
43
+ * R6-2(c) (round-6 audit): a persisted `lastStatus:"running"` is an in-flight
44
+ * flag written by a process that may already be dead when the file is next
45
+ * read. It is meaningless at load time and lethal downstream: the scheduler's
46
+ * fire() treats "running" as backpressure (in-flight guard), so re-loading the
47
+ * flag verbatim made a job that was mid-run at crash/restart time skip EVERY
48
+ * later occurrence — scheduled jobs died after restart. Strip only the stale
49
+ * "running" flag at load; terminal statuses ("success"/"error") survive
50
+ * untouched, as does every other field.
51
+ */
52
+ function normalizeStaleRunningJob(job: unknown): unknown {
53
+ if (!job || typeof job !== "object") return job;
54
+ const obj = job as Record<string, unknown>;
55
+ if (obj.lastStatus !== "running") return job;
56
+ const copy: Record<string, unknown> = { ...obj };
57
+ delete copy.lastStatus;
58
+ return copy;
59
+ }
60
+
42
61
  function sanitizeSettings(raw: unknown): CrewSettings {
43
62
  if (!raw || typeof raw !== "object") return {};
44
63
  const r = raw as Record<string, unknown>;
@@ -74,9 +93,10 @@ function sanitizeSettings(raw: unknown): CrewSettings {
74
93
  if (typeof r.notifierIntervalMs === "number" && r.notifierIntervalMs >= 1000) {
75
94
  out.notifierIntervalMs = r.notifierIntervalMs;
76
95
  }
77
- // Pass through scheduledJobs after basic validation
96
+ // Pass through scheduledJobs after basic validation + stale-flag
97
+ // normalization (R6-2c — see normalizeStaleRunningJob).
78
98
  if (Array.isArray(r.scheduledJobs)) {
79
- out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob);
99
+ out.scheduledJobs = (r.scheduledJobs as unknown[]).filter(validateScheduledJob).map(normalizeStaleRunningJob);
80
100
  }
81
101
  return out;
82
102
  }
@@ -12,12 +12,14 @@
12
12
  * onExit: MỘT interval 2s cho cả provider — mỗi tick gọi list-panes đúng
13
13
  * một lần rồi đối chiếu mọi pane đang được theo dõi (không spawn N subprocess
14
14
  * cho N pane). Pane biến khỏi list hoặc pane_dead=1 → "pane-closed" bắn đúng
15
- * một lần. tmux call throw (binary mất đột ngột, server chết) → "mux-dead"
15
+ * một lần. tmux call throw HAI lần liên tiếp (binary mất đột ngột, server chết
16
+ * — R7-13 strike counter; transient EAGAIN/EMFILE chỉ warn) → "mux-dead"
16
17
  * cho mọi handle còn sống.
17
18
  */
18
19
 
19
20
  import { execFileSync } from "node:child_process";
20
21
 
22
+ import { logInternalError } from "../../utils/internal-error.ts";
21
23
  import type { SurfaceDetection, SurfaceExitReason, SurfaceHandle, SurfaceProvider, SurfaceSpawnOpts } from "./surface-provider.ts";
22
24
  import { MAX_PANES_PER_TAB, splitDirectionFor } from "./surface-provider.ts";
23
25
 
@@ -111,6 +113,11 @@ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider
111
113
  // Watcher theo pane id — MỘT timer chung cho mọi pane đang theo dõi.
112
114
  const watchers = new Map<string, PaneWatcher>();
113
115
  let timer: SurfaceTimer | null = null;
116
+ // R7-13 (DR-04 round-7): consecutive-failure strike counter cho poll list-panes.
117
+ // 1 lỗi transient (EAGAIN/EMFILE, binary hog dụng một chốc) KHÔNG được giết
118
+ // hết watcher — chỉ tăng strike + log warn; đến strike thứ 2 liên tiếp mới
119
+ // kết luận mux chết thật (semantics cũ). Poll thành công bất kỳ → reset về 0.
120
+ let muxFailStrikes = 0;
114
121
 
115
122
  // Tab-layout (spec 2026-08-27-surface-tab-layout): tabKey(run) → MỌI window
116
123
  // của run + số pane đã spawn trong window đang nhận. Tab mở khi run spawn
@@ -153,11 +160,25 @@ export function createTmuxProvider(deps: TmuxProviderDeps = {}): SurfaceProvider
153
160
  // còn không -a thì chỉ thấy current window (pane-closed giả khi user
154
161
  // switch window giữa chừng).
155
162
  stdout = tmux(["list-panes", "-a", "-F", "#{pane_dead} #{pane_id}"]);
156
- } catch {
157
- // tmux binary mất đột ngột / server chết — mọi handle còn sống thành mux-dead.
163
+ } catch (error) {
164
+ // R7-13: strike counter — ≥2 lỗi list-panes LIÊN TIẾP mới coi là mux chết.
165
+ // Lỗi đơn lẻ (transient EAGAIN/EMFILE…) → giữ nguyên watchers + warn,
166
+ // tick kế vẫn poll (counter được reset bởi bất kỳ poll thành công nào).
167
+ muxFailStrikes += 1;
168
+ if (muxFailStrikes < 2) {
169
+ logInternalError(
170
+ "tmux-provider",
171
+ error instanceof Error ? error : new Error(String(error)),
172
+ `list-panes failed (strike ${muxFailStrikes}/2) — keeping pane watchers alive`,
173
+ "warn",
174
+ );
175
+ return;
176
+ }
177
+ // tmux binary mất đột ngột / server chết thật sự — mọi handle còn sống thành mux-dead.
158
178
  for (const paneId of [...watchers.keys()]) fire(paneId, "mux-dead");
159
179
  return;
160
180
  }
181
+ muxFailStrikes = 0;
161
182
  const status = parsePaneStatus(stdout);
162
183
  for (const [paneId, watcher] of [...watchers]) {
163
184
  // Không có trong list (đã dọn) hoặc pane_dead=1 → đóng.
@@ -32,6 +32,10 @@ export interface TaskSummary {
32
32
  id: string;
33
33
  status: string;
34
34
  stalledSince?: number;
35
+ /** L-6 (2026-10-16): last worker heartbeat (tasks already carry
36
+ * `heartbeat.lastSeenAt` — no new writer needed). Preferred over
37
+ * `stalledSince`, which has no writer. */
38
+ heartbeat?: { lastSeenAt: string };
35
39
  }
36
40
 
37
41
  export interface ManifestSummary {
@@ -47,7 +51,18 @@ export function computeRunHealth(manifest: ManifestSummary): RunHealth {
47
51
  if (taskCount === 0) return { score: 100, grade: "A", penalties: [], deltas: [] };
48
52
 
49
53
  const failedCount = tasks.filter((t) => t.status === "failed").length;
50
- const stalledCount = tasks.filter((t) => t.stalledSince !== undefined && Date.now() - t.stalledSince > STALLED_THRESHOLD_MS).length;
54
+ // L-6: stall detection derived from the task's own heartbeat (already
55
+ // written by worker heartbeat tracking — no new writer). Only RUNNING
56
+ // tasks can stall. `stalledSince` (no known writer) stays as a fallback
57
+ // for summaries without heartbeat data.
58
+ const stalledCount = tasks.filter((t) => {
59
+ if (t.status !== "running") return false;
60
+ if (t.heartbeat) {
61
+ const lastSeenAt = Date.parse(t.heartbeat.lastSeenAt);
62
+ return Number.isFinite(lastSeenAt) && Date.now() - lastSeenAt > STALLED_THRESHOLD_MS;
63
+ }
64
+ return t.stalledSince !== undefined && Date.now() - t.stalledSince > STALLED_THRESHOLD_MS;
65
+ }).length;
51
66
 
52
67
  const failureRate = failedCount / taskCount;
53
68
  if (failureRate > 0.2) {
@@ -6,9 +6,11 @@ import { atomicWriteFile } from "../state/atomic-write.ts";
6
6
  import { writeArtifact } from "../state/stores/artifact-store.ts";
7
7
  import type { ArtifactDescriptor, TeamRunManifest, TeamTaskState } from "../state/types.ts";
8
8
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
9
+ import { countTokens } from "../utils/token-counter.ts";
9
10
  import type { WorkflowStep } from "../workflows/workflow-config.ts";
10
11
  import { applyCompactPipeline } from "./compaction/compact-pipeline.ts";
11
12
  import { ANSI_STRIP_STAGE, BLANK_COLLAPSE_STAGE, TruncationStage } from "./compaction/compact-stages/index.ts";
13
+ import { splitWithImportantLines } from "./compaction/important-line-classifier.ts";
12
14
  import { DEFAULT_PRUNE_CONFIG, type FileEditEvent, pruneToolOutputs, type ToolResultEntry } from "./compaction/tool-output-pruner.ts";
13
15
 
14
16
  export interface DependencyContextEntry {
@@ -47,6 +49,13 @@ export interface DependencyContextEntry {
47
49
  * block for marked entries; `writeTaskInputsArtifact` still serializes the
48
50
  * ORIGINAL (untrimmed) context — the trim is render-only, never persisted. */
49
51
  budgetTrimmed?: boolean;
52
+ /** G6 (DR-02 round-5): set by {@link enforceDependencyInlineBudget} when the
53
+ * 96KB layer downgraded this dep to path-only (`resultSummary: ""`). The
54
+ * renderer uses it to emit "[result withheld by inline budget — see
55
+ * artifact path above]" instead of the false "(no result output)" — the
56
+ * output EXISTS on disk at `resultPath`; only the inline copy was
57
+ * budgeted away. */
58
+ resultWithheldByBudget?: boolean;
50
59
  }
51
60
 
52
61
  export interface DependencyOutputContext {
@@ -302,6 +311,22 @@ function aggregateUsage(task: TeamTaskState): DependencyContextEntry["usage"] {
302
311
  return { inputTokens, outputTokens, durationMs };
303
312
  }
304
313
 
314
+ /** L4 + handoff-budget shared priority score (higher = keep inline / keep
315
+ * FULL form longer). R5-5 (DR-02): the handoff-budget trim
316
+ * (`applyHandoffBudget`) reuses the exact scoring the 96KB L4 layer
317
+ * (`enforceDependencyInlineBudget`) uses so a failed/needs-attention dep is
318
+ * the LAST thing either budget layer trims.
319
+ *
320
+ * Score: (highPriority?1000:0) + (recency/1e10) + (relevance*10) - (index*0.001)
321
+ * The 1000 base ensures high-priority wins even with no recency/relevance.
322
+ * recency/1e10 keeps it in [0, 1] for ms timestamps up to 1e13 (~year 2286).
323
+ * relevance*10 puts it in [0, 10].
324
+ * index*0.001 keeps stable order within the same bucket. */
325
+ function inlinePriorityScore(dep: DependencyContextEntry, originalIndex: number): number {
326
+ const highPriority = dep.status === "failed" || dep.status === "needs-attention";
327
+ return (highPriority ? 1000 : 0) + (dep.recency ?? 0) / 1e10 + (dep.relevance ?? 0) * 10 - originalIndex * 0.001;
328
+ }
329
+
305
330
  /**
306
331
  * L4 total-budget trim. When the SUM of `inlineBytes` across all deps
307
332
  * exceeds {@link MAX_TOTAL_DEP_INLINE_BYTES}, downgrade the lowest-priority
@@ -330,16 +355,11 @@ export function enforceDependencyInlineBudget(dependencies: DependencyContextEnt
330
355
  // Build an index-keyed array so we can sort AND preserve the original
331
356
  // position (for stable output order). Each entry gets a numeric
332
357
  // priority score (higher = more important) and a stable tie-breaker.
333
- const indexed = dependencies.map((dep, originalIndex) => {
334
- const highPriority = dep.status === "failed" || dep.status === "needs-attention";
335
- // Score: (highPriority?1000:0) + (recency/1e10) + (relevance*10) - (index*0.001)
336
- // The 1000 base ensures high-priority wins even with no recency/relevance.
337
- // recency/1e10 keeps it in [0, 1] for ms timestamps up to 1e13 (~year 2286).
338
- // relevance*10 puts it in [0, 10].
339
- // index*0.001 keeps stable order within the same bucket.
340
- const score = (highPriority ? 1000 : 0) + (dep.recency ?? 0) / 1e10 + (dep.relevance ?? 0) * 10 - originalIndex * 0.001;
341
- return { dep, originalIndex, score };
342
- });
358
+ const indexed = dependencies.map((dep, originalIndex) => ({
359
+ dep,
360
+ originalIndex,
361
+ score: inlinePriorityScore(dep, originalIndex),
362
+ }));
343
363
  const sorted = [...indexed].sort((a, b) => b.score - a.score);
344
364
  let used = 0;
345
365
  const keepSet = new Set<number>(); // originalIndex values to KEEP inline
@@ -358,6 +378,10 @@ export function enforceDependencyInlineBudget(dependencies: DependencyContextEnt
358
378
  resultSummary: "",
359
379
  structuredResults: undefined,
360
380
  inlineBytes: 0,
381
+ // G6 (DR-02): flag the path-only downgrade so the renderer says the
382
+ // output was withheld by the inline budget instead of falling into the
383
+ // false "(no result output)" (the output exists at resultPath).
384
+ resultWithheldByBudget: true,
361
385
  };
362
386
  });
363
387
  }
@@ -548,34 +572,34 @@ function sanitizeFencedBody(body: string): string {
548
572
  return body.replace(DEPENDENCY_CONTROL_CHAR_PATTERN, "").replace(/<\/dependency-context/g, "&lt;/dependency-context");
549
573
  }
550
574
 
551
- // ── Handoff budget (effective-token cap on this layer: chars/4 heuristic ──
552
- // anchored upward by measured worker usage — U10) ─────────────────────────
575
+ // ── Handoff budget (effective-token cap on this layer: countTokens — ──
576
+ // CJK-aware, in-tree utils/token-counter.ts — anchored upward by measured
577
+ // worker usage, U10) ───────────────────────────────────────────────────────
553
578
 
554
579
  /** Default effective-token budget for the dynamic.dependencyContext layer
555
- * (tokens as measured by the chars/4 heuristic, floored by measured worker
556
- * usage where available — U10). */
580
+ * (tokens as measured by `countTokens` (utils/token-counter.ts — R5-7,
581
+ * DR-02: CJK-aware; the old chars/4 heuristic underestimated CJK ~2-3x),
582
+ * floored by measured worker usage where available — U10). */
557
583
  export const DEFAULT_HANDOFF_BUDGET_TOKENS = 1800;
558
584
  /** Budget ceiling: a resolved budget above this is treated as the off-switch
559
585
  * (same semantics as ≤0 — clearly not a real handoff cap, so render
560
586
  * untrimmed instead of guessing). */
561
587
  export const HANDOFF_BUDGET_CEILING_TOKENS = 1_000_000;
562
- /** Summary head kept per dependency when the budget trims it. */
588
+ /** Summary head+tail budget kept per dependency when the budget trims it.
589
+ * R5-5 (DR-02): the compact form is no longer a plain head slice — the
590
+ * summary runs through `splitWithImportantLines` (75% head + important
591
+ * middle lines + 25% tail) so error/failed/exception lines from ANYWHERE in
592
+ * the summary survive, matching the full-form TruncationStage behavior. */
563
593
  export const HANDOFF_SUMMARY_HEAD_CHARS = 240;
594
+ /** Slack fraction of {@link HANDOFF_SUMMARY_HEAD_CHARS} available for
595
+ * important middle lines in the compact form (R5-5). The classifier default
596
+ * (0.15 = 36 chars) is too tight for realistic diagnostics; 0.25 (60 chars)
597
+ * admits 1-2 typical error lines while keeping the compact entry small. */
598
+ const HANDOFF_COMPACT_IMPORTANT_SLACK = 0.25;
564
599
 
565
600
  /** Strict integer env syntax — "12abc" must NOT parse as 12. */
566
601
  const HANDOFF_BUDGET_ENV_RE = /^[+-]?\d+$/;
567
602
 
568
- /** Est tokens = chars/4 — the same heuristic as `estimateTokens(chars)` in
569
- * src/runtime/task-runner/prompt-builder.ts (SR-02 breakdown). Duplicated
570
- * here instead of imported: importing prompt-builder would pull its
571
- * retrieval/workspace-tree graph into every task-output-context consumer
572
- * (post-execution, aggregate outputs) for a one-line heuristic. Keep the
573
- * two in sync if either changes; the U10 max(heuristic, measured) combine
574
- * rule mirrors prompt-builder's `estimateTokens(chars, measuredTokens)`. */
575
- function estimateHandoffTokens(chars: number): number {
576
- return Math.round(chars / 4);
577
- }
578
-
579
603
  /** U10 measured-token anchor: Σ measured `usage.outputTokens` over the deps
580
604
  * still rendered in FULL form. The usage record is the worker's REAL
581
605
  * tokenizer measurement (message_end usage persisted on the task state via
@@ -590,9 +614,10 @@ function estimateHandoffTokens(chars: number): number {
590
614
  * path is the compact form's artifact pointer.
591
615
  *
592
616
  * Compact (`budgetTrimmed`) deps are deliberately NOT charged: their inline
593
- * body is a ≤240-char head + pointer, no longer the worker's output, so the
594
- * chars/4 heuristic on the small head is the honest measure — otherwise an
595
- * output-heavy dep could never fit any budget even fully trimmed. */
617
+ * body is a ≤240-char head/tail + pointer, no longer the worker's output, so
618
+ * the countTokens heuristic on the small body is the honest measure —
619
+ * otherwise an output-heavy dep could never fit any budget even fully
620
+ * trimmed. */
596
621
  function measuredHandoffTokens(context: DependencyOutputContext): number {
597
622
  let measured = 0;
598
623
  for (const dep of context.dependencies) {
@@ -602,19 +627,18 @@ function measuredHandoffTokens(context: DependencyOutputContext): number {
602
627
  return measured;
603
628
  }
604
629
 
605
- /** Measure a candidate context in EFFECTIVE tokens (U10): the chars/4
606
- * heuristic on the rendered body, floored by the measured-token anchor —
607
- * `max(heuristic, Σ measured output tokens of full-form deps)`, mirroring
608
- * the render byte-for-byte (same parts builder; `.trim()` matches the render
609
- * join). The max rule mirrors `estimateTokens(chars, measuredTokens)` in
610
- * prompt-builder.ts: a real measurement can only TIGHTEN the estimate
611
- * (spec U10 — the budget must never be exceeded when measured usage is
612
- * larger than the heuristic), never loosen it below the heuristic. */
630
+ /** Measure a candidate context in EFFECTIVE tokens (U10): the
631
+ * `countTokens` heuristic on the rendered body (R5-7, DR-02: CJK-aware —
632
+ * replaces the chars/4 estimate that under-counted CJK summaries ~2-3x),
633
+ * floored by the measured-token anchor — `max(heuristic, Σ measured output
634
+ * tokens of full-form deps)`, mirroring the render byte-for-byte (same parts
635
+ * builder; `.trim()` matches the render join). The max rule mirrors
636
+ * `estimateTokens(chars, measuredTokens)` in prompt-builder.ts: a real
637
+ * measurement can only TIGHTEN the estimate (spec U10 — the budget must
638
+ * never be exceeded when measured usage is larger than the heuristic),
639
+ * never loosen it below the heuristic. */
613
640
  function measureHandoffTokens(context: DependencyOutputContext): number {
614
- return Math.max(
615
- estimateHandoffTokens(buildDependencyOutputBodyParts(context).join("\n").trim().length),
616
- measuredHandoffTokens(context),
617
- );
641
+ return Math.max(countTokens(buildDependencyOutputBodyParts(context).join("\n").trim()), measuredHandoffTokens(context));
618
642
  }
619
643
 
620
644
  /** Budget active iff it is a positive finite number at or below the ceiling;
@@ -646,7 +670,11 @@ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
646
670
  `Status: ${dep.status}`,
647
671
  dep.resultPath ? `Result artifact: ${dep.resultPath}` : "",
648
672
  "",
649
- dep.resultSummary?.trim() || "(no result output)",
673
+ // G6 (DR-02 round-5): an L4 path-only downgrade has output on disk —
674
+ // never fall into the "(no result output)" lie for it.
675
+ dep.resultWithheldByBudget
676
+ ? "[result withheld by inline budget — see artifact path above]"
677
+ : dep.resultSummary?.trim() || "(no result output)",
650
678
  "",
651
679
  ];
652
680
  // P1-A dependency tee-recovery hint: when the dependency's result was
@@ -662,18 +690,35 @@ function dependencyEntryLines(dep: DependencyContextEntry): string[] {
662
690
  }
663
691
 
664
692
  /** Compact handoff-budget form for one dependency (pre-sanitize): keeps the
665
- * taskId/role/status lines + a ≤240-char summary head (with a truncation
666
- * marker when the summary was longer) + a relative artifact pointer the
667
- * worker can `read`. Drops structuredResults, usage, artifactsProduced and
668
- * the absolute-path lines — all recoverable via the pointer. The pointer is
669
- * built ONLY from manifest-derived runId/taskId (same trust level as the
670
- * existing `## <taskId>` headers and the results/<taskId>.txt artifact
671
- * naming); dep-controlled content never reaches it. */
693
+ * taskId/role/status lines + a head/tail summary budget of
694
+ * {@link HANDOFF_SUMMARY_HEAD_CHARS} chars with important-line preservation
695
+ * (R5-5, DR-02: the old plain 240-char HEAD slice dropped a failed dep's
696
+ * error tail and any diagnostic beyond char 240 — the summary now runs
697
+ * through the same `splitWithImportantLines` the full-form TruncationStage
698
+ * path uses, so error/failed/exception lines from ANYWHERE in the summary
699
+ * survive) + a `[trimmed, N chars total]` marker when the summary was longer
700
+ * + a relative artifact pointer the worker can `read`. Drops
701
+ * structuredResults, usage, artifactsProduced and the absolute-path lines —
702
+ * all recoverable via the pointer. The pointer is built ONLY from
703
+ * manifest-derived runId/taskId (same trust level as the existing
704
+ * `## <taskId>` headers and the results/<taskId>.txt artifact naming);
705
+ * dep-controlled content never reaches it. */
672
706
  function budgetTrimmedDependencyEntryLines(dep: DependencyContextEntry, runId: string | undefined): string[] {
673
707
  const summary = dep.resultSummary?.trim() ?? "";
674
- const head = summary.slice(0, HANDOFF_SUMMARY_HEAD_CHARS);
675
- const body =
676
- summary.length > HANDOFF_SUMMARY_HEAD_CHARS ? `${head}\n[trimmed, ${summary.length} chars total]` : summary || "(no result output)";
708
+ let body: string;
709
+ if (dep.resultWithheldByBudget) {
710
+ // G6 (DR-02): compact form of an L4-downgraded dep — same anti-lie
711
+ // marker; the pointer line below is the recovery path.
712
+ body = "[result withheld by inline budget — see full output path below]";
713
+ } else if (summary.length <= HANDOFF_SUMMARY_HEAD_CHARS) {
714
+ body = summary || "(no result output)";
715
+ } else {
716
+ const split = splitWithImportantLines(summary, HANDOFF_SUMMARY_HEAD_CHARS, {
717
+ slackFactor: HANDOFF_COMPACT_IMPORTANT_SLACK,
718
+ });
719
+ const segments = [split.head, ...split.importantLines, split.tail].filter((segment) => segment.length > 0);
720
+ body = `${segments.join("\n")}\n[trimmed, ${summary.length} chars total]`;
721
+ }
677
722
  return [
678
723
  `## ${dep.taskId} (${dep.role})`,
679
724
  `Status: ${dep.status}`,
@@ -715,9 +760,10 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
715
760
 
716
761
  /**
717
762
  * Handoff budget: trim the dependency-output context to an EFFECTIVE-token
718
- * budget (U10). Effective tokens = max(chars/4 heuristic on the rendered
719
- * body, Σ measured `usage.outputTokens` of full-form deps — real tokenizer
720
- * numbers from message_end usage persisted on the task state). Measured
763
+ * budget (U10). Effective tokens = max(countTokens heuristic on the rendered
764
+ * body — R5-7 (DR-02): CJK-aware, from utils/token-counter.ts — Σ measured
765
+ * `usage.outputTokens` of full-form deps — real tokenizer numbers from
766
+ * message_end usage persisted on the task state). Measured
721
767
  * usage can only tighten: when a worker reports MORE tokens than the
722
768
  * heuristic estimates, the measured number governs and the trim fires at
723
769
  * least as early as before (spec U10 acceptance — the budget is never
@@ -727,14 +773,20 @@ function buildDependencyOutputBodyParts(context: DependencyOutputContext): strin
727
773
  * input reference unchanged when the budget is off (≤0 or >1_000_000) or
728
774
  * the context already fits.
729
775
  *
730
- * Trim policy (declaration order): dependencies are probed from ALL-full
731
- * downward — the largest prefix of dependencies that still fits keeps its
732
- * FULL output; every dependency after the cutoff is downgraded to the
733
- * compact form (taskId/role/status + ≤240-char summary head + truncation
734
- * marker + `artifacts/<runId>/results/<taskId>.txt` pointer). Earlier deps
735
- * therefore always keep ≥ as much as later ones. Long (>240 chars) sharedRead
736
- * bodies are replaced by a trim marker (the Path pointer is already
737
- * rendered); short shared reads pass through.
776
+ * Trim policy (R5-5, DR-02 — failed-priority, mirroring the 96KB layer):
777
+ * dependencies are probed from ALL-full downward in DOWNGRADE-PRIORITY
778
+ * order — the same score `enforceDependencyInlineBudget` uses
779
+ * (`inlinePriorityScore`: status failed/needs-attention gets the +1000
780
+ * boost, then recency, then relevance, then declaration index). The
781
+ * lowest-keep-priority deps are downgraded to the compact form
782
+ * (taskId/role/status + head/tail summary ≤240 chars with important-line
783
+ * preservation + `artifacts/<runId>/results/<taskId>.txt` pointer) FIRST;
784
+ * failed/needs-attention deps therefore keep their FULL form longest — the
785
+ * downstream worker must see the failure. Declaration order still
786
+ * guarantees that among same-priority deps, earlier ones keep ≥ as much as
787
+ * later ones. Long (>240 chars) sharedRead bodies are replaced by a trim
788
+ * marker (the Path pointer is already rendered); short shared reads pass
789
+ * through.
738
790
  *
739
791
  * When even the fully-trimmed form exceeds the budget (extreme dependency
740
792
  * count), the fully-trimmed context is returned as the bounded best effort —
@@ -757,16 +809,36 @@ export function applyHandoffBudget(context: DependencyOutputContext, budgetToken
757
809
  ? { ...read, content: `[content trimmed, ${content.length} chars total — read Path above for the full file]` }
758
810
  : read;
759
811
  };
760
- const candidateAt = (fullCount: number): DependencyOutputContext => ({
761
- ...base,
762
- dependencies: base.dependencies.map((dep, index) => (index < fullCount ? dep : { ...dep, budgetTrimmed: true })),
763
- sharedReads: base.sharedReads.map(trimmedRead),
764
- });
765
- for (let fullCount = base.dependencies.length; fullCount >= 0; fullCount--) {
766
- const candidate = candidateAt(fullCount);
812
+ // R5-5 (DR-02): downgrade ORDER — ascending keep-priority (the FIRST
813
+ // entries are downgraded first). Reuses the exact scoring the 96KB L4
814
+ // layer uses (`inlinePriorityScore`) so failed/needs-attention deps land
815
+ // LAST and keep their FULL form longest; within the same priority bucket
816
+ // later-declared deps are downgraded before earlier ones.
817
+ const downgradeOrder = base.dependencies
818
+ .map((dep, originalIndex) => ({ originalIndex, score: inlinePriorityScore(dep, originalIndex) }))
819
+ .sort((a, b) => a.score - b.score)
820
+ .map((item) => item.originalIndex);
821
+ const candidateAt = (downgradedCount: number): DependencyOutputContext => {
822
+ // Downgrade the `downgradedCount` LOWEST-keep-priority deps first
823
+ // (R5-5): failed/needs-attention deps are last in `downgradeOrder`, so
824
+ // they keep their FULL form longest.
825
+ const downgradedIndexes = new Set(downgradeOrder.slice(0, downgradedCount));
826
+ return {
827
+ ...base,
828
+ dependencies: base.dependencies.map((dep, index) => (downgradedIndexes.has(index) ? { ...dep, budgetTrimmed: true } : dep)),
829
+ sharedReads: base.sharedReads.map(trimmedRead),
830
+ };
831
+ };
832
+ // Probe from fewest-downgraded upward and return the first candidate that
833
+ // fits — the maximum FULL-form set under the failed-priority order. More
834
+ // downgrades strictly shrink the measure (compact form < full form for
835
+ // both the heuristic and the measured anchor), so the first fit is the
836
+ // best fit.
837
+ for (let downgradedCount = 0; downgradedCount <= base.dependencies.length; downgradedCount++) {
838
+ const candidate = candidateAt(downgradedCount);
767
839
  if (measureHandoffTokens(candidate) <= budgetTokens) return candidate;
768
840
  }
769
- return candidateAt(0);
841
+ return candidateAt(base.dependencies.length);
770
842
  }
771
843
 
772
844
  export interface RenderDependencyOutputContextOptions {
@@ -11,6 +11,28 @@ import { generateTaskHashId } from "./task-id.ts";
11
11
  // See: SECURITY-ISSUES.md SEC-007
12
12
  // ═══════════════════════════════════════════════════════════════════════════
13
13
 
14
+ export interface TaskTextSanitizeResult {
15
+ /** Sanitized text WITHOUT the redaction note (the note is composed by the
16
+ * caller — see {@link sanitizeTaskText} / {@link appendSecurityRedactionNote}). */
17
+ text: string;
18
+ /** G7 (DR-02 round-5): number of content redactions applied by rules 1b–9
19
+ * (each replaced match counts 1 — marker substitutions AND strip-to-empty
20
+ * removals). Zero-width stripping (rule 1) and blank-line collapse
21
+ * (rule 10) are cosmetic and never counted. */
22
+ redactionCount: number;
23
+ }
24
+
25
+ /** G7 (DR-02 round-5): the single trailing note line appended when ≥1
26
+ * security redaction fired. Silent redaction left sanitized objectives
27
+ * like "do not [suspicious-write-redacted]s to logs" with no explanation —
28
+ * the worker could not tell meaning was removed. Count = number of
29
+ * replacements made. No note when nothing was redacted (byte-identical to
30
+ * the pre-G7 output for clean inputs). */
31
+ export function appendSecurityRedactionNote(text: string, redactionCount: number): string {
32
+ if (redactionCount <= 0) return text;
33
+ return `${text}\n\n(${redactionCount} security redaction(s) applied by task-text sanitizer — rephrase constraints avoiding trigger patterns if this removed intended meaning)`;
34
+ }
35
+
14
36
  /**
15
37
  * Sanitize workflow step task text to reduce injection risk.
16
38
  *
@@ -23,51 +45,74 @@ import { generateTaskHashId } from "./task-id.ts";
23
45
  * - Strip known prompt injection directive patterns
24
46
  * - Strip base64/hex encoded payloads
25
47
  * - Collapse excessive whitespace
48
+ *
49
+ * G7: every content redaction is counted; `sanitizeTaskText` appends ONE
50
+ * trailing note line when ≥1 fired (see {@link appendSecurityRedactionNote}).
26
51
  */
27
- export function sanitizeTaskText(task: string): string {
52
+ export function sanitizeTaskTextWithRedactionCount(task: string): TaskTextSanitizeResult {
28
53
  let sanitized = task;
54
+ let redactionCount = 0;
55
+ // Counting replacer: each replacement the pattern performs (marker
56
+ // substitution or strip-to-empty removal) counts as one redaction hit.
57
+ const counted = (replacement: string) => (): string => {
58
+ redactionCount += 1;
59
+ return replacement;
60
+ };
29
61
 
30
- // 1. Strip zero-width and invisible Unicode characters
62
+ // 1. Strip zero-width and invisible Unicode characters (cosmetic — never
63
+ // counted: removing invisible chars cannot change intended meaning).
31
64
  sanitized = sanitized.replace(/[\u200B-\u200F\u2028-\u202F\u2060-\u206F\uFEFF]/g, "");
32
65
 
33
66
  // 1b. Strip HTML/JS comments and script tags (instruction hiding).
34
67
  // SEC-4: bounded quantifier {0,8192} prevents polynomial O(n²) backtracking
35
68
  // DoS on pathological inputs (e.g. an unclosed `<!--` with no matching `-->`).
36
- sanitized = sanitized.replace(/<!--[\s\S]{0,8192}?-->|<\/?script[^>]*>/gi, "");
69
+ sanitized = sanitized.replace(/<!--[\s\S]{0,8192}?-->|<\/?script[^>]*>/gi, counted(""));
37
70
 
38
71
  // 2. Strip known prompt injection directive patterns
39
72
  sanitized = sanitized.replace(
40
73
  /^\s*(?:SYSTEM|INSTRUCTION|IGNORE(?:\s+ALL)?\s+(?:PREVIOUS|INSTRUCTIONS)?|OVERRIDE|YOUR\s+ROLE\s+IS|MALICIOUS)\s*:.*$/gim,
41
- "",
74
+ counted(""),
42
75
  );
43
76
 
44
77
  // 3. Strip base64/hex encoded command payloads
45
- sanitized = sanitized.replace(/\b(?:base64|base32|hex)\s*['":]\s*([A-Za-z0-9+\/=]{16,})/gi, "[encoded-redacted]");
78
+ sanitized = sanitized.replace(/\b(?:base64|base32|hex)\s*['":]\s*([A-Za-z0-9+\/=]{16,})/gi, counted("[encoded-redacted]"));
46
79
 
47
80
  // 4. Strip embedded instruction patterns in brackets (incl. MALICIOUS to match agent sanitizer)
48
- sanitized = sanitized.replace(/\[(?:SYSTEM|INSTRUCTION|OVERRIDE|MALICIOUS)\s*:[^\]]*\]/gi, "");
81
+ sanitized = sanitized.replace(/\[(?:SYSTEM|INSTRUCTION|OVERRIDE|MALICIOUS)\s*:[^\]]*\]/gi, counted(""));
49
82
 
50
83
  // 5. Strip eval/exec patterns with encoded content
51
- sanitized = sanitized.replace(/\b(?:eval|exec|spawn|subprocess)\s*\(\s*(?:base64|Buffer\.from)\s*\(/gi, "[suspicious-call-redacted]");
84
+ sanitized = sanitized.replace(
85
+ /\b(?:eval|exec|spawn|subprocess)\s*\(\s*(?:base64|Buffer\.from)\s*\(/gi,
86
+ counted("[suspicious-call-redacted]"),
87
+ );
52
88
 
53
89
  // 6. Strip markdown codeblocks that attempt to hide instructions.
54
90
  // SEC-4: bounded quantifier {0,8192} prevents polynomial backtracking DoS.
55
- sanitized = sanitized.replace(/```\s*(?:system|instruction|prompt)\n[\s\S]{0,8192}?```/gi, "");
91
+ sanitized = sanitized.replace(/```\s*(?:system|instruction|prompt)\n[\s\S]{0,8192}?```/gi, counted(""));
56
92
 
57
93
  // 7. Strip YAML-like assignment patterns that could override behavior.
58
94
  // Applied unconditionally — task text is always untrusted.
59
- sanitized = sanitized.replace(/^\s*(?:role|persona|behavior|directive)\s*[=:].*$/gim, "");
95
+ sanitized = sanitized.replace(/^\s*(?:role|persona|behavior|directive)\s*[=:].*$/gim, counted(""));
60
96
 
61
97
  // 8. Strip potential exfiltration patterns
62
- sanitized = sanitized.replace(/\b(?:write|append)\s+.*(?:secrets?|keys?|token|credential)/gi, "[suspicious-write-redacted]");
98
+ sanitized = sanitized.replace(/\b(?:write|append)\s+.*(?:secrets?|keys?|token|credential)/gi, counted("[suspicious-write-redacted]"));
63
99
 
64
100
  // 9. Strip network exfiltration patterns
65
- sanitized = sanitized.replace(/\b(?:fetch|curl|wget|axios)\s+.*(?:exfil|steal|leak|send)/gi, "[suspicious-network-redacted]");
101
+ sanitized = sanitized.replace(/\b(?:fetch|curl|wget|axios)\s+.*(?:exfil|steal|leak|send)/gi, counted("[suspicious-network-redacted]"));
66
102
 
67
- // 10. Collapse multiple blank lines (cleanup after removals)
103
+ // 10. Collapse multiple blank lines (cleanup after removals — cosmetic, never counted)
68
104
  sanitized = sanitized.replace(/\n{3,}/g, "\n\n");
69
105
 
70
- return sanitized.trim();
106
+ return { text: sanitized.trim(), redactionCount };
107
+ }
108
+
109
+ /**
110
+ * Sanitize task text and, when any content redaction fired, append ONE
111
+ * trailing note line explaining it (G7, DR-02 round-5).
112
+ */
113
+ export function sanitizeTaskText(task: string): string {
114
+ const { text, redactionCount } = sanitizeTaskTextWithRedactionCount(task);
115
+ return appendSecurityRedactionNote(text, redactionCount);
71
116
  }
72
117
 
73
118
  export interface BuildTaskPacketInput {
@@ -120,9 +165,17 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
120
165
  const scope = inferTaskScope(input.step);
121
166
  const reads = input.step.reads === false ? [] : (input.step.reads ?? []);
122
167
  const scopePath = reads.length === 1 ? reads[0] : reads.length > 1 ? reads.join(", ") : undefined;
123
- // SEC-007: Sanitize task text before inserting into task packet
124
- const sanitizedTask = sanitizeTaskText(input.step.task);
125
- const sanitizedGoal = sanitizeTaskText(input.manifest.goal);
168
+ // SEC-007: Sanitize task text before inserting into task packet.
169
+ // G7 (DR-02 round-5): count redactions on BOTH the task and the goal — the
170
+ // packet objective carries ONE combined trailing note so the worker knows
171
+ // meaning may have been removed (per-text notes inside the {goal}
172
+ // substitution could land mid-objective and stack to two notes).
173
+ const sanitizedTaskResult = sanitizeTaskTextWithRedactionCount(input.step.task);
174
+ const sanitizedGoalResult = sanitizeTaskTextWithRedactionCount(input.manifest.goal);
175
+ const objective = appendSecurityRedactionNote(
176
+ sanitizedTaskResult.text.replaceAll("{goal}", sanitizedGoalResult.text),
177
+ sanitizedTaskResult.redactionCount + sanitizedGoalResult.redactionCount,
178
+ );
126
179
 
127
180
  // Generate a deterministic hash-based task ID for traceability and logging.
128
181
  // Uses goal + step ID + run ID as content parts.
@@ -130,7 +183,7 @@ export function buildTaskPacket(input: BuildTaskPacketInput): TaskPacket {
130
183
  const _taskHashId = generateTaskHashId([input.manifest.goal, input.step.id, input.manifest.runId]);
131
184
 
132
185
  return {
133
- objective: sanitizedTask.replaceAll("{goal}", sanitizedGoal),
186
+ objective,
134
187
  ...(declaredSpecRefs.length > 0 ? { specRefs: declaredSpecRefs, specSnapshots } : {}),
135
188
  ...(unresolvedSpecRefs.length > 0 ? { unresolvedSpecRefs } : {}),
136
189
  ...(input.specStrict === true ? { specStrict: true } : {}),