@agentprojectcontext/apx 1.74.0 → 1.74.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": "@agentprojectcontext/apx",
3
- "version": "1.74.0",
3
+ "version": "1.74.1",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -67,7 +67,8 @@
67
67
  "react": "^19.2.0",
68
68
  "remeda": "^2.21.0",
69
69
  "safer-buffer": "^2.1.2",
70
- "solid-js": "^1.9.13"
70
+ "solid-js": "^1.9.13",
71
+ "ws": "^8.21.3"
71
72
  },
72
73
  "optionalDependencies": {
73
74
  "better-sqlite3": "^11.3.0",
@@ -18,6 +18,7 @@ import os from "node:os";
18
18
  import path from "node:path";
19
19
  import { execFileSync } from "node:child_process";
20
20
  import { fileURLToPath } from "node:url";
21
+ import { augmentedPath } from "#core/util/path-env.js";
21
22
 
22
23
  const __filename = fileURLToPath(import.meta.url);
23
24
  const __dirname = path.dirname(__filename);
@@ -55,6 +56,12 @@ function escapeXml(s) {
55
56
  export function buildPlist(runner, logFile) {
56
57
  const args = [...runner, "desktop", "start"];
57
58
  const argsXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
59
+ // Absolute ProgramArguments (see getApxRunner) only save the *first* hop.
60
+ // Everything the daemon spawns below itself — ffmpeg for whisper, npx/node
61
+ // for stdio MCPs — still resolves through PATH, and launchd's is
62
+ // /usr/bin:/bin:/usr/sbin:/sbin. Baking the installing shell's augmented
63
+ // PATH in makes the whole tree behave the same at login as in a terminal.
64
+ const pathEnv = augmentedPath();
58
65
  return `<?xml version="1.0" encoding="UTF-8"?>
59
66
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
60
67
  <plist version="1.0">
@@ -64,6 +71,10 @@ export function buildPlist(runner, logFile) {
64
71
  <array>
65
72
  ${argsXml}
66
73
  </array>
74
+ <key>EnvironmentVariables</key>
75
+ <dict>
76
+ <key>PATH</key><string>${escapeXml(pathEnv)}</string>
77
+ </dict>
67
78
  <key>RunAtLoad</key><true/>
68
79
  <key>KeepAlive</key><false/>
69
80
  <key>ProcessType</key><string>Interactive</string>
@@ -76,12 +87,18 @@ ${argsXml}
76
87
 
77
88
  // ── public API ───────────────────────────────────────────────────────────
78
89
 
90
+ // The three functions below take `platform` instead of reading process.platform
91
+ // inline so the unsupported-platform branch is reachable from a test on any
92
+ // machine — it's the only branch with no filesystem/registry side effect, and
93
+ // hard-coding process.platform meant it could only ever be exercised on a
94
+ // freebsd/sunos box. Callers always omit it.
95
+
79
96
  /** Boolean: is the autostart entry currently registered on this platform? */
80
- export function autostartIsOn() {
97
+ export function autostartIsOn(platform = process.platform) {
81
98
  try {
82
- if (process.platform === "darwin") return fs.existsSync(MAC_PLIST_PATH);
83
- if (process.platform === "linux") return fs.existsSync(LINUX_DESKTOP_PATH);
84
- if (process.platform === "win32") {
99
+ if (platform === "darwin") return fs.existsSync(MAC_PLIST_PATH);
100
+ if (platform === "linux") return fs.existsSync(LINUX_DESKTOP_PATH);
101
+ if (platform === "win32") {
85
102
  const out = execFileSync("reg", ["query", WIN_RUN_KEY, "/v", WIN_RUN_NAME], {
86
103
  stdio: ["ignore", "pipe", "ignore"],
87
104
  }).toString();
@@ -95,12 +112,12 @@ export function autostartIsOn() {
95
112
  * Enable autostart. Idempotent — running twice is safe.
96
113
  * @returns {{ ok: boolean, message?: string, error?: string, runs?: string, path?: string }}
97
114
  */
98
- export function autostartInstall() {
115
+ export function autostartInstall(platform = process.platform) {
99
116
  const runner = getApxRunner();
100
117
  const sh = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
101
118
  const cmdline = [...runner, "desktop", "start"].map(sh).join(" ");
102
119
 
103
- if (process.platform === "darwin") {
120
+ if (platform === "darwin") {
104
121
  try {
105
122
  fs.mkdirSync(path.dirname(MAC_PLIST_PATH), { recursive: true });
106
123
  fs.mkdirSync(path.dirname(AUTOSTART_LOG_PATH), { recursive: true });
@@ -110,7 +127,7 @@ export function autostartInstall() {
110
127
  return { ok: true, runs: cmdline, path: MAC_PLIST_PATH };
111
128
  } catch (e) { return { ok: false, error: e.message }; }
112
129
  }
113
- if (process.platform === "win32") {
130
+ if (platform === "win32") {
114
131
  try {
115
132
  execFileSync("reg", [
116
133
  "add", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/t", "REG_SZ", "/d", cmdline, "/f",
@@ -118,7 +135,7 @@ export function autostartInstall() {
118
135
  return { ok: true, runs: cmdline, path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}` };
119
136
  } catch (e) { return { ok: false, error: e.message }; }
120
137
  }
