@lelouchhe/webagent 0.7.0 → 0.9.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 +4 -1
- package/dist/index.html +31 -6
- package/dist/js/app.QC7IRDTP.js +5 -0
- package/dist/js/chunk.3CLGCUHW.js +1 -0
- package/dist/js/chunk.AOTG3PL7.js +20 -0
- package/dist/js/{chunk.3CCHZCG7.js → chunk.S5LRNRJI.js} +42 -41
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/viewer.GP5VXAUY.js +1 -0
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.01ky3cex.css → styles.00nlhhf3.css} +261 -6
- package/dist/sw.js +19 -9
- package/lib/agent-key.js +6 -0
- package/lib/attachments.js +44 -7
- package/lib/auth-middleware.js +9 -2
- package/lib/bridge.js +164 -33
- package/lib/event-handler.js +91 -18
- package/lib/files/limits.js +15 -0
- package/lib/files/paths.js +155 -0
- package/lib/files/routes.js +232 -0
- package/lib/home-path.js +35 -0
- package/lib/http-status.js +1 -0
- package/lib/message-cleanup.js +5 -4
- package/lib/routes.js +316 -72
- package/lib/server.js +26 -23
- package/lib/session-manager.js +217 -44
- package/lib/session-state.js +99 -5
- package/lib/share/routes.js +12 -0
- package/lib/sse-manager.js +31 -7
- package/lib/store.js +124 -41
- package/lib/title-service.js +3 -3
- package/package.json +2 -1
- package/dist/js/app.KFQAFHA3.js +0 -4
- package/dist/js/chunk.UMQMOGWO.js +0 -1
- package/dist/js/viewer.T32M5AZZ.js +0 -1
package/lib/server.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
import { loadConfig } from "./config.js";
|
|
6
6
|
import { setLogLevel, log } from "./log.js";
|
|
7
7
|
import { AgentBridge } from "./bridge.js";
|
|
8
|
+
import { agentKeyFromCommand } from "./agent-key.js";
|
|
8
9
|
import { Store } from "./store.js";
|
|
9
10
|
import { SessionManager } from "./session-manager.js";
|
|
10
11
|
import { TitleService } from "./title-service.js";
|
|
@@ -53,7 +54,7 @@ const PKG_VERSION = (() => {
|
|
|
53
54
|
}
|
|
54
55
|
})();
|
|
55
56
|
// --- Core dependencies ---
|
|
56
|
-
const store = new Store(config.data_dir);
|
|
57
|
+
const store = new Store(config.data_dir, agentKeyFromCommand(preflight.agentCmd));
|
|
57
58
|
console.log(`[store] using ${config.data_dir}/`);
|
|
58
59
|
// Pin <data_dir>/sessions realpath at boot so all later anchor checks
|
|
59
60
|
// (file:// URI construction, permission interceptor) compare against the
|
|
@@ -104,31 +105,34 @@ sessions.state.onPatch((event) => {
|
|
|
104
105
|
sseManager.broadcast(event);
|
|
105
106
|
});
|
|
106
107
|
let bridge = null;
|
|
107
|
-
|
|
108
|
+
const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days, (pendingCount) => {
|
|
109
|
+
sseManager.broadcastGlobal({ type: "inbox_count_changed", pendingCount });
|
|
110
|
+
});
|
|
108
111
|
let sharePreviewCleanup = null;
|
|
109
112
|
// --- HTTP server ---
|
|
113
|
+
const requestHandler = createRequestHandler({
|
|
114
|
+
store,
|
|
115
|
+
sessions,
|
|
116
|
+
sseManager,
|
|
117
|
+
clientRegistry,
|
|
118
|
+
titleService,
|
|
119
|
+
getBridge: () => bridge,
|
|
120
|
+
publicDir: PUBLIC_DIR,
|
|
121
|
+
dataDir: config.data_dir,
|
|
122
|
+
limits: config.limits,
|
|
123
|
+
pushService,
|
|
124
|
+
serverVersion: PKG_VERSION,
|
|
125
|
+
debugLevel: config.debug.level,
|
|
126
|
+
authStore,
|
|
127
|
+
ticketStore,
|
|
128
|
+
attachmentSecret,
|
|
129
|
+
shareConfig: config.share,
|
|
130
|
+
});
|
|
110
131
|
const server = createServer((req, res) => {
|
|
111
|
-
void
|
|
112
|
-
store,
|
|
113
|
-
sessions,
|
|
114
|
-
sseManager,
|
|
115
|
-
clientRegistry,
|
|
116
|
-
titleService,
|
|
117
|
-
getBridge: () => bridge,
|
|
118
|
-
publicDir: PUBLIC_DIR,
|
|
119
|
-
dataDir: config.data_dir,
|
|
120
|
-
limits: config.limits,
|
|
121
|
-
pushService,
|
|
122
|
-
serverVersion: PKG_VERSION,
|
|
123
|
-
debugLevel: config.debug.level,
|
|
124
|
-
authStore,
|
|
125
|
-
ticketStore,
|
|
126
|
-
attachmentSecret,
|
|
127
|
-
shareConfig: config.share,
|
|
128
|
-
})(req, res);
|
|
132
|
+
void requestHandler(req, res);
|
|
129
133
|
});
|
|
130
134
|
async function initBridge(agentCmd) {
|
|
131
|
-
const b = new AgentBridge(agentCmd);
|
|
135
|
+
const b = new AgentBridge(agentCmd, store);
|
|
132
136
|
b.setAttachmentDispatcher(attachmentDispatcher);
|
|
133
137
|
const eventHandlerConfig = _buildBridgeEventHandlerConfig({
|
|
134
138
|
cancelTimeout: config.limits.cancel_timeout,
|
|
@@ -153,7 +157,7 @@ async function initBridge(agentCmd) {
|
|
|
153
157
|
async function shutdown() {
|
|
154
158
|
console.log("\n[server] shutting down...");
|
|
155
159
|
sseManager.stopHeartbeat();
|
|
156
|
-
messageCleanup
|
|
160
|
+
messageCleanup.stop();
|
|
157
161
|
sharePreviewCleanup?.stop();
|
|
158
162
|
sessions.killAllBashProcs();
|
|
159
163
|
await bridge?.shutdown();
|
|
@@ -185,7 +189,6 @@ server.listen(config.port, config.host, () => {
|
|
|
185
189
|
// will use. If the gate ran, auth.json exists and has ≥ 1 token.
|
|
186
190
|
await authStore.load();
|
|
187
191
|
console.log(`[server] listening on http://localhost:${config.port}`);
|
|
188
|
-
messageCleanup = startMessageCleanup(store, config.messages.unprocessed_ttl_days);
|
|
189
192
|
if (config.share.enabled) {
|
|
190
193
|
sharePreviewCleanup = startSharePreviewCleanup(store);
|
|
191
194
|
console.log(`[share] preview gc armed (24h interval)`);
|
package/lib/session-manager.js
CHANGED
|
@@ -3,12 +3,14 @@ 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";
|
|
9
|
+
import { abbreviateHomePath, expandHomePath } from "./home-path.js";
|
|
8
10
|
import { log } from "./log.js";
|
|
9
11
|
const slog = log.scope("session");
|
|
10
12
|
const IS_WIN = process.platform === "win32";
|
|
11
|
-
export function interruptBashProc(proc) {
|
|
13
|
+
export function interruptBashProc(proc, force = false) {
|
|
12
14
|
if (!proc)
|
|
13
15
|
return;
|
|
14
16
|
if (IS_WIN && typeof proc.pid === "number") {
|
|
@@ -18,19 +20,23 @@ export function interruptBashProc(proc) {
|
|
|
18
20
|
}
|
|
19
21
|
if (typeof proc.pid === "number") {
|
|
20
22
|
try {
|
|
21
|
-
process.kill(-proc.pid, "SIGINT");
|
|
23
|
+
process.kill(-proc.pid, force ? "SIGKILL" : "SIGINT");
|
|
22
24
|
return;
|
|
23
25
|
}
|
|
24
26
|
catch {
|
|
25
27
|
// Fall through to direct child kill when the process is not a group leader.
|
|
26
28
|
}
|
|
27
29
|
}
|
|
28
|
-
proc.kill("SIGINT");
|
|
30
|
+
proc.kill(force ? "SIGKILL" : "SIGINT");
|
|
29
31
|
}
|
|
30
|
-
/** Known config option IDs that we persist per-session. */
|
|
31
|
-
const _PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
|
|
32
32
|
/** Minimum age (seconds) before an empty session is eligible for cleanup. */
|
|
33
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
|
+
}
|
|
34
40
|
/**
|
|
35
41
|
* Centralizes all session-related state that was previously scattered
|
|
36
42
|
* across module-level variables in server.ts.
|
|
@@ -42,13 +48,20 @@ export class SessionManager {
|
|
|
42
48
|
assistantBuffers = new Map();
|
|
43
49
|
thinkingBuffers = new Map();
|
|
44
50
|
activePrompts = new Set();
|
|
51
|
+
pendingPromptSubmissions = new Map();
|
|
52
|
+
cancelledPromptSubmissions = new Set();
|
|
45
53
|
runningBashProcs = new Map();
|
|
54
|
+
interruptedBashProcs = new WeakSet();
|
|
46
55
|
/** Pending permission requests keyed by requestId. */
|
|
47
56
|
pendingPermissions = new Map();
|
|
48
57
|
/** Per-session runtime state (busy/streaming/permissions snapshots + patches). */
|
|
49
58
|
state = new SessionStateManager();
|
|
50
59
|
/** Deduplicates concurrent resume calls for the same session. */
|
|
51
60
|
pendingResumes = new Map();
|
|
61
|
+
nextPromptNumber = 0;
|
|
62
|
+
nextPromptSubmissionNumber = 0;
|
|
63
|
+
/** Deduplicates concurrent attempts to materialize one inbox message. */
|
|
64
|
+
pendingMessageConsumes = new Map();
|
|
52
65
|
/**
|
|
53
66
|
* Per-session attachment label map (CLAUDE.md "Attachment label
|
|
54
67
|
* egress rewrite"). Built lazily from the `attachments` table on
|
|
@@ -76,15 +89,15 @@ export class SessionManager {
|
|
|
76
89
|
}
|
|
77
90
|
}
|
|
78
91
|
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
79
|
-
async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
|
|
80
|
-
const sessionCwd = cwd ?? this.defaultCwd;
|
|
92
|
+
async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
|
|
93
|
+
const sessionCwd = expandHomePath(cwd ?? this.defaultCwd);
|
|
81
94
|
try {
|
|
82
95
|
const info = await stat(sessionCwd);
|
|
83
96
|
if (!info.isDirectory())
|
|
84
97
|
throw new Error("not a directory");
|
|
85
98
|
}
|
|
86
99
|
catch {
|
|
87
|
-
throw new
|
|
100
|
+
throw new InvalidSessionDirectoryError(sessionCwd);
|
|
88
101
|
}
|
|
89
102
|
// Clean up empty sessions (no events) older than the threshold
|
|
90
103
|
const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
|
|
@@ -97,33 +110,124 @@ export class SessionManager {
|
|
|
97
110
|
const sourceSession = inheritFromSessionId
|
|
98
111
|
? this.store.getSession(inheritFromSessionId)
|
|
99
112
|
: null;
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
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);
|
|
103
130
|
// Inherit config options from source session
|
|
104
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"));
|
|
105
136
|
const inherited = [
|
|
106
137
|
{ configId: "model", value: sourceSession.model },
|
|
107
|
-
{
|
|
138
|
+
{
|
|
139
|
+
configId: thinkingOption?.id ?? "reasoning_effort",
|
|
140
|
+
value: sourceSession.reasoning_effort,
|
|
141
|
+
},
|
|
108
142
|
];
|
|
109
143
|
for (const { configId, value } of inherited) {
|
|
110
144
|
if (!value)
|
|
111
145
|
continue;
|
|
112
146
|
try {
|
|
113
|
-
await bridge.setConfigOption(
|
|
114
|
-
|
|
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
|
+
}
|
|
115
155
|
}
|
|
116
156
|
catch {
|
|
117
157
|
// Option may no longer be available; ignore
|
|
118
158
|
}
|
|
119
159
|
}
|
|
120
160
|
}
|
|
121
|
-
const session = this.store.getSession(
|
|
161
|
+
const session = this.store.getSession(webSessionId);
|
|
122
162
|
return {
|
|
123
|
-
sessionId,
|
|
124
|
-
configOptions: session
|
|
163
|
+
sessionId: webSessionId,
|
|
164
|
+
configOptions: session
|
|
165
|
+
? this.applyStoredConfig(configOptions, session)
|
|
166
|
+
: [],
|
|
125
167
|
};
|
|
126
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
|
+
}
|
|
127
231
|
/** Resume a session — returns event to send to the requesting client. */
|
|
128
232
|
async resumeSession(bridge, sessionId) {
|
|
129
233
|
const session = this.store.getSession(sessionId);
|
|
@@ -136,6 +240,7 @@ export class SessionManager {
|
|
|
136
240
|
type: "session_created",
|
|
137
241
|
sessionId,
|
|
138
242
|
cwd: session.cwd,
|
|
243
|
+
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
139
244
|
title: session.title,
|
|
140
245
|
configOptions,
|
|
141
246
|
};
|
|
@@ -159,6 +264,7 @@ export class SessionManager {
|
|
|
159
264
|
type: "session_created",
|
|
160
265
|
sessionId,
|
|
161
266
|
cwd: session.cwd,
|
|
267
|
+
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
162
268
|
title: session.title,
|
|
163
269
|
configOptions,
|
|
164
270
|
};
|
|
@@ -179,33 +285,42 @@ export class SessionManager {
|
|
|
179
285
|
* DB row — setConfigOption's currentValue for unrelated keys is the
|
|
180
286
|
* agent's in-memory default, not the user's preference.
|
|
181
287
|
*
|
|
182
|
-
* Key priority mode >
|
|
288
|
+
* Key priority mode > thinking aliases > model:
|
|
183
289
|
* - mode is a small stable enum, rewriting current value is idempotent.
|
|
290
|
+
* - ACP agents use both reasoning_effort and thought_level for thinking.
|
|
184
291
|
* - model has the highest schema-drift risk (agent upgrades drop values).
|
|
185
292
|
*/
|
|
186
293
|
async tryWarmCache(bridge, sessionId, session) {
|
|
187
294
|
if (this.cachedConfigOptions.length > 0)
|
|
188
295
|
return;
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
: session.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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)
|
|
197
305
|
return;
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
318
|
}
|
|
204
319
|
}
|
|
205
|
-
|
|
320
|
+
if (lastError) {
|
|
206
321
|
slog.warn("cache warming failed", {
|
|
207
322
|
sessionId: sessionId.slice(0, 8) + "…",
|
|
208
|
-
error:
|
|
323
|
+
error: lastError,
|
|
209
324
|
});
|
|
210
325
|
}
|
|
211
326
|
}
|
|
@@ -233,14 +348,17 @@ export class SessionManager {
|
|
|
233
348
|
/** Override currentValue in configOptions with stored session values. */
|
|
234
349
|
applyStoredConfig(configOptions, session) {
|
|
235
350
|
if (!configOptions.length)
|
|
236
|
-
return
|
|
351
|
+
return configOptions;
|
|
237
352
|
const stored = {
|
|
238
353
|
model: session.model,
|
|
239
354
|
mode: session.mode,
|
|
240
355
|
reasoning_effort: session.reasoning_effort,
|
|
356
|
+
thought_level: session.reasoning_effort,
|
|
241
357
|
};
|
|
242
358
|
return configOptions.map((opt) => {
|
|
243
|
-
const override =
|
|
359
|
+
const override = opt.category === "thought_level"
|
|
360
|
+
? session.reasoning_effort
|
|
361
|
+
: stored[opt.id];
|
|
244
362
|
if (override && "options" in opt)
|
|
245
363
|
return { ...opt, currentValue: override };
|
|
246
364
|
return opt;
|
|
@@ -311,6 +429,11 @@ export class SessionManager {
|
|
|
311
429
|
this.assistantBuffers.delete(sessionId);
|
|
312
430
|
this.thinkingBuffers.delete(sessionId);
|
|
313
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
|
+
}
|
|
314
437
|
this.runningBashProcs.delete(sessionId);
|
|
315
438
|
this.attachmentLabelCache.delete(sessionId);
|
|
316
439
|
this.agentCommandSnapshots.delete(sessionId);
|
|
@@ -338,17 +461,17 @@ export class SessionManager {
|
|
|
338
461
|
/** Flush only the assistant message buffer to store. */
|
|
339
462
|
flushAssistantBuffer(sessionId) {
|
|
340
463
|
const assistant = this.assistantBuffers.get(sessionId);
|
|
341
|
-
|
|
464
|
+
this.assistantBuffers.delete(sessionId);
|
|
465
|
+
if (assistant && this.store.getSession(sessionId)) {
|
|
342
466
|
this.store.saveEvent(sessionId, "assistant_message", { text: assistant }, { from_ref: "agent" });
|
|
343
|
-
this.assistantBuffers.delete(sessionId);
|
|
344
467
|
}
|
|
345
468
|
}
|
|
346
469
|
/** Flush only the thinking buffer to store. */
|
|
347
470
|
flushThinkingBuffer(sessionId) {
|
|
348
471
|
const thinking = this.thinkingBuffers.get(sessionId);
|
|
349
|
-
|
|
472
|
+
this.thinkingBuffers.delete(sessionId);
|
|
473
|
+
if (thinking && this.store.getSession(sessionId)) {
|
|
350
474
|
this.store.saveEvent(sessionId, "thinking", { text: thinking }, { from_ref: "agent" });
|
|
351
|
-
this.thinkingBuffers.delete(sessionId);
|
|
352
475
|
}
|
|
353
476
|
}
|
|
354
477
|
/** Append to assistant message buffer. */
|
|
@@ -366,12 +489,52 @@ export class SessionManager {
|
|
|
366
489
|
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
367
490
|
}
|
|
368
491
|
getBusyKind(sessionId) {
|
|
369
|
-
if (this.
|
|
370
|
-
return "
|
|
492
|
+
if (this.pendingPromptSubmissions.has(sessionId))
|
|
493
|
+
return "agent";
|
|
371
494
|
if (this.activePrompts.has(sessionId))
|
|
372
495
|
return "agent";
|
|
496
|
+
if (this.runningBashProcs.has(sessionId))
|
|
497
|
+
return "bash";
|
|
373
498
|
return null;
|
|
374
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
|
+
}
|
|
375
538
|
/**
|
|
376
539
|
* Recompute busy from active prompts/bash procs and patch the state manager.
|
|
377
540
|
* Call this immediately after mutating activePrompts / runningBashProcs so
|
|
@@ -390,8 +553,14 @@ export class SessionManager {
|
|
|
390
553
|
this.state.clearCancelSafety(sessionId);
|
|
391
554
|
return;
|
|
392
555
|
}
|
|
393
|
-
const nextPromptId = kind === "agent"
|
|
394
|
-
|
|
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)
|
|
395
564
|
return;
|
|
396
565
|
this.state.patch(sessionId, {
|
|
397
566
|
runtime: {
|
|
@@ -399,6 +568,7 @@ export class SessionManager {
|
|
|
399
568
|
kind: kind,
|
|
400
569
|
since: current?.kind === kind ? current.since : new Date().toISOString(),
|
|
401
570
|
promptId: nextPromptId,
|
|
571
|
+
cancelStatus: null,
|
|
402
572
|
},
|
|
403
573
|
},
|
|
404
574
|
});
|
|
@@ -417,13 +587,16 @@ export class SessionManager {
|
|
|
417
587
|
});
|
|
418
588
|
this.activePrompts.add(sessionId);
|
|
419
589
|
this.syncBusy(sessionId);
|
|
590
|
+
const promptId = this.state.getState(sessionId).runtime.busy?.promptId ?? undefined;
|
|
420
591
|
bridge
|
|
421
|
-
.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.")
|
|
592
|
+
.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.", undefined, promptId)
|
|
422
593
|
.catch((err) => {
|
|
423
594
|
slog.error("auto-retry failed", {
|
|
424
595
|
sessionId: sessionId.slice(0, 8) + "…",
|
|
425
596
|
error: err,
|
|
426
597
|
});
|
|
598
|
+
if (!this.isCurrentPrompt(sessionId, promptId))
|
|
599
|
+
return;
|
|
427
600
|
this.activePrompts.delete(sessionId);
|
|
428
601
|
this.syncBusy(sessionId);
|
|
429
602
|
});
|