tmux-weblink 2.0.32 → 2.1.0

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
@@ -1,5 +1,7 @@
1
1
  # tmux-weblink
2
2
 
3
+ **English** | [中文](README.zh-CN.md)
4
+
3
5
  Access your tmux sessions from the browser. A lightweight web server that lists running tmux sessions and lets you attach through a full terminal in your browser — with built-in notes and a sidebar.
4
6
 
5
7
  ## Install
@@ -0,0 +1,112 @@
1
+ # tmux-weblink
2
+
3
+ [English](README.md) | **中文**
4
+
5
+ 在浏览器里访问你的 tmux 会话。一个轻量级 Web 服务器:列出正在运行的 tmux 会话,并让你通过浏览器里的完整终端接入——内置笔记与侧边栏,移动端与桌面端都能用。
6
+
7
+ ## 特性
8
+
9
+ - **完整终端**:xterm.js 渲染,支持 256 色、复制粘贴、粘贴图片到 CLI 工具
10
+ - **触摸优化**:移动端惯性滚动、Shift+拖选松手自动复制、虚拟键盘工具栏
11
+ - **PWA**:手机"添加到主屏幕"、桌面浏览器"安装应用"即为独立窗口应用
12
+ - **侧边栏会话分组**:按工作目录自动聚类(显示文件夹名),可折叠
13
+ - **New Session 目录树选择器**:层层展开目录树,直接定位到当前会话所在路径
14
+ - **笔记(Notes)**:每个会话独立的 Markdown 便签
15
+ - **文件浏览/编辑(Files)**:浏览、编辑本地文件(默认仅本机,路径受限)
16
+ - **Machines 联邦**:把 NAT 后的机器加入公共 hub(hub 无需开放端口),多机终端矩阵
17
+ - **窗口抽屉**:在终端头部切换 tmux 窗口(移动端友好的标签选择器)
18
+ - **安全**:scrypt 哈希 token 认证、登录限流、审计日志
19
+
20
+ ## 安装
21
+
22
+ ```bash
23
+ npm install -g tmux-weblink
24
+ ```
25
+
26
+ 或者直接用 npx 运行:
27
+
28
+ ```bash
29
+ npx tmux-weblink
30
+ ```
31
+
32
+ ## 使用
33
+
34
+ ```bash
35
+ # 交互式初始化(创建 token 等)
36
+ tmux-weblink setup
37
+
38
+ # 默认端口 21000 启动
39
+ tmux-weblink
40
+
41
+ # 自定义端口
42
+ PORT=8080 tmux-weblink
43
+
44
+ # 可选:先读尾部缓冲再加载历史(见 docs/architecture.md)
45
+ TMUX_WEB_INITIAL_LINES=1000 TMUX_WEB_HISTORY_CHUNK=500 tmux-weblink
46
+
47
+ # Machines:把本机加入一个 hub(无需开放端口)
48
+ tmux-weblink agent --hub wss://hub.example.com --token <agent-token> --name laptop
49
+ ```
50
+
51
+ > **每台机器都用同一条启动命令。** 加入 hub:在要接入的机器上打开
52
+ > `http://localhost:21000/settings/machines`,粘贴 hub 地址 + token,点
53
+ > **Save & Connect**——agent 客户端与普通服务器同进程运行。hub 自己的
54
+ > `/settings/machines` 页面负责创建/吊销 token。
55
+
56
+ 然后浏览器打开 `http://localhost:21000`,你会看到活跃的 tmux 会话列表——点击即可接入。
57
+
58
+ ## 手机端 / PWA
59
+
60
+ - **iOS**:Safari 分享 → "添加到主屏幕",全屏独立运行
61
+ - **Android / Chrome**:菜单 → "添加到主屏幕" / "安装应用"
62
+ - 支持软键盘自动适配、触摸惯性滚动、拖选复制
63
+
64
+ ## 桌面端(Linux / Windows / macOS)
65
+
66
+ 桌面浏览器(Chrome / Edge / Chromium)地址栏右侧点"安装"图标即可生成**独立窗口应用**:
67
+
68
+ - 本机 `http://localhost:端口` 或 HTTPS 均可;内网 HTTP IP 不会出现安装提示(非 secure context)
69
+ - Windows 上服务端原生运行受限(node-pty 需 WSL2),可用 WSL2 或接入远程 hub 使用
70
+
71
+ ## 文档
72
+
73
+ - [文档中心](docs/index.md)
74
+ - [笔记](docs/notes.md) — 每个会话独立的 Markdown 便签
75
+ - [文件](docs/files.md) — 浏览/编辑本地文件
76
+ - [Machines / Agents](docs/agents.md) — 把 NAT 机器接入公共 hub
77
+ - [架构](docs/architecture.md) — 服务器、终端、侧边栏如何协作
78
+
79
+ ## 环境变量(常用)
80
+
81
+ | 变量 | 默认 | 说明 |
82
+ | --- | --- | --- |
83
+ | `PORT` | `21000` | HTTP 端口 |
84
+ | `TMUX_WEB_MODE` | `production` | `development` 用 dev 数据目录 |
85
+ | `TMUX_WEB_INITIAL_LINES` | — | 打开会话时先读尾部 N 行 |
86
+ | `TMUX_WEB_HISTORY_CHUNK` | — | 历史分批加载的块大小 |
87
+ | `TMUX_WEB_AGENT_HUB` / `TMUX_WEB_AGENT_TOKEN` / `TMUX_WEB_AGENT_NAME` | — | agent 连接参数 |
88
+ | `FS_ROOTS` | `~` | 文件浏览可访问的根目录(逗号分隔) |
89
+
90
+ ## 前提条件
91
+
92
+ - **Node.js** >= 22
93
+ - **tmux** 已安装且在 PATH 中
94
+ - 可写的 `~/.tmux-web/` 和 `~/.config/tmux-web/`(dev 模式路径见 [docs](docs/index.md))
95
+
96
+ ## Windows 说明
97
+
98
+ - 服务端在 Windows 上原生运行受限(node-pty 在 Windows 无 linux-x64 类 prebuild,需源码编译;推荐 **WSL2** 内运行服务)
99
+ - 浏览器客户端(PWA / 网页)在 Windows 上完全正常,可接入本机 WSL2 或远程 hub 的会话
100
+
101
+ ## 致谢
102
+
103
+ 本项目建立在以下项目的想法和代码之上:
104
+
105
+ - [tmux-web](https://github.com/ashutoshpw/tmux-web) by [@ashutoshpw](https://github.com/ashutoshpw)
106
+ - [persalink](https://github.com/brobata/persalink) by [@brobata](https://github.com/brobata)
107
+
108
+ 它是这些实验的延续与重新打包,目标是做一个单用户、浏览器优先、易于安装运行的 tmux 伴侣。
109
+
110
+ ## 许可证
111
+
112
+ MIT
@@ -1,4 +1,4 @@
1
- import{existsSync as P,readdirSync as ie,statSync as A,unlinkSync as ae,writeFileSync as de,mkdirSync as ue,readFileSync as $}from"node:fs";import{readFile as ce}from"node:fs/promises";import{execSync as _,execFileSync as G}from"node:child_process";import p from"node:path";import{fileURLToPath as le}from"node:url";import{Hono as me}from"hono";import{listSessions as L}from"../sessions.js";import{renderLoginPage as fe,renderNotesIndex as pe,renderNotesPage as he,renderSettings as ge,renderThemeSettings as ye,renderHistoryIndex as je,renderQuickCommandsPage as we,renderFilesIndex as ke,renderShell as ve}from"../frontend.js";import{renderFederationPage as qe}from"./pages/federation.js";import{createAgentToken as Se,removeAgentToken as Te,listAgentTokens as z}from"./agent-tokens.js";import{hashPassword as Ae,verifyPassword as be,validatePassword as xe}from"./auth.js";import{atomicWriteFileSync as _e}from"./atomicWrite.js";import{resolveFsPath as b,resolveFsRoots as B,MAX_FILE_BYTES as V,walkRecursive as Ce}from"./fs-access.js";import{captureSessionWindowsWithPath as Oe}from"./tmux-windows.js";import{audit as j}from"./auditLog.js";import{db as S}from"./db.js";import{getSessionAccessMap as Ee}from"./session-access.js";import{listWindowHistory as Ie,clearWindowHistory as Ne}from"./window-history.js";import{saveSecurityConfig as Re}from"./security-config.js";import{readSettings as Q,writeSettings as Pe}from"./settings.js";import{setActiveThemeTemplate as K}from"./theme-store.js";import{isThemeTemplateId as X,THEME_TEMPLATE_IDS as Le}from"./themes/index.js";import{pinView as De,unpinView as Fe,listPinnedViews as He}from"./pinned-views.js";import{getStoredWindows as Ue}from"./session-windows.js";import{buildSidebarSessions as Me}from"./sessions-sidebar.js";import{createQuickCommand as We,deleteQuickCommand as $e,listQuickCommands as Y,updateQuickCommand as Ge}from"./quick-commands.js";import{getSystemStatus as ze,getTopProcesses as Be,killProcess as Ve}from"./system-monitor.js";import{newTmuxSession as Qe,renameSession as Ke,killSession as Xe,TmuxWindowsError as Ye}from"./tmux-windows.js";import{registerAgentTunnel as Ze}from"./agent-tunnel.js";import{injectScopeScript as Je}from"./scope-script.js";const D="tmux-web-token",et=365;function tt(x){return x==="127.0.0.1"||x==="::1"||x==="::ffff:127.0.0.1"}function Rt(x){const{mode:Z,securityConfig:h,tokenStore:y,rateLimiter:O,settings:rt,terminalBufferConfig:E,getAgentId:F,state:u,federation:w,hub:q}=x,k=Z==="agent";function C(e){if(h.security.trustProxy){const t=(e.req.header("x-forwarded-for")||"").split(",")[0].trim();if(t)return t}return e.env?.incoming?.socket?.remoteAddress||"unknown"}function I(e){const t=e.req.header("authorization");if(t?.startsWith("Bearer "))return t.slice(7);const r=e.req.header("cookie");if(!r)return null;const n=r.match(new RegExp(`(?:^|;\\s*)${D}=([^;]+)`));return n?decodeURIComponent(n[1]):null}function nt(e){const t=I(e);return t?y.validateToken(t):null}function H(e,t){const r=e.req.header("x-forwarded-proto")==="https"||e.req.url.startsWith("https:");e.header("Set-Cookie",`${D}=${encodeURIComponent(t)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${et*86400}${r?"; Secure":""}`)}function J(e){e.header("Set-Cookie",`${D}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)}const o=()=>k?async(e,t)=>t():async(e,t)=>{const r=I(e);return!r||!y.validateToken(r)?(j("http_unauthorized",{ip:C(e)}),e.json({error:"unauthorized"},401)):t()},g=()=>k?async(e,t)=>t():async(e,t)=>{const r=I(e);if(!r||!y.validateToken(r)){const n=encodeURIComponent(e.req.url);return e.redirect(`/login?returnTo=${n}`,302)}return t()};function N(e){const t=Me(L(),Ee(),He()),r=[];if(q)for(const n of q.agents.list()){const i=q.agents.get(n.agentId);r.push({agentId:n.agentId,agentName:n.name,online:n.online,sessions:i?i.sessions:[]})}return{...t,currentSession:e??null,agents:r}}function U(e){const t=typeof e.sessionName=="string"?e.sessionName.trim():"";if(!t)return{error:"sessionName is required"};if(e.windowIndex===void 0)return{sessionName:t};const r=e.windowIndex;return typeof r!="number"||!Number.isInteger(r)||r<0?{error:"windowIndex must be a non-negative integer"}:{sessionName:t,windowIndex:r}}const s=new me;s.use("*",async(e,t)=>{await t(),e.header("X-Content-Type-Options","nosniff"),e.header("X-Frame-Options","DENY"),e.header("Content-Security-Policy",["default-src 'self'","script-src 'self' 'unsafe-inline'","style-src 'self' 'unsafe-inline' https://fonts.googleapis.com","connect-src 'self' ws: wss: http: https:","img-src 'self' data:","font-src 'self' https://fonts.gstatic.com","worker-src 'self' blob:"].join("; ")),e.header("Referrer-Policy","strict-origin-when-cross-origin")}),s.use("*",async(e,t)=>{const r=e.req.method;if(r==="GET"||r==="HEAD"||r==="OPTIONS")return t();const n=e.req.header("sec-fetch-site");return n&&n!=="same-origin"&&n!=="none"?e.text("cross-site request blocked",403):t()}),k||s.use("*",async(e,t)=>{if(await t(),e.req.method!=="GET"||e.req.path==="/login"||!(e.res.headers.get("content-type")??"").includes("text/html"))return;const n=await e.res.text();e.res=new Response(Je(n,{prefix:"",agentId:null}),e.res)});const M=p.dirname(le(import.meta.url)),ee=[p.join(M,"..","assets"),p.join(M,"assets"),p.join(process.cwd(),"dist","assets")];s.get("/assets/:file",async e=>{const t=e.req.param("file");if(!/^[a-zA-Z0-9._-]+$/.test(t))return e.notFound();for(const r of ee){const n=p.join(r,t);if(!P(n))continue;const i=await ce(n),a=p.extname(t),c={".css":"text/css; charset=utf-8",".js":"application/javascript; charset=utf-8",".map":"application/json; charset=utf-8"};return e.body(i,200,{"Content-Type":c[a]??"application/octet-stream","Cache-Control":a===".js"||a===".css"?"no-cache":"public, max-age=3600"})}return e.notFound()});const te=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
1
+ import{existsSync as P,readdirSync as ie,statSync as A,unlinkSync as ae,writeFileSync as de,mkdirSync as ue,readFileSync as $}from"node:fs";import{readFile as ce}from"node:fs/promises";import{execSync as _,execFileSync as G}from"node:child_process";import p from"node:path";import{fileURLToPath as le}from"node:url";import{Hono as me}from"hono";import{listSessions as L}from"../sessions.js";import{renderLoginPage as fe,renderNotesIndex as pe,renderNotesPage as he,renderSettings as ge,renderThemeSettings as ye,renderHistoryIndex as je,renderQuickCommandsPage as we,renderFilesIndex as ke,renderShell as ve}from"../frontend.js";import{renderFederationPage as qe}from"./pages/federation.js";import{createAgentToken as Se,removeAgentToken as Te,listAgentTokens as z}from"./agent-tokens.js";import{hashPassword as Ae,verifyPassword as be,validatePassword as xe}from"./auth.js";import{atomicWriteFileSync as _e}from"./atomicWrite.js";import{resolveFsPath as b,resolveFsRoots as B,MAX_FILE_BYTES as V,walkRecursive as Ce}from"./fs-access.js";import{captureSessionWindowsWithPath as Oe}from"./tmux-windows.js";import{audit as j}from"./auditLog.js";import{db as S}from"./db.js";import{getSessionAccessMap as Ee}from"./session-access.js";import{listWindowHistory as Ie,clearWindowHistory as Ne}from"./window-history.js";import{saveSecurityConfig as Re}from"./security-config.js";import{readSettings as Q,writeSettings as Pe}from"./settings.js";import{setActiveThemeTemplate as K}from"./theme-store.js";import{isThemeTemplateId as X,THEME_TEMPLATE_IDS as Le}from"./themes/index.js";import{pinView as De,unpinView as Fe,listPinnedViews as He}from"./pinned-views.js";import{getStoredWindows as Me}from"./session-windows.js";import{buildSidebarSessions as Ue}from"./sessions-sidebar.js";import{createQuickCommand as We,deleteQuickCommand as $e,listQuickCommands as Y,updateQuickCommand as Ge}from"./quick-commands.js";import{getSystemStatus as ze,getTopProcesses as Be,killProcess as Ve}from"./system-monitor.js";import{newTmuxSession as Qe,renameSession as Ke,killSession as Xe,TmuxWindowsError as Ye}from"./tmux-windows.js";import{registerAgentTunnel as Ze}from"./agent-tunnel.js";import{injectScopeScript as Je}from"./scope-script.js";const D="tmux-web-token",et=365;function tt(x){return x==="127.0.0.1"||x==="::1"||x==="::ffff:127.0.0.1"}function Rt(x){const{mode:Z,securityConfig:h,tokenStore:y,rateLimiter:O,settings:rt,terminalBufferConfig:E,getAgentId:F,state:u,federation:w,hub:q}=x,k=Z==="agent";function C(e){if(h.security.trustProxy){const t=(e.req.header("x-forwarded-for")||"").split(",")[0].trim();if(t)return t}return e.env?.incoming?.socket?.remoteAddress||"unknown"}function I(e){const t=e.req.header("authorization");if(t?.startsWith("Bearer "))return t.slice(7);const r=e.req.header("cookie");if(!r)return null;const n=r.match(new RegExp(`(?:^|;\\s*)${D}=([^;]+)`));return n?decodeURIComponent(n[1]):null}function nt(e){const t=I(e);return t?y.validateToken(t):null}function H(e,t){const r=e.req.header("x-forwarded-proto")==="https"||e.req.url.startsWith("https:");e.header("Set-Cookie",`${D}=${encodeURIComponent(t)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${et*86400}${r?"; Secure":""}`)}function J(e){e.header("Set-Cookie",`${D}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`)}const o=()=>k?async(e,t)=>t():async(e,t)=>{const r=I(e);return!r||!y.validateToken(r)?(j("http_unauthorized",{ip:C(e)}),e.json({error:"unauthorized"},401)):t()},g=()=>k?async(e,t)=>t():async(e,t)=>{const r=I(e);if(!r||!y.validateToken(r)){const n=encodeURIComponent(e.req.url);return e.redirect(`/login?returnTo=${n}`,302)}return t()};function N(e){const t=Ue(L(),Ee(),He()),r=[];if(q)for(const n of q.agents.list()){const i=q.agents.get(n.agentId);r.push({agentId:n.agentId,agentName:n.name,online:n.online,sessions:i?i.sessions:[]})}return{...t,currentSession:e??null,agents:r}}function M(e){const t=typeof e.sessionName=="string"?e.sessionName.trim():"";if(!t)return{error:"sessionName is required"};if(e.windowIndex===void 0)return{sessionName:t};const r=e.windowIndex;return typeof r!="number"||!Number.isInteger(r)||r<0?{error:"windowIndex must be a non-negative integer"}:{sessionName:t,windowIndex:r}}const s=new me;s.use("*",async(e,t)=>{await t(),e.header("X-Content-Type-Options","nosniff"),e.header("X-Frame-Options","DENY"),e.header("Content-Security-Policy",["default-src 'self'","script-src 'self' 'unsafe-inline'","style-src 'self' 'unsafe-inline' https://fonts.googleapis.com","connect-src 'self' ws: wss: http: https:","img-src 'self' data:","font-src 'self' https://fonts.gstatic.com","worker-src 'self' blob:"].join("; ")),e.header("Referrer-Policy","strict-origin-when-cross-origin")}),s.use("*",async(e,t)=>{const r=e.req.method;if(r==="GET"||r==="HEAD"||r==="OPTIONS")return t();const n=e.req.header("sec-fetch-site");return n&&n!=="same-origin"&&n!=="none"?e.text("cross-site request blocked",403):t()}),k||s.use("*",async(e,t)=>{if(await t(),e.req.method!=="GET"||e.req.path==="/login"||!(e.res.headers.get("content-type")??"").includes("text/html"))return;const n=await e.res.text();e.res=new Response(Je(n,{prefix:"",agentId:null}),e.res)});const U=p.dirname(le(import.meta.url)),ee=[p.join(U,"..","assets"),p.join(U,"assets"),p.join(process.cwd(),"dist","assets")];s.get("/assets/:file",async e=>{const t=e.req.param("file");if(!/^[a-zA-Z0-9._-]+$/.test(t))return e.notFound();for(const r of ee){const n=p.join(r,t);if(!P(n))continue;const i=await ce(n),a=p.extname(t),c={".css":"text/css; charset=utf-8",".js":"application/javascript; charset=utf-8",".map":"application/json; charset=utf-8"};return e.body(i,200,{"Content-Type":c[a]??"application/octet-stream","Cache-Control":a===".js"||a===".css"?"no-cache":"public, max-age=3600"})}return e.notFound()});const te=`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2
2
  <rect x="2.5" y="5.5" width="27" height="21" rx="4" fill="#0d1117" stroke="#7dd3fc" stroke-width="2"/>
3
3
  <path d="M8 13l4.2 3.1L8 19.2" fill="none" stroke="#7dd3fc" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
4
4
  <line x1="15.5" y1="19.6" x2="22.5" y2="19.6" stroke="#7dd3fc" stroke-width="2.2" stroke-linecap="round"/>
@@ -13,7 +13,7 @@ self.addEventListener("activate", (e) => { e.waitUntil(clients.claim()); });
13
13
  self.addEventListener("fetch", (e) => {
14
14
  e.respondWith(fetch(e.request).catch(() => caches.match(e.request)));
15
15
  });
16
- `;s.get("/sw.js",e=>e.body(ne,200,{"Content-Type":"application/javascript; charset=utf-8"})),s.get("/login",e=>{const r=e.req.query("setup")==="1"?!0:!h.passwordHash,n=e.req.query("error");return e.html(fe({setupMode:r,error:n?decodeURIComponent(n):void 0,theme:u.activeTheme}))}),s.get("/",g(),e=>{const t=B();return e.html(ve({theme:u.activeTheme,fsRoots:t,terminalCfg:E,scrollback:E.initialLines+2*E.historyChunk,wsBase:k&&F?.()?`/ws/a/${F()}`:void 0}))}),s.get("/notes",g(),e=>e.html(pe(S.data.notes,u.activeTheme))),s.get("/notes/:session",g(),e=>{const t=decodeURIComponent(e.req.param("session"));return e.html(he(t,u.activeTheme))}),s.get("/history",g(),e=>{const t=L(),r=new Set(t.map(n=>n.name));return e.html(je(Ie(),u.activeTheme,r))}),s.get("/quick-commands",g(),e=>e.html(we(Y(),u.activeTheme))),s.get("/files",g(),e=>{const t=B();return e.html(ke(u.activeTheme,t))}),s.post("/api/history/clear",o(),async e=>(await Ne(),e.json({ok:!0}))),s.get("/api/quick-commands",o(),e=>e.json(Y())),s.post("/api/quick-commands",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=await We(t);return"error"in r?e.json({error:r.error},400):e.json(r,201)}),s.patch("/api/quick-commands/:id",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=await Ge(e.req.param("id"),t);return"error"in r?e.json({error:r.error},r.status):e.json(r)}),s.delete("/api/quick-commands/:id",o(),e=>$e(e.req.param("id"))?e.json({ok:!0}):e.json({error:"not found"},404)),s.post("/api/auth/password",async e=>{if(k)return e.json({error:"hub only"},403);const t=C(e),r=O.check(t);if(!r.allowed)return j("rate_limited",{ip:t,retryAfterMs:r.retryAfterMs,permanentLock:r.permanentLock}),r.permanentLock?e.json({error:"Server locked after too many failed attempts",permanentLock:!0},403):e.json({error:"Too many attempts",retryAfterMs:r.retryAfterMs},429);let n;try{n=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const i=typeof n.password=="string"?n.password:"";if(!h.passwordHash){if(!h.security.allowRemoteSetup&&!tt(t))return j("setup_rejected_remote",{ip:t}),e.json({error:"First-run setup must be performed from localhost"},403);if(u.settingUpPassword)return e.json({error:"Password setup in progress"},409);const l=xe(i);if(l)return e.json({error:l},400);u.settingUpPassword=!0;try{h.passwordHash=await Ae(i),Re(h),j("password_set",{ip:t})}finally{u.settingUpPassword=!1}const{plaintext:d}=y.createAccessToken("setup",h.security.tokenTtlDays);return H(e,d),e.json({ok:!0,token:d,setupMode:!0})}if(!await be(i,h.passwordHash)){const l=O.recordFailure(t);return j("auth_failed",{ip:t,method:"password",failures:l.failures,permanentLock:l.permanentLock}),l.permanentLock?(y.revokeAll(),j("permanent_lock",{ip:t,failures:l.failures}),e.json({error:"Server locked after too many failed attempts",permanentLock:!0},403)):l.allowed?e.json({error:"Incorrect password"},401):e.json({error:"Too many attempts",retryAfterMs:l.retryAfterMs},429)}O.recordSuccess(t);const c=`browser-${t}`,{plaintext:v}=y.createAccessToken(c,h.security.tokenTtlDays);return H(e,v),j("auth_success",{ip:t,method:"password",tokenName:c}),e.json({ok:!0,token:v})}),s.post("/api/auth/token",o(),async e=>{if(k)return e.json({error:"hub only"},403);const t=C(e);let r;try{r=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const n=typeof r.name=="string"?r.name:`api-${t}`,i=typeof r.ttlDays=="number"?r.ttlDays:h.security.tokenTtlDays,{stored:a,plaintext:c}=y.createAccessToken(n,i);return j("token_created",{ip:t,name:a.name,tokenId:a.id}),e.json({id:a.id,name:a.name,token:c,expiresAt:a.expiresAt})}),s.get("/api/auth/tokens",o(),e=>k?e.json({error:"hub only"},403):e.json(y.list().map(t=>({id:t.id,name:t.name,createdAt:t.createdAt,lastUsedAt:t.lastUsedAt,expiresAt:t.expiresAt})))),s.delete("/api/auth/tokens/:id",o(),e=>k?e.json({error:"hub only"},403):y.revoke(e.req.param("id"))?(j("token_revoked",{ip:C(e),tokenId:e.req.param("id")}),e.json({ok:!0})):e.json({error:"not found"},404)),s.post("/api/auth/logout",o(),e=>(J(e),e.json({ok:!0}))),s.get("/settings",g(),async e=>{const t=await Q();return e.html(ge({settings:t,theme:u.activeTheme,saved:e.req.query("saved")==="1",error:e.req.query("error")?decodeURIComponent(e.req.query("error")):void 0}))}),s.post("/settings",o(),async e=>{let t;try{t=await e.req.parseBody()}catch{return e.redirect("/settings?error="+encodeURIComponent("invalid form body"),303)}const r=await Q(),n=t.defaultView==="recent"?"recent":"default";return await Pe({...r,defaultView:n}),e.redirect("/settings?saved=1",303)}),s.get("/settings/theme",g(),e=>e.html(ye({theme:u.activeTheme,saved:e.req.query("saved")==="1"}))),s.post("/settings/theme",o(),async e=>{let t;try{t=await e.req.parseBody()}catch{return e.redirect("/settings/theme?error=1",303)}const r=t.template;return typeof r!="string"||!X(r)?e.redirect("/settings/theme",303):(u.activeTheme=await K(r),e.redirect("/settings/theme?saved=1",303))});const se={vscode:"VS Code",ghostty:"Ghostty","warm-clay":"Warm Clay","dark-cove":"Dark Cove"};s.get("/api/theme",o(),e=>e.json({active:u.activeTheme.template,templates:Le.map(t=>({id:t,name:se[t]}))})),s.post("/api/theme",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=t.template;return typeof r!="string"||!X(r)?e.json({error:"invalid theme template"},400):(u.activeTheme=await K(r),e.json({ok:!0,active:u.activeTheme.template}))}),s.get("/api/system/status",o(),e=>e.json(ze())),s.get("/api/system/processes",o(),e=>e.json(Be())),w&&(s.get("/settings/federation",g(),e=>e.redirect("/settings/machines",301)),s.get("/settings/machines",g(),async e=>e.html(qe({config:w.getConfig(),status:w.status(),tokens:z().map(t=>({id:t.id,name:t.name,createdAt:t.createdAt})),hostname:w.hostname},u.activeTheme))),s.get("/api/machines",o(),e=>e.json({config:w.getConfig(),status:w.status()})),s.post("/api/machines",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=typeof t.hub=="string"?t.hub.trim():"",n=typeof t.token=="string"?t.token.trim():"",i=typeof t.name=="string"?t.name.trim().slice(0,64):"",a=t.enabled===!0;if(a){if(!r)return e.json({error:"hub URL is required"},400);if(!/^wss?:\/\//i.test(r))return e.json({error:"hub URL must start with ws:// or wss://"},400);if(!n)return e.json({error:"registration token is required"},400)}const c={hub:r||void 0,token:n||void 0,name:i||void 0,enabled:a};return await w.setConfig(c),e.json({ok:!0,status:w.status()})}),s.get("/api/agent-tokens",o(),e=>e.json(z().map(t=>({id:t.id,name:t.name,createdAt:t.createdAt})))),s.post("/api/agent-tokens",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=typeof t.name=="string"?t.name.trim().slice(0,64):"";if(!r)return e.json({error:"name is required"},400);const{id:n,token:i}=Se(r);return e.json({ok:!0,id:n,name:r,token:i})}),s.delete("/api/agent-tokens/:id",o(),e=>{const t=e.req.param("id");return Te(t)?e.json({ok:!0}):e.json({error:"no such token"},404)})),s.post("/api/system/kill",o(),async e=>{const{pid:t}=await e.req.json();if(typeof t!="number"||!Number.isInteger(t)||t<=0)return e.json({error:"invalid pid"},400);const r=Ve(t);return r.ok?e.json({ok:!0}):e.json({error:r.error},500)}),s.get("/api/agents",o(),e=>q?e.json(q.agents.list()):e.json([])),s.post("/api/sessions/new",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=typeof t.name=="string"?t.name.trim():"";if(!r)return e.json({error:"name is required"},400);if(!/^[a-zA-Z0-9_\-. ]+$/.test(r))return e.json({error:"name contains invalid characters"},400);const n=typeof t.dir=="string"&&t.dir.trim()?t.dir.trim():void 0;if(L().some(a=>a.name===r))return e.json({error:"session already exists"},409);try{return Qe(r,n),e.json({ok:!0})}catch(a){const c=a instanceof Ye?a.message:"failed to create session";return e.json({error:c},500)}}),s.post("/api/sessions/rename",o(),async e=>{try{const{oldName:t,newName:r}=await e.req.json();return!t||!r?e.json({error:"oldName and newName required"},400):/^[a-zA-Z0-9_\-. ]+$/.test(r)?(Ke(t,r),e.json({ok:!0})):e.json({error:"invalid characters in name"},400)}catch{return e.json({error:"rename failed"},500)}}),s.post("/api/sessions/kill",o(),async e=>{try{const{name:t}=await e.req.json();return t?(Xe(t),e.json({ok:!0})):e.json({error:"name required"},400)}catch{return e.json({error:"kill failed"},500)}}),s.get("/api/fs/session-path",o(),e=>{const t=e.req.query("session");if(!t)return e.json({error:"session is required"},400);try{const r=Oe(t),i=r.find(a=>a.active)?.path??r[0]?.path??process.env.HOME??"/";return e.json({path:i})}catch{return e.json({path:process.env.HOME??"/"})}}),s.get("/api/fs/list",o(),e=>{const t=process.env.HOME??"/";let r=e.req.query("path")??t;r.startsWith("~")&&(r=t+r.slice(1)),r.startsWith("/")||(r=p.join(t,r));const n=e.req.query("recursive")==="true";let i=r,a="",c=r.endsWith("/");if(!c)try{c=A(r).isDirectory()}catch{}c||(i=p.dirname(r),a=p.basename(r).toLowerCase());try{const v=ie(i),l=[],d=[];for(const m of v)if(!m.startsWith(".")&&!(a&&!m.toLowerCase().startsWith(a))){try{const f=p.join(i,m);A(f).isDirectory()?(l.push(f),n&&Ce(f,l,d,0)):d.push(f)}catch{}if(l.length+d.length>=5e3)break}return e.json({dirs:l,files:d})}catch{return e.json({dirs:[],files:[]})}});function oe(e){const t={repoRoot:null,branch:null,files:[],linesAdded:0,linesRemoved:0};try{const r=_("git rev-parse --show-toplevel 2>/dev/null",{cwd:e,encoding:"utf-8",timeout:3e3}).trim();if(!r)return t;let n;try{n=_("git branch --show-current 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3}).trim()}catch{n=_("git rev-parse --short HEAD 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3}).trim()}n||(n="HEAD");let i="";try{i=_("git status --porcelain 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3})}catch{}const a=[];for(const d of i.split(`
16
+ `;s.get("/sw.js",e=>e.body(ne,200,{"Content-Type":"application/javascript; charset=utf-8"})),s.get("/login",e=>{const r=e.req.query("setup")==="1"?!0:!h.passwordHash,n=e.req.query("error");return e.html(fe({setupMode:r,error:n?decodeURIComponent(n):void 0,theme:u.activeTheme}))}),s.get("/",g(),e=>{const t=B();return e.html(ve({theme:u.activeTheme,fsRoots:t,terminalCfg:E,scrollback:E.initialLines+2*E.historyChunk,wsBase:k&&F?.()?`/ws/a/${F()}`:void 0}))}),s.get("/notes",g(),e=>e.html(pe(S.data.notes,u.activeTheme))),s.get("/notes/:session",g(),e=>{const t=decodeURIComponent(e.req.param("session"));return e.html(he(t,u.activeTheme))}),s.get("/history",g(),e=>{const t=L(),r=new Set(t.map(n=>n.name));return e.html(je(Ie(),u.activeTheme,r))}),s.get("/quick-commands",g(),e=>e.html(we(Y(),u.activeTheme))),s.get("/files",g(),e=>{const t=B();return e.html(ke(u.activeTheme,t))}),s.post("/api/history/clear",o(),async e=>(await Ne(),e.json({ok:!0}))),s.get("/api/quick-commands",o(),e=>e.json(Y())),s.post("/api/quick-commands",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=await We(t);return"error"in r?e.json({error:r.error},400):e.json(r,201)}),s.patch("/api/quick-commands/:id",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=await Ge(e.req.param("id"),t);return"error"in r?e.json({error:r.error},r.status):e.json(r)}),s.delete("/api/quick-commands/:id",o(),e=>$e(e.req.param("id"))?e.json({ok:!0}):e.json({error:"not found"},404)),s.post("/api/auth/password",async e=>{if(k)return e.json({error:"hub only"},403);const t=C(e),r=O.check(t);if(!r.allowed)return j("rate_limited",{ip:t,retryAfterMs:r.retryAfterMs,permanentLock:r.permanentLock}),r.permanentLock?e.json({error:"Server locked after too many failed attempts",permanentLock:!0},403):e.json({error:"Too many attempts",retryAfterMs:r.retryAfterMs},429);let n;try{n=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const i=typeof n.password=="string"?n.password:"";if(!h.passwordHash){if(!h.security.allowRemoteSetup&&!tt(t))return j("setup_rejected_remote",{ip:t}),e.json({error:"First-run setup must be performed from localhost"},403);if(u.settingUpPassword)return e.json({error:"Password setup in progress"},409);const l=xe(i);if(l)return e.json({error:l},400);u.settingUpPassword=!0;try{h.passwordHash=await Ae(i),Re(h),j("password_set",{ip:t})}finally{u.settingUpPassword=!1}const{plaintext:d}=y.createAccessToken("setup",h.security.tokenTtlDays);return H(e,d),e.json({ok:!0,token:d,setupMode:!0})}if(!await be(i,h.passwordHash)){const l=O.recordFailure(t);return j("auth_failed",{ip:t,method:"password",failures:l.failures,permanentLock:l.permanentLock}),l.permanentLock?(y.revokeAll(),j("permanent_lock",{ip:t,failures:l.failures}),e.json({error:"Server locked after too many failed attempts",permanentLock:!0},403)):l.allowed?e.json({error:"Incorrect password"},401):e.json({error:"Too many attempts",retryAfterMs:l.retryAfterMs},429)}O.recordSuccess(t);const c=`browser-${t}`,{plaintext:v}=y.createAccessToken(c,h.security.tokenTtlDays);return H(e,v),j("auth_success",{ip:t,method:"password",tokenName:c}),e.json({ok:!0,token:v})}),s.post("/api/auth/token",o(),async e=>{if(k)return e.json({error:"hub only"},403);const t=C(e);let r;try{r=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const n=typeof r.name=="string"?r.name:`api-${t}`,i=typeof r.ttlDays=="number"?r.ttlDays:h.security.tokenTtlDays,{stored:a,plaintext:c}=y.createAccessToken(n,i);return j("token_created",{ip:t,name:a.name,tokenId:a.id}),e.json({id:a.id,name:a.name,token:c,expiresAt:a.expiresAt})}),s.get("/api/auth/tokens",o(),e=>k?e.json({error:"hub only"},403):e.json(y.list().map(t=>({id:t.id,name:t.name,createdAt:t.createdAt,lastUsedAt:t.lastUsedAt,expiresAt:t.expiresAt})))),s.delete("/api/auth/tokens/:id",o(),e=>k?e.json({error:"hub only"},403):y.revoke(e.req.param("id"))?(j("token_revoked",{ip:C(e),tokenId:e.req.param("id")}),e.json({ok:!0})):e.json({error:"not found"},404)),s.post("/api/auth/logout",o(),e=>(J(e),e.json({ok:!0}))),s.get("/settings",g(),async e=>{const t=await Q();return e.html(ge({settings:t,theme:u.activeTheme,saved:e.req.query("saved")==="1",error:e.req.query("error")?decodeURIComponent(e.req.query("error")):void 0}))}),s.post("/settings",o(),async e=>{let t;try{t=await e.req.parseBody()}catch{return e.redirect("/settings?error="+encodeURIComponent("invalid form body"),303)}const r=await Q(),n=t.defaultView==="recent"?"recent":"default";return await Pe({...r,defaultView:n}),e.redirect("/settings?saved=1",303)}),s.get("/settings/theme",g(),e=>e.html(ye({theme:u.activeTheme,saved:e.req.query("saved")==="1"}))),s.post("/settings/theme",o(),async e=>{let t;try{t=await e.req.parseBody()}catch{return e.redirect("/settings/theme?error=1",303)}const r=t.template;return typeof r!="string"||!X(r)?e.redirect("/settings/theme",303):(u.activeTheme=await K(r),e.redirect("/settings/theme?saved=1",303))});const se={vscode:"VS Code",ghostty:"Ghostty","warm-clay":"Warm Clay","dark-cove":"Dark Cove"};s.get("/api/theme",o(),e=>e.json({active:u.activeTheme.template,templates:Le.map(t=>({id:t,name:se[t]}))})),s.post("/api/theme",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=t.template;return typeof r!="string"||!X(r)?e.json({error:"invalid theme template"},400):(u.activeTheme=await K(r),e.json({ok:!0,active:u.activeTheme.template}))}),s.get("/api/system/status",o(),e=>e.json(ze())),s.get("/api/system/processes",o(),e=>e.json(Be())),w&&(s.get("/settings/federation",g(),e=>e.redirect("/settings/machines",301)),s.get("/settings/machines",g(),async e=>e.html(qe({config:w.getConfig(),status:w.status(),tokens:z().map(t=>({id:t.id,name:t.name,createdAt:t.createdAt})),hostname:w.hostname},u.activeTheme))),s.get("/api/machines",o(),e=>e.json({config:w.getConfig(),status:w.status()})),s.post("/api/machines",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=typeof t.hub=="string"?t.hub.trim():"",n=typeof t.token=="string"?t.token.trim():"",i=typeof t.name=="string"?t.name.trim().slice(0,64):"",a=t.enabled===!0;if(a){if(!r)return e.json({error:"hub URL is required"},400);if(!/^wss?:\/\//i.test(r))return e.json({error:"hub URL must start with ws:// or wss://"},400);if(!n)return e.json({error:"registration token is required"},400)}const c={hub:r||void 0,token:n||void 0,name:i||void 0,enabled:a};return await w.setConfig(c),e.json({ok:!0,status:w.status()})}),s.get("/api/agent-tokens",o(),e=>e.json(z().map(t=>({id:t.id,name:t.name,createdAt:t.createdAt})))),s.post("/api/agent-tokens",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=typeof t.name=="string"?t.name.trim().slice(0,64):"";if(!r)return e.json({error:"name is required"},400);const{id:n,token:i}=Se(r);return e.json({ok:!0,id:n,name:r,token:i})}),s.delete("/api/agent-tokens/:id",o(),e=>{const t=e.req.param("id");return Te(t)?e.json({ok:!0}):e.json({error:"no such token"},404)})),s.post("/api/system/kill",o(),async e=>{const{pid:t}=await e.req.json();if(typeof t!="number"||!Number.isInteger(t)||t<=0)return e.json({error:"invalid pid"},400);const r=Ve(t);return r.ok?e.json({ok:!0}):e.json({error:r.error},500)}),s.get("/api/agents",o(),e=>q?e.json(q.agents.list()):e.json([])),s.post("/api/sessions/new",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=typeof t.name=="string"?t.name.trim():"";if(!r)return e.json({error:"name is required"},400);if(!/^[a-zA-Z0-9_\-. ]+$/.test(r))return e.json({error:"name contains invalid characters"},400);const n=typeof t.dir=="string"&&t.dir.trim()?t.dir.trim():void 0;if(L().some(a=>a.name===r))return e.json({error:"session already exists"},409);try{return Qe(r,n),e.json({ok:!0})}catch(a){const c=a instanceof Ye?a.message:"failed to create session";return e.json({error:c},500)}}),s.post("/api/sessions/rename",o(),async e=>{try{const{oldName:t,newName:r}=await e.req.json();return!t||!r?e.json({error:"oldName and newName required"},400):/^[a-zA-Z0-9_\-. ]+$/.test(r)?(Ke(t,r),e.json({ok:!0})):e.json({error:"invalid characters in name"},400)}catch{return e.json({error:"rename failed"},500)}}),s.post("/api/sessions/kill",o(),async e=>{try{const{name:t}=await e.req.json();return t?(Xe(t),e.json({ok:!0})):e.json({error:"name required"},400)}catch{return e.json({error:"kill failed"},500)}}),s.get("/api/fs/session-path",o(),e=>{const t=e.req.query("session");if(!t)return e.json({path:process.env.HOME??"/"});try{const r=Oe(t),i=r.find(a=>a.active)?.path??r[0]?.path??process.env.HOME??"/";return e.json({path:i})}catch{return e.json({path:process.env.HOME??"/"})}}),s.get("/api/fs/list",o(),e=>{const t=process.env.HOME??"/";let r=e.req.query("path")??t;r.startsWith("~")&&(r=t+r.slice(1)),r.startsWith("/")||(r=p.join(t,r));const n=e.req.query("recursive")==="true";let i=r,a="",c=r.endsWith("/");if(!c)try{c=A(r).isDirectory()}catch{}c||(i=p.dirname(r),a=p.basename(r).toLowerCase());try{const v=ie(i),l=[],d=[];for(const m of v)if(!m.startsWith(".")&&!(a&&!m.toLowerCase().startsWith(a))){try{const f=p.join(i,m);A(f).isDirectory()?(l.push(f),n&&Ce(f,l,d,0)):d.push(f)}catch{}if(l.length+d.length>=5e3)break}return e.json({dirs:l,files:d})}catch{return e.json({dirs:[],files:[]})}});function oe(e){const t={repoRoot:null,branch:null,files:[],linesAdded:0,linesRemoved:0};try{const r=_("git rev-parse --show-toplevel 2>/dev/null",{cwd:e,encoding:"utf-8",timeout:3e3}).trim();if(!r)return t;let n;try{n=_("git branch --show-current 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3}).trim()}catch{n=_("git rev-parse --short HEAD 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3}).trim()}n||(n="HEAD");let i="";try{i=_("git status --porcelain 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3})}catch{}const a=[];for(const d of i.split(`
17
17
  `)){if(!d.trim())continue;const m=d[0],f=d[1],T=d.substring(3).trim(),R=f!==" "?f:m;a.push({path:T,status:R,staged:m,unstaged:f,additions:0,deletions:0})}const c=new Map;try{const d=_("git diff --numstat HEAD 2>/dev/null",{cwd:r,encoding:"utf-8",timeout:3e3});for(const m of d.split(`
18
18
  `)){if(!m.trim())continue;const f=m.split(" ");if(f.length>=3){const T=parseInt(f[0],10)||0,R=parseInt(f[1],10)||0;c.set(f[2],{added:T,deleted:R})}}}catch{}for(const d of a){const m=c.get(d.path);m&&(d.additions=m.added,d.deletions=m.deleted)}let v=0,l=0;for(const d of a)v+=d.additions,l+=d.deletions;for(const d of a)if(d.status==="?"&&d.additions===0)try{const m=p.join(r,d.path),T=$(m,"utf-8").split(`
19
- `).length;d.additions=T,v+=T}catch{}return{repoRoot:r,branch:n,files:a,linesAdded:v,linesRemoved:l}}catch{return t}}return s.get("/api/git/status",o(),e=>{const t=e.req.query("path");if(!t)return e.json({error:"path is required"},400);try{const r=b(t),n=oe(r);return e.json(n)}catch(r){return r.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):r.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({repoRoot:null,branch:null,files:[],linesAdded:0,linesRemoved:0})}}),s.get("/api/git/diff",o(),e=>{const t=e.req.query("path"),r=e.req.query("file");if(!t||!r)return e.json({error:"path and file are required"},400);try{const n=b(t);if(!P(n)||!A(n).isDirectory())return e.json({error:"invalid repository path: "+n},400);try{const i=G("/usr/bin/git",["diff","HEAD","--",r],{cwd:n,encoding:"utf-8",timeout:5e3}),a=G("/usr/bin/git",["diff","--cached","--",r],{cwd:n,encoding:"utf-8",timeout:5e3});return e.json({diff:i,stagedDiff:a})}catch(i){throw console.error("[git/diff] execFileSync failed:",i?.code,i.message),i}}catch(n){return n.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):n.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):(console.error("[git/diff] exception:",n),e.json({error:String(n)},500))}}),s.get("/api/file",o(),e=>{try{const t=e.req.query("path");if(!t)return e.json({error:"path is required"},400);const r=b(t);if(!A(r).isFile())return e.json({error:"not a file"},400);const n=A(r).size;if(n>V)return e.json({error:"file too large",size:n,maxBytes:V},413);const i=$(r,"utf-8");return e.json({path:r,content:i,size:n})}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):t.code==="ENOENT"?e.json({error:"not found"},404):e.json({error:"internal error"},500)}}),s.put("/api/file",o(),async e=>{try{const t=await e.req.json();if(!t.path||typeof t.content!="string")return e.json({error:"path and content are required"},400);const r=b(t.path);return _e(r,t.content),e.json({ok:!0})}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({error:"write failed"},500)}}),s.post("/api/file/delete",o(),async e=>{try{const t=await e.req.json();if(!t.path)return e.json({error:"path is required"},400);const r=b(t.path);return A(r).isFile()?(ae(r),e.json({ok:!0})):e.json({error:"not a file"},400)}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({error:"delete failed"},500)}}),s.post("/api/file/touch",o(),async e=>{try{const t=await e.req.json();if(!t.path)return e.json({error:"path is required"},400);const r=b(t.path);return P(r)?e.json({error:"file already exists"},409):(ue(p.dirname(r),{recursive:!0}),de(r,"","utf-8"),e.json({ok:!0,path:r}))}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({error:"touch failed"},500)}}),s.get("/api/sidebar/sessions",o(),e=>{const t=e.req.query("currentSession");return e.json(N(typeof t=="string"&&t?t:void 0))}),s.get("/api/sidebar/session-windows/:session",o(),e=>{const t=decodeURIComponent(e.req.param("session"));return e.json(Ue(t))}),s.post("/api/pinned-views",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=U(t);return"error"in r?e.json({error:r.error},400):(await De(r.sessionName,r.windowIndex),e.json(N()))}),s.delete("/api/pinned-views",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=U(t);return"error"in r?e.json({error:r.error},400):(await Fe(r.sessionName,r.windowIndex),e.json(N()))}),s.get("/api/notes",o(),e=>{const t=[...S.data.notes].sort((r,n)=>(n.updatedAt??0)-(r.updatedAt??0));return e.json(t)}),s.get("/api/notes/:scope",o(),e=>{const t=decodeURIComponent(e.req.param("scope")),r=S.data.notes.find(n=>n.scope===t);return r?e.json(r):e.json(null,404)}),s.put("/api/notes/:scope",o(),async e=>{const t=decodeURIComponent(e.req.param("scope"));let r;try{r=await e.req.json()}catch{return e.json({error:"invalid json"},400)}if(typeof r.content!="string")return e.json({error:"content must be string"},400);const n={scope:t,content:r.content,updatedAt:Date.now()},i=S.data.notes.findIndex(a=>a.scope===t);return i>=0?S.data.notes[i]=n:S.data.notes.push(n),await S.write(),e.json({ok:!0})}),q&&(s.use("/a/:agentId/*",o()),Ze(s,{getChannel:q.getChannel})),s}export{Rt as buildApp};
19
+ `).length;d.additions=T,v+=T}catch{}return{repoRoot:r,branch:n,files:a,linesAdded:v,linesRemoved:l}}catch{return t}}return s.get("/api/git/status",o(),e=>{const t=e.req.query("path");if(!t)return e.json({error:"path is required"},400);try{const r=b(t),n=oe(r);return e.json(n)}catch(r){return r.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):r.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({repoRoot:null,branch:null,files:[],linesAdded:0,linesRemoved:0})}}),s.get("/api/git/diff",o(),e=>{const t=e.req.query("path"),r=e.req.query("file");if(!t||!r)return e.json({error:"path and file are required"},400);try{const n=b(t);if(!P(n)||!A(n).isDirectory())return e.json({error:"invalid repository path: "+n},400);try{const i=G("/usr/bin/git",["diff","HEAD","--",r],{cwd:n,encoding:"utf-8",timeout:5e3}),a=G("/usr/bin/git",["diff","--cached","--",r],{cwd:n,encoding:"utf-8",timeout:5e3});return e.json({diff:i,stagedDiff:a})}catch(i){throw console.error("[git/diff] execFileSync failed:",i?.code,i.message),i}}catch(n){return n.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):n.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):(console.error("[git/diff] exception:",n),e.json({error:String(n)},500))}}),s.get("/api/file",o(),e=>{try{const t=e.req.query("path");if(!t)return e.json({error:"path is required"},400);const r=b(t);if(!A(r).isFile())return e.json({error:"not a file"},400);const n=A(r).size;if(n>V)return e.json({error:"file too large",size:n,maxBytes:V},413);const i=$(r,"utf-8");return e.json({path:r,content:i,size:n})}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):t.code==="ENOENT"?e.json({error:"not found"},404):e.json({error:"internal error"},500)}}),s.put("/api/file",o(),async e=>{try{const t=await e.req.json();if(!t.path||typeof t.content!="string")return e.json({error:"path and content are required"},400);const r=b(t.path);return _e(r,t.content),e.json({ok:!0})}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({error:"write failed"},500)}}),s.post("/api/file/delete",o(),async e=>{try{const t=await e.req.json();if(!t.path)return e.json({error:"path is required"},400);const r=b(t.path);return A(r).isFile()?(ae(r),e.json({ok:!0})):e.json({error:"not a file"},400)}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({error:"delete failed"},500)}}),s.post("/api/file/touch",o(),async e=>{try{const t=await e.req.json();if(!t.path)return e.json({error:"path is required"},400);const r=b(t.path);return P(r)?e.json({error:"file already exists"},409):(ue(p.dirname(r),{recursive:!0}),de(r,"","utf-8"),e.json({ok:!0,path:r}))}catch(t){return t.message==="FS_ROOTS_NOT_CONFIGURED"?e.json({error:"file access not configured"},403):t.message==="PATH_NOT_ALLOWED"?e.json({error:"path not allowed"},403):e.json({error:"touch failed"},500)}}),s.get("/api/sidebar/sessions",o(),e=>{const t=e.req.query("currentSession");return e.json(N(typeof t=="string"&&t?t:void 0))}),s.get("/api/sidebar/session-windows/:session",o(),e=>{const t=decodeURIComponent(e.req.param("session"));return e.json(Me(t))}),s.post("/api/pinned-views",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=M(t);return"error"in r?e.json({error:r.error},400):(await De(r.sessionName,r.windowIndex),e.json(N()))}),s.delete("/api/pinned-views",o(),async e=>{let t;try{t=await e.req.json()}catch{return e.json({error:"invalid json"},400)}const r=M(t);return"error"in r?e.json({error:r.error},400):(await Fe(r.sessionName,r.windowIndex),e.json(N()))}),s.get("/api/notes",o(),e=>{const t=[...S.data.notes].sort((r,n)=>(n.updatedAt??0)-(r.updatedAt??0));return e.json(t)}),s.get("/api/notes/:scope",o(),e=>{const t=decodeURIComponent(e.req.param("scope")),r=S.data.notes.find(n=>n.scope===t);return r?e.json(r):e.json(null,404)}),s.put("/api/notes/:scope",o(),async e=>{const t=decodeURIComponent(e.req.param("scope"));let r;try{r=await e.req.json()}catch{return e.json({error:"invalid json"},400)}if(typeof r.content!="string")return e.json({error:"content must be string"},400);const n={scope:t,content:r.content,updatedAt:Date.now()},i=S.data.notes.findIndex(a=>a.scope===t);return i>=0?S.data.notes[i]=n:S.data.notes.push(n),await S.write(),e.json({ok:!0})}),q&&(s.use("/a/:agentId/*",o()),Ze(s,{getChannel:q.getChannel})),s}export{Rt as buildApp};
@@ -1,4 +1,4 @@
1
- import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./icons.js";const d=[{id:"vscode",name:"VS Code",dot:"#007acc"},{id:"ghostty",name:"Ghostty",dot:"#ff5f00"},{id:"warm-clay",name:"Warm Clay",dot:"#b86b52"},{id:"dark-cove",name:"Dark Cove",dot:"#7aa2f7"}];function n(e="var(--panel-accent)"){return`box-shadow: 0 0 0 2px ${e}; outline: none;`}function v(e=""){return`
1
+ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./icons.js";const d=[{id:"vscode",name:"VS Code",dot:"#007acc"},{id:"ghostty",name:"Ghostty",dot:"#ff5f00"},{id:"warm-clay",name:"Warm Clay",dot:"#b86b52"},{id:"dark-cove",name:"Dark Cove",dot:"#7aa2f7"}];function n(e="var(--panel-accent)"){return`box-shadow: 0 0 0 2px ${e}; outline: none;`}function w(e=""){return`
2
2
  @media (prefers-reduced-motion: reduce) {
3
3
  *, *::before, *::after {
4
4
  animation-duration: 0.01ms !important;
@@ -7,7 +7,7 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
7
7
  scroll-behavior: auto !important;
8
8
  }
9
9
  ${e}
10
- }`}function w(e="vscode"){const o=d.find(t=>t.id===e)??d[0],a=d.map(t=>{const s=t.id===e;return`<button type="button" class="theme-option${s?" active":""}" data-theme="${t.id}" role="menuitem">
10
+ }`}function v(e="vscode"){const o=d.find(t=>t.id===e)??d[0],a=d.map(t=>{const s=t.id===e;return`<button type="button" class="theme-option${s?" active":""}" data-theme="${t.id}" role="menuitem">
11
11
  <span class="theme-dot" style="background:${t.dot}"></span>
