@agentprojectcontext/apx 1.73.1 → 1.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/package.json +1 -1
  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/net/ipv4-first.js +32 -0
  6. package/src/core/stores/messages.js +19 -2
  7. package/src/host/daemon/api/super-agent.js +35 -5
  8. package/src/host/daemon/api/voice.js +20 -1
  9. package/src/host/daemon/index.js +2 -0
  10. package/src/host/daemon/plugins/desktop/index.js +13 -2
  11. package/src/interfaces/cli/commands/exec.js +81 -3
  12. package/src/interfaces/web/dist/assets/index-BxYXJEtf.js +819 -0
  13. package/src/interfaces/web/dist/assets/index-BxYXJEtf.js.map +1 -0
  14. package/src/interfaces/web/dist/assets/index-COrRuBp1.css +1 -0
  15. package/src/interfaces/web/dist/index.html +2 -2
  16. package/src/interfaces/web/package-lock.json +327 -324
  17. package/src/interfaces/web/src/components/chat/ContextBar.tsx +83 -28
  18. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +23 -1
  19. package/src/interfaces/web/src/hooks/useChat.ts +37 -5
  20. package/src/interfaces/web/src/i18n/en.ts +3 -0
  21. package/src/interfaces/web/src/i18n/es.ts +3 -0
  22. package/src/interfaces/web/src/lib/api/agents.ts +2 -2
  23. package/src/interfaces/web/src/screens/base/LogsTab.tsx +19 -0
  24. package/src/interfaces/web/src/types/daemon.ts +11 -0
  25. package/src/interfaces/web/dist/assets/index-CUeIhw7z.css +0 -1
  26. package/src/interfaces/web/dist/assets/index-CqlRznhf.js +0 -819
  27. 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.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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
  }
