pi-crew 0.11.20 → 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 (84) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/index.mjs +537 -526
  3. package/package.json +2 -1
  4. package/skills/real-test-pi-crew/SKILL.md +21 -0
  5. package/src/errors.ts +14 -1
  6. package/src/extension/async-notifier.ts +42 -17
  7. package/src/extension/cross-extension-rpc.ts +42 -5
  8. package/src/extension/notification-router.ts +15 -2
  9. package/src/extension/registration/lifecycle-handlers.ts +36 -13
  10. package/src/extension/registration/runtime-cleanup.ts +17 -0
  11. package/src/extension/registration/wire-cross-extension.ts +26 -0
  12. package/src/extension/rpc-hmac.ts +53 -2
  13. package/src/extension/team-tool/cancel.ts +34 -21
  14. package/src/extension/team-tool/dispatch/index.ts +6 -5
  15. package/src/extension/team-tool/dispatch/status.ts +42 -2
  16. package/src/extension/team-tool.ts +24 -2
  17. package/src/prompt/inbox-poll.ts +77 -25
  18. package/src/prompt/message-tool.ts +62 -4
  19. package/src/prompt/prompt-runtime.ts +82 -32
  20. package/src/runtime/broker/crew-broker-client.ts +74 -16
  21. package/src/runtime/broker/crew-broker.ts +111 -77
  22. package/src/runtime/broker/protocol/msg-send-targets.ts +74 -0
  23. package/src/runtime/broker/wait-status-cache.ts +14 -5
  24. package/src/runtime/child-pi/child-pi-spawn.ts +47 -9
  25. package/src/runtime/child-pi/child-pi.ts +20 -2
  26. package/src/runtime/child-pi/mock-fixtures.ts +23 -0
  27. package/src/runtime/compaction/compact-stages/truncation-stage.ts +45 -3
  28. package/src/runtime/compaction/important-line-classifier.ts +85 -11
  29. package/src/runtime/compaction/tool-output-pruner.ts +35 -11
  30. package/src/runtime/crew-agent-records.ts +7 -1
  31. package/src/runtime/delivery-coordinator.ts +30 -0
  32. package/src/runtime/dwf-state-store.ts +41 -7
  33. package/src/runtime/finalize-run.ts +13 -2
  34. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +192 -64
  35. package/src/runtime/goal-workflow/goal-loop-runner.ts +130 -3
  36. package/src/runtime/heartbeat/heartbeat-watcher.ts +7 -0
  37. package/src/runtime/live-session/embedded-tier.ts +3 -1
  38. package/src/runtime/live-session/live-session-runtime.ts +54 -1
  39. package/src/runtime/model/model-fallback.ts +23 -1
  40. package/src/runtime/output/pi-json-output.ts +26 -1
  41. package/src/runtime/policy-engine.ts +18 -0
  42. package/src/runtime/process/zombie-scanner.ts +78 -14
  43. package/src/runtime/process-status.ts +6 -1
  44. package/src/runtime/recovery/crash-recovery.ts +170 -28
  45. package/src/runtime/rpc/rpc-worker.ts +44 -1
  46. package/src/runtime/scheduling/run-coalesced-task-group.ts +64 -3
  47. package/src/runtime/scheduling/scheduler.ts +74 -11
  48. package/src/runtime/scratchpad/engine.ts +26 -3
  49. package/src/runtime/settings-store.ts +22 -2
  50. package/src/runtime/surface/tmux-provider.ts +24 -3
  51. package/src/runtime/task-health.ts +16 -1
  52. package/src/runtime/task-output-context.ts +143 -71
  53. package/src/runtime/task-packet.ts +70 -17
  54. package/src/runtime/task-runner/child-executor.ts +27 -0
  55. package/src/runtime/task-runner/output-splitter.ts +79 -16
  56. package/src/runtime/task-runner/post-execution.ts +75 -15
  57. package/src/runtime/task-runner/state-helpers.ts +92 -2
  58. package/src/runtime/usage-tracker.ts +11 -0
  59. package/src/runtime/verification/green-contract.ts +17 -0
  60. package/src/runtime/verification/verification-gates.ts +24 -6
  61. package/src/runtime/workspace-lock.ts +53 -0
  62. package/src/schema/team-tool-schema.ts +10 -4
  63. package/src/state/coordination/mailbox.ts +133 -38
  64. package/src/state/coordination/process-lock.ts +22 -2
  65. package/src/state/decision-ledger.ts +56 -8
  66. package/src/state/event-log/event-log-rotation.ts +83 -5
  67. package/src/state/event-log/event-log.ts +181 -12
  68. package/src/state/stores/active-run-registry.ts +10 -3
  69. package/src/state/types.ts +9 -1
  70. package/src/ui/agents-jobs-browser.ts +22 -8
  71. package/src/ui/dashboard-panes/agents-pane.ts +5 -2
  72. package/src/ui/events-state-source.ts +67 -28
  73. package/src/ui/inline-panel/agent-pane.ts +13 -6
  74. package/src/ui/inline-panel/agent-transcript.ts +33 -21
  75. package/src/ui/inline-panel/agent-view-overlay.ts +18 -3
  76. package/src/ui/inline-panel/index.ts +21 -1
  77. package/src/ui/live-run-sidebar.ts +14 -3
  78. package/src/ui/overlays/mailbox-compose-overlay.ts +10 -4
  79. package/src/ui/powerbar-publisher.ts +6 -3
  80. package/src/ui/rail.ts +7 -1
  81. package/src/ui/render-scheduler.ts +53 -1
  82. package/src/ui/run-action-dispatcher.ts +68 -20
  83. package/src/ui/status-colors.ts +9 -0
  84. package/src/utils/env-filter.ts +8 -0
