@agentprojectcontext/apx 1.39.1 → 1.40.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 (55) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/constants.js +7 -1
  3. package/src/core/agent/retry.js +9 -0
  4. package/src/core/agent/run-agent.js +56 -5
  5. package/src/core/agent/tools/pseudo-tools.js +13 -1
  6. package/src/core/channels/telegram/dispatch.js +23 -3
  7. package/src/core/engines/mock.js +33 -10
  8. package/src/core/i18n/en.js +2 -4
  9. package/src/core/i18n/es.js +1 -4
  10. package/src/core/i18n/index.js +5 -1
  11. package/src/core/i18n/pt.js +1 -3
  12. package/src/core/routines/runner.js +15 -3
  13. package/src/host/daemon/api/admin.js +29 -0
  14. package/src/interfaces/cli/commands/desktop.js +26 -0
  15. package/src/interfaces/cli/index.js +16 -3
  16. package/src/interfaces/desktop/main.js +7 -1
  17. package/src/interfaces/web/dist/assets/index-DW7j3cXB.js +646 -0
  18. package/src/interfaces/web/dist/assets/index-DW7j3cXB.js.map +1 -0
  19. package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
  20. package/src/interfaces/web/dist/index.html +2 -2
  21. package/src/interfaces/web/package-lock.json +188 -188
  22. package/src/interfaces/web/src/App.tsx +22 -11
  23. package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
  24. package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
  25. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
  26. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
  27. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
  28. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
  29. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
  30. package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
  31. package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
  32. package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
  33. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
  34. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
  35. package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
  36. package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
  37. package/src/interfaces/web/src/components/routines/shared.ts +89 -0
  38. package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
  39. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
  40. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
  41. package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
  42. package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
  43. package/src/interfaces/web/src/components/ui.tsx +4 -0
  44. package/src/interfaces/web/src/i18n/en.ts +34 -11
  45. package/src/interfaces/web/src/i18n/es.ts +34 -11
  46. package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
  47. package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
  48. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
  49. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
  50. package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
  51. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
  52. package/src/interfaces/web/src/styles.css +5 -0
  53. package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
  54. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
  55. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.39.1",
3
+ "version": "1.40.1",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,4 +1,10 @@
1
- export const MAX_TOOL_ITERS = 6;
1
+ // Per-turn tool-loop budget for conversational surfaces (telegram/desktop/voice
2
+ // /deck). The LAST of these iterations is reserved by run-agent.js for a
3
+ // tool-free, model-authored wrap-up — so a multi-step task gets ~N-1 action
4
+ // steps and always closes with a contextual message instead of going silent.
5
+ // Coding surfaces (web Code / terminal Build) raise this via maxIters and use
6
+ // the finish-tool completionContract instead.
7
+ export const MAX_TOOL_ITERS = 10;
2
8
  export const ACK_ONLY_TOOLS = new Set(["send_telegram"]);
3
9
  export const MAX_CONSECUTIVE_ACKS = 2;
4
10
  // Tools whose semantics REQUIRE handing control back to the user. After the
@@ -60,6 +60,15 @@ export function isRetryableEngineError(err) {
60
60
  // → model couldn't pick a tool → retry on a different model.
61
61
  if (status === 400) {
62
62
  if (/failed to call a function|did not produce a (valid )?tool/i.test(msg)) return true;
63
+ // Ollama tool-call grammar failure: small / cloud models that can't emit
64
+ // structured tool JSON return a 400 with a parse error like
65
+ // "Value looks like object, but can't find closing '}' symbol"
66
+ // That's the model failing to produce tools, not our payload being
67
+ // malformed — advance to a tool-capable model instead of dying here.
68
+ if (/ollama/i.test(msg) &&
69
+ /looks like (object|array)|can'?t find closing|unexpected end of json|invalid (json|grammar)/i.test(msg)) {
70
+ return true;
71
+ }
63
72
  // explicit schema / param errors are our bug, not transient
64
73
  return false;
65
74
  }
@@ -84,6 +84,21 @@ export const FINISH_TOOL_SCHEMA = {
84
84
  },
85
85
  };
