@openape/nest 1.0.0 → 1.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 +85 -94
  2. package/package.json +1 -1
package/dist/index.mjs CHANGED
@@ -95,19 +95,18 @@ async function handleAgentSpawn(ctx) {
95
95
  } : void 0
96
96
  };
97
97
  upsertAgent(entry);
98
- ctx.supervisor.reconcile(listAgents());
98
+ await ctx.supervisor.reconcile(listAgents());
99
99
  return { name, email: entry.email, uid, home: entry.home };
100
100
  }
101
101
  async function handleAgentDestroy(ctx, name) {
102
102
  if (!NAME_REGEX.test(name)) throw new Error(`invalid agent name "${name}"`);
103
103
  const entry = findAgent(name);
104
104
  if (!entry) throw new Error(`agent "${name}" not registered with this nest`);
105
- ctx.supervisor.stop(name);
106
105
  ctx.log(`nest: destroying agent "${name}"...`);
107
106
  const args = ["run", "--as", "root", "--", "apes", "agents", "destroy", name, "--force"];
108
107
  await execFileAsync(ctx.apesBin, args, { maxBuffer: 4 * 1024 * 1024 });
109
108
  removeAgent(name);
110
- ctx.supervisor.reconcile(listAgents());
109
+ await ctx.supervisor.reconcile(listAgents());
111
110
  return { name, removed: true };
112
111
  }
113
112
  async function readUidFromDscl(name) {
@@ -233,109 +232,98 @@ function reapStaleResponses(log2) {
233
232
  }
234
233
  }
235
234
 
236
- // src/lib/supervisor.ts
237
- import { spawn } from "child_process";
235
+ // src/lib/pm2-supervisor.ts
236
+ import { execFile as execFile2 } from "child_process";
237
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "fs";
238
+ import { join as join3 } from "path";
238
239
  import process from "process";
