@timqi/pier 0.0.16 → 0.0.19

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.
Files changed (79) hide show
  1. package/dist/agent/events.js +16 -4
  2. package/dist/agent/listing.js +12 -13
  3. package/dist/agent/pi.js +28 -2
  4. package/dist/boards/boards.js +7 -6
  5. package/dist/channels/attach.js +18 -4
  6. package/dist/channels/config.js +7 -2
  7. package/dist/channels/dedup.js +9 -1
  8. package/dist/channels/lark.js +2 -6
  9. package/dist/channels/receipts.js +21 -6
  10. package/dist/channels/slack-tool.js +86 -25
  11. package/dist/channels/slack.js +2 -10
  12. package/dist/channels/telegram.js +1 -0
  13. package/dist/core/hub.js +14 -4
  14. package/dist/core/inbox.js +1 -1
  15. package/dist/core/reply.js +64 -0
  16. package/dist/db.js +105 -0
  17. package/dist/main.js +1 -0
  18. package/dist/secrets.js +18 -6
  19. package/dist/settings.js +2 -11
  20. package/dist/tasks/agent.js +14 -1
  21. package/dist/tasks/definitions.js +3 -1
  22. package/dist/tasks/messages.js +1 -2
  23. package/dist/tasks/service.js +5 -0
  24. package/dist/tasks/store.js +44 -12
  25. package/dist/tools.js +42 -18
  26. package/dist/web/auth.js +212 -68
  27. package/dist/web/config.js +61 -0
  28. package/dist/web/explorer.js +20 -70
  29. package/dist/web/fs.js +159 -0
  30. package/dist/web/instance.js +11 -0
  31. package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
  32. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  33. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  34. package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
  35. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  36. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  37. package/dist/web/public/assets/code-DjWvqL94.js +1 -0
  38. package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
  39. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  40. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  41. package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-D8CLFi_l.js} +1 -1
  42. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  43. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  44. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  45. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  46. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  47. package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
  48. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  49. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  50. package/dist/web/public/assets/{index-2E9_cwpg.css → index-yNocr797.css} +1 -1
  51. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  52. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  53. package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
  54. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  55. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
  57. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  58. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  59. package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
  60. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  61. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
  62. package/dist/web/public/icon.svg.br +0 -0
  63. package/dist/web/public/icon.svg.gz +0 -0
  64. package/dist/web/public/index.html +2 -2
  65. package/dist/web/public/index.html.br +0 -0
  66. package/dist/web/public/index.html.gz +0 -0
  67. package/dist/web/public/manifest.webmanifest.br +0 -0
  68. package/dist/web/public/manifest.webmanifest.gz +0 -0
  69. package/dist/web/public/sw.js.br +0 -0
  70. package/dist/web/public/sw.js.gz +0 -0
  71. package/dist/web/push.js +30 -7
  72. package/dist/web/route.js +17 -0
  73. package/dist/web/server.js +104 -23
  74. package/dist/web/terminal.js +21 -16
  75. package/docs/deploy.md +12 -3
  76. package/package.json +1 -1
  77. package/skills/pier-slack/SKILL.md +4 -16
  78. package/dist/web/files.js +0 -183
  79. package/dist/web/public/assets/index-DVUvzNK1.js +0 -93
