@lelouchhe/webagent 0.8.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 +20 -4
- 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.CT5WBNGZ.js → chunk.S5LRNRJI.js} +50 -49
- package/dist/js/{login.2WA6DTGM.js → login.WMURU4NI.js} +1 -1
- package/dist/js/{viewer.CVWXSKJM.js → viewer.GP5VXAUY.js} +1 -1
- package/dist/login.html +2 -2
- package/dist/share-viewer.html +4 -4
- package/dist/{styles.00etlpgs.css → styles.00nlhhf3.css} +167 -2
- package/lib/agent-key.js +6 -0
- package/lib/attachments.js +44 -7
- package/lib/auth-middleware.js +9 -2
- package/lib/bridge.js +115 -22
- package/lib/event-handler.js +31 -3
- 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/routes.js +117 -28
- package/lib/server.js +22 -20
- package/lib/session-manager.js +54 -29
- package/lib/session-state.js +29 -0
- package/lib/share/routes.js +12 -0
- package/lib/store.js +99 -18
- package/lib/title-service.js +3 -3
- package/package.json +2 -1
- package/dist/js/app.XBFXH37R.js +0 -2
- package/dist/js/chunk.UMQMOGWO.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
|
|
@@ -109,28 +110,29 @@ const messageCleanup = startMessageCleanup(store, config.messages.unprocessed_tt
|
|
|
109
110
|
});
|
|
110
111
|
let sharePreviewCleanup = null;
|
|
111
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
|
+
});
|
|
112
131
|
const server = createServer((req, res) => {
|
|
113
|
-
void
|
|
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
|
-
})(req, res);
|
|
132
|
+
void requestHandler(req, res);
|
|
131
133
|
});
|
|
132
134
|
async function initBridge(agentCmd) {
|
|
133
|
-
const b = new AgentBridge(agentCmd);
|
|
135
|
+
const b = new AgentBridge(agentCmd, store);
|
|
134
136
|
b.setAttachmentDispatcher(attachmentDispatcher);
|
|
135
137
|
const eventHandlerConfig = _buildBridgeEventHandlerConfig({
|
|
136
138
|
cancelTimeout: config.limits.cancel_timeout,
|
package/lib/session-manager.js
CHANGED
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { MessageNotFoundError } from "./store.js";
|
|
7
7
|
import { SessionStateManager } from "./session-state.js";
|
|
8
8
|
import { buildLabelMap } from "./attachment-labels.js";
|
|
9
|
+
import { abbreviateHomePath, expandHomePath } from "./home-path.js";
|
|
9
10
|
import { log } from "./log.js";
|
|
10
11
|
const slog = log.scope("session");
|
|
11
12
|
const IS_WIN = process.platform === "win32";
|
|
@@ -89,7 +90,7 @@ export class SessionManager {
|
|
|
89
90
|
}
|
|
90
91
|
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
91
92
|
async createSession(bridge, cwd, inheritFromSessionId, source = "auto", opts) {
|
|
92
|
-
const sessionCwd = cwd ?? this.defaultCwd;
|
|
93
|
+
const sessionCwd = expandHomePath(cwd ?? this.defaultCwd);
|
|
93
94
|
try {
|
|
94
95
|
const info = await stat(sessionCwd);
|
|
95
96
|
if (!info.isDirectory())
|
|
@@ -109,37 +110,47 @@ export class SessionManager {
|
|
|
109
110
|
const sourceSession = inheritFromSessionId
|
|
110
111
|
? this.store.getSession(inheritFromSessionId)
|
|
111
112
|
: null;
|
|
112
|
-
const
|
|
113
|
+
const webSessionId = randomUUID();
|
|
114
|
+
const { sessionId: agentSessionId, configOptions: createdConfigOptions } = await bridge.newSession(sessionCwd, { silent: opts?.silent });
|
|
113
115
|
let configOptions = createdConfigOptions;
|
|
114
116
|
try {
|
|
115
|
-
this.store.createSession(
|
|
117
|
+
this.store.createSession(webSessionId, sessionCwd, source, agentSessionId);
|
|
116
118
|
}
|
|
117
119
|
catch (err) {
|
|
118
120
|
slog.warn("ACP session created but local persistence failed", {
|
|
119
|
-
|
|
121
|
+
agentSessionId,
|
|
120
122
|
error: err,
|
|
121
123
|
});
|
|
124
|
+
bridge.discardUnboundSession?.(agentSessionId);
|
|
122
125
|
throw err;
|
|
123
126
|
}
|
|
124
|
-
this.liveSessions.add(
|
|
125
|
-
this.recordConfigOptions(
|
|
127
|
+
this.liveSessions.add(webSessionId);
|
|
128
|
+
this.recordConfigOptions(webSessionId, createdConfigOptions);
|
|
129
|
+
bridge.sessionMapped?.(agentSessionId);
|
|
126
130
|
// Inherit config options from source session
|
|
127
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"));
|
|
128
136
|
const inherited = [
|
|
129
137
|
{ configId: "model", value: sourceSession.model },
|
|
130
|
-
{
|
|
138
|
+
{
|
|
139
|
+
configId: thinkingOption?.id ?? "reasoning_effort",
|
|
140
|
+
value: sourceSession.reasoning_effort,
|
|
141
|
+
},
|
|
131
142
|
];
|
|
132
143
|
for (const { configId, value } of inherited) {
|
|
133
144
|
if (!value)
|
|
134
145
|
continue;
|
|
135
146
|
try {
|
|
136
|
-
const updatedConfigOptions = await bridge.setConfigOption(
|
|
147
|
+
const updatedConfigOptions = await bridge.setConfigOption(webSessionId, configId, value);
|
|
137
148
|
if (updatedConfigOptions.length > 0) {
|
|
138
149
|
configOptions = updatedConfigOptions;
|
|
139
|
-
this.recordConfigOptions(
|
|
150
|
+
this.recordConfigOptions(webSessionId, updatedConfigOptions);
|
|
140
151
|
}
|
|
141
152
|
else {
|
|
142
|
-
this.store.updateSessionConfig(
|
|
153
|
+
this.store.updateSessionConfig(webSessionId, configId, value);
|
|
143
154
|
}
|
|
144
155
|
}
|
|
145
156
|
catch {
|
|
@@ -147,9 +158,9 @@ export class SessionManager {
|
|
|
147
158
|
}
|
|
148
159
|
}
|
|
149
160
|
}
|
|
150
|
-
const session = this.store.getSession(
|
|
161
|
+
const session = this.store.getSession(webSessionId);
|
|
151
162
|
return {
|
|
152
|
-
sessionId,
|
|
163
|
+
sessionId: webSessionId,
|
|
153
164
|
configOptions: session
|
|
154
165
|
? this.applyStoredConfig(configOptions, session)
|
|
155
166
|
: [],
|
|
@@ -229,6 +240,7 @@ export class SessionManager {
|
|
|
229
240
|
type: "session_created",
|
|
230
241
|
sessionId,
|
|
231
242
|
cwd: session.cwd,
|
|
243
|
+
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
232
244
|
title: session.title,
|
|
233
245
|
configOptions,
|
|
234
246
|
};
|
|
@@ -252,6 +264,7 @@ export class SessionManager {
|
|
|
252
264
|
type: "session_created",
|
|
253
265
|
sessionId,
|
|
254
266
|
cwd: session.cwd,
|
|
267
|
+
cwdDisplay: abbreviateHomePath(session.cwd),
|
|
255
268
|
title: session.title,
|
|
256
269
|
configOptions,
|
|
257
270
|
};
|
|
@@ -272,33 +285,42 @@ export class SessionManager {
|
|
|
272
285
|
* DB row — setConfigOption's currentValue for unrelated keys is the
|
|
273
286
|
* agent's in-memory default, not the user's preference.
|
|
274
287
|
*
|
|
275
|
-
* Key priority mode >
|
|
288
|
+
* Key priority mode > thinking aliases > model:
|
|
276
289
|
* - mode is a small stable enum, rewriting current value is idempotent.
|
|
290
|
+
* - ACP agents use both reasoning_effort and thought_level for thinking.
|
|
277
291
|
* - model has the highest schema-drift risk (agent upgrades drop values).
|
|
278
292
|
*/
|
|
279
293
|
async tryWarmCache(bridge, sessionId, session) {
|
|
280
294
|
if (this.cachedConfigOptions.length > 0)
|
|
281
295
|
return;
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
: session.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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)
|
|
290
305
|
return;
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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;
|
|
296
318
|
}
|
|
297
319
|
}
|
|
298
|
-
|
|
320
|
+
if (lastError) {
|
|
299
321
|
slog.warn("cache warming failed", {
|
|
300
322
|
sessionId: sessionId.slice(0, 8) + "…",
|
|
301
|
-
error:
|
|
323
|
+
error: lastError,
|
|
302
324
|
});
|
|
303
325
|
}
|
|
304
326
|
}
|
|
@@ -331,9 +353,12 @@ export class SessionManager {
|
|
|
331
353
|
model: session.model,
|
|
332
354
|
mode: session.mode,
|
|
333
355
|
reasoning_effort: session.reasoning_effort,
|
|
356
|
+
thought_level: session.reasoning_effort,
|
|
334
357
|
};
|
|
335
358
|
return configOptions.map((opt) => {
|
|
336
|
-
const override =
|
|
359
|
+
const override = opt.category === "thought_level"
|
|
360
|
+
? session.reasoning_effort
|
|
361
|
+
: stored[opt.id];
|
|
337
362
|
if (override && "options" in opt)
|
|
338
363
|
return { ...opt, currentValue: override };
|
|
339
364
|
return opt;
|
package/lib/session-state.js
CHANGED
|
@@ -17,6 +17,7 @@ function defaultState() {
|
|
|
17
17
|
pendingPermissions: [],
|
|
18
18
|
streaming: { assistant: false, thinking: false },
|
|
19
19
|
plan: null,
|
|
20
|
+
contextUsage: null,
|
|
20
21
|
},
|
|
21
22
|
};
|
|
22
23
|
}
|
|
@@ -56,6 +57,14 @@ function plansEqual(a, b) {
|
|
|
56
57
|
return false;
|
|
57
58
|
return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
|
|
58
59
|
}
|
|
60
|
+
function contextUsageEqual(a, b) {
|
|
61
|
+
if (a === null || b === null)
|
|
62
|
+
return a === b;
|
|
63
|
+
return (a.used === b.used &&
|
|
64
|
+
a.size === b.size &&
|
|
65
|
+
(a.cost?.amount ?? null) === (b.cost?.amount ?? null) &&
|
|
66
|
+
(a.cost?.currency ?? null) === (b.cost?.currency ?? null));
|
|
67
|
+
}
|
|
59
68
|
/** True when the patch would change the current runtime state. */
|
|
60
69
|
function hasRuntimeChanges(current, patch) {
|
|
61
70
|
if (!patch)
|
|
@@ -68,6 +77,9 @@ function hasRuntimeChanges(current, patch) {
|
|
|
68
77
|
return true;
|
|
69
78
|
if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
|
|
70
79
|
return true;
|
|
80
|
+
if ("contextUsage" in patch &&
|
|
81
|
+
!contextUsageEqual(current.contextUsage, patch.contextUsage ?? null))
|
|
82
|
+
return true;
|
|
71
83
|
if ("streaming" in patch && patch.streaming) {
|
|
72
84
|
const s = patch.streaming;
|
|
73
85
|
if (s.assistant !== undefined &&
|
|
@@ -119,6 +131,15 @@ export class SessionStateManager {
|
|
|
119
131
|
state.runtime.plan =
|
|
120
132
|
patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
|
|
121
133
|
}
|
|
134
|
+
if ("contextUsage" in patch.runtime) {
|
|
135
|
+
const usage = patch.runtime.contextUsage;
|
|
136
|
+
state.runtime.contextUsage = usage
|
|
137
|
+
? {
|
|
138
|
+
...usage,
|
|
139
|
+
...(usage.cost ? { cost: { ...usage.cost } } : {}),
|
|
140
|
+
}
|
|
141
|
+
: null;
|
|
142
|
+
}
|
|
122
143
|
if ("streaming" in patch.runtime && patch.runtime.streaming) {
|
|
123
144
|
if (patch.runtime.streaming.assistant !== undefined) {
|
|
124
145
|
state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
|
|
@@ -162,6 +183,14 @@ export class SessionStateManager {
|
|
|
162
183
|
}
|
|
163
184
|
}
|
|
164
185
|
}
|
|
186
|
+
/** Clear context usage for every known session on bridge teardown. */
|
|
187
|
+
clearContextUsage() {
|
|
188
|
+
for (const [sessionId, state] of this.states) {
|
|
189
|
+
if (state.runtime.contextUsage !== null) {
|
|
190
|
+
this.patch(sessionId, { runtime: { contextUsage: null } });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
165
194
|
/** Clear active stream markers for every known session on bridge teardown. */
|
|
166
195
|
clearStreaming() {
|
|
167
196
|
for (const [sessionId, state] of this.states) {
|
package/lib/share/routes.js
CHANGED
|
@@ -444,6 +444,10 @@ async function handlePublish(req, res, deps, sessionId) {
|
|
|
444
444
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
445
445
|
return;
|
|
446
446
|
}
|
|
447
|
+
if (!deps.store.ownsSession(sessionId)) {
|
|
448
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
447
451
|
const row = deps.store.getShareByToken(body.token);
|
|
448
452
|
if (!row) {
|
|
449
453
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
@@ -849,6 +853,10 @@ async function handleRevoke(req, res, deps, sessionId) {
|
|
|
849
853
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
850
854
|
return;
|
|
851
855
|
}
|
|
856
|
+
if (!deps.store.ownsSession(sessionId)) {
|
|
857
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
852
860
|
const row = deps.store.getShareByToken(body.token);
|
|
853
861
|
if (!row) {
|
|
854
862
|
// Idempotent DELETE: row already gone (revoked or never existed).
|
|
@@ -910,6 +918,10 @@ async function handlePatchLabel(req, res, deps, sessionId) {
|
|
|
910
918
|
json(res, HTTP_STATUS.BAD_REQUEST, { error: "token required" });
|
|
911
919
|
return;
|
|
912
920
|
}
|
|
921
|
+
if (!deps.store.ownsSession(sessionId)) {
|
|
922
|
+
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
913
925
|
const row = deps.store.getShareByToken(body.token);
|
|
914
926
|
if (!row) {
|
|
915
927
|
json(res, HTTP_STATUS.NOT_FOUND, { error: "share not found" });
|
package/lib/store.js
CHANGED
|
@@ -9,7 +9,11 @@ export class MessageNotFoundError extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
export class Store {
|
|
11
11
|
db;
|
|
12
|
-
|
|
12
|
+
agentKey;
|
|
13
|
+
constructor(dataDir, agentKey) {
|
|
14
|
+
if (!agentKey)
|
|
15
|
+
throw new Error("agentKey is required");
|
|
16
|
+
this.agentKey = agentKey;
|
|
13
17
|
mkdirSync(dataDir, { recursive: true });
|
|
14
18
|
this.db = new Database(join(dataDir, "webagent.db"));
|
|
15
19
|
this.db.pragma("journal_mode = WAL");
|
|
@@ -27,6 +31,16 @@ export class Store {
|
|
|
27
31
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
28
32
|
last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
29
33
|
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS agent_sessions (
|
|
35
|
+
agent_key TEXT NOT NULL,
|
|
36
|
+
agent_session_id TEXT NOT NULL,
|
|
37
|
+
web_session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
|
|
38
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
39
|
+
PRIMARY KEY (agent_key, agent_session_id)
|
|
40
|
+
);
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_sessions_web
|
|
42
|
+
ON agent_sessions(web_session_id)
|
|
43
|
+
WHERE web_session_id IS NOT NULL;
|
|
30
44
|
CREATE TABLE IF NOT EXISTS events (
|
|
31
45
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
46
|
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
@@ -70,6 +84,21 @@ export class Store {
|
|
|
70
84
|
if (!colNames.has("deleted_at")) {
|
|
71
85
|
this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
|
|
72
86
|
}
|
|
87
|
+
// One-time dual-ID migration. Existing WebAgent session IDs were also the
|
|
88
|
+
// ACP agent's IDs, so preserve the public IDs and record that identity
|
|
89
|
+
// mapping under the agent command active during the upgrade.
|
|
90
|
+
this.db
|
|
91
|
+
.prepare(`
|
|
92
|
+
INSERT INTO agent_sessions (
|
|
93
|
+
agent_key, agent_session_id, web_session_id, created_at
|
|
94
|
+
)
|
|
95
|
+
SELECT ?, s.id, s.id, s.created_at
|
|
96
|
+
FROM sessions s
|
|
97
|
+
WHERE NOT EXISTS (
|
|
98
|
+
SELECT 1 FROM agent_sessions a WHERE a.web_session_id = s.id
|
|
99
|
+
)
|
|
100
|
+
`)
|
|
101
|
+
.run(this.agentKey);
|
|
73
102
|
// messages — pending unbound notifications. POST /api/v1/messages with
|
|
74
103
|
// `to = "user"` lands here; consumeMessageTx transactionally moves the
|
|
75
104
|
// content into an existing ACP-backed session's events and deletes the
|
|
@@ -247,29 +276,72 @@ export class Store {
|
|
|
247
276
|
);
|
|
248
277
|
`);
|
|
249
278
|
}
|
|
250
|
-
createSession(id, cwd, source = "auto") {
|
|
251
|
-
this.db
|
|
252
|
-
.
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
.
|
|
256
|
-
|
|
279
|
+
createSession(id, cwd, source = "auto", agentSessionId = id) {
|
|
280
|
+
return this.db.transaction(() => {
|
|
281
|
+
this.db
|
|
282
|
+
.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)")
|
|
283
|
+
.run(id, cwd, source);
|
|
284
|
+
this.db
|
|
285
|
+
.prepare("INSERT INTO agent_sessions (agent_key, agent_session_id, web_session_id) VALUES (?, ?, ?)")
|
|
286
|
+
.run(this.agentKey, agentSessionId, id);
|
|
287
|
+
return this.db
|
|
288
|
+
.prepare("SELECT * FROM sessions WHERE id = ?")
|
|
289
|
+
.get(id);
|
|
290
|
+
})();
|
|
257
291
|
}
|
|
258
292
|
listSessions(opts) {
|
|
259
293
|
if (opts?.source) {
|
|
260
294
|
return this.db
|
|
261
|
-
.prepare(
|
|
262
|
-
|
|
295
|
+
.prepare(`SELECT s.* FROM sessions s
|
|
296
|
+
JOIN agent_sessions a ON a.web_session_id = s.id
|
|
297
|
+
WHERE a.agent_key = ? AND s.source = ? AND s.deleted_at IS NULL
|
|
298
|
+
ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
|
|
299
|
+
.all(this.agentKey, opts.source);
|
|
263
300
|
}
|
|
264
301
|
return this.db
|
|
265
|
-
.prepare(
|
|
266
|
-
|
|
302
|
+
.prepare(`SELECT s.* FROM sessions s
|
|
303
|
+
JOIN agent_sessions a ON a.web_session_id = s.id
|
|
304
|
+
WHERE a.agent_key = ? AND s.deleted_at IS NULL
|
|
305
|
+
ORDER BY COALESCE(s.last_active_at, s.created_at) DESC`)
|
|
306
|
+
.all(this.agentKey);
|
|
267
307
|
}
|
|
268
308
|
/** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
|
|
269
309
|
getSession(id) {
|
|
270
310
|
return this.db
|
|
271
|
-
.prepare(
|
|
272
|
-
|
|
311
|
+
.prepare(`SELECT s.* FROM sessions s
|
|
312
|
+
JOIN agent_sessions a ON a.web_session_id = s.id
|
|
313
|
+
WHERE s.id = ? AND a.agent_key = ? AND s.deleted_at IS NULL`)
|
|
314
|
+
.get(id, this.agentKey);
|
|
315
|
+
}
|
|
316
|
+
registerInternalAgentSession(agentSessionId) {
|
|
317
|
+
this.db
|
|
318
|
+
.prepare("INSERT OR IGNORE INTO agent_sessions (agent_key, agent_session_id, web_session_id) VALUES (?, ?, NULL)")
|
|
319
|
+
.run(this.agentKey, agentSessionId);
|
|
320
|
+
const row = this.db
|
|
321
|
+
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ?")
|
|
322
|
+
.get(this.agentKey, agentSessionId);
|
|
323
|
+
if (row.web_session_id) {
|
|
324
|
+
throw new Error("Agent reused a user-visible session ID internally");
|
|
325
|
+
}
|
|
326
|
+
return row;
|
|
327
|
+
}
|
|
328
|
+
getAgentSessionId(webSessionId) {
|
|
329
|
+
return this.db
|
|
330
|
+
.prepare("SELECT agent_session_id FROM agent_sessions WHERE agent_key = ? AND web_session_id = ?")
|
|
331
|
+
.get(this.agentKey, webSessionId)?.agent_session_id;
|
|
332
|
+
}
|
|
333
|
+
getWebSessionId(agentSessionId) {
|
|
334
|
+
return this.db
|
|
335
|
+
.prepare("SELECT web_session_id FROM agent_sessions WHERE agent_key = ? AND agent_session_id = ? AND web_session_id IS NOT NULL")
|
|
336
|
+
.get(this.agentKey, agentSessionId)?.web_session_id;
|
|
337
|
+
}
|
|
338
|
+
getAgentSessionBinding(webSessionId) {
|
|
339
|
+
return this.db
|
|
340
|
+
.prepare("SELECT * FROM agent_sessions WHERE agent_key = ? AND web_session_id = ?")
|
|
341
|
+
.get(this.agentKey, webSessionId);
|
|
342
|
+
}
|
|
343
|
+
ownsSession(webSessionId) {
|
|
344
|
+
return this.getAgentSessionBinding(webSessionId) !== undefined;
|
|
273
345
|
}
|
|
274
346
|
/**
|
|
275
347
|
* Returns a session row even if soft-deleted. Used by the public share
|
|
@@ -339,11 +411,14 @@ export class Store {
|
|
|
339
411
|
const empties = this.db
|
|
340
412
|
.prepare(`
|
|
341
413
|
SELECT s.id FROM sessions s
|
|
414
|
+
JOIN agent_sessions a ON a.web_session_id = s.id
|
|
342
415
|
LEFT JOIN events e ON e.session_id = s.id
|
|
343
416
|
WHERE e.id IS NULL
|
|
417
|
+
AND a.agent_key = ?
|
|
418
|
+
AND s.deleted_at IS NULL
|
|
344
419
|
AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
|
|
345
420
|
`)
|
|
346
|
-
.all(minAgeS);
|
|
421
|
+
.all(this.agentKey, minAgeS);
|
|
347
422
|
if (empties.length === 0)
|
|
348
423
|
return [];
|
|
349
424
|
const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
|
|
@@ -367,6 +442,7 @@ export class Store {
|
|
|
367
442
|
model: "model",
|
|
368
443
|
mode: "mode",
|
|
369
444
|
reasoning_effort: "reasoning_effort",
|
|
445
|
+
thought_level: "reasoning_effort",
|
|
370
446
|
}[configId];
|
|
371
447
|
if (!column)
|
|
372
448
|
return;
|
|
@@ -435,14 +511,17 @@ export class Store {
|
|
|
435
511
|
.get(sessionId);
|
|
436
512
|
return row.seq;
|
|
437
513
|
}
|
|
438
|
-
/** Check if the most recent agent turn
|
|
514
|
+
/** Check if the most recent agent turn lacks a completion or error terminal event. */
|
|
439
515
|
hasInterruptedTurn(sessionId) {
|
|
440
516
|
const row = this.db
|
|
441
517
|
.prepare(`
|
|
442
518
|
SELECT 1 FROM events
|
|
443
519
|
WHERE session_id = ? AND type = 'user_message'
|
|
444
520
|
AND seq > COALESCE(
|
|
445
|
-
(
|
|
521
|
+
(
|
|
522
|
+
SELECT MAX(seq) FROM events
|
|
523
|
+
WHERE session_id = ? AND type IN ('prompt_done', 'error')
|
|
524
|
+
),
|
|
446
525
|
0
|
|
447
526
|
)
|
|
448
527
|
LIMIT 1
|
|
@@ -771,9 +850,11 @@ export class Store {
|
|
|
771
850
|
s.ttl_hours AS ttl_hours,
|
|
772
851
|
s.last_accessed_at AS last_accessed_at
|
|
773
852
|
FROM shares s
|
|
853
|
+
JOIN agent_sessions a ON a.web_session_id = s.session_id
|
|
774
854
|
LEFT JOIN sessions sess ON sess.id = s.session_id
|
|
855
|
+
WHERE a.agent_key = ?
|
|
775
856
|
ORDER BY s.created_at DESC`)
|
|
776
|
-
.all();
|
|
857
|
+
.all(this.agentKey);
|
|
777
858
|
}
|
|
778
859
|
/**
|
|
779
860
|
* One-time write of last_accessed_at (share-plan §4.1 R2 ENG-6a +
|
package/lib/title-service.js
CHANGED
|
@@ -64,7 +64,7 @@ export class TitleService {
|
|
|
64
64
|
this.cancelledSourceSessions.add(sessionId);
|
|
65
65
|
if (!this.titleSessionId || !this.activeSourceSessions.has(sessionId))
|
|
66
66
|
return;
|
|
67
|
-
await bridge.
|
|
67
|
+
await bridge.cancelAgentSession(this.titleSessionId);
|
|
68
68
|
}
|
|
69
69
|
/** Clear the cached title session ID (e.g. after agent reload). */
|
|
70
70
|
invalidate() {
|
|
@@ -76,14 +76,14 @@ export class TitleService {
|
|
|
76
76
|
return this.titleSessionId;
|
|
77
77
|
try {
|
|
78
78
|
const { sessionId: id, configOptions } = await bridge.newSession(this.defaultCwd, { silent: true });
|
|
79
|
-
this.
|
|
79
|
+
this.store.registerInternalAgentSession(id);
|
|
80
80
|
// Pick the cheapest available model by matching id substrings against
|
|
81
81
|
// the agent's reported availableModels (`configOptions[id=model].options`).
|
|
82
82
|
// Empty pattern list, no model option, or no match → skip the call and
|
|
83
83
|
// inherit the agent's default model (`currentModelId`).
|
|
84
84
|
const picked = pickModelByPatterns(configOptions, this.modelPatterns);
|
|
85
85
|
if (picked) {
|
|
86
|
-
await bridge.
|
|
86
|
+
await bridge.setAgentConfigOption(id, "model", picked).catch(() => []);
|
|
87
87
|
}
|
|
88
88
|
this.titleSessionId = id;
|
|
89
89
|
return id;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lelouchhe/webagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A terminal-style web UI for ACP-compatible agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -64,6 +64,7 @@
|
|
|
64
64
|
"@types/web-push": "^3.6.4",
|
|
65
65
|
"better-sqlite3": "^12.6.2",
|
|
66
66
|
"busboy": "^1.6.0",
|
|
67
|
+
"diff": "^9.0.0",
|
|
67
68
|
"dompurify": "^3.4.1",
|
|
68
69
|
"file-type": "^22.0.1",
|
|
69
70
|
"highlight.js": "^11.11.1",
|