@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 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 WebSocket communication (`ws`), SQLite persistence (`better-sqlite3`), Zod validation.
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 # build static assets (public/ → dist/)
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, uses data-dev/, auto-restarts on file changes
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/WebSocket server port |
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`, `/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.
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
- | `/delete <title\|id>` | Delete a session |
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
- - WebSocket auto-reconnect (3s retry on disconnect)
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 ←WebSocket→ server.ts ←ACP→ copilot CLI
252
- ├── routes.ts (HTTP handlers)
253
- ├── ws-handler.ts (WS dispatch)
254
- ├── session-manager.ts (state)
255
- ├── title-service.ts (auto-title)
256
- ├── push-service.ts (Web Push)
257
- ├── daemon.ts (background service)
258
- └── store.ts (SQLite)
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/WebSocket server bootstrap
262
+ - **server.ts** — HTTP server bootstrap
262
263
  - **routes.ts** — HTTP request handlers (static files, REST API, image upload, push subscription)
263
- - **ws-handler.ts** — WebSocket message dispatch + broadcast + visibility tracking
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 for WS messages
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
@@ -1,6 +1,6 @@
1
1
  # WebAgent production configuration
2
2
 
3
- # HTTP/WebSocket server port (default: 6800)
3
+ # HTTP server port (default: 6800)
4
4
  port = 6800
5
5
 
6
6
  # SQLite + uploads directory (default: "data")
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.mmmmfxhu.css">
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 type="module" src="/js/app.mmmmfxhu.js"></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")})}}));
@@ -572,3 +572,11 @@ body {
572
572
  display: block;
573
573
  margin-top: 4px;
574
574
  }
575
+
576
+ /* History pagination sentinel */
577
+ .history-sentinel {
578
+ text-align: center;
579
+ padding: 8px;
580
+ opacity: 0.5;
581
+ font-size: 0.85em;
582
+ }
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 WebSocket connection.
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()));
@@ -1,5 +1,4 @@
1
- import { broadcast } from "./ws-handler.js";
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
- broadcast(wss, {
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(wss, event);
81
- // Push notification check (after broadcast so WS clients get the event first)
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 = {};
@@ -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 ? `WebAgent · ${sessionTitle}` : "WebAgent";
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 was queued (caller should then call sendToAll).
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
- const results = await Promise.allSettled(subs.map((sub) => webpush.sendNotification({ endpoint: sub.endpoint, keys: { auth: sub.auth, p256dh: sub.p256dh } }, payload)));
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
- if (result.status === "rejected") {
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(subs[i].endpoint);
104
- console.log(`[push] removed expired subscription: ${subs[i].endpoint}`);
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
- console.error(`[push] send failed for ${subs[i].endpoint}:`, result.reason);
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
  }