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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.4.5",
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
- payload: { text: `turn failed: ${msg}`, ...(lost ? { rejectedPrompt: text } : {}) },
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
- | { t: "turn-start"; sessionId: string; trace?: TurnTrace }
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 }