@onklave/agent-cli 0.1.11 → 0.1.13

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 +1353 -12
  2. package/package.json +1 -1
package/main.js CHANGED
@@ -96,7 +96,7 @@ var AuthService = class {
96
96
  /*
97
97
  * Save the device token received during machine registration.
98
98
  */
99
- async saveDeviceToken(deviceToken, machineId) {
99
+ async saveDeviceToken(deviceToken, machineId, deviceTokenExpiresAt) {
100
100
  this.ensureConfigDir();
101
101
  const existing = await this.getCredentials();
102
102
  if (!existing) {
@@ -107,7 +107,8 @@ var AuthService = class {
107
107
  const credentials = {
108
108
  ...existing,
109
109
  deviceToken,
110
- machineId
110
+ machineId,
111
+ ...deviceTokenExpiresAt ? { deviceTokenExpiresAt } : {}
111
112
  };
112
113
  fs.writeFileSync(
113
114
  this.credentialsPath,
@@ -554,13 +555,41 @@ var PlatformClient = class {
554
555
  * Register this machine as an agent runner.
555
556
  */
556
557
  async registerMachine(linkingToken, metadata) {
558
+ return this.request("POST", "/api/v1/runner/register", {
559
+ linkingToken,
560
+ metadata
561
+ });
562
+ }
563
+ /*
564
+ * V6-CLI-002 Phase 6 — refresh the device token without re-running
565
+ * the linking-token flow. Caller authenticates with the current
566
+ * device token; backend issues a fresh one with extended expiry.
567
+ */
568
+ async refreshDeviceToken(currentDeviceToken) {
569
+ return this.request("POST", "/api/v1/runner/register/refresh", void 0, {
570
+ "x-device-token": currentDeviceToken
571
+ });
572
+ }
573
+ /*
574
+ * V6-CLI-002 Phase 5b — claim an assignment as the registered runner.
575
+ */
576
+ async claimAssignment(assignmentId, machineId, deviceToken) {
557
577
  return this.request(
558
578
  "POST",
559
- "/api/v1/runner/register",
560
- {
561
- linkingToken,
562
- metadata
563
- }
579
+ `/api/v1/runner/assignments/${assignmentId}/claim`,
580
+ { machineId },
581
+ { "x-device-token": deviceToken }
582
+ );
583
+ }
584
+ /*
585
+ * V6-CLI-002 Phase 5c — release a previously claimed assignment.
586
+ */
587
+ async releaseAssignment(assignmentId, machineId, reason, deviceToken) {
588
+ return this.request(
589
+ "POST",
590
+ `/api/v1/runner/assignments/${assignmentId}/release`,
591
+ { machineId, reason },
592
+ { "x-device-token": deviceToken }
564
593
  );
565
594
  }
566
595
  /*
@@ -604,12 +633,13 @@ var PlatformClient = class {
604
633
  /*
605
634
  * Make an authenticated HTTP request to the platform.
606
635
  */
607
- async request(method, path6, body) {
608
- const url = `${this.baseUrl}${path6}`;
636
+ async request(method, path7, body, extraHeaders) {
637
+ const url = `${this.baseUrl}${path7}`;
609
638
  const headers = {
610
639
  Authorization: `Bearer ${this.token}`,
611
640
  "Content-Type": "application/json",
612
- Accept: "application/json"
641
+ Accept: "application/json",
642
+ ...extraHeaders ?? {}
613
643
  };
614
644
  const options = {
615
645
  method,
@@ -908,6 +938,26 @@ var SessionManager = class {
908
938
  }
909
939
  };
910
940
 
941
+ // _apps/@onklave/agent-cli/src/types/events.types.ts
942
+ var DAEMON_AUDIT_ACTIONS = {
943
+ STARTED: "daemon.started",
944
+ ONLINE: "daemon.online",
945
+ DRAINING: "daemon.draining",
946
+ STOPPED: "daemon.stopped",
947
+ FORCE_STOPPED: "daemon.force_stopped",
948
+ DRAIN_TIMEOUT_EXCEEDED: "daemon.drain_timeout_exceeded",
949
+ AUTH_WARNING: "daemon.auth_warning",
950
+ AUTH_EXPIRED: "daemon.auth_expired",
951
+ HEARTBEAT_FAILED: "daemon.heartbeat_failed",
952
+ RESOURCE_LIMIT_BREACHED: "daemon.resource_limit_breached",
953
+ SESSION_CLAIMED: "daemon.session_claimed",
954
+ SESSION_COMPLETED: "daemon.session_completed",
955
+ SESSION_FAILED: "daemon.session_failed",
956
+ UPDATE_AVAILABLE: "daemon.update_available",
957
+ UPDATE_APPLIED: "daemon.update_applied",
958
+ UPDATE_FAILED: "daemon.update_failed"
959
+ };
960
+
911
961
  // _apps/@onklave/agent-cli/src/services/state-publisher.ts
912
962
  var StatePublisher = class {
913
963
  constructor(commsClient) {
@@ -2775,7 +2825,11 @@ Machine ${creds.machineId} updated successfully.`);
2775
2825
  linkingToken,
2776
2826
  metadata
2777
2827
  );
2778
- await authService.saveDeviceToken(result.deviceToken, result.machineId);
2828
+ await authService.saveDeviceToken(
2829
+ result.deviceToken,
2830
+ result.machineId,
2831
+ result.deviceTokenExpiresAt
2832
+ );
2779
2833
  console.log(`
2780
2834
  Machine registered successfully.`);
2781
2835
  console.log(` Machine ID: ${result.machineId}`);
@@ -2846,6 +2900,1292 @@ async function logsCommand(args) {
2846
2900
  }
2847
2901
  }
2848
2902
 
2903
+ // _apps/@onklave/agent-cli/src/commands/daemon.command.ts
2904
+ import * as fs6 from "fs";
2905
+
2906
+ // _apps/@onklave/agent-cli/src/services/daemon-comms.service.ts
2907
+ var DaemonCommsService = class {
2908
+ constructor(opts, client) {
2909
+ this.disconnectedAt = null;
2910
+ this.stopRequested = false;
2911
+ this.client = client ?? new CommsClient();
2912
+ this.opts = opts;
2913
+ this.sleep = opts.sleep ?? defaultSleep;
2914
+ this.onRetry = opts.onRetry ?? ((attempt, delayMs, err) => console.warn(
2915
+ `[daemon-comms] reconnect attempt ${attempt} after ${delayMs}ms (${err.message})`
2916
+ ));
2917
+ }
2918
+ /**
2919
+ * Opens the socket connection. Retries indefinitely with jittered
2920
+ * exponential backoff until either (a) a connect succeeds or (b)
2921
+ * `stop()` is called. Throws only if `stop()` is invoked before any
2922
+ * successful connect.
2923
+ */
2924
+ async connect() {
2925
+ let attempt = 0;
2926
+ while (!this.stopRequested) {
2927
+ try {
2928
+ await this.client.connect(this.opts.platformUrl, this.opts.token, {
2929
+ machineId: this.opts.machineId,
2930
+ deviceToken: this.opts.deviceToken,
2931
+ orgId: this.opts.orgId
2932
+ });
2933
+ this.disconnectedAt = null;
2934
+ return;
2935
+ } catch (err) {
2936
+ attempt += 1;
2937
+ const delay = this.computeBackoff(attempt);
2938
+ this.onRetry(attempt, delay, err);
2939
+ if (this.disconnectedAt == null) this.disconnectedAt = /* @__PURE__ */ new Date();
2940
+ await this.sleep(delay);
2941
+ }
2942
+ }
2943
+ throw new Error("daemon-comms: stop() called before any connect succeeded");
2944
+ }
2945
+ /**
2946
+ * Signal that no further reconnect should be attempted and close the
2947
+ * underlying socket cleanly. Idempotent.
2948
+ */
2949
+ disconnect() {
2950
+ this.stopRequested = true;
2951
+ this.client.disconnect();
2952
+ }
2953
+ inner() {
2954
+ return this.client;
2955
+ }
2956
+ isConnected() {
2957
+ return this.client.isConnected();
2958
+ }
2959
+ /**
2960
+ * Continuous-disconnect duration in ms (null if currently connected).
2961
+ * Per spec §3.3, when this exceeds 120s the daemon's `status` output
2962
+ * should report `offline` — the platform will have done so via
2963
+ * heartbeat staleness anyway.
2964
+ */
2965
+ disconnectedForMs() {
2966
+ if (!this.disconnectedAt) return null;
2967
+ return Date.now() - this.disconnectedAt.getTime();
2968
+ }
2969
+ computeBackoff(attempt) {
2970
+ const max = this.opts.maxBackoffMs ?? 6e4;
2971
+ const base = Math.min(max, 1e3 * 2 ** (attempt - 1));
2972
+ const j = this.opts.jitter ?? 0.2;
2973
+ const swing = base * j;
2974
+ return Math.round(base + (Math.random() * 2 - 1) * swing);
2975
+ }
2976
+ };
2977
+ function defaultSleep(ms) {
2978
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
2979
+ }
2980
+
2981
+ // _apps/@onklave/agent-cli/src/services/daemon-claim-handler.service.ts
2982
+ var DaemonClaimHandler = class {
2983
+ constructor(opts) {
2984
+ this.activeSessions = 0;
2985
+ this.machineId = opts.machineId;
2986
+ this.audit = opts.audit;
2987
+ this.maxConcurrentSessions = opts.maxConcurrentSessions ?? 2;
2988
+ this.spawnRunner = opts.spawnRunner ?? defaultSpawnStub;
2989
+ this.platformClient = opts.platformClient;
2990
+ this.deviceToken = opts.deviceToken;
2991
+ this.resourceSampler = opts.resourceSampler;
2992
+ this.maxCpuPercent = opts.maxCpuPercent ?? 80;
2993
+ this.maxMemoryBytes = opts.maxMemoryBytes;
2994
+ }
2995
+ /**
2996
+ * Wire up the inbound event subscriptions. Call after the socket has
2997
+ * connected — CommsClient `onSpawnRequest` / `onAssignmentAvailable`
2998
+ * register handlers via `this.socket?.on(...)` which is a no-op when
2999
+ * the socket is null.
3000
+ */
3001
+ attachToSocket(comms) {
3002
+ comms.onSpawnRequest((req) => {
3003
+ void this.handleSpawn(req);
3004
+ });
3005
+ comms.onAssignmentAvailable((event) => {
3006
+ void this.handleAssignmentAvailable(event);
3007
+ });
3008
+ }
3009
+ getActiveSessionCount() {
3010
+ return this.activeSessions;
3011
+ }
3012
+ getMaxConcurrentSessions() {
3013
+ return this.maxConcurrentSessions;
3014
+ }
3015
+ canAcceptNew() {
3016
+ if (this.activeSessions >= this.maxConcurrentSessions) return false;
3017
+ const breach = this.checkResourceLimits();
3018
+ return breach == null;
3019
+ }
3020
+ /**
3021
+ * Returns the resource limit that would be breached by accepting a
3022
+ * new claim, or null if all limits are within bounds. Exposed so
3023
+ * `daemon status` can surface which limit is the active gate.
3024
+ */
3025
+ checkResourceLimits() {
3026
+ if (!this.resourceSampler) return null;
3027
+ const snap = this.resourceSampler.snapshot();
3028
+ if (snap.sampleCount === 0) return null;
3029
+ if (snap.cpuPercent > this.maxCpuPercent) {
3030
+ return {
3031
+ limit: "cpu",
3032
+ current: snap.cpuPercent,
3033
+ max: this.maxCpuPercent
3034
+ };
3035
+ }
3036
+ if (this.maxMemoryBytes != null && snap.memoryRssBytes > this.maxMemoryBytes) {
3037
+ return {
3038
+ limit: "memory",
3039
+ current: snap.memoryRssBytes,
3040
+ max: this.maxMemoryBytes
3041
+ };
3042
+ }
3043
+ return null;
3044
+ }
3045
+ /**
3046
+ * Test-and-runtime hook: process an `agent:spawn` payload as if it had
3047
+ * arrived over the socket. Exposed for unit tests and for the
3048
+ * future re-emit-after-claim path; the live wiring is in the
3049
+ * constructor's `onSpawnRequest` registration.
3050
+ */
3051
+ async handleSpawn(req) {
3052
+ if (this.activeSessions >= this.maxConcurrentSessions) {
3053
+ this.emitResourceLimitBreached(req.sessionId, "agent:spawn", {
3054
+ limit: "sessions",
3055
+ current: this.activeSessions,
3056
+ max: this.maxConcurrentSessions
3057
+ });
3058
+ return;
3059
+ }
3060
+ const resourceBreach = this.checkResourceLimits();
3061
+ if (resourceBreach) {
3062
+ this.emitResourceLimitBreached(
3063
+ req.sessionId,
3064
+ "agent:spawn",
3065
+ resourceBreach
3066
+ );
3067
+ return;
3068
+ }
3069
+ this.activeSessions += 1;
3070
+ this.audit.record({
3071
+ sessionId: req.sessionId,
3072
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3073
+ action: DAEMON_AUDIT_ACTIONS.SESSION_CLAIMED,
3074
+ details: {
3075
+ machineId: this.machineId,
3076
+ orgId: req.orgId,
3077
+ task: truncate2(req.task, 120)
3078
+ },
3079
+ outcome: "success"
3080
+ });
3081
+ try {
3082
+ await this.spawnRunner(req);
3083
+ this.audit.record({
3084
+ sessionId: req.sessionId,
3085
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3086
+ action: DAEMON_AUDIT_ACTIONS.SESSION_COMPLETED,
3087
+ details: { machineId: this.machineId },
3088
+ outcome: "success"
3089
+ });
3090
+ } catch (err) {
3091
+ this.audit.record({
3092
+ sessionId: req.sessionId,
3093
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3094
+ action: DAEMON_AUDIT_ACTIONS.SESSION_FAILED,
3095
+ details: {
3096
+ machineId: this.machineId,
3097
+ error: err.message
3098
+ },
3099
+ outcome: "failure"
3100
+ });
3101
+ } finally {
3102
+ this.activeSessions -= 1;
3103
+ }
3104
+ }
3105
+ async handleAssignmentAvailable(event) {
3106
+ if (this.activeSessions >= this.maxConcurrentSessions) {
3107
+ this.emitResourceLimitBreached(
3108
+ event.assignmentId,
3109
+ "assignment:claim-available",
3110
+ {
3111
+ limit: "sessions",
3112
+ current: this.activeSessions,
3113
+ max: this.maxConcurrentSessions
3114
+ }
3115
+ );
3116
+ return;
3117
+ }
3118
+ const resourceBreach = this.checkResourceLimits();
3119
+ if (resourceBreach) {
3120
+ this.emitResourceLimitBreached(
3121
+ event.assignmentId,
3122
+ "assignment:claim-available",
3123
+ resourceBreach
3124
+ );
3125
+ return;
3126
+ }
3127
+ if (!this.platformClient || !this.deviceToken) {
3128
+ console.log(
3129
+ `[daemon] assignment:claim-available assignmentId=${event.assignmentId} (platformClient not wired \u2014 Phase 5b config missing)`
3130
+ );
3131
+ return;
3132
+ }
3133
+ try {
3134
+ const result = await this.platformClient.claimAssignment(
3135
+ event.assignmentId,
3136
+ this.machineId,
3137
+ this.deviceToken
3138
+ );
3139
+ this.audit.record({
3140
+ sessionId: event.assignmentId,
3141
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3142
+ action: DAEMON_AUDIT_ACTIONS.SESSION_CLAIMED,
3143
+ details: {
3144
+ machineId: this.machineId,
3145
+ orgId: event.orgId,
3146
+ workItemId: event.workItemId,
3147
+ claimedAt: result.claimedAt,
3148
+ via: "assignment:claim-available"
3149
+ },
3150
+ outcome: "success"
3151
+ });
3152
+ } catch (err) {
3153
+ this.audit.record({
3154
+ sessionId: event.assignmentId,
3155
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3156
+ action: DAEMON_AUDIT_ACTIONS.SESSION_FAILED,
3157
+ details: {
3158
+ machineId: this.machineId,
3159
+ phase: "claim",
3160
+ error: err.message
3161
+ },
3162
+ outcome: "failure"
3163
+ });
3164
+ }
3165
+ }
3166
+ /**
3167
+ * Phase 5c — release a previously claimed assignment when the
3168
+ * daemon cannot proceed (e.g. drain interrupted, capacity check
3169
+ * failed mid-flight). Best-effort: failures here are logged but
3170
+ * the daemon's local state already moved on.
3171
+ */
3172
+ async releaseClaim(assignmentId, reason) {
3173
+ if (!this.platformClient || !this.deviceToken) return;
3174
+ try {
3175
+ await this.platformClient.releaseAssignment(
3176
+ assignmentId,
3177
+ this.machineId,
3178
+ reason,
3179
+ this.deviceToken
3180
+ );
3181
+ } catch (err) {
3182
+ console.warn(
3183
+ `[daemon] release ${assignmentId} failed: ${err.message}`
3184
+ );
3185
+ }
3186
+ }
3187
+ emitResourceLimitBreached(refId, trigger, breach) {
3188
+ this.audit.record({
3189
+ sessionId: refId,
3190
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3191
+ action: DAEMON_AUDIT_ACTIONS.RESOURCE_LIMIT_BREACHED,
3192
+ details: {
3193
+ machineId: this.machineId,
3194
+ trigger,
3195
+ limit: breach.limit,
3196
+ current: breach.current,
3197
+ max: breach.max,
3198
+ activeSessions: this.activeSessions,
3199
+ maxConcurrentSessions: this.maxConcurrentSessions
3200
+ },
3201
+ outcome: "failure"
3202
+ });
3203
+ }
3204
+ };
3205
+ async function defaultSpawnStub(request) {
3206
+ console.log(
3207
+ `[daemon] STUB: would spawn session ${request.sessionId} for task: ${truncate2(request.task, 80)}`
3208
+ );
3209
+ await new Promise((r) => setTimeout(r, 100));
3210
+ }
3211
+ function truncate2(s, n) {
3212
+ if (!s) return s;
3213
+ return s.length <= n ? s : `${s.slice(0, n - 1)}\u2026`;
3214
+ }
3215
+
3216
+ // _apps/@onklave/agent-cli/src/services/daemon-resource-sampler.service.ts
3217
+ import * as os4 from "os";
3218
+ var DEFAULT_SAMPLE_INTERVAL_MS = 5e3;
3219
+ var DEFAULT_WINDOW_MS = 6e4;
3220
+ var DaemonResourceSampler = class {
3221
+ constructor(opts = {}) {
3222
+ this.samples = [];
3223
+ this.timer = null;
3224
+ this.sampleIntervalMs = opts.sampleIntervalMs ?? DEFAULT_SAMPLE_INTERVAL_MS;
3225
+ this.windowMs = opts.windowMs ?? DEFAULT_WINDOW_MS;
3226
+ this.cpuSource = opts.cpuSource ?? defaultCpuPercent;
3227
+ this.memorySource = opts.memorySource ?? defaultMemoryRss;
3228
+ }
3229
+ /**
3230
+ * Start the periodic sampler. Idempotent — second start is a no-op.
3231
+ * The timer is `unref()`'d so a daemon that has nothing else to do
3232
+ * (no in-flight sessions, no inbound events) does not stay alive only
3233
+ * because of the sampler.
3234
+ */
3235
+ start() {
3236
+ if (this.timer) return;
3237
+ this.sample();
3238
+ this.timer = setInterval(() => this.sample(), this.sampleIntervalMs);
3239
+ this.timer.unref?.();
3240
+ }
3241
+ stop() {
3242
+ if (this.timer) {
3243
+ clearInterval(this.timer);
3244
+ this.timer = null;
3245
+ }
3246
+ }
3247
+ /**
3248
+ * Returns the windowed average of CPU% and the latest memory RSS.
3249
+ * Memory is *not* averaged — the RSS at sample time is what matters
3250
+ * for "can we fit another session right now".
3251
+ */
3252
+ snapshot() {
3253
+ if (this.samples.length === 0) {
3254
+ return {
3255
+ cpuPercent: 0,
3256
+ memoryRssBytes: 0,
3257
+ sampleCount: 0,
3258
+ windowMs: this.windowMs
3259
+ };
3260
+ }
3261
+ const cpuAvg = this.samples.reduce((sum, s) => sum + s.cpuPercent, 0) / this.samples.length;
3262
+ const memLatest = this.samples[this.samples.length - 1].memoryRssBytes;
3263
+ return {
3264
+ cpuPercent: cpuAvg,
3265
+ memoryRssBytes: memLatest,
3266
+ sampleCount: this.samples.length,
3267
+ windowMs: this.windowMs
3268
+ };
3269
+ }
3270
+ /** Take one sample now. Exposed for tests; called internally by start. */
3271
+ sample() {
3272
+ const now = Date.now();
3273
+ this.samples.push({
3274
+ at: now,
3275
+ cpuPercent: this.cpuSource(),
3276
+ memoryRssBytes: this.memorySource()
3277
+ });
3278
+ const cutoff = now - this.windowMs;
3279
+ while (this.samples.length > 0 && this.samples[0].at < cutoff) {
3280
+ this.samples.shift();
3281
+ }
3282
+ }
3283
+ };
3284
+ function defaultCpuPercent() {
3285
+ const cpus2 = os4.cpus().length || 1;
3286
+ const load1m = os4.loadavg()[0];
3287
+ return load1m / cpus2 * 100;
3288
+ }
3289
+ function defaultMemoryRss() {
3290
+ return process.memoryUsage().rss;
3291
+ }
3292
+
3293
+ // _apps/@onklave/agent-cli/src/services/daemon-spawner.service.ts
3294
+ var DaemonSpawner = class {
3295
+ constructor(opts) {
3296
+ this.sessionManager = opts.sessionManager ?? new SessionManager();
3297
+ this.platformUrl = opts.platformUrl;
3298
+ }
3299
+ async runSpawnRequest(req) {
3300
+ const config = {
3301
+ task: req.task,
3302
+ context: req.context,
3303
+ persona: req.persona,
3304
+ workflow: req.workflow,
3305
+ model: req.model,
3306
+ timeout: req.timeout,
3307
+ guardrails: [],
3308
+ allowedTools: [],
3309
+ deniedTools: [],
3310
+ apiKey: req.apiKey ?? null,
3311
+ headless: true,
3312
+ platformUrl: this.platformUrl,
3313
+ orgId: req.orgId,
3314
+ systemPromptAppend: null
3315
+ };
3316
+ return new Promise((resolve3, reject) => {
3317
+ void this.sessionManager.spawnSession(req.sessionId, config, {
3318
+ onStdout: (data) => {
3319
+ process.stdout.write(data);
3320
+ },
3321
+ onStderr: (data) => {
3322
+ process.stderr.write(data);
3323
+ },
3324
+ onExit: (code, signal) => {
3325
+ if (code === 0) {
3326
+ resolve3();
3327
+ } else {
3328
+ reject(
3329
+ new Error(
3330
+ `session ${req.sessionId} exited with code=${code} signal=${signal}`
3331
+ )
3332
+ );
3333
+ }
3334
+ }
3335
+ });
3336
+ });
3337
+ }
3338
+ };
3339
+
3340
+ // _apps/@onklave/agent-cli/src/services/daemon-token-observer.service.ts
3341
+ var WARNING_THRESHOLD_DAYS = 30;
3342
+ var DaemonTokenObserver = class {
3343
+ constructor(opts) {
3344
+ this.warningEmitted = false;
3345
+ this.expiredEmitted = false;
3346
+ this.machineId = opts.machineId;
3347
+ this.audit = opts.audit;
3348
+ this.expiresAt = opts.deviceTokenExpiresAt ? new Date(opts.deviceTokenExpiresAt) : null;
3349
+ this.now = opts.now ?? (() => /* @__PURE__ */ new Date());
3350
+ }
3351
+ /**
3352
+ * Update the tracked expiry after a successful refresh. Resets the
3353
+ * one-shot emitted flags so a future warning / expiry can re-emit.
3354
+ */
3355
+ updateExpiry(newExpiresAt) {
3356
+ this.expiresAt = new Date(newExpiresAt);
3357
+ this.warningEmitted = false;
3358
+ this.expiredEmitted = false;
3359
+ }
3360
+ /**
3361
+ * Snapshot the current expiry status. Idempotent — does not emit audit
3362
+ * events. Use {@link emitIfNeeded} for the side-effectful check.
3363
+ */
3364
+ status() {
3365
+ if (!this.expiresAt || Number.isNaN(this.expiresAt.getTime())) {
3366
+ return {
3367
+ state: "unknown",
3368
+ message: "device token expiry not recorded \u2014 re-register to refresh"
3369
+ };
3370
+ }
3371
+ const msRemaining = this.expiresAt.getTime() - this.now().getTime();
3372
+ if (msRemaining <= 0) {
3373
+ return { state: "expired", expiresAt: this.expiresAt.toISOString() };
3374
+ }
3375
+ const daysRemaining = Math.floor(msRemaining / (24 * 60 * 60 * 1e3));
3376
+ if (daysRemaining <= WARNING_THRESHOLD_DAYS) {
3377
+ return {
3378
+ state: "warning",
3379
+ daysRemaining,
3380
+ expiresAt: this.expiresAt.toISOString()
3381
+ };
3382
+ }
3383
+ return {
3384
+ state: "ok",
3385
+ daysRemaining,
3386
+ expiresAt: this.expiresAt.toISOString()
3387
+ };
3388
+ }
3389
+ /**
3390
+ * Emit audit events for state changes (warning crossed, expiry hit).
3391
+ * Emits at most one warning and one expired event per observer
3392
+ * instance — repeated calls are silent until process restart.
3393
+ */
3394
+ emitIfNeeded() {
3395
+ const status = this.status();
3396
+ if (status.state === "expired" && !this.expiredEmitted) {
3397
+ this.audit.record({
3398
+ sessionId: this.machineId,
3399
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3400
+ action: DAEMON_AUDIT_ACTIONS.AUTH_EXPIRED,
3401
+ details: { machineId: this.machineId, expiresAt: status.expiresAt },
3402
+ outcome: "failure"
3403
+ });
3404
+ this.expiredEmitted = true;
3405
+ } else if (status.state === "warning" && !this.warningEmitted) {
3406
+ this.audit.record({
3407
+ sessionId: this.machineId,
3408
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3409
+ action: DAEMON_AUDIT_ACTIONS.AUTH_WARNING,
3410
+ details: {
3411
+ machineId: this.machineId,
3412
+ daysRemaining: status.daysRemaining,
3413
+ expiresAt: status.expiresAt
3414
+ },
3415
+ outcome: "success"
3416
+ });
3417
+ this.warningEmitted = true;
3418
+ }
3419
+ return status;
3420
+ }
3421
+ /** True iff the token has expired right now. */
3422
+ isExpired() {
3423
+ return this.status().state === "expired";
3424
+ }
3425
+ };
3426
+
3427
+ // _apps/@onklave/agent-cli/src/services/daemon-token-refresher.service.ts
3428
+ var REFRESH_THRESHOLD_DAYS = 7;
3429
+ var DaemonTokenRefresher = class {
3430
+ constructor(opts) {
3431
+ this.opts = opts;
3432
+ this.inFlight = false;
3433
+ this.lastAttemptAt = null;
3434
+ }
3435
+ /**
3436
+ * Idempotent tick — call from the heartbeat / runtime publisher loop.
3437
+ * Does nothing if the token is healthy (>7 days remaining), if the
3438
+ * token's expiry is unknown, or if a refresh is already in flight.
3439
+ */
3440
+ async maybeRefresh() {
3441
+ if (this.inFlight) return;
3442
+ const status = this.opts.observer.status();
3443
+ if (status.state === "unknown") return;
3444
+ if (status.state === "expired") return;
3445
+ if (status.daysRemaining > REFRESH_THRESHOLD_DAYS) return;
3446
+ this.inFlight = true;
3447
+ this.lastAttemptAt = /* @__PURE__ */ new Date();
3448
+ try {
3449
+ const currentToken = this.opts.getDeviceToken();
3450
+ const result = await this.opts.platformClient.refreshDeviceToken(
3451
+ currentToken
3452
+ );
3453
+ await this.opts.authService.saveDeviceToken(
3454
+ result.deviceToken,
3455
+ this.opts.machineId,
3456
+ result.deviceTokenExpiresAt
3457
+ );
3458
+ this.opts.observer.updateExpiry(result.deviceTokenExpiresAt);
3459
+ this.opts.onTokenRefreshed?.(
3460
+ result.deviceToken,
3461
+ result.deviceTokenExpiresAt
3462
+ );
3463
+ } catch (err) {
3464
+ console.warn(
3465
+ `[daemon] device-token refresh failed: ${err.message} (will retry)`
3466
+ );
3467
+ } finally {
3468
+ this.inFlight = false;
3469
+ }
3470
+ }
3471
+ /** Most recent refresh-attempt time, or null if never tried. */
3472
+ getLastAttemptAt() {
3473
+ return this.lastAttemptAt;
3474
+ }
3475
+ };
3476
+
3477
+ // _apps/@onklave/agent-cli/src/services/daemon-update-checker.service.ts
3478
+ var REGISTRY_URL = "https://registry.npmjs.org";
3479
+ var PACKAGE_NAME = "@onklave/agent-cli";
3480
+ var DaemonUpdateChecker = class {
3481
+ constructor(opts) {
3482
+ this.latestVersion = null;
3483
+ this.lastCheckedAt = null;
3484
+ this.emittedForVersion = null;
3485
+ this.currentVersion = opts.currentVersion;
3486
+ this.audit = opts.audit;
3487
+ this.registryFetcher = opts.registryFetcher ?? defaultRegistryFetcher;
3488
+ }
3489
+ /**
3490
+ * Hit the registry. On success, sets latestVersion and (if newer than
3491
+ * current) emits `daemon.update_available` — exactly once per version,
3492
+ * even across repeated checks. On failure, logs and returns; the next
3493
+ * tick can retry.
3494
+ */
3495
+ async check() {
3496
+ try {
3497
+ const latest = await this.registryFetcher(PACKAGE_NAME);
3498
+ this.latestVersion = latest;
3499
+ this.lastCheckedAt = /* @__PURE__ */ new Date();
3500
+ if (isNewerVersion(latest, this.currentVersion) && this.emittedForVersion !== latest) {
3501
+ this.audit.record({
3502
+ sessionId: this.currentVersion,
3503
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3504
+ action: DAEMON_AUDIT_ACTIONS.UPDATE_AVAILABLE,
3505
+ details: {
3506
+ currentVersion: this.currentVersion,
3507
+ latestVersion: latest
3508
+ },
3509
+ outcome: "success"
3510
+ });
3511
+ this.emittedForVersion = latest;
3512
+ }
3513
+ } catch (err) {
3514
+ console.warn(
3515
+ `[daemon] update check failed: ${err.message} (will retry)`
3516
+ );
3517
+ }
3518
+ }
3519
+ info() {
3520
+ return {
3521
+ currentVersion: this.currentVersion,
3522
+ latestVersion: this.latestVersion,
3523
+ updateAvailable: !!this.latestVersion && isNewerVersion(this.latestVersion, this.currentVersion),
3524
+ checkedAt: this.lastCheckedAt?.toISOString() ?? null
3525
+ };
3526
+ }
3527
+ };
3528
+ async function defaultRegistryFetcher(packageName) {
3529
+ const url = `${REGISTRY_URL}/${encodeURIComponent(packageName)}/latest`;
3530
+ const response = await fetch(url, {
3531
+ method: "GET",
3532
+ headers: { Accept: "application/json" },
3533
+ signal: AbortSignal.timeout(1e4)
3534
+ });
3535
+ if (!response.ok) {
3536
+ throw new Error(`registry ${response.status} ${response.statusText}`);
3537
+ }
3538
+ const body = await response.json();
3539
+ if (!body.version) throw new Error("registry response missing version");
3540
+ return body.version;
3541
+ }
3542
+ function isNewerVersion(a, b) {
3543
+ const pa = parseSemverNumeric(a);
3544
+ const pb = parseSemverNumeric(b);
3545
+ if (!pa || !pb) return false;
3546
+ for (let i = 0; i < 3; i += 1) {
3547
+ if (pa[i] > pb[i]) return true;
3548
+ if (pa[i] < pb[i]) return false;
3549
+ }
3550
+ return false;
3551
+ }
3552
+ function parseSemverNumeric(v) {
3553
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
3554
+ if (!match) return null;
3555
+ return [
3556
+ Number.parseInt(match[1], 10),
3557
+ Number.parseInt(match[2], 10),
3558
+ Number.parseInt(match[3], 10)
3559
+ ];
3560
+ }
3561
+
3562
+ // _apps/@onklave/agent-cli/src/services/daemon-state.service.ts
3563
+ import * as fs5 from "fs";
3564
+ import * as path6 from "path";
3565
+ import * as os5 from "os";
3566
+ var VALID_TRANSITIONS = {
3567
+ installing: ["registered"],
3568
+ registered: ["starting"],
3569
+ starting: ["online", "stopping"],
3570
+ online: ["draining", "stopping"],
3571
+ draining: ["stopping"],
3572
+ stopping: ["stopped"],
3573
+ stopped: ["starting"]
3574
+ };
3575
+ function defaultStateFilePath() {
3576
+ return path6.join(os5.homedir(), ".config", "onklave", "daemon.state.json");
3577
+ }
3578
+ function defaultPidFilePath() {
3579
+ return path6.join(os5.homedir(), ".config", "onklave", "daemon.pid");
3580
+ }
3581
+ var DaemonStateError = class extends Error {
3582
+ constructor(message) {
3583
+ super(message);
3584
+ this.name = "DaemonStateError";
3585
+ }
3586
+ };
3587
+ var DaemonStateService = class {
3588
+ constructor(stateFile = defaultStateFilePath(), initial = "registered", machineId) {
3589
+ this.stateFile = stateFile;
3590
+ this.machineId = machineId;
3591
+ this.listeners = [];
3592
+ this.latestRuntime = null;
3593
+ this.current = initial;
3594
+ this.enteredAt = /* @__PURE__ */ new Date();
3595
+ }
3596
+ /**
3597
+ * Read the persisted state from disk, if any. Used by `daemon status`
3598
+ * when the daemon process is not running. Returns null on missing or
3599
+ * malformed file rather than throwing so the caller can decide.
3600
+ */
3601
+ static readPersisted(stateFile = defaultStateFilePath()) {
3602
+ try {
3603
+ if (!fs5.existsSync(stateFile)) return null;
3604
+ const raw = fs5.readFileSync(stateFile, "utf8");
3605
+ const parsed = JSON.parse(raw);
3606
+ if (!parsed.state || !parsed.enteredAt) return null;
3607
+ return parsed;
3608
+ } catch {
3609
+ return null;
3610
+ }
3611
+ }
3612
+ getCurrent() {
3613
+ return this.current;
3614
+ }
3615
+ getEnteredAt() {
3616
+ return this.enteredAt;
3617
+ }
3618
+ /**
3619
+ * Subscribe to transitions. Listeners are awaited sequentially in
3620
+ * registration order. If a listener throws, the transition is treated
3621
+ * as failed — the state is rolled back and the error propagates so the
3622
+ * caller can decide. (Audit emission listeners that fail must therefore
3623
+ * fail the transition — that is the constitutional `audit-fails-action`
3624
+ * coupling described in spec §1.4.)
3625
+ */
3626
+ onTransition(cb) {
3627
+ this.listeners.push(cb);
3628
+ }
3629
+ /**
3630
+ * Attempt a transition. Throws DaemonStateError on illegal transition
3631
+ * (current → next not in VALID_TRANSITIONS). Throws whatever a listener
3632
+ * throws if any listener fails; in that case state is rolled back to
3633
+ * `prev` and is not persisted.
3634
+ */
3635
+ async transition(next, opts = {}) {
3636
+ const prev = this.current;
3637
+ const allowed = VALID_TRANSITIONS[prev];
3638
+ if (!allowed.includes(next)) {
3639
+ throw new DaemonStateError(`illegal daemon transition ${prev} \u2192 ${next}`);
3640
+ }
3641
+ this.current = next;
3642
+ this.enteredAt = /* @__PURE__ */ new Date();
3643
+ try {
3644
+ for (const listener of this.listeners) {
3645
+ await listener(next, prev, opts.reason);
3646
+ }
3647
+ this.persist(opts.reason);
3648
+ } catch (err) {
3649
+ this.current = prev;
3650
+ throw err;
3651
+ }
3652
+ }
3653
+ persist(reason) {
3654
+ const dir = path6.dirname(this.stateFile);
3655
+ fs5.mkdirSync(dir, { recursive: true });
3656
+ const payload = {
3657
+ state: this.current,
3658
+ enteredAt: this.enteredAt.toISOString(),
3659
+ pid: process.pid,
3660
+ ...this.machineId ? { machineId: this.machineId } : {},
3661
+ ...reason ? { reason } : {},
3662
+ ...this.latestRuntime ? { runtime: this.latestRuntime } : {}
3663
+ };
3664
+ const tmp = `${this.stateFile}.tmp`;
3665
+ fs5.writeFileSync(tmp, JSON.stringify(payload, null, 2));
3666
+ fs5.renameSync(tmp, this.stateFile);
3667
+ }
3668
+ /**
3669
+ * Publish the latest runtime snapshot. Persists to the state file
3670
+ * so `daemon status` from another process can read it. Idempotent —
3671
+ * no transition is fired.
3672
+ */
3673
+ publishRuntime(snapshot) {
3674
+ this.latestRuntime = snapshot;
3675
+ this.persist();
3676
+ }
3677
+ /**
3678
+ * Remove the persisted state file. Called on terminal `stopped` once
3679
+ * the daemon process is exiting cleanly so `daemon status` reports
3680
+ * "registered — daemon not running" instead of stale "stopped".
3681
+ */
3682
+ clearPersisted() {
3683
+ try {
3684
+ fs5.unlinkSync(this.stateFile);
3685
+ } catch {
3686
+ }
3687
+ }
3688
+ };
3689
+
3690
+ // _apps/@onklave/agent-cli/src/services/unit-file-emitters.ts
3691
+ var SYSTEMD_UNIT = `[Unit]
3692
+ Description=Onklave Agent CLI Daemon
3693
+ Documentation=https://docs.onklave.app/cli/daemon
3694
+ After=network-online.target
3695
+ Wants=network-online.target
3696
+
3697
+ [Service]
3698
+ Type=simple
3699
+ User=onklave
3700
+ Group=onklave
3701
+ Environment="NODE_ENV=production"
3702
+ Environment="ONKLAVE_CONFIG_DIR=/var/lib/onklave"
3703
+ Environment="ONKLAVE_LOG_DIR=/var/log/onklave"
3704
+ ExecStart=/usr/local/bin/onklave daemon
3705
+ ExecStop=/usr/local/bin/onklave daemon drain --timeout 1800
3706
+ Restart=on-failure
3707
+ RestartSec=10s
3708
+ StartLimitIntervalSec=300
3709
+ StartLimitBurst=5
3710
+ KillMode=mixed
3711
+ KillSignal=SIGTERM
3712
+ TimeoutStopSec=1830s
3713
+
3714
+ # Hardening
3715
+ NoNewPrivileges=true
3716
+ ProtectSystem=strict
3717
+ ProtectHome=true
3718
+ PrivateTmp=true
3719
+ ReadWritePaths=/var/lib/onklave /var/log/onklave
3720
+
3721
+ [Install]
3722
+ WantedBy=multi-user.target
3723
+ `;
3724
+ var LAUNCHD_PLIST = `<?xml version="1.0" encoding="UTF-8"?>
3725
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3726
+ <plist version="1.0">
3727
+ <dict>
3728
+ <key>Label</key>
3729
+ <string>app.onklave.daemon</string>
3730
+ <key>ProgramArguments</key>
3731
+ <array>
3732
+ <string>/usr/local/bin/onklave</string>
3733
+ <string>daemon</string>
3734
+ </array>
3735
+ <key>RunAtLoad</key>
3736
+ <true/>
3737
+ <key>KeepAlive</key>
3738
+ <dict>
3739
+ <key>SuccessfulExit</key>
3740
+ <false/>
3741
+ <key>Crashed</key>
3742
+ <true/>
3743
+ </dict>
3744
+ <key>ThrottleInterval</key>
3745
+ <integer>10</integer>
3746
+ <key>EnvironmentVariables</key>
3747
+ <dict>
3748
+ <key>NODE_ENV</key>
3749
+ <string>production</string>
3750
+ <key>ONKLAVE_CONFIG_DIR</key>
3751
+ <string>/Users/Shared/onklave</string>
3752
+ </dict>
3753
+ <key>StandardOutPath</key>
3754
+ <string>/Users/Shared/onklave/logs/daemon.out.log</string>
3755
+ <key>StandardErrorPath</key>
3756
+ <string>/Users/Shared/onklave/logs/daemon.err.log</string>
3757
+ <key>ProcessType</key>
3758
+ <string>Background</string>
3759
+ </dict>
3760
+ </plist>
3761
+ `;
3762
+ var NSSM_SCRIPT = `@echo off
3763
+ REM Onklave Agent CLI \u2014 NSSM service install script
3764
+ REM Run this as Administrator.
3765
+
3766
+ set NSSM=nssm.exe
3767
+ set SERVICE=OnklaveDaemon
3768
+ set NODE_EXE=%ProgramFiles%\\nodejs\\node.exe
3769
+ set ONKLAVE_CLI=%AppData%\\npm\\node_modules\\@onklave\\agent-cli\\dist\\bin\\onklave.js
3770
+
3771
+ %NSSM% install %SERVICE% "%NODE_EXE%" "%ONKLAVE_CLI%" daemon
3772
+ %NSSM% set %SERVICE% DisplayName "Onklave Agent CLI Daemon"
3773
+ %NSSM% set %SERVICE% Description "Long-running agent worker for the Onklave platform"
3774
+ %NSSM% set %SERVICE% Start SERVICE_AUTO_START
3775
+ %NSSM% set %SERVICE% AppEnvironmentExtra NODE_ENV=production ONKLAVE_CONFIG_DIR=%ProgramData%\\Onklave
3776
+ %NSSM% set %SERVICE% AppStdout %ProgramData%\\Onklave\\logs\\daemon.out.log
3777
+ %NSSM% set %SERVICE% AppStderr %ProgramData%\\Onklave\\logs\\daemon.err.log
3778
+ %NSSM% set %SERVICE% AppRotateFiles 1
3779
+ %NSSM% set %SERVICE% AppRotateOnline 1
3780
+ %NSSM% set %SERVICE% AppRotateBytes 10485760
3781
+ %NSSM% set %SERVICE% AppExit Default Restart
3782
+ %NSSM% set %SERVICE% AppRestartDelay 10000
3783
+
3784
+ %NSSM% start %SERVICE%
3785
+ `;
3786
+ function emitUnitFile(flavour) {
3787
+ switch (flavour) {
3788
+ case "systemd":
3789
+ return SYSTEMD_UNIT;
3790
+ case "launchd":
3791
+ return LAUNCHD_PLIST;
3792
+ case "nssm":
3793
+ return NSSM_SCRIPT;
3794
+ }
3795
+ }
3796
+
3797
+ // _apps/@onklave/agent-cli/src/commands/daemon.command.ts
3798
+ var UNIT_FLAGS = {
3799
+ "--emit-systemd-unit": "systemd",
3800
+ "--emit-launchd-plist": "launchd",
3801
+ "--emit-nssm-script": "nssm"
3802
+ };
3803
+ async function daemonCommand(args) {
3804
+ for (const arg of args) {
3805
+ const flavour = UNIT_FLAGS[arg];
3806
+ if (flavour) {
3807
+ process.stdout.write(emitUnitFile(flavour));
3808
+ return;
3809
+ }
3810
+ }
3811
+ const [sub] = args;
3812
+ if (sub === "status") return daemonStatus();
3813
+ if (sub === "stop" || sub === "drain") return daemonStop();
3814
+ if (sub === "update") return daemonUpdate();
3815
+ if (sub && !sub.startsWith("--")) {
3816
+ console.error(`Unknown subcommand: ${sub}`);
3817
+ console.error(
3818
+ "Usage: onklave daemon [status|stop|drain|update] | --emit-systemd-unit | --emit-launchd-plist | --emit-nssm-script"
3819
+ );
3820
+ process.exitCode = 1;
3821
+ return;
3822
+ }
3823
+ return daemonStart();
3824
+ }
3825
+ async function daemonStart() {
3826
+ const authService = new AuthService();
3827
+ const creds = await authService.getCredentials();
3828
+ if (!creds?.deviceToken || !creds?.machineId) {
3829
+ console.error(
3830
+ "Error: machine is not registered. Run: onklave register --token <bootstrap>"
3831
+ );
3832
+ process.exitCode = 1;
3833
+ return;
3834
+ }
3835
+ const pidFile = defaultPidFilePath();
3836
+ if (isPidAlive(pidFile)) {
3837
+ console.error(`Daemon already running (pid ${readPid(pidFile)}).`);
3838
+ process.exitCode = 1;
3839
+ return;
3840
+ }
3841
+ const stateService = new DaemonStateService(
3842
+ defaultStateFilePath(),
3843
+ "registered",
3844
+ creds.machineId
3845
+ );
3846
+ const platformUrl = await authService.getPlatformUrl();
3847
+ const comms = new DaemonCommsService({
3848
+ platformUrl,
3849
+ token: creds.token,
3850
+ machineId: creds.machineId,
3851
+ deviceToken: creds.deviceToken,
3852
+ orgId: creds.orgId
3853
+ });
3854
+ const auditStreamer = new AuditStreamer(comms.inner());
3855
+ stateService.onTransition(async (next, prev, reason) => {
3856
+ const action = transitionToAction(next);
3857
+ if (!action) return;
3858
+ const event = {
3859
+ sessionId: creds.machineId,
3860
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3861
+ action,
3862
+ details: { from: prev, to: next, ...reason ? { reason } : {} },
3863
+ outcome: next === "stopped" && reason ? "failure" : "success"
3864
+ };
3865
+ auditStreamer.record(event);
3866
+ });
3867
+ const spawner = new DaemonSpawner({ platformUrl });
3868
+ const platformClient = new PlatformClient(platformUrl, creds.token);
3869
+ const resourceSampler = new DaemonResourceSampler();
3870
+ const claimHandler = new DaemonClaimHandler({
3871
+ machineId: creds.machineId,
3872
+ audit: auditStreamer,
3873
+ spawnRunner: (req) => spawner.runSpawnRequest(req),
3874
+ platformClient,
3875
+ deviceToken: creds.deviceToken,
3876
+ resourceSampler,
3877
+ maxCpuPercent: 80
3878
+ // spec §8.1 default
3879
+ });
3880
+ const tokenObserver = new DaemonTokenObserver({
3881
+ machineId: creds.machineId,
3882
+ audit: auditStreamer,
3883
+ deviceTokenExpiresAt: creds.deviceTokenExpiresAt
3884
+ });
3885
+ const updateChecker = new DaemonUpdateChecker({
3886
+ currentVersion: readPackageVersion() ?? "0.0.0",
3887
+ audit: auditStreamer
3888
+ });
3889
+ let currentDeviceToken = creds.deviceToken;
3890
+ const tokenRefresher = new DaemonTokenRefresher({
3891
+ machineId: creds.machineId,
3892
+ observer: tokenObserver,
3893
+ platformClient,
3894
+ authService,
3895
+ getDeviceToken: () => currentDeviceToken,
3896
+ onTokenRefreshed: (newToken) => {
3897
+ currentDeviceToken = newToken;
3898
+ }
3899
+ });
3900
+ const initialTokenStatus = tokenObserver.emitIfNeeded();
3901
+ if (initialTokenStatus.state === "expired") {
3902
+ console.error(
3903
+ `Device token expired (${initialTokenStatus.expiresAt}). Re-register: onklave register --token <bootstrap>`
3904
+ );
3905
+ removePid(pidFile);
3906
+ stateService.clearPersisted();
3907
+ process.exit(1);
3908
+ }
3909
+ if (initialTokenStatus.state === "warning") {
3910
+ console.warn(
3911
+ `\u26A0 Device token expires in ${initialTokenStatus.daysRemaining} days (${initialTokenStatus.expiresAt}).`
3912
+ );
3913
+ }
3914
+ let shuttingDown = false;
3915
+ let sigintCount = 0;
3916
+ let sigintTimer = null;
3917
+ let runtimePublisher = null;
3918
+ let updateCheckTimer = null;
3919
+ const heartbeat = new HeartbeatService({
3920
+ platformUrl,
3921
+ deviceToken: creds.deviceToken,
3922
+ machineId: creds.machineId,
3923
+ getActiveSessionCount: () => claimHandler.getActiveSessionCount()
3924
+ });
3925
+ const drainAndExit = async (reason) => {
3926
+ if (shuttingDown) return;
3927
+ shuttingDown = true;
3928
+ heartbeat.stop();
3929
+ resourceSampler.stop();
3930
+ if (runtimePublisher) clearInterval(runtimePublisher);
3931
+ if (updateCheckTimer) clearInterval(updateCheckTimer);
3932
+ try {
3933
+ if (stateService.getCurrent() === "online") {
3934
+ await stateService.transition("draining", { reason });
3935
+ }
3936
+ while (claimHandler.getActiveSessionCount() > 0) {
3937
+ await new Promise((r) => setTimeout(r, 200));
3938
+ }
3939
+ await stateService.transition("stopping", { reason });
3940
+ auditStreamer.flush();
3941
+ comms.disconnect();
3942
+ await stateService.transition("stopped");
3943
+ } finally {
3944
+ removePid(pidFile);
3945
+ stateService.clearPersisted();
3946
+ process.exit(0);
3947
+ }
3948
+ };
3949
+ process.on("SIGTERM", () => {
3950
+ void drainAndExit("SIGTERM");
3951
+ });
3952
+ process.on("SIGINT", () => {
3953
+ sigintCount += 1;
3954
+ if (sigintCount >= 2) {
3955
+ console.error("\nForce-stop requested. Exiting immediately.");
3956
+ removePid(pidFile);
3957
+ process.exit(1);
3958
+ }
3959
+ if (!sigintTimer) {
3960
+ sigintTimer = setTimeout(() => {
3961
+ sigintCount = 0;
3962
+ sigintTimer = null;
3963
+ }, 5e3);
3964
+ sigintTimer.unref?.();
3965
+ }
3966
+ console.log(
3967
+ "\nSIGINT received. Draining (Ctrl-C again within 5s to force stop)\u2026"
3968
+ );
3969
+ void drainAndExit("SIGINT");
3970
+ });
3971
+ await stateService.transition("starting");
3972
+ writePid(pidFile);
3973
+ console.log("Connecting to Onklave comms service...");
3974
+ try {
3975
+ await comms.connect();
3976
+ } catch (err) {
3977
+ console.error(
3978
+ `Comms connect aborted: ${err.message}. Shutting down.`
3979
+ );
3980
+ removePid(pidFile);
3981
+ stateService.clearPersisted();
3982
+ process.exit(1);
3983
+ }
3984
+ console.log("Connected.");
3985
+ claimHandler.attachToSocket(comms.inner());
3986
+ await stateService.transition("online");
3987
+ heartbeat.start();
3988
+ resourceSampler.start();
3989
+ const publishSnapshot = () => {
3990
+ void tokenRefresher.maybeRefresh();
3991
+ const tokenStatus = tokenObserver.emitIfNeeded();
3992
+ const disconnectedMs = comms.disconnectedForMs();
3993
+ const resourceSnap = resourceSampler.snapshot();
3994
+ stateService.publishRuntime({
3995
+ snapshotAt: (/* @__PURE__ */ new Date()).toISOString(),
3996
+ activeSessions: claimHandler.getActiveSessionCount(),
3997
+ maxConcurrentSessions: claimHandler.getMaxConcurrentSessions(),
3998
+ socketConnected: comms.isConnected(),
3999
+ ...disconnectedMs != null ? { socketDisconnectedForMs: disconnectedMs } : {},
4000
+ ...resourceSnap.sampleCount > 0 ? {
4001
+ cpuPercent: resourceSnap.cpuPercent,
4002
+ memoryRssBytes: resourceSnap.memoryRssBytes
4003
+ } : {},
4004
+ updateInfo: updateChecker.info(),
4005
+ tokenStatus: tokenStatus.state === "unknown" ? { state: "unknown" } : tokenStatus.state === "expired" ? { state: "expired", expiresAt: tokenStatus.expiresAt } : {
4006
+ state: tokenStatus.state,
4007
+ daysRemaining: tokenStatus.daysRemaining,
4008
+ expiresAt: tokenStatus.expiresAt
4009
+ }
4010
+ });
4011
+ };
4012
+ publishSnapshot();
4013
+ runtimePublisher = setInterval(publishSnapshot, 3e4);
4014
+ runtimePublisher.unref?.();
4015
+ void updateChecker.check();
4016
+ updateCheckTimer = setInterval(() => {
4017
+ void updateChecker.check();
4018
+ }, 60 * 60 * 1e3);
4019
+ updateCheckTimer.unref?.();
4020
+ console.log(
4021
+ `Daemon online. machineId=${creds.machineId} pid=${process.pid}. Press Ctrl-C to drain.`
4022
+ );
4023
+ await new Promise(() => void 0);
4024
+ }
4025
+ async function daemonStatus() {
4026
+ const persisted = DaemonStateService.readPersisted();
4027
+ if (!persisted) {
4028
+ console.log("registered \u2014 daemon not running");
4029
+ return;
4030
+ }
4031
+ const alive = persisted.pid ? isProcessAlive(persisted.pid) : false;
4032
+ if (!alive) {
4033
+ console.log(
4034
+ `${persisted.state} \u2014 daemon process not running (last entered ${persisted.enteredAt})`
4035
+ );
4036
+ return;
4037
+ }
4038
+ console.log(`State: ${persisted.state}`);
4039
+ if (persisted.machineId)
4040
+ console.log(`Machine ID: ${persisted.machineId}`);
4041
+ if (persisted.pid) console.log(`PID: ${persisted.pid}`);
4042
+ console.log(`Entered state: ${persisted.enteredAt}`);
4043
+ if (persisted.reason) console.log(`Reason: ${persisted.reason}`);
4044
+ if (persisted.runtime) {
4045
+ const rt = persisted.runtime;
4046
+ console.log(
4047
+ `Active sessions: ${rt.activeSessions} / ${rt.maxConcurrentSessions}`
4048
+ );
4049
+ const socketStatus = rt.socketConnected ? "connected" : rt.socketDisconnectedForMs != null ? `disconnected ${Math.round(rt.socketDisconnectedForMs / 1e3)}s` : "disconnected";
4050
+ console.log(`Comms socket: ${socketStatus}`);
4051
+ if (rt.cpuPercent != null) {
4052
+ console.log(`CPU (60s avg): ${rt.cpuPercent.toFixed(1)}%`);
4053
+ }
4054
+ if (rt.memoryRssBytes != null) {
4055
+ const mb = rt.memoryRssBytes / (1024 * 1024);
4056
+ console.log(`Memory (RSS): ${mb.toFixed(1)} MB`);
4057
+ }
4058
+ if (rt.updateInfo) {
4059
+ const u = rt.updateInfo;
4060
+ const versionLine = u.updateAvailable ? `${u.currentVersion} \u2192 ${u.latestVersion} available (run: onklave daemon update)` : `${u.currentVersion}${u.latestVersion ? " (latest)" : ""}`;
4061
+ console.log(`Daemon version: ${versionLine}`);
4062
+ }
4063
+ if (rt.tokenStatus) {
4064
+ if (rt.tokenStatus.state === "expired") {
4065
+ console.log(`Device token: EXPIRED (${rt.tokenStatus.expiresAt})`);
4066
+ } else if (rt.tokenStatus.state === "warning") {
4067
+ console.log(
4068
+ `Device token: \u26A0 ${rt.tokenStatus.daysRemaining} days remaining (${rt.tokenStatus.expiresAt})`
4069
+ );
4070
+ } else if (rt.tokenStatus.state === "ok") {
4071
+ console.log(
4072
+ `Device token: ${rt.tokenStatus.daysRemaining} days remaining`
4073
+ );
4074
+ } else {
4075
+ console.log("Device token: expiry unknown \u2014 re-register to refresh");
4076
+ }
4077
+ }
4078
+ console.log(`Snapshot age: ${snapshotAge(rt.snapshotAt)}`);
4079
+ }
4080
+ }
4081
+ function snapshotAge(iso) {
4082
+ const ms = Date.now() - new Date(iso).getTime();
4083
+ if (ms < 0) return "just now";
4084
+ if (ms < 6e4) return `${Math.round(ms / 1e3)}s ago`;
4085
+ if (ms < 36e5) return `${Math.round(ms / 6e4)}m ago`;
4086
+ return `${Math.round(ms / 36e5)}h ago`;
4087
+ }
4088
+ async function daemonUpdate() {
4089
+ console.log("To update the Onklave agent CLI, run as the install owner:");
4090
+ console.log("");
4091
+ console.log(" npm install -g @onklave/agent-cli@latest");
4092
+ console.log("");
4093
+ console.log(
4094
+ "Then restart the supervisor: `systemctl restart onklave-daemon` (Linux),"
4095
+ );
4096
+ console.log("`launchctl kickstart -k system/app.onklave.daemon` (macOS), or");
4097
+ console.log("`nssm restart OnklaveDaemon` (Windows).");
4098
+ console.log("");
4099
+ console.log(
4100
+ "Self-replace via npm provenance verification (spec \xA76.3) is deferred \u2014 see V6-CLI-002 Phase 7 in the kanban."
4101
+ );
4102
+ }
4103
+ async function daemonStop() {
4104
+ const pidFile = defaultPidFilePath();
4105
+ const pid = readPid(pidFile);
4106
+ if (!pid || !isProcessAlive(pid)) {
4107
+ console.log("No daemon running.");
4108
+ removePid(pidFile);
4109
+ return;
4110
+ }
4111
+ try {
4112
+ process.kill(pid, "SIGTERM");
4113
+ console.log(`Sent SIGTERM to daemon (pid ${pid}).`);
4114
+ } catch (err) {
4115
+ console.error(`Failed to signal daemon: ${err.message}`);
4116
+ process.exitCode = 1;
4117
+ }
4118
+ }
4119
+ function transitionToAction(next) {
4120
+ switch (next) {
4121
+ case "starting":
4122
+ return DAEMON_AUDIT_ACTIONS.STARTED;
4123
+ case "online":
4124
+ return DAEMON_AUDIT_ACTIONS.ONLINE;
4125
+ case "draining":
4126
+ return DAEMON_AUDIT_ACTIONS.DRAINING;
4127
+ case "stopped":
4128
+ return DAEMON_AUDIT_ACTIONS.STOPPED;
4129
+ default:
4130
+ return null;
4131
+ }
4132
+ }
4133
+ function readPid(pidFile) {
4134
+ try {
4135
+ const raw = fs6.readFileSync(pidFile, "utf8").trim();
4136
+ const parsed = Number.parseInt(raw, 10);
4137
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
4138
+ } catch {
4139
+ return null;
4140
+ }
4141
+ }
4142
+ function writePid(pidFile) {
4143
+ const dir = pidFile.replace(/\/[^/]+$/, "");
4144
+ fs6.mkdirSync(dir, { recursive: true });
4145
+ fs6.writeFileSync(pidFile, String(process.pid), { mode: 384 });
4146
+ }
4147
+ function removePid(pidFile) {
4148
+ try {
4149
+ fs6.unlinkSync(pidFile);
4150
+ } catch {
4151
+ }
4152
+ }
4153
+ function isPidAlive(pidFile) {
4154
+ const pid = readPid(pidFile);
4155
+ if (pid == null) return false;
4156
+ return isProcessAlive(pid);
4157
+ }
4158
+ function readPackageVersion() {
4159
+ try {
4160
+ const candidates = [
4161
+ // dist layout (bin/onklave.js → ../package.json)
4162
+ `${__dirname}/../../package.json`,
4163
+ // src layout during dev
4164
+ `${__dirname}/../../../package.json`
4165
+ ];
4166
+ for (const p of candidates) {
4167
+ if (fs6.existsSync(p)) {
4168
+ const pkg = JSON.parse(fs6.readFileSync(p, "utf8"));
4169
+ if (pkg.name === "@onklave/agent-cli" && pkg.version)
4170
+ return pkg.version;
4171
+ }
4172
+ }
4173
+ } catch {
4174
+ }
4175
+ return null;
4176
+ }
4177
+ function isProcessAlive(pid) {
4178
+ try {
4179
+ process.kill(pid, 0);
4180
+ return true;
4181
+ } catch (err) {
4182
+ if (err.code === "EPERM") {
4183
+ return true;
4184
+ }
4185
+ return false;
4186
+ }
4187
+ }
4188
+
2849
4189
  // _apps/@onklave/agent-cli/src/commands/index.ts
2850
4190
  var COMMANDS = {
2851
4191
  login: loginCommand,
@@ -2864,7 +4204,8 @@ var COMMANDS = {
2864
4204
  init: (_args) => initCommand(),
2865
4205
  config: configCommand,
2866
4206
  register: registerCommand,
2867
- logs: logsCommand
4207
+ logs: logsCommand,
4208
+ daemon: daemonCommand
2868
4209
  };
2869
4210
 
2870
4211
  // _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.11",
3
+ "version": "0.1.13",
4
4
  "description": "Onklave Agent CLI — local agent runner with cloud orchestration",
5
5
  "bin": {
6
6
  "onklave": "./main.js"