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 +4 -4
- package/bin/launcher.mjs +193 -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
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
252
|
-
|
|
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>
|
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.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": {
|