@hellcoder/companion 0.124.0 → 0.125.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. package/bin/cli.ts +9 -1
  2. package/dist/assets/{AgentsPage-Byq8wvdn.js → AgentsPage-0Fi6tbQN.js} +1 -1
  3. package/dist/assets/{CronManager-Bq2lOMbZ.js → CronManager-DNN709nr.js} +1 -1
  4. package/dist/assets/{DashboardPage-V77HIt8S.js → DashboardPage-6cNRrqGQ.js} +1 -1
  5. package/dist/assets/{IntegrationsPage-C3aW9cDI.js → IntegrationsPage-DrE-8KPE.js} +1 -1
  6. package/dist/assets/{LinearOAuthSettingsPage-_eAbciJN.js → LinearOAuthSettingsPage-Czi_NsX1.js} +1 -1
  7. package/dist/assets/{LinearSettingsPage-DDHFHt7j.js → LinearSettingsPage-C1RHlPwX.js} +1 -1
  8. package/dist/assets/{MagicUIDashboard-Cl18AKjV.js → MagicUIDashboard-BOZc4rKn.js} +1 -1
  9. package/dist/assets/{MagicUIView-C0aiMTet.js → MagicUIView-BVM8CqGg.js} +1 -1
  10. package/dist/assets/{Playground-CvoHRMBd.js → Playground-DWAal5bL.js} +1 -1
  11. package/dist/assets/{PromptsPage-CzTs1_Se.js → PromptsPage-D2YI_-95.js} +1 -1
  12. package/dist/assets/{RunsPage-B7xru-Xy.js → RunsPage-dn5rSkPu.js} +1 -1
  13. package/dist/assets/{SandboxManager-C3tFkiWz.js → SandboxManager-vWbQKeLT.js} +1 -1
  14. package/dist/assets/{SettingsPage-BZ2fBrHD.js → SettingsPage-CrV1Tb8F.js} +1 -1
  15. package/dist/assets/{TailscalePage-DFRiFKcb.js → TailscalePage-ba0g8QT1.js} +1 -1
  16. package/dist/assets/{index-C7nK12tB.js → index-e4RnFbra.js} +4 -4
  17. package/dist/assets/{sw-register-Dc72vh_Y.js → sw-register-BBun0drK.js} +1 -1
  18. package/dist/index.html +1 -1
  19. package/dist/sw.js +1 -1
  20. package/package.json +1 -1
  21. package/server/index.ts +2 -2
  22. package/server/routes/system-routes.test.ts +57 -0
  23. package/server/routes/system-routes.ts +20 -24
  24. package/server/service.test.ts +232 -0
  25. package/server/service.ts +286 -23
  26. package/server/settings-manager.ts +1 -0
package/server/service.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  import { join } from "node:path";
9
9
  import { homedir } from "node:os";
10
10
  import { execSync } from "node:child_process";
11
+ import { createServer } from "node:net";
11
12
  import { DEFAULT_PORT_PROD } from "./constants.js";
12
13
  import { getServicePath } from "./path-resolver.js";
13
14
  import { COMPANION_HOME } from "./paths.js";
@@ -32,6 +33,16 @@ const SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
32
33
  const UNIT_NAME = "the-companion.service";
33
34
  const UNIT_PATH = join(SYSTEMD_DIR, UNIT_NAME);
34
35
 
36
+ /**
37
+ * Exit code the server uses to ask an external supervisor (a system-wide
38
+ * systemd unit, or anything configured via COMPANION_SERVICE_MODE=1) to
39
+ * restart it after an in-app update. It must be non-zero so that units with
40
+ * `Restart=on-failure` respawn the process, and it deliberately differs from
41
+ * 42, which our own user-unit template lists in `SuccessExitStatus=`.
42
+ * 75 is EX_TEMPFAIL from sysexits.h ("temporary failure, try again").
43
+ */
44
+ export const UPDATE_RESTART_EXIT_CODE = 75;
45
+
35
46
  // ─── Platform check ─────────────────────────────────────────────────────────────
36
47
 
37
48
  function ensureSupportedPlatform(): void {
@@ -225,6 +236,114 @@ function systemctlUser(cmd: string): string {
225
236
  });
226
237
  }
227
238
 
