@agentprojectcontext/apx 1.78.0 → 1.80.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 (97) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +46 -6
  3. package/src/core/agent/super-agent.js +3 -0
  4. package/src/core/agent/tool-summary.js +65 -0
  5. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  6. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  7. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  8. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  9. package/src/core/agent/tools/names.js +6 -0
  10. package/src/core/agent/tools/registry.js +9 -0
  11. package/src/core/agent/tools/tool-call-parser.js +123 -6
  12. package/src/core/channels/telegram/ask-callbacks.js +121 -6
  13. package/src/core/channels/telegram/dispatch.js +21 -4
  14. package/src/core/channels/telegram/inbound/file.js +108 -0
  15. package/src/core/channels/telegram/inbound/photo.js +42 -11
  16. package/src/core/channels/telegram/media.js +31 -3
  17. package/src/core/channels/telegram/reply.js +32 -5
  18. package/src/core/config/paths.js +3 -0
  19. package/src/core/config/redact.js +22 -0
  20. package/src/core/daemon/service.js +238 -0
  21. package/src/core/engines/gemini.js +343 -63
  22. package/src/core/engines/openai-compatible.js +21 -2
  23. package/src/core/memory/consolidate.js +225 -0
  24. package/src/core/nudge/index.js +192 -0
  25. package/src/core/nudge/policy.js +143 -0
  26. package/src/core/nudge/store.js +141 -0
  27. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  28. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  29. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  30. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  31. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  32. package/src/core/routines/runner.js +102 -3
  33. package/src/core/routines/signals.js +270 -0
  34. package/src/core/stores/commitments.js +331 -0
  35. package/src/core/stores/messages.js +10 -1
  36. package/src/core/stores/routines.js +17 -3
  37. package/src/core/util/thinking.js +51 -0
  38. package/src/host/daemon/api/commitments.js +135 -0
  39. package/src/host/daemon/api/nudges.js +112 -0
  40. package/src/host/daemon/api/routines.js +24 -0
  41. package/src/host/daemon/api/self-memory.js +50 -0
  42. package/src/host/daemon/api/telegram.js +42 -4
  43. package/src/host/daemon/api/voice.js +3 -1
  44. package/src/host/daemon/api.js +6 -0
  45. package/src/host/daemon/callback-reconciler.js +16 -0
  46. package/src/host/daemon/plugins/desktop/index.js +7 -1
  47. package/src/host/daemon/plugins/telegram/index.js +7 -2
  48. package/src/host/daemon/wakeup.js +17 -3
  49. package/src/interfaces/cli/commands/commitment.js +154 -0
  50. package/src/interfaces/cli/commands/daemon.js +57 -0
  51. package/src/interfaces/cli/commands/memory.js +73 -0
  52. package/src/interfaces/cli/commands/nudge.js +130 -0
  53. package/src/interfaces/cli/help/index.js +2 -2
  54. package/src/interfaces/cli/routes/commitment.js +19 -0
  55. package/src/interfaces/cli/routes/daemon.js +7 -1
  56. package/src/interfaces/cli/routes/index.js +4 -0
  57. package/src/interfaces/cli/routes/memory.js +10 -2
  58. package/src/interfaces/cli/routes/nudge.js +17 -0
  59. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  60. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  61. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  62. package/src/interfaces/web/dist/index.html +2 -2
  63. package/src/interfaces/web/package-lock.json +11 -10
  64. package/src/interfaces/web/src/components/Section.tsx +18 -3
  65. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  66. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  67. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  68. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  69. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  70. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  71. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  72. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  73. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  74. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  75. package/src/interfaces/web/src/components/ui.tsx +1 -0
  76. package/src/interfaces/web/src/constants/index.ts +1 -0
  77. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  78. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  79. package/src/interfaces/web/src/i18n/en.ts +127 -0
  80. package/src/interfaces/web/src/i18n/es.ts +127 -0
  81. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  82. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  83. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  84. package/src/interfaces/web/src/lib/cron.ts +196 -0
  85. package/src/interfaces/web/src/lib/when.ts +32 -0
  86. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  87. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  88. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  89. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  90. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  91. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  92. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  93. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  94. package/src/interfaces/web/src/types/daemon.ts +10 -1
  95. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  96. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  97. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
