pi-crew 0.10.2 → 0.10.3

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 (79) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/dist/index.mjs +98 -307
  3. package/package.json +2 -1
  4. package/schema.json +11 -0
  5. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +6 -2
  6. package/skills/real-test-pi-crew/SKILL.md +278 -79
  7. package/src/config/config-merge.ts +11 -1
  8. package/src/config/config-validation.ts +40 -1
  9. package/src/config/config.ts +28 -6
  10. package/src/config/defaults.ts +35 -10
  11. package/src/config/env-vars.ts +27 -2
  12. package/src/config/types.ts +36 -0
  13. package/src/extension/registration/lifecycle-handlers.ts +40 -9
  14. package/src/extension/registration/team-tool.ts +53 -5
  15. package/src/extension/team-tool/doctor.ts +364 -7
  16. package/src/extension/team-tool/handle-settings.ts +19 -0
  17. package/src/extension/team-tool/inspect.ts +10 -2
  18. package/src/extension/team-tool/status.ts +7 -0
  19. package/src/extension/team-tool.ts +35 -2
  20. package/src/hooks/registry.ts +59 -56
  21. package/src/prompt/inbox-poll.ts +90 -0
  22. package/src/prompt/message-tool.ts +166 -0
  23. package/src/prompt/prompt-runtime.ts +201 -18
  24. package/src/prompt/surface-worker.ts +720 -0
  25. package/src/prompt/worker-events-channel.ts +49 -3
  26. package/src/runtime/async-runner.ts +29 -1
  27. package/src/runtime/background-runner.ts +13 -7
  28. package/src/runtime/broker/broker-issuer.ts +27 -2
  29. package/src/runtime/broker/crew-broker-tokens.ts +56 -4
  30. package/src/runtime/broker/crew-broker.ts +261 -41
  31. package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
  32. package/src/runtime/child-pi/child-pi-streams.ts +9 -1
  33. package/src/runtime/child-pi/child-pi.ts +353 -5
  34. package/src/runtime/crew-agent-records.ts +13 -1
  35. package/src/runtime/dispatch-batch.ts +12 -1
  36. package/src/runtime/event-log-tail-source.ts +374 -0
  37. package/src/runtime/finalize-run.ts +4 -0
  38. package/src/runtime/live-session/live-agent-manager.ts +34 -1
  39. package/src/runtime/live-session/live-control-realtime.ts +10 -0
  40. package/src/runtime/live-session/live-session-runtime.ts +47 -27
  41. package/src/runtime/manifest-cache.ts +128 -17
  42. package/src/runtime/model/pi-args.ts +54 -65
  43. package/src/runtime/output/sidechain-output.ts +61 -6
  44. package/src/runtime/process/proc-stat.ts +46 -0
  45. package/src/runtime/process/zombie-scanner.ts +32 -19
  46. package/src/runtime/spawn-policy.ts +27 -41
  47. package/src/runtime/surface/degrade.ts +776 -0
  48. package/src/runtime/surface/herdr-provider.ts +546 -0
  49. package/src/runtime/surface/launch-script.ts +172 -0
  50. package/src/runtime/surface/resolve-surface.ts +274 -0
  51. package/src/runtime/surface/surface-provider.ts +129 -0
  52. package/src/runtime/surface/surface-spawn.ts +475 -0
  53. package/src/runtime/surface/tmux-provider.ts +400 -0
  54. package/src/runtime/task-runner/child-executor.ts +47 -0
  55. package/src/runtime/task-runner/post-execution.ts +57 -2
  56. package/src/runtime/task-runner/prompt-builder.ts +1 -0
  57. package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
  58. package/src/runtime/task-runner/state-helpers.ts +54 -30
  59. package/src/runtime/task-runner.ts +4 -2
  60. package/src/runtime/team-runner.ts +101 -0
  61. package/src/schema/config-schema.ts +24 -0
  62. package/src/state/atomic-write.ts +219 -40
  63. package/src/state/coordination/locks.ts +7 -5
  64. package/src/state/coordination/mailbox.ts +56 -10
  65. package/src/state/event-log/cursor.ts +413 -23
  66. package/src/state/event-log/event-log.ts +120 -113
  67. package/src/state/event-log/sequence-cache.ts +21 -3
  68. package/src/state/stores/state-store.ts +98 -6
  69. package/src/state/types.ts +51 -0
  70. package/src/ui/inline-panel/agent-pane.ts +3 -0
  71. package/src/ui/render-diff.ts +16 -8
  72. package/src/ui/run-dashboard.ts +87 -42
  73. package/src/ui/run-event-bus.ts +10 -1
  74. package/src/ui/run-snapshot-cache.ts +83 -35
  75. package/src/ui/transcript-cache.ts +101 -13
  76. package/src/ui/transcript-viewer.ts +92 -24
  77. package/src/ui/widget/index.ts +32 -8
  78. package/src/utils/visual.ts +43 -0
  79. package/src/worktree/worktree-manager.ts +65 -4
