@lelouchhe/webagent 0.1.4 → 0.1.6

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 CHANGED
@@ -101,7 +101,20 @@ npm start # start on port 6800
101
101
  npm run dev # port 6801, uses data-dev/, auto-restarts on file changes
102
102
  ```
103
103
 
104
- How you keep the process running in the background is intentionally left to your own environment and preferred process manager.
104
+ ### Service management
105
+
106
+ WebAgent includes a built-in daemon with crash recovery:
107
+
108
+ ```bash
109
+ webagent start --config config.toml # start as background daemon
110
+ webagent stop # stop the daemon
111
+ webagent restart # atomic restart (Unix) / stop+start (Windows)
112
+ webagent status # show running state
113
+ ```
114
+
115
+ The daemon writes a PID file (`webagent.pid`) and log file (`webagent.log`) in the current directory. Run all commands from the same directory.
116
+
117
+ For auto-start on boot, see [docs/autostart.md](docs/autostart.md) (launchd, systemd, crontab, and Windows Task Scheduler examples).
105
118
 
106
119
  ### Configuration
107
120
 
@@ -123,6 +136,7 @@ If no `--config` is provided, all settings use built-in defaults. See `config.to
123
136
  | `limits.bash_output` | `1048576` (1 MB) | Max bash output stored in DB per command |
124
137
  | `limits.image_upload` | `10485760` (10 MB) | Max image upload size |
125
138
  | `limits.cancel_timeout` | `10000` (10s) | Cancel timeout in ms; 0 disables |
139
+ | `push.vapid_subject` | `mailto:webagent@localhost` | VAPID subject for Web Push (email or URL) |
126
140
 
127
141
  To use a different ACP-compatible agent backend:
128
142
 
@@ -162,12 +176,20 @@ agent_cmd = "my-agent --acp"
162
176
  - After server restart, restores session context via ACP `loadSession` so conversations can continue
163
177
  - Auto-generated titles (async, using a fast model)
164
178
  - Session history persisted in SQLite, survives restarts
165
- - `/sessions` lists all sessions (git-branch style, `*` marks current in green)
179
+ - `/switch` lists all sessions (git-branch style, `*` marks current in green)
166
180
  - Switching sessions replays full message history
167
181
 
168
182
  ### Slash Commands
169
183
 
170
- Type `/` to trigger an autocomplete menu (arrow keys to navigate, Tab to select, Esc to close).
184
+ Type `/` to trigger an autocomplete menu with arrow keys to navigate, Esc to close.
185
+
186
+ | Key | In menu | Without menu |
187
+ |---|---|---|
188
+ | `Tab` | Fill selected item into input | — |
189
+ | `Enter` | Send current input | Send current input |
190
+ | Click/Tap | Fill and send (Tab + Enter) | — |
191
+
192
+ Commands with submenus (`/model`, `/mode`, `/think`, `/notify`, `/switch`, `/delete`, `/new`) show a picker after typing the command and a space. Tab completes the selection into the input so you can review or edit before pressing Enter to send.
171
193
 
172
194
  | Command | Description |
173
195
  |---|---|
