@openape/nest 0.2.2 → 1.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/README.md +106 -0
- package/dist/index.mjs +207 -89
- package/package.json +3 -2
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @openape/nest
|
|
2
|
+
|
|
3
|
+
Local control-plane daemon. Manages OpenApe agents on a single machine — provisions macOS users, hands the bridge lifecycle off to launchd, exposes a localhost HTTP API gated by DDISA grant tokens.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
- Runs as a long-lived launchd-managed daemon under your user account (`~/Library/LaunchAgents/ai.openape.nest.plist`)
|
|
8
|
+
- Accepts API calls on `127.0.0.1:9091` for agent lifecycle ops (`spawn`, `destroy`, `list`, `status`)
|
|
9
|
+
- Every API call requires a DDISA-signed grant token in the `Authorization: Bearer …` header
|
|
10
|
+
- Bridge processes (`openape-chat-bridge` per agent) are NOT supervised in-daemon — they run as system-domain LaunchDaemons (one plist per agent in `/Library/LaunchDaemons/eco.hofmann.apes.bridge.<agent>.plist`) installed by `apes agents spawn --bridge`. launchd is the right OS-level supervisor on macOS; trying to duplicate that in the daemon crashloops without adding value.
|
|
11
|
+
|
|
12
|
+
## Setup (one-time)
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
apes nest install # install + load the launchd plist
|
|
16
|
+
apes nest enroll # daemon gets its own DDISA agent identity
|
|
17
|
+
apes nest authorize # set the YOLO policy — covers the inner
|
|
18
|
+
# `apes agents spawn` calls the daemon makes
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Set the bridge default model at install time so every spawn uses the
|
|
22
|
+
same one without `--bridge-model` repetition:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
apes nest install --bridge-model gpt-5.4 # ChatGPT-only LiteLLM proxy
|
|
26
|
+
apes nest install --bridge-model claude-haiku-4-5 # Anthropic
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This writes `APE_CHAT_BRIDGE_MODEL=<value>` to `~/litellm/.env`,
|
|
30
|
+
which `resolveBridgeConfig()` reads at every `apes [nest|agents] spawn
|
|
31
|
+
--bridge`. Re-run with a different value to overwrite.
|
|
32
|
+
|
|
33
|
+
### Optional: privilege isolation with a dedicated service user
|
|
34
|
+
|
|
35
|
+
By default, `apes nest install` configures the daemon as a user-domain
|
|
36
|
+
`LaunchAgent` running under your own Mac user account, with state at
|
|
37
|
+
`~/.openape/nest`. That works fine for personal use. For a more
|
|
38
|
+
hardened setup the daemon can be promoted to a system-domain
|
|
39
|
+
`LaunchDaemon` running under a dedicated `_openape_nest` macOS service
|
|
40
|
+
user (uid 481, hidden, no shell, no GUI session) with state under
|
|
41
|
+
`/var/openape/nest`.
|
|
42
|
+
|
|
43
|
+
To migrate an existing user-domain install:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
apes run --as root --wait -- bash apps/openape-nest/scripts/migrate-to-service-user.sh
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The script creates the user/group, copies your data dir to
|
|
50
|
+
`/var/openape/nest`, and swaps the plist. The Nest's IdP identity is
|
|
51
|
+
bound to its ssh keypair (which moves with the data dir), so the same
|
|
52
|
+
`nest-…@id.openape.ai` identity continues to work — no re-enroll
|
|
53
|
+
needed, all existing approved delegations / grants stay valid.
|
|
54
|
+
|
|
55
|
+
After migration you may want a fresh `apes login --key` for the Nest
|
|
56
|
+
to refresh the access token (the migrated `auth.json` carries the
|
|
57
|
+
old token; `cli-auth`'s challenge-response refresh handles it on
|
|
58
|
+
expiry, but a manual login also works).
|
|
59
|
+
|
|
60
|
+
After that, day-to-day lifecycle goes through `apes nest`:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
apes nest spawn igor18 # provision a new agent
|
|
64
|
+
apes nest list # show agents this nest knows about
|
|
65
|
+
apes nest status # health-check
|
|
66
|
+
apes nest destroy igor18 # tear down
|
|
67
|
+
apes nest uninstall # remove the launchd plist
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Why every API call needs a grant token
|
|
71
|
+
|
|
72
|
+
Without auth the API is gated only by "process running as the logged-in human can reach localhost:9091" — a compromised local process inherits everything. The grant-token requirement closes that gap and gives every call an audit record at the IdP. The flow:
|
|
73
|
+
|
|
74
|
+
1. `apes nest <op>` looks for an existing approved `'always'`/`'timed'` grant matching the operation.
|
|
75
|
+
2. If none, requests a fresh grant from the IdP. **First-time** grants for human callers wait for human approval (one approval covers the lifetime of the grant — `'always'` is the default for nest-CLI calls).
|
|
76
|
+
3. Token (RFC-7519 JWT signed by the IdP) is fetched and presented as `Authorization: Bearer …`.
|
|
77
|
+
4. The Nest verifies signature against the IdP's JWKS, checks `aud=nest`, `iss=<IdP URL>`, `target_host=<local hostname>`, and exact-matches the embedded `command` claim against the route. Fails: `401` (auth) or `403` (command mismatch).
|
|
78
|
+
|
|
79
|
+
### Grant-scope conventions
|
|
80
|
+
|
|
81
|
+
| CLI | Grant `command` | Reuse semantics |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `apes nest list` | `["nest","list"]` | One approval, reused forever. |
|
|
84
|
+
| `apes nest status` | `["nest","status"]` | One approval, reused forever. |
|
|
85
|
+
| `apes nest spawn <name>` | `["nest","spawn"]` (no name baked in) | One approval, any future spawn. Trade-off: a compromised local process running as the human can spawn arbitrary agents under that grant. Acceptable because spawn is reversible and audited. |
|
|
86
|
+
| `apes nest destroy <name>` | `["nest","destroy","<name>"]` | Per-name. Destroying each agent is its own approval. Destructive ops keep tighter scoping by design. |
|
|
87
|
+
|
|
88
|
+
Direct `curl` to the API is supported but you must fetch a grant token yourself. See `tests/auth-negative.sh` for an example token-fetch.
|
|
89
|
+
|
|
90
|
+
## Negative-test smoke
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
bash apps/openape-nest/tests/auth-negative.sh
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Verifies: no-bearer → 401, garbage-bearer → 401, wrong-audience → 401, command-mismatch → 403.
|
|
97
|
+
|
|
98
|
+
## Why no in-daemon bridge supervisor
|
|
99
|
+
|
|
100
|
+
Earlier versions ran a per-agent process supervisor inside the Nest daemon to keep `openape-chat-bridge` instances alive. It was removed because:
|
|
101
|
+
|
|
102
|
+
- `apes agents spawn --bridge` already installs a system-domain `LaunchDaemon` per agent. launchd KeepAlive's it as the agent UID with the right PATH (the bridge binary is at `/Users/<agent>/.bun/bin/openape-chat-bridge`).
|
|
103
|
+
- The supervisor's children inherited the daemon's PATH (the human user's PATH, which doesn't include any agent's `~/.bun/bin`), so they crashlooped on `Command not found: openape-chat-bridge` while the launchd-domain bridge ran fine.
|
|
104
|
+
- Each crashloop produced an auto-approved YOLO grant — pushing one notification per cycle.
|
|
105
|
+
|
|
106
|
+
Single-source-of-truth on launchd. The Nest is now an API surface in front of `apes agents spawn|destroy`, nothing more.
|
package/dist/index.mjs
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import
|
|
5
|
-
|
|
4
|
+
import process3 from "process";
|
|
5
|
+
|
|
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";
|
|
6
10
|
|
|
7
11
|
// src/api/agents.ts
|
|
8
12
|
import { execFile } from "child_process";
|
|
@@ -12,7 +16,7 @@ import { promisify } from "util";
|
|
|
12
16
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
13
17
|
import { homedir } from "os";
|
|
14
18
|
import { join } from "path";
|
|
15
|
-
var REGISTRY_DIR =
|
|
19
|
+
var REGISTRY_DIR = homedir();
|
|
16
20
|
var REGISTRY_PATH = join(REGISTRY_DIR, "agents.json");
|
|
17
21
|
function emptyRegistry() {
|
|
18
22
|
return { version: 1, agents: [] };
|
|
@@ -57,15 +61,6 @@ function removeAgent(name) {
|
|
|
57
61
|
// src/api/agents.ts
|
|
58
62
|
var execFileAsync = promisify(execFile);
|
|
59
63
|
var NAME_REGEX = /^[a-z][a-z0-9-]{0,23}$/;
|
|
60
|
-
function handleNestStatus(ctx) {
|
|
61
|
-
return {
|
|
62
|
-
agents: listAgents().length,
|
|
63
|
-
processes: ctx.supervisor.status()
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
function handleAgentsList(_ctx) {
|
|
67
|
-
return { agents: listAgents() };
|
|
68
|
-
}
|
|
69
64
|
async function handleAgentSpawn(ctx) {
|
|
70
65
|
const body = ctx.body;
|
|
71
66
|
const name = typeof body?.name === "string" ? body.name : "";
|
|
@@ -76,7 +71,7 @@ async function handleAgentSpawn(ctx) {
|
|
|
76
71
|
throw new Error(`agent "${name}" is already registered with this nest`);
|
|
77
72
|
}
|
|
78
73
|
const args = ["run", "--as", "root", "--wait", "--", "apes", "agents", "spawn", name];
|
|
79
|
-
const includeBridge = body?.bridge
|
|
74
|
+
const includeBridge = body?.bridge !== false;
|
|
80
75
|
if (includeBridge) {
|
|
81
76
|
args.push("--bridge");
|
|
82
77
|
if (typeof body?.bridgeKey === "string") args.push("--bridge-key", body.bridgeKey);
|
|
@@ -125,34 +120,144 @@ async function readUidFromDscl(name) {
|
|
|
125
120
|
return -1;
|
|
126
121
|
}
|
|
127
122
|
|
|
123
|
+
// src/lib/intent-channel.ts
|
|
124
|
+
var POLL_MS = 1e3;
|
|
125
|
+
var INTENTS_DIR = join2(homedir2(), "intents");
|
|
126
|
+
var IntentChannel = class {
|
|
127
|
+
constructor(deps) {
|
|
128
|
+
this.deps = deps;
|
|
129
|
+
mkdirSync2(INTENTS_DIR, { recursive: true });
|
|
130
|
+
chmodSync(INTENTS_DIR, 504);
|
|
131
|
+
}
|
|
132
|
+
timer;
|
|
133
|
+
inflight = /* @__PURE__ */ new Set();
|
|
134
|
+
start() {
|
|
135
|
+
if (this.timer) return;
|
|
136
|
+
this.timer = setInterval(() => void this.tick(), POLL_MS);
|
|
137
|
+
this.deps.log(`intent-channel: polling ${INTENTS_DIR}`);
|
|
138
|
+
}
|
|
139
|
+
stop() {
|
|
140
|
+
if (this.timer) clearInterval(this.timer);
|
|
141
|
+
this.timer = void 0;
|
|
142
|
+
}
|
|
143
|
+
async tick() {
|
|
144
|
+
let entries;
|
|
145
|
+
try {
|
|
146
|
+
entries = readdirSync(INTENTS_DIR);
|
|
147
|
+
} catch {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
for (const f of entries) {
|
|
151
|
+
if (!f.endsWith(".json")) continue;
|
|
152
|
+
if (this.inflight.has(f)) continue;
|
|
153
|
+
this.inflight.add(f);
|
|
154
|
+
void this.process(f).finally(() => this.inflight.delete(f));
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
async process(filename) {
|
|
158
|
+
const path = join2(INTENTS_DIR, filename);
|
|
159
|
+
let intent;
|
|
160
|
+
try {
|
|
161
|
+
const raw = readFileSync2(path, "utf8");
|
|
162
|
+
intent = JSON.parse(raw);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
this.deps.log(`intent-channel: failed to read ${filename}: ${err instanceof Error ? err.message : String(err)}`);
|
|
165
|
+
try {
|
|
166
|
+
unlinkSync(path);
|
|
167
|
+
} catch {
|
|
168
|
+
}
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
this.deps.log(`intent-channel: processing ${intent.action} (id=${intent.id})`);
|
|
172
|
+
let response;
|
|
173
|
+
try {
|
|
174
|
+
const ctx = {
|
|
175
|
+
url: new URL("intent:/"),
|
|
176
|
+
body: intent,
|
|
177
|
+
log: this.deps.log,
|
|
178
|
+
apesBin: this.deps.apesBin,
|
|
179
|
+
caller: "<intent-channel>",
|
|
180
|
+
grantId: intent.id,
|
|
181
|
+
supervisor: this.deps.supervisor
|
|
182
|
+
};
|
|
183
|
+
let result;
|
|
184
|
+
switch (intent.action) {
|
|
185
|
+
case "spawn":
|
|
186
|
+
result = await handleAgentSpawn(ctx);
|
|
187
|
+
break;
|
|
188
|
+
case "destroy":
|
|
189
|
+
result = await handleAgentDestroy(ctx, intent.name);
|
|
190
|
+
break;
|
|
191
|
+
case "list":
|
|
192
|
+
result = { agents: listAgents() };
|
|
193
|
+
break;
|
|
194
|
+
default:
|
|
195
|
+
throw new Error(`unknown action: ${intent.action ?? "<undefined>"}`);
|
|
196
|
+
}
|
|
197
|
+
response = { ok: true, result };
|
|
198
|
+
} catch (err) {
|
|
199
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
200
|
+
this.deps.log(`intent-channel: ${intent.action} failed: ${msg}`);
|
|
201
|
+
response = { ok: false, error: msg };
|
|
202
|
+
}
|
|
203
|
+
const respTmp = `${path.replace(/\.json$/, "")}.response.tmp`;
|
|
204
|
+
const respFinal = `${path.replace(/\.json$/, "")}.response`;
|
|
205
|
+
writeFileSync2(respTmp, `${JSON.stringify(response)}
|
|
206
|
+
`, { mode: 432 });
|
|
207
|
+
renameSync(respTmp, respFinal);
|
|
208
|
+
try {
|
|
209
|
+
unlinkSync(path);
|
|
210
|
+
} catch {
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
function reapStaleResponses(log2) {
|
|
215
|
+
let entries;
|
|
216
|
+
try {
|
|
217
|
+
entries = readdirSync(INTENTS_DIR);
|
|
218
|
+
} catch {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
const now = Date.now();
|
|
222
|
+
for (const f of entries) {
|
|
223
|
+
if (!f.endsWith(".response")) continue;
|
|
224
|
+
const path = join2(INTENTS_DIR, f);
|
|
225
|
+
try {
|
|
226
|
+
const st = statSync(path);
|
|
227
|
+
if (now - st.mtimeMs > 60 * 60 * 1e3) {
|
|
228
|
+
unlinkSync(path);
|
|
229
|
+
log2(`intent-channel: reaped stale ${f}`);
|
|
230
|
+
}
|
|
231
|
+
} catch {
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
128
236
|
// src/lib/supervisor.ts
|
|
129
237
|
import { spawn } from "child_process";
|
|
130
|
-
|
|
238
|
+
import process from "process";
|
|
239
|
+
var MIN_BACKOFF_MS = 2e3;
|
|
131
240
|
var MAX_BACKOFF_MS = 6e4;
|
|
241
|
+
var STABLE_RUNTIME_MS = 3e4;
|
|
132
242
|
var Supervisor = class {
|
|
133
243
|
constructor(deps) {
|
|
134
244
|
this.deps = deps;
|
|
135
245
|
}
|
|
136
246
|
children = /* @__PURE__ */ new Map();
|
|
137
|
-
/**
|
|
138
|
-
* Bring the supervised set in line with the desired set. Spawns
|
|
139
|
-
* agents that aren't running, kills agents that are no longer in
|
|
140
|
-
* the registry. Idempotent — call after every registry mutation.
|
|
141
|
-
*/
|
|
247
|
+
/** Bring the supervised set in line with the desired set. */
|
|
142
248
|
reconcile(desired) {
|
|
143
|
-
const desiredNames = new Set(desired.map((a) => a.name));
|
|
249
|
+
const desiredNames = new Set(desired.filter((a) => a.bridge != null).map((a) => a.name));
|
|
144
250
|
for (const [name] of this.children) {
|
|
145
251
|
if (!desiredNames.has(name)) this.stop(name);
|
|
146
252
|
}
|
|
147
253
|
for (const agent of desired) {
|
|
254
|
+
if (agent.bridge == null) continue;
|
|
148
255
|
if (!this.children.has(agent.name)) this.start(agent);
|
|
149
256
|
}
|
|
150
257
|
}
|
|
151
|
-
/** Number of currently-running supervised processes. */
|
|
152
258
|
size() {
|
|
153
259
|
return this.children.size;
|
|
154
260
|
}
|
|
155
|
-
/** Snapshot of supervised state — useful for /agents GET. */
|
|
156
261
|
status() {
|
|
157
262
|
const now = Date.now();
|
|
158
263
|
return Array.from(this.children.entries()).map(([name, s]) => ({
|
|
@@ -164,13 +269,13 @@ var Supervisor = class {
|
|
|
164
269
|
}
|
|
165
270
|
start(agent) {
|
|
166
271
|
if (this.children.has(agent.name)) return;
|
|
167
|
-
this.deps.log(`supervisor: starting ${agent.name}`);
|
|
272
|
+
this.deps.log(`supervisor: starting bridge for ${agent.name}`);
|
|
168
273
|
this.spawnChild(agent, 0);
|
|
169
274
|
}
|
|
170
275
|
stop(name) {
|
|
171
276
|
const s = this.children.get(name);
|
|
172
277
|
if (!s) return;
|
|
173
|
-
this.deps.log(`supervisor: stopping ${name}`);
|
|
278
|
+
this.deps.log(`supervisor: stopping bridge for ${name}`);
|
|
174
279
|
if (s.restartTimer) clearTimeout(s.restartTimer);
|
|
175
280
|
this.children.delete(name);
|
|
176
281
|
try {
|
|
@@ -178,15 +283,19 @@ var Supervisor = class {
|
|
|
178
283
|
} catch {
|
|
179
284
|
}
|
|
180
285
|
}
|
|
181
|
-
/** Kill all children — called on daemon shutdown. */
|
|
182
286
|
stopAll() {
|
|
183
287
|
for (const name of Array.from(this.children.keys())) this.stop(name);
|
|
184
288
|
}
|
|
185
289
|
spawnChild(agent, prevCrashes) {
|
|
186
|
-
const args = ["run", "--as", agent.name, "--", "openape-chat-bridge"];
|
|
290
|
+
const args = ["run", "--as", agent.name, "--wait", "--", "openape-chat-bridge"];
|
|
187
291
|
const child = spawn(this.deps.apesBin, args, {
|
|
188
292
|
stdio: ["ignore", "pipe", "pipe"],
|
|
189
|
-
detached: false
|
|
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
|
|
190
299
|
});
|
|
191
300
|
child.stdout?.on("data", (chunk) => this.forwardLog(agent.name, "stdout", chunk));
|
|
192
301
|
child.stderr?.on("data", (chunk) => this.forwardLog(agent.name, "stderr", chunk));
|
|
@@ -199,11 +308,11 @@ var Supervisor = class {
|
|
|
199
308
|
child.on("exit", (code, signal) => {
|
|
200
309
|
const stillManaged = this.children.get(agent.name) === supervised;
|
|
201
310
|
if (!stillManaged) return;
|
|
202
|
-
const ranLongEnough = Date.now() - supervised.startedAt >
|
|
311
|
+
const ranLongEnough = Date.now() - supervised.startedAt > STABLE_RUNTIME_MS;
|
|
203
312
|
const nextCrashes = ranLongEnough ? 1 : prevCrashes + 1;
|
|
204
313
|
const backoff = Math.min(MAX_BACKOFF_MS, MIN_BACKOFF_MS * 2 ** Math.max(0, nextCrashes - 1));
|
|
205
314
|
this.deps.log(
|
|
206
|
-
`supervisor: ${agent.name} exited code=${code} signal=${signal ?? "none"} consecutive=${nextCrashes} \u2192 respawn in ${backoff}ms`
|
|
315
|
+
`supervisor: ${agent.name} bridge exited code=${code} signal=${signal ?? "none"} consecutive=${nextCrashes} \u2192 respawn in ${backoff}ms`
|
|
207
316
|
);
|
|
208
317
|
supervised.restartTimer = setTimeout(() => {
|
|
209
318
|
if (this.children.get(agent.name) !== supervised) return;
|
|
@@ -222,73 +331,82 @@ var Supervisor = class {
|
|
|
222
331
|
}
|
|
223
332
|
};
|
|
224
333
|
|
|
334
|
+
// src/lib/troop-sync.ts
|
|
335
|
+
import { execFile as execFile2 } from "child_process";
|
|
336
|
+
import process2 from "process";
|
|
337
|
+
import { promisify as promisify2 } from "util";
|
|
338
|
+
var execFileAsync2 = promisify2(execFile2);
|
|
339
|
+
var TICK_MS = 5 * 60 * 1e3;
|
|
340
|
+
var TroopSync = class {
|
|
341
|
+
constructor(deps) {
|
|
342
|
+
this.deps = deps;
|
|
343
|
+
}
|
|
344
|
+
timer;
|
|
345
|
+
inflight = false;
|
|
346
|
+
start() {
|
|
347
|
+
if (this.timer) return;
|
|
348
|
+
setTimeout(() => this.tick(), 3e4).unref();
|
|
349
|
+
this.timer = setInterval(() => this.tick(), TICK_MS);
|
|
350
|
+
this.deps.log("troop-sync: loop started (interval=5min)");
|
|
351
|
+
}
|
|
352
|
+
stop() {
|
|
353
|
+
if (this.timer) clearInterval(this.timer);
|
|
354
|
+
this.timer = void 0;
|
|
355
|
+
}
|
|
356
|
+
async tick() {
|
|
357
|
+
if (this.inflight) return;
|
|
358
|
+
this.inflight = true;
|
|
359
|
+
try {
|
|
360
|
+
const agents = listAgents();
|
|
361
|
+
if (agents.length === 0) return;
|
|
362
|
+
this.deps.log(`troop-sync: reconciling ${agents.length} agent(s)`);
|
|
363
|
+
for (const agent of agents) {
|
|
364
|
+
await this.syncOne(agent.name);
|
|
365
|
+
}
|
|
366
|
+
} finally {
|
|
367
|
+
this.inflight = false;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async syncOne(name) {
|
|
371
|
+
try {
|
|
372
|
+
await execFileAsync2(
|
|
373
|
+
this.deps.apesBin,
|
|
374
|
+
["run", "--as", name, "--wait", "--", "apes", "agents", "sync"],
|
|
375
|
+
{ maxBuffer: 1024 * 1024, env: process2.env, timeout: 6e4 }
|
|
376
|
+
);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
this.deps.log(`troop-sync: ${name} failed: ${err instanceof Error ? err.message.split("\n")[0] : String(err)}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
|
|
225
383
|
// src/index.ts
|
|
226
|
-
var
|
|
227
|
-
var PORT = Number(process.env.OPENAPE_NEST_PORT ?? 9091);
|
|
228
|
-
var APES_BIN = process.env.OPENAPE_APES_BIN ?? "apes";
|
|
384
|
+
var APES_BIN = process3.env.OPENAPE_APES_BIN ?? "apes";
|
|
229
385
|
function log(line) {
|
|
230
|
-
|
|
386
|
+
process3.stderr.write(`${(/* @__PURE__ */ new Date()).toISOString()} ${line}
|
|
231
387
|
`);
|
|
232
388
|
}
|
|
233
389
|
var supervisor = new Supervisor({ apesBin: APES_BIN, log });
|
|
390
|
+
var troopSync = new TroopSync({ apesBin: APES_BIN, log });
|
|
391
|
+
var intentChannel = new IntentChannel({ apesBin: APES_BIN, supervisor, log });
|
|
234
392
|
supervisor.reconcile(listAgents());
|
|
235
|
-
log(`nest: supervisor reconciled, ${supervisor.size()}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (!text.trim()) return {};
|
|
242
|
-
try {
|
|
243
|
-
return JSON.parse(text);
|
|
244
|
-
} catch {
|
|
245
|
-
throw new Error("invalid JSON body");
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
function send(res, status, body) {
|
|
249
|
-
res.writeHead(status, { "content-type": "application/json" });
|
|
250
|
-
res.end(JSON.stringify(body));
|
|
251
|
-
}
|
|
252
|
-
var server = createServer((req, res) => {
|
|
253
|
-
;
|
|
254
|
-
(async () => {
|
|
255
|
-
try {
|
|
256
|
-
const url = new URL(req.url ?? "/", `http://${HOST}:${PORT}`);
|
|
257
|
-
const body = req.method && ["POST", "PUT", "PATCH"].includes(req.method) ? await readJsonBody(req) : {};
|
|
258
|
-
const ctx = { url, body, log, apesBin: APES_BIN, supervisor };
|
|
259
|
-
if (req.method === "GET" && url.pathname === "/status") {
|
|
260
|
-
return send(res, 200, handleNestStatus(ctx));
|
|
261
|
-
}
|
|
262
|
-
if (req.method === "GET" && url.pathname === "/agents") {
|
|
263
|
-
return send(res, 200, handleAgentsList(ctx));
|
|
264
|
-
}
|
|
265
|
-
if (req.method === "POST" && url.pathname === "/agents") {
|
|
266
|
-
const result = await handleAgentSpawn(ctx);
|
|
267
|
-
return send(res, 201, result);
|
|
268
|
-
}
|
|
269
|
-
const destroyMatch = req.method === "DELETE" && url.pathname.match(/^\/agents\/([^/]+)$/);
|
|
270
|
-
if (destroyMatch) {
|
|
271
|
-
const result = await handleAgentDestroy(ctx, destroyMatch[1]);
|
|
272
|
-
return send(res, 200, result);
|
|
273
|
-
}
|
|
274
|
-
send(res, 404, { error: "not found" });
|
|
275
|
-
} catch (err) {
|
|
276
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
277
|
-
log(`nest: request failed: ${msg}`);
|
|
278
|
-
send(res, 500, { error: msg });
|
|
279
|
-
}
|
|
280
|
-
})();
|
|
281
|
-
});
|
|
282
|
-
server.listen(PORT, HOST, () => {
|
|
283
|
-
log(`nest: listening on http://${HOST}:${PORT}`);
|
|
284
|
-
});
|
|
285
|
-
process.on("SIGTERM", () => {
|
|
286
|
-
log("nest: SIGTERM \u2014 stopping supervisor");
|
|
393
|
+
log(`nest: supervisor reconciled, ${supervisor.size()} bridge process(es) starting`);
|
|
394
|
+
troopSync.start();
|
|
395
|
+
intentChannel.start();
|
|
396
|
+
var reaperTimer = setInterval(reapStaleResponses, 60 * 60 * 1e3, log);
|
|
397
|
+
process3.on("SIGTERM", () => {
|
|
398
|
+
log("nest: SIGTERM \u2014 stopping");
|
|
287
399
|
supervisor.stopAll();
|
|
288
|
-
|
|
400
|
+
troopSync.stop();
|
|
401
|
+
intentChannel.stop();
|
|
402
|
+
clearInterval(reaperTimer);
|
|
403
|
+
process3.exit(0);
|
|
289
404
|
});
|
|
290
|
-
|
|
291
|
-
log("nest: SIGINT \u2014 stopping
|
|
405
|
+
process3.on("SIGINT", () => {
|
|
406
|
+
log("nest: SIGINT \u2014 stopping");
|
|
292
407
|
supervisor.stopAll();
|
|
293
|
-
|
|
408
|
+
troopSync.stop();
|
|
409
|
+
intentChannel.stop();
|
|
410
|
+
clearInterval(reaperTimer);
|
|
411
|
+
process3.exit(0);
|
|
294
412
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openape/nest",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.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,7 +17,8 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"ofetch": "^1.4.1",
|
|
20
|
-
"@openape/
|
|
20
|
+
"@openape/core": "0.16.0",
|
|
21
|
+
"@openape/cli-auth": "0.4.0"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@antfu/eslint-config": "^7.6.1",
|