@@ -35,7 +35,7 @@ import {
35
35
  } from "../../state/coordination/mailbox.ts";
36
36
  import { appendEventAsync, readEventsCursor } from "../../state/event-log/event-log.ts";
37
37
  import { loadRunManifestById, saveRunManifest, saveRunTasks } from "../../state/stores/state-store.ts";
38
- import type { TeamTaskState } from "../../state/types.ts";
38
+ import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
39
39
  import { runEventBus } from "../../ui/run-event-bus.ts";
40
40
  import { logInternalError } from "../../utils/internal-error.ts";
41
41
  import { BrokerError, encodeBrokerFrame, MAX_BROKER_FRAME_BYTES, NdjsonDecoder } from "../../utils/ndjson.ts";
@@ -46,7 +46,7 @@ import { type GrandchildSpawnInput, type GrandchildSpawnResult, spawnDelegateGra
46
46
  import { resolveCrewMaxDepth } from "../model/pi-args.ts";
47
47
  import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
48
48
  import { evaluateDelegateAdmission } from "../spawn-policy.ts";
49
- import { BrokerTokenRegistry } from "./crew-broker-tokens.ts";
49
+ import { type BrokerToken, BrokerTokenRegistry } from "./crew-broker-tokens.ts";
50
50
  import { WaitStatusCache } from "./wait-status-cache.ts";
51
51
 
52
52
  /** Protocol version negotiated at `hello` time. Bump on breaking change. */
@@ -56,6 +56,13 @@ const BROKER_PROTOCOL = 1;
56
56
  * generic auth/protocol code. */
57
57
  const HELLO_DEADLINE_MS = 1_000;
58
58
 
59
+ /** Task 10 (mux-surface A1 §5.2): run statuses after which every hello token
60
+ * is by definition stale — the run will never issue work again, so the error
61
+ * is "stale-token" instead of generic auth. NARROWER than
62
+ * TEAM_TERMINAL_RUN_STATUSES on purpose: "blocked" is recoverable, so a
63
+ * blocked run still authenticates normally. */
64
+ const STALE_RUN_STATUSES: ReadonlySet<string> = new Set(["completed", "failed", "cancelled"]);
65
+
59
66
  /** Default per-connection outbound queue cap (events). */
60
67
  const DEFAULT_OUTBOUND_QUEUE_CAP = 256;
61
68
 
@@ -89,9 +96,11 @@ export interface CrewBrokerOptions {
89
96
  * `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
90
97
  waitMethodsEnabled?: boolean;
91
98
  /** 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). */
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). */
95
104
  nestingEnabled?: boolean;
96
105
  /** Optional override for the nested-slot budget size (config nesting.maxSlots). */
97
106
  nestingMaxSlots?: number;
@@ -128,6 +137,12 @@ interface ServerConnection {
128
137
  * legacy bare-runId fallback match WITHOUT keeping the raw token on the
129
138
  * connection (tokens stay confined to the heap-only registry). */
130
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;
131
146
  /** Outbound queue of encoded frames awaiting drain. */
132
147
  outbound: Buffer[];
133
148
  /** Set when the queue has hit the cap and a frame was dropped. */
@@ -159,6 +174,12 @@ export class CrewBroker {
159
174
  | "serializeOnPathOverlap"
160
175
  >;
161
176
  private readonly tokens = new BrokerTokenRegistry();
177
+ /** Task 10 (mux-surface A1 §5.2): taskId → the compound token most
178
+ * recently issued for it. revokeTaskToken(taskId) resolves the exact
179
+ * secret through this map — no runId needed (the broker serves many
180
+ * runs; a colliding taskId in another run revokes both, which is the
181
+ * conservative direction). Heap-only like the registry. */
182
+ private readonly taskTokens = new Map<string, BrokerToken>();
162
183
  private server: net.Server | null = null;
163
184
  private resolvedSocketPath: string | null = null;
164
185
  private stopped = false;
@@ -236,7 +257,29 @@ export class CrewBroker {
236
257
  if (typeof runId !== "string" || runId.length === 0) {
237
258
  throw new Error("CrewBroker.issueRunToken: runId must be a non-empty string");
238
259
  }
239
- return this.tokens.issue(runId, taskId);
260
+ const token = this.tokens.issue(runId, taskId);
261
+ // Task 10: track the live secret per taskId so revokeTaskToken can
262
+ // resolve it later. A re-issue overwrites the entry; the OLD token
263
+ // keeps whatever revocation it already had (per-secret, not per-key).
264
+ if (taskId !== undefined) this.taskTokens.set(taskId, token);
265
+ return token;
266
+ }
267
+
268
+ /** Task 10 (mux-surface A1 §5.2): revoke the token issued for `taskId`.
269
+ * The next hello presenting that token — and every subsequent frame on a
270
+ * connection already authenticated with it — is rejected with code
271
+ * "revoked". Open connections are NOT force-closed (A1 enforces at the
272
+ * next frame boundary; re-issue is the A2 remedy). No-op when no token
273
+ * was ever issued for the task. */
274
+ revokeTaskToken(taskId: string): void {
275
+ if (typeof taskId !== "string" || taskId.length === 0) {
276
+ throw new Error("CrewBroker.revokeTaskToken: taskId must be a non-empty string");
277
+ }
278
+ const token = this.taskTokens.get(taskId);
279
+ if (token !== undefined) {
280
+ this.tokens.revokeToken(token);
281
+ this.taskTokens.delete(taskId);
282
+ }
240
283
  }
241
284
 
242
285
  /** Issue the orchestrator token for `runId` (F-06). Cryptographically
@@ -412,6 +455,8 @@ export class CrewBroker {
412
455
  // 3. Clear the token map. This is the single point where the heap
413
456
  // state for runIds is wiped. No persistence to clean up.
414
457
  this.tokens.clear();
458
+ // Task 10: drop the taskId → token index with it.
459
+ this.taskTokens.clear();
415
460
 
416
461
  // 4. Unlink the recorded socket file IF we created it. We never
417
462
  // touch any other path. We also never `process.kill` anything.
@@ -555,7 +600,15 @@ export class CrewBroker {
555
600
  for (const conn of set) {
556
601
  if (conn.closed || !conn.authed) continue;
557
602
  // Recipient filter: deliver to the addressed task, or to all if 'all'.
558
- if (msg.to && msg.to !== "all" && conn.taskId !== msg.to) continue;
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;
559
612
  try {
560
613
  this.writeOrQueue(conn, eventFrame, false);
561
614
  } catch {
@@ -604,6 +657,24 @@ export class CrewBroker {
604
657
  }
605
658
 
606
659
  // Post-hello: dispatch the known set.
660
+ // Task 10 (mux-surface A1 §5.2): a revoked task token is dead on
661
+ // arrival for EVERY frame, not just hellos — an already-authed
662
+ // connection is rejected here, at the next request boundary, with the
663
+ // connection closed (A1: no mid-stream force-close, so the revoke
664
+ // itself never tears a socket out from under a handler).
665
+ // Fix round 1 (BUG #2): WORKER role only — an orchestrator hello may
666
+ // legitimately name a revoked task as its taskId (T11 degrade: revoke
667
+ // → respawn → steer).
668
+ // Fix round 2 (BUG #3): SECRET-based, not key-based — the check
669
+ // evaluates the digest of the secret this connection authenticated
670
+ // with. Looking up the token currently registered for the key let a
671
+ // revoked-secret connection silently regain full capability once the
672
+ // key was re-issued for the respawn (the connection outlived the
673
+ // revoke → re-issue window while staying quiet).
674
+ if (conn.role === "worker" && conn.authedSecretHash !== undefined && this.tokens.isSecretRevoked(conn.authedSecretHash)) {
675
+ this.sendErrorAndClose(conn, id, "revoked", "token revoked");
676
+ return;
677
+ }
607
678
  switch (method) {
608
679
  case "ping":
609
680
  this.sendResult(conn, id, { pong: true, protocol: BROKER_PROTOCOL });
@@ -680,9 +751,48 @@ export class CrewBroker {
680
751
  // task-scoped-token rule without retaining the secret candidate.
681
752
  const resolved = this.tokens.tokenRoleWithMatchKind(runId, taskId, token);
682
753
  if (resolved === null) {
754
+ // Task 10 (mux-surface A1 §5.2): distinguish a STALE token from a
755
+ // wrong one. A worker re-attaching from a durable surface (broker
756
+ // restarted → heap registry lost, run still on disk) presents a
757
+ // token this broker never issued: when the run exists and the task
758
+ // is real, that is a stale token — reject, but say so, because the
759
+ // A2 remedy is a re-issue, not a retry. An unknown run/task keeps
760
+ // the generic auth error (no disclosure of which id was valid).
761
+ const loaded = this.loadRunForHello(runId);
762
+ if (loaded && (loaded.tasks ?? []).some((t) => t.id === taskId)) {
763
+ this.sendErrorAndClose(
764
+ conn,
765
+ id,
766
+ "stale-token",
767
+ "hello rejected: stale token (run/task exist but this broker did not issue the token; re-issue required)",
768
+ );
769
+ return;
770
+ }
683
771
  this.sendErrorAndClose(conn, id, "auth", "hello rejected");
684
772
  return;
685
773
  }
774
+ // Task 10: the token matches — but an explicitly revoked secret is
775
+ // reported as "revoked" (more specific than stale), and a WORKER token
776
+ // for a TERMINAL run is stale by definition: the run will never issue
777
+ // work again, so a surface worker must not re-attach with it.
778
+ // Orchestrator connections are exempt from BOTH checks: the
779
+ // orchestrator is in-process (same root session) and legitimately
780
+ // talks to the broker after the run completed (late steer, closeout
781
+ // reads) and after a task token was revoked (T11 degrade flow).
782
+ // Fix round 1 (BUG #2): the revoked check keys on (runId, taskId), so
783
+ // without the role guard an orchestrator hello naming a revoked task
784
+ // as its taskId was rejected 'revoked'.
785
+ if (resolved.role === "worker" && this.tokens.isTaskTokenRevoked(runId, taskId)) {
786
+ this.sendErrorAndClose(conn, id, "revoked", "hello rejected: token revoked");
787
+ return;
788
+ }
789
+ if (resolved.role === "worker") {
790
+ const loaded = this.loadRunForHello(runId);
791
+ if (loaded && STALE_RUN_STATUSES.has(loaded.manifest.status)) {
792
+ this.sendErrorAndClose(conn, id, "stale-token", "hello rejected: run is already terminal (stale token)");
793
+ return;
794
+ }
795
+ }
686
796
 
687
797
  // Bounded identity checks. taskId must be a non-empty string.
688
798
  if (typeof taskId !== "string" || taskId.length === 0 || taskId.length > 256) {
@@ -700,6 +810,9 @@ export class CrewBroker {
700
810
  conn.taskId = taskId;
701
811
  conn.role = resolved.role;
702
812
  conn.authMatchKind = resolved.matchKind;
813
+ // Fix round 2 (BUG #3): digest of the authenticated secret for the
814
+ // secret-based frame revocation check below (never the plaintext).
815
+ conn.authedSecretHash = BrokerTokenRegistry.hashToken(token);
703
816
  // Phase 1.3: index by runId for live mailbox fanout.
704
817
  let connsForRun = this.connectionsByRun.get(runId);
705
818
  if (!connsForRun) {
@@ -720,6 +833,21 @@ export class CrewBroker {
720
833
  });
721
834
  }
722
835
 
836
+ /** Task 10 (mux-surface A1 §5.2): best-effort manifest load for the hello
837
+ * decision path. Returns undefined when no cwd is configured or the run
838
+ * is not on disk — callers treat that as "cannot classify" and keep the
839
+ * legacy generic-auth behavior (the heap registry stays the source of
840
+ * 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
+ }
850
+
723
851
  // ------------------------------------------------------------------------
724
852
  // Outbound queue + drop-newest + needsResync
725
853
  // ------------------------------------------------------------------------
@@ -819,19 +947,31 @@ export class CrewBroker {
819
947
 
820
948
  /** Phase 1.1: direct or broadcast mailbox write via the durable append path. */
821
949
  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
950
  if (!conn.runId) {
827
951
  this.sendError(conn, id, "auth", "not authed");
828
952
  return;
829
953
  }
954
+ // D9/§15.2 role gate: workers may send messages (for notifying the
955
+ // orchestrator, DMing a sibling, or broadcasting the group) with strictly
956
+ // bounded privileges. Orchestrator role keeps its full prior surface
957
+ // (arrays / "all" / steer kinds / arbitrary recipient sets).
958
+ const isWorker = conn.role === "worker";
959
+ if (conn.role !== "orchestrator" && !isWorker) {
960
+ this.sendError(conn, id, "forbidden", "msg.send requires orchestrator or worker role");
961
+ return;
962
+ }
830
963
  const parsed = parseMsgSendParams(params);
831
964
  if (!parsed) {
832
965
  this.sendError(conn, id, "bad-params", "msg.send: invalid params");
833
966
  return;
834
967
  }
968
+ // Worker constraint (3): kind limited to notify|message. Fire-and-forget
969
+ // `notify` vs inbox-facing `message` — both return immediately to the
970
+ // caller; the distinction is receiver-side handling.
971
+ if (isWorker && parsed.kind !== undefined && parsed.kind !== "notify" && parsed.kind !== "message") {
972
+ this.sendError(conn, id, "bad-params", "msg.send: worker kind must be 'notify' or 'message'");
973
+ return;
974
+ }
835
975
  const bodyJson = safeStringify(parsed.body);
836
976
  if (bodyJson.length > MAX_BROKER_FRAME_BYTES) {
837
977
  this.sendError(conn, id, "oversize-frame", "msg.send: body too large");
@@ -856,41 +996,116 @@ export class CrewBroker {
856
996
  this.sendError(conn, id, "no-manifest", (err as Error).message);
857
997
  return;
858
998
  }
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;
999
+ // ── Recipient resolution ──────────────────────────────────────────────
1000
+ // Each target is {label, mailboxTaskId}: `label` is echoed in the ack
1001
+ // and message id, `mailboxTaskId` is the mailbox file the append lands
1002
+ // in (undefined = run-level inbox, which the orchestrator consumes).
1003
+ let targets: Array<{ label: string; mailboxTaskId: string | undefined }>;
1004
+ // Task 5b (spec §15.2 wake): set when a worker addresses the parent —
1005
+ // the durable write alone would sit unread in the run-level inbox.
1006
+ let sentToParent = false;
1007
+ if (isWorker) {
1008
+ // Worker constraint (1): from is ALWAYS the authenticated taskId.
1009
+ // Worker constraint (2): to is limited to parent | valid sibling
1010
+ // taskId | group.
1011
+ if (!conn.taskId) {
1012
+ this.sendError(conn, id, "forbidden", "msg.send worker requires a task-scoped identity");
1013
+ return;
1014
+ }
1015
+ const to = typeof parsed.to === "string" ? parsed.to : undefined;
1016
+ if (to === "parent") {
1017
+ // Run-level inbox (taskId undefined) → the orchestrator session.
1018
+ targets = [{ label: "parent", mailboxTaskId: undefined }];
1019
+ sentToParent = true;
1020
+ } else if (to === "group") {
1021
+ targets = taskIds.map((t) => ({ label: t, mailboxTaskId: t }));
1022
+ } else if (to !== undefined && taskIds.includes(to)) {
1023
+ targets = [{ label: to, mailboxTaskId: to }];
1024
+ } else {
1025
+ this.sendError(conn, id, "forbidden", `msg.send: worker cannot target '${to}'`);
1026
+ return;
1027
+ }
1028
+ if (targets.length === 0 || targets.length > 64) {
1029
+ this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
1030
+ return;
1031
+ }
1032
+ } else {
1033
+ const recipients: string[] = Array.isArray(parsed.to)
1034
+ ? (parsed.to as string[])
1035
+ : parsed.to === "all"
1036
+ ? taskIds
1037
+ : [parsed.to as string];
1038
+ if (recipients.length === 0 || recipients.length > 64) {
1039
+ this.sendError(conn, id, "bad-params", "msg.send: recipient count out of range");
1040
+ return;
1041
+ }
1042
+ targets = recipients.map((recipient) => ({ label: recipient, mailboxTaskId: recipient }));
867
1043
  }
868
1044
  const messageId = `msg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
869
- const fromField = conn.taskId ?? conn.runId;
1045
+ const fromField = isWorker ? conn.taskId! : (conn.taskId ?? conn.runId);
870
1046
  let durable = false;
871
1047
  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
- });
1048
+ // PERF (2026-08-24): to:"all" with 50 tasks used to run 50 sequential
1049
+ // awaited locked appends (~70 syscalls + 2 fsync each) while the
1050
+ // connection's frames queued behind it. Chunked fan-out — independent
1051
+ // mailbox files append concurrently; delivery.json stays serialized by
1052
+ // its own lock.
1053
+ const CHUNK = 8;
1054
+ for (let i = 0; i < targets.length; i += CHUNK) {
1055
+ const results = await Promise.allSettled(
1056
+ targets.slice(i, i + CHUNK).map((target) =>
1057
+ appendMailboxMessageAsync(manifest, {
1058
+ id: `${messageId}_${target.label}`,
1059
+ direction: "inbox",
1060
+ from: fromField,
1061
+ to: target.label,
1062
+ taskId: target.mailboxTaskId,
1063
+ body: bodyJson,
1064
+ kind: parsed.kind ?? "message",
1065
+ priority: parsed.priority ?? "normal",
1066
+ deliveryMode: "next_turn",
1067
+ replyTo: parsed.replyTo,
1068
+ }),
1069
+ ),
1070
+ );
1071
+ const failure = results.find((r) => r.status === "rejected") as PromiseRejectedResult | undefined;
1072
+ if (failure) throw failure.reason;
885
1073
  }
