agentus 0.10.1 → 0.11.1
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/installation.md +1 -1
- package/docs/installation.zh-CN.md +1 -1
- package/package.json +1 -1
- package/packages/server/src/acp/agent-owner.ts +141 -0
- package/packages/server/src/acp/session-manager.ts +236 -9
- package/packages/server/src/acp/voice-mode.ts +145 -0
- package/packages/server/src/index.ts +43 -2
- package/packages/server/src/instance-lock.ts +235 -0
- package/packages/server/src/notify/center.ts +19 -0
- package/packages/server/src/store/store.ts +95 -0
- package/packages/web/dist/assets/{index-C9RyEkdh.js → index-DKXkp6hP.js} +37 -37
- package/packages/web/dist/index.html +1 -1
package/README.md
CHANGED
|
@@ -57,7 +57,7 @@ background process comes back as a turn of its own), an **ACP session that write
|
|
|
57
57
|
cockpit-side failure is diagnosable afterwards), and a **DeepSeek web backend** (search +
|
|
58
58
|
extract on the model key) — come from the fork's integration build instead. That is a five-line install:
|
|
59
59
|
[§ Which Hermes to run](docs/installation.md#which-hermes-to-run) — currently the tag
|
|
60
|
-
`agentus/v0.22.0/merge-
|
|
60
|
+
`agentus/v0.22.0/merge-20261023-0540`, on Hermes `v0.22.0`.
|
|
61
61
|
|
|
62
62
|
The first slot you start will use the Hermes home **your own agent already uses**
|
|
63
63
|
(`~/.hermes`) unless the backend row names another one — see "HERMES_HOME" below.
|
package/README.zh-CN.md
CHANGED
|
@@ -51,7 +51,7 @@ web bundle、配套 APK —— 然后直接退出,不启动任何东西)。
|
|
|
51
51
|
以及 **DeepSeek 的 web 后端**(搜索 + 正文抓取,共用模型 key)—— 来自 fork 的集成构建(tag)。
|
|
52
52
|
装法就五行:
|
|
53
53
|
[§ 该跑哪份 Hermes](docs/installation.zh-CN.md#该跑哪份-hermes) —— 当前是 tag
|
|
54
|
-
`agentus/v0.22.0/merge-
|
|
54
|
+
`agentus/v0.22.0/merge-20261023-0540`,基座 Hermes `v0.22.0`。
|
|
55
55
|
|
|
56
56
|
你启动的第一个 slot 会使用**你自己的 agent 已经在用的**那个 Hermes home(`~/.hermes`),除非后端那一行
|
|
57
57
|
指定了另一个 —— 见下面的「HERMES_HOME」。
|
package/docs/installation.md
CHANGED
|
@@ -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-
|
|
63
|
+
**Current integration build: `agentus/v0.22.0/merge-20261023-0540`** — 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-
|
|
55
|
+
**当前集成构建:`agentus/v0.22.0/merge-20261023-0540`** —— 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.
|
|
3
|
+
"version": "0.11.1",
|
|
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": {
|
|
@@ -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, voiceAppliesToPrompt, 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,17 @@ 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
|
+
/** Serialises call-state transitions (see syncCallState). */
|
|
322
|
+
#callSync: Promise<void> = Promise.resolve();
|
|
323
|
+
setCallLiveSource(read: () => string | null): void {
|
|
324
|
+
this.#callLive = read;
|
|
325
|
+
}
|
|
326
|
+
|
|
307
327
|
list(): SessionInfo[] {
|
|
308
328
|
// Most recently CHATTED first — the cockpit's left rail is a launch pad, not a log file, and
|
|
309
329
|
// creation order is the wrong key for that: the session you talked to a minute ago is the
|
|
@@ -361,17 +381,44 @@ export class SessionManager {
|
|
|
361
381
|
return this.#sessions.get(id)?.busy ?? false;
|
|
362
382
|
}
|
|
363
383
|
|
|
364
|
-
/** Reclaim orphans from a previous server run (AC5): kill recorded pids
|
|
384
|
+
/** Reclaim orphans from a previous server run (AC5): kill recorded pids — but only the ones that are
|
|
385
|
+
* provably agents of THIS data dir (see `agent-owner.ts`).
|
|
386
|
+
*
|
|
387
|
+
* A pid in the database is a claim, not proof. Three things it can be:
|
|
388
|
+
* · an agent of this store that outlived the previous server → kill it, it holds the browser's
|
|
389
|
+
* session id and nothing will ever answer through it again;
|
|
390
|
+
* · a pid that died with the previous server and got recycled by something else → leave it alone,
|
|
391
|
+
* say so in the log, and drop it from the row;
|
|
392
|
+
* · an agent of ANOTHER server on the same data dir (the 2026-10-22 accident: a second instance
|
|
393
|
+
* that could not even listen reclaimed the live cockpit's slots on every retry) → leave it
|
|
394
|
+
* alone. The instance lock in index.ts refuses that second server outright; this is the second
|
|
395
|
+
* line of defence, for the case where the claim was lost or hand-edited.
|
|
396
|
+
*
|
|
397
|
+
* Never throws: the boot must not fail because one row is odd. */
|
|
365
398
|
reclaimOrphans(): number {
|
|
366
399
|
let killed = 0;
|
|
400
|
+
let skipped = 0;
|
|
367
401
|
for (const row of this.#store.listSessions(true)) {
|
|
368
402
|
if (row.pid == null) continue;
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
403
|
+
const pid = row.pid;
|
|
404
|
+
const verdict = verifyAgentOwner(this.#dataDir, pid, row.id, this.#instanceId);
|
|
405
|
+
if (verdict.kill) {
|
|
406
|
+
try {
|
|
407
|
+
process.kill(pid, "SIGKILL");
|
|
408
|
+
killed++;
|
|
409
|
+
forgetAgentOwner(this.#dataDir, pid);
|
|
410
|
+
console.log(`[agentus] reclaim: killed pid ${pid} (session ${row.id}) — ${verdict.why}`);
|
|
411
|
+
} catch (err) {
|
|
412
|
+
console.log(`[agentus] reclaim: pid ${pid} (session ${row.id}) was gone before the kill — ${(err as Error).message}`);
|
|
413
|
+
}
|
|
414
|
+
} else if (verdict.gone) {
|
|
415
|
+
console.log(`[agentus] reclaim: nothing to kill for session ${row.id} — ${verdict.why}`);
|
|
416
|
+
forgetAgentOwner(this.#dataDir, pid);
|
|
417
|
+
} else {
|
|
418
|
+
// Alive but not provably ours. Do NOT kill; do drop the pid so the next boot does not
|
|
419
|
+
// re-examine (and re-endanger) the same number.
|
|
420
|
+
skipped++;
|
|
421
|
+
console.warn(`[agentus] reclaim: SKIPPED pid ${pid} (session ${row.id}) — ${verdict.why}`);
|
|
375
422
|
}
|
|
376
423
|
if (row.status !== "closed") {
|
|
377
424
|
// Only a turn that was ACTUALLY in flight can be reported as interrupted. A `ready` session
|
|
@@ -385,9 +432,145 @@ export class SessionManager {
|
|
|
385
432
|
if (wasRunning) this.#finalizePlan(row.id, "interrupted");
|
|
386
433
|
}
|
|
387
434
|
}
|
|
435
|
+
if (skipped) {
|
|
436
|
+
console.warn(
|
|
437
|
+
`[agentus] reclaim: ${skipped} recorded pid(s) left running — they are not this data dir's agents`
|
|
438
|
+
+ ` (${this.#dataDir ?? "no data dir"})`,
|
|
439
|
+
);
|
|
440
|
+
}
|
|
388
441
|
return killed;
|
|
389
442
|
}
|
|
390
443
|
|
|
444
|
+
/** 语音通话的三种时刻(issue #48)。Called from the presence path (index.ts) whenever the
|
|
445
|
+
* cockpit reports who is on a call.
|
|
446
|
+
*
|
|
447
|
+
* A call has no "start"/"end" message of its own — the page only says, every 30 s, "I am on a
|
|
448
|
+
* call with X". So transitions are DERIVED from that one fact, and each transition owes the agent
|
|
449
|
+
* a block (entering: the style constraint; leaving: an explanation of what the last N turns were).
|
|
450
|
+
* Nothing is sent at the moment of the transition itself: a bare prompt would make the agent
|
|
451
|
+
* answer 「好的,我了解了」 into the microphone. The block rides the next real prompt.
|
|
452
|
+
*/
|
|
453
|
+
async syncCallState(): Promise<void> {
|
|
454
|
+
// Serialised: presence arrives on a 30 s beat AND on every visibility/enter/leave event, and two
|
|
455
|
+
// overlapping runs used to interleave the dial switch — a hang-up that restored the operator's
|
|
456
|
+
// level could be overtaken by the lowering of the call that had just ended, leaving the session
|
|
457
|
+
// stuck on the lowest level with its snapshot already cleared (review B2).
|
|
458
|
+
this.#callSync = this.#callSync
|
|
459
|
+
.then(() => this.#syncCallStateOnce())
|
|
460
|
+
.catch(() => { /* presence is a hint: never break the caller */ });
|
|
461
|
+
return this.#callSync;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
async #syncCallStateOnce(): Promise<void> {
|
|
465
|
+
const live = this.#callLive();
|
|
466
|
+
for (const v of this.#store.listInCall()) {
|
|
467
|
+
if (v.sessionId !== live) await this.#endCall(v.sessionId);
|
|
468
|
+
}
|
|
469
|
+
if (live && !this.#store.getVoiceMode(live).inCall) await this.#beginCall(live);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async #beginCall(id: string): Promise<void> {
|
|
473
|
+
const prev = this.#store.getVoiceMode(id);
|
|
474
|
+
if (prev.inCall) return; // idempotent: a second presence beat for the same call changes nothing
|
|
475
|
+
if (!this.#store.getSession(id)) return; // a call with a session this store has never seen
|
|
476
|
+
const s = this.#sessions.get(id);
|
|
477
|
+
const choice = s ? thinkingOptionOf(s.info.configOptions) : null;
|
|
478
|
+
// A snapshot left behind by an earlier call (the restore could not run: slot was cold, or the
|
|
479
|
+
// process died) is still the operator's real level — never overwrite it with the pressed-down
|
|
480
|
+
// value we may have persisted onto the session row (review B1).
|
|
481
|
+
const before = prev.thoughtBefore ?? choice?.current ?? null;
|
|
482
|
+
this.#store.upsertVoiceMode(id, {
|
|
483
|
+
inCall: true, boundary: "enter", turns: 0,
|
|
484
|
+
thoughtBefore: before,
|
|
485
|
+
thoughtTouched: prev.thoughtBefore ? prev.thoughtTouched : false,
|
|
486
|
+
});
|
|
487
|
+
// A call is full-duplex: waiting 20 s for a first token reads as a dead line to the operator.
|
|
488
|
+
// Lower the dial for the call and put the operator's own choice back on hang-up (see #restoreThought).
|
|
489
|
+
if (s && choice && choice.lowest && before !== choice.lowest && !this.#store.getVoiceMode(id).thoughtTouched) {
|
|
490
|
+
await this.setConfig(id, choice.id, choice.lowest, { internal: true }).catch(() => { /* the block still goes out */ });
|
|
491
|
+
// A hang-up can land while the switch is in flight: if the call is already over, put it back
|
|
492
|
+
// now rather than leaving the session on the pressed-down level.
|
|
493
|
+
if (!this.#store.getVoiceMode(id).inCall) await this.#restoreThought(id);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
async #endCall(id: string): Promise<void> {
|
|
498
|
+
const v = this.#store.getVoiceMode(id);
|
|
499
|
+
// Nothing was actually said (no prompt ever rode this call): owe nothing. An exit block here
|
|
500
|
+
// would tell the agent "the conversation above was spoken", which is false — and it is cheap to
|
|
501
|
+
// trigger (opening the call overlay and closing it, a denied microphone) (review B3).
|
|
502
|
+
const owed = v.turns > 0;
|
|
503
|
+
this.#store.upsertVoiceMode(id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
504
|
+
await this.#restoreThought(id);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** Put the operator's thinking level back after a call. Clears the snapshot ONLY when the level
|
|
508
|
+
* really went back — a slot that is no longer in memory (idle reap, restart) or a rejected value
|
|
509
|
+
* keeps it, so the next resume can finish the job instead of silently staying on the call level
|
|
510
|
+
* (review M2). */
|
|
511
|
+
async #restoreThought(id: string): Promise<boolean> {
|
|
512
|
+
const v = this.#store.getVoiceMode(id);
|
|
513
|
+
if (!v.thoughtBefore) return true; // nothing to put back
|
|
514
|
+
if (v.thoughtTouched) {
|
|
515
|
+
// The operator picked a level during the call: theirs wins, the snapshot is moot.
|
|
516
|
+
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
519
|
+
const s = this.#sessions.get(id);
|
|
520
|
+
if (!s) return false;
|
|
521
|
+
const choice = thinkingOptionOf(s.info.configOptions);
|
|
522
|
+
if (!choice || !choice.lowest) return false;
|
|
523
|
+
try {
|
|
524
|
+
await this.setConfig(id, choice.id, v.thoughtBefore, { internal: true });
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
this.#store.upsertVoiceMode(id, { thoughtBefore: null, thoughtTouched: false });
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** Attach this prompt's voice block, and settle what it settles. Synchronous on purpose: it runs
|
|
533
|
+
* inside the send path, and a slow store write must not delay the operator's words.
|
|
534
|
+
*
|
|
535
|
+
* Returns a settle() to call once the turn is actually delivered: a prompt that threw before the
|
|
536
|
+
* agent saw it must not consume a boundary or count as a spoken turn (review m1). */
|
|
537
|
+
#applyVoiceMode(s: LiveSession, blocks: unknown[], opts: { operator: boolean }): (delivered: boolean) => void {
|
|
538
|
+
// A background-completion wake is not a spoken turn: letting it consume the boundary injected
|
|
539
|
+
// "the call is over" into a synthetic turn nobody reads, so the operator's next real message
|
|
540
|
+
// never learned it, and each wake inflated the turn count (review M1).
|
|
541
|
+
if (!opts.operator) return () => { /* nothing owed to a synthetic turn */ };
|
|
542
|
+
const live = this.#callLive();
|
|
543
|
+
let v = this.#store.getVoiceMode(s.info.id);
|
|
544
|
+
// Converge on the ONE authority (presence) in both directions: a page that closed mid-call never
|
|
545
|
+
// reports a hang-up, so the exit boundary must be derived here or the model stays on a call for
|
|
546
|
+
// ever; and a call that started while this slot was cold has no enter boundary recorded.
|
|
547
|
+
if (v.inCall && live !== s.info.id) {
|
|
548
|
+
const owed = v.turns > 0;
|
|
549
|
+
v = this.#store.upsertVoiceMode(s.info.id, { inCall: false, boundary: owed ? "exit" : null, ...(owed ? {} : { turns: 0 }) });
|
|
550
|
+
void this.#restoreThought(s.info.id);
|
|
551
|
+
} else if (!v.inCall && live === s.info.id) {
|
|
552
|
+
v = this.#store.upsertVoiceMode(s.info.id, { inCall: true, boundary: v.boundary ?? "enter", turns: 0 });
|
|
553
|
+
}
|
|
554
|
+
const block = voiceModeBlock(s.info.id, { inCall: v.inCall, boundary: v.boundary, turns: v.turns });
|
|
555
|
+
if (block) blocks.push(block);
|
|
556
|
+
const hadBoundary = Boolean(v.boundary);
|
|
557
|
+
const counted = v.inCall;
|
|
558
|
+
if (!hadBoundary && !counted) return () => { /* steady state, nothing to settle */ };
|
|
559
|
+
return (delivered: boolean) => {
|
|
560
|
+
if (!delivered) return;
|
|
561
|
+
if (hadBoundary) this.#store.upsertVoiceMode(s.info.id, { boundary: null });
|
|
562
|
+
if (counted) this.#store.upsertVoiceMode(s.info.id, { turns: this.#store.getVoiceMode(s.info.id).turns + 1 });
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Bind a freshly spawned agent to (this data dir, this session, this server run). Bookkeeping only:
|
|
567
|
+
* a failure here makes a later reclaim more conservative, never a session fail. */
|
|
568
|
+
#stampAgentReceipt(pid: number, sessionId: string, cmd: string): void {
|
|
569
|
+
recordAgentOwner(this.#dataDir, {
|
|
570
|
+
pid, sessionId, startedAt: Date.now(), serverInstance: this.#instanceId, cmd,
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
391
574
|
/** Last stderr lines from a session's agent (for diagnosing failed turns). */
|
|
392
575
|
stderrTail(id: string, lines = 12): string {
|
|
393
576
|
const s = this.#sessions.get(id);
|
|
@@ -529,6 +712,12 @@ export class SessionManager {
|
|
|
529
712
|
throw new Error(why);
|
|
530
713
|
}
|
|
531
714
|
|
|
715
|
+
const pid: number = child.pid;
|
|
716
|
+
|
|
717
|
+
// The receipt that makes a later reclaim provable (agent-owner.ts). Written BEFORE the handshake,
|
|
718
|
+
// because the handshake can fail and leave a running process behind.
|
|
719
|
+
this.#stampAgentReceipt(pid, id, `${spec.cmd} ${args.join(" ")}`);
|
|
720
|
+
|
|
532
721
|
const live: LiveSession = {
|
|
533
722
|
info: {
|
|
534
723
|
id, backend, acpSessionId: null, cwd,
|
|
@@ -709,6 +898,8 @@ export class SessionManager {
|
|
|
709
898
|
throw new Error(why);
|
|
710
899
|
}
|
|
711
900
|
|
|
901
|
+
this.#stampAgentReceipt(child.pid, id, `${spec.cmd} ${args.join(" ")}`);
|
|
902
|
+
|
|
712
903
|
const live: LiveSession = {
|
|
713
904
|
info: {
|
|
714
905
|
id, backend: row.backend, acpSessionId: row.acpSessionId, cwd: row.cwd,
|
|
@@ -847,6 +1038,20 @@ export class SessionManager {
|
|
|
847
1038
|
.catch(() => {});
|
|
848
1039
|
}
|
|
849
1040
|
}
|
|
1041
|
+
// 通话中 resume(页面刷新、空闲回收后再唤起):把通话档位重新压下去,否则「恢复的会话」会带着
|
|
1042
|
+
// 用户平时的高思考档沉默十几秒 —— 正是 issue #48 要消除的那种卡死手感。反过来,通话已经结束却
|
|
1043
|
+
// 留着快照(挂断时会话不在内存,还原没跑成)就要在这里补还原,否则会话永远停在通话档(review M2)。
|
|
1044
|
+
{
|
|
1045
|
+
const vm = this.#store.getVoiceMode(id);
|
|
1046
|
+
if (vm.inCall) {
|
|
1047
|
+
const knob = thinkingOptionOf(live.info.configOptions);
|
|
1048
|
+
if (knob && knob.current !== knob.lowest) {
|
|
1049
|
+
await this.setConfig(id, knob.id, knob.lowest, { internal: true }).catch(() => { /* keep going */ });
|
|
1050
|
+
}
|
|
1051
|
+
} else if (vm.thoughtBefore && !vm.thoughtTouched) {
|
|
1052
|
+
await this.#restoreThought(id);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
850
1055
|
live.info.status = "ready";
|
|
851
1056
|
this.#updateSession(live);
|
|
852
1057
|
// a resumed slot is the same evidence as a fresh one: the row does work
|
|
@@ -1084,6 +1289,12 @@ export class SessionManager {
|
|
|
1084
1289
|
if (reminder) blocks.push(reminder);
|
|
1085
1290
|
}
|
|
1086
1291
|
}
|
|
1292
|
+
// 语音通话模式(issue #48): entering, staying in, and leaving a call each owe the agent one
|
|
1293
|
+
// sentence it cannot infer — that its output is being SPOKEN, or that the speaking is over.
|
|
1294
|
+
// Rides the same `_meta`-tagged resource channel as the plan reminder; the operator's own words
|
|
1295
|
+
// are untouched (the transcript stores `text`, never the blocks). A background wake (`origin`)
|
|
1296
|
+
// is not a spoken turn and must not consume a boundary.
|
|
1297
|
+
const settleVoice = this.#applyVoiceMode(s, blocks, { operator: voiceAppliesToPrompt(opts) });
|
|
1087
1298
|
s.turnSeq += 1;
|
|
1088
1299
|
s.busy = true;
|
|
1089
1300
|
// The operator has spoken: from here anything the agent says is an ANSWER, not its own history
|
|
@@ -1121,6 +1332,9 @@ export class SessionManager {
|
|
|
1121
1332
|
prompt: blocks as any,
|
|
1122
1333
|
});
|
|
1123
1334
|
const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
|
|
1335
|
+
// The agent has the block (a cancelled turn still received it): NOW the boundary is spent and
|
|
1336
|
+
// the turn counts.
|
|
1337
|
+
settleVoice(true);
|
|
1124
1338
|
this.#finalizePlan(sessionId, terminalState);
|
|
1125
1339
|
this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
|
|
1126
1340
|
} catch (err) {
|
|
@@ -1254,7 +1468,7 @@ export class SessionManager {
|
|
|
1254
1468
|
this.#updateSession(s);
|
|
1255
1469
|
}
|
|
1256
1470
|
|
|
1257
|
-
async setConfig(sessionId: string, configId: string, value: string | number | boolean): Promise<void> {
|
|
1471
|
+
async setConfig(sessionId: string, configId: string, value: string | number | boolean, opts: { internal?: boolean } = {}): Promise<void> {
|
|
1258
1472
|
const s = this.#need(sessionId);
|
|
1259
1473
|
if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
|
|
1260
1474
|
const requested = String(value);
|
|
@@ -1298,6 +1512,17 @@ export class SessionManager {
|
|
|
1298
1512
|
this.#settleRow(s, cur.seq, payload);
|
|
1299
1513
|
}
|
|
1300
1514
|
}
|
|
1515
|
+
// 通话中操作者自己调了思考档位 → 以他为准,挂断时不再还原(issue #48)。`internal` marks the
|
|
1516
|
+
// cockpit's own lowering/restoring, which must NOT count as the operator's choice. Only a pick the
|
|
1517
|
+
// AGENT actually accepted counts: a rejected value would otherwise make the hang-up skip the
|
|
1518
|
+
// restore and strand the session on the call's level (review m4).
|
|
1519
|
+
if (!opts.internal && !rejected && actual === requested) {
|
|
1520
|
+
const vm = this.#store.getVoiceMode(sessionId);
|
|
1521
|
+
if (vm.inCall) {
|
|
1522
|
+
const knob = thinkingOptionOf(s.info.configOptions);
|
|
1523
|
+
if (knob && knob.id === configId) this.#store.upsertVoiceMode(sessionId, { thoughtTouched: true });
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1301
1526
|
if (rejected) {
|
|
1302
1527
|
this.#emit({
|
|
1303
1528
|
t: "config-rejected",
|
|
@@ -2202,6 +2427,8 @@ export class SessionManager {
|
|
|
2202
2427
|
}
|
|
2203
2428
|
|
|
2204
2429
|
#onChildExit(live: LiveSession, code: number | null, sig: string | null): void {
|
|
2430
|
+
// The process is gone, so is its claim: leave no receipt that a later boot would try to honour.
|
|
2431
|
+
forgetAgentOwner(this.#dataDir, live.child?.pid ?? live.info.pid);
|
|
2205
2432
|
// If we initiated close, session was already removed — ignore.
|
|
2206
2433
|
if (!this.#sessions.has(live.info.id)) return;
|
|
2207
2434
|
// …and if we are REPLACING this slot's process (restart), the exit is the one we asked for: the
|