@piagent/platform 1.12.0 → 1.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +3 -3
  3. package/README.vi.md +5 -1
  4. package/docs/command-reference-vietnamese.md +2 -2
  5. package/docs/distribution-standard.md +6 -6
  6. package/docs/en/README.md +1 -0
  7. package/docs/en/windows.md +66 -0
  8. package/docs/languages.json +13 -4
  9. package/docs/operator-manual-vietnamese.md +7 -7
  10. package/docs/publishing-for-teams.md +2 -0
  11. package/docs/release-install-policy.md +2 -2
  12. package/docs/team-onboarding.md +2 -2
  13. package/docs/vi/README.md +1 -0
  14. package/docs/vi/windows.md +66 -0
  15. package/docs-site/architecture.html +1 -1
  16. package/docs-site/benchmark.html +1 -1
  17. package/docs-site/commands.html +1 -1
  18. package/docs-site/content/en/index.html +4 -4
  19. package/docs-site/content/en/whats-new.html +19 -0
  20. package/docs-site/content/index.html +4 -4
  21. package/docs-site/content/whats-new.html +19 -0
  22. package/docs-site/context-engine.html +1 -1
  23. package/docs-site/ecosystem.html +1 -1
  24. package/docs-site/en/architecture.html +1 -1
  25. package/docs-site/en/benchmark.html +1 -1
  26. package/docs-site/en/commands.html +1 -1
  27. package/docs-site/en/context-engine.html +1 -1
  28. package/docs-site/en/ecosystem.html +1 -1
  29. package/docs-site/en/index.html +5 -5
  30. package/docs-site/en/mcp-approval.html +1 -1
  31. package/docs-site/en/mcp-auth.html +1 -1
  32. package/docs-site/en/mcp-servers.html +1 -1
  33. package/docs-site/en/mcp.html +1 -1
  34. package/docs-site/en/permissions.html +1 -1
  35. package/docs-site/en/profiles.html +1 -1
  36. package/docs-site/en/quickstart.html +1 -1
  37. package/docs-site/en/runtime.html +1 -1
  38. package/docs-site/en/scope.html +1 -1
  39. package/docs-site/en/security.html +1 -1
  40. package/docs-site/en/team.html +1 -1
  41. package/docs-site/en/whats-new.html +20 -1
  42. package/docs-site/en/workflows.html +1 -1
  43. package/docs-site/index.html +5 -5
  44. package/docs-site/mcp-approval.html +1 -1
  45. package/docs-site/mcp-auth.html +1 -1
  46. package/docs-site/mcp-servers.html +1 -1
  47. package/docs-site/mcp.html +1 -1
  48. package/docs-site/permissions.html +1 -1
  49. package/docs-site/profiles.html +1 -1
  50. package/docs-site/quickstart.html +1 -1
  51. package/docs-site/runtime.html +1 -1
  52. package/docs-site/scope.html +1 -1
  53. package/docs-site/security.html +1 -1
  54. package/docs-site/team.html +1 -1
  55. package/docs-site/whats-new.html +20 -1
  56. package/docs-site/workflows.html +1 -1
  57. package/evals/private-holdout-v1/public-exposure.v2.json +2 -2
  58. package/package.json +1 -1
  59. package/packages/piagent-core/extensions/piagent-guard.ts +3 -1
  60. package/packages/piagent-core/extensions/shell-write-targets.js +2 -1
  61. package/packages/piagent-core/extensions/source-checkout-paths.ts +4 -3
  62. package/packages/piagent-core/extensions/task-journal.js +2 -1
  63. package/packages/piagent-core/extensions/task-state.js +3 -3
  64. package/packages/piagent-core/managed/broker-client.mjs +2 -1
  65. package/packages/piagent-core/managed/doctor.mjs +5 -3
  66. package/packages/piagent-core/managed/language-environment.mjs +16 -6
  67. package/packages/piagent-core/managed/linux-sandbox.mjs +81 -0
  68. package/packages/piagent-core/managed/session.mjs +4 -1
  69. package/packages/piagent-core/managed/store.d.mts +2 -0
  70. package/packages/piagent-core/managed/store.mjs +12 -0
  71. package/packages/piagent-core/managed/tool-boundary.mjs +41 -14
  72. package/packages/piagent-core/managed/toolchain.mjs +5 -2
  73. package/packages/piagent-core/package.json +1 -1
  74. package/packages/piagent-core/runtime/host/browser-opener.d.mts +2 -0
  75. package/packages/piagent-core/runtime/host/browser-opener.mjs +20 -0
  76. package/packages/piagent-core/runtime/input/chat-images.ts +11 -4
  77. package/packages/piagent-core/runtime/resources/agent-resources.mjs +4 -4
  78. package/packages/piagent-webui/dist/client/assets/{MarkdownMessage-FWfOWZjV.js → MarkdownMessage-D8wcWkJg.js} +1 -1
  79. package/packages/piagent-webui/dist/client/assets/{index-DFKHLVYk.js → index-DWL_wVw7.js} +3 -3
  80. package/packages/piagent-webui/dist/client/index.html +1 -1
  81. package/packages/piagent-webui/extension/piagent-webui.ts +6 -5
  82. package/packages/piagent-webui/gateway/control-socket.ts +14 -2
  83. package/packages/piagent-webui/gateway/managed-launch.ts +8 -6
  84. package/packages/piagent-webui/gateway/native-project-picker.ts +28 -3
  85. package/packages/piagent-webui/gateway/path-suggestions.ts +9 -3
  86. package/packages/piagent-webui/gateway/pi-host.ts +11 -3
  87. package/packages/piagent-webui/gateway/session-runtime-supervisor.ts +3 -1
  88. package/scripts/dashboard-update-job.mjs +3 -2
  89. package/scripts/piagent-cli.mjs +2 -2
  90. package/scripts/piagent-dashboard.mjs +7 -7
  91. package/scripts/piagent-studio.mjs +4 -4
  92. package/scripts/piagent-webui-launcher.mjs +4 -3
