pi-crew 0.10.3 → 0.10.4

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 (62) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +5 -1
  3. package/dist/index.mjs +10685 -6882
  4. package/docs/architecture.md +4 -4
  5. package/docs/commands-reference.md +3 -0
  6. package/docs/publishing.md +15 -3
  7. package/install.mjs +90 -39
  8. package/package.json +8 -3
  9. package/scripts/README.md +4 -3
  10. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +1 -0
  11. package/skills/real-test-pi-crew/SKILL.md +153 -6
  12. package/src/config/migration-validator.ts +113 -0
  13. package/src/extension/cross-extension-rpc.ts +3 -7
  14. package/src/extension/register.ts +13 -0
  15. package/src/extension/registration/observability.ts +3 -7
  16. package/src/extension/registration/subagent-tools.ts +3 -7
  17. package/src/extension/registration/team-tool.ts +3 -7
  18. package/src/extension/registration/ui.ts +3 -8
  19. package/src/extension/registration/viewers.ts +3 -10
  20. package/src/extension/team-manager-command.ts +3 -7
  21. package/src/extension/team-tool/api/agent-control.ts +17 -10
  22. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  23. package/src/extension/team-tool/api/mailbox.ts +33 -20
  24. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  25. package/src/extension/team-tool/api/task-claims.ts +8 -7
  26. package/src/extension/team-tool/cancel.ts +6 -0
  27. package/src/extension/team-tool/handle-settings.ts +4 -1
  28. package/src/extension/team-tool/run.ts +3 -7
  29. package/src/extension/team-tool/status.ts +5 -0
  30. package/src/extension/team-tool.ts +6 -14
  31. package/src/hooks/registry.ts +3 -0
  32. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  33. package/src/runtime/background-runner.ts +30 -35
  34. package/src/runtime/broker/crew-broker.ts +112 -441
  35. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  36. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  37. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  38. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  39. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  40. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  41. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  42. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  43. package/src/runtime/child-pi/child-pi.ts +15 -0
  44. package/src/runtime/finalize-run.ts +15 -7
  45. package/src/runtime/foreground-control.ts +19 -6
  46. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  47. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  48. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  49. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  50. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  51. package/src/runtime/model/pi-args.ts +6 -1
  52. package/src/runtime/plan-replan.ts +3 -0
  53. package/src/runtime/stale-reconciler.ts +28 -3
  54. package/src/runtime/supervisor-contact.ts +3 -0
  55. package/src/runtime/task-runner/child-executor.ts +33 -0
  56. package/src/runtime/team-runner.ts +3 -3
  57. package/src/state/stores/ownership-map.ts +5 -4
  58. package/src/state/stores/plan-store.ts +12 -0
  59. package/src/state/stores/state-store.ts +5 -0
  60. package/src/ui/powerbar-publisher.ts +3 -7
  61. package/src/ui/run-action-dispatcher.ts +7 -10
  62. package/src/ui/settings-overlay.ts +4 -1
@@ -25,37 +25,56 @@ import { randomUUID } from "node:crypto";
25
25
  import * as fsp from "node:fs/promises";
26
26
  import * as net from "node:net";
27
27
  import { withRunLockSync } from "../../state/coordination/locks.ts";
28
- import {
29
- appendMailboxMessageAsync,
30
- type MailboxMessage,
31
- type MailboxMessageKind,
32
- type MailboxMessagePriority,
33
- readMailbox,
34
- registerMailboxAppendObserver,
35
- } from "../../state/coordination/mailbox.ts";
36
- import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
28
+ import { appendMailboxMessageAsync, type MailboxMessage, registerMailboxAppendObserver } from "../../state/coordination/mailbox.ts";
29
+ import { appendEventAsync } from "../../state/event-log/event-log.ts";
37
30
  import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
38
- import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
31
+ import type { TeamTaskState } from "../../state/types.ts";
39
32
  import { runEventBus } from "../../ui/run-event-bus.ts";
40
33
  import { logInternalError } from "../../utils/internal-error.ts";
41
34
  import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
42
35
  import { redactSecretString } from "../../utils/redaction.ts";
43
36
  import { resolveRealContainedPath } from "../../utils/safe-paths.ts";
44
37
  import { getBrokerSocketPath, prepareBrokerSocketDir, removeStaleBrokerSocket } from "../../utils/socket-path.ts";
