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
@@ -42,6 +42,65 @@ export function getHooks(name: HookName): HookDefinition[] {
42
42
  return registry.get(name) ?? [];
43
43
  }
44
44
 
45
+ // PERF (2026-08-24): constant sanitizer state hoisted to module scope — it was
46
+ // rebuilt (12 normalize+toLowerCase + Set + 3 closures) on EVERY hook execution.
47
+ const POLLUTED_KEYS = new Set(
48
+ [
49
+ "__proto__",
50
+ "constructor",
51
+ "prototype",
52
+ "hasOwnProperty",
53
+ "toString",
54
+ "valueOf",
55
+ "isPrototypeOf",
56
+ "propertyIsEnumerable",
57
+ "__defineGetter__",
58
+ "__defineSetter__",
59
+ "__lookupGetter__",
60
+ "__lookupSetter__",
61
+ ].map((k) => k.toLowerCase().normalize("NFKC")),
62
+ );
63
+ function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
64
+ const clean: Record<string, unknown> = {};
65
+ for (const [k, v] of Object.entries(data)) {
66
+ if (!POLLUTED_KEYS.has(k.toLowerCase().normalize("NFKC"))) {
67
+ if (v !== null && typeof v === "object") {
68
+ if (Array.isArray(v)) {
69
+ // Sanitize array elements that are objects
70
+ clean[k] = v.map((item) =>
71
+ item !== null && typeof item === "object" && !Array.isArray(item)
72
+ ? sanitizeMergeData(item as Record<string, unknown>)
73
+ : item,
74
+ );
75
+ } else {
76
+ clean[k] = sanitizeMergeData(v as Record<string, unknown>);
77
+ }
78
+ } else {
79
+ clean[k] = v;
80
+ }
81
+ }
82
+ }
83
+ return clean;
84
+ }
85
+ // Sanitize ctx by stripping dangerous property names before passing to handlers.
86
+ // Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS]
87
+ // This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
88
+ function sanitizeContext(ctx: HookContext): HookContext {
89
+ for (const key of Object.keys(ctx)) {
90
+ if (POLLUTED_KEYS.has(key.toLowerCase().normalize("NFKC"))) {
91
+ delete ctx[key];
92
+ }
93
+ }
94
+ return ctx;
95
+ }
96
+ function sanitizeErrorMessage(message: string): string {
97
+ // Remove file paths, environment variable references, and other potentially sensitive data
98
+ return message
99
+ .replace(/\/[^:\s]+/g, "[path]")
100
+ .replace(/\b[A-Z_0-9]+\s*=/g, "[env]")
101
+ .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]");
102
+ }
103
+
45
104
  export async function executeHook(name: HookName, ctx: HookContext): Promise<HookExecutionReport> {
46
105
  const hooks = getHooks(name);
47
106
  if (hooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
@@ -62,62 +121,6 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
62
121
  return ctx.includeGlobalHooks !== false;
63
122
  });
64
123
  if (scopedHooks.length === 0) return { hookName: name, outcome: "allow", durationMs: 0 };
