athena-agent-launcher 0.3.2 → 0.3.4
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/bin/launcher.mjs +38 -1
- package/lib/acp-client.mjs +235 -0
- package/lib/hosted-gateway.mjs +370 -0
- package/lib/webchat-ui.html +15 -1
- package/lib/webchat.mjs +4 -155
- package/package.json +1 -1
package/bin/launcher.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import { homedir } from "node:os";
|
|
|
15
15
|
import { join, dirname } from "node:path";
|
|
16
16
|
import readline from "node:readline";
|
|
17
17
|
import { startWebChat } from "../lib/webchat.mjs";
|
|
18
|
+
import { startHostedGateway } from "../lib/hosted-gateway.mjs";
|
|
18
19
|
|
|
19
20
|
const DEFAULT_BASE_URL = "https://athena-v2-pi.vercel.app";
|
|
20
21
|
|
|
@@ -472,8 +473,13 @@ async function main() {
|
|
|
472
473
|
const hermesHome = join(homedir(), ".athena", "hermes", agentId);
|
|
473
474
|
mkdirSync(hermesHome, { recursive: true });
|
|
474
475
|
|
|
476
|
+
// Tell the config route which launch mode this is. Web chat (ACP) is an
|
|
477
|
+
// attended/interactive session, so it identifies itself differently from an
|
|
478
|
+
// unattended TUI/headless loop — this lets Cortex's protection layer treat
|
|
479
|
+
// human-driven web-chat tool calls distinctly. See hermes-config.ts.
|
|
480
|
+
const launchMode = manifest.web_chat?.enabled ? "web_chat" : "cli";
|
|
475
481
|
const cfgRes = await fetch(
|
|
476
|
-
`${baseUrl}/api/agents/${agentId}/hermes-config`,
|
|
482
|
+
`${baseUrl}/api/agents/${agentId}/hermes-config?mode=${encodeURIComponent(launchMode)}`,
|
|
477
483
|
{ headers: { authorization: `Bearer ${apiKey}` } },
|
|
478
484
|
);
|
|
479
485
|
if (!cfgRes.ok) {
|
|
@@ -539,6 +545,37 @@ async function main() {
|
|
|
539
545
|
ANTHROPIC_TOKEN: "",
|
|
540
546
|
};
|
|
541
547
|
|
|
548
|
+
// Hosted gateway (DAE-6): unattended container mode. Run Hermes headless in
|
|
549
|
+
// ACP mode behind an internet-facing HTTP API (/health, /message) that the
|
|
550
|
+
// Athena Teams turn route calls — NOT the interactive TUI (which exits with
|
|
551
|
+
// "Input is not a terminal (fd=0)" in a container and crash-loops).
|
|
552
|
+
// Selected by manifest.hosted_gateway.enabled, or by the container setting
|
|
553
|
+
// ATHENA_HOSTED_GATEWAY_ENABLED=1 (so the ACA can opt in via env even before
|
|
554
|
+
// the manifest route carries the flag). Takes precedence over web chat.
|
|
555
|
+
const hostedGatewayEnabled =
|
|
556
|
+
manifest.hosted_gateway?.enabled === true ||
|
|
557
|
+
/^(1|true|yes)$/i.test(env.ATHENA_HOSTED_GATEWAY_ENABLED || "");
|
|
558
|
+
if (hostedGatewayEnabled) {
|
|
559
|
+
const gw = await startHostedGateway({
|
|
560
|
+
hermesBin: cmd,
|
|
561
|
+
env,
|
|
562
|
+
hermesHome,
|
|
563
|
+
hostedGateway: manifest.hosted_gateway || {},
|
|
564
|
+
});
|
|
565
|
+
process.stderr.write(
|
|
566
|
+
`\n[athena-agent] 🛰 Hosted gateway listening on ${gw.url}\n` +
|
|
567
|
+
`[athena-agent] Hermes is running headless (ACP). POST /message to drive turns.\n` +
|
|
568
|
+
`[athena-agent] Press Ctrl+C to stop.\n\n`,
|
|
569
|
+
);
|
|
570
|
+
const shutdown = () => {
|
|
571
|
+
gw.close();
|
|
572
|
+
process.exit(0);
|
|
573
|
+
};
|
|
574
|
+
process.on("SIGINT", shutdown);
|
|
575
|
+
process.on("SIGTERM", shutdown);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
|
|
542
579
|
// Web Chat gateway: run Hermes headless in ACP mode behind a local browser
|
|
543
580
|
// chat instead of the interactive terminal TUI. startWebChat owns the
|
|
544
581
|
// hermes child + local server and keeps the process alive.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Shared ACP client for Hermes runtimes.
|
|
2
|
+
//
|
|
3
|
+
// Speaks the Agent Client Protocol (newline-delimited JSON-RPC 2.0) to a
|
|
4
|
+
// `hermes acp --accept-hooks` child over stdio. Extracted verbatim from
|
|
5
|
+
// webchat.mjs (DAE-6) so both the attended web-chat bridge and the unattended
|
|
6
|
+
// hosted gateway share one battle-tested transport.
|
|
7
|
+
//
|
|
8
|
+
// client ──ACP JSON-RPC/stdio──► hermes acp
|
|
9
|
+
//
|
|
10
|
+
// Two usage shapes are supported:
|
|
11
|
+
// 1. Single-session (web chat): `await acp.init(cwd)` then `acp.prompt(text)`.
|
|
12
|
+
// Backwards-compatible with the original webchat.mjs API.
|
|
13
|
+
// 2. Multi-session (hosted gateway): `await acp.initialize()` once, then
|
|
14
|
+
// `acp.newSession(cwd)` per logical session and `acp.prompt(text, sid)`.
|
|
15
|
+
//
|
|
16
|
+
// Every emitted event carries `sessionId` so a multi-session caller can route
|
|
17
|
+
// chunks to the right turn. Single-session callers can ignore it.
|
|
18
|
+
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
|
|
21
|
+
export class AcpClient {
|
|
22
|
+
constructor(hermesBin, env) {
|
|
23
|
+
this.child = spawn(hermesBin, ["acp", "--accept-hooks"], {
|
|
24
|
+
stdio: ["pipe", "pipe", "inherit"],
|
|
25
|
+
env,
|
|
26
|
+
});
|
|
27
|
+
this.nextId = 1;
|
|
28
|
+
this.pending = new Map(); // jsonrpc id -> {resolve, reject}
|
|
29
|
+
this.permissionWaiters = new Map(); // requestId -> resolve(optionId|null)
|
|
30
|
+
this.sessionId = null; // single-session convenience slot
|
|
31
|
+
this.buf = "";
|
|
32
|
+
// Event sink set by the server: (event) => void. Events include sessionId.
|
|
33
|
+
this.onEvent = () => {};
|
|
34
|
+
// Permission policy: "auto" | "ask". Unattended hosts MUST use "auto" —
|
|
35
|
+
// there is no human at the ACP layer to answer a permission prompt; the
|
|
36
|
+
// consent gate lives upstream (Teams turn route + hosted-start gate).
|
|
37
|
+
this.toolApproval = "auto";
|
|
38
|
+
|
|
39
|
+
this.child.stdout.on("data", (c) => this._onData(c));
|
|
40
|
+
this.child.on("exit", (code) =>
|
|
41
|
+
this.onEvent({ type: "error", message: `Hermes exited (code ${code})` }),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_send(obj) {
|
|
46
|
+
this.child.stdin.write(JSON.stringify(obj) + "\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_request(method, params) {
|
|
50
|
+
const id = this.nextId++;
|
|
51
|
+
this._send({ jsonrpc: "2.0", id, method, params });
|
|
52
|
+
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
_onData(chunk) {
|
|
56
|
+
this.buf += chunk.toString("utf8");
|
|
57
|
+
let nl;
|
|
58
|
+
while ((nl = this.buf.indexOf("\n")) >= 0) {
|
|
59
|
+
const line = this.buf.slice(0, nl).trim();
|
|
60
|
+
this.buf = this.buf.slice(nl + 1);
|
|
61
|
+
if (!line) continue;
|
|
62
|
+
let msg;
|
|
63
|
+
try {
|
|
64
|
+
msg = JSON.parse(line);
|
|
65
|
+
} catch {
|
|
66
|
+
continue; // non-JSON banner line on stdout — ignore
|
|
67
|
+
}
|
|
68
|
+
this._handle(msg);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_handle(msg) {
|
|
73
|
+
// Response to one of our requests.
|
|
74
|
+
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
75
|
+
const p = this.pending.get(msg.id);
|
|
76
|
+
if (p) {
|
|
77
|
+
this.pending.delete(msg.id);
|
|
78
|
+
if (msg.error) p.reject(new Error(JSON.stringify(msg.error)));
|
|
79
|
+
else p.resolve(msg.result);
|
|
80
|
+
}
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Request from the agent that we must answer.
|
|
84
|
+
if (msg.id !== undefined && msg.method) {
|
|
85
|
+
this._onAgentRequest(msg);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
// Notification.
|
|
89
|
+
if (msg.method === "session/update") {
|
|
90
|
+
this._onUpdate(msg.params?.update, msg.params?.sessionId);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_onUpdate(u, sessionId) {
|
|
95
|
+
if (!u) return;
|
|
96
|
+
switch (u.sessionUpdate) {
|
|
97
|
+
case "agent_message_chunk":
|
|
98
|
+
if (u.content?.text)
|
|
99
|
+
this.onEvent({ type: "assistant_chunk", text: u.content.text, sessionId });
|
|
100
|
+
break;
|
|
101
|
+
case "tool_call":
|
|
102
|
+
this.onEvent({
|
|
103
|
+
type: "tool",
|
|
104
|
+
id: u.toolCallId,
|
|
105
|
+
title: u.title || u.toolCallId,
|
|
106
|
+
status: u.status || "pending",
|
|
107
|
+
sessionId,
|
|
108
|
+
});
|
|
109
|
+
break;
|
|
110
|
+
case "tool_call_update": {
|
|
111
|
+
const { isError, detail } = extractToolOutcome(u);
|
|
112
|
+
// A blocked/failed MCP call often arrives as status "completed" carrying
|
|
113
|
+
// an error *result* (e.g. the Cortex protection layer returns a JSON
|
|
114
|
+
// error body, not an ACP failure). Forward the extracted error so the
|
|
115
|
+
// UI doesn't render a blocked push as a green checkmark.
|
|
116
|
+
this.onEvent({
|
|
117
|
+
type: "tool_update",
|
|
118
|
+
id: u.toolCallId,
|
|
119
|
+
status: u.status || (isError ? "failed" : "completed"),
|
|
120
|
+
isError,
|
|
121
|
+
detail: isError ? detail : "",
|
|
122
|
+
sessionId,
|
|
123
|
+
});
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
break; // agent_thought_chunk, plan, etc. — ignored for now
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async _onAgentRequest(msg) {
|
|
132
|
+
if (msg.method === "session/request_permission") {
|
|
133
|
+
const opts = msg.params?.options || [];
|
|
134
|
+
const title = msg.params?.toolCall?.title || msg.params?.toolCall?.toolCallId || "tool";
|
|
135
|
+
if (this.toolApproval === "auto") {
|
|
136
|
+
const allow = opts.find((o) => /allow/i.test(o.kind || o.optionId || "")) || opts[0];
|
|
137
|
+
this._send({
|
|
138
|
+
jsonrpc: "2.0",
|
|
139
|
+
id: msg.id,
|
|
140
|
+
result: { outcome: allow ? { outcome: "selected", optionId: allow.optionId } : { outcome: "cancelled" } },
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// ask: surface to the browser and wait for a decision.
|
|
145
|
+
const requestId = String(msg.id);
|
|
146
|
+
this.onEvent({ type: "permission", requestId, title, options: opts, sessionId: msg.params?.sessionId });
|
|
147
|
+
const optionId = await new Promise((resolve) => this.permissionWaiters.set(requestId, resolve));
|
|
148
|
+
this._send({
|
|
149
|
+
jsonrpc: "2.0",
|
|
150
|
+
id: msg.id,
|
|
151
|
+
result: { outcome: optionId ? { outcome: "selected", optionId } : { outcome: "cancelled" } },
|
|
152
|
+
});
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// fs/* and terminal/* — we declared no such capabilities; decline cleanly.
|
|
156
|
+
this._send({
|
|
157
|
+
jsonrpc: "2.0",
|
|
158
|
+
id: msg.id,
|
|
159
|
+
error: { code: -32601, message: `client does not implement ${msg.method}` },
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
resolvePermission(requestId, optionId) {
|
|
164
|
+
const w = this.permissionWaiters.get(requestId);
|
|
165
|
+
if (w) {
|
|
166
|
+
this.permissionWaiters.delete(requestId);
|
|
167
|
+
w(optionId || null);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Send the ACP `initialize` handshake. Call once per child. */
|
|
172
|
+
async initialize() {
|
|
173
|
+
await this._request("initialize", {
|
|
174
|
+
protocolVersion: 1,
|
|
175
|
+
clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
|
|
176
|
+
clientInfo: { name: "athena-acp-client", title: "Athena ACP Client", version: "0.1.0" },
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Create a new ACP session; returns its sessionId. */
|
|
181
|
+
async newSession(cwd) {
|
|
182
|
+
const ns = await this._request("session/new", { cwd, mcpServers: [] });
|
|
183
|
+
return ns?.sessionId;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Single-session convenience: initialize + one session, stored on this.sessionId. */
|
|
187
|
+
async init(cwd) {
|
|
188
|
+
await this.initialize();
|
|
189
|
+
this.sessionId = await this.newSession(cwd);
|
|
190
|
+
return this.sessionId;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Drive a turn. `sessionId` defaults to the single-session slot. Resolves
|
|
194
|
+
* with the ACP session/prompt result ({ stopReason }) when the turn ends. */
|
|
195
|
+
async prompt(text, sessionId) {
|
|
196
|
+
return this._request("session/prompt", {
|
|
197
|
+
sessionId: sessionId ?? this.sessionId,
|
|
198
|
+
prompt: [{ type: "text", text }],
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
kill() {
|
|
203
|
+
try { this.child.kill("SIGTERM"); } catch {}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Pull human-readable output and an error flag out of an ACP tool_call_update.
|
|
208
|
+
// ACP reports genuine failures as status "failed", but MCP tools that return an
|
|
209
|
+
// error *result* (isError, or a JSON {error|success:false} body) are reported
|
|
210
|
+
// "completed" — sniff those so blocked calls surface as failures.
|
|
211
|
+
export function extractToolOutcome(u) {
|
|
212
|
+
let text = "";
|
|
213
|
+
for (const part of Array.isArray(u.content) ? u.content : []) {
|
|
214
|
+
const c = part?.content || part;
|
|
215
|
+
if (c?.type === "text" && typeof c.text === "string") text += c.text;
|
|
216
|
+
}
|
|
217
|
+
let isError = u.status === "failed";
|
|
218
|
+
const raw = u.rawOutput;
|
|
219
|
+
if (raw && typeof raw === "object") {
|
|
220
|
+
if (raw.isError === true) isError = true;
|
|
221
|
+
if (!text && Array.isArray(raw.content)) {
|
|
222
|
+
for (const c of raw.content) if (c?.type === "text" && c.text) text += c.text;
|
|
223
|
+
}
|
|
224
|
+
if (!text && typeof raw.error === "string") text = raw.error;
|
|
225
|
+
}
|
|
226
|
+
if (!isError && text) {
|
|
227
|
+
try {
|
|
228
|
+
const j = JSON.parse(text);
|
|
229
|
+
if (j && (j.error || j.success === false)) isError = true;
|
|
230
|
+
} catch {
|
|
231
|
+
/* not JSON — treat as a normal text result */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return { isError, detail: text.slice(0, 2000) };
|
|
235
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
// Hosted gateway for Hermes agents (DAE-6).
|
|
2
|
+
//
|
|
3
|
+
// Unattended counterpart to webchat.mjs. Where web chat runs Hermes behind a
|
|
4
|
+
// LOCAL browser UI for an attended user, the hosted gateway runs Hermes inside
|
|
5
|
+
// a container (Azure Container App `agent003`) and exposes a small internet-
|
|
6
|
+
// facing HTTP API that the Athena control plane's Teams turn route calls.
|
|
7
|
+
//
|
|
8
|
+
// Athena /api/gateway/teams/turn ──HTTP /message──► hosted-gateway ──ACP──► hermes acp
|
|
9
|
+
//
|
|
10
|
+
// It does NOT spawn the interactive Hermes TUI (which exits with
|
|
11
|
+
// "Input is not a terminal (fd=0)" in a container and crash-loops). It drives
|
|
12
|
+
// `hermes acp --accept-hooks` via the shared AcpClient instead.
|
|
13
|
+
//
|
|
14
|
+
// Endpoints (see docs/design/teams-hermes-bridge.md for the full contract):
|
|
15
|
+
// GET /health → 200 {"ok":true} (no auth, no agent logic)
|
|
16
|
+
// POST /message {session_id, text} → 200 {"text": "..."}
|
|
17
|
+
//
|
|
18
|
+
// Auth on /message — the locked DAE-6 / THE-5 contract (D1):
|
|
19
|
+
// Authorization: Bearer $HERMES_GATEWAY_SECRET (timing-safe — M1)
|
|
20
|
+
// X-Hermes-Timestamp: <unix-ms> (≤60s skew — M2)
|
|
21
|
+
// X-Hermes-Signature: HMAC-SHA256(`${ts}.${rawBody}`, secret) hex (M2)
|
|
22
|
+
// X-Request-Id: <uuid> (Prometheus-minted; echoed in our logs — M7)
|
|
23
|
+
// Plus a nonce/replay cache (M2), a token-bucket rate limit and a hard
|
|
24
|
+
// max-concurrency (M6, default 1 for the pilot). If the secret env var is UNSET
|
|
25
|
+
// the endpoint refuses to bring up agent logic (M1). All error bodies are
|
|
26
|
+
// generic (no secret/body/upstream detail — M7). /health is unauthenticated by
|
|
27
|
+
// design (Azure liveness/readiness probes) and runs no agent logic.
|
|
28
|
+
|
|
29
|
+
import { createServer } from "node:http";
|
|
30
|
+
import { timingSafeEqual, createHmac, randomUUID } from "node:crypto";
|
|
31
|
+
import { AcpClient } from "./acp-client.mjs";
|
|
32
|
+
|
|
33
|
+
const DEFAULT_PORT = 8080;
|
|
34
|
+
const SESSION_IDLE_MS = 30 * 60 * 1000; // evict ACP sessions idle > 30 min
|
|
35
|
+
const SESSION_SWEEP_MS = 5 * 60 * 1000; // sweep cadence
|
|
36
|
+
const TURN_TIMEOUT_MS = 120_000; // hard cap on a single turn
|
|
37
|
+
const SKEW_MS = 60_000; // M2: max clock skew for the HMAC timestamp
|
|
38
|
+
const REPLAY_TTL_MS = 120_000; // M2: nonce cache window (> skew)
|
|
39
|
+
const DEFAULT_RATE_PER_MIN = 30; // M6: token-bucket refill
|
|
40
|
+
const DEFAULT_MAX_CONCURRENCY = 1; // M6: in-flight cap (pilot = 1) — load-bearing for arm custody, do NOT raise
|
|
41
|
+
const BUSY_RETRY_AFTER_MS = 2000; // DAE-16: hint the poller waits before retrying a busy container
|
|
42
|
+
|
|
43
|
+
function sendJson(res, status, obj, headers) {
|
|
44
|
+
const body = JSON.stringify(obj);
|
|
45
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", ...(headers || {}) });
|
|
46
|
+
res.end(body);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Read the raw request body as a string (needed verbatim for HMAC). */
|
|
50
|
+
function readRawBody(req) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
let data = "";
|
|
53
|
+
req.on("data", (c) => (data += c));
|
|
54
|
+
req.on("end", () => resolve(data));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Structured one-line log with the correlation id (M7). Never logs secrets,
|
|
59
|
+
* signatures, the Authorization header, or request bodies. */
|
|
60
|
+
function logEvent(o) {
|
|
61
|
+
try { process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), ...o }) + "\n"); } catch {}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Constant-time bearer check (M1). Unset secret → false (fail closed). */
|
|
65
|
+
function bearerOk(req, secret) {
|
|
66
|
+
if (!secret) return false;
|
|
67
|
+
const auth = req.headers["authorization"] || "";
|
|
68
|
+
const token = auth.toLowerCase().startsWith("bearer ") ? auth.slice(7).trim() : "";
|
|
69
|
+
const a = Buffer.from(token);
|
|
70
|
+
const b = Buffer.from(secret);
|
|
71
|
+
if (a.length !== b.length) return false;
|
|
72
|
+
try { return timingSafeEqual(a, b); } catch { return false; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Verify the HMAC request signature (M2): signature == HMAC-SHA256 over
|
|
76
|
+
* `${X-Hermes-Timestamp}.${rawBody}`, within ±SKEW_MS. Timing-safe compare.
|
|
77
|
+
* Returns { ok, reason?, sig? }. `sig` is the verified signature (used as the
|
|
78
|
+
* replay nonce). */
|
|
79
|
+
function hmacOk(req, rawBody, secret) {
|
|
80
|
+
const ts = req.headers["x-hermes-timestamp"];
|
|
81
|
+
const sig = req.headers["x-hermes-signature"];
|
|
82
|
+
if (!ts || !sig || typeof ts !== "string" || typeof sig !== "string") {
|
|
83
|
+
return { ok: false, reason: "missing_signature" };
|
|
84
|
+
}
|
|
85
|
+
const tsNum = Number(ts);
|
|
86
|
+
if (!Number.isFinite(tsNum)) return { ok: false, reason: "bad_timestamp" };
|
|
87
|
+
if (Math.abs(Date.now() - tsNum) > SKEW_MS) return { ok: false, reason: "timestamp_skew" };
|
|
88
|
+
const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
|
|
89
|
+
const a = Buffer.from(sig);
|
|
90
|
+
const b = Buffer.from(expected);
|
|
91
|
+
if (a.length !== b.length) return { ok: false, reason: "bad_signature" };
|
|
92
|
+
try {
|
|
93
|
+
if (!timingSafeEqual(a, b)) return { ok: false, reason: "bad_signature" };
|
|
94
|
+
} catch {
|
|
95
|
+
return { ok: false, reason: "bad_signature" };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, sig };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Start the hosted gateway. Resolves once the HTTP server is listening.
|
|
102
|
+
*
|
|
103
|
+
* @param {object} o
|
|
104
|
+
* @param {string} o.hermesBin absolute path to the hermes binary
|
|
105
|
+
* @param {object} o.env environment for the hermes child (HERMES_HOME etc.)
|
|
106
|
+
* @param {string} o.hermesHome ACP session cwd
|
|
107
|
+
* @param {object} [o.hostedGateway] manifest.hosted_gateway block (optional)
|
|
108
|
+
* @returns {Promise<{url:string, port:number, close:()=>void}>}
|
|
109
|
+
*/
|
|
110
|
+
export async function startHostedGateway({ hermesBin, env, hermesHome, hostedGateway = {} }) {
|
|
111
|
+
const secret = env.HERMES_GATEWAY_SECRET || process.env.HERMES_GATEWAY_SECRET || "";
|
|
112
|
+
const port = Number(env.PORT || process.env.PORT || hostedGateway.port || DEFAULT_PORT);
|
|
113
|
+
|
|
114
|
+
// M1 (THE-5): boot-refuse-if-unset. Without the secret we must NOT bring up
|
|
115
|
+
// any agent logic — no `hermes acp` child is spawned. /health stays up (so
|
|
116
|
+
// liveness probes don't crash-loop the container) but runs no agent logic;
|
|
117
|
+
// /message fails closed (503). The endpoint is internet-facing once ingress
|
|
118
|
+
// is on, so a misconfigured deploy must never reach Hermes.
|
|
119
|
+
if (!secret) {
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
"[athena-agent] ⚠ HERMES_GATEWAY_SECRET is unset — refusing to start agent logic. " +
|
|
122
|
+
"/health up, /message FAILS CLOSED (503).\n",
|
|
123
|
+
);
|
|
124
|
+
const healthOnly = createServer((req, res) => {
|
|
125
|
+
const url = (req.url || "/").split("?")[0];
|
|
126
|
+
if (req.method === "GET" && url === "/health") return sendJson(res, 200, { ok: true });
|
|
127
|
+
if (req.method === "POST" && url === "/message")
|
|
128
|
+
return sendJson(res, 503, { error: "hosted gateway not configured (secret unset) — failing closed" });
|
|
129
|
+
return sendJson(res, 404, { error: "not found" });
|
|
130
|
+
});
|
|
131
|
+
await new Promise((resolve, reject) => {
|
|
132
|
+
healthOnly.once("error", reject);
|
|
133
|
+
healthOnly.listen(port, "0.0.0.0", resolve);
|
|
134
|
+
});
|
|
135
|
+
const p = healthOnly.address().port;
|
|
136
|
+
return { url: `http://0.0.0.0:${p}`, port: p, close: () => healthOnly.close() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// One hermes acp child, many ACP sessions. ACP supports multiple sessions per
|
|
140
|
+
// connection (session/new returns a sessionId; session/prompt takes one), so
|
|
141
|
+
// a single child serves every Teams conversation that maps to this agent.
|
|
142
|
+
const acp = new AcpClient(hermesBin, env);
|
|
143
|
+
acp.toolApproval = "auto"; // unattended — no human at the ACP layer to approve
|
|
144
|
+
|
|
145
|
+
// Route assistant chunks to the in-flight turn for their session.
|
|
146
|
+
// turnState: acpSessionId -> { buf: string }
|
|
147
|
+
const turnState = new Map();
|
|
148
|
+
acp.onEvent = (event) => {
|
|
149
|
+
if (event.type === "assistant_chunk" && event.sessionId) {
|
|
150
|
+
const st = turnState.get(event.sessionId);
|
|
151
|
+
if (st) st.buf += event.text;
|
|
152
|
+
}
|
|
153
|
+
if (event.type === "error") {
|
|
154
|
+
process.stderr.write(`[athena-agent] hosted-gateway ACP error: ${event.message}\n`);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
process.stderr.write("[athena-agent] starting Hermes in ACP mode for hosted gateway…\n");
|
|
159
|
+
await acp.initialize();
|
|
160
|
+
process.stderr.write("[athena-agent] ACP initialized.\n");
|
|
161
|
+
|
|
162
|
+
// session_id (from Athena) -> { acpSessionId, lastUsed, chain }
|
|
163
|
+
// `chain` serializes turns within a session: ACP allows one prompt per
|
|
164
|
+
// session at a time, so concurrent /message calls for the same session_id
|
|
165
|
+
// queue behind each other rather than interleaving on the same ACP session.
|
|
166
|
+
const sessions = new Map();
|
|
167
|
+
|
|
168
|
+
async function ensureSession(externalId) {
|
|
169
|
+
let s = sessions.get(externalId);
|
|
170
|
+
if (!s) {
|
|
171
|
+
const acpSessionId = await acp.newSession(hermesHome);
|
|
172
|
+
if (!acpSessionId) throw new Error("session/new returned no sessionId");
|
|
173
|
+
s = { acpSessionId, lastUsed: Date.now(), chain: Promise.resolve() };
|
|
174
|
+
sessions.set(externalId, s);
|
|
175
|
+
process.stderr.write(
|
|
176
|
+
`[athena-agent] new ACP session ${acpSessionId} for ${externalId}\n`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
s.lastUsed = Date.now();
|
|
180
|
+
return s;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Drive one turn on an ACP session, accumulating chunks until the prompt
|
|
184
|
+
* resolves (turn end) or the timeout fires. Returns the reply text. */
|
|
185
|
+
async function driveTurn(acpSessionId, text) {
|
|
186
|
+
turnState.set(acpSessionId, { buf: "" });
|
|
187
|
+
let timer;
|
|
188
|
+
const timeout = new Promise((_, reject) => {
|
|
189
|
+
timer = setTimeout(() => reject(new Error("turn timed out")), TURN_TIMEOUT_MS);
|
|
190
|
+
});
|
|
191
|
+
try {
|
|
192
|
+
await Promise.race([acp.prompt(text, acpSessionId), timeout]);
|
|
193
|
+
} finally {
|
|
194
|
+
clearTimeout(timer);
|
|
195
|
+
}
|
|
196
|
+
const out = turnState.get(acpSessionId)?.buf || "";
|
|
197
|
+
turnState.delete(acpSessionId);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function handleMessage(externalId, text) {
|
|
202
|
+
const s = await ensureSession(externalId);
|
|
203
|
+
// Serialize on the session's chain so same-session turns don't overlap.
|
|
204
|
+
const run = s.chain.then(() => driveTurn(s.acpSessionId, text));
|
|
205
|
+
// Keep the chain alive even if this turn throws.
|
|
206
|
+
s.chain = run.then(() => {}, () => {});
|
|
207
|
+
return run;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── M2 replay cache + M6 rate limiter / concurrency cap ──────────────
|
|
211
|
+
// ⚠ CONCURRENCY = 1 IS LOAD-BEARING, not just blast-radius control.
|
|
212
|
+
// Iris's per-turn consent custody (IRIS-7) arms the MCP-gateway credential by
|
|
213
|
+
// *user* for the duration of a turn; with exactly one turn in flight that
|
|
214
|
+
// arm-by-user lookup is unambiguous. Raising max_concurrency > 1 would let two
|
|
215
|
+
// armed turns overlap and a tool call could resolve to the wrong user's
|
|
216
|
+
// credential — a consent escape. Do NOT raise this above 1 until the custody
|
|
217
|
+
// layer is re-keyed to a per-turn token (not per-user). Hard cap, fail-closed.
|
|
218
|
+
const maxConcurrency = Number(hostedGateway.max_concurrency ?? DEFAULT_MAX_CONCURRENCY);
|
|
219
|
+
const ratePerMin = Number(hostedGateway.rate_limit_per_min ?? DEFAULT_RATE_PER_MIN);
|
|
220
|
+
// The limiter is in-process (no external store), so it cannot be "down" — a
|
|
221
|
+
// failure to obtain a token can only mean the bucket is empty ⇒ 429. There is
|
|
222
|
+
// no code path where the limiter is unavailable and we fail open.
|
|
223
|
+
const replayCache = new Map(); // signature -> expiry epoch ms
|
|
224
|
+
const bucket = { tokens: ratePerMin, last: Date.now() }; // token bucket (global; single-employee container)
|
|
225
|
+
let inFlight = 0;
|
|
226
|
+
|
|
227
|
+
function seenBefore(sig) {
|
|
228
|
+
if (replayCache.has(sig)) return true;
|
|
229
|
+
replayCache.set(sig, Date.now() + REPLAY_TTL_MS);
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
function takeToken() {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
bucket.tokens = Math.min(ratePerMin, bucket.tokens + ((now - bucket.last) * ratePerMin) / 60_000);
|
|
235
|
+
bucket.last = now;
|
|
236
|
+
if (bucket.tokens >= 1) { bucket.tokens -= 1; return true; }
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Periodic idle eviction + replay-cache pruning.
|
|
241
|
+
const sweeper = setInterval(() => {
|
|
242
|
+
const now = Date.now();
|
|
243
|
+
for (const [id, s] of sessions) {
|
|
244
|
+
if (now - s.lastUsed > SESSION_IDLE_MS) {
|
|
245
|
+
sessions.delete(id);
|
|
246
|
+
turnState.delete(s.acpSessionId);
|
|
247
|
+
process.stderr.write(`[athena-agent] evicted idle session ${id}\n`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
for (const [sig, exp] of replayCache) if (exp <= now) replayCache.delete(sig);
|
|
251
|
+
}, SESSION_SWEEP_MS);
|
|
252
|
+
if (sweeper.unref) sweeper.unref();
|
|
253
|
+
|
|
254
|
+
const server = createServer(async (req, res) => {
|
|
255
|
+
const url = (req.url || "/").split("?")[0];
|
|
256
|
+
|
|
257
|
+
if (req.method === "GET" && url === "/health") {
|
|
258
|
+
sendJson(res, 200, { ok: true });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (req.method === "POST" && url === "/message") {
|
|
263
|
+
const requestId =
|
|
264
|
+
(typeof req.headers["x-request-id"] === "string" && req.headers["x-request-id"]) ||
|
|
265
|
+
randomUUID();
|
|
266
|
+
// Read the raw body first — the HMAC (M2) is computed over it verbatim.
|
|
267
|
+
const rawBody = await readRawBody(req);
|
|
268
|
+
|
|
269
|
+
if (!secret) {
|
|
270
|
+
logEvent({ event: "message", request_id: requestId, status: 503, reason: "secret_unset" });
|
|
271
|
+
sendJson(res, 503, { error: "service unavailable" });
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
// M1 — bearer first (cheapest, no body needed).
|
|
275
|
+
if (!bearerOk(req, secret)) {
|
|
276
|
+
logEvent({ event: "auth_fail", request_id: requestId, status: 401, reason: "bearer" });
|
|
277
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
// M2 — HMAC signature over `${ts}.${rawBody}`, ±60s skew.
|
|
281
|
+
const sig = hmacOk(req, rawBody, secret);
|
|
282
|
+
if (!sig.ok) {
|
|
283
|
+
logEvent({ event: "auth_fail", request_id: requestId, status: 401, reason: sig.reason });
|
|
284
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
// M2 — replay/nonce: a given signature is single-use within its TTL.
|
|
288
|
+
if (seenBefore(sig.sig)) {
|
|
289
|
+
logEvent({ event: "auth_fail", request_id: requestId, status: 401, reason: "replay" });
|
|
290
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
// M6 — rate limit, then hard concurrency cap. Fail closed (429), never open.
|
|
294
|
+
if (!takeToken()) {
|
|
295
|
+
logEvent({ event: "rate_limited", request_id: requestId, status: 429 });
|
|
296
|
+
sendJson(
|
|
297
|
+
res,
|
|
298
|
+
429,
|
|
299
|
+
{ status: "rate_limited", reason: "rate_limited", message: "Too many requests — try again shortly.", retry_after_ms: BUSY_RETRY_AFTER_MS },
|
|
300
|
+
{ "retry-after": String(Math.ceil(BUSY_RETRY_AFTER_MS / 1000)) },
|
|
301
|
+
);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (inFlight >= maxConcurrency) {
|
|
305
|
+
// DAE-16 (D3 container half): a turn is already in flight. max_concurrency
|
|
306
|
+
// stays 1 (load-bearing for arm custody — never raised), so instead of
|
|
307
|
+
// blocking or a bare 429 we return an IMMEDIATE, TYPED busy signal the
|
|
308
|
+
// poller (IRIS-24) relays. Contract: HTTP 429 + Retry-After, body
|
|
309
|
+
// { status:"busy", reason:"turn_in_flight", message, retry_after_ms }.
|
|
310
|
+
logEvent({ event: "busy", request_id: requestId, status: 429, reason: "turn_in_flight", in_flight: inFlight });
|
|
311
|
+
sendJson(
|
|
312
|
+
res,
|
|
313
|
+
429,
|
|
314
|
+
{
|
|
315
|
+
status: "busy",
|
|
316
|
+
reason: "turn_in_flight",
|
|
317
|
+
message: "The agent is still processing a previous message — try again shortly.",
|
|
318
|
+
retry_after_ms: BUSY_RETRY_AFTER_MS,
|
|
319
|
+
},
|
|
320
|
+
{ "retry-after": String(Math.ceil(BUSY_RETRY_AFTER_MS / 1000)) },
|
|
321
|
+
);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let body;
|
|
326
|
+
try { body = JSON.parse(rawBody || "{}"); } catch { body = {}; }
|
|
327
|
+
const sessionId = typeof body.session_id === "string" ? body.session_id.trim() : "";
|
|
328
|
+
const text = typeof body.text === "string" ? body.text : "";
|
|
329
|
+
// M3 — identity is NEVER taken from the body; only session_id + text are read.
|
|
330
|
+
if (!sessionId || !text.trim()) {
|
|
331
|
+
logEvent({ event: "message", request_id: requestId, status: 400, reason: "bad_request" });
|
|
332
|
+
sendJson(res, 400, { error: "session_id and text are required" });
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
inFlight++;
|
|
337
|
+
const startedAt = Date.now();
|
|
338
|
+
try {
|
|
339
|
+
const reply = await handleMessage(sessionId, text);
|
|
340
|
+
logEvent({ event: "turn", request_id: requestId, session_id: sessionId, status: 200, ms: Date.now() - startedAt });
|
|
341
|
+
sendJson(res, 200, { text: reply });
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// M7 — generic error body; the detail stays in our own logs only.
|
|
344
|
+
logEvent({ event: "turn", request_id: requestId, session_id: sessionId, status: 502, error: String(err?.message || err) });
|
|
345
|
+
sendJson(res, 502, { error: "turn_failed" });
|
|
346
|
+
} finally {
|
|
347
|
+
inFlight--;
|
|
348
|
+
}
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
sendJson(res, 404, { error: "not found" });
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
await new Promise((resolve, reject) => {
|
|
356
|
+
server.once("error", reject);
|
|
357
|
+
// 0.0.0.0 — the container's ingress fronts this; bind to all interfaces.
|
|
358
|
+
server.listen(port, "0.0.0.0", resolve);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const boundPort = server.address().port;
|
|
362
|
+
function close() {
|
|
363
|
+
clearInterval(sweeper);
|
|
364
|
+
acp.kill();
|
|
365
|
+
server.close();
|
|
366
|
+
}
|
|
367
|
+
acp.child.on("exit", () => { clearInterval(sweeper); server.close(); });
|
|
368
|
+
|
|
369
|
+
return { url: `http://0.0.0.0:${boundPort}`, port: boundPort, close };
|
|
370
|
+
}
|
package/lib/webchat-ui.html
CHANGED
|
@@ -41,6 +41,9 @@
|
|
|
41
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
42
|
.tool .t-title { font-weight: 600; color: var(--fg); }
|
|
43
43
|
.tool .t-status { font-size: 11px; color: var(--fg3); }
|
|
44
|
+
.tool.err { border-color: var(--err); }
|
|
45
|
+
.tool.err .t-status { color: var(--err); }
|
|
46
|
+
.tool .t-detail { margin-top: 6px; font-size: 12px; color: var(--err); white-space: pre-wrap; word-wrap: break-word; }
|
|
44
47
|
.perm { background: var(--bg3); border: 1px solid var(--accent); border-radius: 8px; padding: 12px; margin: 8px 0; }
|
|
45
48
|
.perm .q { margin-bottom: 8px; }
|
|
46
49
|
.perm button { margin-right: 8px; }
|
|
@@ -143,7 +146,18 @@ function handle(ev) {
|
|
|
143
146
|
}
|
|
144
147
|
case "tool_update": {
|
|
145
148
|
const el = toolEls.get(ev.id);
|
|
146
|
-
if (el)
|
|
149
|
+
if (el) {
|
|
150
|
+
el.querySelector(".t-status").textContent = "· " + (ev.isError ? "failed" : ev.status);
|
|
151
|
+
if (ev.isError) {
|
|
152
|
+
el.classList.add("err");
|
|
153
|
+
if (ev.detail) {
|
|
154
|
+
let d = el.querySelector(".t-detail");
|
|
155
|
+
if (!d) { d = document.createElement("div"); d.className = "t-detail"; el.append(d); }
|
|
156
|
+
d.textContent = ev.detail;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
break;
|
|
147
161
|
}
|
|
148
162
|
case "permission":
|
|
149
163
|
renderPermission(ev); break;
|
package/lib/webchat.mjs
CHANGED
|
@@ -14,165 +14,14 @@ import { createServer } from "node:http";
|
|
|
14
14
|
import { readFileSync } from "node:fs";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { dirname, join } from "node:path";
|
|
17
|
+
import { AcpClient } from "./acp-client.mjs";
|
|
17
18
|
|
|
18
19
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
19
20
|
const UI_HTML = readFileSync(join(__dirname, "webchat-ui.html"), "utf8");
|
|
20
21
|
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
}
|
|
22
|
+
// The ACP client (newline-delimited JSON-RPC over a `hermes acp` child) now
|
|
23
|
+
// lives in ./acp-client.mjs so the hosted gateway (DAE-6) shares the exact
|
|
24
|
+
// same transport. webchat uses it in single-session mode (init + prompt).
|
|
176
25
|
|
|
177
26
|
function openBrowser(url) {
|
|
178
27
|
const cmd =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "athena-agent-launcher",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
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": {
|