@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.
- 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 +44 -0
- package/dist/main.js +1 -0
- package/dist/secrets.js +18 -6
- 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 +32 -2
- package/dist/web/auth.js +17 -23
- 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-CV7F9_iW.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-CtR-Ob4T.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/route.js +17 -0
- package/dist/web/server.js +104 -23
- package/dist/web/terminal.js +2 -7
- 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-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
|
|
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
|
|
@@ -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,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
|
-
|
|
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
|
@@ -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
|
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
|
-
}
|