@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.
- package/dist/agent/events.js +16 -4
- package/dist/agent/listing.js +12 -13
- package/dist/agent/pi.js +28 -2
- package/dist/boards/boards.js +7 -6
- package/dist/channels/attach.js +18 -4
- package/dist/channels/config.js +7 -2
- package/dist/channels/dedup.js +9 -1
- package/dist/channels/lark.js +2 -6
- package/dist/channels/receipts.js +21 -6
- package/dist/channels/slack-tool.js +86 -25
- package/dist/channels/slack.js +2 -10
- package/dist/channels/telegram.js +1 -0
- package/dist/core/hub.js +14 -4
- package/dist/core/inbox.js +1 -1
- package/dist/core/reply.js +64 -0
- package/dist/db.js +105 -0
- package/dist/main.js +1 -0
- package/dist/secrets.js +18 -6
- package/dist/settings.js +2 -11
- package/dist/tasks/agent.js +14 -1
- package/dist/tasks/definitions.js +3 -1
- package/dist/tasks/messages.js +1 -2
- package/dist/tasks/service.js +5 -0
- package/dist/tasks/store.js +44 -12
- package/dist/tools.js +42 -18
- package/dist/web/auth.js +212 -68
- package/dist/web/config.js +61 -0
- package/dist/web/explorer.js +20 -70
- package/dist/web/fs.js +159 -0
- package/dist/web/instance.js +11 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
- package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
- package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
- package/dist/web/public/assets/code-DjWvqL94.js +1 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
- package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
- package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-D8CLFi_l.js} +1 -1
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
- package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
- package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
- package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
- package/dist/web/public/assets/{index-2E9_cwpg.css → index-yNocr797.css} +1 -1
- package/dist/web/public/assets/index-yNocr797.css.br +0 -0
- package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
- package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
- package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
- package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
- package/dist/web/public/icon.svg.br +0 -0
- package/dist/web/public/icon.svg.gz +0 -0
- package/dist/web/public/index.html +2 -2
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +30 -7
- package/dist/web/route.js +17 -0
- package/dist/web/server.js +104 -23
- package/dist/web/terminal.js +21 -16
- package/docs/deploy.md +12 -3
- package/package.json +1 -1
- package/skills/pier-slack/SKILL.md +4 -16
- package/dist/web/files.js +0 -183
- 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
|
|
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-
|
|
37
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -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 {
|
|
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, "&").replace(/</g, "<")} - 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
|
-
|
|
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
|
|
66
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
513
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/web/terminal.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
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
|
|
233
|
-
|
|
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
|
|
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
|
@@ -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.
|
|
27
|
+
wanted.
|
|
28
28
|
|
|
29
29
|
## Never ask for an id
|
|
30
30
|
|
|
31
|
-
You already have them:
|
|
32
|
-
|
|
33
|
-
|
|
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
|