@@ -0,0 +1,2 @@
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/ghostty-web-D8CLFi_l.js","assets/index-BFzmvpGX.js","assets/index-yNocr797.css"])))=>i.map(i=>d[i]);
2
+ import{B as e,G as t,H as n,I as r,M as i,a,d as o,i as s,n as c,w as l}from"./index-BFzmvpGX.js";var u=`pier.terminalPrefs`,d={fontFamily:`monospace`,fontSize:14,cursorBlink:!0},f=()=>{try{let e=JSON.parse(localStorage.getItem(u)??`null`),t=typeof e?.fontSize==`number`?e.fontSize:d.fontSize;return{fontFamily:typeof e?.fontFamily==`string`&&e.fontFamily.trim()?e.fontFamily.slice(0,200):d.fontFamily,fontSize:Math.round(Math.max(10,Math.min(24,t))),cursorBlink:typeof e?.cursorBlink==`boolean`?e.cursorBlink:d.cursorBlink}}catch{return{...d}}},p=e=>localStorage.setItem(u,JSON.stringify(e)),m=null,h=()=>(m||=o(()=>import(`./ghostty-web-D8CLFi_l.js`).then(async e=>(await e.init(),e)),__vite__mapDeps([0,1,2])).catch(e=>{throw m=null,e}),m),g=()=>{let e=()=>void h().catch(e=>{console.warn(`terminal: emulator warm-up failed`,e)});`requestIdleCallback`in window?requestIdleCallback(e,{timeout:1e4}):setTimeout(e,3e3)},_={background:`#1a1b26`,foreground:`#a9b1d6`,cursor:`#c0caf5`};function v(e){let t=e.renderer,n=document.createElement(`canvas`).getContext(`2d`);if(!n||typeof t?.remeasureFont!=`function`){console.warn(`terminal: could not correct cell metrics — rows will render tight`);return}t.measureFont=()=>{let{fontSize:t,fontFamily:r}=e.options;n.font=`${t}px ${r}`;let i=n.measureText(`M`),a=i.fontBoundingBoxAscent||i.actualBoundingBoxAscent||t*.8,o=i.fontBoundingBoxDescent||i.actualBoundingBoxDescent||t*.25;return{width:Math.ceil(i.width),height:Math.ceil(a+o),baseline:Math.ceil(a)}},t.remeasureFont()}var y=`w-full rounded border border-neutral-300 px-2 py-1.5 font-mono text-[12px] outline-none focus:border-indigo-400`;function b(o,u,m,b,x,S){g();let C=``,w=null,T=null,E=null,D=!1,O=0,k=!1,A=f(),j=r(`div`,`ml-auto flex min-w-0 flex-none items-center gap-2`),M=r(`header`,`flex h-10 flex-none items-center gap-2 border-b border-neutral-200 bg-white px-3`),N=r(`div`,`min-h-0 flex-1 overflow-hidden p-1`);N.style.background=_.background,N.setAttribute(`data-owns-keyboard`,``),N.addEventListener(`keydown`,e=>{e.metaKey&&!e.defaultPrevented&&e.stopImmediatePropagation()},!0),N.addEventListener(`scroll`,()=>{N.scrollTop=0,N.scrollLeft=0}),N.addEventListener(`compositionend`,()=>{for(let e=N.childNodes.length-1;e>=0;e--){let t=N.childNodes[e];t&&t.nodeName!==`CANVAS`&&t.nodeName!==`TEXTAREA`&&t.remove()}}),o.append(M,N);let P=e=>{let t=r(`button`,`min-w-0 max-w-72 cursor-pointer truncate rounded-md bg-neutral-100 px-2 py-0.5 text-left font-mono text-[11.5px] text-neutral-600 hover:bg-neutral-200`,e);return t.type=`button`,t};function F(e,t){if(j.replaceChildren(),e&&j.append(r(`span`,`min-w-0 truncate text-[11.5px] text-neutral-400`,e)),t){let e=P(t.label);e.className=e.className.replace(`bg-neutral-100`,`bg-indigo-50 font-medium text-indigo-700 hover:bg-indigo-100`),e.onclick=t.run,j.append(e)}}function I(){w&&(w.options.fontFamily=A.fontFamily,w.options.fontSize=A.fontSize,w.options.cursorBlink=A.cursorBlink,requestAnimationFrame(H))}function L(){let i=document.createElement(`input`);i.type=`text`,i.placeholder=`tmux new -As "$(basename $PWD)"`,i.spellcheck=!1,i.autocomplete=`off`,i.autocapitalize=`off`,i.disabled=!0,i.className=`${y} disabled:bg-neutral-50`;let a=r(`span`,`block text-[11.5px] text-neutral-400`,`loading…`),o=(e,t)=>{a.isConnected?l(a,e,t):e===`failed`&&F(t)},s=``;(async()=>{try{s=(await n(`/api/settings`,`Could not load the startup command`)).terminalInitCommand??``,i.value=s,i.disabled=!1,o(`idle`,`Runs when a shell starts — Restart applies it here.`)}catch(e){o(`failed`,String(e))}})();let c=async()=>{let n=i.value.trim();if(!(i.disabled||n===s)){o(`saving`,`saving…`);try{let r=await t(`/api/settings`,{terminalInitCommand:n},`PUT`);if(!r.ok)return o(`failed`,await e(r,`Could not save the startup command`));s=(await r.json()).terminalInitCommand,i.value=s,o(`saved`,s?`Saved — the next shell runs it.`:`Cleared.`)}catch(e){o(`failed`,`Could not save the startup command: ${String(e)}`)}}};return i.onblur=()=>void c(),i.onkeydown=e=>{e.key===`Enter`&&c()},r(`div`,`space-y-1.5 border-t border-neutral-200 pt-2.5`,r(`div`,``,r(`span`,`text-[12px] font-semibold text-neutral-700`,`Startup command`),r(`span`,`block text-[11px] text-neutral-400`,`This Pier, every shell — not only this browser.`)),i,a)}function R(e){let t=document.createElement(`input`);t.type=`text`,t.value=A.fontFamily,t.setAttribute(`list`,`terminal-font-presets`),t.className=y;let n=document.createElement(`datalist`);n.id=`terminal-font-presets`;for(let e of[`monospace`,`ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace`,`JetBrains Mono, monospace`,`Cascadia Mono, monospace`,`Fira Code, monospace`]){let t=document.createElement(`option`);t.value=e,n.append(t)}let i=document.createElement(`input`);i.type=`number`,i.min=`10`,i.max=`24`,i.step=`1`,i.value=String(A.fontSize),i.className=`w-20 rounded border border-neutral-300 px-2 py-1.5 text-[12px] outline-none focus:border-indigo-400`;let o=document.createElement(`input`);o.type=`checkbox`,o.checked=A.cursorBlink,o.className=`h-4 w-4 accent-indigo-600`;let s=()=>{let e=t.value.trim(),n=Number(i.value);!e||!Number.isFinite(n)||(A={fontFamily:e.slice(0,200),fontSize:Math.round(Math.max(10,Math.min(24,n))),cursorBlink:o.checked},i.value=String(A.fontSize),p(A),I())};t.oninput=s,i.oninput=s,o.onchange=s;let c=r(`button`,`cursor-pointer text-[12px] text-neutral-500 hover:text-indigo-700`,`Reset`);c.type=`button`,c.onclick=()=>{A={...d},t.value=A.fontFamily,i.value=String(A.fontSize),o.checked=A.cursorBlink,p(A),I()},a(e,r(`div`,`w-72 space-y-3 px-3 py-2.5`,r(`div`,`flex items-center`,r(`span`,`text-[12px] font-semibold text-neutral-700`,`Terminal`),r(`span`,`ml-auto`,c)),r(`label`,`block space-y-1 text-[11.5px] text-neutral-500`,r(`span`,``,`Font family`),t,n),r(`label`,`flex items-center justify-between text-[11.5px] text-neutral-500`,r(`span`,``,`Font size`),i),r(`label`,`flex cursor-pointer items-center justify-between text-[11.5px] text-neutral-500`,r(`span`,``,`Blinking cursor`),o),L()))}function z(){let e=P(C||`Choose a folder…`);e.title=`Switch folder`,e.onclick=()=>c(e,u().map(e=>({path:e})),C||void 0,e=>e===C?void 0:b(e));let t=P(`Files`);t.title=`Browse this folder`,t.onclick=()=>x(C);let n=P(`Fit`);n.title=`Resize the shell to this window`,n.onclick=()=>{H(),w?.focus()};let i=P(`Restart`);i.title=`End this shell and start a new one`,i.onclick=()=>void V();let a=r(`button`,`icon-btn text-[15px]`,`⚙`);a.type=`button`,a.title=`Terminal settings`,a.setAttribute(`aria-label`,`Terminal settings`),a.onclick=()=>R(a);let o=r(`button`,`icon-btn`,`✕`);o.type=`button`,o.title=`Close Terminal`,o.setAttribute(`aria-label`,`Close Terminal`),o.onclick=S,M.replaceChildren(e,j,...C?[t]:[],n,i,a,o)}let B=()=>{w&&E?.readyState===WebSocket.OPEN&&E.send(JSON.stringify({t:`resize`,cols:w.cols,rows:w.rows}))};async function V(){if(E?.readyState!==WebSocket.OPEN){await W();return}window.confirm(`Restart the shell in ${C}? Anything running in it stops.`)&&(k=!0,F(`restarting…`),E.send(JSON.stringify({t:`restart`})))}function H(){!w||N.clientHeight===0||(T?.fit(),B())}window.addEventListener(`focus`,H),document.addEventListener(`visibilitychange`,()=>{document.hidden||H()});function U(){E?.close(),E=null,T=null,w?.dispose(),w=null,N.replaceChildren()}async function W(){let e=++O;U(),D=!1,k=!1,F(`connecting…`);let t;try{t=await h()}catch(e){F(`could not load the terminal: ${String(e)}`,{label:`Retry`,run:()=>void W()});return}if(e!==O)return;w=new t.Terminal({fontFamily:A.fontFamily,fontSize:A.fontSize,cursorBlink:A.cursorBlink,scrollback:2e3,theme:_}),w.open(N),v(w),T=new t.FitAddon,w.loadAddon(T),T.observeResize(),T.fit();let n=new WebSocket(`${location.protocol===`https:`?`wss`:`ws`}://${location.host}/api/terminal?cwd=${encodeURIComponent(C)}`);n.binaryType=`arraybuffer`,E=n,n.onopen=()=>{e===O&&(F(``),B(),w?.focus())},n.onmessage=t=>{if(e!==O)return;if(typeof t.data!=`string`){w?.write(new Uint8Array(t.data));return}let r;try{r=JSON.parse(t.data)}catch{D=!0,F(`invalid terminal response`,{label:`Reconnect`,run:()=>void W()}),n.close();return}if(r.t===`exit`){if(k){k=!1,W();return}D=!0,F(`shell exited (${r.code??`?`})`,{label:`Restart`,run:()=>void W()})}else r.t===`error`&&(D=!0,F(r.message??`terminal error`,{label:`Retry`,run:()=>void W()}))},n.onclose=()=>{e!==O||D||F(`disconnected`,{label:`Reconnect`,run:()=>void W()})},w.onData(e=>{n.readyState===WebSocket.OPEN&&n.send(JSON.stringify({t:`in`,d:e}))}),w.onResize(({cols:e,rows:t})=>{n.readyState===WebSocket.OPEN&&n.send(JSON.stringify({t:`resize`,cols:e,rows:t}))})}return i(o,e=>{let t=e?.startsWith(`/`)?e:C||m()||u()[0]||``;if(t===C&&w){z(),!D&&E&&E.readyState>WebSocket.OPEN?W():(H(),w.focus());return}if(C=t,z(),!C){F(`No project yet — pick a folder from the chip.`);return}W()},s)}export{b as createTerminalView};
Binary file
Binary file
@@ -33,8 +33,8 @@
33
33
  which style.css pays for with the safe-area inset. -->
