pi-crew 0.10.3 → 0.10.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +5 -1
  3. package/dist/index.mjs +10685 -6882
  4. package/docs/architecture.md +4 -4
  5. package/docs/commands-reference.md +3 -0
  6. package/docs/publishing.md +15 -3
  7. package/install.mjs +90 -39
  8. package/package.json +8 -3
  9. package/scripts/README.md +4 -3
  10. package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +1 -0
  11. package/skills/real-test-pi-crew/SKILL.md +153 -6
  12. package/src/config/migration-validator.ts +113 -0
  13. package/src/extension/cross-extension-rpc.ts +3 -7
  14. package/src/extension/register.ts +13 -0
  15. package/src/extension/registration/observability.ts +3 -7
  16. package/src/extension/registration/subagent-tools.ts +3 -7
  17. package/src/extension/registration/team-tool.ts +3 -7
  18. package/src/extension/registration/ui.ts +3 -8
  19. package/src/extension/registration/viewers.ts +3 -10
  20. package/src/extension/team-manager-command.ts +3 -7
  21. package/src/extension/team-tool/api/agent-control.ts +17 -10
  22. package/src/extension/team-tool/api/heartbeat.ts +4 -3
  23. package/src/extension/team-tool/api/mailbox.ts +33 -20
  24. package/src/extension/team-tool/api/plan-approval.ts +5 -5
  25. package/src/extension/team-tool/api/task-claims.ts +8 -7
  26. package/src/extension/team-tool/cancel.ts +6 -0
  27. package/src/extension/team-tool/handle-settings.ts +4 -1
  28. package/src/extension/team-tool/run.ts +3 -7
  29. package/src/extension/team-tool/status.ts +5 -0
  30. package/src/extension/team-tool.ts +6 -14
  31. package/src/hooks/registry.ts +3 -0
  32. package/src/prompt/scratchpad-lifecycle.ts +3 -3
  33. package/src/runtime/background-runner.ts +30 -35
  34. package/src/runtime/broker/crew-broker.ts +112 -441
  35. package/src/runtime/broker/delegate/delegate-event.ts +37 -0
  36. package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
  37. package/src/runtime/broker/protocol/connection-state.ts +103 -0
  38. package/src/runtime/broker/protocol/events-replay.ts +68 -0
  39. package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
  40. package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
  41. package/src/runtime/broker/protocol/request-parsers.ts +175 -0
  42. package/src/runtime/broker/protocol/wait-auth.ts +46 -0
  43. package/src/runtime/child-pi/child-pi.ts +15 -0
  44. package/src/runtime/finalize-run.ts +15 -7
  45. package/src/runtime/foreground-control.ts +19 -6
  46. package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
  47. package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
  48. package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
  49. package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
  50. package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
  51. package/src/runtime/model/pi-args.ts +6 -1
  52. package/src/runtime/plan-replan.ts +3 -0
  53. package/src/runtime/stale-reconciler.ts +28 -3
  54. package/src/runtime/supervisor-contact.ts +3 -0
  55. package/src/runtime/task-runner/child-executor.ts +33 -0
  56. package/src/runtime/team-runner.ts +3 -3
  57. package/src/state/stores/ownership-map.ts +5 -4
  58. package/src/state/stores/plan-store.ts +12 -0
  59. package/src/state/stores/state-store.ts +5 -0
  60. package/src/ui/powerbar-publisher.ts +3 -7
  61. package/src/ui/run-action-dispatcher.ts +7 -10
  62. package/src/ui/settings-overlay.ts +4 -1
