@timqi/pier 0.0.17 → 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 (76) 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 +44 -0
  17. package/dist/main.js +1 -0
  18. package/dist/secrets.js +18 -6
  19. package/dist/tasks/agent.js +14 -1
  20. package/dist/tasks/definitions.js +3 -1
  21. package/dist/tasks/messages.js +1 -2
  22. package/dist/tasks/service.js +5 -0
  23. package/dist/tasks/store.js +44 -12
  24. package/dist/tools.js +32 -2
  25. package/dist/web/auth.js +17 -23
  26. package/dist/web/config.js +61 -0
  27. package/dist/web/explorer.js +20 -70
  28. package/dist/web/fs.js +159 -0
  29. package/dist/web/instance.js +11 -0
  30. package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
  31. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  32. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  33. package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
  34. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  35. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  36. package/dist/web/public/assets/code-DjWvqL94.js +1 -0
  37. package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
  38. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  39. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  40. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-D8CLFi_l.js} +1 -1
  41. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  42. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  43. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  44. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  45. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  46. package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
  47. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  48. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  49. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-yNocr797.css} +1 -1
  50. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  51. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  52. package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
  53. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  54. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  55. package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
  56. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  57. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  58. package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
  59. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  60. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
  61. package/dist/web/public/icon.svg.br +0 -0
  62. package/dist/web/public/icon.svg.gz +0 -0
  63. package/dist/web/public/index.html +2 -2
  64. package/dist/web/public/index.html.br +0 -0
  65. package/dist/web/public/index.html.gz +0 -0
  66. package/dist/web/public/manifest.webmanifest.br +0 -0
  67. package/dist/web/public/manifest.webmanifest.gz +0 -0
  68. package/dist/web/public/sw.js.br +0 -0
  69. package/dist/web/public/sw.js.gz +0 -0
  70. package/dist/web/route.js +17 -0
  71. package/dist/web/server.js +104 -23
  72. package/dist/web/terminal.js +2 -7
  73. package/package.json +1 -1
  74. package/skills/pier-slack/SKILL.md +4 -16
  75. package/dist/web/files.js +0 -183
  76. package/dist/web/public/assets/index-DAxKtidH.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-DAxKtidH.js"></script>
