pi-crew 0.9.44 → 0.9.47

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 (65) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +38 -3
  3. package/dist/build-meta.json +349 -203
  4. package/dist/index.mjs +2229 -2968
  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 +26 -0
  11. package/package.json +3 -1
  12. package/scripts/build-bundle.mjs +7 -0
  13. package/scripts/postinstall.mjs +35 -1
  14. package/scripts/pty_probe.py +174 -0
  15. package/skills/real-test-pi-crew/SKILL.md +659 -0
  16. package/src/agents/discover-agents.ts +1 -1
  17. package/src/config/config.ts +42 -1
  18. package/src/config/defaults.ts +45 -1
  19. package/src/config/types.ts +19 -0
  20. package/src/extension/register.ts +6 -1
  21. package/src/extension/registration/context-builder.ts +4 -0
  22. package/src/extension/registration/lifecycle-handlers.ts +200 -6
  23. package/src/extension/registration/registration-types.ts +9 -0
  24. package/src/extension/registration/subagent-manager-setup.ts +178 -59
  25. package/src/extension/run-import.ts +21 -1
  26. package/src/extension/team-tool/api.ts +4 -2
  27. package/src/prompt/prompt-runtime.ts +108 -0
  28. package/src/runtime/async-runner.ts +9 -1
  29. package/src/runtime/broker-issuer.ts +37 -0
  30. package/src/runtime/child-pi-spawn.ts +53 -0
  31. package/src/runtime/child-pi.ts +42 -11
  32. package/src/runtime/crew-broker-child.ts +88 -0
  33. package/src/runtime/crew-broker-client.ts +673 -0
  34. package/src/runtime/crew-broker-tokens.ts +84 -0
  35. package/src/runtime/crew-broker.ts +1276 -0
  36. package/src/runtime/dynamic-workflow-context.ts +7 -3
  37. package/src/runtime/dynamic-workflow-runner.ts +1 -1
  38. package/src/runtime/manifest-cache.ts +30 -0
  39. package/src/runtime/plan-templates.ts +8 -6
  40. package/src/runtime/resilient-edit.ts +16 -15
  41. package/src/runtime/role-permission.ts +27 -2
  42. package/src/runtime/run-coalesced-task-group.ts +72 -15
  43. package/src/runtime/task-packet.ts +1 -1
  44. package/src/schema/config-schema.ts +14 -0
  45. package/src/state/event-log.ts +88 -34
  46. package/src/state/locks.ts +53 -0
  47. package/src/state/mailbox.ts +208 -4
  48. package/src/state/run-metrics.ts +40 -12
  49. package/src/ui/key-utils.ts +42 -0
  50. package/src/ui/keybinding-map.ts +29 -3
  51. package/src/ui/live-run-sidebar.ts +1 -9
  52. package/src/ui/run-dashboard.ts +29 -9
  53. package/src/ui/settings-overlay.ts +42 -22
  54. package/src/utils/incremental-reader.ts +105 -0
  55. package/src/utils/ndjson.ts +115 -0
  56. package/src/utils/session-utils.ts +30 -0
  57. package/src/utils/socket-path.ts +127 -0
  58. package/src/utils/visual.ts +27 -91
  59. package/workflows/default.workflow.md +1 -1
  60. package/workflows/fast-fix.workflow.md +1 -1
  61. package/workflows/plan-execute.workflow.md +1 -1
  62. package/workflows/review.workflow.md +1 -1
  63. package/src/runtime/auto-resume.ts +0 -100
  64. package/src/runtime/notebook-helpers.ts +0 -88
  65. package/src/runtime/orphan-sentinel.ts +0 -7
@@ -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)
@@ -521,7 +528,15 @@ function setupRenderLoop(
521
528
  {
522
529
  const onRunChange = (runId: string): void => {
523
530
  if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
524
- ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).invalidate(runId);
531
+ // FLICKER FIX: rebuild-in-place instead of deleting the entry. The
532
+ // file just changed on disk, so force a fresh snapshot while keeping
533
+ // the entry populated — deleting it left a window where the widget's
534
+ // `get()` returned undefined and dropped the run to "(loading…)".
535
+ try {
536
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).refresh(runId);
537
+ } catch (error) {
538
+ logInternalError("register.runWatcher.refresh", error, runId);
539
+ }
525
540
  ctx.renderScheduler?.schedule({ runId });
526
541
  };
527
542
  const onWatchErr = (error: unknown): void => {
@@ -678,7 +693,23 @@ function setupRenderLoop(
678
693
  typeof (payload as { runId: unknown }).runId === "string"
679
694
  ? (payload as { runId: string }).runId
680
695
  : undefined;
681
- ctx.getRunSnapshotCache(extensionCtx.cwd).invalidate(runId);
696
+ // FLICKER FIX: never hard-delete snapshot entries from a render-scheduler
697
+ // invalidate. A no-runId payload — emitted by EVERY fallback tick
698
+ // (~every 160ms while a run is active) — previously ran
699
+ // `invalidate(undefined)` → `entries.clear()`, wiping ALL snapshots.
700
+ // The next `renderTick` then saw `get() === undefined` for every run,
701
+ // so `activeWidgetRuns` dropped them to "(loading…)" until the async
702
+ // preload rebuilt the cache — an endless visible flicker. For a
703
+ // specific runId we now refresh-if-stale (stale-while-revalidate) so
704
+ // the widget always sees a populated snapshot; a no-runId tick does
705
+ // nothing (renderTick itself repaints; the cache's own
706
+ // run:state/worker:lifecycle subscription refreshes affected runs).
707
+ if (!runId) return;
708
+ try {
709
+ ctx.getRunSnapshotCache(extensionCtx.cwd).refreshIfStale(runId);
710
+ } catch (error) {
711
+ logInternalError("register.renderScheduler.refresh", error, runId);
712
+ }
682
713
  },
683
714
  });
684
715
  // Fix D: bridge internal runEventBus events to renderScheduler so the UI
@@ -697,7 +728,14 @@ function setupRenderLoop(
697
728
  // Bounded run watcher setup (pts/2 hang fix 2026-06-16).
698
729
  const crewRunWatcherOnChange = (runId: string): void => {
699
730
  if (ctx.cleanedUp || ctx.sessionGeneration !== ownerGeneration) return;
700
- ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).invalidate(runId);
731
+ // FLICKER FIX: rebuild-in-place instead of deleting the entry (see
732
+ // onRunChange above). A hard delete left `get()` returning undefined for
733
+ // a frame, dropping the run to "(loading…)" and causing visible flicker.
734
+ try {
735
+ ctx.getRunSnapshotCache(ctx.currentCtx?.cwd ?? process.cwd()).refresh(runId);
736
+ } catch (error) {
737
+ logInternalError("register.crewRunWatcher.refresh", error, runId);
738
+ }
701
739
  ctx.renderScheduler?.schedule({ runId });
702
740
  };
703
741
  const crewRunWatcherOnError = (error: unknown): void => {
@@ -729,3 +767,159 @@ function setupRenderLoop(
729
767
  // watchers for any runs that are already active on session start.
730
768
  backgroundPreload();
731
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
  }