agentus 0.10.0 → 0.11.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 CHANGED
@@ -53,10 +53,11 @@ depth**, a permission mode that survives a restart, the **context-budget** field
53
53
  channel** (memory recall/save hints in the message stream), per-process skill roots, **project
54
54
  skills made visible and switchable** (a repo's `.agents/skills` shows up as a `project_skills`
55
55
  option instead of being skipped in silence), **background-job completion wake-ups** (a finished
56
- background process comes back as a turn of its own), and a **DeepSeek web backend** (search +
56
+ background process comes back as a turn of its own), an **ACP session that writes its own log files** (`agent.log`/`errors.log`, so a
57
+ cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
57
58
  extract on the model key) — come from the fork's integration build instead. That is a five-line install:
58
59
  [§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
59
- `agentus/v0.22.0/merge-20261021-1400`, on Hermes `v0.22.0`.
60
+ `agentus/v0.22.0/merge-20261023-0522`, on Hermes `v0.22.0`.
60
61
 
61
62
  The first slot you start will use the Hermes home **your own agent already uses**
62
63
  (`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
package/README.zh-CN.md CHANGED
@@ -47,10 +47,11 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
47
47
  **上下文预算**输入框、把记忆召回 / 存记忆提示送进消息流的 **status 通道**、按进程指定的技能根、
48
48
  **项目 skill 可见可设**(仓库的 `.agents/skills` 会以 `project_skills` 选项出现,而不是被无声跳过)、
49
49
  **后台任务完成唤醒**(后台进程跑完会自己开一个回合回来),
50
+ **ACP 会话自己写日志**(`agent.log`/`errors.log`,驾驶舱里出的问题事后可查),
50
51
  以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
51
52
  装法就五行:
52
53
  [§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
53
- `agentus/v0.22.0/merge-20261021-1400`,基座 Hermes `v0.22.0`。
54
+ `agentus/v0.22.0/merge-20261023-0522`,基座 Hermes `v0.22.0`。
54
55
 
55
56
  你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
56
57
  指定了另一个 —— 见下面的「HERMES_HOME」。
@@ -60,7 +60,7 @@ ref and gets rebuilt, while a tag never moves — so pin the tag. The `agentus/`
60
60
  ours apart from upstream's own tags (`v0.22.0`, `v0.22.0+canary.*`), and the timestamp in the
61
61
  last segment sorts, so "newest" is a `sort -V`.
62
62
 
63
- **Current integration build: `agentus/v0.22.0/merge-20261021-1400`** — the newest tag on the fork,
63
+ **Current integration build: `agentus/v0.22.0/merge-20261023-0522`** — the newest tag on the fork,
64
64
  built on Hermes `v0.22.0`. This line is for orientation only, not the source of truth: the
65
65
  `TAG=$(…)` step below resolves the newest one on every run, so read it there if this sentence
66
66
  ever looks stale.
@@ -52,7 +52,7 @@ agentus/<hermes 内部版本>/merge-<YYYYMMDD-HHMM>
52
52
  tag。`agentus/` 这个前缀也把它和上游自己的 tag(`v0.22.0`、`v0.22.0+canary.*`)分开;最后一段是时间戳,
53
53
  可排序,所以「最新」就是一次 `sort -V`。
54
54
 
55
- **当前集成构建:`agentus/v0.22.0/merge-20261021-1400`** —— fork 上最新的那个 tag,基座 Hermes
55
+ **当前集成构建:`agentus/v0.22.0/merge-20261023-0522`** —— fork 上最新的那个 tag,基座 Hermes
56
56
  `v0.22.0`。这行只为一眼看个大概,不是权威:下面的 `TAG=$(…)` 每次都会自己解析出最新,觉得这行过期就
57
57
  以它为准。
58
58
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentus",
3
- "version": "0.10.0",
3
+ "version": "0.11.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": {
@@ -78,6 +78,7 @@
78
78
  "live-stream": "tsx scripts/qa/live-stream.mts",
79
79
  "open-target": "tsx scripts/qa/open-target.mts",
80
80
  "rail-recent": "tsx scripts/qa/rail-recent.mts",
81
+ "rail-breakpoints": "node scripts/qa/rail-breakpoints.mjs",
81
82
  "permission-echo": "node scripts/qa/permission-echo.mjs",
82
83
  "turn-pulse": "tsx scripts/qa/turn-pulse.mts",
83
84
  "resume-tail": "tsx scripts/qa/resume-tail.mts",
@@ -0,0 +1,141 @@
1
+ // WHICH agent processes belong to THIS data directory.
2
+ //
3
+ // `reclaimOrphans()` used to ask one question — `process.kill(pid, 0)`, "is this number alive?" — and
4
+ // then SIGKILL it. Two ways that ends a session that was perfectly healthy:
5
+ // 1. pid reuse: the agent died with the previous server, the number went to something else, and the
6
+ // next boot killed an unrelated process;
7
+ // 2. two servers on one data dir (incident 2026-10-22 §7): the second one reclaimed the first one's
8
+ // agents — every minute, forever, because it kept failing to listen and retrying.
9
+ //
10
+ // A pid is a weak identity, so each spawn now leaves a receipt next to the store:
11
+ // <DATA_DIR>/agents/<pid>.json = { pid, sessionId, dataDir, serverInstance, startedAt, cmd }
12
+ // and a reclaim only kills when the receipt proves the process is an agent of this data dir from a
13
+ // PREVIOUS server run. No receipt (rows written by older builds) → the argv must still look like an
14
+ // ACP agent; otherwise the pid is dropped with a warning instead of being killed.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { commandLineOf, pidAlive } from "../instance-lock.js";
19
+
20
+ export interface AgentReceipt {
21
+ pid: number;
22
+ sessionId: string;
23
+ dataDir: string;
24
+ /** The server run that spawned it — the same value as the instance lock's `instanceId`. */
25
+ serverInstance: string;
26
+ startedAt: number;
27
+ cmd: string;
28
+ }
29
+
30
+ export interface Verdict {
31
+ /** Nothing to kill: the pid is already gone. Not an error, never worth a warning. */
32
+ gone: boolean;
33
+ /** Safe to SIGKILL: the receipt proves ownership. */
34
+ kill: boolean;
35
+ /** Human-readable reason, logged either way (the operator's next 5-minute diagnosis). */
36
+ why: string;
37
+ }
38
+
39
+ function agentsDir(dataDir: string): string {
40
+ return path.join(dataDir, "agents");
41
+ }
42
+
43
+ function receiptPath(dataDir: string, pid: number): string {
44
+ return path.join(agentsDir(dataDir), `${pid}.json`);
45
+ }
46
+
47
+ /** Best effort: bookkeeping must never fail a session. */
48
+ export function recordAgentOwner(dataDir: string | null, receipt: Omit<AgentReceipt, "dataDir"> & { dataDir?: string }): void {
49
+ const dir = dataDir ?? receipt.dataDir;
50
+ if (!dir) return;
51
+ try {
52
+ fs.mkdirSync(agentsDir(dir), { recursive: true, mode: 0o700 });
53
+ fs.writeFileSync(
54
+ receiptPath(dir, receipt.pid),
55
+ JSON.stringify({ ...receipt, dataDir: path.resolve(dir) }, null, 2),
56
+ { mode: 0o600 },
57
+ );
58
+ } catch { /* an unreadable receipt only makes reclaim more conservative */ }
59
+ }
60
+
61
+ export function forgetAgentOwner(dataDir: string | null, pid: number | null | undefined): void {
62
+ if (!dataDir || !pid) return;
63
+ try { fs.unlinkSync(receiptPath(dataDir, pid)); } catch { /* already gone */ }
64
+ }
65
+
66
+ function readReceipt(dataDir: string, pid: number): AgentReceipt | null {
67
+ try {
68
+ const j = JSON.parse(fs.readFileSync(receiptPath(dataDir, pid), "utf8")) as Partial<AgentReceipt>;
69
+ if (typeof j.pid !== "number") return null;
70
+ return {
71
+ pid: j.pid,
72
+ sessionId: typeof j.sessionId === "string" ? j.sessionId : "",
73
+ dataDir: typeof j.dataDir === "string" ? j.dataDir : "",
74
+ serverInstance: typeof j.serverInstance === "string" ? j.serverInstance : "",
75
+ startedAt: typeof j.startedAt === "number" ? j.startedAt : 0,
76
+ cmd: typeof j.cmd === "string" ? j.cmd : "",
77
+ };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
82
+
83
+ /** Does this argv look like a CLI coding agent run over ACP? Fallback for rows without a receipt. */
84
+ export function looksLikeAcpAgent(cmd: string | null): boolean {
85
+ if (!cmd) return false;
86
+ if (/mock[\\/]agent\.mjs\b/.test(cmd)) return true; // the bundled mock backend
87
+ if (/\bqodercli\b/.test(cmd)) return true;
88
+ if (/\bhermes\b/.test(cmd) && /\bacp\b/.test(cmd)) return true;
89
+ if (/\b(claude-code|claude|opencode|codex)\b/.test(cmd) && /\bacp\b/.test(cmd)) return true;
90
+ return false;
91
+ }
92
+
93
+ /**
94
+ * Decide whether `pid` (recorded on session `sessionId`) may be killed by the startup reclaim.
95
+ *
96
+ * `serverInstance` is OUR instance id: a receipt naming it means the process is ours, so it is not an
97
+ * orphan at all (this also makes the call safe to use while running).
98
+ */
99
+ export function verifyAgentOwner(
100
+ dataDir: string | null,
101
+ pid: number,
102
+ sessionId: string,
103
+ serverInstance: string,
104
+ ): Verdict {
105
+ if (!pidAlive(pid)) return { gone: true, kill: false, why: `pid ${pid} already gone` };
106
+
107
+ const cmd = commandLineOf(pid);
108
+ if (!cmd) return { gone: true, kill: false, why: `pid ${pid} vanished while checking` };
109
+
110
+ if (!dataDir) {
111
+ // No data dir wired in (unit tests, odd embedders): fall back to the argv shape, and say so.
112
+ return looksLikeAcpAgent(cmd)
113
+ ? { gone: false, kill: true, why: `pid ${pid} matches an ACP agent (no data dir to compare)` }
114
+ : { gone: false, kill: false, why: `pid ${pid} is not an ACP agent (${cmd.slice(0, 120)})` };
115
+ }
116
+
117
+ const receipt = readReceipt(dataDir, pid);
118
+ if (receipt) {
119
+ if (receipt.dataDir && path.resolve(receipt.dataDir) !== path.resolve(dataDir)) {
120
+ return { gone: false, kill: false, why: `pid ${pid} was spawned by the server of ${receipt.dataDir}` };
121
+ }
122
+ if (receipt.sessionId && receipt.sessionId !== sessionId) {
123
+ return { gone: false, kill: false, why: `pid ${pid} belongs to session ${receipt.sessionId}, not ${sessionId}` };
124
+ }
125
+ if (receipt.serverInstance && receipt.serverInstance === serverInstance) {
126
+ return { gone: false, kill: false, why: `pid ${pid} was spawned by THIS server run` };
127
+ }
128
+ if (!looksLikeAcpAgent(cmd)) {
129
+ // Receipt says ours, argv says something else: the pid was recycled after all. Trust the argv.
130
+ return { gone: false, kill: false, why: `pid ${pid} no longer runs an ACP agent (${cmd.slice(0, 120)})` };
131
+ }
132
+ return { gone: false, kill: true, why: `pid ${pid} = agent of session ${sessionId} from a previous run (receipt)` };
133
+ }
134
+
135
+ // Receipt-less row (written before this file existed, or the file was lost). Judge by argv only:
136
+ // a non-agent process must NEVER be killed for a stale database row.
137
+ if (!looksLikeAcpAgent(cmd)) {
138
+ return { gone: false, kill: false, why: `pid ${pid} is not an ACP agent (${cmd.slice(0, 120)})` };
139
+ }
140
+ return { gone: false, kill: true, why: `pid ${pid} matches an ACP agent (no receipt: legacy row)` };
141
+ }
@@ -15,6 +15,8 @@ import type {
15
15
  RequestPermissionResponse,
16
16
  } from "@agentclientprotocol/sdk";
17
17
  import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
18
+ import { forgetAgentOwner, recordAgentOwner, verifyAgentOwner } from "./agent-owner.js";
19
+ import { thinkingOptionOf, voiceModeBlock } from "./voice-mode.js";
18
20
  import {
19
21
  byActivity,
20
22
  coldPage as pageCold,
@@ -287,10 +289,17 @@ export class SessionManager {
287
289
  * to hibernate. Default ON — this is the CLI/gateway behaviour, and the whole point is that the
288
290
  * agent takes its own result instead of the operator noticing a stale screen. */
289
291
  #completionWake: () => boolean = () => true;
292
+ /** The data directory this server owns, and the id of THIS run. Both come from the instance lock
293
+ * in index.ts; together they are the only evidence that a recorded pid is an agent OF THIS STORE
294
+ * from a PREVIOUS run — see `agent-owner.ts` and the 2026-10-22 incident. */
295
+ #dataDir: string | null = null;
296
+ #instanceId = "";
290
297
 
291
- constructor(store: Store, emit: Emitter) {
298
+ constructor(store: Store, emit: Emitter, opts: { dataDir?: string | null; instanceId?: string } = {}) {
292
299
  this.#store = store;
293
300
  this.#emit = emit;
301
+ this.#dataDir = opts.dataDir ?? null;
302
+ this.#instanceId = opts.instanceId ?? "";
294
303
  }
295
304
 
296
305
  /** Wire the operator's 「后台任务完成唤醒」 switch in (index.ts → settings.agent.completionWake). */
@@ -304,6 +313,15 @@ export class SessionManager {
304
313
  this.#planHint = read;
305
314
  }
306
315
 
316
+ /** 语音通话(issue #48):which session is on a call RIGHT NOW, or null when nobody is.
317
+ * Wired from index.ts to the notify centre's presence — 「在打电话」 already has exactly one
318
+ * answer in this process, and a second source would drift. Optional: with nothing wired the
319
+ * manager never injects anything, which is the pre-#48 behaviour. */
320
+ #callLive: () => string | null = () => null;
321
+ setCallLiveSource(read: () => string | null): void {
322
+ this.#callLive = read;
323
+ }
324
+
307
325
  list(): SessionInfo[] {
308
326
  // Most recently CHATTED first — the cockpit's left rail is a launch pad, not a log file, and
309
327
  // creation order is the wrong key for that: the session you talked to a minute ago is the
@@ -361,17 +379,44 @@ export class SessionManager {
361
379
  return this.#sessions.get(id)?.busy ?? false;
362
380
  }
363
381
 
364
- /** Reclaim orphans from a previous server run (AC5): kill recorded pids. */
382
+ /** Reclaim orphans from a previous server run (AC5): kill recorded pids — but only the ones that are
383
+ * provably agents of THIS data dir (see `agent-owner.ts`).
384
+ *
385
+ * A pid in the database is a claim, not proof. Three things it can be:
386
+ * · an agent of this store that outlived the previous server → kill it, it holds the browser's
387
+ * session id and nothing will ever answer through it again;
388
+ * · a pid that died with the previous server and got recycled by something else → leave it alone,
389
+ * say so in the log, and drop it from the row;
390
+ * · an agent of ANOTHER server on the same data dir (the 2026-10-22 accident: a second instance
391
+ * that could not even listen reclaimed the live cockpit's slots on every retry) → leave it
392
+ * alone. The instance lock in index.ts refuses that second server outright; this is the second
393
+ * line of defence, for the case where the claim was lost or hand-edited.
394
+ *
395
+ * Never throws: the boot must not fail because one row is odd. */
365
396
  reclaimOrphans(): number {
366
397
  let killed = 0;
398
+ let skipped = 0;
367
399
  for (const row of this.#store.listSessions(true)) {
368
400
  if (row.pid == null) continue;
369
- try {
370
- process.kill(row.pid, 0); // alive?
371
- process.kill(row.pid, "SIGKILL");
372
- killed++;
373
- } catch {
374
- /* already gone */
401
+ const pid = row.pid;
402
+ const verdict = verifyAgentOwner(this.#dataDir, pid, row.id, this.#instanceId);
403
+ if (verdict.kill) {
404
+ try {
405
+ process.kill(pid, "SIGKILL");
406
+ killed++;
407
+ forgetAgentOwner(this.#dataDir, pid);
408
+ console.log(`[agentus] reclaim: killed pid ${pid} (session ${row.id}) — ${verdict.why}`);
409
+ } catch (err) {
410
+ console.log(`[agentus] reclaim: pid ${pid} (session ${row.id}) was gone before the kill — ${(err as Error).message}`);
411
+ }
412
+ } else if (verdict.gone) {
413
+ console.log(`[agentus] reclaim: nothing to kill for session ${row.id} — ${verdict.why}`);
414
+ forgetAgentOwner(this.#dataDir, pid);
415
+ } else {
416
+ // Alive but not provably ours. Do NOT kill; do drop the pid so the next boot does not
417
+ // re-examine (and re-endanger) the same number.
418
+ skipped++;
419
+ console.warn(`[agentus] reclaim: SKIPPED pid ${pid} (session ${row.id}) — ${verdict.why}`);
375
420
  }
376
421
  if (row.status !== "closed") {
377
422
  // Only a turn that was ACTUALLY in flight can be reported as interrupted. A `ready` session
@@ -385,9 +430,86 @@ export class SessionManager {
385
430
  if (wasRunning) this.#finalizePlan(row.id, "interrupted");
386
431
  }
387
432
  }
433
+ if (skipped) {
434
+ console.warn(
435
+ `[agentus] reclaim: ${skipped} recorded pid(s) left running — they are not this data dir's agents`
436
+ + ` (${this.#dataDir ?? "no data dir"})`,
437
+ );
438
+ }
388
439
  return killed;
