@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.
- package/package.json +1 -1
- package/src/core/agent/constants.js +7 -1
- package/src/core/agent/retry.js +9 -0
- package/src/core/agent/run-agent.js +56 -5
- package/src/core/agent/tools/pseudo-tools.js +13 -1
- package/src/core/channels/telegram/dispatch.js +23 -3
- package/src/core/engines/mock.js +33 -10
- package/src/core/i18n/en.js +2 -4
- package/src/core/i18n/es.js +1 -4
- package/src/core/i18n/index.js +5 -1
- package/src/core/i18n/pt.js +1 -3
- package/src/core/routines/runner.js +15 -3
- package/src/host/daemon/api/admin.js +29 -0
- package/src/interfaces/cli/commands/desktop.js +26 -0
- package/src/interfaces/cli/index.js +16 -3
- package/src/interfaces/desktop/main.js +7 -1
- package/src/interfaces/web/dist/assets/index-DW7j3cXB.js +646 -0
- package/src/interfaces/web/dist/assets/index-DW7j3cXB.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-wrEbTJbc.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +188 -188
- package/src/interfaces/web/src/App.tsx +22 -11
- package/src/interfaces/web/src/components/AddProjectDialog.tsx +66 -34
- package/src/interfaces/web/src/components/ModelCombobox.tsx +6 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +28 -25
- package/src/interfaces/web/src/components/chat/ModelPicker.tsx +19 -17
- package/src/interfaces/web/src/components/deck/WidgetRow.tsx +9 -7
- package/src/interfaces/web/src/components/inputs/VarTokenInput.tsx +21 -19
- package/src/interfaces/web/src/components/layout/ProjectSidebar.tsx +3 -2
- package/src/interfaces/web/src/components/routines/AvailableVarsCard.tsx +23 -0
- package/src/interfaces/web/src/components/routines/ExecutionsList.tsx +189 -0
- package/src/interfaces/web/src/components/routines/ReadOnlyBlock.tsx +14 -0
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +86 -0
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +263 -0
- package/src/interfaces/web/src/components/routines/RoutineList.tsx +59 -0
- package/src/interfaces/web/src/components/routines/VarTextarea.tsx +70 -0
- package/src/interfaces/web/src/components/routines/shared.ts +89 -0
- package/src/interfaces/web/src/components/settings/PairDeviceDialog.tsx +19 -16
- package/src/interfaces/web/src/components/settings/TelegramContactsPanel.tsx +10 -8
- package/src/interfaces/web/src/components/settings/providers/ProviderModal.tsx +7 -4
- package/src/interfaces/web/src/components/ui/chat-input.tsx +24 -21
- package/src/interfaces/web/src/components/ui/sidebar.tsx +20 -18
- package/src/interfaces/web/src/components/ui.tsx +4 -0
- package/src/interfaces/web/src/i18n/en.ts +34 -11
- package/src/interfaces/web/src/i18n/es.ts +34 -11
- package/src/interfaces/web/src/lib/api/filesystem.ts +6 -0
- package/src/interfaces/web/src/screens/ApxAdminScreen.tsx +11 -3
- package/src/interfaces/web/src/screens/modules/DeckScreen.tsx +6 -3
- package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +8 -5
- package/src/interfaces/web/src/screens/project/McpsTab.tsx +16 -9
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +126 -373
- package/src/interfaces/web/src/styles.css +5 -0
- package/src/interfaces/web/dist/assets/index-CAKEYko0.css +0 -1
- package/src/interfaces/web/dist/assets/index-UzqHxD0B.js +0 -639
- package/src/interfaces/web/dist/assets/index-UzqHxD0B.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
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
|
package/src/core/agent/retry.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
325
|
+
system: isFinalWrapUp ? baseSystem + WRAPUP_NUDGE : baseSystem,
|
|
288
326
|
messages: conversation,
|
|
289
327
|
config: globalConfig,
|
|
290
|
-
tools
|
|
291
|
-
|
|
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
|
-
|
|
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)
|
|
546
|
-
|
|
547
|
-
|
|
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();
|
package/src/core/engines/mock.js
CHANGED
|
@@ -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
|
-
//
|
|
20
|
-
//
|
|
21
|
-
if (
|
|
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
|
|
36
|
+
id,
|
|
24
37
|
type: "function",
|
|
25
|
-
function: { name
|
|
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
|
-
|
|
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-
|
|
52
|
+
id: "mock-finish-1",
|
|
38
53
|
type: "function",
|
|
39
|
-
function: { name:
|
|
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 {
|
package/src/core/i18n/en.js
CHANGED
|
@@ -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
|
};
|
package/src/core/i18n/es.js
CHANGED
|
@@ -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
|
};
|
package/src/core/i18n/index.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/core/i18n/pt.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|
|
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}` } : {},
|