34
34
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
35
35
  <title>Pier</title>
36
- <script type="module" crossorigin src="/assets/index-DVUvzNK1.js"></script>
37
- <link rel="stylesheet" crossorigin href="/assets/index-2E9_cwpg.css">
36
+ <script type="module" crossorigin src="/assets/index-BFzmvpGX.js"></script>
37
+ <link rel="stylesheet" crossorigin href="/assets/index-yNocr797.css">
38
38
  </head>
39
39
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
40
40
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
Binary file
Binary file
Binary file
Binary file
package/dist/web/push.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readableTitle } from "../core/identity.js";
11
11
  import { pierDb } from "../db.js";
12
12
  import { logger } from "../log.js";
13
+ import { sessionIdOf } from "./auth.js";
13
14
  import { generateVapidKeys, sendPush, } from "./webpush.js";
14
15
  const log = logger("push");
15
16
  /** Devices kept at once. A browser mints a new subscription whenever the old
@@ -29,6 +30,10 @@ const MAX_BODY_CHARS = 160;
29
30
  * browser bug rather than as an unnamed session — and a listing that could
30
31
  * not answer must not silence the push. */
31
32
  const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
33
+ /** SQLite's "that parent row does not exist" — the session ended. Anything
34
+ * else that goes wrong here is a broken database, not a signed-out browser. */
35
+ const FOREIGN_KEY_VIOLATION = 787;
36
+ const sessionGone = (err) => err.errcode === FOREIGN_KEY_VIOLATION;
32
37
  /** Subscriptions and the instance's VAPID identity. Both are per-instance
33
38
  * facts nobody edits by hand, so they live beside every other one. */
