athena-agent-launcher 0.1.0 → 0.3.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 CHANGED
@@ -3,11 +3,11 @@
3
3
  Runs an agent configured on the Athena control plane locally on the user's machine.
4
4
 
5
5
  ```
6
- # One-time setup (saves credentials to ~/.athena/credentials.json)
7
- npx athena-agent-launcher setup
8
-
9
- # Run an agent
6
+ # Run an agent — first run auto-prompts for credentials if missing
10
7
  npx athena-agent-launcher run <agent-id>
8
+
9
+ # Optional: re-run setup later to change credentials
10
+ npx athena-agent-launcher setup
11
11
  ```
12
12
 
13
13
  For `hermes` runtime agents, the launcher will auto-install the Hermes binary
package/bin/launcher.mjs CHANGED
@@ -14,6 +14,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "n
14
14
  import { homedir } from "node:os";
15
15
  import { join, dirname } from "node:path";
16
16
  import readline from "node:readline";
17
+ import { startWebChat } from "../lib/webchat.mjs";
17
18
 
18
19
  const DEFAULT_BASE_URL = "https://athena-v2-pi.vercel.app";
19
20
 
@@ -180,6 +181,83 @@ async function ensureHermesInstalled() {
180
181
  return await locateHermes();
181
182
  }
182
183
 
