@openape/nest 1.1.1 → 2.0.0

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 +101 -243
  2. package/package.json +3 -3
package/dist/index.mjs CHANGED
@@ -1,17 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
+ import { watch } from "fs";
4
5
  import process3 from "process";
5
6
 
6
- // src/lib/intent-channel.ts
7
- import { readdirSync, readFileSync as readFileSync2, renameSync, statSync, unlinkSync, writeFileSync as writeFileSync2, mkdirSync as mkdirSync2, chmodSync } from "fs";
8
- import { homedir as homedir2 } from "os";
9
- import { join as join2 } from "path";
10
-
11
- // src/api/agents.ts
12
- import { execFile } from "child_process";
13
- import { promisify } from "util";
14
-
15
7
  // src/lib/registry.ts
16
8
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
17
9
  import { homedir } from "os";
@@ -31,220 +23,23 @@ function readRegistry() {
31
23
  return emptyRegistry();
32
24
  }
33
25
  }
34
- function writeRegistry(reg) {
35
- mkdirSync(REGISTRY_DIR, { recursive: true });
36
- writeFileSync(REGISTRY_PATH, `${JSON.stringify(reg, null, 2)}
37
- `, { mode: 384 });
38
- }
39
26
  function listAgents() {
40
27
  return readRegistry().agents;
41
28
  }
