c8ctl-plugin-nano 1.56.0 → 1.56.2

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/README.md CHANGED
@@ -980,6 +980,59 @@ How it works and where things live:
980
980
  - Stopping is SIGTERM → grace → SIGKILL, per worker and for the daemon; `stop`
981
981
  always clears `supervisor.json` so a stale marker never wedges a future start.
982
982
 
983
+ ### Surviving SSH logout: `supervisor install` / `uninstall`
984
+
985
+ On **macOS**, a supervisor started over SSH is bound to your SSH login session's
986
+ launchd/bootstrap context. When you **log out of that SSH session**, macOS tears
987
+ the per-session context down and the orphaned daemon + workers lose their
988
+ network / mDNS resolution path — the fleet does **not** exit cleanly, it
989
+ **wedges**: the activation loop spins on `SDK activateJobs failed: fetch failed`
990
+ forever and claims **zero** jobs (workers may still show `running` / agentic
991
+ `disconnected`). `setsid`/double-fork detachment is not enough there — the daemon
992
+ must live in a **persistent per-user launchd domain** (`gui/$UID`). **Linux is
993
+ unaffected**: under systemd-logind with the default `KillUserProcesses=no` a
994
+ `setsid`'d daemon keeps full network access after logout.
995
+
996
+ Give the supervisor a session-independent launch path so the fleet survives
997
+ logout and comes back at login/reboot:
998
+
999
+ ```bash
1000
+ c8ctl nano supervisor install # install + start the service
1001
+ c8ctl nano supervisor uninstall # stop + remove it
1002
+ ```
1003
+
1004
+ - **macOS** — writes a per-user **LaunchAgent** and bootstraps it into `gui/$UID`
1005
+ (`launchctl bootstrap gui/$UID …`, `RunAtLoad`, crash-only `KeepAlive`). The
1006
+ plist lives at `~/Library/LaunchAgents/io.nanobpm.c8ctl-nano.supervisor.<hash>.plist`
1007
+ (the `<hash>` is derived from the state home, so distinct `C8CTL_NANO_HOME`
1008
+ instances get distinct services).
1009
+ - **Linux** — writes a `systemd --user` unit
1010
+ (`~/.config/systemd/user/c8ctl-nano-supervisor-<hash>.service`), enables + starts
1011
+ it, and turns on **lingering** (`loginctl enable-linger`) so it survives logout
1012
+ even where `KillUserProcesses=yes`. Where `systemd --user` is unavailable, no
1013
+ service is needed — the existing `setsid` daemon already survives logout — and
1014
+ `install` says so.
1015
+ - The service inherits only a **curated env** (`PATH`, `HOME`, `LANG`,
1016
+ `C8CTL_NANO_HOME`, the CLI entry) — never your whole SSH environment — so
1017
+ short-lived tokens are not persisted into a plist/unit that outlives the session.
1018
+ - `KeepAlive`/`Restart` are **crash-only**: a `supervisor stop` (clean exit) stays
1019
+ down; only a crash is restarted.
1020
+
1021
+ When the service **is installed**, every path that starts the daemon
1022
+ (`supervisor start`, and a bare `supervisor` / `supervisor attach`) brings it up
1023
+ **through the service**: it kickstarts the LaunchAgent when a clean `stop` left it
1024
+ down (a plain `kickstart`, never `-k`, so a running fleet is not bounced) and then
1025
+ **adopts** that service-owned daemon instead of spawning a second, session-bound
1026
+ one. Only if the service cannot be started does it fall back to a detached spawn
1027
+ (saying so).
1028
+
1029
+ When you run `supervisor start` or `attach` **over SSH on macOS without the
1030
+ installed service**, the CLI **auto-reparents** the daemon into the `gui/$UID`
1031
+ launchd domain (equivalent to `install`) so it survives logout, and tells you. If
1032
+ it cannot (e.g. `launchctl` is unavailable, or you set `C8CTL_NANO_NO_LAUNCHD=1`),
1033
+ it prints a prominent **warning** pointing at `supervisor install` instead of
1034
+ silently leaving a fleet that will wedge on logout.
1035
+
983
1036
  ## Composing a workforce: `workforce`
984
1037
 
985
1038
  `supervisor` is imperative — you compose a fleet with a `start --worker …` plus a
