@50arms/platform-mcp 0.0.0-stage → 0.1.436

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 (75) hide show
  1. package/README.md +114 -2
  2. package/dist/access-request.js +101 -0
  3. package/dist/agent-gate.js +69 -0
  4. package/dist/board-id.js +27 -0
  5. package/dist/bounded-retry.js +25 -0
  6. package/dist/checkout-docs-root.js +54 -0
  7. package/dist/clear-control.js +77 -0
  8. package/dist/compaction-control.js +142 -0
  9. package/dist/credential.js +104 -0
  10. package/dist/danxbot-api-examples.js +97 -0
  11. package/dist/dashboard-identity.js +14 -0
  12. package/dist/design-screenshot.js +138 -0
  13. package/dist/digest-hold.js +67 -0
  14. package/dist/docs-tools.js +140 -0
  15. package/dist/durable-write.js +56 -0
  16. package/dist/entrypoint.js +35 -0
  17. package/dist/forbidden-hint.js +23 -0
  18. package/dist/guarded-calls.js +21 -0
  19. package/dist/handlers.js +385 -0
  20. package/dist/heartbeat-reporter.js +156 -0
  21. package/dist/http-client.js +190 -0
  22. package/dist/index.js +1128 -0
  23. package/dist/json-body.js +12 -0
  24. package/dist/key-revoked-halt.js +30 -0
  25. package/dist/lifecycle.js +113 -0
  26. package/dist/listen.js +999 -0
  27. package/dist/listener-reminder.js +119 -0
  28. package/dist/machine-daemon/autostart.js +160 -0
  29. package/dist/machine-daemon/beat.js +77 -0
  30. package/dist/machine-daemon/cli.js +42 -0
  31. package/dist/machine-daemon/install.js +168 -0
  32. package/dist/machine-daemon/state.js +64 -0
  33. package/dist/machine-daemon/version.js +13 -0
  34. package/dist/machine-daemon.js +553 -0
  35. package/dist/machine-toolkit.js +460 -0
  36. package/dist/mini-app-build.js +159 -0
  37. package/dist/mini-app-checkout.js +215 -0
  38. package/dist/mini-app-output.js +111 -0
  39. package/dist/mini-app-publish.js +154 -0
  40. package/dist/mini-app-restore.js +175 -0
  41. package/dist/one-line.js +25 -0
  42. package/dist/pacing-relay.js +16 -0
  43. package/dist/permission-request.js +134 -0
  44. package/dist/permission-watch.js +145 -0
  45. package/dist/plan-events-relay.js +572 -0
  46. package/dist/plan-events-tool.js +9 -0
  47. package/dist/plugin-publish.js +38 -0
  48. package/dist/plugin-screenshot.js +81 -0
  49. package/dist/request-bound.js +21 -0
  50. package/dist/restart-notice.js +271 -0
  51. package/dist/result-limit.js +31 -0
  52. package/dist/role-instruction-file.js +36 -0
  53. package/dist/route-board-scope.generated.js +93 -0
  54. package/dist/route-board-scope.js +71 -0
  55. package/dist/session-access.js +322 -0
  56. package/dist/session-connection.js +264 -0
  57. package/dist/session-control-file.js +58 -0
  58. package/dist/session-key.js +125 -0
  59. package/dist/session-options.js +60 -0
  60. package/dist/session-stats.js +235 -0
  61. package/dist/session-stream.js +345 -0
  62. package/dist/session-title.js +129 -0
  63. package/dist/shutdown.js +38 -0
  64. package/dist/stop-fixes.js +50 -0
  65. package/dist/subagents-live.js +162 -0
  66. package/dist/terms-halt.js +39 -0
  67. package/dist/text-limit.js +24 -0
  68. package/dist/tool-access.js +114 -0
  69. package/dist/tool-defs.js +58 -0
  70. package/dist/tool-surface.js +59 -0
  71. package/dist/transcript-lineage.js +105 -0
  72. package/dist/types.js +45 -0
  73. package/dist/urgent-records.js +47 -0
  74. package/dist/version-floor.js +146 -0
  75. package/package.json +51 -4