@@ -176,11 +198,13 @@ Type `/` to trigger an autocomplete menu (arrow keys to navigate, Tab to select,
176
198
  | `/model [name]` | View or switch model (fuzzy match, e.g. `/model opus`) |
177
199
  | `/mode [name]` | View or switch mode (Agent / Plan / Autopilot) |
178
200
  | `/think [level]` | View or switch reasoning effort (low / medium / high) |
201
+ | `/notify [on\|off]` | Toggle push notifications for background alerts |
179
202
  | `/cancel` | Cancel current response |
180
203
  | `/switch <title\|id>` | Switch to a session (match by title or ID prefix) |
181
204
  | `/delete <title\|id>` | Delete a session |
182
205
  | `/prune` | Delete all sessions except current |
183
- | `/help` | Show help |
206
+
207
+ Type `?` for inline help listing all commands and shortcuts.
184
208
 
185
209
  ### Keyboard Shortcuts
186
210
 
@@ -203,6 +227,7 @@ Tap the `❯` prompt indicator to cycle mode. Tap `new` to create a new session
203
227
  ### Other
204
228
 
205
229
  - PWA support (installable to home screen)
230
+ - Web Push notifications — background alerts when no browser tab is visible (use `/notify on`)
206
231
  - WebSocket auto-reconnect (3s retry on disconnect)
207
232
  - 30s heartbeat keepalive
208
233
  - Auto-expanding input box
@@ -228,16 +253,20 @@ Browser ←WebSocket→ server.ts ←ACP→ copilot CLI
228
253
  ├── ws-handler.ts (WS dispatch)
229
254
  ├── session-manager.ts (state)
230
255
  ├── title-service.ts (auto-title)
256
+ ├── push-service.ts (Web Push)
257
+ ├── daemon.ts (background service)
231
258
  └── store.ts (SQLite)
232
259
  ```
233
260
 
234
261
  - **server.ts** — HTTP/WebSocket server bootstrap
235
- - **routes.ts** — HTTP request handlers (static files, REST API, image upload)
236
- - **ws-handler.ts** — WebSocket message dispatch + broadcast
262
+ - **routes.ts** — HTTP request handlers (static files, REST API, image upload, push subscription)
263
+ - **ws-handler.ts** — WebSocket message dispatch + broadcast + visibility tracking
237
264
  - **session-manager.ts** — Session state management (live sessions, buffers, bash procs, model cache)
238
265
  - **bridge.ts** — ACP bridge, manages agent subprocess, handles permissions and file I/O
239
- - **store.ts** — SQLite persistence (sessions + events tables, WAL mode)
266
+ - **store.ts** — SQLite persistence (sessions, events, push subscriptions; WAL mode)
240
267
  - **title-service.ts** — Async session title generation (dedicated Haiku session)
268
+ - **push-service.ts** — Web Push notifications (VAPID keys, subscriptions, visibility-gated delivery)
269
+ - **daemon.ts** — Background service management (start/stop/status/restart) with supervisor
241
270
  - **types.ts** — Shared types + Zod schemas for WS messages
242
271
 
243
272
  ## ACP Scope and Current Limits
@@ -250,10 +279,20 @@ Current scope in this repo:
250
279
  - The UI renders a subset of ACP session updates: assistant text, thinking text, tool calls, tool call updates, and plans
251
280
  - Session history is persisted locally and restored after server restart
252
281
 
253
- Current limits:
282
+ ### Client extensions
283
+
284
+ ACP allows the client to inject extra capabilities into the agent on top of its native baseline. WebAgent currently provides:
285
+
286
+ | Extension | Status | Notes |
287
+ |---|---|---|
288
+ | `fs` (readTextFile / writeTextFile) | ✅ Implemented | Agent can read/write files through the client |
289
+ | `terminal` | Declared but not wired | `!<command>` runs via the app's own local bash bridge, not ACP `terminal/*` |
290
+ | `mcpServers` | `[]` (no extras) | Agent's own MCP servers (e.g. GitHub MCP) work normally; passing `[]` means the client isn't providing additional ones |
291
+
292
+ Passing `mcpServers: []` does **not** disable MCP — the agent loads its own configured MCP servers independently. The parameter is for the client to provide _additional_ servers the agent wouldn't have on its own.
293
+
294
+ ### Current limits
254
295
 
255
- - MCP servers are not forwarded to the agent; sessions are created with an empty `mcpServers` list
256
- - ACP terminal APIs are not used; `!<command>` runs through the app's own local `bash` bridge instead of an ACP-managed terminal session
257
296
  - The web UI does not expose native CLI command surfaces such as `/plan`, `/fleet`, `/mcp`, `/agent`, or `/skills`
258
297
  - Autopilot mode is supported: permissions are auto-approved server-side using `allow_once`
259
298
  - Event handling is intentionally narrower than a native CLI client; only selected ACP updates are rendered/persisted, and the silent title-generation session suppresses normal UI events
package/bin/webagent.mjs CHANGED
@@ -5,22 +5,35 @@ import { fileURLToPath } from "node:url";
5
5
  import { dirname, join } from "node:path";
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
- const server = join(__dirname, "..", "lib", "server.js");
9
-
10
- const child = spawn(
11
- process.execPath,
12
- [server, ...process.argv.slice(2)],
13
- { stdio: "inherit" },
14
- );
15
-
16
- const signals = process.platform === "win32"
17
- ? ["SIGINT", "SIGTERM"]
18
- : ["SIGINT", "SIGTERM", "SIGHUP"];
19
- for (const sig of signals) {
20
- process.on(sig, () => child.kill(sig));
21
- }
22
8
 
23
- child.on("exit", (code, signal) => {
24
- if (signal) process.kill(process.pid, signal);
25
- else process.exit(code ?? 1);
26
- });
9
+ // ---- Service management subcommands (start/stop/status/restart) -----------
10
+
11
+ const SUBCOMMANDS = new Set(["start", "stop", "status", "restart"]);
12
+ const cmd = process.argv[2];
13
+
14
+ if (cmd && SUBCOMMANDS.has(cmd)) {
15
+ const daemonUrl = new URL("../lib/daemon.js", import.meta.url).href;
16
+ const { run } = await import(daemonUrl);
17
+ await run(cmd, process.argv.slice(3));
18
+ } else {
19
+ // ---- Direct server launch (foreground) ----------------------------------
20
+ const server = join(__dirname, "..", "lib", "server.js");
21
+
22
+ const child = spawn(
23
+ process.execPath,
24
+ [server, ...process.argv.slice(2)],
25
+ { stdio: "inherit" },
26
+ );
27
+
28
+ const signals = process.platform === "win32"
29
+ ? ["SIGINT", "SIGTERM"]
30
+ : ["SIGINT", "SIGTERM", "SIGHUP"];
31
+ for (const sig of signals) {
32
+ process.on(sig, () => child.kill(sig));
33
+ }
34
+
35
+ child.on("exit", (code, signal) => {
36
+ if (signal) process.kill(process.pid, signal);
37
+ else process.exit(code ?? 1);
38
+ });
39
+ }
package/config.toml CHANGED
@@ -26,3 +26,6 @@ image_upload = 10_485_760
26
26
  # Cancel timeout (ms, default 10s). After sending cancel, if the agent
27
27
  # does not respond within this time the UI resets to idle. Set to 0 to disable.
28
28
  cancel_timeout = 10_000
29
+
30
+ [push]
31
+ vapid_subject = "mailto:noreply@example.com"
package/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
14
14
  <script src="https://cdn.jsdelivr.net/npm/dompurify@3.3.2/dist/purify.min.js"></script>
15
15
  <script>document.documentElement.setAttribute('data-theme', localStorage.getItem('theme') || 'auto');</script>
16
- <link rel="stylesheet" href="/styles.mmk25uhc.css">
16
+ <link rel="stylesheet" href="/styles.mmlj9sk2.css">
17
17
  </head>
18
18
  <body>
19
19
 
@@ -34,13 +34,13 @@
34
34
  <div id="input-area">
35
35
  <div id="slash-menu"></div>
36
36
  <span id="input-prompt" title="Cycle mode (Ctrl+M)">❯ </span>
37
- <textarea id="input" rows="1" placeholder="Message or ?" autofocus></textarea>
37
+ <textarea id="input" rows="1" placeholder="Message or ?"></textarea>
38
38
  <button id="new-btn" class="input-btn" title="New session">new</button>
39
39
  <button id="attach-btn" class="input-btn" title="Attach image (Ctrl+U)">^U</button>
40
40
  <button id="send-btn" class="input-btn" title="Send (Enter)">↵</button>
41
41
  <input type="file" id="file-input" accept="image/*" multiple hidden>
42
42
  </div>
43
43
 
44
- <script type="module" src="/js/app.mmk25uhc.js"></script>
44
+ <script type="module" src="/js/app.mmlj9sk2.js"></script>
45
45
  </body>
46
46
  </html>
@@ -0,0 +1,34 @@
1
+ // Boot entry point — imports all modules and starts the app
2
+
3
+ import './render.mmlj9sk2.js'; // theme, click-to-collapse listeners
4
+ import './commands.mmlj9sk2.js'; // slash menu listeners
5
+ import './images.mmlj9sk2.js'; // attach/paste listeners
6
+ import './input.mmlj9sk2.js'; // keyboard/send listeners
7
+ import { connect } from './connection.mmlj9sk2.js';
8
+ import { state, setHashSessionId, resetSessionUI, updateSessionInfo } from './state.mmlj9sk2.js';
9
+ import { loadHistory } from './events.mmlj9sk2.js';
10
+ import { addSystem, scrollToBottom } from './render.mmlj9sk2.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
+ }
@@ -4,9 +4,69 @@ import {
4
4
  state, dom, setBusy, resetSessionUI, requestNewSession, sendCancel,
5
5
  getConfigOption, getConfigValue, setHashSessionId, updateSessionInfo,
6
6
  updateNewBtnVisibility,
7
- } from './state.mmk25uhc.js';
8
- import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.mmk25uhc.js';
9
- import { loadHistory } from './events.mmk25uhc.js';
7
+ } from './state.mmlj9sk2.js';
8
+ import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.mmlj9sk2.js';
9
+ import { loadHistory } from './events.mmlj9sk2.js';
10
+
11
+ // --- Push notification helpers ---
12
+
13
+ async function subscribePush() {
14
+ try {
15
+ const reg = await navigator.serviceWorker?.ready;
16
+ if (!reg) return;
17
+ const res = await fetch('/api/push/vapid-key');
18
+ if (!res.ok) return;
19
+ const { publicKey } = await res.json();
20
+ const sub = await reg.pushManager.subscribe({
21
+ userVisibleOnly: true,
22
+ applicationServerKey: urlBase64ToUint8Array(publicKey),
23
+ });
24
+ const json = sub.toJSON();
25
+ await fetch('/api/push/subscribe', {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ body: JSON.stringify({ endpoint: json.endpoint, keys: json.keys }),
29
+ });
30
+ } catch (err) {
31
+ console.error('[push] subscribe failed:', err);
32
+ }
33
+ }
34
+
35
+ async function unsubscribePush() {
36
+ try {
37
+ const reg = await navigator.serviceWorker?.ready;
38
+ if (!reg) return;
39
+ const sub = await reg.pushManager.getSubscription();
40
+ if (!sub) return;
41
+ const endpoint = sub.endpoint;
42
+ await sub.unsubscribe();
43
+ await fetch('/api/push/unsubscribe', {
44
+ method: 'POST',
45
+ headers: { 'Content-Type': 'application/json' },
46
+ body: JSON.stringify({ endpoint }),
47
+ });
48
+ } catch (err) {
49
+ console.error('[push] unsubscribe failed:', err);
50
+ }
51
+ }
52
+
53
+ function urlBase64ToUint8Array(base64String) {
54
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
55
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
56
+ const raw = atob(base64);
57
+ const arr = new Uint8Array(raw.length);
58
+ for (let i = 0; i < raw.length; i++) arr[i] = raw.charCodeAt(i);
59
+ return arr;
60
+ }
61
+
62
+ async function hasActiveSubscription() {
63
+ try {
64
+ const reg = await navigator.serviceWorker?.ready;
65
+ if (!reg) return false;
66
+ const sub = await reg.pushManager.getSubscription();
67
+ return sub !== null;
68
+ } catch { return false; }
69
+ }
10
70
 
11
71
  // --- Slash command execution ---
12
72
 
@@ -170,6 +230,50 @@ export async function handleSlashCommand(text) {
170
230
  return true;
171
231
  }
172
232
 
233
+ case '/notify': {
234
+ if (typeof Notification === 'undefined') {
235
+ addSystem('err: notifications not supported in this browser');
236
+ return true;
237
+ }
238
+
239
+ const sub = arg.toLowerCase();
240
+
241
+ if (sub === 'on') {
242
+ if (Notification.permission === 'denied') {
243
+ addSystem('notify: blocked — allow in browser site settings to enable');
244
+ return true;
245
+ }
246
+ if (Notification.permission !== 'granted') {
247
+ const result = await Notification.requestPermission();
248
+ if (result !== 'granted') {
249
+ addSystem('notify: blocked — allow in browser site settings to enable');
250
+ return true;
251
+ }
252
+ }
253
+ const alreadyActive = await hasActiveSubscription();
254
+ await subscribePush();
255
+ addSystem(alreadyActive ? 'notify: already enabled' : 'notify: enabled');
256
+ return true;
257
+ }
258
+
259
+ if (sub === 'off') {
260
+ await unsubscribePush();
261
+ addSystem('notify: disabled');
262
+ return true;
263
+ }
264
+
265
+ // No argument — show status based on actual subscription
266
+ const perm = Notification.permission;
267
+ if (perm === 'denied') {
268
+ addSystem('notify: blocked — allow in browser site settings to enable');
269
+ } else if (perm === 'granted' && await hasActiveSubscription()) {
270
+ addSystem('notify: enabled');
271
+ } else {
272
+ addSystem('notify: off — use /notify on to enable');
273
+ }
274
+ return true;
275
+ }
276
+
173
277
  default:
174
278
  return false;
175
279
  }
@@ -183,6 +287,7 @@ const SLASH_COMMANDS = [
183
287
  { cmd: '/mode', args: '[name]', desc: 'Pick or switch mode' },
184
288
  { cmd: '/model', args: '[name]', desc: 'Pick or switch model' },
185
289
  { cmd: '/new', args: '[cwd]', desc: 'New session' },
290
+ { cmd: '/notify', args: '[on|off]', desc: 'Toggle background notifications' },
186
291
  { cmd: '/prune', args: '', desc: 'Delete all sessions except current' },
187
292
  { cmd: '/pwd', args: '', desc: 'Show working directory' },
188
293
  { cmd: '/switch', args: '<title|id>', desc: 'Switch to session' },
@@ -238,6 +343,14 @@ export function updateSlashMenu() {
238
343
  return;
239
344
  }
240
345
 
346
+ // /notify — show on/off picker
347
+ const notifyMatch = text.match(/^\/notify /);
348
+ if (notifyMatch) {
349
+ const query = text.slice(notifyMatch[0].length).toLowerCase();
350
+ showNotifyMenu(query);
351
+ return;
352
+ }
353
+
241
354
  if (!text.startsWith('/') || text.includes(' ')) {
242
355
  hideSlashMenu();
243
356
  return;
@@ -327,6 +440,26 @@ function showConfigMenu(configId, query) {
327
440
  dom.slashMenu.classList.add('active');
328
441
  }
329
442
 
443
+ const NOTIFY_OPTIONS = [
444
+ { value: 'on', name: 'on', desc: 'Enable background notifications' },
445
+ { value: 'off', name: 'off', desc: 'Disable background notifications' },
446
+ ];
447
+
448
+ function showNotifyMenu(query) {
449
+ slashMode = 'notify';
450
+ slashFiltered = NOTIFY_OPTIONS.filter(o => {
451
+ if (!query) return true;
452
+ return o.value.includes(query) || o.name.includes(query);
453
+ });
454
+ if (slashFiltered.length === 0) {
455
+ hideSlashMenu();
456
+ return;
457
+ }
458
+ slashIdx = 0;
459
+ renderSlashMenu();
460
+ dom.slashMenu.classList.add('active');
461
+ }
462
+
330
463
  function renderSlashMenu() {
331
464
  if (slashMode === 'new') {
332
465
  const currentCwd = (state.sessionCwd || '').toLowerCase();
@@ -344,6 +477,15 @@ function renderSlashMenu() {
344
477
  const style = isCurrent ? ' style="color:var(--green)"' : '';
345
478
  return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + o.name)}</span></div>`;
346
479
  }).join('');
480
+ } else if (slashMode === 'notify') {
481
+ const perm = typeof Notification !== 'undefined' ? Notification.permission : 'unsupported';
482
+ const currentVal = perm === 'granted' ? 'on' : 'off';
483
+ dom.slashMenu.innerHTML = slashFiltered.map((o, i) => {
484
+ const isCurrent = o.value === currentVal;
485
+ const prefix = isCurrent ? '* ' : ' ';
486
+ const style = isCurrent ? ' style="color:var(--green)"' : '';
487
+ return `<div class="slash-item${i === slashIdx ? ' selected' : ''}" data-idx="${i}"><span class="slash-cmd"${style}>${escHtml(prefix + o.name)}</span><span class="slash-desc">${escHtml(o.desc)}</span></div>`;
488
+ }).join('');
347
489
  } else if (slashMode === 'switch' || slashMode === 'delete') {
348
490
  dom.slashMenu.innerHTML = slashFiltered.map((s, i) => {
349
491
  const isCurrent = s.id === state.sessionId;
@@ -371,6 +513,50 @@ export function hideSlashMenu() {
371
513
  slashDismissed = dom.input.value;
372
514
  }
373
515
 
516
+ // Tab: fill input only, never execute
517
+ function tabCompleteSlashItem(idx) {
518
+ if (idx < 0 || idx >= slashFiltered.length) return;
519
+
520
+ if (slashMode === 'commands') {
521
+ const item = slashFiltered[idx];
522
+ dom.input.value = item.cmd + (item.args ? ' ' : '');
523
+ hideSlashMenu();
524
+ dom.input.focus();
525
+ if (['/new', '/switch', '/delete', '/model', '/mode', '/think', '/notify'].includes(item.cmd)) {
526
+ slashDismissed = null;
527
+ updateSlashMenu();
528
+ }
529
+ } else if (slashMode === 'config') {
530
+ const o = slashFiltered[idx];
531
+ const configCmd = { model: '/model', mode: '/mode', reasoning_effort: '/think' }[slashConfigId] || `/${slashConfigId}`;
532
+ dom.input.value = `${configCmd} ${o.name}`;
533
+ hideSlashMenu();
534
+ dom.input.focus();
535
+ } else if (slashMode === 'notify') {
536
+ const o = slashFiltered[idx];
537
+ dom.input.value = `/notify ${o.value}`;
538
+ hideSlashMenu();
539
+ dom.input.focus();
540
+ } else if (slashMode === 'new') {
541
+ const p = slashFiltered[idx];
542
+ dom.input.value = `/new ${p.cwd}`;
543
+ hideSlashMenu();
544
+ dom.input.focus();
545
+ } else if (slashMode === 'switch') {
546
+ const s = slashFiltered[idx];
547
+ dom.input.value = `/switch ${s.title || s.id}`;
548
+ hideSlashMenu();
549
+ dom.input.focus();
550
+ } else if (slashMode === 'delete') {
551
+ const s = slashFiltered[idx];
552
+ dom.input.value = `/delete ${s.title || s.id}`;
553
+ hideSlashMenu();
554
+ dom.input.focus();
555
+ }
556
+ updateNewBtnVisibility();
557
+ }
558
+
559
+ // Click: fill input AND execute (equivalent to tab + enter)
374
560
  function selectSlashItem(idx) {
375
561
  if (idx < 0 || idx >= slashFiltered.length) return;
376
562
 
@@ -407,12 +593,19 @@ function selectSlashItem(idx) {
407
593
  hideSlashMenu();
408
594
  state.ws.send(JSON.stringify({ type: 'delete_session', sessionId: s.id }));
409
595
  addSystem(`Deleted: ${s.title || s.id.slice(0, 8) + '…'}`);
596
+ } else if (slashMode === 'notify') {
597
+ const o = slashFiltered[idx];
598
+ dom.input.value = `/notify ${o.value}`;
599
+ hideSlashMenu();
600
+ // Trigger command execution by simulating send
601
+ handleSlashCommand(dom.input.value);
602
+ dom.input.value = '';
410
603
  } else {
411
604
  const item = slashFiltered[idx];
412
605
  dom.input.value = item.cmd + (item.args ? ' ' : '');
413
606
  hideSlashMenu();
414
607
  dom.input.focus();
415
- if (['/new', '/switch', '/delete', '/model', '/mode', '/think'].includes(item.cmd)) {
608
+ if (['/new', '/switch', '/delete', '/model', '/mode', '/think', '/notify'].includes(item.cmd)) {
416
609
  slashDismissed = null;
417
610
  updateSlashMenu();
418
611
  }
@@ -434,7 +627,7 @@ export function handleSlashMenuKey(e) {
434
627
  return true;
435
628
  }
436
629
  if (e.key === 'Tab') {
437
- selectSlashItem(slashIdx);
630
+ tabCompleteSlashItem(slashIdx);
438
631
  return true;
439
632
  }
440
633
  return false;
@@ -1,8 +1,8 @@
1
1
  // WebSocket connection lifecycle
2
2
 
3
- import { state, setBusy, getHashSessionId, requestNewSession, resetSessionUI, setConnectionStatus, clearCancelTimer } from './state.mmk25uhc.js';
4
- import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.mmk25uhc.js';
5
- import { handleEvent, loadHistory, loadNewEvents } from './events.mmk25uhc.js';
3
+ import { state, setBusy, getHashSessionId, requestNewSession, resetSessionUI, setConnectionStatus, clearCancelTimer } from './state.mmlj9sk2.js';
4
+ import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.mmlj9sk2.js';
5
+ import { handleEvent, loadHistory, loadNewEvents, retryUnconfirmedPermissions } from './events.mmlj9sk2.js';
6
6
 
7
7
  export function connect() {
8
8
  const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
@@ -11,12 +11,15 @@ export function connect() {
11
11
 
12
12
  state.ws.onopen = async () => {
13
13
  setConnectionStatus('connecting', 'session loading');
14
+ // Report actual visibility to server for push notification gating
15
+ state.ws.send(JSON.stringify({ type: 'visibility', visible: !document.hidden }));
14
16
 
15
17
  const existingId = getHashSessionId();
16
18
 
17
19
  // Incremental reconnect: same session still in memory — skip DOM wipe
18
20
  if (existingId && existingId === state.sessionId && state.lastEventSeq > 0) {
19
21
  await loadNewEvents(existingId);
22
+ retryUnconfirmedPermissions();
20
23
  scrollToBottom(false);
21
24
  state.ws.send(JSON.stringify({ type: 'resume_session', sessionId: existingId }));
22
25
  return;
@@ -74,3 +77,11 @@ export function connect() {
74
77
  handleEvent(msg);
75
78
  };
76
79
  }
80
+
81
+ // --- Visibility reporting for push notifications ---
82
+
83
+ document.addEventListener('visibilitychange', () => {
84
+ if (state.ws && state.ws.readyState === 1) {
85
+ state.ws.send(JSON.stringify({ type: 'visibility', visible: !document.hidden }));
86
+ }
87
+ });
@@ -4,12 +4,35 @@ import {
4
4
  state, dom, setBusy, setConfigValue, getConfigOption, updateConfigOptions,
5
5
  updateModeUI, resetSessionUI, requestNewSession, setHashSessionId, updateSessionInfo,
6
6
  setConnectionStatus, clearCancelTimer,
7
- } from './state.mmk25uhc.js';
7
+ } from './state.mmlj9sk2.js';
8
8
  import {
9
9
  addMessage, addSystem, finishAssistant, finishThinking, hideWaiting,
10
10
  scrollToBottom, renderMd, escHtml, renderPatchDiff, addBashBlock, finishBash, appendMessageElement,
11
11
  formatLocalTime,
12
- } from './render.mmk25uhc.js';
12
+ } from './render.mmlj9sk2.js';
13
+
14
+ const NOTIFY_TIP_KEY = 'webagent_notify_tip_shown';
15
+ const NOTIFY_TIP_DENIED_KEY = 'webagent_notify_tip_denied_shown';
16
+
17
+ function showNotifyTip() {
18
+ if (typeof Notification === 'undefined') return;
19
+ if (state.replayInProgress) return;
20
+
21
+ const perm = Notification.permission;
22
+ if (perm === 'granted') return; // already enabled
23
+
24
+ if (perm === 'denied') {
25
+ if (localStorage.getItem(NOTIFY_TIP_DENIED_KEY)) return;
26
+ localStorage.setItem(NOTIFY_TIP_DENIED_KEY, '1');
27
+ addSystem('tip: notifications are blocked — allow in browser site settings to enable');
28
+ return;
29
+ }
30
+
31
+ // permission === 'default'
32
+ if (localStorage.getItem(NOTIFY_TIP_KEY)) return;
33
+ localStorage.setItem(NOTIFY_TIP_KEY, '1');
34
+ addSystem('tip: use /notify to enable background notifications');
35
+ }
13
36
 
14
37
  function finishPromptIfIdle() {
15
38
  if (!state.pendingPromptDone) return;
@@ -18,8 +41,8 @@ function finishPromptIfIdle() {
18
41
  finishThinking();
19
42
  finishAssistant();
20
43
  setBusy(false);
21
- dom.input.focus();
22
44
  state.pendingPromptDone = false;
45
+ showNotifyTip();
23
46
  }
24
47
 
25
48
  function cancelPendingTurnUI() {
@@ -118,6 +141,36 @@ export async function loadNewEvents(sid) {
118
141
  }
119
142
  }
120
143
 
144
+ /**
145
+ * Resend permission responses that were sent optimistically but never confirmed
146
+ * by the server (e.g. WS dropped before delivery). Call after loadNewEvents/loadHistory
147
+ * on reconnect.
148
+ */
149
+ export function retryUnconfirmedPermissions() {
150
+ for (const [requestId, response] of state.unconfirmedPermissions) {
151
+ const el = document.querySelector(`.permission[data-request-id="${requestId}"]`);
152
+ if (!el || !el.querySelector('button')) {
153
+ // Element gone or already resolved — clean up
154
+ state.unconfirmedPermissions.delete(requestId);
155
+ continue;
156
+ }
157
+ // Still pending in DOM — resend and optimistically resolve
158
+ if (state.ws && state.ws.readyState === 1) {
159
+ state.ws.send(JSON.stringify({
160
+ type: 'permission_response',
161
+ sessionId: response.sessionId,
162
+ requestId,
163
+ optionId: response.optionId,
164
+ optionName: response.optionName,
165
+ denied: response.denied,
166
+ }));
167
+ }
168
+ const title = el.dataset.title ? `⚿ ${escHtml(el.dataset.title)}` : '⚿';
169
+ el.innerHTML = `<span style="opacity:0.5">${title} — ${escHtml(response.optionName)}</span>`;
170
+ state.unconfirmedPermissions.delete(requestId);
171
+ }
172
+ }
173
+
121
174
  export function replayEvent(type, data, events, idx) {
122
175
  switch (type) {
123
176
  case 'user_message': {
@@ -468,6 +521,8 @@ export function handleEvent(msg) {
468
521
 
469
522
  case 'permission_request': {
470
523
  if (state.turnEnded) break;
524
+ // Dedup: skip if a permission element with this requestId already exists (e.g. bridge restore)
525
+ if (document.querySelector(`.permission[data-request-id="${msg.requestId}"]`)) break;
471
526
  state.pendingPermissionRequestIds.add(msg.requestId);
472
527
  setBusy(true);
473
528
  finishThinking();
@@ -494,6 +549,13 @@ export function handleEvent(msg) {
494
549
  }));
495
550
  } catch { /* connection may be broken; cleanup still runs */ }
496
551
  state.pendingPermissionRequestIds.delete(msg.requestId);
552
+ // Track for retry on reconnect (cleared when server confirms via permission_resolved)
553
+ state.unconfirmedPermissions.set(msg.requestId, {
554
+ sessionId: state.sessionId,
555
+ optionId: opt.optionId,
556
+ optionName: opt.name,
557
+ denied: isDeny,
558
+ });
497
559
  permEl.innerHTML = `<span style="opacity:0.5">⚿ ${escHtml(msg.title)} — ${escHtml(opt.name)}</span>`;
498
560
  finishPromptIfIdle();
499
561
  };
@@ -505,6 +567,7 @@ export function handleEvent(msg) {
505
567
 
506
568
  case 'permission_resolved': {
507
569
  state.pendingPermissionRequestIds.delete(msg.requestId);
570
+ state.unconfirmedPermissions.delete(msg.requestId);
508
571
  const permTarget = document.querySelector(`.permission[data-request-id="${msg.requestId}"]`);
509
572
  if (msg.sessionId === state.sessionId && permTarget) {
510
573
  const title = permTarget.dataset.title ? `⚿ ${permTarget.dataset.title}` : '⚿';
@@ -547,7 +610,6 @@ export function handleEvent(msg) {
547
610
  finishBash(state.currentBashEl, msg.code, msg.signal);
548
611
  if (msg.error) addSystem(`err: ${msg.error}`);
549
612
  setBusy(false);
550
- dom.input.focus();
551
613
  break;
552
614
  }
553
615