@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/README.md +49 -10
- package/bin/webagent.mjs +31 -18
- package/config.toml +3 -0
- package/dist/index.html +3 -3
- package/dist/js/app.mmlj0xfy.js +34 -0
- package/dist/js/{commands.mmk25uhc.js → commands.mmlj0xfy.js} +198 -5
- package/dist/js/{connection.mmk25uhc.js → connection.mmlj0xfy.js} +14 -3
- package/dist/js/{events.mmk25uhc.js → events.mmlj0xfy.js} +66 -4
- package/dist/js/{images.mmk25uhc.js → images.mmlj0xfy.js} +1 -1
- package/dist/js/{input.mmk25uhc.js → input.mmlj0xfy.js} +23 -4
- package/dist/js/{render.mmk25uhc.js → render.mmlj0xfy.js} +1 -1
- package/dist/js/{state.mmk25uhc.js → state.mmlj0xfy.js} +2 -0
- package/dist/sw.js +47 -1
- package/lib/config.js +5 -0
- package/lib/daemon.js +278 -0
- package/lib/event-handler.js +95 -0
- package/lib/push-service.js +112 -0
- package/lib/routes.js +66 -1
- package/lib/server.js +7 -81
- package/lib/store.js +19 -0
- package/lib/types.js +1 -0
- package/lib/ws-handler.js +19 -1
- package/package.json +3 -1
- package/dist/js/app.mmk25uhc.js +0 -10
- /package/dist/{styles.mmk25uhc.css → styles.mmlj0xfy.css} +0 -0
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
|
-
|
|
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
|
-
- `/
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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.
|
|
16
|
+
<link rel="stylesheet" href="/styles.mmlj0xfy.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 ?"
|
|
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.
|
|
44
|
+
<script type="module" src="/js/app.mmlj0xfy.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.mmlj0xfy.js'; // theme, click-to-collapse listeners
|
|
4
|
+
import './commands.mmlj0xfy.js'; // slash menu listeners
|
|
5
|
+
import './images.mmlj0xfy.js'; // attach/paste listeners
|
|
6
|
+
import './input.mmlj0xfy.js'; // keyboard/send listeners
|
|
7
|
+
import { connect } from './connection.mmlj0xfy.js';
|
|
8
|
+
import { state, setHashSessionId, resetSessionUI, updateSessionInfo } from './state.mmlj0xfy.js';
|
|
9
|
+
import { loadHistory } from './events.mmlj0xfy.js';
|
|
10
|
+
import { addSystem, scrollToBottom } from './render.mmlj0xfy.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.
|
|
8
|
-
import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.
|
|
9
|
-
import { loadHistory } from './events.
|
|
7
|
+
} from './state.mmlj0xfy.js';
|
|
8
|
+
import { addSystem, addMessage, scrollToBottom, escHtml, formatLocalTime } from './render.mmlj0xfy.js';
|
|
9
|
+
import { loadHistory } from './events.mmlj0xfy.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
|
-
|
|
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.
|
|
4
|
-
import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.
|
|
5
|
-
import { handleEvent, loadHistory, loadNewEvents } from './events.
|
|
3
|
+
import { state, setBusy, getHashSessionId, requestNewSession, resetSessionUI, setConnectionStatus, clearCancelTimer } from './state.mmlj0xfy.js';
|
|
4
|
+
import { addSystem, finishThinking, finishAssistant, finishBash, scrollToBottom } from './render.mmlj0xfy.js';
|
|
5
|
+
import { handleEvent, loadHistory, loadNewEvents, retryUnconfirmedPermissions } from './events.mmlj0xfy.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.
|
|
7
|
+
} from './state.mmlj0xfy.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.
|
|
12
|
+
} from './render.mmlj0xfy.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
|
|