65
- const POLLUTED_KEYS = new Set(
66
- [
67
- "__proto__",
68
- "constructor",
69
- "prototype",
70
- "hasOwnProperty",
71
- "toString",
72
- "valueOf",
73
- "isPrototypeOf",
74
- "propertyIsEnumerable",
75
- "__defineGetter__",
76
- "__defineSetter__",
77
- "__lookupGetter__",
78
- "__lookupSetter__",
79
- ].map((k) => k.toLowerCase().normalize("NFKC")),
80
- );
81
- function sanitizeMergeData(data: Record<string, unknown>): Record<string, unknown> {
82
- const clean: Record<string, unknown> = {};
83
- for (const [k, v] of Object.entries(data)) {
84
- if (!POLLUTED_KEYS.has(k.toLowerCase().normalize("NFKC"))) {
85
- if (v !== null && typeof v === "object") {
86
- if (Array.isArray(v)) {
87
- // Sanitize array elements that are objects
88
- clean[k] = v.map((item) =>
89
- item !== null && typeof item === "object" && !Array.isArray(item)
90
- ? sanitizeMergeData(item as Record<string, unknown>)
91
- : item,
92
- );
93
- } else {
94
- clean[k] = sanitizeMergeData(v as Record<string, unknown>);
95
- }
96
- } else {
97
- clean[k] = v;
98
- }
99
- }
100
- }
101
- return clean;
102
- }
103
- // Sanitize ctx by stripping dangerous property names before passing to handlers.
104
- // Hook authors must NOT set these keys directly on ctx: [...POLLUTED_KEYS]
105
- // This sanitization runs at the start of executeHook to prevent prototype pollution attacks.
106
- function sanitizeContext(ctx: HookContext): HookContext {
107
- for (const key of Object.keys(ctx)) {
108
- if (POLLUTED_KEYS.has(key.toLowerCase().normalize("NFKC"))) {
109
- delete ctx[key];
110
- }
111
- }
112
- return ctx;
113
- }
114
- function sanitizeErrorMessage(message: string): string {
115
- // Remove file paths, environment variable references, and other potentially sensitive data
116
- return message
117
- .replace(/\/[^:\s]+/g, "[path]")
118
- .replace(/\b[A-Z_0-9]+\s*=/g, "[env]")
119
- .replace(/\b\d+\.\d+\.\d+\.\d+\b/g, "[ip]");
120
- }
121
124
  const start = Date.now();
122
125
  const diagnostics: string[] = [];
123
126
  let capturedModifications: Record<string, unknown> | undefined;
@@ -176,6 +179,9 @@ export async function executeHook(name: HookName, ctx: HookContext): Promise<Hoo
176
179
  }
177
180
 