389
440
  }
390
441
 
442
+ /** 语音通话的三种时刻(issue #48)。Called from the presence path (index.ts) whenever the
443
+ * cockpit reports who is on a call.
444
+ *
445
+ * A call has no "start"/"end" message of its own — the page only says, every 30 s, "I am on a
446
+ * call with X". So transitions are DERIVED from that one fact, and each transition owes the agent
447
+ * a block (entering: the style constraint; leaving: an explanation of what the last N turns were).
448
+ * Nothing is sent at the moment of the transition itself: a bare prompt would make the agent
449
+ * answer 「好的,我了解了」 into the microphone. The block rides the next real prompt.
450
+ */
451
+ async syncCallState(): Promise<void> {
452
+ const live = this.#callLive();
453
+ for (const v of this.#store.listInCall()) {
454
+ if (v.sessionId !== live) await this.#endCall(v.sessionId);
455
+ }
456
+ if (live && !this.#store.getVoiceMode(live).inCall) await this.#beginCall(live);
457
+ }
458
+
459
+ async #beginCall(id: string): Promise<void> {
460
+ if (!this.#store.getSession(id)) return; // a call with a session this store has never seen
461
+ const s = this.#sessions.get(id);
462
+ const choice = s ? thinkingOptionOf(s.info.configOptions) : null;
463
+ this.#store.upsertVoiceMode(id, {
464
+ inCall: true, boundary: "enter", turns: 0,
465
+ thoughtBefore: choice?.current ?? null, thoughtTouched: false,
466
+ });
467
+ // A call is full-duplex: waiting 20 s for a first token reads as a dead line to the operator.
468
+ // Lower the dial for the call and put the operator's own choice back on hang-up (see #endCall).
469
+ if (s && choice && choice.lowest && choice.current !== choice.lowest) {
470
+ await this.setConfig(id, choice.id, choice.lowest, { internal: true }).catch(() => { /* the block still goes out */ });
471
+ }
472
+ }
473
+
474
+ async #endCall(id: string): Promise<void> {
475
+ const v = this.#store.getVoiceMode(id);
476
+ this.#store.upsertVoiceMode(id, { inCall: false, boundary: "exit" });
477
+ const s = this.#sessions.get(id);
478
+ // The operator's explicit pick wins: if they changed the dial DURING the call, do not undo it.
479
+ if (s && v.thoughtBefore && !v.thoughtTouched) {
480
+ const choice = thinkingOptionOf(s.info.configOptions);
481
+ if (choice) await this.setConfig(id, choice.id, v.thoughtBefore, { internal: true }).catch(() => { /* keep going */ });
482
+ }
483
+ this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
484
+ }
485
+
486
+ /** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
487
+ * inside the send path, and a slow store write must not delay the operator's words. */
488
+ #applyVoiceMode(s: LiveSession, blocks: unknown[]): void {
489
+ const live = this.#callLive();
490
+ let v = this.#store.getVoiceMode(s.info.id);
491
+ // Converge on the ONE authority (presence) in both directions: a page that closed mid-call never
492
+ // reports a hang-up, so the exit boundary must be derived here or the model stays on a call for
493
+ // ever; and a call that started while this slot was cold has no enter boundary recorded.
494
+ if (v.inCall && live !== s.info.id) {
495
+ v = this.#store.upsertVoiceMode(s.info.id, { inCall: false, boundary: "exit" });
496
+ } else if (!v.inCall && live === s.info.id) {
497
+ v = this.#store.upsertVoiceMode(s.info.id, { inCall: true, boundary: v.boundary ?? "enter", turns: 0 });
498
+ }
499
+ const block = voiceModeBlock(s.info.id, { inCall: v.inCall, boundary: v.boundary, turns: v.turns });
500
+ if (block) blocks.push(block);
501
+ if (v.boundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
502
+ if (v.inCall) this.#store.upsertVoiceMode(s.info.id, { turns: v.turns + 1 });
503
+ }
504
+
505
+ /** Bind a freshly spawned agent to (this data dir, this session, this server run). Bookkeeping only:
506
+ * a failure here makes a later reclaim more conservative, never a session fail. */
507
+ #stampAgentReceipt(pid: number, sessionId: string, cmd: string): void {
508
+ recordAgentOwner(this.#dataDir, {
509
+ pid, sessionId, startedAt: Date.now(), serverInstance: this.#instanceId, cmd,
510
+ });
511
+ }
512
+
391
513
  /** Last stderr lines from a session's agent (for diagnosing failed turns). */
