@lelouchhe/webagent 0.1.7 → 0.1.9

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/lib/server.js CHANGED
@@ -1,16 +1,15 @@
1
1
  import { createServer } from "node:http";
2
2
  import { join } from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { WebSocketServer } from "ws";
5
4
  import { loadConfig } from "./config.js";
6
5
  import { AgentBridge } from "./bridge.js";
7
6
  import { Store } from "./store.js";
8
7
  import { SessionManager } from "./session-manager.js";
9
8
  import { TitleService } from "./title-service.js";
10
9
  import { createRequestHandler } from "./routes.js";
11
- import { setupWsHandler, broadcast } from "./ws-handler.js";
12
10
  import { handleAgentEvent } from "./event-handler.js";
13
11
  import { PushService } from "./push-service.js";
12
+ import { SseManager } from "./sse-manager.js";
14
13
  const config = loadConfig();
15
14
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
16
15
  const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
@@ -21,23 +20,26 @@ const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
21
20
  const titleService = new TitleService(store, sessions, config.default_cwd);
22
21
  const pushService = new PushService(store, config.data_dir, config.push.vapid_subject);
23
22
  console.log(`[push] VAPID public key ready`);
23
+ const sseManager = new SseManager();
24
+ sseManager.onRemove((clientId) => pushService.removeClient(clientId));
25
+ sseManager.startHeartbeat();
24
26
  let bridge = null;