package/c8ctl-plugin.js CHANGED
@@ -9324,7 +9324,7 @@ function supervisorRequest(req, { socketPath, timeoutMs, responseTimeoutMs = SUP
9324
9324
  * Ensure a daemon is running, spawning it detached if not, and return its
9325
9325
  * running state. Polls the control socket until it answers a status request.
9326
9326
  */
9327
- async function startSupervisorDaemon() {
9327
+ async function startSupervisorDaemon({ adoptOnly = false } = {}) {
9328
9328
  const existing = runningSupervisor();
9329
9329
  if (existing) return existing;
9330
9330
 
@@ -9332,18 +9332,32 @@ async function startSupervisorDaemon() {
9332
9332
  // The state file may be missing (deleted, cleaned up, or not yet written)
9333
9333
  // while a daemon is still listening on the deterministic socket. Adopt that
9334
9334
  // live daemon instead of spawning a second one that would orphan the
9335
- // original and its workers.
9336
- try {
9337
- const res = await supervisorRequest({ op: 'status' }, { socketPath, timeoutMs: 500, responseTimeoutMs: SUPERVISOR_PROBE_RESPONSE_TIMEOUT_MS });
9338
- if (res && res.ok) {
9339
- // Re-persist the adopted daemon's state so subsequent pid-based checks
9340
- // (runningSupervisor()) work immediately, instead of staying broken until
9341
- // some later command happens to heal supervisor.json.
9342
- const adopted = runningSupervisor() || stateFromStatus(res, socketPath);
9343
- try { writeSupervisorState(adopted); } catch { /* best effort */ }
9344
- return adopted;
9345
- }
9346
- } catch { /* no live daemon on the socket — safe to (re)spawn */ }
9335
+ // original and its workers. When a service was just installed/reparented
9336
+ // (adoptOnly), the launchd/systemd-owned daemon may still be booting, so poll
9337
+ // the socket up to the connect timeout rather than racing to spawn a second,
9338
+ // session-bound daemon that would fight it over the same socket/state.
9339
+ const adoptDeadline = adoptOnly ? Date.now() + SUPERVISOR_CONNECT_TIMEOUT_MS : 0;
9340
+ for (;;) {
9341
+ try {
9342
+ const res = await supervisorRequest({ op: 'status' }, { socketPath, timeoutMs: 500, responseTimeoutMs: SUPERVISOR_PROBE_RESPONSE_TIMEOUT_MS });
9343
+ if (res && res.ok) {
9344
+ // Re-persist the adopted daemon's state so subsequent pid-based checks
9345
+ // (runningSupervisor()) work immediately, instead of staying broken until
9346
+ // some later command happens to heal supervisor.json.
9347
+ const adopted = runningSupervisor() || stateFromStatus(res, socketPath);
9348
+ try { writeSupervisorState(adopted); } catch { /* best effort */ }
9349
+ return adopted;
9350
+ }
9351
+ } catch { /* no live daemon on the socket yet */ }
9352
+ if (Date.now() >= adoptDeadline) break;
9353
+ await new Promise((r) => setTimeout(r, 150));
9354
+ }
9355
+
9356
+ if (adoptOnly) {
9357
+ // A service owns the daemon; never spawn a competing session-bound one —
9358
+ // that is exactly the wedge reparenting exists to avoid.
9359
+ throw new Error(`supervisor service was installed but its daemon did not become ready (see ${supervisorDaemonLogFile()})`);
9360
+ }
9347
9361
 
9348
9362
  clearSupervisorState(); // clear any stale marker from a dead daemon
9349
9363
 
@@ -9378,9 +9392,21 @@ async function startSupervisorDaemon() {
9378
9392
  throw new Error(`supervisor daemon did not become ready (see ${logFile})`);
9379
9393
  }
9380
9394
 
9395
+ /**
9396
+ * Bring the daemon up under the session-independence policy: re-parent into the
9397
+ * persistent launchd domain when macOS-over-SSH is exposed (or a service is
9398
+ * already installed), then adopt the service-owned daemon instead of spawning a
9399
+ * competing session-bound one. Every command that starts the daemon goes through
9400
+ * here, so no path can silently bypass the policy and recreate the logout wedge.
9401
+ */
9402
+ async function startSupervisorWithServicePolicy(logger = getLogger()) {
9403
+ const serviceOwned = await maybeReparentOrWarnOnStart(logger);
9404
+ return startSupervisorDaemon({ adoptOnly: serviceOwned });
9405
+ }
9406
+
9381
9407
  async function supervisorStartCmd(req, flags) {
9382
9408
  const logger = getLogger();
9383
- const state = await startSupervisorDaemon();
9409
+ const state = await startSupervisorWithServicePolicy(logger);
9384
9410
  logger.info(`Supervisor daemon running (pid ${state.pid}).`);
9385
9411
 
9386
9412
  const specs = normalizeArgList(flags?.worker);
@@ -9465,7 +9491,7 @@ async function supervisorAddCmd(req, flags) {
9465
9491
  logger.error('--name cannot be combined with --instances > 1 (each instance needs a distinct name); omit --name to auto-name them.');
9466
9492
  process.exit(1);
9467
9493
  }
9468
- await startSupervisorDaemon();
9494
+ await startSupervisorWithServicePolicy(logger);
9469
9495
  const workArgs = reconstructWorkArgs(flags);
9470
9496
  let added = 0;
9471
9497
  let failed = 0;
@@ -9727,6 +9753,442 @@ async function attachSupervisorConsole(state) {
9727
9753
  });
9728
9754
  }
9729
9755
 
9756
+ // ---------------------------------------------------------------------------
9757
+ // Session-independent supervisor service (issue #196).
9758
+ //
9759
+ // On macOS a supervisor started over SSH is bound to the SSH login session's
9760
+ // launchd/bootstrap + audit context. On SSH logout macOS tears that per-session
9761
+ // context down and the orphaned daemon + workers lose their network / mDNS
9762
+ // resolution path — the fleet does NOT cleanly exit, it WEDGES (the activation
9763
+ // loop spins on `activateJobs failed: fetch failed` forever and claims zero
9764
+ // jobs). `setsid`/double-fork detachment is not sufficient there: the daemon
9765
+ // must live in a persistent per-user launchd domain (`gui/$UID`).
9766
+ //
9767
+ // Linux is unaffected — under systemd-logind with the default
9768
+ // `KillUserProcesses=no` a `setsid`'d daemon keeps full network access after
9769
+ // logout — so this is a macOS-specific defect in how the supervisor detaches.
9770
+ //
9771
+ // The fix gives the supervisor a session-independent launch path symmetric with
9772
+ // Linux: `supervisor install` writes a per-user LaunchAgent and bootstraps it
9773
+ // into `gui/$UID` (macOS) or a `systemd --user` unit with lingering (Linux),
9774
+ // and `supervisor start` over SSH on macOS auto-reparents into that domain (or,
9775
+ // when it cannot, WARNS that the fleet will die on logout).
9776
+ // ---------------------------------------------------------------------------
9777
+
9778
+ /** True when the current process is running inside an SSH login session. */
9779
+ function isSshSession(env = process.env) {
9780
+ return Boolean(env.SSH_CONNECTION || env.SSH_CLIENT || env.SSH_TTY);
9781
+ }
9782
+
9783
+ /** Minimal XML text escaping for the LaunchAgent plist. */
9784
+ function xmlEscape(s) {
9785
+ return String(s)
9786
+ .replace(/&/g, '&amp;')
9787
+ .replace(/</g, '&lt;')
9788
+ .replace(/>/g, '&gt;')
9789
+ .replace(/"/g, '&quot;')
9790
+ .replace(/'/g, '&apos;');
9791
+ }
9792
+
9793
+ // Short hash of the (possibly overridden) state home, so distinct
9794
+ // C8CTL_NANO_HOME instances get distinct services and never fight over the same
9795
+ // launchd label / systemd unit.
9796
+ function supervisorServiceHash() {
9797
+ return createHash('sha1').update(getStateHome()).digest('hex').slice(0, 8);
9798
+ }
9799
+
9800
+ /** Reverse-DNS LaunchAgent label for this state home. */
9801
+ function supervisorServiceLabel() {
9802
+ return `io.nanobpm.c8ctl-nano.supervisor.${supervisorServiceHash()}`;
9803
+ }
9804
+
9805
+ /** Per-user LaunchAgent plist path (macOS). */
9806
+ function launchAgentPlistPath() {
9807
+ return join(homedir(), 'Library', 'LaunchAgents', `${supervisorServiceLabel()}.plist`);
9808
+ }
9809
+
9810
+ /** systemd --user unit file name / path (Linux). */
9811
+ function systemdUnitName() {
9812
+ return `c8ctl-nano-supervisor-${supervisorServiceHash()}.service`;
9813
+ }
9814
+ function systemdUserUnitPath() {
9815
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
9816
+ return join(base, 'systemd', 'user', systemdUnitName());
9817
+ }
9818
+
9819
+ /** launchd domain / service targets. */
9820
+ function launchdDomainTarget(uid) {
9821
+ return `gui/${uid}`;
9822
+ }
9823
+ function launchdServiceTarget(uid, label) {
9824
+ return `gui/${uid}/${label}`;
9825
+ }
9826
+
9827
+ // Curate the env a persistent service should inherit — enough to re-invoke the
9828
+ // CLI and locate its state, but NOT the whole SSH environment (which could bleed
9829
+ // short-lived tokens into a persistent plist/unit that outlives the session).
9830
+ const SUPERVISOR_SERVICE_ENV_KEYS = ['PATH', 'HOME', 'LANG', 'LC_ALL', 'C8CTL_NANO_HOME'];
9831
+ function supervisorServiceEnv(env = process.env) {
9832
+ const out = {};
9833
+ for (const k of SUPERVISOR_SERVICE_ENV_KEYS) {
9834
+ if (env[k] != null && env[k] !== '') out[k] = String(env[k]);
9835
+ }
9836
+ // Pin the entry point so the daemon can spawn `work` children even if argv[1]
9837
+ // differs under launchd/systemd.
9838
+ const { entry } = c8ctlInvocation();
9839
+ if (entry) out.C8CTL_NANO_ENTRY = entry;
9840
+ return out;
9841
+ }
9842
+
9843
+ /**
9844
+ * Build a per-user LaunchAgent plist that runs `nano supervisor __daemon`.
9845
+ * `RunAtLoad` starts it at login/reboot; `KeepAlive`={SuccessfulExit:false}
9846
+ * restarts it only on a crash — an intentional `supervisor stop` exits 0 and
9847
+ * stays down, so the invariant "stop stops the fleet" is preserved.
9848
+ */
9849
+ function buildLaunchAgentPlist({ label, exec, entry, env = {}, stdoutPath, stderrPath }) {
9850
+ const args = [exec, entry, 'nano', 'supervisor', '__daemon'];
9851
+ const argXml = args.map((a) => ` <string>${xmlEscape(a)}</string>`).join('\n');
9852
+ const envXml = Object.entries(env)
9853
+ .filter(([, v]) => v != null && v !== '')
9854
+ .map(([k, v]) => ` <key>${xmlEscape(k)}</key>\n <string>${xmlEscape(String(v))}</string>`)
9855
+ .join('\n');
9856
+ return `<?xml version="1.0" encoding="UTF-8"?>
9857
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
9858
+ <plist version="1.0">
9859
+ <dict>
9860
+ <key>Label</key>
9861
+ <string>${xmlEscape(label)}</string>
9862
+ <key>ProgramArguments</key>
9863
+ <array>
9864
+ ${argXml}
9865
+ </array>
9866
+ <key>EnvironmentVariables</key>
9867
+ <dict>
9868
+ ${envXml}
9869
+ </dict>
9870
+ <key>RunAtLoad</key>
9871
+ <true/>
9872
+ <key>KeepAlive</key>
9873
+ <dict>
9874
+ <key>SuccessfulExit</key>
9875
+ <false/>
9876
+ </dict>
9877
+ <key>ProcessType</key>
9878
+ <string>Background</string>
9879
+ <key>StandardOutPath</key>
9880
+ <string>${xmlEscape(stdoutPath)}</string>
9881
+ <key>StandardErrorPath</key>
9882
+ <string>${xmlEscape(stderrPath)}</string>
9883
+ </dict>
9884
+ </plist>
9885
+ `;
9886
+ }
9887
+
9888
+ /**
9889
+ * Quote one `ExecStart` argument for a systemd unit. systemd splits the command
9890
+ * line on unquoted whitespace, so any argument containing whitespace or a shell
9891
+ * metacharacter must be double-quoted with C-style escaping inside. A literal
9892
+ * `%` is doubled so systemd never mistakes it for a specifier (e.g. `%h`).
9893
+ */
9894
+ function systemdQuoteExecArg(arg) {
9895
+ const s = String(arg).replace(/%/g, '%%');
9896
+ if (s === '') return '""';
9897
+ if (/[\s"'\\`$;&|<>()]/.test(String(arg))) {
9898
+ return `"${s.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
9899
+ }
9900
+ return s;
9901
+ }
9902
+
9903
+ /**
9904
+ * Render one `Environment=` line for a systemd unit. When the `KEY=VALUE`
9905
+ * assignment contains whitespace or a quote/backslash, the whole assignment is
9906
+ * double-quoted (systemd otherwise splits it into multiple assignments on
9907
+ * whitespace). A literal `%` is doubled so it is not expanded as a specifier.
9908
+ */
9909
+ function systemdEnvLine(k, v) {
9910
+ const key = String(k).replace(/%/g, '%%');
9911
+ const val = String(v).replace(/%/g, '%%');
9912
+ const assignment = `${key}=${val}`;
9913
+ if (/[\s"'\\`$]/.test(`${k}=${v}`)) {
9914
+ return `Environment="${assignment.replace(/[\\"]/g, (c) => `\\${c}`)}"`;
9915
+ }
9916
+ return `Environment=${assignment}`;
9917
+ }
9918
+
9919
+ /**
9920
+ * Build a `systemd --user` unit that runs `nano supervisor __daemon`.
9921
+ * `Restart=on-failure` mirrors the launchd KeepAlive: a crash is restarted, an
9922
+ * intentional `supervisor stop` (clean exit) stays down. `exec`/`entry` and env
9923
+ * values are quoted/escaped so paths or values with whitespace or quotes don't
9924
+ * make systemd misparse the line and fail to start the service.
9925
+ */
9926
+ function buildSystemdUserUnit({ exec, entry, env = {} }) {
9927
+ const cmd = `${systemdQuoteExecArg(exec)} ${systemdQuoteExecArg(entry)} nano supervisor __daemon`;
9928
+ const envLines = Object.entries(env)
9929
+ .filter(([, v]) => v != null && v !== '')
9930
+ .map(([k, v]) => systemdEnvLine(k, v))
9931
+ .join('\n');
9932
+ return `[Unit]
9933
+ Description=c8ctl nano worker supervisor
9934
+ After=network-online.target
9935
+ Wants=network-online.target
9936
+
9937
+ [Service]
9938
+ Type=simple
9939
+ ExecStart=${cmd}
9940
+ ${envLines}
9941
+ Restart=on-failure
9942
+ RestartSec=5
9943
+
9944
+ [Install]
9945
+ WantedBy=default.target
9946
+ `;
9947
+ }
9948
+
9949
+ /** Whether a persistent supervisor service is installed for this state home. */
9950
+ function supervisorServiceInstalled(platform = osPlatform()) {
9951
+ if (platform === 'darwin') return existsSync(launchAgentPlistPath());
9952
+ if (platform === 'linux') return existsSync(systemdUserUnitPath());
9953
+ return false;
9954
+ }
9955
+
9956
+ /**
9957
+ * Pure predicate: should `supervisor start` warn about SSH-logout teardown?
9958
+ * Only macOS over SSH without an installed service is exposed to the wedge.
9959
+ */
9960
+ function shouldWarnSshTeardown({
9961
+ platform = osPlatform(),
9962
+ env = process.env,
9963
+ installed = supervisorServiceInstalled(platform),
9964
+ } = {}) {
9965
+ return platform === 'darwin' && isSshSession(env) && !installed;
9966
+ }
9967
+
9968
+ function warnSshTeardown(logger) {
9969
+ logger.warn('⚠ macOS + SSH: this supervisor is bound to your SSH login session.');
9970
+ logger.warn(' When you log out, macOS tears that session down and the fleet WEDGES —');
9971
+ logger.warn(' workers keep showing "running" but stop claiming jobs (the activation');
9972
+ logger.warn(' loop spins on: SDK activateJobs failed: fetch failed).');
9973
+ logger.warn(' Install a session-independent service so it survives logout:');
9974
+ logger.warn(' c8ctl nano supervisor install');
9975
+ }
9976
+
9977
+ function runLaunchctl(args) {
9978
+ try {
9979
+ const r = spawnSync('launchctl', args, { encoding: 'utf8', timeout: 15_000 });
9980
+ return { code: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), error: r.error };
9981
+ } catch (err) {
9982
+ return { code: null, stdout: '', stderr: '', error: err };
9983
+ }
9984
+ }
9985
+
9986
+ function runSystemctlUser(args) {
9987
+ try {
9988
+ const r = spawnSync('systemctl', ['--user', ...args], { encoding: 'utf8', timeout: 15_000 });
9989
+ return { code: r.status, stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), error: r.error };
9990
+ } catch (err) {
9991
+ return { code: null, stdout: '', stderr: '', error: err };
9992
+ }
9993
+ }
9994
+
9995
+ function systemdUserAvailable() {
9996
+ return runSystemctlUser(['--version']).code === 0;
9997
+ }
9998
+
9999
+ /**
10000
+ * Stop a running daemon (socket `stop` → SIGTERM → SIGKILL the group), used
10001
+ * before installing a service so the launchd/systemd-owned daemon takes over
10002
+ * the deterministic control socket without a second instance fighting for it.
10003
+ */
10004
+ async function stopSupervisorProcess(running) {
10005
+ try { await supervisorRequest({ op: 'stop' }); }
10006
+ catch { try { process.kill(running.pid, 'SIGTERM'); } catch { /* already gone */ } }
10007
+ const deadline = Date.now() + STOP_GRACE_MS + 2_000;
10008
+ while (Date.now() < deadline) {
10009
+ if (!isPidAlive(running.pid)) break;
10010
+ await new Promise((r) => setTimeout(r, 150));
10011
+ }
10012
+ if (isPidAlive(running.pid)) {
10013
+ if (osPlatform() !== 'win32') {
10014
+ try { process.kill(-running.pid, 'SIGKILL'); }
10015
+ catch { try { process.kill(running.pid, 'SIGKILL'); } catch { /* ignore */ } }
10016
+ } else {
10017
+ try { process.kill(running.pid, 'SIGKILL'); } catch { /* ignore */ }
10018
+ }
10019
+ }
10020
+ clearSupervisorState();
10021
+ }
10022
+
10023
+ async function installSupervisorServiceDarwin(logger) {
10024
+ const uid = process.getuid();
10025
+ const label = supervisorServiceLabel();
10026
+ const plistPath = launchAgentPlistPath();
10027
+ const { exec, entry } = c8ctlInvocation();
10028
+ if (!entry) { logger.error('Cannot resolve the c8ctl entry point to install the service.'); return false; }
10029
+
10030
+ // Hand the socket to the launchd-owned daemon: stop any session-bound one.
10031
+ const running = await liveSupervisor();
10032
+ if (running) {
10033
+ logger.info('Stopping the current session-bound supervisor before installing the service…');
10034
+ await stopSupervisorProcess(running);
10035
+ }
10036
+
10037
+ mkdirSync(dirname(plistPath), { recursive: true });
10038
+ mkdirSync(getSupervisorLogDir(), { recursive: true });
10039
+ const daemonLog = supervisorDaemonLogFile();
10040
+ writeFileSync(plistPath, buildLaunchAgentPlist({
10041
+ label, exec, entry, env: supervisorServiceEnv(), stdoutPath: daemonLog, stderrPath: daemonLog,
10042
+ }));
10043
+
10044
+ runLaunchctl(['bootout', launchdServiceTarget(uid, label)]); // best effort: clear any prior instance
10045
+ const boot = runLaunchctl(['bootstrap', launchdDomainTarget(uid), plistPath]);
10046
+ if (boot.code !== 0 && !/already (loaded|bootstrapped)/i.test(boot.stderr)) {
10047
+ logger.error(`launchctl bootstrap failed: ${boot.stderr || boot.error?.message || 'unknown error'}`);
10048
+ return false;
10049
+ }
10050
+ runLaunchctl(['enable', launchdServiceTarget(uid, label)]);
10051
+ runLaunchctl(['kickstart', '-k', launchdServiceTarget(uid, label)]);
10052
+ logger.info(`Installed LaunchAgent ${label} into ${launchdDomainTarget(uid)}.`);
10053
+ logger.info(` plist: ${plistPath}`);
10054
+ logger.info('The supervisor now survives SSH logout and restarts at login/reboot.');
10055
+ logger.info('Manage it with: c8ctl nano supervisor status | add | stop | uninstall');
10056
+ return true;
10057
+ }
10058
+
10059
+ function uninstallSupervisorServiceDarwin(logger) {
10060
+ const uid = process.getuid();
10061
+ const label = supervisorServiceLabel();
10062
+ const plistPath = launchAgentPlistPath();
10063
+ const existed = existsSync(plistPath);
10064
+ runLaunchctl(['bootout', launchdServiceTarget(uid, label)]); // stops + unloads
10065
+ try { if (existed) rmSync(plistPath, { force: true }); } catch { /* best effort */ }
10066
+ clearSupervisorState();
10067
+ if (existed) logger.info(`Removed LaunchAgent ${label}.`);
10068
+ else logger.info('No LaunchAgent was installed for this state home.');
10069
+ return true;
10070
+ }
10071
+
10072
+ async function installSupervisorServiceLinux(logger) {
10073
+ if (!systemdUserAvailable()) {
10074
+ logger.info('systemd --user is not available on this host.');
10075
+ logger.info('No service is required: the supervisor already detaches with setsid, and under');
10076
+ logger.info('systemd-logind with the default KillUserProcesses=no it survives SSH logout.');
10077
+ return true;
10078
+ }
10079
+ const { exec, entry } = c8ctlInvocation();
10080
+ if (!entry) { logger.error('Cannot resolve the c8ctl entry point to install the service.'); return false; }
10081
+
10082
+ const running = await liveSupervisor();
10083
+ if (running) {
10084
+ logger.info('Stopping the current supervisor before installing the service…');
10085
+ await stopSupervisorProcess(running);
10086
+ }
10087
+
10088
+ const unitPath = systemdUserUnitPath();
10089
+ mkdirSync(dirname(unitPath), { recursive: true });
10090
+ writeFileSync(unitPath, buildSystemdUserUnit({ exec, entry, env: supervisorServiceEnv() }));
10091
+
10092
+ // Enable lingering so the user manager (and the daemon) survive logout even
10093
+ // where KillUserProcesses=yes.
10094
+ const user = process.env.USER || process.env.LOGNAME;
10095
+ try { spawnSync('loginctl', user ? ['enable-linger', user] : ['enable-linger'], { timeout: 10_000 }); }
10096
+ catch { /* best effort */ }
10097
+
10098
+ runSystemctlUser(['daemon-reload']);
10099
+ const en = runSystemctlUser(['enable', '--now', systemdUnitName()]);
10100
+ if (en.code !== 0) { logger.error(`systemctl --user enable failed: ${en.stderr || en.error?.message || 'unknown error'}`); return false; }
10101
+ logger.info(`Installed systemd --user unit ${systemdUnitName()} (enabled + started).`);
10102
+ logger.info(` unit: ${unitPath}`);
10103
+ logger.info('Lingering is enabled so the fleet survives logout and starts at boot.');
10104
+ logger.info('Manage it with: c8ctl nano supervisor status | add | stop | uninstall');
10105
+ return true;
10106
+ }
10107
+
10108
+ function uninstallSupervisorServiceLinux(logger) {
10109
+ const unitPath = systemdUserUnitPath();
10110
+ const existed = existsSync(unitPath);
10111
+ if (systemdUserAvailable()) runSystemctlUser(['disable', '--now', systemdUnitName()]);
10112
+ try { if (existed) rmSync(unitPath, { force: true }); } catch { /* best effort */ }
10113
+ if (systemdUserAvailable()) runSystemctlUser(['daemon-reload']);
10114
+ clearSupervisorState();
10115
+ if (existed) logger.info(`Removed systemd --user unit ${systemdUnitName()}.`);
10116
+ else logger.info('No systemd --user unit was installed for this state home.');
10117
+ return true;
10118
+ }
10119
+
10120
+ async function supervisorInstallCmd() {
10121
+ const logger = getLogger();
10122
+ const plat = osPlatform();
10123
+ if (plat === 'darwin') { await installSupervisorServiceDarwin(logger); return; }
10124
+ if (plat === 'linux') { await installSupervisorServiceLinux(logger); return; }
10125
+ logger.error(`supervisor install is not supported on ${plat}.`);
10126
+ }
10127
+
10128
+ async function supervisorUninstallCmd() {
10129
+ const logger = getLogger();
10130
+ const plat = osPlatform();
10131
+ if (plat === 'darwin') { uninstallSupervisorServiceDarwin(logger); return; }
10132
+ if (plat === 'linux') { uninstallSupervisorServiceLinux(logger); return; }
10133
+ logger.error(`supervisor uninstall is not supported on ${plat}.`);
10134
+ }
10135
+
10136
+ /**
10137
+ * Start an already-installed LaunchAgent when it is not running, so a
10138
+ * service-owned start can adopt it. `RunAtLoad` only fires at bootstrap/login and
10139
+ * `KeepAlive` is crash-only, so nothing brings the daemon back after a clean
10140
+ * `supervisor stop` — adopt-only would poll an empty socket and fail. Plain
10141
+ * `kickstart` (never `-k`) is a no-op when the service is up, so a live fleet is
10142
+ * never bounced; a service launchd no longer has loaded is re-bootstrapped from
10143
+ * its plist. `run`/`uid`/`label`/`plistPath` are injectable for deterministic tests.
10144
+ */
10145
+ function ensureLaunchAgentStarted(logger, { run = runLaunchctl, uid, label, plistPath } = {}) {
10146
+ const svcUid = uid ?? (typeof process.getuid === 'function' ? process.getuid() : 0);
10147
+ const target = launchdServiceTarget(svcUid, label ?? supervisorServiceLabel());
10148
+ let kick = run(['kickstart', target]);
10149
+ if (kick.code === 0) return true;
10150
+
10151
+ // Not loaded in the domain (a `bootout` without uninstall, or a login that
10152
+ // predated the plist): reload it from disk. `bootstrap`'s own status is not the
10153
+ // verdict — an already-loaded service reports a non-zero "Input/output error" —
10154
+ // so re-enable, kick again, and let that decide.
10155
+ run(['bootstrap', launchdDomainTarget(svcUid), plistPath ?? launchAgentPlistPath()]);
10156
+ run(['enable', target]);
10157
+ kick = run(['kickstart', target]);
10158
+ if (kick.code === 0) return true;
10159
+ logger.warn(`launchctl kickstart failed: ${kick.stderr || kick.error?.message || 'unknown error'}`);
10160
+ return false;
10161
+ }
10162
+
10163
+ /**
10164
+ * On a macOS `supervisor start`/`attach` over SSH, re-parent the daemon into the
10165
+ * persistent `gui/$UID` launchd domain (via an installed LaunchAgent) so it
10166
+ * survives logout instead of dying with the SSH session. If we cannot (or the
10167
+ * operator opts out with C8CTL_NANO_NO_LAUNCHD), warn loudly and fall through to
10168
+ * the ordinary detached spawn. Returns `true` when a service owns the daemon
10169
+ * (already installed, or just reparented) so the caller adopts it instead of
10170
+ * spawning a competing session-bound daemon; `false` on every non-exposed path
10171
+ * (Linux, a local login, opt-out/failure, or a service that would not start).
10172
+ */
10173
+ async function maybeReparentOrWarnOnStart(logger) {
10174
+ if (osPlatform() !== 'darwin') return false; // Linux already survives logout
10175
+ if (supervisorServiceInstalled()) {
10176
+ // An installed service owns the daemon — but make sure it is actually up
10177
+ // first, or adopt-only would time out on the empty socket a clean stop left.
10178
+ if (await liveSupervisor()) return true;
10179
+ if (ensureLaunchAgentStarted(logger)) return true;
10180
+ logger.warn('The installed supervisor service would not start; falling back to a detached (session-bound) daemon.');
10181
+ return false;
10182
+ }
10183
+ if (!isSshSession()) return false; // a local login session isn't torn down like SSH
10184
+ if (await liveSupervisor()) return false; // adopting an existing daemon — nothing to re-parent
10185
+ if (coerceBool(process.env.C8CTL_NANO_NO_LAUNCHD, false)) { warnSshTeardown(logger); return false; }
10186
+ logger.info('macOS + SSH detected — re-parenting the supervisor into the gui launchd domain so it survives logout…');
10187
+ const ok = await installSupervisorServiceDarwin(logger);
10188
+ if (!ok) { warnSshTeardown(logger); return false; }
10189
+ return true; // the launchd service now owns the daemon — adopt it, don't spawn
10190
+ }
10191
+
9730
10192
  /** Dispatch the `supervisor` subcommand's action. */
9731
10193
  async function supervisorCommand(req, flags) {
9732
10194
  const action = (req.positional[0] || '').toLowerCase();
@@ -9736,13 +10198,21 @@ async function supervisorCommand(req, flags) {
9736
10198
  return;
9737
10199
  case '':
9738
10200
  case 'attach': {
9739
- const state = await startSupervisorDaemon();
10201
+ // Same session-independence policy as `supervisor start`: a bare `attach`
10202
+ // must not spawn a session-bound daemon that an SSH logout wedges.
10203
+ const state = await startSupervisorWithServicePolicy();
9740
10204
  await attachSupervisorConsole(runningSupervisor() || state);
9741
10205
  return;
9742
10206
  }
9743
10207
  case 'start':
9744
10208
  await supervisorStartCmd(req, flags);
9745
10209
  return;
10210
+ case 'install':
10211
+ await supervisorInstallCmd();
10212
+ return;
10213
+ case 'uninstall':
10214
+ await supervisorUninstallCmd();
10215
+ return;
9746
10216
  case 'status':
9747
10217
  case 'list':
9748
10218
  case 'ls':
@@ -9766,7 +10236,7 @@ async function supervisorCommand(req, flags) {
9766
10236
  supervisorLogsCmd(req);
9767
10237
  return;
9768
10238
  default:
9769
- getLogger().error(`Unknown supervisor action "${action}". Use: start|status|add|remove|restart|stop|logs|attach`);
10239
+ getLogger().error(`Unknown supervisor action "${action}". Use: start|install|uninstall|status|add|remove|restart|stop|logs|attach`);
9770
10240
  process.exit(1);
9771
10241
  }
9772
10242
  }
@@ -10560,7 +11030,7 @@ async function workforceStartCmd(req, flags, manifestName) {
10560
11030
  process.exit(1);
10561
11031
  }
10562
11032
 
10563
- const state = await startSupervisorDaemon();
11033
+ const state = await startSupervisorWithServicePolicy(logger);
10564
11034
  logger.info(`Supervisor daemon running (pid ${state.pid}).`);
10565
11035
  const { reachable, workers: live } = await fetchSupervisorWorkers();
10566
11036
  // A running daemon with an unreachable status socket reports `live: []`, which
@@ -12555,6 +13025,20 @@ export {
12555
13025
  clearSupervisorState,
12556
13026
  getSupervisorSocketPath,
12557
13027
  getSupervisorStateFile,
13028
+ isSshSession,
13029
+ xmlEscape,
13030
+ supervisorServiceLabel,
13031
+ launchAgentPlistPath,
13032
+ systemdUnitName,
13033
+ systemdUserUnitPath,
13034
+ launchdDomainTarget,
13035
+ launchdServiceTarget,
13036
+ supervisorServiceEnv,
13037
+ buildLaunchAgentPlist,
13038
+ buildSystemdUserUnit,
13039
+ supervisorServiceInstalled,
13040
+ shouldWarnSshTeardown,
13041
+ ensureLaunchAgentStarted,
12558
13042
  };
12559
13043
 
12560
13044
  export {
@@ -12635,6 +13119,8 @@ export const metadata = {
12635
13119
  { command: 'c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1', description: 'Run jobs in isolated containers with disk-hygiene reaping' },
12636
13120
  { command: 'NANO_AGENTIC_URL=http://localhost:8080 NANO_AGENTIC_SECRET=<shared-secret> c8ctl nano work reviewer', description: 'Enrol the worker on the app\'s same-port /agentic channel in SECURE mode (same NANO_AGENTIC_SECRET as the server) so it appears live (presence + relay terminals) on the Workforce visibility page' },
12637
13121
  { command: 'c8ctl nano supervisor start --worker reviewer --worker coder', description: 'Start a detached supervisor managing several workers from one terminal' },
13122
+ { command: 'c8ctl nano supervisor install', description: 'Install a session-independent supervisor service (macOS LaunchAgent in gui/$UID; Linux systemd --user + lingering) so the fleet survives SSH logout and returns at login/reboot' },
13123
+ { command: 'c8ctl nano supervisor uninstall', description: 'Remove the installed supervisor service (LaunchAgent / systemd --user unit)' },
12638
13124
  { command: 'c8ctl nano supervisor', description: 'Attach an interactive console to the supervisor (detach with Ctrl-D, leaving it running)' },
12639
13125
  { command: 'c8ctl nano supervisor status', description: 'List supervised workers (state, ENGINE + AGENTIC visibility diagnostics, serviced job / idle, pid, restarts, uptime) without the console' },
12640
13126
  { command: 'c8ctl nano supervisor add decider', description: 'Add a supervised worker (forwarding work flags) to the running supervisor' },
@@ -12879,7 +13365,7 @@ function printUsage() {
12879
13365
  console.log(' c8ctl nano hire [--name <n>] [--rank <r>] [--command <c>] [--arg <switch> ...] [--model <m>] [--capabilities <a,b>] [--sandbox none|docker|podman] [--image <ref>] [--terminal pty|pipe] [--protocol pipe|acp] [--permission yolo|escalate|filter] [--env NAME=VALUE ...] [--list]');
12880
13366
  console.log(' c8ctl nano assign <profileName> <cap[,cap...]> [--name <n>] [--capabilities <a,b>]');
12881
13367
  console.log(' c8ctl nano work <profileName> [--auto [--auto-scope <p>]] [--arg <switch> ...] [--recovery-window <ms>] [--idle-timeout <ms>] [--job-timeout <ms>] [--poll-timeout <ms>] [--job-type <token> ...] [--sandbox none|docker|podman] [--image <ref>] [--env NAME=VALUE ...] [--secret-resolver host] [--min-free-mb <n>] [--clone-timeout <ms>] [--keep-runs] [--stream]');
12882
- console.log(' c8ctl nano supervisor [start|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
13368
+ console.log(' c8ctl nano supervisor [start|install|uninstall|status|add|remove|restart|stop|logs|attach] ... (manage many workers from one terminal)');
12883
13369
  console.log(' c8ctl nano workforce [add|remove|list|start|status|stop] ... [--manifest <manifest>] (declarative, reusable fleet manifests)');
12884
13370
  console.log('');
12885
13371
  console.log('Subcommands:');
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.0.18",
3
- "commit": "a5a1662",
4
- "updated": "2026-08-31T10:34:02Z"
2
+ "version": "0.0.19",
3
+ "commit": "57fa76c",
4
+ "updated": "2026-09-06T17:37:58Z"
5
5
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "c8ctl-plugin-nano",
3
- "version": "1.56.0",
3
+ "version": "1.56.2",
4
4
  "type": "module",
5
5
  "description": "c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster",
6
6
  "main": "c8ctl-plugin.js",
@@ -72,12 +72,12 @@
72
72
  },
73
73
  "optionalDependencies": {
74
74
  "node-pty": "^1.0.0",
75
- "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.56.0",
76
- "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.56.0",
77
- "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.56.0",
78
- "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.56.0",
79
- "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.56.0",
80
- "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.56.0",
81
- "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.56.0"
75
+ "@nanobpm/c8ctl-plugin-nano-darwin-arm64": "1.56.2",
76
+ "@nanobpm/c8ctl-plugin-nano-darwin-x64": "1.56.2",
77
+ "@nanobpm/c8ctl-plugin-nano-linux-x64": "1.56.2",
78
+ "@nanobpm/c8ctl-plugin-nano-linux-arm64": "1.56.2",
79
+ "@nanobpm/c8ctl-plugin-nano-linux-armv7": "1.56.2",
80
+ "@nanobpm/c8ctl-plugin-nano-linux-armv6": "1.56.2",
81
+ "@nanobpm/c8ctl-plugin-nano-win32-x64": "1.56.2"
82
82
  }
83
83
  }