178
181
  export function appendHookEvent(manifest: TeamRunManifest, report: HookExecutionReport): void {
182
+ // REVIEW FIX (2026-09-10): reverted M2b buffered conversion — hook.executed
183
+ // events are read back synchronously (recovery-hooks tests, hooks audit
184
+ // display) and are low-frequency (per hook execution).
179
185
  appendEvent(manifest.eventsPath, {
180
186
  type: "hook.executed",
181
187
  runId: manifest.runId,
@@ -0,0 +1,90 @@
1
+ /**
2
+ * inbox-poll.ts — Task 5 (SDD 2026-08-26-loadout-nesting-messaging) worker
3
+ * inbox pickup.
4
+ *
5
+ * The worker-side `message` tool (D9 / §15.2) writes durable mailbox
6
+ * entries (`kind:"message"` | `"notify"`). This module is the RECEIVE side:
7
+ * the poll loop that shares the ask/delegate cadence picks up new
8
+ * `kind:"message"` entries addressed to THIS task and surfaces them as
9
+ * fenced context at the next turn boundary via `pi.sendMessage` with
10
+ * `deliverAs:"steer"`.
11
+ *
12
+ * Contract (mirrors the broker's recipient resolution in handleMsgSend):
13
+ * - only `kind:"message"` enters the pickup (notify → fire-and-forget,
14
+ * steer/response/follow-up → other channels);
15
+ * - only entries whose mailbox task is THIS worker (`taskId === this`):
16
+ * sibling DMs are written to the sibling's task mailbox, group-broadcast
17
+ * recipient copies are written to each recipient's task mailbox, and
18
+ * `to:"parent"` reports land in the run-level inbox (taskId undefined)
19
+ * which is ORCHESTRATOR territory — a worker must never read it;
20
+ * - a worker must never pick up a message whose `from` is itself (§15.3
21
+ * anti-spoof / self-echo: the broker overrides `from` to the sender's
22
+ * authenticated taskId, so a group broadcast returns to its sender and
23
+ * must be dropped at consume time);
24
+ * - dedup by message id across polls via a caller-owned seen-set and/or a
25
+ * `sinceTs` watermark — one message delivers once.
26
+ */
27
+ import { type MailboxMessage, readAllMailboxMessages } from "../state/coordination/mailbox.ts";
28
+ import type { TeamRunManifest } from "../state/types.ts";
29
+
30
+ export interface WorkerInboxPickup {
31
+ stateRoot: string;
32
+ runId: string;
33
+ taskId: string;
34
+ /** Watermark: only messages with `createdAt > sinceTs` are considered. */
35
+ sinceTs?: string;
36
+ /** Mutable seen-id set for cross-poll dedup (the poll loop owns it). */
37
+ seenIds?: Set<string>;
38
+ }
39
+
40
+ /**
41
+ * Read the worker's inbox mailbox and return the messages that should be
42
+ * surfaced as fenced context on the next turn.
43
+ *
44
+ * Stateless apart from the optional caller-owned `seenIds`/`sinceTs` — safe
45
+ * to call on every 500ms poll tick.
46
+ */
47
+ export function pollWorkerInbox(pickup: WorkerInboxPickup): MailboxMessage[] {
48
+ const { stateRoot, runId, taskId } = pickup;
49
+ if (!stateRoot || !runId || !taskId) return [];
50
+ const manifest = { stateRoot, runId } as unknown as TeamRunManifest;
51
+ let messages: MailboxMessage[];
52
+ try {
53
+ // readAllMailboxMessages already merges run-level inbox + every task
54
+ // mailbox; we route TO this worker below (never trust a caller-scoped
55
+ // file path).
56
+ messages = readAllMailboxMessages(manifest, "inbox");
57
+ } catch {
58
+ // Transient read error (lock contention, rotated file) — never throw
59
+ // out of a poll tick; the next 500ms tick retries.
60
+ return [];
61
+ }
62
+
63
+ const seen = pickup.seenIds;
64
+ const picked: MailboxMessage[] = [];
65
+ const byId = new Set<string>();
66
+ for (const m of messages) {
67
+ // Kind gate: only durable `message`s (per §15.2). Notify is
68
+ // fire-and-forget (its own channel), steer/response/follow-up are
69
+ // other delivery paths.
70
+ if (m.kind !== "message") continue;
71
+ // §15.3 self-echo: a broadcast the worker itself sent must not
72
+ // re-surface on its own next turn.
73
+ if (m.from === taskId) continue;
74
+ // Routing: the entry must be in THIS task's mailbox — never the
75
+ // run-level inbox (orchestrator's parent channel) nor a sibling's.
76
+ if (m.taskId !== taskId) continue;
77
+ if (m.status === "acknowledged") continue;
78
+ // sinceTs watermark (ISO string compare).
79
+ if (pickup.sinceTs !== undefined && m.createdAt <= pickup.sinceTs) continue;
80
+ // Cross-call seen-set dedup.
81
+ if (seen?.has(m.id)) continue;
82
+ // Within-call id dedup (duplicate file rows → one delivery).
83
+ if (byId.has(m.id)) continue;
84
+ byId.add(m.id);
85
+ seen?.add(m.id);
86
+ picked.push(m);
87
+ }
88
+ // Deterministic delivery order (same sort readAllMailboxMessages applies).
89
+ return picked;
90
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * message-tool.ts — D9 / §15.2 worker-side `message` tool.
3
+ *
4
+ * The worker's NON-BLOCKING outbound channel (unlike `ask`, this never parks
5
+ * the task). Three targets:
6
+ * - `to: "parent"` → notify the orchestrator (run-level inbox read by the
7
+ * main session; wake-pattern inject happens host-side, see §15.2).
8
+ * - `to: <taskId>` → DM a sibling task.
9
+ * - `to: "group"` → broadcast every worker that did group_join.
10
+ *
11
+ * Delivery is the broker `msg.send` method (already server-side role-gated
12
+ * since Step 0 — workers may send with `from` always overridden to their
13
+ * authenticated taskId, `to` restricted to parent/sibling/group, kind
14
+ * notify|message). A broker outage falls back to the same "unavailable"
15
+ * notice the ask/delegate tools return — never a hang.
16
+ *
17
+ * Dormant-until-env (ask/delegate precedent): registered ONLY when
18
+ * `PI_CREW_MSG_ENABLED === "1"` (child-pi-spawn sets it unconditionally); a
19
+ * layer-2 dormant check re-verifies inside execute. Tests inject a mock
20
+ * broker client directly, which bypasses the env gate.
21
+ *
22
+ * Local rate-limit: 10 messages/minute per tool instance (sliding window) —
23
+ * the 11th within the window returns a `rate-limited` warning instead of
24
+ * sending, so two workers cannot loop message each other to death.
25
+ */
26
+
27
+ import { type Static, Type } from "@sinclair/typebox";
28
+ import { getCrewEnv } from "../config/env-vars.ts";
29
+ import { CrewBrokerClient } from "../runtime/broker/crew-broker-client.ts";
30
+
31
+ export const PI_CREW_MSG_ENABLED_ENV = "PI_CREW_MSG_ENABLED";
32
+
33
+ /** D9 §15.3: body length cap mirrors the ask/delegate bounded-payload bounds;
34
+ * the broker additionally enforces the 256 KiB frame cap server-side. */
35
+ const MSG_BODY_MAX_CHARS = 8192;
36
+ const MSG_SUBJECT_MAX_CHARS = 256;
37
+ const MSG_TO_MAX_CHARS = 256;
38
+ /** §15.2 rate-limit: 10 messages/min/task, sliding window. */
39
+ const MSG_RATE_LIMIT_MAX = 10;
40
+ const MSG_RATE_LIMIT_WINDOW_MS = 60_000;
41
+
42
+ export const MessageToolParamsSchema = Type.Object({
43
+ to: Type.Union([Type.Literal("parent"), Type.Literal("group"), Type.String({ minLength: 1, maxLength: MSG_TO_MAX_CHARS })]),
44
+ kind: Type.Union([Type.Literal("notify"), Type.Literal("message")]),
45
+ subject: Type.Optional(Type.String({ minLength: 1, maxLength: MSG_SUBJECT_MAX_CHARS })),
46
+ body: Type.String({ minLength: 1, maxLength: MSG_BODY_MAX_CHARS }),
47
+ priority: Type.Optional(Type.Union([Type.Literal("urgent"), Type.Literal("normal"), Type.Literal("low")])),
48
+ });
49
+ export type MessageToolParams = Static<typeof MessageToolParamsSchema>;
50
+
51
+ /** Minimal broker-client surface the message tool needs (structural subset of
52
+ * CrewBrokerClient — tests substitute a recorder). */
53
+ export interface MessageBrokerClientSurface {
54
+ request(method: string, params: unknown): Promise<{ ok: true; value: unknown } | { ok: false; fallback?: boolean; errorCode?: string }>;
55
+ close?(): Promise<void>;
56
+ }
57
+
58
+ export interface MessageToolDeps {
59
+ /** Env source override (tests). Production reads via getCrewEnv. */
60
+ env?: NodeJS.ProcessEnv;
61
+ /** Test seam / injectable clock for the 10/min sliding window. */
62
+ now?: () => number;
63
+ /** Test seam: replace the per-call broker client. */
64
+ makeBrokerClient?: (o: { runId: string; taskId: string; socketPath: string; token: string }) => MessageBrokerClientSurface;
65
+ }
66
+
67
+ export interface MessageTool {
68
+ name: "message";
69
+ description: string;
70
+ inputSchema: object;
71
+ execute: (params: MessageToolParams) => Promise<{ status: string; text: string }>;
72
+ }
73
+
74
+ /** Layer-1 dormant-until-env gate (ask precedent: default-param env — reads
75
+ * the injected env object, never a raw process.env.PI_CREW_* member, so the
76
+ * check:env-vars gate stays green). */
77
+ export function shouldRegisterMessageTool(env: NodeJS.ProcessEnv = process.env): boolean {
78
+ return env[PI_CREW_MSG_ENABLED_ENV] === "1";
79
+ }
80
+
81
+ export function createMessageTool(deps: MessageToolDeps = {}): MessageTool {
82
+ const now = deps.now ?? Date.now;
83
+ const get = (name: string): string | undefined => (deps.env ? deps.env[name] : getCrewEnv(name));
84
+ // Sliding window of send timestamps (ms). Pruned lazily on each send.
85
+ const sentAt: number[] = [];
86
+
87
+ /** Layer-2 dormant check: registered-only-when-env is the primary gate;
88
+ * an injected mock client (tests) deliberately bypasses it so the core
89
+ * send path is exercised without env plumbing. */
90
+ const isActive = (): boolean => get(PI_CREW_MSG_ENABLED_ENV) === "1" || deps.makeBrokerClient !== undefined;
91
+
92
+ /** True when the 10/min window is exhausted (the caller skips the send). */
93
+ const isRateLimited = (): boolean => {
94
+ const t = now();
95
+ while (sentAt.length > 0 && t - sentAt[0]! >= MSG_RATE_LIMIT_WINDOW_MS) sentAt.shift();
96
+ if (sentAt.length >= MSG_RATE_LIMIT_MAX) return true;
97
+ sentAt.push(t);
98
+ return false;
99
+ };
100
+
101
+ const brokerUnavailable = (): { status: string; text: string } => ({
102
+ status: "unavailable",
103
+ text: "[message] broker unavailable — include the note in your final result instead.",
104
+ });
105
+
106
+ return {
107
+ name: "message",
108
+ description:
109
+ "Send a non-blocking message: notify the orchestrator of progress/risks (`to:'parent'`), DM another worker by task id, or broadcast the group. Unlike `ask`, this never waits.",
110
+ inputSchema: MessageToolParamsSchema,
111
+ async execute(params) {
112
+ if (!isActive()) {
113
+ return {
114
+ status: "unavailable",
115
+ text: "[message] is dormant in this worker (PI_CREW_MSG_ENABLED not set) — include the note in your final result instead.",
116
+ };
117
+ }
118
+ if (isRateLimited()) {
119
+ // §15.2: 10 messages/min/task; a warning instead of a silent drop
120
+ // keeps the model informed the note was NOT delivered.
121
+ return {
122
+ status: "rate-limited",
123
+ text: "[message] rate-limited (10 messages/minute) — this message was NOT sent; include the note in your final result instead.",
124
+ };
125
+ }
126
+ const runId = get("PI_CREW_BROKER_RUN_ID") ?? "";
127
+ const taskId = get("PI_CREW_TASK_ID") ?? get("PI_CREW_BROKER_TASK_ID") ?? "";
128
+ const socketPath = get("PI_CREW_BROKER_SOCKET") ?? "";
129
+ const token = get("PI_CREW_BROKER_TOKEN") ?? "";
130
+ const client = deps.makeBrokerClient
131
+ ? deps.makeBrokerClient({ runId, taskId, socketPath, token })
132
+ : (() => {
133
+ // Production: the broker credentials are only present for
134
+ // broker-eligible workers (child-pi-spawn). Scaffold/mock
135
+ // workers fast-fail with a structured notice, never a hang.
136
+ if (!runId || !taskId || !socketPath || !token) return null;
137
+ return new CrewBrokerClient({ runId, taskId, socketPath, token });
138
+ })();
139
+ if (!client) return brokerUnavailable();
140
+ const requestParams: Record<string, unknown> = { to: params.to, kind: params.kind, body: params.body };
141
+ if (params.subject) requestParams.subject = params.subject;
142
+ if (params.priority) requestParams.priority = params.priority;
143
+ try {
144
+ const sent = await client.request("msg.send", requestParams);
145
+ if (!sent.ok) {
146
+ // Broker rejection (role gate / policy / auth / fallback) — all
147
+ // fast-fail, non-blocking.
148
+ return brokerUnavailable();
149
+ }
150
+ return {
151
+ status: "sent",
152
+ text: `Message (${params.kind}) delivered to '${params.to}'.`,
153
+ };
154
+ } catch (error) {
155
+ void error;
156
+ return brokerUnavailable();
157
+ } finally {
158
+ // Only the production-created client needs closing; an injected
159
+ // test recorder owns its own lifecycle.
160
+ if (!deps.makeBrokerClient && client.close) {
161
+ void client.close().catch(() => undefined);
162
+ }
163
+ }
164
+ },
165
+ };
166
+ }