886
1074
  durable = true;
887
1075
  } catch (err) {
888
1076
  this.sendError(conn, id, "durable-failed", (err as Error).message);
889
1077
  return;
890
1078
  }
1079
+ // Task 5b (spec §15.2 wake): a worker message addressed to the parent
1080
+ // appends a bounded `worker.message` run event so the host-side event
1081
+ // bus (sidebar/widget refresh) and any live orchestrator connection
1082
+ // wake up. Only kind/subject are recorded — NEVER the body, to keep the
1083
+ // append-only event log lean. Awaited before the ack so the wake signal
1084
+ // is durable by the time the caller proceeds; failure is non-fatal (the
1085
+ // mailbox write above is the source of truth).
1086
+ if (sentToParent) {
1087
+ try {
1088
+ await appendEventAsync(manifest.eventsPath, {
1089
+ type: "worker.message",
1090
+ runId: manifest.runId,
1091
+ taskId: fromField,
1092
+ data: {
1093
+ to: "parent",
1094
+ kind: parsed.kind ?? "message",
1095
+ ...(parsed.subject !== undefined ? { subject: parsed.subject } : {}),
1096
+ },
1097
+ });
1098
+ } catch (err) {
1099
+ logInternalError(
1100
+ "crew-broker.msg.worker-message-event",
1101
+ err instanceof Error ? err : new Error(String(err)),
1102
+ `runId=${conn.runId}`,
1103
+ );
1104
+ }
1105
+ }
891
1106
  this.sendResult(conn, id, {
892
1107
  messageId,
893
- recipientCount: recipients.length,
1108
+ recipientCount: targets.length,
894
1109
  durableStatus: durable ? "ok" : "failed",
895
1110
  liveDeliveryStatus: "ok",
896
1111
  });