239
- var MIN_BACKOFF_MS = 2e3;
240
- var MAX_BACKOFF_MS = 6e4;
241
- var STABLE_RUNTIME_MS = 3e4;
242
- var Supervisor = class {
240
+ import { promisify as promisify2 } from "util";
241
+ var execFileAsync2 = promisify2(execFile2);
242
+ var AGENTS_DIR = "/var/openape/agents";
243
+ function pm2AppName(agentName) {
244
+ return `openape-bridge-${agentName}`;
245
+ }
246
+ function ecosystemPath(agentName) {
247
+ return join3(AGENTS_DIR, agentName, "ecosystem.config.js");
248
+ }
249
+ function ecosystemContents(apesBin, agentName) {
250
+ void apesBin;
251
+ return `// Auto-generated by Pm2Supervisor for agent '${agentName}'.
252
+ // Edit at runtime via:
253
+ // apes run --as ${agentName} -- pm2 reload ${pm2AppName(agentName)}
254
+ module.exports = {
255
+ apps: [{
256
+ name: '${pm2AppName(agentName)}',
257
+ script: 'openape-chat-bridge',
258
+ autorestart: true,
259
+ max_restarts: 10,
260
+ min_uptime: '30s',
261
+ restart_delay: 2000,
262
+ merge_logs: true,
263
+ }],
264
+ }
265
+ `;
266
+ }
267
+ var Pm2Supervisor = class {
243
268
  constructor(deps) {
244
269
  this.deps = deps;
245
270
  }
246
- children = /* @__PURE__ */ new Map();
247
- /** Bring the supervised set in line with the desired set. */
248
- reconcile(desired) {
249
- const desiredNames = new Set(desired.filter((a) => a.bridge != null).map((a) => a.name));
250
- for (const [name] of this.children) {
251
- if (!desiredNames.has(name)) this.stop(name);
252
- }
271
+ inflight = /* @__PURE__ */ new Set();
272
+ /** Bring per-agent pm2 state in line with the registry. Idempotent. */
273
+ async reconcile(desired) {
253
274
  for (const agent of desired) {
254
275
  if (agent.bridge == null) continue;
255
- if (!this.children.has(agent.name)) this.start(agent);
276
+ if (this.inflight.has(agent.name)) continue;
277
+ this.inflight.add(agent.name);
278
+ try {
279
+ await this.startOrReload(agent.name);
280
+ } catch (err) {
281
+ this.deps.log(`pm2-supervisor: ${agent.name} startOrReload failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
282
+ } finally {
283
+ this.inflight.delete(agent.name);
284
+ }
256
285
  }
257
286
  }
258
- size() {
259
- return this.children.size;
260
- }
261
- status() {
262
- const now = Date.now();
263
- return Array.from(this.children.entries()).map(([name, s]) => ({
264
- name,
265
- pid: s.child.pid ?? -1,
266
- uptimeSec: Math.floor((now - s.startedAt) / 1e3),
267
- consecutiveCrashes: s.consecutiveCrashes
268
- }));
269
- }
270
- start(agent) {
271
- if (this.children.has(agent.name)) return;
272
- this.deps.log(`supervisor: starting bridge for ${agent.name}`);
273
- this.spawnChild(agent, 0);
274
- }
275
- stop(name) {
276
- const s = this.children.get(name);
277
- if (!s) return;
278
- this.deps.log(`supervisor: stopping bridge for ${name}`);
279
- if (s.restartTimer) clearTimeout(s.restartTimer);
280
- this.children.delete(name);
287
+ /** Stop one agent's pm2 app — used at destroy time. */
288
+ async stop(agentName) {
289
+ const name = pm2AppName(agentName);
281
290
  try {
282
- s.child.kill("SIGTERM");
283
- } catch {
291
+ await this.runAsAgent(agentName, ["pm2", "delete", name]);
292
+ this.deps.log(`pm2-supervisor: deleted ${name}`);
293
+ } catch (err) {
294
+ this.deps.log(`pm2-supervisor: delete ${name}: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
284
295
  }
285
296
  }
286
- stopAll() {
287
- for (const name of Array.from(this.children.keys())) this.stop(name);
297
+ /** Best-effort cleanup — called on Nest shutdown. We don't kill
298
+ * the per-agent pm2-daemons; they should keep running so bridges
299
+ * stay alive across Nest restarts. No-op for now. */
300
+ async stopAll() {
288
301
  }
289
- spawnChild(agent, prevCrashes) {
290
- const args = ["run", "--as", agent.name, "--wait", "--", "openape-chat-bridge"];
291
- const child = spawn(this.deps.apesBin, args, {
292
- stdio: ["ignore", "pipe", "pipe"],
293
- detached: false,
294
- // Inherit env most importantly PATH (host bin dirs from
295
- // captureHostBinDirs at install time) and HOME (the Nest's
296
- // data dir, where its own auth.json lives so apes-cli reads
297
- // the nest identity for the YOLO grant).
298
- env: process.env
299
- });
300
- child.stdout?.on("data", (chunk) => this.forwardLog(agent.name, "stdout", chunk));
301
- child.stderr?.on("data", (chunk) => this.forwardLog(agent.name, "stderr", chunk));
302
- const supervised = {
303
- child,
304
- consecutiveCrashes: prevCrashes,
305
- startedAt: Date.now()
306
- };
307
- this.children.set(agent.name, supervised);
308
- child.on("exit", (code, signal) => {
309
- const stillManaged = this.children.get(agent.name) === supervised;
310
- if (!stillManaged) return;
311
- const ranLongEnough = Date.now() - supervised.startedAt > STABLE_RUNTIME_MS;
312
- const nextCrashes = ranLongEnough ? 1 : prevCrashes + 1;
313
- const backoff = Math.min(MAX_BACKOFF_MS, MIN_BACKOFF_MS * 2 ** Math.max(0, nextCrashes - 1));
314
- this.deps.log(
315
- `supervisor: ${agent.name} bridge exited code=${code} signal=${signal ?? "none"} consecutive=${nextCrashes} \u2192 respawn in ${backoff}ms`
316
- );
317
- supervised.restartTimer = setTimeout(() => {
318
- if (this.children.get(agent.name) !== supervised) return;
319
- this.children.delete(agent.name);
320
- this.spawnChild(agent, nextCrashes);
321
- }, backoff);
322
- });
302
+ async startOrReload(agentName) {
303
+ mkdirSync3(AGENTS_DIR, { recursive: true, mode: 493 });
304
+ const dir = join3(AGENTS_DIR, agentName);
305
+ mkdirSync3(dir, { recursive: true, mode: 493 });
306
+ const path = ecosystemPath(agentName);
307
+ writeFileSync3(path, ecosystemContents(this.deps.apesBin, agentName), { mode: 420 });
308
+ await this.runAsAgent(agentName, ["pm2", "startOrReload", path]);
309
+ this.deps.log(`pm2-supervisor: ${agentName} bridge (re)started via pm2`);
323
310
  }
324
- forwardLog(name, stream, chunk) {
325
- const text = chunk.toString("utf8");
326
- for (const line of text.split("\n")) {
327
- const trimmed = line.trimEnd();
328
- if (!trimmed) continue;
329
- this.deps.log(`[${name}/${stream}] ${trimmed}`);
330
- }
311
+ /** Run a pm2 subcommand AS the agent — escapes-helper does the
312
+ * setuid switch, then exec's pm2 in the agent's uid. */
313
+ async runAsAgent(agentName, args) {
314
+ return execFileAsync2(
315
+ this.deps.apesBin,
316
+ ["run", "--as", agentName, "--wait", "--", ...args],
317
+ { maxBuffer: 1024 * 1024, env: process.env, timeout: 6e4 }
318
+ );
331
319
  }
332
320
  };
333
321
 
334
322
  // src/lib/troop-sync.ts
335
- import { execFile as execFile2 } from "child_process";
323
+ import { execFile as execFile3 } from "child_process";
336
324
  import process2 from "process";
337
- import { promisify as promisify2 } from "util";
338
- var execFileAsync2 = promisify2(execFile2);
325
+ import { promisify as promisify3 } from "util";
326
+ var execFileAsync3 = promisify3(execFile3);
339
327
  var TICK_MS = 5 * 60 * 1e3;
340
328
  var TroopSync = class {
341
329
  constructor(deps) {
@@ -369,7 +357,7 @@ var TroopSync = class {
369
357
  }
370
358
  async syncOne(name) {
371
359
  try {
372
- await execFileAsync2(
360
+ await execFileAsync3(
373
361
  this.deps.apesBin,
374
362
  ["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
375
363
  { maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4 }
@@ -386,17 +374,20 @@ function log(line) {
386
374
  process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
387
375
  `);
388
376
  }
389
- var supervisor = new Supervisor({ apesBin: APES_BIN, log });
377
+ var supervisor = new Pm2Supervisor({ apesBin: APES_BIN, log });
390
378
  var troopSync = new TroopSync({ apesBin: APES_BIN, log });
391
379
  var intentChannel = new IntentChannel({ apesBin: APES_BIN, supervisor, log });
392
- supervisor.reconcile(listAgents());
393
- log(`nest: supervisor reconciled, ${supervisor.size()} bridge process(es) starting`);
380
+ void supervisor.reconcile(listAgents()).then(
381
+ () => log("nest: pm2-supervisor reconciled with registry")
382
+ ).catch((err) => {
383
+ log(`nest: pm2-supervisor reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
384
+ });
394
385
  troopSync.start();
395
386
  intentChannel.start();
396
387
  var reaperTimer = setInterval(reapStaleResponses, 60 * 60 * 1e3, log);
397
388
  process3.on("SIGTERM", () => {
398
389
  log("nest: SIGTERM \u2014 stopping");
399
- supervisor.stopAll();
390
+ void supervisor.stopAll();
400
391
  troopSync.stop();
401
392
  intentChannel.stop();
402
393
  clearInterval(reaperTimer);
@@ -404,7 +395,7 @@ process3.on("SIGTERM", () => {
404
395
  });
405
396
  process3.on("SIGINT", () => {
406
397
  log("nest: SIGINT \u2014 stopping");
407
- supervisor.stopAll();
398
+ void supervisor.stopAll();
408
399
  troopSync.stop();
409
400
  intentChannel.stop();
410
401
  clearInterval(reaperTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/nest",
3
- "version": "1.0.0",
3
+ "version": "1.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",