@lelouchhe/webagent 0.6.0 → 0.8.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/dist/index.html +15 -6
- package/dist/js/app.XBFXH37R.js +2 -0
- package/dist/js/chunk.CT5WBNGZ.js +173 -0
- package/dist/js/chunk.UMQMOGWO.js +1 -0
- package/dist/js/{login.PYIK52HN.js → login.2WA6DTGM.js} +1 -1
- package/dist/js/viewer.CVWXSKJM.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.01ky3cex.css → styles.00etlpgs.css} +94 -4
- package/dist/sw.js +19 -9
- package/lib/agent-commands.js +27 -0
- package/lib/bridge.js +60 -11
- package/lib/event-handler.js +75 -15
- package/lib/http-status.js +17 -0
- package/lib/message-cleanup.js +5 -4
- package/lib/push-service.js +2 -1
- package/lib/routes.js +468 -196
- package/lib/server.js +4 -3
- package/lib/session-manager.js +213 -23
- package/lib/session-state.js +70 -5
- package/lib/share/routes.js +106 -72
- package/lib/share/sanitize.js +2 -1
- package/lib/sse-manager.js +31 -7
- package/lib/store.js +25 -23
- package/package.json +1 -1
- package/dist/js/app.HJKQURHR.js +0 -4
- package/dist/js/chunk.D4ZYHJAM.js +0 -1
- package/dist/js/chunk.ON2W5X2T.js +0 -173
- package/dist/js/viewer.VY4PO2O5.js +0 -1
package/lib/server.js
CHANGED
|
@@ -104,7 +104,9 @@ sessions.state.onPatch((event) => {
|
|
|
104
104
|
sseManager.broadcast(event);
|
|
105
105
|
});
|
|
106
106
|
let bridge = null;
|
|
107
|
-
|
|
107
|
+
const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
|
|
108
|
+
sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
|
|
109
|
+
});
|
|
108
110
|
let sharePreviewCleanup = null;
|
|
109
111
|
// --- HTTP server ---
|
|
110
112
|
const server = createServer((req, res) => {
|
|
@@ -153,7 +155,7 @@ async function initBridge(agentCmd) {
|
|
|
153
155
|
async function shutdown() {
|
|
154
156
|
console.log("\n[server] shutting down...");
|
|
155
157
|
sseManager.stopHeartbeat();
|
|
156
|
-
messageCleanup
|
|
158
|
+
messageCleanup.stop();
|
|
157
159
|
sharePreviewCleanup?.stop();
|
|
158
160
|
sessions.killAllBashProcs();
|
|
159
161
|
await bridge?.shutdown();
|
|
@@ -185,7 +187,6 @@ server.listen(config.port, config.host, () => {
|
|
|
185
187
|
// will use. If the gate ran, auth.json exists and has ≥ 1 token.
|
|
186
188
|
await authStore.load();
|
|
187
189
|
console.log(`[server] listening on http://localhost:${config.port}`);
|
|
188
|
-
messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
|
|
189
190
|
if (config.share.enabled) {
|
|
190
191
|
sharePreviewCleanup = startSharePreviewCleanup(store);
|
|
191
192
|
console.log(`[share] preview gc armed (24h interval)`);
|
package/lib/session-manager.js
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { rm } from "node:fs/promises";
|
|
3
4
|
import { stat } from "node:fs/promises";
|
|
4
5
|
import { join } from "node:path";
|
|
6
|
+
import { MessageNotFoundError } from "./store.js";
|
|
5
7
|
import { SessionStateManager } from "./session-state.js";
|
|
6
8
|
import { buildLabelMap } from "./attachment-labels.js";
|
|
7
9
|
import { log } from "./log.js";
|
|
8
10
|
const slog = log.scope("session");
|
|
9
11
|
const IS_WIN = process.platform === "win32";
|
|
10
|
-
export function interruptBashProc(proc) {
|
|
12
|
+
export function interruptBashProc(proc, force = false) {
|
|
11
13
|
if (!proc)
|
|
12
14
|
return;
|
|
13
15
|
if (IS_WIN && typeof proc.pid === "number") {
|
|
@@ -17,19 +19,23 @@ export function interruptBashProc(proc) {
|
|
|
17
19
|
}
|
|
18
20
|
if (typeof proc.pid === "number") {
|
|
19
21
|
try {
|
|
20
|
-
process.kill(-proc.pid, "SIGINT");
|
|
22
|
+
process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
|
|
21
23
|
return;
|
|
22
24
|
}
|
|
23
25
|
catch {
|
|
24
26
|
// Fall through to direct child kill when the process is not a group leader.
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
|
-
proc.kill("SIGINT");
|
|
29
|
+
proc.kill(force ? "SIGKILL" : "SIGINT");
|
|
28
30
|
}
|
|
29
|
-
/** Known config option IDs that we persist per-session. */
|
|
30
|
-
const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
|
|
31
31
|
/** Minimum age (seconds) before an empty session is eligible for cleanup. */
|
|
32
32
|
const EMPTY_SESSION_MIN_AGE_S = 60;
|
|
33
|
+
export class InvalidSessionDirectoryError extends Error {
|
|
34
|
+
constructor(cwd) {
|
|
35
|
+
super(`Directory does not exist: ${cwd}`);
|
|
36
|
+
this.name = "InvalidSessionDirectoryError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
33
39
|
/**
|
|
34
40
|
* Centralizes all session-related state that was previously scattered
|
|
35
41
|
* across module-level variables in server.ts.
|
|
@@ -41,13 +47,20 @@ export class SessionManager {
|
|
|
41
47
|
assistantBuffers = new Map();
|
|
42
48
|
thinkingBuffers = new Map();
|
|
43
49
|
activePrompts = new Set();
|
|
50
|
+
pendingPromptSubmissions = new Map();
|
|
51
|
+
cancelledPromptSubmissions = new Set();
|
|
44
52
|
runningBashProcs = new Map();
|
|
53
|
+
interruptedBashProcs = new WeakSet();
|
|
45
54
|
/** Pending permission requests keyed by requestId. */
|
|
46
55
|
pendingPermissions = new Map();
|
|
47
56
|
/** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
|
|
48
57
|
state = new SessionStateManager();
|
|
49
58
|
/** Deduplicates concurrent resume calls for the same session. */
|
|
50
59
|
pendingResumes = new Map();
|
|
60
|
+
nextPromptNumber = 0;
|
|
61
|
+
nextPromptSubmissionNumber = 0;
|
|
62
|
+
/** Deduplicates concurrent attempts to materialize one inbox message. */
|
|
63
|
+
pendingMessageConsumes = new Map();
|
|
51
64
|
/**
|
|
52
65
|
* Per-session attachment label map (CLAUDE.md "Attachment label
|
|
53
66
|
* egress rewrite"). Built lazily from the `attachments` table on
|
|
@@ -55,6 +68,8 @@ export class SessionManager {
|
|
|
55
68
|
* DELETE. Lookup is cheap (Map.get); rebuild is one SQLite query.
|
|
56
69
|
*/
|
|
57
70
|
attachmentLabelCache = new Map();
|
|
71
|
+
agentCommandSnapshots = new Map();
|
|
72
|
+
agentCommandEpoch = randomUUID();
|
|
58
73
|
cachedConfigOptions = [];
|
|
59
74
|
agentInfo = null;
|
|
60
75
|
store;
|
|
@@ -73,7 +88,7 @@ export class SessionManager {
|
|
|
73
88
|
}
|
|
74
89
|
}
|
|
75
90
|
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
76
|
-
async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
|
|
91
|
+
async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
|
|
77
92
|
const sessionCwd = cwd ?? this.defaultCwd;
|
|
78
93
|
try {
|
|
79
94
|
const info = await stat(sessionCwd);
|
|
@@ -81,20 +96,33 @@ export class SessionManager {
|
|
|
81
96
|
throw new Error("not a directory");
|
|
82
97
|
}
|
|
83
98
|
catch {
|
|
84
|
-
throw new
|
|
99
|
+
throw new InvalidSessionDirectoryError(sessionCwd);
|
|
85
100
|
}
|
|
86
101
|
// Clean up empty sessions (no events) older than the threshold
|
|
87
102
|
const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
|
|
88
|
-
for (const id of cleaned)
|
|
103
|
+
for (const id of cleaned) {
|
|
89
104
|
this.liveSessions.delete(id);
|
|
105
|
+
this.agentCommandSnapshots.delete(id);
|
|
106
|
+
}
|
|
90
107
|
if (cleaned.length > 0)
|
|
91
108
|
slog.info("cleaned empty session(s)", { count: cleaned.length });
|
|
92
109
|
const sourceSession = inheritFromSessionId
|
|
93
110
|
? this.store.getSession(inheritFromSessionId)
|
|
94
111
|
: null;
|
|
95
|
-
const { sessionId } = await bridge.newSession(sessionCwd);
|
|
112
|
+
const { sessionId, configOptions: createdConfigOptions } = await bridge.newSession(sessionCwd, { silent: opts?.silent });
|
|
113
|
+
let configOptions = createdConfigOptions;
|
|
114
|
+
try {
|
|
115
|
+
this.store.createSession(sessionId, sessionCwd, source);
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
slog.warn("ACP session created but local persistence failed", {
|
|
119
|
+
sessionId,
|
|
120
|
+
error: err,
|
|
121
|
+
});
|
|
122
|
+
throw err;
|
|
123
|
+
}
|
|
96
124
|
this.liveSessions.add(sessionId);
|
|
97
|
-
this.
|
|
125
|
+
this.recordConfigOptions(sessionId, createdConfigOptions);
|
|
98
126
|
// Inherit config options from source session
|
|
99
127
|
if (sourceSession) {
|
|
100
128
|
const inherited = [
|
|
@@ -105,8 +133,14 @@ export class SessionManager {
|
|
|
105
133
|
if (!value)
|
|
106
134
|
continue;
|
|
107
135
|
try {
|
|
108
|
-
await bridge.setConfigOption(sessionId, configId, value);
|
|
109
|
-
|
|
136
|
+
const updatedConfigOptions = await bridge.setConfigOption(sessionId, configId, value);
|
|
137
|
+
if (updatedConfigOptions.length > 0) {
|
|
138
|
+
configOptions = updatedConfigOptions;
|
|
139
|
+
this.recordConfigOptions(sessionId, updatedConfigOptions);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
142
|
+
this.store.updateSessionConfig(sessionId, configId, value);
|
|
143
|
+
}
|
|
110
144
|
}
|
|
111
145
|
catch {
|
|
112
146
|
// Option may no longer be available; ignore
|
|
@@ -116,9 +150,73 @@ export class SessionManager {
|
|
|
116
150
|
const session = this.store.getSession(sessionId);
|
|
117
151
|
return {
|
|
118
152
|
sessionId,
|
|
119
|
-
configOptions: session
|
|
153
|
+
configOptions: session
|
|
154
|
+
? this.applyStoredConfig(configOptions, session)
|
|
155
|
+
: [],
|
|
120
156
|
};
|
|
121
157
|
}
|
|
158
|
+
/** Cache the ACP config schema and persist this session's current values. */
|
|
159
|
+
recordConfigOptions(sessionId, configOptions) {
|
|
160
|
+
if (configOptions.length === 0)
|
|
161
|
+
return;
|
|
162
|
+
this.cachedConfigOptions = configOptions;
|
|
163
|
+
for (const option of configOptions) {
|
|
164
|
+
if (typeof option.currentValue === "string") {
|
|
165
|
+
this.store.updateSessionConfig(sessionId, option.id, option.currentValue);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Materialize a pending inbox message as a real ACP-backed session.
|
|
171
|
+
* Returns null when the message is neither pending nor previously consumed.
|
|
172
|
+
*/
|
|
173
|
+
consumeMessage(bridge, messageId, inheritFromSessionId) {
|
|
174
|
+
const pending = this.pendingMessageConsumes.get(messageId);
|
|
175
|
+
if (pending)
|
|
176
|
+
return pending;
|
|
177
|
+
const existing = this.store.findConsumedMessageSession(messageId);
|
|
178
|
+
if (existing) {
|
|
179
|
+
return Promise.resolve({
|
|
180
|
+
sessionId: existing,
|
|
181
|
+
alreadyConsumed: true,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const operation = this.consumePendingMessage(bridge, messageId, inheritFromSessionId).finally(() => {
|
|
185
|
+
if (this.pendingMessageConsumes.get(messageId) === operation) {
|
|
186
|
+
this.pendingMessageConsumes.delete(messageId);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
this.pendingMessageConsumes.set(messageId, operation);
|
|
190
|
+
return operation;
|
|
191
|
+
}
|
|
192
|
+
async consumePendingMessage(bridge, messageId, inheritFromSessionId) {
|
|
193
|
+
const message = this.store.getMessage(messageId);
|
|
194
|
+
if (!message)
|
|
195
|
+
return null;
|
|
196
|
+
const { sessionId } = await this.createSession(bridge, message.cwd ?? undefined, inheritFromSessionId, "message", { silent: true });
|
|
197
|
+
try {
|
|
198
|
+
const result = this.store.consumeMessageTx(messageId, sessionId);
|
|
199
|
+
if (result.alreadyConsumed) {
|
|
200
|
+
this.deleteSession(sessionId);
|
|
201
|
+
}
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
this.deleteSession(sessionId);
|
|
206
|
+
if (err instanceof MessageNotFoundError) {
|
|
207
|
+
const consumedSessionId = this.store.findConsumedMessageSession(messageId);
|
|
208
|
+
return consumedSessionId
|
|
209
|
+
? { sessionId: consumedSessionId, alreadyConsumed: true }
|
|
210
|
+
: null;
|
|
211
|
+
}
|
|
212
|
+
slog.warn("message consume left an unreachable ACP session", {
|
|
213
|
+
messageId,
|
|
214
|
+
sessionId,
|
|
215
|
+
error: err,
|
|
216
|
+
});
|
|
217
|
+
throw err;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
122
220
|
/** Resume a session — returns event to send to the requesting client. */
|
|
123
221
|
async resumeSession(bridge, sessionId) {
|
|
124
222
|
const session = this.store.getSession(sessionId);
|
|
@@ -228,7 +326,7 @@ export class SessionManager {
|
|
|
228
326
|
/** Override currentValue in configOptions with stored session values. */
|
|
229
327
|
applyStoredConfig(configOptions, session) {
|
|
230
328
|
if (!configOptions.length)
|
|
231
|
-
return
|
|
329
|
+
return configOptions;
|
|
232
330
|
const stored = {
|
|
233
331
|
model: session.model,
|
|
234
332
|
mode: session.mode,
|
|
@@ -262,6 +360,42 @@ export class SessionManager {
|
|
|
262
360
|
invalidateLabelCache(sessionId) {
|
|
263
361
|
this.attachmentLabelCache.delete(sessionId);
|
|
264
362
|
}
|
|
363
|
+
updateAgentCommands(sessionId, commands) {
|
|
364
|
+
const current = this.agentCommandSnapshots.get(sessionId);
|
|
365
|
+
const snapshot = {
|
|
366
|
+
epoch: this.agentCommandEpoch,
|
|
367
|
+
revision: (current?.revision ?? 0) + 1,
|
|
368
|
+
commands: commands.map((command) => ({
|
|
369
|
+
...command,
|
|
370
|
+
...(command.input ? { input: { ...command.input } } : {}),
|
|
371
|
+
})),
|
|
372
|
+
};
|
|
373
|
+
this.agentCommandSnapshots.set(sessionId, snapshot);
|
|
374
|
+
return snapshot;
|
|
375
|
+
}
|
|
376
|
+
getAgentCommands(sessionId) {
|
|
377
|
+
return (this.agentCommandSnapshots.get(sessionId) ?? {
|
|
378
|
+
epoch: this.agentCommandEpoch,
|
|
379
|
+
revision: 0,
|
|
380
|
+
commands: [],
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
clearAgentCommands() {
|
|
384
|
+
const cleared = [];
|
|
385
|
+
for (const [sessionId, current] of this.agentCommandSnapshots) {
|
|
386
|
+
const snapshot = {
|
|
387
|
+
epoch: this.agentCommandEpoch,
|
|
388
|
+
revision: current.revision + 1,
|
|
389
|
+
commands: [],
|
|
390
|
+
};
|
|
391
|
+
this.agentCommandSnapshots.set(sessionId, snapshot);
|
|
392
|
+
cleared.push({
|
|
393
|
+
sessionId,
|
|
394
|
+
...snapshot,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
return cleared;
|
|
398
|
+
}
|
|
265
399
|
/** Delete a session from store and clean up all state (including images). */
|
|
266
400
|
deleteSession(sessionId) {
|
|
267
401
|
const mode = this.store.deleteSession(sessionId);
|
|
@@ -270,8 +404,14 @@ export class SessionManager {
|
|
|
270
404
|
this.assistantBuffers.delete(sessionId);
|
|
271
405
|
this.thinkingBuffers.delete(sessionId);
|
|
272
406
|
this.activePrompts.delete(sessionId);
|
|
407
|
+
const pendingSubmission = this.pendingPromptSubmissions.get(sessionId);
|
|
408
|
+
this.pendingPromptSubmissions.delete(sessionId);
|
|
409
|
+
if (pendingSubmission !== undefined) {
|
|
410
|
+
this.cancelledPromptSubmissions.delete(pendingSubmission);
|
|
411
|
+
}
|
|
273
412
|
this.runningBashProcs.delete(sessionId);
|
|
274
413
|
this.attachmentLabelCache.delete(sessionId);
|
|
414
|
+
this.agentCommandSnapshots.delete(sessionId);
|
|
275
415
|
// Clean pending permissions for this session
|
|
276
416
|
for (const [reqId, perm] of this.pendingPermissions) {
|
|
277
417
|
if (perm.sessionId === sessionId)
|
|
@@ -296,17 +436,17 @@ export class SessionManager {
|
|
|
296
436
|
/** Flush only the assistant message buffer to store. */
|
|
297
437
|
flushAssistantBuffer(sessionId) {
|
|
298
438
|
const assistant = this.assistantBuffers.get(sessionId);
|
|
299
|
-
|
|
439
|
+
this.assistantBuffers.delete(sessionId);
|
|
440
|
+
if (assistant && this.store.getSession(sessionId)) {
|
|
300
441
|
this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
|
|
301
|
-
this.assistantBuffers.delete(sessionId);
|
|
302
442
|
}
|
|
303
443
|
}
|
|
304
444
|
/** Flush only the thinking buffer to store. */
|
|
305
445
|
flushThinkingBuffer(sessionId) {
|
|
306
446
|
const thinking = this.thinkingBuffers.get(sessionId);
|
|
307
|
-
|
|
447
|
+
this.thinkingBuffers.delete(sessionId);
|
|
448
|
+
if (thinking && this.store.getSession(sessionId)) {
|
|
308
449
|
this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
|
|
309
|
-
this.thinkingBuffers.delete(sessionId);
|
|
310
450
|
}
|
|
311
451
|
}
|
|
312
452
|
/** Append to assistant message buffer. */
|
|
@@ -324,12 +464,52 @@ export class SessionManager {
|
|
|
324
464
|
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
325
465
|
}
|
|
326
466
|
getBusyKind(sessionId) {
|
|
327
|
-
if (this.
|
|
328
|
-
return "
|
|
467
|
+
if (this.pendingPromptSubmissions.has(sessionId))
|
|
468
|
+
return "agent";
|
|
329
469
|
if (this.activePrompts.has(sessionId))
|
|
330
470
|
return "agent";
|
|
471
|
+
if (this.runningBashProcs.has(sessionId))
|
|
472
|
+
return "bash";
|
|
331
473
|
return null;
|
|
332
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* True when `promptId` still names the session's live turn. A turn can
|
|
477
|
+
* outlive its own supersession — cancelling one and immediately starting
|
|
478
|
+
* another interleaves them — and its terminal work must not clear state
|
|
479
|
+
* that now belongs to the replacement. An absent id is treated as current
|
|
480
|
+
* so callers predating turn identity keep their old behaviour.
|
|
481
|
+
*/
|
|
482
|
+
isCurrentPrompt(sessionId, promptId) {
|
|
483
|
+
if (!promptId)
|
|
484
|
+
return true;
|
|
485
|
+
const current = this.state.getState(sessionId).runtime.busy?.promptId;
|
|
486
|
+
return current == null || current === promptId;
|
|
487
|
+
}
|
|
488
|
+
reservePromptSubmission(sessionId) {
|
|
489
|
+
if (this.getBusyKind(sessionId) !== null)
|
|
490
|
+
return null;
|
|
491
|
+
const submissionId = ++this.nextPromptSubmissionNumber;
|
|
492
|
+
this.pendingPromptSubmissions.set(sessionId, submissionId);
|
|
493
|
+
return submissionId;
|
|
494
|
+
}
|
|
495
|
+
cancelPendingPromptSubmission(sessionId) {
|
|
496
|
+
const submissionId = this.pendingPromptSubmissions.get(sessionId);
|
|
497
|
+
if (submissionId === undefined)
|
|
498
|
+
return false;
|
|
499
|
+
this.cancelledPromptSubmissions.add(submissionId);
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
isPromptSubmissionCancelled(submissionId) {
|
|
503
|
+
return this.cancelledPromptSubmissions.has(submissionId);
|
|
504
|
+
}
|
|
505
|
+
releasePromptSubmission(sessionId, submissionId, sync = true) {
|
|
506
|
+
if (this.pendingPromptSubmissions.get(sessionId) === submissionId) {
|
|
507
|
+
this.pendingPromptSubmissions.delete(sessionId);
|
|
508
|
+
}
|
|
509
|
+
this.cancelledPromptSubmissions.delete(submissionId);
|
|
510
|
+
if (sync)
|
|
511
|
+
this.syncBusy(sessionId);
|
|
512
|
+
}
|
|
333
513
|
/**
|
|
334
514
|
* Recompute busy from active prompts/bash procs and patch the state manager.
|
|
335
515
|
* Call this immediately after mutating activePrompts / runningBashProcs so
|
|
@@ -348,8 +528,14 @@ export class SessionManager {
|
|
|
348
528
|
this.state.clearCancelSafety(sessionId);
|
|
349
529
|
return;
|
|
350
530
|
}
|
|
351
|
-
const nextPromptId = kind === "agent"
|
|
352
|
-
|
|
531
|
+
const nextPromptId = kind === "agent"
|
|
532
|
+
? (promptId ??
|
|
533
|
+
(current?.kind === "agent"
|
|
534
|
+
? current.promptId
|
|
535
|
+
: `prompt-${++this.nextPromptNumber}`))
|
|
536
|
+
: null;
|
|
537
|
+
const sameWork = current?.kind === kind && current.promptId === nextPromptId;
|
|
538
|
+
if (sameWork)
|
|
353
539
|
return;
|
|
354
540
|
this.state.patch(sessionId, {
|
|
355
541
|
runtime: {
|
|
@@ -357,6 +543,7 @@ export class SessionManager {
|
|
|
357
543
|
kind: kind,
|
|
358
544
|
since: current?.kind === kind ? current.since : new Date().toISOString(),
|
|
359
545
|
promptId: nextPromptId,
|
|
546
|
+
cancelStatus: null,
|
|
360
547
|
},
|
|
361
548
|
},
|
|
362
549
|
});
|
|
@@ -375,13 +562,16 @@ export class SessionManager {
|
|
|
375
562
|
});
|
|
376
563
|
this.activePrompts.add(sessionId);
|
|
377
564
|
this.syncBusy(sessionId);
|
|
565
|
+
const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
|
|
378
566
|
bridge
|
|
379
|
-
.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
|
|
567
|
+
.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
|
|
380
568
|
.catch((err) => {
|
|
381
569
|
slog.error("auto-retry failed", {
|
|
382
570
|
sessionId: sessionId.slice(0, 8) + "…",
|
|
383
571
|
error: err,
|
|
384
572
|
});
|
|
573
|
+
if (!this.isCurrentPrompt(sessionId, promptId))
|
|
574
|
+
return;
|
|
385
575
|
this.activePrompts.delete(sessionId);
|
|
386
576
|
this.syncBusy(sessionId);
|
|
387
577
|
});
|
package/lib/session-state.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-session runtime state: single source of truth for "what state is this
|
|
3
|
-
* session in right now" (busy / streaming / pending permissions).
|
|
3
|
+
* session in right now" (busy / streaming / pending permissions / plan).
|
|
4
4
|
*
|
|
5
5
|
* The frontend fetches a full snapshot on connect / reconnect / after long
|
|
6
6
|
* backgrounding, then applies incremental `state_patch` SSE events. This
|
|
7
7
|
* replaces the old "replay history + reconcile" approach which repeatedly
|
|
8
8
|
* grew one-off sync paths per state field.
|
|
9
9
|
*/
|
|
10
|
+
import { log } from "./log.js";
|
|
11
|
+
const clog = log.scope("cancel");
|
|
10
12
|
function defaultState() {
|
|
11
13
|
return {
|
|
12
14
|
seq: 0,
|
|
@@ -14,6 +16,7 @@ function defaultState() {
|
|
|
14
16
|
busy: null,
|
|
15
17
|
pendingPermissions: [],
|
|
16
18
|
streaming: { assistant: false, thinking: false },
|
|
19
|
+
plan: null,
|
|
17
20
|
},
|
|
18
21
|
};
|
|
19
22
|
}
|
|
@@ -22,7 +25,10 @@ function busyEqual(a, b) {
|
|
|
22
25
|
return true;
|
|
23
26
|
if (a === null || b === null)
|
|
24
27
|
return false;
|
|
25
|
-
return a.kind === b.kind &&
|
|
28
|
+
return (a.kind === b.kind &&
|
|
29
|
+
a.since === b.since &&
|
|
30
|
+
a.promptId === b.promptId &&
|
|
31
|
+
(a.cancelStatus ?? null) === (b.cancelStatus ?? null));
|
|
26
32
|
}
|
|
27
33
|
function permsEqual(a, b) {
|
|
28
34
|
if (a.length !== b.length)
|
|
@@ -43,6 +49,13 @@ function permsEqual(a, b) {
|
|
|
43
49
|
}
|
|
44
50
|
return true;
|
|
45
51
|
}
|
|
52
|
+
function plansEqual(a, b) {
|
|
53
|
+
if (a === null || b === null)
|
|
54
|
+
return a === b;
|
|
55
|
+
if (a.length !== b.length)
|
|
56
|
+
return false;
|
|
57
|
+
return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
|
|
58
|
+
}
|
|
46
59
|
/** True when the patch would change the current runtime state. */
|
|
47
60
|
function hasRuntimeChanges(current, patch) {
|
|
48
61
|
if (!patch)
|
|
@@ -53,6 +66,8 @@ function hasRuntimeChanges(current, patch) {
|
|
|
53
66
|
patch.pendingPermissions &&
|
|
54
67
|
!permsEqual(current.pendingPermissions, patch.pendingPermissions))
|
|
55
68
|
return true;
|
|
69
|
+
if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
|
|
70
|
+
return true;
|
|
56
71
|
if ("streaming" in patch && patch.streaming) {
|
|
57
72
|
const s = patch.streaming;
|
|
58
73
|
if (s.assistant !== undefined &&
|
|
@@ -76,6 +91,11 @@ export class SessionStateManager {
|
|
|
76
91
|
}
|
|
77
92
|
return s;
|
|
78
93
|
}
|
|
94
|
+
/** Read streaming state without creating runtime state for an unseen session. */
|
|
95
|
+
peekStreaming(sessionId) {
|
|
96
|
+
const streaming = this.states.get(sessionId)?.runtime.streaming;
|
|
97
|
+
return streaming ? { ...streaming } : { assistant: false, thinking: false };
|
|
98
|
+
}
|
|
79
99
|
/**
|
|
80
100
|
* Merge a patch into the session's runtime state. Bumps seq and notifies
|
|
81
101
|
* listeners only when the patch actually changes something (no-op patches
|
|
@@ -95,6 +115,10 @@ export class SessionStateManager {
|
|
|
95
115
|
state.runtime.pendingPermissions =
|
|
96
116
|
patch.runtime.pendingPermissions.slice();
|
|
97
117
|
}
|
|
118
|
+
if ("plan" in patch.runtime) {
|
|
119
|
+
state.runtime.plan =
|
|
120
|
+
patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
|
|
121
|
+
}
|
|
98
122
|
if ("streaming" in patch.runtime && patch.runtime.streaming) {
|
|
99
123
|
if (patch.runtime.streaming.assistant !== undefined) {
|
|
100
124
|
state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
|
|
@@ -130,9 +154,30 @@ export class SessionStateManager {
|
|
|
130
154
|
this.cancelTimers.delete(sessionId);
|
|
131
155
|
}
|
|
132
156
|
}
|
|
157
|
+
/** Clear current plans for every known session (used on bridge reload). */
|
|
158
|
+
clearPlans() {
|
|
159
|
+
for (const [sessionId, state] of this.states) {
|
|
160
|
+
if (state.runtime.plan !== null) {
|
|
161
|
+
this.patch(sessionId, { runtime: { plan: null } });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/** Clear active stream markers for every known session on bridge teardown. */
|
|
166
|
+
clearStreaming() {
|
|
167
|
+
for (const [sessionId, state] of this.states) {
|
|
168
|
+
if (state.runtime.streaming.assistant ||
|
|
169
|
+
state.runtime.streaming.thinking) {
|
|
170
|
+
this.patch(sessionId, {
|
|
171
|
+
runtime: {
|
|
172
|
+
streaming: { assistant: false, thinking: false },
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
133
178
|
/**
|
|
134
|
-
* Backend
|
|
135
|
-
*
|
|
179
|
+
* Backend acknowledgement timer for cancel: if the same agent prompt is
|
|
180
|
+
* still pending after `timeoutMs`, mark the request unconfirmed.
|
|
136
181
|
* A second arm on the same session replaces the existing timer.
|
|
137
182
|
*/
|
|
138
183
|
armCancelSafety(sessionId, timeoutMs) {
|
|
@@ -143,12 +188,32 @@ export class SessionStateManager {
|
|
|
143
188
|
clearTimeout(existing);
|
|
144
189
|
const t = setTimeout(() => {
|
|
145
190
|
this.cancelTimers.delete(sessionId);
|
|
146
|
-
this.
|
|
191
|
+
const busy = this.getState(sessionId).runtime.busy;
|
|
192
|
+
if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
|
|
193
|
+
clog.warn("agent did not acknowledge", {
|
|
194
|
+
sessionId: sessionId.slice(0, 8),
|
|
195
|
+
promptId: busy.promptId,
|
|
196
|
+
});
|
|
197
|
+
this.patch(sessionId, {
|
|
198
|
+
runtime: {
|
|
199
|
+
busy: { ...busy, cancelStatus: "unconfirmed" },
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
147
203
|
}, timeoutMs);
|
|
148
204
|
if (typeof t === "object" && "unref" in t)
|
|
149
205
|
t.unref();
|
|
150
206
|
this.cancelTimers.set(sessionId, t);
|
|
151
207
|
}
|
|
208
|
+
/** Mark that a cancel notification was sent for the active agent prompt. */
|
|
209
|
+
markCancelRequested(sessionId) {
|
|
210
|
+
const busy = this.getState(sessionId).runtime.busy;
|
|
211
|
+
if (busy?.kind !== "agent")
|
|
212
|
+
return;
|
|
213
|
+
this.patch(sessionId, {
|
|
214
|
+
runtime: { busy: { ...busy, cancelStatus: "requested" } },
|
|
215
|
+
});
|
|
216
|
+
}
|
|
152
217
|
/** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
|
|
153
218
|
clearCancelSafety(sessionId) {
|
|
154
219
|
const t = this.cancelTimers.get(sessionId);
|