184
+ function sleep(ms) {
185
+ return new Promise((r) => setTimeout(r, ms));
186
+ }
187
+
188
+ // Open a URL in the user's default browser (best-effort, cross-platform).
189
+ function openBrowser(url) {
190
+ const cmd =
191
+ process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
192
+ const cmdArgs = process.platform === "win32" ? ["/c", "start", "", url] : [url];
193
+ try {
194
+ spawn(cmd, cmdArgs, { stdio: "ignore", detached: true }).unref();
195
+ } catch {
196
+ /* best-effort; the URL is also printed */
197
+ }
198
+ }
199
+
200
+ // SSO device pairing: open the browser to authorize, poll until a key bound to
201
+ // the verified identity is issued. Returns the ak_ key.
202
+ async function pairAndGetKey(baseUrl, agentId) {
203
+ const startRes = await fetch(`${baseUrl}/api/launcher/pair/start`, {
204
+ method: "POST",
205
+ headers: { "content-type": "application/json" },
206
+ body: JSON.stringify({ agent_id: agentId }),
207
+ });
208
+ if (!startRes.ok) {
209
+ throw new Error(`pair/start failed (${startRes.status}): ${await startRes.text()}`);
210
+ }
211
+ const { device_code, user_code, verification_url } = await startRes.json();
212
+ process.stderr.write(
213
+ `\n[athena-agent] Sign in with your Sonance account to authorize this device:\n` +
214
+ `\n ${verification_url}\n\n` +
215
+ ` (opening your browser… code: ${user_code})\n\n`,
216
+ );
217
+ openBrowser(verification_url);
218
+
219
+ const deadline = Date.now() + 10 * 60 * 1000;
220
+ let notified = false;
221
+ while (Date.now() < deadline) {
222
+ await sleep(3000);
223
+ let j;
224
+ try {
225
+ const pollRes = await fetch(`${baseUrl}/api/launcher/pair/poll`, {
226
+ method: "POST",
227
+ headers: { "content-type": "application/json" },
228
+ body: JSON.stringify({ device_code }),
229
+ });
230
+ j = await pollRes.json();
231
+ } catch {
232
+ continue; // transient network blip — keep polling
233
+ }
234
+ if (j.status === "approved" && j.api_key) {
235
+ process.stderr.write("[athena-agent] ✓ authorized.\n");
236
+ return j.api_key;
237
+ }
238
+ if (j.status === "denied") throw new Error(`Authorization denied: ${j.error || "no access"}`);
239
+ if (j.status === "expired") throw new Error("Pairing expired — run the command again.");
240
+ if (!notified && j.status === "pending") {
241
+ process.stderr.write("[athena-agent] waiting for you to approve in the browser…\n");
242
+ notified = true;
243
+ }
244
+ }
245
+ throw new Error("Pairing timed out — run the command again.");
246
+ }
247
+
248
+ // Persist a per-agent key (new format) without clobbering other agents' keys
249
+ // or the legacy flat `api_key`.
250
+ function saveAgentKey(agentId, apiKey, baseUrl) {
251
+ const credsPath = join(homedir(), ".athena", "credentials.json");
252
+ const creds = loadCredsFile();
253
+ if (!creds.base_url) creds.base_url = baseUrl;
254
+ creds.agent_keys = creds.agent_keys || {};
255
+ creds.agent_keys[agentId] = apiKey;
256
+ mkdirSync(dirname(credsPath), { recursive: true });
257
+ writeFileSync(credsPath, JSON.stringify(creds, null, 2), "utf8");
258
+ chmodSync(credsPath, 0o600);
259
+ }
260
+
183
261
  async function fetchManifest({ baseUrl, agentId, apiKey }) {
184
262
  const res = await fetch(`${baseUrl}/api/agents/${agentId}/launcher-manifest`, {
185
263
  headers: { authorization: `Bearer ${apiKey}` },
@@ -234,12 +312,33 @@ async function main() {
234
312
  const creds = loadCredsFile();
235
313
  const baseUrl =
236
314
  args.baseUrl || process.env.ATHENA_BASE_URL || creds.base_url || DEFAULT_BASE_URL;
237
- const apiKey = args.apiKey || process.env.ATHENA_API_KEY || creds.api_key;
315
+ // Key precedence: explicit flag/env > per-agent saved key > legacy flat key.
316
+ let apiKey =
317
+ args.apiKey ||
318
+ process.env.ATHENA_API_KEY ||
319
+ creds.agent_keys?.[agentId] ||
320
+ creds.api_key;
321
+
322
+ // No saved key for this agent → authorize this device via Sonance SSO. The
323
+ // browser flow verifies the user's Okta identity and (if they hold an access
324
+ // grant) hands back a key bound to their account, which we cache per-agent.
325
+ if (!apiKey && !args.apiKey) {
326
+ process.stderr.write(
327
+ `[athena-agent] no saved key for ${agentId} — starting SSO authorization.\n`,
328
+ );
329
+ try {
330
+ apiKey = await pairAndGetKey(baseUrl, agentId);
331
+ } catch (err) {
332
+ process.stderr.write(`[athena-agent] ${err.message}\n`);
333
+ process.exit(1);
334
+ }
335
+ saveAgentKey(agentId, apiKey, baseUrl);
336
+ }
238
337
 
239
338
  if (!apiKey) {
240
339
  process.stderr.write(
241
340
  "Error: no API key. Pass --api-key ak_..., set $ATHENA_API_KEY, " +
242
- "or write {\"api_key\": \"ak_...\"} to ~/.athena/credentials.json\n",
341
+ "or run without flags to authorize via Sonance SSO.\n",
243
342
  );
244
343
  process.exit(2);
245
344
  }
@@ -248,8 +347,33 @@ async function main() {
248
347
  try {
249
348
  manifest = await fetchManifest({ baseUrl, agentId, apiKey });
250
349
  } catch (err) {
251
- process.stderr.write(`${err.message}\n`);
252
- process.exit(1);
350
+ // A saved key that's been revoked server-side returns 401 — re-authorize
351
+ // via SSO once and retry, unless the key was supplied explicitly.
352
+ if (/\(401\)/.test(err.message) && !args.apiKey && !process.env.ATHENA_API_KEY) {
353
+ process.stderr.write(
354
+ "[athena-agent] saved key was rejected (revoked?) — re-authorizing via SSO.\n",
355
+ );
356
+ try {
357
+ apiKey = await pairAndGetKey(baseUrl, agentId);
358
+ saveAgentKey(agentId, apiKey, baseUrl);
359
+ manifest = await fetchManifest({ baseUrl, agentId, apiKey });
360
+ } catch (err2) {
361
+ process.stderr.write(`[athena-agent] ${err2.message}\n`);
362
+ process.exit(1);
363
+ }
364
+ } else {
365
+ // `fetch failed` from undici is opaque on its own — the real cause
366
+ // (ENOTFOUND, ECONNREFUSED, TLS, etc.) lives on err.cause.
367
+ const cause = err?.cause
368
+ ? ` (${err.cause.code || err.cause.message || err.cause})`
369
+ : "";
370
+ process.stderr.write(
371
+ `[athena-agent] ${err.message}${cause}\n` +
372
+ ` base URL: ${baseUrl}\n` +
373
+ ` agent: ${agentId}\n`,
374
+ );
375
+ process.exit(1);
376
+ }
253
377
  }
254
378
 
255
379
  if (args.manifestOnly) {
@@ -312,12 +436,77 @@ async function main() {
312
436
 
313
437
  const anthropicBase =
314
438
  manifest.env?.ATHENA_ANTHROPIC_URL || manifest.gateway_url;
439
+
440
+ // Hermes credential precedence: HERMES_HOME/auth.json > ~/.hermes/auth.json
441
+ // > env vars. We write an agent-scoped auth.json with the ak_ key at
442
+ // priority 100 so it beats any global OAuth credential (Claude Code's
443
+ // sk-ant-oat01 token lives in ~/.hermes/auth.json at priority 0 and would
444
+ // otherwise silently override everything we set in .env or the environment).
445
+ const authJson = {
446
+ version: 1,
447
+ providers: {},
448
+ active_provider: null,
449
+ updated_at: new Date().toISOString(),
450
+ credential_pool: {
451
+ anthropic: [
452
+ {
453
+ id: "athena-agent",
454
+ label: "athena_ak",
455
+ auth_type: "api_key",
456
+ priority: 100,
457
+ source: "athena",
458
+ api_key: apiKey,
459
+ base_url: anthropicBase,
460
+ },
461
+ ],
462
+ },
463
+ };
464
+ const authPath = join(hermesHome, "auth.json");
465
+ writeFileSync(authPath, JSON.stringify(authJson, null, 2), "utf8");
466
+ chmodSync(authPath, 0o600);
467
+
468
+ // Also write .env for belt-and-suspenders (some Hermes versions read this
469
+ // before auth.json for base URL; others do not — cover both).
470
+ const dotenvPath = join(hermesHome, ".env");
471
+ writeFileSync(
472
+ dotenvPath,
473
+ `ANTHROPIC_API_KEY=${apiKey}\nANTHROPIC_BASE_URL=${anthropicBase}\nANTHROPIC_TOKEN=\n`,
474
+ "utf8",
475
+ );
476
+ chmodSync(dotenvPath, 0o600);
477
+ process.stderr.write(`[athena-agent] wrote auth to ${hermesHome}/{auth.json,.env}\n`);
478
+
315
479
  env = {
316
480
  ...env,
317
481
  HERMES_HOME: hermesHome,
318
482
  ANTHROPIC_BASE_URL: anthropicBase,
319
483
  ANTHROPIC_API_KEY: apiKey,
484
+ ANTHROPIC_TOKEN: "",
320
485
  };
486
+
487
+ // Web Chat gateway: run Hermes headless in ACP mode behind a local browser
488
+ // chat instead of the interactive terminal TUI. startWebChat owns the
489
+ // hermes child + local server and keeps the process alive.
490
+ if (manifest.web_chat?.enabled) {
491
+ const webchat = await startWebChat({
492
+ hermesBin: cmd,
493
+ env,
494
+ hermesHome,
495
+ webChat: manifest.web_chat,
496
+ });
497
+ process.stderr.write(
498
+ `\n[athena-agent] 💬 Web chat ready → ${webchat.url}\n` +
499
+ `[athena-agent] Hermes is running headless (ACP). Chat in your browser.\n` +
500
+ `[athena-agent] Press Ctrl+C to stop.\n\n`,
501
+ );
502
+ const shutdown = () => {
503
+ webchat.close();
504
+ process.exit(0);
505
+ };
506
+ process.on("SIGINT", shutdown);
507
+ process.on("SIGTERM", shutdown);
508
+ return;
509
+ }
321
510
  }
322
511
 
323
512
  process.stderr.write(
@@ -0,0 +1,211 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>__TITLE__</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0b0d12; --bg2: #12151c; --bg3: #1a1f2a; --border: #232a36;
10
+ --fg: #e6e9ef; --fg2: #aab2c0; --fg3: #6b7385;
11
+ --accent: #6ea8fe; --accent-fg: #0b0d12;
12
+ --user: #1f2a3d; --tool: #15202e; --err: #ff6b6b;
13
+ }
14
+ :root[data-theme="light"] {
15
+ --bg: #f6f7f9; --bg2: #ffffff; --bg3: #eef1f5; --border: #dce1e8;
16
+ --fg: #1a1f2a; --fg2: #495060; --fg3: #8a91a0;
17
+ --accent: #2f6fed; --accent-fg: #ffffff;
18
+ --user: #e6eefc; --tool: #eef1f5; --err: #d23b3b;
19
+ }
20
+ * { box-sizing: border-box; }
21
+ html, body { height: 100%; margin: 0; }
22
+ body {
23
+ background: var(--bg); color: var(--fg);
24
+ font: 14px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
25
+ display: flex; flex-direction: column;
26
+ }
27
+ header {
28
+ display: flex; align-items: center; gap: 10px;
29
+ padding: 12px 18px; border-bottom: 1px solid var(--border); background: var(--bg2);
30
+ }
31
+ header .dot { width: 9px; height: 9px; border-radius: 50%; background: var(--fg3); flex: none; }
32
+ header .dot.on { background: #36d399; box-shadow: 0 0 8px #36d39988; }
33
+ header h1 { font-size: 14px; font-weight: 600; margin: 0; }
34
+ header .status { margin-left: auto; font-size: 12px; color: var(--fg3); }
35
+ #log { flex: 1; overflow-y: auto; padding: 22px 0; }
36
+ .wrap { max-width: 820px; margin: 0 auto; padding: 0 18px; }
37
+ .msg { margin: 14px 0; display: flex; gap: 12px; }
38
+ .msg .who { font-size: 11px; text-transform: uppercase; letter-spacing: .04em; color: var(--fg3); width: 64px; flex: none; padding-top: 3px; }
39
+ .msg .body { flex: 1; min-width: 0; white-space: pre-wrap; word-wrap: break-word; }
40
+ .msg.user .body { background: var(--user); border-radius: 10px; padding: 8px 12px; }
41
+ .tool { background: var(--tool); border: 1px solid var(--border); border-radius: 8px; padding: 8px 12px; margin: 8px 0; font-size: 13px; color: var(--fg2); }
42
+ .tool .t-title { font-weight: 600; color: var(--fg); }
43
+ .tool .t-status { font-size: 11px; color: var(--fg3); }
44
+ .perm { background: var(--bg3); border: 1px solid var(--accent); border-radius: 8px; padding: 12px; margin: 8px 0; }
45
+ .perm .q { margin-bottom: 8px; }
46
+ .perm button { margin-right: 8px; }
47
+ .err { color: var(--err); }
48
+ footer { border-top: 1px solid var(--border); background: var(--bg2); padding: 14px 18px; }
49
+ .composer { max-width: 820px; margin: 0 auto; display: flex; gap: 10px; }
50
+ textarea {
51
+ flex: 1; resize: none; background: var(--bg3); color: var(--fg);
52
+ border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px;
53
+ font: inherit; max-height: 160px; outline: none;
54
+ }
55
+ textarea:focus { border-color: var(--accent); }
56
+ button {
57
+ background: var(--accent); color: var(--accent-fg); border: 0; border-radius: 10px;
58
+ padding: 0 18px; font: inherit; font-weight: 600; cursor: pointer;
59
+ }
60
+ button:disabled { opacity: .5; cursor: default; }
61
+ button.ghost { background: var(--bg3); color: var(--fg); border: 1px solid var(--border); }
62
+ .blink::after { content: "▋"; animation: blink 1s step-start infinite; color: var(--fg3); }
63
+ @keyframes blink { 50% { opacity: 0; } }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <header>
68
+ <span class="dot" id="dot"></span>
69
+ <h1 id="title">__TITLE__</h1>
70
+ <span class="status" id="status">connecting…</span>
71
+ </header>
72
+ <div id="log"><div class="wrap" id="logwrap"></div></div>
73
+ <footer>
74
+ <div class="composer">
75
+ <textarea id="input" rows="1" placeholder="Message your agent…" autofocus></textarea>
76
+ <button id="send" disabled>Send</button>
77
+ </div>
78
+ </footer>
79
+ <script>
80
+ const CONFIG = __CONFIG__;
81
+ const log = document.getElementById("logwrap");
82
+ const input = document.getElementById("input");
83
+ const sendBtn = document.getElementById("send");
84
+ const dot = document.getElementById("dot");
85
+ const statusEl = document.getElementById("status");
86
+ const titleEl = document.getElementById("title");
87
+
88
+ titleEl.textContent = CONFIG.title || "Hermes Agent";
89
+ document.title = CONFIG.title || "Hermes Agent";
90
+ if (CONFIG.theme === "light" || CONFIG.theme === "dark") {
91
+ document.documentElement.setAttribute("data-theme", CONFIG.theme);
92
+ } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) {
93
+ document.documentElement.setAttribute("data-theme", "light");
94
+ }
95
+
96
+ let turnActive = false;
97
+ let curAssistant = null; // current streaming assistant .body element
98
+ const toolEls = new Map(); // toolCallId -> element
99
+
100
+ function scroll() { const l = document.getElementById("log"); l.scrollTop = l.scrollHeight; }
101
+
102
+ function addMsg(who, cls) {
103
+ const m = document.createElement("div");
104
+ m.className = "msg " + (cls || "");
105
+ const w = document.createElement("div"); w.className = "who"; w.textContent = who;
106
+ const b = document.createElement("div"); b.className = "body";
107
+ m.append(w, b); log.append(m); scroll();
108
+ return b;
109
+ }
110
+
111
+ function setBusy(b) {
112
+ turnActive = b;
113
+ sendBtn.disabled = b || !input.value.trim();
114
+ input.disabled = b;
115
+ statusEl.textContent = b ? "thinking…" : "ready";
116
+ }
117
+
118
+ if (CONFIG.welcome_message) addMsg("Agent", "").textContent = CONFIG.welcome_message;
119
+
120
+ // ---- streaming connection -------------------------------------------------
121
+ const es = new EventSource("/chat/stream");
122
+ es.onopen = () => { dot.classList.add("on"); statusEl.textContent = "ready"; sendBtn.disabled = !input.value.trim(); };
123
+ es.onerror = () => { dot.classList.remove("on"); statusEl.textContent = "disconnected"; };
124
+ es.onmessage = (e) => {
125
+ let ev; try { ev = JSON.parse(e.data); } catch { return; }
126
+ handle(ev);
127
+ };
128
+
129
+ function handle(ev) {
130
+ switch (ev.type) {
131
+ case "ready":
132
+ statusEl.textContent = "ready"; break;
133
+ case "assistant_chunk":
134
+ if (!curAssistant) { curAssistant = addMsg("Agent", ""); curAssistant.classList.add("blink"); }
135
+ curAssistant.textContent += ev.text; scroll(); break;
136
+ case "tool": {
137
+ const el = document.createElement("div");
138
+ el.className = "tool";
139
+ el.innerHTML = '<span class="t-title"></span> <span class="t-status"></span>';
140
+ el.querySelector(".t-title").textContent = ev.title || ev.id;
141
+ el.querySelector(".t-status").textContent = "· " + (ev.status || "pending");
142
+ log.append(el); toolEls.set(ev.id, el); scroll(); break;
143
+ }
144
+ case "tool_update": {
145
+ const el = toolEls.get(ev.id);
146
+ if (el) el.querySelector(".t-status").textContent = "· " + ev.status; break;
147
+ }
148
+ case "permission":
149
+ renderPermission(ev); break;
150
+ case "turn_done":
151
+ if (curAssistant) curAssistant.classList.remove("blink");
152
+ curAssistant = null; setBusy(false); input.focus(); break;
153
+ case "error": {
154
+ const b = addMsg("Error", ""); b.classList.add("err"); b.textContent = ev.message || "unknown error";
155
+ if (curAssistant) curAssistant.classList.remove("blink");
156
+ curAssistant = null; setBusy(false); break;
157
+ }
158
+ }
159
+ }
160
+
161
+ function renderPermission(ev) {
162
+ const el = document.createElement("div");
163
+ el.className = "perm";
164
+ const q = document.createElement("div"); q.className = "q";
165
+ q.textContent = "Allow tool: " + (ev.title || "tool") + "?";
166
+ el.append(q);
167
+ (ev.options || []).forEach((o) => {
168
+ const btn = document.createElement("button");
169
+ if (/reject|deny|no/i.test(o.kind || o.name || "")) btn.className = "ghost";
170
+ btn.textContent = o.name || o.optionId;
171
+ btn.onclick = () => {
172
+ fetch("/chat/permission", {
173
+ method: "POST", headers: { "content-type": "application/json" },
174
+ body: JSON.stringify({ requestId: ev.requestId, optionId: o.optionId }),
175
+ });
176
+ el.querySelectorAll("button").forEach((b) => (b.disabled = true));
177
+ q.textContent = "→ " + (o.name || o.optionId);
178
+ };
179
+ el.append(btn);
180
+ });
181
+ log.append(el); scroll();
182
+ }
183
+
184
+ // ---- composer -------------------------------------------------------------
185
+ function send() {
186
+ const text = input.value.trim();
187
+ if (!text || turnActive) return;
188
+ addMsg("You", "user").textContent = text;
189
+ input.value = ""; autosize();
190
+ setBusy(true);
191
+ fetch("/chat/send", {
192
+ method: "POST", headers: { "content-type": "application/json" },
193
+ body: JSON.stringify({ text }),
194
+ }).catch((err) => {
195
+ const b = addMsg("Error", ""); b.classList.add("err"); b.textContent = String(err);
196
+ setBusy(false);
197
+ });
198
+ }
199
+
200
+ function autosize() {
201
+ input.style.height = "auto";
202
+ input.style.height = Math.min(input.scrollHeight, 160) + "px";
203
+ }
204
+ input.addEventListener("input", () => { autosize(); if (!turnActive) sendBtn.disabled = !input.value.trim(); });
205
+ input.addEventListener("keydown", (e) => {
206
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); }
207
+ });
208
+ sendBtn.addEventListener("click", send);
209
+ </script>
210
+ </body>
211
+ </html>
@@ -0,0 +1,291 @@
1
+ // Web Chat bridge for Hermes agents.
2
+ //
3
+ // Spawns `hermes acp` (headless ACP stdio server), opens a localhost HTTP
4
+ // server that serves the chat UI and bridges browser ⇄ Hermes over the Agent
5
+ // Client Protocol, and opens the user's browser to it. Everything is local —
6
+ // there is no tunnel back to Athena.
7
+ //
8
+ // browser ──HTTP/SSE──► local server ──ACP JSON-RPC/stdio──► hermes acp
9
+ //
10
+ // Proven viable by services/launcher/scripts/acp-poc.mjs.
11
+
12
+ import { spawn } from "node:child_process";
13
+ import { createServer } from "node:http";
14
+ import { readFileSync } from "node:fs";
15
+ import { fileURLToPath } from "node:url";
16
+ import { dirname, join } from "node:path";
17
+
18
+ const __dirname = dirname(fileURLToPath(import.meta.url));
19
+ const UI_HTML = readFileSync(join(__dirname, "webchat-ui.html"), "utf8");
20
+
21
+ // ---- ACP client over a hermes acp child -----------------------------------
22
+ class AcpClient {
23
+ constructor(hermesBin, env) {
24
+ this.child = spawn(hermesBin, ["acp", "--accept-hooks"], {
25
+ stdio: ["pipe", "pipe", "inherit"],
26
+ env,
27
+ });
28
+ this.nextId = 1;
29
+ this.pending = new Map(); // jsonrpc id -> {resolve, reject}
30
+ this.permissionWaiters = new Map(); // requestId -> resolve(optionId|null)
31
+ this.sessionId = null;
32
+ this.buf = "";
33
+ // Event sink set by the server: (event) => void
34
+ this.onEvent = () => {};
35
+ // Permission policy: "auto" | "ask"
36
+ this.toolApproval = "auto";
37
+
38
+ this.child.stdout.on("data", (c) => this._onData(c));
39
+ this.child.on("exit", (code) =>
40
+ this.onEvent({ type: "error", message: `Hermes exited (code ${code})` }),
41
+ );
42
+ }
43
+
44
+ _send(obj) {
45
+ this.child.stdin.write(JSON.stringify(obj) + "\n");
46
+ }
47
+
48
+ _request(method, params) {
49
+ const id = this.nextId++;
50
+ this._send({ jsonrpc: "2.0", id, method, params });
51
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
52
+ }
53
+
54
+ _onData(chunk) {
55
+ this.buf += chunk.toString("utf8");
56
+ let nl;
57
+ while ((nl = this.buf.indexOf("\n")) >= 0) {
58
+ const line = this.buf.slice(0, nl).trim();
59
+ this.buf = this.buf.slice(nl + 1);
60
+ if (!line) continue;
61
+ let msg;
62
+ try {
63
+ msg = JSON.parse(line);
64
+ } catch {
65
+ continue; // non-JSON banner line on stdout — ignore
66
+ }
67
+ this._handle(msg);
68
+ }
69
+ }
70
+
71
+ _handle(msg) {
72
+ // Response to one of our requests.
73
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
74
+ const p = this.pending.get(msg.id);
75
+ if (p) {
76
+ this.pending.delete(msg.id);
77
+ if (msg.error) p.reject(new Error(JSON.stringify(msg.error)));
78
+ else p.resolve(msg.result);
79
+ }
80
+ return;
81
+ }
82
+ // Request from the agent that we must answer.
83
+ if (msg.id !== undefined && msg.method) {
84
+ this._onAgentRequest(msg);
85
+ return;
86
+ }
87
+ // Notification.
88
+ if (msg.method === "session/update") this._onUpdate(msg.params?.update);
89
+ }
90
+
91
+ _onUpdate(u) {
92
+ if (!u) return;
93
+ switch (u.sessionUpdate) {
94
+ case "agent_message_chunk":
95
+ if (u.content?.text) this.onEvent({ type: "assistant_chunk", text: u.content.text });
96
+ break;
97
+ case "tool_call":
98
+ this.onEvent({
99
+ type: "tool",
100
+ id: u.toolCallId,
101
+ title: u.title || u.toolCallId,
102
+ status: u.status || "pending",
103
+ });
104
+ break;
105
+ case "tool_call_update":
106
+ if (u.status)
107
+ this.onEvent({ type: "tool_update", id: u.toolCallId, status: u.status });
108
+ break;
109
+ default:
110
+ break; // agent_thought_chunk, plan, etc. — ignored for now
111
+ }
112
+ }
113
+
114
+ async _onAgentRequest(msg) {
115
+ if (msg.method === "session/request_permission") {
116
+ const opts = msg.params?.options || [];
117
+ const title = msg.params?.toolCall?.title || msg.params?.toolCall?.toolCallId || "tool";
118
+ if (this.toolApproval === "auto") {
119
+ const allow = opts.find((o) => /allow/i.test(o.kind || o.optionId || "")) || opts[0];
120
+ this._send({
121
+ jsonrpc: "2.0",
122
+ id: msg.id,
123
+ result: { outcome: allow ? { outcome: "selected", optionId: allow.optionId } : { outcome: "cancelled" } },
124
+ });
125
+ return;
126
+ }
127
+ // ask: surface to the browser and wait for a decision.
128
+ const requestId = String(msg.id);
129
+ this.onEvent({ type: "permission", requestId, title, options: opts });
130
+ const optionId = await new Promise((resolve) => this.permissionWaiters.set(requestId, resolve));
131
+ this._send({
132
+ jsonrpc: "2.0",
133
+ id: msg.id,
134
+ result: { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } },
135
+ });
136
+ return;
137
+ }
138
+ // fs/* and terminal/* — we declared no such capabilities; decline cleanly.
139
+ this._send({
140
+ jsonrpc: "2.0",
141
+ id: msg.id,
142
+ error: { code: -32601, message: `web chat does not implement ${msg.method}` },
143
+ });
144
+ }
145
+
146
+ resolvePermission(requestId, optionId) {
147
+ const w = this.permissionWaiters.get(requestId);
148
+ if (w) {
149
+ this.permissionWaiters.delete(requestId);
150
+ w(optionId || null);
151
+ }
152
+ }
153
+
154
+ async init(cwd) {
155
+ await this._request("initialize", {
156
+ protocolVersion: 1,
157
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
158
+ clientInfo: { name: "athena-web-chat", title: "Athena Web Chat", version: "0.1.0" },
159
+ });
160
+ const ns = await this._request("session/new", { cwd, mcpServers: [] });
161
+ this.sessionId = ns?.sessionId;
162
+ return this.sessionId;
163
+ }
164
+
165
+ async prompt(text) {
166
+ return this._request("session/prompt", {
167
+ sessionId: this.sessionId,
168
+ prompt: [{ type: "text", text }],
169
+ });
170
+ }
171
+
172
+ kill() {
173
+ try { this.child.kill("SIGTERM"); } catch {}
174
+ }
175
+ }
176
+
177
+ function openBrowser(url) {
178
+ const cmd =
179
+ process.platform === "darwin" ? "open" :
180
+ process.platform === "win32" ? "cmd" : "xdg-open";
181
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
182
+ try {
183
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
184
+ } catch {
185
+ /* best-effort */
186
+ }
187
+ }
188
+
189
+ function readBody(req) {
190
+ return new Promise((resolve) => {
191
+ let data = "";
192
+ req.on("data", (c) => (data += c));
193
+ req.on("end", () => {
194
+ try { resolve(JSON.parse(data || "{}")); } catch { resolve({}); }
195
+ });
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Start the web chat. Resolves once the server is listening.
201
+ * @returns {Promise<{url: string, close: () => void}>}
202
+ */
203
+ export async function startWebChat({ hermesBin, env, hermesHome, webChat }) {
204
+ const acp = new AcpClient(hermesBin, env);
205
+ acp.toolApproval = webChat.tool_approval === "ask" ? "ask" : "auto";
206
+
207
+ process.stderr.write("[athena-agent] starting Hermes in ACP mode for web chat…\n");
208
+ await acp.init(hermesHome);
209
+ process.stderr.write(`[athena-agent] ACP session ready (${acp.sessionId})\n`);
210
+
211
+ const sseClients = new Set();
212
+ let turnActive = false;
213
+ acp.onEvent = (event) => {
214
+ const line = `data: ${JSON.stringify(event)}\n\n`;
215
+ for (const res of sseClients) res.write(line);
216
+ };
217
+
218
+ const page = UI_HTML
219
+ .replaceAll("__TITLE__", (webChat.title || "Hermes Agent").replace(/[<>]/g, ""))
220
+ .replace("__CONFIG__", JSON.stringify({
221
+ title: webChat.title || "Hermes Agent",
222
+ welcome_message: webChat.welcome_message || "",
223
+ theme: webChat.theme || "auto",
224
+ tool_approval: acp.toolApproval,
225
+ }));
226
+
227
+ const server = createServer(async (req, res) => {
228
+ const url = (req.url || "/").split("?")[0];
229
+
230
+ if (req.method === "GET" && url === "/") {
231
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
232
+ res.end(page);
233
+ return;
234
+ }
235
+
236
+ if (req.method === "GET" && url === "/chat/stream") {
237
+ res.writeHead(200, {
238
+ "content-type": "text/event-stream",
239
+ "cache-control": "no-cache, no-transform",
240
+ connection: "keep-alive",
241
+ });
242
+ res.write(`data: ${JSON.stringify({ type: "ready" })}\n\n`);
243
+ sseClients.add(res);
244
+ req.on("close", () => sseClients.delete(res));
245
+ return;
246
+ }
247
+
248
+ if (req.method === "POST" && url === "/chat/send") {
249
+ const body = await readBody(req);
250
+ const text = typeof body.text === "string" ? body.text.trim() : "";
251
+ if (!text) { res.writeHead(400).end('{"error":"text required"}'); return; }
252
+ if (turnActive) { res.writeHead(409).end('{"error":"a turn is already in progress"}'); return; }
253
+ turnActive = true;
254
+ res.writeHead(202, { "content-type": "application/json" });
255
+ res.end('{"ok":true}');
256
+ acp.prompt(text)
257
+ .then((r) => acp.onEvent({ type: "turn_done", stopReason: r?.stopReason || "end_turn" }))
258
+ .catch((err) => acp.onEvent({ type: "error", message: err.message }))
259
+ .finally(() => { turnActive = false; });
260
+ return;
261
+ }
262
+
263
+ if (req.method === "POST" && url === "/chat/permission") {
264
+ const body = await readBody(req);
265
+ acp.resolvePermission(String(body.requestId), body.optionId);
266
+ res.writeHead(200, { "content-type": "application/json" });
267
+ res.end('{"ok":true}');
268
+ return;
269
+ }
270
+
271
+ res.writeHead(404).end("not found");
272
+ });
273
+
274
+ const preferred = webChat.preferred_port;
275
+ await new Promise((resolve, reject) => {
276
+ server.once("error", reject);
277
+ server.listen(Number.isFinite(preferred) && preferred ? preferred : 0, "127.0.0.1", resolve);
278
+ });
279
+
280
+ const port = server.address().port;
281
+ const chatUrl = `http://127.0.0.1:${port}`;
282
+
283
+ function close() {
284
+ acp.kill();
285
+ server.close();
286
+ }
287
+ acp.child.on("exit", () => server.close());
288
+
289
+ if (webChat.auto_open_browser !== false) openBrowser(chatUrl);
290
+ return { url: chatUrl, close };
291
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "athena-agent-launcher",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run an Athena-configured agent locally. Resolves runtime + bindings from the Athena control plane and spawns the correct agent binary (Anthropic SDK, OpenClaw, or Hermes).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
  },
9
9
  "files": [
10
10
  "bin",
11
+ "lib",
11
12
  "README.md"
12
13
  ],
13
14
  "engines": {