@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/README.md +21 -18
- package/config.toml +1 -1
- package/dist/index.html +2 -2
- package/dist/js/app.IXP5KGP6.js +8 -0
- package/dist/{styles.mmmmfxhu.css → styles.01a9ju9l.css} +8 -0
- package/dist/sw.js +1 -1
- package/lib/event-handler.js +15 -6
- package/lib/push-service.js +66 -10
- package/lib/routes.js +816 -89
- package/lib/server.js +12 -10
- package/lib/session-manager.js +79 -2
- package/lib/shared/constants.js +16 -0
- package/lib/sse-manager.js +80 -0
- package/lib/store.js +61 -7
- package/lib/types.js +0 -35
- package/package.json +5 -6
- package/dist/js/app.mmmmfxhu.js +0 -34
- package/dist/js/commands.mmmmfxhu.js +0 -647
- package/dist/js/connection.mmmmfxhu.js +0 -87
- package/dist/js/events.mmmmfxhu.js +0 -694
- package/dist/js/images.mmmmfxhu.js +0 -58
- package/dist/js/input.mmmmfxhu.js +0 -215
- package/dist/js/render.mmmmfxhu.js +0 -200
- package/dist/js/state.mmmmfxhu.js +0 -203
- package/lib/ws-handler.js +0 -280
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
A terminal-style web UI for ACP-compatible agents.
|
|
7
7
|
|
|
8
|
-
Tech stack: Node.js + TypeScript (`--experimental-strip-types`), real-time
|
|
8
|
+
Tech stack: Node.js + TypeScript (`--experimental-strip-types`), REST + SSE real-time communication, SQLite persistence (`better-sqlite3`), Zod validation, esbuild (frontend bundling).
|
|
9
9
|
|
|
10
10
|
## Screenshots
|
|
11
11
|
|
|
@@ -91,14 +91,14 @@ Data (SQLite database, uploaded images) is stored in `./data/` relative to your
|
|
|
91
91
|
git clone https://github.com/LelouchHe/webagent.git
|
|
92
92
|
cd webagent
|
|
93
93
|
npm install
|
|
94
|
-
npm run build #
|
|
94
|
+
npm run build # bundle frontend TS → dist/ (esbuild)
|
|
95
95
|
npm start # start on port 6800
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
### Development
|
|
99
99
|
|
|
100
100
|
```bash
|
|
101
|
-
npm run dev # port 6801,
|
|
101
|
+
npm run dev # port 6801, esbuild watch + server auto-restart on file changes
|
|
102
102
|
```
|
|
103
103
|
|
|
104
104
|
### Service management
|
|
@@ -128,7 +128,7 @@ If no `--config` is provided, all settings use built-in defaults. See `config.to
|
|
|
128
128
|
|
|
129
129
|
| Key | Default | Description |
|
|
130
130
|
|---|---|---|
|
|
131
|
-
| `port` | `6800` | HTTP
|
|
131
|
+
| `port` | `6800` | HTTP server port |
|
|
132
132
|
| `data_dir` | `data` | SQLite + uploads directory |
|
|
133
133
|
| `default_cwd` | `process.cwd()` | Working directory for new sessions |
|
|
134
134
|
| `public_dir` | `dist` | Static assets directory |
|
|
@@ -189,7 +189,7 @@ Type `/` to trigger an autocomplete menu with arrow keys to navigate, Esc to clo
|
|
|
189
189
|
| `Enter` | Send current input | Send current input |
|
|
190
190
|
| Click/Tap | Fill and send (Tab + Enter) | — |
|
|
191
191
|
|
|
192
|
-
Commands with submenus (`/model`, `/mode`, `/think`, `/notify`, `/switch`, `/
|
|
192
|
+
Commands with submenus (`/model`, `/mode`, `/think`, `/notify`, `/switch`, `/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.
|
|
193
193
|
|
|
194
194
|
| Command | Description |
|
|
195
195
|
|---|---|
|
|
@@ -201,7 +201,8 @@ Commands with submenus (`/model`, `/mode`, `/think`, `/notify`, `/switch`, `/del
|
|
|
201
201
|
| `/notify [on\|off]` | Toggle push notifications for background alerts |
|
|
202
202
|
| `/cancel` | Cancel current response |
|
|
203
203
|
| `/switch <title\|id>` | Switch to a session (match by title or ID prefix) |
|
|
204
|
-
| `/
|
|
204
|
+
| `/rename <new title>` | Rename current session |
|
|
205
|
+
| `/exit` | Close current session (delete + switch to previous) |
|
|
205
206
|
| `/prune` | Delete all sessions except current |
|
|
206
207
|
|
|
207
208
|
Type `?` for inline help listing all commands and shortcuts.
|
|
@@ -228,7 +229,7 @@ Tap the `❯` prompt indicator to cycle mode. Tap `new` to create a new session
|
|
|
228
229
|
|
|
229
230
|
- PWA support (installable to home screen)
|
|
230
231
|
- Web Push notifications — background alerts when no browser tab is visible (use `/notify on`)
|
|
231
|
-
-
|
|
232
|
+
- SSE auto-reconnect (3s retry on disconnect)
|
|
232
233
|
- 30s heartbeat keepalive
|
|
233
234
|
- Auto-expanding input box
|
|
234
235
|
- Mobile-friendly layout
|
|
@@ -248,26 +249,28 @@ npm run test:e2e # Playwright browser E2E
|
|
|
248
249
|
## Architecture
|
|
249
250
|
|
|
250
251
|
```
|
|
251
|
-
Browser ←
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
Browser ←REST+SSE→ server.ts ←ACP→ copilot CLI
|
|
253
|
+
├── routes.ts (HTTP handlers)
|
|
254
|
+
├── event-handler.ts (ACP event routing)
|
|
255
|
+
├── session-manager.ts (state)
|
|
256
|
+
├── title-service.ts (auto-title)
|
|
257
|
+
├── push-service.ts (Web Push)
|
|
258
|
+
├── daemon.ts (background service)
|
|
259
|
+
└── store.ts (SQLite)
|
|
259
260
|
```
|
|
260
261
|
|
|
261
|
-
- **server.ts** — HTTP
|
|
262
|
+
- **server.ts** — HTTP server bootstrap
|
|
262
263
|
- **routes.ts** — HTTP request handlers (static files, REST API, image upload, push subscription)
|
|
263
|
-
- **
|
|
264
|
+
- **event-handler.ts** — ACP event routing + SSE broadcast
|
|
264
265
|
- **session-manager.ts** — Session state management (live sessions, buffers, bash procs, model cache)
|
|
265
266
|
- **bridge.ts** — ACP bridge, manages agent subprocess, handles permissions and file I/O
|
|
266
267
|
- **store.ts** — SQLite persistence (sessions, events, push subscriptions; WAL mode)
|
|
267
268
|
- **title-service.ts** — Async session title generation (dedicated Haiku session)
|
|
268
269
|
- **push-service.ts** — Web Push notifications (VAPID keys, subscriptions, visibility-gated delivery)
|
|
269
270
|
- **daemon.ts** — Background service management (start/stop/status/restart) with supervisor
|
|
270
|
-
- **types.ts** — Shared types + Zod schemas
|
|
271
|
+
- **types.ts** — Shared types + Zod schemas
|
|
272
|
+
- **shared/constants.ts** — Constants shared between frontend and backend (tool icons, plan status icons)
|
|
273
|
+
- **public/js/*.ts** — Frontend TypeScript source, bundled by esbuild into a single `dist/js/app.[hash].js`
|
|
271
274
|
|
|
272
275
|
## ACP Scope and Current Limits
|
|
273
276
|
|
package/config.toml
CHANGED
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.01a9ju9l.css">
|
|
17
17
|
</head>
|
|
18
18
|
<body>
|
|
19
19
|
|
|
@@ -42,6 +42,6 @@
|
|
|
42
42
|
</div>
|
|
43
43
|
<div id="status-bar"></div>
|
|
44
44
|
|
|
45
|
-
<script
|
|
45
|
+
<script src="/js/app.IXP5KGP6.js"></script>
|
|
46
46
|
</body>
|
|
47
47
|
</html>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
var ce=class extends Error{name="ApiError";status;constructor(t,s){super(s),this.status=t}};async function U(e,t){let s=await fetch(e,t);if(!s.ok){let r=`HTTP ${s.status}`;try{let a=await s.json();a.error&&(r=String(a.error))}catch{}throw new ce(s.status,r)}let i=await s.text();if(i)return JSON.parse(i)}function q(e,t){return U(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)})}function He(e){let t={};return e?.cwd&&(t.cwd=e.cwd),e?.inheritFromSessionId&&(t.inheritFromSessionId=e.inheritFromSessionId),q("/api/v1/sessions",t)}function de(e){return U("/api/v1/sessions/"+e,{method:"DELETE"})}function te(){return U("/api/v1/sessions")}function P(e){return U("/api/v1/sessions/"+e)}function ue(e,t,s){let i={text:t};return s?.length&&(i.images=s),q("/api/v1/sessions/"+e+"/prompt",i)}function _e(e){return q("/api/v1/sessions/"+e+"/cancel",{})}function ne(e,t,s){return q("/api/v1/sessions/"+e+"/permissions/"+t,{optionId:s})}function se(e,t){return q("/api/v1/sessions/"+e+"/permissions/"+t,{denied:!0})}function K(e,t,s){let i=t.replace(/_/g,"-");return U("/api/v1/sessions/"+e+"/"+i,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({value:s})})}function Ne(e,t){return U("/api/v1/sessions/"+e+"/title",{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify({value:t})})}function qe(e,t){return q("/api/v1/sessions/"+e+"/bash",{command:t})}function V(e,t,s){let i={visible:t};return s&&(i.sessionId=s),q("/api/v1/clients/"+e+"/visibility",i)}var T=e=>document.querySelector(e),o={messages:T("#messages"),input:T("#input"),sendBtn:T("#send-btn"),prompt:T("#input-prompt"),status:T("#status"),sessionInfo:T("#session-info"),newBtn:T("#new-btn"),attachBtn:T("#attach-btn"),fileInput:T("#file-input"),attachPreview:T("#attach-preview"),themeBtn:T("#theme-btn"),slashMenu:T("#slash-menu"),inputArea:T("#input-area"),statusBar:T("#status-bar")},n={eventSource:null,clientId:null,sessionId:null,sessionSwitchGen:0,sessionCwd:null,sessionTitle:null,awaitingNewSession:!1,configOptions:[],currentAssistantEl:null,currentAssistantText:"",currentThinkingEl:null,currentThinkingText:"",busy:!1,pendingImages:[],currentBashEl:null,followMessages:!0,pendingToolCallIds:new Set,pendingPermissionRequestIds:new Set,pendingPromptDone:!1,turnEnded:!1,newTurnStarted:!1,sentMessageForSession:null,sentBashForSession:null,cancelTimeout:1e4,_cancelTimerId:null,_onCancelTimeout:null,lastEventSeq:0,oldestLoadedSeq:0,hasMoreHistory:!1,loadingOlderEvents:!1,replayInProgress:!1,replayTarget:null,replayQueue:[],unconfirmedPermissions:new Map},at={disconnected:"is-disconnected",connecting:"is-connecting",connected:"is-connected"};function B(e,t=e){o.status.textContent="",o.status.className=`status-dot ${at[e]}`,o.status.dataset.state=e,o.status.setAttribute("aria-label",t),o.status.setAttribute("title",t)}function x(e){return n.configOptions.find(t=>t.id===e)}function ie(e){return x(e)?.currentValue??null}function Be(e,t){let s=x(e);s&&(s.currentValue=t)}function pe(e){n.configOptions=e,_(),O()}function _(){o.inputArea.classList.remove("plan-mode","autopilot-mode");let e=ie("mode")||"";e.includes("#plan")?o.inputArea.classList.add("plan-mode"):e.includes("#autopilot")&&o.inputArea.classList.add("autopilot-mode")}function O(){if(!o.statusBar)return;let e=ie("model"),t=n.sessionCwd||"";if(o.statusBar.textContent="",t){e&&o.statusBar.appendChild(document.createTextNode(e+" \xB7 "));let s=document.createElement("span");s.className="status-cwd",s.textContent=t,o.statusBar.appendChild(s)}else e&&(o.statusBar.textContent=e)}function I(e){n.busy=e,e?(o.sendBtn.textContent="^X",o.sendBtn.title="Cancel (Ctrl+X)",o.sendBtn.classList.add("cancel"),o.prompt.classList.add("busy")):(o.sendBtn.textContent="\u21B5",o.sendBtn.title="Send (Enter)",o.sendBtn.classList.remove("cancel"),o.prompt.classList.remove("busy"))}function M({cwd:e,inheritFromSessionId:t=n.sessionId}={}){n.awaitingNewSession=!0,He({cwd:e,inheritFromSessionId:t}).catch(()=>{})}var Oe=[];function Ae(e){Oe.push(e)}function y(){for(let e of Oe)e();o.messages.innerHTML="",n.currentAssistantEl=null,n.currentAssistantText="",n.currentThinkingEl=null,n.currentThinkingText="",n.pendingImages.length=0,n.followMessages=!0,n.pendingToolCallIds.clear(),n.pendingPermissionRequestIds.clear(),n.unconfirmedPermissions.clear(),n.pendingPromptDone=!1,n.turnEnded=!1,n.newTurnStarted=!1,n._cancelTimerId=null,n.lastEventSeq=0,n.oldestLoadedSeq=0,n.hasMoreHistory=!1,n.loadingOlderEvents=!1,n.replayInProgress=!1,n.replayQueue=[],o.attachPreview.innerHTML="",o.attachPreview.classList.remove("active"),o.input.disabled=!1,o.sendBtn.disabled=!1,o.input.placeholder="",I(!1),n.sessionTitle=null,n.sessionCwd=null,n.configOptions=[],oe(null,null),o.statusBar&&(o.statusBar.textContent="")}function L(){o.newBtn.classList.toggle("hidden",o.input.value.length>0)}function G(){return!n.busy||!n.sessionId?!1:(_e(n.sessionId).catch(()=>{}),J(),n.cancelTimeout>0&&(n._cancelTimerId=setTimeout(()=>{n._cancelTimerId=null,n.busy&&(n.turnEnded=!0,I(!1),n._onCancelTimeout?.())},n.cancelTimeout)),!0)}function J(){n._cancelTimerId!=null&&(clearTimeout(n._cancelTimerId),n._cancelTimerId=null)}function Re(){return location.hash.slice(1)||null}function De(e){history.replaceState(null,"",`#${e}`)}function oe(e,t){o.sessionInfo.textContent=t||(e?e.slice(0,8)+"\u2026":""),document.title=t||">_"}B("disconnected");marked.setOptions({breaks:!0,gfm:!0});function me(e){return DOMPurify.sanitize(marked.parse(e))}function R(e,t){let s=document.createElement("div");return s.className=`msg ${e}`,s.innerHTML=e==="user"?u(t).replace(/\n/g,"<br>"):me(t),E(s),s}function c(e){let t=document.createElement("div");return t.className="system-msg",t.textContent=e,E(t),t}function H(){n.currentAssistantEl=null,n.currentAssistantText=""}function k(){if(n.currentThinkingEl){let e=n.currentThinkingEl.querySelector("summary");e.textContent="\u283F thought",e.classList.remove("active"),e.style.animation="none",n.currentThinkingEl=null,n.currentThinkingText=""}}var A=null,lt=80;function ge(e){return e.scrollHeight-e.scrollTop-e.clientHeight<lt}function ct(){n.followMessages=ge(o.messages)}o.messages.addEventListener("scroll",ct);function dt(){return n.followMessages||ge(o.messages)}function E(e,t=!1){if(n.replayTarget)return n.replayTarget.appendChild(e),e;let s=t||dt();return o.messages.appendChild(e),S(s),e}function Ue(){D(),A=document.createElement("div"),A.id="waiting",A.innerHTML='<span class="cursor">\u258C</span>',E(A,!0)}function D(){A&&(A.remove(),A=null)}var fe=!1;function S(e){let t=o.messages;if(e||n.followMessages){typeof requestAnimationFrame=="function"?fe||(fe=!0,requestAnimationFrame(()=>{fe=!1,t.scrollTop=t.scrollHeight})):t.scrollTop=t.scrollHeight,n.followMessages=!0;return}n.followMessages=ge(t)}function u(e){let t=document.createElement("div");return t.textContent=e,t.innerHTML}function je(e){if(!e)return"";let t=new Date(e.endsWith("Z")?e:e+"Z"),s=i=>String(i).padStart(2,"0");return`${t.getFullYear()}-${s(t.getMonth()+1)}-${s(t.getDate())} ${s(t.getHours())}:${s(t.getMinutes())}`}function he(e){if(typeof e=="string"&&e.includes("*** Begin Patch")){let t=e.split(`
|
|
2
|
+
`),s=[];for(let i of t)i.startsWith("*** Begin Patch")||i.startsWith("*** End Patch")||(i.startsWith("*** Update File:")||i.startsWith("*** Add File:")||i.startsWith("*** Delete File:")?s.push(`<span class="diff-file">${u(i)}</span>`):i.startsWith("@@")?s.push(`<span class="diff-hunk">${u(i)}</span>`):i.startsWith("-")?s.push(`<span class="diff-del">${u(i)}</span>`):i.startsWith("+")?s.push(`<span class="diff-add">${u(i)}</span>`):s.push(u(i)));return s.join(`
|
|
3
|
+
`)}if(e&&typeof e=="object"){let t=[];if(e.path&&t.push(`<span class="diff-file">*** ${u(e.path)}</span>`),e.old_str!=null)for(let s of String(e.old_str).split(`
|
|
4
|
+
`))t.push(`<span class="diff-del">- ${u(s)}</span>`);if(e.new_str!=null)for(let s of String(e.new_str).split(`
|
|
5
|
+
`))t.push(`<span class="diff-add">+ ${u(s)}</span>`);return e.file_text!=null&&t.push(`<span class="diff-add">+ (new file, ${e.file_text.split(`
|
|
6
|
+
`).length} lines)</span>`),t.length>(e.path?1:0)?t.join(`
|
|
7
|
+
`):null}return null}function Q(e,t=!1){let s=document.createElement("div");return s.className="bash-block",s.innerHTML=`<span class="bash-cmd${t?" running":""}">${u(e)}</span><div class="bash-output"></div>`,s.querySelector(".bash-cmd").addEventListener("click",()=>{let i=s.querySelector(".bash-output");i.style.display==="none"?i.style.display="block":i.classList.contains("has-content")&&(i.style.display="none")}),E(s),t&&(n.currentBashEl=s),s}function Y(e,t,s){if(!e)return;let i=e.querySelector(".bash-cmd");i.classList.remove("running");let r="";if(s?r=`[signal: ${s}]`:t!==0&&t!=null&&(r=`[exit: ${t}]`),r){let a=document.createElement("span");a.className=`bash-exit ${t===0?"ok":"fail"}`,a.textContent=r,i.after(a)}e===n.currentBashEl&&(n.currentBashEl=null)}var ut={auto:"\u25D1",light:"\u2600",dark:"\u263E"},Fe=["auto","light","dark"];function We(){return localStorage.getItem("theme")||"auto"}function Ke(e){document.documentElement.setAttribute("data-theme",e),o.themeBtn.textContent=ut[e],o.themeBtn.title=`Theme: ${e}`,localStorage.setItem("theme",e)}o.themeBtn.onclick=()=>{let e=We();Ke(Fe[(Fe.indexOf(e)+1)%3])};Ke(We());var ye={read:"cat",edit:"edit",execute:"exec",search:"find",delete:"rm"},we="run",ve={pending:"\u25CB",in_progress:"\u25C9",completed:"\u25CF"};function Ve(e){return n.replayTarget?.querySelector(`[id="${e}"]`)??document.getElementById(e)}function pt(e){return n.replayTarget?.querySelector(e)??document.querySelector(e)}var Ge="webagent_notify_tip_shown",Je="webagent_notify_tip_denied_shown";function ft(){if(typeof Notification>"u"||n.replayInProgress)return;let e=Notification.permission;if(e!=="granted"){if(e==="denied"){if(localStorage.getItem(Je))return;localStorage.setItem(Je,"1"),c("tip: notifications are blocked \u2014 allow in browser site settings to enable");return}localStorage.getItem(Ge)||(localStorage.setItem(Ge,"1"),c("tip: use /notify to enable background notifications"))}}function re(){n.pendingPromptDone&&(n.pendingToolCallIds.size>0||n.pendingPermissionRequestIds.size>0||(D(),k(),H(),I(!1),n.pendingPromptDone=!1,ft()))}function mt(){for(let e of n.pendingToolCallIds){let t=document.getElementById(`tc-${e}`);if(!t)continue;t.className="tool-call failed";let s=t.querySelector(".icon");s&&(s.textContent="\u2717")}for(let e of n.pendingPermissionRequestIds){let t=document.querySelector(`.permission[data-request-id="${e}"]`);if(!t||!t.querySelector("button"))continue;let i=t.querySelector(".title")?.textContent||"\u26BF";t.innerHTML=`<span style="opacity:0.5">${u(i)} \u2014 cancelled</span>`}n.pendingToolCallIds.clear(),n.pendingPermissionRequestIds.clear()}function gt(){for(let e of n.pendingToolCallIds){let t=document.getElementById(`tc-${e}`);if(!t)continue;t.className="tool-call completed";let s=t.querySelector(".icon");s&&(s.textContent="\u2713")}n.pendingToolCallIds.clear(),n.pendingPermissionRequestIds.clear()}function Se(e){if(Array.isArray(e))return{events:e,streaming:{thinking:!1,assistant:!1}};let t=e;return{events:t.events??[],streaming:t.streaming??{thinking:!1,assistant:!1},total:typeof t.total=="number"?t.total:void 0,hasMore:typeof t.hasMore=="boolean"?t.hasMore:void 0}}var Qe=200;async function N(e){n.replayInProgress=!0,n.replayQueue=[];try{let t=await fetch(`/api/v1/sessions/${e}/events?limit=${Qe}`);if(!t.ok)return!1;let s=await t.json(),{events:i,streaming:r,hasMore:a}=Se(s),l=document.createDocumentFragment(),d=Te(i);n.replayTarget=l;for(let p=0;p<i.length;p++){let m=JSON.parse(i[p].data);Ee(i[p].type,m,i,p,d)}return n.replayTarget=null,o.messages.style.display="none",o.messages.appendChild(l),o.messages.style.display="",i.length&&(n.lastEventSeq=i[i.length-1].seq,n.oldestLoadedSeq=i[0].seq),n.hasMoreHistory=a===!0,n.hasMoreHistory&&ht(),Ye(),Ie(i,r),!0}catch{return!1}finally{n.replayTarget=null,n.replayInProgress=!1,ze()}}function Ie(e,t){if(t.thinking&&e.length){for(let s=e.length-1;s>=0;s--)if(e[s].type==="thinking"){let i=JSON.parse(e[s].data),r=o.messages.querySelectorAll(".thinking"),a=r[r.length-1];if(a){n.currentThinkingEl=a,n.currentThinkingText=i.text;let l=a.querySelector("summary");l&&(l.textContent="\u283F thinking...",l.classList.add("active"))}break}}if(t.assistant&&e.length){for(let s=e.length-1;s>=0;s--)if(e[s].type==="assistant_message"){let i=JSON.parse(e[s].data),r=o.messages.querySelectorAll(".msg.assistant"),a=r[r.length-1];a&&(n.currentAssistantEl=a,n.currentAssistantText=i.text);break}}}function Ye(){let e=o.messages.querySelector("[data-sync-boundary]");e&&e.removeAttribute("data-sync-boundary");let t=o.messages.lastElementChild;t&&t.setAttribute("data-sync-boundary","")}async function be(e){n.replayInProgress=!0,n.replayQueue=[];try{let t=`/api/v1/sessions/${e}/events?after=${n.lastEventSeq}`,s=await fetch(t);if(!s.ok)return!1;let i=await s.json(),{events:r,streaming:a}=Se(i),l=o.messages.querySelector("[data-sync-boundary]");if(l)for(;l.nextElementSibling;)l.nextElementSibling.remove();if(n.currentAssistantEl=null,n.currentAssistantText="",n.currentThinkingEl=null,n.currentThinkingText="",n.currentBashEl=null,r.length===0)return Ie(r,a),!0;let d=document.createDocumentFragment(),p=Te(r);n.replayTarget=d;for(let m=0;m<r.length;m++){let g=JSON.parse(r[m].data);Ee(r[m].type,g,r,m,p)}return n.replayTarget=null,o.messages.appendChild(d),n.lastEventSeq=r[r.length-1].seq,Ye(),Ie(r,a),!0}catch{return!1}finally{n.replayTarget=null,n.replayInProgress=!1,ze()}}var X=null;function ht(){ae();let e=document.createElement("div");e.id="history-sentinel",e.className="history-sentinel",e.textContent="\u2191 loading\u2026",o.messages.prepend(e),typeof IntersectionObserver=="function"&&(X=new IntersectionObserver(t=>{t[0]?.isIntersecting&&!n.loadingOlderEvents&&n.hasMoreHistory&&n.sessionId&&yt(n.sessionId)},{root:o.messages,rootMargin:"200px 0px 0px 0px"}),X.observe(e))}function ae(){X&&(X.disconnect(),X=null),document.getElementById("history-sentinel")?.remove()}Ae(ae);async function yt(e){if(n.loadingOlderEvents||!n.hasMoreHistory||n.oldestLoadedSeq<=0)return!1;n.loadingOlderEvents=!0;try{let t=await fetch(`/api/v1/sessions/${e}/events?limit=${Qe}&before=${n.oldestLoadedSeq}`);if(!t.ok||e!==n.sessionId)return!1;let s=await t.json(),{events:i,hasMore:r}=Se(s);if(i.length===0)return n.hasMoreHistory=!1,ae(),!0;let a=document.createDocumentFragment(),l=Te(i);n.replayTarget=a;for(let g=0;g<i.length;g++){let v=JSON.parse(i[g].data);Ee(i[g].type,v,i,g,l)}n.replayTarget=null;let d=o.messages,p=d.scrollHeight,m=document.getElementById("history-sentinel");return m?m.after(a):d.prepend(a),d.scrollTop+=d.scrollHeight-p,n.oldestLoadedSeq=i[0].seq,n.hasMoreHistory=r===!0,n.hasMoreHistory||ae(),!0}catch{return!1}finally{n.loadingOlderEvents=!1}}function Xe(){for(let[e,t]of n.unconfirmedPermissions){let s=document.querySelector(`.permission[data-request-id="${e}"]`);if(!s||!s.querySelector("button")){n.unconfirmedPermissions.delete(e);continue}t.denied?se(t.sessionId,e).catch(()=>{}):ne(t.sessionId,e,t.optionId).catch(()=>{});let i=s.dataset.title?`\u26BF ${u(s.dataset.title)}`:"\u26BF";s.innerHTML=`<span style="opacity:0.5">${i} \u2014 ${u(t.optionName)}</span>`,n.unconfirmedPermissions.delete(e)}}function Te(e){let t=new Set;for(let s of e)s.type==="permission_response"&&t.add(JSON.parse(s.data).requestId);return{toolCalls:new Map,permissions:new Map,resolvedPermissions:t,currentBashEl:null}}function Ee(e,t,s,i,r){switch(e){case"user_message":{let a=R("user",t.text);if(t.images)for(let l of t.images){let d=document.createElement("img");d.className="user-image",d.src=l.path,a.appendChild(d)}break}case"assistant_message":R("assistant",t.text);break;case"thinking":{let a=document.createElement("details");a.className="thinking",a.innerHTML=`<summary>\u283F thought</summary><div class="thinking-content">${u(t.text)}</div>`,E(a);break}case"tool_call":{let a=ye[t.kind]||we,l=document.createElement("div");l.className="tool-call",l.id=`tc-${t.id}`;let d=`<span class="icon">${a}</span> ${u(t.title)}`,p=t.rawInput;p&&p.command?d+=`<span class="tc-detail">$ ${u(p.command)}</span>`:p&&p.path&&(d+=`<span class="tc-detail">${u(p.path)}</span>`),l.innerHTML=d;let m=t.kind==="edit"?he(p):null;if(m){let v=document.createElement("details");v.innerHTML=`<summary>diff</summary><div class="diff-view">${m}</div>`,l.appendChild(v)}let g=l.querySelector(".tc-detail");g&&l.addEventListener("click",v=>{v.target.closest("details")||g.classList.toggle("expanded")}),E(l),r&&r.toolCalls.set(t.id,l);break}case"tool_call_update":{let a=r?r.toolCalls.get(t.id):Ve(`tc-${t.id}`);if(a){let l=t.status==="completed"?"\u2713":t.status==="failed"?"\u2717":"\u2026";a.className=`tool-call ${t.status}`;let d=a.querySelector(".icon");d&&(d.textContent=l)}(t.status==="completed"||t.status==="failed")&&n.pendingToolCallIds.delete(t.id);break}case"plan":{let a=document.createElement("div");a.className="plan",a.innerHTML='<div class="plan-title">\u2015 plan</div>'+(t.entries||[]).map(l=>`<div class="plan-entry">${ve[l.status]||"?"} ${u(l.content)}</div>`).join(""),E(a);break}case"permission_request":{let a=document.createElement("div");a.className="permission",a.dataset.requestId=t.requestId,a.dataset.title=t.title||"",a.innerHTML=`<span class="title" style="opacity:0.5">\u26BF ${u(t.title)}</span> `,!(r?r.resolvedPermissions.has(t.requestId):s&&s.slice(i+1).some(d=>d.type==="permission_response"&&JSON.parse(d.data).requestId===t.requestId))&&t.options&&(a.querySelector(".title").style.opacity="1",t.options.forEach(d=>{let p=document.createElement("button"),m=(d.kind||"").includes("allow");p.className=m?"allow":"deny",p.textContent=d.name,p.onclick=()=>{(d.kind||"").includes("reject")||(d.kind||"").includes("deny")?se(n.sessionId,t.requestId).catch(()=>{}):ne(n.sessionId,t.requestId,d.optionId).catch(()=>{}),a.innerHTML=`<span style="opacity:0.5">\u26BF ${u(t.title)} \u2014 ${u(d.name)}</span>`},a.appendChild(p)})),E(a),r&&r.permissions.set(t.requestId,a);break}case"permission_response":{let a=r?r.permissions.get(t.requestId):pt(`.permission[data-request-id="${t.requestId}"]`);if(a){let l=a.dataset.title?`\u26BF ${a.dataset.title}`:"\u26BF",d=t.optionName||(t.denied?"denied":"allowed");a.innerHTML=`<span style="opacity:0.5">${u(l)} \u2014 ${u(d)}</span>`}break}case"bash_command":{let a=Q(t.command,!1);r?r.currentBashEl=a:a.id="bash-replay-pending";break}case"bash_result":{let a=r?r.currentBashEl:Ve("bash-replay-pending");if(a){if(r||a.removeAttribute("id"),t.output){let l=a.querySelector(".bash-output");l&&(l.textContent=t.output,l.classList.add("has-content"))}Y(a,t.code,t.signal),r&&(r.currentBashEl=null)}break}case"prompt_done":n.pendingToolCallIds.clear(),n.pendingPermissionRequestIds.clear(),n.pendingPromptDone=!1,I(!1);break}}function ze(){let e=n.replayQueue;n.replayQueue=[];for(let t of e)wt(t)||C(t)}function wt(e){switch(e.type){case"tool_call":return!!document.getElementById(`tc-${e.id}`);case"permission_request":return!!document.querySelector(`.permission[data-request-id="${e.requestId}"]`);case"thought_chunk":return!!n.currentThinkingEl;case"message_chunk":return!!n.currentAssistantEl;default:return!1}}function C(e){if(n.replayInProgress){console.log("[handleEvent-DEBUG] QUEUED (replayInProgress):",e.type),n.replayQueue.push(e);return}if(!(e.sessionId&&e.type!=="session_created"&&e.type!=="session_deleted"&&(!n.sessionId||e.sessionId!==n.sessionId)))switch(e.type){case"connected":e.cancelTimeout!=null&&(n.cancelTimeout=e.cancelTimeout);break;case"session_created":if(!n.awaitingNewSession&&n.sessionId&&e.sessionId!==n.sessionId)break;if(n.awaitingNewSession=!1,n.sessionId=e.sessionId,n.sessionCwd=e.cwd||n.sessionCwd,n.sessionTitle=e.title||null,e.configOptions?.length&&pe(e.configOptions),De(n.sessionId),n.clientId&&V(n.clientId,!document.hidden,n.sessionId).catch(()=>{}),oe(n.sessionId,n.sessionTitle),B("connected","connected"),o.input.disabled=!1,o.sendBtn.disabled=!1,o.input.placeholder="",I(!!e.busyKind),n.newTurnStarted=!1,e.busyKind==="bash"){let t=document.getElementById("bash-replay-pending");t&&(t.removeAttribute("id"),t.querySelector(".bash-cmd")?.classList.add("running"),n.currentBashEl=t)}else n.currentBashEl=null;o.messages.children.length===0&&c(`Session created: ${n.sessionTitle||e.sessionId.slice(0,8)+"\u2026"}`),O();break;case"user_message":{if(n.sentMessageForSession===e.sessionId){n.sentMessageForSession=null;break}if(k(),H(),n.newTurnStarted=!0,n.turnEnded=!1,e.sessionId===n.sessionId){let t=R("user",e.text);if(e.images)for(let s of e.images){let i=document.createElement("img");i.className="user-image",i.src=s.path,t.appendChild(i)}}break}case"message_chunk":if(n.turnEnded)break;D(),k(),n.currentAssistantEl||(n.currentAssistantEl=R("assistant",""),n.currentAssistantText=""),n.currentAssistantText+=e.text,n.currentAssistantEl.innerHTML=me(n.currentAssistantText),S();break;case"thought_chunk":if(n.turnEnded)break;D(),n.currentThinkingEl||(n.currentThinkingEl=document.createElement("details"),n.currentThinkingEl.className="thinking",n.currentThinkingEl.innerHTML='<summary class="active">\u283F thinking...</summary><div class="thinking-content"></div>',n.currentThinkingText="",E(n.currentThinkingEl)),n.currentThinkingText+=e.text,n.currentThinkingEl.querySelector(".thinking-content").textContent=n.currentThinkingText,S();break;case"tool_call":{if(n.turnEnded)break;n.pendingToolCallIds.add(e.id),I(!0),D(),k(),H();let t=ye[e.kind]||we,s=document.createElement("div");s.className="tool-call",s.id=`tc-${e.id}`;let i=`<span class="icon">${t}</span> ${u(e.title)}`,r=e.rawInput;r&&r.command?i+=`<span class="tc-detail">$ ${u(r.command)}</span>`:r&&r.path&&(i+=`<span class="tc-detail">${u(r.path)}</span>`),s.innerHTML=i;let a=e.kind==="edit"?he(r):null;if(a){let d=document.createElement("details");d.innerHTML=`<summary>diff</summary><div class="diff-view">${a}</div>`,s.appendChild(d)}let l=s.querySelector(".tc-detail");l&&s.addEventListener("click",d=>{d.target.closest("details")||l.classList.toggle("expanded")}),E(s);break}case"tool_call_update":{let t=document.getElementById(`tc-${e.id}`);if((e.status==="completed"||e.status==="failed")&&n.pendingToolCallIds.delete(e.id),t){let s=e.status==="completed"?"\u2713":e.status==="failed"?"\u2717":"\u2026";t.className=`tool-call ${e.status}`;let i=t.querySelector(".icon");if(i&&(i.textContent=s),e.content&&e.content.length&&!t.querySelector("details")){let r=e.content.map(a=>a.type==="terminal"?`[terminal ${a.terminalId}]`:a.content?.text?a.content.text:Array.isArray(a.content)?a.content.map(l=>l.text||"").join(""):"").filter(Boolean).join(`
|
|
8
|
+
`);if(r){let a=document.createElement("details");a.innerHTML=`<summary>output</summary><div class="tc-content">${u(r)}</div>`,t.appendChild(a)}}}re(),S();break}case"plan":{k(),H();let t=document.createElement("div");t.className="plan",t.innerHTML='<div class="plan-title">\u2015 plan</div>'+e.entries.map(s=>`<div class="plan-entry">${ve[s.status]||"?"} ${u(s.content)}</div>`).join(""),E(t);break}case"permission_request":{if(n.turnEnded||document.querySelector(`.permission[data-request-id="${e.requestId}"]`))break;n.pendingPermissionRequestIds.add(e.requestId),I(!0),k();let t=document.createElement("div");t.className="permission",t.dataset.requestId=e.requestId,t.dataset.title=e.title||"",t.innerHTML=`<span class="title">\u26BF ${u(e.title)}</span> `,e.options.forEach(s=>{let i=document.createElement("button"),r=(s.kind||"").includes("allow");i.className=r?"allow":"deny",i.textContent=s.name,i.onclick=()=>{let a=(s.kind||"").includes("reject")||(s.kind||"").includes("deny");a?se(n.sessionId,e.requestId).catch(()=>{}):ne(n.sessionId,e.requestId,s.optionId).catch(()=>{}),n.pendingPermissionRequestIds.delete(e.requestId),n.unconfirmedPermissions.set(e.requestId,{sessionId:n.sessionId,optionId:s.optionId,optionName:s.name,denied:a}),t.innerHTML=`<span style="opacity:0.5">\u26BF ${u(e.title)} \u2014 ${u(s.name)}</span>`,re()},t.appendChild(i)}),E(t);break}case"permission_resolved":{n.pendingPermissionRequestIds.delete(e.requestId),n.unconfirmedPermissions.delete(e.requestId);let t=document.querySelector(`.permission[data-request-id="${e.requestId}"]`);if(e.sessionId===n.sessionId&&t){let s=t.dataset.title?`\u26BF ${t.dataset.title}`:"\u26BF",i=e.optionName||(e.denied?"denied":"allowed");t.innerHTML=`<span style="opacity:0.5">${u(s)} \u2014 ${u(i)}</span>`}re();break}case"bash_command":{if(n.sentBashForSession===e.sessionId){n.sentBashForSession=null;break}e.sessionId===n.sessionId&&(Q(e.command,!0),I(!0));break}case"bash_output":{if(e.sessionId!==n.sessionId)break;if(n.currentBashEl){let t=n.currentBashEl.querySelector(".bash-output");if(e.stream==="stderr"){let s=document.createElement("span");s.className="stderr",s.textContent=e.text,t.appendChild(s)}else t.appendChild(document.createTextNode(e.text));t.classList.add("has-content"),t.scrollTop=t.scrollHeight,S()}break}case"bash_done":{if(e.sessionId!==n.sessionId)break;Y(n.currentBashEl,e.code,e.signal),e.error&&c(`err: ${e.error}`),I(!1);break}case"prompt_done":{if(J(),e.stopReason==="cancelled"&&n.newTurnStarted){n.newTurnStarted=!1,k(),H();break}n.newTurnStarted=!1,e.stopReason==="cancelled"?mt():gt(),n.turnEnded=!0,n.pendingPromptDone=!0,re();break}case"session_deleted":e.sessionId===n.sessionId&&(c("warn: This session has been deleted."),o.input.disabled=!0,o.sendBtn.disabled=!0,o.input.placeholder="Session deleted");break;case"session_expired":y(),c("warn: Previous session expired, created new one."),M();break;case"config_set":{Be(e.configId,e.value);let t=x(e.configId),s=t?.name||e.configId,i=t?.options.find(r=>r.value===e.value)?.name||e.value;c(`ok: ${s}: ${i}`),e.configId==="mode"&&_(),O();break}case"config_option_update":e.configOptions?.length&&pe(e.configOptions);break;case"session_title_updated":e.sessionId===n.sessionId&&(n.sessionTitle=e.title,oe(n.sessionId,n.sessionTitle));break;case"error":n.awaitingNewSession=!1,n.pendingToolCallIds.clear(),n.pendingPermissionRequestIds.clear(),n.pendingPromptDone=!1,D(),k(),H(),c(`err: ${e.message}`),I(!1);break}}async function vt(){try{let e=await navigator.serviceWorker?.ready;if(!e)return;let t=await fetch("/api/v1/push/vapid-key");if(!t.ok)return;let{publicKey:s}=await t.json(),r=(await e.pushManager.subscribe({userVisibleOnly:!0,applicationServerKey:St(s)})).toJSON();await fetch("/api/v1/push/subscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({endpoint:r.endpoint,keys:r.keys,clientId:n.clientId})})}catch(e){console.error("[push] subscribe failed:",e)}}async function It(){try{let e=await navigator.serviceWorker?.ready;if(!e)return;let t=await e.pushManager.getSubscription();if(!t)return;let s=t.endpoint;await t.unsubscribe(),await fetch("/api/v1/push/unsubscribe",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({endpoint:s})})}catch(e){console.error("[push] unsubscribe failed:",e)}}function St(e){let t="=".repeat((4-e.length%4)%4),s=(e+t).replace(/-/g,"+").replace(/_/g,"/"),i=atob(s),r=new Uint8Array(i.length);for(let a=0;a<i.length;a++)r[a]=i.charCodeAt(a);return r}async function ke(){try{let e=await navigator.serviceWorker?.ready;return e?await e.pushManager.getSubscription()!==null:!1}catch{return!1}}async function xe(e){let t=e.split(/\s+/),s=t[0].toLowerCase(),i=t.slice(1).join(" ").trim();switch(s){case"/new":return y(),c("Creating new session\u2026"),M({cwd:i||n.sessionCwd}),!0;case"/pwd":return c(`\u{1F4C1} ${n.sessionCwd||"unknown"}`),!0;case"/rename":{if(!n.sessionId)return c("err: No active session"),!0;if(!i)return c(`Current: ${n.sessionTitle||"(untitled)"}`),c("Usage: /rename <new title>"),!0;try{await Ne(n.sessionId,i),c(`Renamed \u2192 ${i}`)}catch{c("err: Failed to rename session")}return!0}case"/sessions":return c("Removed. Use /switch to see all sessions."),!0;case"/exit":{if(!n.sessionId)return c("warn: No active session"),!0;let r=n.sessionId;try{let l=(await te()).find(d=>d.id!==r);if(n.busy&&G(),de(r).catch(()=>{}),$=null,l){n.sessionSwitchGen++;let d=n.sessionSwitchGen;y(),n.sessionId=null;let[p]=await Promise.all([P(l.id),N(l.id)]);if(d!==n.sessionSwitchGen)return!0;C({type:"session_created",sessionId:p.id,cwd:p.cwd,title:p.title,configOptions:p.configOptions,busyKind:p.busyKind}),S(!0)}else y(),n.sessionId=null,c("Creating new session\u2026"),M({inheritFromSessionId:null})}catch{c("err: Failed to exit session")}return!0}case"/prune":{try{let l=(await(await fetch("/api/v1/sessions")).json()).filter(d=>d.id!==n.sessionId);if(l.length===0)return c("No other sessions to prune."),!0;for(let d of l)de(d.id).catch(()=>{});$=null,c(`Pruned ${l.length} session(s).`)}catch{c("err: Failed to prune sessions")}return!0}case"/switch":{if(!i)return c("Usage: /switch <title or id prefix>"),!0;try{let a=await(await fetch("/api/v1/sessions")).json(),l=i.toLowerCase(),d=a.find(g=>g.id.startsWith(i)||g.title&&g.title.toLowerCase().includes(l));if(!d)return c(`err: No session matching "${i}"`),!0;n.sessionSwitchGen++;let p=n.sessionSwitchGen;y(),n.sessionId=null;let[m]=await Promise.all([P(d.id),N(d.id)]);if(p!==n.sessionSwitchGen)return!0;C({type:"session_created",sessionId:m.id,cwd:m.cwd,title:m.title,configOptions:m.configOptions,busyKind:m.busyKind}),S(!0)}catch{y(),n.sessionId=null,c("err: Failed to switch session")}return!0}case"/cancel":return n.busy?(G(),c("^X")):c("Nothing to cancel."),!0;case"/help":case"?":c("? \u2014 Show help"),c("/help \u2014 Show help (alias)"),c("!<command> \u2014 Run bash command");for(let r of Ze){let a=r.args?`${r.cmd} ${r.args}`:r.cmd;c(`${a} \u2014 ${r.desc}`)}c("--- Shortcuts ---");for(let r of bt)c(`${r.key} \u2014 ${r.desc}`);return!0;case"/model":case"/mode":case"/think":{let a={"/model":"model","/mode":"mode","/think":"reasoning_effort"}[s],l=x(a);if(!i){let v=l?.options.find(ee=>ee.value===l.currentValue)?.name||l?.currentValue||"unknown";return c(`${l?.name||a}: ${v}`),c(`Type ${s} + space to pick from list`),!0}if(!l)return c(`err: ${s.slice(1)} is not available.`),!0;let d=i.trim(),p=v=>v.toLowerCase().replace(/[\s_]+/g,"-"),m=p(d),g=l.options.find(v=>p(v.value)===m||p(v.name)===m);if(!g){let v=l.options.filter(ee=>p(ee.value).includes(m)||p(ee.name).includes(m));if(v.length===1)g=v[0];else if(v.length>1)return c(`err: Ambiguous "${i}". Type ${s} + space to see options.`),!0}return g?(l.currentValue=g.value,_(),O(),c(`${l.name} \u2192 ${g.name}`),await K(n.sessionId,a,g.value).catch(()=>{}),!0):(c(`err: Unknown "${i}". Type ${s} + space to see options.`),!0)}case"/notify":{if(typeof Notification>"u")return c("err: notifications not supported in this browser"),!0;let r=i.toLowerCase();if(r==="on"){if(Notification.permission==="denied")return c("notify: blocked \u2014 allow in browser site settings to enable"),!0;if(Notification.permission!=="granted"&&await Notification.requestPermission()!=="granted")return c("notify: blocked \u2014 allow in browser site settings to enable"),!0;let l=await ke();return await vt(),c(l?"notify: already enabled":"notify: enabled"),!0}if(r==="off")return await It(),c("notify: disabled"),!0;let a=Notification.permission;return a==="denied"?c("notify: blocked \u2014 allow in browser site settings to enable"):a==="granted"&&await ke()?c("notify: enabled"):c("notify: off \u2014 use /notify on to enable"),!0}default:return!1}}var Ze=[{cmd:"/cancel",args:"",desc:"Cancel current response"},{cmd:"/exit",args:"",desc:"Close current session"},{cmd:"/mode",args:"[name]",desc:"Pick or switch mode"},{cmd:"/model",args:"[name]",desc:"Pick or switch model"},{cmd:"/new",args:"[cwd]",desc:"New session"},{cmd:"/notify",args:"[on|off]",desc:"Toggle background notifications"},{cmd:"/prune",args:"",desc:"Delete all sessions except current"},{cmd:"/pwd",args:"",desc:"Show working directory"},{cmd:"/rename",args:"<new title>",desc:"Rename current session"},{cmd:"/switch",args:"<title|id>",desc:"Switch to session"},{cmd:"/think",args:"[level]",desc:"Pick or switch reasoning effort"}],bt=[{key:"Enter",desc:"Send message"},{key:"Shift+Enter",desc:"New line"},{key:"^X",desc:"Cancel current response"},{key:"^M",desc:"Cycle mode (Agent \u2192 Plan \u2192 Autopilot)"},{key:"^U",desc:"Upload image"}],b=-1,f=[],w="commands",z=null,$=null,W=null,Ce=!1;function Z(){let e=o.input.value;if(W!==null){if(e===W)return;W=null}let t=e.match(/^\/new /);if(t){let l=e.slice(t[0].length).toLowerCase();Et(l);return}let s=e.match(/^\/switch /);if(s){let l=e.slice(s[0].length).toLowerCase();Tt(l,"switch");return}let i=e.match(/^\/(model|mode|think) /);if(i){let d={model:"model",mode:"mode",think:"reasoning_effort"}[i[1]],p=e.slice(i[0].length).toLowerCase();kt(d,p);return}let r=e.match(/^\/notify /);if(r){let l=e.slice(r[0].length).toLowerCase();xt(l);return}if(!e.startsWith("/")||e.includes(" ")){h();return}w="commands";let a=e.toLowerCase();if(f=Ze.filter(l=>l.cmd.startsWith(a)),f.length===0){h();return}b=0,F(),o.slashMenu.classList.add("active")}async function Tt(e,t="switch"){if(!$)try{$=await(await fetch("/api/v1/sessions")).json(),setTimeout(()=>{$=null},5e3)}catch{return}if(w=t,f=$.filter(i=>e?i.title&&i.title.toLowerCase().includes(e)||i.id.startsWith(e):!0),f.length===0){h();return}b=0,F(),o.slashMenu.classList.add("active")}async function Et(e){if(!$)try{$=await(await fetch("/api/v1/sessions")).json(),setTimeout(()=>{$=null},5e3)}catch{return}w="new";let t=new Map;for(let i of $){let r=t.get(i.cwd);(!r||(i.last_active_at||i.created_at)>r.time)&&t.set(i.cwd,{cwd:i.cwd,time:i.last_active_at||i.created_at})}let s=[...t.values()].sort((i,r)=>r.time.localeCompare(i.time));if(e&&(s=s.filter(i=>i.cwd.toLowerCase().includes(e))),f=s,f.length===0){h();return}b=0,F(),o.slashMenu.classList.add("active")}function kt(e,t){let s=x(e);if(!s){h();return}if(w="config",z=e,f=s.options.filter(i=>t?i.value.toLowerCase().includes(t)||i.name.toLowerCase().includes(t):!0),f.length===0){h();return}b=0,F(),o.slashMenu.classList.add("active")}var Ct=[{value:"on",name:"on",desc:"Enable background notifications"},{value:"off",name:"off",desc:"Disable background notifications"}];async function xt(e){if(w="notify",f=Ct.filter(i=>e?i.value.includes(e)||i.name.includes(e):!0),f.length===0){h();return}Ce=await ke();let t=Ce?"on":"off",s=f.findIndex(i=>i.value===t);b=s>=0?s:0,F(),o.slashMenu.classList.add("active")}function F(){if(w==="new"){let t=(n.sessionCwd||"").toLowerCase();o.slashMenu.innerHTML=f.map((s,i)=>{let r=s.cwd.toLowerCase()===t;return`<div class="slash-item${i===b?" selected":""}" data-idx="${i}"><span class="slash-cmd"${r?' style="color:var(--green)"':""}>${u((r?"* ":" ")+s.cwd)}</span></div>`}).join("")}else if(w==="config"){let t=ie(z)?.toLowerCase()||"";o.slashMenu.innerHTML=f.map((s,i)=>{let r=s.value.toLowerCase()===t;return`<div class="slash-item${i===b?" selected":""}" data-idx="${i}"><span class="slash-cmd"${r?' style="color:var(--green)"':""}>${u((r?"* ":" ")+s.name)}</span></div>`}).join("")}else if(w==="notify"){let t=Ce?"on":"off";o.slashMenu.innerHTML=f.map((s,i)=>{let r=s.value===t;return`<div class="slash-item${i===b?" selected":""}" data-idx="${i}"><span class="slash-cmd"${r?' style="color:var(--green)"':""}>${u((r?"* ":" ")+s.name)}</span><span class="slash-desc">${u(s.desc)}</span></div>`}).join("")}else w==="switch"?o.slashMenu.innerHTML=f.map((t,s)=>{let i=t.id===n.sessionId,r=i?"* ":" ",a=t.title||t.id.slice(0,8)+"\u2026",l=je(t.last_active_at||t.created_at);return`<div class="slash-item${s===b?" selected":""}" data-idx="${s}"><span class="slash-cmd"${i?' style="color:var(--green)"':""}>${u(r+a)}</span><span class="slash-desc">${u(t.cwd)} (${u(l)})</span></div>`}).join(""):o.slashMenu.innerHTML=f.map((t,s)=>{let i=t.args?`${t.cmd} ${t.args}`:t.cmd;return`<div class="slash-item${s===b?" selected":""}" data-idx="${s}"><span class="slash-cmd">${u(i)}</span><span class="slash-desc">${u(t.desc)}</span></div>`}).join("");let e=o.slashMenu.querySelector(".selected");e&&e.scrollIntoView({block:"nearest"})}function h(){o.slashMenu.classList.remove("active"),b=-1,f=[],w="commands",W=o.input.value}function Mt(e){if(!(e<0||e>=f.length)){if(w==="commands"){let t=f[e];o.input.value=t.cmd+(t.args?" ":""),h(),o.input.focus(),["/new","/switch","/model","/mode","/think","/notify"].includes(t.cmd)&&(W=null,Z())}else if(w==="config"){let t=f[e],s={model:"/model",mode:"/mode",reasoning_effort:"/think"}[z]||`/${z}`;o.input.value=`${s} ${t.name}`,h(),o.input.focus()}else if(w==="notify"){let t=f[e];o.input.value=`/notify ${t.value}`,h(),o.input.focus()}else if(w==="new"){let t=f[e];o.input.value=`/new ${t.cwd}`,h(),o.input.focus()}else if(w==="switch"){let t=f[e];o.input.value=`/switch ${t.title||t.id}`,h(),o.input.focus()}L()}}async function Lt(e){if(!(e<0||e>=f.length)){if(w==="new"){let t=f[e];o.input.value="",h(),y(),c("Creating new session\u2026"),M({cwd:t.cwd})}else if(w==="config"){let t=f[e],s=z,i=x(s);o.input.value="",h(),i&&(i.currentValue=t.value),_(),O(),c(`${i?.name||s} \u2192 ${t.name}`),await K(n.sessionId,s,t.value).catch(()=>{})}else if(w==="switch"){let t=f[e];o.input.value="",h(),y(),n.sessionId=null,c("Switching\u2026"),Promise.all([P(t.id),N(t.id)]).then(([s,i])=>{C({type:"session_created",sessionId:s.id,cwd:s.cwd,title:s.title,configOptions:s.configOptions,busyKind:s.busyKind}),i&&S(!0)}).catch(()=>{y(),n.sessionId=null,c("err: Failed to switch session")})}else if(w==="notify"){let t=f[e];o.input.value=`/notify ${t.value}`,h(),xe(o.input.value),o.input.value=""}else{let t=f[e];o.input.value=t.cmd+(t.args?" ":""),h(),o.input.focus(),["/new","/switch","/model","/mode","/think","/notify"].includes(t.cmd)&&(W=null,Z())}L()}}function et(e){return o.slashMenu.classList.contains("active")?e.key==="ArrowDown"?(b=(b+1)%f.length,F(),!0):e.key==="ArrowUp"?(b=(b-1+f.length)%f.length,F(),!0):e.key==="Tab"?(Mt(b),!0):!1:!1}o.slashMenu.addEventListener("mousedown",e=>{e.preventDefault();let t=e.target.closest(".slash-item");t&&Lt(Number(t.dataset.idx))});o.input.addEventListener("input",()=>{Z(),o.inputArea.classList.toggle("bash-mode",o.input.value.startsWith("!"))});function tt(e){return new Promise(t=>{let s=new FileReader;s.onload=()=>{let i=s.result.split(",")[1];t({data:i,mimeType:e.type,previewUrl:s.result})},s.readAsDataURL(e)})}function nt(e){n.pendingImages.push(e),le(),o.input.focus()}function le(){if(o.attachPreview.innerHTML="",n.pendingImages.length===0){o.attachPreview.classList.remove("active");return}o.attachPreview.classList.add("active"),n.pendingImages.forEach((e,t)=>{let s=document.createElement("span");s.className="attach-thumb",s.innerHTML=`<img src="${e.previewUrl}"><button class="remove">\xD7</button>`,s.querySelector(".remove").addEventListener("click",()=>{n.pendingImages.splice(t,1),le()}),o.attachPreview.appendChild(s)})}o.attachBtn.onclick=()=>o.fileInput.click();o.fileInput.onchange=async()=>{for(let e of o.fileInput.files)e.type.startsWith("image/")&&nt(await tt(e));o.fileInput.value=""};o.input.addEventListener("paste",async e=>{for(let t of e.clipboardData.items)t.type.startsWith("image/")&&(e.preventDefault(),nt(await tt(t.getAsFile())))});function Me(){return n.clientId!==null}n._onCancelTimeout=()=>c("warn: Agent not responding to cancel");function st(){let e=o.input.value.trim();if(!e&&n.pendingImages.length===0)return;if((e.startsWith("/")||e==="?"||e.startsWith("? "))&&n.pendingImages.length===0){o.input.value="",o.input.style.height="auto",L(),Le(),xe(e);return}if(e.startsWith("!")&&n.pendingImages.length===0){let i=e.slice(1).trim();if(!i)return;if(!n.sessionId){c("warn: Session not ready yet, please wait\u2026");return}if(!Me()){c("warn: Not connected, please retry");return}o.input.value="",o.input.style.height="auto",o.inputArea.classList.remove("bash-mode"),L(),Q(i,!0),n.sentBashForSession=n.sessionId,qe(n.sessionId,i).catch(()=>{}),I(!0);return}if(n.busy)return;if(o.input.value="",o.input.style.height="auto",o.inputArea.classList.remove("bash-mode"),L(),!n.sessionId){c("warn: Session not ready yet, please wait\u2026");return}if(!Me()){c("warn: Not connected, please retry");return}let t=R("user",e||"(image)");for(let i of n.pendingImages){let r=document.createElement("img");r.className="user-image",r.src=i.previewUrl,t.appendChild(r)}let s=n.pendingImages.slice();n.pendingImages.length=0,le(),s.length>0?Promise.all(s.map(i=>fetch(`/api/v1/sessions/${n.sessionId}/images`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({data:i.data,mimeType:i.mimeType})}).then(r=>r.json()).then(r=>({data:i.data,mimeType:i.mimeType,path:r.url})))).then(i=>{if(!Me()){t.remove(),c("warn: Not connected, please retry"),I(!1);return}ue(n.sessionId,e||"What is in this image?",i.map(r=>({data:r.data,mimeType:r.mimeType,path:r.path}))).catch(()=>{})}):ue(n.sessionId,e).catch(()=>{}),n.turnEnded=!1,n.sentMessageForSession=n.sessionId,I(!0),Ue()}function it(){G()&&c("^X")}function ot(){let e=o.input.value.trim();return e.startsWith("/")||e.startsWith("!")||e==="?"||e.startsWith("? ")}function Le(){n.busy&&(ot()?(o.sendBtn.textContent="\u21B5",o.sendBtn.title="Send (Enter)",o.sendBtn.classList.remove("cancel")):(o.sendBtn.textContent="^X",o.sendBtn.title="Cancel (Ctrl+X)",o.sendBtn.classList.add("cancel")))}o.sendBtn.onclick=()=>{n.busy&&!ot()?it():st()};o.input.addEventListener("keydown",e=>{if(et(e)){e.preventDefault();return}if(e.key==="Enter"&&!e.shiftKey){e.preventDefault(),h(),st();return}if(e.key==="u"&&(e.ctrlKey||e.metaKey)&&!e.shiftKey){e.preventDefault(),o.fileInput.click();return}});document.addEventListener("keydown",e=>{if(e.key==="x"&&(e.ctrlKey||e.metaKey)&&!e.shiftKey&&n.busy){e.preventDefault(),it();return}e.key==="Escape"&&o.slashMenu.classList.contains("active")&&(e.preventDefault(),h(),o.input.focus())});function rt(){let e=x("mode");if(!e||!e.options.length)return;let t=e.options.findIndex(i=>i.value===e.currentValue),s=e.options[(t+1)%e.options.length];e.currentValue=s.value,K(n.sessionId,"mode",s.value).catch(()=>{}),c(`Mode \u2192 ${s.name}`),_()}document.addEventListener("keydown",e=>{e.key==="m"&&(e.ctrlKey||e.metaKey)&&!e.shiftKey&&(e.preventDefault(),rt())});o.prompt.addEventListener("click",rt);o.newBtn.addEventListener("click",()=>{o.input.value="/new ",L(),Le(),Z(),o.input.focus()});o.input.addEventListener("input",()=>{L(),Le()});o.input.addEventListener("focus",L);o.input.addEventListener("input",()=>{o.input.style.height="auto",o.input.style.height=Math.min(o.input.scrollHeight,200)+"px"});async function $t(e){try{let t=await navigator.serviceWorker?.ready;if(!t)return;let s=await t.pushManager.getSubscription();if(!s)return;await fetch("/api/v1/push/register-client",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({clientId:e,endpoint:s.endpoint})})}catch{}}function Pe(){B("connecting","connecting");let e=new EventSource("/api/v1/events/stream");n.eventSource=e,e.onmessage=async t=>{let s=JSON.parse(t.data);if(s.type==="connected"){n.clientId=s.clientId,V(s.clientId,!document.hidden,n.sessionId??void 0).catch(()=>{}),$t(s.clientId);return}C(s)},e.onerror=()=>{e.close(),Ht(),setTimeout(Pe,3e3)},Pt()}async function Pt(){B("connecting","session loading");let e=n.sessionSwitchGen,t=Re();if(t&&t===n.sessionId&&n.lastEventSeq>0){if(await $e(t,!0,e),e!==n.sessionSwitchGen)return;Xe(),S(!1);return}if(t){if(y(),await $e(t,!1,e),e!==n.sessionSwitchGen)return;S(!0);return}try{let s=await te();if(e!==n.sessionSwitchGen)return;if(s.length>0){if(y(),await $e(s[0].id,!1,e),e!==n.sessionSwitchGen)return;S(!0);return}}catch{}e===n.sessionSwitchGen&&M()}async function $e(e,t,s){if(t){try{let i=await P(e);if(s!==n.sessionSwitchGen)return;C({type:"session_created",sessionId:i.id,cwd:i.cwd,title:i.title,configOptions:i.configOptions,busyKind:i.busyKind})}catch{if(s!==n.sessionSwitchGen)return;y(),c("warn: Previous session expired, created new one."),M();return}if(s!==n.sessionSwitchGen)return;await be(e)}else{n.sessionId=null;let i=N(e),r;try{let[a,l]=await Promise.all([P(e),i]);if(s!==n.sessionSwitchGen)return;r=a,l||c("warn: Failed to load history.")}catch{if(s!==n.sessionSwitchGen)return;y(),c("warn: Previous session expired, created new one."),M();return}C({type:"session_created",sessionId:r.id,cwd:r.cwd,title:r.title,configOptions:r.configOptions,busyKind:r.busyKind})}}function Ht(){B("disconnected","disconnected"),n.eventSource=null,n.clientId=null,k(),H(),n.currentBashEl&&Y(n.currentBashEl,null,"disconnected"),n.pendingToolCallIds.clear(),n.pendingPermissionRequestIds.clear(),n.pendingPromptDone=!1,n.turnEnded=!1,J(),I(!1)}document.addEventListener("visibilitychange",()=>{n.clientId&&V(n.clientId,!document.hidden,n.sessionId??void 0).catch(()=>{}),!document.hidden&&n.sessionId&&n.lastEventSeq>0&&!n.replayInProgress&&be(n.sessionId).then(()=>S(!1))});Pe();"serviceWorker"in navigator&&(navigator.serviceWorker.register("/sw.js"),navigator.serviceWorker.addEventListener("message",e=>{if(e.data?.type==="navigate"&&e.data.sessionId){let t=e.data.sessionId;if(n.sessionId===t)return;n.sessionSwitchGen++;let s=n.sessionSwitchGen;history.replaceState(null,"",`#${t}`),y(),n.sessionId=null,c("Switching\u2026"),Promise.all([P(t),N(t)]).then(([i,r])=>{s===n.sessionSwitchGen&&(C({type:"session_created",sessionId:i.id,cwd:i.cwd,title:i.title,configOptions:i.configOptions,busyKind:i.busyKind}),r&&S(!0))}).catch(()=>{y(),n.sessionId=null,c("err: Failed to switch session")})}}));
|
package/dist/sw.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Minimal service worker for PWA installability + push notifications.
|
|
2
|
-
// No offline caching — app requires
|
|
2
|
+
// No offline caching — app requires SSE connection.
|
|
3
3
|
|
|
4
4
|
self.addEventListener('install', () => self.skipWaiting());
|
|
5
5
|
self.addEventListener('activate', (e) => e.waitUntil(self.clients.claim()));
|
package/lib/event-handler.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
export function handleAgentEvent(event, sessions, store, wss, bridge, config, pushService) {
|
|
1
|
+
export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService) {
|
|
3
2
|
if ("sessionId" in event && event.sessionId && sessions.restoringSessions.has(event.sessionId))
|
|
4
3
|
return;
|
|
5
4
|
switch (event.type) {
|
|
@@ -44,23 +43,33 @@ export function handleAgentEvent(event, sessions, store, wss, bridge, config, pu
|
|
|
44
43
|
store.saveEvent(event.sessionId, event.type, {
|
|
45
44
|
requestId: event.requestId, title: event.title, options: event.options,
|
|
46
45
|
});
|
|
46
|
+
sessions.pendingPermissions.set(event.requestId, {
|
|
47
|
+
requestId: event.requestId,
|
|
48
|
+
sessionId: event.sessionId,
|
|
49
|
+
title: event.title,
|
|
50
|
+
options: event.options.map((o) => ({ optionId: o.optionId, label: o.label ?? o.name ?? o.optionId })),
|
|
51
|
+
});
|
|
47
52
|
// Auto-approve permissions in autopilot mode (allow_once only to avoid persisting across mode switches)
|
|
48
53
|
const mode = store.getSession(event.sessionId)?.mode ?? "";
|
|
49
54
|
if (mode.includes("#autopilot")) {
|
|
50
55
|
const opt = event.options.find((o) => o.kind === "allow_once");
|
|
51
56
|
if (opt) {
|
|
52
57
|
bridge.resolvePermission(event.requestId, opt.optionId);
|
|
58
|
+
sessions.pendingPermissions.delete(event.requestId);
|
|
53
59
|
const optionName = opt.label ?? opt.optionId;
|
|
54
60
|
store.saveEvent(event.sessionId, "permission_response", {
|
|
55
61
|
requestId: event.requestId, optionName, denied: false,
|
|
56
62
|
});
|
|
57
|
-
|
|
63
|
+
// Broadcast both so the frontend can render then collapse the permission card
|
|
64
|
+
sseManager.broadcast(event);
|
|
65
|
+
const resolvedEvent = {
|
|
58
66
|
type: "permission_resolved",
|
|
59
67
|
sessionId: event.sessionId,
|
|
60
68
|
requestId: event.requestId,
|
|
61
69
|
optionName,
|
|
62
70
|
denied: false,
|
|
63
|
-
}
|
|
71
|
+
};
|
|
72
|
+
sseManager.broadcast(resolvedEvent);
|
|
64
73
|
return;
|
|
65
74
|
}
|
|
66
75
|
}
|
|
@@ -77,8 +86,8 @@ export function handleAgentEvent(event, sessions, store, wss, bridge, config, pu
|
|
|
77
86
|
}
|
|
78
87
|
break;
|
|
79
88
|
}
|
|
80
|
-
broadcast(
|
|
81
|
-
// Push notification check (after broadcast so
|
|
89
|
+
sseManager.broadcast(event);
|
|
90
|
+
// Push notification check (after broadcast so clients get the event first)
|
|
82
91
|
if (pushService && "sessionId" in event && event.sessionId) {
|
|
83
92
|
const session = store.getSession(event.sessionId);
|
|
84
93
|
const eventData = {};
|
package/lib/push-service.js
CHANGED
|
@@ -2,10 +2,16 @@ import webpush from "web-push";
|
|
|
2
2
|
import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const VAPID_FILE = "vapid.json";
|
|
5
|
+
/** Remove a subscription after this many consecutive send failures. */
|
|
6
|
+
const MAX_CONSECUTIVE_FAILURES = 5;
|
|
5
7
|
export class PushService {
|
|
6
8
|
store;
|
|
7
9
|
vapidKeys;
|
|
8
10
|
clientVisibility = new Map(); // clientId → visible
|
|
11
|
+
clientEndpoints = new Map(); // clientId → push endpoint
|
|
12
|
+
clientSessions = new Map(); // clientId → currently viewed sessionId
|
|
13
|
+
/** endpoint → consecutive failure count (absent or 0 = healthy) */
|
|
14
|
+
failureCounts = new Map();
|
|
9
15
|
constructor(store, dataDir, vapidSubject) {
|
|
10
16
|
this.store = store;
|
|
11
17
|
this.vapidKeys = this.loadOrGenerateKeys(dataDir);
|
|
@@ -34,7 +40,7 @@ export class PushService {
|
|
|
34
40
|
// Notification formatting
|
|
35
41
|
// ---------------------------------------------------------------------------
|
|
36
42
|
formatNotification(sessionId, sessionTitle, eventType, eventData) {
|
|
37
|
-
const title = sessionTitle
|
|
43
|
+
const title = sessionTitle || "WebAgent";
|
|
38
44
|
let body;
|
|
39
45
|
switch (eventType) {
|
|
40
46
|
case "permission_request":
|
|
@@ -60,8 +66,16 @@ export class PushService {
|
|
|
60
66
|
setClientVisibility(clientId, visible) {
|
|
61
67
|
this.clientVisibility.set(clientId, visible);
|
|
62
68
|
}
|
|
69
|
+
setClientSession(clientId, sessionId) {
|
|
70
|
+
this.clientSessions.set(clientId, sessionId);
|
|
71
|
+
}
|
|
72
|
+
registerClient(clientId, endpoint) {
|
|
73
|
+
this.clientEndpoints.set(clientId, endpoint);
|
|
74
|
+
}
|
|
63
75
|
removeClient(clientId) {
|
|
64
76
|
this.clientVisibility.delete(clientId);
|
|
77
|
+
this.clientEndpoints.delete(clientId);
|
|
78
|
+
this.clientSessions.delete(clientId);
|
|
65
79
|
}
|
|
66
80
|
hasVisibleClient() {
|
|
67
81
|
for (const visible of this.clientVisibility.values()) {
|
|
@@ -70,19 +84,39 @@ export class PushService {
|
|
|
70
84
|
}
|
|
71
85
|
return false;
|
|
72
86
|
}
|
|
87
|
+
/** Check if a specific endpoint has at least one visible client. */
|
|
88
|
+
isEndpointVisible(endpoint) {
|
|
89
|
+
for (const [clientId, ep] of this.clientEndpoints) {
|
|
90
|
+
if (ep === endpoint && this.clientVisibility.get(clientId))
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Check if a specific endpoint has a visible client viewing the given session.
|
|
97
|
+
* A client with no session set does not suppress any session's push.
|
|
98
|
+
*/
|
|
99
|
+
isEndpointVisibleForSession(endpoint, sessionId) {
|
|
100
|
+
for (const [clientId, ep] of this.clientEndpoints) {
|
|
101
|
+
if (ep === endpoint
|
|
102
|
+
&& this.clientVisibility.get(clientId)
|
|
103
|
+
&& this.clientSessions.get(clientId) === sessionId)
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
73
108
|
// ---------------------------------------------------------------------------
|
|
74
109
|
// High-level: decide whether to push, and if so, send
|
|
75
110
|
// ---------------------------------------------------------------------------
|
|
76
111
|
static NOTIFIABLE = new Set(["permission_request", "prompt_done", "bash_done"]);
|
|
77
112
|
/**
|
|
78
113
|
* Check if this event should trigger a push notification.
|
|
79
|
-
* Returns true if a notification
|
|
114
|
+
* Returns true if a notification should be sent (caller should then call sendToAll).
|
|
115
|
+
* Per-subscription visibility filtering happens inside sendToAll.
|
|
80
116
|
*/
|
|
81
117
|
maybeNotify(sessionId, sessionTitle, eventType, eventData) {
|
|
82
118
|
if (!PushService.NOTIFIABLE.has(eventType))
|
|
83
119
|
return false;
|
|
84
|
-
if (this.hasVisibleClient())
|
|
85
|
-
return false;
|
|
86
120
|
return true;
|
|
87
121
|
}
|
|
88
122
|
// ---------------------------------------------------------------------------
|
|
@@ -93,20 +127,42 @@ export class PushService {
|
|
|
93
127
|
if (subs.length === 0)
|
|
94
128
|
return;
|
|
95
129
|
const payload = JSON.stringify(notification);
|
|
96
|
-
|
|
130
|
+
// Per-subscription visibility: skip endpoints where a visible client is viewing this session
|
|
131
|
+
const targets = subs.filter((sub) => !this.isEndpointVisibleForSession(sub.endpoint, notification.data.sessionId));
|
|
132
|
+
if (targets.length === 0)
|
|
133
|
+
return;
|
|
134
|
+
const results = await Promise.allSettled(targets.map((sub) => this.sendOne({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, payload)));
|
|
97
135
|
for (let i = 0; i < results.length; i++) {
|
|
98
136
|
const result = results[i];
|
|
99
|
-
|
|
137
|
+
const endpoint = targets[i].endpoint;
|
|
138
|
+
if (result.status === "fulfilled") {
|
|
139
|
+
this.failureCounts.delete(endpoint);
|
|
140
|
+
}
|
|
141
|
+
else {
|
|
100
142
|
const err = result.reason;
|
|
101
143
|
if (err.statusCode === 410) {
|
|
102
|
-
// Subscription expired — clean up
|
|
103
|
-
this.store.removeSubscription(
|
|
104
|
-
|
|
144
|
+
// Subscription expired — clean up immediately
|
|
145
|
+
this.store.removeSubscription(endpoint);
|
|
146
|
+
this.failureCounts.delete(endpoint);
|
|
147
|
+
console.log(`[push] removed expired subscription (410): ${endpoint.slice(0, 60)}…`);
|
|
105
148
|
}
|
|
106
149
|
else {
|
|
107
|
-
|
|
150
|
+
const count = (this.failureCounts.get(endpoint) ?? 0) + 1;
|
|
151
|
+
if (count >= MAX_CONSECUTIVE_FAILURES) {
|
|
152
|
+
this.store.removeSubscription(endpoint);
|
|
153
|
+
this.failureCounts.delete(endpoint);
|
|
154
|
+
console.log(`[push] removed subscription after ${count} consecutive failures: ${endpoint.slice(0, 60)}…`);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
this.failureCounts.set(endpoint, count);
|
|
158
|
+
console.error(`[push] send failed (${count}/${MAX_CONSECUTIVE_FAILURES}) for ${endpoint.slice(0, 60)}…:`, result.reason);
|
|
159
|
+
}
|
|
108
160
|
}
|
|
109
161
|
}
|
|
110
162
|
}
|
|
111
163
|
}
|
|
164
|
+
/** Send a single push notification. Extracted for testability. */
|
|
165
|
+
sendOne(sub, payload) {
|
|
166
|
+
return webpush.sendNotification(sub, payload);
|
|
167
|
+
}
|
|
112
168
|
}
|