pi-crew 0.10.2 → 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 (124) hide show
  1. package/AGENTS.md +2 -1
  2. package/CHANGELOG.md +249 -0
  3. package/README.md +5 -1
  4. package/dist/index.mjs +10844 -7250
  5. package/docs/architecture.md +4 -4
  6. package/docs/commands-reference.md +3 -0
  7. package/docs/publishing.md +15 -3
  8. package/install.mjs +90 -39
  9. package/package.json +9 -3
  10. package/schema.json +11 -0
  11. package/scripts/README.md +4 -3
  12. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
  13. package/skills/real-test-pi-crew/SKILL.md +428 -82
  14. package/src/config/config-merge.ts +11 -1
  15. package/src/config/config-validation.ts +40 -1
  16. package/src/config/config.ts +28 -6
  17. package/src/config/defaults.ts +35 -10
  18. package/src/config/env-vars.ts +27 -2
  19. package/src/config/migration-validator.ts +113 -0
  20. package/src/config/types.ts +36 -0
  21. package/src/extension/cross-extension-rpc.ts +3 -7
  22. package/src/extension/register.ts +13 -0
  23. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  24. package/src/extension/registration/observability.ts +3 -7
  25. package/src/extension/registration/subagent-tools.ts +3 -7
  26. package/src/extension/registration/team-tool.ts +56 -12
  27. package/src/extension/registration/ui.ts +3 -8
  28. package/src/extension/registration/viewers.ts +3 -10
  29. package/src/extension/team-manager-command.ts +3 -7
  30. package/src/extension/team-tool/api/agent-control.ts +17 -10
  31. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  32. package/src/extension/team-tool/api/mailbox.ts +33 -20
  33. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  34. package/src/extension/team-tool/api/task-claims.ts +8 -7
  35. package/src/extension/team-tool/cancel.ts +6 -0
  36. package/src/extension/team-tool/doctor.ts +364 -7
  37. package/src/extension/team-tool/handle-settings.ts +23 -1
  38. package/src/extension/team-tool/inspect.ts +10 -2
  39. package/src/extension/team-tool/run.ts +3 -7
  40. package/src/extension/team-tool/status.ts +12 -0
  41. package/src/extension/team-tool.ts +41 -16
  42. package/src/hooks/registry.ts +62 -56
  43. package/src/prompt/inbox-poll.ts +90 -0
  44. package/src/prompt/message-tool.ts +166 -0
  45. package/src/prompt/prompt-runtime.ts +201 -18
  46. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  47. package/src/prompt/surface-worker.ts +720 -0
  48. package/src/prompt/worker-events-channel.ts +49 -3
  49. package/src/runtime/async-runner.ts +29 -1
  50. package/src/runtime/background-runner.ts +43 -42
  51. package/src/runtime/broker/broker-issuer.ts +27 -2
  52. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  53. package/src/runtime/broker/crew-broker.ts +334 -443
  54. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  55. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  56. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  57. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  58. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  59. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  60. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  61. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  62. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  63. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  64. package/src/runtime/child-pi/child-pi.ts +368 -5
  65. package/src/runtime/crew-agent-records.ts +13 -1
  66. package/src/runtime/dispatch-batch.ts +12 -1
  67. package/src/runtime/event-log-tail-source.ts +374 -0
  68. package/src/runtime/finalize-run.ts +19 -7
  69. package/src/runtime/foreground-control.ts +19 -6
  70. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  71. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  72. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  73. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  74. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  75. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  76. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  77. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  78. package/src/runtime/manifest-cache.ts +128 -17
  79. package/src/runtime/model/pi-args.ts +59 -65
  80. package/src/runtime/output/sidechain-output.ts +61 -6
  81. package/src/runtime/plan-replan.ts +3 -0
  82. package/src/runtime/process/proc-stat.ts +46 -0
  83. package/src/runtime/process/zombie-scanner.ts +32 -19
  84. package/src/runtime/spawn-policy.ts +27 -41
  85. package/src/runtime/stale-reconciler.ts +28 -3
  86. package/src/runtime/supervisor-contact.ts +3 -0
  87. package/src/runtime/surface/degrade.ts +776 -0
  88. package/src/runtime/surface/herdr-provider.ts +546 -0
  89. package/src/runtime/surface/launch-script.ts +172 -0
  90. package/src/runtime/surface/resolve-surface.ts +274 -0
  91. package/src/runtime/surface/surface-provider.ts +129 -0
  92. package/src/runtime/surface/surface-spawn.ts +475 -0
  93. package/src/runtime/surface/tmux-provider.ts +400 -0
  94. package/src/runtime/task-runner/child-executor.ts +80 -0
  95. package/src/runtime/task-runner/post-execution.ts +57 -2
  96. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  97. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  98. package/src/runtime/task-runner/state-helpers.ts +54 -30
  99. package/src/runtime/task-runner.ts +4 -2
  100. package/src/runtime/team-runner.ts +104 -3
  101. package/src/schema/config-schema.ts +24 -0
  102. package/src/state/atomic-write.ts +219 -40
  103. package/src/state/coordination/locks.ts +7 -5
  104. package/src/state/coordination/mailbox.ts +56 -10
  105. package/src/state/event-log/cursor.ts +413 -23
  106. package/src/state/event-log/event-log.ts +120 -113
  107. package/src/state/event-log/sequence-cache.ts +21 -3
  108. package/src/state/stores/ownership-map.ts +5 -4
  109. package/src/state/stores/plan-store.ts +12 -0
  110. package/src/state/stores/state-store.ts +103 -6
  111. package/src/state/types.ts +51 -0
  112. package/src/ui/inline-panel/agent-pane.ts +3 -0
  113. package/src/ui/powerbar-publisher.ts +3 -7
  114. package/src/ui/render-diff.ts +16 -8
  115. package/src/ui/run-action-dispatcher.ts +7 -10
  116. package/src/ui/run-dashboard.ts +87 -42
  117. package/src/ui/run-event-bus.ts +10 -1
  118. package/src/ui/run-snapshot-cache.ts +83 -35
  119. package/src/ui/settings-overlay.ts +4 -1
  120. package/src/ui/transcript-cache.ts +101 -13
  121. package/src/ui/transcript-viewer.ts +92 -24
  122. package/src/ui/widget/index.ts +32 -8
  123. package/src/utils/visual.ts +43 -0
  124. package/src/worktree/worktree-manager.ts +65 -4