@@ -0,0 +1,119 @@
1
+ import { readSessionConnection } from "./session-connection.js";
2
+ /** DX-3997 — how long one health read serves every response; per server process. */
3
+ export const HEALTH_CACHE_WINDOW_MS = 30_000;
4
+ function isReminder(value) {
5
+ const r = value;
6
+ return typeof r === "object" && r !== null && typeof r.key === "string" && typeof r.text === "string";
7
+ }
8
+ /** The reminders the health response carries, or a thrown Error naming why it cannot be read. */
9
+ function readReminders(result) {
10
+ if (!result.ok)
11
+ throw new Error(`GET /api/plan-sessions/me/listener-health answered ${result.status}`);
12
+ const body = result.body;
13
+ if (typeof body !== "object" || body === null || Array.isArray(body)) {
14
+ throw new Error("GET /api/plan-sessions/me/listener-health did not answer a JSON object");
15
+ }
16
+ const reminders = body.reminders;
17
+ if (reminders === undefined)
18
+ return [];
19
+ if (!Array.isArray(reminders) || !reminders.every(isReminder)) {
20
+ throw new Error("GET /api/plan-sessions/me/listener-health carried a `reminders` field that is not an array of {key, text} strings");
21
+ }
22
+ return reminders.map(({ key, text }) => ({ key, text }));
23
+ }
24
+ export function createListenerReminder(deps) {
25
+ let cached = null;
26
+ let inFlight = null;
27
+ // Bumped by `invalidate()`: a read that started under an older value must not refill the cache.
28
+ let generation = 0;
29
+ let failureLogged = false;
30
+ let shapeLogged = false;
31
+ const logFailure = (reason) => {
32
+ if (failureLogged)
33
+ return;
34
+ failureLogged = true;
35
+ deps.logLine(`[platform-mcp] plan listener health check failed (${reason}); nothing relayed`);
36
+ };
37
+ async function read() {
38
+ try {
39
+ const reminders = readReminders(await deps.fetchHealth());
40
+ failureLogged = false;
41
+ return reminders;
42
+ }
43
+ catch (err) {
44
+ logFailure(err.message);
45
+ return [];
46
+ }
47
+ }
48
+ async function remindersNow() {
49
+ if (cached !== null && deps.now() - cached.at < HEALTH_CACHE_WINDOW_MS)
50
+ return cached.reminders;
51
+ // One read serves every caller that arrives while it is outstanding.
52
+ if (inFlight !== null)
53
+ return inFlight;
54
+ const startedUnder = generation;
55
+ const started = read()
56
+ .then((reminders) => {
57
+ if (generation === startedUnder)
58
+ cached = { reminders, at: deps.now() };
59
+ return reminders;
60
+ })
61
+ .finally(() => {
62
+ if (inFlight === started)
63
+ inFlight = null;
64
+ });
65
+ inFlight = started;
66
+ return started;
67
+ }
68
+ return {
69
+ async apply(result) {
70
+ const sessionId = deps.sessionId;
71
+ if (sessionId === undefined || sessionId === "")
72
+ return result;
73
+ const body = result.body;
74
+ // Never wrap or alter a body that is not a JSON object.
75
+ if (typeof body !== "object" || body === null || Array.isArray(body))
76
+ return result;
77
+ // Read per call (a file read): a connect made after this process started takes effect on
78
+ // the next call, and an unconnected session costs zero health reads.
79
+ let connected;
80
+ try {
81
+ connected = deps.isPlanConnected(sessionId);
82
+ }
83
+ catch (err) {
84
+ logFailure(err.message);
85
+ return result;
86
+ }
87
+ if (!connected)
88
+ return result;
89
+ const existing = body.reminders;
90
+ if (existing !== undefined && !Array.isArray(existing)) {
91
+ if (!shapeLogged) {
92
+ shapeLogged = true;
93
+ deps.logLine("[platform-mcp] listener reminder not relayed: the response's own `reminders` is not an array");
94
+ }
95
+ return result;
96
+ }
97
+ const relayed = await remindersNow();
98
+ const present = new Set((existing ?? []).map((r) => (isReminder(r) ? r.key : null)));
99
+ const fresh = relayed.filter((r) => {
100
+ if (present.has(r.key))
101
+ return false;
102
+ present.add(r.key);
103
+ return true;
104
+ });
105
+ if (fresh.length === 0)
106
+ return result;
107
+ return { ...result, body: { ...body, reminders: [...(existing ?? []), ...fresh] } };
108
+ },
109
+ invalidate() {
110
+ generation += 1;
111
+ cached = null;
112
+ inFlight = null;
113
+ },
114
+ };
115
+ }
116
+ /** The production wiring: the real local record. */
117
+ export function isPlanConnectedOnThisMachine(sessionId) {
118
+ return readSessionConnection(sessionId) !== null;
119
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * DX-5902 — the start-at-logon entry for the machine daemon, and what restarts it when it dies.
3
+ *
4
+ * Windows: a per-user logon task (no elevation). It runs through `conhost.exe --headless`, so no console window opens
5
+ * (PLAN-57 R-4: a console program started with no console of its own would open one). It starts at logon, and a
6
+ * time trigger repeating every minute (with `IgnoreNew` keeping one instance) brings a killed or crashed daemon
7
+ * back within a minute. Measured on Windows 11 (DX-5902): `RestartOnFailure` retries only a failed launch, never a
8
+ * process that exited non-zero; `conhost --headless` exits 0 whatever its child returned; and a repetition on the
9
+ * logon trigger runs only after a logon, never after an install mid-session.
10
+ * Linux: a systemd user unit with `Restart=on-failure`. DX-6537: an install restarts it, because `enable --now` leaves a running
11
+ * unit on its old bundle.
12
+ *
13
+ * A daemon whose bearer the dashboard refused stops on purpose and disables its entry (`disableAutostart`), so neither the
14
+ * repeating trigger nor the next logon starts it into the same refusal; `install-daemon` enables it again.
15
+ *
16
+ * Every child process is started with `windowsHide: true`. The XML and unit text are pure functions so the tests pin them.
17
+ * The task XML is UTF-16: `schtasks /XML` reads no other encoding (a UTF-8 file is refused "unable to switch the encoding").
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+ export const TASK_NAME = "danxbot-machine-daemon";
24
+ export const UNIT_NAME = "danxbot-machine-daemon.service";
25
+ function xmlEscape(value) {
26
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
27
+ }
28
+ /** The Task Scheduler definition: this user's logon plus a once-a-minute time trigger, `conhost --headless` running node on the bundle. */
29
+ export function buildWindowsTaskXml(args) {
30
+ return `<?xml version="1.0" encoding="UTF-16"?>
31
+ <Task version="1.4" xmlns="http://schemas.microsoft.com/windows/2004/02/mit/task">
32
+ <RegistrationInfo><Description>danxbot machine daemon (DX-5902): one heartbeat a minute to the 50 Arms dashboard.</Description></RegistrationInfo>
33
+ <Triggers>
34
+ <LogonTrigger><Enabled>true</Enabled><UserId>${xmlEscape(args.userId)}</UserId></LogonTrigger>
35
+ <TimeTrigger>
36
+ <StartBoundary>2000-01-01T00:00:00</StartBoundary>
37
+ <Enabled>true</Enabled>
38
+ <Repetition><Interval>PT1M</Interval><StopAtDurationEnd>false</StopAtDurationEnd></Repetition>
39
+ </TimeTrigger>
40
+ </Triggers>
41
+ <Principals>
42
+ <Principal id="Author"><UserId>${xmlEscape(args.userId)}</UserId><LogonType>InteractiveToken</LogonType><RunLevel>LeastPrivilege</RunLevel></Principal>
43
+ </Principals>
44
+ <Settings>
45
+ <MultipleInstancesPolicy>IgnoreNew</MultipleInstancesPolicy>
46
+ <DisallowStartIfOnBatteries>false</DisallowStartIfOnBatteries>
47
+ <StopIfGoingOnBatteries>false</StopIfGoingOnBatteries>
48
+ <ExecutionTimeLimit>PT0S</ExecutionTimeLimit>
49
+ <Enabled>true</Enabled>
50
+ <Hidden>true</Hidden>
51
+ </Settings>
52
+ <Actions Context="Author">
53
+ <Exec>
54
+ <Command>${xmlEscape(args.conhostPath)}</Command>
55
+ <Arguments>--headless "${xmlEscape(args.nodePath)}" "${xmlEscape(args.bundlePath)}"</Arguments>
56
+ </Exec>
57
+ </Actions>
58
+ </Task>
59
+ `;
60
+ }
61
+ /** The systemd user unit: started at login, restarted after a failure (a deliberate stop, exit 0 on a refused bearer, is not restarted). */
62
+ export function buildSystemdUnit(args) {
63
+ return `[Unit]
64
+ Description=danxbot machine daemon (DX-5902): one heartbeat a minute to the 50 Arms dashboard
65
+ After=network-online.target
66
+
67
+ [Service]
68
+ ExecStart=${args.nodePath} ${args.bundlePath}
69
+ Restart=on-failure
70
+ RestartSec=10
71
+
72
+ [Install]
73
+ WantedBy=default.target
74
+ `;
75
+ }
76
+ /** Runs an OS command with no window and returns its stdout; a non-zero exit throws. */
77
+ export function runHidden(file, args) {
78
+ return execFileSync(file, args, { encoding: "utf8", windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
79
+ }
80
+ export function productionAutostartDeps(bundlePath) {
81
+ return {
82
+ platform: process.platform,
83
+ // DX-5902: the node binary's real path. Under a packaged (MSIX) app such as the Claude desktop app, `process.execPath` can
84
+ // be a redirected AppData path that exists only inside the package; Task Scheduler runs outside it, so `conhost` finds no
85
+ // node there and the task ends at once. The native realpath resolves the redirect.
86
+ nodePath: realpathSync.native(process.execPath),
87
+ bundlePath,
88
+ userId: process.env.USERDOMAIN && process.env.USERNAME ? `${process.env.USERDOMAIN}\\${process.env.USERNAME}` : process.env.USER ?? "",
89
+ conhostPath: join(process.env.SystemRoot ?? "C:\\Windows", "System32", "conhost.exe"),
90
+ home: homedir(),
91
+ run: runHidden,
92
+ writeFile: (file, content, encoding) => writeFileSync(file, encoding === "utf16le" ? `${content}` : content, { encoding }),
93
+ mkdir: (dir) => mkdirSync(dir, { recursive: true }),
94
+ remove: (file) => rmSync(file, { force: true }),
95
+ };
96
+ }
97
+ /** Registers the start-at-logon entry and starts it now. */
98
+ export function installAutostart(deps) {
99
+ if (deps.platform === "win32") {
100
+ if (deps.userId === "")
101
+ throw new Error("cannot register the logon task: the current Windows user is unknown");
102
+ const xmlPath = join(deps.home, ".danxbot", `${TASK_NAME}.xml`);
103
+ deps.mkdir(join(deps.home, ".danxbot"));
104
+ deps.writeFile(xmlPath, buildWindowsTaskXml(deps), "utf16le");
105
+ deps.run("schtasks", ["/Create", "/TN", TASK_NAME, "/XML", xmlPath, "/F"]);
106
+ deps.run("schtasks", ["/Run", "/TN", TASK_NAME]);
107
+ return `scheduled task ${TASK_NAME} (at logon, restarted within a minute when it stops, via conhost --headless)`;
108
+ }
109
+ if (deps.platform === "linux") {
110
+ const unitDir = join(deps.home, ".config", "systemd", "user");
111
+ deps.mkdir(unitDir);
112
+ deps.writeFile(join(unitDir, UNIT_NAME), buildSystemdUnit(deps), "utf8");
113
+ deps.run("systemctl", ["--user", "daemon-reload"]);
114
+ deps.run("systemctl", ["--user", "enable", UNIT_NAME]);
115
+ deps.run("systemctl", ["--user", "restart", UNIT_NAME]);
116
+ return `systemd user unit ${UNIT_NAME} (enabled and started)`;
117
+ }
118
+ throw new Error(`no start-at-logon entry for platform ${deps.platform}: the machine daemon runs on Windows and Linux only`);
119
+ }
120
+ /**
121
+ * Disables the start-at-logon entry without stopping the daemon that calls it (it is about to exit on its own). Called by the
122
+ * daemon after the dashboard refused its bearer, so nothing restarts it into the same refusal.
123
+ */
124
+ export function disableAutostart(deps) {
125
+ if (deps.platform === "win32") {
126
+ deps.run("schtasks", ["/Change", "/TN", TASK_NAME, "/DISABLE"]);
127
+ return;
128
+ }
129
+ if (deps.platform === "linux") {
130
+ deps.run("systemctl", ["--user", "disable", UNIT_NAME]);
131
+ return;
132
+ }
133
+ throw new Error(`no start-at-logon entry for platform ${deps.platform}`);
134
+ }
135
+ /** Stops and removes the start-at-logon entry. Missing entries are not an error: uninstall is re-runnable. */
136
+ export function removeAutostart(deps) {
137
+ if (deps.platform === "win32") {
138
+ try {
139
+ deps.run("schtasks", ["/End", "/TN", TASK_NAME]);
140
+ }
141
+ catch {
142
+ // the task may not be running; deleting it below is what matters
143
+ }
144
+ deps.run("schtasks", ["/Delete", "/TN", TASK_NAME, "/F"]);
145
+ deps.remove(join(deps.home, ".danxbot", `${TASK_NAME}.xml`));
146
+ return `removed scheduled task ${TASK_NAME}`;
147
+ }
148
+ if (deps.platform === "linux") {
149
+ try {
150
+ deps.run("systemctl", ["--user", "disable", "--now", UNIT_NAME]);
151
+ }
152
+ catch {
153
+ // not enabled
154
+ }
155
+ deps.remove(join(deps.home, ".config", "systemd", "user", UNIT_NAME));
156
+ deps.run("systemctl", ["--user", "daemon-reload"]);
157
+ return `removed systemd user unit ${UNIT_NAME}`;
158
+ }
159
+ throw new Error(`no start-at-logon entry for platform ${deps.platform}`);
160
+ }
@@ -0,0 +1,77 @@
1
+ export const BEAT_INTERVAL_MS = 60_000;
2
+ /** A 401 on either path: a replaced bearer (DX-6537) is an update in progress, anything else is a real refusal. */
3
+ function stopOnRefusal(deps) {
4
+ const onDisk = deps.readBearer();
5
+ if (onDisk !== null && onDisk !== deps.config.bearer) {
6
+ deps.log("install-daemon replaced this machine's reporting bearer: this process is the old bundle, so it exits and the start entry runs the new one.");
7
+ return { reason: "replaced" };
8
+ }
9
+ deps.log("the dashboard refused this machine's reporting bearer (HTTP 401): re-run install-daemon. The daemon has stopped beating.");
10
+ return { reason: "refused" };
11
+ }
12
+ /** One POST with the machine's own bearer. `status` is null when the request itself failed; `text` is the answer's body. */
13
+ export async function postAsMachine(config, fetchFn, route, payload) {
14
+ try {
15
+ const res = await fetchFn(`${config.dashboard_url.replace(/\/+$/, "")}${route}`, {
16
+ method: "POST",
17
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.bearer}` },
18
+ body: JSON.stringify({ machine_id: config.machine_id, ...payload }),
19
+ });
20
+ return { status: res.status, error: res.status === 200 ? null : `HTTP ${res.status}`, text: await res.text() };
21
+ }
22
+ catch (err) {
23
+ return { status: null, error: err instanceof Error ? err.message : String(err), text: "" };
24
+ }
25
+ }
26
+ /** Sends one beat. Returns the HTTP status, or null when the request itself failed. */
27
+ export async function sendBeat(deps, vitals) {
28
+ const { status, error } = await postAsMachine(deps.config, deps.fetchFn, "/api/machines/heartbeat", { vitals });
29
+ return { status, error };
30
+ }
31
+ /** Runs beats until the dashboard refuses the bearer, the daemon is uninstalled, or `maxTicks` is spent. */
32
+ export async function runBeatLoop(deps) {
33
+ let ticks = 0;
34
+ for (;;) {
35
+ if (deps.maxTicks !== undefined && ticks >= deps.maxTicks)
36
+ return { reason: "ticks-exhausted" };
37
+ if (!deps.installed()) {
38
+ deps.log("the daemon's config is gone (uninstall-daemon removed it): stopping.");
39
+ return { reason: "uninstalled" };
40
+ }
41
+ ticks += 1;
42
+ const beatAt = deps.now();
43
+ let vitals = null;
44
+ let outcome;
45
+ try {
46
+ vitals = deps.collect();
47
+ outcome = await sendBeat(deps, vitals);
48
+ }
49
+ catch (err) {
50
+ // A vitals probe that cannot read the machine is loud: it is recorded and the next tick retries it.
51
+ outcome = { status: null, error: `vitals: ${err instanceof Error ? err.message : String(err)}` };
52
+ }
53
+ const next = new Date(beatAt.getTime() + BEAT_INTERVAL_MS);
54
+ deps.writeStatus({
55
+ pid: deps.pid,
56
+ last_beat_at: beatAt.toISOString(),
57
+ last_beat_ok: outcome.status === 200,
58
+ last_error: outcome.error,
59
+ next_beat_at: next.toISOString(),
60
+ });
61
+ if (outcome.status === 200) {
62
+ deps.log(`beat ok at ${beatAt.toISOString()}`);
63
+ }
64
+ else if (outcome.status === 401) {
65
+ return stopOnRefusal(deps);
66
+ }
67
+ else {
68
+ deps.log(`beat failed at ${beatAt.toISOString()}: ${outcome.error}; retrying on the next tick`);
69
+ }
70
+ if (deps.reporter.refused())
71
+ return stopOnRefusal(deps);
72
+ deps.reporter.tick(beatAt);
73
+ if (deps.maxTicks !== undefined && ticks >= deps.maxTicks)
74
+ return { reason: "ticks-exhausted" };
75
+ await deps.sleep(Math.max(0, next.getTime() - deps.now().getTime()));
76
+ }
77
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * DX-5902 — the `install-daemon`, `status` and `uninstall-daemon` subcommands of `@50arms/platform-mcp`. The bin
3
+ * (`dist/index.js`) dispatches them here; the daemon itself is the separate bundle `dist/machine-daemon.js`.
4
+ */
5
+ import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { DEFAULT_DASHBOARD_URL, daemonStatusText, installDaemon, productionInstallDeps, uninstallDaemon } from "./install.js";
8
+ import { productionAutostartDeps } from "./autostart.js";
9
+ import { daemonBundleDir, danxbotHome } from "./state.js";
10
+ export const MACHINE_DAEMON_SUBCOMMANDS = ["install-daemon", "status", "uninstall-daemon"];
11
+ function flag(args, name) {
12
+ const i = args.indexOf(name);
13
+ return i >= 0 ? args[i + 1] : undefined;
14
+ }
15
+ export async function runMachineDaemonCommand(subcommand, args) {
16
+ const home = danxbotHome();
17
+ if (subcommand === "status") {
18
+ console.log(daemonStatusText(home));
19
+ return 0;
20
+ }
21
+ if (subcommand === "install-daemon") {
22
+ const kindFlag = flag(args, "--kind") ?? "dev";
23
+ if (kindFlag !== "dev" && kindFlag !== "worker_host")
24
+ throw new Error("--kind must be dev or worker_host");
25
+ // The bundles this package ships beside its dist (dist/machine-daemon/cli.js -> dist/machine-daemon.js, dist/machine-toolkit.js).
26
+ const dist = join(dirname(fileURLToPath(import.meta.url)), "..");
27
+ await installDaemon(productionInstallDeps({
28
+ dashboardUrl: flag(args, "--dashboard-url") ?? process.env.DANXBOT_DASHBOARD_URL ?? DEFAULT_DASHBOARD_URL,
29
+ kind: kindFlag,
30
+ profile: flag(args, "--profile") ?? null,
31
+ bundleSource: join(dist, "machine-daemon.js"),
32
+ toolkitBundleSource: join(dist, "machine-toolkit.js"),
33
+ }));
34
+ return 0;
35
+ }
36
+ if (subcommand === "uninstall-daemon") {
37
+ const autostart = productionAutostartDeps(join(daemonBundleDir(home), "machine-daemon.js"));
38
+ await uninstallDaemon({ home, fetchFn: fetch, autostart, print: (line) => console.log(line) });
39
+ return 0;
40
+ }
41
+ throw new Error(`unknown machine daemon subcommand ${subcommand}`);
42
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * DX-5902 — `install-daemon`, `status` and `uninstall-daemon` for the machine daemon.
3
+ *
4
+ * install: asks the dashboard for a `machine` access request (the DX-5809 flow), prints the approval link and confirm code, and
5
+ * polls until a person approves. The approved key is used ONCE: to register this machine, which returns the machine's own
6
+ * reporting bearer. The key is never written to disk or printed. The bearer goes to `machine-daemon.json` (0600). The daemon
7
+ * and toolkit bundles are copied under `~/.danxbot/machine-daemon/` and the start-at-logon entry is registered.
8
+ *
9
+ * status: reads the local files (no network): the machine id, the last beat, the next beat and the last health report.
10
+ * uninstall: removes the start-at-logon entry, retires the machine row (the daemon's own bearer), and deletes the local config.
11
+ * A daemon still running (on Windows, ending the task leaves node alive) sees its config gone and stops at its next tick.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { copyFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
15
+ import { hostname } from "node:os";
16
+ import { join } from "node:path";
17
+ import { claimAccessRequest } from "../access-request.js";
18
+ import { installAutostart, productionAutostartDeps, removeAutostart } from "./autostart.js";
19
+ import { DAEMON_VERSION } from "./version.js";
20
+ import { toolkitBundlePath, daemonBundleDir, daemonConfigPath, daemonStatusPath, danxbotHome, machineIdentityPath, readJsonIfPresent, writeOwnerOnlyJson, } from "./state.js";
21
+ export const DEFAULT_DASHBOARD_URL = "https://app.50arms.ai";
22
+ function base(url) {
23
+ return url.replace(/\/+$/, "");
24
+ }
25
+ async function postJson(fetchFn, url, body, bearer) {
26
+ const res = await fetchFn(url, {
27
+ method: "POST",
28
+ headers: { "Content-Type": "application/json", ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}) },
29
+ body: JSON.stringify(body),
30
+ });
31
+ const text = await res.text();
32
+ return { status: res.status, json: text === "" ? null : JSON.parse(text) };
33
+ }
34
+ /** Approval: request, show the link, poll the claim until approved. Returns the machine key, held only in memory. */
35
+ async function approveMachineKey(deps) {
36
+ const created = await postJson(deps.fetchFn, `${base(deps.dashboardUrl)}/api/access-requests`, {
37
+ kind: "machine",
38
+ labelId: deps.label,
39
+ title: `Machine ${deps.label}: its daemon reports its vitals, Docker resource names and process command lines to the dashboard`,
40
+ machine: deps.label,
41
+ });
42
+ if (created.status !== 201)
43
+ throw new Error(`the dashboard refused the machine request (HTTP ${created.status})`);
44
+ const request = created.json;
45
+ deps.print(`Open this link, check the code is ${request.confirmCode}, and approve:`);
46
+ deps.print(request.approvalUrl);
47
+ deps.print("Waiting for approval...");
48
+ for (;;) {
49
+ const claim = await claimAccessRequest(deps.fetchFn, deps.dashboardUrl, request.secret);
50
+ switch (claim.kind) {
51
+ case "approved":
52
+ return claim.apiKey;
53
+ case "pending":
54
+ await deps.sleep(request.pollMs > 0 ? request.pollMs : 2000);
55
+ break;
56
+ case "rate_limited":
57
+ await deps.sleep(claim.retryAfterSeconds * 1000);
58
+ break;
59
+ case "denied":
60
+ case "expired":
61
+ case "claimed":
62
+ case "not_found":
63
+ throw new Error(`the machine request ended (${claim.kind}), not approved: run install-daemon again`);
64
+ case "refused":
65
+ throw new Error(`the dashboard refused the claim (HTTP ${claim.status})`);
66
+ }
67
+ }
68
+ }
69
+ /** Registers the machine with the approved key and returns the machine's own reporting bearer. */
70
+ async function registerWithKey(deps, machineId, machineKey) {
71
+ const res = await postJson(deps.fetchFn, `${base(deps.dashboardUrl)}/api/machines/register`, { machine_id: machineId, label: deps.label, kind: deps.kind, profile: deps.profile, daemon_version: DAEMON_VERSION }, machineKey);
72
+ if (res.status !== 200)
73
+ throw new Error(`machine registration refused (HTTP ${res.status})`);
74
+ const bearer = res.json.bearer;
75
+ if (typeof bearer !== "string" || bearer === "")
76
+ throw new Error("machine registration answered without a reporting bearer");
77
+ return bearer;
78
+ }
79
+ export async function installDaemon(deps) {
80
+ // DX-6537: checked before registering, which replaces the bearer of a daemon already running here: a half-installed package changes nothing.
81
+ for (const source of [deps.bundleSource, deps.toolkitBundleSource]) {
82
+ if (!existsSync(source))
83
+ throw new Error(`this package has no ${source}: it cannot install the daemon`);
84
+ }
85
+ const existing = readJsonIfPresent(machineIdentityPath(deps.home));
86
+ const machineId = existing?.machine_id ?? deps.newId();
87
+ const machineKey = await approveMachineKey(deps);
88
+ const bearer = await registerWithKey(deps, machineId, machineKey);
89
+ writeOwnerOnlyJson(machineIdentityPath(deps.home), { machine_id: machineId });
90
+ const config = {
91
+ dashboard_url: base(deps.dashboardUrl),
92
+ machine_id: machineId,
93
+ bearer,
94
+ label: deps.label,
95
+ kind: deps.kind,
96
+ profile: deps.profile,
97
+ daemon_version: DAEMON_VERSION,
98
+ };
99
+ writeOwnerOnlyJson(daemonConfigPath(deps.home), config);
100
+ mkdirSync(daemonBundleDir(deps.home), { recursive: true });
101
+ const bundlePath = join(daemonBundleDir(deps.home), "machine-daemon.js");
102
+ copyFileSync(deps.bundleSource, bundlePath);
103
+ copyFileSync(deps.toolkitBundleSource, toolkitBundlePath(deps.home));
104
+ const entry = installAutostart({ ...deps.autostart, bundlePath });
105
+ deps.print(`Registered machine ${machineId} as "${deps.label}". The daemon runs from ${bundlePath}.`);
106
+ deps.print(`Start at logon: ${entry}.`);
107
+ }
108
+ export function daemonStatusText(home) {
109
+ const identity = readJsonIfPresent(machineIdentityPath(home));
110
+ const status = readJsonIfPresent(daemonStatusPath(home));
111
+ if (identity === null)
112
+ return "Not installed: run npx @50arms/platform-mcp install-daemon.";
113
+ const lines = [`Machine id: ${identity.machine_id}`];
114
+ lines.push(`Last beat: ${status?.last_beat_at ?? "none yet"}${status ? (status.last_beat_ok ? " (ok)" : ` (failed: ${status.last_error ?? "unknown"})`) : ""}`);
115
+ lines.push(`Next beat: ${status?.next_beat_at ?? "none yet"}`);
116
+ lines.push(`Last report: ${status?.last_report_at ?? "none yet"}${status?.last_report_outcome ? ` (${status.last_report_outcome}${status.last_report_error ? `: ${status.last_report_error}` : ""})` : ""}`);
117
+ return lines.join("\n");
118
+ }
119
+ export async function uninstallDaemon(deps) {
120
+ const config = readJsonIfPresent(daemonConfigPath(deps.home));
121
+ deps.print(removeAutostart(deps.autostart));
122
+ // The retire is attempted, then the local files go either way: the daemon is removed from this machine even when the dashboard
123
+ // refuses or cannot be reached. A refusal is reported afterwards, naming the row that stays on the dashboard.
124
+ let refusal = null;
125
+ if (config !== null) {
126
+ try {
127
+ const res = await deps.fetchFn(`${config.dashboard_url}/api/machines/retire`, {
128
+ method: "POST",
129
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${config.bearer}` },
130
+ body: JSON.stringify({ machine_id: config.machine_id }),
131
+ });
132
+ if (res.status === 200)
133
+ deps.print(`Retired machine ${config.machine_id} on the dashboard.`);
134
+ else
135
+ refusal = `HTTP ${res.status}`;
136
+ }
137
+ catch (err) {
138
+ refusal = err instanceof Error ? err.message : String(err);
139
+ }
140
+ }
141
+ for (const file of [daemonConfigPath(deps.home), daemonStatusPath(deps.home)]) {
142
+ if (existsSync(file)) {
143
+ rmSync(file, { force: true });
144
+ }
145
+ }
146
+ if (config !== null && refusal !== null) {
147
+ throw new Error(`retiring machine ${config.machine_id} on the dashboard failed (${refusal}). The local daemon is removed; the row stays listed on the dashboard until a person retires it there.`);
148
+ }
149
+ }
150
+ /** The production wiring the CLI uses (the real network, clock and OS). */
151
+ export function productionInstallDeps(opts) {
152
+ const home = danxbotHome();
153
+ const autostartDeps = productionAutostartDeps(join(daemonBundleDir(home), "machine-daemon.js"));
154
+ return {
155
+ dashboardUrl: opts.dashboardUrl,
156
+ label: hostname(),
157
+ kind: opts.kind,
158
+ profile: opts.profile,
159
+ fetchFn: fetch,
160
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
161
+ home,
162
+ bundleSource: opts.bundleSource,
163
+ toolkitBundleSource: opts.toolkitBundleSource,
164
+ autostart: autostartDeps,
165
+ print: (line) => console.log(line),
166
+ newId: () => randomUUID(),
167
+ };
168
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * DX-5902 — the machine daemon's files under `~/.danxbot/`. Nothing here is inside a repo.
3
+ *
4
+ * machine.json — `{ machine_id }`: the stable id the daemon made at install (never the hostname).
5
+ * machine-daemon.json — the dashboard URL, the machine id and its reporting bearer, owner-only (0600). The bearer is
6
+ * the daemon's only credential; it is never printed or logged.
7
+ * machine-daemon-status.json — written after every beat and report: when it last beat, whether that beat landed, when the next is
8
+ * due, and the last health report's time, outcome and error.
9
+ * machine-daemon/ — the copies of the daemon and toolkit bundles the autostart entry runs (so a package update never changes a running file).
10
+ */
11
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ export const MACHINE_DAEMON_DIR_NAME = ".danxbot";
15
+ /** One status file, two writers: each write merges into the same value, so a beat and a report never clobber each other's fields. */
16
+ export function createStatusWriter(write) {
17
+ let current = {
18
+ pid: process.pid,
19
+ last_beat_at: null,
20
+ last_beat_ok: null,
21
+ last_error: null,
22
+ next_beat_at: null,
23
+ last_report_at: null,
24
+ last_report_outcome: null,
25
+ last_report_error: null,
26
+ };
27
+ const merge = (fields) => {
28
+ current = { ...current, ...fields };
29
+ write(current);
30
+ };
31
+ return { beat: merge, report: merge };
32
+ }
33
+ export function danxbotHome() {
34
+ return join(homedir(), MACHINE_DAEMON_DIR_NAME);
35
+ }
36
+ export function machineIdentityPath(home = danxbotHome()) {
37
+ return join(home, "machine.json");
38
+ }
39
+ export function daemonConfigPath(home = danxbotHome()) {
40
+ return join(home, "machine-daemon.json");
41
+ }
42
+ export function daemonStatusPath(home = danxbotHome()) {
43
+ return join(home, "machine-daemon-status.json");
44
+ }
45
+ export function daemonBundleDir(home = danxbotHome()) {
46
+ return join(home, "machine-daemon");
47
+ }
48
+ export function toolkitBundlePath(home = danxbotHome()) {
49
+ return join(daemonBundleDir(home), "machine-toolkit.js");
50
+ }
51
+ /** Writes the file whole (temp then rename) and owner-only, so a crash never leaves a half-written credential. */
52
+ export function writeOwnerOnlyJson(file, value) {
53
+ mkdirSync(dirname(file), { recursive: true });
54
+ const tmp = `${file}.${process.pid}.tmp`;
55
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
56
+ if (process.platform !== "win32")
57
+ chmodSync(tmp, 0o600);
58
+ renameSync(tmp, file);
59
+ }
60
+ export function readJsonIfPresent(file) {
61
+ if (!existsSync(file))
62
+ return null;
63
+ return JSON.parse(readFileSync(file, "utf8"));
64
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * DX-5902 - the daemon's version, read from this package's own `package.json`: the one source `install-daemon` reports to the
3
+ * dashboard. The file sits two folders up from both `src/machine-daemon/` and the built `dist/machine-daemon/`, and the daemon
4
+ * bundle (`dist/machine-daemon.js`) never includes this module, so the path is always the installed package's own manifest.
5
+ */
6
+ import { readFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ const manifest = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "package.json"), "utf8"));
10
+ if (typeof manifest.version !== "string" || manifest.version === "") {
11
+ throw new Error("packages/danx-dashboard-mcp/package.json has no version: the machine daemon cannot report one");
12
+ }
13
+ export const DAEMON_VERSION = manifest.version;