athena-agent-launcher 0.1.0 → 0.2.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
 
@@ -231,10 +232,23 @@ async function main() {
231
232
  }
232
233
 
233
234
  const agentId = args._[1];
234
- const creds = loadCredsFile();
235
- const baseUrl =
235
+ let creds = loadCredsFile();
236
+ let baseUrl =
236
237
  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;
238
+ let apiKey = args.apiKey || process.env.ATHENA_API_KEY || creds.api_key;
239
+
240
+ // First-run UX: if no credentials are available, drop straight into the
241
+ // setup prompt instead of bailing with an error. Skip when --api-key was
242
+ // passed explicitly (caller is scripting and supplied creds inline).
243
+ if (!apiKey && !args.apiKey) {
244
+ process.stderr.write(
245
+ "[athena-agent] no credentials found — running first-time setup.\n",
246
+ );
247
+ await runSetup();
248
+ creds = loadCredsFile();
249
+ baseUrl = creds.base_url || baseUrl;
250
+ apiKey = creds.api_key;
251
+ }
238
252
 
239
253
  if (!apiKey) {
240
254
  process.stderr.write(
@@ -248,7 +262,16 @@ async function main() {
248
262
  try {
249
263
  manifest = await fetchManifest({ baseUrl, agentId, apiKey });
250
264
  } catch (err) {
251
- process.stderr.write(`${err.message}\n`);
265
+ // `fetch failed` from undici is opaque on its own — the real cause
266
+ // (ENOTFOUND, ECONNREFUSED, TLS, etc.) lives on err.cause.
267
+ const cause = err?.cause
268
+ ? ` (${err.cause.code || err.cause.message || err.cause})`
269
+ : "";
270
+ process.stderr.write(
271
+ `[athena-agent] ${err.message}${cause}\n` +
272
+ ` base URL: ${baseUrl}\n` +
273
+ ` agent: ${agentId}\n`,
274
+ );
252
275
  process.exit(1);
253
276
  }
254
277
 
@@ -312,12 +335,77 @@ async function main() {
312
335
 
313
336
  const anthropicBase =
314
337
  manifest.env?.ATHENA_ANTHROPIC_URL || manifest.gateway_url;
338
+
339
+ // Hermes credential precedence: HERMES_HOME/auth.json > ~/.hermes/auth.json
340
+ // > env vars. We write an agent-scoped auth.json with the ak_ key at
341
+ // priority 100 so it beats any global OAuth credential (Claude Code's
342
+ // sk-ant-oat01 token lives in ~/.hermes/auth.json at priority 0 and would
343
+ // otherwise silently override everything we set in .env or the environment).
344
+ const authJson = {
345
+ version: 1,
346
+ providers: {},
347
+ active_provider: null,
348
+ updated_at: new Date().toISOString(),
349
+ credential_pool: {
350
+ anthropic: [
351
+ {
352
+ id: "athena-agent",
353
+ label: "athena_ak",
354
+ auth_type: "api_key",
355
+ priority: 100,
356
+ source: "athena",
357
+ api_key: apiKey,
358
+ base_url: anthropicBase,
359
+ },
360
+ ],
361
+ },
362
+ };
363
+ const authPath = join(hermesHome, "auth.json");
364
+ writeFileSync(authPath, JSON.stringify(authJson, null, 2), "utf8");
365
+ chmodSync(authPath, 0o600);
366
+
367
+ // Also write .env for belt-and-suspenders (some Hermes versions read this
368
+ // before auth.json for base URL; others do not — cover both).
369
+ const dotenvPath = join(hermesHome, ".env");
370
+ writeFileSync(
371
+ dotenvPath,
372
+ `ANTHROPIC_API_KEY=${apiKey}\nANTHROPIC_BASE_URL=${anthropicBase}\nANTHROPIC_TOKEN=\n`,
373
+ "utf8",
374
+ );
375
+ chmodSync(dotenvPath, 0o600);
376
+ process.stderr.write(`[athena-agent] wrote auth to ${hermesHome}/{auth.json,.env}\n`);
377
+
315
378
  env = {
316
379
  ...env,
317
380
  HERMES_HOME: hermesHome,
318
381
  ANTHROPIC_BASE_URL: anthropicBase,
319
382
  ANTHROPIC_API_KEY: apiKey,
383
+ ANTHROPIC_TOKEN: "",
320
384
  };
385
+
386
+ // Web Chat gateway: run Hermes headless in ACP mode behind a local browser
387
+ // chat instead of the interactive terminal TUI. startWebChat owns the
388
+ // hermes child + local server and keeps the process alive.
389
+ if (manifest.web_chat?.enabled) {
390
+ const webchat = await startWebChat({
391
+ hermesBin: cmd,
392
+ env,
393
+ hermesHome,
394
+ webChat: manifest.web_chat,
395
+ });
396
+ process.stderr.write(
397
+ `\n[athena-agent] 💬 Web chat ready → ${webchat.url}\n` +
398
+ `[athena-agent] Hermes is running headless (ACP). Chat in your browser.\n` +
399
+ `[athena-agent] Press Ctrl+C to stop.\n\n`,
400
+ );
401
+ const shutdown = () => {
402
+ webchat.close();
403
+ process.exit(0);
404
+ };
405
+ process.on("SIGINT", shutdown);
406
+ process.on("SIGTERM", shutdown);
407
+ return;
408
+ }
321
409
  }
322
410
 
323
411
  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.2.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": {