alvin-bot 5.6.2 → 5.7.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/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  All notable changes to Alvin Bot are documented here.
4
4
 
5
+ ## [5.7.0] — 2026-05-19
6
+
7
+ ### Background-task results now arrive the instant the task finishes, and survive a restart
8
+
9
+ Detached background-task results were delivered by a 15-second polling
10
+ loop whose in-memory state could diverge across a bot restart, so a task
11
+ that finished around a restart could keep its result undelivered with
12
+ nothing shown in chat. Delivery is now pushed the moment the task's
13
+ process exits — through an always-on local callback guarded by a
14
+ per-boot token — and a startup reconciliation pass drains anything that
15
+ completed while the bot was down. An atomic deliver-once marker
16
+ guarantees the push, the polling backstop, and reconciliation never
17
+ double-deliver, and a cancelled task can no longer be resurrected. The
18
+ polling loop is kept only as a backstop for timeouts and stalled tasks.
19
+ No configuration is required and nothing changes for existing setups.
20
+
5
21
  ## [5.6.2] — 2026-05-19
6
22
 
7
23
  ### Long background-task results now reliably arrive in chat
package/README.md CHANGED
@@ -71,7 +71,7 @@ Alvin Bot sits in the same category as **Hermes Agent** (Nous Research) and **Op
71
71
  - **Use Hermes Agent when** you want a research-grade self-improving agent, need it to act as an **MCP server** for Claude Desktop / Cursor / VS Code, want 200+ model choice or many execution backends, and value a large community.
72
72
  - **Use OpenClaw when** you want the **widest messaging reach** (25–50+ channels) plus native mobile apps and voice activation, fully transparent plain-file memory you can git-track, and the largest ecosystem.
73
73
 
