@lelouchhe/webagent 0.7.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 +14 -5
- package/dist/js/app.XBFXH37R.js +2 -0
- package/dist/js/chunk.CT5WBNGZ.js +173 -0
- package/dist/js/viewer.CVWXSKJM.js +1 -0
- package/dist/login.html +1 -1
- package/dist/share-viewer.html +3 -3
- package/dist/{styles.01ky3cex.css → styles.00etlpgs.css} +94 -4
- package/dist/sw.js +19 -9
- package/lib/bridge.js +49 -11
- package/lib/event-handler.js +60 -15
- package/lib/message-cleanup.js +5 -4
- package/lib/routes.js +199 -44
- package/lib/server.js +4 -3
- package/lib/session-manager.js +170 -22
- package/lib/session-state.js +70 -5
- package/lib/sse-manager.js +31 -7
- package/lib/store.js +25 -23
- package/package.json +1 -1
- package/dist/js/app.KFQAFHA3.js +0 -4
- package/dist/js/chunk.3CCHZCG7.js +0 -173
- package/dist/js/viewer.T32M5AZZ.js +0 -1
package/lib/session-manager.js
CHANGED
|
@@ -3,12 +3,13 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { rm } from "node:fs/promises";
|
|
4
4
|
import { stat } from "node:fs/promises";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { MessageNotFoundError } from "./store.js";
|
|
6
7
|
import { SessionStateManager } from "./session-state.js";
|
|
7
8
|
import { buildLabelMap } from "./attachment-labels.js";
|
|
8
9
|
import { log } from "./log.js";
|
|
9
10
|
const slog = log.scope("session");
|
|
10
11
|
const IS_WIN = process.platform === "win32";
|
|
11
|
-
export function interruptBashProc(proc) {
|
|
12
|
+
export function interruptBashProc(proc, force = false) {
|
|
12
13
|
if (!proc)
|
|
13
14
|
return;
|
|
14
15
|
if (IS_WIN && typeof proc.pid === "number") {
|
|
@@ -18,19 +19,23 @@ export function interruptBashProc(proc) {
|
|
|
18
19
|
}
|
|
19
20
|
if (typeof proc.pid === "number") {
|
|
20
21
|
try {
|
|
21
|
-
process.kill(-proc.pid, "SIGINT");
|
|
22
|
+
process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
|
|
22
23
|
return;
|
|
23
24
|
}
|
|
24
25
|
catch {
|
|
25
26
|
// Fall through to direct child kill when the process is not a group leader.
|
|
26
27
|
}
|
|
27
28
|
}
|
|
28
|
-
proc.kill("SIGINT");
|
|
29
|
+
proc.kill(force ? "SIGKILL" : "SIGINT");
|
|
29
30
|
}
|
|
30
|
-
/** Known config option IDs that we persist per-session. */
|
|
31
|
-
const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
|
|
32
31
|
/** Minimum age (seconds) before an empty session is eligible for cleanup. */
|
|
33
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
|
+
}
|
|
34
39
|
/**
|
|
35
40
|
* Centralizes all session-related state that was previously scattered
|
|
36
41
|
* across module-level variables in server.ts.
|
|
@@ -42,13 +47,20 @@ export class SessionManager {
|
|
|
42
47
|
assistantBuffers = new Map();
|
|
43
48
|
thinkingBuffers = new Map();
|
|
44
49
|
activePrompts = new Set();
|
|
50
|
+
pendingPromptSubmissions = new Map();
|
|
51
|
+
cancelledPromptSubmissions = new Set();
|
|
45
52
|
runningBashProcs = new Map();
|
|
53
|
+
interruptedBashProcs = new WeakSet();
|
|
46
54
|
/** Pending permission requests keyed by requestId. */
|
|
47
55
|
pendingPermissions = new Map();
|
|
48
56
|
/** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
|
|
49
57
|
state = new SessionStateManager();
|
|
50
58
|
/** Deduplicates concurrent resume calls for the same session. */
|
|
51
59
|
pendingResumes = new Map();
|
|
60
|
+
nextPromptNumber = 0;
|
|
61
|
+
nextPromptSubmissionNumber = 0;
|
|
62
|
+
/** Deduplicates concurrent attempts to materialize one inbox message. */
|
|
63
|
+
pendingMessageConsumes = new Map();
|
|
52
64
|
/**
|
|
53
65
|
* Per-session attachment label map (CLAUDE.md "Attachment label
|
|
54
66
|
* egress rewrite"). Built lazily from the `attachments` table on
|
|
@@ -76,7 +88,7 @@ export class SessionManager {
|
|
|
76
88
|
}
|
|
77
89
|
}
|
|
78
90
|
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
79
|
-
async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
|
|
91
|
+
async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
|
|
80
92
|
const sessionCwd = cwd ?? this.defaultCwd;
|
|
81
93
|
try {
|
|
82
94
|
const info = await stat(sessionCwd);
|
|
@@ -84,7 +96,7 @@ export class SessionManager {
|
|
|
84
96
|
throw new Error("not a directory");
|
|
85
97
|
}
|
|
86
98
|
catch {
|
|
87
|
-
throw new
|
|
99
|
+
throw new InvalidSessionDirectoryError(sessionCwd);
|
|
88
100
|
}
|
|
89
101
|
// Clean up empty sessions (no events) older than the threshold
|
|
90
102
|
const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
|
|
@@ -97,9 +109,20 @@ export class SessionManager {
|
|
|
97
109
|
const sourceSession = inheritFromSessionId
|
|
98
110
|
? this.store.getSession(inheritFromSessionId)
|
|
99
111
|
: null;
|
|
100
|
-
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
|
+
}
|
|
101
124
|
this.liveSessions.add(sessionId);
|
|
102
|
-
this.
|
|
125
|
+
this.recordConfigOptions(sessionId, createdConfigOptions);
|
|
103
126
|
// Inherit config options from source session
|
|
104
127
|
if (sourceSession) {
|
|
105
128
|
const inherited = [
|
|
@@ -110,8 +133,14 @@ export class SessionManager {
|
|
|
110
133
|
if (!value)
|
|
111
134
|
continue;
|
|
112
135
|
try {
|
|
113
|
-
await bridge.setConfigOption(sessionId, configId, value);
|
|
114
|
-
|
|
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
|
+
}
|
|
115
144
|
}
|
|
116
145
|
catch {
|
|
117
146
|
// Option may no longer be available; ignore
|
|
@@ -121,9 +150,73 @@ export class SessionManager {
|
|
|
121
150
|
const session = this.store.getSession(sessionId);
|
|
122
151
|
return {
|
|
123
152
|
sessionId,
|
|
124
|
-
configOptions: session
|
|
153
|
+
configOptions: session
|
|
154
|
+
? this.applyStoredConfig(configOptions, session)
|
|
155
|
+
: [],
|
|
125
156
|
};
|
|
126
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
|
+
}
|
|
127
220
|
/** Resume a session — returns event to send to the requesting client. */
|
|
128
221
|
async resumeSession(bridge, sessionId) {
|
|
129
222
|
const session = this.store.getSession(sessionId);
|
|
@@ -233,7 +326,7 @@ export class SessionManager {
|
|
|
233
326
|
/** Override currentValue in configOptions with stored session values. */
|
|
234
327
|
applyStoredConfig(configOptions, session) {
|
|
235
328
|
if (!configOptions.length)
|
|
236
|
-
return
|
|
329
|
+
return configOptions;
|
|
237
330
|
const stored = {
|
|
238
331
|
model: session.model,
|
|
239
332
|
mode: session.mode,
|
|
@@ -311,6 +404,11 @@ export class SessionManager {
|
|
|
311
404
|
this.assistantBuffers.delete(sessionId);
|
|
312
405
|
this.thinkingBuffers.delete(sessionId);
|
|
313
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
|
+
}
|
|
314
412
|
this.runningBashProcs.delete(sessionId);
|
|
315
413
|
this.attachmentLabelCache.delete(sessionId);
|
|
316
414
|
this.agentCommandSnapshots.delete(sessionId);
|
|
@@ -338,17 +436,17 @@ export class SessionManager {
|
|
|
338
436
|
/** Flush only the assistant message buffer to store. */
|
|
339
437
|
flushAssistantBuffer(sessionId) {
|
|
340
438
|
const assistant = this.assistantBuffers.get(sessionId);
|
|
341
|
-
|
|
439
|
+
this.assistantBuffers.delete(sessionId);
|
|
440
|
+
if (assistant && this.store.getSession(sessionId)) {
|
|
342
441
|
this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
|
|
343
|
-
this.assistantBuffers.delete(sessionId);
|
|
344
442
|
}
|
|
345
443
|
}
|
|
346
444
|
/** Flush only the thinking buffer to store. */
|
|
347
445
|
flushThinkingBuffer(sessionId) {
|
|
348
446
|
const thinking = this.thinkingBuffers.get(sessionId);
|
|
349
|
-
|
|
447
|
+
this.thinkingBuffers.delete(sessionId);
|
|
448
|
+
if (thinking && this.store.getSession(sessionId)) {
|
|
350
449
|
this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
|
|
351
|
-
this.thinkingBuffers.delete(sessionId);
|
|
352
450
|
}
|
|
353
451
|
}
|
|
354
452
|
/** Append to assistant message buffer. */
|
|
@@ -366,12 +464,52 @@ export class SessionManager {
|
|
|
366
464
|
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
367
465
|
}
|
|
368
466
|
getBusyKind(sessionId) {
|
|
369
|
-
if (this.
|
|
370
|
-
return "
|
|
467
|
+
if (this.pendingPromptSubmissions.has(sessionId))
|
|
468
|
+
return "agent";
|
|
371
469
|
if (this.activePrompts.has(sessionId))
|
|
372
470
|
return "agent";
|
|
471
|
+
if (this.runningBashProcs.has(sessionId))
|
|
472
|
+
return "bash";
|
|
373
473
|
return null;
|
|
374
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
|
+
}
|
|
375
513
|
/**
|
|
376
514
|
* Recompute busy from active prompts/bash procs and patch the state manager.
|
|
377
515
|
* Call this immediately after mutating activePrompts / runningBashProcs so
|
|
@@ -390,8 +528,14 @@ export class SessionManager {
|
|
|
390
528
|
this.state.clearCancelSafety(sessionId);
|
|
391
529
|
return;
|
|
392
530
|
}
|
|
393
|
-
const nextPromptId = kind === "agent"
|
|
394
|
-
|
|
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)
|
|
395
539
|
return;
|
|
396
540
|
this.state.patch(sessionId, {
|
|
397
541
|
runtime: {
|
|
@@ -399,6 +543,7 @@ export class SessionManager {
|
|
|
399
543
|
kind: kind,
|
|
400
544
|
since: current?.kind === kind ? current.since : new Date().toISOString(),
|
|
401
545
|
promptId: nextPromptId,
|
|
546
|
+
cancelStatus: null,
|
|
402
547
|
},
|
|
403
548
|
},
|
|
404
549
|
});
|
|
@@ -417,13 +562,16 @@ export class SessionManager {
|
|
|
417
562
|
});
|
|
418
563
|
this.activePrompts.add(sessionId);
|
|
419
564
|
this.syncBusy(sessionId);
|
|
565
|
+
const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
|
|
420
566
|
bridge
|
|
421
|
-
.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)
|
|
422
568
|
.catch((err) => {
|
|
423
569
|
slog.error("auto-retry failed", {
|
|
424
570
|
sessionId: sessionId.slice(0, 8) + "…",
|
|
425
571
|
error: err,
|
|
426
572
|
});
|
|
573
|
+
if (!this.isCurrentPrompt(sessionId, promptId))
|
|
574
|
+
return;
|
|
427
575
|
this.activePrompts.delete(sessionId);
|
|
428
576
|
this.syncBusy(sessionId);
|
|
429
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);
|
package/lib/sse-manager.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
2
|
import { reSignAttachmentUrlsInJson } from "./auth.js";
|
|
3
3
|
import { enrichEventForDisplay } from "./attachment-labels.js";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
const slog = log.scope("sse");
|
|
4
6
|
/**
|
|
5
7
|
* SSE heartbeat frame — a NAMED event so the frontend can hook
|
|
6
8
|
* `es.addEventListener("heartbeat", ...)` and refresh its per-session
|
|
@@ -69,7 +71,7 @@ export class SseManager {
|
|
|
69
71
|
catch {
|
|
70
72
|
/* already torn down */
|
|
71
73
|
}
|
|
72
|
-
this.remove(client.id);
|
|
74
|
+
this.remove(client.id, "token-revoked");
|
|
73
75
|
continue;
|
|
74
76
|
}
|
|
75
77
|
client.res.write(SSE_HEARTBEAT_FRAME);
|
|
@@ -91,8 +93,13 @@ export class SseManager {
|
|
|
91
93
|
/** Register a new SSE client connection. */
|
|
92
94
|
add(client) {
|
|
93
95
|
this.clients.set(client.id, client);
|
|
96
|
+
slog.info("connected", {
|
|
97
|
+
clientId: client.id,
|
|
98
|
+
sessionId: client.sessionId ?? "*",
|
|
99
|
+
clients: this.clients.size,
|
|
100
|
+
});
|
|
94
101
|
client.res.on("close", () => {
|
|
95
|
-
this.remove(client.id);
|
|
102
|
+
this.remove(client.id, "closed");
|
|
96
103
|
});
|
|
97
104
|
}
|
|
98
105
|
/** Write a single heartbeat frame to the given client. Used right after
|
|
@@ -109,9 +116,16 @@ export class SseManager {
|
|
|
109
116
|
// socket already dead; res.on("close") will clean up
|
|
110
117
|
}
|
|
111
118
|
}
|
|
112
|
-
/** Remove a client by ID.
|
|
113
|
-
|
|
114
|
-
|
|
119
|
+
/** Remove a client by ID. `reason` is recorded so an operator can tell an
|
|
120
|
+
* ordinary disconnect apart from a write failure or a revoked token. */
|
|
121
|
+
remove(id, reason = "closed") {
|
|
122
|
+
if (this.clients.delete(id)) {
|
|
123
|
+
slog.info("disconnected", {
|
|
124
|
+
clientId: id,
|
|
125
|
+
reason,
|
|
126
|
+
clients: this.clients.size,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
115
129
|
this.onRemoveCallback?.(id);
|
|
116
130
|
}
|
|
117
131
|
/** Send an SSE event to a single client. */
|
|
@@ -138,10 +152,11 @@ export class SseManager {
|
|
|
138
152
|
try {
|
|
139
153
|
client.res.write(msg);
|
|
140
154
|
}
|
|
141
|
-
catch {
|
|
155
|
+
catch (err) {
|
|
142
156
|
// Socket torn down between writableEnded check and write.
|
|
143
157
|
// Drop the client so we stop writing to it on every broadcast.
|
|
144
|
-
|
|
158
|
+
slog.warn("write failed", { clientId: client.id, err: String(err) });
|
|
159
|
+
this.remove(client.id, "write-failed");
|
|
145
160
|
}
|
|
146
161
|
}
|
|
147
162
|
/**
|
|
@@ -159,6 +174,15 @@ export class SseManager {
|
|
|
159
174
|
this.sendEvent(client, event);
|
|
160
175
|
}
|
|
161
176
|
}
|
|
177
|
+
/** Broadcast global application state regardless of a client's session filter. */
|
|
178
|
+
broadcastGlobal(event) {
|
|
179
|
+
const snapshot = [...this.clients.values()];
|
|
180
|
+
for (const client of snapshot) {
|
|
181
|
+
if (client.res.writableEnded)
|
|
182
|
+
continue;
|
|
183
|
+
this.sendEvent(client, event);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
162
186
|
/** Get count of connected clients. */
|
|
163
187
|
get size() {
|
|
164
188
|
return this.clients.size;
|
package/lib/store.js
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import Database from "better-sqlite3";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
export class MessageNotFoundError extends Error {
|
|
5
|
+
constructor(messageId) {
|
|
6
|
+
super(`Message not found: ${messageId}`);
|
|
7
|
+
this.name = "MessageNotFoundError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
4
10
|
export class Store {
|
|
5
11
|
db;
|
|
6
12
|
constructor(dataDir) {
|
|
@@ -66,9 +72,9 @@ export class Store {
|
|
|
66
72
|
}
|
|
67
73
|
// messages — pending unbound notifications. POST /api/v1/messages with
|
|
68
74
|
// `to = "user"` lands here; consumeMessageTx transactionally moves the
|
|
69
|
-
// content into
|
|
70
|
-
// messages (to = session id) skip this table entirely and go
|
|
71
|
-
// to `events`.
|
|
75
|
+
// content into an existing ACP-backed session's events and deletes the
|
|
76
|
+
// row. Bound messages (to = session id) skip this table entirely and go
|
|
77
|
+
// straight to `events`.
|
|
72
78
|
this.db.exec(`
|
|
73
79
|
CREATE TABLE IF NOT EXISTS messages (
|
|
74
80
|
id TEXT PRIMARY KEY,
|
|
@@ -506,6 +512,12 @@ export class Store {
|
|
|
506
512
|
.prepare("SELECT * FROM messages ORDER BY created_at DESC")
|
|
507
513
|
.all();
|
|
508
514
|
}
|
|
515
|
+
countUnprocessed() {
|
|
516
|
+
const row = this.db
|
|
517
|
+
.prepare("SELECT COUNT(*) AS count FROM messages")
|
|
518
|
+
.get();
|
|
519
|
+
return row.count;
|
|
520
|
+
}
|
|
509
521
|
deleteMessage(id) {
|
|
510
522
|
const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
|
511
523
|
return info.changes;
|
|
@@ -529,29 +541,21 @@ export class Store {
|
|
|
529
541
|
.get(to_ref, dedup_key);
|
|
530
542
|
}
|
|
531
543
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* by looking up the historic `message` event; callers can treat this as
|
|
536
|
-
* idempotent.
|
|
544
|
+
* Atomically move a pending message into an existing session. Session
|
|
545
|
+
* lifecycle belongs to SessionManager because ACP creation is asynchronous
|
|
546
|
+
* and cannot participate in this SQLite transaction.
|
|
537
547
|
*/
|
|
538
|
-
consumeMessageTx(messageId,
|
|
539
|
-
|
|
540
|
-
// opening one for an already-resolved message.
|
|
541
|
-
const existing = this.findMessageEventSession(messageId);
|
|
548
|
+
consumeMessageTx(messageId, sessionId) {
|
|
549
|
+
const existing = this.findConsumedMessageSession(messageId);
|
|
542
550
|
if (existing) {
|
|
543
551
|
return { sessionId: existing, alreadyConsumed: true };
|
|
544
552
|
}
|
|
545
553
|
const row = this.getMessage(messageId);
|
|
546
554
|
if (!row) {
|
|
547
|
-
throw new
|
|
555
|
+
throw new MessageNotFoundError(messageId);
|
|
548
556
|
}
|
|
549
557
|
const tx = this.db.transaction(() => {
|
|
550
|
-
this.
|
|
551
|
-
.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
|
|
552
|
-
.run(opts.sessionId, opts.cwd ?? row.cwd ?? "", "message");
|
|
553
|
-
// Append message event via saveEvent so seq logic applies.
|
|
554
|
-
this.saveEvent(opts.sessionId, "message", {
|
|
558
|
+
this.saveEvent(sessionId, "message", {
|
|
555
559
|
message_id: row.id,
|
|
556
560
|
from_ref: row.from_ref,
|
|
557
561
|
from_label: row.from_label,
|
|
@@ -563,15 +567,13 @@ export class Store {
|
|
|
563
567
|
.prepare("DELETE FROM messages WHERE id = ?")
|
|
564
568
|
.run(messageId);
|
|
565
569
|
if (del.changes === 0) {
|
|
566
|
-
|
|
567
|
-
// roll back via throw.
|
|
568
|
-
throw new Error(`consumeMessageTx: row vanished mid-tx (id=${messageId})`);
|
|
570
|
+
throw new MessageNotFoundError(messageId);
|
|
569
571
|
}
|
|
570
572
|
});
|
|
571
573
|
tx();
|
|
572
|
-
return { sessionId
|
|
574
|
+
return { sessionId, alreadyConsumed: false };
|
|
573
575
|
}
|
|
574
|
-
|
|
576
|
+
findConsumedMessageSession(messageId) {
|
|
575
577
|
const row = this.db
|
|
576
578
|
.prepare(`SELECT session_id FROM events
|
|
577
579
|
WHERE type = 'message'
|