392
514
  stderrTail(id: string, lines = 12): string {
393
515
  const s = this.#sessions.get(id);
@@ -529,6 +651,12 @@ export class SessionManager {
529
651
  throw new Error(why);
530
652
  }
531
653
 
654
+ const pid: number = child.pid;
655
+
656
+ // The receipt that makes a later reclaim provable (agent-owner.ts). Written BEFORE the handshake,
657
+ // because the handshake can fail and leave a running process behind.
658
+ this.#stampAgentReceipt(pid, id, `${spec.cmd} ${args.join(" ")}`);
659
+
532
660
  const live: LiveSession = {
533
661
  info: {
534
662
  id, backend, acpSessionId: null, cwd,
@@ -709,6 +837,8 @@ export class SessionManager {
709
837
  throw new Error(why);
710
838
  }
711
839
 
840
+ this.#stampAgentReceipt(child.pid, id, `${spec.cmd} ${args.join(" ")}`);
841
+
712
842
  const live: LiveSession = {
713
843
  info: {
714
844
  id, backend: row.backend, acpSessionId: row.acpSessionId, cwd: row.cwd,
@@ -847,6 +977,17 @@ export class SessionManager {
847
977
  .catch(() => {});
848
978
  }
849
979
  }
980
+ // 通话中 resume(页面刷新、空闲回收后再唤起):把通话档位重新压下去,否则「恢复的会话」会带着
981
+ // 用户平时的高思考档沉默十几秒 —— 正是 issue #48 要消除的那种卡死手感。
982
+ {
983
+ const vm = this.#store.getVoiceMode(id);
984
+ if (vm.inCall) {
985
+ const knob = thinkingOptionOf(live.info.configOptions);
986
+ if (knob && knob.current !== knob.lowest) {
987
+ await this.setConfig(id, knob.id, knob.lowest, { internal: true }).catch(() => { /* keep going */ });
988
+ }
989
+ }
990
+ }
850
991
  live.info.status = "ready";
851
992
  this.#updateSession(live);
852
993
  // a resumed slot is the same evidence as a fresh one: the row does work
@@ -1084,6 +1225,11 @@ export class SessionManager {
1084
1225
  if (reminder) blocks.push(reminder);
1085
1226
  }
1086
1227
  }
1228
+ // 语音通话模式(issue #48): entering, staying in, and leaving a call each owe the agent one
1229
+ // sentence it cannot infer — that its output is being SPOKEN, or that the speaking is over.
1230
+ // Rides the same `_meta`-tagged resource channel as the plan reminder; the operator's own words
1231
+ // are untouched (the transcript stores `text`, never the blocks).
1232
+ this.#applyVoiceMode(s, blocks);
1087
1233
  s.turnSeq += 1;
1088
1234
  s.busy = true;
1089
1235
  // The operator has spoken: from here anything the agent says is an ANSWER, not its own history
@@ -1254,10 +1400,19 @@ export class SessionManager {
1254
1400
  this.#updateSession(s);
1255
1401
  }
1256
1402
 
1257
- async setConfig(sessionId: string, configId: string, value: string | number | boolean): Promise<void> {
1403
+ async setConfig(sessionId: string, configId: string, value: string | number | boolean, opts: { internal?: boolean } = {}): Promise<void> {
1258
1404
  const s = this.#need(sessionId);
1259
1405
  if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
1260
1406
  const requested = String(value);
1407
+ // 通话中操作者自己调了思考档位 → 以他为准,挂断时不再还原(issue #48)。`internal` marks the
1408
+ // cockpit's own lowering/restoring, which must NOT count as the operator's choice.
1409
+ if (!opts.internal) {
1410
+ const vm = this.#store.getVoiceMode(sessionId);
1411
+ if (vm.inCall) {
1412
+ const knob = thinkingOptionOf(s.info.configOptions);
1413
+ if (knob && knob.id === configId) this.#store.upsertVoiceMode(sessionId, { thoughtTouched: true });
1414
+ }
1415
+ }
1261
1416
  let actual = requested;
1262
1417
  let rejected = false;
1263
1418
  try {
@@ -2202,6 +2357,8 @@ export class SessionManager {
2202
2357
  }
2203
2358
 
2204
2359
  #onChildExit(live: LiveSession, code: number | null, sig: string | null): void {
2360
+ // The process is gone, so is its claim: leave no receipt that a later boot would try to honour.
2361
+ forgetAgentOwner(this.#dataDir, live.child?.pid ?? live.info.pid);
2205
2362
  // If we initiated close, session was already removed — ignore.
2206
2363
  if (!this.#sessions.has(live.info.id)) return;
2207
2364
  // …and if we are REPLACING this slot's process (restart), the exit is the one we asked for: the
@@ -0,0 +1,126 @@
1
+ // 语音通话模式(issue #48):通话里 agent 的输出是要被 TTS 念出来的。
2
+ //
3
+ // The cockpit already told the agent *what* to answer; it never told it that the answer would be
4
+ // SPOKEN. So a question that naturally wants a table produced a table, and the operator heard
5
+ // 「竖线 横线 竖线」. This file owns the two things that fix it, and nothing else:
6
+ //
7
+ // · the three-state lifecycle (enter → constraint every turn → exit) as a pure decision, so the
8
+ // trickiest part — WHICH block is owed on THIS prompt — is testable without an agent;
9
+ // · the thinking-depth lookup, because a call is full-duplex and a 20-second first token is
10
+ // indistinguishable from a hang-up.
11
+ //
12
+ // The block rides the same channel as the plan reminder (`_meta`-tagged resource block), so the
13
+ // transcript keeps the operator's own words and nothing else.
14
+
15
+ /** The `_meta` key that marks a block as OURS — never the operator's words. */
16
+ export const VOICE_META_KEY = "agentus/voice-mode";
17
+
18
+ export type VoiceBoundary = "enter" | "exit";
19
+
20
+ /** What the store remembers about one session's call state. */
21
+ export interface VoiceState {
22
+ inCall: boolean;
23
+ /** A boundary that has not been delivered yet: it rides the NEXT prompt, then clears. */
24
+ boundary: VoiceBoundary | null;
25
+ /** How many prompts were spoken inside this call — the exit block quotes it. */
26
+ turns: number;
27
+ }
28
+
29
+ /** 进入通话:边界 + 约束合一(一次说完,别让模型只记住半句)。 */
30
+ export const VOICE_ENTER_TEXT = [
31
+ "现在开始语音通话:你接下来的回复会被 TTS 朗读出来,用户是用耳朵听的。",
32
+ "请用口语化的短句回答(几句话以内),不要表格、代码块、列表符号、链接和 Markdown 标记;",
33
+ "需要列举时用「第一/第二」这样的口语说法。数字、专有名词、路径要念得出来。",
34
+ ].join("");
35
+
36
+ /** 通话中每一轮都带:单轮指令会被后续对话冲淡,也会被压缩摘要吃掉。 */
37
+ export const VOICE_STEADY_TEXT =
38
+ "(仍在语音通话中:继续用口语短句回答,不要表格、代码块、列表符号。)";
39
+
40
+ /** 退出通话:不只说「结束了」,还要解释刚才那段的性质 —— 否则模型会接着用通话腔回文字提问。 */
41
+ export function voiceExitText(turns: number): string {
42
+ const n = Math.max(0, Math.floor(turns));
43
+ return [
44
+ `语音通话已结束(上面${n > 0 ? ` ${n} 轮` : "的对话"}是通话内容:你的回复被朗读出来,用户的话经语音识别转写,`,
45
+ "可能有识别噪声、断句和口语碎片,不必逐字当真)。",
46
+ "现在恢复文字对话:正常输出格式(表格、代码块、列表都可以用),按文字场景回答。",
47
+ ].join("");
48
+ }
49
+
50
+ /**
51
+ * The block owed on THIS prompt, or null when nothing is owed.
52
+ *
53
+ * Three cases, exactly as the issue specifies:
54
+ * enter boundary → enter text + steady text (合一)
55
+ * in call, no boundary → steady text only (每轮)
56
+ * exit boundary → exit text (一次性)
57
+ */
58
+ export function voiceModeBlock(sessionId: string, st: VoiceState): unknown | null {
59
+ const lines: string[] = [];
60
+ if (st.boundary === "enter") {
61
+ lines.push(VOICE_ENTER_TEXT);
62
+ if (st.inCall) lines.push(VOICE_STEADY_TEXT);
63
+ } else if (st.inCall) {
64
+ lines.push(VOICE_STEADY_TEXT);
65
+ } else if (st.boundary === "exit") {
66
+ lines.push(voiceExitText(st.turns));
67
+ }
68
+ if (!lines.length) return null;
69
+ return {
70
+ type: "resource",
71
+ resource: {
72
+ uri: `agentus://voice/${sessionId}`,
73
+ mimeType: "text/plain",
74
+ text: lines.join("\n"),
75
+ },
76
+ _meta: { [VOICE_META_KEY]: true },
77
+ };
78
+ }
79
+
80
+ /** Is this a block we injected (as opposed to something the operator typed)? */
81
+ export function isVoiceBlock(block: unknown): boolean {
82
+ const meta = (block as { _meta?: Record<string, unknown> } | null)?._meta;
83
+ return Boolean(meta && meta[VOICE_META_KEY] === true);
84
+ }
85
+
86
+ export interface ThinkingChoice {
87
+ id: string;
88
+ /** The operator's (or the session's) current value. */
89
+ current: string;
90
+ /** The cheapest/fastest value the backend offers — what a call switches to. */
91
+ lowest: string;
92
+ }
93
+
94
+ /**
95
+ * Find the session's thinking-depth knob in the options the AGENT advertised.
96
+ *
97
+ * There is no schema for this: ACP options are free-form, and every backend names its own. The two
98
+ * signals that survive across backends (Hermes, the mock, and the adapters we have seen) are the
99
+ * semantic `category: "thought_level"` and an id/name containing think/reason/effort. If neither is
100
+ * present the session simply has no such knob and call mode must not invent one.
101
+ */
102
+ export function thinkingOptionOf(options: unknown): ThinkingChoice | null {
103
+ if (!Array.isArray(options)) return null;
104
+ const list = options as { id?: unknown; name?: unknown; category?: unknown; currentValue?: unknown; options?: unknown }[];
105
+ const found = list.find((o) => {
106
+ if (!o || typeof o.id !== "string") return false;
107
+ if (o.category === "thought_level") return true;
108
+ const hay = `${o.id} ${typeof o.name === "string" ? o.name : ""}`;
109
+ return /think|reason|effort/i.test(hay) && Array.isArray(o.options) && o.options.length > 1;
110
+ });
111
+ if (!found) return null;
112
+ const values = (Array.isArray(found.options) ? found.options : [])
113
+ .map((o) => {
114
+ const v = o as { value?: unknown; name?: unknown };
115
+ return { value: typeof v.value === "string" ? v.value : "", name: typeof v.name === "string" ? v.name : "" };
116
+ })
117
+ .filter((o) => o.value);
118
+ if (values.length < 2) return null;
119
+ const lowest = values.find((v) => /^(off|none|disabled|min|minimal|low)$/i.test(v.value)
120
+ || /^(off|none|disabled|low|最低|关闭)$/i.test(v.name))?.value ?? values[0].value;
121
+ return {
122
+ id: found.id as string,
123
+ current: typeof found.currentValue === "string" ? found.currentValue : "",
124
+ lowest,
125
+ };
126
+ }