agentus 0.0.0-stage → 0.2.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.
Files changed (59) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +11 -0
  3. package/README.md +476 -2
  4. package/README.zh-CN.md +396 -0
  5. package/bin/agentus.mjs +147 -0
  6. package/docs/android-notify-contract.md +658 -0
  7. package/docs/dev/01-dev-loop.md +186 -0
  8. package/docs/dev/02-verify.md +237 -0
  9. package/docs/dev/03-release.md +218 -0
  10. package/docs/dev/04-recover.md +199 -0
  11. package/docs/dev/05-agent-driven.md +114 -0
  12. package/docs/dev/README.md +65 -0
  13. package/docs/installation.md +366 -0
  14. package/docs/installation.zh-CN.md +350 -0
  15. package/docs/refs/aionui-acp-rules.md +46 -0
  16. package/docs/refs/hermes-studio-ui-ideas.md +191 -0
  17. package/docs/transcript-folding.md +139 -0
  18. package/package.json +76 -5
  19. package/packages/server/mcp/plan-server.mjs +217 -0
  20. package/packages/server/mock/agent.mjs +693 -0
  21. package/packages/server/package.json +22 -0
  22. package/packages/server/src/acp/backends.ts +196 -0
  23. package/packages/server/src/acp/errors.ts +46 -0
  24. package/packages/server/src/acp/registry.ts +579 -0
  25. package/packages/server/src/acp/session-manager.ts +1868 -0
  26. package/packages/server/src/auth.ts +688 -0
  27. package/packages/server/src/dashscope.ts +235 -0
  28. package/packages/server/src/fs.ts +379 -0
  29. package/packages/server/src/hotwords.ts +132 -0
  30. package/packages/server/src/index.ts +1486 -0
  31. package/packages/server/src/notify/center.ts +994 -0
  32. package/packages/server/src/notify/types.ts +222 -0
  33. package/packages/server/src/plan/plan.ts +126 -0
  34. package/packages/server/src/pty-helper.py +135 -0
  35. package/packages/server/src/settings.ts +469 -0
  36. package/packages/server/src/store/export.ts +209 -0
  37. package/packages/server/src/store/store.ts +1238 -0
  38. package/packages/server/src/term.ts +201 -0
  39. package/packages/server/src/title.ts +109 -0
  40. package/packages/server/src/voice.ts +251 -0
  41. package/packages/shared/package.json +14 -0
  42. package/packages/shared/src/index.ts +271 -0
  43. package/packages/web/dist/assets/index-D667PX2c.css +32 -0
  44. package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
  45. package/packages/web/dist/coding-agents/hermes.png +0 -0
  46. package/packages/web/dist/coding-agents/qoder.svg +5 -0
  47. package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
  48. package/packages/web/dist/icons/agentus-icon.svg +301 -0
  49. package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
  50. package/packages/web/dist/icons/agentus-mark.svg +301 -0
  51. package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
  52. package/packages/web/dist/icons/favicon.ico +0 -0
  53. package/packages/web/dist/icons/icon-192.png +0 -0
  54. package/packages/web/dist/icons/icon-512.png +0 -0
  55. package/packages/web/dist/icons/icon-maskable-512.png +0 -0
  56. package/packages/web/dist/index.html +30 -0
  57. package/packages/web/dist/manifest.webmanifest +17 -0
  58. package/packages/web/dist/sw.js +61 -0
  59. package/scripts/make-cert.sh +101 -0
