@openape/nest 2.0.2 → 2.1.1

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/dist/index.mjs +552 -15
  2. package/package.json +3 -1
package/dist/index.mjs CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // src/index.ts
4
4
  import { watch } from "fs";
5
- import process3 from "process";
5
+ import process4 from "process";
6
6
 
7
7
  // src/lib/registry.ts
8
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
@@ -31,7 +31,7 @@ function listAgents() {
31
31
  import { execFile } from "child_process";
32
32
  import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
33
33
  import { join as join2 } from "path";
34
- import process from "process";
34
+ import process2 from "process";
35
35
  import { promisify } from "util";
36
36
  var execFileAsync = promisify(execFile);
37
37
  var AGENTS_DIR = "/var/openape/agents";
@@ -49,7 +49,7 @@ function ecosystemContents(apesBin, agentName) {
49
49
  module.exports = {
50
50
  apps: [{
51
51
  name: '${pm2AppName(agentName)}',
52
- script: 'openape-chat-bridge',
52
+ script: 'ape-agent',
53
53
  autorestart: true,
54
54
  max_restarts: 10,
55
55
  min_uptime: '30s',
@@ -105,9 +105,11 @@ var Pm2Supervisor = class {
105
105
  this.deps.log(`pm2-supervisor: delete ${name}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
106
106
  }
107
107
  }
108
- /** Best-effort cleanup — called on Nest shutdown. We don't kill
108
+ /**
109
+ * Best-effort cleanup — called on Nest shutdown. We don't kill
109
110
  * the per-agent pm2-daemons; they should keep running so bridges
110
- * stay alive across Nest restarts. No-op for now. */
111
+ * stay alive across Nest restarts. No-op for now.
112
+ */
111
113
  async stopAll() {
112
114
  }
113
115
  async startOrReload(agentName) {
@@ -139,7 +141,8 @@ var Pm2Supervisor = class {
139
141
  this.deps.log(`pm2-supervisor: ${agentName} bridge NOT online \u2014 see /var/log/openape/${agentName}-pm2.log`);
140
142
  }
141
143
  }
142
- /** Run a pm2 subcommand AS the agent — escapes-helper does the
144
+ /**
145
+ * Run a pm2 subcommand AS the agent — escapes-helper does the
143
146
  * setuid switch, then exec's pm2 in the agent's uid.
144
147
  *
145
148
  * cwd: the agent process inherits cwd from the spawning Nest
@@ -154,7 +157,7 @@ var Pm2Supervisor = class {
154
157
  return await execFileAsync(
155
158
  this.deps.apesBin,
156
159
  ["run", "--as", agentName, "--wait", "--", ...args],
157
- { maxBuffer: 1024 * 1024, env: process.env, timeout: 6e4, cwd: "/tmp" }
160
+ { maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4, cwd: "/tmp" }
158
161
  );
159
162
  } catch (err) {
160
163
  const e = err;
@@ -167,7 +170,7 @@ var Pm2Supervisor = class {
167
170
 
168
171
  // src/lib/troop-sync.ts
169
172
  import { execFile as execFile2 } from "child_process";
170
- import process2 from "process";
173
+ import process3 from "process";
171
174
  import { promisify as promisify2 } from "util";
172
175
  var execFileAsync2 = promisify2(execFile2);
173
176
  var TICK_MS = 5 * 60 * 1e3;
@@ -206,7 +209,7 @@ var TroopSync = class {
206
209
  await execFileAsync2(
207
210
  this.deps.apesBin,
208
211
  ["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
209
- { maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4 }
212
+ { maxBuffer: 1024 * 1024, env: process3.env, timeout: 6e4 }
210
213
  );
211
214
  } catch (err) {
212
215
  this.deps.log(`troop-sync: ${name} failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
@@ -214,15 +217,546 @@ var TroopSync = class {
214
217
  }
215
218
  };
216
219
 
220
+ // src/lib/troop-ws.ts
221
+ import { execFile as execFile3, execFileSync } from "child_process";
222
+ import { createHash } from "crypto";
223
+ import { readFileSync as readFileSync3 } from "fs";
224
+ import { hostname, networkInterfaces } from "os";
225
+
226
+ // ../../packages/cli-auth/dist/index.js
227
+ import { ofetch } from "ofetch";
228
+ import { existsSync as existsSync2, mkdirSync as mkdirSync3, readFileSync as readFileSync2, readdirSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
229
+ import { homedir as homedir2 } from "os";
230
+ import { join as join3 } from "path";
231
+ import { ofetch as ofetch3 } from "ofetch";
232
+ import { Buffer as Buffer2 } from "buffer";
233
+ import { sign } from "crypto";
234
+ import { existsSync as existsSync22, readFileSync as readFileSync22 } from "fs";
235
+ import { homedir as homedir22 } from "os";
236
+ import { join as join22 } from "path";
237
+ import { ofetch as ofetch2 } from "ofetch";
238
+ import { Buffer as Buffer3 } from "buffer";
239
+ import { createPrivateKey } from "crypto";
240
+ import { ofetch as ofetch4 } from "ofetch";
241
+ function getConfigDir() {
242
+ const override = process.env.OPENAPE_CLI_AUTH_HOME;
243
+ if (override) return override;
244
+ return join3(homedir2(), ".config", "apes");
245
+ }
246
+ function getAuthFile() {
247
+ return join3(getConfigDir(), "auth.json");
248
+ }
249
+ function ensureConfigDir() {
250
+ const dir = getConfigDir();
251
+ if (!existsSync2(dir)) {
252
+ mkdirSync3(dir, { recursive: true, mode: 448 });
253
+ }
254
+ }
255
+ function loadIdpAuth() {
256
+ const file = getAuthFile();
257
+ if (!existsSync2(file)) return null;
258
+ try {
259
+ const raw = readFileSync2(file, "utf-8");
260
+ if (!raw.trim()) return null;
261
+ return JSON.parse(raw);
262
+ } catch {
263
+ return null;
264
+ }
265
+ }
266
+ function saveIdpAuth(auth) {
267
+ ensureConfigDir();
268
+ const file = getAuthFile();
269
+ let extra = {};
270
+ if (existsSync2(file)) {
271
+ try {
272
+ const raw = readFileSync2(file, "utf-8");
273
+ if (raw.trim()) {
274
+ const prev = JSON.parse(raw);
275
+ for (const key of Object.keys(prev)) {
276
+ if (!(key in auth)) {
277
+ extra[key] = prev[key];
278
+ }
279
+ }
280
+ }
281
+ } catch {
282
+ extra = {};
283
+ }
284
+ }
285
+ const merged = { ...extra, ...auth };
286
+ writeFileSync3(file, JSON.stringify(merged, null, 2), { mode: 384 });
287
+ }
288
+ var AuthError = class extends Error {
289
+ status;
290
+ hint;
291
+ constructor(status, message, hint) {
292
+ super(hint ? `${message}
293
+ ${hint}` : message);
294
+ this.name = "AuthError";
295
+ this.status = status;
296
+ this.hint = hint;
297
+ }
298
+ };
299
+ var NotLoggedInError = class extends AuthError {
300
+ constructor(hint) {
301
+ super(
302
+ 401,
303
+ "Not logged in",
304
+ hint ?? "Run `apes login <email>` once on this device to authenticate against the OpenApe IdP."
305
+ );
306
+ this.name = "NotLoggedInError";
307
+ }
308
+ };
309
+ var OPENSSH_MAGIC = "openssh-key-v1\0";
310
+ function loadEd25519PrivateKey(pem) {
311
+ if (pem.includes("BEGIN OPENSSH PRIVATE KEY")) {
312
+ return parseOpenSSHEd25519(pem);
313
+ }
314
+ return createPrivateKey(pem);
315
+ }
316
+ function parseOpenSSHEd25519(pem) {
317
+ const b64 = pem.replace(/-----BEGIN OPENSSH PRIVATE KEY-----/, "").replace(/-----END OPENSSH PRIVATE KEY-----/, "").replace(/\s/g, "");
318
+ const buf = Buffer3.from(b64, "base64");
319
+ let offset = 0;
320
+ const magic = buf.subarray(0, OPENSSH_MAGIC.length).toString("ascii");
321
+ if (magic !== OPENSSH_MAGIC) {
322
+ throw new Error("Not an OpenSSH private key");
323
+ }
324
+ offset += OPENSSH_MAGIC.length;
325
+ const cipherLen = buf.readUInt32BE(offset);
326
+ offset += 4;
327
+ const cipher = buf.subarray(offset, offset + cipherLen).toString();
328
+ offset += cipherLen;
329
+ if (cipher !== "none") {
330
+ throw new Error(`Encrypted keys not supported (cipher: ${cipher}). Decrypt first with: ssh-keygen -p -f <key>`);
331
+ }
332
+ const kdfLen = buf.readUInt32BE(offset);
333
+ offset += 4;
334
+ offset += kdfLen;
335
+ const kdfOptsLen = buf.readUInt32BE(offset);
336
+ offset += 4;
337
+ offset += kdfOptsLen;
338
+ const numKeys = buf.readUInt32BE(offset);
339
+ offset += 4;
340
+ if (numKeys !== 1) {
341
+ throw new Error(`Expected 1 key, got ${numKeys}`);
342
+ }
343
+ const pubSectionLen = buf.readUInt32BE(offset);
344
+ offset += 4;
345
+ offset += pubSectionLen;
346
+ const privSectionLen = buf.readUInt32BE(offset);
347
+ offset += 4;
348
+ const privSection = buf.subarray(offset, offset + privSectionLen);
349
+ let pOffset = 0;
350
+ const check1 = privSection.readUInt32BE(pOffset);
351
+ pOffset += 4;
352
+ const check2 = privSection.readUInt32BE(pOffset);
353
+ pOffset += 4;
354
+ if (check1 !== check2) {
355
+ throw new Error("Check integers mismatch \u2014 key may be corrupted or encrypted");
356
+ }
357
+ const keyTypeLen = privSection.readUInt32BE(pOffset);
358
+ pOffset += 4;
359
+ const keyType = privSection.subarray(pOffset, pOffset + keyTypeLen).toString();
360
+ pOffset += keyTypeLen;
361
+ if (keyType !== "ssh-ed25519") {
362
+ throw new Error(`Expected ssh-ed25519, got ${keyType}`);
363
+ }
364
+ const pubKeyLen = privSection.readUInt32BE(pOffset);
365
+ pOffset += 4;
366
+ const pubKey = privSection.subarray(pOffset, pOffset + pubKeyLen);
367
+ pOffset += pubKeyLen;
368
+ const privKeyLen = privSection.readUInt32BE(pOffset);
369
+ pOffset += 4;
370
+ const privKeyData = privSection.subarray(pOffset, pOffset + privKeyLen);
371
+ const seed = privKeyData.subarray(0, 32);
372
+ return createPrivateKey({
373
+ key: { kty: "OKP", crv: "Ed25519", d: seed.toString("base64url"), x: pubKey.toString("base64url") },
374
+ format: "jwk"
375
+ });
376
+ }
377
+ async function getEndpoints(idp) {
378
+ let disco = {};
379
+ try {
380
+ disco = await ofetch2(`${idp}/.well-known/openid-configuration`);
381
+ } catch {
382
+ }
383
+ return {
384
+ challenge: disco.ddisa_agent_challenge_endpoint ?? `${idp}/api/agent/challenge`,
385
+ authenticate: disco.ddisa_agent_authenticate_endpoint ?? `${idp}/api/agent/authenticate`
386
+ };
387
+ }
388
+ function resolveKeyPath(p) {
389
+ if (p.startsWith("~")) return join22(homedir22(), p.slice(1));
390
+ return p;
391
+ }
392
+ function findSigningKey(auth) {
393
+ const candidates = [];
394
+ if (auth.key_path) candidates.push(resolveKeyPath(auth.key_path));
395
+ candidates.push(join22(homedir22(), ".ssh", "id_ed25519"));
396
+ for (const p of candidates) {
397
+ if (existsSync22(p)) {
398
+ try {
399
+ return { keyPath: p, keyContent: readFileSync22(p, "utf-8") };
400
+ } catch {
401
+ }
402
+ }
403
+ }
404
+ return null;
405
+ }
406
+ async function refreshAgentToken(auth, now = Math.floor(Date.now() / 1e3)) {
407
+ const key = findSigningKey(auth);
408
+ if (!key) return null;
409
+ let privateKey;
410
+ try {
411
+ privateKey = loadEd25519PrivateKey(key.keyContent);
412
+ } catch {
413
+ return null;
414
+ }
415
+ let endpoints;
416
+ try {
417
+ endpoints = await getEndpoints(auth.idp);
418
+ } catch {
419
+ return null;
420
+ }
421
+ let challenge;
422
+ try {
423
+ const resp = await ofetch2(endpoints.challenge, {
424
+ method: "POST",
425
+ headers: { "Content-Type": "application/json" },
426
+ body: { agent_id: auth.email }
427
+ });
428
+ challenge = resp.challenge;
429
+ } catch {
430
+ return null;
431
+ }
432
+ let signature;
433
+ try {
434
+ signature = sign(null, Buffer2.from(challenge), privateKey).toString("base64");
435
+ } catch {
436
+ return null;
437
+ }
438
+ let authResp;
439
+ try {
440
+ authResp = await ofetch2(endpoints.authenticate, {
441
+ method: "POST",
442
+ headers: { "Content-Type": "application/json" },
443
+ body: { agent_id: auth.email, challenge, signature }
444
+ });
445
+ } catch {
446
+ return null;
447
+ }
448
+ return {
449
+ ...auth,
450
+ access_token: authResp.token,
451
+ expires_at: now + (authResp.expires_in || 3600),
452
+ key_path: auth.key_path ?? key.keyPath
453
+ };
454
+ }
455
+ var EXPIRY_SKEW_SECONDS = 30;
456
+ async function getTokenEndpoint(idp) {
457
+ try {
458
+ const disco = await ofetch3(`${idp}/.well-known/openid-configuration`);
459
+ if (disco.token_endpoint) return disco.token_endpoint;
460
+ } catch {
461
+ }
462
+ return `${idp}/token`;
463
+ }
464
+ async function ensureFreshIdpAuth(now = Math.floor(Date.now() / 1e3)) {
465
+ const auth = loadIdpAuth();
466
+ if (!auth) {
467
+ throw new NotLoggedInError();
468
+ }
469
+ if (auth.expires_at > now + EXPIRY_SKEW_SECONDS) {
470
+ return auth;
471
+ }
472
+ if (!auth.refresh_token) {
473
+ const refreshed = await refreshAgentToken(auth, now);
474
+ if (refreshed) {
475
+ saveIdpAuth(refreshed);
476
+ return refreshed;
477
+ }
478
+ throw new NotLoggedInError(
479
+ `IdP token expired at ${new Date(auth.expires_at * 1e3).toISOString()} and no refresh_token is stored. Run \`apes login\` again.`
480
+ );
481
+ }
482
+ const tokenEndpoint = await getTokenEndpoint(auth.idp);
483
+ const body = new URLSearchParams({
484
+ grant_type: "refresh_token",
485
+ refresh_token: auth.refresh_token
486
+ });
487
+ let response;
488
+ try {
489
+ response = await ofetch3(tokenEndpoint, {
490
+ method: "POST",
491
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
492
+ body: body.toString()
493
+ });
494
+ } catch (err) {
495
+ const status = err.status ?? err.statusCode ?? 0;
496
+ if (status === 400 || status === 401) {
497
+ saveIdpAuth({ ...auth, refresh_token: void 0 });
498
+ throw new NotLoggedInError(
499
+ `Refresh token rejected by ${auth.idp}. Run \`apes login\` again.`
500
+ );
501
+ }
502
+ throw new AuthError(
503
+ 0,
504
+ `Network error refreshing IdP token at ${tokenEndpoint}`,
505
+ `Underlying: ${err.message ?? err}`
506
+ );
507
+ }
508
+ if (!response.access_token) {
509
+ throw new AuthError(0, `IdP refresh response missing access_token (endpoint: ${tokenEndpoint})`);
510
+ }
511
+ const next = {
512
+ ...auth,
513
+ access_token: response.access_token,
514
+ refresh_token: response.refresh_token ?? auth.refresh_token,
515
+ expires_at: now + (response.expires_in ?? 3600)
516
+ };
517
+ saveIdpAuth(next);
518
+ return next;
519
+ }
520
+
521
+ // src/lib/troop-ws.ts
522
+ import WebSocket from "ws";
523
+ var HEARTBEAT_INTERVAL_MS = 3e4;
524
+ var RECONNECT_BASE_MS = 1e3;
525
+ var RECONNECT_MAX_MS = 3e4;
526
+ var TroopWs = class {
527
+ constructor(opts) {
528
+ this.opts = opts;
529
+ this.troopUrl = (opts.troopUrl ?? process.env.OPENAPE_TROOP_WS_URL ?? "wss://troop.openape.ai").replace(/\/$/, "");
530
+ this.hostId = readHostId();
531
+ this.hostname = hostname();
532
+ }
533
+ socket = null;
534
+ heartbeatTimer = null;
535
+ reconnectTimer = null;
536
+ reconnectAttempts = 0;
537
+ stopped = false;
538
+ troopUrl;
539
+ hostId;
540
+ hostname;
541
+ start() {
542
+ this.stopped = false;
543
+ void this.connect();
544
+ }
545
+ stop() {
546
+ this.stopped = true;
547
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
548
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
549
+ this.reconnectTimer = null;
550
+ this.heartbeatTimer = null;
551
+ if (this.socket) {
552
+ try {
553
+ this.socket.close();
554
+ } catch {
555
+ }
556
+ this.socket = null;
557
+ }
558
+ }
559
+ async connect() {
560
+ if (this.stopped) return;
561
+ let token;
562
+ try {
563
+ const auth = await ensureFreshIdpAuth();
564
+ token = auth.access_token;
565
+ } catch (err) {
566
+ if (err instanceof NotLoggedInError) {
567
+ this.opts.log("troop-ws: not logged in (apes login) \u2014 skip connect, will retry");
568
+ } else {
569
+ this.opts.log(`troop-ws: auth refresh failed: ${err instanceof Error ? err.message : String(err)}`);
570
+ }
571
+ this.scheduleReconnect();
572
+ return;
573
+ }
574
+ const url = `${this.troopUrl}/api/nest-ws?token=${encodeURIComponent(token)}`;
575
+ const ws = new WebSocket(url);
576
+ this.socket = ws;
577
+ ws.on("open", () => {
578
+ this.reconnectAttempts = 0;
579
+ this.opts.log(`troop-ws: connected to ${this.troopUrl}`);
580
+ ws.send(JSON.stringify({
581
+ type: "hello",
582
+ host_id: this.hostId,
583
+ hostname: this.hostname,
584
+ version: this.opts.version ?? "unknown"
585
+ }));
586
+ this.heartbeatTimer = setInterval(() => {
587
+ try {
588
+ ws.send(JSON.stringify({ type: "heartbeat" }));
589
+ } catch {
590
+ }
591
+ }, HEARTBEAT_INTERVAL_MS);
592
+ });
593
+ ws.on("message", (data) => {
594
+ const text = typeof data === "string" ? data : Buffer.isBuffer(data) ? data.toString("utf8") : "";
595
+ if (!text) return;
596
+ let frame;
597
+ try {
598
+ frame = JSON.parse(text);
599
+ } catch {
600
+ return;
601
+ }
602
+ this.handleFrame(frame).catch((err) => {
603
+ this.opts.log(`troop-ws: frame handler error: ${err instanceof Error ? err.message : String(err)}`);
604
+ });
605
+ });
606
+ ws.on("close", (code, reason) => {
607
+ this.opts.log(`troop-ws: disconnected (${code}${reason.length > 0 ? ` ${reason.toString()}` : ""}) \u2014 reconnecting`);
608
+ if (this.heartbeatTimer) {
609
+ clearInterval(this.heartbeatTimer);
610
+ this.heartbeatTimer = null;
611
+ }
612
+ this.socket = null;
613
+ this.scheduleReconnect();
614
+ });
615
+ ws.on("error", (err) => {
616
+ this.opts.log(`troop-ws: socket error: ${err.message}`);
617
+ });
618
+ }
619
+ scheduleReconnect() {
620
+ if (this.stopped) return;
621
+ if (this.reconnectTimer) return;
622
+ const attempt = Math.min(this.reconnectAttempts, 5);
623
+ this.reconnectAttempts++;
624
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
625
+ this.reconnectTimer = setTimeout(() => {
626
+ this.reconnectTimer = null;
627
+ void this.connect();
628
+ }, delay);
629
+ }
630
+ async handleFrame(frame) {
631
+ if (frame.type === "welcome") {
632
+ return;
633
+ }
634
+ if (frame.type === "ack") {
635
+ return;
636
+ }
637
+ if (frame.type === "config-update") {
638
+ await this.handleConfigUpdate(frame);
639
+ return;
640
+ }
641
+ if (frame.type === "spawn-intent") {
642
+ await this.handleSpawnIntent(frame);
643
+ return;
644
+ }
645
+ if (frame.type === "reload-bridge") {
646
+ await this.handleReloadBridge(frame);
647
+ }
648
+ }
649
+ async handleConfigUpdate(frame) {
650
+ const local = frame.agent_email.split("+")[0];
651
+ if (!local) return;
652
+ const dash = local.lastIndexOf("-");
653
+ const name = dash > 0 ? local.slice(0, dash) : local;
654
+ this.opts.log(`troop-ws: config-update for ${name} \u2014 running sync`);
655
+ await this.runApes(["run", "--as", name, "--wait", "--", "apes", "agents", "sync"], `config-update sync ${name}`);
656
+ }
657
+ async handleSpawnIntent(frame) {
658
+ this.opts.log(`troop-ws: spawn-intent ${frame.name} (intent ${frame.intent_id})`);
659
+ const args = ["agents", "spawn", frame.name];
660
+ if (frame.bridge?.key) args.push("--bridge-key", frame.bridge.key);
661
+ if (frame.bridge?.base_url) args.push("--bridge-base-url", frame.bridge.base_url);
662
+ if (frame.bridge?.model) args.push("--bridge-model", frame.bridge.model);
663
+ try {
664
+ const { stdout } = await runWithCapture(this.opts.apesBin, args);
665
+ const match = stdout.match(/Registered as\s+(\S+@\S+)/);
666
+ const agentEmail = match?.[1];
667
+ this.opts.log(`troop-ws: spawn-result ${frame.name} ok agent=${agentEmail ?? "?"}`);
668
+ this.send({ type: "spawn-result", intent_id: frame.intent_id, ok: true, agent_email: agentEmail });
669
+ } catch (err) {
670
+ const error = err instanceof Error ? err.message : String(err);
671
+ this.opts.log(`troop-ws: spawn-result ${frame.name} FAIL: ${error}`);
672
+ this.send({ type: "spawn-result", intent_id: frame.intent_id, ok: false, error });
673
+ }
674
+ }
675
+ async handleReloadBridge(frame) {
676
+ this.opts.log(`troop-ws: reload-bridge ${frame.name}`);
677
+ await this.runApes(
678
+ ["run", "--as", frame.name, "--wait", "--", "pm2", "reload", `openape-bridge-${frame.name}`, "--update-env"],
679
+ `reload-bridge ${frame.name}`
680
+ );
681
+ }
682
+ async runApes(args, label) {
683
+ try {
684
+ await runWithCapture(this.opts.apesBin, args);
685
+ } catch (err) {
686
+ this.opts.log(`troop-ws: ${label} failed: ${err instanceof Error ? err.message : String(err)}`);
687
+ }
688
+ }
689
+ send(frame) {
690
+ const ws = this.socket;
691
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
692
+ try {
693
+ ws.send(JSON.stringify(frame));
694
+ } catch (err) {
695
+ this.opts.log(`troop-ws: send failed: ${err instanceof Error ? err.message : String(err)}`);
696
+ }
697
+ }
698
+ };
699
+ function runWithCapture(bin, args) {
700
+ return new Promise((resolve, reject) => {
701
+ execFile3(bin, args, { maxBuffer: 4 * 1024 * 1024, timeout: 12e4 }, (err, stdout, stderr) => {
702
+ if (err) {
703
+ const isTimeout = err.signal === "SIGTERM";
704
+ if (isTimeout) {
705
+ resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
706
+ return;
707
+ }
708
+ const msg = stderr.toString() || err.message;
709
+ reject(new Error(msg.split("\n").filter(Boolean).slice(-3).join(" / ")));
710
+ return;
711
+ }
712
+ resolve({ stdout: stdout.toString(), stderr: stderr.toString() });
713
+ });
714
+ });
715
+ }
716
+ function readHostId() {
717
+ try {
718
+ if (process.platform === "darwin") {
719
+ const out = execFileSync("/usr/sbin/ioreg", ["-d2", "-c", "IOPlatformExpertDevice"], { encoding: "utf8" });
720
+ const match = out.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/);
721
+ if (match) return match[1];
722
+ }
723
+ } catch {
724
+ }
725
+ return hashBasedHostId();
726
+ }
727
+ function hashBasedHostId() {
728
+ const nics = networkInterfaces();
729
+ const macs = [];
730
+ for (const list of Object.values(nics)) {
731
+ if (!list) continue;
732
+ for (const nic of list) {
733
+ if (!nic.mac || nic.mac === "00:00:00:00:00:00" || nic.internal) continue;
734
+ macs.push(nic.mac);
735
+ }
736
+ }
737
+ const seed = `${hostname()}|${macs.toSorted().join(",")}`;
738
+ return createHash("sha256").update(seed).digest("hex").slice(0, 32);
739
+ }
740
+ function readNestVersion() {
741
+ try {
742
+ const root = new URL("../../package.json", import.meta.url);
743
+ const pkg = JSON.parse(readFileSync3(root, "utf8"));
744
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
745
+ } catch {
746
+ return "unknown";
747
+ }
748
+ }
749
+
217
750
  // src/index.ts
218
- var APES_BIN = process3.env.OPENAPE_APES_BIN ?? "apes";
751
+ var APES_BIN = process4.env.OPENAPE_APES_BIN ?? "apes";
219
752
  var RECONCILE_DEBOUNCE_MS = 1e3;
220
753
  function log(line) {
221
- process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
754
+ process4.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
222
755
  `);
223
756
  }
224
757
  var supervisor = new Pm2Supervisor({ apesBin: APES_BIN, log });
225
758
  var troopSync = new TroopSync({ apesBin: APES_BIN, log });
759
+ var troopWs = new TroopWs({ apesBin: APES_BIN, log, version: readNestVersion() });
226
760
  async function reconcile() {
227
761
  try {
228
762
  await supervisor.reconcile(listAgents());
@@ -233,6 +767,7 @@ async function reconcile() {
233
767
  }
234
768
  void reconcile();
235
769
  troopSync.start();
770
+ troopWs.start();
236
771
  var reconcileTimer;
237
772
  try {
238
773
  watch(REGISTRY_PATH, () => {
@@ -248,15 +783,17 @@ try {
248
783
  void reconcile();
249
784
  }, 5e3).unref();
250
785
  }
251
- process3.on("SIGTERM", () => {
786
+ process4.on("SIGTERM", () => {
252
787
  log("nest: SIGTERM \u2014 stopping");
253
788
  troopSync.stop();
789
+ troopWs.stop();
254
790
  if (reconcileTimer) clearTimeout(reconcileTimer);
255
- process3.exit(0);
791
+ process4.exit(0);
256
792
  });
257
- process3.on("SIGINT", () => {
793
+ process4.on("SIGINT", () => {
258
794
  log("nest: SIGINT \u2014 stopping");
259
795
  troopSync.stop();
796
+ troopWs.stop();
260
797
  if (reconcileTimer) clearTimeout(reconcileTimer);
261
- process3.exit(0);
798
+ process4.exit(0);
262
799
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/nest",
3
- "version": "2.0.2",
3
+ "version": "2.1.1",
4
4
  "description": "OpenApe Nest — local control-plane daemon that supervises agent processes on this computer. Talks to troop SP for ownership state, spawns/destroys agents via DDISA always-grants, supervises chat-bridge children (replacing per-agent launchd plists).",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -17,12 +17,14 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "ofetch": "^1.4.1",
20
+ "ws": "^8.18.0",
20
21
  "@openape/cli-auth": "0.4.0",
21
22
  "@openape/core": "0.16.0"
22
23
  },
23
24
  "devDependencies": {
24
25
  "@antfu/eslint-config": "^7.6.1",
25
26
  "@types/node": "^22.19.13",
27
+ "@types/ws": "^8.5.13",
26
28
  "eslint": "^9.35.0",
27
29
  "tsup": "^8.5.1",
28
30
  "typescript": "^5.9.3",