@lelouchhe/webagent 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +39 -14
- package/config.toml +7 -27
- package/dist/index.html +4 -4
- package/dist/js/app.INIQQEGD.js +5 -0
- package/dist/js/{chunk.S5LRNRJI.js → chunk.7WADDFJZ.js} +27 -27
- package/dist/js/viewer.RHZMFYWJ.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +5 -5
- package/dist/{styles.00nlhhf3.css → styles.01aj0l37.css} +19 -2
- package/dist/sw.js +6 -6
- package/lib/attachment-dispatch.js +60 -31
- package/lib/attachment-interceptor.js +7 -7
- package/lib/attachment-labels.js +1 -1
- package/lib/attachments.js +25 -0
- package/lib/auth-middleware.js +2 -2
- package/lib/auth.js +2 -2
- package/lib/bridge.js +109 -83
- package/lib/client-registry.js +12 -12
- package/lib/config.js +2 -31
- package/lib/event-handler.js +143 -90
- package/lib/files/routes.js +1 -1
- package/lib/mcp/capability.js +74 -0
- package/lib/mcp/server.js +148 -0
- package/lib/mcp/task-history.js +245 -0
- package/lib/mcp/task-host.js +253 -0
- package/lib/mcp/tools.js +168 -0
- package/lib/mode-bucket.js +1 -1
- package/lib/push-service.js +33 -35
- package/lib/routes.js +947 -489
- package/lib/server.js +64 -16
- package/lib/share/routes.js +88 -88
- package/lib/shared/task-reference.js +20 -0
- package/lib/sse-manager.js +8 -8
- package/lib/store.js +941 -314
- package/lib/task-collaboration.js +15 -0
- package/lib/task-manager.js +1409 -0
- package/lib/task-path.js +131 -0
- package/lib/{session-state.js → task-state.js} +64 -41
- package/lib/task-tree-lock.js +74 -0
- package/lib/{sessions-anchor.js → tasks-anchor.js} +8 -7
- package/lib/tokens.js +1 -1
- package/lib/types.js +2 -2
- package/package.json +7 -1
- package/dist/js/app.QC7IRDTP.js +0 -5
- package/dist/js/viewer.GP5VXAUY.js +0 -1
- package/lib/session-manager.js +0 -638
- package/lib/title-service.js +0 -95
package/lib/session-manager.js
DELETED
|
@@ -1,638 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { rm } from "node:fs/promises";
|
|
4
|
-
import { stat } from "node:fs/promises";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { MessageNotFoundError } from "./store.js";
|
|
7
|
-
import { SessionStateManager } from "./session-state.js";
|
|
8
|
-
import { buildLabelMap } from "./attachment-labels.js";
|
|
9
|
-
import { abbreviateHomePath, expandHomePath } from "./home-path.js";
|
|
10
|
-
import { log } from "./log.js";
|
|
11
|
-
const slog = log.scope("session");
|
|
12
|
-
const IS_WIN = process.platform === "win32";
|
|
13
|
-
export function interruptBashProc(proc, force = false) {
|
|
14
|
-
if (!proc)
|
|
15
|
-
return;
|
|
16
|
-
if (IS_WIN && typeof proc.pid === "number") {
|
|
17
|
-
// Windows: kill entire process tree since there are no process groups
|
|
18
|
-
spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)]).unref();
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
if (typeof proc.pid === "number") {
|
|
22
|
-
try {
|
|
23
|
-
process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
catch {
|
|
27
|
-
// Fall through to direct child kill when the process is not a group leader.
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
proc.kill(force ? "SIGKILL" : "SIGINT");
|
|
31
|
-
}
|
|
32
|
-
/** Minimum age (seconds) before an empty session is eligible for cleanup. */
|
|
33
|
-
const EMPTY_SESSION_MIN_AGE_S = 60;
|
|
34
|
-
export class InvalidSessionDirectoryError extends Error {
|
|
35
|
-
constructor(cwd) {
|
|
36
|
-
super(`Directory does not exist: ${cwd}`);
|
|
37
|
-
this.name = "InvalidSessionDirectoryError";
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
/**
|
|
41
|
-
* Centralizes all session-related state that was previously scattered
|
|
42
|
-
* across module-level variables in server.ts.
|
|
43
|
-
*/
|
|
44
|
-
export class SessionManager {
|
|
45
|
-
liveSessions = new Set();
|
|
46
|
-
restoringSessions = new Set();
|
|
47
|
-
sessionHasTitle = new Set();
|
|
48
|
-
assistantBuffers = new Map();
|
|
49
|
-
thinkingBuffers = new Map();
|
|
50
|
-
activePrompts = new Set();
|
|
51
|
-
pendingPromptSubmissions = new Map();
|
|
52
|
-
cancelledPromptSubmissions = new Set();
|
|
53
|
-
runningBashProcs = new Map();
|
|
54
|
-
interruptedBashProcs = new WeakSet();
|
|
55
|
-
/** Pending permission requests keyed by requestId. */
|
|
56
|
-
pendingPermissions = new Map();
|
|
57
|
-
/** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
|
|
58
|
-
state = new SessionStateManager();
|
|
59
|
-
/** Deduplicates concurrent resume calls for the same session. */
|
|
60
|
-
pendingResumes = new Map();
|
|
61
|
-
nextPromptNumber = 0;
|
|
62
|
-
nextPromptSubmissionNumber = 0;
|
|
63
|
-
/** Deduplicates concurrent attempts to materialize one inbox message. */
|
|
64
|
-
pendingMessageConsumes = new Map();
|
|
65
|
-
/**
|
|
66
|
-
* Per-session attachment label map (CLAUDE.md "Attachment label
|
|
67
|
-
* egress rewrite"). Built lazily from the `attachments` table on
|
|
68
|
-
* first read; invalidated on attachment INSERT and session
|
|
69
|
-
* DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
|
|
70
|
-
*/
|
|
71
|
-
attachmentLabelCache = new Map();
|
|
72
|
-
agentCommandSnapshots = new Map();
|
|
73
|
-
agentCommandEpoch = randomUUID();
|
|
74
|
-
cachedConfigOptions = [];
|
|
75
|
-
agentInfo = null;
|
|
76
|
-
store;
|
|
77
|
-
defaultCwd;
|
|
78
|
-
dataDir;
|
|
79
|
-
constructor(store, defaultCwd, dataDir) {
|
|
80
|
-
this.store = store;
|
|
81
|
-
this.defaultCwd = defaultCwd;
|
|
82
|
-
this.dataDir = dataDir;
|
|
83
|
-
}
|
|
84
|
-
/** Populate sessionHasTitle from existing DB sessions on startup. */
|
|
85
|
-
hydrate() {
|
|
86
|
-
for (const s of this.store.listSessions()) {
|
|
87
|
-
if (s.title)
|
|
88
|
-
this.sessionHasTitle.add(s.id);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
92
|
-
async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
|
|
93
|
-
const sessionCwd = expandHomePath(cwd ?? this.defaultCwd);
|
|
94
|
-
try {
|
|
95
|
-
const info = await stat(sessionCwd);
|
|
96
|
-
if (!info.isDirectory())
|
|
97
|
-
throw new Error("not a directory");
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
throw new InvalidSessionDirectoryError(sessionCwd);
|
|
101
|
-
}
|
|
102
|
-
// Clean up empty sessions (no events) older than the threshold
|
|
103
|
-
const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
|
|
104
|
-
for (const id of cleaned) {
|
|
105
|
-
this.liveSessions.delete(id);
|
|
106
|
-
this.agentCommandSnapshots.delete(id);
|
|
107
|
-
}
|
|
108
|
-
if (cleaned.length > 0)
|
|
109
|
-
slog.info("cleaned empty session(s)", { count: cleaned.length });
|
|
110
|
-
const sourceSession = inheritFromSessionId
|
|
111
|
-
? this.store.getSession(inheritFromSessionId)
|
|
112
|
-
: null;
|
|
113
|
-
const webSessionId = randomUUID();
|
|
114
|
-
const { sessionId: agentSessionId, configOptions: createdConfigOptions } = await bridge.newSession(sessionCwd, { silent: opts?.silent });
|
|
115
|
-
let configOptions = createdConfigOptions;
|
|
116
|
-
try {
|
|
117
|
-
this.store.createSession(webSessionId, sessionCwd, source, agentSessionId);
|
|
118
|
-
}
|
|
119
|
-
catch (err) {
|
|
120
|
-
slog.warn("ACP session created but local persistence failed", {
|
|
121
|
-
agentSessionId,
|
|
122
|
-
error: err,
|
|
123
|
-
});
|
|
124
|
-
bridge.discardUnboundSession?.(agentSessionId);
|
|
125
|
-
throw err;
|
|
126
|
-
}
|
|
127
|
-
this.liveSessions.add(webSessionId);
|
|
128
|
-
this.recordConfigOptions(webSessionId, createdConfigOptions);
|
|
129
|
-
bridge.sessionMapped?.(agentSessionId);
|
|
130
|
-
// Inherit config options from source session
|
|
131
|
-
if (sourceSession) {
|
|
132
|
-
const thinkingOption = createdConfigOptions.find((option) => "options" in option &&
|
|
133
|
-
(option.id === "reasoning_effort" ||
|
|
134
|
-
option.id === "thought_level" ||
|
|
135
|
-
option.category === "thought_level"));
|
|
136
|
-
const inherited = [
|
|
137
|
-
{ configId: "model", value: sourceSession.model },
|
|
138
|
-
{
|
|
139
|
-
configId: thinkingOption?.id ?? "reasoning_effort",
|
|
140
|
-
value: sourceSession.reasoning_effort,
|
|
141
|
-
},
|
|
142
|
-
];
|
|
143
|
-
for (const { configId, value } of inherited) {
|
|
144
|
-
if (!value)
|
|
145
|
-
continue;
|
|
146
|
-
try {
|
|
147
|
-
const updatedConfigOptions = await bridge.setConfigOption(webSessionId, configId, value);
|
|
148
|
-
if (updatedConfigOptions.length > 0) {
|
|
149
|
-
configOptions = updatedConfigOptions;
|
|
150
|
-
this.recordConfigOptions(webSessionId, updatedConfigOptions);
|
|
151
|
-
}
|
|
152
|
-
else {
|
|
153
|
-
this.store.updateSessionConfig(webSessionId, configId, value);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
catch {
|
|
157
|
-
// Option may no longer be available; ignore
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
const session = this.store.getSession(webSessionId);
|
|
162
|
-
return {
|
|
163
|
-
sessionId: webSessionId,
|
|
164
|
-
configOptions: session
|
|
165
|
-
? this.applyStoredConfig(configOptions, session)
|
|
166
|
-
: [],
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
/** Cache the ACP config schema and persist this session's current values. */
|
|
170
|
-
recordConfigOptions(sessionId, configOptions) {
|
|
171
|
-
if (configOptions.length === 0)
|
|
172
|
-
return;
|
|
173
|
-
this.cachedConfigOptions = configOptions;
|
|
174
|
-
for (const option of configOptions) {
|
|
175
|
-
if (typeof option.currentValue === "string") {
|
|
176
|
-
this.store.updateSessionConfig(sessionId, option.id, option.currentValue);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Materialize a pending inbox message as a real ACP-backed session.
|
|
182
|
-
* Returns null when the message is neither pending nor previously consumed.
|
|
183
|
-
*/
|
|
184
|
-
consumeMessage(bridge, messageId, inheritFromSessionId) {
|
|
185
|
-
const pending = this.pendingMessageConsumes.get(messageId);
|
|
186
|
-
if (pending)
|
|
187
|
-
return pending;
|
|
188
|
-
const existing = this.store.findConsumedMessageSession(messageId);
|
|
189
|
-
if (existing) {
|
|
190
|
-
return Promise.resolve({
|
|
191
|
-
sessionId: existing,
|
|
192
|
-
alreadyConsumed: true,
|
|
193
|
-
});
|
|
194
|
-
}
|
|
195
|
-
const operation = this.consumePendingMessage(bridge, messageId, inheritFromSessionId).finally(() => {
|
|
196
|
-
if (this.pendingMessageConsumes.get(messageId) === operation) {
|
|
197
|
-
this.pendingMessageConsumes.delete(messageId);
|
|
198
|
-
}
|
|
199
|
-
});
|
|
200
|
-
this.pendingMessageConsumes.set(messageId, operation);
|
|
201
|
-
return operation;
|
|
202
|
-
}
|
|
203
|
-
async consumePendingMessage(bridge, messageId, inheritFromSessionId) {
|
|
204
|
-
const message = this.store.getMessage(messageId);
|
|
205
|
-
if (!message)
|
|
206
|
-
return null;
|
|
207
|
-
const { sessionId } = await this.createSession(bridge, message.cwd ?? undefined, inheritFromSessionId, "message", { silent: true });
|
|
208
|
-
try {
|
|
209
|
-
const result = this.store.consumeMessageTx(messageId, sessionId);
|
|
210
|
-
if (result.alreadyConsumed) {
|
|
211
|
-
this.deleteSession(sessionId);
|
|
212
|
-
}
|
|
213
|
-
return result;
|
|
214
|
-
}
|
|
215
|
-
catch (err) {
|
|
216
|
-
this.deleteSession(sessionId);
|
|
217
|
-
if (err instanceof MessageNotFoundError) {
|
|
218
|
-
const consumedSessionId = this.store.findConsumedMessageSession(messageId);
|
|
219
|
-
return consumedSessionId
|
|
220
|
-
? { sessionId: consumedSessionId, alreadyConsumed: true }
|
|
221
|
-
: null;
|
|
222
|
-
}
|
|
223
|
-
slog.warn("message consume left an unreachable ACP session", {
|
|
224
|
-
messageId,
|
|
225
|
-
sessionId,
|
|
226
|
-
error: err,
|
|
227
|
-
});
|
|
228
|
-
throw err;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
/** Resume a session — returns event to send to the requesting client. */
|
|
232
|
-
async resumeSession(bridge, sessionId) {
|
|
233
|
-
const session = this.store.getSession(sessionId);
|
|
234
|
-
if (!session)
|
|
235
|
-
throw new Error("Session not found");
|
|
236
|
-
if (this.liveSessions.has(sessionId)) {
|
|
237
|
-
// Session already live — build configOptions with stored overrides
|
|
238
|
-
const configOptions = this.buildConfigOptions(session);
|
|
239
|
-
return {
|
|
240
|
-
type: "session_created",
|
|
241
|
-
sessionId,
|
|
242
|
-
cwd: session.cwd,
|
|
243
|
-
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
244
|
-
title: session.title,
|
|
245
|
-
configOptions,
|
|
246
|
-
};
|
|
247
|
-
}
|
|
248
|
-
// Restore via ACP
|
|
249
|
-
this.restoringSessions.add(sessionId);
|
|
250
|
-
try {
|
|
251
|
-
await bridge.loadSession(sessionId, session.cwd);
|
|
252
|
-
this.liveSessions.add(sessionId);
|
|
253
|
-
if (session.title)
|
|
254
|
-
this.sessionHasTitle.add(sessionId);
|
|
255
|
-
// Piggyback a cache-warming setConfigOption on the user's own resume
|
|
256
|
-
// when the global cache is empty (typical after bridge.restart). Uses
|
|
257
|
-
// the session's own stored value — idempotent, no side effect. Failure
|
|
258
|
-
// is swallowed: the resume still succeeds and the frontend falls back
|
|
259
|
-
// to snapshot-based mode/model display (see public/js/state.ts).
|
|
260
|
-
await this.tryWarmCache(bridge, sessionId, session);
|
|
261
|
-
const configOptions = this.applyStoredConfig(this.cachedConfigOptions, session);
|
|
262
|
-
slog.info("restored", { sessionId: sessionId.slice(0, 8) + "…" });
|
|
263
|
-
return {
|
|
264
|
-
type: "session_created",
|
|
265
|
-
sessionId,
|
|
266
|
-
cwd: session.cwd,
|
|
267
|
-
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
268
|
-
title: session.title,
|
|
269
|
-
configOptions,
|
|
270
|
-
};
|
|
271
|
-
}
|
|
272
|
-
catch (err) {
|
|
273
|
-
slog.error("restore failed", { error: err });
|
|
274
|
-
throw err;
|
|
275
|
-
}
|
|
276
|
-
finally {
|
|
277
|
-
this.restoringSessions.delete(sessionId);
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* When cachedConfigOptions is empty, use the session's own stored config
|
|
282
|
-
* value to trigger a setConfigOption. The agent's response carries the
|
|
283
|
-
* full ConfigOption[] schema (options lists + in-memory currentValues),
|
|
284
|
-
* which we cache. Writes **only** the global cache, never the session's
|
|
285
|
-
* DB row — setConfigOption's currentValue for unrelated keys is the
|
|
286
|
-
* agent's in-memory default, not the user's preference.
|
|
287
|
-
*
|
|
288
|
-
* Key priority mode > thinking aliases > model:
|
|
289
|
-
* - mode is a small stable enum, rewriting current value is idempotent.
|
|
290
|
-
* - ACP agents use both reasoning_effort and thought_level for thinking.
|
|
291
|
-
* - model has the highest schema-drift risk (agent upgrades drop values).
|
|
292
|
-
*/
|
|
293
|
-
async tryWarmCache(bridge, sessionId, session) {
|
|
294
|
-
if (this.cachedConfigOptions.length > 0)
|
|
295
|
-
return;
|
|
296
|
-
const candidates = [];
|
|
297
|
-
if (session.mode)
|
|
298
|
-
candidates.push({ id: "mode", value: session.mode });
|
|
299
|
-
if (session.reasoning_effort) {
|
|
300
|
-
candidates.push({ id: "reasoning_effort", value: session.reasoning_effort }, { id: "thought_level", value: session.reasoning_effort });
|
|
301
|
-
}
|
|
302
|
-
if (session.model)
|
|
303
|
-
candidates.push({ id: "model", value: session.model });
|
|
304
|
-
if (candidates.length === 0)
|
|
305
|
-
return;
|
|
306
|
-
let lastError = null;
|
|
307
|
-
for (const candidate of candidates) {
|
|
308
|
-
try {
|
|
309
|
-
const opts = await bridge.setConfigOption(sessionId, candidate.id, candidate.value);
|
|
310
|
-
if (opts.length > 0) {
|
|
311
|
-
this.cachedConfigOptions = opts;
|
|
312
|
-
slog.info("warmed cache on resume", { options: opts.length });
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
catch (err) {
|
|
317
|
-
lastError = err;
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
if (lastError) {
|
|
321
|
-
slog.warn("cache warming failed", {
|
|
322
|
-
sessionId: sessionId.slice(0, 8) + "…",
|
|
323
|
-
error: lastError,
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
/**
|
|
328
|
-
* Ensure a session is resumed (live in ACP). Deduplicates concurrent calls.
|
|
329
|
-
* Unlike resumeSession(), this is fire-and-forget safe — callers that only
|
|
330
|
-
* need the session alive (but not the event payload) can await this.
|
|
331
|
-
*/
|
|
332
|
-
async ensureResumed(bridge, sessionId) {
|
|
333
|
-
if (this.liveSessions.has(sessionId))
|
|
334
|
-
return;
|
|
335
|
-
const existing = this.pendingResumes.get(sessionId);
|
|
336
|
-
if (existing)
|
|
337
|
-
return existing;
|
|
338
|
-
const p = this.resumeSession(bridge, sessionId)
|
|
339
|
-
.then(() => { })
|
|
340
|
-
.finally(() => this.pendingResumes.delete(sessionId));
|
|
341
|
-
this.pendingResumes.set(sessionId, p);
|
|
342
|
-
return p;
|
|
343
|
-
}
|
|
344
|
-
/** Build configOptions from cache, overriding currentValue with stored session values. */
|
|
345
|
-
buildConfigOptions(session) {
|
|
346
|
-
return this.applyStoredConfig(this.cachedConfigOptions, session);
|
|
347
|
-
}
|
|
348
|
-
/** Override currentValue in configOptions with stored session values. */
|
|
349
|
-
applyStoredConfig(configOptions, session) {
|
|
350
|
-
if (!configOptions.length)
|
|
351
|
-
return configOptions;
|
|
352
|
-
const stored = {
|
|
353
|
-
model: session.model,
|
|
354
|
-
mode: session.mode,
|
|
355
|
-
reasoning_effort: session.reasoning_effort,
|
|
356
|
-
thought_level: session.reasoning_effort,
|
|
357
|
-
};
|
|
358
|
-
return configOptions.map((opt) => {
|
|
359
|
-
const override = opt.category === "thought_level"
|
|
360
|
-
? session.reasoning_effort
|
|
361
|
-
: stored[opt.id];
|
|
362
|
-
if (override && "options" in opt)
|
|
363
|
-
return { ...opt, currentValue: override };
|
|
364
|
-
return opt;
|
|
365
|
-
});
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Lazy-build and return the attachment label map for a session.
|
|
369
|
-
* Used by both egress chokepoints (SSE broadcast + replay helper)
|
|
370
|
-
* to translate uuid paths into `<name> [#<id4>]` labels.
|
|
371
|
-
*
|
|
372
|
-
* Cached; call `invalidateLabelCache(sid)` after any attachments
|
|
373
|
-
* INSERT/DELETE to force rebuild. Restart-safe: empty on cold
|
|
374
|
-
* start, rebuilt on first egress per session.
|
|
375
|
-
*/
|
|
376
|
-
getLabelMap(sessionId) {
|
|
377
|
-
const hit = this.attachmentLabelCache.get(sessionId);
|
|
378
|
-
if (hit)
|
|
379
|
-
return hit;
|
|
380
|
-
const map = buildLabelMap(this.store.listAttachmentLabels(sessionId));
|
|
381
|
-
this.attachmentLabelCache.set(sessionId, map);
|
|
382
|
-
return map;
|
|
383
|
-
}
|
|
384
|
-
/** Invalidate label cache for a session (call after attachment write). */
|
|
385
|
-
invalidateLabelCache(sessionId) {
|
|
386
|
-
this.attachmentLabelCache.delete(sessionId);
|
|
387
|
-
}
|
|
388
|
-
updateAgentCommands(sessionId, commands) {
|
|
389
|
-
const current = this.agentCommandSnapshots.get(sessionId);
|
|
390
|
-
const snapshot = {
|
|
391
|
-
epoch: this.agentCommandEpoch,
|
|
392
|
-
revision: (current?.revision ?? 0) + 1,
|
|
393
|
-
commands: commands.map((command) => ({
|
|
394
|
-
...command,
|
|
395
|
-
...(command.input ? { input: { ...command.input } } : {}),
|
|
396
|
-
})),
|
|
397
|
-
};
|
|
398
|
-
this.agentCommandSnapshots.set(sessionId, snapshot);
|
|
399
|
-
return snapshot;
|
|
400
|
-
}
|
|
401
|
-
getAgentCommands(sessionId) {
|
|
402
|
-
return (this.agentCommandSnapshots.get(sessionId) ?? {
|
|
403
|
-
epoch: this.agentCommandEpoch,
|
|
404
|
-
revision: 0,
|
|
405
|
-
commands: [],
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
clearAgentCommands() {
|
|
409
|
-
const cleared = [];
|
|
410
|
-
for (const [sessionId, current] of this.agentCommandSnapshots) {
|
|
411
|
-
const snapshot = {
|
|
412
|
-
epoch: this.agentCommandEpoch,
|
|
413
|
-
revision: current.revision + 1,
|
|
414
|
-
commands: [],
|
|
415
|
-
};
|
|
416
|
-
this.agentCommandSnapshots.set(sessionId, snapshot);
|
|
417
|
-
cleared.push({
|
|
418
|
-
sessionId,
|
|
419
|
-
...snapshot,
|
|
420
|
-
});
|
|
421
|
-
}
|
|
422
|
-
return cleared;
|
|
423
|
-
}
|
|
424
|
-
/** Delete a session from store and clean up all state (including images). */
|
|
425
|
-
deleteSession(sessionId) {
|
|
426
|
-
const mode = this.store.deleteSession(sessionId);
|
|
427
|
-
this.liveSessions.delete(sessionId);
|
|
428
|
-
this.sessionHasTitle.delete(sessionId);
|
|
429
|
-
this.assistantBuffers.delete(sessionId);
|
|
430
|
-
this.thinkingBuffers.delete(sessionId);
|
|
431
|
-
this.activePrompts.delete(sessionId);
|
|
432
|
-
const pendingSubmission = this.pendingPromptSubmissions.get(sessionId);
|
|
433
|
-
this.pendingPromptSubmissions.delete(sessionId);
|
|
434
|
-
if (pendingSubmission !== undefined) {
|
|
435
|
-
this.cancelledPromptSubmissions.delete(pendingSubmission);
|
|
436
|
-
}
|
|
437
|
-
this.runningBashProcs.delete(sessionId);
|
|
438
|
-
this.attachmentLabelCache.delete(sessionId);
|
|
439
|
-
this.agentCommandSnapshots.delete(sessionId);
|
|
440
|
-
// Clean pending permissions for this session
|
|
441
|
-
for (const [reqId, perm] of this.pendingPermissions) {
|
|
442
|
-
if (perm.sessionId === sessionId)
|
|
443
|
-
this.pendingPermissions.delete(reqId);
|
|
444
|
-
}
|
|
445
|
-
this.state.delete(sessionId);
|
|
446
|
-
if (mode === "hard") {
|
|
447
|
-
// Tombstoned sessions keep their attachments alive for the share viewer
|
|
448
|
-
// (shared files still resolve via /s/:token/attachments/...). The reap
|
|
449
|
-
// path in share/routes.ts removes them once the last share is gone.
|
|
450
|
-
rm(join(this.dataDir, "sessions", sessionId), {
|
|
451
|
-
recursive: true,
|
|
452
|
-
force: true,
|
|
453
|
-
}).catch(() => { });
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
/** Flush assistant/thinking buffers to store. */
|
|
457
|
-
flushBuffers(sessionId) {
|
|
458
|
-
this.flushAssistantBuffer(sessionId);
|
|
459
|
-
this.flushThinkingBuffer(sessionId);
|
|
460
|
-
}
|
|
461
|
-
/** Flush only the assistant message buffer to store. */
|
|
462
|
-
flushAssistantBuffer(sessionId) {
|
|
463
|
-
const assistant = this.assistantBuffers.get(sessionId);
|
|
464
|
-
this.assistantBuffers.delete(sessionId);
|
|
465
|
-
if (assistant && this.store.getSession(sessionId)) {
|
|
466
|
-
this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
|
|
467
|
-
}
|
|
468
|
-
}
|
|
469
|
-
/** Flush only the thinking buffer to store. */
|
|
470
|
-
flushThinkingBuffer(sessionId) {
|
|
471
|
-
const thinking = this.thinkingBuffers.get(sessionId);
|
|
472
|
-
this.thinkingBuffers.delete(sessionId);
|
|
473
|
-
if (thinking && this.store.getSession(sessionId)) {
|
|
474
|
-
this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
/** Append to assistant message buffer. */
|
|
478
|
-
appendAssistant(sessionId, text) {
|
|
479
|
-
const buf = (this.assistantBuffers.get(sessionId) ?? "") + text;
|
|
480
|
-
this.assistantBuffers.set(sessionId, buf);
|
|
481
|
-
}
|
|
482
|
-
/** Append to thinking buffer. */
|
|
483
|
-
appendThinking(sessionId, text) {
|
|
484
|
-
const buf = (this.thinkingBuffers.get(sessionId) ?? "") + text;
|
|
485
|
-
this.thinkingBuffers.set(sessionId, buf);
|
|
486
|
-
}
|
|
487
|
-
/** Get CWD for a session (falls back to default). */
|
|
488
|
-
getSessionCwd(sessionId) {
|
|
489
|
-
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
490
|
-
}
|
|
491
|
-
getBusyKind(sessionId) {
|
|
492
|
-
if (this.pendingPromptSubmissions.has(sessionId))
|
|
493
|
-
return "agent";
|
|
494
|
-
if (this.activePrompts.has(sessionId))
|
|
495
|
-
return "agent";
|
|
496
|
-
if (this.runningBashProcs.has(sessionId))
|
|
497
|
-
return "bash";
|
|
498
|
-
return null;
|
|
499
|
-
}
|
|
500
|
-
/**
|
|
501
|
-
* True when `promptId` still names the session's live turn. A turn can
|
|
502
|
-
* outlive its own supersession — cancelling one and immediately starting
|
|
503
|
-
* another interleaves them — and its terminal work must not clear state
|
|
504
|
-
* that now belongs to the replacement. An absent id is treated as current
|
|
505
|
-
* so callers predating turn identity keep their old behaviour.
|
|
506
|
-
*/
|
|
507
|
-
isCurrentPrompt(sessionId, promptId) {
|
|
508
|
-
if (!promptId)
|
|
509
|
-
return true;
|
|
510
|
-
const current = this.state.getState(sessionId).runtime.busy?.promptId;
|
|
511
|
-
return current == null || current === promptId;
|
|
512
|
-
}
|
|
513
|
-
reservePromptSubmission(sessionId) {
|
|
514
|
-
if (this.getBusyKind(sessionId) !== null)
|
|
515
|
-
return null;
|
|
516
|
-
const submissionId = ++this.nextPromptSubmissionNumber;
|
|
517
|
-
this.pendingPromptSubmissions.set(sessionId, submissionId);
|
|
518
|
-
return submissionId;
|
|
519
|
-
}
|
|
520
|
-
cancelPendingPromptSubmission(sessionId) {
|
|
521
|
-
const submissionId = this.pendingPromptSubmissions.get(sessionId);
|
|
522
|
-
if (submissionId === undefined)
|
|
523
|
-
return false;
|
|
524
|
-
this.cancelledPromptSubmissions.add(submissionId);
|
|
525
|
-
return true;
|
|
526
|
-
}
|
|
527
|
-
isPromptSubmissionCancelled(submissionId) {
|
|
528
|
-
return this.cancelledPromptSubmissions.has(submissionId);
|
|
529
|
-
}
|
|
530
|
-
releasePromptSubmission(sessionId, submissionId, sync = true) {
|
|
531
|
-
if (this.pendingPromptSubmissions.get(sessionId) === submissionId) {
|
|
532
|
-
this.pendingPromptSubmissions.delete(sessionId);
|
|
533
|
-
}
|
|
534
|
-
this.cancelledPromptSubmissions.delete(submissionId);
|
|
535
|
-
if (sync)
|
|
536
|
-
this.syncBusy(sessionId);
|
|
537
|
-
}
|
|
538
|
-
/**
|
|
539
|
-
* Recompute busy from active prompts/bash procs and patch the state manager.
|
|
540
|
-
* Call this immediately after mutating activePrompts / runningBashProcs so
|
|
541
|
-
* the frontend snapshot stays in sync via `state_patch` broadcast.
|
|
542
|
-
*
|
|
543
|
-
* `promptId` attaches to an agent busy transition (ignored otherwise). If
|
|
544
|
-
* omitted when staying agent-busy, the existing promptId is preserved.
|
|
545
|
-
*/
|
|
546
|
-
syncBusy(sessionId, promptId) {
|
|
547
|
-
const kind = this.getBusyKind(sessionId);
|
|
548
|
-
const current = this.state.getState(sessionId).runtime.busy;
|
|
549
|
-
if (kind === null) {
|
|
550
|
-
if (current !== null)
|
|
551
|
-
this.state.patch(sessionId, { runtime: { busy: null } });
|
|
552
|
-
// Also clear any pending cancel safety net now that we are idle.
|
|
553
|
-
this.state.clearCancelSafety(sessionId);
|
|
554
|
-
return;
|
|
555
|
-
}
|
|
556
|
-
const nextPromptId = kind === "agent"
|
|
557
|
-
? (promptId ??
|
|
558
|
-
(current?.kind === "agent"
|
|
559
|
-
? current.promptId
|
|
560
|
-
: `prompt-${++this.nextPromptNumber}`))
|
|
561
|
-
: null;
|
|
562
|
-
const sameWork = current?.kind === kind && current.promptId === nextPromptId;
|
|
563
|
-
if (sameWork)
|
|
564
|
-
return;
|
|
565
|
-
this.state.patch(sessionId, {
|
|
566
|
-
runtime: {
|
|
567
|
-
busy: {
|
|
568
|
-
kind: kind,
|
|
569
|
-
since: current?.kind === kind ? current.since : new Date().toISOString(),
|
|
570
|
-
promptId: nextPromptId,
|
|
571
|
-
cancelStatus: null,
|
|
572
|
-
},
|
|
573
|
-
},
|
|
574
|
-
});
|
|
575
|
-
}
|
|
576
|
-
/**
|
|
577
|
-
* If the session's last turn was interrupted (user_message without prompt_done),
|
|
578
|
-
* auto-retry by prompting the agent to continue. Returns true if retrying.
|
|
579
|
-
*/
|
|
580
|
-
autoRetryIfNeeded(bridge, sessionId) {
|
|
581
|
-
if (this.activePrompts.has(sessionId))
|
|
582
|
-
return false;
|
|
583
|
-
if (!this.store.hasInterruptedTurn(sessionId))
|
|
584
|
-
return false;
|
|
585
|
-
slog.info("auto-retrying interrupted turn", {
|
|
586
|
-
sessionId: sessionId.slice(0, 8) + "…",
|
|
587
|
-
});
|
|
588
|
-
this.activePrompts.add(sessionId);
|
|
589
|
-
this.syncBusy(sessionId);
|
|
590
|
-
const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
|
|
591
|
-
bridge
|
|
592
|
-
.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
|
|
593
|
-
.catch((err) => {
|
|
594
|
-
slog.error("auto-retry failed", {
|
|
595
|
-
sessionId: sessionId.slice(0, 8) + "…",
|
|
596
|
-
error: err,
|
|
597
|
-
});
|
|
598
|
-
if (!this.isCurrentPrompt(sessionId, promptId))
|
|
599
|
-
return;
|
|
600
|
-
this.activePrompts.delete(sessionId);
|
|
601
|
-
this.syncBusy(sessionId);
|
|
602
|
-
});
|
|
603
|
-
return true;
|
|
604
|
-
}
|
|
605
|
-
/** Get pending permission requests for a session (or all sessions if no id). */
|
|
606
|
-
getPendingPermissions(sessionId) {
|
|
607
|
-
const perms = [...this.pendingPermissions.values()];
|
|
608
|
-
return sessionId ? perms.filter((p) => p.sessionId === sessionId) : perms;
|
|
609
|
-
}
|
|
610
|
-
/**
|
|
611
|
-
* Re-derive runtime.pendingPermissions from the Map and push via state_patch.
|
|
612
|
-
* Call this after every mutation of `pendingPermissions` so the frontend
|
|
613
|
-
* snapshot stays authoritative.
|
|
614
|
-
*/
|
|
615
|
-
syncPendingPermissions(sessionId) {
|
|
616
|
-
const forSession = [...this.pendingPermissions.values()]
|
|
617
|
-
.filter((p) => p.sessionId === sessionId)
|
|
618
|
-
.map((p) => ({
|
|
619
|
-
requestId: p.requestId,
|
|
620
|
-
toolName: "",
|
|
621
|
-
title: p.title,
|
|
622
|
-
options: p.options.map((o) => ({
|
|
623
|
-
optionId: o.optionId,
|
|
624
|
-
label: o.label,
|
|
625
|
-
})),
|
|
626
|
-
}));
|
|
627
|
-
this.state.patch(sessionId, {
|
|
628
|
-
runtime: { pendingPermissions: forSession },
|
|
629
|
-
});
|
|
630
|
-
}
|
|
631
|
-
/** Kill all running bash processes (for shutdown). */
|
|
632
|
-
killAllBashProcs() {
|
|
633
|
-
const forceSignal = process.platform === "win32" ? undefined : "SIGKILL";
|
|
634
|
-
for (const [, proc] of this.runningBashProcs)
|
|
635
|
-
proc.kill(forceSignal);
|
|
636
|
-
this.runningBashProcs.clear();
|
|
637
|
-
}
|
|
638
|
-
}
|