@@ -0,0 +1,1868 @@
1
+ // SessionManager — one ACP client connection per session, per design.md §1.
2
+ // Owns: subprocess lifecycle (spawn/kill/orphan-reclaim), the ACP ClientSideConnection
3
+ // loop, event fan-out with monotonic seq, permission pending-map with timeout,
4
+ // mode/config plumbing. UI holds zero intelligence (red line D): we only relay.
5
+ import { spawn } from "node:child_process";
6
+ import { randomUUID } from "node:crypto";
7
+ import path from "node:path";
8
+ import { Readable, Writable } from "node:stream";
9
+ import { fileURLToPath } from "node:url";
10
+ import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
11
+ import type {
12
+ McpServer,
13
+ RequestPermissionRequest,
14
+ RequestPermissionResponse,
15
+ } from "@agentclientprotocol/sdk";
16
+ import { BACKENDS, buildSpawnEnv, nativePlanSourceOf, type BackendSpec } from "./backends.js";
17
+ import { classifyError, checkedHealth, effectiveArgs, handshakeFrom, rowToSpec } from "./registry.js";
18
+ import type { BackendHandshake, CheckErrorCode } from "./registry.js";
19
+ import {
20
+ cleanAgentTitle, deriveTitle, titleFromLatestPrompt, TITLE_INSTRUCTION,
21
+ } from "../title.js";
22
+ import type { Store, SessionRow } from "../store/store.js";
23
+ import { textOf, withText } from "../store/store.js";
24
+ import type {
25
+ AttachmentSummary,
26
+ BackendId,
27
+ ConfigOptionView,
28
+ PermissionDecision,
29
+ PermissionDiff,
30
+ PermissionRequestView,
31
+ PlanSnapshot,
32
+ PromptAttachment,
33
+ ServerEvent,
34
+ SessionInfo,
35
+ SessionModeState,
36
+ TurnTrace,
37
+ } from "../../../shared/src/index.js";
38
+ import { acceptsToolWrite, acceptsWriteFrom, clampExplanation, demoteInProgress, foldPlanUpdate, hasUnfinished, normalizeItems, v2PlanEnabled } from "../plan/plan.js";
39
+
40
+ // Permission prompts must not hang a session forever (design.md §8-4).
41
+ // Env-tunable so QA can exercise the timeout path in seconds instead of minutes.
42
+ const PERMISSION_TIMEOUT_MS = Number(process.env.AGENTUS_PERM_TIMEOUT_MS || 5 * 60_000);
43
+
44
+ // ── What the request is ABOUT ─────────────────────────────────────────────────────────────────
45
+ // An edit approval arrives as a `ToolCallUpdate` whose `content` carries a diff (path + the
46
+ // whole text before and after), and the agent puts the tool's own arguments in `rawInput`. The
47
+ // operator is being asked to let a file change, so the surface that asks must be able to say
48
+ // WHICH file and WHAT changes — a bare title is the difference between a decision and a coin
49
+ // flip. Sent BOUNDED: a patch proposal carries the entire file twice, and this view also
50
+ // travels to a phone over a tunnel.
51
+ const PREVIEW_LIMIT = 4000;
52
+ // …but bounded AROUND the change, never from the front: see boundedDiff.
53
+ const CONTEXT_KEEP = 400;
54
+
55
+ interface PermissionToolCall {
56
+ title?: string;
57
+ kind?: string;
58
+ content?: { type?: string; path?: string; oldText?: string | null; newText?: string | null }[];
59
+ rawInput?: { path?: string; arguments?: { path?: string } };
60
+ }
61
+
62
+ function permissionArtifacts(tc: PermissionToolCall): { path: string | null; diff: PermissionDiff | null } {
63
+ const node = (tc.content ?? []).find((c) => c && typeof c.path === "string" && c.path);
64
+ const rawPath = tc.rawInput?.arguments?.path ?? tc.rawInput?.path ?? null;
65
+ const path = node?.path ?? (typeof rawPath === "string" && rawPath ? rawPath : null);
66
+ if (!path) return { path: null, diff: null };
67
+ if (!node || (node.oldText == null && node.newText == null)) return { path, diff: null };
68
+ return { path, diff: boundedDiff(path, node.oldText ?? "", node.newText ?? "") };
69
+ }
70
+
71
+ /** The before/after, bounded so the change survives — not sliced from the front.
72
+ *
73
+ * A real edit approval carries the WHOLE file on each side (hermes: old_text/new_text are the
74
+ * complete contents). Cutting both sides at the same offset keeps their shared head and tail,
75
+ * drops the middle — which is exactly where the change is — and hands the client two texts that
76
+ * differ at the cut, so it reports the truncation as a rewrite: an 83-line file with ONE edited
77
+ * line showed up as ~60 changed lines (measured 2026-10-06, mock `[tool-hermes]`). Trim the
78
+ * shared head and tail first, keep a little of each as context, and only then bound. */
79
+ function boundedDiff(path: string, oldText: string, newText: string): PermissionDiff {
80
+ let head = 0;
81
+ const shared = Math.min(oldText.length, newText.length);
82
+ while (head < shared && oldText[head] === newText[head]) head++;
83
+ let tail = 0;
84
+ while (tail < shared - head && oldText[oldText.length - 1 - tail] === newText[newText.length - 1 - tail]) tail++;
85
+ const from = Math.max(0, head - CONTEXT_KEEP);
86
+ const upto = (s: string): number => Math.max(from, s.length - Math.max(0, tail - CONTEXT_KEEP));
87
+ const window = (s: string): string => s.slice(from, upto(s));
88
+ const oldSide = window(oldText);
89
+ const newSide = window(newText);
90
+ const clipped = oldSide.length > PREVIEW_LIMIT || newSide.length > PREVIEW_LIMIT;
91
+ return {
92
+ path,
93
+ oldText: oldSide.slice(0, PREVIEW_LIMIT),
94
+ newText: newSide.slice(0, PREVIEW_LIMIT),
95
+ // "truncated" means the operator is NOT looking at the whole proposal. Dropping a shared
96
+ // head/tail we kept in full inside CONTEXT_KEEP is not that — a two-line sample file would
97
+ // otherwise carry a "only part of it is shown" note and teach the operator to ignore it.
98
+ truncated: clipped || from > 0 || tail > CONTEXT_KEEP,
99
+ };
100
+ }
101
+
102
+ // A title fork is a real model turn over a copy of the conversation: give it room, but never
103
+ // let a stuck one hang the operator's click — the caller falls back to the derived name on
104
+ // timeout. Env-tunable so a sweep can exercise the timeout path in seconds.
105
+ const TITLE_FORK_TIMEOUT_MS = Number(process.env.AGENTUS_TITLE_TIMEOUT_MS || 90_000);
106
+
107
+ // How long a cancelled turn may take to actually stop before an interrupting prompt gives up
108
+ // (an agent that ignores session/cancel must not hang the operator's next sentence forever).
109
+ const TURN_SETTLE_TIMEOUT_MS = Number(process.env.AGENTUS_SETTLE_TIMEOUT_MS || 15_000);
110
+
111
+ /** A cold slot the agent cannot adopt, and why — the only two answers there are.
112
+ *
113
+ * Resuming has to *ask* the agent: `session/load` is "do you still have this session?", and the
114
+ * agent answers for the home it was handed NOW. Nothing in ACP says "no" loudly — Hermes returns
115
+ * a null result (just a log line) and its `prompt` answers `stop_reason:"refusal"` — so before
116
+ * this the cockpit reported the slot `ready` and then swallowed every message: the operator wrote
117
+ * into a slot that looked alive and heard nothing back (measured 2026-10-06, 5 minutes, no error).
118
+ *
119
+ * Both cases are refusals the operator must SEE — with the two homes named — and the only useful
120
+ * action for a slot that can never come back is to delete it. */
121
+ export class SessionUnavailable extends Error {
122
+ readonly code: "home_mismatch" | "context_missing";
123
+ /** the home the session was created under (null for a row written before this was recorded) */
124
+ readonly sessionHome: string | null;
125
+ /** the home the backend row hands out today */
126
+ readonly rowHome: string | null;
127
+
128
+ constructor(
129
+ code: SessionUnavailable["code"],
130
+ message: string,
131
+ homes: { sessionHome?: string | null; rowHome?: string | null } = {},
132
+ ) {
133
+ super(message);
134
+ this.name = "SessionUnavailable";
135
+ this.code = code;
136
+ this.sessionHome = homes.sessionHome ?? null;
137
+ this.rowHome = homes.rowHome ?? null;
138
+ }
139
+ }
140
+
141
+ interface LiveSession {
142
+ info: SessionInfo;
143
+ child?: ReturnType<typeof spawn>;
144
+ conn?: ClientSideConnection;
145
+ busy: boolean;
146
+ pendingPermissions: Map<string, {
147
+ /** The view the operator is looking at. Kept (not just the resolver) so a page that
148
+ * connects or REFRESHES later can be told what is already waiting for it. */
149
+ view: PermissionRequestView;
150
+ resolve: (r: RequestPermissionResponse) => void;
151
+ timer: NodeJS.Timeout;
152
+ }>;
153
+ alwaysAllow: Set<string>; // "allow_always" remembered per live session only (AionUi F-PERM-05)
154
+ stderrBuf: string[];
155
+ /** What the agent says it can do (initialize.agentCapabilities), kept because the title
156
+ * regeneration is only possible when it advertises `session/fork`. */
157
+ caps: { fork: boolean; load: boolean };
158
+ /** Incremented when a turn STARTS. An interrupt waits for the specific turn it cancelled,
159
+ * so a second interrupting utterance that already started its own turn is not mistaken for
160
+ * "the one I cancelled is still running". */
161
+ turnSeq: number;
162
+ /** Text sinks for sessions that live on this connection but are NOT the one in the rail —
163
+ * today only the throwaway fork used to summarise the conversation for a title. Anything
164
+ * whose sessionId is not `info.acpSessionId` is routed here and never persisted. */
165
+ collectors: Map<string, (u: Record<string, unknown>) => void>;
166
+ /** Set while restart() is swapping this slot's process. The child that is exiting belongs to the
167
+ * slot being REPLACED, so its exit must not be read as "the agent died" — that would stamp
168
+ * `error` on the row its replacement is adopting and fire a turn-end on a slot mid-swap. */
169
+ replacing?: boolean;
170
+ /** Set from spawn/resume until this slot's first prompt: the agent is re-sending its own history
171
+ * (ACP `session/load` replays the transcript), so its frames are a RECORD of work already stored,
172
+ * not new work. Persisting them wrote 1240 rows on one restart and moved every session's clock to
173
+ * 「刚刚」; the clock is the operator's "when was this last talked to", and a restart is not a
174
+ * conversation. Cleared the moment the operator actually says something — from then on the agent
175
+ * really is answering. */
176
+ replaying?: boolean;
177
+ /** The plan hand-over is owed on the NEXT prompt: a freshly (re)started agent has no memory of
178
+ * the plan it was working on, while the cockpit still has it (design-plan-service.md §7).
179
+ * Cleared after the first prompt, because after that the agent maintains the plan itself —
180
+ * re-sending it every turn would cost context and invite re-deriving finished work. */
181
+ planReminderPending?: boolean;
182
+ /** Minted per agent process and handed to the plan MCP server through the HANDSHAKE (§4): it is
183
+ * what keeps the tool's writes scoped to this one session even if the spawn command is copied
184
+ * out of `ps`. Lives and dies with the process that received it. */
185
+ planToken: string;
186
+ /** ACP v2 lets a session carry several plans (`planId`); the card renders one. Whichever planId
187
+ * shows up first is adopted and the rest are left alone — see the `plan_update` case. */
188
+ planId?: string;
189
+ /** One log line per agent process the first time a frame is ignored, so a reader of the log can
190
+ * tell "the cockpit is dropping these on purpose" from "the agent stopped emitting them". */
191
+ planFramesDropped?: boolean;
192
+ }
193
+
194
+ type Emitter = (evt: ServerEvent) => void;
195
+
196
+ export class SessionManager {
197
+ #sessions = new Map<string, LiveSession>();
198
+ #store: Store;
199
+ #emit: Emitter;
200
+
201
+ constructor(store: Store, emit: Emitter) {
202
+ this.#store = store;
203
+ this.#emit = emit;
204
+ }
205
+
206
+ list(): SessionInfo[] {
207
+ // Most recently CHATTED first — the cockpit's left rail is a launch pad, not a log file, and
208
+ // creation order is the wrong key for that: the session you talked to a minute ago is the
209
+ // one you come back to, even when it is the oldest row in the list.
210
+ const lastAt = this.#store.lastActivityAt();
211
+ return [...this.#sessions.values()]
212
+ .map((s) => ({
213
+ ...s.info,
214
+ lastSeq: this.#store.maxSeq(s.info.id),
215
+ lastAt: lastAt.get(s.info.id) ?? s.info.createdAt,
216
+ }))
217
+ .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt);
218
+ }
219
+
220
+ isBusy(id: string): boolean {
221
+ return this.#sessions.get(id)?.busy ?? false;
222
+ }
223
+
224
+ /** Reclaim orphans from a previous server run (AC5): kill recorded pids. */
225
+ reclaimOrphans(): number {
226
+ let killed = 0;
227
+ for (const row of this.#store.listSessions(true)) {
228
+ if (row.pid == null) continue;
229
+ try {
230
+ process.kill(row.pid, 0); // alive?
231
+ process.kill(row.pid, "SIGKILL");
232
+ killed++;
233
+ } catch {
234
+ /* already gone */
235
+ }
236
+ if (row.status !== "closed") {
237
+ // Only a turn that was ACTUALLY in flight can be reported as interrupted. A `ready` session
238
+ // had finished its turn, and restamping its plan would replace an honest "回合结束" with
239
+ // "已打断" — a lie the restart invents on its own (measured: a plan the mock had already
240
+ // closed out came back stamped interrupted after a restart).
241
+ const wasRunning = row.status === "running";
242
+ this.#store.upsertSession({ ...row, status: "error", closedAt: Date.now(), pid: null });
243
+ // A crash between turns left the plan card mid-work with nothing to finish it; the
244
+ // startup reclaim is the one that owns the run now, so it closes the card honestly.
245
+ if (wasRunning) this.#finalizePlan(row.id, "interrupted");
246
+ }
247
+ }
248
+ return killed;
249
+ }
250
+
251
+ /** Last stderr lines from a session's agent (for diagnosing failed turns). */
252
+ stderrTail(id: string, lines = 12): string {
253
+ const s = this.#sessions.get(id);
254
+ if (!s) return "";
255
+ return s.stderrBuf.slice(-lines).join("\n ");
256
+ }
257
+
258
+ /** Graceful shutdown: SIGTERM every live child before server exit. */
259
+ async shutdown(): Promise<void> {
260
+ const kills = [...this.#sessions.values()].map(async (s) => {
261
+ for (const { resolve, timer } of s.pendingPermissions.values()) {
262
+ clearTimeout(timer);
263
+ resolve({ outcome: { outcome: "cancelled" } });
264
+ }
265
+ s.pendingPermissions.clear();
266
+ s.child?.kill("SIGTERM");
267
+ });
268
+ await Promise.allSettled(kills);
269
+ }
270
+
271
+ /**
272
+ * A session's backend spec + argv. The registry row (what the operator edits) wins; an id
273
+ * that only exists among the builtin seeds still resolves, so a session created before the
274
+ * row was touched never breaks. argv comes from registry.effectiveArgs — that is where the
275
+ * hermes profile flag (`-p <profile>`) is composed.
276
+ */
277
+ #spawnShape(id: string): { spec: BackendSpec; args: string[] } | null {
278
+ const row = this.#store.getBackend(id);
279
+ if (row) return { spec: rowToSpec(row), args: effectiveArgs(row) };
280
+ const builtin = BACKENDS[id];
281
+ return builtin ? { spec: builtin, args: builtin.args } : null;
282
+ }
283
+
284
+ /**
285
+ * Fold a REAL session outcome back onto the backend row (kind=session). This is the only
286
+ * check that proves a row works (`acp --check` returns before the adapter's server module is
287
+ * even imported), so it is worth remembering: the settings list can then say "online, session,
288
+ * 2m ago" for a row that has actually run a slot, and a row that dies in the handshake keeps
289
+ * the reason instead of losing it with the failed session. The handshake is stored for the same
290
+ * reason — it is a property of (command, env, home, profile), not of one conversation.
291
+ *
292
+ * Bookkeeping must never fail a session: a throw in here is swallowed.
293
+ */
294
+ #noteBackendCheck(
295
+ backendId: string,
296
+ outcome: { ok: boolean; error?: string | null; errorCode?: CheckErrorCode; latencyMs?: number | null },
297
+ handshake?: BackendHandshake | null,
298
+ ): void {
299
+ try {
300
+ const row = this.#store.getBackend(backendId);
301
+ if (!row) return; // a builtin-only id that was never seeded: nothing to write to
302
+ const health = outcome.ok
303
+ ? checkedHealth(row.health, { status: "online", kind: "session", latencyMs: outcome.latencyMs ?? null })
304
+ : checkedHealth(row.health, {
305
+ status: "offline",
306
+ kind: "session",
307
+ errorCode: outcome.errorCode ?? classifyError(outcome.error ?? "unknown"),
308
+ message: outcome.error ?? null,
309
+ latencyMs: outcome.latencyMs ?? null,
310
+ });
311
+ this.#store.recordBackendCheck(backendId, health, handshake ?? null);
312
+ } catch { /* the row is evidence, not control flow */ }
313
+ }
314
+
315
+ async create(backend: BackendId, cwd: string, title?: string): Promise<SessionInfo> {
316
+ const shape = this.#spawnShape(backend);
317
+ if (!shape) throw new Error(`unknown backend: ${backend}`);
318
+ const { spec, args } = shape;
319
+ const id = randomUUID().slice(0, 8);
320
+ const now = Date.now();
321
+
322
+ // isolation: never let a child inherit its way back into the live runtime home
323
+ const plan = buildSpawnEnv(spec);
324
+ // measured once per spawn: how long the row took to reach `ready` (or to die trying)
325
+ const spawnStarted = Date.now();
326
+
327
+ // detached:true => killing our node process does NOT kill the child,
328
+ // so we MUST track pid for startup reclaim (reclaimOrphans). See design.md §8-1.
329
+ const child = spawn(spec.cmd, args, {
330
+ cwd,
331
+ stdio: ["pipe", "pipe", "pipe"],
332
+ detached: true,
333
+ env: plan.env,
334
+ });
335
+ child.unref();
336
+ // `spawn` reports an unrunnable command (ENOENT/EACCES) through an 'error' event instead of
337
+ // throwing, and leaves `child.pid` undefined. Left alone that becomes an uncaught exception
338
+ // plus a caller awaiting a handshake that can never arrive — so it is turned into a normal
339
+ // failure here, carrying the code the settings page shows for it.
340
+ let spawnError: Error | null = null;
341
+ child.on("error", (err: Error) => { spawnError = err; });
342
+ if (child.pid === undefined) {
343
+ const why = `failed to spawn ${spec.cmd}${spawnError ? `: ${(spawnError as Error).message}` : " (no pid)"}`;
344
+ this.#noteBackendCheck(String(backend), {
345
+ ok: false, errorCode: "spawn_failed", error: why, latencyMs: Date.now() - spawnStarted,
346
+ });
347
+ throw new Error(why);
348
+ }
349
+
350
+ const live: LiveSession = {
351
+ info: {
352
+ id, backend, acpSessionId: null, cwd,
353
+ workspace: null, contextLimit: null,
354
+ status: "starting",
355
+ pid: child.pid ?? null,
356
+ title: title || `${spec.label} @ ${shortCwd(cwd)}`,
357
+ autoTitle: title || `${spec.label} @ ${shortCwd(cwd)}`,
358
+ createdAt: now, modes: null, configOptions: [], commands: [],
359
+ },
360
+ child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
361
+ caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
362
+ planToken: randomUUID(),
363
+ // Until this slot's first PROMPT, anything the agent says is its own history being replayed,
364
+ // not an answer (see #onSessionUpdate). A resumed agent re-sends its whole transcript on
365
+ // `session/load` — full-length blocks, no messageId — and persisting those wrote 1240 rows
366
+ // into the live DB on one restart and pushed every session's clock to「刚刚」.
367
+ replaying: true,
368
+ };
369
+ this.#sessions.set(id, live);
370
+ live.info.lastAt = this.#store.lastActivityAt().get(id) ?? live.info.createdAt;
371
+ // the home is recorded with the row: it is the thing a later resume has to be checked against
372
+ this.#store.upsertSession(sessionRow(live.info, plan.home));
373
+
374
+ child.stderr?.on("data", (d: Buffer) => {
375
+ // stderr never touches the ndjson stream (design.md §8-2); keep tail for errors.
376
+ const line = d.toString().trim();
377
+ if (line) {
378
+ live.stderrBuf.push(line);
379
+ if (live.stderrBuf.length > 50) live.stderrBuf.shift();
380
+ }
381
+ });
382
+ child.on("exit", (code, sig) => this.#onChildExit(live, code, sig));
383
+
384
+ try {
385
+ const conn = new ClientSideConnection(
386
+ () => ({
387
+ sessionUpdate: (params) => this.#onSessionUpdate(live, params),
388
+ requestPermission: (params) => this.#onRequestPermission(live, params),
389
+ }),
390
+ ndJsonStream(
391
+ Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
392
+ Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
393
+ ),
394
+ );
395
+ live.conn = conn;
396
+
397
+ const init = await conn.initialize({
398
+ protocolVersion: 1,
399
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
400
+ });
401
+ const caps = (init?.agentCapabilities ?? {}) as {
402
+ loadSession?: boolean;
403
+ sessionCapabilities?: { fork?: unknown };
404
+ };
405
+ live.caps = {
406
+ fork: Boolean(caps.sessionCapabilities?.fork),
407
+ load: Boolean(caps.loadSession),
408
+ };
409
+ const res = await conn.newSession({ cwd, mcpServers: this.#planMcpServers(spec, live) });
410
+ live.info.acpSessionId = res.sessionId;
411
+ live.info.status = "ready";
412
+ live.info.modes = (res.modes ?? null) as SessionModeState | null;
413
+ live.info.configOptions = normConfigOptions(res.configOptions);
414
+ // `models` is on the wire but not in the SDK's published types (see shared/index.ts),
415
+ // so read it defensively instead of trusting a typed field that does not exist.
416
+ live.info.models = readModels(res);
417
+ // the row just proved itself: remember that, plus what the agent said it can do
418
+ this.#noteBackendCheck(String(backend), { ok: true, latencyMs: Date.now() - spawnStarted }, handshakeFrom(init, res));
419
+ this.#updateSession(live);
420
+ return live.info;
421
+ } catch (err) {
422
+ child.kill("SIGKILL");
423
+ live.info.status = "error";
424
+ live.info.lastError = errMessage(err) + stderrTail(live);
425
+ this.#sessions.delete(id); // failed handshake => don't keep zombie session rows
426
+ this.#store.upsertSession({ ...sessionRow(live.info), closedAt: Date.now() });
427
+ this.#noteBackendCheck(String(backend), {
428
+ ok: false, error: live.info.lastError, errorCode: classifyError(err),
429
+ latencyMs: Date.now() - spawnStarted,
430
+ });
431
+ throw err instanceof Error ? err : new Error(String(err));
432
+ }
433
+ }
434
+
435
+ /** Re-attach to a session that outlived its process (server restart, crash, or
436
+ * a deliberate close): spawn a fresh child and ask the agent to load the old
437
+ * ACP session, then re-apply the stored mode/effort. AC5's "restart and keep
438
+ * going" half — no intelligence here, the agent owns the transcript. */
439
+ async resume(id: string, opts: { replace?: boolean } = {}): Promise<SessionInfo> {
440
+ // `replace` is restart()'s path: the slot is still in the map on purpose (dropping it made the
441
+ // rail flash the slot as cold for the length of a spawn), so the "already live, nothing to do"
442
+ // shortcut has to be skipped and the fresh child takes its place.
443
+ const existing = this.#sessions.get(id);
444
+ if (existing && !opts.replace) return existing.info;
445
+
446
+ const row = this.#store.getSession(id);
447
+ if (!row) throw new Error(`unknown session: ${id}`);
448
+ if (!row.acpSessionId) throw new Error(`session ${id} was never handed to an agent`);
449
+ const shape = this.#spawnShape(row.backend);
450
+ if (!shape) throw new Error(`unknown backend: ${row.backend}`);
451
+ const { spec, args } = shape;
452
+
453
+ // Resume in the operator's workspace when they picked one: that is the whole
454
+ // point of the workspace being a separate field (see shared/index.ts).
455
+ const resumeCwd = row.workspace || row.cwd;
456
+ const plan = buildSpawnEnv(spec);
457
+ // ── Can this slot come back AT ALL? ──────────────────────────────────────────────────────
458
+ // A session's agent-side state lives in the home it was CREATED under; a resume hands the
459
+ // agent whatever home the row names TODAY. If those differ, the agent is being asked about a
460
+ // session in a database it does not have — it will answer politely and do nothing (measured:
461
+ // `{}` from session/load, `refusal` from session/prompt, and the slot still said "ready").
462
+ // Refuse BEFORE spawning, so the operator gets a reason instead of a dead slot.
463
+ if (row.home && plan.home && row.home !== plan.home) {
464
+ throw new SessionUnavailable(
465
+ "home_mismatch",
466
+ `this session's agent-side state is in ${row.home}, but the backend row now uses ${plan.home}`,
467
+ { sessionHome: row.home, rowHome: plan.home },
468
+ );
469
+ }
470
+ const resumeStarted = Date.now();
471
+ const child = spawn(spec.cmd, args, {
472
+ cwd: resumeCwd,
473
+ stdio: ["pipe", "pipe", "pipe"],
474
+ detached: true,
475
+ env: plan.env,
476
+ });
477
+ child.unref();
478
+ // same guard as create(): an unrunnable command must fail normally, with a code attached
479
+ let resumeSpawnError: Error | null = null;
480
+ child.on("error", (err: Error) => { resumeSpawnError = err; });
481
+ if (child.pid === undefined) {
482
+ const why = `failed to spawn ${spec.cmd}${resumeSpawnError ? `: ${(resumeSpawnError as Error).message}` : " (no pid)"}`;
483
+ this.#noteBackendCheck(String(row.backend), {
484
+ ok: false, errorCode: "spawn_failed", error: why, latencyMs: Date.now() - resumeStarted,
485
+ });
486
+ throw new Error(why);
487
+ }
488
+
489
+ const live: LiveSession = {
490
+ info: {
491
+ id, backend: row.backend, acpSessionId: row.acpSessionId, cwd: row.cwd,
492
+ workspace: row.workspace ?? null,
493
+ contextLimit: row.contextLimit ?? null,
494
+ status: "starting", pid: child.pid ?? null, title: row.title,
495
+ createdAt: row.createdAt,
496
+ modes: (row.modes ?? null) as SessionModeState | null,
497
+ configOptions: (row.configOptions ?? []) as ConfigOptionView[],
498
+ // restored from disk so the gauge + slash palette are there before the agent
499
+ // re-announces them (and stay if the re-announce never comes)
500
+ usage: (row.usage ?? null) as SessionInfo["usage"],
501
+ commands: normCommands(row.commands),
502
+ },
503
+ child, busy: false, pendingPermissions: new Map(), alwaysAllow: new Set(), stderrBuf: [],
504
+ caps: { fork: false, load: false }, turnSeq: 0, collectors: new Map(), planReminderPending: true,
505
+ planToken: randomUUID(),
506
+ // Until this slot's first PROMPT, anything the agent says is its own history being replayed,
507
+ // not an answer (see #onSessionUpdate). A resumed agent re-sends its whole transcript on
508
+ // `session/load` — full-length blocks, no messageId — and persisting those wrote 1240 rows
509
+ // into the live DB on one restart and pushed every session's clock to「刚刚」.
510
+ replaying: true,
511
+ };
512
+ this.#sessions.set(id, live);
513
+ live.info.lastAt = this.#store.lastActivityAt().get(id) ?? live.info.createdAt;
514
+ this.#store.upsertSession(sessionRow(live.info));
515
+
516
+ child.stderr?.on("data", (d: Buffer) => {
517
+ const line = d.toString().trim();
518
+ if (line) {
519
+ live.stderrBuf.push(line);
520
+ if (live.stderrBuf.length > 50) live.stderrBuf.shift();
521
+ }
522
+ });
523
+ child.on("exit", (code, sig) => this.#onChildExit(live, code, sig));
524
+
525
+ try {
526
+ const conn = new ClientSideConnection(
527
+ () => ({
528
+ sessionUpdate: (params) => this.#onSessionUpdate(live, params),
529
+ requestPermission: (params) => this.#onRequestPermission(live, params),
530
+ }),
531
+ ndJsonStream(
532
+ Writable.toWeb(child.stdin!) as unknown as WritableStream<Uint8Array>,
533
+ Readable.toWeb(child.stdout!) as unknown as ReadableStream<Uint8Array>,
534
+ ),
535
+ );
536
+ live.conn = conn;
537
+ const reloadInit = await conn.initialize({
538
+ protocolVersion: 1,
539
+ clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } },
540
+ });
541
+ let loaded = (await conn.loadSession({
542
+ sessionId: row.acpSessionId, cwd: row.cwd, mcpServers: this.#planMcpServers(spec, live),
543
+ })) as { modes?: SessionModeState | null; configOptions?: ConfigOptionView[]; sessionId?: string } | undefined;
544
+ // ── Did the agent actually adopt it? ────────────────────────────────────────────────────
545
+ // Measured against the real CLI (scripts/probe-load-missing.mjs): for a session it does not
546
+ // have, hermes answers `session/load` with an EMPTY result and `session/prompt` with
547
+ // `stopReason:"refusal"` — no error either way, which is precisely how a dead slot came to
548
+ // look alive (it said "ready" and ate every message). An empty answer is the only "no" we
549
+ // get, so it is the one we act on.
550
+ const adopted = Boolean(loaded && typeof loaded === "object" && Object.keys(loaded).length > 0);
551
+ if (!adopted) {
552
+ // Two different things look the same here, and they are not equally bad:
553
+ // · the slot never had a turn — the agent persists a session when it starts WORKING, so a
554
+ // slot closed before its first message has nothing anywhere. There is no context to
555
+ // lose and nothing to tell the operator: opening a fresh agent session in the same slot
556
+ // is exactly what reopening it means;
557
+ // · there IS a transcript — the operator's conversation is the thing that is gone, and
558
+ // that must be said out loud rather than papered over with a session that starts blank.
559
+ if (this.#store.maxSeq(id) === 0) {
560
+ loaded = (await conn.newSession({ cwd: resumeCwd, mcpServers: this.#planMcpServers(spec, live) })) as typeof loaded;
561
+ const freshId = loaded?.sessionId;
562
+ if (!freshId) throw new Error(`the agent would not open a fresh session in ${resumeCwd}`);
563
+ live.info.acpSessionId = freshId;
564
+ } else {
565
+ throw new SessionUnavailable(
566
+ "context_missing",
567
+ `the agent has no session ${row.acpSessionId} in ${plan.home ?? "its default home"}`,
568
+ { sessionHome: row.home ?? null, rowHome: plan.home },
569
+ );
570
+ }
571
+ }
572
+ // The agent has this session (or just opened one in the same slot), so THIS home is where the
573
+ // state lives: a row that predates the home column stops being ambiguous.
574
+ if (!row.home && plan.home) {
575
+ this.#store.setSessionHome(id, plan.home);
576
+ row.home = plan.home;
577
+ }
578
+ const loadedModels = readModels(loaded);
579
+ // The agent re-announces its modes/options on load — prefer that SET (capabilities can
580
+ // differ after an upgrade), but keep the OPERATOR's pick for any option that still
581
+ // exists: a backend that does not restore its own session state would otherwise silently
582
+ // reset thinking depth on every resume while the rail still showed the old choice.
583
+ const storedPicks = new Map(
584
+ ((row.configOptions ?? []) as ConfigOptionView[])
585
+ .filter((o) => o?.id && o.currentValue)
586
+ .map((o) => [o.id, o.currentValue]),
587
+ );
588
+ if (loaded?.modes) live.info.modes = loaded.modes;
589
+ // the agent's model list is authoritative; the stored one is only a fallback for
590
+ // backends that answer loadSession without it
591
+ if (loadedModels) live.info.models = loadedModels;
592
+ if (loaded?.configOptions?.length) {
593
+ live.info.configOptions = normConfigOptions(loaded.configOptions).map((o) => (
594
+ storedPicks.has(o.id) ? { ...o, currentValue: storedPicks.get(o.id) } : o
595
+ ));
596
+ }
597
+ // same for the mode: the archived mode wins over a backend default
598
+ if (row.modes && live.info.modes && (row.modes as SessionModeState).currentModeId) {
599
+ live.info.modes = { ...live.info.modes, currentModeId: (row.modes as SessionModeState).currentModeId };
600
+ }
601
+ this.#updateSession(live);
602
+ // …against the session the agent is actually serving NOW (a re-minted one, if the slot had no
603
+ // state to restore): asking about the old id would be the same silent no-op all over again.
604
+ const acpId = live.info.acpSessionId as string;
605
+ const modeId = (live.info.modes as SessionModeState | null)?.currentModeId;
606
+ if (modeId) await conn.setSessionMode({ sessionId: acpId, modeId }).catch(() => {});
607
+ for (const cfg of (live.info.configOptions ?? []) as ConfigOptionView[]) {
608
+ // "" is a legitimate "unset / follow default" — replaying it can be rejected as an
609
+ // invalid option value, so only re-apply real picks.
610
+ if (cfg?.id && cfg.currentValue) {
611
+ await conn
612
+ .setSessionConfigOption({ sessionId: acpId, configId: cfg.id, value: String(cfg.currentValue) })
613
+ .catch(() => {});
614
+ }
615
+ }
616
+ live.info.status = "ready";
617
+ this.#updateSession(live);
618
+ // a resumed slot is the same evidence as a fresh one: the row does work
619
+ this.#noteBackendCheck(
620
+ String(row.backend),
621
+ { ok: true, latencyMs: Date.now() - resumeStarted },
622
+ handshakeFrom(reloadInit, loaded),
623
+ );
624
+ this.#emitSessions();
625
+ return live.info;
626
+ } catch (err) {
627
+ child.kill("SIGKILL");
628
+ live.info.status = "error";
629
+ live.info.lastError = errMessage(err) + stderrTail(live);
630
+ this.#sessions.delete(id);
631
+ if (err instanceof SessionUnavailable) {
632
+ // The ROW is not at fault — a brand-new slot on it works — so this is not a row failure and
633
+ // must not be recorded as one. Put the stored row back exactly as we read it: the early
634
+ // `upsertSession` above had already stamped it "starting" with a pid that is now dead.
635
+ this.#store.upsertSession(row);
636
+ throw err;
637
+ }
638
+ this.#store.upsertSession({ ...sessionRow(live.info), closedAt: Date.now() });
639
+ this.#noteBackendCheck(String(row.backend), {
640
+ ok: false, error: live.info.lastError, errorCode: classifyError(err),
641
+ latencyMs: Date.now() - resumeStarted,
642
+ });
643
+ throw err instanceof Error ? err : new Error(String(err));
644
+ }
645
+ }
646
+
647
+ /** Swap the agent process under a LIVE slot, keeping everything the operator can see: same slot
648
+ * id, same ACP session (the transcript lives agent-side), same stored picks (model, mode,
649
+ * thinking depth). Only the pid changes.
650
+ *
651
+ * Why it exists: a spawned CLI holds the modules it loaded at startup, so a fix sitting on disk
652
+ * is invisible to a slot that is already up. Before this the only way to pick new code up was
653
+ * archive → un-archive (measured 2026-10-07: exactly that dance was needed for a thinking-depth
654
+ * fix). This reuses the resume path instead of duplicating it — resume already rebuilds a child,
655
+ * calls loadSession, and re-applies the stored picks, including the rule that the OPERATOR's
656
+ * thinking depth wins over whatever the backend re-announces on load.
657
+ *
658
+ * A restart kills the process mid-turn; the client confirms that with the operator first. */
659
+ async restart(id: string): Promise<SessionInfo> {
660
+ const live = this.#sessions.get(id);
661
+ if (!live) throw new Error(`session ${id} has no running agent to restart`);
662
+ // The slot stays IN the map for the whole swap: it keeps its place in the rail, and it must
663
+ // never read as cold while the old child dies (an earlier revision deleted it here and the
664
+ // operator watched their slot flicker into the archive for a second — measured on :8901).
665
+ // Mark the swap BEFORE the signal, so the exit we are causing is not read as a dead agent.
666
+ live.replacing = true;
667
+ for (const { resolve, timer } of live.pendingPermissions.values()) {
668
+ clearTimeout(timer);
669
+ resolve({ outcome: { outcome: "cancelled" } });
670
+ }
671
+ live.pendingPermissions.clear();
672
+ live.busy = false;
673
+ // show the swap honestly: "starting" with no pid, not a pid that is already gone
674
+ live.info.status = "starting";
675
+ live.info.pid = null;
676
+ this.#updateSession(live);
677
+ await this.#stopChild(live, 5000);
678
+ return this.resume(id, { replace: true });
679
+ }
680
+
681
+ /** SIGTERM, then SIGKILL if it will not go. Resolves once the child is really gone, so the caller
682
+ * can spawn its replacement without two processes racing over the same session state. */
683
+ #stopChild(live: LiveSession, graceMs: number): Promise<void> {
684
+ const child = live.child;
685
+ if (!child || child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
686
+ return new Promise<void>((resolve) => {
687
+ const done = (): void => {
688
+ clearTimeout(timer);
689
+ resolve();
690
+ };
691
+ const timer = setTimeout(() => {
692
+ child.kill("SIGKILL");
693
+ // SIGKILL cannot be caught, so its exit event is the honest signal — but never hang the
694
+ // request on a zombie that never reports one.
695
+ setTimeout(done, 250);
696
+ }, graceMs);
697
+ child.once("exit", done);
698
+ child.kill("SIGTERM");
699
+ });
700
+ }
701
+
702
+ /** Sessions we know about on disk but have no process for (rail's cold slots). */
703
+ archived(limit = 20): SessionInfo[] {
704
+ return this.#store
705
+ // include closed rows: "close" means "kill the process, keep the transcript as a cold
706
+ // slot" (that is what the UI's close prompt promises) — filtering status!='closed' made
707
+ // the slot vanish from the rail entirely, leaving the transcript unreachable even though
708
+ // it was still on disk. Real deletion is the separate ✕ purge button.
709
+ .listSessions(true)
710
+ .filter((r) => !this.#sessions.has(r.id))
711
+ .map((r) => ({
712
+ id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
713
+ workspace: r.workspace ?? null,
714
+ models: (r.models ?? null) as SessionInfo["models"],
715
+ contextLimit: r.contextLimit ?? null,
716
+ title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
717
+ modes: (r.modes ?? null) as SessionModeState | null,
718
+ configOptions: (r.configOptions ?? []) as ConfigOptionView[],
719
+ usage: (r.usage ?? null) as SessionInfo["usage"],
720
+ commands: normCommands(r.commands),
721
+ lastSeq: this.#store.maxSeq(r.id),
722
+ lastAt: this.#store.lastActivityAt().get(r.id) ?? r.createdAt,
723
+ }))
724
+ // cold rows by the same rule as live ones (recent chat first), THEN take the page
725
+ .sort((a, b) => (b.lastAt ?? 0) - (a.lastAt ?? 0) || b.createdAt - a.createdAt)
726
+ .slice(0, limit);
727
+ }
728
+
729
+ async prompt(
730
+ sessionId: string,
731
+ text: string,
732
+ attachments: PromptAttachment[] = [],
733
+ opts: { interrupt?: boolean } = {},
734
+ ): Promise<void> {
735
+ const s = this.#need(sessionId);
736
+ if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
737
+ if (s.busy) {
738
+ // Someone is talking over the agent. Plain prompts are refused (the operator's send
739
+ // button is a stop button), but a call utterance carries `interrupt: true`: give the
740
+ // floor back by cancelling, then WAIT for the running turn to really end before
741
+ // starting the new one. Without the wait, the cancel (async) races the new prompt and
742
+ // the operator got "turn already running" for doing exactly what a call invites.
743
+ if (!opts.interrupt) throw new Error("turn already running");
744
+ // Take the floor: cancel whatever is running and wait for THE TURN WE CANCELLED to
745
+ // really stop. Another interrupting utterance may have started a turn meanwhile — this
746
+ // call is an interrupt too, so it takes that one as well rather than failing with
747
+ // "still running". (A plain sleep would guess; the per-turn counter knows.)
748
+ const deadline = Date.now() + TURN_SETTLE_TIMEOUT_MS;
749
+ do {
750
+ await this.cancel(sessionId);
751
+ const mine = s.turnSeq;
752
+ while (s.busy && s.turnSeq === mine && Date.now() < deadline) {
753
+ await new Promise((r) => setTimeout(r, 40));
754
+ }
755
+ } while (s.busy && Date.now() < deadline);
756
+ if (s.busy) throw new Error("上一个回合没能停下来(agent 未响应取消),请稍后再试");
757
+ }
758
+ const blocks = buildPromptBlocks(text, attachments);
759
+ if (!blocks.length) throw new Error("empty prompt");
760
+ // The plan hand-over, owed once per agent process (§7): a restarted agent never knew about the
761
+ // plan, and nothing else on this wire would tell it (ACP has no server->agent state channel).
762
+ if (s.planReminderPending) {
763
+ s.planReminderPending = false;
764
+ const reminder = this.#planReminder(s);
765
+ if (reminder) blocks.push(reminder);
766
+ }
767
+ s.turnSeq += 1;
768
+ s.busy = true;
769
+ // The operator has spoken: from here anything the agent says is an ANSWER, not its own history
770
+ // being replayed (see the replay window in #onSessionUpdate).
771
+ s.replaying = false;
772
+ s.info.status = "running";
773
+ this.#updateSession(s);
774
+ // Timed from the announcement, not from the prompt call: the operator's wait INCLUDES the
775
+ // moment the card lit up. Only the queue-wait above is excluded (that is other turns' time).
776
+ const turnStartedAt = Date.now();
777
+ this.#emit({ t: "turn-start", sessionId, trace: this.#turnTrace(s) });
778
+ const msg = this.#store.appendMessage({
779
+ sessionId, kind: "user", payload: { text, attachments: summarize(attachments) }, createdAt: Date.now(),
780
+ });
781
+ this.#touch(s, msg.createdAt);
782
+ this.#emit({ t: "message", message: msg });
783
+ // A brand-new slot is called "<backend> @ <dir>" until something better exists, and the
784
+ // moment the operator asks something that placeholder is plainly wrong. Name it now,
785
+ // from the prompt itself: instant, offline, no model call in our layer (AionUi derives
786
+ // its auto title the same way). The AGENT may have its own opinion — Hermes generates a
787
+ // title in the turn prologue and announces it via `session_info_update` a little later,
788
+ // and that one wins, because it can be right about a conversation we never parse.
789
+ this.#autoTitleFrom(s, text);
790
+ try {
791
+ const res = await s.conn.prompt({
792
+ sessionId: s.info.acpSessionId,
793
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ContentBlock union, built by hand
794
+ prompt: blocks as any,
795
+ });
796
+ const terminalState = res?.stopReason === "cancelled" ? "interrupted" : "ended";
797
+ this.#finalizePlan(sessionId, terminalState);
798
+ this.#emit({ t: "turn-end", sessionId, stopReason: res?.stopReason, durationMs: Date.now() - turnStartedAt });
799
+ } catch (err) {
800
+ this.#finalizePlan(sessionId, "failed");
801
+ this.#emit({ t: "turn-end", sessionId, error: errMessage(err), durationMs: Date.now() - turnStartedAt });
802
+ } finally {
803
+ s.busy = false;
804
+ if (s.info.status === "running") {
805
+ s.info.status = "ready";
806
+ this.#updateSession(s);
807
+ }
808
+ }
809
+ }
810
+
811
+ async cancel(sessionId: string): Promise<void> {
812
+ const s = this.#need(sessionId);
813
+ if (!s.conn || !s.info.acpSessionId) return;
814
+ // An idle session must NOT be cancelled. On the agent side this is not a no-op: Hermes' ACP
815
+ // `session/cancel` sets the hard-interrupt flag unconditionally (`request_hard_interrupt`) and
816
+ // clears it only at a turn boundary, so the NEXT turn aborts before it starts — at its
817
+ // cross-process turn-lease admission — with "Stopped waiting for another Hermes process on this
818
+ // session. Your message was not processed." The operator's words are dropped and that notice is
819
+ // rendered (and in a call, READ OUT) like an answer. The call hit this whenever the operator
820
+ // talked over a reply still being read aloud: the agent's turn was already over, so the
821
+ // barge-in's cancel landed on an idle session.
822
+ if (!s.busy) {
823
+ console.log(`[agentus] cancel ignored for ${sessionId}: no turn is running`);
824
+ return;
825
+ }
826
+ console.log(`[agentus] cancel sent for ${sessionId}`);
827
+ await s.conn.cancel({ sessionId: s.info.acpSessionId }).catch(() => {});
828
+ }
829
+
830
+ async setMode(sessionId: string, modeId: string): Promise<void> {
831
+ const s = this.#need(sessionId);
832
+ if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
833
+ try {
834
+ await s.conn.setSessionMode({ sessionId: s.info.acpSessionId, modeId });
835
+ } catch (err) {
836
+ // qodercli advertises modes but setSessionMode returned -32601 "not found" in
837
+ // design.md probe => mode may be advertised-but-unimplemented; degrade gracefully.
838
+ if (!/not found|Unsupported/i.test(errMessage(err))) throw err;
839
+ }
840
+ if (s.info.modes) s.info.modes = { ...s.info.modes, currentModeId: modeId };
841
+ this.#updateSession(s);
842
+ }
843
+
844
+ async setConfig(sessionId: string, configId: string, value: string | number | boolean): Promise<void> {
845
+ const s = this.#need(sessionId);
846
+ if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready");
847
+ const requested = String(value);
848
+ let actual = requested;
849
+ let rejected = false;
850
+ try {
851
+ const res = (await s.conn.setSessionConfigOption({
852
+ sessionId: s.info.acpSessionId,
853
+ configId,
854
+ value: value as never,
855
+ })) as unknown as { configOptions?: ConfigOptionView[] } | undefined;
856
+ // The agent's OWN answer is the truth: ACP's response carries the rebuilt option list, and a
857
+ // level the route folds away comes back with the old currentValue. Reading our own request
858
+ // back would show a pick that never landed (operator report: "设置了也好像没什么变化").
859
+ const opts = res && Array.isArray(res.configOptions) ? res.configOptions : null;
860
+ if (opts) {
861
+ s.info.configOptions = normConfigOptions(opts);
862
+ const picked = opts.find((o) => o.id === configId);
863
+ if (picked && picked.currentValue !== undefined && picked.currentValue !== null) {
864
+ actual = String(picked.currentValue);
865
+ rejected = actual !== requested;
866
+ }
867
+ } else {
868
+ s.info.configOptions = s.info.configOptions.map((o) =>
869
+ o.id === configId ? { ...o, currentValue: value } : o,
870
+ );
871
+ }
872
+ } catch (e) {
873
+ if (!/not found|Unsupported|method/i.test(errMessage(e))) throw e;
874
+ // The backend advertises the option but has no setter for it: nothing reaches the agent, so a
875
+ // local-only "success" would be a lie. Keep the session's real value and flag the pick.
876
+ rejected = true;
877
+ actual = String(s.info.configOptions.find((o) => o.id === configId)?.currentValue ?? "");
878
+ }
879
+ if (rejected) {
880
+ this.#emit({
881
+ t: "config-rejected",
882
+ sessionId,
883
+ configId,
884
+ value: requested,
885
+ actual,
886
+ name: s.info.configOptions.find((o) => o.id === configId)?.name,
887
+ });
888
+ }
889
+ this.#updateSession(s);
890
+ }
891
+
892
+ /** The spawn spec for a backend id, as the registry defines it right now. For the places that
893
+ * hold a session but not the spec it was spawned with (fork, the hand-over wording) — resolved
894
+ * fresh, so a row edited in the settings page takes effect on the next use. */
895
+ #specFor(backendId: string): BackendSpec {
896
+ const row = this.#store.getBackend(backendId);
897
+ return row ? rowToSpec(row) : (BACKENDS[backendId] ?? BACKENDS.hermes);
898
+ }
899
+
900
+ /** The plan MCP server to hand this agent at HANDSHAKE time — or nothing, for a row that has
901
+ * opted back into native frames (`nativePlanSource: "acp"`).
902
+ *
903
+ * Injected rather than registered anywhere, on purpose: `mcpServers` is a per-session parameter
904
+ * of `session/new` / `session/load` / `session/resume`, so the tool exists exactly while Agentus
905
+ * is driving the session and vanishes for an agent started from the CLI, cron or Studio — which
906
+ * is the whole difference from a `config.yaml` entry like the `ekko_studio_*` ones (§4).
907
+ * The token in `env` is minted for THIS session, so a copied-out command writes only its own. */
908
+ #planMcpServers(spec: BackendSpec, live: LiveSession): McpServer[] {
909
+ if (nativePlanSourceOf(spec) !== "none") return [];
910
+ return [
911
+ {
912
+ name: "agentus-plan",
913
+ command: process.execPath,
914
+ args: [path.join(path.dirname(fileURLToPath(import.meta.url)), "../../mcp/plan-server.mjs")],
915
+ env: [
916
+ { name: "AGENTUS_PLAN_ENDPOINT", value: `http://127.0.0.1:${process.env.AGENTUS_PORT ?? 8787}` },
917
+ { name: "AGENTUS_PLAN_TOKEN", value: live.planToken },
918
+ { name: "AGENTUS_PLAN_SESSION", value: live.info.id },
919
+ ],
920
+ },
921
+ ];
922
+ }
923
+
924
+ /** Does this session's plan come from the agent's own ACP frames?
925
+ *
926
+ * Only when the row opted into `nativePlanSource: "acp"`. Every other backend — Hermes included —
927
+ * is MCP-driven, and its frames are DROPPED rather than rendered: a frame describes the agent's
928
+ * PROCESS-LOCAL todo list, and the cockpit shows the plan OBJECT precisely because that list does
929
+ * not survive a restart (design-plan-service.md §1). Dropping the frame at the door also keeps
930
+ * the transcript free of plan rows that nothing ever updated. */
931
+ #framesAccepted(live: LiveSession): boolean {
932
+ if (nativePlanSourceOf(this.#specFor(live.info.backend)) === "acp") return true;
933
+ // Said once per agent process: an agent may emit a frame every turn, and the operator reading
934
+ // the log should be able to see that the cockpit is ignoring them on purpose.
935
+ if (!live.planFramesDropped) {
936
+ live.planFramesDropped = true;
937
+ console.log(
938
+ `[agentus] plan frame ignored for ${live.info.id}: backend '${live.info.backend}' is MCP-driven ` +
939
+ `(the card is fed by the agentus-plan tool)`,
940
+ );
941
+ }
942
+ return false;
943
+ }
944
+
945
+ /** Fold an agent's ACP plan frame into the session's plan OBJECT.
946
+ *
947
+ * The object (Store's `plans` row) is what the card renders, because it survives what the frame
948
+ * does not: the agent's own todo state dies with its process, and with it every reason to keep
949
+ * believing the frames still sitting in the log (design-plan-service.md §1).
950
+ *
951
+ * One writer per session (plan.ts): a plan hand-delivered through the plan MCP tool is not
952
+ * overwritten by frames, and vice versa — two writers means two plans racing on one card. */
953
+ #ingestPlan(sessionId: string, update: Record<string, unknown>): void {
954
+ const prev = this.#store.getPlan(sessionId);
955
+ if (!acceptsWriteFrom(prev?.source, "acp")) return;
956
+ const items = normalizeItems(update.entries);
957
+ // An EMPTY frame is "no update", never "clear" (design-plan-service §3): Hermes empties its
958
+ // todo list when a task ends, and a client that took that literally would wipe a finished plan
959
+ // off the card at the exact moment the operator wants to look at it — the bug this work started
960
+ // from. Nothing on this wire expresses "forget the plan".
961
+ if (!items.length) return;
962
+ const meta = (update._meta ?? {}) as Record<string, unknown>;
963
+ const plan = this.#store.upsertPlan(sessionId, {
964
+ items,
965
+ // ACP v1 has no remark field; `_meta` is the protocol's own extension slot, so a writer that
966
+ // has one has somewhere to put it. Absent means "say nothing", NOT "clear" — a frame that
967
+ // carries no remark must not wipe the one a previous frame left on the card.
968
+ ...(meta["agentus/explanation"] !== undefined
969
+ ? { explanation: clampExplanation(meta["agentus/explanation"]) }
970
+ : {}),
971
+ source: "acp",
972
+ // A fresh snapshot means the run is moving again: the last turn's terminal stamp no longer
973
+ // describes this one.
974
+ terminal: null,
975
+ });
976
+ this.#emit({ t: "plan", sessionId, plan });
977
+ }
978
+
979
+ /** Turn lifecycle closes the plan card (the Studio rule: the agent may never set the
980
+ * execution state — only the run does). A leftover `in_progress` step is demoted back to
981
+ * `pending` (nothing runs it anymore) and the card gets an honest terminal line; without
982
+ * this, a crash or an interrupted turn leaves the card pretending to work forever.
983
+ * Writes only when the plan still has unfinished steps — a plan the agent closed out itself
984
+ * (all completed) needs no banner. Idempotent per state. */
985
+ #finalizePlan(sessionId: string, state: "ended" | "interrupted" | "failed"): void {
986
+ const plan = this.#store.getPlan(sessionId);
987
+ if (!plan) return;
988
+ if (plan.terminal === state) return;
989
+ if (!hasUnfinished(plan.items)) return;
990
+ const items = demoteInProgress(plan.items);
991
+ const stored = this.#store.upsertPlan(sessionId, { items, source: plan.source, terminal: state });
992
+ // The OBJECT is the live card's truth; the transcript row is what an ARCHIVE card replays later
993
+ // (it keeps the cockpit-only `_slotPlanTerminal` field the archived card reads).
994
+ const msg = this.#store.appendMessage({
995
+ sessionId, kind: "plan",
996
+ payload: { sessionUpdate: "plan", entries: items, _slotPlanTerminal: state },
997
+ createdAt: Date.now(),
998
+ });
999
+ this.#emit({ t: "plan", sessionId, plan: stored });
1000
+ this.#emit({ t: "message", message: msg });
1001
+ }
1002
+
1003
+ /** The plan MCP tool's READ side. Only a RUNNING session can be read, and only with the token its
1004
+ * own handshake carried: the token dies with the process that was given it, which is what keeps
1005
+ * the tool scoped to one session rather than "any plan in this cockpit". */
1006
+ planForMcp(sessionId: string, token: string):
1007
+ | { ok: true; plan: PlanSnapshot | null }
1008
+ | { ok: false; error: string } {
1009
+ const live = this.#sessions.get(sessionId);
1010
+ if (!live) return { ok: false, error: "no such running session" };
1011
+ if (!token || token !== live.planToken) return { ok: false, error: "bad plan token" };
1012
+ return { ok: true, plan: this.#store.getPlan(sessionId) };
1013
+ }
1014
+
1015
+ /** The plan MCP tool's WRITE side — the generic channel for agents that cannot emit plan frames
1016
+ * (design-plan-service §6-B).
1017
+ *
1018
+ * Two rules meet here. The token scopes the write to one session. And plan.ts's one-writer rule
1019
+ * decides who owns the card: on a row that renders frames they keep it and this write is refused —
1020
+ * `accepted: false` is a normal ANSWER, not an error, because the caller has to tell the model the
1021
+ * truth (it does not own this list) or the model will keep believing it does. On an MCP-driven row
1022
+ * the tool is the session's ONLY writer, so it takes the card over even when an older build had
1023
+ * written it from frames (`acceptsToolWrite`). */
1024
+ writePlanFromMcp(sessionId: string, token: string, input: { items: unknown; explanation?: unknown }):
1025
+ | { ok: true; accepted: boolean; reason?: string; plan: PlanSnapshot | null }
1026
+ | { ok: false; error: string } {
1027
+ const live = this.#sessions.get(sessionId);
1028
+ if (!live) return { ok: false, error: "no such running session" };
1029
+ if (!token || token !== live.planToken) return { ok: false, error: "bad plan token" };
1030
+ const prev = this.#store.getPlan(sessionId);
1031
+ const rowRendersFrames = nativePlanSourceOf(this.#specFor(live.info.backend)) !== "none";
1032
+ if (!acceptsToolWrite(prev?.source, rowRendersFrames)) {
1033
+ return {
1034
+ ok: true, accepted: false, plan: prev ?? null,
1035
+ reason: `this backend renders the agent's own ACP frames, and this session's plan (source: ${prev?.source}) is theirs, so the tool is ignored`,
1036
+ };
1037
+ }
1038
+ const items = normalizeItems(input.items);
1039
+ // Same rule as the frame path: an empty list is not a clear, it is a mistake worth naming.
1040
+ if (!items.length) {
1041
+ return {
1042
+ ok: true, accepted: false, plan: prev ?? null,
1043
+ reason: "an empty list is not a plan — send the steps as they stand, or leave the plan alone",
1044
+ };
1045
+ }
1046
+ const plan = this.#store.upsertPlan(sessionId, {
1047
+ items,
1048
+ ...(input.explanation !== undefined ? { explanation: clampExplanation(input.explanation) } : {}),
1049
+ source: "mcp",
1050
+ // A write means work is happening again: the previous turn's terminal stamp no longer
1051
+ // describes what this plan is doing.
1052
+ terminal: null,
1053
+ });
1054
+ this.#emit({ t: "plan", sessionId, plan });
1055
+ return { ok: true, accepted: true, plan };
1056
+ }
1057
+
1058
+ /** The plan, handed to an agent that just (re)started, as an ACP `resource` content block: a
1059
+ * stable URI plus the steps still owed. The card is fed by the plan OBJECT, but the AGENT's own
1060
+ * copy died with its process — this block is the only thing that tells it the plan exists
1061
+ * (design-plan-service.md §7). Returns null when there is nothing to hand over: no plan at all,
1062
+ * or one whose steps are all closed (spending context to say "you are done" is worse than
1063
+ * saying nothing). */
1064
+ #planReminder(s: LiveSession): unknown | null {
1065
+ const plan = this.#store.getPlan(s.info.id);
1066
+ if (!plan) return null;
1067
+ const open = plan.items.filter((i) => i.status !== "completed" && i.status !== "cancelled");
1068
+ if (!open.length) return null;
1069
+ const lines = open.map((i) => `- [${i.status}] ${i.content}`).join("\n");
1070
+ // How the agent keeps it current depends on which channel it actually has: an agent whose row
1071
+ // opted into native frames must use its own todo tool (those frames are what feed the card),
1072
+ // while an MCP-driven agent — which is every backend by default, Hermes included — was handed
1073
+ // `update_plan` in this very handshake, and its own todo tool does NOT reach the card.
1074
+ const mcpDriven = nativePlanSourceOf(this.#specFor(s.info.backend)) === "none";
1075
+ const closing = mcpDriven
1076
+ ? [
1077
+ "Carry on with it, and keep it updated with the `update_plan` tool as you go — that list is",
1078
+ "the ONLY one the cockpit shows the operator, so a built-in todo/checklist tool of yours will",
1079
+ "not be seen there. Mark a step completed once its work is actually verified, and drop or",
1080
+ "cancel what you are no longer doing. If you disagree with an item, change it rather than",
1081
+ "quietly skipping it.",
1082
+ ]
1083
+ : [
1084
+ "Carry on with it, and keep it updated with your own plan/todo tool as you go: mark a step",
1085
+ "completed only once its work is actually verified, and drop or cancel what you are no",
1086
+ "longer doing. If you disagree with an item, change it rather than quietly skipping it.",
1087
+ ];
1088
+ return {
1089
+ type: "resource",
1090
+ resource: {
1091
+ uri: `agentus://plan/${s.info.id}`,
1092
+ mimeType: "text/plain",
1093
+ text: [
1094
+ "This session has an unfinished plan. It is kept by the cockpit and survives your",
1095
+ "restarts; your own in-memory task list does not.",
1096
+ "",
1097
+ lines,
1098
+ "",
1099
+ ...closing,
1100
+ ].join("\n"),
1101
+ },
1102
+ // ACP reserves `_meta` for the client: tagging our own blocks is what lets us tell them from
1103
+ // the operator's words later (and strip them from anything we echo back).
1104
+ _meta: { "agentus/plan-reminder": true },
1105
+ };
1106
+ }
1107
+
1108
+ async closeSession(sessionId: string): Promise<void> {
1109
+ const s = this.#need(sessionId);
1110
+ for (const { resolve, timer } of s.pendingPermissions.values()) {
1111
+ clearTimeout(timer);
1112
+ resolve({ outcome: { outcome: "cancelled" } });
1113
+ }
1114
+ s.pendingPermissions.clear();
1115
+ s.child?.kill("SIGTERM");
1116
+ s.info.status = "closed";
1117
+ const row = this.#store.getSession(sessionId);
1118
+ if (row) this.#store.upsertSession({ ...row, status: "closed", closedAt: Date.now(), pid: null });
1119
+ this.#sessions.delete(sessionId);
1120
+ this.#emitSessions();
1121
+ }
1122
+
1123
+ /** The list every client needs to be current: the rows PLUS what is already waiting for an
1124
+ * answer. Sent on connect too, which is what makes a refreshed page show a live approval. */
1125
+ #emitSessions(): void {
1126
+ this.#emit({ t: "sessions", sessions: this.list(), pending: this.pendingPermissions() });
1127
+ }
1128
+
1129
+ /** Every request currently waiting for an answer, across live sessions. Exists so a page
1130
+ * that connects or REFRESHES can be told what is already waiting: the operator asked to see
1131
+ * the approval card "even after refreshing another page", and an event-only design cannot
1132
+ * deliver that (the events happened before the page existed). */
1133
+ pendingPermissions(): PermissionRequestView[] {
1134
+ const out: PermissionRequestView[] = [];
1135
+ for (const s of this.#sessions.values()) for (const p of s.pendingPermissions.values()) out.push(p.view);
1136
+ return out;
1137
+ }
1138
+
1139
+ respondPermission(
1140
+ sessionId: string,
1141
+ requestId: string,
1142
+ decision: PermissionDecision,
1143
+ meta?: { optionKind?: string; signature?: string },
1144
+ ): boolean {
1145
+ const s = this.#need(sessionId);
1146
+ if (decision.outcome === "selected" && meta?.optionKind === "allow_always" && meta.signature) {
1147
+ s.alwaysAllow.add(meta.signature);
1148
+ }
1149
+ const pending = s.pendingPermissions.get(requestId);
1150
+ if (!pending) {
1151
+ // Someone answered a request we no longer hold (our timeout fired, or the agent gave up
1152
+ // first). Silently returning false made a click look like a broken button.
1153
+ console.log(`[agentus] permission answer dropped for ${sessionId}: request ${requestId} is not pending`);
1154
+ return false; // already timed out / resolved / never existed
1155
+ }
1156
+ clearTimeout(pending.timer);
1157
+ s.pendingPermissions.delete(requestId);
1158
+ pending.resolve({ outcome: decision });
1159
+ this.#emit({ t: "permission-resolved", requestId, decision });
1160
+ return true;
1161
+ }
1162
+
1163
+ // ---- ACP callbacks ----
1164
+
1165
+ /** What this turn will actually run with. Cheap provenance for the operator's
1166
+ * "why did it behave differently?" question (AionUi F-DISPLAY-11): only what the
1167
+ * session truly knows (effort pick + mode) — never an invented model name. */
1168
+ #turnTrace(s: LiveSession): TurnTrace {
1169
+ const effortCfg = s.info.configOptions.find((o) => /reason|effort|think/i.test(o.id));
1170
+ return {
1171
+ effort: effortCfg ? String(effortCfg.currentValue ?? "") || null : null,
1172
+ mode: s.info.modes?.currentModeId ?? null,
1173
+ };
1174
+ }
1175
+
1176
+ #onSessionUpdate(live: LiveSession, params: { sessionId: string; update: Record<string, unknown> }): void {
1177
+ if (live.info.acpSessionId !== params.sessionId) {
1178
+ // Another session on OUR connection: only the title fork does that. Its output is a
1179
+ // scratch copy of this conversation, so it goes to the collector and stops there —
1180
+ // never persisted, never broadcast (the rail must not grow a phantom session).
1181
+ live.collectors.get(params.sessionId)?.(params.update);
1182
+ return;
1183
+ }
1184
+ const u = params.update;
1185
+ const st = String(u.sessionUpdate ?? "");
1186
+ // ── The replay window ──────────────────────────────────────────────────────────────────────
1187
+ // From spawn/resume until this slot's first prompt, the agent is re-sending its own history
1188
+ // (`session/load` replays the transcript). Those frames are a RECORD of work that is already in
1189
+ // the store — persisting them is not "receiving work", and it is what wrote 1240 rows into the
1190
+ // live DB on one restart and pushed every session's clock to 「刚刚」.
1191
+ //
1192
+ // Dropped only when we DO hold a transcript: a slot whose rows are gone has nothing else to
1193
+ // rebuild from, so that replay is the only copy and it is kept.
1194
+ const isTextFrame = st === "agent_message_chunk" || st === "agent_thought_chunk";
1195
+ const isToolFrame = st === "tool_call" || st === "tool_call_update";
1196
+ if (live.replaying && (isTextFrame || isToolFrame) && this.#store.maxSeq(live.info.id) > 0) {
1197
+ return;
1198
+ }
1199
+ let msg: Parameters<Store["appendMessage"]>[0] | null = null;
1200
+ switch (st) {
1201
+ case "agent_message_chunk":
1202
+ msg = { sessionId: live.info.id, kind: "agent", payload: u, createdAt: Date.now() };
1203
+ break;
1204
+ case "agent_thought_chunk":
1205
+ msg = { sessionId: live.info.id, kind: "thought", payload: u, createdAt: Date.now() };
1206
+ break;
1207
+ case "tool_call": {
1208
+ // ONE call = ONE row, wherever the frame came from. The agent re-sends every call it holds
1209
+ // whenever it re-attaches (`session/load` on a resume / a restart), and appending those was
1210
+ // what put a single call in a transcript 17 times (measured 2026-10-08: 7507 extra rows in
1211
+ // the operator's store) and dragged every resumed session's activity clock to 「刚刚」.
1212
+ const up = this.#store.appendToolCall({
1213
+ sessionId: live.info.id, kind: "tool", payload: u,
1214
+ toolCallId: String(u.toolCallId ?? ""), createdAt: Date.now(),
1215
+ });
1216
+ if (up.isNew) {
1217
+ this.#touch(live, up.message.createdAt);
1218
+ this.#emit({ t: "message", message: up.message });
1219
+ }
1220
+ return;
1221
+ }
1222
+ case "tool_call_update": {
1223
+ // upsert into original row (design.md §8-5), never a new bubble
1224
+ const up = this.#store.upsertToolMessage(
1225
+ live.info.id, String(u.toolCallId ?? ""), u,
1226
+ );
1227
+ if (up) this.#emit({ t: "message", message: up });
1228
+ return;
1229
+ }
1230
+ case "plan":
1231
+ // The AGENT's channel — honoured only for a row that opted into it (see #framesAccepted).
1232
+ // For every MCP-driven backend this frame is dropped here: it describes the agent's own
1233
+ // process-local todo list, and the card is fed by the plan OBJECT instead, so rendering it
1234
+ // would put a list on screen that dies with the next restart.
1235
+ if (!this.#framesAccepted(live)) return;
1236
+ // Still lands in the transcript (that is what the per-turn archive card replays), while the
1237
+ // cockpit's LIVE card is fed by the plan OBJECT below — the one that outlives the process.
1238
+ msg = { sessionId: live.info.id, kind: "plan", payload: u, createdAt: Date.now() };
1239
+ this.#ingestPlan(live.info.id, u as Record<string, unknown>);
1240
+ break;
1241
+ case "plan_update": {
1242
+ // ACP v2's `plan_update` (still a draft — hence the switch, plan.ts:v2PlanEnabled). A v2
1243
+ // agent may carry several plans; the card renders ONE, so the first planId seen is adopted
1244
+ // and the rest are deliberately left alone rather than guessed at. The frame is folded back
1245
+ // into the v1 shape so the transcript row an ARCHIVED card replays stays a single shape.
1246
+ if (!v2PlanEnabled()) return;
1247
+ if (!this.#framesAccepted(live)) return; // same rule as the v1 frame above
1248
+ const folded = foldPlanUpdate(u);
1249
+ if (!folded) return; // a markdown/file plan is not a step list
1250
+ if (!live.planId) live.planId = folded.planId;
1251
+ else if (folded.planId && folded.planId !== live.planId) return;
1252
+ const frame = { sessionUpdate: "plan", entries: folded.entries, _meta: folded.meta };
1253
+ msg = { sessionId: live.info.id, kind: "plan", payload: frame, createdAt: Date.now() };
1254
+ this.#ingestPlan(live.info.id, frame as Record<string, unknown>);
1255
+ break;
1256
+ }
1257
+ case "current_mode_update": {
1258
+ const m = u as { currentModeId?: string };
1259
+ if (live.info.modes && m.currentModeId) {
1260
+ live.info.modes = { ...live.info.modes, currentModeId: m.currentModeId };
1261
+ this.#updateSession(live);
1262
+ }
1263
+ return;
1264
+ }
1265
+ case "available_commands_update": {
1266
+ const c = u as { availableCommands?: { name: string; description?: string }[] };
1267
+ live.info.commands = (c.availableCommands ?? []).map((x) => ({
1268
+ name: x.name, description: x.description,
1269
+ }));
1270
+ this.#updateSession(live);
1271
+ return;
1272
+ }
1273
+ case "session_info_update": {
1274
+ // The protocol's own title channel: "Agents send this notification to update session
1275
+ // information like title… This allows clients to display dynamic session names."
1276
+ // Hermes uses it for the auto title it generates in the turn prologue (and for
1277
+ // provenance updates where the title is unchanged — a no-op here). This is why the
1278
+ // cockpit shows generated names at all: the intelligence stays in the CLI.
1279
+ const t = cleanAgentTitle((u as { title?: string | null }).title ?? null);
1280
+ if (t) this.#applyAutoTitle(live.info.id, t, false);
1281
+ return;
1282
+ }
1283
+ case "usage_update": {
1284
+ // Context-window gauge (AionUi F-DISPLAY-07). The agent reports used/size in
1285
+ // tokens; a backend that never reports size still gets a used-only reading.
1286
+ const uu = u as { used?: number; size?: number; cost?: { amount?: number } | null };
1287
+ const used = Number(uu.used ?? 0);
1288
+ const size = Number(uu.size ?? 0);
1289
+ live.info.usage = {
1290
+ used: Number.isFinite(used) ? used : 0,
1291
+ size: Number.isFinite(size) ? size : 0,
1292
+ cost: uu.cost && typeof uu.cost.amount === "number" ? uu.cost.amount : null,
1293
+ at: Date.now(),
1294
+ };
1295
+ this.#updateSession(live);
1296
+ this.#emit({ t: "usage", sessionId: live.info.id, usage: live.info.usage });
1297
+ return;
1298
+ }
1299
+ default:
1300
+ return; // unknown update kinds are ignored (protocol may grow)
1301
+ }
1302
+ if (msg) {
1303
+ // The streaming kinds accumulate into one row per message (see Store.appendTextChunk): a reply
1304
+ // is ONE row, not 1259. The client gets the row plus the part that is genuinely new, so a live
1305
+ // turn stays a small delta on the wire while the store stays the source of truth.
1306
+ if (msg.kind === "agent" || msg.kind === "thought") {
1307
+ const r = this.#store.appendTextChunk({ ...msg, kind: msg.kind });
1308
+ if (r.delta || r.isNew) {
1309
+ // A row that did NOT move is not activity. A re-attach replays blocks we already hold, and
1310
+ // touching the clock for those is what made every resumed session read 「刚刚」 at once.
1311
+ this.#touch(live, r.message.createdAt);
1312
+ // One message = one row, so a row that GREW keeps its seq. A client that deduplicated frames
1313
+ // by seq therefore dropped every chunk after the first one (measured: a phone bubble showing
1314
+ // "不是" with the rest of the reply nowhere, and the next frame landing in an empty bubble).
1315
+ // The frame carries `n` — the block's total length — as its version, and a growth ships ONLY
1316
+ // the new part, so a long reply is never re-sent whole on every token.
1317
+ const n = textOf(r.message.payload).length;
1318
+ this.#emit({
1319
+ t: "message",
1320
+ message: r.isNew ? r.message : { ...r.message, payload: withText(r.message.payload, r.delta) },
1321
+ delta: r.isNew ? undefined : r.delta,
1322
+ n,
1323
+ });
1324
+ }
1325
+ return;
1326
+ }
1327
+ const stored = this.#store.appendMessage(msg);
1328
+ this.#touch(live, stored.createdAt);
1329
+ this.#emit({ t: "message", message: stored });
1330
+ }
1331
+ }
1332
+
1333
+ #onRequestPermission(
1334
+ live: LiveSession,
1335
+ params: RequestPermissionRequest,
1336
+ ): Promise<RequestPermissionResponse> {
1337
+ const requestId = randomUUID().slice(0, 8);
1338
+ const tc = (params.toolCall ?? {}) as PermissionToolCall;
1339
+ const view: PermissionRequestView = {
1340
+ requestId,
1341
+ sessionId: live.info.id,
1342
+ toolCallTitle: tc.title ?? "tool call",
1343
+ kind: tc.kind ?? "other",
1344
+ options: (params.options ?? []) as PermissionRequestView["options"],
1345
+ createdAt: Date.now(),
1346
+ ...permissionArtifacts(tc),
1347
+ };
1348
+ const sig = `${view.kind}:${view.toolCallTitle}`;
1349
+ const remembered = view.options.find((o) => o.kind === "allow_always" || o.kind === "allow_once");
1350
+ if (live.alwaysAllow.has(sig) && remembered) {
1351
+ // previously "always allowed" in this session => pass silently
1352
+ return Promise.resolve({
1353
+ outcome: { outcome: "selected", optionId: remembered.optionId } as never,
1354
+ });
1355
+ }
1356
+ this.#emit({ t: "permission", request: view });
1357
+ return new Promise<RequestPermissionResponse>((resolve) => {
1358
+ const timer = setTimeout(() => {
1359
+ live.pendingPermissions.delete(requestId);
1360
+ const d: PermissionDecision = { outcome: "cancelled" };
1361
+ resolve({ outcome: d });
1362
+ this.#emit({ t: "permission-resolved", requestId, decision: d });
1363
+ }, PERMISSION_TIMEOUT_MS);
1364
+ live.pendingPermissions.set(requestId, { view, resolve, timer });
1365
+ });
1366
+ }
1367
+
1368
+ #onChildExit(live: LiveSession, code: number | null, sig: string | null): void {
1369
+ // If we initiated close, session was already removed — ignore.
1370
+ if (!this.#sessions.has(live.info.id)) return;
1371
+ // …and if we are REPLACING this slot's process (restart), the exit is the one we asked for: the
1372
+ // slot has not failed, it is coming back on a new child.
1373
+ if (live.replacing) return;
1374
+ for (const { resolve, timer } of live.pendingPermissions.values()) {
1375
+ clearTimeout(timer);
1376
+ resolve({ outcome: { outcome: "cancelled" } });
1377
+ }
1378
+ live.pendingPermissions.clear();
1379
+ live.busy = false;
1380
+ live.info.status = "error";
1381
+ live.info.lastError = `agent process exited (code=${code} sig=${sig})` + stderrTail(live);
1382
+ const row = this.#store.getSession(live.info.id);
1383
+ if (row) this.#store.upsertSession({ ...row, status: "error", pid: null });
1384
+ this.#finalizePlan(live.info.id, "failed"); // the turn died with the process — close the card honestly
1385
+ this.#emit({ t: "session", session: live.info });
1386
+ this.#emit({ t: "turn-end", sessionId: live.info.id, error: live.info.lastError });
1387
+ }
1388
+
1389
+ // ---- helpers ----
1390
+
1391
+ #need(id: string): LiveSession {
1392
+ const s = this.#sessions.get(id);
1393
+ if (!s) throw new Error(`no such session: ${id}`);
1394
+ return s;
1395
+ }
1396
+
1397
+ #updateSession(s: LiveSession): void {
1398
+ // The remembered window follows the model, so it has to be refreshed whenever the
1399
+ // model (or anything else) changes — this is the one place every mutation passes.
1400
+ s.info.modelContextLimit = this.#store.getModelLimit(s.info.models?.currentModelId ?? null);
1401
+ this.#emit({ t: "session", session: s.info });
1402
+ const row = this.#store.getSession(s.info.id);
1403
+ if (row) {
1404
+ // Persist the LIVE metadata too, not just status/pid: without modes/configOptions/
1405
+ // usage/commands here, a restart (or a cold-slot rail) shows a slot that has lost the
1406
+ // operator's thinking depth, the context gauge and the slash palette — the stored
1407
+ // snapshot stayed frozen at creation time. `??` keeps whatever the row already had
1408
+ // when the live value is genuinely absent (e.g. before the agent announces it).
1409
+ this.#store.upsertSession({
1410
+ ...row,
1411
+ acpSessionId: s.info.acpSessionId ?? row.acpSessionId,
1412
+ status: s.info.status,
1413
+ pid: s.info.pid,
1414
+ title: s.info.title,
1415
+ modes: s.info.modes ?? row.modes ?? null,
1416
+ configOptions: s.info.configOptions ?? row.configOptions ?? [],
1417
+ usage: s.info.usage ?? row.usage ?? null,
1418
+ commands: s.info.commands?.length ? s.info.commands : (row.commands ?? []),
1419
+ });
1420
+ }
1421
+ }
1422
+
1423
+ /** Replay messages since lastSeq per session (AC6 reconnect). */
1424
+ replay(sessionId: string, afterSeq: number) {
1425
+ return this.#store.messagesAfter(sessionId, afterSeq);
1426
+ }
1427
+
1428
+ messages(sessionId: string, afterSeq = -1, limit = 500) {
1429
+ return this.#store.messagesAfter(sessionId, afterSeq, limit);
1430
+ }
1431
+
1432
+ /** Re-point a slot's workspace. Live slot: panels + the *next* resume move; the
1433
+ * running child keeps the cwd it was spawned with (ACP fixes cwd at newSession).
1434
+ * Cold slot: the resume will come up in the new directory. */
1435
+ setWorkspace(id: string, workspace: string | null): SessionInfo {
1436
+ const live = this.#sessions.get(id);
1437
+ if (live) {
1438
+ live.info.workspace = workspace;
1439
+ this.#updateSession(live);
1440
+ return live.info;
1441
+ }
1442
+ const row = this.#store.getSession(id);
1443
+ if (!row) throw new Error(`no such session: ${id}`);
1444
+ if (!this.#store.setWorkspace(id, workspace)) throw new Error(`no such session: ${id}`);
1445
+ return {
1446
+ id: row.id, backend: row.backend, acpSessionId: row.acpSessionId, cwd: row.cwd,
1447
+ workspace, title: row.title, status: row.status, pid: null, createdAt: row.createdAt,
1448
+ modes: (row.modes ?? null) as SessionModeState | null,
1449
+ configOptions: (row.configOptions ?? []) as ConfigOptionView[],
1450
+ usage: (row.usage ?? null) as SessionInfo["usage"],
1451
+ commands: normCommands(row.commands),
1452
+ lastSeq: this.#store.maxSeq(id),
1453
+ };
1454
+ }
1455
+
1456
+ /** Rename a slot. A title is DISPLAY state, not agent state: a cold slot must be
1457
+ * renamable without waking it up (the agent never hears about this). `null`/blank
1458
+ * clears back to the generated title; control characters fold to spaces and the
1459
+ * result is capped, because this string is rendered in a single-line row. */
1460
+ rename(id: string, title: string | null): SessionInfo {
1461
+ const clean = title == null ? null : title.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, 120).trim();
1462
+ if (!this.#store.renameSession(id, clean && clean.length ? clean : null)) {
1463
+ throw new Error(`no such session: ${id}`);
1464
+ }
1465
+ const live = this.#sessions.get(id);
1466
+ if (live) {
1467
+ live.info.title = this.#store.getSession(id)?.title ?? live.info.title;
1468
+ this.#updateSession(live);
1469
+ this.#emit({ t: "session", session: live.info });
1470
+ return live.info;
1471
+ }
1472
+ const row = this.#store.getSession(id);
1473
+ if (!row) throw new Error(`no such session: ${id}`);
1474
+ return {
1475
+ id: row.id, backend: row.backend, acpSessionId: row.acpSessionId, cwd: row.cwd,
1476
+ workspace: row.workspace ?? null,
1477
+ models: (row.models ?? null) as SessionInfo["models"],
1478
+ contextLimit: row.contextLimit ?? null,
1479
+ title: row.title, status: row.status, pid: null, createdAt: row.createdAt,
1480
+ modes: (row.modes ?? null) as SessionModeState | null,
1481
+ configOptions: (row.configOptions ?? []) as ConfigOptionView[],
1482
+ usage: (row.usage ?? null) as SessionInfo["usage"],
1483
+ commands: normCommands(row.commands),
1484
+ lastSeq: this.#store.maxSeq(id),
1485
+ };
1486
+ }
1487
+
1488
+ // ---- generated titles -----------------------------------------------------
1489
+ //
1490
+ // Two producers, one authority rule (see Store.setAutoTitle): a generated name always lands
1491
+ // in `auto_title`, and it moves the DISPLAY title only while the operator has not named the
1492
+ // session themselves. So an automatic title can fire at any time without ever overwriting a
1493
+ // hand-written one, and a cleared rename still falls back to something real.
1494
+
1495
+ /** The name a slot wears before anything is known about it ("Hermes @ tmp"). */
1496
+ #placeholder(row: SessionRow): string {
1497
+ const label = this.#store.getBackend(row.backend)?.label ?? BACKENDS[row.backend]?.label ?? row.backend;
1498
+ return `${label} @ ${shortCwd(row.cwd)}`;
1499
+ }
1500
+
1501
+ /** Derive a title from the prompt the operator just sent (no model, instant).
1502
+ *
1503
+ * Only while the row still wears its placeholder. A derived name is a STARTING point, not a
1504
+ * running commentary on the conversation: once a better name exists — the agent announced
1505
+ * one, or the operator regenerated it — quoting the newest prompt over it would be a
1506
+ * downgrade. (AionUi draws the same line: it only re-derives while the name is still the
1507
+ * default one.) */
1508
+ #autoTitleFrom(s: LiveSession, text: string): void {
1509
+ const row = this.#store.getSession(s.info.id);
1510
+ if (!row) return;
1511
+ const placeholder = this.#placeholder(row);
1512
+ if (row.autoTitle && row.autoTitle !== placeholder) return;
1513
+ const t = deriveTitle(text);
1514
+ if (!t || t === row.autoTitle) return;
1515
+ this.#applyAutoTitle(s.info.id, t, false);
1516
+ }
1517
+
1518
+ /** Write a generated title and tell everyone who renders a row about it. */
1519
+ #applyAutoTitle(id: string, title: string, force: boolean): SessionRow | null {
1520
+ const row = this.#store.setAutoTitle(id, title, { force });
1521
+ if (!row) return null;
1522
+ const live = this.#sessions.get(id);
1523
+ if (live) {
1524
+ live.info.title = row.title;
1525
+ live.info.autoTitle = row.autoTitle ?? null;
1526
+ this.#updateSession(live);
1527
+ }
1528
+ // the rail lists cold slots too, so refresh the whole list rather than just this session
1529
+ this.#emitSessions();
1530
+ return row;
1531
+ }
1532
+
1533
+ /** How the last regeneration got its name — "agent" (a real summary) or "derived" (the
1534
+ * fallback that just names the session after the latest prompt). */
1535
+ lastTitleVia: "agent" | "derived" = "derived";
1536
+
1537
+ /**
1538
+ * Regenerate a slot's name from the CURRENT conversation (the operator asking for one).
1539
+ *
1540
+ * The agent does the summarising, on a THROWAWAY FORK of the session: ACP's `session/fork`
1541
+ * exists for exactly this — "Creates a new session based on the context of an existing one,
1542
+ * allowing operations like generating summaries without affecting the original session's
1543
+ * history" (Hermes deep-copies the history into the fork). So we get a name that reflects
1544
+ * everything said so far while the session's own transcript, context window and turn count
1545
+ * stay untouched. Needs a live agent advertising `session/fork`; otherwise — and on any
1546
+ * failure — we fall back to naming it after the LATEST prompt: still the newest context,
1547
+ * just without a model.
1548
+ */
1549
+ async regenerateTitle(id: string): Promise<SessionInfo> {
1550
+ const live = this.#sessions.get(id);
1551
+ const msgs = this.#store.messagesTail(id, Number.MAX_SAFE_INTEGER).messages;
1552
+ // Nothing has been said yet: there is no context to name, and asking the agent would
1553
+ // either burn a turn on an empty conversation or produce a title about nothing. Refuse.
1554
+ if (!msgs.some((m) => m.kind === "user")) {
1555
+ throw new Error("这个会话还没有可以用来生成标题的对话内容");
1556
+ }
1557
+ let title: string | null = null;
1558
+ let via: "agent" | "derived" = "derived";
1559
+ if (live?.conn && live.info.acpSessionId && live.caps.fork && !live.busy) {
1560
+ title = await this.#titleFromFork(live).catch(() => null);
1561
+ if (title) via = "agent";
1562
+ }
1563
+ if (!title) title = titleFromLatestPrompt(msgs);
1564
+ if (!title) throw new Error("这个会话还没有可以用来生成标题的对话内容");
1565
+ if (!this.#applyAutoTitle(id, title, true)) throw new Error(`no such session: ${id}`);
1566
+ this.lastTitleVia = via;
1567
+ const info = this.#sessions.get(id)?.info ?? this.#coldInfo(id);
1568
+ this.#emit({ t: "session", session: info });
1569
+ return info;
1570
+ }
1571
+
1572
+ /**
1573
+ * Fork the session, ask the FORK for a title, throw the fork away.
1574
+ *
1575
+ * WHY THIS IS CHEAP (measured against real `hermes acp`, 2026-10-04): the title turn billed
1576
+ * **147 fresh input tokens + 51 output**, with **12,544 tokens served from the provider's
1577
+ * prefix cache** — i.e. ~12.5k of the ~12.9k prompt was a cache read, not fresh tokens.
1578
+ *
1579
+ * The reason is the SHAPE of what we send: the fork deep-copies the parent's history and we
1580
+ * append exactly ONE small user message at the END. The front — system prompt, tool
1581
+ * definitions, the whole conversation — is byte-identical to the parent's last request, so
1582
+ * the prefix cache matches.
1583
+ *
1584
+ * Therefore: NEVER put anything in front of the copied history. A "you are a titling
1585
+ * assistant" system message, a rewritten or reordered history, a different cwd/model, or
1586
+ * forking from a fresh process would invalidate the entire prefix and turn this cheap click
1587
+ * into a full-context re-read (~12.9k today, growing with the session). The instruction text
1588
+ * is deliberately at the tail for that reason.
1589
+ */
1590
+ async #titleFromFork(s: LiveSession): Promise<string | null> {
1591
+ const conn = s.conn!;
1592
+ const fork = await conn.unstable_forkSession({
1593
+ sessionId: s.info.acpSessionId!,
1594
+ cwd: s.info.workspace || s.info.cwd,
1595
+ // Deliberately NO plan tool: this fork exists to be asked for a title and then thrown away.
1596
+ // Injecting one would spawn a second `agentus-plan` child per title for nothing.
1597
+ mcpServers: [],
1598
+ });
1599
+ const forkId = (fork as { sessionId?: string } | null)?.sessionId;
1600
+ if (!forkId) return null;
1601
+ let text = "";
1602
+ s.collectors.set(forkId, (u) => {
1603
+ if (String(u.sessionUpdate ?? "") !== "agent_message_chunk") return;
1604
+ const c = u.content as { text?: string } | undefined;
1605
+ if (typeof c?.text === "string") text += c.text;
1606
+ });
1607
+ let timer: NodeJS.Timeout | undefined;
1608
+ try {
1609
+ const turn = conn
1610
+ .prompt({
1611
+ sessionId: forkId,
1612
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ContentBlock union
1613
+ prompt: [{ type: "text", text: TITLE_INSTRUCTION }] as any,
1614
+ })
1615
+ .then(() => "done" as const)
1616
+ .catch(() => "failed" as const);
1617
+ const timeout = new Promise<"timeout">((r) => {
1618
+ timer = setTimeout(() => r("timeout"), TITLE_FORK_TIMEOUT_MS);
1619
+ timer.unref?.();
1620
+ });
1621
+ const winner = await Promise.race([turn, timeout]);
1622
+ if (winner === "timeout") await conn.cancel({ sessionId: forkId }).catch(() => {});
1623
+ return winner === "timeout" ? null : cleanAgentTitle(text);
1624
+ } finally {
1625
+ clearTimeout(timer);
1626
+ s.collectors.delete(forkId);
1627
+ // best effort: the agent may not implement session/close (Hermes advertises fork but
1628
+ // not close today, and an unclosed fork is just a copy the agent will drop)
1629
+ await conn.closeSession({ sessionId: forkId }).catch(() => {});
1630
+ }
1631
+ }
1632
+
1633
+ /** Switch the model for a live session. ACP method `session/set_model` — not in the
1634
+ * SDK's typed surface, so it goes through the generic request() overload (verified
1635
+ * against hermes acp, which implements set_session_model). A cold slot has no agent
1636
+ * to switch: resume it first, then pick (same rule as modes). */
1637
+ async setModel(id: string, modelId: string): Promise<SessionInfo> {
1638
+ const s = this.#need(id);
1639
+ if (!s.conn || !s.info.acpSessionId) throw new Error("session not ready — resume it first");
1640
+ const known = s.info.models?.availableModels ?? [];
1641
+ if (known.length && !known.some((m) => m.modelId === modelId)) {
1642
+ throw new Error(`unknown model: ${modelId}`);
1643
+ }
1644
+ await s.conn.request("session/set_model", { sessionId: s.info.acpSessionId, modelId });
1645
+ if (s.info.models) s.info.models = { ...s.info.models, currentModelId: modelId };
1646
+ this.#updateSession(s);
1647
+ this.#emitSessions();
1648
+ return s.info;
1649
+ }
1650
+
1651
+ /** Declare (or clear) the context window the gauge measures against. ACP has no
1652
+ * method for this — a window is a property of the model/provider, reported to us via
1653
+ * usage_update — so this is the operator's own number, kept per slot. */
1654
+ setContextLimit(
1655
+ id: string,
1656
+ limit: number | null,
1657
+ opts: { remember?: boolean; forgetModel?: boolean } = {},
1658
+ ): SessionInfo {
1659
+ const clean = limit && Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : null;
1660
+ const live = this.#sessions.get(id);
1661
+ if (!live && !this.#store.getSession(id)) throw new Error(`no such session: ${id}`);
1662
+ const info = live?.info ?? this.#coldInfo(id);
1663
+ const modelId = info.models?.currentModelId ?? null;
1664
+ // Studio's shape: the number belongs to the MODEL, and a session may deviate. "reset"
1665
+ // therefore clears only this session's deviation — forgetting the model is a separate,
1666
+ // deliberate act.
1667
+ if (opts.forgetModel) this.#store.setModelLimit(modelId ?? "", null);
1668
+ if (modelId && clean != null && opts.remember === true) this.#store.setModelLimit(modelId, clean);
1669
+ if (live) {
1670
+ live.info.contextLimit = clean;
1671
+ this.#updateSession(live);
1672
+ } else {
1673
+ if (!this.#store.setContextLimit(id, clean)) throw new Error(`no such session: ${id}`);
1674
+ }
1675
+ this.#emitSessions();
1676
+ const row = live?.info ?? this.#coldInfo(id);
1677
+ return row;
1678
+ }
1679
+
1680
+ /** Keep `lastAt` on the session ITSELF, not only on the list projection. A single-session
1681
+ * payload (status change, title update, a rename response) built from `info` used to arrive
1682
+ * without the field, so the client's copy dropped it and that session fell back to creation
1683
+ * order — the rail stopped moving a session up the moment you talked to it. */
1684
+ #touch(live: LiveSession, at = Date.now()): void {
1685
+ live.info.lastAt = at;
1686
+ // …and persist it: `lastAt` on the live object is only the projection the sockets see. The rail
1687
+ // is rebuilt from the STORE on every list()/restart, so a clock that only ever lived in memory
1688
+ // reset every session to its creation order the moment the process came back.
1689
+ this.#store.touchActivity(live.info.id, at);
1690
+ }
1691
+
1692
+ /** One cold row, in the same shape list()/archived() produce. */
1693
+ #coldInfo(id: string): SessionInfo {
1694
+ const r = this.#store.getSession(id);
1695
+ if (!r) throw new Error(`no such session: ${id}`);
1696
+ return {
1697
+ id: r.id, backend: r.backend, acpSessionId: r.acpSessionId, cwd: r.cwd,
1698
+ workspace: r.workspace ?? null, contextLimit: r.contextLimit ?? null,
1699
+ modelContextLimit: this.#store.getModelLimit(((r.models ?? null) as SessionInfo["models"])?.currentModelId ?? null),
1700
+ models: (r.models ?? null) as SessionInfo["models"],
1701
+ title: r.title, autoTitle: r.autoTitle ?? null, status: r.status, pid: null, createdAt: r.createdAt,
1702
+ modes: (r.modes ?? null) as SessionModeState | null,
1703
+ configOptions: (r.configOptions ?? []) as ConfigOptionView[],
1704
+ usage: (r.usage ?? null) as SessionInfo["usage"],
1705
+ commands: normCommands(r.commands),
1706
+ lastSeq: this.#store.maxSeq(id),
1707
+ lastAt: this.#store.lastActivityAt().get(id) ?? r.createdAt,
1708
+ };
1709
+ }
1710
+
1711
+ /** Fork a session (ACP `session/fork`): the agent deep-copies the parent's history into
1712
+ * a NEW session id, which we then bring up as its own slot — same architecture as every
1713
+ * other session (one child, one ACP session), so a fork behaves like a resume whose id
1714
+ * came from the parent instead of from the store.
1715
+ *
1716
+ * A cold parent is resumed first: the fork call has to reach a live agent, and asking
1717
+ * the operator to wake a session before forking it would be a pointless step.
1718
+ *
1719
+ * Note the capability is `unstable` in ACP and only some agents offer it (hermes does;
1720
+ * it advertises session.fork). When the agent does not, this throws and the UI shows
1721
+ * the reason instead of inventing a client-side "fork". */
1722
+ async fork(id: string): Promise<SessionInfo> {
1723
+ if (!this.#sessions.has(id)) await this.resume(id); // cold parent: wake it first
1724
+ const source = this.#need(id);
1725
+ if (!source.conn || !source.info.acpSessionId) throw new Error("session not ready — resume it first");
1726
+ if (source.busy) throw new Error("the session is mid-turn — wait for it to finish before forking");
1727
+ const cwd = source.info.workspace || source.info.cwd;
1728
+ const res = (await source.conn.request("session/fork", {
1729
+ sessionId: source.info.acpSessionId,
1730
+ cwd,
1731
+ // A fork is a real session and keeps the parent's backend, so it gets the same plan channel.
1732
+ mcpServers: this.#planMcpServers(this.#specFor(source.info.backend), source),
1733
+ })) as { sessionId?: unknown; modes?: SessionModeState | null; configOptions?: ConfigOptionView[] } | null;
1734
+ const acpSessionId = res?.sessionId ? String(res.sessionId) : "";
1735
+ if (!acpSessionId) throw new Error("the agent did not return a session id for the fork");
1736
+
1737
+ const newId = randomUUID();
1738
+ const now = Date.now();
1739
+ const forkedOptions = normConfigOptions(res?.configOptions);
1740
+ this.#store.upsertSession({
1741
+ id: newId,
1742
+ backend: source.info.backend,
1743
+ acpSessionId,
1744
+ cwd,
1745
+ title: `${source.info.title} · fork`,
1746
+ // "closed" so the rail treats it as a slot to open (resume spawns its child)
1747
+ status: "closed",
1748
+ pid: null,
1749
+ createdAt: now,
1750
+ closedAt: null,
1751
+ // the agent just told us the fork's modes/options — keep them so the new slot shows
1752
+ // the right permission mode and thinking depth before it is even resumed
1753
+ modes: res?.modes ?? source.info.modes ?? null,
1754
+ configOptions: forkedOptions.length ? forkedOptions : (source.info.configOptions ?? []),
1755
+ usage: null,
1756
+ commands: source.info.commands ?? [],
1757
+ workspace: source.info.workspace ?? null,
1758
+ });
1759
+ this.#emitSessions();
1760
+ // bring it up exactly like a cold slot (spawn + loadSession of the forked id)
1761
+ return await this.resume(newId);
1762
+ }
1763
+
1764
+ hasSession(id: string): boolean {
1765
+ return this.#sessions.has(id);
1766
+ }
1767
+ }
1768
+
1769
+ /** ACP ContentBlock[] for a prompt: text first (the instruction), then media.
1770
+ * Text attachments are inlined with a filename header because that is what every
1771
+ * agent actually reads well; images go as protocol image blocks. */
1772
+ function buildPromptBlocks(text: string, attachments: PromptAttachment[]): unknown[] {
1773
+ const blocks: unknown[] = [];
1774
+ const body = text.trim();
1775
+ if (body) blocks.push({ type: "text", text: body });
1776
+ for (const a of attachments) {
1777
+ if (a.kind === "image") {
1778
+ blocks.push({ type: "image", mimeType: a.mimeType, data: a.data });
1779
+ } else if (a.kind === "text") {
1780
+ blocks.push({ type: "text", text: `--- attached file: ${a.name} ---\n${a.text}` });
1781
+ } else if (a.kind === "link") {
1782
+ blocks.push({ type: "resource_link", uri: a.uri, name: a.name });
1783
+ }
1784
+ }
1785
+ return blocks;
1786
+ }
1787
+
1788
+ /** Attachment metadata for the transcript row (names only — see AttachmentSummary). */
1789
+ function summarize(attachments: PromptAttachment[]): AttachmentSummary[] {
1790
+ return attachments.map((a) => ({
1791
+ kind: a.kind,
1792
+ name: a.name,
1793
+ mimeType: a.kind === "image" ? a.mimeType : undefined,
1794
+ }));
1795
+ }
1796
+
1797
+ function sessionRow(i: SessionInfo, home: string | null = null) {
1798
+ return {
1799
+ id: i.id, backend: i.backend, acpSessionId: i.acpSessionId, cwd: i.cwd,
1800
+ title: i.title, status: i.status, pid: i.pid, createdAt: i.createdAt, closedAt: null,
1801
+ modes: i.modes ?? null, configOptions: i.configOptions ?? [],
1802
+ usage: i.usage ?? null, commands: i.commands ?? [],
1803
+ models: i.models ?? null,
1804
+ // only ever written by the insert (creation); see Store.upsertSession
1805
+ home,
1806
+ };
1807
+ }
1808
+
1809
+ /** ACP carries the session's model list as ``models`` on newSession/loadSession. The SDK
1810
+ * in use does not publish the type (and may drop it in a future version), so this reads
1811
+ * it from the loose response and normalizes it rather than trusting a shape. */
1812
+ function readModels(res: unknown): SessionInfo["models"] {
1813
+ const m = (res as { models?: unknown } | null | undefined)?.models as
1814
+ | { currentModelId?: unknown; availableModels?: unknown }
1815
+ | null
1816
+ | undefined;
1817
+ if (!m || !Array.isArray(m.availableModels)) return null;
1818
+ const availableModels = m.availableModels
1819
+ .map((raw) => {
1820
+ const o = raw as { modelId?: unknown; name?: unknown; description?: unknown };
1821
+ return {
1822
+ modelId: String(o.modelId ?? ""),
1823
+ name: String(o.name ?? o.modelId ?? ""),
1824
+ description: o.description == null ? null : String(o.description),
1825
+ };
1826
+ })
1827
+ .filter((o) => o.modelId);
1828
+ if (!availableModels.length) return null;
1829
+ return { currentModelId: m.currentModelId == null ? null : String(m.currentModelId), availableModels };
1830
+ }
1831
+
1832
+ /** Rows written before commands carried descriptions hold plain strings — normalize,
1833
+ * never trust the stored shape (a `c.name` on a string would render "undefined"). */
1834
+ function normCommands(raw: unknown): SessionInfo["commands"] {
1835
+ if (!Array.isArray(raw)) return [];
1836
+ return raw
1837
+ .map((c) => (typeof c === "string" ? { name: c } : (c as { name?: unknown })))
1838
+ .filter((c): c is { name: string; description?: string } => Boolean(c && typeof c.name === "string"));
1839
+ }
1840
+
1841
+ /** ACP options arrive with `_meta`; the view exposes it as `meta` and keeps `category`.
1842
+ * Custom option kinds live entirely in there (Hermes marks its context budget
1843
+ * `freeform: true` with a unit/floor/presets), so dropping it would silently strip a
1844
+ * backend's richer control down to a plain dropdown. */
1845
+ function normConfigOptions(raw: unknown): ConfigOptionView[] {
1846
+ if (!Array.isArray(raw)) return [];
1847
+ return raw
1848
+ .filter((o): o is Record<string, unknown> => Boolean(o) && typeof o === "object")
1849
+ .map((o) => {
1850
+ const { _meta, ...rest } = o as { _meta?: Record<string, unknown> | null };
1851
+ const meta = _meta ?? (o as { meta?: Record<string, unknown> | null }).meta ?? null;
1852
+ return { ...rest, meta } as unknown as ConfigOptionView;
1853
+ });
1854
+ }
1855
+
1856
+ function errMessage(e: unknown): string {
1857
+ if (e && typeof e === "object" && "message" in e) return String((e as { message: unknown }).message);
1858
+ return String(e);
1859
+ }
1860
+
1861
+ function stderrTail(s: LiveSession): string {
1862
+ return s.stderrBuf.length ? ` | stderr: ${s.stderrBuf.slice(-2).join(" / ")}` : "";
1863
+ }
1864
+
1865
+ function shortCwd(cwd: string): string {
1866
+ const parts = cwd.split("/").filter(Boolean);
1867
+ return parts.length ? parts[parts.length - 1] : cwd;
1868
+ }