@@ -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
+ }
@@ -157,6 +157,17 @@ export interface ChildPiRunInput {
157
157
  onSpawn?: (pid: number) => void;
158
158
  /** Structured lifecycle events for durable logging (spawn, crash, timeout, kill, exit). */
159
159
  onLifecycleEvent?: (event: ChildPiLifecycleEvent) => void;
160
+ /** F1 fix (battery 2026-09-10): task-state activity bridge for SURFACE workers.
161
+ * Surface workers have no stdout pipe, so onJsonEvent/onStdoutLine never fire
162
+ * and task.heartbeat/agentProgress starve - the heartbeat-watcher then flags a
163
+ * healthy pane worker dead (~340s) and the stale-reconciler cancels the run
164
+ * (observed live: run team_20260910161234, worker productive 7min in pane
165
+ * w2:p8Z, 0 task.progress events). This callback receives every event tailed
166
+ * from the per-agent recorder log so the CALLER can update task liveness state.
167
+ * Contract: the callback MUST NOT append to the per-agent events file being
168
+ * tailed (watch->append loop - see event-log-tail-source.ts header); task-state
169
+ * persist + run-level task.progress are safe (different files). */
170
+ onSurfaceActivity?: (event: Record<string, unknown>) => void;
160
171
  maxDepth?: number;
161
172
  finalDrainMs?: number;
162
173
  /** F12: early-exit the drain when stdout has been silent for this many ms
@@ -492,6 +503,10 @@ async function trySurfaceBranch(
492
503
  }
493
504
  const bridgeEvent = bridgeEventFromJsonEvent(runId, taskId, event);
494
505
  if (bridgeEvent) runEventBus.emit({ type: "worker_status", runId, taskId, data: bridgeEvent });
506
+ // F1 fix (battery 2026-09-10): feed the same tailed event to the task-state
507
+ // bridge so child-executor can keep task.heartbeat/agentProgress fresh for
508
+ // surface workers (see ChildPiRunInput.onSurfaceActivity doc).
509
+ input.onSurfaceActivity?.(event as Record<string, unknown>);
495
510
  });
496
511
  }
497
512
  let exitInfo: SurfaceExitInfo;
@@ -19,7 +19,7 @@ import type { CrewLimitsConfig, CrewRuntimeConfig } from "../config/config.ts";
19
19
  import { flushPendingAtomicWrites } from "../state/atomic-write.ts";
20
20
  import { TEAM_TERMINAL_TASK_STATUSES } from "../state/contracts.ts";
21
21
  import { withRunLock } from "../state/coordination/locks.ts";
22
- import { appendEvent, appendEventAsync, appendEventFireAndForget, readEvents } from "../state/event-log/event-log.ts";
22
+ import { appendEventAsync, appendEventBuffered, appendEventFireAndForget, readEvents } from "../state/event-log/event-log.ts";
23
23
  import { hashArtifactContent as hashContent, writeArtifact } from "../state/stores/artifact-store.ts";
24
24
  import { HealthStore } from "../state/stores/health-store.ts";
25
25
  import { loadRunManifestById, saveRunManifestAsync, saveRunTasksAsync, updateRunStatus } from "../state/stores/state-store.ts";
@@ -211,12 +211,12 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
211
211
  createdAt: new Date().toISOString(),
212
212
  };
213
213
  decisions = [...decisions, branchDecision];
214
- appendEvent(manifest.eventsPath, {
214
+ appendEventBuffered(manifest.eventsPath, {
215
215
  type: "branch.stale",
216
216
  runId: manifest.runId,
217
217
  message: branchFreshness.message,
218
218
  data: { branchFreshness },
219
- });
219
+ }).catch((e) => logInternalError("finalize-run.buffered", e, "type=branch.stale"));
220
220
  }
221
221
  const policyArtifact = writeArtifact(manifest.artifactsRoot, {
222
222
  kind: "metadata",
@@ -232,15 +232,17 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
232
232
  content: `${JSON.stringify(recoveryLedger, null, 2)}\n`,
233
233
  });
234
234
  for (const item of decisions)
235
- appendEvent(manifest.eventsPath, {
235
+ appendEventBuffered(manifest.eventsPath, {
236
236
  type: item.action === "escalate" ? "policy.escalated" : "policy.action",
237
237
  runId: manifest.runId,
238
238
  taskId: item.taskId,
239
239
  message: item.message,
240
240
  data: { action: item.action, reason: item.reason },
241
- });
241
+ }).catch((e) =>
242
+ logInternalError("finalize-run.buffered", e, `type=${item.action === "escalate" ? "policy.escalated" : "policy.action"}`),
243
+ );
242
244
  for (const item of recoveryLedger.entries)
243
- appendEvent(manifest.eventsPath, {
245
+ appendEventBuffered(manifest.eventsPath, {
244
246
  type: item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted",
245
247
  runId: manifest.runId,
246
248
  taskId: item.taskId,
@@ -251,7 +253,13 @@ function applyPolicy(manifest: TeamRunManifest, tasks: TeamTaskState[], limits?:
251
253
  attempt: item.attempt,
252
254
  state: item.state,
253
255
  },
254
- });
256
+ }).catch((e) =>
257
+ logInternalError(
258
+ "finalize-run.buffered",
259
+ e,
260
+ `type=${item.state === "escalation_required" ? "recovery.escalated" : "recovery.attempted"}`,
261
+ ),
262
+ );
255
263
  return {
256
264
  ...manifest,
257
265
  updatedAt: new Date().toISOString(),