239
+ /**
240
+ * Run a command against the system-wide systemd instance (no `--user`).
241
+ * Read-only queries (show / is-active) work unprivileged; mutating commands
242
+ * need root or a polkit rule.
243
+ */
244
+ function systemctlSystem(cmd: string): string {
245
+ return execSync(`systemctl ${cmd}`, {
246
+ encoding: "utf-8",
247
+ stdio: ["pipe", "pipe", "pipe"],
248
+ });
249
+ }
250
+
251
+ export interface SystemUnitInfo {
252
+ activeState: string;
253
+ mainPid?: number;
254
+ invocationId?: string;
255
+ fragmentPath?: string;
256
+ port?: number;
257
+ }
258
+
259
+ /**
260
+ * Look up a system-wide `the-companion.service` (e.g. one an admin placed in
261
+ * /etc/systemd/system with `User=...`). Returns undefined when no such unit
262
+ * is loaded or systemctl is unavailable. Never throws.
263
+ */
264
+ export function getSystemUnitInfo(): SystemUnitInfo | undefined {
265
+ if (!isLinux()) return undefined;
266
+ let output: string;
267
+ try {
268
+ output = systemctlSystem(
269
+ `show ${UNIT_NAME} --property=LoadState,ActiveState,MainPID,InvocationID,FragmentPath,Environment --no-pager`,
270
+ );
271
+ } catch {
272
+ return undefined;
273
+ }
274
+ if (typeof output !== "string") return undefined;
275
+ const prop = (name: string): string | undefined => {
276
+ const m = output.match(new RegExp(`^${name}=(.*)$`, "m"));
277
+ return m ? m[1].trim() : undefined;
278
+ };
279
+ // LoadState=not-found is what systemd reports for units that don't exist.
280
+ if (prop("LoadState") !== "loaded") return undefined;
281
+
282
+ const pid = Number(prop("MainPID"));
283
+ const portMatch = prop("Environment")?.match(/(?:^|\s)PORT=(\d+)/);
284
+ return {
285
+ activeState: prop("ActiveState") || "unknown",
286
+ mainPid: Number.isFinite(pid) && pid > 0 ? pid : undefined,
287
+ invocationId: prop("InvocationID") || undefined,
288
+ fragmentPath: prop("FragmentPath") || undefined,
289
+ port: portMatch ? Number(portMatch[1]) : undefined,
290
+ };
291
+ }
292
+
293
+ /** Explains how to manage a system-wide unit, which we can't do for the user. */
294
+ function printSystemUnitHint(action: string, info: SystemUnitInfo): void {
295
+ console.log("The Companion is managed by a system-wide systemd unit:");
296
+ console.log(` Unit: ${info.fragmentPath ?? UNIT_NAME}`);
297
+ console.log(` State: ${info.activeState}`);
298
+ console.log("");
299
+ console.log(`Use: sudo systemctl ${action} ${UNIT_NAME}`);
300
+ }
301
+
302
+ function isRoot(): boolean {
303
+ return typeof process.getuid === "function" && process.getuid() === 0;
304
+ }
305
+
306
+ /**
307
+ * Handle start/stop/restart when only a system-wide unit exists. As root we
308
+ * can drive it directly; otherwise print the sudo command and fail, rather
309
+ * than falling through to `systemctl --user` (misleading) or, for `start`,
310
+ * installing a second, conflicting user unit.
311
+ */
312
+ function controlSystemUnit(action: "start" | "stop" | "restart", info: SystemUnitInfo): void {
313
+ if (isRoot()) {
314
+ try {
315
+ systemctlSystem(`${action} ${UNIT_NAME}`);
316
+ } catch (err: unknown) {
317
+ console.error(`Failed to ${action} the system service with systemctl:`);
318
+ console.error(err instanceof Error ? err.message : String(err));
319
+ process.exit(1);
320
+ }
321
+ const past = action === "stop" ? "stopped" : action === "start" ? "started" : "restarted";
322
+ console.log(`The Companion system service has been ${past}.`);
323
+ return;
324
+ }
325
+ printSystemUnitHint(action, info);
326
+ process.exit(1);
327
+ }
328
+
329
+ /**
330
+ * Resolve true when something is already listening on `port`. Binding with
331
+ * no host uses the dual-stack wildcard, which conflicts with a listener on
332
+ * any address, so this also catches servers bound to 0.0.0.0 or 127.0.0.1.
333
+ */
334
+ export function isPortInUse(port: number): Promise<boolean> {
335
+ return new Promise((resolve) => {
336
+ const server = createServer();
337
+ server.once("error", (err: NodeJS.ErrnoException) => {
338
+ resolve(err.code === "EADDRINUSE");
339
+ });
340
+ server.once("listening", () => {
341
+ server.close(() => resolve(false));
342
+ });
343
+ server.listen(port);
344
+ });
345
+ }
346
+
228
347
  // ─── Install ────────────────────────────────────────────────────────────────────
