@onklave/agent-cli 0.1.12 → 0.1.14

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 +813 -19
  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, path7, body) {
636
+ async request(method, path7, body, extraHeaders) {
608
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,
@@ -919,7 +949,13 @@ var DAEMON_AUDIT_ACTIONS = {
919
949
  AUTH_WARNING: "daemon.auth_warning",
920
950
  AUTH_EXPIRED: "daemon.auth_expired",
921
951
  HEARTBEAT_FAILED: "daemon.heartbeat_failed",
922
- RESOURCE_LIMIT_BREACHED: "daemon.resource_limit_breached"
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"
923
959
  };
924
960
 
925
961
  // _apps/@onklave/agent-cli/src/services/state-publisher.ts
@@ -2789,7 +2825,11 @@ Machine ${creds.machineId} updated successfully.`);
2789
2825
  linkingToken,
2790
2826
  metadata
2791
2827
  );
2792
- await authService.saveDeviceToken(result.deviceToken, result.machineId);
2828
+ await authService.saveDeviceToken(
2829
+ result.deviceToken,
2830
+ result.machineId,
2831
+ result.deviceTokenExpiresAt
2832
+ );
2793
2833
  console.log(`
2794
2834
  Machine registered successfully.`);
2795
2835
  console.log(` Machine ID: ${result.machineId}`);
@@ -2938,10 +2978,591 @@ function defaultSleep(ms) {
2938
2978
  return new Promise((resolve3) => setTimeout(resolve3, ms));
2939
2979
  }
2940
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
+
2941
3562
  // _apps/@onklave/agent-cli/src/services/daemon-state.service.ts
2942
3563
  import * as fs5 from "fs";
2943
3564
  import * as path6 from "path";
2944
- import * as os4 from "os";
3565
+ import * as os5 from "os";
2945
3566
  var VALID_TRANSITIONS = {
2946
3567
  installing: ["registered"],
2947
3568
  registered: ["starting"],
@@ -2952,10 +3573,10 @@ var VALID_TRANSITIONS = {
2952
3573
  stopped: ["starting"]
2953
3574
  };
2954
3575
  function defaultStateFilePath() {
2955
- return path6.join(os4.homedir(), ".config", "onklave", "daemon.state.json");
3576
+ return path6.join(os5.homedir(), ".config", "onklave", "daemon.state.json");
2956
3577
  }
2957
3578
  function defaultPidFilePath() {
2958
- return path6.join(os4.homedir(), ".config", "onklave", "daemon.pid");
3579
+ return path6.join(os5.homedir(), ".config", "onklave", "daemon.pid");
2959
3580
  }
2960
3581
  var DaemonStateError = class extends Error {
2961
3582
  constructor(message) {
@@ -2968,6 +3589,7 @@ var DaemonStateService = class {
2968
3589
  this.stateFile = stateFile;
2969
3590
  this.machineId = machineId;
2970
3591
  this.listeners = [];
3592
+ this.latestRuntime = null;
2971
3593
  this.current = initial;
2972
3594
  this.enteredAt = /* @__PURE__ */ new Date();
2973
3595
  }
@@ -3036,12 +3658,22 @@ var DaemonStateService = class {
3036
3658
  enteredAt: this.enteredAt.toISOString(),
3037
3659
  pid: process.pid,
3038
3660
  ...this.machineId ? { machineId: this.machineId } : {},
3039
- ...reason ? { reason } : {}
3661
+ ...reason ? { reason } : {},
3662
+ ...this.latestRuntime ? { runtime: this.latestRuntime } : {}
3040
3663
  };
3041
3664
  const tmp = `${this.stateFile}.tmp`;
3042
3665
  fs5.writeFileSync(tmp, JSON.stringify(payload, null, 2));
3043
3666
  fs5.renameSync(tmp, this.stateFile);
3044
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
+ }
3045
3677
  /**
3046
3678
  * Remove the persisted state file. Called on terminal `stopped` once
3047
3679
  * the daemon process is exiting cleanly so `daemon status` reports
@@ -3179,10 +3811,11 @@ async function daemonCommand(args) {
3179
3811
  const [sub] = args;
3180
3812
  if (sub === "status") return daemonStatus();
3181
3813
  if (sub === "stop" || sub === "drain") return daemonStop();
3814
+ if (sub === "update") return daemonUpdate();
3182
3815
  if (sub && !sub.startsWith("--")) {
3183
3816
  console.error(`Unknown subcommand: ${sub}`);
3184
3817
  console.error(
3185
- "Usage: onklave daemon [status|stop|drain] | --emit-systemd-unit | --emit-launchd-plist | --emit-nssm-script"
3818
+ "Usage: onklave daemon [status|stop|drain|update] | --emit-systemd-unit | --emit-launchd-plist | --emit-nssm-script"
3186
3819
  );
3187
3820
  process.exitCode = 1;
3188
3821
  return;
@@ -3231,25 +3864,76 @@ async function daemonStart() {
3231
3864
  };
3232
3865
  auditStreamer.record(event);
3233
3866
  });
3234
- let activeSessions = 0;
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
+ }
3235
3914
  let shuttingDown = false;
3236
3915
  let sigintCount = 0;
3237
3916
  let sigintTimer = null;
3917
+ let runtimePublisher = null;
3918
+ let updateCheckTimer = null;
3238
3919
  const heartbeat = new HeartbeatService({
3239
3920
  platformUrl,
3240
3921
  deviceToken: creds.deviceToken,
3241
3922
  machineId: creds.machineId,
3242
- getActiveSessionCount: () => activeSessions
3923
+ getActiveSessionCount: () => claimHandler.getActiveSessionCount()
3243
3924
  });
3244
3925
  const drainAndExit = async (reason) => {
3245
3926
  if (shuttingDown) return;
3246
3927
  shuttingDown = true;
3247
3928
  heartbeat.stop();
3929
+ resourceSampler.stop();
3930
+ if (runtimePublisher) clearInterval(runtimePublisher);
3931
+ if (updateCheckTimer) clearInterval(updateCheckTimer);
3248
3932
  try {
3249
3933
  if (stateService.getCurrent() === "online") {
3250
3934
  await stateService.transition("draining", { reason });
3251
3935
  }
3252
- while (activeSessions > 0) {
3936
+ while (claimHandler.getActiveSessionCount() > 0) {
3253
3937
  await new Promise((r) => setTimeout(r, 200));
3254
3938
  }
3255
3939
  await stateService.transition("stopping", { reason });
@@ -3298,8 +3982,41 @@ async function daemonStart() {
3298
3982
  process.exit(1);
3299
3983
  }
3300
3984
  console.log("Connected.");
3985
+ claimHandler.attachToSocket(comms.inner());
3301
3986
  await stateService.transition("online");
3302
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?.();
3303
4020
  console.log(
3304
4021
  `Daemon online. machineId=${creds.machineId} pid=${process.pid}. Press Ctrl-C to drain.`
3305
4022
  );
@@ -3324,6 +4041,64 @@ async function daemonStatus() {
3324
4041
  if (persisted.pid) console.log(`PID: ${persisted.pid}`);
3325
4042
  console.log(`Entered state: ${persisted.enteredAt}`);
3326
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
+ );
3327
4102
  }
3328
4103
  async function daemonStop() {
3329
4104
  const pidFile = defaultPidFilePath();
@@ -3380,6 +4155,25 @@ function isPidAlive(pidFile) {
3380
4155
  if (pid == null) return false;
3381
4156
  return isProcessAlive(pid);
3382
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
+ }
3383
4177
  function isProcessAlive(pid) {
3384
4178
  try {
3385
4179
  process.kill(pid, 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onklave/agent-cli",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Onklave Agent CLI — local agent runner with cloud orchestration",
5
5
  "bin": {
6
6
  "onklave": "./main.js"