@agentprojectcontext/apx 1.74.0 → 1.74.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": "@agentprojectcontext/apx",
3
- "version": "1.74.0",
3
+ "version": "1.74.2",
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();
@@ -4,6 +4,7 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import { CronExpressionParser } from "cron-parser";
6
6
  import { nowIso, isoToMs } from "../util/time.js";
7
+ import { shortId } from "../util/ids.js";
7
8
 
8
9
  function routinesPath(storagePath) {
9
10
  // storagePath is always ~/.apx/projects/{apxId}/ — flat, no .apc subdir needed.
@@ -82,13 +83,58 @@ export function computeNextRun(routine, baseMs = Date.now()) {
82
83
  return null;
83
84
  }
84
85
 
86
+ // --------------------- ids + migration --------------------------------------
87
+
88
+ // Routines are addressed by `name` everywhere (getRoutine/deleteRoutine/
89
+ // setEnabled/updateRunState), but per-routine memory is keyed by `id`
90
+ // (stores/routine-memory.js). Records written before ids existed have none, so
91
+ // every one of them resolved to the shared `routines/_unknown/memory.md`.
92
+ //
93
+ // This migration is deliberately NOT inside readFile(): that helper is on the
94
+ // scheduler's 5s polling path, and writing from it would mean write
95
+ // amplification plus a read-modify-write race against a concurrent CLI edit.
96
+ // Instead the public read entry points call this, and it early-returns without
97
+ // touching disk once every record has an id — so the write happens once per
98
+ // project, ever.
99
+
100
+ /**
101
+ * Backfill `id` on any routine record that predates the field.
102
+ * Returns the number of records migrated (0 when there was nothing to do).
103
+ */
104
+ export function ensureRoutineIds(storagePath) {
105
+ const routines = readFile(storagePath);
106
+ const missing = routines.filter((r) => r && !r.id);
107
+ if (missing.length === 0) return 0;
108
+
109
+ for (const r of missing) r.id = shortId("r");
110
+ writeFile(storagePath, routines);
111
+
112
+ // Anything already written under routines/_unknown/ belonged to an
113
+ // indeterminate set of routines — we can't know which, so we leave it where
114
+ // it is rather than guess, and say so out loud once.
115
+ const orphan = path.join(storagePath, "routines", "_unknown");
116
+ if (fs.existsSync(orphan)) {
117
+ // eslint-disable-next-line no-console
118
+ console.warn(
119
+ `[apx] routines: assigned ids to ${missing.length} routine(s). Pre-existing shared\n` +
120
+ ` memory at ${orphan} was left untouched — it cannot be attributed to a single\n` +
121
+ ` routine. Copy anything worth keeping into the per-routine memory files.`
122
+ );
123
+ }
124
+ return missing.length;
125
+ }
126
+
85
127
  // --------------------- CRUD -------------------------------------------------
86
128
 
87
129
  export function listRoutines(projectPath) {
130
+ ensureRoutineIds(projectPath);
88
131
  return readFile(projectPath);
89
132
  }
90
133
 
91
134
  export function getRoutine(projectPath, name) {
135
+ // Callers hand the record straight to the runner, which needs `id` for
136
+ // per-routine memory.
137
+ ensureRoutineIds(projectPath);
92
138
  return readFile(projectPath).find((r) => r.name === name) || null;
93
139
  }
94
140
 
@@ -100,6 +146,10 @@ export function upsertRoutine(storagePath, { name, kind, schedule, spec, enabled
100
146
  const prev = idx >= 0 ? routines[idx] : null;
101
147
  const next = computeNextRun({ schedule, last_run_at: null });
102
148
  const entry = {
149
+ // `entry` is rebuilt from scratch on every upsert, so the id MUST be
150
+ // carried over explicitly (same as created_at below). Dropping it here
151
+ // would re-id the routine on every edit and orphan its memory directory.
152
+ id: prev?.id || shortId("r"),
103
153
  name,
104
154
  kind,
105
155
  schedule,
@@ -167,6 +217,8 @@ export function updateRunState(projectPath, name, { last_run_at, last_status, la
167
217
  }
168
218
 
169
219
  export function getDueRoutines(projectPath, nowStr) {
220
+ // The runner keys per-routine memory off `id`, so due records must carry one.
221
+ ensureRoutineIds(projectPath);
170
222
  return readFile(projectPath).filter((r) => {
171
223
  if (!r.enabled) return false;
172
224
  // CRITICAL: If the schedule cannot be parsed, NEVER run it.
@@ -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);