12
12
  ${c(t.name)}
13
13
  ${s?i("check",'class="theme-check" aria-hidden="true"'):""}
@@ -47,7 +47,7 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
47
47
  document.addEventListener('click', (e) => {
48
48
  if (switcher.classList.contains('open') && !switcher.contains(e.target)) close();
49
49
  });
50
- })();`}function S(e=""){return`
50
+ })();`}function L(e=""){return`
51
51
  *, *::before, *::after { box-sizing: border-box; }
52
52
  html, body { background: var(--page-bg); color: var(--page-fg); min-height: 100%; font-family: var(--font-sans); margin: 0; padding: 0; }
53
53
 
@@ -216,7 +216,7 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
216
216
  .header-btn, .theme-switcher-btn { padding: 8px; }
217
217
  }
218
218
 
219
- ${v()}
219
+ ${w()}
220
220
  ${e}`}function R(e){const{title:o="TMUX Sessions",themeTemplate:a="vscode"}=e;return`<script>
221
221
  (function() {
222
222
  const token = localStorage.getItem('tmux-web-token');
@@ -240,15 +240,15 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
240
240
  ${i("notes")}
241
241
  <span>Notes</span>
242
242
  </button>
243
- ${w(a)}
243
+ ${v(a)}
244
244
  <a class="header-btn" href="/settings" title="Settings">
245
245
  ${i("settings")}
246
246
  <span>Settings</span>
247
247
  </a>
248
248
  </div>
249
249
  </header>
250
- <script>${y()}</script>`}function A(e){const{activePage:o,refreshHref:a}=e;function t(l,b,f,x,g=""){return`<a href="${b}" class="${l!==null&&l===o?"sidebar-btn current":"sidebar-btn"}"${g}>
251
- <svg viewBox="0 0 24 24" fill="currentColor">${f}</svg>
250
+ <script>${y()}</script>`}function A(e){const{activePage:o,refreshHref:a}=e;function t(l,f,b,x,g=""){return`<a href="${f}" class="${l!==null&&l===o?"sidebar-btn current":"sidebar-btn"}"${g}>
251
+ <svg viewBox="0 0 24 24" fill="currentColor">${b}</svg>
252
252
  ${x}
253
253
  </a>`}const s=r("sessions"),p=r("notes"),h=r("history"),m=r("quick-commands"),u=r("file"),k=r("refresh");return`<aside class="action-sidebar">
254
254
  <p class="sidebar-label">Actions</p>
@@ -482,6 +482,7 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
482
482
  let treeRoot = '/';
483
483
  let treeExpanded = new Set(); // paths currently expanded
484
484
  let treeSelected = ''; // path currently selected (highlighted)
485
+ let treeUserSelected = ''; // path the user actively clicked (closes on second click)
485
486
  let treeRows = []; // visible rows [{path, el}] for keyboard nav
486
487
  let treeActiveIdx = -1;
487
488
  let treeOpen = false;
@@ -524,11 +525,24 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
524
525
  e.stopPropagation();
525
526
  void treeToggle(path, depth);
526
527
  });
