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 +4 -4
- package/bin/launcher.mjs +92 -4
- package/lib/webchat-ui.html +211 -0
- package/lib/webchat.mjs +291 -0
- package/package.json +2 -1
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
|
-
#
|
|
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
|
-
|
|
235
|
-
|
|
235
|
+
let creds = loadCredsFile();
|
|
236
|
+
let baseUrl =
|
|
236
237
|
args.baseUrl || process.env.ATHENA_BASE_URL || creds.base_url || DEFAULT_BASE_URL;
|
|
237
|
-
|
|
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
|
-
|
|
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>
|
package/lib/webchat.mjs
ADDED
|
@@ -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.
|
|
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": {
|