@@ -1401,17 +1616,19 @@ export class CrewBroker {
1401
1616
  this.sendError(conn, id, "no-manifest", (err as Error).message);
1402
1617
  return;
1403
1618
  }
1404
- // Capability gate (ADR-5 §10): fail-closed, NEVER silent.
1619
+ // Capability gate (ADR-5 §10): fail-closed, NEVER silent. Since the D8
1620
+ // flip the DEFAULT is true, so reaching this branch means the user
1621
+ // closed the surface via config — the message points back at the knob.
1405
1622
  if (this.options.nestingEnabled !== true) {
1406
1623
  this.recordDelegateEvent(loaded.manifest, "delegate.rejected", conn.taskId, {
1407
1624
  reason: "nesting-disabled",
1408
- policy: "nesting.enabled=false (fail-closed default)",
1625
+ policy: "nesting.enabled=false (user config; default is true since D8)",
1409
1626
  });
1410
1627
  this.sendError(
1411
1628
  conn,
1412
1629
  id,
1413
1630
  "policy-disabled",
1414
- "delegate is disabled: nesting.enabled=false (fail-closed default; delegate.rejected recorded in events.jsonl)",
1631
+ "delegate is disabled: nesting.enabled=false (set nesting.enabled=true in user config; delegate.rejected recorded in events.jsonl)",
1415
1632
  );
1416
1633
  return;
1417
1634
  }