42
- function findAgent(name) {
43
- return readRegistry().agents.find((a) => a.name === name);
44
- }
45
- function upsertAgent(entry) {
46
- const reg = readRegistry();
47
- const existing = reg.agents.findIndex((a) => a.name === entry.name);
48
- if (existing >= 0) reg.agents[existing] = entry;
49
- else reg.agents.push(entry);
50
- writeRegistry(reg);
51
- }
52
- function removeAgent(name) {
53
- const reg = readRegistry();
54
- const before = reg.agents.length;
55
- reg.agents = reg.agents.filter((a) => a.name !== name);
56
- if (reg.agents.length === before) return false;
57
- writeRegistry(reg);
58
- return true;
59
- }
60
-
61
- // src/api/agents.ts
62
- var execFileAsync = promisify(execFile);
63
- var NAME_REGEX = /^[a-z][a-z0-9-]{0,23}$/;
64
- async function handleAgentSpawn(ctx) {
65
- const body = ctx.body;
66
- const name = typeof body?.name === "string" ? body.name : "";
67
- if (!NAME_REGEX.test(name)) {
68
- throw new Error(`name must match ${NAME_REGEX} (got "${name}")`);
69
- }
70
- if (findAgent(name)) {
71
- throw new Error(`agent "${name}" is already registered with this nest`);
72
- }
73
- const args = ["run", "--as", "root", "--wait", "--", "apes", "agents", "spawn", name];
74
- const includeBridge = body?.bridge !== false;
75
- if (includeBridge) {
76
- args.push("--bridge");
77
- if (typeof body?.bridgeKey === "string") args.push("--bridge-key", body.bridgeKey);
78
- if (typeof body?.bridgeBaseUrl === "string") args.push("--bridge-base-url", body.bridgeBaseUrl);
79
- if (typeof body?.bridgeModel === "string") args.push("--bridge-model", body.bridgeModel);
80
- }
81
- ctx.log(`nest: spawning agent "${name}" via apes...`);
82
- const { stdout: _stdout } = await execFileAsync(ctx.apesBin, args, { maxBuffer: 4 * 1024 * 1024 });
83
- const uid = await readUidFromDscl(name);
84
- const entry = {
85
- name,
86
- uid,
87
- home: `/Users/${name}`,
88
- email: "",
89
- // filled in on first sync — we don't know it locally
90
- registeredAt: Math.floor(Date.now() / 1e3),
91
- bridge: includeBridge ? {
92
- baseUrl: typeof body?.bridgeBaseUrl === "string" ? body.bridgeBaseUrl : void 0,
93
- apiKey: typeof body?.bridgeKey === "string" ? body.bridgeKey : void 0,
94
- model: typeof body?.bridgeModel === "string" ? body.bridgeModel : void 0
95
- } : void 0
96
- };
97
- upsertAgent(entry);
98
- await ctx.supervisor.reconcile(listAgents());
99
- return { name, email: entry.email, uid, home: entry.home };
100
- }
101
- async function handleAgentDestroy(ctx, name) {
102
- if (!NAME_REGEX.test(name)) throw new Error(`invalid agent name "${name}"`);
103
- const entry = findAgent(name);
104
- if (!entry) throw new Error(`agent "${name}" not registered with this nest`);
105
- ctx.log(`nest: destroying agent "${name}"...`);
106
- const args = ["run", "--as", "root", "--", "apes", "agents", "destroy", name, "--force"];
107
- await execFileAsync(ctx.apesBin, args, { maxBuffer: 4 * 1024 * 1024 });
108
- removeAgent(name);
109
- await ctx.supervisor.reconcile(listAgents());
110
- return { name, removed: true };
111
- }
112
- async function readUidFromDscl(name) {
113
- try {
114
- const { stdout } = await execFileAsync("/usr/bin/dscl", [".", "-read", `/Users/${name}`, "UniqueID"]);
115
- const match = stdout.match(/UniqueID:\s*(\d+)/);
116
- if (match) return Number(match[1]);
117
- } catch {
118
- }
119
- return -1;
120
- }
121
-
122
- // src/lib/intent-channel.ts
123
- var POLL_MS = 1e3;
124
- var INTENTS_DIR = join2(homedir2(), "intents");
125
- var IntentChannel = class {
126
- constructor(deps) {
127
- this.deps = deps;
128
- mkdirSync2(INTENTS_DIR, { recursive: true });
129
- chmodSync(INTENTS_DIR, 504);
130
- }
131
- timer;
132
- inflight = /* @__PURE__ */ new Set();
133
- start() {
134
- if (this.timer) return;
135
- this.timer = setInterval(() => void this.tick(), POLL_MS);
136
- this.deps.log(`intent-channel: polling ${INTENTS_DIR}`);
137
- }
138
- stop() {
139
- if (this.timer) clearInterval(this.timer);
140
- this.timer = void 0;
141
- }
142
- async tick() {
143
- let entries;
144
- try {
145
- entries = readdirSync(INTENTS_DIR);
146
- } catch {
147
- return;
148
- }
149
- for (const f of entries) {
150
- if (!f.endsWith(".json")) continue;
151
- if (this.inflight.has(f)) continue;
152
- this.inflight.add(f);
153
- void this.process(f).finally(() => this.inflight.delete(f));
154
- }
155
- }
156
- async process(filename) {
157
- const path = join2(INTENTS_DIR, filename);
158
- let intent;
159
- try {
160
- const raw = readFileSync2(path, "utf8");
161
- intent = JSON.parse(raw);
162
- } catch (err) {
163
- this.deps.log(`intent-channel: failed to read ${filename}: ${err instanceof Error ? err.message : String(err)}`);
164
- try {
165
- unlinkSync(path);
166
- } catch {
167
- }
168
- return;
169
- }
170
- this.deps.log(`intent-channel: processing ${intent.action} (id=${intent.id})`);
171
- let response;
172
- try {
173
- const ctx = {
174
- url: new URL("intent:/"),
175
- body: intent,
176
- log: this.deps.log,
177
- apesBin: this.deps.apesBin,
178
- caller: "<intent-channel>",
179
- grantId: intent.id,
180
- supervisor: this.deps.supervisor
181
- };
182
- let result;
183
- switch (intent.action) {
184
- case "spawn":
185
- result = await handleAgentSpawn(ctx);
186
- break;
187
- case "destroy":
188
- result = await handleAgentDestroy(ctx, intent.name);
189
- break;
190
- case "list":
191
- result = { agents: listAgents() };
192
- break;
193
- default:
194
- throw new Error(`unknown action: ${intent.action ?? "<undefined>"}`);
195
- }
196
- response = { ok: true, result };
197
- } catch (err) {
198
- const msg = err instanceof Error ? err.message : String(err);
199
- this.deps.log(`intent-channel: ${intent.action} failed: ${msg}`);
200
- response = { ok: false, error: msg };
201
- }
202
- const respTmp = `${path.replace(/\.json$/, "")}.response.tmp`;
203
- const respFinal = `${path.replace(/\.json$/, "")}.response`;
204
- writeFileSync2(respTmp, `${JSON.stringify(response)}
205
- `, { mode: 432 });
206
- renameSync(respTmp, respFinal);
207
- try {
208
- unlinkSync(path);
209
- } catch {
210
- }
211
- }
212
- };
213
- function reapStaleResponses(log2) {
214
- let entries;
215
- try {
216
- entries = readdirSync(INTENTS_DIR);
217
- } catch {
218
- return;
219
- }
220
- const now = Date.now();
221
- for (const f of entries) {
222
- if (!f.endsWith(".response")) continue;
223
- const path = join2(INTENTS_DIR, f);
224
- try {
225
- const st = statSync(path);
226
- if (now - st.mtimeMs > 60 * 60 * 1e3) {
227
- unlinkSync(path);
228
- log2(`intent-channel: reaped stale ${f}`);
229
- }
230
- } catch {
231
- }
232
- }
233
- }
234
29
 
