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.
- package/AGENTS.md +2 -1
- package/CHANGELOG.md +249 -0
- package/README.md +5 -1
- package/dist/index.mjs +10844 -7250
- package/docs/architecture.md +4 -4
- package/docs/commands-reference.md +3 -0
- package/docs/publishing.md +15 -3
- package/install.mjs +90 -39
- package/package.json +9 -3
- package/schema.json +11 -0
- package/scripts/README.md +4 -3
- package/skills/real-test-pi-crew/REPORT-TEMPLATE.md +7 -2
- package/skills/real-test-pi-crew/SKILL.md +428 -82
- package/src/config/config-merge.ts +11 -1
- package/src/config/config-validation.ts +40 -1
- package/src/config/config.ts +28 -6
- package/src/config/defaults.ts +35 -10
- package/src/config/env-vars.ts +27 -2
- package/src/config/migration-validator.ts +113 -0
- package/src/config/types.ts +36 -0
- package/src/extension/cross-extension-rpc.ts +3 -7
- package/src/extension/register.ts +13 -0
- package/src/extension/registration/lifecycle-handlers.ts +40 -9
- package/src/extension/registration/observability.ts +3 -7
- package/src/extension/registration/subagent-tools.ts +3 -7
- package/src/extension/registration/team-tool.ts +56 -12
- package/src/extension/registration/ui.ts +3 -8
- package/src/extension/registration/viewers.ts +3 -10
- package/src/extension/team-manager-command.ts +3 -7
- package/src/extension/team-tool/api/agent-control.ts +17 -10
- package/src/extension/team-tool/api/heartbeat.ts +4 -3
- package/src/extension/team-tool/api/mailbox.ts +33 -20
- package/src/extension/team-tool/api/plan-approval.ts +5 -5
- package/src/extension/team-tool/api/task-claims.ts +8 -7
- package/src/extension/team-tool/cancel.ts +6 -0
- package/src/extension/team-tool/doctor.ts +364 -7
- package/src/extension/team-tool/handle-settings.ts +23 -1
- package/src/extension/team-tool/inspect.ts +10 -2
- package/src/extension/team-tool/run.ts +3 -7
- package/src/extension/team-tool/status.ts +12 -0
- package/src/extension/team-tool.ts +41 -16
- package/src/hooks/registry.ts +62 -56
- package/src/prompt/inbox-poll.ts +90 -0
- package/src/prompt/message-tool.ts +166 -0
- package/src/prompt/prompt-runtime.ts +201 -18
- package/src/prompt/scratchpad-lifecycle.ts +3 -3
- package/src/prompt/surface-worker.ts +720 -0
- package/src/prompt/worker-events-channel.ts +49 -3
- package/src/runtime/async-runner.ts +29 -1
- package/src/runtime/background-runner.ts +43 -42
- package/src/runtime/broker/broker-issuer.ts +27 -2
- package/src/runtime/broker/crew-broker-tokens.ts +56 -4
- package/src/runtime/broker/crew-broker.ts +334 -443
- package/src/runtime/broker/delegate/delegate-event.ts +37 -0
- package/src/runtime/broker/mailbox-observer/mailbox-fanout.ts +59 -0
- package/src/runtime/broker/protocol/connection-state.ts +103 -0
- package/src/runtime/broker/protocol/events-replay.ts +68 -0
- package/src/runtime/broker/protocol/manifest-loader.ts +20 -0
- package/src/runtime/broker/protocol/msg-inbox.ts +69 -0
- package/src/runtime/broker/protocol/request-parsers.ts +175 -0
- package/src/runtime/broker/protocol/wait-auth.ts +46 -0
- package/src/runtime/child-pi/child-pi-spawn.ts +23 -9
- package/src/runtime/child-pi/child-pi-streams.ts +9 -1
- package/src/runtime/child-pi/child-pi.ts +368 -5
- package/src/runtime/crew-agent-records.ts +13 -1
- package/src/runtime/dispatch-batch.ts +12 -1
- package/src/runtime/event-log-tail-source.ts +374 -0
- package/src/runtime/finalize-run.ts +19 -7
- package/src/runtime/foreground-control.ts +19 -6
- package/src/runtime/goal-workflow/dynamic-workflow-context.ts +6 -0
- package/src/runtime/goal-workflow/dynamic-workflow-runner.ts +3 -0
- package/src/runtime/goal-workflow/goal-loop-runner.ts +29 -27
- package/src/runtime/goal-workflow/goal-state-store.ts +3 -0
- package/src/runtime/heartbeat/heartbeat-watcher.ts +3 -3
- package/src/runtime/live-session/live-agent-manager.ts +34 -1
- package/src/runtime/live-session/live-control-realtime.ts +10 -0
- package/src/runtime/live-session/live-session-runtime.ts +47 -27
- package/src/runtime/manifest-cache.ts +128 -17
- package/src/runtime/model/pi-args.ts +59 -65
- package/src/runtime/output/sidechain-output.ts +61 -6
- package/src/runtime/plan-replan.ts +3 -0
- package/src/runtime/process/proc-stat.ts +46 -0
- package/src/runtime/process/zombie-scanner.ts +32 -19
- package/src/runtime/spawn-policy.ts +27 -41
- package/src/runtime/stale-reconciler.ts +28 -3
- package/src/runtime/supervisor-contact.ts +3 -0
- package/src/runtime/surface/degrade.ts +776 -0
- package/src/runtime/surface/herdr-provider.ts +546 -0
- package/src/runtime/surface/launch-script.ts +172 -0
- package/src/runtime/surface/resolve-surface.ts +274 -0
- package/src/runtime/surface/surface-provider.ts +129 -0
- package/src/runtime/surface/surface-spawn.ts +475 -0
- package/src/runtime/surface/tmux-provider.ts +400 -0
- package/src/runtime/task-runner/child-executor.ts +80 -0
- package/src/runtime/task-runner/post-execution.ts +57 -2
- package/src/runtime/task-runner/prompt-builder.ts +1 -0
- package/src/runtime/task-runner/retrieval-orchestrator.ts +191 -56
- package/src/runtime/task-runner/state-helpers.ts +54 -30
- package/src/runtime/task-runner.ts +4 -2
- package/src/runtime/team-runner.ts +104 -3
- package/src/schema/config-schema.ts +24 -0
- package/src/state/atomic-write.ts +219 -40
- package/src/state/coordination/locks.ts +7 -5
- package/src/state/coordination/mailbox.ts +56 -10
- package/src/state/event-log/cursor.ts +413 -23
- package/src/state/event-log/event-log.ts +120 -113
- package/src/state/event-log/sequence-cache.ts +21 -3
- package/src/state/stores/ownership-map.ts +5 -4
- package/src/state/stores/plan-store.ts +12 -0
- package/src/state/stores/state-store.ts +103 -6
- package/src/state/types.ts +51 -0
- package/src/ui/inline-panel/agent-pane.ts +3 -0
- package/src/ui/powerbar-publisher.ts +3 -7
- package/src/ui/render-diff.ts +16 -8
- package/src/ui/run-action-dispatcher.ts +7 -10
- package/src/ui/run-dashboard.ts +87 -42
- package/src/ui/run-event-bus.ts +10 -1
- package/src/ui/run-snapshot-cache.ts +83 -35
- package/src/ui/settings-overlay.ts +4 -1
- package/src/ui/transcript-cache.ts +101 -13
- package/src/ui/transcript-viewer.ts +92 -24
- package/src/ui/widget/index.ts +32 -8
- package/src/utils/visual.ts +43 -0
- package/src/worktree/worktree-manager.ts +65 -4
package/src/hooks/registry.ts
CHANGED
|
@@ -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
|
+
}
|