@@ -8,6 +8,7 @@
8
8
  * Inline cases (graph, search, onboard, explain, cache, checkpoint, recommend,
9
9
  * help) are moved verbatim from the former handleTeamTool switch.
10
10
  */
11
+ import * as fs from "node:fs";
11
12
  import * as path from "node:path";
12
13
  import { allAgents, discoverAgents } from "../../../agents/discover-agents.ts";
13
14
  import { loadConfig } from "../../../config/config.ts";
@@ -16,6 +17,7 @@ import { getSkillCacheStats } from "../../../runtime/skill-instructions.ts";
16
17
  import type { TeamToolParamsValue } from "../../../schema/team-tool-schema.ts";
17
18
  import { computeRunCacheKey, getCachedRun, getCacheStats } from "../../../state/stores/run-cache.ts";
18
19
  import { listRunGraphs, loadRunGraph } from "../../../state/stores/run-graph.ts";
20
+ import type { TaskCheckpointState } from "../../../state/types.ts";
19
21
  import { allTeams, discoverTeams } from "../../../teams/discover-teams.ts";
20
22
  import { searchAgents, searchTeams } from "../../../utils/bm25-search.ts";
21
23
  import { projectCrewRoot } from "../../../utils/paths.ts";
@@ -200,14 +202,27 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
200
202
  assertSafePathId("runId", params.runId);
201
203
  assertSafePathId("taskId", params.taskId);
202
204
  const stateRoot = path.join(projectCrewRoot(ctx.cwd), "state", "runs", params.runId);
205
+ // R7-8 (DR-04 round-7): `task.checkpoint` in tasks.json is the REAL
206
+ // production source — `checkpointTask` (task-runner/state-helpers.ts)
207
+ // writes the checkpoint into the task record and persists it via
208
+ // saveRunTasks. FileCheckpointStore is only a fallback for runs whose
209
+ // task record carries no checkpoint. The output says which source won.
210
+ const taskCheckpoint = readTaskCheckpointFromTasksJson(stateRoot, params.taskId);
211
+ if (taskCheckpoint) {
212
+ return result(
213
+ `Checkpoint (source: task.checkpoint in tasks.json): phase=${taskCheckpoint.phase}, updatedAt=${taskCheckpoint.updatedAt}` +
214
+ (taskCheckpoint.childPid !== undefined ? `, childPid=${taskCheckpoint.childPid}` : ""),
215
+ { action: "checkpoint", status: "ok", data: { source: "task-record", checkpoint: taskCheckpoint } },
216
+ );
217
+ }
203
218
  const store = new FileCheckpointStore(stateRoot);
204
219
  const checkpoint = store.load(params.runId, params.taskId);
205
220
  if (!checkpoint) {
206
221
  return result("No checkpoint found.", { action: "checkpoint", status: "error" }, true);
207
222
  }