25
- // --- HTTP + WebSocket servers ---
26
- const server = createServer(createRequestHandler(store, PUBLIC_DIR, config.data_dir, config.limits, pushService));
27
- const wss = new WebSocketServer({ server });
28
- setupWsHandler({
29
- wss,
27
+ // --- HTTP server ---
28
+ const server = createServer(createRequestHandler({
30
29
  store,
31
30
  sessions,
31
+ sseManager,
32
32
  titleService,
33
33
  getBridge: () => bridge,
34
+ publicDir: PUBLIC_DIR,
35
+ dataDir: config.data_dir,
34
36
  limits: config.limits,
35
37
  pushService,
36
- });
38
+ }));
37
39
  async function initBridge() {
38
40
  const b = new AgentBridge(config.agent_cmd);
39
41
  b.on("event", (event) => {
40
- handleAgentEvent(event, sessions, store, wss, b, { cancelTimeout: config.limits.cancel_timeout }, pushService);
42
+ handleAgentEvent(event, sessions, store, b, { cancelTimeout: config.limits.cancel_timeout }, sseManager, pushService);
41
43
  });
42
44
  await b.start();
43
45
  bridge = b;
@@ -46,8 +48,8 @@ async function initBridge() {
46
48
  // --- Graceful shutdown ---
47
49
  async function shutdown() {
48
50
  console.log("\n[server] shutting down...");
51
+ sseManager.stopHeartbeat();
49
52
  sessions.killAllBashProcs();
50
- wss.close();
51
53
  await bridge?.shutdown();
52
54
  store.close();
53
55
  server.close();
@@ -1,8 +1,31 @@
1
+ import { spawn } from "node:child_process";
1
2
  import { rm } from "node:fs/promises";
2
3
  import { stat } from "node:fs/promises";
3
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
+ }
4
25
  /** Known config option IDs that we persist per-session. */
5
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;
6
29
  /**
7
30
  * Centralizes all session-related state that was previously scattered
8
31
  * across module-level variables in server.ts.
@@ -15,6 +38,10 @@ export class SessionManager {
15
38
  thinkingBuffers = new Map();
16
39
  activePrompts = new Set();
17
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();
18
45
  cachedConfigOptions = [];
19
46
  store;
20
47
  defaultCwd;
@@ -32,7 +59,7 @@ export class SessionManager {
32
59
  }
33
60
  }
34
61
  /** Create a new session in both bridge and store, inheriting the source session's config. */
35
- async createSession(bridge, cwd, inheritFromSessionId) {
62
+ async createSession(bridge, cwd, inheritFromSessionId, source = "auto") {
36
63
  const sessionCwd = cwd ?? this.defaultCwd;
37
64
  try {
38
65
  const info = await stat(sessionCwd);
@@ -42,12 +69,18 @@ export class SessionManager {
42
69
  catch {
43
70
  throw new Error(`Directory does not exist: ${sessionCwd}`);
44
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)`);
45
78
  const sourceSession = inheritFromSessionId
46
79
  ? this.store.getSession(inheritFromSessionId)
47
80
  : null;
48
81
  const sessionId = await bridge.newSession(sessionCwd);
49
82
  this.liveSessions.add(sessionId);
50
- this.store.createSession(sessionId, sessionCwd);
83
+ this.store.createSession(sessionId, sessionCwd, source);
51
84
  // Inherit config options from source session
52
85
  if (sourceSession) {
53
86
  const inherited = [
@@ -115,6 +148,23 @@ export class SessionManager {
115
148
  this.restoringSessions.delete(sessionId);
116
149
  }
117
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
+ }
118
168
  /** Build configOptions from cache, overriding currentValue with stored session values. */
119
169
  buildConfigOptions(session) {
120
170
  return this.applyStoredConfig(this.cachedConfigOptions, session);
@@ -144,6 +194,11 @@ export class SessionManager {
144
194
  this.thinkingBuffers.delete(sessionId);
145
195
  this.activePrompts.delete(sessionId);
146
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
+ }
147
202
  // Remove uploaded images for this session
148
203
  rm(join(this.dataDir, "images", sessionId), { recursive: true, force: true }).catch(() => { });
149
204
  }
@@ -189,6 +244,28 @@ export class SessionManager {
189
244
  return "agent";
190
245
  return null;
191
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
+ }
192
269
  /** Kill all running bash processes (for shutdown). */
193
270
  killAllBashProcs() {
194
271
  const forceSignal = process.platform === "win32" ? undefined : "SIGKILL";
@@ -0,0 +1,16 @@
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
+ };
@@ -0,0 +1,80 @@
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 CHANGED
@@ -55,12 +55,18 @@ export class Store {
55
55
  if (!colNames.has("reasoning_effort")) {
56
56
  this.db.exec("ALTER TABLE sessions ADD COLUMN reasoning_effort TEXT");
57
57
  }
58
+ if (!colNames.has("source")) {
59
+ this.db.exec("ALTER TABLE sessions ADD COLUMN source TEXT NOT NULL DEFAULT 'auto'");
60
+ }
58
61
  }
59
- createSession(id, cwd) {
60
- this.db.prepare("INSERT INTO sessions (id, cwd) VALUES (?, ?)").run(id, cwd);
62
+ createSession(id, cwd, source = "auto") {
63
+ this.db.prepare("INSERT INTO sessions (id, cwd, source) VALUES (?, ?, ?)").run(id, cwd, source);
61
64
  return this.db.prepare("SELECT * FROM sessions WHERE id = ?").get(id);
62
65
  }
63
- listSessions() {
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
+ }
64
70
  return this.db.prepare("SELECT * FROM sessions ORDER BY COALESCE(last_active_at, created_at) DESC").all();
65
71
  }
66
72
  getSession(id) {
@@ -70,6 +76,21 @@ export class Store {
70
76
  this.db.prepare("DELETE FROM events WHERE session_id = ?").run(id);
71
77
  this.db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
72
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
+ }
73
94
  updateSessionTitle(id, title) {
74
95
  this.db.prepare("UPDATE sessions SET title = ? WHERE id = ?").run(title, id);
75
96
  }
@@ -90,17 +111,50 @@ export class Store {
90
111
  .get(sessionId, seq);
91
112
  }
92
113
  getEvents(sessionId, opts) {
93
- let query = "SELECT * FROM events WHERE session_id = ?";
114
+ const hasLimit = opts?.limit != null && opts.limit > 0;
115
+ const conditions = ["session_id = ?"];
94
116
  const params = [sessionId];
95
117
  if (opts?.afterSeq != null) {
96
- query += " AND seq > ?";
118
+ conditions.push("seq > ?");
97
119
  params.push(opts.afterSeq);
98
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];
99
141
  if (opts?.excludeThinking) {
100
142
  query += " AND type != 'thinking'";
101
143
  }
102
- query += " ORDER BY seq";
103
- return this.db.prepare(query).all(...params);
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;
104
158
  }
105
159
  // --- Push subscriptions ---
106
160
  saveSubscription(endpoint, auth, p256dh) {
package/lib/types.js CHANGED
@@ -1,38 +1,3 @@
1
- import { z } from "zod/v4";
2
- // --- Inbound WS messages (client → server) ---
3
- const ImageSchema = z.object({
4
- data: z.string(),
5
- mimeType: z.string(),
6
- path: z.string().optional(),
7
- });
8
- export const WsMessageSchema = z.discriminatedUnion("type", [
9
- z.object({
10
- type: z.literal("new_session"),
11
- cwd: z.string().optional(),
12
- inheritFromSessionId: z.string().optional(),
13
- }),
14
- z.object({ type: z.literal("resume_session"), sessionId: z.string() }),
15
- z.object({ type: z.literal("delete_session"), sessionId: z.string() }),
16
- z.object({
17
- type: z.literal("prompt"),
18
- sessionId: z.string(),
19
- text: z.string(),
20
- images: z.array(ImageSchema).optional(),
21
- }),
22
- z.object({
23
- type: z.literal("permission_response"),
24
- sessionId: z.string().optional(),
25
- requestId: z.string(),
26
- optionId: z.string().optional(),
27
- optionName: z.string().optional(),
28
- denied: z.boolean().optional(),
29
- }),
30
- z.object({ type: z.literal("cancel"), sessionId: z.string() }),
31
- z.object({ type: z.literal("set_config_option"), sessionId: z.string(), configId: z.string(), value: z.string() }),
32
- z.object({ type: z.literal("bash_exec"), sessionId: z.string(), command: z.string() }),
33
- z.object({ type: z.literal("bash_cancel"), sessionId: z.string() }),
34
- z.object({ type: z.literal("visibility"), visible: z.boolean() }),
35
- ]);
36
1
  // --- Utility ---
37
2
  export function errorMessage(err) {
38
3
  if (err instanceof Error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lelouchhe/webagent",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "A terminal-style web UI for ACP-compatible agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,7 +15,7 @@
15
15
  "web-ui",
16
16
  "copilot",
17
17
  "chat",
18
- "websocket"
18
+ "sse"
19
19
  ],
20
20
  "engines": {
21
21
  "node": ">=22.6.0"
@@ -35,8 +35,8 @@
35
35
  "start": "node --experimental-strip-types src/server.ts --config config.toml",
36
36
  "test": "node --experimental-strip-types --test test/*.test.ts",
37
37
  "test:e2e": "playwright test",
38
- "dev": "node --experimental-strip-types --watch src/server.ts --config config.dev.toml",
39
- "dev:start": "node --experimental-strip-types src/server.ts --config config.dev.toml &",
38
+ "dev": "node scripts/build.js --watch & node --experimental-strip-types --watch src/server.ts --config config.dev.toml",
39
+ "dev:start": "node scripts/build.js --dev && node --experimental-strip-types src/server.ts --config config.dev.toml &",
40
40
  "dev:stop": "lsof -ti:6801 | xargs kill 2>/dev/null || true"
41
41
  },
42
42
  "dependencies": {
@@ -45,13 +45,12 @@
45
45
  "better-sqlite3": "^12.6.2",
46
46
  "smol-toml": "^1.6.0",
47
47
  "web-push": "^3.6.7",
48
- "ws": "^8.19.0",
49
48
  "zod": "^4.3.6"
50
49
  },
51
50
  "devDependencies": {
52
51
  "@types/better-sqlite3": "^7.6.13",
53
52
  "@types/node": "^25.3.3",
54
- "@types/ws": "^8.18.1",
53
+ "esbuild": "^0.27.3",
55
54
  "happy-dom": "^20.8.3",
56
55
  "playwright": "^1.58.2",
57
56
  "typescript": "^5.9.3"
@@ -1,34 +0,0 @@
1
- // Boot entry point — imports all modules and starts the app
2
-
3
- import './render.mmmmfxhu.js'; // theme, click-to-collapse listeners
4
- import './commands.mmmmfxhu.js'; // slash menu listeners
5
- import './images.mmmmfxhu.js'; // attach/paste listeners
6
- import './input.mmmmfxhu.js'; // keyboard/send listeners
7
- import { connect } from './connection.mmmmfxhu.js';
8
- import { state, setHashSessionId, resetSessionUI, updateSessionInfo } from './state.mmmmfxhu.js';
9
- import { loadHistory } from './events.mmmmfxhu.js';
10
- import { addSystem, scrollToBottom } from './render.mmmmfxhu.js';
11
-
12
- connect();
13
-
14
- if ('serviceWorker' in navigator) {
15
- navigator.serviceWorker.register('/sw.js');
16
-
17
- // Handle push notification click → navigate to session
18
- navigator.serviceWorker.addEventListener('message', (e) => {
19
- if (e.data?.type === 'navigate' && e.data.sessionId) {
20
- const targetId = e.data.sessionId;
21
- if (state.sessionId === targetId) return; // already there
22
- resetSessionUI();
23
- state.sessionId = targetId;
24
- state.sessionTitle = null;
25
- setHashSessionId(targetId);
26
- updateSessionInfo(targetId, null);
27
- addSystem('Switching…');
28
- loadHistory(targetId).then(loaded => { if (loaded) scrollToBottom(true); });
29
- if (state.ws && state.ws.readyState === 1) {
30
- state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: targetId }));
31
- }
32
- }
33
- });
34
- }