pi-maestro-teammate 2.6.0 → 2.6.1

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.1",
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"
@@ -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 }>,
@@ -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",
@@ -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;