@agentprojectcontext/apx 1.78.0 → 1.79.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 (93) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +30 -5
  3. package/src/core/agent/tool-summary.js +65 -0
  4. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  5. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  6. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  7. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  8. package/src/core/agent/tools/names.js +6 -0
  9. package/src/core/agent/tools/registry.js +9 -0
  10. package/src/core/agent/tools/tool-call-parser.js +70 -1
  11. package/src/core/channels/telegram/ask-callbacks.js +35 -0
  12. package/src/core/channels/telegram/dispatch.js +3 -0
  13. package/src/core/channels/telegram/reply.js +30 -5
  14. package/src/core/config/paths.js +3 -0
  15. package/src/core/config/redact.js +22 -0
  16. package/src/core/daemon/service.js +238 -0
  17. package/src/core/engines/gemini.js +322 -60
  18. package/src/core/engines/openai-compatible.js +21 -2
  19. package/src/core/memory/consolidate.js +225 -0
  20. package/src/core/nudge/index.js +192 -0
  21. package/src/core/nudge/policy.js +143 -0
  22. package/src/core/nudge/store.js +141 -0
  23. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  24. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  25. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  26. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  27. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  28. package/src/core/routines/runner.js +102 -3
  29. package/src/core/routines/signals.js +270 -0
  30. package/src/core/stores/commitments.js +331 -0
  31. package/src/core/stores/messages.js +4 -0
  32. package/src/core/stores/routines.js +17 -3
  33. package/src/core/util/thinking.js +51 -0
  34. package/src/host/daemon/api/commitments.js +135 -0
  35. package/src/host/daemon/api/nudges.js +112 -0
  36. package/src/host/daemon/api/routines.js +24 -0
  37. package/src/host/daemon/api/self-memory.js +50 -0
  38. package/src/host/daemon/api/telegram.js +42 -4
  39. package/src/host/daemon/api/voice.js +3 -1
  40. package/src/host/daemon/api.js +6 -0
  41. package/src/host/daemon/callback-reconciler.js +16 -0
  42. package/src/host/daemon/plugins/desktop/index.js +7 -1
  43. package/src/host/daemon/plugins/telegram/index.js +7 -2
  44. package/src/host/daemon/wakeup.js +17 -3
  45. package/src/interfaces/cli/commands/commitment.js +154 -0
  46. package/src/interfaces/cli/commands/daemon.js +57 -0
  47. package/src/interfaces/cli/commands/memory.js +73 -0
  48. package/src/interfaces/cli/commands/nudge.js +130 -0
  49. package/src/interfaces/cli/help/index.js +2 -2
  50. package/src/interfaces/cli/routes/commitment.js +19 -0
  51. package/src/interfaces/cli/routes/daemon.js +7 -1
  52. package/src/interfaces/cli/routes/index.js +4 -0
  53. package/src/interfaces/cli/routes/memory.js +10 -2
  54. package/src/interfaces/cli/routes/nudge.js +17 -0
  55. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  56. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  57. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  58. package/src/interfaces/web/dist/index.html +2 -2
  59. package/src/interfaces/web/package-lock.json +11 -10
  60. package/src/interfaces/web/src/components/Section.tsx +18 -3
  61. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  62. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  63. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  64. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  65. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  66. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  67. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  68. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  69. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  70. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  71. package/src/interfaces/web/src/components/ui.tsx +1 -0
  72. package/src/interfaces/web/src/constants/index.ts +1 -0
  73. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  74. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  75. package/src/interfaces/web/src/i18n/en.ts +127 -0
  76. package/src/interfaces/web/src/i18n/es.ts +127 -0
  77. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  78. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  79. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  80. package/src/interfaces/web/src/lib/cron.ts +196 -0
  81. package/src/interfaces/web/src/lib/when.ts +32 -0
  82. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  83. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  84. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  85. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  86. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  87. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  88. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  89. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  90. package/src/interfaces/web/src/types/daemon.ts +10 -1
  91. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  92. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  93. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
@@ -12,6 +12,7 @@ import { getConfirmationStore as getConfirmStore } from "#core/confirmation/pend
12
12
  import { getRecentTelegramTurnsFromFs, appendGlobalMessage } from "#core/stores/messages.js";