@@ -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 };
@@ -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) {
@@ -104,6 +104,10 @@ export function register(app, { projects, plugins, registries }) {
104
104
  const channel = body.channel || "voice";
105
105
  let suggestions = [];
106
106
  let toolsUsed = [];
107
+ // Attribution for the persisted turn (see the appendGlobalMessage below).
108
+ let replyModel = null;
109
+ let replyUsage = null;
110
+ let replyName = null;
107
111
 
108
112
  const channelCtx = buildVoiceChannelContext(channel, {
109
113
  projectId: body.projectId,
@@ -125,6 +129,9 @@ export function register(app, { projects, plugins, registries }) {
125
129
  previousMessages,
126
130
  });
127
131
  const raw = (result?.text || "").trim();
132
+ replyModel = result?.model || null;
133
+ replyUsage = result?.usage || null;
134
+ replyName = result?.name || null;
128
135
  // Surface the tools the agent actually executed this turn so
129
136
  // the overlay can show "lo que APX hizo" instead of echoing
130
137
  // the transcript. Dedup by name (a list_tasks before+after
@@ -188,7 +195,19 @@ export function register(app, { projects, plugins, registries }) {
188
195
  const logCh = channelCtx.channel || channel;
189
196
  if (logCh && logCh !== "api") {
190
197
  appendGlobalMessage({ channel: logCh, direction: "in", type: "user", author: "user", body: userText });
191
- if (replyText) appendGlobalMessage({ channel: logCh, direction: "out", type: "agent", body: replyText });
198
+ if (replyText) {
199
+ appendGlobalMessage({
200
+ channel: logCh,
201
+ direction: "out",
202
+ type: "agent",
203
+ author: replyName || undefined,
204
+ body: replyText,
205
+ meta: {
206
+ ...(replyModel ? { model: replyModel } : {}),
207
+ ...(replyUsage ? { usage: replyUsage } : {}),
208
+ },
209
+ });
210
+ }
192
211
  }
193
212
  } catch { /* best-effort */ }
194
213
 
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // APX daemon entry point. Boots config + projects + Express + plugins.
3
+ // Must run before any outbound fetch — see the module header for why.
4
+ import "#core/net/ipv4-first.js";
3
5
  import fs from "node:fs";
4
6
  import path from "node:path";
5
7
  import { fileURLToPath } from "node:url";
@@ -199,9 +199,20 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
199
199
  }
200
200
  }
201
201
 
202
- // Persist assistant response
202
+ // Persist assistant response, stamped with the model that answered and the
203
+ // turn's token usage so the web thread viewer can attribute it.
203
204
  try {
204
- await appendGlobalMessage({ channel: CHANNEL, direction: "out", type: "agent", body: finalText });
205
+ await appendGlobalMessage({
206
+ channel: CHANNEL,
207
+ direction: "out",
208
+ type: "agent",
209
+ author: result?.name || undefined,
210
+ body: finalText,
211
+ meta: {
212
+ ...(result?.model ? { model: result.model } : {}),
213
+ ...(result?.usage ? { usage: result.usage } : {}),
214
+ },
215
+ });
205
216
  } catch {}
206
217
 
207
218
  } catch (e) {
@@ -58,6 +58,69 @@ export function resolveExecChannel(args) {
58
58
  return CHANNELS.CLI;
59
59
  }
60
60
 
61
+ // Braille spinner frames for the live "pensando…" indicator.
62
+ const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
63
+ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
64
+
65
+ /**
66
+ * A single-line progress indicator drawn on stderr while the super-agent turn
67
+ * runs, so `apx exec` never looks frozen. Renders only on an interactive stderr
68
+ * (TTY); when piped/redirected it is a no-op so stdout stays clean for scripts.
69
+ * Set APX_NO_SPINNER=1 to disable.
70
+ */
71
+ function createExecStatus() {
72
+ const active =
73
+ process.stderr.isTTY && process.env.APX_NO_SPINNER !== "1" && !process.env.NO_COLOR;
74
+ let label = "pensando…";
75
+ let frame = 0;
76
+ let timer = null;
77
+ const t0 = Date.now();
78
+
79
+ const draw = () => {
80
+ const secs = Math.floor((Date.now() - t0) / 1000);
81
+ process.stderr.write(`\r\x1b[2K${dim(SPINNER[frame])} ${label} ${dim(secs + "s")}`);
82
+ frame = (frame + 1) % SPINNER.length;
83
+ };
84
+
85
+ return {
86
+ start() {
87
+ if (!active || timer) return;
88
+ draw();
89
+ timer = setInterval(draw, 90);
90
+ timer.unref?.();
91
+ },
92
+ set(next) {
93
+ if (next) label = next;
94
+ },
95
+ clear() {
96
+ if (timer) {
97
+ clearInterval(timer);
98
+ timer = null;
99
+ }
100
+ if (active) process.stderr.write("\r\x1b[2K");
101
+ },
102
+ };
103
+ }
104
+
105
+ /** Map a stream event to the label shown next to the spinner. */
106
+ function labelForEvent(event) {
107
+ switch (event?.type) {
108
+ case "skill_inspector":
109
+ return "cargando skills…";
110
+ case "model_routed":
111
+ case "model_start": {
112
+ const m = event.model || event.to || event.engine;
113
+ return m ? `pensando… ${dim(m)}` : "pensando…";
114
+ }
115
+ case "tool_start":
116
+ return `${event.trace?.tool || event.tool || "herramienta"}…`;
117
+ case "tool_result":
118
+ return "pensando…";
119
+ default:
120
+ return null;
121
+ }
122
+ }
123
+
61
124
  async function readPromptFromStdin() {
62
125
  const fs = await import("node:fs");
63
126
  if (process.stdin.isTTY) return "";
@@ -99,11 +162,26 @@ export async function cmdExec(args) {
99
162
  if (args.flags["max-tokens"]) body.maxTokens = parseInt(args.flags["max-tokens"], 10);
100
163
 
101
164
  if (useSuperAgent) {
102
- const result = await http.post(`/projects/${pid}/super-agent/chat`, body);
103
- process.stdout.write(result.text + "\n");
165
+ // Stream the turn so we can render a live progress indicator instead of a
166
+ // silent hang. `confirm: false` opts out of the interactive confirmation
167
+ // round-trip (which the CLI can't answer), keeping the same semantics as the
168
+ // blocking POST /super-agent/chat endpoint.
169
+ const status = createExecStatus();
170
+ status.start();
171
+ let result;
172
+ try {
173
+ result = await http.streamPost(
174
+ `/projects/${pid}/super-agent/chat/stream`,
175
+ { ...body, confirm: false },
176
+ (event) => status.set(labelForEvent(event))
177
+ );
178
+ } finally {
179
+ status.clear();
180
+ }
181
+ process.stdout.write((result?.text ?? "") + "\n");
104
182
  if (process.stderr.isTTY || args.flags.verbose) {
105
183
  process.stderr.write(
106
- `\n— ${result.name || "super-agent"} | model=${result.trace ? "tools" : "engine"} | in=${result.usage?.input_tokens || "?"} out=${result.usage?.output_tokens || "?"}${result.model ? ` | ${result.model}` : ""}\n`
184
+ `\n— ${result?.name || "super-agent"} | model=${result?.trace ? "tools" : "engine"} | in=${result?.usage?.input_tokens || "?"} out=${result?.usage?.output_tokens || "?"}${result?.model ? ` | ${result.model}` : ""}\n`
107
185
  );
108
186
  }
109
187
  return;