@@ -1453,8 +1670,7 @@ export class CrewBroker {
1453
1670
  t.cwd === task.cwd,
1454
1671
  ).length;
1455
1672
  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)
1673
+ maxDepth: this.options.nestingMaxDepth ?? resolveCrewMaxDepth(undefined), // config knob > env-clamped 1..10, default 4 (D8; ADR-5 §3)
1458
1674
  parentTask: {
1459
1675
  taskId: parentTaskId,
1460
1676
  role: task.role,
@@ -1995,6 +2211,9 @@ interface MsgSendParams {
1995
2211
  kind?: MailboxMessageKind;
1996
2212
  priority?: MailboxMessagePriority;
1997
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;
1998
2217
  }
1999
2218
 
2000
2219
  function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
@@ -2006,7 +2225,7 @@ function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
2006
2225
  if (typeof to === "string" && to.length === 0) return undefined;
2007
2226
  if (v.body === undefined) return undefined;
2008
2227
  const kind = v.kind as MailboxMessageKind | undefined;
2009
- if (kind !== undefined && !["message", "steer", "follow-up", "response", "group_join"].includes(kind)) {
2228
+ if (kind !== undefined && !["message", "notify", "steer", "follow-up", "response", "group_join"].includes(kind)) {
2010
2229
  return undefined;
2011
2230
  }
2012
2231
  const priority = v.priority as MailboxMessagePriority | undefined;
@@ -2014,7 +2233,8 @@ function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
2014
2233
  return undefined;
2015
2234
  }
2016
2235
  const replyTo = typeof v.replyTo === "string" ? v.replyTo : undefined;
2017
- return { to: to as string | string[] | "all", body: v.body, kind, priority, replyTo };
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 };
2018
2238
  }
