pi-crew 0.9.46 → 0.9.48

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 (46) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +16 -2
  3. package/dist/build-meta.json +289 -164
  4. package/dist/index.mjs +1744 -2732
  5. package/dist/index.mjs.map +4 -4
  6. package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
  7. package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
  8. package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
  9. package/docs/decisions/README.md +3 -0
  10. package/docs/publishing.md +29 -0
  11. package/package.json +3 -1
  12. package/scripts/build-bundle.mjs +7 -0
  13. package/scripts/postinstall.mjs +60 -1
  14. package/scripts/pty_probe.py +174 -0
  15. package/skills/real-test-pi-crew/SKILL.md +659 -0
  16. package/src/config/config.ts +42 -1
  17. package/src/config/defaults.ts +45 -1
  18. package/src/config/types.ts +19 -0
  19. package/src/extension/register.ts +6 -1
  20. package/src/extension/registration/context-builder.ts +4 -0
  21. package/src/extension/registration/lifecycle-handlers.ts +166 -3
  22. package/src/extension/registration/registration-types.ts +9 -0
  23. package/src/prompt/prompt-runtime.ts +108 -0
  24. package/src/runtime/broker-issuer.ts +37 -0
  25. package/src/runtime/child-pi-spawn.ts +53 -0
  26. package/src/runtime/child-pi.ts +42 -11
  27. package/src/runtime/crew-broker-child.ts +88 -0
  28. package/src/runtime/crew-broker-client.ts +673 -0
  29. package/src/runtime/crew-broker-tokens.ts +84 -0
  30. package/src/runtime/crew-broker.ts +1276 -0
  31. package/src/runtime/dynamic-workflow-context.ts +7 -3
  32. package/src/runtime/dynamic-workflow-runner.ts +1 -1
  33. package/src/runtime/plan-templates.ts +8 -6
  34. package/src/schema/config-schema.ts +14 -0
  35. package/src/state/mailbox.ts +43 -0
  36. package/src/ui/key-utils.ts +42 -0
  37. package/src/ui/keybinding-map.ts +29 -3
  38. package/src/ui/run-dashboard.ts +28 -0
  39. package/src/ui/settings-overlay.ts +42 -22
  40. package/src/utils/ndjson.ts +115 -0
  41. package/src/utils/session-utils.ts +30 -0
  42. package/src/utils/socket-path.ts +127 -0
  43. package/workflows/default.workflow.md +1 -1
  44. package/workflows/fast-fix.workflow.md +1 -1
  45. package/workflows/plan-execute.workflow.md +1 -1
  46. package/workflows/review.workflow.md +1 -1
@@ -8,6 +8,7 @@ import { atomicWriteFile } from "../state/atomic-write.ts";
8
8
  import { withFileLockSync } from "../state/locks.ts";
9
9
  import { logInternalError } from "../utils/internal-error.ts";
10
10
  import { projectCrewRoot, projectPiRoot } from "../utils/paths.ts";
11
+ import { DEFAULT_BROKER, resolveBrokerEnvOverride } from "./defaults.ts";
11
12
  import { suggestConfigKey } from "./suggestions.ts";
12
13
 
13
14
  // 2.9: interface types extracted to ./types.ts; re-export for back-compat.