527
- // Clicking the row selects the directory.
528
- row.addEventListener('click', () => {
528
+ // Clicking the row selects the directory and expands it so the user can
529
+ // keep browsing without the tree closing on them. Clicking the already
530
+ // selected row confirms and closes.
531
+ row.addEventListener('click', async () => {
529
532
  dirInput.value = path;
530
- closeTree();
531
- dirInput.focus();
533
+ if (treeUserSelected === path) {
534
+ closeTree();
535
+ dirInput.focus();
536
+ return;
537
+ }
538
+ treeUserSelected = path;
539
+ treeSelected = path;
540
+ if (!treeExpanded.has(path)) {
541
+ treeExpanded.add(path);
542
+ await treeRender();
543
+ } else {
544
+ treeRefreshActive();
545
+ }
532
546
  });
533
547
  return row;
534
548
  }
@@ -608,40 +622,48 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
608
622
  treeOpen = false;
609
623
  treeExpanded = new Set();
610
624
  treeSelected = '';
625
+ treeUserSelected = '';
611
626
  treeRows = [];
612
627
  treeActiveIdx = -1;
613
628
  }
614
629
 
630
+ // Resolve the path the tree should open at: the current session's working
631
+ // directory if one is open, otherwise the home directory (both served by
632
+ // /api/fs/session-path \u2014 it returns HOME when no session is given).
633
+ async function treeOpenTarget() {
634
+ const session = (typeof window.__tmuxWebCurrentSession === 'string' && window.__tmuxWebCurrentSession)
635
+ ? window.__tmuxWebCurrentSession : '';
636
+ try {
637
+ const url = session
638
+ ? selectedBase() + '/api/fs/session-path?session=' + encodeURIComponent(session)
639
+ : selectedBase() + '/api/fs/session-path';
640
+ const res = await fetch(url);
641
+ const data = await res.json();
642
+ if (data && typeof data.path === 'string' && data.path) {
643
+ if (session) dirInput.value = data.path;
644
+ return data.path.startsWith('/') ? data.path : '/' + data.path;
645
+ }
646
+ } catch {}
647
+ return '/'
648
+ }
649
+
615
650
  async function openTree() {
616
651
  closeDropdown();
617
- const v = dirInput.value.trim();
618
- const target = (!v || v === '~') ? '/' : (v.startsWith('/') ? v : '/' + v);
619
652
  treeRoot = '/';
620
653
  treePanel.classList.add('open');
621
654
  treeOpen = true;
655
+ const target = await treeOpenTarget();
622
656
  await treeExpandTo(target);
623
657
  }
