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.
- package/CHANGELOG.md +83 -0
- package/README.md +16 -2
- package/dist/build-meta.json +289 -164
- package/dist/index.mjs +1744 -2732
- package/dist/index.mjs.map +4 -4
- package/docs/decisions/2026-07-21-broker-phase4-default-on.md +77 -0
- package/docs/decisions/2026-07-21-broker-windows-perms.md +91 -0
- package/docs/decisions/2026-07-22-broker-phase4-gated-on.md +99 -0
- package/docs/decisions/README.md +3 -0
- package/docs/publishing.md +29 -0
- package/package.json +3 -1
- package/scripts/build-bundle.mjs +7 -0
- package/scripts/postinstall.mjs +60 -1
- package/scripts/pty_probe.py +174 -0
- package/skills/real-test-pi-crew/SKILL.md +659 -0
- package/src/config/config.ts +42 -1
- package/src/config/defaults.ts +45 -1
- package/src/config/types.ts +19 -0
- package/src/extension/register.ts +6 -1
- package/src/extension/registration/context-builder.ts +4 -0
- package/src/extension/registration/lifecycle-handlers.ts +166 -3
- package/src/extension/registration/registration-types.ts +9 -0
- package/src/prompt/prompt-runtime.ts +108 -0
- package/src/runtime/broker-issuer.ts +37 -0
- package/src/runtime/child-pi-spawn.ts +53 -0
- package/src/runtime/child-pi.ts +42 -11
- package/src/runtime/crew-broker-child.ts +88 -0
- package/src/runtime/crew-broker-client.ts +673 -0
- package/src/runtime/crew-broker-tokens.ts +84 -0
- package/src/runtime/crew-broker.ts +1276 -0
- package/src/runtime/dynamic-workflow-context.ts +7 -3
- package/src/runtime/dynamic-workflow-runner.ts +1 -1
- package/src/runtime/plan-templates.ts +8 -6
- package/src/schema/config-schema.ts +14 -0
- package/src/state/mailbox.ts +43 -0
- package/src/ui/key-utils.ts +42 -0
- package/src/ui/keybinding-map.ts +29 -3
- package/src/ui/run-dashboard.ts +28 -0
- package/src/ui/settings-overlay.ts +42 -22
- package/src/utils/ndjson.ts +115 -0
- package/src/utils/session-utils.ts +30 -0
- package/src/utils/socket-path.ts +127 -0
- package/workflows/default.workflow.md +1 -1
- package/workflows/fast-fix.workflow.md +1 -1
- package/workflows/plan-execute.workflow.md +1 -1
- package/workflows/review.workflow.md +1 -1
package/src/config/config.ts
CHANGED
|
@@ -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
|
package/src/config/defaults.ts
CHANGED
|
@@ -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:
|
|
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
|
+
}
|
package/src/config/types.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|