@agentprojectcontext/apx 1.61.0 → 1.63.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.
@@ -0,0 +1,107 @@
1
+ // Durable pending-callback store for background runtime runs.
2
+ //
3
+ // When call_runtime launches a runtime detached (background mode), the result
4
+ // must reach the originating chat when the runtime finishes — even if the
5
+ // daemon that spawned it dies in the meantime (a crash, a pull, or, as we hit
6
+ // in testing, a task whose very job is to restart the daemon). An in-memory
7
+ // promise can't survive that. So we drop a small durable "IOU" here at launch;
8
+ // a reconciler (host/daemon/callback-reconciler.js) delivers it once the
9
+ // runtime's session record shows the run finished — regardless of WHICH daemon
10
+ // is alive, and regardless of who closed the session (the daemon's own await,
11
+ // or the runtime proactively via `apx session close`).
12
+ //
13
+ // One JSON file per pending callback: <APX_HOME>/pending-callbacks/<id>.json.
14
+ // The in-process fast path deletes the file the moment it takes ownership of
15
+ // delivery; whatever files remain are runs whose spawning daemon never got to
16
+ // deliver — exactly the set the reconciler must handle.
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { APX_HOME } from "#core/config/paths.js";
20
+ import { nowIso } from "#core/util/time.js";
21
+
22
+ export const PENDING_CALLBACKS_DIR = path.join(APX_HOME, "pending-callbacks");
23
+
24
+ const SAFE_ID = /^[A-Za-z0-9._-]+$/;
25
+
26
+ function fileFor(sessionId) {
27
+ return path.join(PENDING_CALLBACKS_DIR, `${sessionId}.json`);
28
+ }
29
+
30
+ /**
31
+ * Record that a background runtime run owes a callback to a channel. `entry`:
32
+ * { session_id, session_path, channel, chat_id, tg_channel, runtime, agent, who }.
33
+ * Best-effort — a failure to persist must never break the launch.
34
+ */
35
+ export function writePendingCallback(entry) {
36
+ try {
37
+ if (!entry?.session_id || !SAFE_ID.test(String(entry.session_id))) return;
38
+ fs.mkdirSync(PENDING_CALLBACKS_DIR, { recursive: true });
39
+ fs.writeFileSync(fileFor(entry.session_id), JSON.stringify({ ...entry, created: nowIso() }, null, 2));
40
+ } catch {
41
+ /* best-effort */
42
+ }
43
+ }
44
+
45
+ /** Drop the IOU — called the instant an in-process delivery takes ownership. */
46
+ export function deletePendingCallback(sessionId) {
47
+ try {
48
+ if (!sessionId || !SAFE_ID.test(String(sessionId))) return;
49
+ fs.rmSync(fileFor(sessionId), { force: true });
50
+ } catch {
51
+ /* best-effort */
52
+ }
53
+ }
54
+
55
+ /** All outstanding IOUs (parsed). Silently skips unreadable/corrupt files. */
56
+ export function listPendingCallbacks() {
57
+ let files;
58
+ try {
59
+ files = fs.readdirSync(PENDING_CALLBACKS_DIR).filter((f) => f.endsWith(".json"));
60
+ } catch {
61
+ return [];
62
+ }
63
+ const out = [];
64
+ for (const f of files) {
65
+ try {
66
+ const entry = JSON.parse(fs.readFileSync(path.join(PENDING_CALLBACKS_DIR, f), "utf8"));
67
+ if (entry?.session_id) out.push(entry);
68
+ } catch {
69
+ /* skip corrupt */
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * Read the finished-state of a runtime session .md by parsing its frontmatter.
77
+ * Returns { exists, done, status, result, completed } — `done` is true once the
78
+ * run has a completed timestamp (set by the daemon's closeRuntimeSession OR by
79
+ * the runtime's proactive `apx session close`).
80
+ */
81
+ export function readSessionState(sessionPath) {
82
+ let text;
83
+ try {
84
+ text = fs.readFileSync(sessionPath, "utf8");
85
+ } catch {
86
+ return { exists: false, done: false };
87
+ }
88
+ const fm = {};
89
+ if (text.startsWith("---\n")) {
90
+ const end = text.indexOf("\n---", 4);
91
+ if (end !== -1) {
92
+ for (const line of text.slice(4, end).split("\n")) {
93
+ const i = line.indexOf(":");
94
+ if (i > 0) fm[line.slice(0, i).trim()] = line.slice(i + 1).trim();
95
+ }
96
+ }
97
+ }
98
+ const completed = fm.completed || "";
99
+ const status = fm.status || "";
100
+ return {
101
+ exists: true,
102
+ done: !!completed && !/in progress/i.test(status),
103
+ status,
104
+ result: fm.result || "",
105
+ completed,
106
+ };
107
+ }
@@ -0,0 +1,87 @@
1
+ // Durable delivery of background-runtime callbacks.
2
+ //
3
+ // Pairs with core/stores/runtime-callbacks.js: call_runtime drops a pending
4
+ // "IOU" when it launches a runtime detached, and deletes it the moment the
5
+ // in-process fast path delivers. Whatever IOUs survive belong to runs whose
6
+ // spawning daemon died before delivering (crash, pull, or a task that restarted
7
+ // the daemon). This reconciler — run once at boot and on an interval — delivers
8
+ // those late, keying on the runtime SESSION being finished rather than on any
9
+ // in-memory promise. That's what makes the callback survive a restart, and also
10
+ // absorbs the "proactive" close: it doesn't care whether the daemon's own await
11
+ // or the runtime's `apx session close` marked the session done.
12
+ //
13
+ // Recovery delivery is a plain channel send of the session's recorded result
14
+ // (after a restart the full stdout is gone — only the one-line result the
15
+ // session close captured remains). The rich A2A relay (Roby re-voicing the
16
+ // result) stays the job of the live in-process path.
17
+ import { listPendingCallbacks, deletePendingCallback, readSessionState } from "#core/stores/runtime-callbacks.js";
18
+
19
+ const GRACE_MS = 30_000; // let the live in-process path win a fresh completion
20
+ const STALE_MS = 24 * 60 * 60 * 1000; // drop IOUs for runs that never finished in a day
21
+
22
+ function deliverText(entry, state) {
23
+ const who = entry.who || entry.runtime || "runtime";
24
+ const result = String(state.result || "").trim();
25
+ const isError = /error|⚠️/i.test(state.status) || /^(failed|error)/i.test(result);
26
+ const head = isError
27
+ ? `⚠️ La sesión de ${who} (\`${entry.session_id}\`) terminó con error${result ? `: ${result}` : ""}.`
28
+ : `✅ Terminó la sesión de ${who} (\`${entry.session_id}\`).`;
29
+ const text = !isError && result ? `${head}\n\n${result}` : head;
30
+ return text.slice(0, 3800);
31
+ }
32
+
33
+ /** One reconciliation pass. Best-effort per entry; a failure keeps the IOU for
34
+ * the next tick rather than dropping the callback. */
35
+ export async function reconcilePendingCallbacks({ plugins, log }) {
36
+ const pending = listPendingCallbacks();
37
+ if (!pending.length) return;
38
+ const telegram = plugins?.get?.("telegram");
39
+ const now = Date.now();
40
+
41
+ for (const entry of pending) {
42
+ try {
43
+ if (entry.channel !== "telegram") continue; // only telegram delivery for now
44
+ const state = readSessionState(entry.session_path);
45
+
46
+ if (!state.exists) {
47
+ deletePendingCallback(entry.session_id); // session file gone → orphan IOU
48
+ continue;
49
+ }
50
+ if (!state.done) {
51
+ const age = now - Date.parse(entry.created || "");
52
+ if (Number.isFinite(age) && age > STALE_MS) {
53
+ log?.(`callback-reconciler: dropping stale pending ${entry.session_id} (never completed)`);
54
+ deletePendingCallback(entry.session_id);
55
+ }
56
+ continue; // still running — check again next tick
57
+ }
58
+
59
+ // Finished. Give the live in-process path a grace window to win the
60
+ // normal (no-restart) case, so we don't double-deliver.
61
+ const compAge = now - Date.parse(state.completed || "");
62
+ if (Number.isFinite(compAge) && compAge < GRACE_MS) continue;
63
+
64
+ if (!telegram) continue; // telegram plugin not up this boot — retry next tick
65
+ await telegram.send({
66
+ channel: entry.tg_channel || undefined,
67
+ chat_id: entry.chat_id,
68
+ text: deliverText(entry, state),
69
+ });
70
+ deletePendingCallback(entry.session_id);
71
+ log?.(`callback-reconciler: delivered late callback for ${entry.session_id} → chat ${entry.chat_id}`);
72
+ } catch (e) {
73
+ log?.(`callback-reconciler: delivery failed for ${entry.session_id}: ${e.message}`);
74
+ // keep the IOU; next tick retries
75
+ }
76
+ }
77
+ }
78
+
79
+ /** Start the reconciler: one pass at boot (recovers anything a prior daemon
80
+ * left behind), then every `intervalMs`. Returns { stop }. */
81
+ export function startCallbackReconciler({ plugins, log, intervalMs = 30_000 }) {
82
+ const tick = () => reconcilePendingCallbacks({ plugins, log }).catch(() => {});
83
+ tick();
84
+ const timer = setInterval(tick, intervalMs);
85
+ timer.unref?.();
86
+ return { stop: () => clearInterval(timer) };
87
+ }
@@ -19,6 +19,7 @@ import { ProjectManager } from "./db.js";
19
19
  import { McpRegistry } from "#core/mcp/runner.js";
20
20
  import { PluginManager } from "./plugins/index.js";
21
21
  import { RoutineScheduler } from "./routines-scheduler.js";
22
+ import { startCallbackReconciler } from "./callback-reconciler.js";
22
23
  import { buildApi } from "./api.js";
23
24
  import { createTokenStore } from "./token-store.js";
24
25
  import { triggerWakeup } from "./wakeup.js";
@@ -208,12 +209,17 @@ async function main() {
208
209
 
209
210
  plugins.installRoutes(app);
210
211
 
212
+ let callbackReconciler = null;
211
213
  const server = app.listen(port, host, () => {
212
214
  writePid();
213
215
  log(`apx-daemon ${PKG.version} listening on http://${host}:${port}`);
214
216
  log(`projects: ${projects.list().length} | plugins: ${Object.keys(plugins.status()).join(", ") || "(none)"}`);
215
217
  plugins.startAll();
216
218
  scheduler.start();
219
+ // Durable background-runtime callbacks: deliver any results whose spawning
220
+ // daemon died before it could (crash, pull, or a task that restarted the
221
+ // daemon). Runs once now to recover prior IOUs, then on an interval.
222
+ callbackReconciler = startCallbackReconciler({ plugins, log });
217
223
  // Cross-channel memory: ensure ~/.apx/memory.md exists, open the vector
218
224
  // store, and start the incremental RAG indexer. Best-effort — never blocks
219
225
  // boot and never throws into the daemon.
@@ -280,6 +286,7 @@ async function main() {
280
286
  function shutdown(signal) {
281
287
  log(`received ${signal}, shutting down...`);
282
288
  scheduler.stop();
289
+ callbackReconciler?.stop();
283
290
  plugins.stopAll();
284
291
  stopMemory();
285
292
  registries.shutdown();
@@ -31,6 +31,33 @@ export function resolveExecRequest(args) {
31
31
  };
32
32
  }
33
33
 
34
+ // Valid channel strings the daemon knows how to route.
35
+ const KNOWN_CHANNELS = new Set(Object.values(CHANNELS));
36
+
37
+ /**
38
+ * Resolve which channel `apx exec` should tag the turn with.
39
+ * Default: CHANNELS.CLI (unchanged behaviour).
40
+ * --code / -c → CHANNELS.CODE (coding system prompt + code tools)
41
+ * --channel <name> → explicit channel (must be a known channel string)
42
+ */
43
+ export function resolveExecChannel(args) {
44
+ const flags = args?.flags || {};
45
+ if (flags.code) return CHANNELS.CODE;
46
+
47
+ const raw = flags.channel;
48
+ if (raw && raw !== true) {
49
+ const channel = String(raw).toLowerCase();
50
+ if (!KNOWN_CHANNELS.has(channel)) {
51
+ throw new Error(
52
+ `apx exec: unknown channel "${raw}". Known channels: ${[...KNOWN_CHANNELS].join(", ")}`
53
+ );
54
+ }
55
+ return channel;
56
+ }
57
+
58
+ return CHANNELS.CLI;
59
+ }
60
+
34
61
  async function readPromptFromStdin() {
35
62
  const fs = await import("node:fs");
36
63
  if (process.stdin.isTTY) return "";
@@ -57,14 +84,14 @@ export async function cmdExec(args) {
57
84
  }
58
85
  if (!prompt) {
59
86
  throw new Error(
60
- 'apx exec: prompt is empty. Usage: apx exec "prompt" | apx exec -a <agent> "prompt" | apx exec -- "prompt"'
87
+ 'apx exec: prompt is empty. Usage: apx exec "prompt" | apx exec --code "prompt" | apx exec -a <agent> "prompt" | apx exec -- "prompt"'
61
88
  );
62
89
  }
63
90
 
64
91
  const pid = await resolveProjectId(args?.flags?.project);
65
92
  const body = {
66
93
  prompt,
67
- channel: CHANNELS.CLI,
94
+ channel: resolveExecChannel(args),
68
95
  channelMeta: { cwd: process.cwd() },
69
96
  };
70
97
  if (args.flags.model && args.flags.model !== true) body.model = args.flags.model;
@@ -1100,12 +1100,15 @@ const HELP_TOPICS = new Map(Object.entries({
1100
1100
  summary: "One-shot LLM call. Default target is the APX super-agent (daemon); use -a for an APC agent slug.",
1101
1101
  usage: [
1102
1102
  "apx exec \"<prompt>\" [--model <id>] [--project <name|id|path>]",
1103
+ "apx exec --code \"<prompt>\" (coding channel: code system prompt + git tools)",
1103
1104
  "apx exec -- \"<prompt>\"",
1104
1105
  "apx exec -a <agent> \"<prompt>\" [--model <id>]",
1105
1106
  "apx exec <agent> \"<prompt>\" (legacy positional agent)",
1106
1107
  ],
1107
1108
  options: [
1108
1109
  ["-a, --agent <slug>", "APC agent slug (omit for super-agent default)."],
1110
+ ["-c, --code", "Run on the 'code' channel (coding system prompt + code tools)."],
1111
+ ["--channel <name>", "Explicit channel (cli, code, api, …). Default cli."],
1109
1112
  ["--model <id>", "Override configured model."],
1110
1113
  ["--max-tokens N", "Output token limit."],
1111
1114
  ["--temperature T", "Sampling temperature."],
@@ -1113,6 +1116,7 @@ const HELP_TOPICS = new Map(Object.entries({
1113
1116
  ],
1114
1117
  examples: [
1115
1118
  "apx exec \"What time is it in UTC?\"",
1119
+ "apx exec --code \"refactor the auth middleware\"",
1116
1120
  "apx exec -- \"decime qué hora es\"",
1117
1121
  "apx exec -a reviewer \"Summarize your role\"",
1118
1122
  "apx exec reviewer \"Summarize your role\" --model gpt-5.2",
@@ -2090,7 +2094,7 @@ function buildHelp(version) {
2090
2094
 
2091
2095
  hSec("LLM / Code"),
2092
2096
  hCmd("apx code", 36, "APX terminal coding assistant"),
2093
- hCmd("apx exec \"prompt\"", 36, "super-agent (default) --model <id> -a <agent> for APC slug"),
2097
+ hCmd("apx exec \"prompt\"", 36, "super-agent (default) --code coding channel -a <agent> for APC slug"),
2094
2098
  hCmd("apx chat <agent>", 36, "interactive agent REPL --conversation <id>"),
2095
2099
  hCmd("apx search \"query\"", 36, "web search (ddg | brave | browser) --mode <m> -n N"),
2096
2100
  hCmd("apx conversations list", 36, "stored exec/chat conversations for <agent>"),
@@ -2241,6 +2245,11 @@ function findHelpTopic(argv) {
2241
2245
  return { global: true };
2242
2246
  }
2243
2247
 
2248
+ // Flags that never take a value. Without this the parser would greedily
2249
+ // swallow the following positional (e.g. `apx exec --code "hi"` would set
2250
+ // flags.code = "hi" and drop the prompt). Boolean flags always resolve to true.
2251
+ const BOOLEAN_FLAGS = new Set(["code", "verbose"]);
2252
+
2244
2253
  function parseArgs(argv) {
2245
2254
  const args = { _: [], flags: {} };
2246
2255
  for (let i = 0; i < argv.length; i++) {
@@ -2253,7 +2262,9 @@ function parseArgs(argv) {
2253
2262
  if (a.startsWith("--")) {
2254
2263
  const key = a.slice(2);
2255
2264
  const next = argv[i + 1];
2256
- if (next === undefined || next.startsWith("--")) {
2265
+ if (BOOLEAN_FLAGS.has(key)) {
2266
+ args.flags[key] = true;
2267
+ } else if (next === undefined || next.startsWith("--")) {
2257
2268
  args.flags[key] = true;
2258
2269
  } else {
2259
2270
  // support repeated flags (e.g. --env A=1 --env B=2)
@@ -2269,6 +2280,8 @@ function parseArgs(argv) {
2269
2280
  args.flags.n = argv[++i];
2270
2281
  } else if (a === "-a") {
2271
2282
  args.flags.agent = argv[++i];
2283
+ } else if (a === "-c") {
2284
+ args.flags.code = true;
2272
2285
  } else {
2273
2286
  args._.push(a);
2274
2287
  }