45
- import { type GrandchildSpawnInput, type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
38
+ import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
46
39
  import { resolveCrewMaxDepth } from "../model/pi-args.ts";
47
40
  import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
48
41
  import { evaluateDelegateAdmission } from "../spawn-policy.ts";
49
42
  import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
43
+ import { recordDelegateEvent } from "./delegate/delegate-event.ts";
44
+ import { fanoutMailboxMessage } from "./mailbox-observer/mailbox-fanout.ts";
45
+ import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
46
+ import { handleEventsSince } from "./protocol/events-replay.ts";
47
+ import { loadRunForHello } from "./protocol/manifest-loader.ts";
48
+ import { handleMsgInbox } from "./protocol/msg-inbox.ts";
49
+ import {
50
+ BROKER_PROTOCOL,
51
+ isHelloParams,
52
+ isRequestObject,
53
+ parseMsgSendParams,
54
+ parseWaitRequestParams,
55
+ parseWaitResolveParams,
56
+ safeStringify,
57
+ WAIT_REQUEST_TIMEOUT_SEC_DEFAULT,
58
+ WAIT_REQUEST_TIMEOUT_SEC_MAX,
59
+ } from "./protocol/request-parsers.ts";
60
+ import { recordWaitPolicyRejection, waitAuthError } from "./protocol/wait-auth.ts";
50
61
  import { WaitStatusCache } from "./wait-status-cache.ts";
51
62
 
52
- /** Protocol version negotiated at `hello` time. Bump on breaking change. */
53
- const BROKER_PROTOCOL = 1;
63
+ /** Protocol version negotiated at `hello` time. Bump on breaking change.
64
+ * (Re-export removed 2026-09-10 — zero consumers; defined + exported in
65
+ * request-parsers.ts.) */
54
66
 
55
67
  /** Hard hello deadline (per spec). After 1s, the connection is closed with a
56
- * generic auth/protocol code. */
68
+ * generic auth/protocol code. (Unexported 2026-09-10 — zero consumers.) */
57
69
  const HELLO_DEADLINE_MS = 1_000;
58
70
 
71
+ /** Per-connection server-side state.
72
+ * Moved to ./protocol/connection-state.ts (M4 / WI-4.1):
73
+ * - interface CrewBrokerOptions
74
+ * - interface ServerConnection
75
+ * Both re-exported from connection-state.ts; the class body is unchanged.
76
+ */
77
+
59
78
  /** Task 10 (mux-surface A1 §5.2): run statuses after which every hello token
60
79
  * is by definition stale — the run will never issue work again, so the error
61
80
  * is "stale-token" instead of generic auth. NARROWER than
@@ -66,95 +85,6 @@ const STALE_RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed",
66
85
  /** Default per-connection outbound queue cap (events). */
67
86
  const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
68
87
 
69
- export interface CrewBrokerOptions {
70
- /** Root session ID used to derive the socket path. */
71
- sessionId: string;
72
- /** Pre-resolved socket path (skips re-derivation; useful for tests). */
73
- socketPath?: string;
74
- /** Frame cap in UTF-8 bytes. Default 256 KiB. */
75
- maxFrameBytes?: number;
76
- /** Per-connection outbound queue cap. Default 256. */
77
- outboundQueueCap?: number;
78
- /** Required: when false, start() is a no-op and the server never binds.
79
- * Lets the lifecycle controller install the broker unconditionally and
80
- * have a single kill switch. */
81
- enabled: boolean;
82
- /** CWD for `loadRunManifestById` (Phase 1 msg.send / msg.inbox resolution).
83
- * When omitted, manifest-touching methods return no-manifest errors. */
84
- cwd?: string;
85
- /** Optional test seam: override the `net` module (allows fake-server tests). */
86
- netModule?: typeof net;
87
- /** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
88
- * wrapping a loader spy). Production uses a plain cache — see
89
- * wait-status-cache.ts (R10-3). */
90
- waitStatusCache?: WaitStatusCache;
91
- /** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
92
- * gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
93
- * explicitly true, wait.request/wait.resolve are rejected with a
94
- * `policy-disabled` error AND a `policy.action` event is appended to the
95
- * run's events.jsonl (never silent). The production wiring threads
96
- * `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
97
- waitMethodsEnabled?: boolean;
98
- /** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
99
- * TRUE since D8 (spec v0.7) — nested spawning is open out of the box; the
100
- * broker still fail-closes when the flag is anything but true. The
101
- * production wiring threads `config.nesting.enabled` (loadConfig layers
102
- * DEFAULT_NESTING.enabled=true; a user `false` closes the surface); tests
103
- * pass it explicitly. Rejections are NEVER silent (delegate.rejected). */
104
- nestingEnabled?: boolean;
105
- /** Optional override for the nested-slot budget size (config nesting.maxSlots). */
106
- nestingMaxSlots?: number;
107
- nestingMaxDepth?: number;
108
- nestingTrustedEscalation?: boolean;
109
- /** Global worker semaphore size, used to size the nested-slot budget. */
110
- globalWorkerSemaphore?: number;
111
- /** Test seam / alternative spawner for delegate grandchildren. Production
112
- * uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
113
- grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
114
- /** Resolved model catalog (canonical provider/id strings) for admission-time
115
- * model validation (ADR-5 §7). When omitted, model validation is skipped
116
- * (documented gap — the production wiring must always supply it). */
117
- modelCatalog?: () => string[] | undefined;
118
- /** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
119
- * admission gate. Default false. */
120
- serializeOnPathOverlap?: boolean;
121
- }
122
-
123
- /** Per-connection server-side state. */
124
- interface ServerConnection {
125
- socket: net.Socket;
126
- decoder: NdjsonDecoder;
127
- /** Whether the connection has completed `hello` successfully. */
128
- authed: boolean;
129
- /** Run id bound by hello. */
130
- runId?: string;
131
- /** Task id bound by hello. */
132
- taskId?: string;
133
- /** Role bound by hello: orchestrator can steer/msg-send; workers default. */
134
- role?: "orchestrator" | "worker";
135
- /** How the hello token matched the registry (ADR-0 item 6). Derived,
136
- * non-secret metadata recorded at hello time so `wait.*` can reject a
137
- * legacy bare-runId fallback match WITHOUT keeping the raw token on the
138
- * connection (tokens stay confined to the heap-only registry). */
139
- authMatchKind?: "compound" | "runId-fallback";
140
- /** Task 10 fix round 2 (BUG #3): sha256 of the secret this connection
141
- * authenticated with. Derived, one-way — never the plaintext token. The
142
- * post-hello revocation check evaluates THIS digest so a revoke →
143
- * re-issue window cannot let an old connection ride the freshly issued
144
- * token for the same key. */
145
- authedSecretHash?: string;
146
- /** Outbound queue of encoded frames awaiting drain. */
147
- outbound: Buffer[];
148
- /** Set when the queue has hit the cap and a frame was dropped. */
149
- needsResync: boolean;
150
- /** Set when the connection is closing (idempotent). */
151
- closed: boolean;
152
- /** Timer for the hello deadline. */
153
- helloTimer: NodeJS.Timeout | null;
154
- /** Monotonic seq counter for outbound events (diagnostic). */
155
- outboundSeq: number;
156
- }
157
-
158
88
  export class CrewBroker {
159
89
  private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
160
90
  Pick<
@@ -478,9 +408,7 @@ export class CrewBroker {
478
408
  this.resolvedSocketPath = null;
479
409
  }
480
410
 
481
- // ------------------------------------------------------------------------
482
411
  // Connection lifecycle
483
- // ------------------------------------------------------------------------
484
412
 
485
413
  private async handleConnection(sock: net.Socket): Promise<void> {
486
414
  // B1 (Round 14): a connection event queued after stop() must not be
@@ -583,38 +511,11 @@ export class CrewBroker {
583
511
  }
584
512
 
585
513
  /**
586
- * Phase 1.3: push a durable-appended mailbox message to any connected
587
- * recipient for the message's run. Best-effort — silently skips
588
- * recipients that are offline (they recover via msg.inbox). Never throws.
514
+ * Phase 1.3: see ./mailbox-observer/mailbox-fanout.ts (M4 / WI-4.1 moved).
515
+ * Class method delegates with 1-line binding of connectionsByRun + writers.
589
516
  */
590
517
  private fanoutMailboxMessage(msg: MailboxMessage): void {
591
- const set = this.connectionsByRun.get(msg.runId);
592
- if (!set || set.size === 0) return;
593
- // Recipient delivery dedup lives in src/prompt/prompt-runtime.ts and is
594
- // keyed by the same message id in this mailbox event and the steering JSONL.
595
- const eventFrame = encodeBrokerFrame({
596
- event: "mailbox.message",
597
- data: { id: msg.id, from: msg.from, to: msg.to, body: msg.body, kind: msg.kind, priority: msg.priority },
598
- seq: 0, // mailbox messages don't carry a TeamEvent seq; dedup by msg.id
599
- });
600
- for (const conn of set) {
601
- if (conn.closed || !conn.authed) continue;
602
- // Recipient filter: deliver to the addressed task, or to all if 'all'.
603
- // Task 5b (§15.2 wake): "parent"-addressed messages land in the
604
- // run-level inbox, whose live consumer is the run's orchestrator
605
- // connection (role from the orchestrator token — its taskId never
606
- // equals "parent"), so without this branch the wake frame would be
607
- // filtered out and the orchestrator would only see the message on
608
- // its next inbox poll.
609
- const isRecipient =
610
- !msg.to || msg.to === "all" || conn.taskId === msg.to || (msg.to === "parent" && conn.role === "orchestrator");
611
- if (!isRecipient) continue;
612
- try {
613
- this.writeOrQueue(conn, eventFrame, false);
614
- } catch {
615
- /* a slow/dead recipient must not break fanout to others */
616
- }
617
- }
518
+ fanoutMailboxMessage(this.connectionsByRun, { writeOrQueue: (conn, buf, force) => this.writeOrQueue(conn, buf, force) }, msg);
618
519
  }
619
520
 
620
521
  private async handleData(conn: ServerConnection, chunk: Buffer): Promise<void> {
@@ -758,7 +659,7 @@ export class CrewBroker {
758
659
  // is real, that is a stale token — reject, but say so, because the
759
660
  // A2 remedy is a re-issue, not a retry. An unknown run/task keeps
760
661
  // the generic auth error (no disclosure of which id was valid).
761
- const loaded = this.loadRunForHello(runId);
662
+ const loaded = loadRunForHello(this.options.cwd, runId);
762
663
  if (loaded && (loaded.tasks ?? []).some((t) => t.id === taskId)) {
763
664
  this.sendErrorAndClose(
764
665
  conn,
@@ -787,7 +688,7 @@ export class CrewBroker {
787
688
  return;
788
689
  }
789
690
  if (resolved.role === "worker") {
790
- const loaded = this.loadRunForHello(runId);
691
+ const loaded = loadRunForHello(this.options.cwd, runId);
791
692
  if (loaded && STALE_RUN_STATUSES.has(loaded.manifest.status)) {
792
693
  this.sendErrorAndClose(conn, id, "stale-token", "hello rejected: run is already terminal (stale token)");
793
694
  return;
@@ -838,19 +739,9 @@ export class CrewBroker {
838
739
  * is not on disk — callers treat that as "cannot classify" and keep the
839
740
  * legacy generic-auth behavior (the heap registry stays the source of
840
741
  * truth for authentication). */
841
- private loadRunForHello(runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
842
- const cwd = this.options.cwd;
843
- if (!cwd) return undefined;
844
- try {
845
- return loadRunManifestById(cwd, runId) ?? undefined;
846
- } catch {
847
- return undefined;
848
- }
849
- }
742
+ // loadRunForHello: inlined at the 2 call sites (was a 5-line method; M4/WI-4.1).
850
743
 
851
- // ------------------------------------------------------------------------
852
744
  // Outbound queue + drop-newest + needsResync
853
- // ------------------------------------------------------------------------
854
745
 
855
746
  private sendResult(conn: ServerConnection, id: string, result: unknown): void {
856
747
  this.enqueueFrame(conn, { id, result });
@@ -941,9 +832,7 @@ export class CrewBroker {
941
832
  }
942
833
  }
943
834
 
944
- // ------------------------------------------------------------------------
945
835
  // Phase 1: msg.send + msg.inbox handlers
946
- // ------------------------------------------------------------------------
947
836
 
948
837
  /** Phase 1.1: direct or broadcast mailbox write via the durable append path. */
949
838
  private async handleMsgSend(conn: ServerConnection, id: string, params: unknown): Promise<void> {
@@ -1111,96 +1000,35 @@ export class CrewBroker {
1111
1000
  });
1112
1001
  }
1113
1002
 
1114
- /** Phase 1.2: paginated inbox pull for the authenticated run/task. */
1003
+ /** Phase 1.2: paginated inbox pull — see ./protocol/msg-inbox.ts
1004
+ * (M4 / WI-4.1 moved; label corrected 2026-09-10: Phase 1.1 = msg.send). */
1115
1005
  private async handleMsgInbox(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1116
- if (!conn.runId) {
1117
- this.sendError(conn, id, "auth", "not authed");
1118
- return;
1119
- }
1120
- const parsed = parseMsgInboxParams(params);
1121
- if (!parsed) {
1122
- this.sendError(conn, id, "bad-params", "msg.inbox: invalid params");
1123
- return;
1124
- }
1125
- const cwd = this.options.cwd;
1126
- if (!cwd) {
1127
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1128
- return;
1129
- }
1130
- let manifest: Parameters<typeof readMailbox>[0];
1131
- try {
1132
- const loaded = loadRunManifestById(cwd, conn.runId);
1133
- if (!loaded) {
1134
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1135
- return;
1136
- }
1137
- manifest = loaded.manifest;
1138
- } catch (err) {
1139
- this.sendError(conn, id, "no-manifest", (err as Error).message);
1140
- return;
1141
- }
1142
- const limit = Math.min(Math.max(parsed.limit ?? 100, 1), 1000);
1143
- const taskId = conn.taskId ?? undefined;
1144
- const all = readMailbox(manifest, "inbox", taskId);
1145
- const filtered = all.filter((m) => m.status !== "acknowledged");
1146
- const offset = parsed.cursor ? parseInt(parsed.cursor, 10) || 0 : 0;
1147
- const page = filtered.slice(offset, offset + limit);
1148
- const nextOffset = offset + page.length;
1149
- const hasMore = nextOffset < filtered.length;
1150
- this.sendResult(conn, id, {
1151
- messages: page,
1152
- nextCursor: hasMore ? String(nextOffset) : undefined,
1153
- hasMore,
1154
- total: filtered.length,
1155
- });
1006
+ await handleMsgInbox(
1007
+ conn,
1008
+ id,
1009
+ params,
1010
+ {
1011
+ sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
1012
+ sendResult: (c, i, r) => this.sendResult(c, i, r),
1013
+ },
1014
+ this.options.cwd,
1015
+ );
1156
1016
  }
1157
1017
 
1158
- /**
1159
- * Phase 1.5: events.since — bounded replay of structured events with seq >
1160
- * sinceSeq from the durable log. Used by clients to resync after a missed
1161
- * live frame (e.g. after a queue overflow or reconnect). Reuses the same
1162
- * readEventsCursor + seq semantics as runEventBus.onWithReplay.
1163
- */
1018
+ /** Phase 1.5: events.since — bounded replay; clients resync after a missed
1019
+ * live frame (queue overflow / reconnect). See ./protocol/events-replay.ts
1020
+ * (M4 / WI-4.1 moved; Phase 2 = events.subscribe, not this). */
1164
1021
  private async handleEventsSince(conn: ServerConnection, id: string, params: unknown): Promise<void> {
1165
- if (!conn.runId) {
1166
- this.sendError(conn, id, "auth", "not authed");
1167
- return;
1168
- }
1169
- const cwd = this.options.cwd;
1170
- if (!cwd) {
1171
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
1172
- return;
1173
- }
1174
- let eventsPath: string;
1175
- try {
1176
- const loaded = loadRunManifestById(cwd, conn.runId);
1177
- if (!loaded) {
1178
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
1179
- return;
1180
- }
1181
- eventsPath = loaded.manifest.eventsPath;
1182
- } catch (err) {
1183
- this.sendError(conn, id, "no-manifest", (err as Error).message);
1184
- return;
1185
- }
1186
- const v = params && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : {};
1187
- const sinceSeq = typeof v.sinceSeq === "number" && Number.isFinite(v.sinceSeq) ? Math.max(0, Math.floor(v.sinceSeq)) : 0;
1188
- const limit = typeof v.limit === "number" && Number.isFinite(v.limit) ? Math.min(Math.max(1, Math.floor(v.limit)), 1000) : 1000;
1189
- try {
1190
- const result = readEventsCursor(eventsPath, { sinceSeq, limit });
1191
- // hasMore is true iff the total filtered count exceeds the page we
1192
- // returned. When `total === events.length` we are at the exact end
1193
- // of the stream (caller will discover this on the next call when
1194
- // `nextSeq` is unchanged from `sinceSeq`).
1195
- const hasMore = result.total > result.events.length;
1196
- this.sendResult(conn, id, {
1197
- events: result.events,
1198
- nextSeq: result.nextSeq,
1199
- hasMore,
1200
- });
1201
- } catch (err) {
1202
- this.sendError(conn, id, "replay-failed", (err as Error).message);
1203
- }
1022
+ await handleEventsSince(
1023
+ conn,
1024
+ id,
1025
+ params,
1026
+ {
1027
+ sendError: (c, i, code, msg) => this.sendError(c, i, code, msg),
1028
+ sendResult: (c, i, r) => this.sendResult(c, i, r),
1029
+ },
1030
+ this.options.cwd,
1031
+ );
1204
1032
  }
1205
1033
 
1206
1034
  /**
@@ -1510,39 +1338,19 @@ export class CrewBroker {
1510
1338
  }
1511
1339
  }
1512
1340
 
1513
- // ------------------------------------------------------------------------
1514
1341
  // WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
1515
- // ------------------------------------------------------------------------
1516
1342
 
1517
- /** Shared auth for wait.*: worker role + task-scoped (compound-key) token
1518
- * ONLY (ADR item 6). A legacy bare-runId fallback match is REJECTED with
1519
- * a migrate hint; the orchestrator token is rejected by role. Returns the
1520
- * error to send, or null when auth passes. */
1343
+ // waitAuthError: protocol/wait-auth.ts (M4/WI-4.1).
1521
1344
  private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
1522
- if (conn.role !== "worker" || conn.authMatchKind === undefined) {
1523
- return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
1524
- }
1525
- if (conn.authMatchKind !== "compound") {
1526
- return {
1527
- code: "forbidden",
1528
- message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
1529
- };
1530
- }
1531
- return null;
1345
+ return waitAuthError(conn);
1532
1346
  }
1533
1347
 
1534
- /** ADR item 7: a disabled-gate rejection MUST leave a durable trace in
1535
- * events.jsonl — the gate fails CLOSED but never SILENTLY. Fire-and-forget
1536
- * async append (broker handlers must not block the event loop on the sync
1537
- * event-log lock); an append failure is logged, never thrown. */
1348
+ // (The "ADR item 7" doc that used to dangle here documents
1349
+ // recordWaitPolicyRejection — see wait-auth.ts, where it belongs.
1350
+ // Removed 2026-09-10, review F6.)
1538
1351
  // T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
1539
1352
  // grandchild spawn with durable mailbox delivery (WP-5 step 5).
1540
- private getDelegateNestedSlots(): NestedSlotBudget {
1541
- if (!this.nestedSlots) {
1542
- this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1543
- }
1544
- return this.nestedSlots;
1545
- }
1353
+ // getDelegateNestedSlots: inlined at 4 call sites (5-line method; M4/WI-4.1).
1546
1354
 
1547
1355
  private recordDelegateEvent(
1548
1356
  manifest: { eventsPath: string; runId: string },
@@ -1556,15 +1364,7 @@ export class CrewBroker {
1556
1364
  taskId: string,
1557
1365
  data: Record<string, unknown>,
1558
1366
  ): void {
1559
- void appendEventAsync(manifest.eventsPath, {
1560
- type,
1561
- runId: manifest.runId,
1562
- taskId,
1563
- message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
1564
- data,
1565
- }).catch((err) =>
1566
- logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
1567
- );
1367
+ recordDelegateEvent(manifest, type, taskId, data);
1568
1368
  }
1569
1369
 
1570
1370
  private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
@@ -1677,7 +1477,11 @@ export class CrewBroker {
1677
1477
  ...(task.depth !== undefined ? { depth: task.depth } : {}),
1678
1478
  ...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
1679
1479
  },
1680
- slots: this.getDelegateNestedSlots().snapshot(),
1480
+ slots: (() => {
1481
+ if (!this.nestedSlots)
1482
+ this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1483
+ return this.nestedSlots;
1484
+ })().snapshot(),
1681
1485
  requested,
1682
1486
  ...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
1683
1487
  // ADR-5 §12: the delegate surface is an escalation — trusted only by the
@@ -1697,11 +1501,28 @@ export class CrewBroker {
1697
1501
  return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
1698
1502
  }
1699
1503
  // Slot acquisition INSIDE the lock (no reserve-then-race refund window).
1700
- if (!this.getDelegateNestedSlots().tryAcquire(subId)) {
1504
+ if (
1505
+ !(() => {
1506
+ if (!this.nestedSlots)
1507
+ this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1508
+ return this.nestedSlots;
1509
+ })().tryAcquire(subId)
1510
+ ) {
1701
1511
  this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
1702
1512
  return {
1703
1513
  code: "policy-denied" as const,
1704
- message: `delegate rejected: nested spawn budget exhausted; ${this.getDelegateNestedSlots().statusLine}`,
1514
+ message: `delegate rejected: nested spawn budget exhausted; ${
1515
+ (
1516
+ () => {
1517
+ if (!this.nestedSlots)
1518
+ this.nestedSlots = new NestedSlotBudget(
1519
+ this.options.globalWorkerSemaphore ?? 4,
1520
+ this.options.nestingMaxSlots,
1521
+ );
1522
+ return this.nestedSlots;
1523
+ }
1524
+ )().statusLine
1525
+ }`,
1705
1526
  };
1706
1527
  }
1707
1528
  // Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
@@ -1892,7 +1713,11 @@ export class CrewBroker {
1892
1713
  }
1893
1714
  }
1894
1715
  } finally {
1895
- this.getDelegateNestedSlots().release(subId);
1716
+ (() => {
1717
+ if (!this.nestedSlots)
1718
+ this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1719
+ return this.nestedSlots;
1720
+ })().release(subId);
1896
1721
  }
1897
1722
  this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
1898
1723
  subId,
@@ -1902,16 +1727,7 @@ export class CrewBroker {
1902
1727
  }
1903
1728
 
1904
1729
  private recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
1905
- const runId = manifest.runId;
1906
- void appendEventAsync(manifest.eventsPath, {
1907
- type: "policy.action",
1908
- runId,
1909
- taskId,
1910
- message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
1911
- data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
1912
- }).catch((err) =>
1913
- logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1914
- );
1730
+ recordWaitPolicyRejection(manifest, taskId, method);
1915
1731
  }
1916
1732
 
1917
1733
  /** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
@@ -2170,159 +1986,14 @@ export class CrewBroker {
2170
1986
  // Type guards (no `any`)
2171
1987
  // ============================================================================
2172
1988
 
2173
- function isRequestObject(value: unknown): value is { id: string; method: string; params: unknown } {
2174
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2175
- const v = value as Record<string, unknown>;
2176
- if (typeof v.id !== "string" || v.id.length === 0 || v.id.length > 256) return false;
2177
- if (typeof v.method !== "string" || v.method.length === 0 || v.method.length > 64) return false;
2178
- // Method names are restricted to a small safe charset. This guards against
2179
- // odd inputs (control chars, very long names) reaching the dispatcher.
2180
- if (!/^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(v.method)) return false;
2181
- // params may be anything (validated per-method), but not undefined-shaped.
2182
- return "params" in v;
2183
- }
2184
-
2185
- function isHelloParams(value: unknown): value is {
2186
- protocol: number;
2187
- runId: string;
2188
- taskId: string;
2189
- token: string;
2190
- role?: string;
2191
- } {
2192
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2193
- const v = value as Record<string, unknown>;
2194
- if (v.protocol !== BROKER_PROTOCOL) {
2195
- // Force exact-type comparison (must be the number 1, not "1").
2196
- if (typeof v.protocol !== "number" || !Number.isInteger(v.protocol)) return false;
2197
- }
2198
- if (typeof v.runId !== "string" || v.runId.length === 0 || v.runId.length > 256) return false;
2199
- if (typeof v.taskId !== "string" || v.taskId.length === 0 || v.taskId.length > 256) return false;
2200
- if (typeof v.token !== "string" || v.token.length === 0 || v.token.length > 256) return false;
2201
- return true;
2202
- }
2203
-
2204
- // ============================================================================
2205
- // Phase 1 parameter parsers (module-level; no `any`)
2206
- // ============================================================================
2207
-
2208
- interface MsgSendParams {
2209
- to: string | string[] | "all";
2210
- body: unknown;
2211
- kind?: MailboxMessageKind;
2212
- priority?: MailboxMessagePriority;
2213
- replyTo?: string;
2214
- /** Task 5b (§15.2): short subject echoed into the worker.message wake
2215
- * event (bounded like the tool-side MSG_SUBJECT_MAX_CHARS). */
2216
- subject?: string;
2217
- }
2218
-
2219
- function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
2220
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2221
- const v = value as Record<string, unknown>;
2222
- const to = v.to;
2223
- if (typeof to !== "string" && !Array.isArray(to)) return undefined;
2224
- if (Array.isArray(to) && !to.every((s) => typeof s === "string" && s.length > 0)) return undefined;
2225
- if (typeof to === "string" && to.length === 0) return undefined;
2226
- if (v.body === undefined) return undefined;
2227
- const kind = v.kind as MailboxMessageKind | undefined;
2228
- if (kind !== undefined && !["message", "notify", "steer", "follow-up", "response", "group_join"].includes(kind)) {
2229
- return undefined;
2230
- }
2231
- const priority = v.priority as MailboxMessagePriority | undefined;
2232
- if (priority !== undefined && !["urgent", "normal", "low"].includes(priority)) {
2233
- return undefined;
2234
- }
2235
- const replyTo = typeof v.replyTo === "string" ? v.replyTo : undefined;
2236
- const subject = typeof v.subject === "string" && v.subject.length > 0 && v.subject.length <= 256 ? v.subject : undefined;
2237
- return { to: to as string | string[] | "all", body: v.body, kind, priority, replyTo, subject };
2238
- }
2239
-
2240
- interface MsgInboxParams {
2241
- limit?: number;
2242
- cursor?: string;
2243
- }
2244
-
2245
- function parseMsgInboxParams(value: unknown): MsgInboxParams | undefined {
2246
- if (value === undefined || value === null) return { limit: 100, cursor: undefined };
2247
- if (typeof value !== "object" || Array.isArray(value)) return undefined;
2248
- const v = value as Record<string, unknown>;
2249
- const limit = v.limit;
2250
- if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
2251
- return undefined;
2252
- }
2253
- const cursor = v.cursor;
2254
- if (cursor !== undefined && typeof cursor !== "string") return undefined;
2255
- return { limit: limit as number | undefined, cursor: cursor as string | undefined };
2256
- }
2257
-
2258
- function safeStringify(value: unknown): string {
2259
- try {
2260
- return JSON.stringify(value) ?? "{}";
2261
- } catch {
2262
- return "{}";
2263
- }
2264
- }
2265
-
2266
- // ============================================================================
2267
- // WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
2268
- // ============================================================================
2269
-
2270
- /** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
2271
- * timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
2272
- * amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
2273
- const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
2274
- /** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
2275
- const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
2276
- /** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
2277
- const WAIT_QUESTION_MAX_CHARS = 8192;
2278
- /** Bounded answer-choice list: at most 16 options, 256 chars each. */
2279
- const WAIT_OPTIONS_MAX = 16;
2280
- const WAIT_OPTION_MAX_CHARS = 256;
2281
-
2282
- interface WaitRequestParams {
2283
- to: string;
2284
- question: string;
2285
- options?: string[];
2286
- timeoutSec?: number;
2287
- }
2288
-
2289
- function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
2290
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2291
- const v = value as Record<string, unknown>;
2292
- if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
2293
- if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
2294
- return undefined;
2295
- }
2296
- let options: string[] | undefined;
2297
- if (v.options !== undefined) {
2298
- if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
2299
- for (const o of v.options) {
2300
- if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
2301
- }
2302
- options = v.options as string[];
2303
- }
2304
- // timeoutSec is clamped server-side in the handler (max 3600); the parser
2305
- // only rejects non-finite values. Non-positive values clamp to 1s.
2306
- if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
2307
- return undefined;
2308
- }
2309
- return {
2310
- to: v.to,
2311
- question: v.question,
2312
- options,
2313
- timeoutSec: v.timeoutSec as number | undefined,
2314
- };
2315
- }
2316
-
2317
- interface WaitResolveParams {
2318
- to: string;
2319
- questionId: string;
2320
- }
2321
-
2322
- function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
2323
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2324
- const v = value as Record<string, unknown>;
2325
- if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
2326
- if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
2327
- return { to: v.to, questionId: v.questionId };
2328
- }
1989
+ /** Moved to ./protocol/request-parsers.ts (M4 / WI-4.1):
1990
+ * - isRequestObject
1991
+ * - isHelloParams + BROKER_PROTOCOL
1992
+ * - parseMsgSendParams + MsgSendParams
1993
+ * - parseMsgInboxParams + MsgInboxParams
1994
+ * - parseWaitRequestParams + WaitRequestParams
1995
+ * - parseWaitResolveParams + WaitResolveParams
1996
+ * - safeStringify
1997
+ * - WAIT_* constants
1998
+ * Removed from this file; re-exported via "./protocol/request-parsers.ts".
1999
+ */