@onklave/agent-cli 0.1.10 → 0.1.12

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 (2) hide show
  1. package/main.js +742 -18
  2. package/package.json +1 -1
package/main.js CHANGED
@@ -510,7 +510,7 @@ var PlatformClient = class {
510
510
  async respondToGate(sessionId, decision, value) {
511
511
  await this.request(
512
512
  "POST",
513
- `/api/v1/agent/sessions/${sessionId}/gate`,
513
+ `/api/v1/agent/sessions/${sessionId}/respond`,
514
514
  {
515
515
  decision,
516
516
  message: value
@@ -569,6 +569,17 @@ var PlatformClient = class {
569
569
  async unregisterMachine() {
570
570
  await this.request("POST", "/api/v1/runner/unregister");
571
571
  }
572
+ /*
573
+ * Update an already-registered machine's configuration. Used by
574
+ * `onklave register --refresh` to push freshly-detected capabilities.
575
+ */
576
+ async updateMachine(machineId, patch) {
577
+ await this.request(
578
+ "PATCH",
579
+ `/api/v1/runner/machines/${machineId}`,
580
+ patch
581
+ );
582
+ }
572
583
  /*
573
584
  * List all registered machines.
574
585
  */
@@ -593,8 +604,8 @@ var PlatformClient = class {
593
604
  /*
594
605
  * Make an authenticated HTTP request to the platform.
595
606
  */
596
- async request(method, path6, body) {
597
- const url = `${this.baseUrl}${path6}`;
607
+ async request(method, path7, body) {
608
+ const url = `${this.baseUrl}${path7}`;
598
609
  const headers = {
599
610
  Authorization: `Bearer ${this.token}`,
600
611
  "Content-Type": "application/json",
@@ -642,12 +653,19 @@ var CommsClient = class {
642
653
  }
643
654
  /*
644
655
  * Connect to the Onklave comms service via Socket.IO.
656
+ * Pass `machineId` so the server can route inbound runner events
657
+ * (e.g. `assignment:claim-available`) to this specific runner.
645
658
  */
646
- async connect(platformUrl, token) {
659
+ async connect(platformUrl, token, extra) {
647
660
  return new Promise((resolve3, reject) => {
648
661
  const commsUrl = platformUrl.replace(/\/+$/, "");
649
- this.socket = io(`${commsUrl}/agent`, {
650
- auth: { token },
662
+ this.socket = io(`${commsUrl}/agent-cli`, {
663
+ auth: {
664
+ token,
665
+ machineId: extra?.machineId,
666
+ deviceToken: extra?.deviceToken,
667
+ orgId: extra?.orgId
668
+ },
651
669
  transports: ["websocket", "polling"],
652
670
  reconnection: true,
653
671
  reconnectionAttempts: this.maxReconnectAttempts,
@@ -713,10 +731,13 @@ var CommsClient = class {
713
731
  this.socket?.emit("agent:audit", event);
714
732
  }
715
733
  /*
716
- * Emit a heartbeat to the platform.
734
+ * Emit a heartbeat to the platform. The comms namespace expects the
735
+ * `runner:heartbeat` event and the RunnerHeartbeatPayload shape; the
736
+ * primary heartbeat path is HTTP via HeartbeatService, this socket path
737
+ * is reserved for the future daemon-with-open-socket flow.
717
738
  */
718
739
  emitHeartbeat(data) {
719
- this.socket?.emit("agent:heartbeat", data);
740
+ this.socket?.emit("runner:heartbeat", data);
720
741
  }
721
742
  /*
722
743
  * Listen for approval responses from the platform.
@@ -738,6 +759,15 @@ var CommsClient = class {
738
759
  onSpawnRequest(handler) {
739
760
  this.socket?.on("agent:spawn", handler);
740
761
  }
762
+ /*
763
+ * Listen for assignment:claim-available events from the platform.
764
+ * The daemon mode (V6-CLI-002, deferred) will consume these to claim
765
+ * work items. For now this stays as a registerable listener so
766
+ * non-daemon use is unaffected.
767
+ */
768
+ onAssignmentAvailable(handler) {
769
+ this.socket?.on("assignment:claim-available", handler);
770
+ }
741
771
  };
742
772
 
743
773
  // _apps/@onklave/agent-cli/src/services/session-manager.ts
@@ -762,6 +792,12 @@ var SessionManager = class {
762
792
  if (config.allowedTools && config.allowedTools.length > 0) {
763
793
  args.push("--allowedTools", config.allowedTools.join(","));
764
794
  }
795
+ if (config.deniedTools && config.deniedTools.length > 0) {
796
+ args.push("--disallowedTools", config.deniedTools.join(","));
797
+ }
798
+ if (config.addDirs && config.addDirs.length > 0) {
799
+ args.push("--add-dir", ...config.addDirs);
800
+ }
765
801
  if (config.systemPromptAppend) {
766
802
  args.push("--append-system-prompt", config.systemPromptAppend);
767
803
  }
@@ -872,6 +908,20 @@ var SessionManager = class {
872
908
  }
873
909
  };
874
910
 
911
+ // _apps/@onklave/agent-cli/src/types/events.types.ts
912
+ var DAEMON_AUDIT_ACTIONS = {
913
+ STARTED: "daemon.started",
914
+ ONLINE: "daemon.online",
915
+ DRAINING: "daemon.draining",
916
+ STOPPED: "daemon.stopped",
917
+ FORCE_STOPPED: "daemon.force_stopped",
918
+ DRAIN_TIMEOUT_EXCEEDED: "daemon.drain_timeout_exceeded",
919
+ AUTH_WARNING: "daemon.auth_warning",
920
+ AUTH_EXPIRED: "daemon.auth_expired",
921
+ HEARTBEAT_FAILED: "daemon.heartbeat_failed",
922
+ RESOURCE_LIMIT_BREACHED: "daemon.resource_limit_breached"
923
+ };
924
+
875
925
  // _apps/@onklave/agent-cli/src/services/state-publisher.ts
876
926
  var StatePublisher = class {
877
927
  constructor(commsClient) {
@@ -1178,6 +1228,69 @@ var GuardrailEnforcer = class {
1178
1228
  }
1179
1229
  };
1180
1230
 
1231
+ // _apps/@onklave/agent-cli/src/services/heartbeat.service.ts
1232
+ var DEFAULT_INTERVAL_MS = 3e4;
1233
+ var HeartbeatService = class {
1234
+ constructor(opts) {
1235
+ this.opts = opts;
1236
+ this.timer = null;
1237
+ this.intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
1238
+ this.onError = opts.onError ?? ((err) => console.error(`[heartbeat] ${err.message}`));
1239
+ }
1240
+ start() {
1241
+ if (this.timer) return;
1242
+ void this.emit();
1243
+ this.timer = setInterval(() => {
1244
+ void this.emit();
1245
+ }, this.intervalMs);
1246
+ if (typeof this.timer.unref === "function") {
1247
+ this.timer.unref();
1248
+ }
1249
+ }
1250
+ stop() {
1251
+ if (this.timer) {
1252
+ clearInterval(this.timer);
1253
+ this.timer = null;
1254
+ }
1255
+ }
1256
+ /**
1257
+ * Single heartbeat emission. Exposed for tests and one-shot diagnostics
1258
+ * (`onklave doctor`).
1259
+ */
1260
+ async emit() {
1261
+ const body = {
1262
+ machineId: this.opts.machineId,
1263
+ status: "online",
1264
+ activeSessionCount: this.opts.getActiveSessionCount(),
1265
+ resourceUsage: this.opts.getResourceUsage?.()
1266
+ };
1267
+ try {
1268
+ const response = await fetch(
1269
+ `${this.opts.platformUrl}/api/v1/runner/heartbeat`,
1270
+ {
1271
+ method: "POST",
1272
+ headers: {
1273
+ "Content-Type": "application/json",
1274
+ Accept: "application/json",
1275
+ "x-device-token": this.opts.deviceToken
1276
+ },
1277
+ body: JSON.stringify(body),
1278
+ signal: AbortSignal.timeout(1e4)
1279
+ }
1280
+ );
1281
+ if (!response.ok) {
1282
+ this.onError(
1283
+ new Error(
1284
+ `heartbeat failed: ${response.status} ${response.statusText}`
1285
+ )
1286
+ );
1287
+ }
1288
+ } catch (err) {
1289
+ this.onError(err instanceof Error ? err : new Error(String(err)));
1290
+ }
1291
+ }
1292
+ };
1293
+
1181
1294
  // _apps/@onklave/agent-cli/src/tui/render.tsx
1182
1295
  import { render } from "ink";
1183
1296
 
@@ -1764,6 +1877,21 @@ async function runCommand(args) {
1764
1877
  resolvedConfig.platformUrl,
1765
1878
  creds.token
1766
1879
  );
1880
+ const sessionManager = new SessionManager();
1881
+ let heartbeat = null;
1882
+ if (creds.machineId && creds.deviceToken) {
1883
+ heartbeat = new HeartbeatService({
1884
+ platformUrl: resolvedConfig.platformUrl,
1885
+ deviceToken: creds.deviceToken,
1886
+ machineId: creds.machineId,
1887
+ getActiveSessionCount: () => sessionManager.getActiveSessionIds().length
1888
+ });
1889
+ heartbeat.start();
1890
+ const stopHeartbeat = () => heartbeat?.stop();
1891
+ process.once("SIGTERM", stopHeartbeat);
1892
+ process.once("SIGINT", stopHeartbeat);
1893
+ process.once("beforeExit", stopHeartbeat);
1894
+ }
1767
1895
  let sessionId;
1768
1896
  try {
1769
1897
  console.log("Creating agent session on Onklave platform...");
@@ -1788,7 +1916,11 @@ async function runCommand(args) {
1788
1916
  const auditStreamer = new AuditStreamer(commsClient);
1789
1917
  try {
1790
1918
  console.log("Connecting to Onklave comms service...");
1791
- await commsClient.connect(resolvedConfig.platformUrl, creds.token);
1919
+ await commsClient.connect(resolvedConfig.platformUrl, creds.token, {
1920
+ machineId: creds.machineId,
1921
+ deviceToken: creds.deviceToken,
1922
+ orgId: creds.orgId
1923
+ });
1792
1924
  console.log("Connected.");
1793
1925
  } catch (err) {
1794
1926
  console.warn(
@@ -1817,13 +1949,27 @@ async function runCommand(args) {
1817
1949
  );
1818
1950
  }
1819
1951
  }
1820
- const _guardrailEnforcer = new GuardrailEnforcer({
1952
+ const guardrailEnforcer = new GuardrailEnforcer({
1821
1953
  rules: resolvedConfig.guardrails,
1822
1954
  allowedTools: resolvedConfig.allowedTools,
1823
1955
  deniedTools: resolvedConfig.deniedTools,
1824
1956
  readablePaths: resolvedConfig.readablePaths,
1825
1957
  writablePaths: resolvedConfig.writablePaths
1826
1958
  });
1959
+ for (const tool of resolvedConfig.allowedTools) {
1960
+ const verdict = guardrailEnforcer.checkToolCall(tool, {});
1961
+ if (!verdict.allowed) {
1962
+ console.warn(
1963
+ `Warning: allowed tool "${tool}" is also denied by guardrails \u2014 Claude will reject this tool.`
1964
+ );
1965
+ }
1966
+ }
1967
+ const addDirs = [
1968
+ .../* @__PURE__ */ new Set([
1969
+ ...resolvedConfig.writablePaths,
1970
+ ...resolvedConfig.readablePaths
1971
+ ])
1972
+ ];
1827
1973
  const sessionConfig = {
1828
1974
  task: resolvedConfig.task,
1829
1975
  context: resolvedConfig.context,
@@ -1834,12 +1980,25 @@ async function runCommand(args) {
1834
1980
  guardrails: resolvedConfig.guardrails,
1835
1981
  allowedTools: resolvedConfig.allowedTools,
1836
1982
  deniedTools: resolvedConfig.deniedTools,
1983
+ addDirs,
1837
1984
  apiKey: resolvedConfig.apiKey,
1838
1985
  headless: resolvedConfig.headless,
1839
1986
  platformUrl: resolvedConfig.platformUrl,
1840
1987
  orgId: resolvedConfig.orgId,
1841
1988
  systemPromptAppend: personaSystemPrompt ?? resolvedConfig.systemPromptAppend
1842
1989
  };
1990
+ auditStreamer.record({
1991
+ sessionId,
1992
+ type: "state_change" /* STATE_CHANGE */,
1993
+ action: "guardrails_applied",
1994
+ details: {
1995
+ ruleCount: resolvedConfig.guardrails.length,
1996
+ allowedTools: resolvedConfig.allowedTools,
1997
+ deniedTools: resolvedConfig.deniedTools,
1998
+ addDirs
1999
+ },
2000
+ outcome: "success"
2001
+ });
1843
2002
  statePublisher.publishSessionStarted(sessionId, sessionConfig);
1844
2003
  auditStreamer.record({
1845
2004
  sessionId,
@@ -1849,7 +2008,6 @@ async function runCommand(args) {
1849
2008
  outcome: "success"
1850
2009
  });
1851
2010
  const useTui = flags["tui"] === true || process.stdout.isTTY === true && !resolvedConfig.headless && flags["tui"] !== false;
1852
- const sessionManager = new SessionManager();
1853
2011
  if (useTui) {
1854
2012
  const tuiInstance = renderTui({
1855
2013
  sessionId,
@@ -2568,11 +2726,14 @@ async function configCommand(args) {
2568
2726
  import * as os3 from "os";
2569
2727
  async function registerCommand(args) {
2570
2728
  const { flags } = parseArgs(args);
2729
+ const refresh = flags["refresh"] === true;
2571
2730
  const linkingToken = flags["token"];
2572
- if (typeof linkingToken !== "string" || !linkingToken) {
2573
- console.error("Error: --token is required.\n");
2574
- console.log("Usage: onklave register --token <linking-token>\n");
2575
- console.log("To obtain a linking token:");
2731
+ if (!refresh && (typeof linkingToken !== "string" || !linkingToken)) {
2732
+ console.error("Error: --token is required (or pass --refresh).\n");
2733
+ console.log("Usage:");
2734
+ console.log(" onklave register --token <linking-token>");
2735
+ console.log(" onklave register --refresh # re-detect capabilities");
2736
+ console.log("\nTo obtain a linking token:");
2576
2737
  console.log(" 1. Log in to the Onklave Portal");
2577
2738
  console.log(" 2. Navigate to Settings > Machines");
2578
2739
  console.log(' 3. Click "Register Machine" to generate a linking token');
@@ -2586,6 +2747,28 @@ async function registerCommand(args) {
2586
2747
  process.exitCode = 1;
2587
2748
  return;
2588
2749
  }
2750
+ const platformClient = new PlatformClient(creds.platformUrl, creds.token);
2751
+ if (refresh) {
2752
+ if (!creds.machineId) {
2753
+ console.error(
2754
+ "Error: --refresh requires a previously-registered machine. Run `onklave register --token <linking-token>` first."
2755
+ );
2756
+ process.exitCode = 1;
2757
+ return;
2758
+ }
2759
+ const capabilities = detectCapabilities();
2760
+ console.log("Re-detecting capabilities for this machine...");
2761
+ console.log(` Capabilities: ${capabilities.join(", ") || "(none)"}`);
2762
+ try {
2763
+ await platformClient.updateMachine(creds.machineId, { capabilities });
2764
+ console.log(`
2765
+ Machine ${creds.machineId} updated successfully.`);
2766
+ } catch (err) {
2767
+ console.error(`Refresh failed: ${err.message}`);
2768
+ process.exitCode = 1;
2769
+ }
2770
+ return;
2771
+ }
2589
2772
  const metadata = {
2590
2773
  hostname: os3.hostname(),
2591
2774
  os: `${os3.platform()} ${os3.release()}`,
@@ -2593,14 +2776,19 @@ async function registerCommand(args) {
2593
2776
  nodeVersion: process.version,
2594
2777
  capabilities: detectCapabilities()
2595
2778
  };
2596
- const platformClient = new PlatformClient(creds.platformUrl, creds.token);
2597
2779
  try {
2598
2780
  console.log("Registering machine with Onklave platform...");
2599
2781
  console.log(` Hostname: ${metadata.hostname}`);
2600
2782
  console.log(` OS: ${metadata.os}`);
2601
2783
  console.log(` Arch: ${metadata.arch}`);
2602
2784
  console.log(` Node: ${metadata.nodeVersion}`);
2603
- const result = await platformClient.registerMachine(linkingToken, metadata);
2785
+ console.log(
2786
+ ` Capabilities: ${metadata.capabilities?.join(", ") || "(none)"}`
2787
+ );
2788
+ const result = await platformClient.registerMachine(
2789
+ linkingToken,
2790
+ metadata
2791
+ );
2604
2792
  await authService.saveDeviceToken(result.deviceToken, result.machineId);
2605
2793
  console.log(`
2606
2794
  Machine registered successfully.`);
@@ -2610,6 +2798,9 @@ Machine registered successfully.`);
2610
2798
  `
2611
2799
  This machine can now receive remote agent session requests.`
2612
2800
  );
2801
+ console.log(
2802
+ `Tip: after installing new tools (e.g. Docker), run \`onklave register --refresh\` to update capabilities.`
2803
+ );
2613
2804
  } catch (err) {
2614
2805
  console.error(`Registration failed: ${err.message}`);
2615
2806
  process.exitCode = 1;
@@ -2669,6 +2860,538 @@ async function logsCommand(args) {
2669
2860
  }
2670
2861
  }
2671
2862
 
2863
+ // _apps/@onklave/agent-cli/src/commands/daemon.command.ts
2864
+ import * as fs6 from "fs";
2865
+
2866
+ // _apps/@onklave/agent-cli/src/services/daemon-comms.service.ts
2867
+ var DaemonCommsService = class {
2868
+ constructor(opts, client) {
2869
+ this.disconnectedAt = null;
2870
+ this.stopRequested = false;
2871
+ this.client = client ?? new CommsClient();
2872
+ this.opts = opts;
2873
+ this.sleep = opts.sleep ?? defaultSleep;
2874
+ this.onRetry = opts.onRetry ?? ((attempt, delayMs, err) => console.warn(
2875
+ `[daemon-comms] reconnect attempt ${attempt} after ${delayMs}ms (${err.message})`
2876
+ ));
2877
+ }
2878
+ /**
2879
+ * Opens the socket connection. Retries indefinitely with jittered
2880
+ * exponential backoff until either (a) a connect succeeds or (b)
2881
+ * `stop()` is called. Throws only if `stop()` is invoked before any
2882
+ * successful connect.
2883
+ */
2884
+ async connect() {
2885
+ let attempt = 0;
2886
+ while (!this.stopRequested) {
2887
+ try {
2888
+ await this.client.connect(this.opts.platformUrl, this.opts.token, {
2889
+ machineId: this.opts.machineId,
2890
+ deviceToken: this.opts.deviceToken,
2891
+ orgId: this.opts.orgId
2892
+ });
2893
+ this.disconnectedAt = null;
2894
+ return;
2895
+ } catch (err) {
2896
+ attempt += 1;
2897
+ const delay = this.computeBackoff(attempt);
2898
+ this.onRetry(attempt, delay, err);
2899
+ if (this.disconnectedAt == null) this.disconnectedAt = /* @__PURE__ */ new Date();
2900
+ await this.sleep(delay);
2901
+ }
2902
+ }
2903
+ throw new Error("daemon-comms: stop() called before any connect succeeded");
2904
+ }
2905
+ /**
2906
+ * Signal that no further reconnect should be attempted and close the
2907
+ * underlying socket cleanly. Idempotent.
2908
+ */
2909
+ disconnect() {
2910
+ this.stopRequested = true;
2911
+ this.client.disconnect();
2912
+ }
2913
+ inner() {
2914
+ return this.client;
2915
+ }
2916
+ isConnected() {
2917
+ return this.client.isConnected();
2918
+ }
2919
+ /**
2920
+ * Continuous-disconnect duration in ms (null if currently connected).
2921
+ * Per spec §3.3, when this exceeds 120s the daemon's `status` output
2922
+ * should report `offline` — the platform will have done so via
2923
+ * heartbeat staleness anyway.
2924
+ */
2925
+ disconnectedForMs() {
2926
+ if (!this.disconnectedAt) return null;
2927
+ return Date.now() - this.disconnectedAt.getTime();
2928
+ }
2929
+ computeBackoff(attempt) {
2930
+ const max = this.opts.maxBackoffMs ?? 6e4;
2931
+ const base = Math.min(max, 1e3 * 2 ** (attempt - 1));
2932
+ const j = this.opts.jitter ?? 0.2;
2933
+ const swing = base * j;
2934
+ return Math.round(base + (Math.random() * 2 - 1) * swing);
2935
+ }
2936
+ };
2937
+ function defaultSleep(ms) {
2938
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
2939
+ }
2940
+
2941
+ // _apps/@onklave/agent-cli/src/services/daemon-state.service.ts
2942
+ import * as fs5 from "fs";
2943
+ import * as path6 from "path";
2944
+ import * as os4 from "os";
2945
+ var VALID_TRANSITIONS = {
2946
+ installing: ["registered"],
2947
+ registered: ["starting"],
2948
+ starting: ["online", "stopping"],
2949
+ online: ["draining", "stopping"],
2950
+ draining: ["stopping"],
2951
+ stopping: ["stopped"],
2952
+ stopped: ["starting"]
2953
+ };
2954
+ function defaultStateFilePath() {
2955
+ return path6.join(os4.homedir(), ".config", "onklave", "daemon.state.json");
2956
+ }
2957
+ function defaultPidFilePath() {
2958
+ return path6.join(os4.homedir(), ".config", "onklave", "daemon.pid");
2959
+ }
2960
+ var DaemonStateError = class extends Error {
2961
+ constructor(message) {
2962
+ super(message);
2963
+ this.name = "DaemonStateError";
2964
+ }
2965
+ };
2966
+ var DaemonStateService = class {
2967
+ constructor(stateFile = defaultStateFilePath(), initial = "registered", machineId) {
2968
+ this.stateFile = stateFile;
2969
+ this.machineId = machineId;
2970
+ this.listeners = [];
2971
+ this.current = initial;
2972
+ this.enteredAt = /* @__PURE__ */ new Date();
2973
+ }
2974
+ /**
2975
+ * Read the persisted state from disk, if any. Used by `daemon status`
2976
+ * when the daemon process is not running. Returns null on missing or
2977
+ * malformed file rather than throwing so the caller can decide.
2978
+ */
2979
+ static readPersisted(stateFile = defaultStateFilePath()) {
2980
+ try {
2981
+ if (!fs5.existsSync(stateFile)) return null;
2982
+ const raw = fs5.readFileSync(stateFile, "utf8");
2983
+ const parsed = JSON.parse(raw);
2984
+ if (!parsed.state || !parsed.enteredAt) return null;
2985
+ return parsed;
2986
+ } catch {
2987
+ return null;
2988
+ }
2989
+ }
2990
+ getCurrent() {
2991
+ return this.current;
2992
+ }
2993
+ getEnteredAt() {
2994
+ return this.enteredAt;
2995
+ }
2996
+ /**
2997
+ * Subscribe to transitions. Listeners are awaited sequentially in
2998
+ * registration order. If a listener throws, the transition is treated
2999
+ * as failed — the state is rolled back and the error propagates so the
3000
+ * caller can decide. (Audit emission listeners that fail must therefore
3001
+ * fail the transition — that is the constitutional `audit-fails-action`
3002
+ * coupling described in spec §1.4.)
3003
+ */
3004
+ onTransition(cb) {
3005
+ this.listeners.push(cb);
3006
+ }
3007
+ /**
3008
+ * Attempt a transition. Throws DaemonStateError on illegal transition
3009
+ * (current → next not in VALID_TRANSITIONS). Throws whatever a listener
3010
+ * throws if any listener fails; in that case state is rolled back to
3011
+ * `prev` and is not persisted.
3012
+ */
3013
+ async transition(next, opts = {}) {
3014
+ const prev = this.current;
3015
+ const allowed = VALID_TRANSITIONS[prev];
3016
+ if (!allowed.includes(next)) {
3017
+ throw new DaemonStateError(`illegal daemon transition ${prev} \u2192 ${next}`);
3018
+ }
3019
+ this.current = next;
3020
+ this.enteredAt = /* @__PURE__ */ new Date();
3021
+ try {
3022
+ for (const listener of this.listeners) {
3023
+ await listener(next, prev, opts.reason);
3024
+ }
3025
+ this.persist(opts.reason);
3026
+ } catch (err) {
3027
+ this.current = prev;
3028
+ throw err;
3029
+ }
3030
+ }
3031
+ persist(reason) {
3032
+ const dir = path6.dirname(this.stateFile);
3033
+ fs5.mkdirSync(dir, { recursive: true });
3034
+ const payload = {
3035
+ state: this.current,
3036
+ enteredAt: this.enteredAt.toISOString(),
3037
+ pid: process.pid,
3038
+ ...this.machineId ? { machineId: this.machineId } : {},
3039
+ ...reason ? { reason } : {}
3040
+ };
3041
+ const tmp = `${this.stateFile}.tmp`;
3042
+ fs5.writeFileSync(tmp, JSON.stringify(payload, null, 2));
3043
+ fs5.renameSync(tmp, this.stateFile);
3044
+ }
3045
+ /**
3046
+ * Remove the persisted state file. Called on terminal `stopped` once
3047
+ * the daemon process is exiting cleanly so `daemon status` reports
3048
+ * "registered — daemon not running" instead of stale "stopped".
3049
+ */
3050
+ clearPersisted() {
3051
+ try {
3052
+ fs5.unlinkSync(this.stateFile);
3053
+ } catch {
3054
+ }
3055
+ }
3056
+ };
3057
+
3058
+ // _apps/@onklave/agent-cli/src/services/unit-file-emitters.ts
3059
+ var SYSTEMD_UNIT = `[Unit]
3060
+ Description=Onklave Agent CLI Daemon
3061
+ Documentation=https://docs.onklave.app/cli/daemon
3062
+ After=network-online.target
3063
+ Wants=network-online.target
3064
+
3065
+ [Service]
3066
+ Type=simple
3067
+ User=onklave
3068
+ Group=onklave
3069
+ Environment="NODE_ENV=production"
3070
+ Environment="ONKLAVE_CONFIG_DIR=/var/lib/onklave"
3071
+ Environment="ONKLAVE_LOG_DIR=/var/log/onklave"
3072
+ ExecStart=/usr/local/bin/onklave daemon
3073
+ ExecStop=/usr/local/bin/onklave daemon drain --timeout 1800
3074
+ Restart=on-failure
3075
+ RestartSec=10s
3076
+ StartLimitIntervalSec=300
3077
+ StartLimitBurst=5
3078
+ KillMode=mixed
3079
+ KillSignal=SIGTERM
3080
+ TimeoutStopSec=1830s
3081
+
3082
+ # Hardening
3083
+ NoNewPrivileges=true
3084
+ ProtectSystem=strict
3085
+ ProtectHome=true
3086
+ PrivateTmp=true
3087
+ ReadWritePaths=/var/lib/onklave /var/log/onklave
3088
+
3089
+ [Install]
3090
+ WantedBy=multi-user.target
3091
+ `;
3092
+ var LAUNCHD_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
3093
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3094
+ <plist version="1.0">
3095
+ <dict>
3096
+ <key>Label</key>
3097
+ <string>app.onklave.daemon</string>
3098
+ <key>ProgramArguments</key>
3099
+ <array>
3100
+ <string>/usr/local/bin/onklave</string>
3101
+ <string>daemon</string>
3102
+ </array>
3103
+ <key>RunAtLoad</key>
3104
+ <true/>
3105
+ <key>KeepAlive</key>
3106
+ <dict>
3107
+ <key>SuccessfulExit</key>
3108
+ <false/>
3109
+ <key>Crashed</key>
3110
+ <true/>
3111
+ </dict>
3112
+ <key>ThrottleInterval</key>
3113
+ <integer>10</integer>
3114
+ <key>EnvironmentVariables</key>
3115
+ <dict>
3116
+ <key>NODE_ENV</key>
3117
+ <string>production</string>
3118
+ <key>ONKLAVE_CONFIG_DIR</key>
3119
+ <string>/Users/Shared/onklave</string>
3120
+ </dict>
3121
+ <key>StandardOutPath</key>
3122
+ <string>/Users/Shared/onklave/logs/daemon.out.log</string>
3123
+ <key>StandardErrorPath</key>
3124
+ <string>/Users/Shared/onklave/logs/daemon.err.log</string>
3125
+ <key>ProcessType</key>
3126
+ <string>Background</string>
3127
+ </dict>
3128
+ </plist>
3129
+ `;
3130
+ var NSSM_SCRIPT = `@echo off
3131
+ REM Onklave Agent CLI \u2014 NSSM service install script
3132
+ REM Run this as Administrator.
3133
+
3134
+ set NSSM=nssm.exe
3135
+ set SERVICE=OnklaveDaemon
3136
+ set NODE_EXE=%ProgramFiles%\\nodejs\\node.exe
3137
+ set ONKLAVE_CLI=%AppData%\\npm\\node_modules\\@onklave\\agent-cli\\dist\\bin\\onklave.js
3138
+
3139
+ %NSSM% install %SERVICE% "%NODE_EXE%" "%ONKLAVE_CLI%" daemon
3140
+ %NSSM% set %SERVICE% DisplayName "Onklave Agent CLI Daemon"
3141
+ %NSSM% set %SERVICE% Description "Long-running agent worker for the Onklave platform"
3142
+ %NSSM% set %SERVICE% Start SERVICE_AUTO_START
3143
+ %NSSM% set %SERVICE% AppEnvironmentExtra NODE_ENV=production ONKLAVE_CONFIG_DIR=%ProgramData%\\Onklave
3144
+ %NSSM% set %SERVICE% AppStdout %ProgramData%\\Onklave\\logs\\daemon.out.log
3145
+ %NSSM% set %SERVICE% AppStderr %ProgramData%\\Onklave\\logs\\daemon.err.log
3146
+ %NSSM% set %SERVICE% AppRotateFiles 1
3147
+ %NSSM% set %SERVICE% AppRotateOnline 1
3148
+ %NSSM% set %SERVICE% AppRotateBytes 10485760
3149
+ %NSSM% set %SERVICE% AppExit Default Restart
3150
+ %NSSM% set %SERVICE% AppRestartDelay 10000
3151
+
3152
+ %NSSM% start %SERVICE%
3153
+ `;
3154
+ function emitUnitFile(flavour) {
3155
+ switch (flavour) {
3156
+ case "systemd":
3157
+ return SYSTEMD_UNIT;
3158
+ case "launchd":
3159
+ return LAUNCHD_PLIST;
3160
+ case "nssm":
3161
+ return NSSM_SCRIPT;
3162
+ }
3163
+ }
3164
+
3165
+ // _apps/@onklave/agent-cli/src/commands/daemon.command.ts
3166
+ var UNIT_FLAGS = {
3167
+ "--emit-systemd-unit": "systemd",
3168
+ "--emit-launchd-plist": "launchd",
3169
+ "--emit-nssm-script": "nssm"
3170
+ };
3171
+ async function daemonCommand(args) {
3172
+ for (const arg of args) {
3173
+ const flavour = UNIT_FLAGS[arg];
3174
+ if (flavour) {
3175
+ process.stdout.write(emitUnitFile(flavour));
3176
+ return;
3177
+ }
3178
+ }
3179
+ const [sub] = args;
3180
+ if (sub === "status") return daemonStatus();
3181
+ if (sub === "stop" || sub === "drain") return daemonStop();
3182
+ if (sub && !sub.startsWith("--")) {
3183
+ console.error(`Unknown subcommand: ${sub}`);
3184
+ console.error(
3185
+ "Usage: onklave daemon [status|stop|drain] | --emit-systemd-unit | --emit-launchd-plist | --emit-nssm-script"
3186
+ );
3187
+ process.exitCode = 1;
3188
+ return;
3189
+ }
3190
+ return daemonStart();
3191
+ }
3192
+ async function daemonStart() {
3193
+ const authService = new AuthService();
3194
+ const creds = await authService.getCredentials();
3195
+ if (!creds?.deviceToken || !creds?.machineId) {
3196
+ console.error(
3197
+ "Error: machine is not registered. Run: onklave register --token <bootstrap>"
3198
+ );
3199
+ process.exitCode = 1;
3200
+ return;
3201
+ }
3202
+ const pidFile = defaultPidFilePath();
3203
+ if (isPidAlive(pidFile)) {
3204
+ console.error(`Daemon already running (pid ${readPid(pidFile)}).`);
3205
+ process.exitCode = 1;
3206
+ return;
3207
+ }
3208
+ const stateService = new DaemonStateService(
3209
+ defaultStateFilePath(),
3210
+ "registered",
3211
+ creds.machineId
3212
+ );
3213
+ const platformUrl = await authService.getPlatformUrl();
3214
+ const comms = new DaemonCommsService({
3215
+ platformUrl,
3216
+ token: creds.token,
3217
+ machineId: creds.machineId,
3218
+ deviceToken: creds.deviceToken,
3219
+ orgId: creds.orgId
3220
+ });
3221
+ const auditStreamer = new AuditStreamer(comms.inner());
3222
+ stateService.onTransition(async (next, prev, reason) => {
3223
+ const action = transitionToAction(next);
3224
+ if (!action) return;
3225
+ const event = {
3226
+ sessionId: creds.machineId,
3227
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3228
+ action,
3229
+ details: { from: prev, to: next, ...reason ? { reason } : {} },
3230
+ outcome: next === "stopped" && reason ? "failure" : "success"
3231
+ };
3232
+ auditStreamer.record(event);
3233
+ });
3234
+ let activeSessions = 0;
3235
+ let shuttingDown = false;
3236
+ let sigintCount = 0;
3237
+ let sigintTimer = null;
3238
+ const heartbeat = new HeartbeatService({
3239
+ platformUrl,
3240
+ deviceToken: creds.deviceToken,
3241
+ machineId: creds.machineId,
3242
+ getActiveSessionCount: () => activeSessions
3243
+ });
3244
+ const drainAndExit = async (reason) => {
3245
+ if (shuttingDown) return;
3246
+ shuttingDown = true;
3247
+ heartbeat.stop();
3248
+ try {
3249
+ if (stateService.getCurrent() === "online") {
3250
+ await stateService.transition("draining", { reason });
3251
+ }
3252
+ while (activeSessions > 0) {
3253
+ await new Promise((r) => setTimeout(r, 200));
3254
+ }
3255
+ await stateService.transition("stopping", { reason });
3256
+ auditStreamer.flush();
3257
+ comms.disconnect();
3258
+ await stateService.transition("stopped");
3259
+ } finally {
3260
+ removePid(pidFile);
3261
+ stateService.clearPersisted();
3262
+ process.exit(0);
3263
+ }
3264
+ };
3265
+ process.on("SIGTERM", () => {
3266
+ void drainAndExit("SIGTERM");
3267
+ });
3268
+ process.on("SIGINT", () => {
3269
+ sigintCount += 1;
3270
+ if (sigintCount >= 2) {
3271
+ console.error("\nForce-stop requested. Exiting immediately.");
3272
+ removePid(pidFile);
3273
+ process.exit(1);
3274
+ }
3275
+ if (!sigintTimer) {
3276
+ sigintTimer = setTimeout(() => {
3277
+ sigintCount = 0;
3278
+ sigintTimer = null;
3279
+ }, 5e3);
3280
+ sigintTimer.unref?.();
3281
+ }
3282
+ console.log(
3283
+ "\nSIGINT received. Draining (Ctrl-C again within 5s to force stop)\u2026"
3284
+ );
3285
+ void drainAndExit("SIGINT");
3286
+ });
3287
+ await stateService.transition("starting");
3288
+ writePid(pidFile);
3289
+ console.log("Connecting to Onklave comms service...");
3290
+ try {
3291
+ await comms.connect();
3292
+ } catch (err) {
3293
+ console.error(
3294
+ `Comms connect aborted: ${err.message}. Shutting down.`
3295
+ );
3296
+ removePid(pidFile);
3297
+ stateService.clearPersisted();
3298
+ process.exit(1);
3299
+ }
3300
+ console.log("Connected.");
3301
+ await stateService.transition("online");
3302
+ heartbeat.start();
3303
+ console.log(
3304
+ `Daemon online. machineId=${creds.machineId} pid=${process.pid}. Press Ctrl-C to drain.`
3305
+ );
3306
+ await new Promise(() => void 0);
3307
+ }
3308
+ async function daemonStatus() {
3309
+ const persisted = DaemonStateService.readPersisted();
3310
+ if (!persisted) {
3311
+ console.log("registered \u2014 daemon not running");
3312
+ return;
3313
+ }
3314
+ const alive = persisted.pid ? isProcessAlive(persisted.pid) : false;
3315
+ if (!alive) {
3316
+ console.log(
3317
+ `${persisted.state} \u2014 daemon process not running (last entered ${persisted.enteredAt})`
3318
+ );
3319
+ return;
3320
+ }
3321
+ console.log(`State: ${persisted.state}`);
3322
+ if (persisted.machineId)
3323
+ console.log(`Machine ID: ${persisted.machineId}`);
3324
+ if (persisted.pid) console.log(`PID: ${persisted.pid}`);
3325
+ console.log(`Entered state: ${persisted.enteredAt}`);
3326
+ if (persisted.reason) console.log(`Reason: ${persisted.reason}`);
3327
+ }
3328
+ async function daemonStop() {
3329
+ const pidFile = defaultPidFilePath();
3330
+ const pid = readPid(pidFile);
3331
+ if (!pid || !isProcessAlive(pid)) {
3332
+ console.log("No daemon running.");
3333
+ removePid(pidFile);
3334
+ return;
3335
+ }
3336
+ try {
3337
+ process.kill(pid, "SIGTERM");
3338
+ console.log(`Sent SIGTERM to daemon (pid ${pid}).`);
3339
+ } catch (err) {
3340
+ console.error(`Failed to signal daemon: ${err.message}`);
3341
+ process.exitCode = 1;
3342
+ }
3343
+ }
3344
+ function transitionToAction(next) {
3345
+ switch (next) {
3346
+ case "starting":
3347
+ return DAEMON_AUDIT_ACTIONS.STARTED;
3348
+ case "online":
3349
+ return DAEMON_AUDIT_ACTIONS.ONLINE;
3350
+ case "draining":
3351
+ return DAEMON_AUDIT_ACTIONS.DRAINING;
3352
+ case "stopped":
3353
+ return DAEMON_AUDIT_ACTIONS.STOPPED;
3354
+ default:
3355
+ return null;
3356
+ }
3357
+ }
3358
+ function readPid(pidFile) {
3359
+ try {
3360
+ const raw = fs6.readFileSync(pidFile, "utf8").trim();
3361
+ const parsed = Number.parseInt(raw, 10);
3362
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
3363
+ } catch {
3364
+ return null;
3365
+ }
3366
+ }
3367
+ function writePid(pidFile) {
3368
+ const dir = pidFile.replace(/\/[^/]+$/, "");
3369
+ fs6.mkdirSync(dir, { recursive: true });
3370
+ fs6.writeFileSync(pidFile, String(process.pid), { mode: 384 });
3371
+ }
3372
+ function removePid(pidFile) {
3373
+ try {
3374
+ fs6.unlinkSync(pidFile);
3375
+ } catch {
3376
+ }
3377
+ }
3378
+ function isPidAlive(pidFile) {
3379
+ const pid = readPid(pidFile);
3380
+ if (pid == null) return false;
3381
+ return isProcessAlive(pid);
3382
+ }
3383
+ function isProcessAlive(pid) {
3384
+ try {
3385
+ process.kill(pid, 0);
3386
+ return true;
3387
+ } catch (err) {
3388
+ if (err.code === "EPERM") {
3389
+ return true;
3390
+ }
3391
+ return false;
3392
+ }
3393
+ }
3394
+
2672
3395
  // _apps/@onklave/agent-cli/src/commands/index.ts
2673
3396
  var COMMANDS = {
2674
3397
  login: loginCommand,
@@ -2687,7 +3410,8 @@ var COMMANDS = {
2687
3410
  init: (_args) => initCommand(),
2688
3411
  config: configCommand,
2689
3412
  register: registerCommand,
2690
- logs: logsCommand
3413
+ logs: logsCommand,
3414
+ daemon: daemonCommand
2691
3415
  };
2692
3416
 
2693
3417
  // _apps/@onklave/agent-cli/src/main.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onklave/agent-cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Onklave Agent CLI — local agent runner with cloud orchestration",
5
5
  "bin": {
6
6
  "onklave": "./main.js"