pi-crew 0.11.46 → 0.11.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-crew",
3
- "version": "0.11.46",
3
+ "version": "0.11.48",
4
4
  "description": "Pi extension for coordinated AI teams, workflows, worktrees, and async task orchestration",
5
5
  "author": "baphuongna",
6
6
  "license": "MIT",
@@ -668,7 +668,7 @@ export const CREW_ENV_VARS: Record<string, CrewEnvVarSpec> = {
668
668
  },
669
669
  PI_CREW_BROKER_STDIN: {
670
670
  name: "PI_CREW_BROKER_STDIN",
671
- doc: "LOW-1 closure (2026-10-27) broker-credential transport gate: unset/non-'0' (default) sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over the spawn STDIN pipe (one buildBrokerStdinLine v2 payload; heap → pipe → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader) so the token never appears in the child env; '0' rolls back to the legacy env route (token in built.env, stdin stays 'ignore') on BOTH sides. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side stdin read skips --mode rpc argv and TTY stdin by design)",
671
+ doc: "LOW-1 closure (2026-10-27) broker-credential transport gate, OPT-IN since v0.11.48 (2026-10-28): '1' sends the per-task compound PI_CREW_BROKER_TOKEN to the headless JSON worker over the spawn STDIN pipe (one buildBrokerStdinLine v2 payload; heap → pipe → heap — child-pi-spawn.ts writer, crew-broker-child.ts startChildBrokerClientAsync reader) so the token never appears in the child env; unset/any-other-value (default) keeps the LEGACY ENV ROUTE (token in built.env, stdin stays 'ignore') on BOTH sides — the stdin reader path was caught by the live battery (real-test-pi-crew T7) killing real headless workers with a silent exit 0 and is disabled-by-default until root-caused. RPC-mode workers and surface TUI panes are unaffected (they keep the env route; the worker-side stdin read skips --mode rpc argv and TTY stdin by design)",
672
672
  },
