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
@@ -0,0 +1,37 @@
1
+ /**
2
+ * delegate-event.ts — Event recording for the broker delegate surface.
3
+ *
4
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
5
+ * recordDelegateEvent uses ONLY the module-scoped event-log + error helpers;
6
+ * no broker state. Promoted to a top-level function so the class method is
7
+ * a 1-line delegation.
8
+ */
9
+
10
+ import { appendEventAsync } from "../../../state/event-log/event-log.ts";
11
+ import { logInternalError } from "../../../utils/internal-error.ts";
12
+
13
+ /** Fire-and-forget async append; an append failure is logged, never thrown
14
+ * (broker handlers must not block the event loop on the sync event-log lock).
15
+ * See `crew-broker.delegate.event` event scope conventions in ADR-5 §10. */
16
+ export function recordDelegateEvent(
17
+ manifest: { eventsPath: string; runId: string },
18
+ type:
19
+ | "delegate.requested"
20
+ | "delegate.admitted"
21
+ | "delegate.rejected"
22
+ | "delegate.completed"
23
+ | "delegate.timed_out"
24
+ | "delegate.rolled_up",
25
+ taskId: string,
26
+ data: Record<string, unknown>,
27
+ ): void {
28
+ void appendEventAsync(manifest.eventsPath, {
29
+ type,
30
+ runId: manifest.runId,
31
+ taskId,
32
+ message: `${type}: ${JSON.stringify(data).slice(0, 200)}`,
33
+ data,
34
+ }).catch((err) =>
35
+ logInternalError("crew-broker.delegate.event", err instanceof Error ? err : new Error(String(err)), `runId=${manifest.runId}`),
36
+ );
37
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * mailbox-fanout.ts — Phase 1.3 mailbox-event fanout helper.
3
+ *
4
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
5
+ * Operates only on the connectionsByRun index + writeOrQueue/enqueueFrame
6
+ * functions passed in. Promoted to a top-level function so the class
7
+ * method is a 1-line delegation.
8
+ */
9
+
10
+ import type { MailboxMessage } from "../../../state/coordination/mailbox.ts";
11
+ import { encodeBrokerFrame } from "../../../utils/ndjson.ts";
12
+ import type { ServerConnection } from "../protocol/connection-state.ts";
13
+
14
+ export interface FanoutWriters {
15
+ writeOrQueue(conn: ServerConnection, buf: Buffer, force: boolean): void;
16
+ }
17
+
18
+ /** Phase 1.3: push a durable-appended mailbox message to any connected
19
+ * recipient for the message's run. Best-effort — silently skips
20
+ * recipients that are offline (they recover via msg.inbox). Never throws. */
21
+ export function fanoutMailboxMessage(
22
+ connectionsByRun: Map<string, Set<ServerConnection>>,
23
+ writers: FanoutWriters,
24
+ msg: MailboxMessage,
25
+ ): void {
26
+ const set = connectionsByRun.get(msg.runId);
27
+ if (!set || set.size === 0) return;
28
+ // Recipient delivery dedup lives in src/prompt/prompt-runtime.ts and is
29
+ // keyed by the same message id in this mailbox event and the steering JSONL.
30
+ const eventFrame = encodeBrokerFrame({
31
+ event: "mailbox.message",
32
+ data: {
33
+ id: msg.id,
34
+ from: msg.from,
35
+ to: msg.to,
36
+ body: msg.body,
37
+ kind: msg.kind,
38
+ priority: msg.priority,
39
+ },
40
+ seq: 0, // mailbox messages don't carry a TeamEvent seq; dedup by msg.id
41
+ });
42
+ for (const conn of set) {
43
+ if (conn.closed || !conn.authed) continue;
44
+ // Recipient filter: deliver to the addressed task, or to all if 'all'.
45
+ // Task 5b (§15.2 wake): "parent"-addressed messages land in the
46
+ // run-level inbox, whose live consumer is the run's orchestrator
47
+ // connection (role from the orchestrator token — its taskId never
48
+ // equals "parent"), so without this branch the wake frame would be
49
+ // filtered out and the orchestrator would only see the message on
50
+ // its next inbox poll.
51
+ const isRecipient = !msg.to || msg.to === "all" || conn.taskId === msg.to || (msg.to === "parent" && conn.role === "orchestrator");
52
+ if (!isRecipient) continue;
53
+ try {
54
+ writers.writeOrQueue(conn, eventFrame, false);
55
+ } catch {
56
+ /* a slow/dead recipient must not break fanout to others */
57
+ }
58
+ }
59
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * connection-state.ts — Type definitions for crew-broker connection state
3
+ * and broker options.
4
+ *
5
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
6
+ * These are pure types/interfaces used by the CrewBroker class but they
7
+ * contribute ~80 lines to the file's line count. Splitting them out
8
+ * contributes to the ≤2000-line gate (spec §5 M4 acceptance).
9
+ */
10
+
11
+ import type * as net from "node:net";
12
+ import type { NdjsonDecoder } from "../../../utils/ndjson.ts";
13
+ import type { GrandchildSpawnInput, GrandchildSpawnResult } from "../../delegate-spawn.ts";
14
+ import type { WaitStatusCache } from "../wait-status-cache.ts";
15
+
16
+ export interface CrewBrokerOptions {
17
+ /** Root session ID used to derive the socket path. */
18
+ sessionId: string;
19
+ /** Pre-resolved socket path (skips re-derivation; useful for tests). */
20
+ socketPath?: string;
21
+ /** Frame cap in UTF-8 bytes. Default 256 KiB. */
22
+ maxFrameBytes?: number;
23
+ /** Per-connection outbound queue cap. Default 256. */
24
+ outboundQueueCap?: number;
25
+ /** Required: when false, start() is a no-op and the server never binds.
26
+ * Lets the lifecycle controller install the broker unconditionally and
27
+ * have a single kill switch. */
28
+ enabled: boolean;
29
+ /** CWD for `loadRunManifestById` (Phase 1 msg.send / msg.inbox resolution).
30
+ * When omitted, manifest-touching methods return no-manifest errors. */
31
+ cwd?: string;
32
+ /** Optional test seam: override the `net` module (allows fake-server tests). */
33
+ netModule?: typeof net;
34
+ /** Optional test seam: inject a pre-configured WaitStatusCache (e.g. one
35
+ * wrapping a loader spy). Production uses a plain cache — see
36
+ * wait-status-cache.ts (R10-3). */
37
+ waitStatusCache?: WaitStatusCache;
38
+ /** WP-2/R2 (ADR-0 2026-08-17-waiting-producer-ask item 7): capability
39
+ * gate for the `wait.*` methods. DEFAULT FALSE — fail-closed. When not
40
+ * explicitly true, wait.request/wait.resolve are rejected with a
41
+ * `policy-disabled` error AND a `policy.action` event is appended to the
42
+ * run's events.jsonl (never silent). The production wiring threads
43
+ * `config.broker.waitMethodsEnabled` here; tests pass it explicitly. */
44
+ waitMethodsEnabled?: boolean;
45
+ /** T3/R5 (ADR-5 §10): capability gate for the `delegate` surface. DEFAULT
46
+ * TRUE since D8 (spec v0.7) — nested spawning is open out of the box; the
47
+ * broker still fail-closes when the flag is anything but true. The
48
+ * production wiring threads `config.nesting.enabled` (loadConfig layers
49
+ * DEFAULT_NESTING.enabled=true; a user `false` closes the surface); tests
50
+ * pass it explicitly. Rejections are NEVER silent (delegate.rejected). */
51
+ nestingEnabled?: boolean;
52
+ /** Optional override for the nested-slot budget size (config nesting.maxSlots). */
53
+ nestingMaxSlots?: number;
54
+ nestingMaxDepth?: number;
55
+ nestingTrustedEscalation?: boolean;
56
+ /** Global worker semaphore size, used to size the nested-slot budget. */
57
+ globalWorkerSemaphore?: number;
58
+ /** Test seam / alternative spawner for delegate grandchildren. Production
59
+ * uses spawnDelegateGrandchild (direct runChildPi call-site, ADR-5 §2). */
60
+ grandchildSpawner?: (input: GrandchildSpawnInput) => Promise<GrandchildSpawnResult>;
61
+ /** Resolved model catalog (canonical provider/id strings) for admission-time
62
+ * model validation (ADR-5 §7). When omitted, model validation is skipped
63
+ * (documented gap — the production wiring must always supply it). */
64
+ modelCatalog?: () => string[] | undefined;
65
+ /** ADR-5 §9: mirrors config limits.serializeOnPathOverlap for the workspace
66
+ * admission gate. Default false. */
67
+ serializeOnPathOverlap?: boolean;
68
+ }
69
+
70
+ /** Per-connection server-side state. */
71
+ export interface ServerConnection {
72
+ socket: net.Socket;
73
+ decoder: NdjsonDecoder;
74
+ /** Whether the connection has completed `hello` successfully. */
75
+ authed: boolean;
76
+ /** Run id bound by hello. */
77
+ runId?: string;
78
+ /** Task id bound by hello. */
79
+ taskId?: string;
80
+ /** Role bound by hello: orchestrator can steer/msg-send; workers default. */
81
+ role?: "orchestrator" | "worker";
82
+ /** How the hello token matched the registry (ADR-0 item 6). Derived,
83
+ * non-secret metadata recorded at hello time so `wait.*` can reject a
84
+ * legacy bare-runId fallback match WITHOUT keeping the raw token on the
85
+ * connection (tokens stay confined to the heap-only registry). */
86
+ authMatchKind?: "compound" | "runId-fallback";
87
+ /** Task 10 fix round 2 (BUG #3): sha256 of the secret this connection
88
+ * authenticated with. Derived, one-way — never the plaintext token. The
89
+ * post-hello revocation check evaluates THIS digest so a revoke →
90
+ * re-issue window cannot let an old connection ride the freshly issued
91
+ * token for the same key. */
92
+ authedSecretHash?: string;
93
+ /** Outbound queue of encoded frames awaiting drain. */
94
+ outbound: Buffer[];
95
+ /** Set when the queue has hit the cap and a frame was dropped. */
96
+ needsResync: boolean;
97
+ /** Set when the connection is closing (idempotent). */
98
+ closed: boolean;
99
+ /** Timer for the hello deadline. */
100
+ helloTimer: NodeJS.Timeout | null;
101
+ /** Monotonic seq counter for outbound events (diagnostic). */
102
+ outboundSeq: number;
103
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * events-replay.ts — Phase 1.5 events.since handler (label corrected
3
+ * 2026-09-10 per review F4 — "Phase 2" is events.subscribe). Used by clients
4
+ * to resync after a missed live frame (e.g. after a queue overflow or
5
+ * reconnect).
6
+ *
7
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
8
+ * Operates only on the connection + a writers adapter (sendError/sendResult).
9
+ * Promoted to a top-level function so the class method is a 1-line delegation.
10
+ */
11
+
12
+ import { readEventsCursor } from "../../../state/event-log/event-log.ts";
13
+ import { loadRunManifestById } from "../../../state/stores/state-store.ts";
14
+ import type { ServerConnection } from "./connection-state.ts";
15
+
16
+ export interface EventWriterHelpers {
17
+ sendError(conn: ServerConnection, id: string, code: string, message: string): void;
18
+ sendResult(conn: ServerConnection, id: string, result: unknown): void;
19
+ }
20
+
21
+ /** Phase 2: events.since — page-replay handler. Returns events with
22
+ * seq > sinceSeq from the durable log, capped to `limit` (default 1000). */
23
+ export async function handleEventsSince(
24
+ conn: ServerConnection,
25
+ id: string,
26
+ params: unknown,
27
+ helpers: EventWriterHelpers,
28
+ cwd: string | undefined,
29
+ ): Promise<void> {
30
+ if (!conn.runId) {
31
+ helpers.sendError(conn, id, "auth", "not authed");
32
+ return;
33
+ }
34
+ if (!cwd) {
35
+ helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
36
+ return;
37
+ }
38
+ let eventsPath: string;
39
+ try {
40
+ const loaded = loadRunManifestById(cwd, conn.runId);
41
+ if (!loaded) {
42
+ helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
43
+ return;
44
+ }
45
+ eventsPath = loaded.manifest.eventsPath;
46
+ } catch (err) {
47
+ helpers.sendError(conn, id, "no-manifest", (err as Error).message);
48
+ return;
49
+ }
50
+ const v = params && typeof params === "object" && !Array.isArray(params) ? (params as Record<string, unknown>) : {};
51
+ const sinceSeq = typeof v.sinceSeq === "number" && Number.isFinite(v.sinceSeq) ? Math.max(0, Math.floor(v.sinceSeq)) : 0;
52
+ const limit = typeof v.limit === "number" && Number.isFinite(v.limit) ? Math.min(Math.max(1, Math.floor(v.limit)), 1000) : 1000;
53
+ try {
54
+ const result = readEventsCursor(eventsPath, { sinceSeq, limit });
55
+ // hasMore is true iff the total filtered count exceeds the page we
56
+ // returned. When `total === events.length` we are at the exact end
57
+ // of the stream (caller will discover this on the next call when
58
+ // `nextSeq` is unchanged from `sinceSeq`).
59
+ const hasMore = result.total > result.events.length;
60
+ helpers.sendResult(conn, id, {
61
+ events: result.events,
62
+ nextSeq: result.nextSeq,
63
+ hasMore,
64
+ });
65
+ } catch (err) {
66
+ helpers.sendError(conn, id, "replay-failed", (err as Error).message);
67
+ }
68
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * manifest-loader.ts — Helper for loading run manifests during hello.
3
+ *
4
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
5
+ * The CrewBroker.loadRunForHello method delegated entirely to
6
+ * loadRunManifestById after one safety check (cwd absence); the helper is
7
+ * now a top-level function the class method delegates to in 1 line.
8
+ */
9
+
10
+ import { loadRunManifestById } from "../../../state/stores/state-store.ts";
11
+ import type { TeamRunManifest, TeamTaskState } from "../../../state/types.ts";
12
+
13
+ export function loadRunForHello(cwd: string | undefined, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined {
14
+ if (!cwd) return undefined;
15
+ try {
16
+ return loadRunManifestById(cwd, runId) ?? undefined;
17
+ } catch {
18
+ return undefined;
19
+ }
20
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * msg-inbox.ts — Phase 1.2 msg.inbox broker handler (Phase 1.1 = msg.send;
3
+ * label corrected 2026-09-10 per review F3 — it was mislabeled 1.1 at extraction).
4
+ *
5
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
6
+ * Operates on a connection + writers adapter + cwd. Top-level function so
7
+ * the class method is a 1-line delegation.
8
+ */
9
+
10
+ import { readMailbox } from "../../../state/coordination/mailbox.ts";
11
+ import { loadRunManifestById } from "../../../state/stores/state-store.ts";
12
+ import type { ServerConnection } from "./connection-state.ts";
13
+ import { parseMsgInboxParams } from "./request-parsers.ts";
14
+
15
+ export interface MsgInboxHelpers {
16
+ sendError(conn: ServerConnection, id: string, code: string, message: string): void;
17
+ sendResult(conn: ServerConnection, id: string, result: unknown): void;
18
+ }
19
+
20
+ /** Phase 1.1: read the durable mailbox for the addressed task (or run-level
21
+ * "inbox" lane) with cursor/limit pagination. Returns messages whose status
22
+ * is NOT yet `acknowledged`; callers should ack after processing. */
23
+ export async function handleMsgInbox(
24
+ conn: ServerConnection,
25
+ id: string,
26
+ params: unknown,
27
+ helpers: MsgInboxHelpers,
28
+ cwd: string | undefined,
29
+ ): Promise<void> {
30
+ if (!conn.runId) {
31
+ helpers.sendError(conn, id, "auth", "not authed");
32
+ return;
33
+ }
34
+ const parsed = parseMsgInboxParams(params);
35
+ if (!parsed) {
36
+ helpers.sendError(conn, id, "bad-params", "msg.inbox: invalid params");
37
+ return;
38
+ }
39
+ if (!cwd) {
40
+ helpers.sendError(conn, id, "no-manifest", "broker has no cwd configured");
41
+ return;
42
+ }
43
+ let manifest: Parameters<typeof readMailbox>[0];
44
+ try {
45
+ const loaded = loadRunManifestById(cwd, conn.runId);
46
+ if (!loaded) {
47
+ helpers.sendError(conn, id, "no-manifest", `run '${conn.runId}' not found`);
48
+ return;
49
+ }
50
+ manifest = loaded.manifest;
51
+ } catch (err) {
52
+ helpers.sendError(conn, id, "no-manifest", (err as Error).message);
53
+ return;
54
+ }
55
+ const limit = Math.min(Math.max(parsed.limit ?? 100, 1), 1000);
56
+ const taskId = conn.taskId ?? undefined;
57
+ const all = readMailbox(manifest, "inbox", taskId);
58
+ const filtered = all.filter((m) => m.status !== "acknowledged");
59
+ const offset = parsed.cursor ? Number.parseInt(parsed.cursor, 10) || 0 : 0;
60
+ const page = filtered.slice(offset, offset + limit);
61
+ const nextOffset = offset + page.length;
62
+ const hasMore = nextOffset < filtered.length;
63
+ helpers.sendResult(conn, id, {
64
+ messages: page,
65
+ nextCursor: hasMore ? String(nextOffset) : undefined,
66
+ hasMore,
67
+ total: filtered.length,
68
+ });
69
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * request-parsers.ts — Protocol-level parameter parsers and type guards for
3
+ * the crew-broker wire protocol.
4
+ *
5
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
6
+ * Every parser rejects malformed frames at the boundary so handlers see only
7
+ * typed params. Method-name charset check (^[a-zA-Z][a-zA-Z0-9._-]{0,63}$)
8
+ * guards against control chars / oversized names reaching the dispatcher.
9
+ */
10
+
11
+ import type { MailboxMessageKind, MailboxMessagePriority } from "../../../state/coordination/mailbox.ts";
12
+
13
+ /** Protocol version negotiated at `hello` time. Bump on breaking change. */
14
+ export const BROKER_PROTOCOL = 1;
15
+
16
+ // ============================================================================
17
+ // Type guards (no `any`)
18
+ // ============================================================================
19
+
20
+ export function isRequestObject(value: unknown): value is { id: string; method: string; params: unknown } {
21
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
22
+ const v = value as Record<string, unknown>;
23
+ if (typeof v.id !== "string" || v.id.length === 0 || v.id.length > 256) return false;
24
+ if (typeof v.method !== "string" || v.method.length === 0 || v.method.length > 64) return false;
25
+ // Method names are restricted to a small safe charset. This guards against
26
+ // odd inputs (control chars, very long names) reaching the dispatcher.
27
+ if (!/^[a-zA-Z][a-zA-Z0-9._-]{0,63}$/.test(v.method)) return false;
28
+ // params may be anything (validated per-method), but not undefined-shaped.
29
+ return "params" in v;
30
+ }
31
+
32
+ export function isHelloParams(value: unknown): value is {
33
+ protocol: number;
34
+ runId: string;
35
+ taskId: string;
36
+ token: string;
37
+ role?: string;
38
+ } {
39
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
40
+ const v = value as Record<string, unknown>;
41
+ if (v.protocol !== BROKER_PROTOCOL) {
42
+ // Force exact-type comparison (must be the number 1, not "1").
43
+ if (typeof v.protocol !== "number" || !Number.isInteger(v.protocol)) return false;
44
+ }
45
+ if (typeof v.runId !== "string" || v.runId.length === 0 || v.runId.length > 256) return false;
46
+ if (typeof v.taskId !== "string" || v.taskId.length === 0 || v.taskId.length > 256) return false;
47
+ if (typeof v.token !== "string" || v.token.length === 0 || v.token.length > 256) return false;
48
+ return true;
49
+ }
50
+
51
+ // ============================================================================
52
+ // Phase 1 parameter parsers (module-level; no `any`)
53
+ // ============================================================================
54
+
55
+ export interface MsgSendParams {
56
+ to: string | string[] | "all";
57
+ body: unknown;
58
+ kind?: MailboxMessageKind;
59
+ priority?: MailboxMessagePriority;
60
+ replyTo?: string;
61
+ /** Task 5b (§15.2): short subject echoed into the worker.message wake
62
+ * event (bounded like the tool-side MSG_SUBJECT_MAX_CHARS). */
63
+ subject?: string;
64
+ }
65
+
66
+ export function parseMsgSendParams(value: unknown): MsgSendParams | undefined {
67
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
68
+ const v = value as Record<string, unknown>;
69
+ const to = v.to;
70
+ if (typeof to !== "string" && !Array.isArray(to)) return undefined;
71
+ if (Array.isArray(to) && !to.every((s) => typeof s === "string" && s.length > 0)) return undefined;
72
+ if (typeof to === "string" && to.length === 0) return undefined;
73
+ if (v.body === undefined) return undefined;
74
+ const kind = v.kind as MailboxMessageKind | undefined;
75
+ if (kind !== undefined && !["message", "notify", "steer", "follow-up", "response", "group_join"].includes(kind)) {
76
+ return undefined;
77
+ }
78
+ const priority = v.priority as MailboxMessagePriority | undefined;
79
+ if (priority !== undefined && !["urgent", "normal", "low"].includes(priority)) {
80
+ return undefined;
81
+ }
82
+ const replyTo = typeof v.replyTo === "string" ? v.replyTo : undefined;
83
+ const subject = typeof v.subject === "string" && v.subject.length > 0 && v.subject.length <= 256 ? v.subject : undefined;
84
+ return { to: to as string | string[] | "all", body: v.body, kind, priority, replyTo, subject };
85
+ }
86
+
87
+ export interface MsgInboxParams {
88
+ limit?: number;
89
+ cursor?: string;
90
+ }
91
+
92
+ export function parseMsgInboxParams(value: unknown): MsgInboxParams | undefined {
93
+ if (value === undefined || value === null) return { limit: 100, cursor: undefined };
94
+ if (typeof value !== "object" || Array.isArray(value)) return undefined;
95
+ const v = value as Record<string, unknown>;
96
+ const limit = v.limit;
97
+ if (limit !== undefined && (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1)) {
98
+ return undefined;
99
+ }
100
+ const cursor = v.cursor;
101
+ if (cursor !== undefined && typeof cursor !== "string") return undefined;
102
+ return { limit: limit as number | undefined, cursor: cursor as string | undefined };
103
+ }
104
+
105
+ export function safeStringify(value: unknown): string {
106
+ try {
107
+ return JSON.stringify(value) ?? "{}";
108
+ } catch {
109
+ return "{}";
110
+ }
111
+ }
112
+
113
+ // ============================================================================
114
+ // WP-2/R2 wait.* parameter parsers (ADR-0 2026-08-17-waiting-producer-ask)
115
+ // ============================================================================
116
+
117
+ /** Server-side ceiling for the ask deadline (ADR P2-7): worker-controlled
118
+ * timeoutSec may NEVER exceed 1h — an unbounded timeout would pin slots and
119
+ * amplify I/O. Applied as deadline = now + min(timeoutSec, 3600). */
120
+ export const WAIT_REQUEST_TIMEOUT_SEC_MAX = 3600;
121
+ /** Default ask timeout when the caller omits timeoutSec (ADR item 1). */
122
+ export const WAIT_REQUEST_TIMEOUT_SEC_DEFAULT = 600;
123
+ /** Bounded question payload (defense-in-depth under the 256 KiB frame cap). */
124
+ export const WAIT_QUESTION_MAX_CHARS = 8192;
125
+ /** Bounded answer-choice list: at most 16 options, 256 chars each. */
126
+ export const WAIT_OPTIONS_MAX = 16;
127
+ export const WAIT_OPTION_MAX_CHARS = 256;
128
+
129
+ export interface WaitRequestParams {
130
+ to: string;
131
+ question: string;
132
+ options?: string[];
133
+ timeoutSec?: number;
134
+ }
135
+
136
+ export function parseWaitRequestParams(value: unknown): WaitRequestParams | undefined {
137
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
138
+ const v = value as Record<string, unknown>;
139
+ if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
140
+ if (typeof v.question !== "string" || v.question.length === 0 || v.question.length > WAIT_QUESTION_MAX_CHARS) {
141
+ return undefined;
142
+ }
143
+ let options: string[] | undefined;
144
+ if (v.options !== undefined) {
145
+ if (!Array.isArray(v.options) || v.options.length === 0 || v.options.length > WAIT_OPTIONS_MAX) return undefined;
146
+ for (const o of v.options) {
147
+ if (typeof o !== "string" || o.length === 0 || o.length > WAIT_OPTION_MAX_CHARS) return undefined;
148
+ }
149
+ options = v.options as string[];
150
+ }
151
+ // timeoutSec is clamped server-side in the handler (max 3600); the parser
152
+ // only rejects non-finite values. Non-positive values clamp to 1s.
153
+ if (v.timeoutSec !== undefined && (typeof v.timeoutSec !== "number" || !Number.isFinite(v.timeoutSec))) {
154
+ return undefined;
155
+ }
156
+ return {
157
+ to: v.to,
158
+ question: v.question,
159
+ options,
160
+ timeoutSec: v.timeoutSec as number | undefined,
161
+ };
162
+ }
163
+
164
+ export interface WaitResolveParams {
165
+ to: string;
166
+ questionId: string;
167
+ }
168
+
169
+ export function parseWaitResolveParams(value: unknown): WaitResolveParams | undefined {
170
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
171
+ const v = value as Record<string, unknown>;
172
+ if (typeof v.to !== "string" || v.to.length === 0 || v.to.length > 256) return undefined;
173
+ if (typeof v.questionId !== "string" || v.questionId.length === 0 || v.questionId.length > 128) return undefined;
174
+ return { to: v.to, questionId: v.questionId };
175
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * wait-auth.ts — Auth + policy-rejection helpers for the broker wait.*
3
+ * surface.
4
+ *
5
+ * Moved from crew-broker.ts (M4 / WI-4.1) — pure move, no behavior change.
6
+ * waitAuthError is pure (operates only on conn metadata). recordWaitPolicyRejection
7
+ * uses module-scoped event-log + internal-error helpers — same as the class
8
+ * method did. Both are now top-level functions; the class delegates to them.
9
+ */
10
+
11
+ import { appendEventAsync } from "../../../state/event-log/event-log.ts";
12
+ import { logInternalError } from "../../../utils/internal-error.ts";
13
+ import type { ServerConnection } from "./connection-state.ts";
14
+
15
+ /** Auth failure for the wait.* methods: worker role + task-scoped (compound)
16
+ * token ONLY (ADR-0 2026-08-17 item 6). Legacy bare-runId fallback match is
17
+ * REJECTED with a migrate hint; orchestrator token is rejected by role. */
18
+ export function waitAuthError(conn: ServerConnection): { code: string; message: string } | null {
19
+ if (conn.role !== "worker" || conn.authMatchKind === undefined) {
20
+ return { code: "forbidden", message: "wait.* requires a worker task-scoped token" };
21
+ }
22
+ if (conn.authMatchKind !== "compound") {
23
+ return {
24
+ code: "forbidden",
25
+ message: "wait.* requires a task-scoped token; re-dispatch with PI_CREW_BROKER_TASK_ID",
26
+ };
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** ADR-0 item 7: a disabled-gate rejection MUST leave a durable trace in
32
+ * events.jsonl. The gate fails CLOSED but never SILENTLY. Fire-and-forget
33
+ * async append (broker handlers must not block the event loop on the sync
34
+ * event-log lock); an append failure is logged, never thrown. */
35
+ export function recordWaitPolicyRejection(manifest: { eventsPath: string; runId: string }, taskId: string, method: string): void {
36
+ const runId = manifest.runId;
37
+ void appendEventAsync(manifest.eventsPath, {
38
+ type: "policy.action",
39
+ runId,
40
+ taskId,
41
+ message: `${method} rejected: waitMethodsEnabled=false (fail-closed)`,
42
+ data: { action: method, reason: "wait-methods-disabled", policy: "broker.waitMethodsEnabled=false" },
43
+ }).catch((err) =>
44
+ logInternalError("crew-broker.wait.policy-event", err instanceof Error ? err : new Error(String(err)), `runId=${runId}`),
45
+ );
46
+ }
@@ -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;