34
39
  export class PushStore {
@@ -59,14 +64,16 @@ export class PushStore {
59
64
  .all();
60
65
  }
61
66
  /** Upsert: a browser re-posts the same subscription on every load, which is
62
- * what repairs a row this instance lost. */
63
- save(target, label) {
67
+ * what repairs a row this instance lost — and what re-attaches one to the
68
+ * session that is signed in now. */
69
+ save(target, label, sessionId) {
64
70
  this.#db
65
- .prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at)
66
- VALUES (?, ?, ?, ?, ?)
71
+ .prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at, session_id)
72
+ VALUES (?, ?, ?, ?, ?, ?)
67
73
  ON CONFLICT(endpoint) DO UPDATE SET
68
- p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label`)
69
- .run(target.endpoint, target.p256dh, target.auth, label, Date.now());
74
+ p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label,
75
+ session_id = excluded.session_id`)
76
+ .run(target.endpoint, target.p256dh, target.auth, label, Date.now(), sessionId);
70
77
  this.#db
71
78
  .prepare(`DELETE FROM push_subscriptions WHERE endpoint NOT IN
72
79
  (SELECT endpoint FROM push_subscriptions ORDER BY created_at DESC LIMIT ?)`)
@@ -222,7 +229,23 @@ export function registerPushRoutes(app, deps) {
222
229
  if (!target)
223
230
  return c.json({ error: "not a push subscription" }, 400);
224
231
  const label = String(body.label ?? "a browser").slice(0, 80);
225
- store.save(target, label);
232
+ // The session that is asking owns the subscription, and the foreign key is
233
+ // what makes signing this browser out take the subscription with it. It can
234
+ // refuse: the boundary let this request in and the browser was signed out
235
+ // while its body was still arriving. Say so rather than 500 — the browser
236
+ // is about to be sent to the login form by its next request anyway.
237
+ try {
238
+ store.save(target, label, sessionIdOf(c));
239
+ }
240
+ catch (err) {
241
+ // Only that. A full or read-only database answering 401 would send a
242
+ // signed-in browser to the login form, where the password it types will
243
+ // not help either.
244
+ if (!sessionGone(err))
245
+ throw err;
246
+ log.warn(`subscription refused for a session that ended: ${String(err)}`);
247
+ return c.json({ error: "session ended" }, 401);
248
+ }
226
249
  log.info(`subscribed ${label}`);
227
250
  return c.json({ ok: true }, 201);
228
251
  });
