agentus 0.10.1 → 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 +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 +166 -9
- package/packages/server/src/acp/voice-mode.ts +126 -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-D34Kt-uv.js} +1 -1
- 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-0522`, 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-0522`,基座 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-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-
|
|
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.
|
|
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": {
|
|
@@ -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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
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
|
+
}
|
|
@@ -17,6 +17,7 @@ import { describeAcpError } from "./acp/errors.js";
|
|
|
17
17
|
import { BACKENDS } from "./acp/backends.js";
|
|
18
18
|
import { HERMES_DEFAULT_HOME, classifyError, checkedHealth, coerceRow, inspectRow, planFor, rowWarnings, seedRows, startupCheck } from "./acp/registry.js";
|
|
19
19
|
import { ChangedOnDiskError, FsError, listDirs, readTextFile, statFile, writeTextFile } from "./fs.js";
|
|
20
|
+
import { acquireInstanceLock, humanAge } from "./instance-lock.js";
|
|
20
21
|
import { terms } from "./term.js";
|
|
21
22
|
import { VoiceError, listVoiceModels, setHotwordSource, synthesize, transcribe, voiceCapabilities } from "./voice.js";
|
|
22
23
|
import { dictationContext, hotwordsFor, vocabularyOf } from "./hotwords.js";
|
|
@@ -133,6 +134,33 @@ if (process.env.AGENTUS_PRINT_PATHS) {
|
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
fs.mkdirSync(DATA_DIR, { recursive: true, mode: 0o700 }); // 0700: credentials live here
|
|
137
|
+
|
|
138
|
+
// ---- one data directory, ONE server (incident 2026-10-22, incident-20261022-orphan-reclaim.md §7) ----
|
|
139
|
+
// Claimed BEFORE the store is opened and long before anything reclaims or listens. The reclaim below
|
|
140
|
+
// kills the agent processes this database remembers, so a second instance on the same data dir is not
|
|
141
|
+
// a "port conflict" — it is the destruction of a running cockpit. Refuse, and touch nothing.
|
|
142
|
+
// A dead owner (crash, SIGKILL, reboot) is replaced, so a stale claim never bricks an install.
|
|
143
|
+
const instanceLock = acquireInstanceLock(DATA_DIR, { port: PORT });
|
|
144
|
+
if (!instanceLock.ok) {
|
|
145
|
+
const h = instanceLock.holder;
|
|
146
|
+
console.error(`[agentus] refusing to start: this data directory already has an owner`);
|
|
147
|
+
console.error(`[agentus] data dir: ${DATA_DIR} (${DATA_DIR_SOURCE})`);
|
|
148
|
+
if (h) {
|
|
149
|
+
console.error(`[agentus] owner: pid ${h.pid}${h.port ? ` on port ${h.port}` : ""}, up ${humanAge(Date.now() - h.startedAt)}`);
|
|
150
|
+
}
|
|
151
|
+
console.error(`[agentus] reason: ${instanceLock.why}`);
|
|
152
|
+
console.error(`[agentus] two servers on one data dir reclaim each other's agents — stop that instance first,`);
|
|
153
|
+
console.error(`[agentus] or give this one its own directory: AGENTUS_DATA=/some/other/dir`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
if (instanceLock.tookOver) {
|
|
157
|
+
const prev = instanceLock.tookOver;
|
|
158
|
+
console.log(
|
|
159
|
+
`[agentus] instance lock: took over ${DATA_DIR} from pid ${prev.pid}`
|
|
160
|
+
+ ` (gone or recycled; up ${humanAge(Date.now() - (prev.startedAt || Date.now()))})`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
136
164
|
const store = new Store(path.join(DATA_DIR, "agentus.sqlite"));
|
|
137
165
|
// M6: an existing cockpit must behave exactly as before, so the registry starts as a copy of
|
|
138
166
|
// the builtin (env-driven) rows and the operator edits from there. Seeding is one-shot: an
|
|
@@ -203,7 +231,12 @@ function emit(evt: ServerEvent): void {
|
|
|
203
231
|
notify.observe(evt);
|
|
204
232
|
}
|
|
205
233
|
|
|
206
|
-
|
|
234
|
+
// The data dir and this run's instance id travel into the manager: they are what lets a reclaim tell
|
|
235
|
+
// "an agent of THIS directory from a previous run" from "some pid the database still remembers".
|
|
236
|
+
const mgr = new SessionManager(store, emit, {
|
|
237
|
+
dataDir: DATA_DIR,
|
|
238
|
+
instanceId: instanceLock.holder.instanceId,
|
|
239
|
+
});
|
|
207
240
|
|
|
208
241
|
/** 工作播报 (issue #16). Constructed AFTER the manager so it can spawn observers through it, and
|
|
209
242
|
* wired into `emit` above through this mutable handle — the hub itself is not in the broadcast
|
|
@@ -431,6 +464,12 @@ setHotwordSource((sessionId) => hotwordsFor(store, sessionId).map((h) => h.word)
|
|
|
431
464
|
// behaviour); the switch exists because a synthetic turn spends tokens the operator may not want
|
|
432
465
|
// spent at 3am — and it is read live, so flipping it applies to the next completion.
|
|
433
466
|
mgr.setCompletionWakeSource(() => agentSettings().completionWake);
|
|
467
|
+
// 语音通话(issue #48): 「在打电话」 has exactly one authority in this process (the notify centre's
|
|
468
|
+
// presence, which the page already reports every 30 s), so the voice-mode block and the thinking
|
|
469
|
+
// dial derive from it instead of adding a second client→server state channel. The presence
|
|
470
|
+
// listener turns each report into the transitions the manager records.
|
|
471
|
+
mgr.setCallLiveSource(() => notify.callLiveSession());
|
|
472
|
+
notify.onPresenceChange(() => { void mgr.syncCallState(); });
|
|
434
473
|
mgr.startIdleReaper({
|
|
435
474
|
idleMs: () => envIdleMs ?? agentSettings().idleKillMin * 60_000,
|
|
436
475
|
warmSlots: () => envWarm ?? agentSettings().warmSlots,
|
|
@@ -494,7 +533,9 @@ function installSafetyNet(): void {
|
|
|
494
533
|
});
|
|
495
534
|
}
|
|
496
535
|
|
|
497
|
-
// AC5: reclaim orphans from previous run
|
|
536
|
+
// AC5: reclaim orphans from a previous run — only reachable once the instance lock above proved this
|
|
537
|
+
// process is the single owner of DATA_DIR, and only killing agents that receipt proves are ours
|
|
538
|
+
// (incident 2026-10-22: a second instance used to execute the live cockpit's slots here, every 10 s).
|
|
498
539
|
const killed = mgr.reclaimOrphans();
|
|
499
540
|
if (killed) console.log(`[agentus] reclaimed ${killed} orphan agent process(es)`);
|
|
500
541
|
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// ONE data directory, ONE server.
|
|
2
|
+
//
|
|
3
|
+
// Why this file exists (incident 2026-10-22, `tasks/20261001-agentus/incident-20261022-orphan-reclaim.md` §7):
|
|
4
|
+
// a hand-written promote script started a SECOND server on the live data dir (a different port, so
|
|
5
|
+
// `listen` was happy). That instance then ran the startup orphan reclaim — which kills every agent
|
|
6
|
+
// pid the database remembers — and executed the agents of the server that was already serving. Every
|
|
7
|
+
// 10 s the launchd job retried, failed with EADDRINUSE, and killed the operator's freshly restored
|
|
8
|
+
// sessions again: the failure looked like "my sessions die on resume".
|
|
9
|
+
//
|
|
10
|
+
// So identity is claimed BEFORE anything that can touch another instance's processes:
|
|
11
|
+
// · the claim is an O_EXCL `server.pid` in the data dir (atomic on every filesystem we ship to),
|
|
12
|
+
// · a claim is only honoured while its pid is alive AND still looks like an agentus server,
|
|
13
|
+
// · a dead or foreign holder is replaced (crash/SIGKILL must not brick the data dir),
|
|
14
|
+
// · nothing here kills anything — refusing to start is the whole remedy.
|
|
15
|
+
|
|
16
|
+
import fs from "node:fs";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
|
|
21
|
+
/** The claim lives beside the sqlite store: same dir, same 0700 guarantees. */
|
|
22
|
+
export const LOCK_FILE = "server.pid";
|
|
23
|
+
|
|
24
|
+
export interface LockHolder {
|
|
25
|
+
pid: number;
|
|
26
|
+
port: number | null;
|
|
27
|
+
dataDir: string;
|
|
28
|
+
/** Identifies THIS process run. Agents record it too, so a reclaim can tell "previous run" from
|
|
29
|
+
* "mine" — see `acp/agent-owner.ts`. */
|
|
30
|
+
instanceId: string;
|
|
31
|
+
startedAt: number;
|
|
32
|
+
entry: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface LockHandle {
|
|
36
|
+
ok: true;
|
|
37
|
+
path: string;
|
|
38
|
+
holder: LockHolder;
|
|
39
|
+
/** Set when the previous holder was gone and we took the directory over. */
|
|
40
|
+
tookOver: LockHolder | null;
|
|
41
|
+
release(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface LockRefused {
|
|
45
|
+
ok: false;
|
|
46
|
+
holder: LockHolder | null;
|
|
47
|
+
/** One line an operator can act on; `index.ts` prints it verbatim. */
|
|
48
|
+
why: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** `ps -o command=` — the argv of a pid, or null when it is gone. */
|
|
52
|
+
export function commandLineOf(pid: number): string | null {
|
|
53
|
+
try {
|
|
54
|
+
const out = execFileSync("ps", ["-o", "command=", "-p", String(pid)], {
|
|
55
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
56
|
+
});
|
|
57
|
+
const s = out.trim();
|
|
58
|
+
return s || null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Kernel start time of a pid (ms), or null when it cannot be read. Used only to detect pid REUSE. */
|
|
65
|
+
export function startedAtOf(pid: number): number | null {
|
|
66
|
+
try {
|
|
67
|
+
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
|
|
68
|
+
encoding: "utf8", stdio: ["ignore", "pipe", "ignore"],
|
|
69
|
+
}).trim();
|
|
70
|
+
if (!out) return null;
|
|
71
|
+
const t = Date.parse(out); // "Thu Oct 22 23:42:48 2026" under the C locale ps uses
|
|
72
|
+
return Number.isFinite(t) ? t : null;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function pidAlive(pid: number): boolean {
|
|
79
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
80
|
+
try {
|
|
81
|
+
process.kill(pid, 0);
|
|
82
|
+
return true;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// EPERM = exists, just not ours to signal. Either way it is NOT free to take over silently.
|
|
85
|
+
return (err as NodeJS.ErrnoException).code === "EPERM";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Does this argv look like an agentus server? Deliberately narrow: a pid that is merely alive must
|
|
90
|
+
* never block a restart because its number was recycled by an unrelated program. Covers the source
|
|
91
|
+
* entry (`packages/server/src/index.ts`, tsx or node), the built one (`dist/server/index.js`) and a
|
|
92
|
+
* globally installed package (`…/node_modules/agentus/…`). */
|
|
93
|
+
export function looksLikeServer(cmd: string | null): boolean {
|
|
94
|
+
if (!cmd) return false;
|
|
95
|
+
if (!/\b(node|tsx|bun|deno)\b/.test(cmd)) return false;
|
|
96
|
+
return /packages[\\/]server[\\/](src[\\/]index|dist[\\/]index)\.(ts|js|mjs|cjs)\b/.test(cmd)
|
|
97
|
+
|| /agentus[\\/](dist[\\/])?server[\\/]index\.(js|mjs|cjs)\b/.test(cmd);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Synchronous sleep: this runs during module init, before any event loop turn is meaningful. */
|
|
101
|
+
function sleepSync(ms: number): void {
|
|
102
|
+
try {
|
|
103
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
104
|
+
} catch {
|
|
105
|
+
const end = Date.now() + ms;
|
|
106
|
+
while (Date.now() < end) { /* spin: only when Atomics is unavailable */ }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readHolder(lockPath: string): LockHolder | null {
|
|
111
|
+
try {
|
|
112
|
+
const raw = fs.readFileSync(lockPath, "utf8");
|
|
113
|
+
const j = JSON.parse(raw) as Partial<LockHolder>;
|
|
114
|
+
if (typeof j.pid !== "number" || !Number.isInteger(j.pid)) return null;
|
|
115
|
+
return {
|
|
116
|
+
pid: j.pid,
|
|
117
|
+
port: typeof j.port === "number" ? j.port : null,
|
|
118
|
+
dataDir: typeof j.dataDir === "string" ? j.dataDir : "",
|
|
119
|
+
instanceId: typeof j.instanceId === "string" ? j.instanceId : "",
|
|
120
|
+
startedAt: typeof j.startedAt === "number" ? j.startedAt : 0,
|
|
121
|
+
entry: typeof j.entry === "string" ? j.entry : "",
|
|
122
|
+
};
|
|
123
|
+
} catch {
|
|
124
|
+
return null; // unreadable or truncated: a crashed writer, treat as no claim
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Is the recorded holder still a live agentus server on THIS data dir? */
|
|
129
|
+
function judgeHolder(holder: LockHolder, dataDir: string): { alive: boolean; why: string } {
|
|
130
|
+
if (holder.pid === process.pid) return { alive: false, why: `claim names this very pid (${holder.pid})` };
|
|
131
|
+
if (!pidAlive(holder.pid)) return { alive: false, why: `pid ${holder.pid} is gone` };
|
|
132
|
+
const cmd = commandLineOf(holder.pid);
|
|
133
|
+
if (!cmd) return { alive: false, why: `pid ${holder.pid} disappeared while checking` };
|
|
134
|
+
if (!looksLikeServer(cmd)) {
|
|
135
|
+
return { alive: false, why: `pid ${holder.pid} was recycled by another program (${cmd.slice(0, 120)})` };
|
|
136
|
+
}
|
|
137
|
+
if (holder.dataDir && path.resolve(holder.dataDir) !== path.resolve(dataDir)) {
|
|
138
|
+
// The file lives in OUR dir but names another one: copied/hand-edited file. Its pid may well be
|
|
139
|
+
// a healthy server for the OTHER directory — replacing the claim is right, killing is not.
|
|
140
|
+
return { alive: false, why: `claim names data dir ${holder.dataDir}, not ${dataDir}` };
|
|
141
|
+
}
|
|
142
|
+
if (holder.startedAt) {
|
|
143
|
+
const actual = startedAtOf(holder.pid);
|
|
144
|
+
// 1 s of slack: `ps` prints whole seconds. Anything larger means pid reuse, not our process.
|
|
145
|
+
if (actual !== null && Math.abs(actual - holder.startedAt) > 2000) {
|
|
146
|
+
return { alive: false, why: `pid ${holder.pid} start time does not match the claim (reused pid)` };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { alive: true, why: `pid ${holder.pid} (port ${holder.port ?? "?"}) is serving this data dir` };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export interface AcquireOptions {
|
|
153
|
+
/** Port this instance intends to listen on; recorded for the operator, not used for judging. */
|
|
154
|
+
port?: number | null;
|
|
155
|
+
/** How long to wait for a dying holder to release (a restart hands over within a second; a live
|
|
156
|
+
* second instance must be refused quickly enough to be useful). */
|
|
157
|
+
waitMs?: number;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Claim `dataDir`. Returns a handle (already released on process exit) or a refusal with the reason.
|
|
162
|
+
*
|
|
163
|
+
* Callers MUST `process.exit(1)` on a refusal and MUST NOT touch the store, the agents or the port:
|
|
164
|
+
* the whole point is that a second instance leaves the first one's world untouched.
|
|
165
|
+
*/
|
|
166
|
+
export function acquireInstanceLock(dataDir: string, opts: AcquireOptions = {}): LockHandle | LockRefused {
|
|
167
|
+
const lockPath = path.join(dataDir, LOCK_FILE);
|
|
168
|
+
const me: LockHolder = {
|
|
169
|
+
pid: process.pid,
|
|
170
|
+
port: opts.port ?? null,
|
|
171
|
+
dataDir: path.resolve(dataDir),
|
|
172
|
+
instanceId: randomUUID(),
|
|
173
|
+
startedAt: Date.now(),
|
|
174
|
+
entry: process.argv[1] ?? "",
|
|
175
|
+
};
|
|
176
|
+
const deadline = Date.now() + (opts.waitMs ?? 4000);
|
|
177
|
+
let tookOver: LockHolder | null = null;
|
|
178
|
+
|
|
179
|
+
for (;;) {
|
|
180
|
+
try {
|
|
181
|
+
// O_EXCL: the kernel decides who wins, even with two servers starting in the same tick.
|
|
182
|
+
const fd = fs.openSync(lockPath, "wx", 0o600);
|
|
183
|
+
try {
|
|
184
|
+
fs.writeFileSync(fd, JSON.stringify(me, null, 2));
|
|
185
|
+
} finally {
|
|
186
|
+
fs.closeSync(fd);
|
|
187
|
+
}
|
|
188
|
+
const handle: LockHandle = {
|
|
189
|
+
ok: true, path: lockPath, holder: me, tookOver,
|
|
190
|
+
release: () => {
|
|
191
|
+
// Only ever remove OUR claim: a slow instance must not delete the claim of the one that
|
|
192
|
+
// replaced it (that is how two servers end up owning one directory).
|
|
193
|
+
const onDisk = readHolder(lockPath);
|
|
194
|
+
if (!onDisk || onDisk.pid !== me.pid || onDisk.instanceId !== me.instanceId) return;
|
|
195
|
+
try { fs.unlinkSync(lockPath); } catch { /* already gone */ }
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
process.once("exit", handle.release);
|
|
199
|
+
return handle;
|
|
200
|
+
} catch (err) {
|
|
201
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
202
|
+
if (code !== "EEXIST") {
|
|
203
|
+
return { ok: false, holder: null, why: `cannot create ${lockPath}: ${(err as Error).message}` };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const holder = readHolder(lockPath);
|
|
208
|
+
if (!holder) {
|
|
209
|
+
// A crashed writer left junk (or a zero-byte file). Remove and retry.
|
|
210
|
+
try { fs.unlinkSync(lockPath); } catch { /* raced with another starter: loop decides */ }
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const verdict = judgeHolder(holder, dataDir);
|
|
214
|
+
if (verdict.alive) {
|
|
215
|
+
if (Date.now() < deadline) {
|
|
216
|
+
// A restart tears the old process down first; give it a moment instead of failing a
|
|
217
|
+
// `tsx watch` reload or a promote on a scheduling hiccup.
|
|
218
|
+
sleepSync(250);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
return { ok: false, holder, why: verdict.why };
|
|
222
|
+
}
|
|
223
|
+
tookOver = holder;
|
|
224
|
+
console.log(`[agentus] instance lock: taking over ${lockPath} — ${verdict.why}`);
|
|
225
|
+
try { fs.unlinkSync(lockPath); } catch { /* someone else cleaned it: the next open decides */ }
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** For a diagnostic line: "1m 12s". */
|
|
230
|
+
export function humanAge(ms: number): string {
|
|
231
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
232
|
+
if (s < 60) return `${s}s`;
|
|
233
|
+
const m = Math.floor(s / 60);
|
|
234
|
+
return `${m}m ${s % 60}s`;
|
|
235
|
+
}
|
|
@@ -238,6 +238,13 @@ export class NotifyCenter {
|
|
|
238
238
|
return next;
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
/** Called after EVERY presence report (issue #48 wires it to the voice-mode transitions). Kept as
|
|
242
|
+
* a listener rather than a direct call so the notify centre stays free of session knowledge. */
|
|
243
|
+
#onPresence?: (p: Presence) => void;
|
|
244
|
+
onPresenceChange(fn: (p: Presence) => void): void {
|
|
245
|
+
this.#onPresence = fn;
|
|
246
|
+
}
|
|
247
|
+
|
|
241
248
|
/** The cockpit says which session it is looking at; null/visible=false means "nobody is". */
|
|
242
249
|
setPresence(sessionId: string | null, visible: boolean, call = false): Presence {
|
|
243
250
|
this.presence = {
|
|
@@ -254,6 +261,9 @@ export class NotifyCenter {
|
|
|
254
261
|
if (this.presence.sessionId && (this.presence.visible || this.presence.call)) {
|
|
255
262
|
this.onFocus?.(this.presence.sessionId);
|
|
256
263
|
}
|
|
264
|
+
// Announce AFTER the fields are settled: a listener asking `callLiveSession()` must see the
|
|
265
|
+
// report it was just told about, not the previous one.
|
|
266
|
+
try { this.#onPresence?.(this.presence); } catch { /* presence is a hint, never a requirement */ }
|
|
257
267
|
return this.presence;
|
|
258
268
|
}
|
|
259
269
|
|
|
@@ -273,6 +283,15 @@ export class NotifyCenter {
|
|
|
273
283
|
return Boolean(this.presence.call) && Date.now() - this.presence.at < NotifyCenter.WATCH_TTL_MS;
|
|
274
284
|
}
|
|
275
285
|
|
|
286
|
+
/** WHICH session is on a call right now, or null. The public form of `onCall()` for issue #48:
|
|
287
|
+
* the voice-mode block and the thinking-dial switch need the session id, not just a boolean, and
|
|
288
|
+
* they must agree with the notification rules about what "on a call" means (one presence, one TTL,
|
|
289
|
+
* one answer). */
|
|
290
|
+
callLiveSession(): string | null {
|
|
291
|
+
if (!this.onCall()) return null;
|
|
292
|
+
return this.presence.sessionId;
|
|
293
|
+
}
|
|
294
|
+
|
|
276
295
|
/** True when a notification for this session would interrupt the operator looking at it. */
|
|
277
296
|
private watching(sessionId: string): boolean {
|
|
278
297
|
return this.rules().quietWhenWatching && this.operatorWatching(sessionId);
|
|
@@ -267,6 +267,29 @@ interface RawPlanRow {
|
|
|
267
267
|
updated_at: number;
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
+
/** 语音通话状态的一行(issue #48)—— 见 `voice_mode` 表。 */
|
|
271
|
+
export interface VoiceRow {
|
|
272
|
+
sessionId: string;
|
|
273
|
+
inCall: boolean;
|
|
274
|
+
boundary: "enter" | "exit" | null;
|
|
275
|
+
turns: number;
|
|
276
|
+
thoughtBefore: string | null;
|
|
277
|
+
thoughtTouched: boolean;
|
|
278
|
+
updatedAt: number;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export type VoiceModePatch = Partial<Omit<VoiceRow, "sessionId" | "updatedAt">>;
|
|
282
|
+
|
|
283
|
+
interface RawVoiceRow {
|
|
284
|
+
session_id: string;
|
|
285
|
+
in_call: number;
|
|
286
|
+
boundary: string | null;
|
|
287
|
+
turns: number;
|
|
288
|
+
thought_before: string | null;
|
|
289
|
+
thought_touched: number;
|
|
290
|
+
updated_at: number;
|
|
291
|
+
}
|
|
292
|
+
|
|
270
293
|
/** What a plan write carries. `explanation: undefined` means "keep the remark that is there"
|
|
271
294
|
* (only an agent writes one, and most plan updates carry none) — see `upsertPlan`. */
|
|
272
295
|
export interface PlanPatch {
|
|
@@ -351,6 +374,19 @@ export class Store {
|
|
|
351
374
|
items text not null, title text, explanation text, source text not null,
|
|
352
375
|
terminal text, updated_at integer not null
|
|
353
376
|
);
|
|
377
|
+
-- 语音通话模式(issue #48). The two boundaries must SURVIVE a reload, an idle-reap and a
|
|
378
|
+
-- cold start: if the exit boundary were only in memory, a page refresh after hanging up would
|
|
379
|
+
-- leave the model believing it is still on a call (the transcript's last turns all sound like
|
|
380
|
+
-- one). Same待遇 as the plan row: state the cockpit owns, not something the transcript records.
|
|
381
|
+
create table if not exists voice_mode (
|
|
382
|
+
session_id text primary key,
|
|
383
|
+
in_call integer not null default 0,
|
|
384
|
+
boundary text, -- 'enter' | 'exit' | null (owed, rides the next prompt)
|
|
385
|
+
turns integer not null default 0, -- prompts spoken inside this call (the exit block quotes it)
|
|
386
|
+
thought_before text, -- thinking depth to restore on hang-up (null = none taken)
|
|
387
|
+
thought_touched integer not null default 0, -- operator changed depth during the call
|
|
388
|
+
updated_at integer not null
|
|
389
|
+
);
|
|
354
390
|
`);
|
|
355
391
|
// The DB now holds the speech endpoint's key, so it is the operator's secret material:
|
|
356
392
|
// 0600 like the file it replaced (a default 0644 sqlite file would be a downgrade).
|
|
@@ -913,6 +949,65 @@ export class Store {
|
|
|
913
949
|
return this.getPlan(sessionId) as PlanSnapshot;
|
|
914
950
|
}
|
|
915
951
|
|
|
952
|
+
/** 语音通话状态(issue #48)—— 读一行,缺省即「没在通话、没有欠着的边界」。 */
|
|
953
|
+
getVoiceMode(sessionId: string): VoiceRow {
|
|
954
|
+
const row = this.#db
|
|
955
|
+
.prepare("select session_id, in_call, boundary, turns, thought_before, thought_touched, updated_at from voice_mode where session_id = ?")
|
|
956
|
+
.get(sessionId) as RawVoiceRow | undefined;
|
|
957
|
+
return row
|
|
958
|
+
? {
|
|
959
|
+
sessionId: row.session_id,
|
|
960
|
+
inCall: Boolean(row.in_call),
|
|
961
|
+
boundary: row.boundary === "enter" || row.boundary === "exit" ? row.boundary : null,
|
|
962
|
+
turns: row.turns ?? 0,
|
|
963
|
+
thoughtBefore: row.thought_before ?? null,
|
|
964
|
+
thoughtTouched: Boolean(row.thought_touched),
|
|
965
|
+
updatedAt: row.updated_at,
|
|
966
|
+
}
|
|
967
|
+
: { sessionId, inCall: false, boundary: null, turns: 0, thoughtBefore: null, thoughtTouched: false, updatedAt: 0 };
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Write the parts that changed; undefined keeps the stored value (the plan patch's rule). */
|
|
971
|
+
upsertVoiceMode(sessionId: string, patch: VoiceModePatch): VoiceRow {
|
|
972
|
+
const prev = this.getVoiceMode(sessionId);
|
|
973
|
+
const next: VoiceRow = {
|
|
974
|
+
sessionId,
|
|
975
|
+
inCall: patch.inCall ?? prev.inCall,
|
|
976
|
+
boundary: patch.boundary === undefined ? prev.boundary : patch.boundary,
|
|
977
|
+
turns: patch.turns ?? prev.turns,
|
|
978
|
+
thoughtBefore: patch.thoughtBefore === undefined ? prev.thoughtBefore : patch.thoughtBefore,
|
|
979
|
+
thoughtTouched: patch.thoughtTouched ?? prev.thoughtTouched,
|
|
980
|
+
updatedAt: Date.now(),
|
|
981
|
+
};
|
|
982
|
+
this.#db
|
|
983
|
+
.prepare(
|
|
984
|
+
`insert into voice_mode (session_id, in_call, boundary, turns, thought_before, thought_touched, updated_at)
|
|
985
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
986
|
+
on conflict(session_id) do update set
|
|
987
|
+
in_call = excluded.in_call, boundary = excluded.boundary, turns = excluded.turns,
|
|
988
|
+
thought_before = excluded.thought_before, thought_touched = excluded.thought_touched,
|
|
989
|
+
updated_at = excluded.updated_at`,
|
|
990
|
+
)
|
|
991
|
+
.run(
|
|
992
|
+
sessionId,
|
|
993
|
+
next.inCall ? 1 : 0,
|
|
994
|
+
next.boundary,
|
|
995
|
+
next.turns,
|
|
996
|
+
next.thoughtBefore,
|
|
997
|
+
next.thoughtTouched ? 1 : 0,
|
|
998
|
+
next.updatedAt,
|
|
999
|
+
);
|
|
1000
|
+
return next;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** Every session the store still believes is on a call — the list a hang-up has to sweep. */
|
|
1004
|
+
listInCall(): VoiceRow[] {
|
|
1005
|
+
const rows = this.#db
|
|
1006
|
+
.prepare("select session_id from voice_mode where in_call = 1")
|
|
1007
|
+
.all() as { session_id: string }[];
|
|
1008
|
+
return rows.map((r) => this.getVoiceMode(r.session_id));
|
|
1009
|
+
}
|
|
1010
|
+
|
|
916
1011
|
appendMessage(m: Omit<StoredMessage, "seq">): StoredMessage {
|
|
917
1012
|
const seq = this.nextSeq(m.sessionId);
|
|
918
1013
|
this.#db
|
|
@@ -122,7 +122,7 @@ click to point this session at another directory`,"aria-label":"change workspace
|
|
|
122
122
|
|
|
123
123
|
旧进程退出、新进程接管同一个会话:对话记录、模型和思考深度都保留,只有进程换新(改完代码或改了后端配置后,用它让槽位吃到新东西)。正在跑的这一轮会被中断。`)&&X.restart(he.id)},onDelete:()=>{te(null),confirm(`删除“${he.title}”?
|
|
124
124
|
|
|
125
|
-
记录会从磁盘上移除,无法撤销。`)&&X.purgeCold(he.id)}}),document.body):null,$e?hl.createPortal(o.jsx("div",{className:"modal-bg",onClick:()=>Ze(""),children:o.jsxs("div",{className:"modal narrow",onClick:ee=>ee.stopPropagation(),children:[o.jsx("h3",{children:"工作目录"}),o.jsx("div",{className:"hint",children:"文件面板与终端以它为根。运行中的 agent 仍留在启动时的目录 —— 新目录对下一次恢复生效。"}),o.jsx(tg,{value:Y?.workspace??Y?.cwd??"",onChange:ee=>{const ve=$e;Ze(""),X.setWorkspace(ve,ee)}}),o.jsx("div",{className:"row",children:o.jsx("button",{className:"cancel",onClick:()=>Ze(""),children:"取消"})})]})}),document.body):null,o.jsxs("footer",{children:[o.jsx("span",{className:`conn ${p}`,children:p==="online"?"● online":p}),y?o.jsx("span",{className:"who",title:`signed in as ${y.username??"?"}`,children:y.username}):null,o.jsx("button",{className:"logout-btn",title:`signed in as ${y?.username??"?"} — sign out`,"aria-label":"sign out",onClick:()=>void X.logout(),children:o.jsx(Ow,{size:14})})]})]})}function JP({onMenu:e,settingsOpen:t,onCloseSettings:s,onPermClick:n,panelWidth:i,panelHandle:r}){const{active:a}=_.useSyncExternalStore(X.subscribe,X.getSnapshot),[l,c]=_.useState(!1),[u,h]=_.useState(null);_.useEffect(()=>RN(y=>{c(!0),h({...y})}),[]);const[d,p]=_.useState(!1),[f,m]=_.useState(!1);return _.useEffect(()=>{vu()},[]),lN(XP(a),!!(a&&!a.busy&&a.loaded)),cN(ZP(a),!!a),t?o.jsx(rD,{onClose:s,sessionId:a?.info.id}):a?o.jsxs("div",{className:"main",children:[o.jsx(e5,{v:a,onMenu:e,panelOpen:l,onTogglePanel:()=>c(y=>!y),onPickWorkspace:()=>p(!0),call:f,onCall:()=>{we.stop(),m(!0)},onPerm:n}),o.jsxs("div",{className:"main-body",children:[o.jsxs("div",{className:"chat-col",children:[o.jsx(i5,{v:a}),o.jsx(T5,{v:a,call:f,onCloseCall:()=>m(!1)})]}),l&&o.jsx(yP,{v:a,onClose:()=>c(!1),onPickWorkspace:()=>p(!0),focus:u,panelWidth:i,panelHandle:r})]}),d&&o.jsx(QP,{v:a,onClose:()=>p(!1)})]}):o.jsxs("div",{className:"main",children:[o.jsxs("div",{className:"chat-head",children:[o.jsx("button",{className:"icon-btn menu-btn",onClick:e,title:"sessions","aria-label":"sessions",children:o.jsx(Rw,{})}),o.jsx("span",{className:"title",children:"Agentus"})]}),o.jsxs("div",{className:"empty",children:[o.jsx("div",{className:"big",children:o.jsx(eg,{size:72})}),o.jsxs("p",{children:["No session selected.",o.jsx("br",{}),"Create one and point it at a real ",o.jsx("code",{children:"hermes acp"})," / ",o.jsx("code",{children:"qodercli --acp"}),"."]})]})]})}function XP(e){if(!e||e.busy||!e.loaded||Date.now()-e.lastAt>3e4)return null;for(let t=e.msgs.length-1;t>=0;t-=1){const s=e.msgs[t];if(s.kind==="agent"&&s.text.trim())return{key:s.key,text:s.text}}return null}function ZP(e){const t=e?.speaks?.[e.speaks.length-1];return t?{key:`brief:${t.id}`,text:t.text}:null}function QP({v:e,onClose:t}){Wo(!0,t);const s=e.info.workspace||e.info.cwd,[n,i]=_.useState(s),[r,a]=_.useState(!1),[l,c]=_.useState(""),u=async()=>{a(!0),c("");try{await X.setWorkspace(e.info.id,n.trim()),t()}catch(h){c(String(h?.message??h))}finally{a(!1)}};return o.jsx("div",{className:"modal-bg",onClick:t,children:o.jsxs("div",{className:"modal narrow",onClick:h=>h.stopPropagation(),children:[o.jsx("h3",{children:"workspace"}),o.jsx("div",{className:"hint",children:"the file panel and terminal work here. A running agent keeps the directory it was started in — the new one applies to this session from the next resume."}),o.jsx(tg,{value:n,onChange:i}),l&&o.jsx("div",{className:"err",children:l}),o.jsxs("div",{className:"row",children:[o.jsx("button",{className:"cancel",onClick:t,children:"cancel"}),o.jsx("button",{className:"go",disabled:r||!n.trim(),onClick:()=>void u(),children:r?"saving…":"use this folder"})]})]})})}function e5({v:e,onMenu:t,panelOpen:s,onTogglePanel:n,onPickWorkspace:i,call:r,onCall:a,onPerm:l}){const c=e.info;(c.workspace||c.cwd).split("/").filter(Boolean).pop()??c.cwd;const[u,h]=lr(),p=_.useSyncExternalStore(LE,Vn).tts.server||typeof window<"u"&&"speechSynthesis"in window,f=u.speakMode??(u.autoRead?"reply":"off"),[m,y]=_.useState(!1),x=_.useRef(null);Br(m,[x],()=>y(!1));const[g,v]=x5(),b=(N,P)=>{y(!1),h({speakMode:N,autoRead:N==="reply"}),N==="brief"&&P!==void 0&&v(P)},w=_.useRef({timer:null,fired:!1}),k=()=>{w.current.timer&&(window.clearTimeout(w.current.timer),w.current.timer=null)};_.useEffect(()=>k,[]);const j=()=>{w.current.fired=!1,k(),w.current.timer=window.setTimeout(()=>{w.current.fired=!0,y(!0)},500)},I=()=>{if(w.current.fired){w.current.fired=!1;return}b(f==="off"?"reply":"off")},A=[{key:"off",mode:"off",interval:null,label:"关"},{key:"reply",mode:"reply",interval:null,label:"自动朗读(点一下就是这个 · 默认)"},{key:"brief",mode:"brief",interval:0,label:"工作播报(有进展就说一句)"},{key:"brief-60",mode:"brief",interval:60,label:"工作播报(每 1 分钟也报一次)"},{key:"brief-120",mode:"brief",interval:120,label:"工作播报(每 2 分钟也报一次)"},{key:"brief-300",mode:"brief",interval:300,label:"工作播报(每 5 分钟也报一次)"}],D=N=>N.mode===f&&(N.mode!=="brief"||N.interval===g),O=f==="brief"&&!A.some(N=>N.mode==="brief"&&N.interval===g),F=f==="off"?"关":f==="reply"?"自动朗读(念整条回复)":g>0?`工作播报 · 每 ${Dd(g)}`:"工作播报(念驾驶舱的一句话)",C=p?`朗读:${F} —— 点一下开/关,长按选档(右键同)`:"朗读:这台设备没有可用语音(浏览器无 speechSynthesis,服务端也没配)";return o.jsxs("div",{className:"chat-head",children:[o.jsx("button",{className:"icon-btn menu-btn",onClick:t,title:"sessions","aria-label":"sessions",children:o.jsx(Rw,{})}),o.jsx("span",{className:"title",title:c.title,children:c.title}),o.jsx(t5,{busy:Rr(e),lastAt:e.lastAt}),e.perms.length>0?o.jsxs("button",{type:"button",className:"chip perm-chip",title:"这个会话有请求等你的答复 —— 点一下打开授权弹窗","aria-label":`有 ${e.perms.length} 个请求等你授权`,onClick:l,children:[o.jsx("span",{className:"perm-pulse"}),"⚿ ",e.perms.length]}):null,o.jsx("span",{className:"head-spacer"}),o.jsxs("span",{className:"head-actions",children:[o.jsx("button",{className:`icon-btn ${r?"on":""}`,title:"语音通话 —— 像打电话一样跟这个会话说话","aria-label":"开始语音通话",onClick:a,children:o.jsx(pE,{})}),o.jsxs("span",{className:"speak-wrap",ref:x,children:[o.jsxs("button",{className:`icon-btn auto-read ${f!=="off"?"on":""}`,title:C,"aria-label":"朗读:点一下开/关,长按选读什么","aria-pressed":f!=="off","aria-haspopup":"menu","aria-expanded":m,"data-speak-mode":f,disabled:!p,onClick:I,onPointerDown:j,onPointerUp:k,onPointerLeave:k,onPointerCancel:k,onContextMenu:N=>{N.preventDefault(),k(),y(!0)},onKeyDown:N=>{(N.key==="F10"&&N.shiftKey||N.key==="ContextMenu")&&(N.preventDefault(),y(!0))},children:[f==="off"?o.jsx(Lw,{}):o.jsx(th,{}),f==="brief"?o.jsx("span",{className:"speak-dot","aria-hidden":!0}):null]}),m?o.jsxs("div",{className:"speak-menu",role:"menu",children:[A.map(N=>o.jsxs("button",{type:"button",role:"menuitemradio","aria-checked":D(N),className:`speak-menu-item ${D(N)?"on":""}`,"data-speak-option":N.key,onClick:()=>b(N.mode,N.interval??void 0),children:[o.jsx("span",{className:"speak-check",children:D(N)?"✓":""}),o.jsx("span",{className:"speak-name",children:N.label})]},N.key)),o.jsxs("button",{type:"button",role:"menuitem","aria-checked":O,className:`speak-menu-item ${O?"on":""}`,"data-speak-option":"custom",onClick:()=>{y(!1),window.dispatchEvent(new CustomEvent("agentus:open-settings",{detail:"voice"}))},children:[o.jsx("span",{className:"speak-check",children:O?"✓":""}),o.jsx("span",{className:"speak-name",children:O?`自定义节拍(现在 · 每 ${Dd(g)})`:"自定义节拍…"})]}),o.jsx("p",{className:"speak-hint",children:f==="brief"?g>0?`有任务在跑时每 ${Dd(g)} 念一句;没任务在跑不念。观察员在 设置 → 观察智能体`:"回合结束、待审批时由一个观察智能体念一句(设置 → 观察智能体)":f==="reply"?"每条回复结束后念全文 —— 长回复会被截断":"不自动出声;点消息旁的喇叭可以按需念"})]}):null]}),o.jsx("button",{className:"icon-btn",title:`workspace: ${c.workspace||c.cwd}
|
|
125
|
+
记录会从磁盘上移除,无法撤销。`)&&X.purgeCold(he.id)}}),document.body):null,$e?hl.createPortal(o.jsx("div",{className:"modal-bg",onClick:()=>Ze(""),children:o.jsxs("div",{className:"modal narrow",onClick:ee=>ee.stopPropagation(),children:[o.jsx("h3",{children:"工作目录"}),o.jsx("div",{className:"hint",children:"文件面板与终端以它为根。运行中的 agent 仍留在启动时的目录 —— 新目录对下一次恢复生效。"}),o.jsx(tg,{value:Y?.workspace??Y?.cwd??"",onChange:ee=>{const ve=$e;Ze(""),X.setWorkspace(ve,ee)}}),o.jsx("div",{className:"row",children:o.jsx("button",{className:"cancel",onClick:()=>Ze(""),children:"取消"})})]})}),document.body):null,o.jsxs("footer",{children:[o.jsx("span",{className:`conn ${p}`,children:p==="online"?"● online":p}),y?o.jsx("span",{className:"who",title:`signed in as ${y.username??"?"}`,children:y.username}):null,o.jsx("button",{className:"logout-btn",title:`signed in as ${y?.username??"?"} — sign out`,"aria-label":"sign out",onClick:()=>void X.logout(),children:o.jsx(Ow,{size:14})})]})]})}function JP({onMenu:e,settingsOpen:t,onCloseSettings:s,onPermClick:n,panelWidth:i,panelHandle:r}){const{active:a}=_.useSyncExternalStore(X.subscribe,X.getSnapshot),[l,c]=_.useState(!1),[u,h]=_.useState(null);_.useEffect(()=>RN(y=>{c(!0),h({...y})}),[]);const[d,p]=_.useState(!1),[f,m]=_.useState(!1);return _.useEffect(()=>{fetch("/api/notify/presence",{method:"POST",credentials:"same-origin",headers:{"content-type":"application/json"},body:JSON.stringify({sessionId:a?.info.id??null,visible:!0,call:f})}).catch(()=>{})},[f,a?.info.id]),_.useEffect(()=>{vu()},[]),lN(XP(a),!!(a&&!a.busy&&a.loaded)),cN(ZP(a),!!a),t?o.jsx(rD,{onClose:s,sessionId:a?.info.id}):a?o.jsxs("div",{className:"main",children:[o.jsx(e5,{v:a,onMenu:e,panelOpen:l,onTogglePanel:()=>c(y=>!y),onPickWorkspace:()=>p(!0),call:f,onCall:()=>{we.stop(),m(!0)},onPerm:n}),o.jsxs("div",{className:"main-body",children:[o.jsxs("div",{className:"chat-col",children:[o.jsx(i5,{v:a}),o.jsx(T5,{v:a,call:f,onCloseCall:()=>m(!1)})]}),l&&o.jsx(yP,{v:a,onClose:()=>c(!1),onPickWorkspace:()=>p(!0),focus:u,panelWidth:i,panelHandle:r})]}),d&&o.jsx(QP,{v:a,onClose:()=>p(!1)})]}):o.jsxs("div",{className:"main",children:[o.jsxs("div",{className:"chat-head",children:[o.jsx("button",{className:"icon-btn menu-btn",onClick:e,title:"sessions","aria-label":"sessions",children:o.jsx(Rw,{})}),o.jsx("span",{className:"title",children:"Agentus"})]}),o.jsxs("div",{className:"empty",children:[o.jsx("div",{className:"big",children:o.jsx(eg,{size:72})}),o.jsxs("p",{children:["No session selected.",o.jsx("br",{}),"Create one and point it at a real ",o.jsx("code",{children:"hermes acp"})," / ",o.jsx("code",{children:"qodercli --acp"}),"."]})]})]})}function XP(e){if(!e||e.busy||!e.loaded||Date.now()-e.lastAt>3e4)return null;for(let t=e.msgs.length-1;t>=0;t-=1){const s=e.msgs[t];if(s.kind==="agent"&&s.text.trim())return{key:s.key,text:s.text}}return null}function ZP(e){const t=e?.speaks?.[e.speaks.length-1];return t?{key:`brief:${t.id}`,text:t.text}:null}function QP({v:e,onClose:t}){Wo(!0,t);const s=e.info.workspace||e.info.cwd,[n,i]=_.useState(s),[r,a]=_.useState(!1),[l,c]=_.useState(""),u=async()=>{a(!0),c("");try{await X.setWorkspace(e.info.id,n.trim()),t()}catch(h){c(String(h?.message??h))}finally{a(!1)}};return o.jsx("div",{className:"modal-bg",onClick:t,children:o.jsxs("div",{className:"modal narrow",onClick:h=>h.stopPropagation(),children:[o.jsx("h3",{children:"workspace"}),o.jsx("div",{className:"hint",children:"the file panel and terminal work here. A running agent keeps the directory it was started in — the new one applies to this session from the next resume."}),o.jsx(tg,{value:n,onChange:i}),l&&o.jsx("div",{className:"err",children:l}),o.jsxs("div",{className:"row",children:[o.jsx("button",{className:"cancel",onClick:t,children:"cancel"}),o.jsx("button",{className:"go",disabled:r||!n.trim(),onClick:()=>void u(),children:r?"saving…":"use this folder"})]})]})})}function e5({v:e,onMenu:t,panelOpen:s,onTogglePanel:n,onPickWorkspace:i,call:r,onCall:a,onPerm:l}){const c=e.info;(c.workspace||c.cwd).split("/").filter(Boolean).pop()??c.cwd;const[u,h]=lr(),p=_.useSyncExternalStore(LE,Vn).tts.server||typeof window<"u"&&"speechSynthesis"in window,f=u.speakMode??(u.autoRead?"reply":"off"),[m,y]=_.useState(!1),x=_.useRef(null);Br(m,[x],()=>y(!1));const[g,v]=x5(),b=(N,P)=>{y(!1),h({speakMode:N,autoRead:N==="reply"}),N==="brief"&&P!==void 0&&v(P)},w=_.useRef({timer:null,fired:!1}),k=()=>{w.current.timer&&(window.clearTimeout(w.current.timer),w.current.timer=null)};_.useEffect(()=>k,[]);const j=()=>{w.current.fired=!1,k(),w.current.timer=window.setTimeout(()=>{w.current.fired=!0,y(!0)},500)},I=()=>{if(w.current.fired){w.current.fired=!1;return}b(f==="off"?"reply":"off")},A=[{key:"off",mode:"off",interval:null,label:"关"},{key:"reply",mode:"reply",interval:null,label:"自动朗读(点一下就是这个 · 默认)"},{key:"brief",mode:"brief",interval:0,label:"工作播报(有进展就说一句)"},{key:"brief-60",mode:"brief",interval:60,label:"工作播报(每 1 分钟也报一次)"},{key:"brief-120",mode:"brief",interval:120,label:"工作播报(每 2 分钟也报一次)"},{key:"brief-300",mode:"brief",interval:300,label:"工作播报(每 5 分钟也报一次)"}],D=N=>N.mode===f&&(N.mode!=="brief"||N.interval===g),O=f==="brief"&&!A.some(N=>N.mode==="brief"&&N.interval===g),F=f==="off"?"关":f==="reply"?"自动朗读(念整条回复)":g>0?`工作播报 · 每 ${Dd(g)}`:"工作播报(念驾驶舱的一句话)",C=p?`朗读:${F} —— 点一下开/关,长按选档(右键同)`:"朗读:这台设备没有可用语音(浏览器无 speechSynthesis,服务端也没配)";return o.jsxs("div",{className:"chat-head",children:[o.jsx("button",{className:"icon-btn menu-btn",onClick:t,title:"sessions","aria-label":"sessions",children:o.jsx(Rw,{})}),o.jsx("span",{className:"title",title:c.title,children:c.title}),o.jsx(t5,{busy:Rr(e),lastAt:e.lastAt}),e.perms.length>0?o.jsxs("button",{type:"button",className:"chip perm-chip",title:"这个会话有请求等你的答复 —— 点一下打开授权弹窗","aria-label":`有 ${e.perms.length} 个请求等你授权`,onClick:l,children:[o.jsx("span",{className:"perm-pulse"}),"⚿ ",e.perms.length]}):null,o.jsx("span",{className:"head-spacer"}),o.jsxs("span",{className:"head-actions",children:[o.jsx("button",{className:`icon-btn ${r?"on":""}`,title:"语音通话 —— 像打电话一样跟这个会话说话","aria-label":"开始语音通话",onClick:a,children:o.jsx(pE,{})}),o.jsxs("span",{className:"speak-wrap",ref:x,children:[o.jsxs("button",{className:`icon-btn auto-read ${f!=="off"?"on":""}`,title:C,"aria-label":"朗读:点一下开/关,长按选读什么","aria-pressed":f!=="off","aria-haspopup":"menu","aria-expanded":m,"data-speak-mode":f,disabled:!p,onClick:I,onPointerDown:j,onPointerUp:k,onPointerLeave:k,onPointerCancel:k,onContextMenu:N=>{N.preventDefault(),k(),y(!0)},onKeyDown:N=>{(N.key==="F10"&&N.shiftKey||N.key==="ContextMenu")&&(N.preventDefault(),y(!0))},children:[f==="off"?o.jsx(Lw,{}):o.jsx(th,{}),f==="brief"?o.jsx("span",{className:"speak-dot","aria-hidden":!0}):null]}),m?o.jsxs("div",{className:"speak-menu",role:"menu",children:[A.map(N=>o.jsxs("button",{type:"button",role:"menuitemradio","aria-checked":D(N),className:`speak-menu-item ${D(N)?"on":""}`,"data-speak-option":N.key,onClick:()=>b(N.mode,N.interval??void 0),children:[o.jsx("span",{className:"speak-check",children:D(N)?"✓":""}),o.jsx("span",{className:"speak-name",children:N.label})]},N.key)),o.jsxs("button",{type:"button",role:"menuitem","aria-checked":O,className:`speak-menu-item ${O?"on":""}`,"data-speak-option":"custom",onClick:()=>{y(!1),window.dispatchEvent(new CustomEvent("agentus:open-settings",{detail:"voice"}))},children:[o.jsx("span",{className:"speak-check",children:O?"✓":""}),o.jsx("span",{className:"speak-name",children:O?`自定义节拍(现在 · 每 ${Dd(g)})`:"自定义节拍…"})]}),o.jsx("p",{className:"speak-hint",children:f==="brief"?g>0?`有任务在跑时每 ${Dd(g)} 念一句;没任务在跑不念。观察员在 设置 → 观察智能体`:"回合结束、待审批时由一个观察智能体念一句(设置 → 观察智能体)":f==="reply"?"每条回复结束后念全文 —— 长回复会被截断":"不自动出声;点消息旁的喇叭可以按需念"})]}):null]}),o.jsx("button",{className:"icon-btn",title:`workspace: ${c.workspace||c.cwd}
|
|
126
126
|
click to point this session at another directory`,"aria-label":"workspace",onClick:i,children:o.jsx($r,{})}),o.jsx("button",{className:`icon-btn ${s?"on":""}`,title:"workspace panel — files and terminal","aria-label":"workspace panel","aria-expanded":s,onClick:n,children:o.jsx(uE,{})})]})]})}const Va={current:()=>{}},cy=4,t5=_.memo(function({busy:t,lastAt:s}){const[,n]=_.useState(0);if(_.useEffect(()=>{if(!t)return;const a=window.setInterval(()=>n(l=>l+1),1e3);return()=>window.clearInterval(a)},[t]),!t)return null;const{quiet:i,said:r}=cD(s,Date.now());return o.jsx("span",{className:`turn-pulse ${i?"quiet":""}`,role:"status","aria-label":r,title:r})}),s5=_.memo(function({busy:t,since:s}){const[n,i]=_.useState(null),[r,a]=_.useState(()=>Date.now());if(_.useEffect(()=>{if(!t){i(null);return}i(s??Date.now()),a(Date.now());const c=window.setInterval(()=>a(Date.now()),1e3);return()=>window.clearInterval(c)},[t,s]),!t)return null;const l=n??s;return o.jsxs("div",{className:"stream-hint",role:"status","data-anchor":l??void 0,children:[o.jsx("span",{className:"stream-spin","aria-hidden":"true"}),o.jsx("span",{children:"处理中"}),l!=null?o.jsxs("span",{className:"elapsed",children:["(",Ru(r-l),")"]}):null]})});function n5({v:e}){const t=e.background??[],[s,n]=_.useState(!1);if(!t.length)return null;const i=t.filter(c=>c.status==="running"),r=t.filter(c=>c.status!=="running"),a=i.reduce((c,u)=>c+(u.kind==="delegation"?u.taskCount??1:0),0),l=i.length?`后台任务 ${i.length} 个运行中${a?`(含 ${a} 个子任务)`:""} · 完成后会唤醒会话`:`${r.length} 个后台任务已结束`;return o.jsxs("div",{className:`bg-row ${s?"open":""}`,"data-count":t.length,children:[o.jsxs("button",{type:"button",className:"bg-head","aria-expanded":s,onClick:()=>n(!s),children:[o.jsx("span",{className:"bg-dot","aria-hidden":"true","data-state":i.length?"running":"exited","data-wake":i.length?"1":void 0}),o.jsx("span",{className:"bg-text",children:l}),i.length&&!e.busy?o.jsx("span",{className:"bg-hint",children:"会话已空闲,后台仍在跑"}):null,o.jsx("span",{className:"bg-caret","aria-hidden":"true",children:s?"▾":"▸"})]}),s&&o.jsx("ul",{className:"bg-list",children:t.map(c=>o.jsxs("li",{className:"bg-item","data-state":c.status,"data-kind":c.kind??"process",children:[c.kind==="delegation"?o.jsx("span",{className:"bg-tag",children:"子任务"}):null,o.jsx("pre",{className:"bg-cmd-full",children:c.command||c.id}),o.jsxs("div",{className:"bg-item-foot",children:[o.jsx("span",{className:"bg-meta",children:c.kind==="delegation"?o.jsxs(o.Fragment,{children:[(c.taskCount??1)>1?`${c.taskCount} 个子代理 · `:"",c.status==="running"?`运行 ${Ru((c.uptimeSeconds??0)*1e3)}${c.phase==="stalling"?" · 停滞中":c.phase==="finalizing"?" · 收尾中":""}`:"已结束"]}):o.jsxs(o.Fragment,{children:[c.pid?`pid ${c.pid} · `:"",c.status==="running"?`运行 ${Ru((c.uptimeSeconds??0)*1e3)}`:`exit ${c.exitCode??"?"}`]})}),c.command?o.jsx(Lu,{text:c.command,what:c.kind==="delegation"?"这个目标":"这条命令"}):null]})]},c.id))})]})}function Cc({n:e}){const t=new Date(e.at),s=`${String(t.getHours()).padStart(2,"0")}:${String(t.getMinutes()).padStart(2,"0")}:${String(t.getSeconds()).padStart(2,"0")}`,n=e.items??[],[i,r]=_.useState(!1),a=_.useRef(null),[l,c]=_.useState(!1);return _.useLayoutEffect(()=>{if(!i){c(!1);return}const u=a.current;if(!u)return;const h=()=>c(u.scrollHeight-u.clientHeight>4);h();const d=new ResizeObserver(h);return d.observe(u),()=>d.disconnect()},[i,n.length]),o.jsxs("div",{className:"status-note","data-kind":e.kind||"lifecycle",role:"note",title:t.toLocaleString(),children:[o.jsx("span",{className:"status-note-time",children:s}),o.jsx("span",{className:"status-note-text",children:e.text}),n.length?o.jsxs(o.Fragment,{children:[o.jsx("button",{type:"button",className:"status-note-toggle","aria-expanded":i,"aria-label":`${i?"收起":"展开"}这 ${n.length} 条记忆`,onClick:()=>r(u=>!u),children:i?"收起":`${n.length} 条`}),i?o.jsx("div",{className:"status-note-drawer-wrap",children:o.jsxs("div",{className:"status-note-drawer","data-scroll":l?"1":"0",children:[o.jsxs("div",{className:"status-note-drawer-head",children:[o.jsxs("span",{children:["共 ",n.length," 条"]}),l?o.jsx("span",{className:"status-note-drawer-hint",children:"滚轮查看"}):null]}),o.jsxs("ol",{className:"status-note-items",ref:a,children:[n.map((u,h)=>o.jsxs("li",{className:"status-note-item",children:[o.jsx("span",{className:"status-note-item-n",children:h+1}),o.jsxs("span",{className:"status-note-item-body",children:[o.jsx("span",{className:"status-note-item-text",children:u.text}),u.tags?.length?o.jsx("span",{className:"status-note-item-tags",children:u.tags.join(" · ")}):null]})]},h)),e.truncated?o.jsx("li",{className:"status-note-item more",children:"…还有更多,已截断"}):null]})]})}):null]}):null]})}function _1({msgs:e,sid:t,backend:s,busy:n,base:i,notes:r=[]}){const a=g5(e),l=[];let c=0;for(;c<r.length&&r[c].afterKey===null;)l.push(o.jsx(Cc,{n:r[c]},r[c].key)),c++;for(const u of a){u.kind==="work"?(l.push(o.jsx(_5,{items:u.items},u.key)),l.push(o.jsx(dy,{msgs:u.items},`fc-${u.key}`))):u.m.kind==="note"?l.push(o.jsx(Cc,{n:{key:u.m.key,kind:u.m.category,text:u.m.text,at:u.m.at??Date.now(),hits:u.m.hits,items:u.m.items,truncated:u.m.truncated}},u.m.key)):(l.push(o.jsx(h5,{m:u.m,sid:t,backend:s,busy:n,last:u.tail,live:u.live,base:i},u.m.key)),u.m.kind==="tool"&&l.push(o.jsx(dy,{msgs:[u.m]},`fc-${u.m.key}`)));const h=new Set(u.kind==="msg"?[u.m.key]:u.items.map(d=>d.key));for(let d=r[c];d&&d.afterKey!==null&&h.has(d.afterKey);d=r[c])l.push(o.jsx(Cc,{n:d},d.key)),c++}for(;c<r.length;)l.push(o.jsx(Cc,{n:r[c]},r[c].key)),c++;return o.jsx(o.Fragment,{children:l})}function i5({v:e}){const t=_.useRef(null),s=_.useRef(!0),n=_.useRef(0),i=_.useRef(-1e9),r=_.useRef(0),a=_.useRef(0),l=_.useRef(0),[c,u]=_.useState(!0),[h,d]=_.useState(!1),p=64,f=()=>performance.now(),m=D=>D.scrollHeight-D.scrollTop-D.clientHeight<=p,y=1200,x=D=>{r.current=f()+150,a.current=D.scrollTop,l.current=D.scrollHeight},g=()=>{const D=t.current;D&&(D.scrollTop=D.scrollHeight,x(D))},v=()=>{s.current=!1,n.current=0,u(!1)};Va.current=()=>{s.current=!0,n.current=f()+1200,u(!0),d(!1),g()},_.useEffect(()=>{const D=t.current;if(!D)return;const O=()=>{i.current=f()},F=()=>{const E=D.scrollTop,z=E-a.current;a.current=E;const H=D.scrollHeight,ne=H<l.current-1;if(l.current=H,z<-2&&!ne&&f()-i.current<y){v();return}if(f()<r.current)return;const ae=m(D);ae!==s.current&&(s.current=ae,u(ae)),ae&&d(!1)},C=E=>{O(),E.deltaY<0&&v()};let N=0;const P=E=>{O(),N=E.touches[0]?.clientY??0},B=E=>{O(),(E.touches[0]?.clientY??0)>N+4&&v()},T=E=>{O(),(E.key==="PageUp"||E.key==="ArrowUp"||E.key==="Home")&&v()},$=()=>O();return D.addEventListener("scroll",F,{passive:!0}),D.addEventListener("wheel",C,{passive:!0}),D.addEventListener("touchstart",P,{passive:!0}),D.addEventListener("touchmove",B,{passive:!0}),D.addEventListener("keydown",T),D.addEventListener("pointerdown",$,{passive:!0}),x(D),()=>{D.removeEventListener("scroll",F),D.removeEventListener("wheel",C),D.removeEventListener("touchstart",P),D.removeEventListener("touchmove",B),D.removeEventListener("keydown",T),D.removeEventListener("pointerdown",$)}},[]),_.useEffect(()=>{if(s.current||f()<n.current){g();return}e.msgs.length&&d(!0)},[e.rev,e.busy,e.perms.length]),_.useEffect(()=>{const D=t.current,O=D?.firstElementChild??null;if(!D||!O||typeof ResizeObserver>"u")return;const F=new ResizeObserver(()=>{!s.current&&f()>=n.current||requestAnimationFrame(()=>{(s.current||f()<n.current)&&g()})});return F.observe(O),()=>F.disconnect()},[e.info.id]);const b=async D=>{const O=t.current,F=O?.scrollHeight??0,C=O?.scrollTop??0;D||(s.current=!1,u(!1)),A(!0),await X.loadEarlier(e.info.id),requestAnimationFrame(()=>{const N=t.current;N&&(N.scrollTop=N.scrollHeight-F+C,x(N))})},w=()=>{b(!1)},k=async D=>{const O=[...D.queue];try{const F=await X.createSession(String(D.info.backend),D.info.cwd,"");X.setActive(F);for(const C of O)X.enqueuePrompt(F,C.text,C.attachments);X.purgeCold(D.info.id)}catch{}},j=_.useRef(0),[I,A]=_.useState(!1);return _.useEffect(()=>{j.current=0,A(!1)},[e.info.id]),_.useEffect(()=>{const D=t.current;!D||!e.hasOlder||e.loadingOlder||j.current>=cy||D.scrollHeight>D.clientHeight+80||(j.current+=1,b(!0))},[e.rev,e.hasOlder,e.loadingOlder,e.info.id]),_.useEffect(()=>{const D=t.current;if(!D)return;const O=()=>{!e.hasOlder||e.loadingOlder||j.current>=cy||D.scrollTop>D.clientHeight*1.5||(j.current+=1,b(!0))};return D.addEventListener("scroll",O,{passive:!0}),()=>D.removeEventListener("scroll",O)},[e.info.id,e.hasOlder,e.loadingOlder]),o.jsxs("div",{className:"stream-wrap",children:[o.jsx("div",{className:"stream",ref:t,tabIndex:0,children:o.jsxs("div",{className:"stream-inner",children:[e.info.status==="starting"?o.jsxs("div",{className:"start-hold",role:"status",children:[o.jsx("span",{className:"stream-spin","aria-hidden":"true"}),o.jsx("span",{children:"正在启动 agent 进程…好了会自动把消息发出去"})]}):null,e.info.status==="error"&&e.info.lastError?o.jsxs("div",{className:"start-fail",role:"alert",children:[o.jsxs("div",{className:"start-fail-why",children:["会话启动失败:",e.info.lastError]}),o.jsxs("div",{className:"start-fail-act",children:[o.jsx("button",{className:"ghost-btn",onClick:()=>void k(e),children:"再试一次"}),o.jsx("button",{className:"ghost-btn",onClick:()=>X.purgeCold(e.info.id),children:"删除这个会话"})]})]}):null,e.hasOlder?o.jsx("div",{className:"load-earlier",children:e.loadingOlder?o.jsx("span",{className:"load-earlier-busy",children:"正在加载更早的消息…"}):o.jsx("button",{className:"ghost-btn",onClick:()=>w(),children:"↑ load earlier messages"})}):I&&e.msgs.length?o.jsx("div",{className:"load-earlier",children:o.jsx("span",{className:"load-earlier-end",children:"已到开头"})}):null,o.jsx(_1,{msgs:e.msgs,notes:e.notes,sid:e.info.id,backend:e.info.backend,busy:Rr(e),base:e.info.workspace||e.info.cwd}),o.jsx(s5,{busy:Rr(e),since:e.busySince}),e.perms.filter(D=>!e.msgs.some(O=>O.kind==="approval"&&O.requestId===D.requestId)).map(D=>o.jsx(b1,{sid:e.info.id,req:D},D.requestId))]})}),!c&&o.jsxs("button",{className:`jump-latest ${h?"has-new":""}`,onClick:()=>Va.current(),title:h?"new output — jump to the newest":"jump to the newest","aria-label":"jump to newest output",children:[o.jsx(Pw,{size:14}),h?o.jsx("span",{className:"jump-dot","aria-hidden":"true"}):null]})]})}const v1=_.memo(m5,(e,t)=>e.live===t.live&&e.m.key===t.m.key&&e.m.text===t.m.text&&e.m.at===t.m.at),r5=(e,t)=>{const s=e.m,n=t.m;return s.key===n.key&&s.title===n.title&&s.status===n.status&&s.detail===n.detail&&s.input===n.input&&s.at===n.at},y1=_.memo(p5,r5),o5=(e,t)=>{const s=e.m,n=t.m;return e.busy===t.busy&&s.key===n.key&&s.at===n.at&&s.terminal===n.terminal&&s.explanation===n.explanation&&JSON.stringify(s.items)===JSON.stringify(n.items)},a5=_.memo(f5,o5);function l5({text:e}){const t=jP(e),[s,n]=_.useState(!1);return t?o.jsxs("div",{className:`bg-notice ${s?"open":""}`,"data-ok":t.ok?"ok":"fail",children:[o.jsxs("button",{type:"button",className:"bg-notice-head","aria-expanded":s,onClick:()=>n(!s),children:[o.jsx("span",{className:"bg-notice-dot","aria-hidden":"true"}),o.jsx("span",{className:"bg-notice-title",children:t.kind==="watch"?"后台任务命中":"后台任务完成"}),o.jsx("code",{className:"bg-notice-cmd",title:t.command,children:ry(t.command)}),t.kind==="watch"?o.jsxs("code",{className:"bg-notice-code",title:t.pattern,children:["「",ry(t.pattern,28),"」"]}):t.exitCode===null?o.jsx("span",{className:"bg-notice-code",children:"已结束"}):o.jsxs("span",{className:"bg-notice-code",children:["exit ",t.exitCode]}),o.jsx("span",{className:"bg-notice-caret","aria-hidden":"true",children:s?"▾":"▸"})]}),s?o.jsxs("div",{className:"bg-notice-body",children:[o.jsx("div",{className:"bg-notice-label",children:"命令"}),o.jsx("pre",{className:"bg-notice-pre",children:t.command||"(未报告)"}),o.jsxs("div",{className:"bg-notice-label",children:[t.kind==="watch"?"命中行":"输出",t.output?"":"(空)"]}),t.output?o.jsx("pre",{className:"bg-notice-pre",children:t.output}):null]}):null]}):o.jsx(o.Fragment,{children:e})}function c5({d:e,base:t}){const[s,n]=_.useState(!1),i=e.tasks.filter(r=>!r.ok).length;return o.jsxs("div",{className:`bg-notice deleg-notice ${s?"open":""}`,"data-ok":i?"fail":"ok",children:[o.jsxs("button",{type:"button",className:"bg-notice-head","aria-expanded":s,onClick:()=>n(!s),children:[o.jsx("span",{className:"bg-notice-dot","aria-hidden":"true"}),o.jsx("span",{className:"bg-notice-title",children:"子代理回来了"}),o.jsx("code",{className:"bg-notice-cmd",title:e.tasks.map(r=>r.goal).join(`
|
|
127
127
|
`),children:MP(e)}),o.jsx("span",{className:"bg-notice-caret","aria-hidden":"true",children:s?"▾":"▸"})]}),s?o.jsxs("div",{className:"bg-notice-body",children:[e.dispatched?o.jsxs("div",{className:"bg-notice-label",children:["派发于 ",e.dispatched,e.role?` · ${e.role}`:"",e.total?` · 合计 ${e.total}`:""," · ",e.id]}):null,e.tasks.map(r=>o.jsxs("div",{className:"deleg-task","data-ok":r.ok?"ok":"fail",children:[o.jsxs("div",{className:"deleg-task-head",children:[o.jsx("span",{className:"deleg-mark","aria-hidden":"true",children:r.ok?"✓":"✗"}),o.jsx("span",{className:"deleg-goal",children:r.goal||`任务 ${r.index}`}),o.jsxs("span",{className:"deleg-meta",children:[r.status,r.meta?` · ${r.meta}`:""]})]}),r.body?o.jsx("div",{className:"deleg-body",children:o.jsx(aS,{text:r.body,base:t})}):null]},r.index))]}):null]})}function u5({text:e,base:t}){const s=DP(e);return s?o.jsx(c5,{d:s,base:t}):o.jsx(l5,{text:e})}function h5({m:e,sid:t,backend:s,last:n,busy:i,live:r,base:a}){switch(e.kind){case"user":return o.jsxs("div",{className:`msg user${e.from?" from-agent":""}`,children:[o.jsx("div",{className:"role",title:e.from?`会话 ${e.from.id}`:void 0,children:e.from?`来自「${e.from.title}」`:"YOU"}),o.jsxs("div",{className:"bubble",children:[e.files.length>0?o.jsx("div",{className:"bubble-files",children:e.files.map((l,c)=>o.jsxs("span",{className:"bubble-file",title:l.name,children:[l.kind==="image"?o.jsx(yE,{size:12}):o.jsx(Jn,{size:12}),l.name]},`${l.name}-${c}`))}):null,o.jsx(u5,{text:e.text,base:a}),o.jsxs("div",{className:"bubble-actions",children:[e.at?o.jsx("span",{className:"msg-at",title:Fr(e.at),children:Gf(e.at)}):null,o.jsx(Lu,{text:e.text,what:"这条消息"})]})]})]});case"agent":return o.jsxs("div",{className:"msg agent",children:[o.jsxs("div",{className:"role with-mark",children:[o.jsx(VP,{backend:s}),ah(s).label||"AGENT"]}),o.jsxs("div",{className:"bubble",children:[o.jsx(aS,{text:e.text,base:a}),o.jsxs("div",{className:"bubble-actions",children:[e.at?o.jsx("span",{className:"msg-at",title:Fr(e.at),children:Gf(e.at)}):null,o.jsx(Lu,{text:e.text,what:"这条回复"}),o.jsx(S5,{id:e.key,text:e.text,sid:t}),n&&!i?o.jsx(KP,{sid:t,busy:i}):null]}),o.jsx(k5,{mkey:e.key})]})]});case"thought":return e.text.trim()?o.jsx(v1,{m:e,live:!!r}):null;case"approval":return o.jsx(v5,{m:e,sid:t});case"project-skills":return o.jsx(y5,{m:e,sid:t});case"tool":return o.jsx(y1,{m:e});case"plan":return o.jsx(a5,{m:e,busy:i});case"meta":return o.jsx("div",{className:"msg meta",children:o.jsx("div",{className:"bubble",children:e.text})});default:return null}}function d5({v:e}){const[t,s]=_.useState(!1),n=_.useRef(null),i=e.plan;if(_.useLayoutEffect(()=>{t&&p1(n.current)},[t,i]),!i||!i.items.length)return null;const r=i.items.length,a=i.items.filter(h=>h.status==="completed").length,l=a===r,c=i.terminal==="interrupted"?"已打断":i.terminal==="failed"?"运行出错":i.terminal==="ended"?"回合结束":l?"已完成":e.busy?"进行中":"待命中",u=i.terminal?"terminal":l?"done":e.busy?"running":"";return o.jsxs("div",{className:`plan-bar ${l&&!i.terminal?"completed":""}`,children:[o.jsxs("button",{type:"button",className:"plan-head",onClick:()=>s(h=>!h),"aria-expanded":t,title:"任务计划 · 点击展开/折叠",children:[o.jsx("span",{className:"plan-title",children:"任务计划"}),o.jsx("span",{className:"plan-dots","aria-hidden":"true",children:i.items.map((h,d)=>o.jsx("span",{className:`dot ${h.status}`},d))}),o.jsxs("span",{className:"plan-count",children:[a,"/",r]}),o.jsx("span",{className:`plan-state ${u}`,children:c}),o.jsx(en,{size:11,className:`tool-chev ${t?"open":""}`})]}),t?o.jsxs("ol",{className:"plan-body",ref:n,children:[i.title?o.jsx("li",{className:"plan-name","aria-label":"计划名",children:i.title}):null,i.items.map((h,d)=>o.jsxs("li",{className:`plan-step ${h.status}`,children:[o.jsx("span",{className:"step-icon","aria-hidden":"true",children:h.status==="completed"?"✓":h.status==="in_progress"?"◉":h.status==="cancelled"?"✕":"○"}),o.jsx("span",{className:"step-title",children:h.content})]},d))]}):null,t&&i.explanation?o.jsx("p",{className:"plan-explanation",children:i.explanation}):null]})}function f5({m:e,busy:t}){const[s,n]=_.useState(!1),i=_.useRef(null);_.useLayoutEffect(()=>{s&&p1(i.current)},[s,e]);const r=e.items.length;if(!r)return null;const a=e.items.filter(h=>h.status==="completed").length,l=a===r,c=e.terminal==="interrupted"?"已打断":e.terminal==="failed"?"运行出错":e.terminal==="ended"?"回合结束":l?"已完成":t?"进行中":"",u=e.terminal?"terminal":l?"done":t?"running":"";return o.jsx("div",{className:"msg",children:o.jsxs("div",{className:`plan-card ${l&&!e.terminal?"completed":""}`,children:[o.jsxs("button",{type:"button",className:"plan-head",onClick:()=>n(h=>!h),"aria-expanded":s,title:"任务计划 · click to collapse/expand",children:[o.jsx("span",{className:"plan-title",children:"任务计划"}),o.jsx("span",{className:"plan-dots","aria-hidden":"true",children:e.items.map((h,d)=>o.jsx("span",{className:`dot ${h.status}`},d))}),o.jsxs("span",{className:"plan-count",children:[a,"/",r]}),c?o.jsx("span",{className:`plan-state ${u}`,children:c}):null,o.jsx(en,{size:11,className:`tool-chev ${s?"open":""}`})]}),s?o.jsxs("ol",{className:"plan-body",ref:i,children:[e.title?o.jsx("li",{className:"plan-name","aria-label":"计划名",children:e.title}):null,e.items.map((h,d)=>o.jsxs("li",{className:`plan-step ${h.status}`,children:[o.jsx("span",{className:"step-icon","aria-hidden":"true",children:h.status==="completed"?"✓":h.status==="in_progress"?"◉":h.status==="cancelled"?"✕":"○"}),o.jsx("span",{className:"step-title",children:h.content})]},d))]}):null,s&&e.explanation?o.jsx("p",{className:"plan-explanation",children:e.explanation}):null]})})}function p5({m:e}){const[t,s]=_.useState(!1),n=!!(e.input||e.detail),i=Op(e.status);return o.jsx("div",{className:"msg",children:o.jsxs("div",{className:`tool-card ${e.status}`,children:[o.jsxs("button",{type:"button",className:"tool-head",onClick:()=>n&&s(r=>!r),title:n?`${e.title}
|
|
128
128
|
click for the full call and its result`:e.title,"aria-expanded":n?t:void 0,children:[o.jsx("span",{className:`tool-dot ${i}`,"aria-hidden":"true"}),o.jsx("span",{className:"tool-title",children:e.title}),e.at?o.jsx("span",{className:"msg-at",title:Fr(e.at),children:mi(e.at)}):null,n?o.jsx(en,{size:11,className:`tool-chev ${t?"open":""}`}):null]}),t&&o.jsxs("div",{className:"tool-body",children:[o.jsx("div",{className:"lbl",children:"tool"}),o.jsx("pre",{className:"tool-meta",children:e.title}),o.jsx("div",{className:"lbl",children:"kind · status"}),o.jsxs("pre",{className:"tool-meta",children:[e.kind2?`${e.kind2} · `:"",e.status]}),e.input?o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"lbl",children:"input"}),o.jsx("pre",{children:e.input})]}):null,e.detail?o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"lbl",children:"output"}),o.jsx("pre",{children:e.detail})]}):null]})]})})}function Op(e){const t=String(e||"").toLowerCase();return t==="completed"||t==="success"?"ok":t==="failed"||t==="error"||t==="cancelled"?"err":t==="in_progress"||t==="running"||t==="pending"?"running":"idle"}function m5({m:e,live:t}){const[s,n]=_.useState(null),i=s??t,r=_.useRef(null),[a,l]=_.useState(!0),c=24;_.useEffect(()=>{const h=r.current;if(!h||!t)return;let d=h.scrollTop;const p=()=>h.scrollHeight-h.scrollTop-h.clientHeight<=c,f=()=>{const b=h.scrollTop,w=b-d;if(d=b,w<-2){l(!1);return}l(p())},m=b=>{b.deltaY<0&&l(!1)};let y=0;const x=b=>{y=b.touches[0]?.clientY??0},g=b=>{(b.touches[0]?.clientY??0)>y+4&&l(!1)},v=b=>{(b.key==="PageUp"||b.key==="ArrowUp"||b.key==="Home")&&l(!1)};return h.addEventListener("scroll",f,{passive:!0}),h.addEventListener("wheel",m,{passive:!0}),h.addEventListener("touchstart",x,{passive:!0}),h.addEventListener("touchmove",g,{passive:!0}),h.addEventListener("keydown",v),()=>{h.removeEventListener("scroll",f),h.removeEventListener("wheel",m),h.removeEventListener("touchstart",x),h.removeEventListener("touchmove",g),h.removeEventListener("keydown",v)}},[t,i]),_.useEffect(()=>{if(!t||!a)return;const h=r.current;h&&(h.scrollTop=h.scrollHeight)},[e.text,t,a]);const u=()=>{l(!0);const h=r.current;h&&(h.scrollTop=h.scrollHeight)};return o.jsxs("div",{className:`msg thought${t?" live":""}`,children:[o.jsxs("button",{type:"button",className:"role thought-head","aria-expanded":i,"aria-label":`${i?"fold":"open"} the agent's thinking`,onClick:()=>n(!i),children:[o.jsx(en,{size:11,className:`thought-chev${i?" open":""}`}),"💭 ",t?"思考中…":"思考",o.jsxs("span",{className:"thought-meta",children:[e.at?`${Gf(e.at)} · `:"",e.text.length," 字"]})]}),i&&o.jsxs("div",{className:`bubble${t?" live":""}`,ref:r,children:[e.text,t?null:o.jsx("div",{className:"bubble-actions",children:o.jsx(Lu,{text:e.text,what:"这段思考"})})]}),t&&i&&!a&&o.jsxs("button",{type:"button",className:"thought-stick",onClick:u,title:"follow the newest thinking again","aria-label":"follow the newest thinking again",children:[o.jsx(Pw,{size:11})," 回到最新"]})]})}const Ad=e=>e.kind==="thought"||e.kind==="tool";function g5(e){const t=[];let s=0;for(;s<e.length;){const c=e[s];if(!Ad(c)){t.push({kind:"msg",m:c}),s++;continue}let u=s;for(;u<e.length&&Ad(e[u]);)u++;t.push({kind:"work",items:e.slice(s,u).filter(Ad),current:!1}),s=u}const n=t.map(c=>c.kind).lastIndexOf("work");let i=-1;if(t.forEach((c,u)=>{c.kind==="msg"&&c.m.kind==="user"&&(i=u)}),n>i){const c=t[n];c.kind==="work"&&(c.current=!0)}const r=[],a=c=>({kind:"msg",m:c,live:!1,tail:!1});for(const c of t){if(c.kind==="msg"){r.push(a(c.m));continue}const u=c.current?c.items.slice(-5):[],h=c.items.slice(0,c.items.length-u.length);h.length>=(c.current?1:2)?r.push({kind:"work",key:`work-${r.length}`,items:h,tail:!1}):h.forEach(d=>r.push(a(d))),u.forEach(d=>r.push(a(d)))}const l=[...r].reverse().find(c=>c.kind==="msg");l&&l.kind==="msg"&&(l.tail=!0);for(const c of r)c.kind==="msg"&&(c.m.kind==="agent"||c.m.kind==="thought")&&(c.live=!!c.m.open);return r}function _5({items:e}){const[t,s]=_.useState(!1),n=e.filter(m=>m.kind==="tool"),i=e.filter(m=>m.kind==="thought"),r=n.filter(m=>Op(m.status)==="err").length,a=n.some(m=>Op(m.status)==="running"),l=i.reduce((m,y)=>m+y.text.length,0),c=[...new Set(n.map(m=>m.title.replace(/\s+/g," ").trim()))],u=c.slice(0,2).map(m=>m.length>34?`${m.slice(0,33)}…`:m).join(" · "),h=n.length&&i.length?`${n.length} 次工具调用 · ${i.length} 段思考`:n.length?`${n.length} 次工具调用`:`${i.length} 段思考`,d=e.map(m=>m.at).filter(m=>typeof m=="number"),p=d.length?Math.min(...d):null,f=d.length?Math.max(...d):null;return o.jsx("div",{className:"msg",children:o.jsxs("div",{className:"work-run",children:[o.jsxs("button",{type:"button",className:"work-run-head","aria-expanded":t,title:[`折叠了 ${n.length} 次工具调用、${i.length} 段思考(${l} 字)`,"点开看它们",...n.map(m=>`· ${m.title}`)].join(`
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
<link rel="icon" href="/icons/favicon.ico" sizes="any" />
|
|
15
15
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon-180.png" />
|
|
16
16
|
<title>Agentus</title>
|
|
17
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
+
<script type="module" crossorigin src="/assets/index-D34Kt-uv.js"></script>
|
|
18
18
|
<link rel="stylesheet" crossorigin href="/assets/index-Dej-v4qD.css">
|
|
19
19
|
</head>
|
|
20
20
|
<body>
|