@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.
- package/dist/index.mjs +47 -238
- 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
|
|
237
|
-
import { mkdirSync as
|
|
238
|
-
import { join as
|
|
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
|
|
241
|
-
var
|
|
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
|
|
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
|
|
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
|
-
|
|
319
|
-
const dir =
|
|
320
|
-
|
|
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
|
-
|
|
118
|
+
writeFileSync2(path, ecosystemContents(this.deps.apesBin, agentName), { mode: 420 });
|
|
323
119
|
const startPath = startScriptPath(agentName);
|
|
324
|
-
|
|
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
|
|
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
|
|
169
|
+
import { execFile as execFile2 } from "child_process";
|
|
374
170
|
import process2 from "process";
|
|
375
|
-
import { promisify as
|
|
376
|
-
var
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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",
|