@euqns/nudge-mcp 1.22.0 → 1.25.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/dist/service.js CHANGED
@@ -1,4 +1,4 @@
1
- // Run the runner or the agent companion as a login service.
1
+ // Run the runner or the agent companion as a login service, and manage it.
2
2
  //
3
3
  // Both long-lived subcommands die with the terminal that started them, so a
4
4
  // reboot (or a closed laptop lid followed by a logout) silently takes the
@@ -9,39 +9,33 @@
9
9
  // macOS → ~/Library/LaunchAgents/com.nudge.<runner|agent>.plist (launchd)
10
10
  // Linux → ~/.config/systemd/user/nudge-<runner|agent>.service (systemd --user)
11
11
  //
12
- // The service runs THIS node binary and THIS installed copy of nudge-mcp, with
13
- // the caller's PATH baked in launchd and systemd start user services with a
14
- // bare PATH, and the runner needs `git`, `gh`, `claude`, `codex` and friends to
15
- // be findable. The in-process supervisor (runner-restart.ts) still handles
16
- // rebuilds; the OS service manager handles reboots and crashes.
12
+ // and gives both kinds the same verbs: install, uninstall, start, stop,
13
+ // restart, status, logs. The service runs THIS node binary and THIS installed
14
+ // copy of nudge-mcp, with the caller's PATH baked in launchd and systemd
15
+ // start user services with a bare PATH, and the runner needs `git`, `gh`,
16
+ // `claude`, `codex` and friends to be findable. The in-process supervisor
17
+ // (runner-restart.ts) still handles rebuilds; the OS service manager handles
18
+ // reboots and crashes.
17
19
  //
18
- // Windows has no user-level equivalent this code can write safely, so it gets
19
- // an actionable message instead of a half-working Task Scheduler entry.
20
- import { execFile } from "node:child_process";
20
+ // `status` and `install` never ask the user to read a log: the process itself
21
+ // reports in through ~/.nudge/state (service-state.ts) version, machine,
22
+ // board, listener, pairing URL — and when it fails to, the CLI shows the exit
23
+ // code and the last log lines inline.
24
+ //
25
+ // Windows has no user-level equivalent this code can write safely, so every
26
+ // lifecycle verb gets an actionable message instead of a half-working Task
27
+ // Scheduler entry; `status` still reports a process started from a terminal.
28
+ import { execFile, spawn } from "node:child_process";
21
29
  import fs from "node:fs/promises";
22
30
  import os from "node:os";
23
31
  import path from "node:path";
24
32
  import { promisify } from "node:util";
25
33
  import { CREDENTIALS_DIR } from "./credentials.js";
34
+ import { SERVICE_KINDS, SERVICE_LABEL_ENV, SERVICE_MANAGER_ENV, isStateLive, readProcessState, sanitizeInstance, serviceLabel, unitName, waitForProcessState, } from "./service-state.js";
35
+ import { NUDGE_MCP_VERSION } from "./version.js";
36
+ export { SERVICE_KINDS, sanitizeInstance, serviceLabel, unitName };
26
37
  const execFileAsync = promisify(execFile);
27
- export const SERVICE_KINDS = ["runner", "agent"];
28
38
  export const LOG_DIR = path.join(CREDENTIALS_DIR, "logs");
29
- /** Only [a-z0-9-] survive; anything else becomes a dash. `--instance "Mac 2"` → `mac-2`. */
30
- export function sanitizeInstance(raw) {
31
- return raw
32
- .toLowerCase()
33
- .replace(/[^a-z0-9]+/g, "-")
34
- .replace(/^-+|-+$/g, "");
35
- }
36
- export function serviceLabel(kind, instance) {
37
- const suffix = instance ? sanitizeInstance(instance) : "";
38
- return suffix ? `com.nudge.${kind}.${suffix}` : `com.nudge.${kind}`;
39
- }
40
- /** systemd unit names can't start with the reverse-DNS prefix comfortably;
41
- * `com.nudge.runner` → `nudge-runner`, `com.nudge.runner.mac-2` → `nudge-runner-mac-2`. */
42
- export function unitName(label) {
43
- return label.replace(/^com\.nudge\./, "nudge-").replace(/\./g, "-");
44
- }
45
39
  /** Value of `--flag VALUE` or `--flag=VALUE` in argv, or null. */