@@ -25,15 +25,8 @@ 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
31
  import type { TeamTaskState } from "../../state/types.ts";
39
32
  import { runEventBus } from "../../ui/run-event-bus.ts";
@@ -42,103 +35,55 @@ import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder }
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
- import { BrokerTokenRegistry } from "./crew-broker-tokens.ts";
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
 
59
- /** Default per-connection outbound queue cap (events). */
60
- const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
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
+ */
61
77
 
62
- export interface CrewBrokerOptions {
63
- /** Root session ID used to derive the socket path. */
64
- sessionId: string;
65
- /** Pre-resolved socket path (skips re-derivation; useful for tests). */
66
- socketPath?: string;
67
- /** Frame cap in UTF-8 bytes. Default 256 KiB. */
68
- maxFrameBytes?: number;
69
- /** Per-connection outbound queue cap. Default 256. */
70
- outboundQueueCap?: number;
71
- /** Required: when false, start() is a no-op and the server never binds.
72
- * Lets the lifecycle controller install the broker unconditionally and
73
- * have a single kill switch. */
74
- enabled: boolean;
75
- /** CWD for `loadRunManifestById` (Phase 1 msg.send / msg.inbox resolution).
76
- * When omitted, manifest-touching methods return no-manifest errors. */
77
- cwd?: string;
78
- /** Optional test seam: override the `net` module (allows fake-server tests). */
79
- netModule?: typeof net;
80
- /** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
81
- * wrapping a loader spy). Production uses a plain cache — see
82
- * wait-status-cache.ts (R10-3). */
83
- waitStatusCache?: WaitStatusCache;
84
- /** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
85
- * gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
86
- * explicitly true, wait.request/wait.resolve are rejected with a
87
- * `policy-disabled` error AND a `policy.action` event is appended to the
88
- * run's events.jsonl (never silent). The production wiring threads
89
- * `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
90
- waitMethodsEnabled?: boolean;
91
- /** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
92
- * FALSE — fail-closed until the WP-5 completion gate flips it. The
93
- * production wiring threads `config.nesting.enabled` here; tests pass it
94
- * explicitly. Rejections are NEVER silent (delegate.rejected event). */
95
- nestingEnabled?: boolean;
96
- /** Optional override for the nested-slot budget size (config nesting.maxSlots). */
97
- nestingMaxSlots?: number;
98
- nestingMaxDepth?: number;
99
- nestingTrustedEscalation?: boolean;
100
- /** Global worker semaphore size, used to size the nested-slot budget. */
101
- globalWorkerSemaphore?: number;
102
- /** Test seam / alternative spawner for delegate grandchildren. Production
103
- * uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
104
- grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
105
- /** Resolved model catalog (canonical provider/id strings) for admission-time
106
- * model validation (ADR-5 §7). When omitted, model validation is skipped
107
- * (documented gap — the production wiring must always supply it). */
108
- modelCatalog?: () => string[] | undefined;
109
- /** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
110
- * admission gate. Default false. */
111
- serializeOnPathOverlap?: boolean;
112
- }
78
+ /** Task 10 (mux-surface A1 §5.2): run statuses after which every hello token
79
+ * is by definition stale — the run will never issue work again, so the error
80
+ * is "stale-token" instead of generic auth. NARROWER than
81
+ * TEAM_TERMINAL_RUN_STATUSES on purpose: "blocked" is recoverable, so a
82
+ * blocked run still authenticates normally. */
83
+ const STALE_RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed", "cancelled"]);
113
84
 
114
- /** Per-connection server-side state. */
115
- interface ServerConnection {
116
- socket: net.Socket;
117
- decoder: NdjsonDecoder;
118
- /** Whether the connection has completed `hello` successfully. */
119
- authed: boolean;
120
- /** Run id bound by hello. */
121
- runId?: string;
122
- /** Task id bound by hello. */
123
- taskId?: string;
124
- /** Role bound by hello: orchestrator can steer/msg-send; workers default. */
125
- role?: "orchestrator" | "worker";
126
- /** How the hello token matched the registry (ADR-0 item 6). Derived,
127
- * non-secret metadata recorded at hello time so `wait.*` can reject a
128
- * legacy bare-runId fallback match WITHOUT keeping the raw token on the
129
- * connection (tokens stay confined to the heap-only registry). */
130
- authMatchKind?: "compound" | "runId-fallback";
131
- /** Outbound queue of encoded frames awaiting drain. */
132
- outbound: Buffer[];
133
- /** Set when the queue has hit the cap and a frame was dropped. */
134
- needsResync: boolean;
135
- /** Set when the connection is closing (idempotent). */
136
- closed: boolean;
137
- /** Timer for the hello deadline. */
138
- helloTimer: NodeJS.Timeout | null;
139
- /** Monotonic seq counter for outbound events (diagnostic). */
140
- outboundSeq: number;
141
- }
85
+ /** Default per-connection outbound queue cap (events). */
86
+ const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
142
87
 
