@lelouchhe/webagent 0.1.9 → 0.2.1
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 +45 -268
- package/dist/index.html +2 -3
- package/dist/js/app.C4WRSLDF.js +10 -0
- package/dist/{styles.01a9ju9l.css → styles.01a6wdjv.css} +30 -5
- package/package.json +5 -4
- package/dist/js/app.IXP5KGP6.js +0 -8
- package/lib/bridge.js +0 -284
- package/lib/config.js +0 -62
- package/lib/daemon.js +0 -278
- package/lib/event-handler.js +0 -104
- package/lib/push-service.js +0 -168
- package/lib/routes.js +0 -929
- package/lib/server.js +0 -72
- package/lib/session-manager.js +0 -276
- package/lib/shared/constants.js +0 -16
- package/lib/sse-manager.js +0 -80
- package/lib/store.js +0 -174
- package/lib/title-service.js +0 -71
- package/lib/types.js +0 -13
package/lib/server.js
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { createServer } from "node:http";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { loadConfig } from "./config.js";
|
|
5
|
-
import { AgentBridge } from "./bridge.js";
|
|
6
|
-
import { Store } from "./store.js";
|
|
7
|
-
import { SessionManager } from "./session-manager.js";
|
|
8
|
-
import { TitleService } from "./title-service.js";
|
|
9
|
-
import { createRequestHandler } from "./routes.js";
|
|
10
|
-
import { handleAgentEvent } from "./event-handler.js";
|
|
11
|
-
import { PushService } from "./push-service.js";
|
|
12
|
-
import { SseManager } from "./sse-manager.js";
|
|
13
|
-
const config = loadConfig();
|
|
14
|
-
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
15
|
-
const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
|
|
16
|
-
// --- Core dependencies ---
|
|
17
|
-
const store = new Store(config.data_dir);
|
|
18
|
-
console.log(`[store] using ${config.data_dir}/`);
|
|
19
|
-
const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
|
|
20
|
-
const titleService = new TitleService(store, sessions, config.default_cwd);
|
|
21
|
-
const pushService = new PushService(store, config.data_dir, config.push.vapid_subject);
|
|
22
|
-
console.log(`[push] VAPID public key ready`);
|
|
23
|
-
const sseManager = new SseManager();
|
|
24
|
-
sseManager.onRemove((clientId) => pushService.removeClient(clientId));
|
|
25
|
-
sseManager.startHeartbeat();
|
|
26
|
-
let bridge = null;
|
|
27
|
-
// --- HTTP server ---
|
|
28
|
-
const server = createServer(createRequestHandler({
|
|
29
|
-
store,
|
|
30
|
-
sessions,
|
|
31
|
-
sseManager,
|
|
32
|
-
titleService,
|
|
33
|
-
getBridge: () => bridge,
|
|
34
|
-
publicDir: PUBLIC_DIR,
|
|
35
|
-
dataDir: config.data_dir,
|
|
36
|
-
limits: config.limits,
|
|
37
|
-
pushService,
|
|
38
|
-
}));
|
|
39
|
-
async function initBridge() {
|
|
40
|
-
const b = new AgentBridge(config.agent_cmd);
|
|
41
|
-
b.on("event", (event) => {
|
|
42
|
-
handleAgentEvent(event, sessions, store, b, { cancelTimeout: config.limits.cancel_timeout }, sseManager, pushService);
|
|
43
|
-
});
|
|
44
|
-
await b.start();
|
|
45
|
-
bridge = b;
|
|
46
|
-
return b;
|
|
47
|
-
}
|
|
48
|
-
// --- Graceful shutdown ---
|
|
49
|
-
async function shutdown() {
|
|
50
|
-
console.log("\n[server] shutting down...");
|
|
51
|
-
sseManager.stopHeartbeat();
|
|
52
|
-
sessions.killAllBashProcs();
|
|
53
|
-
await bridge?.shutdown();
|
|
54
|
-
store.close();
|
|
55
|
-
server.close();
|
|
56
|
-
process.exit(0);
|
|
57
|
-
}
|
|
58
|
-
process.on("SIGINT", shutdown);
|
|
59
|
-
process.on("SIGTERM", shutdown);
|
|
60
|
-
// --- Start ---
|
|
61
|
-
server.listen(config.port, "0.0.0.0", async () => {
|
|
62
|
-
console.log(`[server] listening on http://localhost:${config.port}`);
|
|
63
|
-
console.log(`[bridge] starting: ${config.agent_cmd}...`);
|
|
64
|
-
try {
|
|
65
|
-
await initBridge();
|
|
66
|
-
console.log(`[bridge] ready`);
|
|
67
|
-
sessions.hydrate();
|
|
68
|
-
}
|
|
69
|
-
catch (err) {
|
|
70
|
-
console.error(`[bridge] failed to start:`, err);
|
|
71
|
-
}
|
|
72
|
-
});
|
package/lib/session-manager.js
DELETED
|
@@ -1,276 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { rm } from "node:fs/promises";
|
|
3
|
-
import { stat } from "node:fs/promises";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
const IS_WIN = process.platform === "win32";
|
|
6
|
-
export function interruptBashProc(proc) {
|
|
7
|
-
if (!proc)
|
|
8
|
-
return;
|
|
9
|
-
if (IS_WIN && typeof proc.pid === "number") {
|
|
10
|
-
// Windows: kill entire process tree since there are no process groups
|
|
11
|
-
spawn("taskkill", ["/T", "/F", "/PID", String(proc.pid)]).unref();
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
if (typeof proc.pid === "number") {
|
|
15
|
-
try {
|
|
16
|
-
process.kill(-proc.pid, "SIGINT");
|
|
17
|
-
return;
|
|
18
|
-
}
|
|
19
|
-
catch {
|
|
20
|
-
// Fall through to direct child kill when the process is not a group leader.
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
proc.kill("SIGINT");
|
|
24
|
-
}
|
|
25
|
-
/** Known config option IDs that we persist per-session. */
|
|
26
|
-
const PERSISTED_CONFIG_IDS = ["model", "mode", "reasoning_effort"];
|
|
27
|
-
/** Minimum age (seconds) before an empty session is eligible for cleanup. */
|
|
28
|
-
const EMPTY_SESSION_MIN_AGE_S = 60;
|
|
29
|
-
/**
|
|
30
|
-
* Centralizes all session-related state that was previously scattered
|
|
31
|
-
* across module-level variables in server.ts.
|
|
32
|
-
*/
|
|
33
|
-
export class SessionManager {
|
|
34
|
-
liveSessions = new Set();
|
|
35
|
-
restoringSessions = new Set();
|
|
36
|
-
sessionHasTitle = new Set();
|
|
37
|
-
assistantBuffers = new Map();
|
|
38
|
-
thinkingBuffers = new Map();
|
|
39
|
-
activePrompts = new Set();
|
|
40
|
-
runningBashProcs = new Map();
|
|
41
|
-
/** Pending permission requests keyed by requestId. */
|
|
42
|
-
pendingPermissions = new Map();
|
|
43
|
-
/** Deduplicates concurrent resume calls for the same session. */
|
|
44
|
-
pendingResumes = new Map();
|
|
45
|
-
cachedConfigOptions = [];
|
|
46
|
-
store;
|
|
47
|
-
defaultCwd;
|
|
48
|
-
dataDir;
|
|
49
|
-
constructor(store, defaultCwd, dataDir) {
|
|
50
|
-
this.store = store;
|
|
51
|
-
this.defaultCwd = defaultCwd;
|
|
52
|
-
this.dataDir = dataDir;
|
|
53
|
-
}
|
|
54
|
-
/** Populate sessionHasTitle from existing DB sessions on startup. */
|
|
55
|
-
hydrate() {
|
|
56
|
-
for (const s of this.store.listSessions()) {
|
|
57
|
-
if (s.title)
|
|
58
|
-
this.sessionHasTitle.add(s.id);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
/** Create a new session in both bridge and store, inheriting the source session's config. */
|
|
62
|
-
async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
|
|
63
|
-
const sessionCwd = cwd ?? this.defaultCwd;
|
|
64
|
-
try {
|
|
65
|
-
const info = await stat(sessionCwd);
|
|
66
|
-
if (!info.isDirectory())
|
|
67
|
-
throw new Error("not a directory");
|
|
68
|
-
}
|
|
69
|
-
catch {
|
|
70
|
-
throw new Error(`Directory does not exist: ${sessionCwd}`);
|
|
71
|
-
}
|
|
72
|
-
// Clean up empty sessions (no events) older than the threshold
|
|
73
|
-
const cleaned = this.store.deleteEmptySessions(EMPTY_SESSION_MIN_AGE_S);
|
|
74
|
-
for (const id of cleaned)
|
|
75
|
-
this.liveSessions.delete(id);
|
|
76
|
-
if (cleaned.length > 0)
|
|
77
|
-
console.log(`[session] cleaned ${cleaned.length} empty session(s)`);
|
|
78
|
-
const sourceSession = inheritFromSessionId
|
|
79
|
-
? this.store.getSession(inheritFromSessionId)
|
|
80
|
-
: null;
|
|
81
|
-
const sessionId = await bridge.newSession(sessionCwd);
|
|
82
|
-
this.liveSessions.add(sessionId);
|
|
83
|
-
this.store.createSession(sessionId, sessionCwd, source);
|
|
84
|
-
// Inherit config options from source session
|
|
85
|
-
if (sourceSession) {
|
|
86
|
-
const inherited = [
|
|
87
|
-
{ configId: "model", value: sourceSession.model },
|
|
88
|
-
{ configId: "reasoning_effort", value: sourceSession.reasoning_effort },
|
|
89
|
-
];
|
|
90
|
-
for (const { configId, value } of inherited) {
|
|
91
|
-
if (!value)
|
|
92
|
-
continue;
|
|
93
|
-
try {
|
|
94
|
-
await bridge.setConfigOption(sessionId, configId, value);
|
|
95
|
-
this.store.updateSessionConfig(sessionId, configId, value);
|
|
96
|
-
}
|
|
97
|
-
catch {
|
|
98
|
-
// Option may no longer be available; ignore
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
const session = this.store.getSession(sessionId);
|
|
103
|
-
return {
|
|
104
|
-
sessionId,
|
|
105
|
-
configOptions: session ? this.buildConfigOptions(session) : [],
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
/** Resume a session — returns event to send to the requesting client. */
|
|
109
|
-
async resumeSession(bridge, sessionId) {
|
|
110
|
-
const session = this.store.getSession(sessionId);
|
|
111
|
-
if (!session)
|
|
112
|
-
throw new Error("Session not found");
|
|
113
|
-
if (this.liveSessions.has(sessionId)) {
|
|
114
|
-
// Session already live — build configOptions with stored overrides
|
|
115
|
-
const configOptions = this.buildConfigOptions(session);
|
|
116
|
-
return {
|
|
117
|
-
type: "session_created",
|
|
118
|
-
sessionId,
|
|
119
|
-
cwd: session.cwd,
|
|
120
|
-
title: session.title,
|
|
121
|
-
configOptions,
|
|
122
|
-
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
123
|
-
};
|
|
124
|
-
}
|
|
125
|
-
// Restore via ACP
|
|
126
|
-
this.restoringSessions.add(sessionId);
|
|
127
|
-
try {
|
|
128
|
-
const restored = await bridge.loadSession(sessionId, session.cwd);
|
|
129
|
-
this.liveSessions.add(sessionId);
|
|
130
|
-
if (session.title)
|
|
131
|
-
this.sessionHasTitle.add(sessionId);
|
|
132
|
-
const configOptions = this.applyStoredConfig(restored.configOptions, session);
|
|
133
|
-
console.log(`[session] restored: ${sessionId.slice(0, 8)}…`);
|
|
134
|
-
return {
|
|
135
|
-
type: "session_created",
|
|
136
|
-
sessionId,
|
|
137
|
-
cwd: session.cwd,
|
|
138
|
-
title: session.title,
|
|
139
|
-
configOptions,
|
|
140
|
-
busyKind: this.getBusyKind(sessionId) ?? undefined,
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
catch (err) {
|
|
144
|
-
console.error(`[session] restore failed:`, err);
|
|
145
|
-
throw err;
|
|
146
|
-
}
|
|
147
|
-
finally {
|
|
148
|
-
this.restoringSessions.delete(sessionId);
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Ensure a session is resumed (live in ACP). Deduplicates concurrent calls.
|
|
153
|
-
* Unlike resumeSession(), this is fire-and-forget safe — callers that only
|
|
154
|
-
* need the session alive (but not the event payload) can await this.
|
|
155
|
-
*/
|
|
156
|
-
async ensureResumed(bridge, sessionId) {
|
|
157
|
-
if (this.liveSessions.has(sessionId))
|
|
158
|
-
return;
|
|
159
|
-
const existing = this.pendingResumes.get(sessionId);
|
|
160
|
-
if (existing)
|
|
161
|
-
return existing;
|
|
162
|
-
const p = this.resumeSession(bridge, sessionId)
|
|
163
|
-
.then(() => { })
|
|
164
|
-
.finally(() => this.pendingResumes.delete(sessionId));
|
|
165
|
-
this.pendingResumes.set(sessionId, p);
|
|
166
|
-
return p;
|
|
167
|
-
}
|
|
168
|
-
/** Build configOptions from cache, overriding currentValue with stored session values. */
|
|
169
|
-
buildConfigOptions(session) {
|
|
170
|
-
return this.applyStoredConfig(this.cachedConfigOptions, session);
|
|
171
|
-
}
|
|
172
|
-
/** Override currentValue in configOptions with stored session values. */
|
|
173
|
-
applyStoredConfig(configOptions, session) {
|
|
174
|
-
if (!configOptions.length)
|
|
175
|
-
return this.cachedConfigOptions;
|
|
176
|
-
const stored = {
|
|
177
|
-
model: session.model,
|
|
178
|
-
mode: session.mode,
|
|
179
|
-
reasoning_effort: session.reasoning_effort,
|
|
180
|
-
};
|
|
181
|
-
return configOptions.map((opt) => {
|
|
182
|
-
const override = stored[opt.id];
|
|
183
|
-
if (override)
|
|
184
|
-
return { ...opt, currentValue: override };
|
|
185
|
-
return opt;
|
|
186
|
-
});
|
|
187
|
-
}
|
|
188
|
-
/** Delete a session from store and clean up all state (including images). */
|
|
189
|
-
deleteSession(sessionId) {
|
|
190
|
-
this.store.deleteSession(sessionId);
|
|
191
|
-
this.liveSessions.delete(sessionId);
|
|
192
|
-
this.sessionHasTitle.delete(sessionId);
|
|
193
|
-
this.assistantBuffers.delete(sessionId);
|
|
194
|
-
this.thinkingBuffers.delete(sessionId);
|
|
195
|
-
this.activePrompts.delete(sessionId);
|
|
196
|
-
this.runningBashProcs.delete(sessionId);
|
|
197
|
-
// Clean pending permissions for this session
|
|
198
|
-
for (const [reqId, perm] of this.pendingPermissions) {
|
|
199
|
-
if (perm.sessionId === sessionId)
|
|
200
|
-
this.pendingPermissions.delete(reqId);
|
|
201
|
-
}
|
|
202
|
-
// Remove uploaded images for this session
|
|
203
|
-
rm(join(this.dataDir, "images", sessionId), { recursive: true, force: true }).catch(() => { });
|
|
204
|
-
}
|
|
205
|
-
/** Flush assistant/thinking buffers to store. */
|
|
206
|
-
flushBuffers(sessionId) {
|
|
207
|
-
this.flushAssistantBuffer(sessionId);
|
|
208
|
-
this.flushThinkingBuffer(sessionId);
|
|
209
|
-
}
|
|
210
|
-
/** Flush only the assistant message buffer to store. */
|
|
211
|
-
flushAssistantBuffer(sessionId) {
|
|
212
|
-
const assistant = this.assistantBuffers.get(sessionId);
|
|
213
|
-
if (assistant) {
|
|
214
|
-
this.store.saveEvent(sessionId, "assistant_message", { text: assistant });
|
|
215
|
-
this.assistantBuffers.delete(sessionId);
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
/** Flush only the thinking buffer to store. */
|
|
219
|
-
flushThinkingBuffer(sessionId) {
|
|
220
|
-
const thinking = this.thinkingBuffers.get(sessionId);
|
|
221
|
-
if (thinking) {
|
|
222
|
-
this.store.saveEvent(sessionId, "thinking", { text: thinking });
|
|
223
|
-
this.thinkingBuffers.delete(sessionId);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
/** Append to assistant message buffer. */
|
|
227
|
-
appendAssistant(sessionId, text) {
|
|
228
|
-
const buf = (this.assistantBuffers.get(sessionId) ?? "") + text;
|
|
229
|
-
this.assistantBuffers.set(sessionId, buf);
|
|
230
|
-
}
|
|
231
|
-
/** Append to thinking buffer. */
|
|
232
|
-
appendThinking(sessionId, text) {
|
|
233
|
-
const buf = (this.thinkingBuffers.get(sessionId) ?? "") + text;
|
|
234
|
-
this.thinkingBuffers.set(sessionId, buf);
|
|
235
|
-
}
|
|
236
|
-
/** Get CWD for a session (falls back to default). */
|
|
237
|
-
getSessionCwd(sessionId) {
|
|
238
|
-
return this.store.getSession(sessionId)?.cwd ?? this.defaultCwd;
|
|
239
|
-
}
|
|
240
|
-
getBusyKind(sessionId) {
|
|
241
|
-
if (this.runningBashProcs.has(sessionId))
|
|
242
|
-
return "bash";
|
|
243
|
-
if (this.activePrompts.has(sessionId))
|
|
244
|
-
return "agent";
|
|
245
|
-
return null;
|
|
246
|
-
}
|
|
247
|
-
/**
|
|
248
|
-
* If the session's last turn was interrupted (user_message without prompt_done),
|
|
249
|
-
* auto-retry by prompting the agent to continue. Returns true if retrying.
|
|
250
|
-
*/
|
|
251
|
-
autoRetryIfNeeded(bridge, sessionId) {
|
|
252
|
-
if (this.activePrompts.has(sessionId))
|
|
253
|
-
return false;
|
|
254
|
-
if (!this.store.hasInterruptedTurn(sessionId))
|
|
255
|
-
return false;
|
|
256
|
-
console.log(`[session] auto-retrying interrupted turn for ${sessionId.slice(0, 8)}…`);
|
|
257
|
-
this.activePrompts.add(sessionId);
|
|
258
|
-
bridge.prompt(sessionId, "Continue your previous response — it was interrupted mid-way.").catch((err) => {
|
|
259
|
-
console.error(`[session] auto-retry failed for ${sessionId.slice(0, 8)}…:`, err);
|
|
260
|
-
this.activePrompts.delete(sessionId);
|
|
261
|
-
});
|
|
262
|
-
return true;
|
|
263
|
-
}
|
|
264
|
-
/** Get pending permission requests for a session (or all sessions if no id). */
|
|
265
|
-
getPendingPermissions(sessionId) {
|
|
266
|
-
const perms = [...this.pendingPermissions.values()];
|
|
267
|
-
return sessionId ? perms.filter(p => p.sessionId === sessionId) : perms;
|
|
268
|
-
}
|
|
269
|
-
/** Kill all running bash processes (for shutdown). */
|
|
270
|
-
killAllBashProcs() {
|
|
271
|
-
const forceSignal = process.platform === "win32" ? undefined : "SIGKILL";
|
|
272
|
-
for (const [, proc] of this.runningBashProcs)
|
|
273
|
-
proc.kill(forceSignal);
|
|
274
|
-
this.runningBashProcs.clear();
|
|
275
|
-
}
|
|
276
|
-
}
|
package/lib/shared/constants.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
// Shared constants used by both frontend and backend.
|
|
2
|
-
// --- Tool call kind → display icon ---
|
|
3
|
-
export const TOOL_ICONS = {
|
|
4
|
-
read: "cat",
|
|
5
|
-
edit: "edit",
|
|
6
|
-
execute: "exec",
|
|
7
|
-
search: "find",
|
|
8
|
-
delete: "rm",
|
|
9
|
-
};
|
|
10
|
-
export const DEFAULT_TOOL_ICON = "run";
|
|
11
|
-
// --- Plan entry status → display symbol ---
|
|
12
|
-
export const PLAN_STATUS_ICONS = {
|
|
13
|
-
pending: "○",
|
|
14
|
-
in_progress: "◉",
|
|
15
|
-
completed: "●",
|
|
16
|
-
};
|
package/lib/sse-manager.js
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from "node:crypto";
|
|
2
|
-
/**
|
|
3
|
-
* Manages Server-Sent Event connections.
|
|
4
|
-
* Tracks connected clients, broadcasts events, handles cleanup.
|
|
5
|
-
*/
|
|
6
|
-
export class SseManager {
|
|
7
|
-
clients = new Map();
|
|
8
|
-
heartbeatTimer = null;
|
|
9
|
-
heartbeatInterval;
|
|
10
|
-
onRemoveCallback = null;
|
|
11
|
-
constructor(heartbeatMs = 20_000) {
|
|
12
|
-
this.heartbeatInterval = heartbeatMs;
|
|
13
|
-
}
|
|
14
|
-
/** Register a callback invoked when a client disconnects. */
|
|
15
|
-
onRemove(cb) {
|
|
16
|
-
this.onRemoveCallback = cb;
|
|
17
|
-
}
|
|
18
|
-
/** Start the periodic heartbeat. Call once after construction. */
|
|
19
|
-
startHeartbeat() {
|
|
20
|
-
if (this.heartbeatTimer)
|
|
21
|
-
return;
|
|
22
|
-
this.heartbeatTimer = setInterval(() => {
|
|
23
|
-
for (const client of this.clients.values()) {
|
|
24
|
-
if (!client.res.writableEnded)
|
|
25
|
-
client.res.write(": heartbeat\n\n");
|
|
26
|
-
}
|
|
27
|
-
}, this.heartbeatInterval);
|
|
28
|
-
this.heartbeatTimer.unref();
|
|
29
|
-
}
|
|
30
|
-
/** Stop the heartbeat (e.g. on shutdown). */
|
|
31
|
-
stopHeartbeat() {
|
|
32
|
-
if (this.heartbeatTimer) {
|
|
33
|
-
clearInterval(this.heartbeatTimer);
|
|
34
|
-
this.heartbeatTimer = null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/** Generate a unique client ID. */
|
|
38
|
-
generateClientId() {
|
|
39
|
-
return `cl-${randomBytes(6).toString("hex")}`;
|
|
40
|
-
}
|
|
41
|
-
/** Register a new SSE client connection. */
|
|
42
|
-
add(client) {
|
|
43
|
-
this.clients.set(client.id, client);
|
|
44
|
-
client.res.on("close", () => this.remove(client.id));
|
|
45
|
-
}
|
|
46
|
-
/** Remove a client by ID. */
|
|
47
|
-
remove(id) {
|
|
48
|
-
this.clients.delete(id);
|
|
49
|
-
this.onRemoveCallback?.(id);
|
|
50
|
-
}
|
|
51
|
-
/** Send an SSE event to a single client. */
|
|
52
|
-
sendEvent(client, event, seq) {
|
|
53
|
-
if (client.res.writableEnded)
|
|
54
|
-
return;
|
|
55
|
-
let msg = "";
|
|
56
|
-
if (seq != null)
|
|
57
|
-
msg += `id: ${seq}\n`;
|
|
58
|
-
msg += `data: ${JSON.stringify(event)}\n\n`;
|
|
59
|
-
client.res.write(msg);
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Broadcast an event to all connected SSE clients.
|
|
63
|
-
* Global clients get all events. Per-session clients only get events for their session.
|
|
64
|
-
*/
|
|
65
|
-
broadcast(event) {
|
|
66
|
-
const sessionId = event.sessionId;
|
|
67
|
-
for (const client of this.clients.values()) {
|
|
68
|
-
if (client.res.writableEnded)
|
|
69
|
-
continue;
|
|
70
|
-
// Global clients get everything; session clients only get matching events
|
|
71
|
-
if (client.sessionId && client.sessionId !== sessionId)
|
|
72
|
-
continue;
|
|
73
|
-
this.sendEvent(client, event);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
/** Get count of connected clients. */
|
|
77
|
-
get size() {
|
|
78
|
-
return this.clients.size;
|
|
79
|
-
}
|
|
80
|
-
}
|
package/lib/store.js
DELETED
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import Database from "better-sqlite3";
|
|
2
|
-
import { mkdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
export class Store {
|
|
5
|
-
db;
|
|
6
|
-
constructor(dataDir) {
|
|
7
|
-
mkdirSync(dataDir, { recursive: true });
|
|
8
|
-
this.db = new Database(join(dataDir, "webagent.db"));
|
|
9
|
-
this.db.pragma("journal_mode = WAL");
|
|
10
|
-
this.migrate();
|
|
11
|
-
}
|
|
12
|
-
migrate() {
|
|
13
|
-
this.db.exec(`
|
|
14
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
15
|
-
id TEXT PRIMARY KEY,
|
|
16
|
-
cwd TEXT NOT NULL,
|
|
17
|
-
title TEXT,
|
|
18
|
-
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now')),
|
|
19
|
-
last_active_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
20
|
-
);
|
|
21
|
-
CREATE TABLE IF NOT EXISTS events (
|
|
22
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
-
session_id TEXT NOT NULL REFERENCES sessions(id),
|
|
24
|
-
seq INTEGER NOT NULL,
|
|
25
|
-
type TEXT NOT NULL,
|
|
26
|
-
data TEXT NOT NULL DEFAULT '{}',
|
|
27
|
-
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
28
|
-
);
|
|
29
|
-
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id, seq);
|
|
30
|
-
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
|
31
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
32
|
-
endpoint TEXT NOT NULL UNIQUE,
|
|
33
|
-
auth TEXT NOT NULL,
|
|
34
|
-
p256dh TEXT NOT NULL,
|
|
35
|
-
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
36
|
-
);
|
|
37
|
-
`);
|
|
38
|
-
// Migrate existing tables: add columns if missing
|
|
39
|
-
const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
|
|
40
|
-
const colNames = new Set(cols.map(c => c.name));
|
|
41
|
-
if (!colNames.has("title")) {
|
|
42
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN title TEXT");
|
|
43
|
-
}
|
|
44
|
-
if (!colNames.has("last_active_at")) {
|
|
45
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN last_active_at TEXT");
|
|
46
|
-
// Backfill from created_at
|
|
47
|
-
this.db.exec("UPDATE sessions SET last_active_at = created_at WHERE last_active_at IS NULL");
|
|
48
|
-
}
|
|
49
|
-
if (!colNames.has("model")) {
|
|
50
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN model TEXT");
|
|
51
|
-
}
|
|
52
|
-
if (!colNames.has("mode")) {
|
|
53
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN mode TEXT");
|
|
54
|
-
}
|
|
55
|
-
if (!colNames.has("reasoning_effort")) {
|
|
56
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
|
|
57
|
-
}
|
|
58
|
-
if (!colNames.has("source")) {
|
|
59
|
-
this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
createSession(id, cwd, source = "auto") {
|
|
63
|
-
this.db.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)").run(id, cwd, source);
|
|
64
|
-
return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
65
|
-
}
|
|
66
|
-
listSessions(opts) {
|
|
67
|
-
if (opts?.source) {
|
|
68
|
-
return this.db.prepare("SELECT * FROM sessions WHERE source = ? ORDER BY COALESCE(last_active_at, created_at) DESC").all(opts.source);
|
|
69
|
-
}
|
|
70
|
-
return this.db.prepare("SELECT * FROM sessions ORDER BY COALESCE(last_active_at, created_at) DESC").all();
|
|
71
|
-
}
|
|
72
|
-
getSession(id) {
|
|
73
|
-
return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
|
|
74
|
-
}
|
|
75
|
-
deleteSession(id) {
|
|
76
|
-
this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
|
|
77
|
-
this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
|
78
|
-
}
|
|
79
|
-
/** Delete sessions that have zero events and are older than minAgeS seconds. Returns IDs deleted. */
|
|
80
|
-
deleteEmptySessions(minAgeS) {
|
|
81
|
-
const empties = this.db.prepare(`
|
|
82
|
-
SELECT s.id FROM sessions s
|
|
83
|
-
LEFT JOIN events e ON e.session_id = s.id
|
|
84
|
-
WHERE e.id IS NULL
|
|
85
|
-
AND strftime('%s', 'now') - strftime('%s', s.created_at) >= ?
|
|
86
|
-
`).all(minAgeS);
|
|
87
|
-
if (empties.length === 0)
|
|
88
|
-
return [];
|
|
89
|
-
const del = this.db.prepare("DELETE FROM sessions WHERE id = ?");
|
|
90
|
-
for (const r of empties)
|
|
91
|
-
del.run(r.id);
|
|
92
|
-
return empties.map(r => r.id);
|
|
93
|
-
}
|
|
94
|
-
updateSessionTitle(id, title) {
|
|
95
|
-
this.db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run(title, id);
|
|
96
|
-
}
|
|
97
|
-
updateSessionLastActive(id) {
|
|
98
|
-
this.db.prepare("UPDATE sessions SET last_active_at = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = ?").run(id);
|
|
99
|
-
}
|
|
100
|
-
/** Update a config option value (model, mode, reasoning_effort) for a session. */
|
|
101
|
-
updateSessionConfig(id, configId, value) {
|
|
102
|
-
const column = { model: "model", mode: "mode", reasoning_effort: "reasoning_effort" }[configId];
|
|
103
|
-
if (!column)
|
|
104
|
-
return;
|
|
105
|
-
this.db.prepare(`UPDATE sessions SET ${column} = ? WHERE id = ?`).run(value, id);
|
|
106
|
-
}
|
|
107
|
-
saveEvent(sessionId, type, data = {}) {
|
|
108
|
-
const seq = this.db.prepare("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM events WHERE session_id = ?").get(sessionId).next;
|
|
109
|
-
this.db.prepare("INSERT INTO events (session_id, seq, type, data) VALUES (?, ?, ?, ?)").run(sessionId, seq, type, JSON.stringify(data));
|
|
110
|
-
return this.db.prepare("SELECT * FROM events WHERE session_id = ? AND seq = ?")
|
|
111
|
-
.get(sessionId, seq);
|
|
112
|
-
}
|
|
113
|
-
getEvents(sessionId, opts) {
|
|
114
|
-
const hasLimit = opts?.limit != null && opts.limit > 0;
|
|
115
|
-
const conditions = ["session_id = ?"];
|
|
116
|
-
const params = [sessionId];
|
|
117
|
-
if (opts?.afterSeq != null) {
|
|
118
|
-
conditions.push("seq > ?");
|
|
119
|
-
params.push(opts.afterSeq);
|
|
120
|
-
}
|
|
121
|
-
if (opts?.beforeSeq != null) {
|
|
122
|
-
conditions.push("seq < ?");
|
|
123
|
-
params.push(opts.beforeSeq);
|
|
124
|
-
}
|
|
125
|
-
if (opts?.excludeThinking) {
|
|
126
|
-
conditions.push("type != 'thinking'");
|
|
127
|
-
}
|
|
128
|
-
const where = conditions.join(" AND ");
|
|
129
|
-
if (hasLimit) {
|
|
130
|
-
// Fetch the last N matching rows: subquery orders DESC with LIMIT,
|
|
131
|
-
// outer query re-orders ASC so the page is in chronological order.
|
|
132
|
-
const sql = `SELECT * FROM (SELECT * FROM events WHERE ${where} ORDER BY seq DESC LIMIT ?) ORDER BY seq`;
|
|
133
|
-
params.push(opts.limit);
|
|
134
|
-
return this.db.prepare(sql).all(...params);
|
|
135
|
-
}
|
|
136
|
-
return this.db.prepare(`SELECT * FROM events WHERE ${where} ORDER BY seq`).all(...params);
|
|
137
|
-
}
|
|
138
|
-
getEventCount(sessionId, opts) {
|
|
139
|
-
let query = "SELECT COUNT(*) as count FROM events WHERE session_id = ?";
|
|
140
|
-
const params = [sessionId];
|
|
141
|
-
if (opts?.excludeThinking) {
|
|
142
|
-
query += " AND type != 'thinking'";
|
|
143
|
-
}
|
|
144
|
-
return this.db.prepare(query).get(...params).count;
|
|
145
|
-
}
|
|
146
|
-
/** Check if the most recent agent turn was interrupted (user_message without a following prompt_done). */
|
|
147
|
-
hasInterruptedTurn(sessionId) {
|
|
148
|
-
const row = this.db.prepare(`
|
|
149
|
-
SELECT 1 FROM events
|
|
150
|
-
WHERE session_id = ? AND type = 'user_message'
|
|
151
|
-
AND seq > COALESCE(
|
|
152
|
-
(SELECT MAX(seq) FROM events WHERE session_id = ? AND type = 'prompt_done'),
|
|
153
|
-
0
|
|
154
|
-
)
|
|
155
|
-
LIMIT 1
|
|
156
|
-
`).get(sessionId, sessionId);
|
|
157
|
-
return !!row;
|
|
158
|
-
}
|
|
159
|
-
// --- Push subscriptions ---
|
|
160
|
-
saveSubscription(endpoint, auth, p256dh) {
|
|
161
|
-
this.db.prepare(`INSERT INTO push_subscriptions (endpoint, auth, p256dh)
|
|
162
|
-
VALUES (?, ?, ?)
|
|
163
|
-
ON CONFLICT(endpoint) DO UPDATE SET auth = excluded.auth, p256dh = excluded.p256dh`).run(endpoint, auth, p256dh);
|
|
164
|
-
}
|
|
165
|
-
removeSubscription(endpoint) {
|
|
166
|
-
this.db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
|
|
167
|
-
}
|
|
168
|
-
getAllSubscriptions() {
|
|
169
|
-
return this.db.prepare("SELECT * FROM push_subscriptions").all();
|
|
170
|
-
}
|
|
171
|
-
close() {
|
|
172
|
-
this.db.close();
|
|
173
|
-
}
|
|
174
|
-
}
|
package/lib/title-service.js
DELETED
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
const TITLE_MODEL = "claude-haiku-4.5";
|
|
2
|
-
export class TitleService {
|
|
3
|
-
titleSessionId = null;
|
|
4
|
-
activeSourceSessions = new Set();
|
|
5
|
-
cancelledSourceSessions = new Set();
|
|
6
|
-
defaultCwd;
|
|
7
|
-
store;
|
|
8
|
-
sessions;
|
|
9
|
-
constructor(store, sessions, defaultCwd) {
|
|
10
|
-
this.store = store;
|
|
11
|
-
this.sessions = sessions;
|
|
12
|
-
this.defaultCwd = defaultCwd;
|
|
13
|
-
}
|
|
14
|
-
/** Generate a title for the session (non-blocking, fire-and-forget). */
|
|
15
|
-
generate(bridge, userMessage, sessionId, onTitle) {
|
|
16
|
-
if (this.sessions.sessionHasTitle.has(sessionId) || this.activeSourceSessions.has(sessionId))
|
|
17
|
-
return;
|
|
18
|
-
this._generate(bridge, userMessage, sessionId).then((title) => {
|
|
19
|
-
if (title && onTitle)
|
|
20
|
-
onTitle(title);
|
|
21
|
-
}).catch((err) => {
|
|
22
|
-
console.error(`[title] generation failed:`, err);
|
|
23
|
-
});
|
|
24
|
-
}
|
|
25
|
-
async _generate(bridge, userMessage, sessionId) {
|
|
26
|
-
this.activeSourceSessions.add(sessionId);
|
|
27
|
-
const tsId = await this.ensureTitleSession(bridge);
|
|
28
|
-
if (!tsId) {
|
|
29
|
-
this.activeSourceSessions.delete(sessionId);
|
|
30
|
-
this.cancelledSourceSessions.delete(sessionId);
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
try {
|
|
34
|
-
const prompt = `Generate a short title (max 30 chars, no quotes) for a chat that starts with this message. Reply with ONLY the title, nothing else:\n\n${userMessage.slice(0, 500)}`;
|
|
35
|
-
const title = await bridge.promptForText(tsId, prompt);
|
|
36
|
-
if (!title || this.cancelledSourceSessions.has(sessionId))
|
|
37
|
-
return;
|
|
38
|
-
const cleaned = title.replace(/^["']|["']$/g, "").trim().slice(0, 30);
|
|
39
|
-
if (!cleaned)
|
|
40
|
-
return;
|
|
41
|
-
this.store.updateSessionTitle(sessionId, cleaned);
|
|
42
|
-
this.sessions.sessionHasTitle.add(sessionId);
|
|
43
|
-
return cleaned;
|
|
44
|
-
}
|
|
45
|
-
finally {
|
|
46
|
-
this.activeSourceSessions.delete(sessionId);
|
|
47
|
-
this.cancelledSourceSessions.delete(sessionId);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
async cancel(sessionId, bridge) {
|
|
51
|
-
this.cancelledSourceSessions.add(sessionId);
|
|
52
|
-
if (!this.titleSessionId || !this.activeSourceSessions.has(sessionId))
|
|
53
|
-
return;
|
|
54
|
-
await bridge.cancel(this.titleSessionId);
|
|
55
|
-
}
|
|
56
|
-
/** Ensure the dedicated title session exists. Returns session ID or null. */
|
|
57
|
-
async ensureTitleSession(bridge) {
|
|
58
|
-
if (this.titleSessionId)
|
|
59
|
-
return this.titleSessionId;
|
|
60
|
-
try {
|
|
61
|
-
const id = await bridge.newSession(this.defaultCwd, { silent: true });
|
|
62
|
-
this.sessions.liveSessions.add(id);
|
|
63
|
-
await bridge.setConfigOption(id, "model", TITLE_MODEL).catch(() => []);
|
|
64
|
-
this.titleSessionId = id;
|
|
65
|
-
return id;
|
|
66
|
-
}
|
|
67
|
-
catch {
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
}
|