@onklave/agent-cli 0.1.29 → 0.1.31

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 +327 -25
  2. package/package.json +1 -1
package/main.js CHANGED
@@ -712,8 +712,8 @@ var PlatformClient = class {
712
712
  /*
713
713
  * Make an authenticated HTTP request to the platform.
714
714
  */
715
- async request(method, path7, body, extraHeaders) {
716
- const url = `${this.baseUrl}${path7}`;
715
+ async request(method, path8, body, extraHeaders) {
716
+ const url = `${this.baseUrl}${path8}`;
717
717
  const headers = {
718
718
  Authorization: `Bearer ${this.token}`,
719
719
  "Content-Type": "application/json",
@@ -2980,7 +2980,7 @@ async function logsCommand(args) {
2980
2980
  }
2981
2981
 
2982
2982
  // _apps/@onklave/agent-cli/src/commands/daemon.command.ts
2983
- import * as fs6 from "fs";
2983
+ import * as fs7 from "fs";
2984
2984
 
2985
2985
  // _apps/@onklave/agent-cli/src/services/daemon-comms.service.ts
2986
2986
  var DaemonCommsService = class {
@@ -3070,6 +3070,8 @@ var DaemonClaimHandler = class {
3070
3070
  this.resourceSampler = opts.resourceSampler;
3071
3071
  this.maxCpuPercent = opts.maxCpuPercent ?? 80;
3072
3072
  this.maxMemoryBytes = opts.maxMemoryBytes;
3073
+ this.brokerClient = opts.brokerClient;
3074
+ this.workItemRunner = opts.workItemRunner;
3073
3075
  }
3074
3076
  /**
3075
3077
  * Wire up the inbound event subscriptions. Call after the socket has
@@ -3203,6 +3205,10 @@ var DaemonClaimHandler = class {
3203
3205
  );
3204
3206
  return;
3205
3207
  }
3208
+ if (this.brokerClient && this.workItemRunner) {
3209
+ await this.runLocalPickup(event);
3210
+ return;
3211
+ }
3206
3212
  if (!this.platformClient || !this.deviceToken) {
3207
3213
  console.log(
3208
3214
  `[daemon] assignment:claim-available assignmentId=${event.assignmentId} (platformClient not wired \u2014 Phase 5b config missing)`
@@ -3242,6 +3248,89 @@ var DaemonClaimHandler = class {
3242
3248
  });
3243
3249
  }
3244
3250
  }
3251
+ /**
3252
+ * Phase 2 "Slice B" — the full local-execution path for one assignment:
3253
+ * broker.accept → clone + run Claude Code locally → broker.report-result.
3254
+ * The daemon authenticates to the broker with its DEVICE token only; the
3255
+ * broker is the only thing that ever touches teams' internal (S2S) endpoints.
3256
+ * Holds an active-session slot for the lifetime of the run so the capacity
3257
+ * gate stays accurate. Best-effort: failures are audited and, where a session
3258
+ * already exists, reported back as `failed`.
3259
+ */
3260
+ async runLocalPickup(event) {
3261
+ const broker = this.brokerClient;
3262
+ const runner = this.workItemRunner;
3263
+ this.activeSessions += 1;
3264
+ let sessionId;
3265
+ try {
3266
+ const accepted = await broker.accept({
3267
+ assignmentId: event.assignmentId,
3268
+ workItemId: event.workItemId,
3269
+ orgId: event.orgId,
3270
+ machineId: this.machineId
3271
+ });
3272
+ sessionId = accepted.sessionId;
3273
+ this.audit.record({
3274
+ sessionId,
3275
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3276
+ action: DAEMON_AUDIT_ACTIONS.SESSION_CLAIMED,
3277
+ details: {
3278
+ machineId: this.machineId,
3279
+ orgId: event.orgId,
3280
+ workItemId: event.workItemId,
3281
+ assignmentId: event.assignmentId,
3282
+ via: "assignment:claim-available"
3283
+ },
3284
+ outcome: "success"
3285
+ });
3286
+ const result = await runner.run(sessionId, accepted.workItem);
3287
+ await broker.reportResult({
3288
+ sessionId,
3289
+ status: result.status,
3290
+ summary: result.summary,
3291
+ branchName: result.branchName,
3292
+ assignmentId: event.assignmentId,
3293
+ machineId: this.machineId
3294
+ });
3295
+ this.audit.record({
3296
+ sessionId,
3297
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3298
+ action: result.status === "failed" ? DAEMON_AUDIT_ACTIONS.SESSION_FAILED : DAEMON_AUDIT_ACTIONS.SESSION_COMPLETED,
3299
+ details: {
3300
+ machineId: this.machineId,
3301
+ status: result.status,
3302
+ ...result.branchName ? { branchName: result.branchName } : {}
3303
+ },
3304
+ outcome: result.status === "failed" ? "failure" : "success"
3305
+ });
3306
+ } catch (err) {
3307
+ const error = err.message;
3308
+ this.audit.record({
3309
+ sessionId: sessionId ?? event.assignmentId,
3310
+ type: "daemon_lifecycle" /* DAEMON_LIFECYCLE */,
3311
+ action: DAEMON_AUDIT_ACTIONS.SESSION_FAILED,
3312
+ details: { machineId: this.machineId, phase: "pickup", error },
3313
+ outcome: "failure"
3314
+ });
3315
+ if (sessionId) {
3316
+ try {
3317
+ await broker.reportResult({
3318
+ sessionId,
3319
+ status: "failed",
3320
+ summary: error,
3321
+ assignmentId: event.assignmentId,
3322
+ machineId: this.machineId
3323
+ });
3324
+ } catch (reportErr) {
3325
+ console.warn(
3326
+ `[daemon] failed to report pickup failure for ${sessionId}: ${reportErr.message}`
3327
+ );
3328
+ }
3329
+ }
3330
+ } finally {
3331
+ this.activeSessions -= 1;
3332
+ }
3333
+ }
3245
3334
  /**
3246
3335
  * Phase 5c — release a previously claimed assignment when the
3247
3336
  * daemon cannot proceed (e.g. drain interrupted, capacity check
@@ -3292,8 +3381,217 @@ function truncate2(s, n) {
3292
3381
  return s.length <= n ? s : `${s.slice(0, n - 1)}\u2026`;
3293
3382
  }
3294
3383
 
3295
- // _apps/@onklave/agent-cli/src/services/daemon-resource-sampler.service.ts
3384
+ // _apps/@onklave/agent-cli/src/services/platform-broker-client.ts
3385
+ var PlatformBrokerClient = class {
3386
+ constructor(baseUrl, deviceToken) {
3387
+ this.baseUrl = baseUrl;
3388
+ this.deviceToken = deviceToken;
3389
+ }
3390
+ /**
3391
+ * Accept an assignment as this registered machine. The broker accepts on
3392
+ * teams (→ in_progress), persists a tracking session, and returns the
3393
+ * sessionId + work context.
3394
+ */
3395
+ async accept(params) {
3396
+ return this.request(
3397
+ "POST",
3398
+ "/api/v1/work-item-pickup/accept",
3399
+ params
3400
+ );
3401
+ }
3402
+ /** Report a finished local run back to the broker (→ WorkItem). */
3403
+ async reportResult(params) {
3404
+ await this.request(
3405
+ "POST",
3406
+ "/api/v1/work-item-pickup/report-result",
3407
+ params
3408
+ );
3409
+ }
3410
+ async request(method, path8, body) {
3411
+ const url = `${this.baseUrl}${path8}`;
3412
+ const response = await fetch(url, {
3413
+ method,
3414
+ headers: {
3415
+ "x-device-token": this.deviceToken,
3416
+ "Content-Type": "application/json",
3417
+ Accept: "application/json"
3418
+ },
3419
+ body: body ? JSON.stringify(body) : void 0,
3420
+ signal: AbortSignal.timeout(3e4)
3421
+ });
3422
+ if (!response.ok) {
3423
+ let errorMessage = `Broker API error: ${response.status} ${response.statusText}`;
3424
+ try {
3425
+ const errorBody = await response.json();
3426
+ if (errorBody && typeof errorBody === "object" && "message" in errorBody) {
3427
+ errorMessage = `Broker API error: ${errorBody.message}`;
3428
+ }
3429
+ } catch {
3430
+ }
3431
+ throw new Error(errorMessage);
3432
+ }
3433
+ const contentType = response.headers.get("content-type");
3434
+ if (!contentType || !contentType.includes("application/json")) {
3435
+ return void 0;
3436
+ }
3437
+ const text = await response.text();
3438
+ if (!text) {
3439
+ return void 0;
3440
+ }
3441
+ return JSON.parse(text);
3442
+ }
3443
+ };
3444
+
3445
+ // _apps/@onklave/agent-cli/src/services/work-item-runner.service.ts
3446
+ import { spawn as spawn2 } from "child_process";
3447
+ import * as fs5 from "fs";
3296
3448
  import * as os4 from "os";
3449
+ import * as path6 from "path";
3450
+ var WorkItemRunner = class {
3451
+ constructor(opts = {}) {
3452
+ this.sessionManager = opts.sessionManager ?? new SessionManager();
3453
+ this.git = opts.git ?? defaultGit;
3454
+ this.model = opts.model ?? "claude-sonnet-4-6";
3455
+ this.timeoutSeconds = opts.timeoutSeconds ?? 1800;
3456
+ }
3457
+ /**
3458
+ * Clone → branch → run Claude Code. Returns the run outcome; never throws —
3459
+ * failures are mapped to a `failed` result with an error summary so the
3460
+ * caller can always report a status back to the broker.
3461
+ */
3462
+ async run(sessionId, workItem) {
3463
+ const repo = workItem.project?.repos?.[0];
3464
+ if (!repo?.url) {
3465
+ return {
3466
+ status: "failed",
3467
+ summary: `Work item ${workItem.id} has no clonable repo (project.repos[0].url missing).`
3468
+ };
3469
+ }
3470
+ const workDir = fs5.mkdtempSync(
3471
+ path6.join(os4.tmpdir(), `onklave-pickup-${sessionId}-`)
3472
+ );
3473
+ const repoDir = path6.join(workDir, sanitizeName(repo.name) || "repo");
3474
+ const branchName = `onklave/work-item/${workItem.id}`;
3475
+ try {
3476
+ await this.git(["clone", repo.url, repoDir], workDir);
3477
+ await this.git(["checkout", "-b", branchName], repoDir);
3478
+ } catch (err) {
3479
+ cleanup(workDir);
3480
+ return {
3481
+ status: "failed",
3482
+ summary: `Failed to prepare workspace for ${repo.url}: ${err.message}`,
3483
+ branchName
3484
+ };
3485
+ }
3486
+ const guardrails = new GuardrailEnforcer({
3487
+ rules: [],
3488
+ allowedTools: [],
3489
+ deniedTools: [],
3490
+ readablePaths: [repoDir],
3491
+ writablePaths: [repoDir]
3492
+ });
3493
+ const writeCheck = guardrails.checkPathAccess(repoDir, "write");
3494
+ if (!writeCheck.allowed) {
3495
+ cleanup(workDir);
3496
+ return {
3497
+ status: "failed",
3498
+ summary: `Guardrails blocked the workspace: ${writeCheck.reason}`,
3499
+ branchName
3500
+ };
3501
+ }
3502
+ const addDirs = [repoDir];
3503
+ const task = buildTask(workItem);
3504
+ const config = {
3505
+ task,
3506
+ context: repoDir,
3507
+ persona: null,
3508
+ workflow: null,
3509
+ model: this.model,
3510
+ timeout: this.timeoutSeconds,
3511
+ guardrails: [],
3512
+ allowedTools: [],
3513
+ deniedTools: [],
3514
+ addDirs,
3515
+ apiKey: null,
3516
+ headless: true,
3517
+ platformUrl: "",
3518
+ orgId: null,
3519
+ systemPromptAppend: null
3520
+ };
3521
+ let output = "";
3522
+ const exitCode = await new Promise((resolve3) => {
3523
+ const timeout = setTimeout(() => {
3524
+ void this.sessionManager.stopSession(sessionId).catch(() => void 0);
3525
+ }, this.timeoutSeconds * 1e3);
3526
+ void this.sessionManager.spawnSession(sessionId, config, {
3527
+ onStdout: (data) => {
3528
+ output += data;
3529
+ },
3530
+ onStderr: (data) => {
3531
+ output += data;
3532
+ },
3533
+ onExit: (code) => {
3534
+ clearTimeout(timeout);
3535
+ resolve3(code);
3536
+ }
3537
+ });
3538
+ });
3539
+ cleanup(workDir);
3540
+ if (exitCode === 0) {
3541
+ return {
3542
+ status: "in_review",
3543
+ summary: output.trim().slice(-2e3) || "Run completed (no output).",
3544
+ branchName
3545
+ };
3546
+ }
3547
+ return {
3548
+ status: "failed",
3549
+ summary: `Claude Code exited with code ${exitCode}.
3550
+ ` + output.trim().slice(-2e3),
3551
+ branchName
3552
+ };
3553
+ }
3554
+ };
3555
+ function buildTask(workItem) {
3556
+ const parts = [workItem.title];
3557
+ if (workItem.description) {
3558
+ parts.push("", workItem.description);
3559
+ }
3560
+ return parts.join("\n");
3561
+ }
3562
+ function sanitizeName(name) {
3563
+ if (!name) return "";
3564
+ return name.replace(/[^a-zA-Z0-9._-]/g, "-");
3565
+ }
3566
+ function cleanup(dir) {
3567
+ try {
3568
+ fs5.rmSync(dir, { recursive: true, force: true });
3569
+ } catch {
3570
+ }
3571
+ }
3572
+ function defaultGit(args, cwd) {
3573
+ return new Promise((resolve3, reject) => {
3574
+ const child = spawn2("git", args, {
3575
+ cwd,
3576
+ stdio: ["ignore", "pipe", "pipe"]
3577
+ });
3578
+ let stderr = "";
3579
+ child.stderr?.on("data", (d) => {
3580
+ stderr += d.toString();
3581
+ });
3582
+ child.on("error", (err) => reject(err));
3583
+ child.on("exit", (code) => {
3584
+ if (code === 0) {
3585
+ resolve3();
3586
+ } else {
3587
+ reject(new Error(`git ${args[0]} exited ${code}: ${stderr.trim()}`));
3588
+ }
3589
+ });
3590
+ });
3591
+ }
3592
+
3593
+ // _apps/@onklave/agent-cli/src/services/daemon-resource-sampler.service.ts
3594
+ import * as os5 from "os";
3297
3595
  var DEFAULT_SAMPLE_INTERVAL_MS = 5e3;
3298
3596
  var DEFAULT_WINDOW_MS = 6e4;
3299
3597
  var DaemonResourceSampler = class {
@@ -3361,8 +3659,8 @@ var DaemonResourceSampler = class {
3361
3659
  }
3362
3660
  };
3363
3661
  function defaultCpuPercent() {
3364
- const cpus2 = os4.cpus().length || 1;
3365
- const load1m = os4.loadavg()[0];
3662
+ const cpus2 = os5.cpus().length || 1;
3663
+ const load1m = os5.loadavg()[0];
3366
3664
  return load1m / cpus2 * 100;
3367
3665
  }
3368
3666
  function defaultMemoryRss() {
@@ -3639,9 +3937,9 @@ function parseSemverNumeric(v) {
3639
3937
  }
3640
3938
 
3641
3939
  // _apps/@onklave/agent-cli/src/services/daemon-state.service.ts
3642
- import * as fs5 from "fs";
3643
- import * as path6 from "path";
3644
- import * as os5 from "os";
3940
+ import * as fs6 from "fs";
3941
+ import * as path7 from "path";
3942
+ import * as os6 from "os";
3645
3943
  var VALID_TRANSITIONS = {
3646
3944
  installing: ["registered"],
3647
3945
  registered: ["starting"],
@@ -3652,10 +3950,10 @@ var VALID_TRANSITIONS = {
3652
3950
  stopped: ["starting"]
3653
3951
  };
3654
3952
  function defaultStateFilePath() {
3655
- return path6.join(os5.homedir(), ".config", "onklave", "daemon.state.json");
3953
+ return path7.join(os6.homedir(), ".config", "onklave", "daemon.state.json");
3656
3954
  }
3657
3955
  function defaultPidFilePath() {
3658
- return path6.join(os5.homedir(), ".config", "onklave", "daemon.pid");
3956
+ return path7.join(os6.homedir(), ".config", "onklave", "daemon.pid");
3659
3957
  }
3660
3958
  var DaemonStateError = class extends Error {
3661
3959
  constructor(message) {
@@ -3679,8 +3977,8 @@ var DaemonStateService = class {
3679
3977
  */
3680
3978
  static readPersisted(stateFile = defaultStateFilePath()) {
3681
3979
  try {
3682
- if (!fs5.existsSync(stateFile)) return null;
3683
- const raw = fs5.readFileSync(stateFile, "utf8");
3980
+ if (!fs6.existsSync(stateFile)) return null;
3981
+ const raw = fs6.readFileSync(stateFile, "utf8");
3684
3982
  const parsed = JSON.parse(raw);
3685
3983
  if (!parsed.state || !parsed.enteredAt) return null;
3686
3984
  return parsed;
@@ -3730,8 +4028,8 @@ var DaemonStateService = class {
3730
4028
  }
3731
4029
  }
3732
4030
  persist(reason) {
3733
- const dir = path6.dirname(this.stateFile);
3734
- fs5.mkdirSync(dir, { recursive: true });
4031
+ const dir = path7.dirname(this.stateFile);
4032
+ fs6.mkdirSync(dir, { recursive: true });
3735
4033
  const payload = {
3736
4034
  state: this.current,
3737
4035
  enteredAt: this.enteredAt.toISOString(),
@@ -3741,8 +4039,8 @@ var DaemonStateService = class {
3741
4039
  ...this.latestRuntime ? { runtime: this.latestRuntime } : {}
3742
4040
  };
3743
4041
  const tmp = `${this.stateFile}.tmp`;
3744
- fs5.writeFileSync(tmp, JSON.stringify(payload, null, 2));
3745
- fs5.renameSync(tmp, this.stateFile);
4042
+ fs6.writeFileSync(tmp, JSON.stringify(payload, null, 2));
4043
+ fs6.renameSync(tmp, this.stateFile);
3746
4044
  }
3747
4045
  /**
3748
4046
  * Publish the latest runtime snapshot. Persists to the state file
@@ -3760,7 +4058,7 @@ var DaemonStateService = class {
3760
4058
  */
3761
4059
  clearPersisted() {
3762
4060
  try {
3763
- fs5.unlinkSync(this.stateFile);
4061
+ fs6.unlinkSync(this.stateFile);
3764
4062
  } catch {
3765
4063
  }
3766
4064
  }
@@ -3946,6 +4244,8 @@ async function daemonStart() {
3946
4244
  const spawner = new DaemonSpawner({ platformUrl });
3947
4245
  const platformClient = new PlatformClient(platformUrl, creds.token);
3948
4246
  const resourceSampler = new DaemonResourceSampler();
4247
+ const brokerClient = new PlatformBrokerClient(platformUrl, creds.deviceToken);
4248
+ const workItemRunner = new WorkItemRunner();
3949
4249
  const claimHandler = new DaemonClaimHandler({
3950
4250
  machineId: creds.machineId,
3951
4251
  audit: auditStreamer,
@@ -3953,8 +4253,10 @@ async function daemonStart() {
3953
4253
  platformClient,
3954
4254
  deviceToken: creds.deviceToken,
3955
4255
  resourceSampler,
3956
- maxCpuPercent: 80
4256
+ maxCpuPercent: 80,
3957
4257
  // spec §8.1 default
4258
+ brokerClient,
4259
+ workItemRunner
3958
4260
  });
3959
4261
  const tokenObserver = new DaemonTokenObserver({
3960
4262
  machineId: creds.machineId,
@@ -4211,7 +4513,7 @@ function transitionToAction(next) {
4211
4513
  }
4212
4514
  function readPid(pidFile) {
4213
4515
  try {
4214
- const raw = fs6.readFileSync(pidFile, "utf8").trim();
4516
+ const raw = fs7.readFileSync(pidFile, "utf8").trim();
4215
4517
  const parsed = Number.parseInt(raw, 10);
4216
4518
  return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
4217
4519
  } catch {
@@ -4220,12 +4522,12 @@ function readPid(pidFile) {
4220
4522
  }
4221
4523
  function writePid(pidFile) {
4222
4524
  const dir = pidFile.replace(/\/[^/]+$/, "");
4223
- fs6.mkdirSync(dir, { recursive: true });
4224
- fs6.writeFileSync(pidFile, String(process.pid), { mode: 384 });
4525
+ fs7.mkdirSync(dir, { recursive: true });
4526
+ fs7.writeFileSync(pidFile, String(process.pid), { mode: 384 });
4225
4527
  }
4226
4528
  function removePid(pidFile) {
4227
4529
  try {
4228
- fs6.unlinkSync(pidFile);
4530
+ fs7.unlinkSync(pidFile);
4229
4531
  } catch {
4230
4532
  }
4231
4533
  }
@@ -4243,8 +4545,8 @@ function readPackageVersion() {
4243
4545
  `${__dirname}/../../../package.json`
4244
4546
  ];
4245
4547
  for (const p of candidates) {
4246
- if (fs6.existsSync(p)) {
4247
- const pkg = JSON.parse(fs6.readFileSync(p, "utf8"));
4548
+ if (fs7.existsSync(p)) {
4549
+ const pkg = JSON.parse(fs7.readFileSync(p, "utf8"));
4248
4550
  if (pkg.name === "@onklave/agent-cli" && pkg.version)
4249
4551
  return pkg.version;
4250
4552
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onklave/agent-cli",
3
- "version": "0.1.29",
3
+ "version": "0.1.31",
4
4
  "description": "Onklave Agent CLI — local agent runner with cloud orchestration",
5
5
  "bin": {
6
6
  "onklave": "./main.js"