46
40
  export function findFlag(argv, flag) {
47
41
  for (let i = 0; i < argv.length; i++) {
@@ -52,6 +46,22 @@ export function findFlag(argv, flag) {
52
46
  }
53
47
  return null;
54
48
  }
49
+ /** argv without `--flag VALUE` / `--flag=VALUE` / bare `--flag` occurrences. */
50
+ export function stripFlag(argv, flag, takesValue) {
51
+ const out = [];
52
+ for (let i = 0; i < argv.length; i++) {
53
+ const arg = argv[i];
54
+ if (arg === flag) {
55
+ if (takesValue)
56
+ i++;
57
+ continue;
58
+ }
59
+ if (arg.startsWith(`${flag}=`))
60
+ continue;
61
+ out.push(arg);
62
+ }
63
+ return out;
64
+ }
55
65
  /**
56
66
  * Rewrite `--cwd X` to an absolute path. The service has no notion of "the
57
67
  * directory I typed the install command in", so a relative `--cwd .` pasted
@@ -111,6 +121,13 @@ export function serviceEnv(kind, source = process.env) {
111
121
  }
112
122
  return env;
113
123
  }
124
+ export function managerForPlatform(platform) {
125
+ if (platform === "darwin")
126
+ return "launchd";
127
+ if (platform === "linux")
128
+ return "systemd";
129
+ return null;
130
+ }
114
131
  export function buildSpec(kind, argv, ctx) {
115
132
  // Flags pass through untouched (including `--save`: re-writing runner.json
116
133
  // with the same values at every start is idempotent), except that a
@@ -118,6 +135,11 @@ export function buildSpec(kind, argv, ctx) {
118
135
  const args = absolutizeCwd(argv, ctx.cwd);
119
136
  const label = serviceLabel(kind, kind === "runner" ? findFlag(args, "--instance") : null);
120
137
  const cwd = findFlag(args, "--cwd") ?? ctx.cwd;
138
+ const env = serviceEnv(kind, ctx.env);
139
+ if (ctx.manager) {
140
+ env[SERVICE_MANAGER_ENV] = ctx.manager;
141
+ env[SERVICE_LABEL_ENV] = label;
142
+ }
121
143
  return {
122
144
  kind,
123
145
  label,
@@ -125,7 +147,7 @@ export function buildSpec(kind, argv, ctx) {
125
147
  entry: ctx.entry,
126
148
  args,
127
149
  cwd,
128
- env: serviceEnv(kind, ctx.env),
150
+ env,
129
151
  logPath: path.join(LOG_DIR, `${label}.log`),
130
152
  };
131
153
  }
@@ -210,6 +232,17 @@ TimeoutStopSec=120
210
232
  WantedBy=default.target
211
233
  `;
212
234
  }
235
+ /** The nudge-mcp entry point an installed service file runs, so `status` can
236
+ * notice when an upgrade or uninstall moved it out from under the service. */
237
+ export function entryFromServiceFile(text) {
238
+ const plist = /<key>ProgramArguments<\/key>\s*<array>\s*<string>[^<]*<\/string>\s*<string>([^<]*)<\/string>/.exec(text);
239
+ if (plist)
240
+ return plist[1].replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"');
241
+ const unit = /^ExecStart="[^"]*" "((?:[^"\\]|\\.)*)"/m.exec(text);
242
+ if (unit)
243
+ return unit[1].replace(/\\(.)/g, "$1");
244
+ return null;
245
+ }
213
246
  // ---- platform paths -------------------------------------------------------
214
247
  export function launchdPlistPath(label, home = os.homedir()) {
215
248
  return path.join(home, "Library", "LaunchAgents", `${label}.plist`);
@@ -240,184 +273,586 @@ async function run(cmd, args, opts) {
240
273
  throw new Error(`\`${cmd} ${args.join(" ")}\` failed${detail ? `: ${detail}` : ""}`);
241
274
  }
242
275
  }
243
- // ---- launchd (macOS) ------------------------------------------------------
244
- const gui = () => `gui/${process.getuid?.() ?? 501}`;
245
- async function launchdInstall(spec) {
246
- const plist = launchdPlistPath(spec.label);
247
- await fs.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
248
- await writePrivate(plist, renderLaunchdPlist(spec));
249
- // Replace rather than layer: bootstrapping a label that's already loaded
250
- // fails, and a stale definition would keep the old flags.
251
- await run("launchctl", ["bootout", `${gui()}/${spec.label}`], { ignoreFailure: true });
252
- await run("launchctl", ["bootstrap", gui(), plist]);
253
- return plist;
276
+ /** Stream a command's output to this terminal (for `logs`, esp. `--follow`). */
277
+ function stream(cmd, args) {
278
+ return new Promise((resolve, reject) => {
279
+ const child = spawn(cmd, args, { stdio: "inherit" });
280
+ child.once("error", (err) => reject(err.code === "ENOENT"
281
+ ? new Error(`\`${cmd}\` is not available on this machine.`)
282
+ : err));
283
+ child.once("exit", () => resolve());
284
+ });
254
285
  }
255
- async function launchdUninstall(label) {
256
- const plist = launchdPlistPath(label);
257
- await run("launchctl", ["bootout", `${gui()}/${label}`], { ignoreFailure: true });
286
+ async function fileExists(file) {
287
+ return fs.access(file).then(() => true, () => false);
288
+ }
289
+ async function tailFile(file, lines) {
258
290
  try {
259
- await fs.unlink(plist);
260
- return true;
291
+ const text = await fs.readFile(file, "utf8");
292
+ return text.split("\n").filter((l, i, all) => i < all.length - 1 || l !== "").slice(-lines).join("\n");
261
293
  }
262
- catch (err) {
263
- if (err.code === "ENOENT")
264
- return false;
265
- throw err;
294
+ catch {
295
+ return "";
266
296
  }
267
297
  }
268
- async function launchdStatus(label) {
269
- const plist = launchdPlistPath(label);
270
- const installed = await fs.access(plist).then(() => true, () => false);
271
- if (!installed)
272
- return "not installed";
273
- const out = await run("launchctl", ["print", `${gui()}/${label}`], { ignoreFailure: true });
274
- const pid = /^\s*pid = (\d+)/m.exec(out)?.[1];
275
- const state = /^\s*state = (\w+)/m.exec(out)?.[1];
276
- if (pid)
277
- return `running (pid ${pid})`;
278
- if (state)
279
- return state;
280
- return out ? "loaded" : "installed but not loaded (log in again, or run `nudge-mcp service install` once more)";
281
- }
282
- // ---- systemd --user (Linux) -----------------------------------------------
283
- async function systemdInstall(spec) {
284
- const unit = systemdUnitPath(spec.label);
285
- await writePrivate(unit, renderSystemdUnit(spec));
286
- await run("systemctl", ["--user", "daemon-reload"]);
287
- await run("systemctl", ["--user", "enable", "--now", path.basename(unit)]);
288
- return unit;
289
- }
290
- async function systemdUninstall(label) {
291
- const unit = systemdUnitPath(label);
292
- await run("systemctl", ["--user", "disable", "--now", path.basename(unit)], {
298
+ const gui = () => `gui/${process.getuid?.() ?? 501}`;
299
+ export const launchd = {
300
+ name: "launchd",
301
+ file: (label) => launchdPlistPath(label),
302
+ render: renderLaunchdPlist,
303
+ async install(spec) {
304
+ const plist = launchdPlistPath(spec.label);
305
+ await fs.mkdir(LOG_DIR, { recursive: true, mode: 0o700 });
306
+ await writePrivate(plist, renderLaunchdPlist(spec));
307
+ // Replace rather than layer: bootstrapping a label that's already loaded
308
+ // fails, and a stale definition would keep the old flags.
309
+ await run("launchctl", ["bootout", `${gui()}/${spec.label}`], { ignoreFailure: true });
310
+ await run("launchctl", ["bootstrap", gui(), plist]);
311
+ return plist;
312
+ },
313
+ async uninstall(label) {
314
+ const plist = launchdPlistPath(label);
315
+ await run("launchctl", ["bootout", `${gui()}/${label}`], { ignoreFailure: true });
316
+ try {
317
+ await fs.unlink(plist);
318
+ return true;
319
+ }
320
+ catch (err) {
321
+ if (err.code === "ENOENT")
322
+ return false;
323
+ throw err;
324
+ }
325
+ },
326
+ async start(label) {
327
+ const plist = launchdPlistPath(label);
328
+ // `bootstrap` fails when the label is already loaded — harmless here; a
329
+ // loaded-but-stopped service is what `kickstart` is for.
330
+ await run("launchctl", ["bootstrap", gui(), plist], { ignoreFailure: true });
331
+ await run("launchctl", ["kickstart", `${gui()}/${label}`]);
332
+ },
333
+ async stop(label) {
334
+ // Unloading (rather than killing) is the only way to stop a KeepAlive
335
+ // service without launchd restarting it. The plist stays, so it loads
336
+ // again at next login — same semantics as `systemctl --user stop`.
337
+ await run("launchctl", ["bootout", `${gui()}/${label}`]);
338
+ },
339
+ async restart(label) {
340
+ const plist = launchdPlistPath(label);
341
+ await run("launchctl", ["bootstrap", gui(), plist], { ignoreFailure: true });
342
+ await run("launchctl", ["kickstart", "-k", `${gui()}/${label}`]);
343
+ },
344
+ async status(label) {
345
+ const file = launchdPlistPath(label);
346
+ const installed = await fileExists(file);
347
+ const entry = installed ? entryFromServiceFile(await fs.readFile(file, "utf8")) : null;
348
+ if (!installed) {
349
+ return { installed, file, loaded: false, running: false, pid: null, lastExitCode: null, detail: null, entry };
350
+ }
351
+ const out = await run("launchctl", ["print", `${gui()}/${label}`], { ignoreFailure: true });
352
+ const pid = /^\s*pid = (\d+)/m.exec(out)?.[1];
353
+ const state = /^\s*state = (\w+)/m.exec(out)?.[1] ?? null;
354
+ const exit = /^\s*last exit code = (-?\d+|\(never exited\))/m.exec(out)?.[1];
355
+ return {
356
+ installed,
357
+ file,
358
+ loaded: out.trim().length > 0,
359
+ running: !!pid,
360
+ pid: pid ? Number(pid) : null,
361
+ lastExitCode: exit && exit !== "(never exited)" ? Number(exit) : null,
362
+ detail: state,
363
+ entry,
364
+ };
365
+ },
366
+ async logs(label, { lines, follow }) {
367
+ const file = path.join(LOG_DIR, `${label}.log`);
368
+ if (!(await fileExists(file))) {
369
+ throw new Error(`No log yet at ${file} — the service has not started.`);
370
+ }
371
+ await stream("tail", follow ? ["-n", String(lines), "-f", file] : ["-n", String(lines), file]);
372
+ },
373
+ recentLog: (label, lines) => tailFile(path.join(LOG_DIR, `${label}.log`), lines),
374
+ logHint: (label) => path.join(LOG_DIR, `${label}.log`),
375
+ installNotes: () => [],
376
+ };
377
+ export const systemd = {
378
+ name: "systemd",
379
+ file: (label) => systemdUnitPath(label),
380
+ render: renderSystemdUnit,
381
+ async install(spec) {
382
+ const unit = systemdUnitPath(spec.label);
383
+ await writePrivate(unit, renderSystemdUnit(spec));
384
+ await run("systemctl", ["--user", "daemon-reload"]);
385
+ // `restart` rather than `enable --now` alone: a re-install must load the
386
+ // new flags into an already-running unit.
387
+ await run("systemctl", ["--user", "enable", path.basename(unit)]);
388
+ await run("systemctl", ["--user", "restart", path.basename(unit)]);
389
+ return unit;
390
+ },
391
+ async uninstall(label) {
392
+ const unit = systemdUnitPath(label);
393
+ await run("systemctl", ["--user", "disable", "--now", path.basename(unit)], {
394
+ ignoreFailure: true,
395
+ });
396
+ let removed = true;
397
+ try {
398
+ await fs.unlink(unit);
399
+ }
400
+ catch (err) {
401
+ if (err.code !== "ENOENT")
402
+ throw err;
403
+ removed = false;
404
+ }
405
+ await run("systemctl", ["--user", "daemon-reload"], { ignoreFailure: true });
406
+ return removed;
407
+ },
408
+ start: (label) => run("systemctl", ["--user", "start", `${unitName(label)}.service`]).then(() => undefined),
409
+ stop: (label) => run("systemctl", ["--user", "stop", `${unitName(label)}.service`]).then(() => undefined),
410
+ restart: (label) => run("systemctl", ["--user", "restart", `${unitName(label)}.service`]).then(() => undefined),
411
+ async status(label) {
412
+ const file = systemdUnitPath(label);
413
+ const installed = await fileExists(file);
414
+ const entry = installed ? entryFromServiceFile(await fs.readFile(file, "utf8")) : null;
415
+ if (!installed) {
416
+ return { installed, file, loaded: false, running: false, pid: null, lastExitCode: null, detail: null, entry };
417
+ }
418
+ const out = await run("systemctl", ["--user", "show", `${unitName(label)}.service`, "-p", "ActiveState,SubState,MainPID,ExecMainStatus,UnitFileState"], { ignoreFailure: true });
419
+ const prop = (name) => new RegExp(`^${name}=(.*)$`, "m").exec(out)?.[1]?.trim() ?? null;
420
+ const pid = Number(prop("MainPID") ?? 0);
421
+ const active = prop("ActiveState");
422
+ const sub = prop("SubState");
423
+ const exitStatus = prop("ExecMainStatus");
424
+ const unitFileState = prop("UnitFileState");
425
+ return {
426
+ installed,
427
+ file,
428
+ loaded: unitFileState === "enabled" || unitFileState === "static" || active === "active",
429
+ running: active === "active" && pid > 0,
430
+ pid: pid > 0 ? pid : null,
431
+ lastExitCode: exitStatus !== null && exitStatus !== "" ? Number(exitStatus) : null,
432
+ detail: active ? (sub && sub !== active ? `${active} (${sub})` : active) : null,
433
+ entry,
434
+ };
435
+ },
436
+ async logs(label, { lines, follow }) {
437
+ const args = ["--user", "-u", `${unitName(label)}.service`, "-n", String(lines), "--no-pager"];
438
+ if (follow)
439
+ args.push("-f");
440
+ await stream("journalctl", args);
441
+ },
442
+ recentLog: (label, lines) => run("journalctl", ["--user", "-u", `${unitName(label)}.service`, "-n", String(lines), "--no-pager", "-o", "cat"], {
293
443
  ignoreFailure: true,
294
- });
295
- let removed = true;
296
- try {
297
- await fs.unlink(unit);
444
+ }).then((s) => s.trim()),
445
+ logHint: (label) => `journalctl --user -u ${unitName(label)} -f`,
446
+ installNotes: () => [
447
+ ` Boot: loginctl enable-linger ${os.userInfo().username} # start before anyone logs in`,
448
+ ],
449
+ };
450
+ function nudgeCmd(kind, verb, label) {
451
+ const instance = /^com\.nudge\.(?:runner|agent)\.(.+)$/.exec(label)?.[1];
452
+ return `nudge-mcp service ${verb} ${kind}${instance ? ` --instance ${instance}` : ""}`;
453
+ }
454
+ /** Turn raw facts into the summary, problems and next steps a person needs.
455
+ * Pure, so every branch is unit-testable without a service manager. */
456
+ export function diagnose(input) {
457
+ const { kind, label, service, process, live } = input;
458
+ const problems = [];
459
+ const nextSteps = [];
460
+ let summary;
461
+ let recentLog = null;
462
+ const startCmd = nudgeCmd(kind, "start", label);
463
+ const logsCmd = nudgeCmd(kind, "logs", label);
464
+ const installCmd = kind === "runner"
465
+ ? "nudge-mcp service install runner --cwd <checkout> [--token nrun_… --convex-url …] --save"
466
+ : "nudge-mcp service install agent --no-open";
467
+ if (!service) {
468
+ // No service manager on this platform: only the process report counts.
469
+ if (live) {
470
+ summary = `running in a terminal (pid ${process.pid})`;
471
+ problems.push(`Login services are not supported on ${input.platform}; this process dies with its terminal.`);
472
+ nextSteps.push(`Keep the terminal open, or add a Task Scheduler task that runs \`nudge-mcp ${kind}\` at log-on.`);
473
+ }
474
+ else {
475
+ summary = "not running";
476
+ problems.push(`Login services are not supported on ${input.platform}.`);
477
+ nextSteps.push(`Start it in a terminal: nudge-mcp ${kind}`);
478
+ }
298
479
  }
299
- catch (err) {
300
- if (err.code !== "ENOENT")
301
- throw err;
302
- removed = false;
480
+ else if (!service.installed) {
481
+ if (live) {
482
+ summary = `running in a terminal (pid ${process.pid}), not installed as a service`;
483
+ problems.push("This process will not come back after a reboot or a closed terminal.");
484
+ nextSteps.push(`Install it as a login service: ${installCmd}`);
485
+ }
486
+ else {
487
+ summary = "not installed";
488
+ nextSteps.push(installCmd);
489
+ }
490
+ }
491
+ else if (service.running) {
492
+ if (live) {
493
+ summary = `running (pid ${service.pid})`;
494
+ }
495
+ else if (process && !live) {
496
+ summary = `running (pid ${service.pid}), process has not reported in yet`;
497
+ problems.push("The service is up but the process has not written its status yet (starting, or an older build).");
498
+ nextSteps.push(`Wait a few seconds and run ${nudgeCmd(kind, "status", label)} again; if it stays this way: ${logsCmd}`);
499
+ }
500
+ else {
501
+ summary = `running (pid ${service.pid}), no status report yet`;
502
+ nextSteps.push(`Starting up? Run ${nudgeCmd(kind, "status", label)} again in a few seconds. Still nothing? The service runs a build older than 1.24 — \`${nudgeCmd(kind, "restart", label)}\` loads the installed copy${kind === "agent" ? " (and prints the new pairing URL)" : ""}.`);
503
+ }
504
+ if (input.entryExists === false && service.entry) {
505
+ problems.push(`The installed copy at ${service.entry} no longer exists — the running process is the last one that will start.`);
506
+ nextSteps.push(`Reinstall from the current copy: nudge-mcp service install ${kind} …`);
507
+ }
303
508
  }
304
- await run("systemctl", ["--user", "daemon-reload"], { ignoreFailure: true });
305
- return removed;
509
+ else {
510
+ // Installed, not running.
511
+ if (input.entryExists === false && service.entry) {
512
+ summary = "installed, cannot start";
513
+ problems.push(`The service points at ${service.entry}, which no longer exists (an npm upgrade or uninstall moved it).`);
514
+ nextSteps.push(`Reinstall from the current copy: nudge-mcp service install ${kind} …`);
515
+ }
516
+ else if (!service.loaded) {
517
+ summary = "installed, not loaded in this login session";
518
+ problems.push("The OS has not loaded the service (it was stopped, or installed from another session).");
519
+ nextSteps.push(startCmd);
520
+ }
521
+ else if (service.lastExitCode !== null && service.lastExitCode !== 0) {
522
+ summary = `stopped — last run exited with code ${service.lastExitCode}`;
523
+ problems.push(`The process exited with code ${service.lastExitCode}; the OS is holding it back or has given up.`);
524
+ recentLog = input.recentLog?.trim() ? input.recentLog.trim() : null;
525
+ nextSteps.push(`Fix the error above, then: ${startCmd}`);
526
+ nextSteps.push(`Full log: ${logsCmd}`);
527
+ }
528
+ else {
529
+ summary = `stopped${service.detail ? ` (${service.detail})` : ""}`;
530
+ nextSteps.push(startCmd);
531
+ }
532
+ }
533
+ return {
534
+ kind,
535
+ label,
536
+ platform: input.platform,
537
+ manager: input.manager,
538
+ service,
539
+ process,
540
+ live,
541
+ summary,
542
+ problems,
543
+ nextSteps,
544
+ recentLog,
545
+ };
306
546
  }
307
- async function systemdStatus(label) {
308
- const unit = systemdUnitPath(label);
309
- const installed = await fs.access(unit).then(() => true, () => false);
310
- if (!installed)
311
- return "not installed";
312
- const out = await run("systemctl", ["--user", "is-active", path.basename(unit)], {
313
- ignoreFailure: true,
314
- });
315
- return out.trim() || "inactive";
547
+ function ago(at, now) {
548
+ const s = Math.max(0, Math.round((now - at) / 1000));
549
+ if (s < 60)
550
+ return `${s}s ago`;
551
+ const m = Math.floor(s / 60);
552
+ if (m < 60)
553
+ return `${m}m ago`;
554
+ const h = Math.floor(m / 60);
555
+ if (h < 48)
556
+ return `${h}h ${m % 60}m ago`;
557
+ return `${Math.floor(h / 24)}d ago`;
558
+ }
559
+ function short(id) {
560
+ return id.length > 12 ? `${id.slice(0, 8)}…` : id;
561
+ }
562
+ /** Render one service's block for humans. */
563
+ export function formatStatus(report, opts = {}) {
564
+ const now = opts.now ?? Date.now();
565
+ const { kind, label, service, process: p, live } = report;
566
+ const lines = [];
567
+ lines.push(`${kind.padEnd(7)} ${label}`);
568
+ lines.push(` Service: ${report.manager ?? "none"} · ${report.summary}${service?.installed ? ` · ${service.file}` : ""}`);
569
+ if (p && live) {
570
+ const managed = p.service ? `managed by ${p.service.manager}` : p.supervised ? "auto-restart on rebuild" : "foreground";
571
+ lines.push(` Process: nudge-mcp ${p.version} · pid ${p.pid} · started ${ago(p.startedAt, now)} · ${managed}`);
572
+ lines.push(` Machine: ${p.hostname}${p.kind === "runner" ? ` · id ${short(p.machineId)}${p.instance ? ` · instance ${p.instance}` : ""}` : ""}`);
573
+ if (p.kind === "runner") {
574
+ lines.push(` Board: ${p.board.title}${p.board.repo ? ` (${p.board.repo})` : ""} · ${p.convexUrl}`);
575
+ lines.push(` Checkout: ${p.cwd}`);
576
+ lines.push(` Agents: ${p.engines.length ? p.engines.join(", ") : "none detected"} · up to ${p.concurrency} job${p.concurrency === 1 ? "" : "s"} at once`);
577
+ }
578
+ else {
579
+ lines.push(` Listener: http://127.0.0.1:${p.port} · app ${p.appUrl}`);
580
+ lines.push(` Workspace: ${p.cwd}`);
581
+ lines.push(` Agents: ${p.providers.length ? p.providers.join(", ") : "none ready"}`);
582
+ lines.push(` Pairing: ${p.pairingUrl}`);
583
+ lines.push(" (current — open it in the browser where Nudge is signed in)");
584
+ }
585
+ }
586
+ else if (p && !live) {
587
+ lines.push(` Process: last reported nudge-mcp ${p.version} on ${p.hostname}, ${ago(p.updatedAt ?? p.startedAt, now)} (pid ${p.pid} is gone)`);
588
+ if (p.kind === "agent")
589
+ lines.push(" Pairing: none current — a stopped companion's URL is not shown; start it and run status again");
590
+ }
591
+ if (report.recentLog) {
592
+ lines.push(" Last log lines:");
593
+ for (const l of report.recentLog.split("\n").slice(-12))
594
+ lines.push(` ${l}`);
595
+ }
596
+ for (const problem of report.problems)
597
+ lines.push(` ⚠ ${problem}`);
598
+ if (report.nextSteps.length) {
599
+ lines.push(` ${report.nextSteps.length === 1 ? "Next: " : "Next:"}${report.nextSteps.length === 1 ? ` ${report.nextSteps[0]}` : ""}`);
600
+ if (report.nextSteps.length > 1)
601
+ for (const step of report.nextSteps)
602
+ lines.push(` - ${step}`);
603
+ }
604
+ if (opts.logHint && (service?.installed || live))
605
+ lines.push(` Logs: ${nudgeCmd(kind, "logs", label)} (${opts.logHint})`);
606
+ return lines.join("\n");
316
607
  }
317
608
  // ---- CLI ------------------------------------------------------------------
318
609
  export const SERVICE_USAGE = [
319
610
  "Usage:",
320
611
  " nudge-mcp service install runner [runner options] Start the runner at login and keep it alive.",
321
612
  " nudge-mcp service install agent [agent options] Same for the Nudge Agent companion.",
322
- " nudge-mcp service uninstall runner|agent [--instance NAME]",
323
- " nudge-mcp service status",
613
+ " nudge-mcp service uninstall runner|agent Stop it and remove the login service.",
614
+ " nudge-mcp service start|stop|restart runner|agent Control the installed service now.",
615
+ " nudge-mcp service status [runner|agent] [--json] State, version, machine, board / pairing URL, and what to do next.",
616
+ " nudge-mcp service logs runner|agent [-n N] [-f] Show (or follow) the service log.",
324
617
  "",
325
- "Options after the kind are passed to the runner/agent unchanged (a relative",
326
- "--cwd is pinned to the current directory). Add --dry-run to print the service",
327
- "file without installing it. macOS uses a launchd LaunchAgent, Linux a systemd",
328
- "--user unit; both restart the process after a crash and at every login.",
618
+ "Every verb accepts --instance NAME for a second runner installed with --instance.",
619
+ "Options after `install <kind>` are passed to the runner/agent unchanged (a relative",
620
+ "--cwd is pinned to the current directory). Add --dry-run to print the service file",
621
+ "without installing it. macOS uses a launchd LaunchAgent, Linux a systemd --user unit;",
622
+ "both start the process at login and restart it after a crash. `stop` keeps the",
623
+ "service installed (it returns at next login); `uninstall` removes it for good.",
329
624
  ].join("\n");
330
- function unsupported() {
331
- throw new Error([
332
- `Login services are supported on macOS (launchd) and Linux (systemd --user); this is ${process.platform}.`,
625
+ export const SERVICE_ACTIONS = [
626
+ "install",
627
+ "uninstall",
628
+ "start",
629
+ "stop",
630
+ "restart",
631
+ "status",
632
+ "logs",
633
+ ];
634
+ function unsupportedMessage(platform, kind, node, entry) {
635
+ return [
636
+ `Login services are supported on macOS (launchd) and Linux (systemd --user); this is ${platform}.`,
333
637
  "On Windows, create a Task Scheduler task that runs at log-on with the action:",
334
- ` "${process.execPath}" "${process.argv[1]}" runner`,
335
- "and set it to restart on failure.",
336
- ].join("\n"));
638
+ ` "${node}" "${entry}" ${kind}`,
639
+ "and set it to restart on failure. `nudge-mcp service status` still reports a",
640
+ `${kind} started from a terminal.`,
641
+ ].join("\n");
337
642
  }
338
- function parseKind(raw) {
643
+ function parseKind(raw, action) {
339
644
  if (raw === "runner" || raw === "agent")
340
645
  return raw;
341
- throw new Error(`Expected "runner" or "agent" after the service action, got ${raw ? `"${raw}"` : "nothing"}.\n\n${SERVICE_USAGE}`);
646
+ throw new Error(`Expected "runner" or "agent" after \`service ${action}\`, got ${raw ? `"${raw}"` : "nothing"}.\n\n${SERVICE_USAGE}`);
342
647
  }
343
- export async function runService(argv) {
344
- const action = argv[0];
648
+ function parseLines(argv) {
649
+ const raw = findFlag(argv, "--lines") ?? findFlag(argv, "-n");
650
+ const n = raw === null ? 60 : Number(raw);
651
+ if (!Number.isInteger(n) || n <= 0)
652
+ throw new Error(`--lines must be a positive integer, got "${raw}".`);
653
+ return n;
654
+ }
655
+ export async function defaultDeps() {
345
656
  const platform = process.platform;
657
+ const managerName = managerForPlatform(platform);
658
+ return {
659
+ platform,
660
+ manager: managerName === "launchd" ? launchd : managerName === "systemd" ? systemd : null,
661
+ node: process.execPath,
662
+ entry: await fs.realpath(process.argv[1]),
663
+ cwd: process.cwd(),
664
+ env: process.env,
665
+ hostname: os.hostname(),
666
+ now: Date.now,
667
+ out: (line) => console.log(line),
668
+ warn: (line) => console.warn(line),
669
+ readState: (label) => readProcessState(label),
670
+ isLive: (state) => isStateLive(state),
671
+ waitForState: (label, since) => waitForProcessState(label, { since }),
672
+ entryExists: fileExists,
673
+ };
674
+ }
675
+ async function buildReport(kind, label, deps) {
676
+ const service = deps.manager ? await deps.manager.status(label) : null;
677
+ const state = await deps.readState(label);
678
+ const live = !!state && deps.isLive(state);
679
+ const entryExists = service?.entry ? await deps.entryExists(service.entry) : null;
680
+ const needsLog = !!service && service.installed && !service.running && service.lastExitCode !== null && service.lastExitCode !== 0;
681
+ const recentLog = needsLog && deps.manager ? await deps.manager.recentLog(label, 12) : null;
682
+ return diagnose({ kind, label, platform: deps.platform, manager: deps.manager?.name ?? null, service, process: state, live, entryExists, recentLog });
683
+ }
684
+ export async function runService(argv, overrides = {}) {
685
+ const deps = { ...(await defaultDeps()), ...overrides };
686
+ const action = argv[0];
687
+ const { out, manager } = deps;
688
+ if (action === undefined || action === "help" || action === "--help" || action === "-h") {
689
+ out(SERVICE_USAGE);
690
+ return;
691
+ }
692
+ if (!SERVICE_ACTIONS.includes(action)) {
693
+ throw new Error(`Unknown service action "${action}".\n\n${SERVICE_USAGE}`);
694
+ }
695
+ // ---- status -------------------------------------------------------------
346
696
  if (action === "status") {
347
- if (platform !== "darwin" && platform !== "linux")
348
- unsupported();
349
- const rows = [];
350
- for (const kind of SERVICE_KINDS) {
351
- const label = serviceLabel(kind);
352
- const status = platform === "darwin" ? await launchdStatus(label) : await systemdStatus(label);
353
- rows.push(` ${kind.padEnd(7)} ${label.padEnd(20)} ${status}`);
697
+ const rest = argv.slice(1);
698
+ const json = rest.includes("--json");
699
+ const only = rest[0] === "runner" || rest[0] === "agent" ? rest[0] : null;
700
+ const instance = findFlag(rest, "--instance");
701
+ const kinds = only ? [only] : [...SERVICE_KINDS];
702
+ const reports = await Promise.all(kinds.map((kind) => {
703
+ const label = serviceLabel(kind, kind === "runner" ? instance : null);
704
+ return buildReport(kind, label, deps);
705
+ }));
706
+ if (json) {
707
+ out(JSON.stringify({ hostname: deps.hostname, platform: deps.platform, nudgeMcp: NUDGE_MCP_VERSION, services: reports }, null, 2));
708
+ return;
354
709
  }
355
- console.log(["Nudge login services:", ...rows].join("\n"));
356
- if (platform === "darwin")
357
- console.log(`\nLogs: ${LOG_DIR}/<label>.log`);
358
- else
359
- console.log("\nLogs: journalctl --user -u nudge-runner -f");
710
+ const platformName = deps.platform === "darwin" ? "macOS" : deps.platform;
711
+ out(`Nudge services on ${deps.hostname} (${platformName}, nudge-mcp ${NUDGE_MCP_VERSION})`);
712
+ out("");
713
+ for (const report of reports) {
714
+ out(formatStatus(report, { now: deps.now(), logHint: manager ? manager.logHint(report.label) : null }));
715
+ out("");
716
+ }
717
+ if (!manager)
718
+ out(unsupportedMessage(deps.platform, only ?? "runner", deps.node, deps.entry));
360
719
  return;
361
720
  }
362
- if (action === "uninstall") {
363
- const kind = parseKind(argv[1]);
364
- const label = serviceLabel(kind, findFlag(argv.slice(2), "--instance"));
365
- if (platform !== "darwin" && platform !== "linux")
366
- unsupported();
367
- const removed = platform === "darwin" ? await launchdUninstall(label) : await systemdUninstall(label);
368
- console.log(removed
369
- ? `Removed ${label}. The ${kind} no longer starts at login; a running instance was stopped.`
370
- : `${label} was not installed.`);
721
+ const kind = parseKind(argv[1], action);
722
+ const rest = argv.slice(2);
723
+ // ---- logs ---------------------------------------------------------------
724
+ if (action === "logs") {
725
+ if (!manager)
726
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
727
+ const label = serviceLabel(kind, kind === "runner" ? findFlag(rest, "--instance") : null);
728
+ await manager.logs(label, { lines: parseLines(rest), follow: rest.includes("--follow") || rest.includes("-f") });
371
729
  return;
372
730
  }
373
- if (action === "install") {
374
- const kind = parseKind(argv[1]);
375
- const rest = argv.slice(2);
376
- const dryRun = rest.includes("--dry-run");
377
- const passthrough = rest.filter((a) => a !== "--dry-run");
378
- const entry = await fs.realpath(process.argv[1]);
379
- const spec = buildSpec(kind, passthrough, {
380
- node: process.execPath,
381
- entry,
382
- cwd: process.cwd(),
383
- });
384
- if (platform !== "darwin" && platform !== "linux")
385
- unsupported();
386
- const rendered = platform === "darwin" ? renderLaunchdPlist(spec) : renderSystemdUnit(spec);
387
- const target = platform === "darwin" ? launchdPlistPath(spec.label) : systemdUnitPath(spec.label);
388
- if (dryRun) {
389
- console.log(`# ${target}\n${rendered}`);
390
- return;
731
+ // ---- start / stop / restart ---------------------------------------------
732
+ if (action === "start" || action === "stop" || action === "restart") {
733
+ if (!manager)
734
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
735
+ const label = serviceLabel(kind, kind === "runner" ? findFlag(rest, "--instance") : null);
736
+ const before = await manager.status(label);
737
+ if (!before.installed) {
738
+ throw new Error(`${label} is not installed, so there is nothing to ${action}.\n` +
739
+ `Install it first: nudge-mcp service install ${kind}${kind === "agent" ? " --no-open" : " …"}`);
391
740
  }
392
- if (isNpxCachePath(entry)) {
393
- console.warn([
394
- `[nudge-mcp service] warning: this copy lives in npm's npx cache (${entry}).`,
395
- " npm may prune it, which would break the service on a later boot.",
396
- " Prefer: npm install -g @euqns/nudge-mcp && nudge-mcp service install " + kind,
397
- ].join("\n"));
741
+ if (before.entry && !(await deps.entryExists(before.entry))) {
742
+ throw new Error(`${label} points at ${before.entry}, which no longer exists (an npm upgrade or uninstall moved it).\n` +
743
+ `Reinstall from the current copy: nudge-mcp service install ${kind} …`);
398
744
  }
399
- const written = platform === "darwin" ? await launchdInstall(spec) : await systemdInstall(spec);
400
- const lines = [
401
- `Installed ${spec.label} ${written}`,
402
- ` Runs: ${[spec.kind, ...spec.args].join(" ")}`,
403
- ` Directory: ${spec.cwd}`,
404
- " Starts at login and restarts after a crash. It is starting now.",
405
- ];
406
- if (platform === "darwin") {
407
- lines.push(` Logs: ${spec.logPath}`);
408
- lines.push(` Stop: launchctl bootout ${gui()}/${spec.label}`);
409
- }
410
- else {
411
- lines.push(` Logs: journalctl --user -u ${unitName(spec.label)} -f`);
412
- lines.push(` Boot: loginctl enable-linger ${os.userInfo().username} # start before anyone logs in`);
745
+ if (action === "stop") {
746
+ await manager.stop(label);
747
+ out(`Stopped ${label}. It stays installed and starts again at next login; \`${nudgeCmd(kind, "start", label)}\` brings it back now.`);
748
+ return;
413
749
  }
414
- if (kind === "agent" && !passthrough.includes("--no-open")) {
415
- lines.push(" Note: the companion opens its pairing page in the browser at every login; add --no-open to skip that.");
750
+ const since = deps.now();
751
+ if (action === "start")
752
+ await manager.start(label);
753
+ else
754
+ await manager.restart(label);
755
+ out(`${action === "start" ? "Started" : "Restarted"} ${label}. Waiting for it to report in…`);
756
+ const state = await deps.waitForState(label, since);
757
+ if (state) {
758
+ out(describeFreshState(state));
759
+ return;
416
760
  }
417
- lines.push(` Remove: nudge-mcp service uninstall ${kind}`);
418
- console.log(lines.join("\n"));
761
+ // It did not report in: say why, right here.
762
+ const report = await buildReport(kind, label, deps);
763
+ out(formatStatus(report, { now: deps.now(), logHint: manager.logHint(label) }));
764
+ if (!report.service?.running)
765
+ throw new Error(`${label} did not stay running.`);
419
766
  return;
420
767
  }
421
- throw new Error(SERVICE_USAGE);
768
+ // ---- uninstall ----------------------------------------------------------
769
+ if (action === "uninstall") {
770
+ if (!manager)
771
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
772
+ const label = serviceLabel(kind, kind === "runner" ? findFlag(rest, "--instance") : null);
773
+ const removed = await manager.uninstall(label);
774
+ out(removed
775
+ ? `Removed ${label}. The ${kind} no longer starts at login; a running instance was stopped.`
776
+ : `${label} was not installed.`);
777
+ return;
778
+ }
779
+ // ---- install ------------------------------------------------------------
780
+ const dryRun = rest.includes("--dry-run");
781
+ const passthrough = rest.filter((a) => a !== "--dry-run");
782
+ const spec = buildSpec(kind, passthrough, {
783
+ node: deps.node,
784
+ entry: deps.entry,
785
+ cwd: deps.cwd,
786
+ env: deps.env,
787
+ manager: manager?.name ?? null,
788
+ });
789
+ if (!manager)
790
+ throw new Error(unsupportedMessage(deps.platform, kind, deps.node, deps.entry));
791
+ if (dryRun) {
792
+ out(`# ${manager.file(spec.label)}\n${manager.render(spec)}`);
793
+ return;
794
+ }
795
+ if (isNpxCachePath(deps.entry)) {
796
+ deps.warn([
797
+ `[nudge-mcp service] warning: this copy lives in npm's npx cache (${deps.entry}).`,
798
+ " npm may prune it, which would break the service on a later boot.",
799
+ " Prefer: npm install -g @euqns/nudge-mcp && nudge-mcp service install " + kind,
800
+ ].join("\n"));
801
+ }
802
+ const since = deps.now();
803
+ const written = await manager.install(spec);
804
+ const lines = [
805
+ `Installed ${spec.label} → ${written}`,
806
+ ` Runs: ${[spec.kind, ...spec.args].join(" ")}`,
807
+ ` Directory: ${spec.cwd}`,
808
+ " Starts at login and restarts after a crash. It is starting now.",
809
+ ` Logs: ${nudgeCmd(kind, "logs", spec.label)} (${manager.logHint(spec.label)})`,
810
+ ` Status: ${nudgeCmd(kind, "status", spec.label)}`,
811
+ ` Control: nudge-mcp service start|stop|restart ${kind}`,
812
+ ...manager.installNotes(spec),
813
+ ];
814
+ if (kind === "agent" && !passthrough.includes("--no-open")) {
815
+ lines.push(" Note: the companion opens its pairing page in the browser at every login; add --no-open to skip that.");
816
+ }
817
+ lines.push(` Remove: ${nudgeCmd(kind, "uninstall", spec.label)}`);
818
+ out(lines.join("\n"));
819
+ // Wait for THIS start to report in — the state file carries the pairing URL
820
+ // (agent) or the registration (runner), so nobody has to open a log. Only a
821
+ // report written after `since` counts: a URL from a previous run is stale.
822
+ out("");
823
+ out(kind === "agent" ? "Waiting for the pairing URL…" : "Waiting for the runner to register…");
824
+ const state = await deps.waitForState(spec.label, since);
825
+ if (state) {
826
+ out(describeFreshState(state));
827
+ return;
828
+ }
829
+ const report = await buildReport(kind, spec.label, deps);
830
+ out("");
831
+ out(formatStatus(report, { now: deps.now(), logHint: manager.logHint(spec.label) }));
832
+ if (!report.service?.running)
833
+ throw new Error(`${spec.label} did not stay running — see above.`);
834
+ }
835
+ /** What to tell the user once a freshly (re)started process has reported in. */
836
+ export function describeFreshState(state) {
837
+ if (state.kind === "agent") {
838
+ return [
839
+ `Nudge Agent is running (nudge-mcp ${state.version} on ${state.hostname}, pid ${state.pid}).`,
840
+ ` Agents: ${state.providers.length ? state.providers.join(", ") : "none ready — sign in to codex, claude or cursor-agent"}`,
841
+ ` Listener: http://127.0.0.1:${state.port}`,
842
+ "",
843
+ "Open this pairing URL in the browser where Nudge is signed in:",
844
+ state.pairingUrl,
845
+ "",
846
+ "A rebuild restart keeps this pairing. A reboot or crash restart mints a new one —",
847
+ "`nudge-mcp service status agent` always prints the current URL.",
848
+ ].join("\n");
849
+ }
850
+ return [
851
+ `Runner is registered as "${state.name}" on ${state.board.title}${state.board.repo ? ` (${state.board.repo})` : ""}.`,
852
+ ` Version: nudge-mcp ${state.version} on ${state.hostname} · pid ${state.pid}`,
853
+ ` Checkout: ${state.cwd}`,
854
+ ` Agents: ${state.engines.length ? state.engines.join(", ") : "none detected"} · up to ${state.concurrency} job${state.concurrency === 1 ? "" : "s"} at once`,
855
+ "The board's Agent View shows this machine as online while it heartbeats.",
856
+ ].join("\n");
422
857
  }
423
858
  //# sourceMappingURL=service.js.map