@@ -44,6 +45,7 @@ import type {
44
45
  AgentOverrideConfig,
45
46
  ConfigValidationResult,
46
47
  CrewAgentsConfig,
48
+ CrewBrokerConfig,
47
49
  CrewControlConfig,
48
50
  CrewLimitsConfig,
49
51
  CrewNotificationsConfig,
@@ -744,6 +746,38 @@ function parseControlConfig(value: unknown): CrewControlConfig | undefined {
744
746
  return Object.values(control).some((entry) => entry !== undefined) ? control : undefined;
745
747
  }
746
748
 
749
+ /**
750
+ * Phase 0 broker parser. Returns `undefined` only when input is not an object;
751
+ * otherwise returns the broker config (with only defined fields populated)
752
+ * so the caller can layer defaults on top.
753
+ */
754
+ function parseBrokerConfig(value: unknown): CrewBrokerConfig | undefined {
755
+ const obj = asRecord(value);
756
+ if (!obj) return undefined;
757
+ // Use the exact schema bounds (4..32 / 1024..1048576 / 32..4096). The
758
+ // previous version used parsePositiveInteger(value, default) which clamps
759
+ // the UPPER bound to the default — effectively pathHashLen was capped
760
+ // at 8, much narrower than the schema advertises.
761
+ const broker: CrewBrokerConfig = {
762
+ enabled: parseWithSchema(Type.Boolean(), obj.enabled),
763
+ pathHashLen: parseIntegerInRange(obj.pathHashLen, 4, 32),
764
+ maxFrameBytes: parseIntegerInRange(obj.maxFrameBytes, 1024, 1_048_576),
765
+ outboundQueueCap: parseIntegerInRange(obj.outboundQueueCap, 32, 4096),
766
+ };
767
+ return Object.values(broker).some((entry) => entry !== undefined) ? broker : undefined;
768
+ }
769
+
770
+ /**
771
+ * Apply PI_CREW_BROKER env override to the parsed broker config, then
772
+ * layer in DEFAULT_BROKER for any field the user did not set. Keeps the
773
+ * kill switch (enabled:false) reachable in three independent ways: env,
774
+ * config block, or default.
775
+ */
776
+ function applyBrokerEnvOverrideAndDefaults(parsed: CrewBrokerConfig | undefined): CrewBrokerConfig {
777
+ const envOverridden = resolveBrokerEnvOverride(parsed);
778
+ return { ...DEFAULT_BROKER, ...envOverridden };
779
+ }
780
+
747
781
  function parseWorktreeConfig(value: unknown): CrewWorktreeConfig | undefined {
748
782
  const obj = asRecord(value);
749
783
  if (!obj) return undefined;
@@ -1019,6 +1053,7 @@ export function parseConfig(raw: unknown): PiTeamsConfig {
1019
1053
  reliability: parseReliabilityConfig(obj.reliability),
1020
1054
  otlp: parseOtlpConfig(obj.otlp),
1021
1055
  ui: parseUiConfig(obj.ui),
1056
+ broker: parseBrokerConfig(obj.broker),
1022
1057
  };
1023
1058
  }
1024
1059
 
@@ -1164,7 +1199,13 @@ export function loadConfig(cwd?: string): LoadedPiTeamsConfig {
1164
1199
  const result: LoadedPiTeamsConfig = {
1165
1200
  path: filePath,
1166
1201
  paths,
1167
- config,
1202
+ config: {
1203
+ ...config,
1204
+ // Phase 0 broker: layer in env override + defaults. Env wins over
1205
+ // config; defaults fill any missing field. Env `"1"`/`"0"` forces
1206
+ // the enabled flag even when no broker block is configured.
1207
+ broker: applyBrokerEnvOverrideAndDefaults(config.broker),
1208
+ },
1168
1209
  warnings: warnings.length > 0 ? warnings : undefined,
1169
1210
  };
1170
1211
  // Only cache when at least one of the watched paths exists — this avoids
@@ -1,3 +1,5 @@
1
+ import type { CrewBrokerConfig } from "./types.ts";
2
+
1
3
  export const DEFAULT_CHILD_PI: Readonly<{
2
4
  postExitStdioGuardMs: number;
3
5
  finalDrainMs: number;
@@ -20,7 +22,7 @@ export const DEFAULT_CHILD_PI: Readonly<{
20
22
  hardKillMs: 3000,
21
23
  // Child workers can spend more than a few seconds in provider calls or long-running tools without emitting stdout.
22
24
  // Keep this as a coarse stuck-worker guard rather than a short per-message latency budget.
23
- responseTimeoutMs: 5 * 60_000,
25
+ responseTimeoutMs: 10 * 60_000,
24
26
  // #3 unresponsive worker hardening: increased from 256KB to 512KB so critical
25
27
  // diagnostic stderr is less likely to be silently truncated during hang analysis.
26
28
  maxCaptureBytes: 512 * 1024,
@@ -147,3 +149,45 @@ export const DEFAULT_MAILBOX = {
147
149
  export const DEFAULT_SUBAGENT = {
148
150
  stuckBlockedNotifyMs: 5 * 60_000,
149
151
  };
152
+
153
+ /**
154
+ * Phase 0 inter-pi broker defaults.
155
+ * Phase 4 (v0.9.47): default is ON (enabled:true). The broker runs
156
+ * automatically for users on supported platforms (Linux + macOS).
157
+ * Three independent ways to disable:
158
+ * 1. `broker.enabled: false` in user config
159
+ * 2. env `PI_CREW_BROKER=0` (beats config=true)
160
+ * 3. (Windows) auto-disabled — broker requires unix socket which Windows
161
+ * supports only via WSL1/2; native Windows perm model lacks the
162
+ * abstract-socket guarantees the broker relies on. See
163
+ * docs/decisions/2026-07-21-broker-windows-perms.md.
164
+ * Limits are bounded by `src/schema/config-schema.ts` CrewBrokerConfigSchema:
165
+ * pathHashLen 4..32 (default 8)
166
+ * maxFrameBytes 1024..1048576 (default 262144 = 256 KiB)
167
+ * outboundQueueCap 32..4096 (default 256)
168
+ */
169
+ export const DEFAULT_BROKER = {
170
+ enabled: true,
171
+ pathHashLen: 8,
172
+ maxFrameBytes: 262144,
173
+ outboundQueueCap: 256,
174
+ } as const;
175
+
176
+ /**
177
+ * Apply `PI_CREW_BROKER` env override to a parsed broker config.
178
+ * - `"1"` forces `enabled: true` (beats a config of `false` AND the
179
+ * default of `false` when no broker block is configured).
180
+ * - `"0"` forces `enabled: false` (beats a config of `true`).
181
+ * - unset / any other value falls through to the parsed value (which
182
+ * may itself be `undefined`; the loadConfig merge layer fills defaults).
183
+ * Phase 0 scope: only the `enabled` flag is overridable via env; numeric
184
+ * bounds must go through the schema + parser path, not env.
185
+ */
186
+ export function resolveBrokerEnvOverride(parsed: CrewBrokerConfig | undefined): CrewBrokerConfig | undefined {
187
+ const override = process.env.PI_CREW_BROKER;
188
+ if (override === "1" || override === "0") {
189
+ const base: CrewBrokerConfig = parsed ?? {};
190
+ return { ...base, enabled: override === "1" };
191
+ }
192
+ return parsed;
193
+ }
@@ -224,6 +224,25 @@ export interface PiTeamsConfig {
224
224
  reliability?: CrewReliabilityConfig;
225
225
  otlp?: CrewOtlpConfig;
226
226
  ui?: CrewUiConfig;
227
+ /**
228
+ * Inter-pi broker (Phase 0). Local-only socket transport between parent
229
+ * and child pi workers. Default is OFF (`enabled:false`) — keep it that
230
+ * way until Phase 4 soak completes. Numeric limits are bounded by the
231
+ * schema (`src/schema/config-schema.ts`).
232
+ */
233
+ broker?: CrewBrokerConfig;
234
+ }
235
+
236
+ /** CrewBroker config (Phase 0 inter-pi broker). */
237
+ export interface CrewBrokerConfig {
238
+ /** Master switch. `false` keeps the broker fully dormant. */
239
+ enabled?: boolean;
240
+ /** Length of the SHA-256 hex prefix used in the socket filename. 4..32. */
241
+ pathHashLen?: number;
242
+ /** Maximum NDJSON frame size in UTF-8 bytes (default 262144 = 256 KiB). 1024..1048576. */
243
+ maxFrameBytes?: number;
244
+ /** Per-connection outbound queue cap. 32..4096 (default 256). */
245
+ outboundQueueCap?: number;
227
246
  }
228
247
 
229
248
  export interface LoadedPiTeamsConfig {
@@ -36,7 +36,7 @@ import { importCrashRecovery, purgeStaleActiveRunIndexSyncIfLoaded } from "./reg
36
36
  import { installForegroundRunController } from "./registration/foreground-run-controller.ts";
37
37
  import { installPiHooks } from "./registration/hook-registration.ts";
38
38
  import { installLazyConfigurers } from "./registration/lazy-configurers.ts";
39
- import { installSessionLifecycleHandlers } from "./registration/lifecycle-handlers.ts";
39
+ import { installCrewBrokerLifecycleController, installSessionLifecycleHandlers } from "./registration/lifecycle-handlers.ts";
40
40
  import { installRuntimeCleanup } from "./registration/runtime-cleanup.ts";
41
41
  import { __test__subagentSpawnParams } from "./registration/subagent-helpers.ts";
42
42
  import { installSubagentManager } from "./registration/subagent-manager-setup.ts";
@@ -73,6 +73,11 @@ export function registerPiTeams(pi: ExtensionAPI): void {
73
73
  registerPiCommands(pi, ctx);
74
74
  installPiHooks(pi, ctx);
75
75
  installSessionLifecycleHandlers(pi, ctx);
76
+ // Phase 0 inter-pi broker: install the lifecycle controller immediately
77
+ // after the session handlers. The controller's gate (broker.enabled AND
78
+ // root-session only) decides whether anything is actually done; for
79
+ // subagents or when the flag is off, it returns a no-op controller.
80
+ ctx.brokerController = installCrewBrokerLifecycleController(pi, ctx);
76
81
 
77
82
  registerCleanupHandler(pi);
78
83
  registerCompactionGuard(pi, {
@@ -102,6 +102,10 @@ export function buildRegistrationContext(pi: ExtensionAPI): RegistrationContext
102
102
  startForegroundRun: undefined as never,
103
103
  abortForegroundRun: () => false,
104
104
  openLiveSidebar: () => {},
105
+ // Phase 0 inter-pi broker controller. Initially undefined; register.ts
106
+ // replaces this with a real controller (or no-op) after lifecycle
107
+ // handlers are installed.
108
+ brokerController: undefined,
105
109
  };
106
110
 
107
111
  ctx.notifyOperator = (notification: NotificationDescriptor): void => {
@@ -20,11 +20,13 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
20
20
  import { loadConfig } from "../../config/config.ts";
21
21
  import { DEFAULT_UI } from "../../config/defaults.ts";
22
22
  import { pruneFinishedRuns, pruneUserLevelRuns } from "../../extension/run-maintenance.ts";
23
+ import { type BrokerSpawnCredentials, setActiveBrokerIssuer } from "../../runtime/broker-issuer.ts";
23
24
  import { reconcileAllStaleRuns } from "../../runtime/crash-recovery.ts";
25
+ import { CrewBroker } from "../../runtime/crew-broker.ts";
24
26
  import { listLiveAgents } from "../../runtime/live-agent-manager.ts";
25
27
  import type { createManifestCache } from "../../runtime/manifest-cache.ts";
26
28
  import { cleanupOrphanWorkers } from "../../runtime/orphan-worker-registry.ts";
27
- import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs } from "../../runtime/pi-args.ts";
29
+ import { cleanupLegacyOrphanTempDirs, cleanupOrphanTempDirs, currentCrewDepth } from "../../runtime/pi-args.ts";
28
30
  import { CrewScheduler, type ScheduledJob } from "../../runtime/scheduler.ts";
29
31
  import { tryRegisterSessionCleanup } from "../../runtime/session-resources.ts";
30
32
  import { createSessionSnapshot } from "../../runtime/session-snapshot.ts";
@@ -47,7 +49,8 @@ import { updateCrewWidget } from "../../ui/widget/index.ts";
47
49
  import { logInternalError } from "../../utils/internal-error.ts";
48
50
  import { projectCrewRoot, userCrewRoot } from "../../utils/paths.ts";
49
51
  import { RunWatcherRegistry } from "../../utils/run-watcher-registry.ts";
50
- import { extractSessionId } from "../../utils/session-utils.ts";
52
+ import { extractBrokerSessionId } from "../../utils/session-utils.ts";
53
+ import { getBrokerSocketPath } from "../../utils/socket-path.ts";
51
54
  import { startAsyncRunNotifier, stopAsyncRunNotifier } from "../async-notifier.ts";
52
55
  import { registerCrewAutocomplete } from "../crew-autocomplete.ts";
53
56
  import { notifyActiveRuns } from "../session-summary.ts";
@@ -169,7 +172,11 @@ function installSessionStartHandler(pi: ExtensionAPI, ctx: RegistrationContext):
169
172
  ctx.widgetState.interval = undefined;
170
173
  notifyActiveRuns(extensionCtx);
171
174
 
172
- const currentSessionId = extractSessionId(extensionCtx);
175
+ const currentSessionId = extractBrokerSessionId(extensionCtx);
176
+ // Phase 0 broker: feed the captured session_id to the controller so
177
+ // it can issue tokens for child runs in this session. The controller
178
+ // already gates by flag + root-session; this is a no-op when disabled.
179
+ ctx.brokerController?.setSessionId(currentSessionId);
173
180
 
174
181
  // Defer ALL heavy cleanup to after the session_start handler returns.
175
182
  // These operations involve synchronous directory scanning (readdirSync, readFileSync)
@@ -760,3 +767,159 @@ function setupRenderLoop(
760
767
  // watchers for any runs that are already active on session start.
761
768
  backgroundPreload();
762
769
  }
770
+
771
+ // =============================================================================
772
+ // Phase 0 inter-pi broker lifecycle controller (sub-task 0.5)
773
+ // =============================================================================
774
+ //
775
+ // `installCrewBrokerLifecycleController` wires the per-session broker into
776
+ // the existing extension lifecycle. The controller:
777
+ //
778
+ // - is a no-op unless broker.enabled is true AND the current process is the
779
+ // root pi session (PI_CREW_KIND !== "subagent" AND currentCrewDepth === 0).
780
+ // Children NEVER install a broker.
781
+ // - lazily constructs a single CrewBroker instance per session_id. listen()
782
+ // is deferred until the first child run actually requests broker credentials.
783
+ // - issues a heap-only token per child run via `issueForChild`. The token
784
+ // NEVER leaves the parent's heap and the child's env (PI_CREW_BROKER_TOKEN).
785
+ // It is never written to disk.
786
+ // - retains the broker across session switches when the session_id is
787
+ // unchanged; on a session_id change, stops the old broker and binds a new
788
+ // one on the next acquire.
789
+ // - stops the broker during session_shutdown BEFORE the runtime cleanup path.
790
+ //
791
+ // The gate is re-evaluated on every `issueForChild` call (cheap env+depth
792
+ // check) so the kill switch (PI_CREW_BROKER=0) takes effect immediately.
793
+
794
+ export interface CrewBrokerLifecycleController {
795
+ /** Issue credentials for a child run. Returns undefined when the broker
796
+ * is disabled, this process is a subagent, or no session_id is known. */
797
+ issueForChild(runId: string): Promise<BrokerSpawnCredentials | undefined>;
798
+ /** Stop the broker (idempotent). Called on session_shutdown. */
799
+ stop(): Promise<void>;
800
+ /** Test/lifecycle seam: remember the most recent session_id for token issuance. */
801
+ setSessionId(sessionId: string | undefined): void;
802
+ }
803
+
804
+ function isRootSession(env: NodeJS.ProcessEnv = process.env): boolean {
805
+ if (env.PI_CREW_KIND === "subagent") return false;
806
+ try {
807
+ return currentCrewDepth(env) === 0;
808
+ } catch {
809
+ return false;
810
+ }
811
+ }
812
+
813
+ export function installCrewBrokerLifecycleController(_pi: ExtensionAPI, _ctx: RegistrationContext): CrewBrokerLifecycleController {
814
+ let broker: CrewBroker | null = null;
815
+ let brokerSessionId: string | undefined;
816
+ let starting: Promise<CrewBroker> | null = null;
817
+ let cachedSessionId: string | undefined;
818
+
819
+ function effectiveEnabled(): boolean {
820
+ // Env wins over config. PI_CREW_BROKER=1 forces on, =0 forces off.
821
+ const envOverride = process.env.PI_CREW_BROKER;
822
+ if (envOverride === "0") return false;
823
+ // Config block: read fresh so a runtime config update takes effect.
824
+ try {
825
+ const cfg = loadConfig().config.broker;
826
+ if (envOverride === "1") return cfg !== undefined ? cfg.enabled !== false : true;
827
+ // Phase 4 (v0.9.47) default-on: enabled unless explicitly disabled.
828
+ return cfg?.enabled !== false;
829
+ } catch {
830
+ // Fail-safe: config load failed → keep broker disabled.
831
+ return false;
832
+ }
833
+ }
834
+
835
+ async function getOrStartBroker(sessionId: string): Promise<CrewBroker> {
836
+ if (broker && brokerSessionId === sessionId) return broker;
837
+ if (broker && brokerSessionId !== sessionId) {
838
+ try {
839
+ await broker.stop();
840
+ } catch {
841
+ /* ignore */
842
+ }
843
+ broker = null;
844
+ brokerSessionId = undefined;
845
+ }
846
+ if (!broker && !starting) {
847
+ starting = (async () => {
848
+ const cfg = (() => {
849
+ try {
850
+ return loadConfig().config.broker;
851
+ } catch {
852
+ return undefined;
853
+ }
854
+ })();
855
+ const b = new CrewBroker({
856
+ sessionId,
857
+ socketPath: getBrokerSocketPath(sessionId),
858
+ maxFrameBytes: cfg?.maxFrameBytes ?? 262144,
859
+ outboundQueueCap: cfg?.outboundQueueCap ?? 256,
860
+ enabled: true,
861
+ cwd: process.cwd(),
862
+ });
863
+ try {
864
+ await b.start();
865
+ broker = b;
866
+ brokerSessionId = sessionId;
867
+ return b;
868
+ } finally {
869
+ starting = null;
870
+ }
871
+ })();
872
+ }
873
+ return starting!;
874
+ }
875
+
876
+ const issueForChild = async (runId: string): Promise<BrokerSpawnCredentials | undefined> => {
877
+ if (!runId || typeof runId !== "string") return undefined;
878
+ if (!isRootSession(process.env)) return undefined;
879
+ if (!effectiveEnabled()) return undefined;
880
+ const sessionId = cachedSessionId;
881
+ if (!sessionId) return undefined;
882
+ try {
883
+ const b = await getOrStartBroker(sessionId);
884
+ const token = b.issueRunToken(runId);
885
+ return { socketPath: b.socketPath, token };
886
+ } catch {
887
+ return undefined;
888
+ }
889
+ };
890
+
891
+ // Publish this issuer as the process-local active issuer so runChildPi can
892
+ // default `brokerIssuer` without the registration context being threaded
893
+ // through every runner call site. The issuer self-gates (root + flag), so
894
+ // publishing it unconditionally is safe even when the broker is disabled.
895
+ setActiveBrokerIssuer(issueForChild);
896
+
897
+ return {
898
+ issueForChild,
899
+ stop: async () => {
900
+ setActiveBrokerIssuer(undefined);
901
+ if (broker) {
902
+ try {
903
+ await broker.stop();
904
+ } catch {
905
+ /* ignore */
906
+ }
907
+ broker = null;
908
+ brokerSessionId = undefined;
909
+ }
910
+ },
911
+ /** Test seam: remember the most recent session_id for token issuance. */
912
+ setSessionId: (sessionId: string | undefined) => {
913
+ // Runtime validation: cap the length and charset to defend against
914
+ // a hostile extension supplying a huge or pathological id. The
915
+ // socket path is already hash-derived (4..32 hex), so a longer
916
+ // sessionId is harmless on disk but wastes heap.
917
+ if (typeof sessionId !== "string") return;
918
+ if (sessionId.length === 0 || sessionId.length > 256) return;
919
+ cachedSessionId = sessionId;
920
+ },
921
+ };
922
+ }
923
+
924
+ /** Marker used by tests to confirm the controller object identity. */
925
+ export const __test__brokerControllerMarker = true;
@@ -146,4 +146,13 @@ export interface RegistrationContext {
146
146
  startForegroundRun: (ctx: ExtensionContext, runner: (signal?: AbortSignal) => Promise<void>, runId?: string) => void;
147
147
  abortForegroundRun: (runId: string) => boolean;
148
148
  openLiveSidebar: (ctx: ExtensionContext, runId: string) => void;
149
+ /**
150
+ * Phase 0 inter-pi broker lifecycle controller. Set by `register.ts`
151
+ * after `installSessionLifecycleHandlers`. The controller is a no-op
152
+ * when the broker is disabled or the current process is a subagent;
153
+ * callers (e.g. child-pi-spawn consumers) MUST handle the case where
154
+ * `issueForChild` returns undefined. The controller is stopped
155
+ * during session_shutdown via `stop()`.
156
+ */
157
+ brokerController: import("./lifecycle-handlers.ts").CrewBrokerLifecycleController | undefined;
149
158
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { startChildBrokerClient } from "../runtime/crew-broker-child.ts";
4
5
  import { logInternalError } from "../utils/internal-error.ts";
5
6
  import { resolveRealContainedPath } from "../utils/safe-paths.ts";
6
7
 
@@ -34,6 +35,59 @@ export interface SteerSanitizeResult {
34
35
  export interface SteerEntry {
35
36
  type?: string;
36
37
  message?: string;
38
+ /** Message id. Present in entries the broker writes; absent in legacy
39
+ * entries pre-dating the broker id-forwarding change. */
40
+ id?: string;
41
+ }
42
+
43
+ // ── FIX-S1: Cross-channel steer dedup state ──────────────────────────────
44
+ // Both the live broker push (mailbox.message → onSteer) and the durable
45
+ // file poll (pollSteering JSONL) can deliver the SAME steer to the worker
46
+ // for two reasons:
47
+ // 1. The broker writes to BOTH the mailbox AND the steering JSONL for
48
+ // durability. A connected child receives the broker push FIRST, then
49
+ // file-poll sees the JSONL shortly after (the broker's own write is
50
+ // what populates that file).
51
+ // 2. A reconnect / catch-up from a previously-disconnected child can
52
+ // re-deliver the same mailbox.message id a second time.
53
+ //
54
+ // We dedup at the recipient (worker) by tracking steer ids across BOTH
55
+ // channels in a single bounded FIFO set. Entries without an id (legacy
56
+ // JSONL rows written before S1) are NOT deduped, because they lack a
57
+ // stable identity — the file-poll path is their only delivery route.
58
+ const SEEN_STEER_ID_CAP = 1024;
59
+
60
+ /**
61
+ * Bounded FIFO seen-set for steer message ids.
62
+ *
63
+ * - `markOrSkip(undefined)` returns true (the file-poll path may emit
64
+ * legacy id-less entries; forward them).
65
+ * - `markOrSkip('a')` first call returns true; the same call again returns
66
+ * false (id already seen → drop the duplicate deliver).
67
+ * - When the cap is exceeded, the oldest id is evicted so the set stays
68
+ * bounded under long-running workers with high steer churn.
69
+ *
70
+ * Factory-shaped so multiple prompt-runtime instances (tests, parallel
71
+ * workers) get independent sets.
72
+ */
73
+ export function createSeenSteerIdSet(): { markOrSkip: (id?: string) => boolean; size: () => number } {
74
+ const seen: string[] = [];
75
+ const set = new Set<string>();
76
+ return {
77
+ markOrSkip(id?: string): boolean {
78
+ if (id === undefined) return true; // legacy steers have no id; only file-poll path can produce these
79
+ if (set.has(id)) return false;
80
+ set.add(id);
81
+ seen.push(id);
82
+ // FIFO eviction: when the cap is exceeded, drop the oldest entry.
83
+ while (seen.length > SEEN_STEER_ID_CAP) {
84
+ const oldest = seen.shift();
85
+ if (oldest !== undefined) set.delete(oldest);
86
+ }
87
+ return true;
88
+ },
89
+ size: () => set.size,
90
+ };
37
91
  }
38
92
 
39
93
  /**
@@ -157,6 +211,16 @@ export function rewriteTeamWorkerPrompt(prompt: string, options: { inheritProjec
157
211
  }
158
212
 
159
213
  export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
214
+ // ── FIX-S1: cross-channel steer dedup state ────────────────────────────
215
+ // Both the broker push (mailbox.message → onSteer callback below) and the
216
+ // file poll (pollSteering below) are wired to sendMessage. The broker
217
+ // writes to BOTH the mailbox observer (live fanout) AND the steering
218
+ // JSONL (durable fallback) so a connected worker will see the same steer
219
+ // twice: once via the broker, once via the next poll tick. We dedup at the
220
+ // consumer by tracking steer ids in a bounded FIFO set shared by both
221
+ // delivery paths. Without id-bearing entries (legacy producer), pollSteering
222
+ // is the only delivery channel and dedup is a no-op for id-less entries.
223
+ const seenSteers = createSeenSteerIdSet();
160
224
  // ── Feature 1: maxTokens cap ──────────────────────────────────────────
161
225
  // Cap output tokens per API call for background workers. Reads
162
226
  // PI_CREW_MAX_OUTPUT_TOKENS env (set by pi-args.ts from agent.maxTokens).
@@ -220,6 +284,15 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
220
284
  try {
221
285
  const entry = JSON.parse(line) as SteerEntry;
222
286
  if (entry.type !== "steer") continue;
287
+ // FIX-S1: cross-channel dedup. The broker writes the
288
+ // same steer to both the mailbox (live fanout via the
289
+ // onSteer callback below) and this JSONL file. A
290
+ // connected worker receives it via the broker first
291
+ // and via this poll second; the seen-id set ensures
292
+ // only the first arrival reaches pi.sendMessage.
293
+ const entryId =
294
+ typeof (entry as { id?: unknown }).id === "string" ? (entry as { id: string }).id : undefined;
295
+ if (!seenSteers.markOrSkip(entryId)) continue;
223
296
  // FIX-02: sanitize each steer entry before forwarding
224
297
  // to pi.sendMessage. Reject oversized payloads,
225
298
  // excessive newlines, and control characters.
@@ -257,6 +330,41 @@ export default function registerPiTeamsPromptRuntime(pi: ExtensionAPI): void {
257
330
  }
258
331
  }
259
332
 
333
+ // ── Feature 2b: broker push steering (opt-in, layered on the file poll) ──
334
+ // When the parent injected broker credentials, connect a broker client and
335
+ // deliver pushed steers with the SAME sanitize + pi.sendMessage path as the
336
+ // file poll above. The file poll remains the durable fallback; a broker
337
+ // connect failure is invisible to the worker.
338
+ const brokerHandle = startChildBrokerClient({
339
+ onSteer: (rawMessage, id) => {
340
+ // FIX-S1: cross-channel dedup. The broker persists every steer to
341
+ // the steering JSONL (durable) AND pushes it via the mailbox
342
+ // observer (live). This callback sees the live push; the
343
+ // pollSteering path above will see the same steer on its next
344
+ // tick. Keying on `id` (a stable identifier emitted by the broker)
345
+ // guarantees a single pi.sendMessage per steer.
346
+ if (!seenSteers.markOrSkip(id)) return;
347
+ const sanitized = sanitizeSteerMessage({ type: "steer", message: rawMessage });
348
+ if (!sanitized.valid || sanitized.message === undefined) {
349
+ logInternalError(
350
+ "prompt-runtime.broker-steer-rejected",
351
+ new Error(sanitized.reason ?? "steer-sanitization-failed"),
352
+ undefined,
353
+ "warn",
354
+ );
355
+ return;
356
+ }
357
+ pi.sendMessage({ customType: "crew-steer", content: sanitized.message, display: false }, { deliverAs: "steer" });
358
+ },
359
+ });
360
+ // Close the broker connection on session shutdown to avoid leaking the
361
+ // persistent socket / reconnect timer. Fire-and-forget: the socket is
362
+ // already .unref()'d so it never blocks event-loop exit; errors are
363
+ // swallowed because teardown failures during shutdown are harmless.
364
+ pi.on("session_shutdown", () => {
365
+ void brokerHandle.close().catch(() => {});
366
+ });
367
+
260
368
  // ── Prompt rewriting (existing) ────────────────────────────────────────
261
369
  pi.on("before_agent_start", (event) => {
262
370
  const inheritProjectContext = readBooleanEnvAny(PI_CREW_INHERIT_PROJECT_CONTEXT_ENV, PI_TEAMS_INHERIT_PROJECT_CONTEXT_ENV);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * broker-issuer.ts — Process-local registry for the active broker credential
3
+ * issuer.
4
+ *
5
+ * The broker lifecycle controller (parent/root session) registers its
6
+ * `issueForChild` function here on start and clears it on stop. `runChildPi`
7
+ * reads it as the default `brokerIssuer` so the spawn path does not need the
8
+ * registration context threaded through every runner call site.
9
+ *
10
+ * This mirrors the existing module-level singletons in the codebase
11
+ * (`runEventBus`, the mailbox append observers). It lives ONLY in the parent
12
+ * process — children never register an issuer (they receive credentials via
13
+ * env). The value is a function reference, never a token; nothing here is
14
+ * persisted or logged.
15
+ */
16
+
17
+ /** Credentials handed to a child worker so it can authenticate to the broker. */
18
+ export interface BrokerSpawnCredentials {
19
+ socketPath: string;
20
+ token: string;
21
+ }
22
+
23
+ /** Issuer signature: given a runId, return credentials or undefined when the
24
+ * broker is disabled / this process is not the root session. */
25
+ export type BrokerIssuer = (runId: string) => Promise<BrokerSpawnCredentials | undefined>;
26
+
27
+ let activeIssuer: BrokerIssuer | undefined;
28
+
29
+ /** Register the active issuer (called by the lifecycle controller on start). */
30
+ export function setActiveBrokerIssuer(issuer: BrokerIssuer | undefined): void {
31
+ activeIssuer = issuer;
32
+ }
33
+
34
+ /** Read the active issuer, if any. Returns undefined when no broker is wired. */
35
+ export function getActiveBrokerIssuer(): BrokerIssuer | undefined {
36
+ return activeIssuer;
37
+ }
@@ -166,6 +166,44 @@ export function assertOnlyControlEnvKeys(builtEnv: Record<string, string | undef
166
166
  }
167
167
  }
168
168
 
169
+ /**
170
+ * Compose the final SpawnOptions for a child Pi worker: runs the runtime canary
171
+ * (assertOnlyControlEnvKeys) on builtEnv, builds the allowlist-filtered base
172
+ * SpawnOptions via buildChildPiSpawnOptions, then re-applies builtEnv on top
173
+ * so the PI_CREW_-prefixed / PI_TEAMS_-prefixed execution-control vars actually
174
+ * reach the child.
175
+ *
176
+ * Extracted from child-pi.ts (BLOCKER 2 / S5) so the spread step is testable
177
+ * in isolation and the canary lives in one place. Guarantees:
178
+ * 1. The canary runs FIRST — a non-control key in builtEnv throws before
179
+ * buildChildPiSpawnOptions (and before spawn()) is ever called.
180
+ * 2. The spread always runs LAST on the SpawnOptions returned by
181
+ * buildChildPiSpawnOptions — the filtered base env cannot accidentally
182
+ * drop execution-control vars that the child needs (steering file, kind,
183
+ * role, broker credentials, etc.).
184
+ * 3. The returned SpawnOptions.env contains BOTH the allowlist-filtered
185
+ * system vars (PATH, HOME, …) AND the per-call control vars.
186
+ */
187
+ export function buildFinalChildPiSpawnOptions(
188
+ cwd: string,
189
+ mergedEnv: NodeJS.ProcessEnv,
190
+ builtEnv: Record<string, string | undefined>,
191
+ model?: string,
192
+ ): SpawnOptions {
193
+ // (a) Canary: builtEnv must contain ONLY PI_CREW_*/PI_TEAMS_* keys.
194
+ assertOnlyControlEnvKeys(builtEnv);
195
+ // (b) Build the allowlist-filtered base SpawnOptions (cwd validation, env
196
+ // filtering, provider-key scoping when model is set, NODE_PATH guard).
197
+ const spawnOptions = buildChildPiSpawnOptions(cwd, mergedEnv, model);
198
+ // (c) Spread builtEnv back on top — the allowlist in step (b) intentionally
199
+ // strips PI_CREW_*/PI_TEAMS_* keys, so without this spread the child
200
+ // process would never see steering file, kind, role, or broker creds.
201
+ // Safe because step (a) just proved builtEnv holds no secret keys.
202
+ spawnOptions.env = { ...spawnOptions.env, ...builtEnv };
203
+ // (d) Return the composed SpawnOptions for spawn(...).
204
+ return spawnOptions;
205
+ }
206
+
169
207
  /** What the spawn site needs to start the child process. */
170
208
  export interface SpawnContext {
171
209
  /** The command + args returned by getPiSpawnCommand. */
@@ -201,6 +239,21 @@ export function prepareSpawnContext(
201
239
  });
202
240
  // Pass steering file path to child for real-time steer injection
203
241
  if (input.steeringFile) built.env.PI_CREW_STEERING_FILE = input.steeringFile;
242
+ // Phase 0 inter-pi broker: inject socket path + token (control-namespace keys,
243
+ // safe under assertOnlyControlEnvKeys). Only when the parent broker issued
244
+ // credentials for this run — i.e. the broker is enabled AND this run is
245
+ // eligible. The token is heap-only on the parent; the child receives it
246
+ // solely through env. NEVER persisted to disk.
247
+ if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
248
+ built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
249
+ built.env.PI_CREW_BROKER_TOKEN = input.brokerSpawn.token;
250
+ // The child needs its own runId + taskId to complete the broker `hello`
251
+ // (the token is validated against the runId; the taskId binds the
252
+ // connection for message routing). Both are control-namespace keys so
253
+ // they pass assertOnlyControlEnvKeys. agentId is the per-task id.
254
+ if (input.runId) built.env.PI_CREW_BROKER_RUN_ID = input.runId;
255
+ if (input.agentId) built.env.PI_CREW_BROKER_TASK_ID = input.agentId;
256
+ }
204
257
  // B5: if the parent already aborted before we spawn, do not start the child
205
258
  // at all. Spawning a doomed process wastes resources, and the abort listener
206
259
  // registered below will not re-fire for an already-aborted signal (so the