@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.
- package/package.json +1 -1
- package/src/core/agent/run-agent.js +46 -6
- package/src/core/agent/super-agent.js +3 -0
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +123 -6
- package/src/core/channels/telegram/ask-callbacks.js +121 -6
- package/src/core/channels/telegram/dispatch.js +21 -4
- package/src/core/channels/telegram/inbound/file.js +108 -0
- package/src/core/channels/telegram/inbound/photo.js +42 -11
- package/src/core/channels/telegram/media.js +31 -3
- package/src/core/channels/telegram/reply.js +32 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +343 -63
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +10 -1
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- 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
|
-
|
|
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 = "",
|
|
248
|
-
extraMeta = {},
|
|
255
|
+
saUsage = null, saModel = null, saTrace = null, streamedCount = 0, lastStreamedText = "",
|
|
256
|
+
agentDisplay, extraMeta = {},
|
|
249
257
|
}) {
|
|
250
|
-
|
|
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",
|
package/src/core/config/paths.js
CHANGED
|
@@ -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
|
+
({ "<": "<", ">": ">", "&": "&", "'": "'", '"': """ }[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
|
+
}
|