@agentprojectcontext/apx 1.73.1 → 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.
Files changed (33) hide show
  1. package/package.json +3 -2
  2. package/src/core/channels/telegram/ask-callbacks.js +4 -0
  3. package/src/core/channels/telegram/dispatch.js +13 -3
  4. package/src/core/channels/telegram/reply.js +19 -7
  5. package/src/core/desktop/autostart.js +31 -14
  6. package/src/core/mcp/runner.js +6 -1
  7. package/src/core/net/ipv4-first.js +32 -0
  8. package/src/core/stores/messages.js +19 -2
  9. package/src/core/util/index.js +1 -0
  10. package/src/core/util/path-env.js +73 -0
  11. package/src/core/voice/transcription.js +28 -3
  12. package/src/host/daemon/api/super-agent.js +35 -5
  13. package/src/host/daemon/api/voice.js +20 -1
  14. package/src/host/daemon/index.js +2 -0
  15. package/src/host/daemon/plugins/desktop/index.js +13 -2
  16. package/src/host/daemon/whisper-server.js +27 -0
  17. package/src/interfaces/cli/commands/exec.js +81 -3
  18. package/src/interfaces/web/dist/assets/index-COrRuBp1.css +1 -0
  19. package/src/interfaces/web/dist/assets/index-DUXlrW8P.js +819 -0
  20. package/src/interfaces/web/dist/assets/index-DUXlrW8P.js.map +1 -0
  21. package/src/interfaces/web/dist/index.html +2 -2
  22. package/src/interfaces/web/package-lock.json +407 -378
  23. package/src/interfaces/web/src/components/chat/ContextBar.tsx +83 -28
  24. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +23 -1
  25. package/src/interfaces/web/src/hooks/useChat.ts +37 -5
  26. package/src/interfaces/web/src/i18n/en.ts +3 -0
  27. package/src/interfaces/web/src/i18n/es.ts +3 -0
  28. package/src/interfaces/web/src/lib/api/agents.ts +2 -2
  29. package/src/interfaces/web/src/screens/base/LogsTab.tsx +19 -0
  30. package/src/interfaces/web/src/types/daemon.ts +11 -0
  31. package/src/interfaces/web/dist/assets/index-CUeIhw7z.css +0 -1
  32. package/src/interfaces/web/dist/assets/index-CqlRznhf.js +0 -819
  33. package/src/interfaces/web/dist/assets/index-CqlRznhf.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.73.1",
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",
@@ -181,6 +181,7 @@ export async function runResumedTurn(self, ctx) {
181
181
  let replyText;
182
182
  let replyAuthor;
183
183
  let saUsage = null;
184
+ let saModel = null;
184
185
  try {
185
186
  const sa = await runTelegramSuperAgent(self, {
186
187
  chat_id,
@@ -216,10 +217,12 @@ export async function runResumedTurn(self, ctx) {
216
217
  replyText = sa.text;
217
218
  replyAuthor = sa.name || agentDisplay;
218
219
  saUsage = sa.usage;
220
+ saModel = sa.model || state.model || null;
219
221
  } catch (e) {
220
222
  self.log(`telegram[${self.channel.name}] ask resume failed: ${e.message}`);
221
223
  replyText = telegramErrorText(self, e);
222
224
  replyAuthor = agentDisplay;
225
+ saModel = state.model || null;
223
226
  }
224
227
 
225
228
  stopTyping();
@@ -231,6 +234,7 @@ export async function runResumedTurn(self, ctx) {
231
234
  replyActorId: SUPERAGENT_ACTOR_ID,
232
235
  replyKind: "superagent",
233
236
  saUsage,
237
+ saModel,
234
238
  streamedCount: state.streamedCount,
235
239
  lastStreamedText: state.lastStreamedText,
236
240
  agentDisplay,
@@ -226,6 +226,8 @@ export async function handleUpdate(self, u) {
226
226
  let replyAuthor;
227
227
  let replyActorId = SUPERAGENT_ACTOR_ID; // stable id: super_agent | agent slug
228
228
  let replyKind = "superagent"; // actor_kind: superagent | agent
229
+ let replyModel = null; // model that actually produced the reply
230
+ let replyUsage = null; // token accounting for this turn
229
231
  const projectCfg = target.config || self.globalConfig;
230
232
  // Display name for the super-agent persona on this channel (from identity.json).
231
233
  const agentDisplay = resolveAgentName(self.globalConfig);
@@ -254,6 +256,10 @@ export async function handleUpdate(self, u) {
254
256
  replyAuthor = agent.slug;
255
257
  replyActorId = agent.slug;
256
258
  replyKind = "agent";
259
+ // Fully-qualified id from the agent card (provider:model) — callEngine
260
+ // resolves it internally and doesn't hand it back.
261
+ replyModel = agent.fields.Model;
262
+ replyUsage = result.usage || null;
257
263
  } catch (e) {
258
264
  self.log(`telegram[${self.channel.name}] agent reply failed: ${e.message}`);
259
265
  replyText = t("telegram.error_agent", {
@@ -263,6 +269,8 @@ export async function handleUpdate(self, u) {
263
269
  replyAuthor = agentDisplay;
264
270
  replyActorId = SUPERAGENT_ACTOR_ID;
265
271
  replyKind = "superagent";
272
+ replyModel = null;
273
+ replyUsage = null;
266
274
  }
267
275
  } else {
268
276
  self.log(
@@ -276,7 +284,6 @@ export async function handleUpdate(self, u) {
276
284
  // calls are logged but never sent (internal). The streamed turn + its final
277
285
  // send live in ./reply.js so this dispatcher and the ask-flow resume
278
286
  // (_runResumedTurn in the host poller) share ONE reply path — no drift.
279
- let saUsage = null;
280
287
  let streamedCount = 0;
281
288
  let lastStreamedText = "";
282
289
  if (!replyText && isSuperAgentEnabled(self.globalConfig)) {
@@ -327,7 +334,8 @@ export async function handleUpdate(self, u) {
327
334
  replyAuthor = sa.name || agentDisplay;
328
335
  replyActorId = SUPERAGENT_ACTOR_ID;
329
336
  replyKind = "superagent";
330
- saUsage = sa.usage;
337
+ replyUsage = sa.usage;
338
+ replyModel = sa.model || state.model || null;
331
339
 
332
340
  // ── ask_questions integration ────────────────────────────────────
333
341
  // If the super-agent ended this turn by calling ask_questions, hand off
@@ -374,6 +382,7 @@ export async function handleUpdate(self, u) {
374
382
  replyAuthor = agentDisplay;
375
383
  replyActorId = SUPERAGENT_ACTOR_ID;
376
384
  replyKind = "superagent";
385
+ replyModel = state.model || null;
377
386
  }
378
387
  }
379
388
 
@@ -386,7 +395,8 @@ export async function handleUpdate(self, u) {
386
395
  replyAuthor,
387
396
  replyActorId,
388
397
  replyKind,
389
- saUsage,
398
+ saUsage: replyUsage,
399
+ saModel: replyModel,
390
400
  streamedCount,
391
401
  lastStreamedText,
392
402
  agentDisplay,
@@ -23,12 +23,19 @@ import { buildTelegramMeta, resolveBotToken } from "./helpers.js";
23
23
  * (audit trail / other channels — never sent to Telegram). Returns the handler
24
24
  * plus a live `state` the caller reads AFTER the run to drive the final send.
25
25
  *
26
- * @returns {{ onEvent: Function, state: { streamedCount: number, lastStreamedText: string } }}
26
+ * `state.model` tracks the model actually answering RIGHT NOW (it can rotate
27
+ * mid-turn on a fallback), so every record this handler writes is stamped with
28
+ * the model that produced it — not with the one the turn happened to end on.
29
+ *
30
+ * @returns {{ onEvent: Function, state: { streamedCount: number, lastStreamedText: string, model: string } }}
27
31
  */
28
32
  export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
29
- const state = { streamedCount: 0, lastStreamedText: "", sentHeadsUp: false };
33
+ const state = { streamedCount: 0, lastStreamedText: "", sentHeadsUp: false, model: "" };
30
34
  const onEvent = async (ev) => {
31
35
  try {
36
+ if ((ev.type === "model_start" || ev.type === "model_routed" || ev.type === "final_wrapup") && ev.model) {
37
+ state.model = ev.model;
38
+ }
32
39
  if (ev.type === "tool_start" && !state.sentHeadsUp && state.streamedCount === 0) {
33
40
  state.sentHeadsUp = true;
34
41
  const heads = t("telegram.heads_up", { lang: resolveLang(self.globalConfig) });
@@ -42,7 +49,7 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
42
49
  agent_slug: SUPERAGENT_ACTOR_ID,
43
50
  author: agentDisplay,
44
51
  body: heads,
45
- meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, heads_up: true },
52
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, heads_up: true, ...(state.model ? { model: state.model } : {}) },
46
53
  });
47
54
  return;
48
55
  }
@@ -61,7 +68,7 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
61
68
  agent_slug: SUPERAGENT_ACTOR_ID,
62
69
  author: agentDisplay,
63
70
  body: piece,
64
- meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, streamed: true, iteration: ev.iteration },
71
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, streamed: true, iteration: ev.iteration, ...(state.model ? { model: state.model } : {}) },
65
72
  });
66
73
  } else if (ev.type === "tool_result" && ev.trace) {
67
74
  // Logged for the audit trail / other channels — NOT sent to Telegram.
@@ -74,7 +81,7 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
74
81
  actor_kind: "tool",
75
82
  author: agentDisplay,
76
83
  body: `${tr.tool}(${JSON.stringify(tr.args || {}).slice(0, 200)})`,
77
- meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, tool: tr.tool, args: tr.args, result: tr.result, iteration: ev.iteration },
84
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, tool: tr.tool, args: tr.args, result: tr.result, iteration: ev.iteration, ...(state.model ? { model: state.model } : {}) },
78
85
  });
79
86
  } else if (ev.type === "engine_failed") {
80
87
  // A model in the fallback chain errored; the loop is rotating to the
@@ -175,6 +182,7 @@ export async function runFollowupTurn(self, {
175
182
  let replyText;
176
183
  let replyAuthor;
177
184
  let saUsage = null;
185
+ let saModel = null;
178
186
  try {
179
187
  const sa = await runTelegramSuperAgent(self, {
180
188
  chat_id,
@@ -191,10 +199,12 @@ export async function runFollowupTurn(self, {
191
199
  replyText = sa.text;
192
200
  replyAuthor = sa.name || agentDisplay;
193
201
  saUsage = sa.usage;
202
+ saModel = sa.model || state.model || null;
194
203
  } catch (e) {
195
204
  self.log(`telegram[${self.channel.name}] a2a followup failed: ${e.message}`);
196
205
  replyText = telegramErrorText(self, e);
197
206
  replyAuthor = agentDisplay;
207
+ saModel = state.model || null;
198
208
  }
199
209
  stopTyping();
200
210
  await sendFinalReply(self, {
@@ -205,6 +215,7 @@ export async function runFollowupTurn(self, {
205
215
  replyActorId: SUPERAGENT_ACTOR_ID,
206
216
  replyKind: "superagent",
207
217
  saUsage,
218
+ saModel,
208
219
  streamedCount: state.streamedCount,
209
220
  lastStreamedText: state.lastStreamedText,
210
221
  agentDisplay,
@@ -233,7 +244,7 @@ export function telegramErrorText(self, e) {
233
244
  */
234
245
  export async function sendFinalReply(self, {
235
246
  chat_id, update_id, replyText, replyAuthor, replyActorId, replyKind,
236
- saUsage = null, streamedCount = 0, lastStreamedText = "", agentDisplay,
247
+ saUsage = null, saModel = null, streamedCount = 0, lastStreamedText = "", agentDisplay,
237
248
  extraMeta = {},
238
249
  }) {
239
250
  const finalClean = replyText ? stripThinking(replyText).trim() : "";
@@ -255,6 +266,7 @@ export async function sendFinalReply(self, {
255
266
  const meta = { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, final: true, ...extraMeta };
256
267
  if (replyText && stripThinking(replyText) !== replyText) meta.thinking_stripped = true;
257
268
  if (saUsage) meta.usage = saUsage;
269
+ if (saModel) meta.model = saModel;
258
270
  appendGlobalMessage({
259
271
  channel: CHANNELS.TELEGRAM,
260
272
  direction: "out",
@@ -277,7 +289,7 @@ export async function sendFinalReply(self, {
277
289
  agent_slug: actorId,
278
290
  author: replyAuthor || agentDisplay,
279
291
  body: `[send_failed] ${toSend}`,
280
- meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, send_error: e.message, ...(saUsage ? { usage: saUsage } : {}) },
292
+ meta: { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, send_error: e.message, ...(saUsage ? { usage: saUsage } : {}), ...(saModel ? { model: saModel } : {}) },
281
293
  });
282
294
  }
283
295
  }
@@ -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();
@@ -0,0 +1,32 @@
1
+ // Prefer IPv4 for all outbound connections made by this process.
2
+ //
3
+ // Some networks — notably machines on Tailscale, or misconfigured dual-stack
4
+ // setups — advertise an IPv6 route that black-holes. A dual-stack host such as
5
+ // api.telegram.org then resolves to an unreachable AAAA address, and Node's
6
+ // fetch/undici stalls on it with ETIMEDOUT instead of falling back to the
7
+ // reachable IPv4 the way `curl` does. The symptom is every daemon outbound
8
+ // `fetch` failing ("fetch failed") — Telegram long-poll, some LLM providers —
9
+ // while the shell and `curl -4` work fine.
10
+ //
11
+ // Forcing ipv4first + disabling Happy-Eyeballs family auto-selection makes
12
+ // `fetch` use the reachable A record. On a healthy IPv6 network this is a no-op
13
+ // preference (IPv4 is still universally reachable); opt out with
14
+ // APX_NET_IPV4_FIRST=0 if you specifically need IPv6-first.
15
+ import dns from "node:dns";
16
+ import net from "node:net";
17
+
18
+ if (process.env.APX_NET_IPV4_FIRST !== "0") {
19
+ // Available on Node ≥17; guarded for older runtimes.
20
+ try {
21
+ dns.setDefaultResultOrder("ipv4first");
22
+ } catch {
23
+ /* older node — ignore */
24
+ }
25
+ // Available on Node ≥18.13; disabling it stops undici from racing an
26
+ // unreachable IPv6 address and timing out before it tries IPv4.
27
+ try {
28
+ net.setDefaultAutoSelectFamily?.(false);
29
+ } catch {
30
+ /* older node — ignore */
31
+ }
32
+ }
@@ -643,12 +643,18 @@ export function listGlobalThreads({ channels, _globalMessagesDir } = {}) {
643
643
  }
644
644
 
645
645
  // Read one channel+day thread shaped for the web chat viewer:
646
- // { id, channel, messages: [{ role, content, ts }] } — or null when missing.
646
+ // { id, channel, messages: [{ role, content, ts, }] } — or null when missing.
647
647
  // Tool records are INCLUDED (role:"tool" with structured tool/args/result from
648
648
  // meta) so the web viewer can render tool executions the same way the live
649
649
  // stream does. They're persisted by every channel (e.g. telegram reply.js) but
650
650
  // never sent to the channel itself; dropping them here is what made Telegram
651
651
  // tool calls invisible in the web chat even though they were on disk.
652
+ //
653
+ // Assistant rows also carry their ATTRIBUTION — which agent answered
654
+ // (agent/agent_name/actor_kind) and on which model, plus the turn's token
655
+ // usage. All of it is already on disk in `meta`; dropping it here is what made
656
+ // a reloaded thread render "0 tok" with no model, even though the live stream
657
+ // showed both.
652
658
  export function readGlobalThread({ channel, date, _globalMessagesDir } = {}) {
653
659
  if (!CHANNEL_NAME_RE.test(String(channel || ""))) return null;
654
660
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(date || ""))) return null;
@@ -668,10 +674,21 @@ export function readGlobalThread({ channel, date, _globalMessagesDir } = {}) {
668
674
  result: r.meta?.result,
669
675
  };
670
676
  }
677
+ if (r.type === "user") {
678
+ return { role: "user", content: r.body || "", ts: r.ts };
679
+ }
680
+ const usage = r.meta?.usage;
671
681
  return {
672
- role: r.type === "user" ? "user" : "assistant",
682
+ role: "assistant",
673
683
  content: r.body || "",
674
684
  ts: r.ts,
685
+ // Stable id of who answered (super_agent | project-agent slug) plus the
686
+ // display name that was shown on the channel.
687
+ ...(r.agent_slug || r.actor_id ? { agent: r.agent_slug || r.actor_id } : {}),
688
+ ...(r.author ? { agent_name: r.author } : {}),
689
+ ...(r.actor_kind ? { actor_kind: r.actor_kind } : {}),
690
+ ...(r.meta?.model ? { model: r.meta.model } : {}),
691
+ ...(usage && typeof usage === "object" ? { usage } : {}),
675
692
  };
676
693
  });
677
694
  return { id: date, channel, messages };
@@ -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);
@@ -44,12 +44,21 @@ function logInspectorDecision(trace, { trace_id, channel } = {}) {
44
44
  // the human surfaces (web big chat + sidebar) — not generic "api"/automation
45
45
  // callers. Best-effort: a logging failure never breaks the reply.
46
46
  const WEB_LOGGED_CHANNELS = new Set([CHANNELS.WEB, CHANNELS.WEB_SIDEBAR]);
47
- function logWebTurn(channel, { prompt, replyText }) {
47
+ function logWebTurn(channel, { prompt, replyText, name, model, usage }) {
48
48
  if (!WEB_LOGGED_CHANNELS.has(channel)) return;
49
49
  try {
50
50
  appendGlobalMessage({ channel, direction: "in", type: "user", author: "user", body: prompt });
51
51
  if (replyText) {
52
- appendGlobalMessage({ channel, direction: "out", type: "agent", body: replyText });
52
+ // Attribution rides along on the record: which model answered and what the
53
+ // turn cost. Without it a reloaded thread renders "0 tok" and no model.
54
+ appendGlobalMessage({
55
+ channel,
56
+ direction: "out",
57
+ type: "agent",
58
+ author: name || undefined,
59
+ body: replyText,
60
+ meta: { ...(model ? { model } : {}), ...(usage ? { usage } : {}) },
61
+ });
53
62
  }
54
63
  } catch {
55
64
  /* best-effort */
@@ -146,7 +155,12 @@ export function register(app, { projects, registries, plugins, project, config }
146
155
 
147
156
  // Web/TUI channels receive a "confirmation_required" SSE event and respond
148
157
  // via POST /super-agent/confirm/:correlationId (see api/confirm.js).
149
- const requestConfirmation = createWebConfirmAdapter({ onEvent });
158
+ // Non-interactive callers (e.g. `apx exec`, which streams only to render a
159
+ // progress indicator) send `confirm: false` to opt out: they have no way to
160
+ // answer the round-trip, so they fall back to the default permission policy —
161
+ // exactly matching the blocking POST /super-agent/chat endpoint's semantics.
162
+ const requestConfirmation =
163
+ req.body?.confirm === false ? undefined : createWebConfirmAdapter({ onEvent });
150
164
 
151
165
  try {
152
166
  const saResult = await runSuperAgent({
@@ -168,13 +182,22 @@ export function register(app, { projects, registries, plugins, project, config }
168
182
  skipSkillsHint: inspectorOn,
169
183
  });
170
184
  projects.rebuild(p.id);
171
- logWebTurn(ctx.channel, { prompt, replyText: saResult.text });
185
+ logWebTurn(ctx.channel, {
186
+ prompt,
187
+ replyText: saResult.text,
188
+ name: saResult.name,
189
+ model: saResult.model,
190
+ usage: saResult.usage,
191
+ });
172
192
  send({
173
193
  type: "final",
174
194
  result: {
175
195
  text: saResult.text,
176
196
  usage: saResult.usage,
197
+ // `name` is the agent persona; `model` is the engine that answered
198
+ // (it can differ from the configured one after a routing fallback).
177
199
  name: saResult.name,
200
+ model: saResult.model,
178
201
  trace: saResult.trace,
179
202
  },
180
203
  });
@@ -270,11 +293,18 @@ export function register(app, { projects, registries, plugins, project, config }
270
293
  skipSkillsHint: inspectorOn,
271
294
  });
272
295
  projects.rebuild(p.id);
273
- logWebTurn(ctx.channel, { prompt, replyText: saResult.text });
296
+ logWebTurn(ctx.channel, {
297
+ prompt,
298
+ replyText: saResult.text,
299
+ name: saResult.name,
300
+ model: saResult.model,
301
+ usage: saResult.usage,
302
+ });
274
303
  res.json({
275
304
  text: saResult.text,
276
305
  usage: saResult.usage,
277
306
  name: saResult.name,
307
+ model: saResult.model,
278
308
  trace: saResult.trace,
279
309
  });
280
310
  } catch (e) {