121
- if (process.platform === "linux") {
138
+ if (platform === "linux") {
122
139
  try {
123
140
  fs.mkdirSync(path.dirname(LINUX_DESKTOP_PATH), { recursive: true });
124
141
  fs.writeFileSync(LINUX_DESKTOP_PATH,
@@ -127,15 +144,15 @@ export function autostartInstall() {
127
144
  return { ok: true, runs: cmdline, path: LINUX_DESKTOP_PATH };
128
145
  } catch (e) { return { ok: false, error: e.message }; }
129
146
  }
130
- return { ok: false, error: `autostart not supported on platform: ${process.platform}` };
147
+ return { ok: false, error: `autostart not supported on platform: ${platform}` };
131
148
  }
132
149
 
133
150
  /**
134
151
  * Disable autostart. Idempotent — no-op if not installed.
135
152
  * @returns {{ ok: boolean, removed?: boolean, path?: string, error?: string }}
136
153
  */
137
- export function autostartUninstall() {
138
- if (process.platform === "darwin") {
154
+ export function autostartUninstall(platform = process.platform) {
155
+ if (platform === "darwin") {
139
156
  if (!fs.existsSync(MAC_PLIST_PATH)) return { ok: true, removed: false };
140
157
  try {
141
158
  try { execFileSync("launchctl", ["unload", "-w", MAC_PLIST_PATH], { stdio: "ignore" }); } catch {}
@@ -143,7 +160,7 @@ export function autostartUninstall() {
143
160
  return { ok: true, removed: true, path: MAC_PLIST_PATH };
144
161
  } catch (e) { return { ok: false, error: e.message }; }
145
162
  }
146
- if (process.platform === "win32") {
163
+ if (platform === "win32") {
147
164
  try {
148
165
  execFileSync("reg", ["delete", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/f"], { stdio: "ignore" });
149
166
  return { ok: true, removed: true, path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}` };
@@ -151,12 +168,12 @@ export function autostartUninstall() {
151
168
  return { ok: true, removed: false };
152
169
  }
153
170
  }
154
- if (process.platform === "linux") {
171
+ if (platform === "linux") {
155
172
  if (!fs.existsSync(LINUX_DESKTOP_PATH)) return { ok: true, removed: false };
156
173
  try {
157
174
  fs.unlinkSync(LINUX_DESKTOP_PATH);
158
175
  return { ok: true, removed: true, path: LINUX_DESKTOP_PATH };
159
176
  } catch (e) { return { ok: false, error: e.message }; }
160
177
  }
161
- return { ok: false, error: `autostart not supported on platform: ${process.platform}` };
178
+ return { ok: false, error: `autostart not supported on platform: ${platform}` };
162
179
  }
@@ -9,6 +9,7 @@ import { spawn } from "node:child_process";
9
9
  import { loadAll } from "./sources.js";
10
10
  import { interpolate, MissingVarError } from "#core/vars/interpolate.js";
11
11
  import { loadAllVars } from "#core/vars/sources.js";
12
+ import { envWithPath } from "#core/util/path-env.js";
12
13
 
13
14
  const DEFAULT_TIMEOUT_MS = 30_000;
14
15
  const LOG_CAP = 64; // entries per MCP we keep in memory
@@ -59,8 +60,12 @@ class McpProcess {
59
60
  start() {
60
61
  if (this.proc) return;
61
62
  this._log("info", `spawn ${this.command} ${(this.args || []).join(" ")}`);
63
+ // envWithPath, not a bare process.env spread: most stdio MCPs are launched
64
+ // via `npx`/`node`, which live in the nvm/pnpm bin dir. Booted from launchd
65
+ // the daemon only has /usr/bin:/bin:/usr/sbin:/sbin and every one of them
66
+ // fails with "spawn npx ENOENT".
62
67
  this.proc = spawn(this.command, this.args, {
63
- env: { ...process.env, ...this.env },
68
+ env: envWithPath(this.env),
64
69
  stdio: ["pipe", "pipe", "pipe"],
65
70
  });
66
71
  this.startedAt = nowIso();
@@ -1,2 +1,3 @@
1
1
  export * from "./time.js";
2
2
  export * from "./ids.js";
3
+ export * from "./path-env.js";
@@ -0,0 +1,73 @@
1
+ // PATH repair for processes APX spawns.
2
+ //
3
+ // When the daemon is booted from a GUI context — the launchd agent
4
+ // (dev.apx.desktop), a .app bundle, an IDE — it inherits launchd's minimal
5
+ // PATH: /usr/bin:/bin:/usr/sbin:/sbin. No Homebrew, no nvm, no pnpm. Every
6
+ // child we spawn then dies with ENOENT on binaries that work fine in a
7
+ // terminal:
8
+ //
9
+ // - whisper-server.py → "No such file or directory: 'ffmpeg'" (mlx/faster
10
+ // whisper shell out to ffmpeg to decode .oga/.webm)
11
+ // - stdio MCP servers → "spawn npx ENOENT" / "spawn node ENOENT"
12
+ //
13
+ // getApxRunner() in core/desktop/autostart.js already dodges this for the
14
+ // node binary itself by using an absolute process.execPath. This module is
15
+ // the same idea for everything spawned *below* that process.
16
+ //
17
+ // Extra dirs are APPENDED, never prepended: an inherited PATH that already
18
+ // resolves a binary keeps winning, we only fill the gaps.
19
+ import fs from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+
23
+ /**
24
+ * Directories worth appending on this machine, in priority order. Only ones
25
+ * that actually exist are returned, so PATH stays free of dead entries.
26
+ */
27
+ export function extraBinDirs() {
28
+ const home = os.homedir();
29
+ // dirname(process.execPath) is the bin/ of the node currently running us —
30
+ // under nvm/fnm/volta that's also where npm and npx live, which is exactly
31
+ // what stdio MCP servers need.
32
+ const candidates = process.platform === "win32"
33
+ ? [path.dirname(process.execPath)]
34
+ : [
35
+ path.dirname(process.execPath),
36
+ "/opt/homebrew/bin", // Homebrew on Apple Silicon
37
+ "/opt/homebrew/sbin",
38
+ "/usr/local/bin", // Homebrew on Intel, most manual installs
39
+ "/usr/local/sbin",
40
+ path.join(home, ".local", "bin"),
41
+ path.join(home, "Library", "pnpm"),
42
+ path.join(home, ".bun", "bin"),
43
+ path.join(home, ".cargo", "bin"),
44
+ ];
45
+ return candidates.filter((dir) => {
46
+ try { return fs.existsSync(dir); } catch { return false; }
47
+ });
48
+ }
49
+
50
+ /**
51
+ * `basePath` with every missing entry from extraBinDirs() appended.
52
+ * @param {string} [basePath] defaults to the current process PATH
53
+ */
54
+ export function augmentedPath(basePath = process.env.PATH || "") {
55
+ const sep = path.delimiter;
56
+ const seen = new Set(basePath.split(sep).filter(Boolean));
57
+ const additions = extraBinDirs().filter((dir) => !seen.has(dir));
58
+ if (!additions.length) return basePath;
59
+ return [...basePath.split(sep).filter(Boolean), ...additions].join(sep);
60
+ }
61
+
62
+ /**
63
+ * An env object for child_process.spawn: process.env + `extra`, with PATH
64
+ * repaired. A PATH supplied in `extra` is respected as the base and augmented
65
+ * too, so callers can still pin their own dirs first.
66
+ */
67
+ export function envWithPath(extra = {}) {
68
+ const merged = { ...process.env, ...extra };
69
+ // Windows env keys are case-insensitive and may arrive as "Path".
70
+ const key = Object.keys(merged).find((k) => k.toUpperCase() === "PATH") || "PATH";
71
+ merged[key] = augmentedPath(merged[key] || "");
72
+ return merged;
73
+ }
@@ -174,12 +174,37 @@ export function listSttProviders(rawConfig = {}) {
174
174
  return { configured_provider: provider, engines };
175
175
  }
176
176
 
177
+ // Host-injected "make sure the whisper subprocess is alive" hook. Core must
178
+ // not import from host/daemon, so host/daemon/whisper-server.js registers
179
+ // ensureWhisperServer() here at module load instead. Stays null in contexts
180
+ // that have no subprocess to manage (tests, CLI talking to a remote daemon),
181
+ // where transcribeViaLocalServer just fetches as before.
182
+ let _ensureLocalServer = null;
183
+
184
+ /** @param {((opts:object)=>Promise<void>)|null} fn */
185
+ export function setLocalServerEnsure(fn) {
186
+ _ensureLocalServer = typeof fn === "function" ? fn : null;
187
+ }
188
+
177
189
  /**
178
- * Call the local whisper-server.py over HTTP. Does NOT spawn or check the
179
- * subprocess — that's host/daemon/whisper-server.js's job. If the server is
180
- * down, this throws a clear "ECONNREFUSED" the caller can surface.
190
+ * Call the local whisper-server.py over HTTP.
191
+ *
192
+ * The server shuts itself down after `idle_minutes` (10 by default), so on any
193
+ * request that isn't back-to-back with the last one the port is simply dead.
194
+ * We therefore ask the host to (re)spawn it first — ensureWhisperServer() is a
195
+ * cheap health check when the process is already up. Without this, the first
196
+ * voice message after an idle gap always failed with a bare "fetch failed".
181
197
  */
182
198
  export async function transcribeViaLocalServer(filePath, opts) {
199
+ if (_ensureLocalServer) {
200
+ try {
201
+ await _ensureLocalServer(opts);
202
+ } catch (e) {
203
+ // Not fatal: the server may still be reachable (e.g. an orphan listener
204
+ // this process doesn't own). Let the fetch below decide.
205
+ logWarn("whisper", `ensure local server failed, trying anyway: ${e.message}`);
206
+ }
207
+ }
183
208
  const language = (opts.language || DEFAULT_LOCAL.language) === "auto"
184
209
  ? null
185
210
  : (opts.language || null);
@@ -15,7 +15,9 @@ import {
15
15
  WHISPER_LOCAL_PORT,
16
16
  DEFAULT_LOCAL,
17
17
  getConfig,
18
+ setLocalServerEnsure,
18
19
  } from "#core/voice/transcription.js";
20
+ import { envWithPath } from "#core/util/path-env.js";
19
21
  import { pythonForWhisper } from "./stt-venv.js";
20
22
 
21
23
  const __filename = fileURLToPath(import.meta.url);
@@ -82,7 +84,21 @@ async function _killOrphanWhisper() {
82
84
  }
83
85
  }
84
86
 
87
+ // Serializes concurrent ensures. Two voice messages arriving together (or a
88
+ // desktop utterance racing a Telegram note) would otherwise each see a dead
89
+ // port and spawn their own python, and the loser then trips
90
+ // "address already in use".
91
+ let _ensureInFlight = null;
92
+
85
93
  export async function ensureWhisperServer(opts) {
94
+ if (_ensureInFlight) return _ensureInFlight;
95
+ _ensureInFlight = _ensureWhisperServer(opts).finally(() => {
96
+ _ensureInFlight = null;
97
+ });
98
+ return _ensureInFlight;
99
+ }
100
+
101
+ async function _ensureWhisperServer(opts) {
86
102
  const model = opts.model || DEFAULT_LOCAL.model;
87
103
  const backend = opts.backend || "faster";
88
104
 
@@ -129,9 +145,15 @@ async function _spawnWhisper(opts, model, backend, retried) {
129
145
 
130
146
  // Prefer APX's dedicated venv interpreter (isolated mlx/faster-whisper);
131
147
  // fall back to system python3 for the legacy user-site install.
148
+ //
149
+ // envWithPath: both whisper backends shell out to `ffmpeg` to decode
150
+ // .oga/.webm. Booted from launchd the daemon's PATH is /usr/bin:/bin:
151
+ // /usr/sbin:/sbin, so a Homebrew ffmpeg is invisible and every transcription
152
+ // dies with "[Errno 2] No such file or directory: 'ffmpeg'".
132
153
  const proc = spawn(pythonForWhisper(), args, {
133
154
  stdio: ["ignore", "pipe", "inherit"],
134
155
  detached: false,
156
+ env: envWithPath(),
135
157
  });
136
158
 
137
159
  _serverProcess = proc;
@@ -236,3 +258,8 @@ export const WHISPER_PATHS = {
236
258
  whisper_server: WHISPER_SERVER,
237
259
  port: WHISPER_LOCAL_PORT,
238
260
  };
261
+
262
+ // Hand core the ability to revive the subprocess on demand. Importing this
263
+ // module (the daemon does, to preload at boot) is what wires it up — core
264
+ // itself never reaches into host/.
265
+ setLocalServerEnsure(ensureWhisperServer);