208
223
  return result(
209
- `Checkpoint: step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
210
- { action: "checkpoint", status: "ok", data: { checkpoint } },
224
+ `Checkpoint (source: checkpoint store): step=${checkpoint.step}, progress=${checkpoint.progress}, savedAt=${new Date(checkpoint.savedAt).toISOString()}`,
225
+ { action: "checkpoint", status: "ok", data: { source: "checkpoint-store", checkpoint } },
211
226
  );
212
227
  }
213
228
  default: {
@@ -221,3 +236,28 @@ export async function handleStatusDomain(params: TeamToolParamsValue, ctx: TeamC
221
236
  }
222
237
  }
223
238
  }
239
+
240
+ /**
241
+ * R7-8 (DR-04 round-7): read `task.checkpoint` from tasks.json — the source
242
+ * production writers actually use (`checkpointTask` persists the checkpoint
243
+ * into the task record via saveRunTasks). Read-only and tolerant on purpose:
244
+ * a status query must never mutate run state (no quarantine/reconstruction),
245
+ * so a missing/corrupt tasks.json simply falls through to the store fallback.
246
+ * Handles both on-disk shapes: bare `TeamTaskState[]` (v0, current writer) and
247
+ * the defensive `{ schemaVersion, tasks }` envelope (see manifest-io.ts).
248
+ */
249
+ function readTaskCheckpointFromTasksJson(stateRoot: string, taskId: string): TaskCheckpointState | undefined {
250
+ try {
251
+ const parsed: unknown = JSON.parse(fs.readFileSync(path.join(stateRoot, "tasks.json"), "utf-8"));
252
+ const tasks: unknown = Array.isArray(parsed) ? parsed : (parsed as { tasks?: unknown }).tasks;
253
+ if (!Array.isArray(tasks)) return undefined;
254
+ for (const task of tasks) {
255
+ if (task === null || typeof task !== "object" || (task as { id?: unknown }).id !== taskId) continue;
256
+ const checkpoint = (task as { checkpoint?: unknown }).checkpoint;
257
+ return checkpoint !== null && typeof checkpoint === "object" ? (checkpoint as TaskCheckpointState) : undefined;
258
+ }
259
+ return undefined;
260
+ } catch {
261
+ return undefined; // ENOENT / corrupt tasks.json → caller falls back to the store
262
+ }
263
+ }
@@ -954,11 +954,33 @@ export async function handleResume(params: TeamToolParamsValue, ctx: TeamContext
954
954
  );
955
955
  }
956
956
 
957
+ // R5-6 (DR-02 round-5): send-side clamp for `team steer` bodies. The
958
+ // worker-side receiver (prompt-runtime sanitizeSteerMessage) clamps too (its
959
+ // rejection of >4096-char steers was terminal — the poll cursor advanced
960
+ // past the line, so an oversized leader steer was silently dropped), but the
961
+ // send-side clamp means pendingSteers, the steering JSONL, and the
962
+ // task.steer_queued event all already carry a receiver-safe body with the
963
+ // truncation marker visible to the leader's audit trail. Deliberately
964
+ // duplicated from prompt-runtime's clampSteerMessage (same rationale as the
965
+ // estimateHandoffTokens duplication in task-output-context.ts): importing
966
+ // prompt-runtime here would pull the broker-client/scratchpad/surface-worker
967
+ // graph into the extension registration path (cold-start). Keep the two
968
+ // clamps in sync.
969
+ const TEAM_STEER_MAX_MESSAGE_LENGTH = 4096;
970
+ const TEAM_STEER_TRUNCATION_MARKER = `\n[message truncated at ${TEAM_STEER_MAX_MESSAGE_LENGTH} chars]`;
971
+ function clampSteerBody(message: string): string {
972
+ if (message.length <= TEAM_STEER_MAX_MESSAGE_LENGTH) return message;
973
+ return `${message.slice(0, TEAM_STEER_MAX_MESSAGE_LENGTH - TEAM_STEER_TRUNCATION_MARKER.length)}${TEAM_STEER_TRUNCATION_MARKER}`;
974
+ }
975
+
957
976
  export function handleSteer(params: TeamToolParamsValue, ctx: TeamContext): PiTeamsToolResult {
958
- const { runId, taskId, message } = params;
959
- if (!runId || !taskId || !message) {
977
+ const { runId, taskId } = params;
978
+ const rawMessage = params.message;
979
+ if (!runId || !taskId || !rawMessage) {
960
980
  return result("steer requires runId, taskId, and message", { action: "steer", status: "error" }, true);
961
981
  }
982
+ // R5-6: clamp BEFORE any sink (pending queue, JSONL, event) sees the body.
983
+ const message = clampSteerBody(String(rawMessage));
962
984
  const runCwd = locateRunCwd(runId, ctx.cwd);
963
985
  if (!runCwd) return result(`Run '${runId}' not found`, { action: "steer", status: "error" }, true);
964
986
  const loaded = loadRunManifestById(runCwd, runId); // NOTE: no withRunLock - best-effort only; concurrent writes may cause inconsistency
@@ -23,12 +23,19 @@
23
23
  * must be dropped at consume time);
24
24
  * - dedup by message id across polls via a caller-owned seen-set and/or a
25
25
  * `sinceTs` watermark — one message delivers once;
26
- * - U5 (2026-10-10): dedup is ALSO durable across restarts — every picked
27
- * row is recorded in delivery.json (`messages[id]="acknowledged"` +
26
+ * - U5 (2026-10-10): dedup is ALSO durable across restarts — delivered
27
+ * rows are recorded in delivery.json (`messages[id]="acknowledged"` +
28
28
  * `${direction}:${requestId}` index) and re-read on the next poll, so a
29
29
  * worker that restarts mid-run (fresh process, EMPTY seen-set) never
30
30
  * re-delivers. A resend carrying the same requestId is dropped by the
31
31
  * same index; duplicate rows under one requestId deliver exactly once.
32
+ * - R5-1 (DR-02, 2026-10-15): the durable record is written in TWO PHASES.
33
+ * `pollWorkerInbox` only picks rows and marks the IN-MEMORY seen-set;
34
+ * the durable "acknowledged" write lives in
35
+ * `acknowledgeWorkerInboxDelivery`, which the delivery loop calls ONLY
36
+ * after `pi.sendMessage` succeeded. A send that throws must instead call
37
+ * `unmarkSeenInboxIds` so the next tick re-picks the batch — at-least-once
38
+ * beats the pre-fix at-most-once-with-a-false-"delivered"-audit-record.
32
39
  */
33
40
  import * as path from "node:path";
34
41
  import {
@@ -125,13 +132,22 @@ function emitPickupEvent(stateRoot: string, runId: string, taskId: string, picke
125
132
 
126
133
  /**
127
134
  * Read the worker's inbox mailbox and return the messages that should be
128
- * surfaced as fenced context on the next turn.
135
+ * surfaced as fenced context on the next turn. PICKUP PHASE ONLY (R5-1):
136
+ * picked rows are marked in the caller-owned in-memory seen-set, but the
137
+ * durable delivery record is NOT written here — the caller must run
138
+ * {@link acknowledgeWorkerInboxDelivery} after its send succeeded, and
139
+ * {@link unmarkSeenInboxIds} when the send threw.
140
+ *
141
+ * `limit` (R5-2, DR-02): pick at most this many rows per call (callers pass
142
+ * their per-tick batch cap). Leftover rows are simply not picked — not
143
+ * marked, in memory or durably — and flow on a later tick.
129
144
  *
130
145
  * Stateless apart from the optional caller-owned `seenIds`/`sinceTs` — safe
131
- * to call on every 500ms poll tick. U5: picked rows are ALSO recorded in the
132
- * durable delivery state (delivery.json), so dedup survives a restart.
146
+ * to call on every 500ms poll tick. U5: the durable dedup-read checks
147
+ * (acknowledged rows / delivered requestIds) still run here so dedup
148
+ * survives a restart.
133
149
  */
134
- export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
150
+ export function pollWorkerInbox(pickup: WorkerInboxPickup, limit?: number): MailboxMessage[] {
135
151
  const { stateRoot, runId, taskId } = pickup;
136
152
  if (!stateRoot || !runId || !taskId) return [];
137
153
  const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
@@ -157,6 +173,11 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
157
173
  const byId = new Set<string>();
158
174
  const byRequestId = new Set<string>();
159
175
  for (const m of messages) {
176
+ // R5-2 (DR-02): enforce the batch cap AT PICKUP, not at the send site —
177
+ // every picked row must also be a row the caller can actually send (and
178
+ // later acknowledge). The pre-fix shape picked N rows, durably acked all
179
+ // N, then sliced 8 for the send — the surplus was never re-delivered.
180
+ if (limit !== undefined && picked.length >= limit) break;
160
181
  // Kind gate: only durable `message`s (per §15.2). Notify is
161
182
  // fire-and-forget (its own channel), steer/response/follow-up are
162
183
  // other delivery paths.
@@ -204,25 +225,56 @@ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
204
225
  seen?.add(m.id);
205
226
  picked.push(m);
206
227
  }
207
- if (picked.length > 0) {
208
- // U5: persist the pickup — the durable record that powers the two
209
- // layers above after a restart. Best-effort on failure: the in-memory
210
- // seen-set still dedups within THIS process, and logInternalError
211
- // makes the lost-durability window visible.
212
- try {
213
- recordMailboxRequestIdDelivery(
214
- manifest,
215
- picked.map((m) => ({
216
- direction: m.direction,
217
- messageId: m.id,
218
- requestId: messageIdempotencyKey(m),
219
- })),
220
- );
221
- emitPickupEvent(stateRoot, runId, taskId, picked);
222
- } catch (error) {
223
- logInternalError("inbox-poll.record-delivery", error, `runId=${runId} taskId=${taskId}`);
224
- }
225
- }
226
228
  // Deterministic delivery order (same sort readAllMailboxMessages applies).
227
229
  return picked;
228
230
  }
231
+
232
+ /**
233
+ * R5-1 (DR-02, 2026-10-15): durable-ack phase of the pickup, split OUT of
234
+ * `pollWorkerInbox`. The caller invokes this ONLY after the picked rows were
235
+ * successfully delivered (`pi.sendMessage` returned) — the durable
236
+ * "acknowledged" record and the pickup audit event must never precede a send
237
+ * that can fail (the pre-fix bug wrote them BEFORE the send, so a send
238
+ * failure produced a permanent false "delivered" record plus silent message
239
+ * loss). Mid-batch all-or-nothing: recordMailboxRequestIdDelivery is a single
240
+ * locked RMW, so acks are atomic per call.
241
+ *
242
+ * Best-effort on failure (never throws into the poll tick): the in-memory
243
+ * seen-set still dedups within THIS process, and logInternalError makes the
244
+ * lost-durability window visible.
245
+ */
246
+ export function acknowledgeWorkerInboxDelivery(
247
+ pickup: Pick<WorkerInboxPickup, "stateRoot" | "runId" | "taskId">,
248
+ picked: MailboxMessage[],
249
+ ): void {
250
+ const { stateRoot, runId, taskId } = pickup;
251
+ if (!stateRoot || !runId || !taskId || picked.length === 0) return;
252
+ const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
253
+ try {
254
+ // U5: persist the delivery — the durable record that powers the two
255
+ // dedup layers above after a restart.
256
+ recordMailboxRequestIdDelivery(
257
+ manifest,
258
+ picked.map((m) => ({
259
+ direction: m.direction,
260
+ messageId: m.id,
261
+ requestId: messageIdempotencyKey(m),
262
+ })),
263
+ );
264
+ emitPickupEvent(stateRoot, runId, taskId, picked);
265
+ } catch (error) {
266
+ logInternalError("inbox-poll.record-delivery", error, `runId=${runId} taskId=${taskId}`);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * R5-1 (DR-02): release the in-memory seen marks for a batch whose
272
+ * `pi.sendMessage` THREW, so the next poll tick re-picks and re-delivers it
273
+ * (at-least-once beats at-most-once with a false "delivered" audit record).
274
+ * Mirrors the steer channel's `seenSteers.unmark` + offset-rewind
275
+ * compensation in prompt-runtime.ts pollSteering.
276
+ */
277
+ export function unmarkSeenInboxIds(seen: Set<string> | undefined, picked: MailboxMessage[]): void {
278
+ if (!seen) return;
279
+ for (const m of picked) seen.delete(m.id);
280
+ }
@@ -24,6 +24,7 @@
24
24
  * sending, so two workers cannot loop message each other to death.
25
25
  */
26
26
 
27
+ import { createHash } from "node:crypto";
27
28
  import { type Static, Type } from "@sinclair/typebox";
28
29
  import { getCrewEnv } from "../config/env-vars.ts";
29
30
  import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
@@ -38,6 +39,14 @@ const MSG_TO_MAX_CHARS = 256;
38
39
  /** §15.2 rate-limit: 10 messages/min/task, sliding window. */
39
40
  const MSG_RATE_LIMIT_MAX = 10;
40
41
  const MSG_RATE_LIMIT_WINDOW_MS = 60_000;
42
+ /** R6-6 (DR-03 round-6 2026-10-16): explicit per-request timeout for
43
+ * msg.send. The client's 15s default (REQUEST_TIMEOUT_DEFAULT_MS) turns a
44
+ * merely-slow durable send (multi-recipient chunked appends + fsync + audit
45
+ * event) into a reported "NOT sent" — and the model's natural retry into a
46
+ * duplicate delivery. 60s matches the worst bounded fanout (64 recipients,
47
+ * 8-wide chunks) with headroom while staying below the tool's own "never a
48
+ * hang" budget (the request still resolves via this timeout, never hangs). */
49
+ export const MSG_SEND_TIMEOUT_MS = 60_000;
41
50
 
42
51
  export const MessageToolParamsSchema = Type.Object({
43
52
  to: Type.Union([Type.Literal("parent"), Type.Literal("group"), Type.String({ minLength: 1, maxLength: MSG_TO_MAX_CHARS })]),
@@ -49,12 +58,46 @@ export const MessageToolParamsSchema = Type.Object({
49
58
  export type MessageToolParams = Static<typeof MessageToolParamsSchema>;
50
59
 
51
60
  /** Minimal broker-client surface the message tool needs (structural subset of
52
- * CrewBrokerClient — tests substitute a recorder). */
61
+ * CrewBrokerClient — tests substitute a recorder). R6-6: the optional third
62
+ * argument carries the per-request timeout; existing 2-arg recorders remain
63
+ * structurally assignable. */
53
64
  export interface MessageBrokerClientSurface {
54
- request(method: string, params: unknown): Promise<{ ok: true; value: unknown } | { ok: false; fallback?: boolean; errorCode?: string }>;
65
+ request(
66
+ method: string,
67
+ params: unknown,
68
+ opts?: { timeoutMs?: number },
69
+ ): Promise<{ ok: true; value: unknown } | { ok: false; fallback?: boolean; errorCode?: string }>;
55
70
  close?(): Promise<void>;
56
71
  }
57
72
 
73
+ /** R6-6: stable idempotency key derived from message identity. A retry of
74
+ * the same logical message (same sender, target, kind, content — e.g. the
75
+ * model re-invoking `message` after a lost/slow ack) computes the SAME
76
+ * requestId, and the broker's U5 durable (direction, requestId) index in
77
+ * delivery.json + appendMailboxMessageIdempotentAsync turn the resend into
78
+ * a no-op instead of a duplicate row. Pure content hash: matches the
79
+ * request-parsers `^[\\w:.-]{1,256}$` requestId charset (hex + `-`). */
80
+ export function computeMessageRequestId(identity: {
81
+ runId: string;
82
+ taskId: string;
83
+ to: string;
84
+ kind: string;
85
+ body: string;
86
+ subject?: string;
87
+ priority?: string;
88
+ }): string {
89
+ const material = JSON.stringify([
90
+ identity.runId,
91
+ identity.taskId,
92
+ identity.to,
93
+ identity.kind,
94
+ identity.body,
95
+ identity.subject ?? null,
96
+ identity.priority ?? null,
97
+ ]);
98
+ return `msgtool-${createHash("sha256").update(material).digest("hex").slice(0, 32)}`;
99
+ }
100
+
58
101
  export interface MessageToolDeps {
59
102
  /** Env source override (tests). Production reads via getCrewEnv. */
60
103
  env?: NodeJS.ProcessEnv;
@@ -137,11 +180,26 @@ export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
137
180
  return new CrewBrokerClient({ runId, taskId, socketPath, token });
138
181
  })();
139
182
  if (!client) return brokerUnavailable();
140
- const requestParams: Record<string, unknown> = { to: params.to, kind: params.kind, body: params.body };
183
+ // R6-6: stable requestId (idempotency across retries) + explicit
184
+ // timeoutMs (a slow durable ack is NOT a failure).
185
+ const requestParams: Record<string, unknown> = {
186
+ to: params.to,
187
+ kind: params.kind,
188
+ body: params.body,
189
+ requestId: computeMessageRequestId({
190
+ runId,
191
+ taskId,
192
+ to: params.to,
193
+ kind: params.kind,
194
+ body: params.body,
195
+ subject: params.subject,
196
+ priority: params.priority,
197
+ }),
198
+ };
141
199
  if (params.subject) requestParams.subject = params.subject;
142
200
  if (params.priority) requestParams.priority = params.priority;
143
201
  try {
144
- const sent = await client.request("msg.send", requestParams);
202
+ const sent = await client.request("msg.send", requestParams, { timeoutMs: MSG_SEND_TIMEOUT_MS });
145
203
  if (!sent.ok) {
146
204
  // Broker rejection (role gate / policy / auth / fallback) — all
147
205
  // fast-fail, non-blocking.
@@ -12,7 +12,7 @@ import { appendEventFireAndForget } from "../state/event-log/event-log.ts";
12
12
  import type { TeamRunManifest } from "../state/types.ts";
13
13
  import { logInternalError } from "../utils/internal-error.ts";
14
14
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
15
- import { pollWorkerInbox } from "./inbox-poll.ts";
15
+ import { acknowledgeWorkerInboxDelivery, pollWorkerInbox, unmarkSeenInboxIds } from "./inbox-poll.ts";
16
16
  import { createMessageTool, type MessageToolParams as MessageToolInputs, shouldRegisterMessageTool } from "./message-tool.ts";
17
17
  import { registerScratchpadLifecycle } from "./scratchpad-lifecycle.ts";
18
18
  import { createWorkerActivityTracker, registerSurfaceWorkerLifecycle, trackToolActivity } from "./surface-worker.ts";
@@ -32,13 +32,31 @@ const DATE_HEADER = "\nCurrent date:";
32
32
  // ── FIX-02: Steering content sanitization limits ──────────────────────────
33
33
  // Bounded to keep a malformed/malicious steer entry from blowing up the
34
34
  // worker's prompt budget or smuggling control sequences into the agent.
35
- const MAX_STEER_MESSAGE_LENGTH = 4096;
35
+ export const MAX_STEER_MESSAGE_LENGTH = 4096;
36
36
  const MAX_STEER_MESSAGE_NEWLINES = 50;
37
37
  // C0 control characters minus the printable whitespace (\t \n \r). These
38
38
  // are the bytes most useful for ANSI escapes, terminal-control tricks, and
39
39
  // NUL-injection attacks when steer content reaches the worker's UI.
40
40
  const STEER_CONTROL_CHAR_PATTERN = /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/;
41
41
 
42
+ /** R5-6 (DR-02 round-5): truncation marker for oversized steer bodies —
43
+ * mirrors renderInboxMessage's 8192-char marker pattern below. The clamp
44
+ * slices so the FINAL message (slice + marker) stays ≤
45
+ * {@link MAX_STEER_MESSAGE_LENGTH}, keeping the receiver's length invariant
46
+ * honest. */
47
+ const STEER_TRUNCATION_MARKER = `\n[message truncated at ${MAX_STEER_MESSAGE_LENGTH} chars]`;
48
+
49
+ /** R5-6 (DR-02 round-5): clamp a steer body to
50
+ * {@link MAX_STEER_MESSAGE_LENGTH} with a truncation marker. The send side
51
+ * (team-tool handleSteer) clamps at dispatch; this helper is the receiver's
52
+ * clean seam — every delivery path (file poll AND broker push) funnels
53
+ * through `sanitizeSteerMessage`, so an oversized steer from ANY writer is
54
+ * delivered truncated-with-marker instead of rejected-and-dropped. */
55
+ export function clampSteerMessage(message: string): string {
56
+ if (message.length <= MAX_STEER_MESSAGE_LENGTH) return message;
57
+ return `${message.slice(0, MAX_STEER_MESSAGE_LENGTH - STEER_TRUNCATION_MARKER.length)}${STEER_TRUNCATION_MARKER}`;
58
+ }
59
+
42
60
  export interface SteerSanitizeResult {
43
61
  valid: boolean;
44
62
  reason?: string;
@@ -118,25 +136,28 @@ export function createSeenSteerIdSet(): {
118
136
 
119
137
  /**
120
138
  * Validate a single steering-file entry before forwarding it to
121
- * `pi.sendMessage`. FIX-02: reject oversized payloads, excessive newlines,
122
- * or control characters that could be used to confuse the worker UI.
123
- */
139
+ * `pi.sendMessage`. FIX-02: reject excessive newlines or control characters
140
+ * that could be used to confuse the worker UI. R5-6 (DR-02 round-5): an
141
+ * OVERSIZED payload (>4096 chars) is no longer rejected-and-dropped — the
142
+ * rejection was terminal (the poll cursor advanced past the line, warn-only)
143
+ * and the send side had no clamp, so a leader steer over the limit silently
144
+ * vanished. It is now truncated-with-marker ({@link clampSteerMessage}) and
145
+ * delivered; the newline/control-char rejections remain terminal (different
146
+ * failure modes — UI confusion / escape smuggling, not length). */
124
147
  export function sanitizeSteerMessage(entry: SteerEntry): SteerSanitizeResult {
125
148
  const message = entry.message;
126
149
  if (typeof message !== "string" || message.length === 0) {
127
150
  return { valid: false, reason: "missing-or-empty-message" };
128
151
  }
129
- if (message.length > MAX_STEER_MESSAGE_LENGTH) {
130
- return { valid: false, reason: `message-too-long:${message.length}` };
131
- }
132
- const newlineCount = (message.match(/\n/g) ?? []).length;
152
+ const clamped = clampSteerMessage(message);
153
+ const newlineCount = (clamped.match(/\n/g) ?? []).length;
133
154
  if (newlineCount > MAX_STEER_MESSAGE_NEWLINES) {
134
155
  return { valid: false, reason: `too-many-newlines:${newlineCount}` };
135
156
  }
136
- if (STEER_CONTROL_CHAR_PATTERN.test(message)) {
157
+ if (STEER_CONTROL_CHAR_PATTERN.test(clamped)) {
137
158
  return { valid: false, reason: "contains-control-characters" };
138
159
  }
139
- return { valid: true, message };
160
+ return { valid: true, message: clamped };
140
161
  }
141
162
 
142
163
  // ── FIX-03: Steering file path containment validation ─────────────────────
@@ -1067,45 +1088,74 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
1067
1088
  // (`deliverAs:"steer"`, customType "crew-inbox") so the model sees
1068
1089
  // conversations between agents. Dormant-until-env: only when this worker
1069
1090
  // carries broker credentials (child-pi-spawn sets them for every worker)
1070
- // + a state root. A message is delivered EXACTLY ONCE via a seen-id set
1071
- // owned by this loop (the same keying that guards cross-channel steer
1072
- // dedup; here the durable mailbox is the only channel).
1091
+ // + a state root. R5-1/R5-2 (DR-02, 2026-10-15): delivery is ack-AFTER-send
1092
+ // — the poll picks (in-memory seen-set) up to INBOX_BATCH_MAX rows, the
1093
+ // DURABLE "acknowledged" record is written (acknowledgeWorkerInboxDelivery)
1094
+ // only after pi.sendMessage succeeded; a send throw unmarks the batch
1095
+ // (unmarkSeenInboxIds) so the next tick re-delivers it. Pickup is thus
1096
+ // at-least-once across send failures and exactly-once once the durable
1097
+ // ack lands (it also survives a restart — U5 semantics).
1073
1098
  const inboxBrokerRunId = getCrewEnv(PI_CREW_BROKER_RUN_ID_ASK_ENV);
1074
1099
  const inboxStateRoot = getCrewEnv(PI_CREW_STATE_ROOT_ENV);
1075
1100
  const inboxTaskId = getCrewEnv(PI_CREW_TASK_ID_ASK_ENV) ?? getCrewEnv(PI_CREW_BROKER_TASK_ID_ASK_ENV);
1076
1101
  if (inboxBrokerRunId && inboxStateRoot && inboxTaskId) {
1077
1102
  const seenInboxIds = new Set<string>();
1078
1103
  // Batch cap per tick: bounds a single steer frame that could otherwise
1079
- // pile up many fenced messages in one delivery.
1104
+ // pile up many fenced messages in one delivery. R5-2 (DR-02): the cap
1105
+ // is enforced AT PICKUP (pollWorkerInbox `limit`) so every picked row is
1106
+ // also a row actually sent + acked — the pre-fix shape acked N rows
1107
+ // durably but sliced 8 for the send, orphaning the surplus forever.
1080
1108
  const INBOX_BATCH_MAX = 8;
1081
1109
  const pollInbox = (): void => {
1082
1110
  try {
1083
- const picked = pollWorkerInbox({
1084
- stateRoot: inboxStateRoot,
1085
- runId: inboxBrokerRunId,
1086
- taskId: inboxTaskId,
1087
- seenIds: seenInboxIds,
1088
- });
1111
+ const picked = pollWorkerInbox(
1112
+ {
1113
+ stateRoot: inboxStateRoot,
1114
+ runId: inboxBrokerRunId,
1115
+ taskId: inboxTaskId,
1116
+ seenIds: seenInboxIds,
1117
+ },
1118
+ INBOX_BATCH_MAX,
1119
+ );
1089
1120
  if (picked.length === 0) return;
1090
- const batch = picked.slice(0, INBOX_BATCH_MAX);
1091
1121
  // §15.2 trust boundary: the sender's body is DATA, never
1092
1122
  // instructions. pollWorkerInbox hands back raw mailbox entries —
1093
1123
  // fence each body (control chars stripped, closing fence
1094
1124
  // neutralized, length capped) before it reaches the agent.
1095
- const fenced = batch.map((m) => ({
1125
+ const fenced = picked.map((m) => ({
1096
1126
  from: m.from,
1097
1127
  to: m.to,
1098
1128
  body: renderInboxMessage(m),
1099
1129
  }));
1100
- pi.sendMessage(
1101
- {
1102
- customType: "crew-inbox",
1103
- content: fenced.map((e) => e.body).join("\n"),
1104
- display: false,
1105
- details: { messages: fenced, count: batch.length },
1106
- },
1107
- { deliverAs: "steer" },
1108
- );
1130
+ try {
1131
+ pi.sendMessage(
1132
+ {
1133
+ customType: "crew-inbox",
1134
+ content: fenced.map((e) => e.body).join("\n"),
1135
+ display: false,
1136
+ details: { messages: fenced, count: picked.length },
1137
+ },
1138
+ { deliverAs: "steer" },
1139
+ );
1140
+ } catch (sendErr) {
1141
+ // R5-1: the send FAILED (e.g. pre-bind notInitialized — exactly
1142
+ // what the registration-time synchronous pollInbox() below can
1143
+ // hit). No durable ack was written yet, so there is no false
1144
+ // "delivered" record; release the seen marks so the next tick
1145
+ // re-picks and re-delivers the batch — mirrors seenSteers.unmark
1146
+ // + offset rewind in pollSteering above (at-least-once).
1147
+ unmarkSeenInboxIds(seenInboxIds, picked);
1148
+ logInternalError(
1149
+ "prompt-runtime.inbox-delivery-failed",
1150
+ sendErr instanceof Error ? sendErr : new Error(String(sendErr)),
1151
+ `runId=${inboxBrokerRunId} taskId=${inboxTaskId} count=${picked.length}`,
1152
+ "warn",
1153
+ );
1154
+ return;
1155
+ }
1156
+ // R5-1: the durable "acknowledged" record + pickup audit event
1157
+ // happen ONLY after a successful send — never before it.
1158
+ acknowledgeWorkerInboxDelivery({ stateRoot: inboxStateRoot, runId: inboxBrokerRunId, taskId: inboxTaskId }, picked);
1109
1159
  } catch {
1110
1160
  // A transient mailbox read error must never break the tick — the
1111
1161
  // next 500ms (or 50ms under live-session realtime) poll retries.