2019
2239
 
2020
2240
  interface MsgInboxParams {
@@ -237,6 +237,13 @@ export function buildFinalChildPiSpawnOptions(
237
237
  export interface SpawnContext {
238
238
  /** The command + args returned by getPiSpawnCommand. */
239
239
  spawnSpec: ReturnType<typeof getPiSpawnCommand>;
240
+ /**
241
+ * RAW worker argv as produced by buildPiWorkerArgs (BEFORE any binary/script
242
+ * wrapping) — the surface spawn branch re-resolves the command line after
243
+ * stripping `--mode json -p` (spec §5.2: surface variant differs ONLY in run
244
+ * mode), so it needs the untouched argument list.
245
+ */
246
+ builtArgs: string[];
240
247
  /** The merged env (process.env + built.env) to pass to spawn(). */
241
248
  mergedEnv: NodeJS.ProcessEnv;
242
249
  /** Temp dir created by buildPiWorkerArgs (caller must clean up after spawn). */
@@ -288,15 +295,21 @@ export function prepareSpawnContext(
288
295
  // design (S-6), which would leave the ask tool dead-on-arrival there.
289
296
  // Control-namespace keys → pass assertOnlyControlEnvKeys.
290
297
  built.env.PI_CREW_ASK_ENABLED = "1"; // dormant gate (worker conditional registerTool)
291
- // T3/R5 (ADR-5 §1): the worker-side `delegate` tool is registered ONLY for
292
- // executor-class roles at depth 1 (read-only roles are spawn-denied
293
- // server-side anyway — the env gate is UX/dead-weight hygiene, not the
294
- // security boundary; broker admission re-checks role+depth from the task
295
- // RECORD). Control-namespace key → assertOnlyControlEnvKeys.
296
- const childDepth = Number(built.env.PI_CREW_DEPTH ?? "1");
297
- if (childDepth <= 1 && (input.role === "executor" || input.role === "test-engineer")) {
298
- built.env.PI_CREW_DELEGATE_ENABLED = "1";
299
- }
298
+ // D9/§15.2: the worker-side `message` tool env is UNCONDITIONAL for EVERY
299
+ // role and depth, like ask/delegate. The env gate is UX/hygiene, NOT the
300
+ // security boundary: the broker re-checks `from` (always the authenticated
301
+ // taskId) + `to` (parent/sibling/group) + kind (notify|message) from the
302
+ // connection identity, so an env flag alone cannot forge a sender.
303
+ // Control-namespace key → assertOnlyControlEnvKeys.
304
+ built.env.PI_CREW_MSG_ENABLED = "1"; // dormant gate (worker conditional registerTool)
305
+ // T3/R5 (ADR-5 §1, D8): the worker-side `delegate` tool env is now
306
+ // UNCONDITIONAL for EVERY role and depth — read-only/analyst roles get the
307
+ // tool too. The env gate is UX/hygiene, NOT the security boundary:
308
+ // broker admission re-checks depth + nested-slot budget from the task
309
+ // RECORD (spawn-policy.ts), and the spawn-side checkCrewDepth cap stops
310
+ // depth ≥ maxDepth children from even starting. Control-namespace key →
311
+ // assertOnlyControlEnvKeys.
312
+ built.env.PI_CREW_DELEGATE_ENABLED = "1"; // dormant gate (worker conditional registerTool)
300
313
  // stateRoot from the spawn manifest: ChildPiRunInput threads
301
314
  // manifest.eventsPath unconditionally (child-executor / background-runner)
302
315
  // and the state store pins eventsPath === <stateRoot>/events.jsonl
@@ -386,6 +399,7 @@ export function prepareSpawnContext(
386
399
  kind: "ready",
387
400
  ctx: {
388
401
  spawnSpec,
402
+ builtArgs: built.args,
389
403
  mergedEnv: { ...process.env, ...built.env },
390
404
  tempDir: built.tempDir,
391
405
  builtEnv: built.env,
@@ -69,7 +69,15 @@ function compactContentPart(part: unknown): unknown | undefined {
69
69
  return undefined;
70
70
  }
71
71
 
72
- function compactChildPiEvent(event: unknown): unknown | undefined {
72
+ /**
73
+ * Compact one child-pi JSON event into the bounded record shape stored in
74
+ * agent transcripts / per-agent event logs. Shared by TWO producers that must
75
+ * stay byte-compatible (the consumer, agent-transcript.ts, parses both):
76
+ * 1. the host-side stdout funnel (headless workers), and
77
+ * 2. the worker-side surface recorder (S2-T8 — surface panes have no
78
+ * stdout JSON stream, so the worker records its own events).
79
+ */
80
+ export function compactChildPiEvent(event: unknown): unknown | undefined {
73
81
  const record = asRecord(event);
74
82
  if (!record) return undefined;
75
83
  if (record.type === "message_update") return undefined;