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.
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +476 -2
- package/README.zh-CN.md +396 -0
- package/bin/agentus.mjs +147 -0
- package/docs/android-notify-contract.md +658 -0
- package/docs/dev/01-dev-loop.md +186 -0
- package/docs/dev/02-verify.md +237 -0
- package/docs/dev/03-release.md +218 -0
- package/docs/dev/04-recover.md +199 -0
- package/docs/dev/05-agent-driven.md +114 -0
- package/docs/dev/README.md +65 -0
- package/docs/installation.md +366 -0
- package/docs/installation.zh-CN.md +350 -0
- package/docs/refs/aionui-acp-rules.md +46 -0
- package/docs/refs/hermes-studio-ui-ideas.md +191 -0
- package/docs/transcript-folding.md +139 -0
- package/package.json +76 -5
- package/packages/server/mcp/plan-server.mjs +217 -0
- package/packages/server/mock/agent.mjs +693 -0
- package/packages/server/package.json +22 -0
- package/packages/server/src/acp/backends.ts +196 -0
- package/packages/server/src/acp/errors.ts +46 -0
- package/packages/server/src/acp/registry.ts +579 -0
- package/packages/server/src/acp/session-manager.ts +1868 -0
- package/packages/server/src/auth.ts +688 -0
- package/packages/server/src/dashscope.ts +235 -0
- package/packages/server/src/fs.ts +379 -0
- package/packages/server/src/hotwords.ts +132 -0
- package/packages/server/src/index.ts +1486 -0
- package/packages/server/src/notify/center.ts +994 -0
- package/packages/server/src/notify/types.ts +222 -0
- package/packages/server/src/plan/plan.ts +126 -0
- package/packages/server/src/pty-helper.py +135 -0
- package/packages/server/src/settings.ts +469 -0
- package/packages/server/src/store/export.ts +209 -0
- package/packages/server/src/store/store.ts +1238 -0
- package/packages/server/src/term.ts +201 -0
- package/packages/server/src/title.ts +109 -0
- package/packages/server/src/voice.ts +251 -0
- package/packages/shared/package.json +14 -0
- package/packages/shared/src/index.ts +271 -0
- package/packages/web/dist/assets/index-D667PX2c.css +32 -0
- package/packages/web/dist/assets/index-d9NzwPFu.js +283 -0
- package/packages/web/dist/coding-agents/hermes.png +0 -0
- package/packages/web/dist/coding-agents/qoder.svg +5 -0
- package/packages/web/dist/icons/agentus-icon-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-icon.svg +301 -0
- package/packages/web/dist/icons/agentus-mark-inverse.svg +301 -0
- package/packages/web/dist/icons/agentus-mark.svg +301 -0
- package/packages/web/dist/icons/apple-touch-icon-180.png +0 -0
- package/packages/web/dist/icons/favicon.ico +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/icon-maskable-512.png +0 -0
- package/packages/web/dist/index.html +30 -0
- package/packages/web/dist/manifest.webmanifest +17 -0
- package/packages/web/dist/sw.js +61 -0
- 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
|
+
}
|