pi-maestro-teammate 2.6.0 → 2.6.2

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-maestro-teammate",
3
- "version": "2.6.0",
3
+ "version": "2.6.2",
4
4
  "description": "Pi extension — teammate agent dispatch with DAG task graphs, RPC messaging, and compact TUI",
5
5
  "type": "module",
6
6
  "engines": {
@@ -69,6 +69,10 @@
69
69
  "types": "./types/public/v1/extension.d.ts",
70
70
  "default": "./src/public/v1/extension.ts"
71
71
  },
72
+ "./v1/foreground-detach": {
73
+ "types": "./types/public/v1/foreground-detach.d.ts",
74
+ "default": "./src/public/v1/foreground-detach.ts"
75
+ },
72
76
  "./v1/model-routing": {
73
77
  "types": "./types/public/v1/model-routing.d.ts",
74
78
  "default": "./src/public/v1/model-routing.ts"
@@ -197,6 +197,23 @@ function isReservedAgentName(name: string): boolean {
197
197
  return isBuiltinAgentName(name);
198
198
  }
199
199
 
200
+ function agentDefinitionFingerprint(agent: AgentConfig): string {
201
+ return JSON.stringify([
202
+ agent.name,
203
+ agent.description,
204
+ agent.tools ?? null,
205
+ agent.model ?? null,
206
+ agent.fallbackModels ?? null,
207
+ agent.taskType ?? null,
208
+ agent.thinking ?? null,
209
+ agent.systemPromptMode,
210
+ agent.inheritProjectContext,
211
+ agent.inheritSkills,
212
+ agent.defaultContext ?? null,
213
+ agent.systemPrompt,
214
+ ]);
215
+ }
216
+
200
217
  interface DiscoveryDirs {
201
218
  legacyUserAgentsDir: string;
202
219
  userPiAgentsDir: string;
@@ -394,8 +411,17 @@ export function discoverAgents(
394
411
  }> = [];
395
412
  const mergeAgent = (agent: AgentConfig): void => {
396
413
  // Builtin names are reserved so custom definitions cannot silently replace
397
- // the stable general, exploration, and DAG orchestration roles.
414
+ // the stable general, exploration, and DAG orchestration roles. Package
415
+ // catalogs may carry byte-equivalent builtin mirrors for other consumers;
416
+ // those are duplicates, not attempted overrides.
398
417
  if (agent.source !== "builtin" && isReservedAgentName(agent.name)) {
418
+ const builtin = agentMap.get(agent.name);
419
+ if (
420
+ builtin?.source === "builtin"
421
+ && agentDefinitionFingerprint(builtin) === agentDefinitionFingerprint(agent)
422
+ ) {
423
+ return;
424
+ }
399
425
  rejectedCandidates.push({ name: agent.name, reason: "reserved-builtin", candidate: agent });
400
426
  return;
401
427
  }
@@ -487,7 +487,7 @@ import {
487
487
  type RuntimeReadModelSnapshotV2,
488
488
  } from "../runtime-v2/read-model.ts";
489
489
  import { RuntimeReadModelBrokerBridge } from "../runtime-v2/broker-read-model.ts";
490
- import { formatLocalAgentMessage } from "../shared/routing.ts";
490
+ import { formatLocalAgentMessage, formatNoRestorableRuntimeError } from "../shared/routing.ts";
491
491
  export * from "./teammate-core.ts";
492
492
  import {
493
493
  appendTeammateDepthContext,
@@ -2704,7 +2704,7 @@ export default function registerTeammateExtension(
2704
2704
  const restarted = agent.status === "sleeping" && agent.restart?.(message, provenance) === true;
2705
2705
  if (!restarted) {
2706
2706
  restoreDeferredAgentContext(agent, deferredContext);
2707
- return { delivered: false, error: `Agent "${targetLabel}" has no restorable runtime.` };
2707
+ return { delivered: false, error: formatNoRestorableRuntimeError(targetLabel, agent.status) };
2708
2708
  }
2709
2709
  const restartDelivery = agent.restartDelivery;
2710
2710
  if (restartDelivery) {
@@ -6201,7 +6201,7 @@ export default function registerTeammateExtension(
6201
6201
  const agent = cid ? state.activeRuns.get(cid) : undefined;
6202
6202
  if (agent && !LIVE_AGENT_STATUSES.has(agent.status)) {
6203
6203
  return {
6204
- content: [{ type: "text", text: `Agent "${params.to}" is already ${agent.status} and cannot receive commands.` }],
6204
+ content: [{ type: "text", text: formatNoRestorableRuntimeError(params.to, agent.status) }],
6205
6205
  isError: true,
6206
6206
  details: { delivered: false },
6207
6207
  };
@@ -10,11 +10,11 @@ import { randomUUID } from "node:crypto";
10
10
  import { logDiagnosticError, logDiagnosticWarn } from "../shared/diagnostic-log.ts";
11
11
 
12
12
  import { altKey } from "pi-maestro-settings-core/v1";
13
+ import { registerForegroundDetach, setPersistentUi } from "../public/v1/foreground-detach.ts";
13
14
  import type {
14
15
  ExtensionAPI,
15
16
  ExtensionCommandContext,
16
17
  ExtensionContext,
17
- ExtensionUIContext,
18
18
  ToolDefinition,
19
19
  } from "@earendil-works/pi-coding-agent";
20
20
  import { createHash } from "node:crypto";
@@ -896,81 +896,7 @@ export function backgroundWaitGuidance(correlationId: string): string {
896
896
  */
897
897
  export const FOREGROUND_DETACH_HINT = `${altKey("B")} detaches a foreground call to background.`;
898
898
 
899
- /**
900
- * One session-scoped Alt+B listener dispatches to the oldest active foreground
901
- * owner. This makes nested calls detach layer by layer from the outermost call
902
- * instead of relying on TUI listener registration order.
903
- */
904
- type ForegroundDetachOwner = {
905
- active: boolean;
906
- detach(): void;
907
- };
908
-
909
- let persistentUi: ExtensionUIContext | undefined;
910
- let persistentUiUnsubscribe: (() => void) | undefined;
911
- const foregroundDetachOwners: ForegroundDetachOwner[] = [];
912
-
913
- function uninstallForegroundDetachListener(): void {
914
- const unsubscribe = persistentUiUnsubscribe;
915
- persistentUiUnsubscribe = undefined;
916
- unsubscribe?.();
917
- }
918
-
919
- function installForegroundDetachListener(): void {
920
- if (!persistentUi || persistentUiUnsubscribe || foregroundDetachOwners.length === 0) return;
921
- persistentUiUnsubscribe = persistentUi.onTerminalInput((data: string) => {
922
- if (data !== "\x1bb") return undefined;
923
- const owner = foregroundDetachOwners.shift();
924
- if (!owner) return undefined;
925
- owner.active = false;
926
- if (foregroundDetachOwners.length === 0) uninstallForegroundDetachListener();
927
- owner.detach();
928
- return { consume: true };
929
- });
930
- }
931
-
932
- export function setPersistentUi(
933
- ui: ExtensionUIContext | undefined,
934
- resetOwners = false,
935
- ): void {
936
- if (persistentUi !== ui || resetOwners) {
937
- uninstallForegroundDetachListener();
938
- persistentUi = ui;
939
- }
940
- if (!ui || resetOwners) {
941
- for (const owner of foregroundDetachOwners) owner.active = false;
942
- foregroundDetachOwners.length = 0;
943
- if (!ui) return;
944
- }
945
- installForegroundDetachListener();
946
- }
947
-
948
- /** Registers one foreground owner; unregister is idempotent on every race path. */
949
- export function registerForegroundDetach(
950
- detach: () => void,
951
- ui?: ExtensionUIContext,
952
- ): () => void {
953
- if (ui) setPersistentUi(ui);
954
- const owner: ForegroundDetachOwner = { active: true, detach };
955
- foregroundDetachOwners.push(owner);
956
- try {
957
- installForegroundDetachListener();
958
- } catch (error) {
959
- owner.active = false;
960
- const index = foregroundDetachOwners.indexOf(owner);
961
- if (index >= 0) foregroundDetachOwners.splice(index, 1);
962
- if (foregroundDetachOwners.length === 0) uninstallForegroundDetachListener();
963
- throw error;
964
- }
965
-
966
- return () => {
967
- if (!owner.active) return;
968
- owner.active = false;
969
- const index = foregroundDetachOwners.indexOf(owner);
970
- if (index >= 0) foregroundDetachOwners.splice(index, 1);
971
- if (foregroundDetachOwners.length === 0) uninstallForegroundDetachListener();
972
- };
973
- }
899
+ export { registerForegroundDetach, setPersistentUi };
974
900
 
975
901
  export function foregroundWaitWindowMs(
976
902
  tasks: ReadonlyArray<{ timeoutMs?: number }>,
@@ -20,7 +20,11 @@ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
20
20
  import { Check } from "typebox/value";
21
21
  import { isGuiTeammateToolAllowed, registerGuiTool, unregisterGuiTool } from "../shared/gui-registry.ts";
22
22
  import { aggregateAgentRunPhase, projectAgentRuntime } from "../shared/agent-status.ts";
23
- import { formatLocalAgentMessage, resolveAgentCompletionTarget } from "../shared/routing.ts";
23
+ import {
24
+ formatLocalAgentMessage,
25
+ formatNoRestorableRuntimeError,
26
+ resolveAgentCompletionTarget,
27
+ } from "../shared/routing.ts";
24
28
  import { Text, truncateToWidth } from "@earendil-works/pi-tui";
25
29
  import { TeammateParams, TeammateSendParams, TeammateListParams, TeammateWatchParams, TeammateWaitParams, TeammateMonitorParams, ObserveParams, LocalObserveParams } from "./schemas.ts";
26
30
  import {
@@ -3113,7 +3117,7 @@ export async function handleProxyRequest(
3113
3117
  const agent = state.activeRuns.get(cid);
3114
3118
  if (agent && !LIVE_AGENT_STATUSES.has(agent.status)) {
3115
3119
  reply({ type: "teammate_proxy_result", requestId, result: {
3116
- content: [{ type: "text", text: `Agent "${to}" is already ${agent.status} and cannot receive commands.` }],
3120
+ content: [{ type: "text", text: formatNoRestorableRuntimeError(to, agent.status) }],
3117
3121
  isError: true, details: { delivered: false },
3118
3122
  }});
3119
3123
  return;
@@ -3298,7 +3302,7 @@ export async function handleProxyRequest(
3298
3302
  if (!restarted || !agent) {
3299
3303
  if (agent) restoreDeferredAgentContext(agent, deferredContext);
3300
3304
  reply({ type: "teammate_proxy_result", requestId, result: {
3301
- content: [{ type: "text", text: `Agent "${to}" has no restorable runtime.` }],
3305
+ content: [{ type: "text", text: formatNoRestorableRuntimeError(to, agent?.status) }],
3302
3306
  isError: true, details: { delivered: false },
3303
3307
  }});
3304
3308
  return;
@@ -0,0 +1,90 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ /** One session-scoped Alt+B listener shared by every foreground tool owner. */
4
+ type ForegroundDetachOwner = {
5
+ active: boolean;
6
+ detach(): void;
7
+ };
8
+
9
+ type ForegroundDetachRegistry = {
10
+ ui?: ExtensionUIContext;
11
+ unsubscribe?: () => void;
12
+ owners: ForegroundDetachOwner[];
13
+ };
14
+
15
+ const REGISTRY_KEY = Symbol.for("pi-maestro.foreground-detach.v1");
16
+ const globals = globalThis as typeof globalThis & Record<symbol, unknown>;
17
+
18
+ function registry(): ForegroundDetachRegistry {
19
+ const existing = globals[REGISTRY_KEY];
20
+ if (existing && typeof existing === "object" && Array.isArray((existing as ForegroundDetachRegistry).owners)) {
21
+ return existing as ForegroundDetachRegistry;
22
+ }
23
+ const created: ForegroundDetachRegistry = { owners: [] };
24
+ globals[REGISTRY_KEY] = created;
25
+ return created;
26
+ }
27
+
28
+ function uninstallForegroundDetachListener(state: ForegroundDetachRegistry): void {
29
+ const unsubscribe = state.unsubscribe;
30
+ state.unsubscribe = undefined;
31
+ unsubscribe?.();
32
+ }
33
+
34
+ function installForegroundDetachListener(state: ForegroundDetachRegistry): void {
35
+ if (!state.ui || state.unsubscribe || state.owners.length === 0) return;
36
+ state.unsubscribe = state.ui.onTerminalInput((data: string) => {
37
+ if (data !== "\x1bb") return undefined;
38
+ const owner = state.owners.shift();
39
+ if (!owner) return undefined;
40
+ owner.active = false;
41
+ if (state.owners.length === 0) uninstallForegroundDetachListener(state);
42
+ owner.detach();
43
+ return { consume: true };
44
+ });
45
+ }
46
+
47
+ export function setPersistentUi(
48
+ ui: ExtensionUIContext | undefined,
49
+ resetOwners = false,
50
+ ): void {
51
+ const state = registry();
52
+ if (state.ui !== ui || resetOwners) {
53
+ uninstallForegroundDetachListener(state);
54
+ state.ui = ui;
55
+ }
56
+ if (!ui || resetOwners) {
57
+ for (const owner of state.owners) owner.active = false;
58
+ state.owners.length = 0;
59
+ if (!ui) return;
60
+ }
61
+ installForegroundDetachListener(state);
62
+ }
63
+
64
+ /** Registers one foreground owner; unregister is idempotent on every race path. */
65
+ export function registerForegroundDetach(
66
+ detach: () => void,
67
+ ui?: ExtensionUIContext,
68
+ ): () => void {
69
+ if (ui) setPersistentUi(ui);
70
+ const state = registry();
71
+ const owner: ForegroundDetachOwner = { active: true, detach };
72
+ state.owners.push(owner);
73
+ try {
74
+ installForegroundDetachListener(state);
75
+ } catch (error) {
76
+ owner.active = false;
77
+ const index = state.owners.indexOf(owner);
78
+ if (index >= 0) state.owners.splice(index, 1);
79
+ if (state.owners.length === 0) uninstallForegroundDetachListener(state);
80
+ throw error;
81
+ }
82
+
83
+ return () => {
84
+ if (!owner.active) return;
85
+ owner.active = false;
86
+ const index = state.owners.indexOf(owner);
87
+ if (index >= 0) state.owners.splice(index, 1);
88
+ if (state.owners.length === 0) uninstallForegroundDetachListener(state);
89
+ };
90
+ }
@@ -16,6 +16,7 @@ export * from "./completion-durability.ts";
16
16
  export * from "./events.ts";
17
17
  export * from "./execution.ts";
18
18
  export * from "./extension.ts";
19
+ export * from "./foreground-detach.ts";
19
20
  export * from "./mailbox.ts";
20
21
  export * from "./model-routing.ts";
21
22
  export * from "./monitor-window-state.ts";
@@ -1502,75 +1502,247 @@ export function normalizeTeammateParams(
1502
1502
  }
1503
1503
 
1504
1504
  // ---------------------------------------------------------------------------
1505
- // AC3: Windows-safe pi binary resolution
1505
+ // AC3: shell-free, provenance-bearing Pi launcher resolution
1506
1506
  // ---------------------------------------------------------------------------
1507
1507
 
1508
- export let resolvedPiEntryPoint: string | null | undefined;
1508
+ export type PiLaunchSource =
1509
+ | "override"
1510
+ | "path-native"
1511
+ | "windows-shim"
1512
+ | "host-entry"
1513
+ | "path-fallback";
1509
1514
 
1510
- export function resolvePiEntryPoint(): string | null {
1511
- if (resolvedPiEntryPoint !== undefined) return resolvedPiEntryPoint;
1515
+ export interface PiLaunchSpec {
1516
+ command: string;
1517
+ argsPrefix: string[];
1518
+ source: PiLaunchSource;
1519
+ }
1520
+
1521
+ export interface PiSpawnCommandOptions {
1522
+ envBinary?: string | null;
1523
+ entryPoint?: string | null;
1524
+ platform?: NodeJS.Platform;
1525
+ pathValue?: string | null;
1526
+ argv?: readonly string[];
1527
+ execPath?: string;
1528
+ appData?: string | null;
1529
+ /** @internal deterministic filesystem seam for focused launcher tests. */
1530
+ isFile?: (candidate: string) => boolean;
1531
+ /** @internal deterministic package-manifest seam for focused launcher tests. */
1532
+ readTextFile?: (candidate: string) => string;
1533
+ }
1512
1534
 
1513
- // Try current process argv (if pi is the host)
1514
- const argv1 = process.argv[1];
1515
- if (argv1 && (argv1.endsWith(".mjs") || argv1.endsWith(".js"))) {
1516
- resolvedPiEntryPoint = argv1;
1517
- return resolvedPiEntryPoint;
1535
+ function regularFileExists(candidate: string): boolean {
1536
+ try {
1537
+ return fs.statSync(candidate).isFile();
1538
+ } catch {
1539
+ return false;
1518
1540
  }
1541
+ }
1519
1542
 
1520
- if (process.platform === "win32") {
1521
- // Parse pi.cmd to find the real .js entry point
1522
- const npmDir = process.env.APPDATA
1523
- ? path.join(process.env.APPDATA, "npm")
1524
- : null;
1525
- if (npmDir) {
1526
- const cmdFile = path.join(npmDir, "pi.cmd");
1527
- try {
1528
- const content = fs.readFileSync(cmdFile, "utf-8");
1529
- // pi.cmd contains: "%_prog%" "%dp0%\node_modules\...\cli.js" %*
1530
- const match = content.match(/"?%dp0%\\([^"*%\r\n]+\.(?:js|mjs))"?/);
1531
- if (match) {
1532
- const entryPoint = path.join(npmDir, match[1]);
1533
- if (fs.existsSync(entryPoint)) {
1534
- resolvedPiEntryPoint = entryPoint;
1535
- return resolvedPiEntryPoint;
1536
- }
1537
- }
1538
- } catch { /* fallback */ }
1543
+ function pathPiCandidate(
1544
+ platform: NodeJS.Platform,
1545
+ pathValue: string | null | undefined,
1546
+ isFile: (candidate: string) => boolean,
1547
+ ): Pick<PiLaunchSpec, "command" | "source"> | undefined {
1548
+ if (!pathValue) return undefined;
1549
+ const directories = pathValue.split(platform === "win32" ? ";" : ":").filter(Boolean);
1550
+ if (platform !== "win32") {
1551
+ for (const directory of directories) {
1552
+ const candidate = path.join(directory, "pi");
1553
+ if (isFile(candidate)) return { command: candidate, source: "path-native" };
1539
1554
  }
1555
+ return undefined;
1540
1556
  }
1541
1557
 
1542
- resolvedPiEntryPoint = null;
1543
- return null;
1558
+ // Prefer a native executable anywhere on PATH before considering cmd/bat
1559
+ // wrappers. Ignore npm's extensionless POSIX shim: cross-spawn follows its
1560
+ // shebang through sh.exe, so the Pi Node process does not own the IPC channel.
1561
+ for (const extension of [".exe", ".com"] as const) {
1562
+ for (const directory of directories) {
1563
+ const candidate = path.join(directory, `pi${extension}`);
1564
+ if (isFile(candidate)) return { command: candidate, source: "path-native" };
1565
+ }
1566
+ }
1567
+ for (const extension of [".cmd", ".bat"] as const) {
1568
+ for (const directory of directories) {
1569
+ const candidate = path.join(directory, `pi${extension}`);
1570
+ if (isFile(candidate)) return { command: candidate, source: "windows-shim" };
1571
+ }
1572
+ }
1573
+ return undefined;
1544
1574
  }
1545
1575
 
1546
- export interface PiSpawnCommandOptions {
1547
- envBinary?: string | null;
1548
- entryPoint?: string | null;
1549
- platform?: NodeJS.Platform;
1576
+ const WINDOWS_PI_SHIM_ENTRY_PATTERN = /"?%dp0%[\\/]([^"*%\r\n]+?\.(?:js|mjs|cjs))"?/i;
1577
+
1578
+ function resolvedWindowsPiShim(
1579
+ shim: string,
1580
+ isFile: (candidate: string) => boolean,
1581
+ readTextFile: (candidate: string) => string,
1582
+ execPath: string,
1583
+ ): PiLaunchSpec | undefined {
1584
+ try {
1585
+ const match = WINDOWS_PI_SHIM_ENTRY_PATTERN.exec(readTextFile(shim));
1586
+ if (!match) return undefined;
1587
+ const entry = path.resolve(path.dirname(shim), match[1]!.replace(/[\\/]/g, path.sep));
1588
+ const verified = verifiedHostPiEntry(entry, isFile, readTextFile);
1589
+ return verified
1590
+ ? { command: execPath, argsPrefix: [verified], source: "windows-shim" }
1591
+ : undefined;
1592
+ } catch {
1593
+ return undefined;
1594
+ }
1550
1595
  }
1551
1596
 
1552
- export function getPiSpawnCommand(
1553
- args: string[],
1554
- options: PiSpawnCommandOptions = {},
1555
- ): { command: string; args: string[]; shell: false } {
1597
+ function verifiedHostPiEntry(
1598
+ entryPoint: string | null | undefined,
1599
+ isFile: (candidate: string) => boolean,
1600
+ readTextFile: (candidate: string) => string,
1601
+ ): string | undefined {
1602
+ if (!entryPoint || !/\.(?:js|mjs|cjs)$/i.test(entryPoint) || !isFile(entryPoint)) return undefined;
1603
+ const absoluteEntry = path.resolve(entryPoint);
1604
+ let directory = path.dirname(absoluteEntry);
1605
+ for (let depth = 0; depth < 10; depth += 1) {
1606
+ const manifestPath = path.join(directory, "package.json");
1607
+ if (isFile(manifestPath)) {
1608
+ try {
1609
+ const manifest = JSON.parse(readTextFile(manifestPath)) as {
1610
+ name?: unknown;
1611
+ bin?: unknown;
1612
+ };
1613
+ const acceptedPackage = manifest.name === "@earendil-works/pi-coding-agent"
1614
+ || manifest.name === "@mariozechner/pi-coding-agent";
1615
+ const piBin = typeof manifest.bin === "object" && manifest.bin !== null
1616
+ ? (manifest.bin as Record<string, unknown>).pi
1617
+ : undefined;
1618
+ if (acceptedPackage && typeof piBin === "string"
1619
+ && path.resolve(directory, piBin) === absoluteEntry) return absoluteEntry;
1620
+ } catch {
1621
+ return undefined;
1622
+ }
1623
+ return undefined;
1624
+ }
1625
+ const parent = path.dirname(directory);
1626
+ if (parent === directory) break;
1627
+ directory = parent;
1628
+ }
1629
+ return undefined;
1630
+ }
1631
+
1632
+ export let resolvedPiEntryPoint: string | null | undefined;
1633
+
1634
+ /**
1635
+ * Resolve process.argv[1] only when its owning package manifest proves it is
1636
+ * Pi's declared CLI. A generic .js/.mjs suffix is never evidence of identity.
1637
+ */
1638
+ export function resolvePiEntryPoint(): string | null {
1639
+ if (resolvedPiEntryPoint !== undefined) return resolvedPiEntryPoint;
1640
+ resolvedPiEntryPoint = verifiedHostPiEntry(
1641
+ process.argv[1],
1642
+ regularFileExists,
1643
+ (candidate) => fs.readFileSync(candidate, "utf-8"),
1644
+ ) ?? null;
1645
+ return resolvedPiEntryPoint;
1646
+ }
1647
+
1648
+ export function resolvePiLaunchSpec(options: PiSpawnCommandOptions = {}): PiLaunchSpec {
1556
1649
  const envBinary = options.envBinary === undefined
1557
1650
  ? process.env.PI_TEAMMATE_PI_BINARY
1558
1651
  : options.envBinary;
1559
- if (envBinary) {
1560
- return { command: envBinary, args, shell: false };
1652
+ if (envBinary?.trim()) {
1653
+ return { command: envBinary, argsPrefix: [], source: "override" };
1561
1654
  }
1562
1655
 
1563
- const entryPoint = options.entryPoint === undefined
1656
+ const platform = options.platform ?? process.platform;
1657
+ const isFile = options.isFile ?? regularFileExists;
1658
+ const readTextFile = options.readTextFile ?? ((candidate: string) => fs.readFileSync(candidate, "utf-8"));
1659
+ const execPath = options.execPath ?? process.execPath;
1660
+ const pathCandidate = pathPiCandidate(
1661
+ platform,
1662
+ options.pathValue === undefined ? process.env.PATH : options.pathValue,
1663
+ isFile,
1664
+ );
1665
+ if (pathCandidate?.source === "path-native") return { ...pathCandidate, argsPrefix: [] };
1666
+ if (pathCandidate?.source === "windows-shim") {
1667
+ const resolved = resolvedWindowsPiShim(pathCandidate.command, isFile, readTextFile, execPath);
1668
+ if (resolved) return resolved;
1669
+ }
1670
+
1671
+ // APPDATA/npm is not guaranteed to be present in PATH for service/gateway
1672
+ // processes, so retain an explicit, verified Windows shim fallback.
1673
+ if (platform === "win32") {
1674
+ const appData = options.appData === undefined ? process.env.APPDATA : options.appData;
1675
+ if (appData) {
1676
+ const shim = path.join(appData, "npm", "pi.cmd");
1677
+ if (isFile(shim)) {
1678
+ const resolved = resolvedWindowsPiShim(shim, isFile, readTextFile, execPath);
1679
+ if (resolved) return resolved;
1680
+ }
1681
+ }
1682
+ }
1683
+ const injectedEntry = options.entryPoint !== undefined
1684
+ ? verifiedHostPiEntry(options.entryPoint, isFile, readTextFile)
1685
+ : options.argv !== undefined
1686
+ ? verifiedHostPiEntry(options.argv[1], isFile, readTextFile)
1687
+ : undefined;
1688
+ const hostEntry = options.entryPoint === undefined && options.argv === undefined
1564
1689
  ? resolvePiEntryPoint()
1565
- : options.entryPoint;
1566
- if (entryPoint) {
1567
- return { command: process.execPath, args: [entryPoint, ...args], shell: false };
1690
+ : injectedEntry;
1691
+ if (hostEntry) {
1692
+ return {
1693
+ command: options.execPath ?? process.execPath,
1694
+ argsPrefix: [hostEntry],
1695
+ source: "host-entry",
1696
+ };
1568
1697
  }
1569
1698
 
1570
- // cross-spawn resolves Windows .cmd shims without opting into shell mode
1571
- // and escapes each argv item before invoking cmd.exe internally.
1572
- void options.platform;
1573
- return { command: "pi", args, shell: false };
1699
+ // Preserve the historical ENOENT failure mode when no resolver candidate is
1700
+ // installed, but make that unverified fallback explicit in diagnostics.
1701
+ return { command: "pi", argsPrefix: [], source: "path-fallback" };
1702
+ }
1703
+
1704
+ export function getPiSpawnCommand(
1705
+ args: string[],
1706
+ options: PiSpawnCommandOptions = {},
1707
+ ): PiLaunchSpec & { args: string[]; shell: false } {
1708
+ const launch = resolvePiLaunchSpec(options);
1709
+ return {
1710
+ ...launch,
1711
+ args: [...launch.argsPrefix, ...args],
1712
+ shell: false,
1713
+ };
1714
+ }
1715
+
1716
+ export interface PiLaunchDiagnostic {
1717
+ type: "teammate_pi_launch_diagnostic";
1718
+ source: PiLaunchSource;
1719
+ phase: "spawn" | "child-error" | "close";
1720
+ exitCode: number | null;
1721
+ signal: NodeJS.Signals | null;
1722
+ stderrTail: string;
1723
+ }
1724
+
1725
+ export function piLaunchDiagnostic(
1726
+ launch: Pick<PiLaunchSpec, "source">,
1727
+ phase: PiLaunchDiagnostic["phase"],
1728
+ exitCode: number | null,
1729
+ signal: NodeJS.Signals | null,
1730
+ stderr: string,
1731
+ ): PiLaunchDiagnostic {
1732
+ return {
1733
+ type: "teammate_pi_launch_diagnostic",
1734
+ source: launch.source,
1735
+ phase,
1736
+ exitCode,
1737
+ signal,
1738
+ stderrTail: truncateUtf8Tail(stderr.trim(), EXECUTION_BUFFER_LIMITS.stderrBytes),
1739
+ };
1740
+ }
1741
+
1742
+ export function formatPiLaunchDiagnostic(diagnostic: PiLaunchDiagnostic): string {
1743
+ return `source=${diagnostic.source}, phase=${diagnostic.phase}, exit=${diagnostic.exitCode ?? "null"}, `
1744
+ + `signal=${diagnostic.signal ?? "none"}`
1745
+ + (diagnostic.stderrTail ? `\nstderr tail:\n${diagnostic.stderrTail}` : "");
1574
1746
  }
1575
1747
 
1576
1748
  export interface InteractiveTerminalLaunchOptions {
@@ -94,10 +94,12 @@ import {
94
94
  extractPiEventError,
95
95
  extractStructuredOutputCandidate,
96
96
  extractTextContent,
97
+ formatPiLaunchDiagnostic,
97
98
  getPiSpawnCommand,
98
99
  getTeammateDepth,
99
100
  getTeammateSessionRoot,
100
101
  isPiResultReadyTurn,
102
+ piLaunchDiagnostic,
101
103
  readRegularTextFile,
102
104
  releasePublishedTurnHistory,
103
105
  resetUsage,
@@ -876,8 +878,8 @@ export async function runSingleAttempt(
876
878
  );
877
879
 
878
880
  let useIpc = false;
881
+ const spawnSpec = getPiSpawnCommand(piArgs);
879
882
  try {
880
- const spawnSpec = getPiSpawnCommand(piArgs);
881
883
  useIpc = !spawnSpec.shell;
882
884
  const spawnOpts: Parameters<typeof crossSpawn>[2] = {
883
885
  cwd,
@@ -892,6 +894,8 @@ export async function runSingleAttempt(
892
894
  if (schemaFile) cleanupFile(schemaFile);
893
895
  if (outputFile) cleanupFile(outputFile);
894
896
 
897
+ const spawnDiagnostic = piLaunchDiagnostic(spawnSpec, "spawn", null, null, "");
898
+ options.onChildEvent?.({ ...spawnDiagnostic, correlationId });
895
899
  const result: SingleResult = {
896
900
  agent: params.agent,
897
901
  name: params.name,
@@ -901,7 +905,8 @@ export async function runSingleAttempt(
901
905
  role: "system",
902
906
  content:
903
907
  `Failed to spawn pi subprocess (agent=${params.agent}, model=${state.resolvedModel || "unknown"}, `
904
- + `correlationId=${correlationId}, phase=spawn): ${error instanceof Error ? error.message : String(error)}`,
908
+ + `correlationId=${correlationId}, ${formatPiLaunchDiagnostic(spawnDiagnostic)}): `
909
+ + `${error instanceof Error ? error.message : String(error)}`,
905
910
  }],
906
911
  usage: emptyUsage(),
907
912
  model: state.resolvedModel,
@@ -2869,24 +2874,31 @@ export async function runSingleAttempt(
2869
2874
  if (finalContent && !messages.some((message) => message.content === finalContent)) {
2870
2875
  appendDistinctAssistantMessage(messages, finalContent);
2871
2876
  }
2872
- let stderrAlreadyReported = false;
2873
2877
  if (messages.length === 0) {
2874
2878
  const content = state.lastContent.trim() || stderrTail || "(no output)";
2875
- stderrAlreadyReported = stderrTail.length > 0 && content === stderrTail;
2876
2879
  appendBoundedTranscriptMessage(messages, { role: "assistant", content });
2877
2880
  }
2878
2881
 
2879
- // An abnormal exit used to be a bare number: stderr was dropped whenever
2880
- // the child had produced any assistant text, and the signal was ignored.
2882
+ // Gateway results and Monitor observers consume the same bounded,
2883
+ // provenance-bearing diagnostic projection.
2881
2884
  if ((code ?? 1) !== 0) {
2882
- const detail = stderrAlreadyReported ? "" : stderrTail;
2885
+ const closeDiagnostic = piLaunchDiagnostic(
2886
+ spawnSpec,
2887
+ "close",
2888
+ code,
2889
+ signal,
2890
+ stderrTail,
2891
+ );
2892
+ options.onChildEvent?.({ ...closeDiagnostic, correlationId });
2893
+ const transcriptDiagnostic = stderrTail && messages.some((message) => message.content === stderrTail)
2894
+ ? { ...closeDiagnostic, stderrTail: "" }
2895
+ : closeDiagnostic;
2883
2896
  appendBoundedTranscriptMessage(messages, {
2884
2897
  role: "system",
2885
2898
  content:
2886
2899
  `Teammate child process exited abnormally (agent=${params.agent}, `
2887
- + `correlationId=${correlationId}, exit=${code ?? "null"}, signal=${signal ?? "none"}, `
2888
- + `elapsed=${Date.now() - startTime}ms, tools=${progress.toolCount}).`
2889
- + (detail ? `\nstderr tail:\n${truncateUtf8Tail(detail, EXECUTION_BUFFER_LIMITS.stderrBytes)}` : ""),
2900
+ + `correlationId=${correlationId}, ${formatPiLaunchDiagnostic(transcriptDiagnostic)}, `
2901
+ + `elapsed=${Date.now() - startTime}ms, tools=${progress.toolCount}).`,
2890
2902
  });
2891
2903
  }
2892
2904
 
@@ -2989,9 +3001,17 @@ export async function runSingleAttempt(
2989
3001
  progress.durationMs = Date.now() - startTime;
2990
3002
  options.onProgress?.(progress);
2991
3003
 
3004
+ const childErrorDiagnostic = piLaunchDiagnostic(
3005
+ spawnSpec,
3006
+ "child-error",
3007
+ null,
3008
+ child.signalCode,
3009
+ state.stderrBuffer,
3010
+ );
3011
+ options.onChildEvent?.({ ...childErrorDiagnostic, correlationId });
2992
3012
  const processError =
2993
3013
  `Teammate child process error (agent=${params.agent}, model=${state.resolvedModel || "unknown"}, `
2994
- + `correlationId=${correlationId}, phase=child-error): ${error.message}`;
3014
+ + `correlationId=${correlationId}, ${formatPiLaunchDiagnostic(childErrorDiagnostic)}): ${error.message}`;
2995
3015
  if (state.initialResultPublished) {
2996
3016
  appendBoundedTranscriptMessage(messages, {
2997
3017
  role: "system",
@@ -96,3 +96,13 @@ export function formatLocalAgentMessage(input: LocalAgentMessageInput): string {
96
96
  input.message,
97
97
  ].join("\n");
98
98
  }
99
+
100
+ /** Explain why a settled local agent cannot accept another message and how to continue. */
101
+ export function formatNoRestorableRuntimeError(label: string, status?: string): string {
102
+ const state = status === "completed"
103
+ ? "has completed"
104
+ : status
105
+ ? `is ${status}`
106
+ : "is unavailable";
107
+ return `Agent "${label}" ${state} and has no restorable runtime. Dispatch a new teammate and pass the previous agent:// publication via tasks[].briefing.`;
108
+ }
@@ -5,7 +5,8 @@
5
5
  * TUI: Alt+R mode-aware session list, widget above editor, Alt+B foreground→background detach
6
6
  * Mode: RPC subprocess — stdin open for steer/follow_up/abort
7
7
  */
8
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionUIContext } from "@earendil-works/pi-coding-agent";
8
+ import { registerForegroundDetach, setPersistentUi } from "../public/v1/foreground-detach.ts";
9
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
9
10
  import type { WorkspaceSessionScan } from "../transcript/session-transcript.ts";
10
11
  import type { RecentToolInfo } from "../shared/types.ts";
11
12
  import { type WorkspaceBackgroundJobSnapshot, type WorkspaceOwnerState } from "./workspace-peers.ts";
@@ -122,9 +123,7 @@ export declare function backgroundWaitGuidance(correlationId: string): string;
122
123
  * discoverable across the root single, root graph, and nested foreground paths.
123
124
  */
124
125
  export declare const FOREGROUND_DETACH_HINT: string;
125
- export declare function setPersistentUi(ui: ExtensionUIContext | undefined, resetOwners?: boolean): void;
126
- /** Registers one foreground owner; unregister is idempotent on every race path. */
127
- export declare function registerForegroundDetach(detach: () => void, ui?: ExtensionUIContext): () => void;
126
+ export { registerForegroundDetach, setPersistentUi };
128
127
  export declare function foregroundWaitWindowMs(tasks: ReadonlyArray<{
129
128
  timeoutMs?: number;
130
129
  }>, fallbackMs?: number): number;
@@ -0,0 +1,4 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+ export declare function setPersistentUi(ui: ExtensionUIContext | undefined, resetOwners?: boolean): void;
3
+ /** Registers one foreground owner; unregister is idempotent on every race path. */
4
+ export declare function registerForegroundDetach(detach: () => void, ui?: ExtensionUIContext): () => void;
@@ -16,6 +16,7 @@ export * from "./completion-durability.ts";
16
16
  export * from "./events.ts";
17
17
  export * from "./execution.ts";
18
18
  export * from "./extension.ts";
19
+ export * from "./foreground-detach.ts";
19
20
  export * from "./mailbox.ts";
20
21
  export * from "./model-routing.ts";
21
22
  export * from "./monitor-window-state.ts";
@@ -571,18 +571,46 @@ export declare function buildExpertLeaderPrompt(objective: string): string;
571
571
  export declare function prepareTeammateMode(params: RunTeammateParams): RunTeammateParams;
572
572
  /** Normalize the tasks-only public contract into executable graph tasks. */
573
573
  export declare function normalizeTeammateParams(params: RunTeammateParams): NormalizeTeammateResult;
574
- export declare let resolvedPiEntryPoint: string | null | undefined;
575
- export declare function resolvePiEntryPoint(): string | null;
574
+ export type PiLaunchSource = "override" | "path-native" | "windows-shim" | "host-entry" | "path-fallback";
575
+ export interface PiLaunchSpec {
576
+ command: string;
577
+ argsPrefix: string[];
578
+ source: PiLaunchSource;
579
+ }
576
580
  export interface PiSpawnCommandOptions {
577
581
  envBinary?: string | null;
578
582
  entryPoint?: string | null;
579
583
  platform?: NodeJS.Platform;
584
+ pathValue?: string | null;
585
+ argv?: readonly string[];
586
+ execPath?: string;
587
+ appData?: string | null;
588
+ /** @internal deterministic filesystem seam for focused launcher tests. */
589
+ isFile?: (candidate: string) => boolean;
590
+ /** @internal deterministic package-manifest seam for focused launcher tests. */
591
+ readTextFile?: (candidate: string) => string;
580
592
  }
581
- export declare function getPiSpawnCommand(args: string[], options?: PiSpawnCommandOptions): {
582
- command: string;
593
+ export declare let resolvedPiEntryPoint: string | null | undefined;
594
+ /**
595
+ * Resolve process.argv[1] only when its owning package manifest proves it is
596
+ * Pi's declared CLI. A generic .js/.mjs suffix is never evidence of identity.
597
+ */
598
+ export declare function resolvePiEntryPoint(): string | null;
599
+ export declare function resolvePiLaunchSpec(options?: PiSpawnCommandOptions): PiLaunchSpec;
600
+ export declare function getPiSpawnCommand(args: string[], options?: PiSpawnCommandOptions): PiLaunchSpec & {
583
601
  args: string[];
584
602
  shell: false;
585
603
  };
604
+ export interface PiLaunchDiagnostic {
605
+ type: "teammate_pi_launch_diagnostic";
606
+ source: PiLaunchSource;
607
+ phase: "spawn" | "child-error" | "close";
608
+ exitCode: number | null;
609
+ signal: NodeJS.Signals | null;
610
+ stderrTail: string;
611
+ }
612
+ export declare function piLaunchDiagnostic(launch: Pick<PiLaunchSpec, "source">, phase: PiLaunchDiagnostic["phase"], exitCode: number | null, signal: NodeJS.Signals | null, stderr: string): PiLaunchDiagnostic;
613
+ export declare function formatPiLaunchDiagnostic(diagnostic: PiLaunchDiagnostic): string;
586
614
  export interface InteractiveTerminalLaunchOptions {
587
615
  platform?: NodeJS.Platform;
588
616
  terminalCommand?: string;
@@ -37,3 +37,5 @@ export interface LocalAgentMessageInput {
37
37
  }
38
38
  /** Canonical model-visible envelope for local agent-to-agent messages. */
39
39
  export declare function formatLocalAgentMessage(input: LocalAgentMessageInput): string;
40
+ /** Explain why a settled local agent cannot accept another message and how to continue. */
41
+ export declare function formatNoRestorableRuntimeError(label: string, status?: string): string;