229
348
 
230
349
  export async function install(opts?: { port?: number }): Promise<void> {
@@ -285,9 +404,31 @@ async function installLinux(opts?: { port?: number }): Promise<void> {
285
404
  process.exit(1);
286
405
  }
287
406
 
407
+ // A system-wide unit already supervises the Companion on this host. A
408
+ // second (user) unit would fight it for the port and crash-loop, or, if it
409
+ // ever won, start with the wrong environment and an empty session store.
410
+ const systemUnit = getSystemUnitInfo();
411
+ if (systemUnit) {
412
+ console.error("The Companion is already installed as a system-wide systemd unit:");
413
+ console.error(` ${systemUnit.fragmentPath ?? UNIT_NAME} (${systemUnit.activeState})`);
414
+ console.error("");
415
+ console.error("Refusing to install a second, per-user unit.");
416
+ console.error(`Manage it with: sudo systemctl <start|stop|restart|status> ${UNIT_NAME}`);
417
+ console.error("In-app updates work there too (see COMPANION_SERVICE_MODE in the docs).");
418
+ process.exit(1);
419
+ }
420
+
288
421
  const binPath = resolveBinPath();
289
422
  const port = opts?.port ?? DEFAULT_PORT_PROD;
290
423
 
424
+ if (await isPortInUse(port)) {
425
+ console.error(`Port ${port} is already in use.`);
426
+ console.error("Another Companion (or something else) is listening there, so the");
427
+ console.error("service would crash-loop. Stop it first, or install on another port:");
428
+ console.error(" the-companion install --port <port>");
429
+ process.exit(1);
430
+ }
431
+
291
432
  // Create log directory
292
433
  mkdirSync(LOG_DIR, { recursive: true });
293
434
 
@@ -356,6 +497,16 @@ async function uninstallDarwin(): Promise<void> {
356
497
 
357
498
  async function uninstallLinux(): Promise<void> {
358
499
  if (!isSystemdUnitInstalled()) {
500
+ const systemUnit = getSystemUnitInfo();
501
+ if (systemUnit) {
502
+ // Never remove an admin-managed unit on the user's behalf.
503
+ console.log("The Companion is managed by a system-wide systemd unit, which");
504
+ console.log("'the-companion uninstall' does not remove. To remove it:");
505
+ console.log(` sudo systemctl disable --now ${UNIT_NAME}`);
506
+ console.log(` sudo rm ${systemUnit.fragmentPath ?? `/etc/systemd/system/${UNIT_NAME}`}`);
507
+ console.log(" sudo systemctl daemon-reload");
508
+ return;
509
+ }
359
510
  console.log("The Companion is not installed as a service.");
360
511
  return;
361
512
  }
@@ -428,6 +579,8 @@ async function startDarwin(): Promise<void> {
428
579
 
429
580
  async function startLinux(): Promise<void> {
430
581
  if (!isSystemdUnitInstalled()) {
582
+ const systemUnit = getSystemUnitInfo();
583
+ if (systemUnit) return controlSystemUnit("start", systemUnit);
431
584
  console.log("Service not yet installed. Installing now...");
432
585
  await installLinux();
433
586
  return; // installLinux uses enable --now which starts the service
@@ -484,6 +637,8 @@ async function stopDarwin(): Promise<void> {
484
637
 
485
638
  async function stopLinux(): Promise<void> {
486
639
  if (!isSystemdUnitInstalled()) {
640
+ const systemUnit = getSystemUnitInfo();
641
+ if (systemUnit) return controlSystemUnit("stop", systemUnit);
487
642
  console.log("The Companion is not installed as a service.");
488
643
  return;
489
644
  }
@@ -540,6 +695,8 @@ async function restartDarwin(): Promise<void> {
540
695
 
541
696
  async function restartLinux(): Promise<void> {
542
697
  if (!isSystemdUnitInstalled()) {
698
+ const systemUnit = getSystemUnitInfo();
699
+ if (systemUnit) return controlSystemUnit("restart", systemUnit);
543
700
  console.log("The Companion is not installed as a service.");
544
701
  return;
545
702
  }
@@ -565,38 +722,135 @@ export interface ServiceStatus {
565
722
  running: boolean;
566
723
  pid?: number;
567
724
  port?: number;
725
+ /** Linux only: "system" when managed by a system-wide unit. */
726
+ scope?: "user" | "system";
568
727
  }
569
728
 
570
729
  /**
571
- * Safe check for whether the current process is running as a managed service.
572
- * Unlike status(), this never calls process.exit() and works on all platforms.
730
+ * How the current process is supervised, which decides how an in-app update
731
+ * restarts it:
732
+ * - "launchd" / "systemd-user": our own `the-companion install` units; the
733
+ * updater asks the service manager to restart us.
734
+ * - "systemd-system": a system-wide `the-companion.service` (typically with
735
+ * `User=...`), which an unprivileged process can't restart, so the updater
736
+ * exits with UPDATE_RESTART_EXIT_CODE and lets `Restart=` respawn it.
737
+ * - "external": COMPANION_SERVICE_MODE=1, i.e. the operator promises some
738
+ * supervisor restarts the process on a non-zero exit. Same exit strategy.
573
739
  */
574
- export function isRunningAsService(): boolean {
740
+ export type ServiceKind = "launchd" | "systemd-user" | "systemd-system" | "external";
741
+
742
+ function serviceModeEnv(): "on" | "off" | undefined {
743
+ const raw = process.env.COMPANION_SERVICE_MODE?.trim().toLowerCase();
744
+ if (!raw) return undefined;
745
+ if (["1", "true", "yes", "on"].includes(raw)) return "on";
746
+ if (["0", "false", "no", "off"].includes(raw)) return "off";
747
+ return undefined;
748
+ }
749
+
750
+ /**
751
+ * Detect how (and whether) the current process runs as a managed service.
752
+ * Never calls process.exit() and never throws.
753
+ *
754
+ * COMPANION_SERVICE_MODE=0 forces service mode off; =1 forces it on when no
755
+ * known service manager is detected.
756
+ */
757
+ export function detectServiceKind(): ServiceKind | null {
758
+ const envMode = serviceModeEnv();
759
+ if (envMode === "off") return null;
760
+
575
761
  if (isDarwin()) {
576
762
  const installedService = getInstalledLaunchdService();
577
- if (!installedService) return false;
578
- try {
579
- const output = execSync(`launchctl list "${installedService.label}"`, {
580
- encoding: "utf-8",
581
- stdio: ["pipe", "pipe", "pipe"],
582
- });
583
- return /"PID"\s*=\s*\d+/.test(output);
584
- } catch {
585
- return false;
763
+ if (installedService) {
764
+ try {
765
+ const output = execSync(`launchctl list "${installedService.label}"`, {
766
+ encoding: "utf-8",
767
+ stdio: ["pipe", "pipe", "pipe"],
768
+ });
769
+ if (/"PID"\s*=\s*\d+/.test(output)) return "launchd";
770
+ } catch { /* not loaded */ }
586
771
  }
587
- }
588
-
589
- if (isLinux()) {
590
- if (!isSystemdUnitInstalled()) return false;
591
- try {
592
- const output = systemctlUser(`is-active ${UNIT_NAME}`);
593
- return output.trim() === "active";
594
- } catch {
595
- return false;
772
+ } else if (isLinux()) {
773
+ if (isSystemdUnitInstalled()) {
774
+ try {
775
+ if (systemctlUser(`is-active ${UNIT_NAME}`).trim() === "active") {
776
+ return "systemd-user";
777
+ }
778
+ } catch { /* inactive or no user bus */ }
596
779
  }
780
+ if (envMode !== "on" && isUnderSystemUnit()) return "systemd-system";
597
781
  }
598
782
 
599
- return false;
783
+ return envMode === "on" ? "external" : null;
784
+ }
785
+
786
+ /**
787
+ * True when this process was started by systemd as the system-wide
788
+ * the-companion.service. systemd exports INVOCATION_ID to every service it
789
+ * starts; matching it against the unit's InvocationID proves we are that
790
+ * unit's process rather than, say, a foreground run on a host that also has
791
+ * the unit installed.
792
+ */
793
+ function isUnderSystemUnit(): boolean {
794
+ const invocationId = process.env.INVOCATION_ID?.trim();
795
+ if (!invocationId) return false;
796
+ const info = getSystemUnitInfo();
797
+ if (!info) return false;
798
+ if (info.activeState !== "active" && info.activeState !== "reloading") return false;
799
+ // Very old systemd versions don't expose InvocationID; fall back to the
800
+ // active state alone there.
801
+ return !info.invocationId || info.invocationId === invocationId.replace(/-/g, "");
802
+ }
803
+
804
+ let detectedServiceKind: ServiceKind | null = null;
805
+
806
+ /**
807
+ * Safe check for whether the current process is running as a managed service.
808
+ * Unlike status(), this never calls process.exit() and works on all platforms.
809
+ * The detected kind is remembered for getDetectedServiceKind().
810
+ */
811
+ export function isRunningAsService(): boolean {
812
+ detectedServiceKind = detectServiceKind();
813
+ return detectedServiceKind !== null;
814
+ }
815
+
816
+ /** The kind found by the last isRunningAsService() call (null if none). */
817
+ export function getDetectedServiceKind(): ServiceKind | null {
818
+ return detectedServiceKind;
819
+ }
820
+
821
+ export interface UpdateRestartPlan {
822
+ /** Command to spawn (detached) before exiting, if any. */
823
+ command?: string[];
824
+ /** Extra environment for the command. */
825
+ env?: Record<string, string>;
826
+ /** Exit code for the current process once the command was spawned. */
827
+ exitCode: number;
828
+ }
829
+
830
+ /**
831
+ * How to get the new version running after `bun install -g` succeeded.
832
+ * A null/undefined kind keeps the historical platform-based behaviour.
833
+ */
834
+ export function getUpdateRestartPlan(kind: ServiceKind | null | undefined): UpdateRestartPlan {
835
+ if (kind === "systemd-system" || kind === "external") {
836
+ // Nothing to spawn: an unprivileged process can't restart a system unit,
837
+ // and the non-zero exit makes Restart=on-failure (or always) respawn us.
838
+ return { exitCode: UPDATE_RESTART_EXIT_CODE };
839
+ }
840
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
841
+ if (kind === "systemd-user" || (!kind && process.platform === "linux")) {
842
+ return {
843
+ command: ["systemctl", "--user", "restart", UNIT_NAME],
844
+ env: { XDG_RUNTIME_DIR: process.env.XDG_RUNTIME_DIR || `/run/user/${uid ?? 1000}` },
845
+ exitCode: 0,
846
+ };
847
+ }
848
+ return {
849
+ command: uid !== undefined
850
+ ? ["launchctl", "kickstart", "-k", `gui/${uid}/${LABEL}`]
851
+ : ["launchctl", "kickstart", "-k", LABEL],
852
+ exitCode: 0,
853
+ };
600
854
  }
601
855
 
602
856
  /**
@@ -687,7 +941,16 @@ async function statusDarwin(): Promise<ServiceStatus> {
687
941
 
688
942
  async function statusLinux(): Promise<ServiceStatus> {
689
943
  if (!isSystemdUnitInstalled()) {
690
- return { installed: false, running: false };
944
+ const systemUnit = getSystemUnitInfo();
945
+ if (!systemUnit) return { installed: false, running: false };
946
+ const running = systemUnit.activeState === "active" && !!systemUnit.mainPid;
947
+ return {
948
+ installed: true,
949
+ running,
950
+ pid: running ? systemUnit.mainPid : undefined,
951
+ port: systemUnit.port ?? DEFAULT_PORT_PROD,
952
+ scope: "system",
953
+ };
691
954
  }
692
955
 
693
956
  // Read port from the unit file
@@ -16,6 +16,7 @@ export const DEFAULT_ANTHROPIC_MODEL = "claude-opus-5-5";
16
16
  export const DEFAULT_DASHBOARD_MODEL = "claude-haiku-4-5";
17
17
  export const DASHBOARD_MODEL_OPTIONS = [
18
18
  "claude-haiku-4-5",
19
+ "claude-haiku-5-5",
19
20
  "claude-sonnet-4-6",
20
21
  "claude-sonnet-5",
21
22
  ] as const;