@openape/nest 1.1.2 → 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.
- package/dist/index.mjs +45 -237
- 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);
|
|
@@ -315,13 +110,13 @@ var Pm2Supervisor = class {
|
|
|
315
110
|
async stopAll() {
|
|
316
111
|
}
|
|
317
112
|
async startOrReload(agentName) {
|
|
318
|
-
|
|
319
|
-
const dir =
|
|
320
|
-
|
|
113
|
+
mkdirSync2(AGENTS_DIR, { recursive: true, mode: 493 });
|
|
114
|
+
const dir = join2(AGENTS_DIR, agentName);
|
|
115
|
+
mkdirSync2(dir, { recursive: true, mode: 493 });
|
|
321
116
|
const path = ecosystemPath(agentName);
|
|
322
|
-
|
|
117
|
+
writeFileSync2(path, ecosystemContents(this.deps.apesBin, agentName), { mode: 420 });
|
|
323
118
|
const startPath = startScriptPath(agentName);
|
|
324
|
-
|
|
119
|
+
writeFileSync2(startPath, startScriptContents(agentName), { mode: 493 });
|
|
325
120
|
void path;
|
|
326
121
|
try {
|
|
327
122
|
await this.runAsAgent(agentName, ["bash", startPath]);
|
|
@@ -355,7 +150,7 @@ var Pm2Supervisor = class {
|
|
|
355
150
|
*/
|
|
356
151
|
async runAsAgent(agentName, args) {
|
|
357
152
|
try {
|
|
358
|
-
return await
|
|
153
|
+
return await execFileAsync(
|
|
359
154
|
this.deps.apesBin,
|
|
360
155
|
["run", "--as", agentName, "--wait", "--", ...args],
|
|
361
156
|
{ maxBuffer: 1024 * 1024, env: process.env, timeout: 6e4, cwd: "/tmp" }
|
|
@@ -370,10 +165,10 @@ var Pm2Supervisor = class {
|
|
|
370
165
|
};
|
|
371
166
|
|
|
372
167
|
// src/lib/troop-sync.ts
|
|
373
|
-
import { execFile as
|
|
168
|
+
import { execFile as execFile2 } from "child_process";
|
|
374
169
|
import process2 from "process";
|
|
375
|
-
import { promisify as
|
|
376
|
-
var
|
|
170
|
+
import { promisify as promisify2 } from "util";
|
|
171
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
377
172
|
var TICK_MS = 5 * 60 * 1e3;
|
|
378
173
|
var TroopSync = class {
|
|
379
174
|
constructor(deps) {
|
|
@@ -407,7 +202,7 @@ var TroopSync = class {
|
|
|
407
202
|
}
|
|
408
203
|
async syncOne(name) {
|
|
409
204
|
try {
|
|
410
|
-
await
|
|
205
|
+
await execFileAsync2(
|
|
411
206
|
this.deps.apesBin,
|
|
412
207
|
["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
|
|
413
208
|
{ maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4 }
|
|
@@ -420,34 +215,47 @@ var TroopSync = class {
|
|
|
420
215
|
|
|
421
216
|
// src/index.ts
|
|
422
217
|
var APES_BIN = process3.env.OPENAPE_APES_BIN ?? "apes";
|
|
218
|
+
var RECONCILE_DEBOUNCE_MS = 1e3;
|
|
423
219
|
function log(line) {
|
|
424
220
|
process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
425
221
|
`);
|
|
426
222
|
}
|
|
427
223
|
var supervisor = new Pm2Supervisor({ apesBin: APES_BIN, log });
|
|
428
224
|
var troopSync = new TroopSync({ apesBin: APES_BIN, log });
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
});
|
|
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();
|
|
435
234
|
troopSync.start();
|
|
436
|
-
|
|
437
|
-
|
|
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
|
+
}
|
|
438
250
|
process3.on("SIGTERM", () => {
|
|
439
251
|
log("nest: SIGTERM \u2014 stopping");
|
|
440
|
-
void supervisor.stopAll();
|
|
441
252
|
troopSync.stop();
|
|
442
|
-
|
|
443
|
-
clearInterval(reaperTimer);
|
|
253
|
+
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
444
254
|
process3.exit(0);
|
|
445
255
|
});
|
|
446
256
|
process3.on("SIGINT", () => {
|
|
447
257
|
log("nest: SIGINT \u2014 stopping");
|
|
448
|
-
void supervisor.stopAll();
|
|
449
258
|
troopSync.stop();
|
|
450
|
-
|
|
451
|
-
clearInterval(reaperTimer);
|
|
259
|
+
if (reconcileTimer) clearTimeout(reconcileTimer);
|
|
452
260
|
process3.exit(0);
|
|
453
261
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openape/nest",
|
|
3
|
-
"version": "
|
|
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",
|