624
658
 
625
659
  // On modal open, expand the tree directly to the currently selected
626
660
  // session's working directory (window.__tmuxWebCurrentSession is set by
627
- // the shell client; falls back to / otherwise).
661
+ // the shell client; falls back to the home directory otherwise).
628
662
  async function openTreeForSession() {
629
- const session = (typeof window.__tmuxWebCurrentSession === 'string' && window.__tmuxWebCurrentSession)
630
- ? window.__tmuxWebCurrentSession : '';
631
- let target = '/';
632
- if (session) {
633
- try {
634
- const res = await fetch(selectedBase() + '/api/fs/session-path?session=' + encodeURIComponent(session));
635
- const data = await res.json();
636
- if (data && typeof data.path === 'string' && data.path) {
637
- dirInput.value = data.path;
638
- target = data.path.startsWith('/') ? data.path : '/' + data.path;
639
- }
640
- } catch {}
641
- }
642
663
  treeRoot = '/';
643
664
  treePanel.classList.add('open');
644
665
  treeOpen = true;
666
+ const target = await treeOpenTarget();
645
667
  await treeExpandTo(target);
646
668
  }
647
669
 
@@ -718,4 +740,4 @@ import{escapeHtml as c}from"./html.js";import{icon as i,iconPath as r}from"./ico
718
740
  if (e.key === 'Enter') submit();
719
741
  });
720
742
  window.__openNewSessionModal = openModal;
721
- })();`}export{T as newSessionModalCSS,B as newSessionModalHTML,z as newSessionModalScript,v as reducedMotion,R as sharedHeader,S as sharedLayoutCSS,A as sharedSidebar,w as themeSwitcherButtonHTML,y as themeSwitcherScript};
743
+ })();`}export{T as newSessionModalCSS,B as newSessionModalHTML,z as newSessionModalScript,w as reducedMotion,R as sharedHeader,L as sharedLayoutCSS,A as sharedSidebar,v as themeSwitcherButtonHTML,y as themeSwitcherScript};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-weblink",
3
- "version": "2.0.32",
3
+ "version": "2.1.0",
4
4
  "description": "Access your tmux sessions from the browser. A continuation of tmux-web and persalink.",
5
5
  "type": "module",
6
6
  "bin": {