@@ -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;
@@ -108,6 +111,7 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
108
111
  export function runTelegramSuperAgent(self, {
109
112
  chat_id, prompt, previousMessages, target, author, authorId, relationshipBlock,
110
113
  allowedTools, contextNote, signal, onEvent, backgroundResultSink = null,
114
+ attachments = [],
111
115
  }) {
112
116
  const confirmAdapter = createTelegramConfirmAdapter({
113
117
  token: resolveBotToken(self.channel),
@@ -123,6 +127,7 @@ export function runTelegramSuperAgent(self, {
123
127
  registries: self.registries,
124
128
  prompt,
125
129
  previousMessages,
130
+ attachments,
126
131
  channel: CHANNELS.TELEGRAM,
127
132
  relationshipBlock,
128
133
  allowedTools,
@@ -183,6 +188,7 @@ export async function runFollowupTurn(self, {
183
188
  let replyAuthor;
184
189
  let saUsage = null;
185
190
  let saModel = null;
191
+ let saTrace = null;
186
192
  try {
187
193
  const sa = await runTelegramSuperAgent(self, {
188
194
  chat_id,
@@ -200,6 +206,7 @@ export async function runFollowupTurn(self, {
200
206
  replyAuthor = sa.name || agentDisplay;
201
207
  saUsage = sa.usage;
202
208
  saModel = sa.model || state.model || null;
209
+ saTrace = sa.trace || null;
203
210
  } catch (e) {
204
211
  self.log(`telegram[${self.channel.name}] a2a followup failed: ${e.message}`);
205
212
  replyText = telegramErrorText(self, e);
@@ -216,6 +223,7 @@ export async function runFollowupTurn(self, {
216
223
  replyKind: "superagent",
217
224
  saUsage,
218
225
  saModel,
226
+ saTrace,
219
227
  streamedCount: state.streamedCount,
220
228
  lastStreamedText: state.lastStreamedText,
221
229
  agentDisplay,
@@ -244,10 +252,22 @@ export function telegramErrorText(self, e) {
244
252
  */
245
253
  export async function sendFinalReply(self, {
246
254
  chat_id, update_id, replyText, replyAuthor, replyActorId, replyKind,
247
- saUsage = null, saModel = null, streamedCount = 0, lastStreamedText = "", agentDisplay,
248
- extraMeta = {},
255
+ saUsage = null, saModel = null, saTrace = null, streamedCount = 0, lastStreamedText = "",
256
+ agentDisplay, extraMeta = {},
249
257
  }) {
250
- const finalClean = replyText ? stripThinking(replyText).trim() : "";
258
+ // A model that dumps raw planning must never have it forwarded. When that
259
+ // happens the answer comes back empty and the existing never-silent fallback
260
+ // below sends a short line instead — a worse reply, but not the model's notes.
261
+ const stripped = replyText ? stripReasoning(replyText) : { answer: "", leaked: false };
262
+ const finalClean = stripped.answer.trim();
263
+ if (stripped.leaked) {
264
+ // eslint-disable-next-line no-console
265
+ console.warn(
266
+ `[apx] telegram: suppressed an untagged reasoning dump from ${saModel || "the model"} ` +
267
+ `(${replyText.length} chars). Check the model chain — a router that returns raw ` +
268
+ `chain-of-thought is not usable on a user-facing channel.`
269
+ );
270
+ }
251
271
  let toSend = "";
252
272
  if (finalClean && finalClean !== lastStreamedText) {
253
273
  toSend = finalClean;
@@ -265,8 +285,15 @@ export async function sendFinalReply(self, {
265
285
  await self._send({ chat_id, text: toSend });
266
286
  const meta = { chat_id, tg_channel: self.channel.name, in_reply_to: update_id, final: true, ...extraMeta };
267
287
  if (replyText && stripThinking(replyText) !== replyText) meta.thinking_stripped = true;
288
+ if (stripped.leaked) meta.reasoning_leak_suppressed = true;
268
289
  if (saUsage) meta.usage = saUsage;
269
290
  if (saModel) meta.model = saModel;
291
+ // A COMPACT summary, not the trace: the full one carries args and results
292
+ // and would bloat the day-file for a detail nobody reads back. What is
293
+ // worth recovering later is "it read three files and sent a message", and
294
+ // whether any of it failed.
295
+ const toolSummary = summarizeToolTrace(saTrace);
296
+ if (toolSummary) meta.tool_summary = toolSummary;
270
297
  appendGlobalMessage({
271
298
  channel: CHANNELS.TELEGRAM,
272
299
  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
+ }