@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.
- package/package.json +3 -2
- package/src/core/channels/telegram/ask-callbacks.js +4 -0
- package/src/core/channels/telegram/dispatch.js +13 -3
- package/src/core/channels/telegram/reply.js +19 -7
- package/src/core/desktop/autostart.js +31 -14
- package/src/core/mcp/runner.js +6 -1
- package/src/core/net/ipv4-first.js +32 -0
- package/src/core/stores/messages.js +19 -2
- package/src/core/util/index.js +1 -0
- package/src/core/util/path-env.js +73 -0
- package/src/core/voice/transcription.js +28 -3
- package/src/host/daemon/api/super-agent.js +35 -5
- package/src/host/daemon/api/voice.js +20 -1
- package/src/host/daemon/index.js +2 -0
- package/src/host/daemon/plugins/desktop/index.js +13 -2
- package/src/host/daemon/whisper-server.js +27 -0
- package/src/interfaces/cli/commands/exec.js +81 -3
- package/src/interfaces/web/dist/assets/index-COrRuBp1.css +1 -0
- package/src/interfaces/web/dist/assets/index-DUXlrW8P.js +819 -0
- package/src/interfaces/web/dist/assets/index-DUXlrW8P.js.map +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +407 -378
- package/src/interfaces/web/src/components/chat/ContextBar.tsx +83 -28
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +23 -1
- package/src/interfaces/web/src/hooks/useChat.ts +37 -5
- package/src/interfaces/web/src/i18n/en.ts +3 -0
- package/src/interfaces/web/src/i18n/es.ts +3 -0
- package/src/interfaces/web/src/lib/api/agents.ts +2 -2
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +19 -0
- package/src/interfaces/web/src/types/daemon.ts +11 -0
- package/src/interfaces/web/dist/assets/index-CUeIhw7z.css +0 -1
- package/src/interfaces/web/dist/assets/index-CqlRznhf.js +0 -819
- 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.
|
|
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
|
-
|
|
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
|
-
*
|
|
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 (
|
|
83
|
-
if (
|
|
84
|
-
if (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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: ${
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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: ${
|
|
178
|
+
return { ok: false, error: `autostart not supported on platform: ${platform}` };
|
|
162
179
|
}
|
package/src/core/mcp/runner.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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 };
|
package/src/core/util/index.js
CHANGED
|
@@ -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.
|
|
179
|
-
*
|
|
180
|
-
* down
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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, {
|
|
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) {
|