13
13
  import { CHANNELS } from "#core/constants/channels.js";
14
14
  import { SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
15
+ import { applyNudgeCallback } from "#core/nudge/index.js";
15
16
 
16
17
  /**
17
18
  * Route an inbound callback_query. ask_questions button presses are handled
@@ -24,6 +25,10 @@ export async function handleCallbackQuery(self, callbackQuery) {
24
25
  await handleAskCallback(self, callbackQuery);
25
26
  return;
26
27
  }
28
+ if (data.startsWith("apx:nudge:")) {
29
+ await handleNudgeCallback(self, callbackQuery);
30
+ return;
31
+ }
27
32
  const adapter = createTelegramConfirmAdapter({
28
33
  token: resolveBotToken(self.channel),
29
34
  chatId: callbackQuery.message?.chat?.id,
@@ -35,6 +40,33 @@ export async function handleCallbackQuery(self, callbackQuery) {
35
40
  }
36
41
  }
37
42
 
43
+ /**
44
+ * "Was that worth interrupting you for?" — the feedback loop on proactive
45
+ * pushes (core/nudge). One tap, no reply, and the keyboard disappears so the
46
+ * chat does not accumulate stale buttons. Never re-enters the super-agent: an
47
+ * opinion about a message is not a new turn to answer.
48
+ */
49
+ export async function handleNudgeCallback(self, callbackQuery) {
50
+ const chatId = callbackQuery.message?.chat?.id;
51
+ const result = applyNudgeCallback(callbackQuery.data || "");
52
+ await self._answerCallback({
53
+ callback_query_id: callbackQuery.id,
54
+ text: result?.ack || "",
55
+ });
56
+ if (!result || !chatId) return;
57
+ try {
58
+ await self._editKeyboard({
59
+ chat_id: chatId,
60
+ message_id: callbackQuery.message?.message_id,
61
+ reply_markup: { inline_keyboard: [] },
62
+ });
63
+ } catch { /* best-effort */ }
64
+ self.log(
65
+ `telegram[${self.channel.name}] nudge feedback: ${result.entry?.kind || "?"} → ` +
66
+ `${result.entry?.feedback?.useful ? "useful" : "noise"}`
67
+ );
68
+ }
69
+
38
70
  /**
39
71
  * Draw the current question as a fresh message with its inline keyboard, wiping
40
72
  * the previous question's keyboard so the chat reads as a clean history.
@@ -182,6 +214,7 @@ export async function runResumedTurn(self, ctx) {
182
214
  let replyAuthor;
183
215
  let saUsage = null;
184
216
  let saModel = null;
217
+ let saTrace = null;
185
218
  try {
186
219
  const sa = await runTelegramSuperAgent(self, {
187
220
  chat_id,
@@ -218,6 +251,7 @@ export async function runResumedTurn(self, ctx) {
218
251
  replyAuthor = sa.name || agentDisplay;
219
252
  saUsage = sa.usage;
220
253
  saModel = sa.model || state.model || null;
254
+ saTrace = sa.trace || null;
221
255
  } catch (e) {
222
256
  self.log(`telegram[${self.channel.name}] ask resume failed: ${e.message}`);
223
257
  replyText = telegramErrorText(self, e);
@@ -235,6 +269,7 @@ export async function runResumedTurn(self, ctx) {
235
269
  replyKind: "superagent",
236
270
  saUsage,
237
271
  saModel,
272
+ saTrace,
238
273
  streamedCount: state.streamedCount,
239
274
  lastStreamedText: state.lastStreamedText,
240
275
  agentDisplay,
@@ -228,6 +228,7 @@ export async function handleUpdate(self, u) {
228
228
  let replyKind = "superagent"; // actor_kind: superagent | agent
229
229
  let replyModel = null; // model that actually produced the reply
230
230
  let replyUsage = null; // token accounting for this turn
231
+ let replyTrace = null; // what the turn actually did (summarised on the message)
231
232
  const projectCfg = target.config || self.globalConfig;
232
233
  // Display name for the super-agent persona on this channel (from identity.json).
233
234
  const agentDisplay = resolveAgentName(self.globalConfig);
@@ -335,6 +336,7 @@ export async function handleUpdate(self, u) {
335
336
  replyActorId = SUPERAGENT_ACTOR_ID;
336
337
  replyKind = "superagent";
337
338
  replyUsage = sa.usage;
339
+ replyTrace = sa.trace || null;
338
340
  replyModel = sa.model || state.model || null;
339
341
 
340
342
  // ── ask_questions integration ────────────────────────────────────
@@ -397,6 +399,7 @@ export async function handleUpdate(self, u) {
397
399
  replyKind,
398
400
  saUsage: replyUsage,
399
401
  saModel: replyModel,
402
+ saTrace: replyTrace,
400
403
  streamedCount,
401
404
  lastStreamedText,
402
405
  agentDisplay,
@@ -7,9 +7,10 @@
7
7
  // of truth fixes that for good.
8
8
  import { runSuperAgent } from "#core/agent/super-agent.js";
9
9
  import { TELEGRAM_TOOL_ITERS } from "#core/agent/constants.js";
10
- import { stripThinking } from "#core/util/thinking.js";
10
+ import { stripThinking, stripReasoning } from "#core/util/thinking.js";
11
11
  import { appendGlobalMessage, getRecentTelegramTurnsFromFs } from "#core/stores/messages.js";
12
12
  import { CHANNELS } from "#core/constants/channels.js";
13
+ import { summarizeToolTrace } from "#core/agent/tool-summary.js";
13
14
  import { SUPERAGENT_ACTOR_ID } from "#core/identity/index.js";
14
15
  import { createTelegramConfirmAdapter } from "#core/confirmation/adapters/telegram.js";
15
16
  import { getConfirmationStore as getConfirmStore } from "#core/confirmation/pending-store.js";
@@ -54,7 +55,9 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
54
55
  return;
55
56
  }
56
57
  if (ev.type === "assistant_text" && ev.text) {
57
- const piece = stripThinking(ev.text).trim();
58
+ // Untagged planning is suppressed mid-stream too, or the user
59
+ // watches the model think in real time.
60
+ const piece = stripReasoning(ev.text).answer.trim();
58
61
  if (!piece) return;
59
62
  await self._send({ chat_id, text: piece });
60
63
  state.lastStreamedText = piece;
@@ -183,6 +186,7 @@ export async function runFollowupTurn(self, {
183
186
  let replyAuthor;
184
187
  let saUsage = null;
185
188
  let saModel = null;
189
+ let saTrace = null;
186
190
  try {
187
191
  const sa = await runTelegramSuperAgent(self, {
188
192
  chat_id,
@@ -200,6 +204,7 @@ export async function runFollowupTurn(self, {
200
204
  replyAuthor = sa.name || agentDisplay;
201
205
  saUsage = sa.usage;
202
206
  saModel = sa.model || state.model || null;
207
+ saTrace = sa.trace || null;
203
208
  } catch (e) {
204
209
  self.log(`telegram[${self.channel.name}] a2a followup failed: ${e.message}`);
205
210
  replyText = telegramErrorText(self, e);
@@ -216,6 +221,7 @@ export async function runFollowupTurn(self, {
216
221
  replyKind: "superagent",
217
222
  saUsage,
218
223
  saModel,
224
+ saTrace,
219
225
  streamedCount: state.streamedCount,
220
226
  lastStreamedText: state.lastStreamedText,
221
227
  agentDisplay,
@@ -244,10 +250,22 @@ export function telegramErrorText(self, e) {
244
250
  */
245
251
  export async function sendFinalReply(self, {
246
252
  chat_id, update_id, replyText, replyAuthor, replyActorId, replyKind,
247
- saUsage = null, saModel = null, streamedCount = 0, lastStreamedText = "", agentDisplay,
248
- extraMeta = {},
253
+ saUsage = null, saModel = null, saTrace = null, streamedCount = 0, lastStreamedText = "",
254
+ agentDisplay, extraMeta = {},
249
255
  }) {
250
- const finalClean = replyText ? stripThinking(replyText).trim() : "";
256
+ // A model that dumps raw planning must never have it forwarded. When that
257
+ // happens the answer comes back empty and the existing never-silent fallback
258
+ // below sends a short line instead — a worse reply, but not the model's notes.
259
+ const stripped = replyText ? stripReasoning(replyText) : { answer: "", leaked: false };
260
+ const finalClean = stripped.answer.trim();
261
+ if (stripped.leaked) {
262
+ // eslint-disable-next-line no-console
263
+ console.warn(
264
+ `[apx] telegram: suppressed an untagged reasoning dump from ${saModel || "the model"} ` +
265
+ `(${replyText.length} chars). Check the model chain — a router that returns raw ` +
266
+ `chain-of-thought is not usable on a user-facing channel.`
267
+ );
268
+ }
251
269
  let toSend = "";
252
270
  if (finalClean && finalClean !== lastStreamedText) {
253
271
  toSend = finalClean;
@@ -265,8 +283,15 @@ export async function sendFinalReply(self, {
265
283
  await self._send({ chat_id, text: toSend });
266
284
  const meta = { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, final: true, ...extraMeta };
267
285
  if (replyText && stripThinking(replyText) !== replyText) meta.thinking_stripped = true;
286
+ if (stripped.leaked) meta.reasoning_leak_suppressed = true;
268
287
  if (saUsage) meta.usage = saUsage;
269
288
  if (saModel) meta.model = saModel;
289
+ // A COMPACT summary, not the trace: the full one carries args and results
290
+ // and would bloat the day-file for a detail nobody reads back. What is
291
+ // worth recovering later is "it read three files and sent a message", and
292
+ // whether any of it failed.
293
+ const toolSummary = summarizeToolTrace(saTrace);
294
+ if (toolSummary) meta.tool_summary = toolSummary;
270
295
  appendGlobalMessage({
271
296
  channel: CHANNELS.TELEGRAM,
272
297
  direction: "out",
@@ -55,6 +55,9 @@ export const SKILLS_INDEX_PATH = path.join(SKILLS_DIR, ".index.json");
55
55
  /** Agent vault — reusable agent definitions, not tied to one project. */
56
56
  export const AGENT_VAULT_DIR = path.join(APX_HOME, "agents");
57
57
 
58
+ /** Ledger of unrequested outbound messages and the user's feedback on them. */
59
+ export const NUDGES_PATH = path.join(APX_HOME, "nudges.json");
60
+
58
61
  /** Unified log tree. Everything writes here so one tail follows the system. */
59
62
  export const LOG_DIR = path.join(APX_HOME, "logs");
60
63
  export const APX_LOG_PATH = path.join(LOG_DIR, "apx.log");
@@ -25,6 +25,11 @@ export const SECRET_PATHS = [
25
25
  // Telegram bot tokens live inside an array — handled separately in redact()
26
26
  // because dotted paths can't address array entries.
27
27
  "telegram.channels.*.bot_token",
28
+ // Same problem: engines.gemini.api_keys is a LIST of spare keys (quota
29
+ // rotation). A dotted path cannot reach into it, so redact() handles it
30
+ // below. Listed here so the "which keys are secrets" question still has one
31
+ // answer to read.
32
+ "engines.gemini.api_keys.*",
28
33
  ];
29
34
 
30
35
  /** Replace a secret string with the visible marker, preserving the last 5 chars. */
@@ -64,6 +69,12 @@ export function redactConfig(cfg) {
64
69
  }
65
70
  }
66
71
  }
72
+ // Spare Gemini keys. Missing this would have served every one of them in
73
+ // clear text to anyone who opened Settings → Engines.
74
+ const spareKeys = out?.engines?.gemini?.api_keys;
75
+ if (Array.isArray(spareKeys)) {
76
+ out.engines.gemini.api_keys = spareKeys.map(mark);
77
+ }
67
78
  return out;
68
79
  }
69
80
 
@@ -108,6 +119,17 @@ export function mergeRedactedSecrets(next, prior) {
108
119
  if (Array.isArray(nextChannels)) {
109
120
  next.telegram.channels = mergeRedactedChannels(nextChannels, prior?.telegram?.channels);
110
121
  }
122
+ // Spare Gemini keys. Without this, opening Settings → Engines and pressing
123
+ // Save would write the redaction MARKERS over the real keys and silently
124
+ // empty the rotation pool. Matched by position, which is the only identity
125
+ // an entry in this list has.
126
+ const nextSpare = next?.engines?.gemini?.api_keys;
127
+ if (Array.isArray(nextSpare)) {
128
+ const priorSpare = Array.isArray(prior?.engines?.gemini?.api_keys)
129
+ ? prior.engines.gemini.api_keys : [];
130
+ next.engines.gemini.api_keys = nextSpare.map((v, i) =>
131
+ isSecretMarker(v) && typeof priorSpare[i] === "string" && priorSpare[i] ? priorSpare[i] : v);
132
+ }
111
133
  return next;
112
134
  }
113
135
 
@@ -0,0 +1,238 @@
1
+ // Run the daemon as a real, self-healing service.
2
+ //
3
+ // Today the daemon starts transitively — some CLI command calls ensureDaemon()
4
+ // and one appears. If it dies at 3am nothing brings it back, so the routines
5
+ // that were meant to run at 8:30 do not, and nobody finds out until they
6
+ // wonder why the morning message never arrived. A scheduler you cannot rely on
7
+ // to be running is not a scheduler.
8
+ //
9
+ // Per-user, never sudo, fully reversible. Same shape as core/desktop/autostart.js,
10
+ // which this deliberately mirrors rather than reinvents:
11
+ //
12
+ // macOS → ~/Library/LaunchAgents/dev.apx.daemon.plist (KeepAlive true)
13
+ // linux → ~/.config/systemd/user/apx-daemon.service (Restart=always)
14
+ // win32 → HKCU\…\Run\APXDaemon (see the caveat below)
15
+ //
16
+ // OPT-IN, always. Installing a system service without being asked is the kind
17
+ // of thing that makes people uninstall software, and APX works perfectly well
18
+ // without it.
19
+ //
20
+ // Functions return { ok, … } and never throw or exit, so the CLI and the HTTP
21
+ // layer can both render the result.
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+ import { execFileSync } from "node:child_process";
26
+ import { fileURLToPath } from "node:url";
27
+ import { LOG_DIR } from "#core/config/paths.js";
28
+ import { augmentedPath } from "#core/util/path-env.js";
29
+
30
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
31
+
32
+ export const SERVICE_LABEL = "dev.apx.daemon";
33
+ export const MAC_PLIST_PATH = path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`);
34
+ export const LINUX_UNIT_PATH = path.join(os.homedir(), ".config", "systemd", "user", "apx-daemon.service");
35
+ export const WIN_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
36
+ export const WIN_RUN_NAME = "APXDaemon";
37
+
38
+ export const SERVICE_LOG_PATH = path.join(LOG_DIR, "daemon-service.log");
39
+
40
+ /**
41
+ * [bin, ...args] the supervisor should run.
42
+ *
43
+ * TWO things this must NOT be, both of which look right and are not:
44
+ *
45
+ * 1. The `apx` shim. npm/pnpm shims are shell scripts that `exec node`, and a
46
+ * launchd/systemd environment has a minimal PATH with no nvm and often no
47
+ * /usr/local/bin, so they fail at boot with "node: not found". That exact
48
+ * mistake already cost a day of silent voice failures.
49
+ *
50
+ * 2. `apx daemon start`. That command SPAWNS THE DAEMON DETACHED AND EXITS
51
+ * (cli/http.js autoStart). Under KeepAlive the supervisor would see its
52
+ * child exit within a second, restart it, and loop forever — spawning a new
53
+ * daemon each time and never noticing the one already running. A supervised
54
+ * process must be the daemon itself, in the foreground.
55
+ */
56
+ export function getDaemonRunner() {
57
+ const entry = path.resolve(__dirname, "..", "..", "host", "daemon", "index.js");
58
+ return [process.execPath, entry];
59
+ }
60
+
61
+ function escapeXml(s) {
62
+ return String(s).replace(/[<>&'"]/g, (c) =>
63
+ ({ "<": "&lt;", ">": "&gt;", "&": "&amp;", "'": "&apos;", '"': "&quot;" }[c]));
64
+ }
65
+
66
+ /**
67
+ * launchd plist with KeepAlive TRUE — the whole point. The desktop's plist sets
68
+ * it false because a window the user closed should stay closed; a daemon that
69
+ * exits should come back.
70
+ */
71
+ export function buildDaemonPlist(runner = getDaemonRunner(), logFile = SERVICE_LOG_PATH) {
72
+ const args = [...runner];
73
+ const argsXml = args.map((a) => ` <string>${escapeXml(a)}</string>`).join("\n");
74
+ return `<?xml version="1.0" encoding="UTF-8"?>
75
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
76
+ <plist version="1.0">
77
+ <dict>
78
+ <key>Label</key><string>${SERVICE_LABEL}</string>
79
+ <key>ProgramArguments</key>
80
+ <array>
81
+ ${argsXml}
82
+ </array>
83
+ <key>EnvironmentVariables</key>
84
+ <dict>
85
+ <key>PATH</key><string>${escapeXml(augmentedPath())}</string>
86
+ </dict>
87
+ <key>RunAtLoad</key><true/>
88
+ <key>KeepAlive</key><true/>
89
+ <key>ThrottleInterval</key><integer>10</integer>
90
+ <key>ProcessType</key><string>Background</string>
91
+ <key>StandardOutPath</key><string>${escapeXml(logFile)}</string>
92
+ <key>StandardErrorPath</key><string>${escapeXml(logFile)}</string>
93
+ </dict>
94
+ </plist>
95
+ `;
96
+ }
97
+
98
+ /** systemd --user unit. `default.target`, not `multi-user.target`: this is a
99
+ * per-user service that should start with the user's session, not at boot. */
100
+ export function buildSystemdUnit(runner = getDaemonRunner()) {
101
+ const exec = [...runner]
102
+ .map((a) => (/\s/.test(a) ? `"${a}"` : a))
103
+ .join(" ");
104
+ return `[Unit]
105
+ Description=APX daemon
106
+ After=network-online.target
107
+
108
+ [Service]
109
+ Type=simple
110
+ ExecStart=${exec}
111
+ Environment=PATH=${augmentedPath()}
112
+ Restart=always
113
+ RestartSec=10
114
+ # Give up only if it is crash-looping, so a genuinely broken install does not
115
+ # spin forever writing to the log.
116
+ StartLimitBurst=5
117
+ StartLimitIntervalSec=120
118
+
119
+ [Install]
120
+ WantedBy=default.target
121
+ `;
122
+ }
123
+
124
+ /** @returns {{installed: boolean, path?: string, platform: string, supervised: boolean, note?: string}} */
125
+ export function serviceStatus(platform = process.platform) {
126
+ if (platform === "darwin") {
127
+ return {
128
+ platform, supervised: true, installed: fs.existsSync(MAC_PLIST_PATH),
129
+ path: MAC_PLIST_PATH,
130
+ };
131
+ }
132
+ if (platform === "linux") {
133
+ return {
134
+ platform, supervised: true, installed: fs.existsSync(LINUX_UNIT_PATH),
135
+ path: LINUX_UNIT_PATH,
136
+ };
137
+ }
138
+ if (platform === "win32") {
139
+ let installed = false;
140
+ try {
141
+ const out = execFileSync("reg", ["query", WIN_RUN_KEY, "/v", WIN_RUN_NAME], {
142
+ stdio: ["ignore", "pipe", "ignore"],
143
+ }).toString();
144
+ installed = new RegExp(WIN_RUN_NAME).test(out);
145
+ } catch { /* not present */ }
146
+ return {
147
+ platform, installed, supervised: false,
148
+ path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}`,
149
+ // Said plainly rather than implied. A Run key starts the daemon at login
150
+ // and does NOT restart it if it dies — which is most of what a service is
151
+ // for. Promising self-healing here would be a lie the user only discovers
152
+ // the morning nothing ran.
153
+ note: "starts at login, but does NOT restart the daemon if it dies — " +
154
+ "Windows needs a real service wrapper (nssm/sc.exe) for that, which APX does not ship yet",
155
+ };
156
+ }
157
+ return { platform, installed: false, supervised: false, note: `not supported on ${platform}` };
158
+ }
159
+
160
+ export function installService(platform = process.platform) {
161
+ const runner = getDaemonRunner();
162
+
163
+ if (platform === "darwin") {
164
+ try {
165
+ fs.mkdirSync(path.dirname(MAC_PLIST_PATH), { recursive: true });
166
+ fs.mkdirSync(LOG_DIR, { recursive: true });
167
+ fs.writeFileSync(MAC_PLIST_PATH, buildDaemonPlist(runner), "utf8");
168
+ try { execFileSync("launchctl", ["unload", MAC_PLIST_PATH], { stdio: "ignore" }); } catch { /* not loaded */ }
169
+ execFileSync("launchctl", ["load", "-w", MAC_PLIST_PATH], { stdio: "ignore" });
170
+ return { ok: true, path: MAC_PLIST_PATH, supervised: true, log: SERVICE_LOG_PATH };
171
+ } catch (e) { return { ok: false, error: e.message }; }
172
+ }
173
+
174
+ if (platform === "linux") {
175
+ try {
176
+ fs.mkdirSync(path.dirname(LINUX_UNIT_PATH), { recursive: true });
177
+ fs.writeFileSync(LINUX_UNIT_PATH, buildSystemdUnit(runner), "utf8");
178
+ execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" });
179
+ execFileSync("systemctl", ["--user", "enable", "--now", "apx-daemon.service"], { stdio: "ignore" });
180
+ return {
181
+ ok: true, path: LINUX_UNIT_PATH, supervised: true,
182
+ // Without lingering, systemd --user stops when the last session ends,
183
+ // so a headless box would silently lose the daemon on logout.
184
+ note: "for a machine you are not logged into, run: loginctl enable-linger $USER",
185
+ };
186
+ } catch (e) { return { ok: false, error: e.message }; }
187
+ }
188
+
189
+ if (platform === "win32") {
190
+ const cmdline = [...runner]
191
+ .map((s) => `"${String(s).replace(/"/g, '\\"')}"`).join(" ");
192
+ try {
193
+ execFileSync("reg", ["add", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/t", "REG_SZ", "/d", cmdline, "/f"],
194
+ { stdio: "ignore" });
195
+ return {
196
+ ok: true, path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}`, supervised: false,
197
+ note: serviceStatus("win32").note,
198
+ };
199
+ } catch (e) { return { ok: false, error: e.message }; }
200
+ }
201
+
202
+ return { ok: false, error: `service installation not supported on platform: ${platform}` };
203
+ }
204
+
205
+ /** Idempotent: uninstalling something that was never installed is not an error. */
206
+ export function uninstallService(platform = process.platform) {
207
+ if (platform === "darwin") {
208
+ if (!fs.existsSync(MAC_PLIST_PATH)) return { ok: true, removed: false };
209
+ try {
210
+ try { execFileSync("launchctl", ["unload", "-w", MAC_PLIST_PATH], { stdio: "ignore" }); } catch { /* not loaded */ }
211
+ fs.unlinkSync(MAC_PLIST_PATH);
212
+ return { ok: true, removed: true, path: MAC_PLIST_PATH };
213
+ } catch (e) { return { ok: false, error: e.message }; }
214
+ }
215
+
216
+ if (platform === "linux") {
217
+ if (!fs.existsSync(LINUX_UNIT_PATH)) return { ok: true, removed: false };
218
+ try {
219
+ try {
220
+ execFileSync("systemctl", ["--user", "disable", "--now", "apx-daemon.service"], { stdio: "ignore" });
221
+ } catch { /* already stopped */ }
222
+ fs.unlinkSync(LINUX_UNIT_PATH);
223
+ try { execFileSync("systemctl", ["--user", "daemon-reload"], { stdio: "ignore" }); } catch { /* best effort */ }
224
+ return { ok: true, removed: true, path: LINUX_UNIT_PATH };
225
+ } catch (e) { return { ok: false, error: e.message }; }
226
+ }
227
+
228
+ if (platform === "win32") {
229
+ try {
230
+ execFileSync("reg", ["delete", WIN_RUN_KEY, "/v", WIN_RUN_NAME, "/f"], { stdio: "ignore" });
231
+ return { ok: true, removed: true, path: `${WIN_RUN_KEY}\\${WIN_RUN_NAME}` };
232
+ } catch {
233
+ return { ok: true, removed: false };
234
+ }
235
+ }
236
+
237
+ return { ok: false, error: `service removal not supported on platform: ${platform}` };
238
+ }