@lelouchhe/webagent 0.1.4 → 0.1.5

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/routes.js CHANGED
@@ -13,7 +13,7 @@ const MIME = {
13
13
  ".gif": "image/gif",
14
14
  ".webp": "image/webp",
15
15
  };
16
- export function createRequestHandler(store, publicDir, dataDir, limits) {
16
+ export function createRequestHandler(store, publicDir, dataDir, limits, pushService) {
17
17
  return async (req, res) => {
18
18
  const url = req.url ?? "/";
19
19
  // --- API routes ---
@@ -89,6 +89,71 @@ export function createRequestHandler(store, publicDir, dataDir, limits) {
89
89
  res.end(JSON.stringify({ path: relPath, url: imgUrl }));
90
90
  return;
91
91
  }
92
+ // --- Push notification routes ---
93
+ // GET /api/push/vapid-key
94
+ if (url === "/api/push/vapid-key" && req.method === "GET") {
95
+ if (!pushService) {
96
+ res.writeHead(404);
97
+ res.end(JSON.stringify({ error: "Push not configured" }));
98
+ return;
99
+ }
100
+ res.end(JSON.stringify({ publicKey: pushService.getPublicKey() }));
101
+ return;
102
+ }
103
+ // POST /api/push/subscribe
104
+ if (url === "/api/push/subscribe" && req.method === "POST") {
105
+ if (!pushService) {
106
+ res.writeHead(404);
107
+ res.end(JSON.stringify({ error: "Push not configured" }));
108
+ return;
109
+ }
110
+ const chunks = [];
111
+ for await (const chunk of req)
112
+ chunks.push(chunk);
113
+ let body;
114
+ try {
115
+ body = JSON.parse(Buffer.concat(chunks).toString());
116
+ }
117
+ catch {
118
+ res.writeHead(400);
119
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
120
+ return;
121
+ }
122
+ if (!body.endpoint || !body.keys?.auth || !body.keys?.p256dh) {
123
+ res.writeHead(400);
124
+ res.end(JSON.stringify({ error: "Missing endpoint or keys (auth, p256dh)" }));
125
+ return;
126
+ }
127
+ store.saveSubscription(body.endpoint, body.keys.auth, body.keys.p256dh);
128
+ res.writeHead(201);
129
+ res.end(JSON.stringify({ ok: true }));
130
+ return;
131
+ }
132
+ // POST /api/push/unsubscribe
133
+ if (url === "/api/push/unsubscribe" && req.method === "POST") {
134
+ if (!pushService) {
135
+ res.writeHead(404);
136
+ res.end(JSON.stringify({ error: "Push not configured" }));
137
+ return;
138
+ }
139
+ const chunks = [];
140
+ for await (const chunk of req)
141
+ chunks.push(chunk);
142
+ let body;
143
+ try {
144
+ body = JSON.parse(Buffer.concat(chunks).toString());
145
+ }
146
+ catch {
147
+ res.writeHead(400);
148
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
149
+ return;
150
+ }
151
+ if (body.endpoint) {
152
+ store.removeSubscription(body.endpoint);
153
+ }
154
+ res.end(JSON.stringify({ ok: true }));
155
+ return;
156
+ }
92
157
  res.writeHead(404);
93
158
  res.end(JSON.stringify({ error: "Not found" }));
94
159
  return;
package/lib/server.js CHANGED
@@ -9,6 +9,8 @@ import { SessionManager } from "./session-manager.js";
9
9
  import { TitleService } from "./title-service.js";
10
10
  import { createRequestHandler } from "./routes.js";
11
11
  import { setupWsHandler, broadcast } from "./ws-handler.js";
12
+ import { handleAgentEvent } from "./event-handler.js";
13
+ import { PushService } from "./push-service.js";
12
14
  const config = loadConfig();
13
15
  const __dirname = fileURLToPath(new URL(".", import.meta.url));
14
16
  const PUBLIC_DIR = join(__dirname, "..", config.public_dir);
@@ -17,9 +19,11 @@ const store = new Store(config.data_dir);
17
19
  console.log(`[store] using ${config.data_dir}/`);
18
20
  const sessions = new SessionManager(store, config.default_cwd, config.data_dir);
19
21
  const titleService = new TitleService(store, sessions, config.default_cwd);
22
+ const pushService = new PushService(store, config.data_dir, config.push.vapid_subject);
23
+ console.log(`[push] VAPID public key ready`);
20
24
  let bridge = null;
21
25
  // --- HTTP + WebSocket servers ---
22
- const server = createServer(createRequestHandler(store, PUBLIC_DIR, config.data_dir, config.limits));
26
+ const server = createServer(createRequestHandler(store, PUBLIC_DIR, config.data_dir, config.limits, pushService));
23
27
  const wss = new WebSocketServer({ server });
24
28
  setupWsHandler({
25
29
  wss,
@@ -28,90 +32,12 @@ setupWsHandler({
28
32
  titleService,
29
33
  getBridge: () => bridge,
30
34
  limits: config.limits,
35
+ pushService,
31
36
  });
32
- // --- Bridge initialization ---
33
37
  async function initBridge() {
34
38
  const b = new AgentBridge(config.agent_cmd);
35
39
  b.on("event", (event) => {
36
- if ("sessionId" in event && event.sessionId && sessions.restoringSessions.has(event.sessionId))
37
- return;
38
- switch (event.type) {
39
- case "connected":
40
- event.cancelTimeout = config.limits.cancel_timeout;
41
- break;
42
- case "session_created":
43
- if (event.configOptions?.length)
44
- sessions.cachedConfigOptions = event.configOptions;
45
- for (const opt of event.configOptions ?? []) {
46
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
47
- }
48
- break;
49
- case "config_option_update":
50
- if (event.configOptions?.length)
51
- sessions.cachedConfigOptions = event.configOptions;
52
- for (const opt of event.configOptions ?? []) {
53
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
54
- }
55
- break;
56
- case "message_chunk":
57
- sessions.flushThinkingBuffer(event.sessionId);
58
- sessions.appendAssistant(event.sessionId, event.text);
59
- break;
60
- case "thought_chunk":
61
- sessions.flushAssistantBuffer(event.sessionId);
62
- sessions.appendThinking(event.sessionId, event.text);
63
- break;
64
- case "tool_call":
65
- sessions.flushBuffers(event.sessionId);
66
- store.saveEvent(event.sessionId, event.type, { id: event.id, title: event.title, kind: event.kind, rawInput: event.rawInput });
67
- break;
68
- case "tool_call_update":
69
- store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content });
70
- break;
71
- case "plan":
72
- sessions.flushBuffers(event.sessionId);
73
- store.saveEvent(event.sessionId, event.type, { entries: event.entries });
74
- break;
75
- case "permission_request": {
76
- sessions.flushBuffers(event.sessionId);
77
- store.saveEvent(event.sessionId, event.type, {
78
- requestId: event.requestId, title: event.title, options: event.options,
79
- });
80
- // Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
81
- const mode = store.getSession(event.sessionId)?.mode ?? "";
82
- if (mode.includes("#autopilot")) {
83
- const opt = event.options.find((o) => o.kind === "allow_once");
84
- if (opt) {
85
- b.resolvePermission(event.requestId, opt.optionId);
86
- const optionName = opt.label ?? opt.optionId;
87
- store.saveEvent(event.sessionId, "permission_response", {
88
- requestId: event.requestId, optionName, denied: false,
89
- });
90
- // Skip broadcasting the permission_request — send resolved directly
91
- broadcast(wss, {
92
- type: "permission_resolved",
93
- sessionId: event.sessionId,
94
- requestId: event.requestId,
95
- optionName,
96
- denied: false,
97
- });
98
- return;
99
- }
100
- }
101
- break;
102
- }
103
- case "prompt_done":
104
- sessions.activePrompts.delete(event.sessionId);
105
- sessions.flushBuffers(event.sessionId);
106
- store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason });
107
- break;
108
- case "error":
109
- if (event.sessionId) {
110
- sessions.activePrompts.delete(event.sessionId);
111
- }
112
- break;
113
- }
114
- broadcast(wss, event);
40
+ handleAgentEvent(event, sessions, store, wss, b, { cancelTimeout: config.limits.cancel_timeout }, pushService);
115
41
  });
116
42
  await b.start();
117
43
  bridge = b;
package/lib/store.js CHANGED
@@ -27,6 +27,13 @@ export class Store {
27
27
  created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%f', 'now'))
28
28
  );
29
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
+ );
30
37
  `);
31
38
  // Migrate existing tables: add columns if missing
32
39
  const cols = this.db.prepare("PRAGMA table_info(sessions)").all();
@@ -95,6 +102,18 @@ export class Store {
95
102
  query += " ORDER BY seq";
96
103
  return this.db.prepare(query).all(...params);
97
104
  }
105
+ // --- Push subscriptions ---
106
+ saveSubscription(endpoint, auth, p256dh) {
107
+ this.db.prepare(`INSERT INTO push_subscriptions (endpoint, auth, p256dh)
108
+ VALUES (?, ?, ?)
109
+ ON CONFLICT(endpoint) DO UPDATE SET auth = excluded.auth, p256dh = excluded.p256dh`).run(endpoint, auth, p256dh);
110
+ }
111
+ removeSubscription(endpoint) {
112
+ this.db.prepare("DELETE FROM push_subscriptions WHERE endpoint = ?").run(endpoint);
113
+ }
114
+ getAllSubscriptions() {
115
+ return this.db.prepare("SELECT * FROM push_subscriptions").all();
116
+ }
98
117
  close() {
99
118
  this.db.close();
100
119
  }
package/lib/types.js CHANGED
@@ -31,6 +31,7 @@ export const WsMessageSchema = z.discriminatedUnion("type", [
31
31
  z.object({ type: z.literal("set_config_option"), sessionId: z.string(), configId: z.string(), value: z.string() }),
32
32
  z.object({ type: z.literal("bash_exec"), sessionId: z.string(), command: z.string() }),
33
33
  z.object({ type: z.literal("bash_cancel"), sessionId: z.string() }),
34
+ z.object({ type: z.literal("visibility"), visible: z.boolean() }),
34
35
  ]);
35
36
  // --- Utility ---
36
37
  export function errorMessage(err) {
package/lib/ws-handler.js CHANGED
@@ -41,9 +41,13 @@ function send(ws, event) {
41
41
  }
42
42
  }
43
43
  export function setupWsHandler(deps) {
44
- const { wss, store, sessions, titleService, getBridge, limits } = deps;
44
+ const { wss, store, sessions, titleService, getBridge, limits, pushService } = deps;
45
+ let nextClientId = 1;
45
46
  wss.on("connection", (ws) => {
47
+ const clientId = `ws-${nextClientId++}`;
46
48
  console.log(`[ws] client connected (total: ${wss.clients.size})`);
49
+ // Track client for push notification visibility — actual state sent by client
50
+ // (no default assumed; client sends visibility message in onopen)
47
51
  const pingInterval = setInterval(() => {
48
52
  if (ws.readyState === WebSocket.OPEN)
49
53
  ws.ping();
@@ -235,6 +239,15 @@ export function setupWsHandler(deps) {
235
239
  const stored = outputTruncated ? "[truncated]\n" + output : output;
236
240
  store.saveEvent(msg.sessionId, "bash_result", { output: stored, code, signal });
237
241
  broadcast(wss, { type: "bash_done", sessionId: msg.sessionId, code, signal });
242
+ // Push notification for bash completion
243
+ if (pushService) {
244
+ const session = store.getSession(msg.sessionId);
245
+ const eventData = { command: msg.command, exitCode: code };
246
+ if (pushService.maybeNotify(msg.sessionId, session?.title ?? null, "bash_done", eventData)) {
247
+ const notification = pushService.formatNotification(msg.sessionId, session?.title ?? null, "bash_done", eventData);
248
+ pushService.sendToAll(notification).catch(err => console.error("[push] failed to send:", err));
249
+ }
250
+ }
238
251
  });
239
252
  child.on("error", (err) => {
240
253
  sessions.runningBashProcs.delete(msg.sessionId);
@@ -248,6 +261,10 @@ export function setupWsHandler(deps) {
248
261
  interruptBashProc(sessions.runningBashProcs.get(msg.sessionId));
249
262
  break;
250
263
  }
264
+ case "visibility": {
265
+ pushService?.setClientVisibility(clientId, msg.visible);
266
+ break;
267
+ }
251
268
  }
252
269
  }
253
270
  catch (err) {
@@ -256,6 +273,7 @@ export function setupWsHandler(deps) {
256
273
  });
257
274
  ws.on("close", () => {
258
275
  clearInterval(pingInterval);
276
+ pushService?.removeClient(clientId);
259
277
  console.log(`[ws] client disconnected (total: ${wss.clients.size})`);
260
278
  });
261
279
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lelouchhe/webagent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "A terminal-style web UI for ACP-compatible agents",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -41,8 +41,10 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@agentclientprotocol/sdk": "^0.14.1",
44
+ "@types/web-push": "^3.6.4",
44
45
  "better-sqlite3": "^12.6.2",
45
46
  "smol-toml": "^1.6.0",
47
+ "web-push": "^3.6.7",
46
48
  "ws": "^8.19.0",
47
49
  "zod": "^4.3.6"
48
50
  },
@@ -1,10 +0,0 @@
1
- // Boot entry point — imports all modules and starts the app
2
-
3
- import './render.mmk25uhc.js'; // theme, click-to-collapse listeners
4
- import './commands.mmk25uhc.js'; // slash menu listeners
5
- import './images.mmk25uhc.js'; // attach/paste listeners
6
- import './input.mmk25uhc.js'; // keyboard/send listeners
7
- import { connect } from './connection.mmk25uhc.js';
8
-
9
- connect();
10
- if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js');