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