@openape/nest 1.1.2 → 2.0.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 +47 -238
  2. package/package.json +1 -1
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;
@@ -265,7 +60,7 @@ module.exports = {
265
60
  `;
266
61
  }
267
62
  function startScriptPath(agentName) {
268
- return join3(AGENTS_DIR, agentName, "start.sh");
63
+ return join2(AGENTS_DIR, agentName, "start.sh");
269
64
  }
270
65
  function startScriptContents(agentName) {
271
66
  const ecosystem = ecosystemPath(agentName);
@@ -273,7 +68,8 @@ function startScriptContents(agentName) {
273
68
  return `#!/bin/bash
274
69
  # Auto-generated by Pm2Supervisor for agent '${agentName}'.
275
70
  set -e
276
- export HOME="/Users/${agentName}"
71
+ export HOME="$(dscl . -read /Users/${agentName} NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
72
+ test -n "$HOME" || { echo "no NFSHomeDirectory for ${agentName}" >&2; exit 1; }
277
73
  export PM2_HOME="$HOME/.pm2"
278
74
  mkdir -p "$(dirname "${log2}")"
279
75
  exec pm2 startOrReload ${ecosystem} >> ${log2} 2>&1 < /dev/null
@@ -315,13 +111,13 @@ var Pm2Supervisor = class {
315
111
  async stopAll() {
316
112
  }
317
113
  async startOrReload(agentName) {
318
- mkdirSync3(AGENTS_DIR, { recursive: true, mode: 493 });
319
- const dir = join3(AGENTS_DIR, agentName);
320
- mkdirSync3(dir, { recursive: true, mode: 493 });
114
+ mkdirSync2(AGENTS_DIR, { recursive: true, mode: 493 });
115
+ const dir = join2(AGENTS_DIR, agentName);
116
+ mkdirSync2(dir, { recursive: true, mode: 493 });
321
117
  const path = ecosystemPath(agentName);
322
- writeFileSync3(path, ecosystemContents(this.deps.apesBin, agentName), { mode: 420 });
118
+ writeFileSync2(path, ecosystemContents(this.deps.apesBin, agentName), { mode: 420 });
323
119
  const startPath = startScriptPath(agentName);
324
- writeFileSync3(startPath, startScriptContents(agentName), { mode: 493 });
120
+ writeFileSync2(startPath, startScriptContents(agentName), { mode: 493 });
325
121
  void path;
326
122
  try {
327
123
  await this.runAsAgent(agentName, ["bash", startPath]);
@@ -355,7 +151,7 @@ var Pm2Supervisor = class {
355
151
  */
356
152
  async runAsAgent(agentName, args) {
357
153
  try {
358
- return await execFileAsync2(
154
+ return await execFileAsync(
359
155
  this.deps.apesBin,
360
156
  ["run", "--as", agentName, "--wait", "--", ...args],
361
157
  { maxBuffer: 1024 * 1024, env: process.env, timeout: 6e4, cwd: "/tmp" }
@@ -370,10 +166,10 @@ var Pm2Supervisor = class {
370
166
  };
371
167
 
372
168
  // src/lib/troop-sync.ts
373
- import { execFile as execFile3 } from "child_process";
169
+ import { execFile as execFile2 } from "child_process";
374
170
  import process2 from "process";
375
- import { promisify as promisify3 } from "util";
376
- var execFileAsync3 = promisify3(execFile3);
171
+ import { promisify as promisify2 } from "util";
172
+ var execFileAsync2 = promisify2(execFile2);
377
173
  var TICK_MS = 5 * 60 * 1e3;
378
174
  var TroopSync = class {
379
175
  constructor(deps) {
@@ -407,7 +203,7 @@ var TroopSync = class {
407
203
  }
408
204
  async syncOne(name) {
409
205
  try {
410
- await execFileAsync3(
206
+ await execFileAsync2(
411
207
  this.deps.apesBin,
412
208
  ["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
413
209
  { maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4 }
@@ -420,34 +216,47 @@ var TroopSync = class {
420
216
 
421
217
  // src/index.ts
422
218
  var APES_BIN = process3.env.OPENAPE_APES_BIN ?? "apes";
219
+ var RECONCILE_DEBOUNCE_MS = 1e3;
423
220
  function log(line) {
424
221
  process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
425
222
  `);
426
223
  }
427
224
  var supervisor = new Pm2Supervisor({ apesBin: APES_BIN, log });
428
225
  var troopSync = new TroopSync({ apesBin: APES_BIN, log });
429
- var intentChannel = new IntentChannel({ apesBin: APES_BIN, supervisor, log });
430
- void supervisor.reconcile(listAgents()).then(
431
- () => log("nest: pm2-supervisor reconciled with registry")
432
- ).catch((err) => {
433
- log(`nest: pm2-supervisor reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
434
- });
226
+ async function reconcile() {
227
+ try {
228
+ await supervisor.reconcile(listAgents());
229
+ log("nest: pm2-supervisor reconciled with registry");
230
+ } catch (err) {
231
+ log(`nest: reconcile failed: ${err instanceof Error ? err.message : String(err)}`);
232
+ }
233
+ }
234
+ void reconcile();
435
235
  troopSync.start();
436
- intentChannel.start();
437
- var reaperTimer = setInterval(reapStaleResponses, 60 * 60 * 1e3, log);
236
+ var reconcileTimer;
237
+ try {
238
+ watch(REGISTRY_PATH, () => {
239
+ if (reconcileTimer) clearTimeout(reconcileTimer);
240
+ reconcileTimer = setTimeout(() => {
241
+ void reconcile();
242
+ }, RECONCILE_DEBOUNCE_MS);
243
+ });
244
+ log(`nest: watching ${REGISTRY_PATH} for registry changes`);
245
+ } catch (err) {
246
+ log(`nest: registry watch failed (${err instanceof Error ? err.message : String(err)}) \u2014 falling back to 5s poll`);
247
+ setInterval(() => {
248
+ void reconcile();
249
+ }, 5e3).unref();
250
+ }
438
251
  process3.on("SIGTERM", () => {
439
252
  log("nest: SIGTERM \u2014 stopping");
440
- void supervisor.stopAll();
441
253
  troopSync.stop();
442
- intentChannel.stop();
443
- clearInterval(reaperTimer);
254
+ if (reconcileTimer) clearTimeout(reconcileTimer);
444
255
  process3.exit(0);
445
256
  });
446
257
  process3.on("SIGINT", () => {
447
258
  log("nest: SIGINT \u2014 stopping");
448
- void supervisor.stopAll();
449
259
  troopSync.stop();
450
- intentChannel.stop();
451
- clearInterval(reaperTimer);
260
+ if (reconcileTimer) clearTimeout(reconcileTimer);
452
261
  process3.exit(0);
453
262
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openape/nest",
3
- "version": "1.1.2",
3
+ "version": "2.0.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",