agentus 0.4.4 → 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.4",
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": {
@@ -30,7 +30,8 @@
30
30
  "dependencies": {
31
31
  "@agentclientprotocol/sdk": "1.5.1",
32
32
  "tsx": "^4.19.0",
33
- "ws": "^8.22.0"
33
+ "ws": "^8.22.0",
34
+ "yaml": "^2.9.1"
34
35
  },
35
36
  "workspaces": [
36
37
  "packages/*"
@@ -64,6 +65,10 @@
64
65
  "notify-testbed": "tsx scripts/notify-testbed.mts",
65
66
  "notify-smoke": "tsx scripts/notify-smoke.mts",
66
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",
67
72
  "web-notify-sweep": "node scripts/qa/web-notify-sweep.mjs",
68
73
  "apk": "bash scripts/build-apk.sh",
69
74
  "transcript-fold": "tsx scripts/qa/transcript-fold.mts",
@@ -416,6 +416,12 @@ const agent = () => ({
416
416
  // per-turn slow mode: long stream so QA can drop the socket mid-turn (AC6)
417
417
  const slow = /\[slow\]/.test(text) ? Math.max(SLOW, 900) : SLOW;
418
418
 
419
+ // `[hold:<ms>]` — a turn that is IN FLIGHT but SILENT for that long (issue #24's heartbeat
420
+ // fixture). No frames at all, so 「有任务在跑 + 没有动静」 is exactly the state the ticker must
421
+ // speak on — and the state a `[slow]` stream can never reproduce (it emits every 900 ms).
422
+ const hold = Number((/\[hold:(\d+)\]/.exec(text) || [])[1] || 0);
423
+ if (hold > 0) await sleep(Math.min(180_000, hold));
424
+
419
425
  // `[status]` — the agent-status carrier from issue #18, exactly as hermes' `make_status_cb` sends
420
426
  // it: an EMPTY thought chunk whose `_meta.hermes` holds the sentence. Sent mid-turn on purpose, so a
421
427
  // sweep can prove the line appears while the turn runs and is gone when it ends, and that NOTHING
@@ -11,7 +11,8 @@
11
11
  "dependencies": {
12
12
  "@agentclientprotocol/sdk": "1.5.1",
13
13
  "@agentus/shared": "*",
14
- "ws": "^8.22.0"
14
+ "ws": "^8.22.0",
15
+ "yaml": "^2.9.1"
15
16
  },
16
17
  "devDependencies": {
17
18
  "@types/node": "^22.0.0",
@@ -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",
@@ -233,3 +233,85 @@ export function openDashscopeStream(opts: {
233
233
  started,
234
234
  };
235
235
  }
236
+
237
+ /**
238
+ * 语音总结: one message in, a few spoken sentences out.
239
+ *
240
+ * Same door as batch ASR (`{compat}/chat/completions`) with the audio block swapped for text, so
241
+ * the operator's existing voice endpoint and key are enough — there is no second model to
242
+ * configure and no agent, no session and no history involved.
243
+ *
244
+ * The input is capped at `maxChars × 20` characters (30k ≈ 15k tokens is already the ceiling for a
245
+ * 30-second listen): a 200 KB pasted log must not turn a long press into a bill.
246
+ */
247
+ export async function summarizeForSpeech(opts: {
248
+ text: string;
249
+ maxChars: number;
250
+ prompt: string;
251
+ model: string;
252
+ timeoutSec: number;
253
+ /** Where to send it. Absent = the voice endpoint (settings → 语音), which is the old, always-on
254
+ * route. Present = the SESSION's own provider (see speech/route.ts) — the operator asked for the
255
+ * summary to be billed to whatever model he is actually talking to, not to a second vendor. */
256
+ endpoint?: { baseUrl: string; apiKey: string };
257
+ }): Promise<{ text: string; truncated: boolean; ms: number }> {
258
+ const { key, compat } = opts.endpoint
259
+ ? { key: opts.endpoint.apiKey, compat: opts.endpoint.baseUrl.replace(/\/+$/, "") }
260
+ : creds();
261
+ const v = getSettings().voice;
262
+ const limit = Math.max(400, opts.maxChars * 20);
263
+ const body = opts.text.trim();
264
+ const truncated = body.length > limit;
265
+ const input = truncated ? `${body.slice(0, limit)}\n…(后面还有 ${body.length - limit} 字没发)` : body;
266
+ const ac = new AbortController();
267
+ const timer = setTimeout(() => ac.abort(), Math.max(5, opts.timeoutSec) * 1000);
268
+ const started = Date.now();
269
+ try {
270
+ const res = await fetch(`${compat}/chat/completions`, {
271
+ method: "POST",
272
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
273
+ body: JSON.stringify({
274
+ model: opts.model || (opts.endpoint ? "" : "qwen-flash"),
275
+ messages: [
276
+ { role: "system", content: opts.prompt.replace(/\{maxChars\}/g, String(opts.maxChars)) },
277
+ { role: "user", content: input },
278
+ ],
279
+ // No temperature knob: this surface is not a playground, and the default has produced
280
+ // the steadiest length compliance. `max_tokens` guards against a chatty model — and it
281
+ // must leave room for REASONING tokens, which count against the same budget: measured on
282
+ // deepseek-v4-flash, a 480-token cap was spent entirely on reasoning_content and the
283
+ // visible answer came back empty with finish_reason=length. 1200 is the floor that makes a
284
+ // reasoning model answer; for a plain one it is only a ceiling nobody reaches.
285
+ max_tokens: Math.max(1200, opts.maxChars * 6),
286
+ }),
287
+ signal: ac.signal,
288
+ });
289
+ if (!res.ok) {
290
+ const detail = (await res.text().catch(() => "")).slice(0, 200);
291
+ throw new DashscopeError(`chat/completions ${res.status}: ${detail}`, "upstream");
292
+ }
293
+ const data = (await res.json()) as {
294
+ choices?: { message?: { content?: string; reasoning_content?: string }; finish_reason?: string }[];
295
+ };
296
+ const choice = data?.choices?.[0];
297
+ const out = String(choice?.message?.content ?? "").trim();
298
+ if (!out) {
299
+ // Say WHICH silence this was. An empty `content` with a fat `reasoning_content` and
300
+ // finish_reason=length means the budget went to thinking, not that the model refused.
301
+ const thought = String(choice?.message?.reasoning_content ?? "").length;
302
+ const why = choice?.finish_reason === "length"
303
+ ? `预算被推理吃光了(reasoning ${thought} 字,finish_reason=length)—— 调大字数上限,或换一个非推理模型`
304
+ : `模型返回空内容(finish_reason=${choice?.finish_reason ?? "?"})`;
305
+ throw new DashscopeError(why, "upstream");
306
+ }
307
+ void v;
308
+ return { text: out, truncated, ms: Date.now() - started };
309
+ } catch (e) {
310
+ if ((e as Error).name === "AbortError") {
311
+ throw new DashscopeError(`summary timed out after ${opts.timeoutSec}s`, "timeout");
312
+ }
313
+ throw e;
314
+ } finally {
315
+ clearTimeout(timer);
316
+ }
317
+ }
@@ -9,20 +9,22 @@ import { fileURLToPath } from "node:url";
9
9
  import path from "node:path";
10
10
  import fs from "node:fs";
11
11
  import { WebSocketServer, WebSocket } from "ws";
12
- import { Store } from "./store/store.js";
12
+ import { Store, textOf } from "./store/store.js";
13
+ import { routeForModel, type SpeechRoute } from "./speech/route.js";
13
14
  import { exportFilename, renderJson, renderMarkdown, type ExportSessionHeader } from "./store/export.js";
14
15
  import { IMAGE_UNSUPPORTED, SessionManager, SessionUnavailable } from "./acp/session-manager.js";
15
16
  import { describeAcpError } from "./acp/errors.js";
16
17
  import { BACKENDS } from "./acp/backends.js";
17
- import { classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
18
+ import { HERMES_DEFAULT_HOME, classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
18
19
  import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
19
20
  import { terms } from "./term.js";
20
21
  import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
21
22
  import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
22
- import { openDashscopeStream } from "./dashscope.js";
23
+ import { openDashscopeStream, summarizeForSpeech } from "./dashscope.js";
23
24
  import {
25
+ SUMMARY_CHARS_RANGE, SUMMARY_MODEL_DEFAULT, SUMMARY_PROMPT_DEFAULT,
24
26
  agentSettings, getSettings, initSettings, observerSettings, planHintSettings, publicSettings,
25
- saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveTheme,
27
+ saveAgent, saveCall, saveObserver, savePrefs, saveSettings, saveSummary, saveTheme, summarySettings,
26
28
  } from "./settings.js";
27
29
  import { ObserverHub } from "./observer/hub.js";
28
30
  import * as auth from "./auth.js";
@@ -261,6 +263,21 @@ hub = new ObserverHub({
261
263
  };
262
264
  pick();
263
265
  },
266
+ // 工作播报 的作用域与生命周期 (issues #23/#24, 2026-10-18).
267
+ // `watching` is the SAME presence the notification rules use: 「他在看哪个会话」 has one answer in
268
+ // this process, and a second, quieter definition of it is how a feature starts talking about the
269
+ // wrong session. `notify` is a `const` declared below — this closure only ever runs from an event
270
+ // handler, never during construction.
271
+ watching: (id) => notify.operatorWatching(id),
272
+ // The reaped-observer fix: a `system` session the idle reaper took no longer accepts prompts, and
273
+ // trusting its id forever is what made the brief say 「播报失败:no such session」 until a restart.
274
+ // `hasSession` (not `list()`) on purpose: `list()` is the RAIL's view and excludes system sessions,
275
+ // so asking it about an observer answers 「no」 every single time — which would re-spawn a fresh
276
+ // observer on every turn and throw away the context continuity the whole design is for.
277
+ alive: (id) => mgr.hasSession(id),
278
+ // 切走即关 / 关掉收听即关: closing the PROCESS is the point — an idle observer is a whole ACP
279
+ // runtime, and the reaper's five minutes are five minutes of paying for nothing.
280
+ disposeSession: (id) => mgr.closeSession(id),
264
281
  emit,
265
282
  });
266
283
 
@@ -282,7 +299,12 @@ const notify = new NotifyCenter({
282
299
  // The page reporting which session it is showing is also the idle reaper's clock: opening a slot
283
300
  // (and staying in it) counts as USED, so switching away gives it a full threshold instead of
284
301
  // reaping it on the next tick because its last MESSAGE was days ago.
285
- onFocus: (sessionId: string) => mgr.touch(sessionId),
302
+ // ...and it is also the 工作播报 scope switch (issue #23): the observer follows the session on
303
+ // screen, and the previous one is CLOSED rather than left warming. Two consumers, one fact.
304
+ onFocus: (sessionId: string) => {
305
+ mgr.touch(sessionId);
306
+ hub?.focusChanged(sessionId);
307
+ },
286
308
  // Username+password pairing: the phone types the same login the browser uses. The check and
287
309
  // its rate limit stay here, in auth.ts, so a phone cannot have a laxer door than the UI.
288
310
  credentials: (username, password, ip) => {
@@ -299,6 +321,11 @@ const notify = new NotifyCenter({
299
321
  },
300
322
  });
301
323
 
324
+ // The beat starts here, and not at construction: `tick()` reads `notify`'s presence, and a timer
325
+ // started before that `const` exists would be the one thing in this file able to throw a TDZ error
326
+ // at runtime. 15s is finer than the smallest beat the menu offers (1 minute).
327
+ hub?.start();
328
+
302
329
  // One row per interaction. A notification button carries an opaque actionId plus the ref the
303
330
  // publisher attached; the cockpit is the only place that knows what it means. Adding a new
304
331
  // interaction is therefore a case here (or a new ref.type), never an app rebuild.
@@ -423,8 +450,9 @@ function runPrompt(
423
450
  text: string,
424
451
  attachments: PromptAttachment[] = [],
425
452
  interrupt = false,
453
+ pid?: string,
426
454
  ): void {
427
- void mgr.prompt(sessionId, text, attachments, { interrupt }).catch((e: unknown) => {
455
+ void mgr.prompt(sessionId, text, attachments, { interrupt, pid }).catch((e: unknown) => {
428
456
  const msg = describeAcpError(e);
429
457
  const tail = mgr.stderrTail(sessionId);
430
458
  console.error(`[agentus] prompt failed for ${sessionId}: ${msg}${tail ? `\n agent stderr tail:\n ${tail}` : ""}`);
@@ -436,7 +464,10 @@ function runPrompt(
436
464
  const lost = msg === "turn already running" || msg === IMAGE_UNSUPPORTED;
437
465
  emit({ t: "message", message: {
438
466
  seq: -1, sessionId, kind: "meta",
439
- 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 } : {}) },
440
471
  createdAt: Date.now(),
441
472
  } });
442
473
  // `turn-end` is the frame that says A TURN IS OVER. For the two "nothing left the client" refusals
@@ -808,6 +839,22 @@ const handleRequest = async (req: IncomingMessage, res: ServerResponse) => {
808
839
  });
809
840
  }
810
841
 
842
+ // ---- 工作播报 diagnostics (issues #23/#24) ------------------------------------------
843
+ // What the hub is watching and what it spawned. Operator-only, read-only, no secrets: this is
844
+ // the answer to 「它有没有在看这个会话 / 观察员还活着吗」 that used to require reading logs.
845
+ if (url.pathname === "/api/observer" && req.method === "GET") {
846
+ if (!auth.authenticate(req.headers, url)) return send(res, 401, { error: "login required" });
847
+ const o = getSettings().observer;
848
+ return send(res, 200, {
849
+ enabled: o.enabled,
850
+ intervalSec: o.intervalSec,
851
+ throttleSec: o.throttleSec,
852
+ observers: hub?.observerIds() ?? [],
853
+ watches: hub?.debug() ?? [],
854
+ ticks: hub?.ticks() ?? 0,
855
+ });
856
+ }
857
+
811
858
  // ---- the phone's onboarding page + the APK, both behind the operator login ----------
812
859
  // The pairing code lives on this page, so it is NOT public: an unauthenticated visitor is
813
860
  // sent to the login screen (the SPA at /) and comes back with a session cookie.
@@ -1238,7 +1285,12 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1238
1285
  if (body.prefs && typeof body.prefs === "object") savePrefs(body.prefs as Record<string, unknown>);
1239
1286
  if (body.agent && typeof body.agent === "object") saveAgent(body.agent as Record<string, unknown>);
1240
1287
  if (body.observer && typeof body.observer === "object") saveObserver(body.observer as Record<string, unknown>);
1241
- if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer) saveSettings(body);
1288
+ // Turning the speaker off (or the observer off) must stop the COST, not just the voice:
1289
+ // the observer is a whole ACP process, and waiting for the idle reaper to take it is five
1290
+ // minutes of paying for a feature the operator just switched off (issue #23 §3.2).
1291
+ if (body.prefs || body.observer) hub?.onPolicyChanged();
1292
+ if (body.summary && typeof body.summary === "object") saveSummary(body.summary as Record<string, unknown>);
1293
+ if (!body.voice && !body.theme && !body.call && !body.prefs && !body.agent && !body.observer && !body.summary) saveSettings(body);
1242
1294
  return send(res, 200, publicSettings());
1243
1295
  } catch (e) {
1244
1296
  return send(res, 400, { error: String((e as Error)?.message ?? e) });
@@ -1350,11 +1402,15 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1350
1402
  // here used to kill the whole server (QA#19, unhandled rejection)
1351
1403
  if (mgr.hasSession(id)) {
1352
1404
  await mgr.closeSession(id);
1405
+ // 工作播报 (#23): a closed session's observer and its watch entry have no future. Before
1406
+ // this, `forget()` had zero call sites and the map only grew.
1407
+ hub?.forget(id);
1353
1408
  return send(res, 200, { closed: id });
1354
1409
  }
1355
1410
  // not live: either a cold slot the operator wants gone, or an unknown id.
1356
1411
  // Purging is what makes the rail's "on disk · N" list manageable (M4).
1357
1412
  if (store.deleteSession(id)) {
1413
+ hub?.forget(id);
1358
1414
  emit(sessionsEvent());
1359
1415
  return send(res, 200, { purged: id });
1360
1416
  }
@@ -1367,6 +1423,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1367
1423
  // had no way to make it.
1368
1424
  try {
1369
1425
  const info = await mgr.setArchived(id, true);
1426
+ hub?.forget(id);
1370
1427
  emit(sessionsEvent());
1371
1428
  return send(res, 200, info);
1372
1429
  } catch (e) {
@@ -1457,7 +1514,7 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1457
1514
  const attachments = Array.isArray(body.attachments) ? (body.attachments as PromptAttachment[]) : [];
1458
1515
  if (!text && !attachments.length) return send(res, 400, { error: "empty prompt" });
1459
1516
  // fire & forget: stream arrives via WS; client watches turn-start/end
1460
- void runPrompt(id, text, attachments, body.interrupt === true);
1517
+ void runPrompt(id, text, attachments, body.interrupt === true, typeof body.pid === "string" ? body.pid : undefined);
1461
1518
  return send(res, 202, { ok: true });
1462
1519
  }
1463
1520
  if (req.method === "POST" && sub === "/workspace") {
@@ -1525,6 +1582,86 @@ async function post(path){const r=await fetch(path,{method:'POST'});alert(r.ok?'
1525
1582
  return send(res, 409, { error: msg });
1526
1583
  }
1527
1584
  }
1585
+ if (req.method === "POST" && sub === "/speech-summary") {
1586
+ /** The endpoint this session's OWN model lives on. Read per request: the operator can
1587
+ * switch the model from the toolbar and the next long press must follow it. */
1588
+ const sessionRoute = (): { route: SpeechRoute | null; why: string } => {
1589
+ const row = store.getSession(id);
1590
+ if (!row) return { route: null, why: "会话不在库里" };
1591
+ const be = store.listBackends().find((b) => b.id === row.backend);
1592
+ const models = (row.models ?? {}) as { currentModelId?: string | null };
1593
+ // The provider keys live in this box's agent home, which is a property of the runtime —
1594
+ // not of the vendor that happens to be driving this session. A backend that owns no home
1595
+ // (mock, qoder) therefore still resolves a `deepseek:…` model through the Hermes home.
1596
+ const home = (be ? planFor(be).home : null) ?? HERMES_DEFAULT_HOME;
1597
+ return routeForModel(models.currentModelId ?? null, home);
1598
+ };
1599
+ // 语音总结 (the message speaker's long press): ONE stored message, made listenable.
1600
+ //
1601
+ // No agent, no fork, no history: the text goes to the voice endpoint's chat surface, the
1602
+ // same one ASR already posts to. The BODY of the message is read from the store by key —
1603
+ // the browser does not get to say what reaches the model, and the server's copy is what the
1604
+ // operator actually saw on screen.
1605
+ const cfg = summarySettings();
1606
+ if (!cfg.enabled) return send(res, 409, { error: "语音总结已关闭(设置 → 语音总结)", code: "disabled" });
1607
+ const body = await readJson(req);
1608
+ const key = String(body.key ?? "").trim();
1609
+ if (!key) return send(res, 400, { error: "key is required" });
1610
+ const row = store.messageByKey(id, key);
1611
+ if (!row) return send(res, 404, { error: `no such message: ${key}` });
1612
+ // 字数档 (issue from the operator, 2026-10-18): the message speaker's long press offers
1613
+ // 100/150/200 字 next to 「按设置」. The count is the ONE thing that varies per call, and it
1614
+ // rides the prompt's existing `{maxChars}` placeholder — so this is the only place it has to
1615
+ // be honoured. Clamped to the same range the settings page validates, so a crafted body
1616
+ // cannot ask for a 10k-character invoice.
1617
+ const asked = Math.round(Number(body.maxChars));
1618
+ const [clo, chi] = SUMMARY_CHARS_RANGE;
1619
+ const maxChars = Number.isFinite(asked) && asked >= clo && asked <= chi ? asked : cfg.maxChars;
1620
+ // Judge "short enough to just read" on what would actually be SPOKEN: voice.ts drops code
1621
+ // blocks, and a 20-line diff must not count as a long message worth a model call.
1622
+ const spoken = textOf(row.payload).replace(/```[\s\S]*?```/g, " ").replace(/\s+/g, " ").trim();
1623
+ if (!spoken) return send(res, 409, { error: "这条消息没有可以念的文字", code: "empty" });
1624
+ if (spoken.length <= maxChars) {
1625
+ return send(res, 200, { ok: true, via: "verbatim", text: spoken, chars: spoken.length, ms: 0, maxChars });
1626
+ }
1627
+ try {
1628
+ // The operator's rule: bill this to the model he is already talking to. The voice
1629
+ // endpoint stays as the fallback (and is the whole route when followSession is off).
1630
+ const own = cfg.followSession ? sessionRoute() : { route: null, why: "" };
1631
+ const model = own.route?.model || cfg.model || SUMMARY_MODEL_DEFAULT;
1632
+ const r = await summarizeForSpeech({
1633
+ text: spoken,
1634
+ maxChars,
1635
+ prompt: cfg.prompt.trim() || SUMMARY_PROMPT_DEFAULT,
1636
+ model,
1637
+ timeoutSec: cfg.timeoutSec,
1638
+ endpoint: own.route ? { baseUrl: own.route.baseUrl, apiKey: own.route.apiKey } : undefined,
1639
+ });
1640
+ // Newlines are the one thing the model keeps ignoring: 「不要换行」 in the prompt is a
1641
+ // request, so the answer is normalised here — the note clamps to two lines and the TTS
1642
+ // chunker reads sentence by sentence, and a stray blank line costs both of them.
1643
+ const text = r.text.replace(/\s*\n+\s*/g, " ").replace(/\s{2,}/g, " ").trim();
1644
+ return send(res, 200, {
1645
+ ok: true, via: "model", text, chars: text.length,
1646
+ // echo the bucket: the note prints 「约 150 字」 and a summary that silently used the
1647
+ // configured number would make the menu look broken
1648
+ maxChars,
1649
+ ms: r.ms, truncated: r.truncated, model,
1650
+ // which account this call was billed to — the note prints it, and a fallback that
1651
+ // silently used the OTHER provider would be a lie about the cost
1652
+ route: own.route ? "session" : "voice",
1653
+ provider: own.route?.provider ?? "dashscope",
1654
+ routeNote: own.route ? own.route.source : own.why,
1655
+ });
1656
+ } catch (e) {
1657
+ // 409 = nothing is configured, 504 = we gave up waiting, 502 = the endpoint said no.
1658
+ // None of them may be papered over with the original text: a fallback that looks like a
1659
+ // summary is worse than an honest failure the UI can offer to retry.
1660
+ const err = e as { code?: string; message?: string };
1661
+ const status = err.code === "not_configured" ? 409 : err.code === "timeout" ? 504 : 502;
1662
+ return send(res, status, { error: String(err.message ?? e), code: err.code ?? "upstream" });
1663
+ }
1664
+ }
1528
1665
  if (req.method === "POST" && sub === "/fork") {
1529
1666
  try {
1530
1667
  // forking a cold session resumes it first (the call has to reach a live agent)
@@ -1662,6 +1799,9 @@ const handleUpgrade = (req: IncomingMessage, socket: Duplex, head: Buffer) => {
1662
1799
  httpServer.on("upgrade", handleUpgrade);
1663
1800
  wss.on("connection", (ws) => {
1664
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; });
1665
1805
  clients.set(clientId, { ws, lastSeen: new Map() });
1666
1806
  const sendEvt = (e: ServerEvent) => {
1667
1807
  if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(e));
@@ -1698,7 +1838,7 @@ wss.on("connection", (ws) => {
1698
1838
  break;
1699
1839
  }
1700
1840
  case "prompt":
1701
- 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);
1702
1842
  break;
1703
1843
  case "cancel":
1704
1844
  await mgr.cancel(cmd.sessionId);
@@ -1732,6 +1872,35 @@ wss.on("connection", (ws) => {
1732
1872
  ws.on("close", () => clients.delete(clientId));
1733
1873
  });
1734
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
+
1735
1904
  // ---- the workspace panel's terminal -----------------------------------------
1736
1905
  // A shell is a stream, so it gets its own socket instead of riding the event bus:
1737
1906
  // one socket = one shell, and closing the socket (or the tab) kills the shell.