@@ -0,0 +1,17 @@
1
+ // A route body that throws answers as JSON — the one shape every view's
2
+ // fetch helper already knows how to read, with the status the route owns.
3
+ // It lives alone because all three route families use it and none of them is
4
+ // where the other two would look for it.
5
+ /** Register a route whose body may throw: the error becomes the JSON shape
6
+ * the UI expects, with the status the route owns. Takes the literal path so
7
+ * `c.req.param()` keeps its typed keys. */
8
+ export function guarded(app, method, path, status, fn) {
9
+ app.on(method, path, async (c) => {
10
+ try {
11
+ return await fn(c);
12
+ }
13
+ catch (err) {
14
+ return c.json({ error: String(err) }, status);
15
+ }
16
+ });
17
+ }
@@ -1,8 +1,8 @@
1
1
  // Web workbench backend: REST + SSE, a pure consumer of core.
2
2
  // See docs/design/03-web-workbench.md for the route contract.
3
3
  import { hostname } from "node:os";
4
- import { readFile } from "node:fs/promises";
5
- import { join, relative } from "node:path";
4
+ import { readFile, stat } from "node:fs/promises";
5
+ import { isAbsolute, join, relative } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { serveStatic } from "@hono/node-server/serve-static";
8
8
  import { Hono } from "hono";
@@ -11,11 +11,13 @@ import { streamSSE } from "hono/streaming";
11
11
  import { EventHub } from "../core/hub.js";
12
12
  import { logger } from "../log.js";
13
13
  import { Router } from "../core/router.js";
14
+ import { registerConfigRoutes } from "./config.js";
14
15
  import { registerExplorerRoutes } from "./explorer.js";
15
- import { guarded, registerFileRoutes } from "./files.js";
16
+ import { fileHeaders, MAX_FILE_BYTES, registerFsRoutes, scopedFile } from "./fs.js";
17
+ import { guarded } from "./route.js";
16
18
  import { isThinkingLevel } from "../core/types.js";
17
19
  import { SESSION_TITLE_MAX } from "../limits.js";
18
- import { saveInbound } from "../core/inbox.js";
20
+ import { INBOX_DIR, saveInbound } from "../core/inbox.js";
19
21
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
20
22
  import { RepoIndex } from "./repos.js";
21
23
  import {} from "./session-state.js";
@@ -38,9 +40,30 @@ export const withTabPrefix = (html, prefix) => prefix
38
40
  ? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&amp;").replace(/</g, "&lt;")} - Pier</title>`)
39
41
  : html;
40
42
  const HEARTBEAT_MS = 15_000;
43
+ /** A reader that stopped reading must not queue frames without bound. Past
44
+ * this much frame text in flight the stream is dropped with a line in the
45
+ * log; EventSource reconnects and replays from its Last-Event-ID. */
46
+ const SSE_HIGH_WATER = 4 * 1024 * 1024;
41
47
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
42
48
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
43
- export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
49
+ /** The SSE frame for one event, built once and shared by every stream watching
50
+ * that session — three open tabs used to stringify the same delta three
51
+ * times. One slot is the whole cache: the hub fans an event out to its
52
+ * subscribers in one synchronous loop, so the reuse is always of the event
53
+ * just built, and the memo is keyed on identity so a miss only costs a
54
+ * rebuild. The hub stays transport-blind; this is the transport's business.
55
+ * Byte-identical to hono's `writeSSE`: JSON has no raw newline, so `data` is
56
+ * one line. */
57
+ let lastEvent = null;
58
+ let lastFrame = "";
59
+ const sseFrame = (e) => {
60
+ if (e !== lastEvent) {
61
+ lastEvent = e;
62
+ lastFrame = `data: ${JSON.stringify(e)}\nid: ${e.seq}\n\n`;
63
+ }
64
+ return lastFrame;
65
+ };
66
+ export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, channelOf, }) {
44
67
  const app = new Hono();
45
68
  // A finished turn marks its session unread until some client reports it was
46
69
  // seen (session selected + tab visible → POST read below). Server-side so
@@ -62,8 +85,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
62
85
  });
63
86
  // Repository identity per project directory, refreshed off the request path.
64
87
  const repos = new RepoIndex(() => hub.emitWorkspace({ type: "sessions-changed" }));
65
- /** Background runs this session launched that are still in flight. */
66
- const activeRuns = (id) => backgroundRuns?.(id).filter((r) => r.state === "queued" || r.state === "running").length ?? 0;
88
+ /** Background runs still in flight, per launching session. One query for a
89
+ * whole list: asking row by row loaded every run object of every session to
90
+ * draw one dot each. */
91
+ const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
67
92
  /** The web channel's session for `id` — every session route resolves here. */
68
93
  const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
69
94
  // Sessions created here that Pi doesn't list yet — it persists a session
@@ -118,7 +143,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
118
143
  // decided about it, and what is true of it right now.
119
144
  // The listing's `modified` is dropped rather than forwarded: it dates a
120
145
  // transcript, and no surface renders it.