143
88
  export class CrewBroker {
144
89
  private readonly options: Required<Pick<CrewBrokerOptions, "sessionId" | "enabled" | "waitMethodsEnabled" | "nestingEnabled">> &
@@ -159,6 +104,12 @@ export class CrewBroker {
159
104
  | "serializeOnPathOverlap"
160
105
  >;
161
106
  private readonly tokens = new BrokerTokenRegistry();
107
+ /** Task 10 (mux-surface A1 §5.2): taskId → the compound token most
108
+ * recently issued for it. revokeTaskToken(taskId) resolves the exact
109
+ * secret through this map — no runId needed (the broker serves many
110
+ * runs; a colliding taskId in another run revokes both, which is the
111
+ * conservative direction). Heap-only like the registry. */
112
+ private readonly taskTokens = new Map<string, BrokerToken>();
162
113
  private server: net.Server | null = null;
163
114
  private resolvedSocketPath: string | null = null;
164
115
  private stopped = false;
@@ -236,7 +187,29 @@ export class CrewBroker {
236
187
  if (typeof runId !== "string" || runId.length === 0) {
237
188
  throw new Error("CrewBroker.issueRunToken: runId must be a non-empty string");
238
189
  }
239
- return this.tokens.issue(runId, taskId);
190
+ const token = this.tokens.issue(runId, taskId);
191
+ // Task 10: track the live secret per taskId so revokeTaskToken can
192
+ // resolve it later. A re-issue overwrites the entry; the OLD token
193
+ // keeps whatever revocation it already had (per-secret, not per-key).
194
+ if (taskId !== undefined) this.taskTokens.set(taskId, token);
195
+ return token;
196
+ }
197
+
198
+ /** Task 10 (mux-surface A1 §5.2): revoke the token issued for `taskId`.
199
+ * The next hello presenting that token — and every subsequent frame on a
200
+ * connection already authenticated with it — is rejected with code
201
+ * "revoked". Open connections are NOT force-closed (A1 enforces at the
202
+ * next frame boundary; re-issue is the A2 remedy). No-op when no token
203
+ * was ever issued for the task. */
204
+ revokeTaskToken(taskId: string): void {
205
+ if (typeof taskId !== "string" || taskId.length === 0) {
206
+ throw new Error("CrewBroker.revokeTaskToken: taskId must be a non-empty string");
207
+ }
208
+ const token = this.taskTokens.get(taskId);
209
+ if (token !== undefined) {
210
+ this.tokens.revokeToken(token);
211
+ this.taskTokens.delete(taskId);
212
+ }
240
213
  }
241
214
 
242
215
  /** Issue the orchestrator token for `runId` (F-06). Cryptographically
@@ -412,6 +385,8 @@ export class CrewBroker {
412
385
  // 3. Clear the token map. This is the single point where the heap
413
386
  // state for runIds is wiped. No persistence to clean up.
414
387
  this.tokens.clear();
388
+ // Task 10: drop the taskId → token index with it.
389
+ this.taskTokens.clear();
415
390
 
416
391
  // 4. Unlink the recorded socket file IF we created it. We never
417
392
  // touch any other path. We also never `process.kill` anything.
@@ -433,9 +408,7 @@ export class CrewBroker {
433
408
  this.resolvedSocketPath = null;
434
409
  }
435
410
 
436
- // ------------------------------------------------------------------------
437
411
  // Connection lifecycle
438
- // ------------------------------------------------------------------------
439
412
 
440
413
  private async handleConnection(sock: net.Socket): Promise<void> {
441
414
  // B1 (Round 14): a connection event queued after stop() must not be
@@ -538,30 +511,11 @@ export class CrewBroker {
538
511
  }
539
512
 
540
513
  /**
541
- * Phase 1.3: push a durable-appended mailbox message to any connected
542
- * recipient for the message's run. Best-effort — silently skips
543
- * 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.
544
516
  */
545
517
  private fanoutMailboxMessage(msg: MailboxMessage): void {
546
- const set = this.connectionsByRun.get(msg.runId);
547
- if (!set || set.size === 0) return;
548
- // Recipient delivery dedup lives in src/prompt/prompt-runtime.ts and is
549
- // keyed by the same message id in this mailbox event and the steering JSONL.
550
- const eventFrame = encodeBrokerFrame({
551
- event: "mailbox.message",
552
- data: { id: msg.id, from: msg.from, to: msg.to, body: msg.body, kind: msg.kind, priority: msg.priority },
553
- seq: 0, // mailbox messages don't carry a TeamEvent seq; dedup by msg.id
554
- });
555
- for (const conn of set) {
556
- if (conn.closed || !conn.authed) continue;
557
- // Recipient filter: deliver to the addressed task, or to all if 'all'.
558
- if (msg.to && msg.to !== "all" && conn.taskId !== msg.to) continue;
559
- try {
560
- this.writeOrQueue(conn, eventFrame, false);
561
- } catch {
562
- /* a slow/dead recipient must not break fanout to others */
563
- }
564
- }
518
+ fanoutMailboxMessage(this.connectionsByRun, { writeOrQueue: (conn, buf, force) => this.writeOrQueue(conn, buf, force) }, msg);
565
519
  }
566
520
 
567
521
  private async handleData(conn: ServerConnection, chunk: Buffer): Promise<void> {
@@ -604,6 +558,24 @@ export class CrewBroker {
604
558
  }
605
559
 
606
560
  // Post-hello: dispatch the known set.
561
+ // Task 10 (mux-surface A1 §5.2): a revoked task token is dead on
562
+ // arrival for EVERY frame, not just hellos — an already-authed
563
+ // connection is rejected here, at the next request boundary, with the
564
+ // connection closed (A1: no mid-stream force-close, so the revoke
565
+ // itself never tears a socket out from under a handler).
566
+ // Fix round 1 (BUG #2): WORKER role only — an orchestrator hello may
567
+ // legitimately name a revoked task as its taskId (T11 degrade: revoke
568
+ // → respawn → steer).
569
+ // Fix round 2 (BUG #3): SECRET-based, not key-based — the check
570
+ // evaluates the digest of the secret this connection authenticated
571
+ // with. Looking up the token currently registered for the key let a
572
+ // revoked-secret connection silently regain full capability once the
573
+ // key was re-issued for the respawn (the connection outlived the
574
+ // revoke → re-issue window while staying quiet).
575
+ if (conn.role === "worker" && conn.authedSecretHash !== undefined && this.tokens.isSecretRevoked(conn.authedSecretHash)) {
576
+ this.sendErrorAndClose(conn, id, "revoked", "token revoked");
577
+ return;
578
+ }
607
579
  switch (method) {
608
580
  case "ping":
609
581
  this.sendResult(conn, id, { pong: true, protocol: BROKER_PROTOCOL });
@@ -680,9 +652,48 @@ export class CrewBroker {
680
652
  // task-scoped-token rule without retaining the secret candidate.
681
653
  const resolved = this.tokens.tokenRoleWithMatchKind(runId, taskId, token);
682
654
  if (resolved === null) {
655
+ // Task 10 (mux-surface A1 §5.2): distinguish a STALE token from a
656
+ // wrong one. A worker re-attaching from a durable surface (broker
657
+ // restarted → heap registry lost, run still on disk) presents a
658
+ // token this broker never issued: when the run exists and the task
659
+ // is real, that is a stale token — reject, but say so, because the
660
+ // A2 remedy is a re-issue, not a retry. An unknown run/task keeps
661
+ // the generic auth error (no disclosure of which id was valid).
662
+ const loaded = loadRunForHello(this.options.cwd, runId);
663
+ if (loaded && (loaded.tasks ?? []).some((t) => t.id === taskId)) {
664
+ this.sendErrorAndClose(
665
+ conn,
666
+ id,
667
+ "stale-token",
668
+ "hello rejected: stale token (run/task exist but this broker did not issue the token; re-issue required)",
669
+ );
670
+ return;
671
+ }
683
672
  this.sendErrorAndClose(conn, id, "auth", "hello rejected");
684
673
  return;
685
674
  }
675
+ // Task 10: the token matches — but an explicitly revoked secret is
676
+ // reported as "revoked" (more specific than stale), and a WORKER token
677
+ // for a TERMINAL run is stale by definition: the run will never issue
678
+ // work again, so a surface worker must not re-attach with it.
679
+ // Orchestrator connections are exempt from BOTH checks: the
680
+ // orchestrator is in-process (same root session) and legitimately
681
+ // talks to the broker after the run completed (late steer, closeout
682
+ // reads) and after a task token was revoked (T11 degrade flow).
683
+ // Fix round 1 (BUG #2): the revoked check keys on (runId, taskId), so
684
+ // without the role guard an orchestrator hello naming a revoked task
685
+ // as its taskId was rejected 'revoked'.
686
+ if (resolved.role === "worker" && this.tokens.isTaskTokenRevoked(runId, taskId)) {
687
+ this.sendErrorAndClose(conn, id, "revoked", "hello rejected: token revoked");
688
+ return;
689
+ }
690
+ if (resolved.role === "worker") {
691
+ const loaded = loadRunForHello(this.options.cwd, runId);
692
+ if (loaded && STALE_RUN_STATUSES.has(loaded.manifest.status)) {
693
+ this.sendErrorAndClose(conn, id, "stale-token", "hello rejected: run is already terminal (stale token)");
694
+ return;
695
+ }
696
+ }
686
697
 
687
698
  // Bounded identity checks. taskId must be a non-empty string.
688
699
  if (typeof taskId !== "string" || taskId.length === 0 || taskId.length > 256) {
@@ -700,6 +711,9 @@ export class CrewBroker {
700
711
  conn.taskId = taskId;
701
712
  conn.role = resolved.role;
702
713
  conn.authMatchKind = resolved.matchKind;
714
+ // Fix round 2 (BUG #3): digest of the authenticated secret for the
715
+ // secret-based frame revocation check below (never the plaintext).
716
+ conn.authedSecretHash = BrokerTokenRegistry.hashToken(token);
703
717
  // Phase 1.3: index by runId for live mailbox fanout.
704
718
  let connsForRun = this.connectionsByRun.get(runId);
705
719
  if (!connsForRun) {
@@ -720,9 +734,14 @@ export class CrewBroker {
720
734
  });
721
735
  }
722
736
 
723
- // ------------------------------------------------------------------------
737
+ /** Task 10 (mux-surface A1 §5.2): best-effort manifest load for the hello
738
+ * decision path. Returns undefined when no cwd is configured or the run
739
+ * is not on disk — callers treat that as "cannot classify" and keep the
740
+ * legacy generic-auth behavior (the heap registry stays the source of
741
+ * truth for authentication). */
742
+ // loadRunForHello: inlined at the 2 call sites (was a 5-line method; M4/WI-4.1).
743
+
724
744
  // Outbound queue + drop-newest + needsResync
725
- // ------------------------------------------------------------------------
726
745
 
727
746
  private sendResult(conn: ServerConnection, id: string, result: unknown): void {
728
747
  this.enqueueFrame(conn, { id, result });
@@ -813,25 +832,35 @@ export class CrewBroker {
813
832
  }
814
833
  }
815
834
 
816
- // ------------------------------------------------------------------------
817
835
  // Phase 1: msg.send + msg.inbox handlers
818
- // ------------------------------------------------------------------------
819
836
 
820
837
  /** Phase 1.1: direct or broadcast mailbox write via the durable append path. */
821
838
  private async handleMsgSend(conn: ServerConnection, id: string, params: unknown): Promise<void> {
822
- if (conn.role !== "orchestrator") {
823
- this.sendError(conn, id, "forbidden", "msg.send requires orchestrator role");
824
- return;
825
- }
826
839
  if (!conn.runId) {
827
840
  this.sendError(conn, id, "auth", "not authed");
828
841
  return;
829
842
  }
843
+ // D9/§15.2 role gate: workers may send messages (for notifying the
844
+ // orchestrator, DMing a sibling, or broadcasting the group) with strictly
845
+ // bounded privileges. Orchestrator role keeps its full prior surface
846
+ // (arrays / "all" / steer kinds / arbitrary recipient sets).
847
+ const isWorker = conn.role === "worker";
848
+ if (conn.role !== "orchestrator" && !isWorker) {
849
+ this.sendError(conn, id, "forbidden", "msg.send requires orchestrator or worker role");
850
+ return;
851
+ }
830
852
  const parsed = parseMsgSendParams(params);
831
853
  if (!parsed) {
832
854
  this.sendError(conn, id, "bad-params", "msg.send: invalid params");
833
855
  return;
834
856
  }
857
+ // Worker constraint (3): kind limited to notify|message. Fire-and-forget
858
+ // `notify` vs inbox-facing `message` — both return immediately to the
859
+ // caller; the distinction is receiver-side handling.
860
+ if (isWorker && parsed.kind !== undefined && parsed.kind !== "notify" && parsed.kind !== "message") {
861
+ this.sendError(conn, id, "bad-params", "msg.send: worker kind must be 'notify' or 'message'");
862
+ return;
863
+ }
835
864
  const bodyJson = safeStringify(parsed.body);
836
865
  if (bodyJson.length > MAX_BROKER_FRAME_BYTES) {
837
866
  this.sendError(conn, id, "oversize-frame", "msg.send: body too large");
@@ -856,136 +885,150 @@ export class CrewBroker {
856
885
  this.sendError(conn, id, "no-manifest", (err as Error).message);
857
886
  return;
858
887
  }
859
- const recipients: string[] = Array.isArray(parsed.to)
860
- ? (parsed.to as string[])
861
- : parsed.to === "all"
862
- ? taskIds
863
- : [parsed.to as string];
864
- if (recipients.length === 0 || recipients.length > 64) {
865
- this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
866
- return;
888
+ // ── Recipient resolution ──────────────────────────────────────────────
889
+ // Each target is {label, mailboxTaskId}: `label` is echoed in the ack
890
+ // and message id, `mailboxTaskId` is the mailbox file the append lands
891
+ // in (undefined = run-level inbox, which the orchestrator consumes).
892
+ let targets: Array<{ label: string; mailboxTaskId: string | undefined }>;
893
+ // Task 5b (spec §15.2 wake): set when a worker addresses the parent —
894
+ // the durable write alone would sit unread in the run-level inbox.
895
+ let sentToParent = false;
896
+ if (isWorker) {
897
+ // Worker constraint (1): from is ALWAYS the authenticated taskId.
898
+ // Worker constraint (2): to is limited to parent | valid sibling
899
+ // taskId | group.
900
+ if (!conn.taskId) {
901
+ this.sendError(conn, id, "forbidden", "msg.send worker requires a task-scoped identity");
902
+ return;
903
+ }
904
+ const to = typeof parsed.to === "string" ? parsed.to : undefined;
905
+ if (to === "parent") {
906
+ // Run-level inbox (taskId undefined) → the orchestrator session.
907
+ targets = [{ label: "parent", mailboxTaskId: undefined }];
908
+ sentToParent = true;
909
+ } else if (to === "group") {
910
+ targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
911
+ } else if (to !== undefined && taskIds.includes(to)) {
912
+ targets = [{ label: to, mailboxTaskId: to }];
913
+ } else {
914
+ this.sendError(conn, id, "forbidden", `msg.send: worker cannot target '${to}'`);
915
+ return;
916
+ }
917
+ if (targets.length === 0 || targets.length > 64) {
918
+ this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
919
+ return;
920
+ }
921
+ } else {
922
+ const recipients: string[] = Array.isArray(parsed.to)
923
+ ? (parsed.to as string[])
924
+ : parsed.to === "all"
925
+ ? taskIds
926
+ : [parsed.to as string];
927
+ if (recipients.length === 0 || recipients.length > 64) {
928
+ this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
929
+ return;
930
+ }
931
+ targets = recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient }));
867
932
  }
868
933
  const messageId = `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
869
- const fromField = conn.taskId ?? conn.runId;
934
+ const fromField = isWorker ? conn.taskId! : (conn.taskId ?? conn.runId);
870
935
  let durable = false;
871
936
  try {
872
- for (const recipient of recipients) {
873
- await appendMailboxMessageAsync(manifest, {
874
- id: `${messageId}_${recipient}`,
875
- direction: "inbox",
876
- from: fromField,
877
- to: recipient,
878
- taskId: recipient,
879
- body: bodyJson,
880
- kind: parsed.kind ?? "message",
881
- priority: parsed.priority ?? "normal",
882
- deliveryMode: "next_turn",
883
- replyTo: parsed.replyTo,
884
- });
937
+ // PERF (2026-08-24): to:"all" with 50 tasks used to run 50 sequential
938
+ // awaited locked appends (~70 syscalls + 2 fsync each) while the
939
+ // connection's frames queued behind it. Chunked fan-out — independent
940
+ // mailbox files append concurrently; delivery.json stays serialized by
941
+ // its own lock.
942
+ const CHUNK = 8;
943
+ for (let i = 0; i < targets.length; i += CHUNK) {
944
+ const results = await Promise.allSettled(
945
+ targets.slice(i, i + CHUNK).map((target) =>
946
+ appendMailboxMessageAsync(manifest, {
947
+ id: `${messageId}_${target.label}`,
948
+ direction: "inbox",
949
+ from: fromField,
950
+ to: target.label,
951
+ taskId: target.mailboxTaskId,
952
+ body: bodyJson,
953
+ kind: parsed.kind ?? "message",
954
+ priority: parsed.priority ?? "normal",
955
+ deliveryMode: "next_turn",
956
+ replyTo: parsed.replyTo,
957
+ }),
958
+ ),
959
+ );
960
+ const failure = results.find((r) => r.status === "rejected") as PromiseRejectedResult | undefined;
961
+ if (failure) throw failure.reason;
885
962
  }
886
963
  durable = true;
887
964
  } catch (err) {
888
965
  this.sendError(conn, id, "durable-failed", (err as Error).message);
889
966
  return;
890
967
  }
968
+ // Task 5b (spec §15.2 wake): a worker message addressed to the parent
969
+ // appends a bounded `worker.message` run event so the host-side event
970
+ // bus (sidebar/widget refresh) and any live orchestrator connection
971
+ // wake up. Only kind/subject are recorded — NEVER the body, to keep the
972
+ // append-only event log lean. Awaited before the ack so the wake signal
973
+ // is durable by the time the caller proceeds; failure is non-fatal (the
974
+ // mailbox write above is the source of truth).
975
+ if (sentToParent) {
976
+ try {
977
+ await appendEventAsync(manifest.eventsPath, {
978
+ type: "worker.message",
979
+ runId: manifest.runId,
980
+ taskId: fromField,
981
+ data: {
982
+ to: "parent",
983
+ kind: parsed.kind ?? "message",
984
+ ...(parsed.subject !== undefined ? { subject: parsed.subject } : {}),
985
+ },
986
+ });
987
+ } catch (err) {
988
+ logInternalError(
989
+ "crew-broker.msg.worker-message-event",
990
+ err instanceof Error ? err : new Error(String(err)),
991
+ `runId=${conn.runId}`,
992
+ );
993
+ }
994
+ }
891
995
  this.sendResult(conn, id, {
892
996
  messageId,
893
- recipientCount: recipients.length,
997
+ recipientCount: targets.length,
894
998
  durableStatus: durable ? "ok" : "failed",
895
999
  liveDeliveryStatus: "ok",
896
1000
  });
897
1001
  }
898
1002
 
899
- /** 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). */
900
1005
  private async handleMsgInbox(conn: ServerConnection, id: string, params: unknown): Promise<void> {
901
- if (!conn.runId) {
902
- this.sendError(conn, id, "auth", "not authed");
903
- return;
904
- }
905
- const parsed = parseMsgInboxParams(params);
906
- if (!parsed) {
907
- this.sendError(conn, id, "bad-params", "msg.inbox: invalid params");
908
- return;
909
- }
910
- const cwd = this.options.cwd;
911
- if (!cwd) {
912
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
913
- return;
914
- }
915
- let manifest: Parameters<typeof readMailbox>[0];
916
- try {
917
- const loaded = loadRunManifestById(cwd, conn.runId);
918
- if (!loaded) {
919
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
920
- return;
921
- }
922
- manifest = loaded.manifest;
923
- } catch (err) {
924
- this.sendError(conn, id, "no-manifest", (err as Error).message);
925
- return;
926
- }
927
- const limit = Math.min(Math.max(parsed.limit ?? 100, 1), 1000);
928
- const taskId = conn.taskId ?? undefined;
929
- const all = readMailbox(manifest, "inbox", taskId);
930
- const filtered = all.filter((m) => m.status !== "acknowledged");
931
- const offset = parsed.cursor ? parseInt(parsed.cursor, 10) || 0 : 0;
932
- const page = filtered.slice(offset, offset + limit);
933
- const nextOffset = offset + page.length;
934
- const hasMore = nextOffset < filtered.length;
935
- this.sendResult(conn, id, {
936
- messages: page,
937
- nextCursor: hasMore ? String(nextOffset) : undefined,
938
- hasMore,
939
- total: filtered.length,
940
- });
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
+ );
941
1016
  }
942
1017
 
943
- /**
944
- * Phase 1.5: events.since — bounded replay of structured events with seq >
945
- * sinceSeq from the durable log. Used by clients to resync after a missed
946
- * live frame (e.g. after a queue overflow or reconnect). Reuses the same
947
- * readEventsCursor + seq semantics as runEventBus.onWithReplay.
948
- */
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). */
949
1021
  private async handleEventsSince(conn: ServerConnection, id: string, params: unknown): Promise<void> {
950
- if (!conn.runId) {
951
- this.sendError(conn, id, "auth", "not authed");
952
- return;
953
- }
954
- const cwd = this.options.cwd;
955
- if (!cwd) {
956
- this.sendError(conn, id, "no-manifest", "broker has no cwd configured");
957
- return;
958
- }
959
- let eventsPath: string;
960
- try {
961
- const loaded = loadRunManifestById(cwd, conn.runId);
962
- if (!loaded) {
963
- this.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
964
- return;
965
- }
966
- eventsPath = loaded.manifest.eventsPath;
967
- } catch (err) {
968
- this.sendError(conn, id, "no-manifest", (err as Error).message);
969
- return;
970
- }
971
- const v = params && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : {};
972
- const sinceSeq = typeof v.sinceSeq === "number" && Number.isFinite(v.sinceSeq) ? Math.max(0, Math.floor(v.sinceSeq)) : 0;
973
- const limit = typeof v.limit === "number" && Number.isFinite(v.limit) ? Math.min(Math.max(1, Math.floor(v.limit)), 1000) : 1000;
974
- try {
975
- const result = readEventsCursor(eventsPath, { sinceSeq, limit });
976
- // hasMore is true iff the total filtered count exceeds the page we
977
- // returned. When `total === events.length` we are at the exact end
978
- // of the stream (caller will discover this on the next call when
979
- // `nextSeq` is unchanged from `sinceSeq`).
980
- const hasMore = result.total > result.events.length;
981
- this.sendResult(conn, id, {
982
- events: result.events,
983
- nextSeq: result.nextSeq,
984
- hasMore,
985
- });
986
- } catch (err) {
987
- this.sendError(conn, id, "replay-failed", (err as Error).message);
988
- }
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
+ );
989
1032
  }
990
1033
 
991
1034
  /**
@@ -1295,39 +1338,19 @@ export class CrewBroker {
1295
1338
  }
1296
1339
  }
1297
1340
 
1298
- // ------------------------------------------------------------------------
1299
1341
  // WP-2/R2: wait.request / wait.resolve (ADR-0 2026-08-17-waiting-producer-ask)
1300
- // ------------------------------------------------------------------------
1301
1342
 
1302
- /** Shared auth for wait.*: worker role + task-scoped (compound-key) token
1303
- * ONLY (ADR item 6). A legacy bare-runId fallback match is REJECTED with
1304
- * a migrate hint; the orchestrator token is rejected by role. Returns the
1305
- * error to send, or null when auth passes. */
1343
+ // waitAuthError: protocol/wait-auth.ts (M4/WI-4.1).
1306
1344
  private waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
1307
- if (conn.role !== "worker" || conn.authMatchKind === undefined) {
1308
- return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
1309
- }
1310
- if (conn.authMatchKind !== "compound") {
1311
- return {
1312
- code: "forbidden",
1313
- message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
1314
- };
1315
- }
1316
- return null;
1345
+ return waitAuthError(conn);
1317
1346
  }
1318
1347
 
1319
- /** ADR item 7: a disabled-gate rejection MUST leave a durable trace in
1320
- * events.jsonl — the gate fails CLOSED but never SILENTLY. Fire-and-forget
1321
- * async append (broker handlers must not block the event loop on the sync
1322
- * 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.)
1323
1351
  // T3/R5 (ADR-5): delegate.request — governed-nesting admission + background
1324
1352
  // grandchild spawn with durable mailbox delivery (WP-5 step 5).
1325
- private getDelegateNestedSlots(): NestedSlotBudget {
1326
- if (!this.nestedSlots) {
1327
- this.nestedSlots = new NestedSlotBudget(this.options.globalWorkerSemaphore ?? 4, this.options.nestingMaxSlots);
1328
- }
1329
- return this.nestedSlots;
1330
- }
1353
+ // getDelegateNestedSlots: inlined at 4 call sites (5-line method; M4/WI-4.1).
1331
1354
 
1332
1355
  private recordDelegateEvent(
1333
1356
  manifest: { eventsPath: string; runId: string },
@@ -1341,15 +1364,7 @@ export class CrewBroker {
1341
1364
  taskId: string,
1342
1365
  data: Record<string, unknown>,
1343
1366
  ): void {
1344
- void appendEventAsync(manifest.eventsPath, {
1345
- type,
1346
- runId: manifest.runId,
1347
- taskId,
1348
- message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
1349
- data,
1350
- }).catch((err) =>
1351
- logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
1352
- );
1367
+ recordDelegateEvent(manifest, type, taskId, data);
1353
1368
  }
1354
1369
 
1355
1370
  private async handleDelegateRequest(conn: ServerConnection, id: string, params: unknown): Promise<void> {
@@ -1401,17 +1416,19 @@ export class CrewBroker {
1401
1416
  this.sendError(conn, id, "no-manifest", (err as Error).message);
1402
1417
  return;
1403
1418
  }
1404
- // Capability gate (ADR-5 §10): fail-closed, NEVER silent.
1419
+ // Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
1420
+ // flip the DEFAULT is true, so reaching this branch means the user
1421
+ // closed the surface via config — the message points back at the knob.
1405
1422
  if (this.options.nestingEnabled !== true) {
1406
1423
  this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
1407
1424
  reason: "nesting-disabled",
1408
- policy: "nesting.enabled=false (fail-closed default)",
1425
+ policy: "nesting.enabled=false (user config; default is true since D8)",
1409
1426
  });
1410
1427
  this.sendError(
1411
1428
  conn,
1412
1429
  id,
1413
1430
  "policy-disabled",
1414
- "delegate is disabled: nesting.enabled=false (fail-closed default; delegate.rejected recorded in events.jsonl)",
1431
+ "delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
1415
1432
  );
1416
1433
  return;
1417
1434
  }
@@ -1453,15 +1470,18 @@ export class CrewBroker {
1453
1470
  t.cwd === task.cwd,
1454
1471
  ).length;
1455
1472
  const decision = evaluateDelegateAdmission({
1456
- nestingEnabled: true, // flag already checked above
1457
- maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 2 (ADR-5 §3)
1473
+ maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
1458
1474
  parentTask: {
1459
1475
  taskId: parentTaskId,
1460
1476
  role: task.role,
1461
1477
  ...(task.depth !== undefined ? { depth: task.depth } : {}),
1462
1478
  ...(task.allocation !== undefined ? { allocation: task.allocation } : {}),
1463
1479
  },
1464
- 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(),
1465
1485
  requested,
1466
1486
  ...(effectiveCatalog !== undefined ? { modelCatalog: effectiveCatalog } : {}),
1467
1487
  // ADR-5 §12: the delegate surface is an escalation — trusted only by the
@@ -1481,11 +1501,28 @@ export class CrewBroker {
1481
1501
  return { code: "policy-denied" as const, message: decision.message ?? decision.reason ?? "delegate denied" };
1482
1502
  }
1483
1503
  // Slot acquisition INSIDE the lock (no reserve-then-race refund window).
1484
- 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
+ ) {
1485
1511
  this.recordDelegateEvent(fresh.manifest, "delegate.rejected", parentTaskId, { subId, reason: "slots-exhausted" });
1486
1512
  return {
1487
1513
  code: "policy-denied" as const,
1488
- 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
+ }`,
1489
1526
  };
1490
1527
  }
1491
1528
  // Reserve the requested budget pessimistically (ADR-5 §5): tokensSpent
@@ -1676,7 +1713,11 @@ export class CrewBroker {
1676
1713
  }
1677
1714
  }
1678
1715
  } finally {
1679
- 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);
1680
1721
  }
1681
1722
  this.recordDelegateEvent(loaded.manifest, outcome.timedOut ? "delegate.timed_out" : "delegate.completed", parentTaskId, {
1682
1723
  subId,
@@ -1686,16 +1727,7 @@ export class CrewBroker {
1686
1727
  }
1687
1728
 
1688
1729
  private recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
1689
- const runId = manifest.runId;
1690
- void appendEventAsync(manifest.eventsPath, {
1691
- type: "policy.action",
1692
- runId,
1693
- taskId,
1694
- message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
1695
- data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
1696
- }).catch((err) =>
1697
- logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
1698
- );
1730
+ recordWaitPolicyRejection(manifest, taskId, method);
1699
1731
  }
1700
1732
 
1701
1733
  /** WP-2/R2 step 4: park the calling task while its `ask` tool awaits a
@@ -1954,155 +1986,14 @@ export class CrewBroker {
1954
1986
  // Type guards (no `any`)
1955
1987
  // ============================================================================
1956
1988
 
1957
- function isRequestObject(value: unknown): value is { id: string; method: string; params: unknown } {
1958
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1959
- const v = value as Record<string, unknown>;
1960
- if (typeof v.id !== "string" || v.id.length === 0 || v.id.length > 256) return false;
1961
- if (typeof v.method !== "string" || v.method.length === 0 || v.method.length > 64) return false;
1962
- // Method names are restricted to a small safe charset. This guards against
1963
- // odd inputs (control chars, very long names) reaching the dispatcher.
1964
- if (!/^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(v.method)) return false;
1965
- // params may be anything (validated per-method), but not undefined-shaped.
1966
- return "params" in v;
1967
- }
1968
-
1969
- function isHelloParams(value: unknown): value is {
1970
- protocol: number;
1971
- runId: string;
1972
- taskId: string;
1973
- token: string;
1974
- role?: string;
1975
- } {
1976
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1977
- const v = value as Record<string, unknown>;
1978
- if (v.protocol !== BROKER_PROTOCOL) {
1979
- // Force exact-type comparison (must be the number 1, not "1").
1980
- if (typeof v.protocol !== "number" || !Number.isInteger(v.protocol)) return false;
1981
- }
1982
- if (typeof v.runId !== "string" || v.runId.length === 0 || v.runId.length > 256) return false;
1983
- if (typeof v.taskId !== "string" || v.taskId.length === 0 || v.taskId.length > 256) return false;
1984
- if (typeof v.token !== "string" || v.token.length === 0 || v.token.length > 256) return false;
1985
- return true;
1986
- }
1987
-
1988
- // ============================================================================
1989
- // Phase 1 parameter parsers (module-level; no `any`)
1990
- // ============================================================================
1991
-
1992
- interface MsgSendParams {
1993
- to: string | string[] | "all";
1994
- body: unknown;
1995
- kind?: MailboxMessageKind;
1996
- priority?: MailboxMessagePriority;
1997
- replyTo?: string;
1998
- }
1999
-
2000
- function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
2001
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2002
- const v = value as Record<string, unknown>;
2003
- const to = v.to;
2004
- if (typeof to !== "string" && !Array.isArray(to)) return undefined;
2005
- if (Array.isArray(to) && !to.every((s) => typeof s === "string" && s.length > 0)) return undefined;
2006
- if (typeof to === "string" && to.length === 0) return undefined;
2007
- if (v.body === undefined) return undefined;
2008
- const kind = v.kind as MailboxMessageKind | undefined;
2009
- if (kind !== undefined && !["message", "steer", "follow-up", "response", "group_join"].includes(kind)) {
2010
- return undefined;
2011
- }
2012
- const priority = v.priority as MailboxMessagePriority | undefined;
2013
- if (priority !== undefined && !["urgent", "normal", "low"].includes(priority)) {
2014
- return undefined;
2015
- }
2016
- const replyTo = typeof v.replyTo === "string" ? v.replyTo : undefined;
2017
- return { to: to as string | string[] | "all", body: v.body, kind, priority, replyTo };
2018
- }
2019
-
2020
- interface MsgInboxParams {
2021
- limit?: number;
2022
- cursor?: string;
2023
- }
2024
-
2025
- function parseMsgInboxParams(value: unknown): MsgInboxParams | undefined {
2026
- if (value === undefined || value === null) return { limit: 100, cursor: undefined };
2027
- if (typeof value !== "object" || Array.isArray(value)) return undefined;
2028
- const v = value as Record<string, unknown>;
2029
- const limit = v.limit;
2030
- if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
2031
- return undefined;
2032
- }
2033
- const cursor = v.cursor;
2034
- if (cursor !== undefined && typeof cursor !== "string") return undefined;
2035
- return { limit: limit as number | undefined, cursor: cursor as string | undefined };
2036
- }
2037
-
2038
- function safeStringify(value: unknown): string {
2039
- try {
2040
- return JSON.stringify(value) ?? "{}";
2041
- } catch {
2042
- return "{}";
2043
- }
2044
- }
2045
-
2046
- // ============================================================================
2047
- // WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
2048
- // ============================================================================
2049
-
2050
- /** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
2051
- * timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
2052
- * amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
2053
- const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
2054
- /** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
2055
- const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
2056
- /** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
2057
- const WAIT_QUESTION_MAX_CHARS = 8192;
2058
- /** Bounded answer-choice list: at most 16 options, 256 chars each. */
2059
- const WAIT_OPTIONS_MAX = 16;
2060
- const WAIT_OPTION_MAX_CHARS = 256;
2061
-
2062
- interface WaitRequestParams {
2063
- to: string;
2064
- question: string;
2065
- options?: string[];
2066
- timeoutSec?: number;
2067
- }
2068
-
2069
- function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
2070
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2071
- const v = value as Record<string, unknown>;
2072
- if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
2073
- if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
2074
- return undefined;
2075
- }
2076
- let options: string[] | undefined;
2077
- if (v.options !== undefined) {
2078
- if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
2079
- for (const o of v.options) {
2080
- if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
2081
- }
2082
- options = v.options as string[];
2083
- }
2084
- // timeoutSec is clamped server-side in the handler (max 3600); the parser
2085
- // only rejects non-finite values. Non-positive values clamp to 1s.
2086
- if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
2087
- return undefined;
2088
- }
2089
- return {
2090
- to: v.to,
2091
- question: v.question,
2092
- options,
2093
- timeoutSec: v.timeoutSec as number | undefined,
2094
- };
2095
- }
2096
-
2097
- interface WaitResolveParams {
2098
- to: string;
2099
- questionId: string;
2100
- }
2101
-
2102
- function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
2103
- if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
2104
- const v = value as Record<string, unknown>;
2105
- if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
2106
- if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
2107
- return { to: v.to, questionId: v.questionId };
2108
- }
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
+ */