673
673
  PI_CREW_GUEST: {
674
674
  name: "PI_CREW_GUEST",
@@ -182,7 +182,7 @@ export function parentAlive(pid: number, expectedStartTime: string | undefined,
182
182
  // workers at the first 5s tick.
183
183
  readerThrew = true;
184
184
  }
185
- if (readerThrew) return expectedStartTime ? false : true;
185
+ if (readerThrew) return !expectedStartTime;
186
186
  // Reader returned "no entry" (default /proc reader ENOENT = dead on
187
187
  // Linux) — dead-parent detection stays intact.
188
188
  if (stat === undefined) return false;
@@ -139,8 +139,11 @@ export async function startChildBrokerClientAsync(options: StartChildBrokerClien
139
139
  }
140
140
 
141
141
  /** Worker-side mirror of the parent gate (child-pi-spawn
142
- * .resolveBrokerStdinHandshakeEnabled): PI_CREW_BROKER_STDIN=0 keeps the
143
- * fallback OFF so a forced rollback is env-only on both sides. Default ON. */
142
+ * .resolveBrokerStdinHandshakeEnabled). **OPT-IN since v0.11.48** (live
143
+ * battery caught the reader path killing real headless workers — see the
144
+ * parent gate's doc block): the default is now OFF, so a new parent that set
145
+ * the env token travels the legacy env route. PI_CREW_BROKER_STDIN=1 opts
146
+ * BOTH sides into the stdin route. */
144
147
  function resolveBrokerStdinFallbackEnabled(): boolean {
145
- return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
148
+ return getCrewEnv("PI_CREW_BROKER_STDIN") === "1";
146
149
  }
@@ -43,14 +43,14 @@ import { loadRunManifestById, saveRunTasks } from "../../state/stores/state-stor
43
43
  import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
44
44
  import { type GrandchildSpawnResult, spawnDelegateGrandchild } from "../delegate-spawn.ts";
45
45
  import { resolveCrewMaxDepth } from "../model/pi-args.ts";
46
- import { NestedSlotBudget } from "../scheduling/nested-slots.ts";
46
+ import type { NestedSlotBudget } from "../scheduling/nested-slots.ts";
47
47
  import { evaluateDelegateAdmission } from "../spawn-policy.ts";
48
- import { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
49
- import { type DelegateEventTarget, type DelegateEventType } from "./delegate/delegate-event.ts";
48
+ import type { DelegateAbortRegistry } from "./delegate/delegate-abort.ts";
49
+ import type { DelegateEventTarget, DelegateEventType } from "./delegate/delegate-event.ts";
50
50
  import { delegateTerminalEvent, finalizeDelegateResult } from "./delegate/delegate-finalize.ts";
51
51
  import { promoteShadowToRunning } from "./delegate/shadow-lifecycle.ts";
52
- import { type CrewBrokerOptions, type ServerConnection } from "./protocol/connection-state.ts";
53
- import { type LockBusyOutcome } from "./protocol/lock-busy.ts";
52
+ import type { CrewBrokerOptions, ServerConnection } from "./protocol/connection-state.ts";
53
+ import type { LockBusyOutcome } from "./protocol/lock-busy.ts";
54
54
  import { parseDelegateRequestParams } from "./protocol/request-parsers.ts";
55
55
 
56
56
  /** Options slice the delegate handler reads off the broker's RESOLVED
@@ -46,17 +46,24 @@ import {
46
46
  // NOT set this (their sessions persist and benefit from long-lived cache).
47
47
  export const PI_CACHE_RETENTION_ENV = "PI_CACHE_RETENTION";
48
48
 
49
- /** LOW-1 residual closure (2026-10-27): when the inter-pi broker credentials
50
- * are present, the per-task compound TOKEN travels over the spawn stdin pipe
51
- * (one `buildBrokerStdinLine` payload, heap → pipe → heap — same shape as the
52
- * F4 background-runner handshake) instead of the child env. Set
53
- * PI_CREW_BROKER_STDIN=0 to roll back to the legacy env route (token in
54
- * built.env, stdin stays "ignore"). Default ON. Only the headless JSON spawn
55
- * branch consumes the payload: the RPC branch (stdin is the JSONL protocol
56
- * channel) and the surface TUI panes keep the env route, so their env is
57
- * untouched. */
49
+ /** LOW-1 residual closure (2026-10-27, DISABLED-DEFAULT 2026-10-28 v0.11.48):
50
+ * when the inter-pi broker credentials are present, the per-task compound TOKEN
51
+ * can travel over the spawn stdin pipe (one `buildBrokerStdinLine` payload,
52
+ * heap → pipe → heap — same shape as the F4 background-runner handshake)
53
+ * instead of the child env. **EXPERIMENTAL / OPT-IN since v0.11.48**: the
54
+ * worker-side stdin reader path (startChildBrokerClientAsync →
55
+ * readStdinFirstLine) was caught by the live battery (real-test-pi-crew T7)
56
+ * making REAL headless JSON workers exit 0 silently with zero stdout — only
57
+ * when the reader activates (env broker identity + no env token + gate ON).
58
+ * Unit tests missed it because they inject the `readStdinLine` seam instead of
59
+ * running process.stdin in a real pi process. Until that is root-caused, the
60
+ * default is OFF (legacy env route, bit-for-bit pre-0.11.46 behavior); set
61
+ * PI_CREW_BROKER_STDIN=1 to opt the parent into the stdin route. Only the
62
+ * headless JSON spawn branch consumes the payload: the RPC branch (stdin is
63
+ * the JSONL protocol channel) and the surface TUI panes keep the env route,
64
+ * so their env is untouched either way. */
58
65
  export function resolveBrokerStdinHandshakeEnabled(): boolean {
59
- return getCrewEnv("PI_CREW_BROKER_STDIN") !== "0";
66
+ return getCrewEnv("PI_CREW_BROKER_STDIN") === "1";
60
67
  }
61
68
 
62
69
  // ── Env allowlist (base set always passed to children) ──────────────────
@@ -452,12 +459,13 @@ export function prepareSpawnContext(
452
459
  // safe under assertOnlyControlEnvKeys). Only when the parent broker issued
453
460
  // credentials for this run — i.e. the broker is enabled AND this run is
454
461
  // eligible. NEVER persisted to disk.
455
- // LOW-1 closure: by default the TOKEN leaves the env entirely — it rides the
462
+ // LOW-1 closure (OPT-IN since v0.11.48): when PI_CREW_BROKER_STDIN=1 the
463
+ // TOKEN leaves the env entirely — it rides the
456
464
  // spawn stdin pipe as a single buildBrokerStdinLine payload (ctx
457
465
  // .brokerStdinPayload; the headless JSON branch pipes stdin and writes it
458
466
  // right after spawn). SOCKET/RUN_ID/TASK_ID stay in env (non-secret, needed
459
- // by the worker bootstrap to bind + validate the stdin payload). Rollback
460
- // gate PI_CREW_BROKER_STDIN=0 (or a legacy shape without runId/taskId to
467
+ // by the worker bootstrap to bind and validate the stdin payload). Default
468
+ // (gate off, or a legacy shape without runId/taskId to
461
469
  // bind the payload to) keeps the env route bit-for-bit.
462
470
  if (input.brokerSpawn?.socketPath && input.brokerSpawn.token) {
463
471
  built.env.PI_CREW_BROKER_SOCKET = input.brokerSpawn.socketPath;
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Model resolution + system-prompt building for the live-session path.
3
+ *
4
+ * PROVENANCE (pure move, 2026-10-27): every declaration in this file was
5
+ * moved verbatim from `live-session-runtime.ts` @ 39fe0b3a (ranges 133-174,
6
+ * 240-260, 455-725) to open wc-gate headroom. Byte-identical bodies except
7
+ * added `export` keywords. Public surface of live-session-runtime.ts is
8
+ * preserved via re-exports there (LiveSessionSpawnInput, liveSystemPrompt,
9
+ * resolveScopedSessionModels, resolveParentModelFromRegistry).
10
+ */
11
+
12
+ import type { AgentConfig } from "../../agents/agent-config.ts";
13
+ import { resolveToolPolicy } from "../../agents/agent-config.ts";
14
+ import type { CrewRuntimeConfig } from "../../config/config.ts";
15
+ import { loadConfig } from "../../config/config.ts";
16
+ import type { TeamRunManifest, TeamTaskState } from "../../state/types.ts";
17
+ import { logInternalError } from "../../utils/internal-error.ts";
18
+ import type { WorkflowStep } from "../../workflows/workflow-config.ts";
19
+ import { mcpPermittedForRole } from "../mcp-proxy.ts";
20
+ import {
21
+ availableModelInfosFromRegistry,
22
+ type ModelFallbackPolicy,
23
+ modelRefToString,
24
+ providerOfModelRef,
25
+ resolveDefaultSubagentModel,
26
+ resolveModelFallbackPolicy,
27
+ } from "../model/model-fallback.ts";
28
+ import { readEnabledModelsPatterns } from "../model/model-scope.ts";
29
+ import { buildSensitivePathConstraint } from "../sensitive-paths.ts";
30
+ import type { LiveSessionEmbeddedMitigations } from "./embedded-tier.ts";
31
+ import { asRecord, type SessionStatsLike } from "./usage-bridge.ts";
32
+
33
+ export interface LiveSessionSpawnInput {
34
+ manifest: TeamRunManifest;
35
+ task: TeamTaskState;
36
+ step: WorkflowStep;
37
+ agent: AgentConfig;
38
+ prompt: string;
39
+ signal?: AbortSignal;
40
+ transcriptPath?: string;
41
+ onEvent?: (event: unknown) => void;
42
+ onOutput?: (text: string) => void;
43
+ runtimeConfig?: CrewRuntimeConfig;
44
+ parentContext?: string;
45
+ parentModel?: unknown;
46
+ modelRegistry?: unknown;
47
+ modelOverride?: string;
48
+ teamRoleModel?: string;
49
+ teamRoleFallbackModels?: string[];
50
+ teamRoleThinking?: string;
51
+ isCurrent?: () => boolean;
52
+ /** Workspace where this run was initiated — used for session-scoped live-agent visibility. */
53
+ workspaceId: string;
54
+ /** Phase 2: Output schema for validating yield data. */
55
+ outputSchema?: unknown;
56
+ /**
57
+ * U3 test seam: optional pi-ai `Provider`-like object registered on the
58
+ * created session's ModelRuntime immediately after session creation, before
59
+ * anything streams. Lets tests run the REAL in-process live-session worker
60
+ * key-free and network-free against `@earendil-works/pi-ai/providers/faux`
61
+ * (tests construct the provider and pass it here; runtime code never
62
+ * imports the faux module). Must stay undefined in production runs.
63
+ */
64
+ modelProviderOverride?: unknown;
65
+ /**
66
+ * U11 embedded-tier mitigations (DEFAULT-OFF). Present ONLY when the hybrid
67
+ * orchestration layer routed this task to the embedded tier for an
68
+ * opted-in role: builtin-tools-only enforcement, provider timeoutMs,
69
+ * turn-count watchdog, embedded concurrency cap, process-level fault
70
+ * guard, bounded memory metrics and factory/global-state asserts. When
71
+ * undefined this function behaves byte-identically to pre-U11.
72
+ */
73
+ embeddedMitigations?: LiveSessionEmbeddedMitigations;
74
+ }
75
+
76
+ export type LiveSessionLike = {
77
+ subscribe?: (listener: (event: unknown) => void) => () => void;
78
+ prompt?: (text: string, options?: Record<string, unknown>) => Promise<void>;
79
+ steer?: (text: string) => Promise<void>;
80
+ abort?: () => Promise<void> | void;
81
+ dispose?: () => void;
82
+ /** U16: SDK AgentSession session-level stats surface (aggregate tokens + cost) — shape: usage-bridge.ts (SessionStatsLike). */
83
+ getSessionStats?: () => SessionStatsLike;
84
+ bindExtensions?: (bindings?: Record<string, unknown>) => Promise<void>;
85
+ getActiveToolNames?: () => string[];
86
+ setActiveToolsByName?: (names: string[]) => void;
87
+ /** U3 test-seam surface: SDK AgentSession exposes its ModelRuntime here. */
88
+ modelRuntime?: {
89
+ registerNativeProvider?: (provider: unknown) => void;
90
+ /** U11 (b): surfaces used to wrap/restore the provider with a default timeoutMs. */
91
+ getProvider?: (providerId: string) => unknown;
92
+ getRegisteredNativeProvider?: (providerId: string) => unknown;
93
+ };
94
+ /** U4 recursion guard (layer 1): public SDK accessor for loaded extension paths. */
95
+ extensionRunner?: { getExtensionPaths?: () => string[] };
96
+ };
97
+
98
+ export function applyModelProviderOverride(session: LiveSessionLike | undefined, provider: unknown): void {
99
+ if (!session || !provider) return;
100
+ const runtime = asRecord(session.modelRuntime);
101
+ const register = runtime?.registerNativeProvider;
102
+ if (typeof register !== "function") return;
103
+ try {
104
+ register.call(runtime, provider);
105
+ } catch (error) {
106
+ logInternalError("live-session.model-provider-override", error, "registerNativeProvider failed");
107
+ }
108
+ }
109
+
110
+ /**
111
+ * F7: resolve the enabledModels allowlist for the current project, but only
112
+ * if the `reliability.scopeModels` toggle is ON. Returns an empty
113
+ * array when the toggle is off or no allowlist is configured — the routing
114
+ * gate treats empty patterns as "no enforcement" (no-op). Best-effort:
115
+ * any failure to read the toggle or the allowlist silently disables the gate
116
+ * rather than blocking spawn.
117
+ */
118
+ export async function resolveScopeModelsPatterns(cwd: string, agentDir?: string): Promise<string[]> {
119
+ let scopeModels = false;
120
+ try {
121
+ scopeModels = loadConfig(cwd).config.reliability?.scopeModels === true;
122
+ } catch {
123
+ return [];
124
+ }
125
+ if (!scopeModels) return [];
126
+ return readEnabledModelsPatterns(cwd, agentDir);
127
+ }
128
+
129
+ /** Resolve model fallback policy from crew config + env (best-effort). */
130
+ export function resolveLiveModelFallbackPolicy(cwd: string): ModelFallbackPolicy | undefined {
131
+ try {
132
+ return resolveModelFallbackPolicy(loadConfig(cwd).config.runtime?.modelFallback);
133
+ } catch {
134
+ return undefined;
135
+ }
136
+ }
137
+
138
+ /** Resolve the default subagent model from crew config + env. */
139
+ export function resolveLiveDefaultSubagentModel(cwd: string): string | undefined {
140
+ try {
141
+ return resolveDefaultSubagentModel(loadConfig(cwd).config.runtime?.modelFallback);
142
+ } catch {
143
+ return undefined;
144
+ }
145
+ }
146
+
147
+ export function modelFromRegistry(modelRegistry: unknown, modelId: string | undefined): unknown {
148
+ if (!modelId?.includes("/")) return undefined;
149
+ const registry = asRecord(modelRegistry);
150
+ const find = registry?.find;
151
+ if (typeof find !== "function") return undefined;
152
+ const [provider, ...modelParts] = modelId.split("/");
153
+ const id = modelParts.join("/");
154
+ try {
155
+ return find.call(modelRegistry, provider, id);
156
+ } catch {
157
+ return undefined;
158
+ }
159
+ }
160
+
161
+ /**
162
+ * R3-12: resolve pi-crew's fallback chain into pi SDK `scopedModels` entries.
163
+ *
164
+ * SDK semantics (pi-coding-agent docs/sdk.md; dist/core/agent-session.js):
165
+ * `scopedModels` bounds the session's CYCLING scope and what extensions see
166
+ * through `getScopedModels` — it is NOT a hard access gate (`setModel` still
167
+ * accepts any credentialed model), so binding the fallback chain cannot break
168
+ * a legitimate user/model override. Candidates that don't resolve in the
169
+ * registry are skipped; an empty result means the caller passes NO option so
170
+ * pi keeps its default scope (all available models) — the "explicit
171
+ * non-empty" guard from the R3 spec.
172
+ */
173
+ export function resolveScopedSessionModels(modelRegistry: unknown, candidates: readonly string[]): Array<{ model: unknown }> {
174
+ const scoped: Array<{ model: unknown }> = [];
175
+ for (const candidate of candidates) {
176
+ const model = modelFromRegistry(modelRegistry, candidate);
177
+ if (!model || typeof model !== "object") continue;
178
+ if (scoped.some((entry) => entry.model === model)) continue;
179
+ scoped.push({ model });
180
+ }
181
+ return scoped;
182
+ }
183
+
184
+ /**
185
+ * Round 18: when agent declares `model: false`, the inherited `parentModel`
186
+ * (= `ctx.model` from Pi runtime, set via `team-tool.ts:541/655`) is the
187
+ * session's SAVED model. That saved model can be stale (e.g. a previous
188
+ * session used claude-sonnet-4-5 and saved it as session.model; the new
189
+ * session actually runs on minimax-M3 displayed in the footer). If the
190
+ * saved model has no auth in `modelRegistry`, the worker fails immediately
191
+ * with "No API key found" before reaching any fallback candidate.
192
+ *
193
+ * This helper prefers the saved model when it is in the auth-available
194
+ * registry; otherwise falls back to the first auth-available registry
195
+ * model (e.g. minimax/MiniMax-M3, zai/glm-5.2); otherwise returns the
196
+ * raw `parentModel` unchanged so the caller surfaces E008.
197
+ */
198
+ export function resolveParentModelFromRegistry(modelRegistry: unknown, rawParentModel: unknown): string | undefined {
199
+ // `ctx.model` is a pi `Model` OBJECT, not a string. The old string-only
200
+ // guard silently discarded it, so every live-session subagent that inherited
201
+ // the parent model landed on `getAvailable()[0]` instead — i.e. the parent
202
+ // model was never actually honoured on this path.
203
+ const raw = modelRefToString(rawParentModel);
204
+ if (raw) {
205
+ const candidate = raw.includes("/")
206
+ ? raw
207
+ : (() => {
208
+ const m = modelFromRegistry(modelRegistry, raw);
209
+ if (m && typeof m === "object" && "fullId" in m) {
210
+ return String((m as { fullId?: unknown }).fullId ?? raw);
211
+ }
212
+ return undefined;
213
+ })();
214
+ if (candidate && modelFromRegistry(modelRegistry, candidate)) return candidate;
215
+ }
216
+ const available = availableModelInfosFromRegistry(modelRegistry);
217
+ if (available && available.length > 0) {
218
+ // Staying on the parent's provider keeps the same auth + cost profile;
219
+ // only cross providers when the parent's provider has nothing available.
220
+ const parentProvider = providerOfModelRef(raw);
221
+ const sameProvider = parentProvider ? available.find((entry) => entry.provider === parentProvider) : undefined;
222
+ return (sameProvider ?? available[0]).fullId;
223
+ }
224
+ return raw;
225
+ }
226
+
227
+ /** Communication intensity by role (caveman-inspired token optimization) */
228
+ const ROLE_INTENSITY: Record<string, "lite" | "full" | "ultra"> = {
229
+ explorer: "ultra",
230
+ analyst: "full",
231
+ planner: "full",
232
+ critic: "full",
233
+ executor: "full",
234
+ reviewer: "full",
235
+ "security-reviewer": "full",
236
+ "test-engineer": "full",
237
+ verifier: "full",
238
+ writer: "lite",
239
+ };
240
+
241
+ function buildCommunicationStyle(role: string): string {
242
+ const intensity = ROLE_INTENSITY[role] ?? "full";
243
+ if (intensity === "lite") return "## Communication\nProfessional concise. No filler/hedging. Full sentences OK.";
244
+ if (intensity === "ultra")
245
+ return [
246
+ "## Communication (ultra-compressed)",
247
+ "Drop: articles, filler, hedging, pleasantries. Fragments OK.",
248
+ "Pattern: [thing] [action] [reason].",
249
+ "Code/paths/symbols: exact, never abbreviated. Errors quoted exact.",
250
+ "Abbreviate prose words: DB/auth/config/req/res/fn/impl.",
251
+ "Arrows for causality: X → Y. One word when one word enough.",
252
+ "Security/destructive: write normal English. Resume compressed after.",
253
+ ].join("\n");
254
+ return [
255
+ "## Communication (compressed)",
256
+ "Drop: articles (a/an/the), filler (just/really/basically/actually/simply), hedging, pleasantries.",
257
+ "Short synonyms. Fragments OK. Pattern: [thing] [action] [reason]. [next step].",
258
+ "Code/paths/symbols: exact. Errors quoted exact.",
259
+ "Security/destructive: write normal English. Resume compressed after.",
260
+ ].join("\n");
261
+ }
262
+
263
+ function buildOutputContract(role: string): string {
264
+ if (role === "explorer")
265
+ return [
266
+ "## Output Contract",
267
+ "<path>:<line> — `<symbol>` — <≤6 word note>",
268
+ "Group: Defs: / Refs: / Callers: / Tests: / Sites:",
269
+ 'Zero hits → "No match."',
270
+ "Last line → totals: N defs, M refs.",
271
+ ].join("\n");
272
+ if (role === "executor")
273
+ return [
274
+ "## Output Contract",
275
+ "<path>:<line-range> — <change ≤10 words>.",
276
+ "verified: <re-read OK | mismatch @ path:line>.",
277
+ "Refusal tokens: too-big. / needs-confirm. / ambiguous. / regressed.",
278
+ ].join("\n");
279
+ if (role === "reviewer" || role === "security-reviewer")
280
+ return [
281
+ "## Output Contract",
282
+ "<path>:<line>: <emoji> <severity>: <problem>. <fix>.",
283
+ "Severity: 🔴 bug, 🟡 risk, 🔵 nit, ❓ question.",
284
+ 'Zero findings → "No issues."',
285
+ "Sorted: file order → ascending line numbers.",
286
+ ].join("\n");
287
+ if (role === "verifier")
288
+ return [
289
+ "## Output Contract",
290
+ "PASS: <what verified> — <evidence ≤20 words>.",
291
+ "FAIL: <what failed> — <reason>. <expected vs actual>.",
292
+ "Evidence: file paths, test output, or diffs.",
293
+ ].join("\n");
294
+ if (role === "writer") return "## Output Contract\nWrite clear documentation. Full sentences. No compression.";
295
+ return ""; // planner, critic, analyst, test-engineer: no strict format
296
+ }
297
+
298
+ /**
299
+ * Phase 3 (caveman): Compress tool descriptions in a live session to reduce
300
+ * input token cost per tool call. MCP tools often have verbose descriptions
301
+ * (e.g. "This tool allows you to search for files in the filesystem..." → "Search files in filesystem.").
302
+ * Compresses only description text, never modifies tool names or parameters.
303
+ */
304
+ export function compressSessionToolDescriptions(session: LiveSessionLike): void {
305
+ if (typeof session.getActiveToolNames !== "function") return;
306
+ // The Pi SDK doesn't expose a setDescription API, but we can attempt
307
+ // to compress via setActiveToolsByName if the session supports it.
308
+ // For now, this is a no-op that documents the intent for future SDK support.
309
+ // When Pi SDK adds tool description mutation, this function will compress.
310
+ // Side benefit: the import of compressToolDescription ensures the module
311
+ // is loaded and tree-shakeable, so adding the actual logic later is trivial.
312
+ }
313
+
314
+ export function liveSystemPrompt(input: LiveSessionSpawnInput): string {
315
+ // Agent MEMORY is intentionally omitted here — it is already injected via
316
+ // renderTaskPrompt().full (the user prompt, shared with the child-pi path).
317
+ // See the import-block note above and the G3 fix.
318
+ const role = input.task.role;
319
+ const styleBlock = buildCommunicationStyle(role);
320
+ const contractBlock = buildOutputContract(role);
321
+ const sensitiveConstraint = buildSensitivePathConstraint();
322
+ return [
323
+ "# pi-crew Live Subagent",
324
+ `Run ID: ${input.manifest.runId}`,
325
+ `Task ID: ${input.task.id}`,
326
+ `Role: ${role}`,
327
+ `Agent: ${input.agent.name}`,
328
+ `Working directory: ${input.task.cwd}`,
329
+ "",
330
+ styleBlock,
331
+ contractBlock,
332
+ sensitiveConstraint,
333
+ "",
334
+ input.agent.systemPrompt || "Follow the user task exactly and report verification evidence.",
335
+ ]
336
+ .filter(Boolean)
337
+ .join("\n");
338
+ }
339
+
340
+ export function filterActiveTools(session: LiveSessionLike, agent: AgentConfig, role?: string): void {
341
+ if (typeof session.getActiveToolNames !== "function" || typeof session.setActiveToolsByName !== "function") return;
342
+ const recursiveTools = new Set(["team", "Team", "Agent", "get_subagent_result", "steer_subagent"]);
343
+ // U4 hung-tool mitigation (a) "builtin-tools-only": hard floor on the
344
+ // active set — built-in tools (bash honors AbortSignal via killProcessTree),
345
+ // MCP tools for MCP-permitted roles, and pi-crew's own custom tools. Any
346
+ // other tool name (extension drift the loader-level guard missed) is
347
+ // dropped from the ACTIVE set here (layer 2).
348
+ const builtinFloor = new Set(["read", "bash", "edit", "write", "grep", "find", "ls", "powershell", "submit_result", "irc"]);
349
+ const mcpPermitted = mcpPermittedForRole(role);
350
+ // F1 unify (v0.8.0): use the shared resolveToolPolicy so this path agrees
351
+ // with child-pi (pi-args.ts). Before this, live-session used frontmatter
352
+ // only and ignored role-config entirely — so a builtin explorer on the
353
+ // live-session path wasn't bound by the role's read-only security constraint.
354
+ // Now allowlist precedence is source-aware and the denylist is additive.
355
+ const policy = resolveToolPolicy(agent, role);
356
+ const disallowed = policy.excludeTools?.length ? new Set(policy.excludeTools) : undefined;
357
+ const allowed = policy.tools?.length ? new Set(policy.tools) : undefined;
358
+ const active = session
359
+ .getActiveToolNames()
360
+ .filter(
361
+ (name) =>
362
+ !recursiveTools.has(name) &&
363
+ !disallowed?.has(name) &&
364
+ (!allowed || allowed.has(name)) &&
365
+ (builtinFloor.has(name) || (mcpPermitted && name.startsWith("mcp__"))),
366
+ );
367
+ session.setActiveToolsByName(active);
368
+ }