86
86
 
87
+ // Behavioral nudge appended to the system prompt for the ONE tool-free wrap-up
88
+ // step at the end of a turn (see the loop's `isFinalWrapUp`). This shapes
89
+ // BEHAVIOR only — it never dictates wording or supplies a canned/templated
90
+ // sentence. The reply the user sees is 100% model-authored and varies with
91
+ // what the model actually did this turn. We do NOT mention any "tool limit":
92
+ // the model just speaks from where it is. Critically it must not claim work it
93
+ // didn't do (weak models otherwise fabricate "all done").
94
+ const WRAPUP_NUDGE =
95
+ "\n\n[Internal note — last step of this turn. No more tools will run now. " +
96
+ "Reply in plain prose, in the user's language, from your own context: briefly " +
97
+ "say what you actually accomplished so far (check the tool results above — do " +
98
+ "NOT claim anything you didn't do), and if work is still pending, name what's " +
99
+ "left and ask the user whether you should continue. Do not mention limits, " +
100
+ "steps, or iterations — just talk naturally.]";
101
+
87
102
  /**
88
103
  * Shared tool-calling agent loop used by super-agent and future surfaces.
89
104
  */
@@ -222,6 +237,12 @@ export async function runAgent({
222
237
  };
223
238
  let usePseudoTools = false;
224
239
  let ackOnlyStreak = 0;
240
+ // "Never end on silence": a model call that returns no tool calls AND no
241
+ // usable text is a dud (weak models do this). We re-prompt instead of ending
242
+ // the turn empty, and the retry does NOT consume an iteration of the tool
243
+ // budget. Bounded so a model that only ever returns empty can't spin forever.
244
+ let emptyRetries = 0;
245
+ const MAX_EMPTY_RETRIES = 2;
225
246
  // Side-effect dedupe. Weaker models (Gemini especially) sometimes
226
247
  // re-emit the SAME tool call across iterations — e.g. send_telegram
227
248
  // three times with identical args, spamming the user. For tools
@@ -276,25 +297,44 @@ export async function runAgent({
276
297
  for (let iter = 0; iter < maxIters; iter++) {
277
298
  // Merge any tools activated via discover_tools on the previous iteration.
278
299
  drainPendingTools();
279
- await emitProgress(onEvent, { type: "model_start", iteration: iter + 1, model: activeModel });
300
+ // Final iteration of a non-contract turn: the model is out of action steps.
301
+ // Rather than cut off silently mid-tool-call, we run ONE tool-free step so
302
+ // the model writes a natural closing in its OWN words — what it did, what's
303
+ // left, and (if anything remains) whether to continue. We change only the
304
+ // STRUCTURE (no tools this step) + a behavioral nudge; the wording is
305
+ // entirely the model's. Coding surfaces keep their finish-tool flow, so
306
+ // this never applies under completionContract.
307
+ const isFinalWrapUp =
308
+ !useContract && effectiveSchemas.length > 0 && iter === maxIters - 1;
309
+ await emitProgress(onEvent, {
310
+ type: isFinalWrapUp ? "final_wrapup" : "model_start",
311
+ iteration: iter + 1,
312
+ model: activeModel,
313
+ });
280
314
  const forceTool =
315
+ !isFinalWrapUp &&
281
316
  effectiveSchemas.length > 0 &&
282
317
  (useContract ||
283
318
  (ackOnlyStreak > 0 && ackOnlyStreak <= MAX_CONSECUTIVE_ACKS));
319
+ const baseSystem = usePseudoTools
320
+ ? pseudoToolSystem(system, effectiveSchemas)
321
+ : system;
284
322
  let result;
285
323
  try {
286
324
  result = await tryCallEngine({
287
- system: usePseudoTools ? pseudoToolSystem(system, effectiveSchemas) : system,
325
+ system: isFinalWrapUp ? baseSystem + WRAPUP_NUDGE : baseSystem,
288
326
  messages: conversation,
289
327
  config: globalConfig,
290
- tools: usePseudoTools ? null : effectiveSchemas,
291
- toolChoice: usePseudoTools ? null : (forceTool ? "required" : "auto"),
328
+ // On the wrap-up step we withhold tools entirely so the model must
329
+ // answer in prose same as a real engine called with tools omitted.
330
+ tools: (usePseudoTools || isFinalWrapUp) ? null : effectiveSchemas,
331
+ toolChoice: (usePseudoTools || isFinalWrapUp) ? null : (forceTool ? "required" : "auto"),
292
332
  // Smaller cap by default: 1024 ate too much of the cheap-tier TPM
293
333
  // budget. The super-agent rarely emits long replies; tool args are
294
334
  // small. Summarization callers raise it via the maxTokens arg.
295
335
  maxTokens,
296
336
  signal,
297
- onToken: (!forceTool && onToken) ? onToken : null,
337
+ onToken: ((!forceTool || isFinalWrapUp) && onToken) ? onToken : null,
298
338
  });
299
339
  } catch (e) {
300
340
  if (usePseudoTools && /^ollama:/i.test(String(activeModel || "")) && /ollama\s+500/i.test(String(e?.message || "")) && trace.length > 0) {
@@ -333,6 +373,17 @@ export async function runAgent({
333
373
 
334
374
  if (!toolCalls || toolCalls.length === 0) {
335
375
  lastText = cleanTextOfPseudoToolCalls(lastText) || lastText;
376
+ // Dud turn (no tools, no text): re-prompt instead of ending empty, and
377
+ // don't let it cost an iteration of the tool budget. `iter -= 1` cancels
378
+ // the loop's `iter++`; the emptyRetries cap stops an all-empty model from
379
+ // looping forever (after which we break and the surface's last-resort
380
+ // floor sends a non-silent reply).
381
+ if (!String(lastText).trim() && emptyRetries < MAX_EMPTY_RETRIES) {
382
+ emptyRetries += 1;
383
+ await emitProgress(onEvent, { type: "empty_retry", iteration: iter + 1, attempt: emptyRetries });
384
+ iter -= 1;
385
+ continue;
386
+ }
336
387
  break;
337
388
  }
338
389
 
@@ -35,6 +35,18 @@ export function pseudoToolSystem(system, toolSchemas) {
35
35
 
36
36
  export function shouldRetryWithPseudoTools(modelId, error, alreadyPseudo) {
37
37
  if (alreadyPseudo) return false;
38
+ if (!/^ollama:/i.test(String(modelId || ""))) return false;
38
39
  const message = String(error?.message || "");
39
- return /^ollama:/i.test(String(modelId || "")) && /ollama\s+500/i.test(message);
40
+ // Ollama can't always do native/structured tool-calling. Two failure shapes,
41
+ // same fix — drop structured tools and re-run with text-based pseudo-tools
42
+ // (parsed from the prompt, no grammar required):
43
+ // • 5xx mid-call (model timeout / server error)
44
+ // • 400 with a JSON/grammar parse error, e.g.
45
+ // "Value looks like object, but can't find closing '}' symbol"
46
+ if (/ollama\s+5\d\d/i.test(message)) return true;
47
+ if (/ollama\s+400/i.test(message) &&
48
+ /looks like (object|array)|can'?t find closing|unexpected end of json|invalid (json|grammar)/i.test(message)) {
49
+ return true;
50
+ }
51
+ return false;
40
52
  }
@@ -436,6 +436,17 @@ export async function handleUpdate(self, u) {
436
436
  iteration: ev.iteration,
437
437
  },
438
438
  });
439
+ } else if (ev.type === "engine_failed") {
440
+ // A model in the fallback chain errored; the loop is rotating to
441
+ // the next one. Log it so a mid-turn provider failure (rate limit,
442
+ // tool-grammar 400, …) is diagnosable instead of invisible.
443
+ self.log(
444
+ `telegram[${self.channel.name}] engine_failed: ${ev.model || "?"} (${ev.reason || "?"}) → ${ev.retry_with || "end of chain"}`,
445
+ );
446
+ } else if (ev.type === "model_routed" || ev.type === "model_retry") {
447
+ self.log(
448
+ `telegram[${self.channel.name}] ${ev.type}: model=${ev.model || "?"}${ev.reason ? ` reason=${ev.reason}` : ""}${ev.from_fallback ? " (fallback)" : ""}`,
449
+ );
439
450
  }
440
451
  } catch (e) {
441
452
  // A failed intermediate send must not abort the whole run.
@@ -542,9 +553,18 @@ export async function handleUpdate(self, u) {
542
553
  // turn isn't silently empty.
543
554
  const finalClean = replyText ? stripThinking(replyText).trim() : "";
544
555
  let toSend = "";
545
- if (finalClean && finalClean !== lastStreamedText) toSend = finalClean;
546
- else if (!finalClean && streamedCount === 0) {
547
- toSend = t("telegram.fallback_listo", { lang: resolveLang(self.globalConfig) });
556
+ if (finalClean && finalClean !== lastStreamedText) {
557
+ toSend = finalClean;
558
+ } else if (!finalClean) {
559
+ // Never end a turn on silence. The loop's tool-free wrap-up normally
560
+ // fills finalClean with a model-authored closing (handled above); this is
561
+ // the last-resort floor for the rare case it still came back empty. A
562
+ // pure chit-chat turn that did nothing gets the short ack; a turn that
563
+ // streamed/acted but produced no closing gets a neutral "continue?" that
564
+ // does NOT claim completion.
565
+ toSend = streamedCount === 0
566
+ ? t("telegram.fallback_listo", { lang: resolveLang(self.globalConfig) })
567
+ : t("telegram.fallback_continue", { lang: resolveLang(self.globalConfig) });
548
568
  }
549
569
 
550
570
  stopTyping();
@@ -10,19 +10,32 @@ export default {
10
10
  return { ok: true, soft: true };
11
11
  },
12
12
 
13
- async chat({ system, messages, model = "mock" }) {
13
+ async chat({ system, messages, model = "mock", tools }) {
14
14
  const last = [...messages].reverse().find((m) => m.role === "user");
15
15
  const userText = last?.content || "";
16
+ // Mirror real engines: tool calls are only possible when the caller offers
17
+ // tools. The loop withholds them on its tool-free wrap-up step, and we must
18
+ // honor that here — otherwise the mock would keep "calling" tools the model
19
+ // can't actually reach.
20
+ const toolsAvailable = Array.isArray(tools) && tools.length > 0;
16
21
  const requestedTool = userText.match(/\[mock:tool:([a-z_]+)\]/)?.[1];
22
+ const loopTool = userText.match(/\[mock:loop:([a-z_]+)\]/)?.[1];
17
23
  const finishSummary = userText.match(/\[mock:finish:([^\]]*)\]/)?.[1];
18
24
  const hasToolResult = messages.some((m) => m.role === "tool");
19
- // Completion-contract path: once a tool has run, emit a `finish` call with
20
- // the requested summary so tests can exercise the loop's graceful exit.
21
- if (finishSummary != null && hasToolResult) {
25
+ // `[mock:empty]` a dud turn (no text, no tools) to exercise the loop's
26
+ // empty-retry / never-end-silent guard.
27
+ if (/\[mock:empty\]/.test(userText)) {
28
+ return {
29
+ text: "",
30
+ usage: { input_tokens: userText.length, output_tokens: 0 },
31
+ raw: { model, mock: true },
32
+ };
33
+ }
34
+ const mkToolCall = (name, id) => {
22
35
  const toolCall = {
23
- id: "mock-finish-1",
36
+ id,
24
37
  type: "function",
25
- function: { name: "finish", arguments: JSON.stringify({ summary: finishSummary }) },
38
+ function: { name, arguments: "{}" },
26
39
  };
27
40
  return {
28
41
  text: "",
@@ -31,12 +44,14 @@ export default {
31
44
  usage: { input_tokens: userText.length, output_tokens: 4 },
32
45
  raw: { model, mock: true },
33
46
  };
34
- }
35
- if (requestedTool && !hasToolResult) {
47
+ };
48
+ // Completion-contract path: once a tool has run, emit a `finish` call with
49
+ // the requested summary so tests can exercise the loop's graceful exit.
50
+ if (finishSummary != null && hasToolResult && toolsAvailable) {
36
51
  const toolCall = {
37
- id: "mock-call-1",
52
+ id: "mock-finish-1",
38
53
  type: "function",
39
- function: { name: requestedTool, arguments: "{}" },
54
+ function: { name: "finish", arguments: JSON.stringify({ summary: finishSummary }) },
40
55
  };
41
56
  return {
42
57
  text: "",
@@ -46,6 +61,14 @@ export default {
46
61
  raw: { model, mock: true },
47
62
  };
48
63
  }
64
+ // `[mock:loop:<tool>]` → re-fire the tool every step it's offered, modeling
65
+ // a model that never stops on its own (drives the loop to its cap).
66
+ if (loopTool && toolsAvailable) {
67
+ return mkToolCall(loopTool, "mock-loop-1");
68
+ }
69
+ if (requestedTool && !hasToolResult && toolsAvailable) {
70
+ return mkToolCall(requestedTool, "mock-call-1");
71
+ }
49
72
 
50
73
  const sysHint = system ? ` (system: ${system.slice(0, 40)}…)` : "";
51
74
  return {
@@ -1,9 +1,7 @@
1
- // Backend strings — English (en).
1
+ // Backend strings — English (en). This is also the default fallback locale.
2
2
  export default {
3
3
  "telegram.heads_up": "On it — working on that… 🛠️",
4
4
  "telegram.reset_ack": "Done, context cleared. Starting fresh. What do you need?",
5
- "telegram.error_generic": "Something broke on my side — already logged.",
6
5
  "telegram.fallback_listo": "Done.",
7
-
8
- "common.unknown_error": "Something went wrong.",
6
+ "telegram.fallback_continue": "Made some headway. Want me to keep going?",
9
7
  };
@@ -4,9 +4,6 @@ export default {
4
4
  // Telegram channel
5
5
  "telegram.heads_up": "Dale, estoy con eso… 🛠️",
6
6
  "telegram.reset_ack": "Listo, contexto borrado. Arranco un hilo nuevo, ¿qué necesitás?",
7
- "telegram.error_generic": "Algo se rompió de mi lado — ya lo registré.",
8
7
  "telegram.fallback_listo": "Listo.",
9
-
10
- // Generic helpers reused from several surfaces
11
- "common.unknown_error": "Algo salió mal.",
8
+ "telegram.fallback_continue": "Avancé con eso. ¿Querés que siga?",
12
9
  };
@@ -16,7 +16,11 @@ import es from "./es.js";
16
16
  import pt from "./pt.js";
17
17
 
18
18
  const DICTS = Object.freeze({ en, es, pt });
19
- const DEFAULT_LANG = "es";
19
+ // English is the universal fallback: any unrecognized language code resolves
20
+ // to "en" rather than forcing a per-country dict. Real agent replies are
21
+ // model-authored and already come back in the user's language; these dicts
22
+ // only cover the few host-emitted strings that never pass through the model.
23
+ const DEFAULT_LANG = "en";
20
24
 
21
25
  /**
22
26
  * Pull the user's preferred language code from a globalConfig snapshot.
@@ -2,8 +2,6 @@
2
2
  export default {
3
3
  "telegram.heads_up": "Já estou nisso… 🛠️",
4
4
  "telegram.reset_ack": "Pronto, contexto limpo. Começando do zero — do que você precisa?",
5
- "telegram.error_generic": "Algo quebrou do meu lado — já registrei.",
6
5
  "telegram.fallback_listo": "Pronto.",
7
-
8
- "common.unknown_error": "Algo deu errado.",
6
+ "telegram.fallback_continue": "Avancei com isso. Quer que eu continue?",
9
7
  };
@@ -157,7 +157,8 @@ async function handleTelegram(ctx, routine) {
157
157
  const { channel, chat_id, text } = routine.spec;
158
158
  if (!text) throw new Error("telegram routine needs spec.text");
159
159
  await tg.send({ channel, chat_id, text });
160
- return { status: "ok" };
160
+ // Return the (interpolated) text so the run detail can show what was sent.
161
+ return { status: "ok", text };
161
162
  }
162
163
 
163
164
  function handleShell(ctx, routine) {
@@ -296,7 +297,9 @@ export async function runRoutineNow(ctx, routine) {
296
297
  ...routine,
297
298
  spec: {
298
299
  ...routine.spec,
300
+ // {{pre_output}} works in both the LLM prompt and the telegram text.
299
301
  prompt: injectPreOutput(routine.spec?.prompt, preStdout),
302
+ text: injectPreOutput(routine.spec?.text, preStdout),
300
303
  },
301
304
  }
302
305
  : routine;
@@ -323,6 +326,7 @@ export async function runRoutineNow(ctx, routine) {
323
326
  }
324
327
 
325
328
  // ── Phase 3: post_commands ────────────────────────────────────────────────
329
+ const postRuns = [];
326
330
  if (hasPostCmds) {
327
331
  const llmOutput = result?.reply || result?.text || "";
328
332
  const postEnv = {
@@ -333,7 +337,8 @@ export async function runRoutineNow(ctx, routine) {
333
337
  };
334
338
  for (const rawCmd of routine.post_commands) {
335
339
  const cmd = resolveArtifactRef(rawCmd, storagePath);
336
- await runShellCmd(cmd, postEnv, cwd);
340
+ const r = await runShellCmd(cmd, postEnv, cwd);
341
+ postRuns.push({ cmd: rawCmd, exit: r.exitCode, stdout: (r.stdout || "").slice(0, 4000), stderr: (r.stderr || "").slice(0, 2000) });
337
342
  }
338
343
  }
339
344
 
@@ -358,7 +363,14 @@ export async function runRoutineNow(ctx, routine) {
358
363
  body: status === "ok"
359
364
  ? `routine ${routine.name} ok${skip ? " (skipped LLM)" : ""}`
360
365
  : `routine ${routine.name} error: ${errMsg}`,
361
- meta: { routine: routine.name, status, skipped: skip, result },
366
+ meta: {
367
+ routine: routine.name, status, skipped: skip, result,
368
+ // Persisted run flow so the UI can replay pre → action → post.
369
+ flow: {
370
+ pre: hasPreCmds ? { output: preStdout.slice(0, 8000), exit: preExitCode } : null,
371
+ post: postRuns.length ? postRuns : null,
372
+ },
373
+ },
362
374
  });
363
375
  return { ...result, last_run_at: lastRun, next_run_at: next };
364
376
  }
@@ -4,6 +4,7 @@
4
4
  //
5
5
  // Both are auth-gated (the global middleware applies).
6
6
  import { readConfig } from "#core/config/index.js";
7
+ import { exec } from "node:child_process";
7
8
  import fs from "node:fs";
8
9
  import os from "node:os";
9
10
  import path from "node:path";
@@ -49,6 +50,34 @@ export function register(app, { scheduler, plugins, config }) {
49
50
  setTimeout(() => process.exit(0), 50);
50
51
  });
51
52
 
53
+ // Opens the OS-native folder picker on the daemon host and resolves with
54
+ // the absolute path the user chose. macOS uses osascript; Linux uses
55
+ // zenity (if present); Windows uses PowerShell's Shell.Application. If the
56
+ // platform lacks a usable picker — or none is installed — the endpoint
57
+ // returns 501 so the frontend can fall back to the inline directory list.
58
+ app.get("/admin/fs/pick-dir", (req, res) => {
59
+ const prompt = String(req.query.prompt || "Select a folder").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
60
+ const platform = process.platform;
61
+ let cmd;
62
+ if (platform === "darwin") {
63
+ // try/end-try makes cancel exit with code 0 + empty stdout so we can
64
+ // distinguish "cancelled" from "no picker available".
65
+ cmd = `osascript -e 'try' -e 'POSIX path of (choose folder with prompt "${prompt}")' -e 'on error' -e 'return ""' -e 'end try'`;
66
+ } else if (platform === "linux") {
67
+ cmd = `command -v zenity >/dev/null && zenity --file-selection --directory --title="${prompt}" 2>/dev/null || true`;
68
+ } else if (platform === "win32") {
69
+ cmd = `powershell -NoProfile -Command "$f = (New-Object -ComObject Shell.Application).BrowseForFolder(0, '${prompt.replace(/'/g, "''")}', 0, 0); if ($f) { $f.Self.Path }"`;
70
+ } else {
71
+ return res.status(501).json({ error: "Native folder picker not supported on this platform" });
72
+ }
73
+ exec(cmd, { timeout: 5 * 60 * 1000 }, (err, stdout) => {
74
+ if (err) return res.status(500).json({ error: err.message });
75
+ const picked = (stdout || "").trim().replace(/[\r\n]+$/g, "");
76
+ if (!picked) return res.json({ cancelled: true });
77
+ res.json({ path: picked.replace(/\/+$/, "") });
78
+ });
79
+ });
80
+
52
81
  app.get("/admin/fs/dirs", (req, res) => {
53
82
  const requested = String(req.query.path || os.homedir());
54
83
  const base = path.resolve(requested.replace(/^~(?=$|\/)/, os.homedir()));
@@ -277,6 +277,32 @@ export async function cmdDesktopStop(_args = {}) {
277
277
  }
278
278
  }
279
279
 
280
+ // True when the desktop Electron process is alive. Used by `apx restart` so it
281
+ // only re-launches the desktop if the user already had it running.
282
+ export function desktopRunning() {
283
+ return pidAlive(readPid());
284
+ }
285
+
286
+ // Stop (if running) then start — the only way the desktop picks up new
287
+ // renderer/main.js code after a pull, since the Electron process holds the
288
+ // old bundle in memory. Token re-sync after a daemon restart is handled
289
+ // automatically by the WS reconnect (it re-reads daemon.token), so this is
290
+ // purely about refreshing stale desktop CODE.
291
+ export async function cmdDesktopRestart(args = {}) {
292
+ const pid = readPid();
293
+ if (pidAlive(pid)) {
294
+ try { process.kill(pid, "SIGTERM"); } catch {}
295
+ clearPid();
296
+ // Give Electron a moment to release the tray/shortcut before relaunch.
297
+ await new Promise((r) => setTimeout(r, 600));
298
+ process.stderr.write("apx: restarting desktop...\n");
299
+ } else {
300
+ clearPid();
301
+ process.stderr.write("apx: desktop not running — starting...\n");
302
+ }
303
+ await cmdDesktopStart(args);
304
+ }
305
+
280
306
  export async function cmdDesktopStatus(_args = {}) {
281
307
  const pid = readPid();
282
308
  const alive = pidAlive(pid);
@@ -99,7 +99,7 @@ import {
99
99
  cmdPermission,
100
100
  } from "./commands/config.js";
101
101
  import { cmdPluginsList, cmdPluginStatus } from "./commands/plugins.js";
102
- import { cmdDesktopStart, cmdDesktopStop, cmdDesktopStatus, cmdDesktopInstall, cmdDesktopUninstall } from "./commands/desktop.js";
102
+ import { cmdDesktopStart, cmdDesktopStop, cmdDesktopRestart, cmdDesktopStatus, cmdDesktopInstall, cmdDesktopUninstall, desktopRunning } from "./commands/desktop.js";
103
103
  import { cmdVoiceSay, cmdVoiceListen, cmdVoiceProviders } from "./commands/voice.js";
104
104
  import { cmdSkillsAdd, cmdSkillsList, cmdSkillsStatus, cmdSkillsSync, cmdSkillsIndex, cmdSkillsInspect, cmdSkillsInspector } from "./commands/skills.js";
105
105
  import { cmdIdentity } from "./commands/identity.js";
@@ -2615,6 +2615,18 @@ async function dispatch(cmd, rest) {
2615
2615
  await cmdUpdate(parseArgs(rest), VERSION);
2616
2616
  return; // skip checkForUpdate after an update
2617
2617
 
2618
+ // Refresh everything held in memory after a code change (e.g. a `git
2619
+ // pull` in a dev checkout): restart the daemon, and restart the desktop
2620
+ // too if it was running. The daemon picks up new code/prompts; the
2621
+ // desktop picks up its new renderer/main.js. Token re-sync is automatic
2622
+ // (the desktop WS re-reads daemon.token on reconnect).
2623
+ case "restart": {
2624
+ const a = parseArgs(rest);
2625
+ await cmdDaemonRestart(a);
2626
+ if (desktopRunning()) await cmdDesktopRestart(a);
2627
+ return;
2628
+ }
2629
+
2618
2630
  case "overlay":
2619
2631
  console.error(" apx overlay has been renamed to apx desktop — forwarding.");
2620
2632
  /* falls through */
@@ -2623,10 +2635,11 @@ async function dispatch(cmd, rest) {
2623
2635
  const oArgs = parseArgs(oRest);
2624
2636
  if (!sub || sub === "start") { await cmdDesktopStart(oArgs); return; }
2625
2637
  if (sub === "stop") { await cmdDesktopStop(oArgs); return; }
2638
+ if (sub === "restart") { await cmdDesktopRestart(oArgs); return; }
2626
2639
  if (sub === "status") { await cmdDesktopStatus(oArgs);return; }
2627
2640
  if (sub === "install") { await cmdDesktopInstall(oArgs); return; }
2628
2641
  if (sub === "uninstall") { await cmdDesktopUninstall(oArgs);return; }
2629
- die(`unknown desktop sub-command: ${sub}\nUsage: apx desktop <start|stop|status|install|uninstall>`);
2642
+ die(`unknown desktop sub-command: ${sub}\nUsage: apx desktop <start|stop|restart|status|install|uninstall>`);
2630
2643
  return;
2631
2644
  }
2632
2645
 
@@ -2656,7 +2669,7 @@ const [topCmd, ...topRest] = argv;
2656
2669
  // of the compact line.
2657
2670
  // Suppress everything with APX_QUIET=1 / APX_NO_BANNER=1 (see branding.js).
2658
2671
  const SELF_BRANDED = new Set([
2659
- "status", "setup", "install", "daemon", "update", "upgrade", "help",
2672
+ "status", "setup", "install", "daemon", "update", "upgrade", "help", "restart",
2660
2673
  ]);
2661
2674
  const BANNERED = new Set(["init"]);
2662
2675
 
@@ -561,10 +561,16 @@ function connectDaemon() {
561
561
  return;
562
562
  }
563
563
 
564
- const token = readToken();
565
564
  const url = `ws://${DAEMON_HOST}:${DAEMON_PORT}/desktop/ws`;
566
565
 
567
566
  function connect() {
567
+ // Re-read the token on EVERY attempt — the daemon regenerates
568
+ // ~/.apx/daemon.token on each restart, so a token captured once at
569
+ // startup goes stale the moment the daemon is restarted (e.g. after a
570
+ // pull / `apx daemon restart`) and every reconnect 401s forever. Reading
571
+ // it fresh here lets the desktop self-heal: the next retry picks up the
572
+ // new token and reconnects on its own.
573
+ const token = readToken();
568
574
  try {
569
575
  wsConn = new WS(url, {
570
576
  headers: token ? { Authorization: `Bearer ${token}` } : {},