235
30
  // 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";
31
+ import { execFile } from "child_process";
32
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync2 } from "fs";
33
+ import { join as join2 } from "path";
239
34
  import process from "process";
240
- import { promisify as promisify2 } from "util";
241
- var execFileAsync2 = promisify2(execFile2);
35
+ import { promisify } from "util";
36
+ var execFileAsync = promisify(execFile);
242
37
  var AGENTS_DIR = "/var/openape/agents";
243
38
  function pm2AppName(agentName) {
244
39
  return `openape-bridge-${agentName}`;
245
40
  }
246
41
  function ecosystemPath(agentName) {
247
- return join3(AGENTS_DIR, agentName, "ecosystem.config.js");
42
+ return join2(AGENTS_DIR, agentName, "ecosystem.config.js");
248
43
  }
249
44
  function ecosystemContents(apesBin, agentName) {
250
45
  void apesBin;
@@ -264,6 +59,21 @@ module.exports = {
264
59
  }
265
60
  `;
266
61
  }
62
+ function startScriptPath(agentName) {
63
+ return join2(AGENTS_DIR, agentName, "start.sh");
64
+ }
65
+ function startScriptContents(agentName) {
66
+ const ecosystem = ecosystemPath(agentName);
67
+ const log2 = `/var/log/openape/${agentName}-pm2.log`;
68
+ return `#!/bin/bash
69
+ # Auto-generated by Pm2Supervisor for agent '${agentName}'.
70
+ set -e
71
+ export HOME="/Users/${agentName}"
72
+ export PM2_HOME="$HOME/.pm2"
73
+ mkdir -p "$(dirname "${log2}")"
74
+ exec pm2 startOrReload ${ecosystem} >> ${log2} 2>&1 < /dev/null
75
+ `;
76
+ }
267
77
  var Pm2Supervisor = class {
268
78
  constructor(deps) {
269
79
  this.deps = deps;
@@ -278,7 +88,7 @@ var Pm2Supervisor = class {
278
88
  try {
279
89
  await this.startOrReload(agent.name);
280
90
  } catch (err) {
281
- this.deps.log(`pm2-supervisor: ${agent.name} startOrReload failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
91
+ this.deps.log(`pm2-supervisor: ${agent.name} reconcile errored: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
282
92
  } finally {
283
93
  this.inflight.delete(agent.name);
284
94
  }
@@ -300,30 +110,65 @@ var Pm2Supervisor = class {
300
110
  async stopAll() {
301
111
  }
302
112
  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 });
113
+ mkdirSync2(AGENTS_DIR, { recursive: true, mode: 493 });
114
+ const dir = join2(AGENTS_DIR, agentName);
115
+ mkdirSync2(dir, { recursive: true, mode: 493 });
306
116
  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`);
117
+ writeFileSync2(path, ecosystemContents(this.deps.apesBin, agentName), { mode: 420 });
118
+ const startPath = startScriptPath(agentName);
119
+ writeFileSync2(startPath, startScriptContents(agentName), { mode: 493 });
120
+ void path;
121
+ try {
122
+ await this.runAsAgent(agentName, ["bash", startPath]);
123
+ } catch {
124
+ }
125
+ let online = false;
126
+ try {
127
+ const { stdout } = await this.runAsAgent(agentName, ["pm2", "jlist"]);
128
+ const json = stdout.match(/\[\s*\{.*\}\s*\]/s)?.[0];
129
+ if (json) {
130
+ const list = JSON.parse(json);
131
+ online = list.some((p) => p.name === pm2AppName(agentName) && p.pm2_env?.status === "online");
132
+ }
133
+ } catch {
134
+ }
135
+ if (online) {
136
+ this.deps.log(`pm2-supervisor: ${agentName} bridge online (pm2)`);
137
+ } else {
138
+ this.deps.log(`pm2-supervisor: ${agentName} bridge NOT online \u2014 see /var/log/openape/${agentName}-pm2.log`);
139
+ }
310
140
  }
311
141
  /** Run a pm2 subcommand AS the agent — escapes-helper does the
312
- * setuid switch, then exec's pm2 in the agent's uid. */
142
+ * setuid switch, then exec's pm2 in the agent's uid.
143
+ *
144
+ * cwd: the agent process inherits cwd from the spawning Nest
145
+ * daemon, whose cwd is /var/openape/nest (mode 750, no access for
146
+ * other uids). Without setting cwd to a world-readable dir, the
147
+ * child's first `process.cwd()` call (which Node does internally
148
+ * during module loading) throws EACCES. /tmp is the most portable
149
+ * always-writable location.
150
+ */
313
151
  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