74
- <!-- comparison-page link intentionally deferred until the landing page is live (HTTP 200); re-enabled in a separate commit per docs/positioning/05-SHIP-CHECKLIST.md Step 5 -->
74
+ A longer head-to-head with FAQ and decision guide: **[Alvin Bot vs Hermes vs OpenClaw](https://alvin.alev-b.com/vs/hermes-openclaw)**.
75
75
 
76
76
  ---
77
77
 
@@ -35,6 +35,40 @@ import { SUBAGENTS_DIR } from "../paths.js";
35
35
  function generateAgentId() {
36
36
  return "alvin-" + crypto.randomBytes(12).toString("hex");
37
37
  }
38
+ /**
39
+ * v5.7.0 — Best-effort push: tell the bot a detached sub-agent just
40
+ * exited so it can read the jsonl and deliver immediately instead of
41
+ * waiting for the next 15s poll tick. An in-process self-call to the
42
+ * always-on loopback route. Any failure (bot mid-restart, port race,
43
+ * network) is swallowed — startup reconciliation and the poll backstop
44
+ * are the safety nets. Never throws, always resolves.
45
+ */
46
+ export async function postSubagentExit(agentId, exitCode) {
47
+ try {
48
+ const { getWebPort, getInternalToken } = await import("../web/server.js");
49
+ const port = getWebPort();
50
+ const token = getInternalToken();
51
+ const ac = new AbortController();
52
+ const timer = setTimeout(() => ac.abort(), 4000);
53
+ try {
54
+ await fetch(`http://127.0.0.1:${port}/internal/subagent-exit`, {
55
+ method: "POST",
56
+ headers: {
57
+ "Content-Type": "application/json",
58
+ Authorization: `Bearer ${token}`,
59
+ },
60
+ body: JSON.stringify({ agentId, exitCode }),
61
+ signal: ac.signal,
62
+ });
63
+ }
64
+ finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+ catch {
69
+ /* best-effort — reconciliation/poll backstop cover any miss */
70
+ }
71
+ }
38
72
  /**
39
73
  * Dispatch a detached sub-agent. Returns synchronously — the subprocess
40
74
  * runs in the background. Throws if spawn fails. On success:
@@ -96,6 +130,16 @@ export function dispatchDetachedAgent(input) {
96
130
  catch {
97
131
  /* ignore */
98
132
  }
133
+ // v5.7.0 — Push: deliver the result the instant the subprocess exits,
134
+ // instead of waiting for the next 15s poll tick. `exit` fires after
135
+ // the OS has reaped the process and flushed its inherited stdout FD,
136
+ // so the terminating result line is fully on disk before the handler
137
+ // reads it (race-safe). Best-effort: startup reconciliation + the poll
138
+ // backstop cover a missed push (bot restarted mid-run, port race).
139
+ // Attached before unref() so the listener is registered on the handle.
140
+ child.on("exit", (code) => {
141
+ void postSubagentExit(agentId, code);
142
+ });
99
143
  // Detach from parent Node's event loop so parent exit doesn't wait.
100
144
  child.unref();
101
145
  // Register with watcher so it polls the output file and delivers.
@@ -23,9 +23,10 @@
23
23
  * for the JSONL format details.
24
24
  */
25
25
  import fs from "fs";
26
- import { dirname } from "path";
26
+ import { dirname, resolve } from "path";
27
27
  import { parseOutputFileStatus } from "./async-agent-parser.js";
28
- import { ASYNC_AGENTS_STATE_FILE } from "../paths.js";
28
+ import { claimDelivery, markDelivered, isDelivered, cleanupAgentFiles, } from "./subagent-dedup.js";
29
+ import { ASYNC_AGENTS_STATE_FILE, SUBAGENTS_DIR } from "../paths.js";
29
30
  import { getAllSessions } from "./session.js";
30
31
  /**
31
32
  * B3 — Detect a permanent "target chat does not exist" delivery failure
@@ -191,12 +192,133 @@ function decrementPendingCount(sessionKey) {
191
192
  export function listPendingAgents() {
192
193
  return [...pending.values()];
193
194
  }
194
- /** Start the polling loop. Idempotent. Loads any persisted state from disk. */
195
+ /**
196
+ * v5.7.0 push entry point — called by POST /internal/subagent-exit when
197
+ * a detached subprocess exits. Looks the agent up, classifies its jsonl,
198
+ * and delivers immediately (claim-gated, so push / the poll backstop /
199
+ * reconciliation never double-deliver). Returns a coarse status for the
200
+ * HTTP layer:
201
+ *
202
+ * - "unknown" → no matching pending entry (cancelled, already
203
+ * delivered, or never tracked). HTTP 404. No side effect.
204
+ * - "delivered" → terminal jsonl found and delivered (or the claim was
205
+ * lost to another path — either way it is handled and
206
+ * the entry is dropped). HTTP 200.
207
+ * - "pending" → exit fired but the jsonl is not yet terminal (rare
208
+ * flush race). Left to the poll backstop. HTTP 202.
209
+ */
210
+ export async function deliverByAgentId(agentId) {
211
+ const entry = pending.get(agentId);
212
+ if (!entry)
213
+ return "unknown";
214
+ const status = await parseOutputFileStatus(entry.outputFile);
215
+ if (status.state === "completed") {
216
+ // Invariant: the path that WINS the claim owns removal of the
217
+ // shared pending entry. A claim-loser must not mutate shared state
218
+ // — the winner (this push, the poll backstop, or reconcile) removes
219
+ // it. If a winner ever crashes mid-delivery the next poll tick
220
+ // self-heals: claimDelivery() is false there too, so it skips
221
+ // re-delivery but still drops the entry. Either way "delivered".
222
+ if (claimDelivery(agentId)) {
223
+ await deliverAsCompleted(entry, status.output, status.tokensUsed);
224
+ pending.delete(agentId);
225
+ saveToDisk();
226
+ }
227
+ return "delivered";
228
+ }
229
+ if (status.state === "failed") {
230
+ if (claimDelivery(agentId)) {
231
+ await deliverAsFailure(entry, "error", status.error);
232
+ pending.delete(agentId);
233
+ saveToDisk();
234
+ }
235
+ return "delivered";
236
+ }
237
+ // running / missing → not yet flushed; the 15s poll backstop will get it.
238
+ return "pending";
239
+ }
240
+ /**
241
+ * v5.7.0 — Startup reconciliation. Runs once inside startWatcher() after
242
+ * loadFromDisk(). Two passes:
243
+ *
244
+ * Pass A — immediate terminal drain. For every entry already in the
245
+ * pending map (full delivery metadata present, restored by
246
+ * loadFromDisk), check its jsonl ONCE: if terminal, deliver immediately
247
+ * at startup instead of waiting up to 15s for the first poll tick. This
248
+ * is what removes the post-restart latency the BACKLOG observed and
249
+ * works even if the poll loop were disabled. Claim-gated.
250
+ *
251
+ * Pass B — disk hygiene, never delivers. Walk SUBAGENTS_DIR: skip
252
+ * delivered/tombstoned agents, age-cap-clean ancient files, and leave
253
+ * fresh orphans (jsonl with no persisted state entry → no delivery
254
+ * target) untouched for a later age-cap. We never fabricate a target.
255
+ *
256
+ * Idempotent (re-runnable), dedup-guarded by the .delivered marker.
257
+ */
258
+ async function reconcileOnStartup() {
259
+ // Pass A — drain pending entries with full metadata.
260
+ for (const entry of [...pending.values()]) {
261
+ try {
262
+ if (isDelivered(entry.agentId)) {
263
+ pending.delete(entry.agentId);
264
+ continue;
265
+ }
266
+ const status = await parseOutputFileStatus(entry.outputFile);
267
+ if (status.state === "completed" && claimDelivery(entry.agentId)) {
268
+ await deliverAsCompleted(entry, status.output, status.tokensUsed);
269
+ pending.delete(entry.agentId);
270
+ }
271
+ else if (status.state === "failed" && claimDelivery(entry.agentId)) {
272
+ await deliverAsFailure(entry, "error", status.error);
273
+ pending.delete(entry.agentId);
274
+ }
275
+ }
276
+ catch (err) {
277
+ console.error(`[async-watcher] reconcile passA ${entry.agentId}:`, err);
278
+ }
279
+ }
280
+ saveToDisk();
281
+ // Pass B — disk hygiene. Never delivers.
282
+ let files;
283
+ try {
284
+ files = fs.readdirSync(SUBAGENTS_DIR);
285
+ }
286
+ catch {
287
+ return; // no subagents dir yet — nothing to reconcile
288
+ }
289
+ const now = Date.now();
290
+ for (const f of files) {
291
+ if (!f.endsWith(".jsonl"))
292
+ continue;
293
+ const agentId = f.slice(0, -".jsonl".length);
294
+ if (isDelivered(agentId))
295
+ continue; // delivered or cancel-tombstone
296
+ if (pending.has(agentId))
297
+ continue; // Pass A / poll / push own it
298
+ const jsonlPath = resolve(SUBAGENTS_DIR, f);
299
+ try {
300
+ const st = fs.statSync(jsonlPath);
301
+ if (now - st.mtimeMs > MAX_AGENT_AGE_MS) {
302
+ cleanupAgentFiles(agentId); // ancient orphan → clean
303
+ }
304
+ // else: orphan within age, no persisted target → cannot deliver;
305
+ // leave it (age-cap cleans it on a later boot). No spam, no
306
+ // fabricated delivery target.
307
+ }
308
+ catch {
309
+ /* stat race — ignore */
310
+ }
311
+ }
312
+ }
313
+ /** Start the polling loop. Idempotent. Loads any persisted state from
314
+ * disk, then runs startup reconciliation (immediate terminal drain +
315
+ * disk hygiene) before the 15s poll backstop begins. */
195
316
  export function startWatcher() {
196
317
  if (started)
197
318
  return;
198
319
  started = true;
199
320
  loadFromDisk();
321
+ void reconcileOnStartup().catch((err) => console.error("[async-watcher] reconcile failed:", err));
200
322
  pollTimer = setInterval(() => {
201
323
  pollOnce().catch((err) => console.error("[async-watcher] poll cycle failed:", err));
202
324
  }, POLL_INTERVAL_MS);
@@ -235,21 +357,31 @@ export async function pollOnce() {
235
357
  entry.lastCheckedAt = now;
236
358
  // Timeout check first — if the agent is past its giveUpAt, give up
237
359
  // regardless of whether the file shows progress.
360
+ // v5.7.0 — every terminal delivery is claim-gated so push,
361
+ // this poll backstop, and startup reconciliation never
362
+ // double-deliver. A lost claim means another path already
363
+ // handled it: just drop the entry without re-delivering.
238
364
  if (now >= entry.giveUpAt) {
239
- const outcome = await deliverAsFailure(entry, "timeout", "Agent ran longer than 12h — giving up");
240
- abandonIfInvalidTarget(entry, outcome);
365
+ if (claimDelivery(entry.agentId)) {
366
+ const outcome = await deliverAsFailure(entry, "timeout", "Agent ran longer than 12h — giving up");
367
+ abandonIfInvalidTarget(entry, outcome);
368
+ }
241
369
  toRemove.push(entry.agentId);
242
370
  continue;
243
371
  }
244
372
  const status = await parseOutputFileStatus(entry.outputFile);
245
373
  if (status.state === "completed") {
246
- const outcome = await deliverAsCompleted(entry, status.output, status.tokensUsed);
247
- abandonIfInvalidTarget(entry, outcome);
374
+ if (claimDelivery(entry.agentId)) {
375
+ const outcome = await deliverAsCompleted(entry, status.output, status.tokensUsed);
376
+ abandonIfInvalidTarget(entry, outcome);
377
+ }
248
378
  toRemove.push(entry.agentId);
249
379
  }
250
380
  else if (status.state === "failed") {
251
- const outcome = await deliverAsFailure(entry, "error", status.error);
252
- abandonIfInvalidTarget(entry, outcome);
381
+ if (claimDelivery(entry.agentId)) {
382
+ const outcome = await deliverAsFailure(entry, "error", status.error);
383
+ abandonIfInvalidTarget(entry, outcome);
384
+ }
253
385
  toRemove.push(entry.agentId);
254
386
  }
255
387
  else if (status.state === "missing" &&
@@ -257,8 +389,10 @@ export async function pollOnce() {
257
389
  // v4.14.2 — Zombie guard: the subprocess never created its
258
390
  // output file within `missingFileFailureMs` (default 10 min).
259
391
  // Declare failed instead of polling until the 12h giveUpAt.
260
- const outcome = await deliverAsFailure(entry, "error", `Dispatched subprocess never wrote its output file (${Math.round((now - entry.startedAt) / 60_000)}m after start). Likely crashed before initializing, or the file was removed externally.`);
261
- abandonIfInvalidTarget(entry, outcome);
392
+ if (claimDelivery(entry.agentId)) {
393
+ const outcome = await deliverAsFailure(entry, "error", `Dispatched subprocess never wrote its output file (${Math.round((now - entry.startedAt) / 60_000)}m after start). Likely crashed before initializing, or the file was removed externally.`);
394
+ abandonIfInvalidTarget(entry, outcome);
395
+ }
262
396
  toRemove.push(entry.agentId);
263
397
  }
264
398
  // running / missing-but-young → keep polling next cycle
@@ -424,6 +558,12 @@ export function cancelPendingForSession(sessionKey) {
424
558
  let changed = false;
425
559
  for (const [id, entry] of pending.entries()) {
426
560
  if (entry.sessionKey === sessionKey) {
561
+ // v5.7.0 — tombstone before removal: a cancelled agent's
562
+ // subprocess may still exit later and POST, or its leftover jsonl
563
+ // may be rediscovered by reconciliation on a future boot. The
564
+ // .delivered marker makes "cancelled" authoritative and
565
+ // restart-proof so neither path can resurrect it.
566
+ markDelivered(entry.agentId);
427
567
  pending.delete(id);
428
568
  changed = true;
429
569
  }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Atomic deliver-once primitive for detached sub-agents (v5.7.0).
3
+ *
4
+ * The single source of truth for "has this agent's result already been
5
+ * delivered (or been cancel-tombstoned)?" — shared by all three delivery
6
+ * paths: the exit-push endpoint, the 15s poll backstop, and startup
7
+ * reconciliation. The marker is an empty `<agentId>.delivered` file in
8
+ * SUBAGENTS_DIR; `claimDelivery` creates it with O_EXCL ("wx") so exactly
9
+ * one caller wins the race. Crash-safe and restart-safe (it is on disk),
10
+ * which the in-memory pending map alone cannot be.
11
+ */
12
+ import fs from "fs";
13
+ import { resolve } from "path";
14
+ import { SUBAGENTS_DIR } from "../paths.js";
15
+ function markerPath(agentId) {
16
+ return resolve(SUBAGENTS_DIR, `${agentId}.delivered`);
17
+ }
18
+ function ensureDir() {
19
+ try {
20
+ fs.mkdirSync(SUBAGENTS_DIR, { recursive: true });
21
+ }
22
+ catch {
23
+ /* race-safe — a concurrent create is fine */
24
+ }
25
+ }
26
+ /**
27
+ * Attempt to claim delivery for `agentId`. Returns true exactly once
28
+ * (the caller that atomically created the marker) and false for every
29
+ * subsequent call. On an unexpected fs error other than EEXIST we return
30
+ * true: a rare double-delivery (a duplicate message) is strictly
31
+ * preferable to silently losing a result — the exact bug this whole
32
+ * feature exists to fix.
33
+ */
34
+ export function claimDelivery(agentId) {
35
+ ensureDir();
36
+ try {
37
+ const fd = fs.openSync(markerPath(agentId), "wx");
38
+ fs.closeSync(fd);
39
+ return true;
40
+ }
41
+ catch (err) {
42
+ if (err.code === "EEXIST")
43
+ return false;
44
+ return true; // prefer a possible duplicate over a lost delivery
45
+ }
46
+ }
47
+ /**
48
+ * Write the marker if absent, ignore if already present. Used as a
49
+ * cancel tombstone (claim-without-deliver) so a cancelled agent can
50
+ * never be resurrected by push or reconciliation. Idempotent; never
51
+ * throws.
52
+ */
53
+ export function markDelivered(agentId) {
54
+ ensureDir();
55
+ try {
56
+ const fd = fs.openSync(markerPath(agentId), "wx");
57
+ fs.closeSync(fd);
58
+ }
59
+ catch {
60
+ /* EEXIST or fs error — tombstone already effective / best-effort */
61
+ }
62
+ }
63
+ /** True if a delivered/tombstone marker exists for this agent. */
64
+ export function isDelivered(agentId) {
65
+ try {
66
+ return fs.existsSync(markerPath(agentId));
67
+ }
68
+ catch {
69
+ return false;
70
+ }
71
+ }
72
+ /**
73
+ * Best-effort removal of every on-disk artifact for an aged-out agent
74
+ * (jsonl + err + delivered marker). Used by reconciliation's age-cap so
75
+ * ancient files don't accumulate. Never throws.
76
+ */
77
+ export function cleanupAgentFiles(agentId) {
78
+ for (const ext of [".jsonl", ".err", ".delivered"]) {
79
+ try {
80
+ fs.unlinkSync(resolve(SUBAGENTS_DIR, `${agentId}${ext}`));
81
+ }
82
+ catch {
83
+ /* ignore — already gone or never existed */
84
+ }
85
+ }
86
+ }
@@ -12,6 +12,7 @@ import fs from "fs";
12
12
  import path from "path";
13
13
  import { resolve } from "path";
14
14
  import { execSync } from "child_process";
15
+ import crypto from "crypto";
15
16
  import { WebSocketServer, WebSocket } from "ws";
16
17
  import { getRegistry } from "../engine.js";
17
18
  import { getSession, resetSession, getAllSessions } from "../services/session.js";
@@ -51,6 +52,18 @@ let bindRetryTimer = null;
51
52
  * this and exits silently if set, so stop is truly terminal. */
52
53
  let stopRequested = false;
53
54
  const WEB_PASSWORD = process.env.WEB_PASSWORD || "";
55
+ /**
56
+ * v5.7.0 — Per-boot random token guarding POST /internal/subagent-exit.
57
+ * Generated once at module load; never persisted, never logged. The
58
+ * detached-agent exit hook reads it in-process via getInternalToken().
59
+ * The route is loopback-bound by default; this token additionally
60
+ * defends the opt-in WEB_HOST=0.0.0.0 (LAN-exposed) case. 48 hex chars.
61
+ */
62
+ const INTERNAL_TOKEN = crypto.randomBytes(24).toString("hex");
63
+ /** In-process accessor for the per-boot internal-route token. */
64
+ export function getInternalToken() {
65
+ return INTERNAL_TOKEN;
66
+ }
54
67
  /** The actual port the Web UI is running on (may differ from WEB_PORT if busy). */
55
68
  let actualWebPort = WEB_PORT;
56
69
  // ── MIME Types ──────────────────────────────────────────
@@ -154,6 +167,56 @@ async function handleAPI(req, res, urlPath, body) {
154
167
  }
155
168
  return;
156
169
  }
170
+ // POST /internal/subagent-exit — detached sub-agent exit push (v5.7.0).
171
+ // Always available (NO WEBHOOK_ENABLED dependency, so it works for every
172
+ // install with zero config); loopback-bound by default; per-boot bearer
173
+ // token. Reads the agent's jsonl, classifies, delivers immediately
174
+ // (claim-gated). Idempotent — a repeat POST is a 404 no-op.
175
+ if (urlPath === "/internal/subagent-exit" && req.method === "POST") {
176
+ // The legitimate payload is ~80 bytes ({agentId, exitCode}). Reject
177
+ // anything absurd before the auth compare so an unauthenticated
178
+ // caller (only reachable at all under the opt-in WEB_HOST=0.0.0.0)
179
+ // cannot push large bodies through this always-on route. (A deeper
180
+ // streaming cap on the shared body accumulator — also used by
181
+ // /api/ and /v1/ — is a separate pre-existing hardening item.)
182
+ if (body.length > 8 * 1024) {
183
+ res.statusCode = 413;
184
+ res.end(JSON.stringify({ error: "Payload too large" }));
185
+ return;
186
+ }
187
+ if (!timingSafeBearerMatch(req.headers.authorization, INTERNAL_TOKEN)) {
188
+ res.statusCode = 401;
189
+ res.end(JSON.stringify({ error: "Unauthorized" }));
190
+ return;
191
+ }
192
+ let agentId;
193
+ try {
194
+ const payload = JSON.parse(body);
195
+ agentId = typeof payload.agentId === "string" ? payload.agentId : "";
196
+ }
197
+ catch {
198
+ res.statusCode = 400;
199
+ res.end(JSON.stringify({ error: "Invalid JSON body" }));
200
+ return;
201
+ }
202
+ if (!agentId) {
203
+ res.statusCode = 400;
204
+ res.end(JSON.stringify({ error: "Missing agentId" }));
205
+ return;
206
+ }
207
+ try {
208
+ const { deliverByAgentId } = await import("../services/async-agent-watcher.js");
209
+ const outcome = await deliverByAgentId(agentId);
210
+ res.statusCode = outcome === "unknown" ? 404 : outcome === "pending" ? 202 : 200;
211
+ res.end(JSON.stringify({ ok: outcome !== "unknown", outcome }));
212
+ }
213
+ catch (err) {
214
+ res.statusCode = 500;
215
+ res.end(JSON.stringify({ error: "delivery failed" }));
216
+ console.error("[internal/subagent-exit] delivery error:", err);
217
+ }
218
+ return;
219
+ }
157
220
  // Auth check for all other API routes
158
221
  if (!checkAuth(req)) {
159
222
  res.statusCode = 401;
@@ -1580,6 +1643,14 @@ function handleWebRequest(req, res) {
1580
1643
  handleAPI(req, res, urlPath, body);
1581
1644
  return;
1582
1645
  }
1646
+ // v5.7.0 — internal bot-to-bot routes (detached sub-agent exit
1647
+ // push). Dispatched through handleAPI, which bearer-auths it and
1648
+ // returns before the cookie-auth gate. Kept off the /api/ prefix so
1649
+ // it is never surfaced in the Web UI route surface.
1650
+ if (urlPath.startsWith("/internal/")) {
1651
+ handleAPI(req, res, urlPath, body);
1652
+ return;
1653
+ }
1583
1654
  // Auth page (if password set and not authenticated)
1584
1655
  if (WEB_PASSWORD && !checkAuth(req) && urlPath !== "/login.html") {
1585
1656
  res.writeHead(302, { Location: "/login.html" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "alvin-bot",
3
- "version": "5.6.2",
3
+ "version": "5.7.0",
4
4
  "description": "Alvin Bot — open-source, self-hosted autonomous AI agent on Telegram, Slack, Discord, WhatsApp, Signal, terminal & web. Built on the Claude Agent SDK with a multi-provider engine (OpenAI, Groq, Gemini, NVIDIA NIM, OpenRouter, Ollama) and automatic failover, detached sub-agents that survive a parent abort, zero-config indexed memory (no embedding key needed), 4-tier browser automation, cron tasks, MCP client and a self-preservation subsystem. Local-first, telemetry-free. An OpenClaw / Hermes Agent alternative.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",