121
- const present = ({ modified: _drop, ...s }, own) => {
146
+ const present = ({ modified: _drop, ...s }, own, active) => {
122
147
  return {
123
148
  ...s,
124
149
  ...(own?.sort === undefined ? {} : { sort: own.sort }),
@@ -133,7 +158,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
133
158
  listed: own?.pinned ?? false,
134
159
  unread: own?.unread ?? false,
135
160
  channel: channelOf?.(s.id) ?? "web",
136
- activeRuns: activeRuns(s.id),
161
+ activeRuns: active.get(s.id) ?? 0,
137
162
  };
138
163
  };
139
164
  // The rail: the same rows as the full list, minus everything Projects is not
@@ -141,8 +166,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
141
166
  // the summaries out of SQLite, which is what had to be kept in step.
142
167
  app.get("/api/projects", async (c) => {
143
168
  const flags = state.flags();
169
+ const active = activeRuns();
144
170
  return c.json((await allSessions())
145
- .map((s) => present(s, flags.get(s.id)))
171
+ .map((s) => present(s, flags.get(s.id), active))
146
172
  .filter((row) => row.listed));
147
173
  });
148
174
  // One drag, one write of the list that changed: the projects, or one
@@ -167,7 +193,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
167
193
  });
168
194
  app.get("/api/sessions", async (c) => {
169
195
  const flags = state.flags();
170
- return c.json((await allSessions()).map((s) => present(s, flags.get(s.id))));
196
+ const active = activeRuns();
197
+ return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
171
198
  });
172
199
  app.post("/api/sessions", async (c) => {
173
200
  const body = await c.req.json().catch(() => ({}));
@@ -245,6 +272,29 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
245
272
  // hands back what a surface shows, not the untruncated tool result.
246
273
  return c.json({ steps: turn.steps ?? [] });
247
274
  });
275
+ // Attachments, both directions: the agent links a file it produced, the chat
276
+ // renders a file the user sent. Read-only, and only from the session's own
277
+ // cwd or the inbox inbound files land in (core/inbox.ts) — a session route
278
+ // rather than an /api/fs one, because the session *is* the scope here.
279
+ guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
280
+ const raw = c.req.query("path");
281
+ if (!raw)
282
+ return c.json({ error: "path required" }, 400);
283
+ const id = c.req.param("id");
284
+ const cwd = nascent.get(id)?.cwd ?? (await factory.find(id))?.cwd;
285
+ // Absolute only: a link into a session's files is written by the agent or
286
+ // by Pier, and neither of them writes a path relative to anything.
287
+ const file = cwd && isAbsolute(raw) ? await scopedFile([cwd, INBOX_DIR], raw) : null;
288
+ if (!file)
289
+ return c.json({ error: "no such file" }, 404);
290
+ if ((await stat(file)).size > MAX_FILE_BYTES)
291
+ return c.json({ error: "file too large" }, 413);
292
+ const bytes = await readFile(file);
293
+ return c.body(bytes, 200, {
294
+ ...fileHeaders(file, bytes, c.req.query("download") === "1"),
295
+ "cache-control": "private, max-age=60",
296
+ });
297
+ });
248
298
  // Composer attachments: bytes land in the inbox, the message carries the
249
299
  // path as a `[name](file:///…)` line the client builds itself — upload
250
300
  // first, so the text it sends (and optimistically renders) is final.
@@ -450,12 +500,32 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
450
500
  Number(c.req.query("after") ?? "") ||
451
501
  0;