37
- <link rel="stylesheet" crossorigin href="/assets/index-CtR-Ob4T.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
@@ -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,11 +6,10 @@
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";
12
+ import { scoped } from "./fs.js";
14
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. */
@@ -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) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.17",
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
package/dist/web/files.js DELETED
@@ -1,183 +0,0 @@
1
- // Filesystem-facing routes: scoped Pi config editing, directory browsing for
2
- // the cwd pickers, and session attachments. Split from server.ts, which keeps
3
- // the session/queue/SSE routes; `guarded` lives here because both halves wrap
4
- // their route bodies with it.
5
- import { mkdir, readdir, readFile, realpath, stat } from "node:fs/promises";
6
- import { homedir } from "node:os";
7
- import { basename, dirname, extname, isAbsolute, resolve, sep } from "node:path";
8
- import { INBOX_DIR } from "../core/inbox.js";
9
- // Content types for the attachment route. Anything unlisted downloads as
10
- // bytes — guessing a type we can't vouch for is how a file starts executing.
11
- const FILE_TYPES = {
12
- ".png": "image/png",
13
- ".jpg": "image/jpeg",
14
- ".jpeg": "image/jpeg",
15
- ".gif": "image/gif",
16
- ".webp": "image/webp",
17
- ".avif": "image/avif",
18
- ".bmp": "image/bmp",
19
- ".pdf": "application/pdf",
20
- ".txt": "text/plain; charset=utf-8",
21
- ".md": "text/plain; charset=utf-8",
22
- ".log": "text/plain; charset=utf-8",
23
- ".json": "text/plain; charset=utf-8",
24
- ".csv": "text/plain; charset=utf-8",
25
- ".yaml": "text/plain; charset=utf-8",
26
- ".yml": "text/plain; charset=utf-8",
27
- };
28
- const MAX_FILE_BYTES = 32 * 1024 * 1024;
29
- /** Register a route whose body may throw: the error becomes the JSON shape
30
- * the UI expects, with the status the route owns. Takes the literal path so
31
- * `c.req.param()` keeps its typed keys. */
32
- export function guarded(app, method, path, status, fn) {
33
- app.on(method, path, async (c) => {
34
- try {
35
- return await fn(c);
36
- }
37
- catch (err) {
38
- return c.json({ error: String(err) }, status);
39
- }
40
- });
41
- }
42
- export function registerFileRoutes(app, { factory, config, nascentCwd, onConfigWritten }) {
43
- // Scope comes from the client as "global" or a project cwd; only cwds Pi
44
- // already knows (the session list) are accepted — never an arbitrary path.
45
- const parseScope = async (raw) => {
46
- if (!raw || raw === "global")
47
- return { kind: "global" };
48
- const known = await factory.list();
49
- return known.some((s) => s.cwd === raw) ? { kind: "project", cwd: raw } : null;
50
- };
51
- app.get("/api/config", async (c) => {
52
- c.header("cache-control", "no-store");
53
- const scope = await parseScope(c.req.query("scope"));
54
- if (!scope)
55
- return c.json({ error: "unknown scope" }, 400);
56
- return c.json({
57
- // Where this scope's files live on disk — the UI labels "Global" with it.
58
- dir: scope.kind === "global" ? config.globalDir : scope.cwd,
59
- files: await config.listFiles(scope),
60
- resources: await config.listResources(scope),
61
- });
62
- });
63
- guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
64
- c.header("cache-control", "no-store");
65
- const scope = await parseScope(c.req.query("scope"));
66
- if (!scope)
67
- return c.json({ error: "unknown scope" }, 400);
68
- return c.json({ content: await config.readFile(scope, c.req.param("name")) });
69
- });
70
- guarded(app, "PUT", "/api/config/files/:name", 400, async (c) => {
71
- const scope = await parseScope(c.req.query("scope"));
72
- if (!scope)
73
- return c.json({ error: "unknown scope" }, 400);
74
- const body = await c.req.json().catch(() => null);
75
- if (typeof body?.content !== "string" || typeof body?.expected !== "string") {
76
- return c.json({ error: "content and expected content required" }, 400);
77
- }
78
- const name = c.req.param("name");
79
- await config.writeFile(scope, name, body.content, body.expected);
80
- onConfigWritten?.();
81
- return c.json({ ok: true, content: await config.readFile(scope, name) });
82
- });
83
- // Resource names may contain slashes — query params, not path params.
84
- guarded(app, "GET", "/api/config/resource", 400, async (c) => {
85
- c.header("cache-control", "no-store");
86
- const scope = await parseScope(c.req.query("scope"));
87
- if (!scope)
88
- return c.json({ error: "unknown scope" }, 400);
89
- const kind = c.req.query("kind");
90
- const name = c.req.query("name");
91
- if ((kind !== "extensions" && kind !== "skills") || !name) {
92
- return c.json({ error: "kind and name required" }, 400);
93
- }
94
- return c.json({ content: await config.readResource(scope, kind, name) });
95
- });
96
- /** Real path of a file a session may expose: inside its cwd or the inbox
97
- * (where inbound user attachments land — core/inbox.ts), nothing else. */
98
- const resolveFile = async (id, raw) => {
99
- const cwd = nascentCwd(id) ?? (await factory.find(id))?.cwd;
100
- if (!cwd || !isAbsolute(raw))
101
- return null;
102
- try {
103
- // realpath both ends, so neither `..` nor a symlink can step outside.
104
- const roots = [await realpath(cwd)];
105
- try {
106
- roots.push(await realpath(INBOX_DIR));
107
- }
108
- catch {
109
- /* no inbox yet — nothing was ever uploaded */
110
- }
111
- const target = await realpath(resolve(roots[0], raw));
112
- if (!roots.some((root) => target === root || target.startsWith(root + sep)))
113
- return null;
114
- return (await stat(target)).isFile() ? target : null;
115
- }
116
- catch {
117
- return null; // missing, unreadable, or not a file
118
- }
119
- };
120
- // Attachments, both directions: the agent links a file it produced, the
121
- // chat renders a file the user sent — the client fetches the bytes here,
122
- // read-only, and only from the session's own cwd or the inbox.
123
- app.get("/api/sessions/:id/files", async (c) => {
124
- const raw = c.req.query("path");
125
- if (!raw)
126
- return c.json({ error: "path required" }, 400);
127
- const file = await resolveFile(c.req.param("id"), raw);
128
- if (!file)
129
- return c.json({ error: "no such file" }, 404);
130
- const { size } = await stat(file);
131
- if (size > MAX_FILE_BYTES)
132
- return c.json({ error: "file too large" }, 413);
133
- const name = basename(file);
134
- // Unknown extension → octet-stream: never let the browser guess a type we
135
- // can't vouch for (that is how an attachment starts executing).
136
- const type = FILE_TYPES[extname(file).toLowerCase()] ?? "application/octet-stream";
137
- const disposition = c.req.query("download") === "1" ? "attachment" : "inline";
138
- return c.body(await readFile(file), 200, {
139
- "content-type": type,
140
- "content-disposition": `${disposition}; filename*=UTF-8''${encodeURIComponent(name)}`,
141
- "cache-control": "private, max-age=60",
142
- });
143
- });
144
- // Directory browsing for the working-directory picker (new session, IM chat
145
- // config). Names only, never contents — the file route above stays the only
146
- // way to read bytes, and it is scoped to a session's own cwd.
147
- app.get("/api/fs/dirs", async (c) => {
148
- const raw = c.req.query("path");
149
- const path = raw ? resolve(raw) : homedir();
150
- if (!isAbsolute(path))
151
- return c.json({ error: "absolute path required" }, 400);
152
- let entries = [];
153
- try {
154
- const dir = await readdir(path, { withFileTypes: true });
155
- entries = dir
156
- // Dotfiles are noise in a project picker; a hidden cwd can still be
157
- // typed by hand.
158
- .filter((e) => e.isDirectory() && !e.name.startsWith("."))
159
- .map((e) => e.name)
160
- .sort((a, b) => a.localeCompare(b));
161
- }
162
- catch {
163
- return c.json({ error: "cannot read directory" }, 404);
164
- }
165
- const parent = dirname(path);
166
- return c.json({ path, parent: parent === path ? null : parent, entries });
167
- });
168
- // Create a folder while picking one — a new project usually needs a new
169
- // directory. A name, never a path: traversal is rejected, not normalized.
170
- guarded(app, "POST", "/api/fs/dirs", 400, async (c) => {
171
- const body = await c.req.json().catch(() => null);
172
- const parent = typeof body?.path === "string" ? body.path : "";
173
- const name = typeof body?.name === "string" ? body.name.trim() : "";
174
- if (!isAbsolute(parent))
175
- return c.json({ error: "absolute path required" }, 400);
176
- if (!name || name.length > 64 || /[/\\]|^\.\.?$/.test(name)) {
177
- return c.json({ error: "invalid folder name" }, 400);
178
- }
179
- const path = resolve(parent, name);
180
- await mkdir(path); // no recursive: the parent must already exist
181
- return c.json({ path });
182
- });
183
- }