@@ -7,7 +7,7 @@
7
7
  <meta name="csp-nonce" content="__PIAGENT_CSP_NONCE__" />
8
8
  <meta name="piagent-webui-mode" content="__PIAGENT_WEBUI_MODE__" />
9
9
  <title>Piagent WebUI</title>
10
- <script type="module" crossorigin src="/assets/index-DFKHLVYk.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-DWL_wVw7.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-Z91z6SlN.css">
12
12
  </head>
13
13
  <body>
@@ -3,7 +3,7 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { fileURLToPath } from "node:url";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
8
8
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
9
9
 
@@ -31,6 +31,7 @@ import { SourceMutationController } from "./source-mutation-controller.ts";
31
31
  import { SourceRevertController } from "./source-revert-controller.ts";
32
32
  import { SourceOpenController } from "./source-open-controller.ts";
33
33
  import { VSCodeHandoff } from "./vscode-handoff.ts";
34
+ import { browserCommand } from "../../piagent-core/runtime/host/browser-opener.mjs";
34
35
  import { TerminalSessionAdapter } from "./terminal-session-adapter.ts";
35
36
  import { isGatewayRuntimeContext } from "../ownership/gateway-runtime-context.ts";
36
37
 
@@ -124,9 +125,9 @@ function removeControlDirectory(directory: string): void {
124
125
  }
125
126
 