452
502
  return streamSSE(c, async (stream) => {
453
- const send = (e) => stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
454
- for (const e of hub.replay(id, lastId))
455
- await send(e);
456
- // Same as above: the client may be gone by the time an event fires.
457
- const unsubscribe = hub.subscribe(id, (e) => void send(e).catch(() => { }));
503
+ let queued = 0; // frame chars written but not yet drained by the reader
504
+ const send = (frame) => {
505
+ if (stream.aborted || stream.closed)
506
+ return;
507
+ if (queued > SSE_HIGH_WATER) {
508
+ log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
509
+ stream.abort(); // unsubscribes; the client reconnects and replays
510
+ return;
511
+ }
512
+ queued += frame.length;
513
+ void stream
514
+ .write(frame)
515
+ .catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
516
+ .finally(() => (queued -= frame.length));
517
+ };
518
+ // Subscribe before the replay write can wait on its reader: an event that
519
+ // arrives while that write is backpressured must queue behind it, not fall
520
+ // between replay() and subscribe(). Both snapshots happen synchronously,
521
+ // so live writes cannot overtake the replay write.
522
+ const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
458
523
  stream.onAbort(unsubscribe);
524
+ // One write for the whole replay: a reconnect after a busy turn used to
525
+ // cost an await per event before the client saw any of them.
526
+ const missed = hub.replay(id, lastId);
527
+ if (missed.length)
528
+ await stream.write(missed.map(sseFrame).join(""));
459
529
  // Heartbeat keeps proxies from closing the stream; loop ends on abort.
460
530
  while (!stream.aborted) {
461
531
  await stream.sleep(HEARTBEAT_MS);
@@ -509,12 +579,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
509
579
  onSettingsChanged: () => recycle("instance settings"),
510
580
  });
511
581
  registerProviderRoutes(app, providers, () => recycle("provider configuration"));
512
- registerFileRoutes(app, {
513
- factory,
514
- config,
515
- nascentCwd: (id) => nascent.get(id)?.cwd,
516
- onConfigWritten: () => recycle("an agent file"),
517
- });
582
+ registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
583
+ registerFsRoutes(app);
518
584
  registerExplorerRoutes(app);
519
585
  // serveStatic resolves `root` against the *working directory*, and an
520
586
  // installed Pier is started from wherever the operator happens to be. The
@@ -530,6 +596,11 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
530
596
  // at /login learns neither. Read once: neither can change under a process.
531
597
  const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
532
598
  let shell = null;
599
+ // The one file the precompressed siblings below cannot cover: this route
600
+ // answers from the patched string, not from disk, and it is re-fetched on
601
+ // every navigation because it may not be cached. Exact path, like the two
602
+ // API routes above — never the SSE streams.
603
+ app.use("/", compress());
533
604
  app.get("/", async (c, next) => {
534
605
  // A release replaces hashed assets. Revalidate the shell on every
535
606
  // navigation so a cached index cannot name bundles that no longer exist.
@@ -563,6 +634,16 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
563
634
  c.header("cache-control", "private, max-age=31536000, immutable");
564
635
  await next();
565
636
  });
566
- app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
637
+ // `precompressed` looks for a `.br`/`.gz` sibling of the file it is about to
638
+ // serve and hands that over when the request accepts the encoding; the build
639
+ // writes them (vite.config.ts). Without it the 325 kB bundle, the 87 kB
640
+ // stylesheet and the 636 kB terminal emulator all go out verbatim.
641
+ // serveStatic only sets Vary when it selects a sibling. Identity must carry
642
+ // it too, or a cache can reuse that response for a later Brotli request.
643
+ app.use("/*", async (c, next) => {
644
+ await next();
645
+ c.header("Vary", "Accept-Encoding");
646
+ });
647
+ app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || ".", precompressed: true }));
567
648
  return app;
568
649
  }
@@ -6,12 +6,11 @@
6
6
  // to survive that — durability is tmux's job, not ours). This is the one
7
7
  // WebSocket surface: a keystroke is a round trip, which SSE cannot carry
8
8
  // upstream. Everything else stays on the event stream.
9
- import { realpath, stat } from "node:fs/promises";
10
- import { isAbsolute } from "node:path";
11
9
  import { spawn } from "node-pty";
12
10
  import { WebSocketServer } from "ws";
13
11
  import { logger } from "../log.js";
14
- import { upgradeAuthorized } from "./auth.js";
12
+ import { scoped } from "./fs.js";
13
+ import { ALL, upgradeAuthorized } from "./auth.js";
15
14
  const log = logger("terminal");
16
15
  /** Live shells at once — a guard against forgotten spawns, not a quota. */
17
16
  const MAX_TERMS = 8;
