@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/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,8 @@ function defaultState() {
|
|
|
14
16
|
busy: null,
|
|
15
17
|
pendingPermissions: [],
|
|
16
18
|
streaming: { assistant: false, thinking: false },
|
|
19
|
+
plan: null,
|
|
20
|
+
contextUsage: null,
|
|
17
21
|
},
|
|
18
22
|
};
|
|
19
23
|
}
|
|
@@ -22,7 +26,10 @@ function busyEqual(a, b) {
|
|
|
22
26
|
return true;
|
|
23
27
|
if (a === null || b === null)
|
|
24
28
|
return false;
|
|
25
|
-
return a.kind === b.kind &&
|
|
29
|
+
return (a.kind === b.kind &&
|
|
30
|
+
a.since === b.since &&
|
|
31
|
+
a.promptId === b.promptId &&
|
|
32
|
+
(a.cancelStatus ?? null) === (b.cancelStatus ?? null));
|
|
26
33
|
}
|
|
27
34
|
function permsEqual(a, b) {
|
|
28
35
|
if (a.length !== b.length)
|
|
@@ -43,6 +50,21 @@ function permsEqual(a, b) {
|
|
|
43
50
|
}
|
|
44
51
|
return true;
|
|
45
52
|
}
|
|
53
|
+
function plansEqual(a, b) {
|
|
54
|
+
if (a === null || b === null)
|
|
55
|
+
return a === b;
|
|
56
|
+
if (a.length !== b.length)
|
|
57
|
+
return false;
|
|
58
|
+
return a.every((entry, index) => entry.status === b[index].status && entry.content === b[index].content);
|
|
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
|
+
}
|
|
46
68
|
/** True when the patch would change the current runtime state. */
|
|
47
69
|
function hasRuntimeChanges(current, patch) {
|
|
48
70
|
if (!patch)
|
|
@@ -53,6 +75,11 @@ function hasRuntimeChanges(current, patch) {
|
|
|
53
75
|
patch.pendingPermissions &&
|
|
54
76
|
!permsEqual(current.pendingPermissions, patch.pendingPermissions))
|
|
55
77
|
return true;
|
|
78
|
+
if ("plan" in patch && !plansEqual(current.plan, patch.plan ?? null))
|
|
79
|
+
return true;
|
|
80
|
+
if ("contextUsage" in patch &&
|
|
81
|
+
!contextUsageEqual(current.contextUsage, patch.contextUsage ?? null))
|
|
82
|
+
return true;
|
|
56
83
|
if ("streaming" in patch && patch.streaming) {
|
|
57
84
|
const s = patch.streaming;
|
|
58
85
|
if (s.assistant !== undefined &&
|
|
@@ -76,6 +103,11 @@ export class SessionStateManager {
|
|
|
76
103
|
}
|
|
77
104
|
return s;
|
|
78
105
|
}
|
|
106
|
+
/** Read streaming state without creating runtime state for an unseen session. */
|
|
107
|
+
peekStreaming(sessionId) {
|
|
108
|
+
const streaming = this.states.get(sessionId)?.runtime.streaming;
|
|
109
|
+
return streaming ? { ...streaming } : { assistant: false, thinking: false };
|
|
110
|
+
}
|
|
79
111
|
/**
|
|
80
112
|
* Merge a patch into the session's runtime state. Bumps seq and notifies
|
|
81
113
|
* listeners only when the patch actually changes something (no-op patches
|
|
@@ -95,6 +127,19 @@ export class SessionStateManager {
|
|
|
95
127
|
state.runtime.pendingPermissions =
|
|
96
128
|
patch.runtime.pendingPermissions.slice();
|
|
97
129
|
}
|
|
130
|
+
if ("plan" in patch.runtime) {
|
|
131
|
+
state.runtime.plan =
|
|
132
|
+
patch.runtime.plan?.map((entry) => ({ ...entry })) ?? null;
|
|
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
|
+
}
|
|
98
143
|
if ("streaming" in patch.runtime && patch.runtime.streaming) {
|
|
99
144
|
if (patch.runtime.streaming.assistant !== undefined) {
|
|
100
145
|
state.runtime.streaming.assistant = patch.runtime.streaming.assistant;
|
|
@@ -130,9 +175,38 @@ export class SessionStateManager {
|
|
|
130
175
|
this.cancelTimers.delete(sessionId);
|
|
131
176
|
}
|
|
132
177
|
}
|
|
178
|
+
/** Clear current plans for every known session (used on bridge reload). */
|
|
179
|
+
clearPlans() {
|
|
180
|
+
for (const [sessionId, state] of this.states) {
|
|
181
|
+
if (state.runtime.plan !== null) {
|
|
182
|
+
this.patch(sessionId, { runtime: { plan: null } });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
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
|
+
}
|
|
194
|
+
/** Clear active stream markers for every known session on bridge teardown. */
|
|
195
|
+
clearStreaming() {
|
|
196
|
+
for (const [sessionId, state] of this.states) {
|
|
197
|
+
if (state.runtime.streaming.assistant ||
|
|
198
|
+
state.runtime.streaming.thinking) {
|
|
199
|
+
this.patch(sessionId, {
|
|
200
|
+
runtime: {
|
|
201
|
+
streaming: { assistant: false, thinking: false },
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
133
207
|
/**
|
|
134
|
-
* Backend
|
|
135
|
-
*
|
|
208
|
+
* Backend acknowledgement timer for cancel: if the same agent prompt is
|
|
209
|
+
* still pending after `timeoutMs`, mark the request unconfirmed.
|
|
136
210
|
* A second arm on the same session replaces the existing timer.
|
|
137
211
|
*/
|
|
138
212
|
armCancelSafety(sessionId, timeoutMs) {
|
|
@@ -143,12 +217,32 @@ export class SessionStateManager {
|
|
|
143
217
|
clearTimeout(existing);
|
|
144
218
|
const t = setTimeout(() => {
|
|
145
219
|
this.cancelTimers.delete(sessionId);
|
|
146
|
-
this.
|
|
220
|
+
const busy = this.getState(sessionId).runtime.busy;
|
|
221
|
+
if (busy?.kind === "agent" && busy.cancelStatus === "requested") {
|
|
222
|
+
clog.warn("agent did not acknowledge", {
|
|
223
|
+
sessionId: sessionId.slice(0, 8),
|
|
224
|
+
promptId: busy.promptId,
|
|
225
|
+
});
|
|
226
|
+
this.patch(sessionId, {
|
|
227
|
+
runtime: {
|
|
228
|
+
busy: { ...busy, cancelStatus: "unconfirmed" },
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
147
232
|
}, timeoutMs);
|
|
148
233
|
if (typeof t === "object" && "unref" in t)
|
|
149
234
|
t.unref();
|
|
150
235
|
this.cancelTimers.set(sessionId, t);
|
|
151
236
|
}
|
|
237
|
+
/** Mark that a cancel notification was sent for the active agent prompt. */
|
|
238
|
+
markCancelRequested(sessionId) {
|
|
239
|
+
const busy = this.getState(sessionId).runtime.busy;
|
|
240
|
+
if (busy?.kind !== "agent")
|
|
241
|
+
return;
|
|
242
|
+
this.patch(sessionId, {
|
|
243
|
+
runtime: { busy: { ...busy, cancelStatus: "requested" } },
|
|
244
|
+
});
|
|
245
|
+
}
|
|
152
246
|
/** Cancel the safety net timer (e.g. when prompt_done arrives naturally). */
|
|
153
247
|
clearCancelSafety(sessionId) {
|
|
154
248
|
const t = this.cancelTimers.get(sessionId);
|
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/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,9 +1,19 @@
|
|
|
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
|
+
agentKey;
|
|
13
|
+
constructor(dataDir, agentKey) {
|
|
14
|
+
if (!agentKey)
|
|
15
|
+
throw new Error("agentKey is required");
|
|
16
|
+
this.agentKey = agentKey;
|
|
7
17
|
mkdirSync(dataDir, { recursive: true });
|
|
8
18
|
this.db = new Database(join(dataDir, "webagent.db"));
|
|
9
19
|
this.db.pragma("journal_mode = WAL");
|
|
@@ -21,6 +31,16 @@ export class Store {
|
|
|
21
31
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
22
32
|
last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
23
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;
|
|
24
44
|
CREATE TABLE IF NOT EXISTS events (
|
|
25
45
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
26
46
|
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
@@ -64,11 +84,26 @@ export class Store {
|
|
|
64
84
|
if (!colNames.has("deleted_at")) {
|
|
65
85
|
this.db.exec("ALTER TABLE sessions ADD COLUMN deleted_at INTEGER");
|
|
66
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);
|
|
67
102
|
// messages — pending unbound notifications. POST /api/v1/messages with
|
|
68
103
|
// `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`.
|
|
104
|
+
// content into an existing ACP-backed session's events and deletes the
|
|
105
|
+
// row. Bound messages (to = session id) skip this table entirely and go
|
|
106
|
+
// straight to `events`.
|
|
72
107
|
this.db.exec(`
|
|
73
108
|
CREATE TABLE IF NOT EXISTS messages (
|
|
74
109
|
id TEXT PRIMARY KEY,
|
|
@@ -241,29 +276,72 @@ export class Store {
|
|
|
241
276
|
);
|
|
242
277
|
`);
|
|
243
278
|
}
|
|
244
|
-
createSession(id, cwd, source = "auto") {
|
|
245
|
-
this.db
|
|
246
|
-
.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
.
|
|
250
|
-
|
|
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
|
+
})();
|
|
251
291
|
}
|
|
252
292
|
listSessions(opts) {
|
|
253
293
|
if (opts?.source) {
|
|
254
294
|
return this.db
|
|
255
|
-
.prepare(
|
|
256
|
-
|
|
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);
|
|
257
300
|
}
|
|
258
301
|
return this.db
|
|
259
|
-
.prepare(
|
|
260
|
-
|
|
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);
|
|
261
307
|
}
|
|
262
308
|
/** Returns live sessions only. Soft-deleted (tombstone) rows are hidden. */
|
|
263
309
|
getSession(id) {
|
|
264
310
|
return this.db
|
|
265
|
-
.prepare(
|
|
266
|
-
|
|
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;
|
|
267
345
|
}
|
|
268
346
|
/**
|
|
269
347
|
* Returns a session row even if soft-deleted. Used by the public share
|
|
@@ -333,11 +411,14 @@ export class Store {
|
|
|
333
411
|
const empties = this.db
|
|
334
412
|
.prepare(`
|
|
335
413
|
SELECT s.id FROM sessions s
|
|
414
|
+
JOIN agent_sessions a ON a.web_session_id = s.id
|
|
336
415
|
LEFT JOIN events e ON e.session_id = s.id
|
|
337
416
|
WHERE e.id IS NULL
|
|
417
|
+
AND a.agent_key = ?
|
|
418
|
+
AND s.deleted_at IS NULL
|
|
338
419
|
AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
|
|
339
420
|
`)
|
|
340
|
-
.all(minAgeS);
|
|
421
|
+
.all(this.agentKey, minAgeS);
|
|
341
422
|
if (empties.length === 0)
|
|
342
423
|
return [];
|
|
343
424
|
const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
|
|
@@ -361,6 +442,7 @@ export class Store {
|
|
|
361
442
|
model: "model",
|
|
362
443
|
mode: "mode",
|
|
363
444
|
reasoning_effort: "reasoning_effort",
|
|
445
|
+
thought_level: "reasoning_effort",
|
|
364
446
|
}[configId];
|
|
365
447
|
if (!column)
|
|
366
448
|
return;
|
|
@@ -429,14 +511,17 @@ export class Store {
|
|
|
429
511
|
.get(sessionId);
|
|
430
512
|
return row.seq;
|
|
431
513
|
}
|
|
432
|
-
/** Check if the most recent agent turn
|
|
514
|
+
/** Check if the most recent agent turn lacks a completion or error terminal event. */
|
|
433
515
|
hasInterruptedTurn(sessionId) {
|
|
434
516
|
const row = this.db
|
|
435
517
|
.prepare(`
|
|
436
518
|
SELECT 1 FROM events
|
|
437
519
|
WHERE session_id = ? AND type = 'user_message'
|
|
438
520
|
AND seq > COALESCE(
|
|
439
|
-
(
|
|
521
|
+
(
|
|
522
|
+
SELECT MAX(seq) FROM events
|
|
523
|
+
WHERE session_id = ? AND type IN ('prompt_done', 'error')
|
|
524
|
+
),
|
|
440
525
|
0
|
|
441
526
|
)
|
|
442
527
|
LIMIT 1
|
|
@@ -506,6 +591,12 @@ export class Store {
|
|
|
506
591
|
.prepare("SELECT * FROM messages ORDER BY created_at DESC")
|
|
507
592
|
.all();
|
|
508
593
|
}
|
|
594
|
+
countUnprocessed() {
|
|
595
|
+
const row = this.db
|
|
596
|
+
.prepare("SELECT COUNT(*) AS count FROM messages")
|
|
597
|
+
.get();
|
|
598
|
+
return row.count;
|
|
599
|
+
}
|
|
509
600
|
deleteMessage(id) {
|
|
510
601
|
const info = this.db.prepare("DELETE FROM messages WHERE id = ?").run(id);
|
|
511
602
|
return info.changes;
|
|
@@ -529,29 +620,21 @@ export class Store {
|
|
|
529
620
|
.get(to_ref, dedup_key);
|
|
530
621
|
}
|
|
531
622
|
/**
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
*
|
|
535
|
-
* by looking up the historic `message` event; callers can treat this as
|
|
536
|
-
* idempotent.
|
|
623
|
+
* Atomically move a pending message into an existing session. Session
|
|
624
|
+
* lifecycle belongs to SessionManager because ACP creation is asynchronous
|
|
625
|
+
* and cannot participate in this SQLite transaction.
|
|
537
626
|
*/
|
|
538
|
-
consumeMessageTx(messageId,
|
|
539
|
-
|
|
540
|
-
// opening one for an already-resolved message.
|
|
541
|
-
const existing = this.findMessageEventSession(messageId);
|
|
627
|
+
consumeMessageTx(messageId, sessionId) {
|
|
628
|
+
const existing = this.findConsumedMessageSession(messageId);
|
|
542
629
|
if (existing) {
|
|
543
630
|
return { sessionId: existing, alreadyConsumed: true };
|
|
544
631
|
}
|
|
545
632
|
const row = this.getMessage(messageId);
|
|
546
633
|
if (!row) {
|
|
547
|
-
throw new
|
|
634
|
+
throw new MessageNotFoundError(messageId);
|
|
548
635
|
}
|
|
549
636
|
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", {
|
|
637
|
+
this.saveEvent(sessionId, "message", {
|
|
555
638
|
message_id: row.id,
|
|
556
639
|
from_ref: row.from_ref,
|
|
557
640
|
from_label: row.from_label,
|
|
@@ -563,15 +646,13 @@ export class Store {
|
|
|
563
646
|
.prepare("DELETE FROM messages WHERE id = ?")
|
|
564
647
|
.run(messageId);
|
|
565
648
|
if (del.changes === 0) {
|
|
566
|
-
|
|
567
|
-
// roll back via throw.
|
|
568
|
-
throw new Error(`consumeMessageTx: row vanished mid-tx (id=${messageId})`);
|
|
649
|
+
throw new MessageNotFoundError(messageId);
|
|
569
650
|
}
|
|
570
651
|
});
|
|
571
652
|
tx();
|
|
572
|
-
return { sessionId
|
|
653
|
+
return { sessionId, alreadyConsumed: false };
|
|
573
654
|
}
|
|
574
|
-
|
|
655
|
+
findConsumedMessageSession(messageId) {
|
|
575
656
|
const row = this.db
|
|
576
657
|
.prepare(`SELECT session_id FROM events
|
|
577
658
|
WHERE type = 'message'
|
|
@@ -769,9 +850,11 @@ export class Store {
|
|
|
769
850
|
s.ttl_hours AS ttl_hours,
|
|
770
851
|
s.last_accessed_at AS last_accessed_at
|
|
771
852
|
FROM shares s
|
|
853
|
+
JOIN agent_sessions a ON a.web_session_id = s.session_id
|
|
772
854
|
LEFT JOIN sessions sess ON sess.id = s.session_id
|
|
855
|
+
WHERE a.agent_key = ?
|
|
773
856
|
ORDER BY s.created_at DESC`)
|
|
774
|
-
.all();
|
|
857
|
+
.all(this.agentKey);
|
|
775
858
|
}
|
|
776
859
|
/**
|
|
777
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",
|