126
127
  function openLocalUrl(url: string): boolean {
127
- const command = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
128
- if (!command) return false;
129
- try { const child = spawn(command, [url], { detached: true, stdio: "ignore" }); child.unref(); return true; }
128
+ const opener = browserCommand(url);
129
+ if (!opener) return false;
130
+ try { const child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", ...opener.options }); child.unref(); return true; }
130
131
  catch { return false; }
131
132
  }
132
133
 
@@ -342,7 +343,7 @@ export default function piagentWebUiExtension(pi: ExtensionAPI): void {
342
343
  fs.chmodSync(socketDirectory, 0o700);
343
344
  const socket = path.join(socketDirectory, "control.sock");
344
345
  const controlToken = randomBytes(32).toString("base64url");
345
- const loader = path.join(PLATFORM_ROOT, "scripts", "register-typescript-loader.mjs");
346
+ const loader = pathToFileURL(path.join(PLATFORM_ROOT, "scripts", "register-typescript-loader.mjs")).href;
346
347
  const main = path.join(PACKAGE_ROOT, "server", "sidecar-main.ts");
347
348
  const child = spawn(process.execPath, ["--disable-warning=ExperimentalWarning", "--import", loader, main], {
348
349
  cwd: ctx.cwd, env: { PATH: process.env.PATH ?? "", TMPDIR: process.env.TMPDIR ?? os.tmpdir(), NO_COLOR: "1" },
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import net from "node:net";
3
+ import path from "node:path";
3
4
 
4
5
  const MAX_MESSAGE_BYTES = 4_096;
5
6
  // The folder list grows with every project the company runtime has seen.
@@ -60,7 +61,8 @@ export async function startGatewayControlSocket(options: {
60
61
  socketPath: string;
61
62
  handle(request: GatewayControlRequest): Promise<GatewayControlResponse> | GatewayControlResponse;
62
63
  }): Promise<{ close(): Promise<void> }> {
63
- await removeStaleSocket(options.socketPath);
64
+ const windows = process.platform === "win32";
65
+ if (!windows) await removeStaleSocket(options.socketPath);
64
66
  const server = net.createServer((socket) => {
65
67
  socket.setEncoding("utf8");
66
68
  let body = "", finished = false;
@@ -79,7 +81,7 @@ export async function startGatewayControlSocket(options: {
79
81
  const register = parsed?.action === "project.register";
80
82
  if (!parsed || !["health", "issue-launch-url", "stop", "project.paths", "project.register"].includes(String(parsed.action))
81
83
  || Object.keys(parsed).some((key) => key !== "action" && !(register && key === "cwd"))
82
- || register && (typeof parsed.cwd !== "string" || !parsed.cwd.startsWith("/") || parsed.cwd.length > 4096)) throw new Error("invalid");
84
+ || register && (typeof parsed.cwd !== "string" || !path.isAbsolute(parsed.cwd) || parsed.cwd.length > 4096)) throw new Error("invalid");
83
85
  request = parsed as GatewayControlRequest;
84
86
  } catch { socket.end(`${JSON.stringify({ ok: false, error: "invalid-request" })}\n`); return; }
85
87
  try { socket.end(`${JSON.stringify(await options.handle(request))}\n`); }
@@ -88,6 +90,16 @@ export async function startGatewayControlSocket(options: {
88
90
  });
89
91
  socket.once("error", () => { socket.destroy(); });
90
92
  });
93
+ // A Windows pipe is not a file: there is no stale one to remove and nothing
94
+ // to link. Its name stays taken while a Gateway listens on it.
95
+ if (windows) {
96
+ await new Promise<void>((resolve, reject) => {
97
+ server.once("error", (error: NodeJS.ErrnoException) => reject(error.code === "EADDRINUSE" ? new Error("gateway-already-running") : error));
98
+ server.listen(options.socketPath, resolve);
99
+ });
100
+ let closed: Promise<void> | null = null;
101
+ return { close: () => closed ??= new Promise<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve())) };
102
+ }
91
103
  // Closing a server removes the path it was bound to, whoever holds that path
92
104
  // by then: a Gateway that exited after another had taken the path removed the
93
105
  // running Gateway's socket, which could no longer be stopped or asked for a
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { requestGatewayControl } from "./control-socket.ts";
8
8
  import { gatewayProfileState } from "./profile-state.ts";
9
- import { storedSlot } from "../../piagent-core/managed/store.mjs";
9
+ import { agentWatchDataDirectory, interopEnvironment, storedSlot } from "../../piagent-core/managed/store.mjs";
10
10
 
11
11
  // The personal dashboard shows company sessions but never runs them: it starts
12
12
  // the pinned managed entrypoint (its own process and Gateway, `--serve`) the
@@ -15,7 +15,7 @@ import { storedSlot } from "../../piagent-core/managed/store.mjs";
15
15
  export type ManagedLaunchStatus = { schemaVersion: 1; version: "piagent-managed-launch-status-v1"; available: boolean; model: "agent-watch-auto" };
16
16
  type Binding = { file: string; profile: string; node: string; entrypoint: string; broker: string; brokerSha256: string };
17
17
 
18
- const CLEAN_ENV = { PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8", TERM: "xterm-256color" };
18
+ const CLEAN_ENV = { PATH: "/usr/bin:/bin", LANG: "en_US.UTF-8", TERM: "xterm-256color", ...interopEnvironment() };
19
19
  const READY = /^Agent Watch Auto: ready$/m;
20
20
  const sha256 = (file: string) => createHash("sha256").update(fs.readFileSync(file)).digest("hex");
21
21
  const HEX64 = /^[a-f0-9]{64}$/;
@@ -51,7 +51,7 @@ export function managedLaunchStatus(agentDir = DEFAULT_AGENT_DIR): ManagedLaunch
51
51
  // A key's slot points at its member's conversation store once it has been
52
52
  // launched (store.mjs); until then the slot's own folder is asked.
53
53
  function controlSocket(home: string, profile: string): string {
54
- const root = path.join(home, "Library/Application Support/AgentWatch/ManagedSessions");
54
+ const root = path.join(agentWatchDataDirectory(home), "ManagedSessions");
55
55
  return gatewayProfileState(path.join(root, storedSlot(root, profile) ?? profile)).controlSocket;
56
56
  }
57
57
 
@@ -66,7 +66,9 @@ async function running(socket: string): Promise<{ packageVersion?: unknown } | "
66
66
 
67
67
  // Same foreground step as Agent Watch: macOS may ask once to let this build of
68
68
  // the broker read its Keychain item. Nothing secret comes back. Exit 67 means
69
- // the imported key is no longer connected in Agent Watch.
69
+ // the imported key is no longer connected in Agent Watch. From WSL the broker is
70
+ // Agent Watch for Windows, whose key has no prompt: any other failure means the
71
+ // key cannot be read on this computer.
70
72
  async function authorize(pinned: Binding): Promise<void> {
71
73
  if (fs.realpathSync(pinned.broker) !== pinned.broker || sha256(pinned.broker) !== pinned.brokerSha256) throw new Error("managed-launch-binding-changed");
72
74
  const code = await new Promise<number | null>((resolve) => {
@@ -75,7 +77,7 @@ async function authorize(pinned: Binding): Promise<void> {
75
77
  child.once("exit", (exit) => resolve(exit));
76
78
  });
77
79
  if (code === 67) throw new Error("managed-profile-disconnected");
78
- if (code !== 0) throw new Error("managed-keychain-approval-required");
80
+ if (code !== 0) throw new Error(process.platform === "linux" ? "managed-key-unavailable" : "managed-keychain-approval-required");
79
81
  }
80
82
 
81
83
  // The control socket of a running company Gateway, without launching one.
@@ -117,7 +119,7 @@ async function start(agentDir: string, home: string, timeoutMs: number): Promise
117
119
  await authorize(pinned);
118
120
  // Startup failures are reported on stderr as `Agent Watch: managed-…`; the
119
121
  // log is private and removed once the process is ready or has exited.
120
- const directory = path.join(home, "Library/Application Support/AgentWatch/ManagedLaunch");
122
+ const directory = path.join(agentWatchDataDirectory(home), "ManagedLaunch");
121
123
  fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); fs.chmodSync(directory, 0o700);
122
124
  const log = path.join(directory, `launch-${process.pid}-${Date.now()}.log`);
123
125
  const fd = fs.openSync(log, "wx", 0o600);
@@ -1,9 +1,22 @@
1
- import { execFile } from "node:child_process";
1
+ import { execFile, execFileSync } from "node:child_process";
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
- type PickerSpec = { executable: string; args: string[] };
6
+ // `wsl`: the picker is Windows' own, run from WSL; its paths are converted.
7
+ type PickerSpec = { executable: string; args: string[]; wsl?: boolean };
8
+
9
+ // Windows' folder dialog, through PowerShell (also from WSL, where Windows
10
+ // programs run as they are). It prints the chosen folder; cancel exits 1.
11
+ const WINDOWS_PICKER = [
12
+ "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8",
13
+ "Add-Type -AssemblyName System.Windows.Forms",
14
+ "$dialog = New-Object System.Windows.Forms.FolderBrowserDialog",
15
+ "$dialog.Description = 'Choose a project folder for Piagent'",
16
+ "if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.WriteLine($dialog.SelectedPath); exit 0 } else { exit 1 }"
17
+ ].join("; ");
18
+ const windowsPicker = (executable: string, wsl = false): PickerSpec =>
19
+ ({ executable, args: ["-NoProfile", "-NonInteractive", "-STA", "-Command", WINDOWS_PICKER], ...(wsl ? { wsl } : {}) });
7
20
  type PickerEnvironment = Readonly<Record<string, string | undefined>>;
8
21
 
9
22
  function executableOnPath(name: string, environment: PickerEnvironment): string | null {
@@ -32,6 +45,14 @@ export function resolveNativeProjectPicker(options: { platform?: NodeJS.Platform
32
45
  ].join("\n");
33
46
  return { executable: "/usr/bin/osascript", args: ["-e", script] };
34
47
  }
48
+ if (platform === "win32") {
49
+ const executable = path.win32.join(environment.SystemRoot ?? "C:\\Windows", "System32", "WindowsPowerShell", "v1.0", "powershell.exe");
50
+ return fs.existsSync(executable) ? windowsPicker(executable) : null;
51
+ }
52
+ if (platform === "linux" && environment.WSL_DISTRO_NAME) {
53
+ const executable = executableOnPath("powershell.exe", environment);
54
+ if (executable) return windowsPicker(executable, true);
55
+ }
35
56
  if (platform !== "linux" || !(environment.DISPLAY || environment.WAYLAND_DISPLAY)) return null;
36
57
  for (const name of ["zenity", "qarma"]) {
37
58
  const executable = executableOnPath(name, environment);
@@ -71,7 +92,11 @@ export function pickNativeProjectFolders(): Promise<string[]> {
71
92
  ? (error as unknown as { code: number }).code : null;
72
93
  reject(new Error(exitCode === 1 ? "project-import-cancelled" : "project-import-picker-failed")); return;
73
94
  }
74
- const selected = selectedFolders(stdout);
95
+ let selected = selectedFolders(stdout.replace(/\r/g, ""));
96
+ // C:\Users\me\shop, as seen from WSL: /mnt/c/Users/me/shop.
97
+ if (picker.wsl) selected = selected.flatMap((folder) => {
98
+ try { return [execFileSync("wslpath", ["-u", folder], { encoding: "utf8", timeout: 5000 }).trim()]; } catch { return []; }
99
+ });
75
100
  if (!selected.length) { reject(new Error("project-import-cancelled")); return; }
76
101
  resolve(selected);
77
102
  });
@@ -24,12 +24,18 @@ const either = (value: string) => value.normalize("NFC") === value.normalize("NF
24
24
  : `(?:${escape(value.normalize("NFC"))}|${escape(value.normalize("NFD"))})`;
25
25
  const inside = (file: string, root: string) => file === root || file.startsWith(root + path.sep);
26
26
 
27
+ // fd where it is commonly installed, then on PATH (Debian and Ubuntu name it
28
+ // fdfind; Windows adds .exe).
27
29
  export function findFd(home = os.homedir()): string | null {
28
- for (const candidate of ["/opt/homebrew/bin/fd", "/usr/local/bin/fd", path.join(home, ".pi/agent/bin/fd")]) {
30
+ const names = process.platform === "win32" ? ["fd.exe"] : ["fd", "fdfind"];
31
+ const onPath = String(process.env.PATH ?? "").split(path.delimiter).filter(Boolean).flatMap((dir) => names.map((name) => path.join(dir, name)));
32
+ for (const candidate of ["/opt/homebrew/bin/fd", "/usr/local/bin/fd", path.join(home, ".pi/agent/bin", names[0]), ...onPath]) {
29
33
  try { fs.accessSync(candidate, fs.constants.X_OK); return candidate; } catch { /* next */ }
30
34
  }
31
35
  return null;
32
36
  }
37
+ // macOS: the system's git, always there; elsewhere git from PATH.
38
+ const GIT = process.platform === "darwin" ? "/usr/bin/git" : "git";
33
39
 
34
40
  // Higher is better: the name itself, then its start, then anywhere in it,
35
41
  // then anywhere in the path; a folder before a file with the same score.
@@ -59,7 +65,7 @@ async function listed(folder: string, signal?: AbortSignal): Promise<Entry[]> {
59
65
  }
60
66
  } catch { /* unreadable */ }
61
67
  if (!entries.length) return entries;
62
- const ignored = new Set((await run("/usr/bin/git", ["-C", folder, "check-ignore", "-z", "--stdin"], signal, entries.map((entry) => `${entry.path}\0`).join("")))
68
+ const ignored = new Set((await run(GIT, ["-C", folder, "check-ignore", "-z", "--stdin"], signal, entries.map((entry) => `${entry.path}\0`).join("")))
63
69
  .split("\0").filter(Boolean));
64
70
  return ignored.size ? entries.filter((entry) => !ignored.has(entry.path)) : entries;
65
71
  }
@@ -75,7 +81,7 @@ async function searched(folder: string, query: string, options: { fd: string | n
75
81
  .map((line) => ({ path: line.replace(/^\.\//, "").replace(/\/$/, ""), directory: line.endsWith("/") }));
76
82
  }
77
83
  if (!options.atRoot) return [];
78
- const files = (await run("/usr/bin/git", ["-C", folder, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal))
84
+ const files = (await run(GIT, ["-C", folder, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], options.signal))
79
85
  .split("\0").filter(Boolean).slice(0, 50_000);
80
86
  const folders = new Set<string>();
81
87
  for (const file of files) for (let dir = path.posix.dirname(file); dir !== "."; dir = path.posix.dirname(dir)) folders.add(dir);
@@ -29,8 +29,12 @@ export function installedPiHostRoot(): string {
29
29
  // The operator installation is normally global.
30
30
  }
31
31
  try {
32
- const executable = execFileSync("which", ["pi"], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim();
33
- const found = packageRootFrom(fs.realpathSync(executable));
32
+ const windows = process.platform === "win32";
33
+ const executable = execFileSync(windows ? "where" : "which", ["pi"], { encoding: "utf8", timeout: 2_000, stdio: ["ignore", "pipe", "ignore"] }).trim().split(/\r?\n/)[0];
34
+ // npm on Windows puts the pi.cmd shim in the global prefix and the package
35
+ // in node_modules beside it.
36
+ const found = packageRootFrom(fs.realpathSync(executable))
37
+ ?? (windows ? packageRootFrom(path.join(path.dirname(executable), "node_modules", "@earendil-works", "pi-coding-agent", "package.json")) : null);
34
38
  if (found) return found;
35
39
  } catch {
36
40
  // A launcher with a short PATH (Agent Watch, launchd) cannot see `pi`.
@@ -42,8 +46,12 @@ export function installedPiHostRoot(): string {
42
46
  const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(home, ".pi", "agent");
43
47
  recorded = JSON.parse(fs.readFileSync(path.join(agentDir, "piagent-runtime.json"), "utf8")).pi_sdk_root ?? null;
44
48
  } catch { /* no record yet */ }
49
+ // npm's default Windows prefixes (%APPDATA%\npm, or beside node.exe) have no lib folder.
50
+ const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
51
+ const windowsHosts = process.platform !== "win32" ? []
52
+ : [path.join(appData, "npm"), path.dirname(process.execPath)].map((prefix) => path.join(prefix, "node_modules", "@earendil-works", "pi-coding-agent"));
45
53
  for (const candidate of [recorded, path.join(home, ".pi", "npm-global", hostPackage), path.join(home, ".local", hostPackage),
46
- path.join("/opt/homebrew", hostPackage), path.join("/usr/local", hostPackage)]) {
54
+ path.join("/opt/homebrew", hostPackage), path.join("/usr/local", hostPackage), ...windowsHosts]) {
47
55
  if (!candidate) continue;
48
56
  const found = packageRootFrom(path.join(candidate, "package.json"));
49
57
  if (found) return found;
@@ -426,7 +426,9 @@ export class SessionRuntimeSupervisor {
426
426
  if (!file) throw new Error("session-fork-unavailable");
427
427
  forkManager = this.#host.SessionManager.open(file);
428
428
  } else {
429
- forkManager = this.#host.SessionManager.forkFrom(source.path, source.cwd);
429
+ // Managed members keep sessions flat in one folder; Pi would otherwise write
430
+ // the fork into its per-cwd folder, where the gateway never finds it again.
431
+ forkManager = this.#host.SessionManager.forkFrom(source.path, source.cwd, this.#sessionDirectory);
430
432
  }
431
433
  if (title) forkManager.appendSessionInfo(title);
432
434
  const file = forkManager.getSessionFile(), id = forkManager.getSessionId?.();
@@ -13,7 +13,7 @@ import fs from "node:fs";
13
13
  import os from "node:os";
14
14
  import path from "node:path";
15
15
  import { spawnSync } from "node:child_process";
16
- import { fileURLToPath } from "node:url";
16
+ import { fileURLToPath, pathToFileURL } from "node:url";
17
17
 
18
18
  const RELEASE = /^(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})\.(0|[1-9]\d{0,8})$/;
19
19
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -70,7 +70,8 @@ try {
70
70
  fs.writeSync(log, "\nRestarting the dashboard on the new release\n");
71
71
  // Like the \`piagent\` command, with the package's TypeScript loader: an npm
72
72
  // install sits under node_modules, where Node does not strip types itself.
73
- const loader = path.join(packageRoot, "scripts", "register-typescript-loader.mjs");
73
+ // --import takes a URL: a Windows path (C:\...) reads as a URL scheme.
74
+ const loader = pathToFileURL(path.join(packageRoot, "scripts", "register-typescript-loader.mjs")).href;
74
75
  const restart = spawnSync(process.execPath, ["--disable-warning=ExperimentalWarning", "--import", loader, dashboard, "restart", "--json"],
75
76
  { stdio: ["ignore", "ignore", log], env, timeout: 3 * 60 * 1000 });
76
77
  finish("succeeded", { installed, ...(bindingChanged ? { bindingChanged } : {}), ...(restart.status === 0 ? {} : { reason: "dashboard-restart-failed" }) });
@@ -3,7 +3,7 @@ import { spawn } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { fileURLToPath } from "node:url";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
8
8
  const scriptByCommand = {
9
9
  "piagent-setup": "scripts/setup.sh",
@@ -140,7 +140,7 @@ const sourceRoot = invokedAs === "piagent-benchmark" ? benchmarkSourceRoot() : p
140
140
  const target = path.join(sourceRoot, script);
141
141
  const runner = target.endsWith(".mjs") ? process.execPath : "bash";
142
142
  const runnerArgs = target.endsWith(".mjs")
143
- ? ["--disable-warning=ExperimentalWarning", "--import", path.join(sourceRoot, "scripts", "register-typescript-loader.mjs"), target]
143
+ ? ["--disable-warning=ExperimentalWarning", "--import", pathToFileURL(path.join(sourceRoot, "scripts", "register-typescript-loader.mjs")).href, target]
144
144
  : [target];
145
145
  const child = spawn(runner, [...runnerArgs, ...forwardedArgs], {
146
146
  cwd: process.cwd(),
@@ -3,9 +3,10 @@ import { spawn, spawnSync } from "node:child_process";
3
3
  import fs from "node:fs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { fileURLToPath } from "node:url";
6
+ import { fileURLToPath, pathToFileURL } from "node:url";
7
7
 
8
8
  import { requestGatewayControl } from "../packages/piagent-webui/gateway/control-socket.ts";
9
+ import { browserCommand } from "../packages/piagent-core/runtime/host/browser-opener.mjs";
9
10
  import { installedPiHostRoot } from "../packages/piagent-webui/gateway/pi-host.ts";
10
11
  import { gatewayProfileState, profileRef, readGatewayDescriptor, readOrCreateCatalogKey,
11
12
  removeGatewayDescriptor } from "../packages/piagent-webui/gateway/profile-state.ts";
@@ -32,11 +33,10 @@ function output(value) {
32
33
 
33
34
  function openBrowser(url) {
34
35
  if (noOpen) return;
35
- const opener = process.platform === "darwin" ? ["open", [url]]
36
- : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]]
37
- : ["xdg-open", [url]];
38
- const result = spawnSync(opener[0], opener[1], { stdio: "ignore" });
39
- if (result.status !== 0) process.stderr.write(`Dashboard is ready at ${url}\n`);
36
+ const opener = browserCommand(url);
37
+ const result = opener ? spawnSync(opener.command, opener.args, { stdio: "ignore", ...opener.options }) : null;
38
+ // explorer.exe (WSL) exits 1 even when it opened the page.
39
+ if (!result || result.error || (result.status !== 0 && !/explorer\.exe$/.test(opener.command))) process.stderr.write(`Dashboard is ready at ${url}\n`);
40
40
  }
41
41
 
42
42
  function processAlive(pid) {
@@ -170,7 +170,7 @@ async function ensureStarted() {
170
170
  writeGatewayErrorLog(state);
171
171
  const child = spawn(process.execPath, [
172
172
  "--disable-warning=ExperimentalWarning",
173
- "--import", path.join(packageRoot, "scripts", "register-typescript-loader.mjs"),
173
+ "--import", pathToFileURL(path.join(packageRoot, "scripts", "register-typescript-loader.mjs")).href,
174
174
  fileURLToPath(import.meta.url),
175
175
  "serve",
176
176
  ...(agentDir ? ["--agent-dir", agentDir] : [])
@@ -6,7 +6,7 @@ import { createHash } from 'node:crypto';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { ManagedBrokerClient } from '../packages/piagent-core/managed/broker-client.mjs';
8
8
  import { ManagedSession } from '../packages/piagent-core/managed/session.mjs';
9
- import { resolveStore, storedSlot } from '../packages/piagent-core/managed/store.mjs';
9
+ import { agentWatchDataDirectory, interopEnvironment, resolveStore, storedSlot } from '../packages/piagent-core/managed/store.mjs';
10
10
 
11
11
  const digest = file => createHash('sha256').update(fs.readFileSync(file)).digest('hex');
12
12
  // Node's experimental-feature notices (type stripping, SQLite) are not
@@ -23,7 +23,7 @@ for (let i = 0; i < args.length; i++) {
23
23
  const home = os.homedir();
24
24
  // Do this before importing the SDK: it must not discover personal keys, Node
25
25
  // preload hooks, proxy overrides or provider endpoints from an ambient shell.
26
- const clean = { PATH: '/usr/bin:/bin', HOME: home, LANG: 'en_US.UTF-8', TERM: process.env.TERM || 'xterm-256color' };
26
+ const clean = { PATH: '/usr/bin:/bin', HOME: home, LANG: 'en_US.UTF-8', TERM: process.env.TERM || 'xterm-256color', ...interopEnvironment() };
27
27
  for (const key of Object.keys(process.env)) delete process.env[key];
28
28
  Object.assign(process.env, clean);
29
29
  let broker, current, gateway, hold;
@@ -53,7 +53,7 @@ try {
53
53
  }
54
54
  // Conversations live in one store per member on this machine, whichever key
55
55
  // Agent Watch holds: a key's slot learns its member once, then points there.
56
- const root = path.join(home, 'Library/Application Support/AgentWatch/ManagedSessions');
56
+ const root = path.join(agentWatchDataDirectory(home), 'ManagedSessions');
57
57
  let store = storedSlot(root, config.profile_id);
58
58
  if (!store) {
59
59
  const asked = new ManagedBrokerClient({ executable: config.broker, profileID: config.profile_id });
@@ -150,6 +150,6 @@ try {
150
150
  // Never print JSON payloads or startup objects that can contain role tokens.
151
151
  const code = error instanceof Error && /^managed-[a-z:_-]+$/.test(error.message) ? error.message : 'managed-launch-failed';
152
152
  const hint = code === 'managed-session-open-elsewhere' ? 'This conversation is running in the Piagent WebUI; continue it there, or start a new one here.'
153
- : 'Refresh the Studio import or check Keychain access.';
153
+ : process.platform === 'linux' ? 'In Agent Watch for Windows, choose Connect Piagent in WSL again.' : 'Refresh the Studio import or check Keychain access.';
154
154
  process.stderr.write(`Agent Watch: ${code}. ${hint}\n`); process.exitCode = 1;
155
155
  } finally { await gateway?.close(); if (current) await current.dispose(); else await broker?.dispose(); hold?.release(); }
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import net from "node:net";
5
5
  import os from "node:os";
6
6
  import path from "node:path";
7
+ import { browserCommand } from "../packages/piagent-core/runtime/host/browser-opener.mjs";
7
8
 
8
9
  const REF = /^[A-Za-z0-9][A-Za-z0-9._~-]{0,159}$/;
9
10
 
@@ -83,9 +84,9 @@ function requestLaunch(descriptor) {
83
84
  }
84
85
 
85
86
  function openUrl(url) {
86
- const command = process.platform === "darwin" ? "open" : process.platform === "linux" ? "xdg-open" : null;
87
- if (!command) return false;
88
- const child = spawn(command, [url], { detached: true, stdio: "ignore" });
87
+ const opener = browserCommand(url);
88
+ if (!opener) return false;
89
+ const child = spawn(opener.command, opener.args, { detached: true, stdio: "ignore", ...opener.options });
89
90
  child.unref(); return true;
90
91
  }
91
92