@agentprojectcontext/apx 1.39.0 → 1.40.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 (52) hide show
  1. package/package.json +1 -2
  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/web/dist/assets/index-Cg-uHCex.js +646 -0
  15. package/src/interfaces/web/dist/assets/index-Cg-uHCex.js.map +1 -0
  16. package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
  17. package/src/interfaces/web/dist/index.html +2 -2
  18. package/src/interfaces/web/package-lock.json +11 -11
  19. package/src/interfaces/web/src/App.tsx +22 -11
  20. package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
  21. package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
  22. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
  23. package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
  24. package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
  25. package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
  26. package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
  27. package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
  28. package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
  29. package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
  30. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
  31. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
  32. package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
  33. package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
  34. package/src/interfaces/web/src/components/routines/shared.ts +89 -0
  35. package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
  36. package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
  37. package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
  38. package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
  39. package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
  40. package/src/interfaces/web/src/components/ui.tsx +4 -0
  41. package/src/interfaces/web/src/i18n/en.ts +34 -11
  42. package/src/interfaces/web/src/i18n/es.ts +34 -11
  43. package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
  44. package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
  45. package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
  46. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
  47. package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
  48. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
  49. package/src/interfaces/web/src/styles.css +5 -0
  50. package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
  51. package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
  52. 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.0",
3
+ "version": "1.40.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -41,7 +41,6 @@
41
41
  "prepack": "node scripts/sync-apc-skill.js && node scripts/build-web.js",
42
42
  "postinstall": "node src/interfaces/cli/postinstall.js"
43
43
  },
44
- "packageManager": "pnpm@10.25.0",
45
44
  "dependencies": {
46
45
  "@modelcontextprotocol/sdk": "^1.29.0",
47
46
  "@opentui/core": "^0.2.16",
@@ -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()));