agentus 0.4.5 → 0.5.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/package.json +5 -1
- package/packages/server/src/acp/session-manager.ts +7 -1
- package/packages/server/src/index.ts +40 -4
- package/packages/shared/src/index.ts +10 -2
- package/packages/web/dist/assets/index-SSGsR6n4.js +127 -0
- package/packages/web/dist/assets/index-xZyah18L.css +32 -0
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/assets/index-1rw3zpcN.css +0 -32
- package/packages/web/dist/assets/index-BxGj4INU.js +0 -125
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Multi-session web cockpit for ACP-speaking coding agents (Qoder CLI / Hermes). UI has no intelligence — the agent lives in spawned CLI subprocesses.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -65,6 +65,10 @@
|
|
|
65
65
|
"notify-testbed": "tsx scripts/notify-testbed.mts",
|
|
66
66
|
"notify-smoke": "tsx scripts/notify-smoke.mts",
|
|
67
67
|
"web-notify-rules": "tsx scripts/qa/web-notify-rules.mts",
|
|
68
|
+
"notify-chime": "tsx scripts/qa/notify-chime.mts",
|
|
69
|
+
"ws-send-ack": "node scripts/qa/ws-send-ack.mjs",
|
|
70
|
+
"send-ack": "node scripts/qa/send-ack-page.mjs",
|
|
71
|
+
"composer-input": "node scripts/qa/composer-input-sweep.mjs",
|
|
68
72
|
"web-notify-sweep": "node scripts/qa/web-notify-sweep.mjs",
|
|
69
73
|
"apk": "bash scripts/build-apk.sh",
|
|
70
74
|
"transcript-fold": "tsx scripts/qa/transcript-fold.mts",
|
|
@@ -972,6 +972,12 @@ export class SessionManager {
|
|
|
972
972
|
* message, no attribution, indistinguishable from something they typed).
|
|
973
973
|
*/
|
|
974
974
|
origin?: { sessionId: string; title?: string | null };
|
|
975
|
+
/**
|
|
976
|
+
* Client-generated id of the prompt that is being sent (issue #26). Echoed on the `turn-start`
|
|
977
|
+
* this call causes, so a page can prove its frame landed instead of assuming `ws.send()` did
|
|
978
|
+
* something. Opaque to the server: it comes back unchanged, it is never stored.
|
|
979
|
+
*/
|
|
980
|
+
pid?: string;
|
|
975
981
|
} = {},
|
|
976
982
|
): Promise<void> {
|
|
977
983
|
const s = this.#need(sessionId);
|
|
@@ -1038,7 +1044,7 @@ export class SessionManager {
|
|
|
1038
1044
|
// Timed from the announcement, not from the prompt call: the operator's wait INCLUDES the
|
|
1039
1045
|
// moment the card lit up. Only the queue-wait above is excluded (that is other turns' time).
|
|
1040
1046
|
const turnStartedAt = Date.now();
|
|
1041
|
-
this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s) });
|
|
1047
|
+
this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s), ...(opts.pid ? { pid: opts.pid } : {}) });
|
|
1042
1048
|
const msg = this.#store.appendMessage({
|
|
1043
1049
|
sessionId,
|
|
1044
1050
|
kind: "user",
|
|
@@ -450,8 +450,9 @@ function runPrompt(
|
|
|
450
450
|
text: string,
|
|
451
451
|
attachments: PromptAttachment[] = [],
|
|
452
452
|
interrupt = false,
|
|
453
|
+
pid?: string,
|
|
453
454
|
): void {
|
|
454
|
-
void mgr.prompt(sessionId, text, attachments, { interrupt }).catch((e: unknown) => {
|
|
455
|
+
void mgr.prompt(sessionId, text, attachments, { interrupt, pid }).catch((e: unknown) => {
|
|
455
456
|
const msg = describeAcpError(e);
|
|
456
457
|
const tail = mgr.stderrTail(sessionId);
|
|
457
458
|
console.error(`[agentus] prompt failed for ${sessionId}: ${msg}${tail ? `\n agent stderr tail:\n ${tail}` : ""}`);
|
|
@@ -463,7 +464,10 @@ function runPrompt(
|
|
|
463
464
|
const lost = msg === "turn already running" || msg === IMAGE_UNSUPPORTED;
|
|
464
465
|
emit({ t: "message", message: {
|
|
465
466
|
seq: -1, sessionId, kind: "meta",
|
|
466
|
-
|
|
467
|
+
// `pid` rides along (issue #26) so the page can pair this refusal with the prompt it is still
|
|
468
|
+
// waiting to acknowledge: without it the refusal and the ack timeout both put the same sentence
|
|
469
|
+
// back in line, and the operator sees his message twice.
|
|
470
|
+
payload: { text: `turn failed: ${msg}`, ...(lost ? { rejectedPrompt: text } : {}), ...(pid ? { pid } : {}) },
|
|
467
471
|
createdAt: Date.now(),
|
|
468
472
|
} });
|
|
469
473
|
// `turn-end` is the frame that says A TURN IS OVER. For the two "nothing left the client" refusals
|
|
@@ -1510,7 +1514,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
|
|
|
1510
1514
|
const attachments = Array.isArray(body.attachments) ? (body.attachments as PromptAttachment[]) : [];
|
|
1511
1515
|
if (!text && !attachments.length) return send(res, 400, { error: "empty prompt" });
|
|
1512
1516
|
// fire & forget: stream arrives via WS; client watches turn-start/end
|
|
1513
|
-
void runPrompt(id, text, attachments, body.interrupt === true);
|
|
1517
|
+
void runPrompt(id, text, attachments, body.interrupt === true, typeof body.pid === "string" ? body.pid : undefined);
|
|
1514
1518
|
return send(res, 202, { ok: true });
|
|
1515
1519
|
}
|
|
1516
1520
|
if (req.method === "POST" && sub === "/workspace") {
|
|
@@ -1795,6 +1799,9 @@ const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
|
1795
1799
|
httpServer.on("upgrade", handleUpgrade);
|
|
1796
1800
|
wss.on("connection", (ws) => {
|
|
1797
1801
|
const clientId = randomUUID().slice(0, 8);
|
|
1802
|
+
// Any frame — including a pong — proves the peer is alive (see the heartbeat below).
|
|
1803
|
+
(ws as WebSocket & { isAlive?: boolean }).isAlive = true;
|
|
1804
|
+
ws.on("pong", () => { (ws as WebSocket & { isAlive?: boolean }).isAlive = true; });
|
|
1798
1805
|
clients.set(clientId, { ws, lastSeen: new Map() });
|
|
1799
1806
|
const sendEvt = (e: ServerEvent) => {
|
|
1800
1807
|
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(e));
|
|
@@ -1831,7 +1838,7 @@ wss.on("connection", (ws) => {
|
|
|
1831
1838
|
break;
|
|
1832
1839
|
}
|
|
1833
1840
|
case "prompt":
|
|
1834
|
-
void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true);
|
|
1841
|
+
void runPrompt(cmd.sessionId, cmd.text, cmd.attachments ?? [], cmd.interrupt === true, cmd.pid);
|
|
1835
1842
|
break;
|
|
1836
1843
|
case "cancel":
|
|
1837
1844
|
await mgr.cancel(cmd.sessionId);
|
|
@@ -1865,6 +1872,35 @@ wss.on("connection", (ws) => {
|
|
|
1865
1872
|
ws.on("close", () => clients.delete(clientId));
|
|
1866
1873
|
});
|
|
1867
1874
|
|
|
1875
|
+
// ---- liveness: the half-open socket ---------------------------------------------------------
|
|
1876
|
+
// A TCP connection that dies without a FIN (Wi-Fi switch, laptop sleep, NAT timeout) stays OPEN in
|
|
1877
|
+
// BOTH ends: the page's `send()` succeeds into the void, and this server keeps a client that will
|
|
1878
|
+
// never read again. There is no traffic to fail on, so nothing above this line can notice — and the
|
|
1879
|
+
// operator's message is written into a socket nobody owns, with no bubble and no error (issue #26,
|
|
1880
|
+
// measured live 2026-10-11 with the network cut). Ping/pong is the only detector the protocol gives
|
|
1881
|
+
// us: browsers answer `ping` automatically, so a missing pong means the peer is gone.
|
|
1882
|
+
//
|
|
1883
|
+
// 20s, not 60s: the window between "socket died" and "page knows" is exactly how long a prompt can
|
|
1884
|
+
// vanish, and the ack timeout on the client (8s) covers what happens inside one beat. An idle
|
|
1885
|
+
// cockpit costs 3 tiny frames a minute.
|
|
1886
|
+
const HEARTBEAT_MS = Number(process.env.AGENTUS_WS_HEARTBEAT_MS ?? "") || 20_000;
|
|
1887
|
+
const heartbeat = setInterval(() => {
|
|
1888
|
+
for (const [id, client] of clients) {
|
|
1889
|
+
const ws = client.ws as WebSocket & { isAlive?: boolean };
|
|
1890
|
+
if (ws.isAlive === false) {
|
|
1891
|
+
// No pong since the last beat: the peer is not there. `terminate()` (not `close()`) because a
|
|
1892
|
+
// half-open peer never answers the closing handshake either — this is the only way out.
|
|
1893
|
+
ws.terminate();
|
|
1894
|
+
clients.delete(id);
|
|
1895
|
+
continue;
|
|
1896
|
+
}
|
|
1897
|
+
ws.isAlive = false;
|
|
1898
|
+
try { ws.ping(); } catch { /* already gone; the next beat reaps it */ }
|
|
1899
|
+
}
|
|
1900
|
+
}, HEARTBEAT_MS);
|
|
1901
|
+
heartbeat.unref();
|
|
1902
|
+
|
|
1903
|
+
|
|
1868
1904
|
// ---- the workspace panel's terminal -----------------------------------------
|
|
1869
1905
|
// A shell is a stream, so it gets its own socket instead of riding the event bus:
|
|
1870
1906
|
// one socket = one shell, and closing the socket (or the tab) kills the shell.
|
|
@@ -206,7 +206,10 @@ export type ServerEvent =
|
|
|
206
206
|
durationMs?: number }
|
|
207
207
|
/** per-turn trace: what model/effort/mode this turn is actually running with
|
|
208
208
|
* (AionUi F-DISPLAY-11 lineage — "why did it behave differently?" ) */
|
|
209
|
-
|
|
209
|
+
/** `pid` is the id of the prompt that started this turn, copied from the client's command (issue
|
|
210
|
+
* #26): it is the only thing that can prove a particular frame landed. Absent for turns the page
|
|
211
|
+
* did not start (a call utterance, the cockpit's cross-session `send`, a resumed transcript). */
|
|
212
|
+
| { t: "turn-start"; sessionId: string; trace?: TurnTrace; pid?: string }
|
|
210
213
|
/** A one-line status from the AGENT itself (issue #18): `👁️ Hindsight — recalled 8 memories`,
|
|
211
214
|
* a compaction countdown, a provider wait. Deliberately NOT a transcript row and not persisted —
|
|
212
215
|
* it describes what the process is doing NOW, so the page shows the latest one beside the running
|
|
@@ -407,7 +410,12 @@ export type ClientCommand =
|
|
|
407
410
|
* wait for it to actually stop before sending this prompt. Call mode sends every utterance
|
|
408
411
|
* this way (the operator speaking over the agent IS the interrupt), which is also what
|
|
409
412
|
* removes the race between an async cancel and the next prompt. */
|
|
410
|
-
| { t: "prompt"; sessionId: string; text: string; attachments?: PromptAttachment[]; interrupt?: boolean
|
|
413
|
+
| { t: "prompt"; sessionId: string; text: string; attachments?: PromptAttachment[]; interrupt?: boolean;
|
|
414
|
+
/** Client-generated id for THIS prompt, echoed back on the `turn-start` it causes (issue #26).
|
|
415
|
+
* Without it a page can only guess whether its frame landed: a socket that dies without a FIN
|
|
416
|
+
* stays `OPEN` in JS, so `send()` returns true for a frame nobody received and the operator's
|
|
417
|
+
* words vanish with no error. With it, "the server started a turn for prompt X" is a fact. */
|
|
418
|
+
pid?: string }
|
|
411
419
|
| { t: "cancel"; sessionId: string }
|
|
412
420
|
| { t: "set-mode"; sessionId: string; modeId: string }
|
|
413
421
|
| { t: "set-config"; sessionId: string; configId: string; value: string | number | boolean }
|