- );
152
+ try {
153
+ return await execFileAsync(
154
+ this.deps.apesBin,
155
+ ["run", "--as", agentName, "--wait", "--", ...args],
156
+ { maxBuffer: 1024 * 1024, env: process.env, timeout: 6e4, cwd: "/tmp" }
157
+ );
158
+ } catch (err) {
159
+ const e = err;
160
+ const detail = (e.stderr ?? "").trim().split("\n").slice(-3).join(" / ");
161
+ const stdoutDetail = (e.stdout ?? "").trim().split("\n").slice(-2).join(" / ");
162
+ throw new Error(`${e.message?.split("\n")[0] ?? "execFile failed"} | stderr: ${detail || "<empty>"} | stdout: ${stdoutDetail || "<empty>"}`);
163
+ }
319
164
  }
320
165
  };
321
166
 
322
167
  // src/lib/troop-sync.ts
323
- import { execFile as execFile3 } from "child_process";
168
+ import { execFile as execFile2 } from "child_process";
324
169
  import process2 from "process";
325
- import { promisify as promisify3 } from "util";
326
- var execFileAsync3 = promisify3(execFile3);
170
+ import { promisify as promisify2 } from "util";
171
+ var execFileAsync2 = promisify2(execFile2);
327
172
  var TICK_MS = 5 * 60 * 1e3;
328
173
  var TroopSync = class {
329
174
  constructor(deps) {
@@ -357,7 +202,7 @@ var TroopSync = class {
357
202
  }
358
203
  async syncOne(name) {
359
204
  try {
360
- await execFileAsync3(
205
+ await execFileAsync2(
361
206
  this.deps.apesBin,
362
207
  ["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
363
208
  { maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4 }
@@ -370,34 +215,47 @@ var TroopSync = class {
370
215
 
371
216
  // src/index.ts
372
217
  var APES_BIN = process3.env.OPENAPE_APES_BIN ?? "apes";
218
+ var RECONCILE_DEBOUNCE_MS = 1e3;
373
219
  function log(line) {
374
220
  process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
375
221
  `);
376
222
  }
377
223
  var supervisor = new Pm2Supervisor({ apesBin: APES_BIN, log });
378
224
  var troopSync = new TroopSync({ apesBin: APES_BIN, log });
379
- var intentChannel = new IntentChannel({ apesBin: APES_BIN, supervisor, log });
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
- });
225
+ async function reconcile() {
226
+ try {
227
+ await supervisor.reconcile(listAgents());
228
+ log("nest: pm2-supervisor reconciled with registry");
229
+ } catch (err) {
230
+ log(`nest: reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
231
+ }
232
+ }
233
+ void reconcile();
385
234
  troopSync.start();
386
- intentChannel.start();
387
- var reaperTimer = setInterval(reapStaleResponses, 60 * 60 * 1e3, log);
235
+ var reconcileTimer;
236
+ try {
237
+ watch(REGISTRY_PATH, () => {
238
+ if (reconcileTimer) clearTimeout(reconcileTimer);
239
+ reconcileTimer = setTimeout(() => {
240
+ void reconcile();
241
+ }, RECONCILE_DEBOUNCE_MS);
242
+ });
243
+ log(`nest: watching ${REGISTRY_PATH} for registry changes`);
244
+ } catch (err) {
245
+ log(`nest: registry watch failed (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to 5s poll`);
246
+ setInterval(() => {
247
+ void reconcile();
248
+ }, 5e3).unref();
249
+ }
388
250
  process3.on("SIGTERM", () => {
389
251
  log("nest: SIGTERM \u2014 stopping");
390
- void supervisor.stopAll();
391
252
  troopSync.stop();
392
- intentChannel.stop();
393
- clearInterval(reaperTimer);
253
+ if (reconcileTimer) clearTimeout(reconcileTimer);
394
254
  process3.exit(0);
395
255
  });
396
256
  process3.on("SIGINT", () => {
397
257
  log("nest: SIGINT \u2014 stopping");
398
- void supervisor.stopAll();
399
258
  troopSync.stop();
400
- intentChannel.stop();
401
- clearInterval(reaperTimer);
259
+ if (reconcileTimer) clearTimeout(reconcileTimer);
402
260
  process3.exit(0);
403
261
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/nest",
3
- "version": "1.1.1",
3
+ "version": "2.0.0",
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,8 +17,8 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "ofetch": "^1.4.1",
20
- "@openape/core": "0.16.0",
21
- "@openape/cli-auth": "0.4.0"
20
+ "@openape/cli-auth": "0.4.0",
21
+ "@openape/core": "0.16.0"
22
22
  },
23
23
  "devDependencies": {
24
24
  "@antfu/eslint-config": "^7.6.1",