@@ -71,11 +70,7 @@ export class TerminalHub {
71
70
  async attach(cwd, sock) {
72
71
  let key;
73
72
  try {
74
- if (!isAbsolute(cwd))
75
- throw new Error("cwd must be an absolute directory");
76
- key = await realpath(cwd);
77
- if (!(await stat(key)).isDirectory())
78
- throw new Error("cwd must be an absolute directory");
73
+ key = await scoped(cwd); // absolute, real, and a directory — or it throws
79
74
  }
80
75
  catch (err) {
81
76
  control(sock, { t: "error", message: String(err) });
@@ -276,10 +271,18 @@ export function attachTerminal(server, auth, opts = {}) {
276
271
  });
277
272
  server.once("close", () => hub.close());
278
273
  wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
279
- auth.onRotation(() => {
280
- log.info("password changed; closing terminal clients");
281
- for (const client of wss.clients)
282
- client.close(1008, "password changed");
274
+ // A shell outlives the request that opened it, so revocation has to reach it
275
+ // here: the session that opened this socket was signed out (or every session
276
+ // was, by a password change), and the terminal goes with it.
277
+ const sessionOf = new WeakMap();
278
+ const revoked = new WeakSet();
279
+ auth.onRevoke((id) => {
280
+ for (const client of wss.clients) {
281
+ if (id !== ALL && sessionOf.get(client) !== id)
282
+ continue;
283
+ revoked.add(client);
284
+ client.close(1008, "signed out");
285
+ }
283
286
  });
284
287
  server.on("upgrade", (req, socket, head) => {
285
288
  let url;
@@ -296,14 +299,15 @@ export function attachTerminal(server, auth, opts = {}) {
296
299
  socket.destroy();
297
300
  return;
298
301
  }
299
- if (!upgradeAuthorized(auth, req)) {
302
+ const sessionId = upgradeAuthorized(auth, req);
303
+ if (!sessionId) {
300
304
  log.warn("refused terminal upgrade (unauthorized)");
301
305
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
302
306
  socket.destroy();
303
307
  return;
304
308
  }
305
- const authKey = auth.cookieKey;
306
309
  wss.handleUpgrade(req, socket, head, (ws) => {
310
+ sessionOf.set(ws, sessionId);
307
311
  alive.add(ws);
308
312
  ws.on("pong", () => alive.add(ws));
309
313
  // Frames can land while attach() is resolving realpath; hold a bounded
@@ -320,9 +324,10 @@ export function attachTerminal(server, auth, opts = {}) {
320
324
  ws.on("message", (data, binary) => {
321
325
  if (closed)
322
326
  return;
323
- if (auth.cookieKey !== authKey) {
327
+ // A frame can already be in flight when the close above goes out.
328
+ if (revoked.has(ws)) {
324
329
  closed = true;
325
- ws.close(1008, "password changed");
330
+ ws.close(1008, "signed out");
326
331
  return;
327
332
  }
328
333
  if (binary) {
package/docs/deploy.md CHANGED
@@ -229,8 +229,16 @@ sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
229
229
  pier restart
230
230
  ```
231
231
 
232
- Changing the password invalidates every session cookie: the cookies are signed
233
- with the stored hash.
232
+ Changing the password signs out every browser: the session rows behind the
233
+ cookies are deleted. So does the recovery above — a new password does not leave
234
+ the old one's browsers signed in. To end one browser without changing the
235
+ password, use Settings → Instance → Signed-in devices. A session that is not
236
+ used expires 7 days after its last request. However a session ends — signed
237
+ out, expired, password changed, password recovered — its push subscription is
238
+ deleted with it, so that browser stops being notified as well.
239
+
240
+ The upgrade that introduced these rows signs everyone out once: a cookie issued
241
+ before it names no row. Have the password to hand.
234
242
 
235
243
  ## Restarting and reloading
236
244
 
@@ -400,7 +408,8 @@ elsewhere, pick a tunnel rather than a wider bind:
400
408
  there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
401
409
  `X-Forwarded-For`; Pier uses the external host for write-origin checks and
402
410
  counts login failures per forwarded client. Its session cookie is marked
403
- `Secure` when the proxy reports `X-Forwarded-Proto: https`. The proxy must
411
+ `Secure` when a loopback proxy reports `X-Forwarded-Proto: https` (the header
412
+ is ignored from anywhere else, where it is a header the client wrote). The proxy must
404
413
  pass WebSocket upgrades for `/api/terminal` (Caddy does automatically;
405
414
  nginx needs its usual HTTP/1.1 `Upgrade`/`Connection` forwarding).
406
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.16",
3
+ "version": "0.0.19",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",
@@ -24,20 +24,13 @@ this: there is no current conversation and `channel` is required.
24
24
 
25
25
  `{"operation":"channels"}` lists what Pier can reach (`id`, `name`, `kind`,
26
26
  `respondsToMessages`); pass either an id or a `#name` anywhere a channel is
27
- wanted. `not_in_channel` means someone must run `/invite @Pier`.
27
+ wanted.
28
28
 
29
29
  ## Never ask for an id
30
30
 
31
- You already have them:
32
-
33
- - **The person talking to you** a message may start with `[name<id> time]`,
34
- added by Pier, not typed by them. It appears only on a change — new speaker,
35
- a time gap, a new day — so the last one you saw still applies (a gap alone
36
- shows as time only, like `[14:23]`).
37
- - **Anyone in a transcript** — every line carries `name[id]`.
38
- - **This channel and thread** — from `context`.
39
-
40
- Asking a human to paste their own user ID is never acceptable.
31
+ You already have them: the sender header on the message you are answering,
32
+ `name[id]` on every line of a transcript, and `context` for this channel and
33
+ thread. Asking a human to paste their own user ID is never acceptable.
41
34
 
42
35
  ## Reading
43
36
 
@@ -107,8 +100,6 @@ The response carries `ts` and `threadTs` for replying under what you posted.
107
100
  `text` replaces the message outright; there is no partial edit. Read the
108
101
  message first if you are changing part of it.
109
102
 
110
- - Slack only lets Pier edit what **its own bot** posted — anyone else's message
111
- answers `cant_update_message`, and the answer is to reply, not to retry.
112
103
  - `ts` is always explicit, as with `delete`, and means nothing outside the
113
104
  channel it was read in.
114
105
  - The old text is gone — Slack keeps no version a reader can open, and may not
@@ -123,9 +114,6 @@ message first if you are changing part of it.
123
114
  {"operation":"delete","channel":"#ops","ts":"1717243800.000100"}
124
115
  ```
125
116
 
126
- Slack only lets Pier delete what **its own bot** posted — a person's message
127
- answers `cant_delete_message`, and the answer is to ask them, not to retry.
128
-
129
117
  - `ts` is always explicit. There is no "delete the last one" and no default
130
118
  from the thread you are in: the wrong `ts` cannot be undone.
131
119
  - A `ts` means nothing outside the conversation it came from — pass the