@timqi/pier 0.1.9 → 0.1.11

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 (59) hide show
  1. package/dist/boards/boards.js +104 -41
  2. package/dist/channels/config.js +16 -0
  3. package/dist/channels/control.js +10 -3
  4. package/dist/channels/conversations.js +9 -0
  5. package/dist/channels/handoff.js +6 -2
  6. package/dist/channels/lark.js +3 -0
  7. package/dist/channels/panel.js +3 -6
  8. package/dist/channels/routes.js +3 -0
  9. package/dist/channels/slack.js +3 -0
  10. package/dist/channels/types.js +1 -0
  11. package/dist/main.js +3 -1
  12. package/dist/web/auth.js +6 -5
  13. package/dist/web/public/assets/{activity-DE9Dct4L.js → activity-dxSOEHmf.js} +1 -1
  14. package/dist/web/public/assets/activity-dxSOEHmf.js.br +0 -0
  15. package/dist/web/public/assets/activity-dxSOEHmf.js.gz +0 -0
  16. package/dist/web/public/assets/{boards-il6ceDEs.js → boards-BRwn5UZi.js} +1 -1
  17. package/dist/web/public/assets/boards-BRwn5UZi.js.br +0 -0
  18. package/dist/web/public/assets/boards-BRwn5UZi.js.gz +0 -0
  19. package/dist/web/public/assets/{explorer-JDO21aAs.js → explorer-CCJQ22Dp.js} +1 -1
  20. package/dist/web/public/assets/explorer-CCJQ22Dp.js.br +0 -0
  21. package/dist/web/public/assets/explorer-CCJQ22Dp.js.gz +0 -0
  22. package/dist/web/public/assets/index-CzV2cZEH.js +85 -0
  23. package/dist/web/public/assets/index-CzV2cZEH.js.br +0 -0
  24. package/dist/web/public/assets/index-CzV2cZEH.js.gz +0 -0
  25. package/dist/web/public/assets/{runs-CsINd08H.js → runs-DtvDXSfo.js} +1 -1
  26. package/dist/web/public/assets/runs-DtvDXSfo.js.br +0 -0
  27. package/dist/web/public/assets/runs-DtvDXSfo.js.gz +0 -0
  28. package/dist/web/public/assets/{settings-ChwAWwE_.js → settings-CJbHCEZo.js} +1 -1
  29. package/dist/web/public/assets/settings-CJbHCEZo.js.br +0 -0
  30. package/dist/web/public/assets/settings-CJbHCEZo.js.gz +0 -0
  31. package/dist/web/public/assets/{task-runs-I0eOy3HT.js → task-runs-DcORJXHU.js} +1 -1
  32. package/dist/web/public/assets/task-runs-DcORJXHU.js.br +0 -0
  33. package/dist/web/public/assets/task-runs-DcORJXHU.js.gz +0 -0
  34. package/dist/web/public/assets/{tasks-D3i9Kc5-.js → tasks-8l1M8EJU.js} +1 -1
  35. package/dist/web/public/assets/tasks-8l1M8EJU.js.br +0 -0
  36. package/dist/web/public/assets/tasks-8l1M8EJU.js.gz +0 -0
  37. package/dist/web/public/index.html +1 -1
  38. package/dist/web/public/index.html.br +0 -0
  39. package/dist/web/public/index.html.gz +0 -0
  40. package/dist/web/server.js +1 -1
  41. package/package.json +1 -1
  42. package/skills/pier-boards/SKILL.md +1 -1
  43. package/dist/web/public/assets/activity-DE9Dct4L.js.br +0 -0
  44. package/dist/web/public/assets/activity-DE9Dct4L.js.gz +0 -0
  45. package/dist/web/public/assets/boards-il6ceDEs.js.br +0 -0
  46. package/dist/web/public/assets/boards-il6ceDEs.js.gz +0 -0
  47. package/dist/web/public/assets/explorer-JDO21aAs.js.br +0 -0
  48. package/dist/web/public/assets/explorer-JDO21aAs.js.gz +0 -0
  49. package/dist/web/public/assets/index-BNjWeodx.js +0 -85
  50. package/dist/web/public/assets/index-BNjWeodx.js.br +0 -0
  51. package/dist/web/public/assets/index-BNjWeodx.js.gz +0 -0
  52. package/dist/web/public/assets/runs-CsINd08H.js.br +0 -0
  53. package/dist/web/public/assets/runs-CsINd08H.js.gz +0 -0
  54. package/dist/web/public/assets/settings-ChwAWwE_.js.br +0 -0
  55. package/dist/web/public/assets/settings-ChwAWwE_.js.gz +0 -0
  56. package/dist/web/public/assets/task-runs-I0eOy3HT.js.br +0 -0
  57. package/dist/web/public/assets/task-runs-I0eOy3HT.js.gz +0 -0
  58. package/dist/web/public/assets/tasks-D3i9Kc5-.js.br +0 -0
  59. package/dist/web/public/assets/tasks-D3i9Kc5-.js.gz +0 -0
@@ -1,8 +1,9 @@
1
1
  // Boards: static pages an agent writes, derived by scanning $PIER_HOME/boards,
2
2
  // never registered (docs/design/05-boards.md). Only <board>/site is reachable
3
- // over HTTP, so a public board leaks nothing about how it was made. `/p/*` is
4
- // the only password-free prefix, stylesheet included, and runs sandboxed.
5
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
3
+ // over HTTP, so a public board leaks nothing about how it was made. Bytes are
4
+ // served on two password-free prefixes, `/p/*` (published) and `/b/*` (a
5
+ // signed prefix the boundary mints), stylesheet included, and run sandboxed.
6
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
6
7
  import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
7
8
  import { extname, join, resolve, sep } from "node:path";
8
9
  import { logger } from "../log.js";
@@ -34,17 +35,47 @@ const TYPES = {
34
35
  ".txt": "text/plain; charset=utf-8",
35
36
  ".csv": "text/plain; charset=utf-8",
36
37
  };
37
- // Active content on the workbench origin: the sandbox removes forms, frames,
38
- // popups and subresource requests. A private board keeps `allow-same-origin`
39
- // for the session cookie, so its script reads this origin's storage and
40
- // top-level navigation remains a way out (docs/design/05-boards.md). Public
41
- // pages omit same-origin, so their scripts inherit no authority at all.
42
- const CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
43
- "script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-src 'none'; " +
44
- "worker-src 'none'; object-src 'none'; base-uri 'none'; form-action 'none'; " +
45
- "frame-ancestors 'none'";
46
- const PRIVATE_CSP = `sandbox allow-scripts allow-same-origin; ${CSP}`;
47
- const PUBLIC_CSP = `sandbox allow-scripts; ${CSP}`;
38
+ // Agent-written script, so it runs in an opaque origin: no `allow-same-origin`
39
+ // on any board, published or not, and the sandbox also removes forms, frames,
40
+ // popups and subresource requests. What that costs is the session cookie —
41
+ // an opaque-origin document sends none with its own assets — which is why a
42
+ // board is never served on a cookie-authorized URL (docs/design/05-boards.md).
43
+ const CSP = "sandbox allow-scripts; default-src 'self'; img-src 'self' data:; " +
44
+ "style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; " +
45
+ "connect-src 'none'; frame-src 'none'; worker-src 'none'; object-src 'none'; " +
46
+ "base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
47
+ /** Long enough to read a board without a second thought, short enough that a
48
+ * URL left in a history or a chat log stops working the same day. */
49
+ const VIEW_TTL_MS = 8 * 60 * 60_000;
50
+ /** Process-scoped: a view prefix is a read capability for one board, and
51
+ * nothing may outlive the instance that vouched for it. */
52
+ let viewKey = randomBytes(32);
53
+ /** Signing out must end the boards that session opened too — the pages carry
54
+ * no cookie to revoke, so the key they were signed with is what goes. */
55
+ export const rotateBoardViews = () => {
56
+ viewKey = randomBytes(32);
57
+ };
58
+ const sign = (slug, expires) => createHmac("sha256", viewKey).update(`${slug}\0${String(expires)}`).digest("base64url").slice(0, 22);
59
+ /** `<expiry in base36>-<signature>`: its own path segment, so the first hyphen
60
+ * is the cut and a hyphenated slug stays unambiguous. */
61
+ const mintView = (slug) => {
62
+ const expires = Date.now() + VIEW_TTL_MS;
63
+ return `${expires.toString(36)}-${sign(slug, expires)}`;
64
+ };
65
+ function validView(slug, view) {
66
+ const cut = view.indexOf("-");
67
+ if (cut < 1)
68
+ return false;
69
+ const stamp = view.slice(0, cut);
70
+ const expires = Number.parseInt(stamp, 36);
71
+ // Canonical only: `parseInt` stops at the first stray character, which would
72
+ // make one signature valid under several spellings of its own prefix.
73
+ if (!Number.isSafeInteger(expires) || expires.toString(36) !== stamp)
74
+ return false;
75
+ if (expires <= Date.now())
76
+ return false;
77
+ return sameToken(sign(slug, expires), view.slice(cut + 1));
78
+ }
48
79
  /** Malformed boards are reported once, not on every scan. */
49
80
  const warned = new Set();
50
81
  /** The one place a slug becomes a path, so it is validated here (`../../etc`,
@@ -176,14 +207,7 @@ const sameToken = (want, got) => {
176
207
  const digest = (s) => createHash("sha256").update(s).digest();
177
208
  return timingSafeEqual(digest(want), digest(got));
178
209
  };
179
- async function serveFile(c, dir, key, rest, publicOnly) {
180
- const { slug, token } = publicOnly ? publicKey(key) : { slug: key, token: "" };
181
- const manifest = await readManifest(dir, slug);
182
- // 404, never 403: a private board's existence is not public information, and
183
- // a wrong token is the same non-answer as a wrong name.
184
- if (!manifest || (publicOnly && (!manifest.public || !sameToken(manifest.token, token)))) {
185
- return c.notFound();
186
- }
210
+ async function serveFile(c, dir, slug, rest) {
187
211
  const file = await resolveFile(dir, slug, rest);
188
212
  if (!file)
189
213
  return c.notFound();
@@ -193,17 +217,16 @@ async function serveFile(c, dir, key, rest, publicOnly) {
193
217
  const headers = {
194
218
  "content-type": type,
195
219
  "x-content-type-options": "nosniff",
196
- // Every public request rechecks the manifest, so unpublishing cannot leave
197
- // a shared-cache copy reachable. Private assets may stay in one browser.
198
- "cache-control": publicOnly || type.startsWith("text/html")
199
- ? "no-store"
200
- : "private, max-age=300",
201
- "content-security-policy": publicOnly ? PUBLIC_CSP : PRIVATE_CSP,
220
+ // Nothing is cached: both prefixes are revocable (unpublish, sign out,
221
+ // expiry), and a stored copy would outlive the revocation.
222
+ "cache-control": "no-store",
223
+ "content-security-policy": CSP,
224
+ // The URL is the credential on both prefixes; an outbound link must not carry it.
225
+ "referrer-policy": "no-referrer",
226
+ // A sandboxed page has an opaque origin. Fonts and module scripts need CORS
227
+ // even when their URLs are under the same board.
228
+ "access-control-allow-origin": "*",
202
229
  };
203
- // Public sandboxed pages have an opaque origin. Fonts and module scripts need
204
- // CORS even when their URLs are under the same published board.
205
- if (publicOnly)
206
- headers["access-control-allow-origin"] = "*";
207
230
  return c.body(await readFile(file), 200, headers);
208
231
  }
209
232
  export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
@@ -231,6 +254,9 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
231
254
  if (!(await readManifest(dir, slug)))
232
255
  return c.json({ error: "no such board" }, 404);
233
256
  await rename(join(dir, slug), join(dir, `${slug}.deleted-${Date.now()}`));
257
+ // A capability names a slug, and a slug can be taken again: the prefixes
258
+ // handed out for the board that just died must not open its successor.
259
+ rotateBoardViews();
234
260
  return c.json({ deleted: slug });
235
261
  });
236
262
  // Declared before the wildcards below: `_assets` is not a slug.
@@ -242,13 +268,50 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
242
268
  });
243
269
  });
244
270
  // Trailing slash matters: without it a board's relative asset paths resolve
245
- // against /boards instead of the board.
246
- for (const prefix of ["/boards", "/p"]) {
247
- app.get(`${prefix}/:key`, (c) => c.redirect(`${prefix}/${c.req.param("key")}/`));
248
- app.get(`${prefix}/:key/*`, (c) => {
249
- const key = c.req.param("key");
250
- const rest = c.req.path.slice(`${prefix}/${key}/`.length);
251
- return serveFile(c, dir, key, rest, prefix === "/p");
252
- });
253
- }
271
+ // against the prefix instead of the board.
272
+ app.get("/p/:key", (c) => c.redirect(`/p/${c.req.param("key")}/`));
273
+ app.get("/p/:key/*", async (c) => {
274
+ const key = c.req.param("key");
275
+ const { slug, token } = publicKey(key);
276
+ const manifest = await readManifest(dir, slug);
277
+ // 404, never 403: a private board's existence is not public information,
278
+ // and a wrong token is the same non-answer as a wrong name.
279
+ if (!manifest || !manifest.public || !sameToken(manifest.token, token))
280
+ return c.notFound();
281
+ return serveFile(c, dir, slug, c.req.path.slice(`/p/${key}/`.length));
282
+ });
283
+ // The operator's link, and the one place the password is spent on a board:
284
+ // it hands out a signed prefix instead of bytes, so the page that follows
285
+ // needs no cookie and can be sandboxed into an opaque origin.
286
+ const mint = async (c) => {
287
+ const slug = c.req.param("slug") ?? "";
288
+ // Signed before the read: a sign-out landing during it would otherwise
289
+ // hand this request a capability made with the key that replaced the
290
+ // revoked one.
291
+ const view = SLUG.test(slug) ? mintView(slug) : "";
292
+ // A board that is gone says so here, rather than after a redirect.
293
+ if (!view || !(await readManifest(dir, slug)))
294
+ return c.notFound();
295
+ const rest = c.req.path.slice(`/boards/${slug}`.length).replace(/^\//, "");
296
+ return c.redirect(`/b/${slug}/${view}/${rest}${new URL(c.req.url).search}`);
297
+ };
298
+ app.get("/boards/:slug", mint);
299
+ app.get("/boards/:slug/*", mint);
300
+ app.get("/b/:slug/:view", (c) => c.redirect(`${c.req.path}/${new URL(c.req.url).search}`));
301
+ app.get("/b/:slug/:view/*", async (c) => {
302
+ const slug = c.req.param("slug");
303
+ const view = c.req.param("view");
304
+ const rest = c.req.path.slice(`/b/${slug}/${view}/`.length);
305
+ if (!SLUG.test(slug))
306
+ return c.notFound();
307
+ // Expired, forged, or signed with a key that has since rotated: send it
308
+ // back through the boundary, which re-mints for a live session in one hop
309
+ // and asks a stranger for the password. Existence stays unsaid either way.
310
+ if (!validView(slug, view)) {
311
+ return c.redirect(`/boards/${slug}/${rest}${new URL(c.req.url).search}`);
312
+ }
313
+ if (!(await readManifest(dir, slug)))
314
+ return c.notFound();
315
+ return serveFile(c, dir, slug, rest);
316
+ });
254
317
  }
@@ -91,6 +91,22 @@ export class ChannelStore {
91
91
  }
92
92
  this.save(platform, config);
93
93
  }
94
+ /** A DM's id belongs to the bot it was opened with, so a new app or a rotated
95
+ * token leaves DMs no one can reach and nothing on screen tells them apart
96
+ * from the live ones — they are dropped. Group ids survive the swap. Returns
97
+ * the chat ids forgotten; the first identity ever seen forgets nothing. */
98
+ claimBot(platform, botId) {
99
+ const known = this.cached(platform).botId;
100
+ if (!botId || known === botId)
101
+ return [];
102
+ const config = this.get(platform);
103
+ const keep = (chat) => !known || chat.kind !== "dm";
104
+ const dropped = config.chats.filter((c) => !keep(c)).map((c) => c.id);
105
+ config.chats = config.chats.filter(keep);
106
+ config.botId = botId;
107
+ this.save(platform, config);
108
+ return dropped;
109
+ }
94
110
  // On the per-message path: no clone, nothing here escapes.
95
111
  policy(platform, chatId) {
96
112
  const config = this.cached(platform);
@@ -1,6 +1,7 @@
1
- // What an adapter may do to a session beyond handing it a prompt. Not part of
2
- // the `Channel` seam: the channel layer owns the router and hands adapters this
3
- // narrow interface instead. Thin wrappers over core; no policy.
1
+ // What an adapter may do beyond handing a prompt over: act on a thread's
2
+ // session, and claim the identity its chats belong to. Not part of the
3
+ // `Channel` seam: the channel layer owns the router and the stores and hands
4
+ // adapters this narrow interface instead. Thin wrappers; no policy.
4
5
  import { projectCwds } from "../core/identity.js";
5
6
  import { chatOf, isChannelPlatform } from "./types.js";
6
7
  export const NO_SESSION = "No session in this thread yet — start one first (Start in the panel).";
@@ -25,6 +26,12 @@ export function createControl({ router, factory, conversations, store, modelMenu
25
26
  };
26
27
  return {
27
28
  launchFor,
29
+ claimBot(platform, botId) {
30
+ const dropped = store.claimBot(platform, botId);
31
+ for (const chatId of dropped)
32
+ conversations.forgetChat(platform, chatId);
33
+ return dropped;
34
+ },
28
35
  knows: (key) => conversations.get(key) !== undefined,
29
36
  abort: (key) => router.abortConversation(key),
30
37
  working: (key) => router.sessionOf(key)?.state === "streaming",
@@ -52,6 +52,15 @@ export class ConversationStore {
52
52
  const rows = this.db.prepare(`SELECT session_id FROM conversations`).all();
53
53
  return new Set(rows.map((r) => r.session_id));
54
54
  }
55
+ /** Every thread of one chat: a chat that is gone would otherwise keep its
56
+ * sessions bound to a conversation nothing can reach. */
57
+ forgetChat(channelId, chatId) {
58
+ const prefix = `${chatId}/`;
59
+ this.db.prepare(`
60
+ DELETE FROM conversations
61
+ WHERE channel_id = ? AND (conversation_id = ? OR substr(conversation_id, 1, ?) = ?)
62
+ `).run(channelId, chatId, prefix.length, prefix);
63
+ }
55
64
  forget(key) {
56
65
  this.db.prepare(`
57
66
  DELETE FROM conversations WHERE channel_id = ? AND conversation_id = ?
@@ -42,9 +42,11 @@ export function createHandoff(deps) {
42
42
  log(`session ${sessionId} continued in ${key.channelId}:${key.conversationId}`);
43
43
  };
44
44
  return {
45
+ // DMs only: a group's thread belongs to the group, and a session pushed
46
+ // into one answers everyone there.
45
47
  targets: () => runtime.running().flatMap((platform) => store.get(platform).chats
46
- .filter((chat) => chat.enabled)
47
- .map((chat) => ({ platform, chatId: chat.id, name: chat.name, kind: chat.kind }))),
48
+ .filter((chat) => chat.enabled && chat.kind === "dm")
49
+ .map((chat) => ({ platform, chatId: chat.id, name: chat.name }))),
48
50
  async continueIn({ sessionId, platform, chatId }) {
49
51
  if (!runtime.running().includes(platform)) {
50
52
  throw new HandoffError(409, `${platformName(platform)} is not running — enable it in Settings → Channels.`);
@@ -52,6 +54,8 @@ export function createHandoff(deps) {
52
54
  const chat = store.chat(platform, chatId);
53
55
  if (!chat?.enabled)
54
56
  throw new HandoffError(chat ? 409 : 404, "That chat is not enabled for the bot.");
57
+ if (chat.kind !== "dm")
58
+ throw new HandoffError(409, "Only a direct message can continue a web session.");
55
59
  const summary = await claimable(sessionId);
56
60
  const base = publicUrl();
57
61
  const note = {
@@ -90,6 +90,9 @@ export class LarkChannel {
90
90
  // Every chat with require-mention on goes silent; loud, not a debug line.
91
91
  this.log("bot info returned no open_id: mention detection is disabled");
92
92
  }
93
+ const dropped = this.deps.control?.claimBot("lark", this.me) ?? [];
94
+ if (dropped.length)
95
+ this.log(`bot is now ${this.me}: forgot ${dropped.length} DM(s) the previous bot opened`);
93
96
  this.running = true;
94
97
  void this.receipts.sweep(true);
95
98
  this.socket = await this.api.connect({
@@ -31,11 +31,8 @@ export const CWD_DRAFT_TAIL = "Start creates the session there.";
31
31
  export const CWD_PLACEHOLDER = "/path/to/project";
32
32
  export const QUESTION_TOO_LONG = "Your question is too long to hold — send it again after Start.";
33
33
  const btn = (label, action) => ({ label, action });
34
- /** A button label is the last two segments; the numbered line above has the whole path. */
35
- const shortDir = (path) => {
36
- const parts = path.split("/").filter(Boolean);
37
- return parts.length > 2 ? `…/${parts.slice(-2).join("/")}` : path;
38
- };
34
+ /** A button label is the last segment; the numbered line above has the whole path. */
35
+ const shortDir = (path) => path.split("/").filter(Boolean).at(-1) ?? path;
39
36
  /** One transcript line as a reader sees it: what was said, minus what was
40
37
  * written for the model (the speaker header, attachment markers, the
41
38
  * next-step block, a silent turn's reason). */
@@ -324,7 +321,7 @@ export class ChatPanel {
324
321
  title: "Continue web session",
325
322
  suffix: ` · page ${at + 1}/${pages}`,
326
323
  lines: slice.length
327
- ? slice.map((s, i) => `${String(from + i + 1)}. ${this.esc(shortTitle(s))} · ${this.code(s.cwd.split("/").filter(Boolean).at(-1) ?? s.cwd)} · ${age(s.modified ?? s.createdAt, now)}`)
324
+ ? slice.map((s, i) => `${String(from + i + 1)}. ${this.esc(shortTitle(s))} · ${this.code(shortDir(s.cwd))} · ${age(s.modified ?? s.createdAt, now)}`)
328
325
  : [unavailable ?? "No unbound sessions."],
329
326
  }],
330
327
  picks: slice.map((s, i) => btn(`${String(from + i + 1)} ${shortTitle(s)}`, `session:${String(from + i)}`)),
@@ -79,6 +79,9 @@ export function registerChannelRoutes(app, store, runtime, handoff) {
79
79
  users: current.users,
80
80
  chats: parseChats(body.chats, current.chats),
81
81
  bindCode: current.bindCode,
82
+ // Learned at start, never edited here: a save that reset it would make the
83
+ // next start read as the first one and skip the swapped bot's cleanup.
84
+ botId: current.botId,
82
85
  };
83
86
  store.save(platform, next);
84
87
  await runtime.reload();
@@ -126,6 +126,9 @@ export class SlackChannel {
126
126
  // Every channel with require-mention on goes silent; loud, not a debug line.
127
127
  this.log("auth.test returned no user id: mention detection is disabled");
128
128
  }
129
+ const dropped = this.deps.control?.claimBot("slack", this.me) ?? [];
130
+ if (dropped.length)
131
+ this.log(`bot is now ${this.me}: forgot ${dropped.length} DM(s) the previous bot opened`);
129
132
  this.running = true;
130
133
  void this.receipts.sweep(true);
131
134
  this.socket = await this.api.connect((env) => this.onEnvelope(env, onMessage));
@@ -20,4 +20,5 @@ export const defaultChannelConfig = () => ({
20
20
  users: [],
21
21
  chats: [],
22
22
  bindCode: null,
23
+ botId: "",
23
24
  });
package/dist/main.js CHANGED
@@ -11,7 +11,7 @@ import { CredentialStore } from "./agent/credentials.js";
11
11
  import { IndexedListing } from "./agent/listing.js";
12
12
  import { PiPackageStore } from "./agent/packages.js";
13
13
  import { PiAgentFactory } from "./agent/pi.js";
14
- import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
14
+ import { defaultBoardsDir, registerBoardRoutes, rotateBoardViews } from "./boards/boards.js";
15
15
  import { ChannelStore } from "./channels/config.js";
16
16
  import { createControl } from "./channels/control.js";
17
17
  import { ConversationStore, resolveConversation } from "./channels/conversations.js";
@@ -265,6 +265,8 @@ app.onError((err, c) => {
265
265
  // Before every route: Hono runs middleware in registration order, so a surface
266
266
  // added later is covered without knowing this exists.
267
267
  const auth = new AuthStore(db);
268
+ // A board page carries no cookie, so the signing key is what a sign-out revokes.
269
+ auth.onRevoke(rotateBoardViews);
268
270
  const passkeys = new PasskeyStore(db);
269
271
  registerConfigShareRoute(app, configSync);
270
272
  app.use("*", requireAuth(auth));
package/dist/web/auth.js CHANGED
@@ -192,10 +192,11 @@ export class AuthStore {
192
192
  }
193
193
  const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString("hex");
194
194
  const digest = (token) => createHash("sha256").update(token).digest("hex");
195
- /** The login form, the passkey half of it (web/passkeys.ts) and `/p/*` —
196
- * published boards and their stylesheet — are the exemptions here
197
- * (`/config-sync/:token` is mounted before this middleware,
198
- * docs/architecture.md names both); `/boards/*` stays behind. */
195
+ /** The login form, the passkey half of it (web/passkeys.ts), `/p/*` —
196
+ * published boards and their stylesheet — and `/b/*`, the signed prefix
197
+ * `/boards/*` redirects to once this boundary has passed, are the exemptions
198
+ * here (`/config-sync/:token` is mounted before this middleware,
199
+ * docs/architecture.md names them); `/boards/*` itself stays behind. */
199
200
  function isPublic(method, path) {
200
201
  if (path === "/login")
201
202
  return method === "GET" || method === "HEAD" || method === "POST";
@@ -205,7 +206,7 @@ function isPublic(method, path) {
205
206
  return method === "POST";
206
207
  if (method !== "GET" && method !== "HEAD")
207
208
  return false;
208
- return path.startsWith("/p/");
209
+ return path.startsWith("/p/") || path.startsWith("/b/");
209
210
  }
210
211
  /** Constant-time equality that also hides length: both sides are digested. */
211
212
  function sameSecret(a, b) {
@@ -1,4 +1,4 @@
1
- import{C as e,G as t,J as n,O as r,P as i,Q as a,V as o,Y as s,Z as c,at as l,et as u,t as d,u as f}from"./index-BNjWeodx.js";var p=[[`path`,{d:`M3 5h.01`}],[`path`,{d:`M3 12h.01`}],[`path`,{d:`M3 19h.01`}],[`path`,{d:`M8 5h13`}],[`path`,{d:`M8 12h13`}],[`path`,{d:`M8 19h13`}]],m=[[`path`,{d:`m10.586 5.414-5.172 5.172`}],[`path`,{d:`m18.586 13.414-5.172 5.172`}],[`path`,{d:`M6 12h12`}],[`circle`,{cx:`12`,cy:`20`,r:`2`}],[`circle`,{cx:`12`,cy:`4`,r:`2`}],[`circle`,{cx:`20`,cy:`12`,r:`2`}],[`circle`,{cx:`4`,cy:`12`,r:`2`}]],h=(e,t={})=>{let n=document.createElementNS(`http://www.w3.org/2000/svg`,e);for(let[e,r]of Object.entries(t))n.setAttribute(e,r);return n},g=e=>e===null?`-`:n(Date.now()-e),_={invocation:{stroke:`stroke-neutral-400`,fill:`fill-neutral-400`},callback:{stroke:`stroke-cyan-500`,fill:`fill-cyan-500`,dash:`6 5`},message:{stroke:`stroke-amber-500`,fill:`fill-amber-500`,dash:`2 5`}},v={scheduler:`border-violet-200 bg-violet-50 text-violet-700`,console:`border-indigo-200 bg-indigo-50 text-indigo-700`,task:`border-amber-200 bg-amber-50 text-amber-700`,process:`border-dashed border-neutral-300 bg-neutral-50 text-neutral-500`,streaming:`border-emerald-200 bg-white font-medium text-neutral-800`,idle:`border-neutral-200 bg-white font-medium text-neutral-600`},y=300,b=4;function x(e,t){let n,r=null,i=!1,a=!1,o=()=>{clearTimeout(n),n=setTimeout(()=>{i||a||!r||r.dataset.closing!==void 0||(f(),r=null)},120)};e.onpointerenter=s=>{s.pointerType!==`touch`&&(i=!0,clearTimeout(n),!(r?.isConnected&&r.dataset.closing===void 0)&&(n=setTimeout(()=>{!e.isConnected||!i||(r=d(e,t),r.onpointerenter=()=>{a=!0,clearTimeout(n)},r.onpointerleave=()=>{a=!1,o()})},y)))},e.onpointerleave=()=>{i=!1,o()},e.onpointerdown=()=>clearTimeout(n)}function S(e){let t,n=!1;e.classList.add(`cursor-grab`),e.onpointerdown=r=>{n=!1,!(r.button!==0||r.pointerType===`touch`||r.target.closest(`[data-node], path`))&&(t={x:r.clientX,y:r.clientY,left:e.scrollLeft,top:e.scrollTop},e.setPointerCapture(r.pointerId),e.classList.add(`cursor-grabbing`,`select-none`))},e.onpointermove=r=>{if(!t)return;let i=r.clientX-t.x,a=r.clientY-t.y;(Math.abs(i)>b||Math.abs(a)>b)&&(n=!0),e.scrollLeft=t.left-i,e.scrollTop=t.top-a};let r=()=>{t=void 0,e.classList.remove(`cursor-grabbing`,`select-none`)};e.onpointerup=r,e.onpointercancel=r,e.addEventListener(`click`,e=>{n&&(e.stopPropagation(),e.preventDefault())},!0)}function C(n,d,f){let y=`sessions`,b=`active`,C={sessions:[],runs:[],messages:[]},w=``,T=a(async()=>{let e=b,t=await u(`/api/activity?scope=${e}`,`Failed to load activity`);if(!t.ok){w=``,n.replaceChildren(s(`p`,`p-4 text-[13px] text-red-600`,t.error));return}if(e!==b)return;let r=JSON.stringify(t.value);if(`${e}:${r}`===w)return;w=`${e}:${r}`;let i=t.value;C={...i,sessions:i.sessions.map(e=>({...e,title:l(e.title)}))},E()});function E(){let e=o(i([[`Sessions`,`sessions`,p],[`Relationships`,`dependencies`,m]],y,e=>{y=e,E()}),s(`span`,`ml-auto text-[11.5px] text-neutral-400`,`${C.sessions.length} sessions · ${C.runs.length} runs`),i([[`Current`,`active`],[`Last 24h`,`recent`]],b,e=>{b=e,E(),T()})),t=s(`div`,`automation-list min-h-0 flex-1 overflow-auto`);y===`sessions`?D(t):k(t),n.replaceChildren(e,t)}function D(e){let t=document.createElement(`table`);t.className=`w-full table-fixed text-left text-[12.5px]`,t.innerHTML=`<thead class="sticky top-0 bg-neutral-50 text-[10.5px] uppercase tracking-wide text-neutral-400 shadow-[inset_0_-1px_0_var(--color-neutral-200)]"><tr>
1
+ import{C as e,G as t,J as n,O as r,P as i,Q as a,V as o,Y as s,Z as c,at as l,et as u,t as d,u as f}from"./index-CzV2cZEH.js";var p=[[`path`,{d:`M3 5h.01`}],[`path`,{d:`M3 12h.01`}],[`path`,{d:`M3 19h.01`}],[`path`,{d:`M8 5h13`}],[`path`,{d:`M8 12h13`}],[`path`,{d:`M8 19h13`}]],m=[[`path`,{d:`m10.586 5.414-5.172 5.172`}],[`path`,{d:`m18.586 13.414-5.172 5.172`}],[`path`,{d:`M6 12h12`}],[`circle`,{cx:`12`,cy:`20`,r:`2`}],[`circle`,{cx:`12`,cy:`4`,r:`2`}],[`circle`,{cx:`20`,cy:`12`,r:`2`}],[`circle`,{cx:`4`,cy:`12`,r:`2`}]],h=(e,t={})=>{let n=document.createElementNS(`http://www.w3.org/2000/svg`,e);for(let[e,r]of Object.entries(t))n.setAttribute(e,r);return n},g=e=>e===null?`-`:n(Date.now()-e),_={invocation:{stroke:`stroke-neutral-400`,fill:`fill-neutral-400`},callback:{stroke:`stroke-cyan-500`,fill:`fill-cyan-500`,dash:`6 5`},message:{stroke:`stroke-amber-500`,fill:`fill-amber-500`,dash:`2 5`}},v={scheduler:`border-violet-200 bg-violet-50 text-violet-700`,console:`border-indigo-200 bg-indigo-50 text-indigo-700`,task:`border-amber-200 bg-amber-50 text-amber-700`,process:`border-dashed border-neutral-300 bg-neutral-50 text-neutral-500`,streaming:`border-emerald-200 bg-white font-medium text-neutral-800`,idle:`border-neutral-200 bg-white font-medium text-neutral-600`},y=300,b=4;function x(e,t){let n,r=null,i=!1,a=!1,o=()=>{clearTimeout(n),n=setTimeout(()=>{i||a||!r||r.dataset.closing!==void 0||(f(),r=null)},120)};e.onpointerenter=s=>{s.pointerType!==`touch`&&(i=!0,clearTimeout(n),!(r?.isConnected&&r.dataset.closing===void 0)&&(n=setTimeout(()=>{!e.isConnected||!i||(r=d(e,t),r.onpointerenter=()=>{a=!0,clearTimeout(n)},r.onpointerleave=()=>{a=!1,o()})},y)))},e.onpointerleave=()=>{i=!1,o()},e.onpointerdown=()=>clearTimeout(n)}function S(e){let t,n=!1;e.classList.add(`cursor-grab`),e.onpointerdown=r=>{n=!1,!(r.button!==0||r.pointerType===`touch`||r.target.closest(`[data-node], path`))&&(t={x:r.clientX,y:r.clientY,left:e.scrollLeft,top:e.scrollTop},e.setPointerCapture(r.pointerId),e.classList.add(`cursor-grabbing`,`select-none`))},e.onpointermove=r=>{if(!t)return;let i=r.clientX-t.x,a=r.clientY-t.y;(Math.abs(i)>b||Math.abs(a)>b)&&(n=!0),e.scrollLeft=t.left-i,e.scrollTop=t.top-a};let r=()=>{t=void 0,e.classList.remove(`cursor-grabbing`,`select-none`)};e.onpointerup=r,e.onpointercancel=r,e.addEventListener(`click`,e=>{n&&(e.stopPropagation(),e.preventDefault())},!0)}function C(n,d,f){let y=`sessions`,b=`active`,C={sessions:[],runs:[],messages:[]},w=``,T=a(async()=>{let e=b,t=await u(`/api/activity?scope=${e}`,`Failed to load activity`);if(!t.ok){w=``,n.replaceChildren(s(`p`,`p-4 text-[13px] text-red-600`,t.error));return}if(e!==b)return;let r=JSON.stringify(t.value);if(`${e}:${r}`===w)return;w=`${e}:${r}`;let i=t.value;C={...i,sessions:i.sessions.map(e=>({...e,title:l(e.title)}))},E()});function E(){let e=o(i([[`Sessions`,`sessions`,p],[`Relationships`,`dependencies`,m]],y,e=>{y=e,E()}),s(`span`,`ml-auto text-[11.5px] text-neutral-400`,`${C.sessions.length} sessions · ${C.runs.length} runs`),i([[`Current`,`active`],[`Last 24h`,`recent`]],b,e=>{b=e,E(),T()})),t=s(`div`,`automation-list min-h-0 flex-1 overflow-auto`);y===`sessions`?D(t):k(t),n.replaceChildren(e,t)}function D(e){let t=document.createElement(`table`);t.className=`w-full table-fixed text-left text-[12.5px]`,t.innerHTML=`<thead class="sticky top-0 bg-neutral-50 text-[10.5px] uppercase tracking-wide text-neutral-400 shadow-[inset_0_-1px_0_var(--color-neutral-200)]"><tr>
2
2
  <th class="w-[50%] px-4 py-2 font-semibold md:w-[34%]">Session</th>
3
3
  <th class="hidden w-[38%] px-2 py-2 font-semibold md:table-cell">Project</th>
4
4
  <th class="px-2 py-2 font-semibold md:w-[14%]">State</th>
@@ -1 +1 @@
1
- import{$ as e,B as t,E as n,G as r,K as i,M as a,O as o,X as s,Y as c,et as l,it as u,rt as d,w as f}from"./index-BNjWeodx.js";var p=e=>e.public&&e.token?`/p/${e.slug}-${e.token}/`:`/boards/${e.slug}/`;function m(m,h){let g=[],_=``,v=c(`header`,`pagehead sticky top-[-8px] z-30 max-md:hidden`,a(`Boards`)),y=c(`div`,`px-4 pb-5 pt-1`);m.append(c(`div`,`min-h-0 flex-1 overflow-y-auto`,v,y));async function b(t,n){let r=await u(`/api/boards/${t.slug}`,{public:n},`PATCH`);if(r.ok)return t.token=(await r.json().catch(()=>({}))).token??``,w();_=await e(r,`Could not change ${t.slug}`),await T()}async function x(e){_=await d(`/api/boards/${e}`,`DELETE`,`Could not delete ${e}`)??``,await T()}function S(e){let n=c(`div`,`group flex flex-col gap-1 py-2.5`),r=document.createElement(`a`);r.className=`min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800 hover:text-indigo-700 max-md:basis-full`,r.href=p(e),r.target=`_blank`,r.rel=`noreferrer`,r.textContent=e.title;let a=c(`div`,`flex flex-wrap items-center gap-x-2 gap-y-1`,r),o=t(``,``,e.public,t=>{e.public=t,b(e,t)});o.className=`flex flex-none cursor-pointer items-center gap-1.5 text-[11.5px] text-neutral-500`,o.append(c(`span`,``,`Public`));let l=f(`Delete`,`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-red-600 group-hover:opacity-100 pointer-coarse:opacity-100`);l.title=`Renames the folder on disk; nothing is erased`,l.onclick=()=>void x(e.slug);let u=i(`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-indigo-600 group-hover:opacity-100 pointer-coarse:opacity-100`,()=>`${location.origin}${p(e)}`);u.title=`Copy the board's link`,a.append(c(`div`,`ml-auto flex flex-none items-center gap-2`,o,u,l)),n.append(a);let d=c(`div`,`flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-neutral-500`);d.append(c(`span`,`min-w-0 break-all font-mono text-neutral-400`,e.slug)),e.description&&d.append(c(`span`,`min-w-0 truncate`,e.description)),d.append(c(`span`,`flex-none text-neutral-400`,s(Date.parse(e.updatedAt))));for(let t of e.sessions){let e=f(t.slice(0,8),`flex-none cursor-pointer font-mono text-indigo-600 hover:underline`);e.title=`Open session ${t}`,e.onclick=()=>h(t),d.append(e)}if(e.public&&e.token){let t=c(`span`,`min-w-0 break-all font-mono text-neutral-400`,p(e));t.title=`Public URL — anyone holding this link can read the board, no password`,d.append(t)}return n.append(d),n}let C=[{title:`Public`,hint:`Readable at /p/<slug>-<token>/ by anyone holding the link.`,wanted:!0},{title:`Private`,hint:`Reachable from the Console only.`,wanted:!1}];function w(){let e=c(`div`,`mx-auto flex w-full max-w-3xl flex-col gap-4`);g.length===0&&e.append(o(`No boards yet. Ask an agent to build one — it writes a folder under ~/.pier/boards.`));for(let{title:t,hint:r,wanted:i}of C){let a=g.filter(e=>e.public===i);a.length!==0&&e.append(n(t,r,c(`div`,`flex flex-col divide-y divide-neutral-200/70`,...a.map(S))))}y.replaceChildren(..._?[c(`p`,`mb-3 text-[13px] text-red-600`,_)]:[],e)}async function T(){let e=await l(`/api/boards`,`Could not load boards`);if(!e.ok)return _=e.error,w();g=e.value,w(),_=``}let E=r(m,()=>void T());return window.addEventListener(`focus`,()=>{E.visible&&T()}),E}export{m as createBoardsView};
1
+ import{$ as e,B as t,E as n,G as r,K as i,M as a,O as o,X as s,Y as c,et as l,it as u,rt as d,w as f}from"./index-CzV2cZEH.js";var p=e=>e.public&&e.token?`/p/${e.slug}-${e.token}/`:`/boards/${e.slug}/`;function m(m,h){let g=[],_=``,v=c(`header`,`pagehead sticky top-[-8px] z-30 max-md:hidden`,a(`Boards`)),y=c(`div`,`px-4 pb-5 pt-1`);m.append(c(`div`,`min-h-0 flex-1 overflow-y-auto`,v,y));async function b(t,n){let r=await u(`/api/boards/${t.slug}`,{public:n},`PATCH`);if(r.ok)return t.token=(await r.json().catch(()=>({}))).token??``,w();_=await e(r,`Could not change ${t.slug}`),await T()}async function x(e){_=await d(`/api/boards/${e}`,`DELETE`,`Could not delete ${e}`)??``,await T()}function S(e){let n=c(`div`,`group flex flex-col gap-1 py-2.5`),r=document.createElement(`a`);r.className=`min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800 hover:text-indigo-700 max-md:basis-full`,r.href=p(e),r.target=`_blank`,r.rel=`noreferrer`,r.textContent=e.title;let a=c(`div`,`flex flex-wrap items-center gap-x-2 gap-y-1`,r),o=t(``,``,e.public,t=>{e.public=t,b(e,t)});o.className=`flex flex-none cursor-pointer items-center gap-1.5 text-[11.5px] text-neutral-500`,o.append(c(`span`,``,`Public`));let l=f(`Delete`,`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-red-600 group-hover:opacity-100 pointer-coarse:opacity-100`);l.title=`Renames the folder on disk; nothing is erased`,l.onclick=()=>void x(e.slug);let u=i(`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-indigo-600 group-hover:opacity-100 pointer-coarse:opacity-100`,()=>`${location.origin}${p(e)}`);u.title=`Copy the board's link`,a.append(c(`div`,`ml-auto flex flex-none items-center gap-2`,o,u,l)),n.append(a);let d=c(`div`,`flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-neutral-500`);d.append(c(`span`,`min-w-0 break-all font-mono text-neutral-400`,e.slug)),e.description&&d.append(c(`span`,`min-w-0 truncate`,e.description)),d.append(c(`span`,`flex-none text-neutral-400`,s(Date.parse(e.updatedAt))));for(let t of e.sessions){let e=f(t.slice(0,8),`flex-none cursor-pointer font-mono text-indigo-600 hover:underline`);e.title=`Open session ${t}`,e.onclick=()=>h(t),d.append(e)}if(e.public&&e.token){let t=c(`span`,`min-w-0 break-all font-mono text-neutral-400`,p(e));t.title=`Public URL — anyone holding this link can read the board, no password`,d.append(t)}return n.append(d),n}let C=[{title:`Public`,hint:`Readable at /p/<slug>-<token>/ by anyone holding the link.`,wanted:!0},{title:`Private`,hint:`Opened from here; the address that opens is a temporary link, good for 8 hours.`,wanted:!1}];function w(){let e=c(`div`,`mx-auto flex w-full max-w-3xl flex-col gap-4`);g.length===0&&e.append(o(`No boards yet. Ask an agent to build one — it writes a folder under ~/.pier/boards.`));for(let{title:t,hint:r,wanted:i}of C){let a=g.filter(e=>e.public===i);a.length!==0&&e.append(n(t,r,c(`div`,`flex flex-col divide-y divide-neutral-200/70`,...a.map(S))))}y.replaceChildren(..._?[c(`p`,`mb-3 text-[13px] text-red-600`,_)]:[],e)}async function T(){let e=await l(`/api/boards`,`Could not load boards`);if(!e.ok)return _=e.error,w();g=e.value,w(),_=``}let E=r(m,()=>void T());return window.addEventListener(`focus`,()=>{E.visible&&T()}),E}export{m as createBoardsView};
@@ -1,4 +1,4 @@
1
- import{G as e,M as t,O as n,W as r,X as i,Y as a,a as o,ct as s,f as c,g as l,h as u,l as d,m as f,mt as p,p as m,pt as ee,q as te,st as h,tt as g,u as _,v as ne,w as re,y as ie}from"./index-BNjWeodx.js";var ae=[[`path`,{d:`M12 5v14`}],[`path`,{d:`m19 12-7 7-7-7`}]],oe=[[`path`,{d:`M12 22v-6`}],[`path`,{d:`M12 8V2`}],[`path`,{d:`M4 12H2`}],[`path`,{d:`M10 12H8`}],[`path`,{d:`M16 12h-2`}],[`path`,{d:`M22 12h-2`}],[`path`,{d:`m15 19-3-3-3 3`}],[`path`,{d:`m15 5-3 3-3-3`}]],se=[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`}]],ce=[[`path`,{d:`M15 6a9 9 0 0 0-9 9V3`}],[`circle`,{cx:`18`,cy:`6`,r:`3`}],[`circle`,{cx:`6`,cy:`18`,r:`3`}]],v=[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`}],[`path`,{d:`M3 3v5h5`}]],y=e=>{let t=new Date(e),n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`},le=e=>[e.subject,e.body,[[e.author,e.email?`<${e.email}>`:``].filter(Boolean).join(` `),e.at?y(e.at):``].filter(Boolean).join(` · `)].filter(Boolean).join(`
1
+ import{G as e,M as t,O as n,W as r,X as i,Y as a,a as o,ct as s,f as c,g as l,h as u,l as d,m as f,mt as p,p as m,pt as ee,q as te,st as h,tt as g,u as _,v as ne,w as re,y as ie}from"./index-CzV2cZEH.js";var ae=[[`path`,{d:`M12 5v14`}],[`path`,{d:`m19 12-7 7-7-7`}]],oe=[[`path`,{d:`M12 22v-6`}],[`path`,{d:`M12 8V2`}],[`path`,{d:`M4 12H2`}],[`path`,{d:`M10 12H8`}],[`path`,{d:`M16 12h-2`}],[`path`,{d:`M22 12h-2`}],[`path`,{d:`m15 19-3-3-3 3`}],[`path`,{d:`m15 5-3 3-3-3`}]],se=[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`}]],ce=[[`path`,{d:`M15 6a9 9 0 0 0-9 9V3`}],[`circle`,{cx:`18`,cy:`6`,r:`3`}],[`circle`,{cx:`6`,cy:`18`,r:`3`}]],v=[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`}],[`path`,{d:`M3 3v5h5`}]],y=e=>{let t=new Date(e),n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`},le=e=>[e.subject,e.body,[[e.author,e.email?`<${e.email}>`:``].filter(Boolean).join(` `),e.at?y(e.at):``].filter(Boolean).join(` · `)].filter(Boolean).join(`
2
2
 
3
3
  `),b=null;function x(){b&&(b.remove(),b=null,document.removeEventListener(`pointerdown`,x,!0),window.removeEventListener(`scroll`,x,!0))}function ue(e,t){e.onmouseenter=()=>{x();let n=t();if(!n)return;b=a(`div`,`pointer-events-none fixed z-50 max-w-96 whitespace-pre-wrap rounded-lg bg-neutral-800/95 px-3 py-2 font-sans text-[11.5px] leading-snug text-neutral-100 shadow-lg`),b.textContent=n,document.body.append(b),document.addEventListener(`pointerdown`,x,!0),window.addEventListener(`scroll`,x,!0);let r=e.getBoundingClientRect();b.style.left=`${Math.max(8,Math.min(r.left,window.innerWidth-b.offsetWidth-8))}px`,b.style.top=r.bottom+6+b.offsetHeight>window.innerHeight?`${r.top-b.offsetHeight-6}px`:`${r.bottom+6}px`},e.onmouseleave=x}function S(e,t,n){let r=a(`button`,`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left hover:bg-neutral-100`,h(ee,`h-3 w-3 text-indigo-600 ${e.value===t?``:`invisible`}`),a(`span`,`inline-flex flex-none items-center gap-1.5 font-mono text-[11.5px] text-neutral-800`,e.label??e.value));return e.subject&&r.append(a(`span`,`min-w-0 truncate text-[11px] text-neutral-400`,e.subject)),e.author&&r.append(a(`span`,`ml-auto flex-none pl-1.5 text-[10.5px] text-neutral-400`,`${e.author} · ${e.at?i(e.at):``}`)),ue(r,()=>le(e)),r.onclick=()=>n(e.value),r}function de(e,t,n,r,i){let{base:o,head:s}=n,l=()=>s&&o===`${s}~1`?s:``,u=l()?`commit`:`compare`,d=t.commits[0],f=e=>[...e?[{value:``,label:`Working tree`,subject:`the files on disk, uncommitted changes included`}]:[],{...d,value:`HEAD`},...t.refs.map(e=>({value:e.name,subject:e.subject})),...t.commits.map(e=>({...e,value:e.hash}))],p=a(`div`,`flex w-[38rem] max-w-full flex-col`);function m(){let e=(e,t)=>{let n=a(`button`,`cursor-pointer rounded-full px-2.5 py-0.5 text-[11.5px] ${u===t?`bg-indigo-50 font-medium text-indigo-700`:`text-neutral-500 hover:bg-neutral-100`}`,e);return n.onclick=()=>{u=t,m()},n},n=(e,t,n,r)=>a(`div`,`flex min-w-0 flex-1 flex-col`,a(`div`,`flex-none px-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-neutral-400`,e),a(`div`,`min-h-0 flex-1 overflow-y-auto`,...t.map(e=>S(e,n,r)))),c=u===`compare`?a(`div`,`flex h-72 min-h-0 divide-x divide-neutral-200 pb-1`,n(`base`,f(!1),o,e=>{o=e,m(),r(o,s)}),n(`to`,f(!0),s,e=>{s=e,m(),r(o,s)})):a(`div`,`flex h-72 min-h-0 flex-col overflow-y-auto pb-1`,...t.commits.map(e=>S({value:e.hash,...e},l(),e=>{o=`${e}~1`,s=e,_(),r(o,s)}))),d=a(`div`,`flex-none border-t border-neutral-200`,S({value:i,label:`${i} ↔ Working tree`,subject:`back to the default: the main line's tip vs the files on disk`},o===i&&s===``?i:``,()=>{o=i,s=``,m(),r(o,s)}));d.querySelector(`button > span`).prepend(h(v)),p.replaceChildren(a(`div`,`flex flex-none items-center gap-1 border-b border-neutral-200 px-2 py-1.5`,e(`Compare two points`,`compare`),e(`One commit`,`commit`)),c,...u===`compare`?[d]:[])}m(),c(e,p)}var fe=/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i,C={A:`text-emerald-600`,M:`text-amber-600`,D:`text-red-600`},pe=30,w=`pier.filesPrefs`,T=50,E=()=>{try{let e=JSON.parse(localStorage.getItem(w)??`{}`);return e&&typeof e==`object`?e:{}}catch{return{}}},me=e=>{let t=e?E()[e]:void 0;return typeof t?.cwd==`string`&&typeof t.base==`string`&&typeof t.head==`string`?{cwd:t.cwd,base:t.base,head:t.head}:null},he=(e,t)=>{let n=E();delete n[e],n[e]=t;let r=Object.keys(n);for(let e of r.slice(0,Math.max(0,r.length-T)))delete n[e];try{localStorage.setItem(w,JSON.stringify(n))}catch(e){console.warn(`Files: could not remember this folder`,e)}},ge=`flex min-w-0 flex-none items-center gap-1.5 rounded-full px-2.5 py-1 font-mono text-[11.5px]`,_e=(e,t=`indigo`)=>re(e,`${ge} cursor-pointer truncate text-left ${t===`indigo`?`bg-indigo-50 font-medium text-indigo-700 hover:bg-indigo-100`:`bg-neutral-100 text-neutral-600 hover:bg-neutral-200`}`);function D(i,c,ee,_){let v=``,y=``,b={branch:null,refs:[],commits:[],worktrees:[]},x=`HEAD`,S=``,w=new Map,T=!0,E=!1,D=[],O=new Set,k=null,A=null,j=null,ve=a(`header`,`pagehead flex-nowrap`),M=a(`div`,`${l} flex-col items-stretch gap-1.5 px-3 py-2.5`),N=a(`div`,`min-h-0 flex-1 overflow-y-auto px-1.5 py-1.5`),ye=a(`aside`,`${ie} w-64 flex-none text-[12.5px] leading-5 max-md:h-1/2 max-md:w-full`,M,N),P=a(`div`,`${l} flex-wrap gap-2 px-4 py-2.5 font-mono text-[11.5px] text-neutral-500`),F=a(`div`,`h-full min-w-0 overflow-auto`),I=a(`div`,`pointer-events-none absolute inset-y-1 right-0.5 z-20 hidden w-2`),be=a(`div`,`relative min-h-0 flex-1`,F,I),xe=a(`section`,`${ie} min-h-0 min-w-0 flex-1`,P,be);i.append(ve,a(`div`,`flex min-h-0 flex-1 gap-3 px-4 pb-4 pt-1 max-md:flex-col`,ye,xe));let L=(e,t)=>`/api/explorer/${e}?${new URLSearchParams({root:v,...t})}`,R=(e,t)=>`/api/fs/${e}?${new URLSearchParams({root:v,...t})}`,z=(e,t=`text-neutral-400`)=>a(`p`,`px-3 py-2 text-[12.5px] ${t}`,e),B=e=>a(`div`,`flex h-full items-center justify-center p-6`,n(e)),Se=()=>T&&b.branch!==null,Ce=e=>{let t=0;for(let n of w.keys())n.startsWith(`${e}/`)&&t++;return t};function we(e,t){let n=new Set(t.map(e=>e.name)),r=e?`${e}/`:``;for(let[e,i]of w){if(i.status!==`D`||!e.startsWith(r))continue;let a=e.slice(r.length).split(`/`)[0];n.has(a)||(n.add(a),t.push({name:a,dir:e.slice(r.length).includes(`/`)}))}let i=t;return Se()&&(i=t.filter(e=>e.dir?Ce(`${r}${e.name}`)>0:w.has(`${r}${e.name}`))),i.sort((e,t)=>Number(t.dir)-Number(e.dir)||e.name.localeCompare(t.name)),i.map(e=>e.dir?Ee(`${r}${e.name}`,e.name):De(`${r}${e.name}`,e.name))}async function Te(e,t){try{let{entries:n}=await g(R(`ls`,{path:t}),`could not list this folder`);e.replaceChildren(...we(t,n)),e.childElementCount||e.append(z(Se()?`No changes.`:`Empty.`))}catch(n){let r=we(t,[]);r.length?e.replaceChildren(...r):e.replaceChildren(z(String(n),`text-red-600`))}}function Ee(e,t){let n=Ce(e),r=[a(`span`,`truncate`,t)];n&&r.push(a(`span`,`ml-auto flex-none rounded-full bg-amber-100/80 px-1.5 font-mono text-[10px] font-semibold text-amber-700`,String(n)));let{el:i,summary:o}=te(``,r);o.className+=` rounded-lg px-2 py-1 transition-colors hover:bg-neutral-100`;let s=a(`div`,`pl-3`);i.append(s);let c=!1,l=()=>{c||(c=!0,s.append(z(`…`)),Te(s,e))};return i.ontoggle=()=>{O[i.open?`add`:`delete`](e),i.open&&l()},((b.branch?T:!e.includes(`/`))&&!E||O.has(e))&&(i.open=!0,l()),i}function V(e){A?.classList.remove(`bg-indigo-50`,`font-medium`,`text-indigo-700`),A=e,e.classList.add(`bg-indigo-50`,`font-medium`,`text-indigo-700`)}function De(e,t){let n=w.get(e)?.status,r=a(`button`,`flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 pl-6 pr-2 text-left transition-colors hover:bg-neutral-100`,a(`span`,`truncate`,t));return n&&r.append(a(`span`,`ml-auto flex-none font-mono text-[10.5px] font-semibold ${C[n]??`text-neutral-500`}`,n)),r.title=e,e===k&&V(r),e===j&&(j=null,k=e,V(r),J(e)),r.onclick=()=>{V(r),k=e,J(e)},r}let H=()=>void Te(N,``),U=(e,t)=>[...e?[a(`span`,`flex-none font-mono text-[10.5px] font-semibold text-emerald-600`,`+${e}`)]:[],...t?[a(`span`,`flex-none font-mono text-[10.5px] font-semibold text-red-600`,`−${t}`)]:[]];function W(e,t=!1,n){if(P.classList.toggle(`hidden`,!e),P.classList.toggle(`flex`,!!e),!e)return P.replaceChildren();let i=w.get(e),o=[a(`span`,`min-w-0 truncate`,e)];i&&(o.push(a(`span`,`flex-none font-semibold ${C[i.status]??``}`,i.status)),o.push(...U(i.add,i.del)));let s=a(`div`,`ml-auto flex flex-none items-center gap-2`);if(n&&s.append(n),t){let t=document.createElement(`a`);t.className=`btn flex-none text-[12px]`,t.href=R(`file`,{path:e}),t.download=r(e),t.textContent=`Download`,s.append(t)}P.replaceChildren(...o,s)}function Oe(e){let t=0;for(;t<e.length;){if(e[t].tone!==`del`){t++;continue}let n=t;for(;t<e.length&&e[t].tone===`del`;)t++;let r=t;for(;t<e.length&&e[t].tone===`add`;)t++;let i=Math.min(r-n,t-r);for(let t=0;t<i;t++){let i=e[n+t],a=e[r+t],o=Math.min(i.text.length,a.text.length),s=0;for(;s<o&&i.text[s]===a.text[s];)s++;let c=0;for(;c<o-s&&i.text[i.text.length-1-c]===a.text[a.text.length-1-c];)c++;s+c!==0&&(i.mark=[s,i.text.length-c],a.mark=[s,a.text.length-c])}}}let G=Ie();function K(){I.classList.add(`hidden`),I.replaceChildren(),G.set([],[])}let ke=0,q=e=>e===ke;function J(e){let t=++ke;return w.has(e)?Le(e,t):Ae(e,t)}async function Ae(e,t){if(!q(t))return;K();let n=R(`file`,{path:e}),r=a(`div`,`min-w-0`);if(F.classList.remove(`flex`,`flex-col`),W(e,!0),F.replaceChildren(r),fe.test(e)){let e=a(`img`,`max-w-full p-4`);e.src=n,e.onerror=()=>r.replaceChildren(z(`Could not load image.`,`text-red-600`)),r.append(e);return}if(e.toLowerCase().endsWith(`.pdf`)){let e=document.createElement(`iframe`);e.className=`h-full w-full`,e.src=n,F.replaceChildren(e),F.classList.add(`flex`,`flex-col`);return}r.append(z(`…`));let i=await fetch(n);if(!q(t))return;if(!i.ok){let{error:e}=await i.json().catch(()=>({}));r.replaceChildren(z(e??`HTTP ${i.status}`,`text-red-600`));return}if(!(i.headers.get(`content-type`)??``).startsWith(`text/plain`)){r.replaceChildren(z(`Binary file — use Download.`));return}let o=f(await i.text());if(!q(t))return;let s=await u(e);q(t)&&r.replaceChildren(m(o,s))}function je(e){let t=[],n=0,r=0,i=!1,a=e.split(`
4
4
  `);a.at(-1)===``&&a.pop();for(let e of a){let a=/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(e);if(a){i&&t.push({nums:[``,``],text:`⋯`,tone:``}),[n,r]=[Number(a[1]),Number(a[2])],i=!0;continue}!i||e.startsWith(`\\`)||(e.startsWith(`+`)?t.push({nums:[``,r++],text:e.slice(1),tone:`add`}):e.startsWith(`-`)?t.push({nums:[n++,``],text:e.slice(1),tone:`del`}):t.push({nums:[n++,r++],text:e.slice(1),tone:``}))}return t}function Me(e){let t=[];for(let n=0;n<e.length;n++){let r=e[n].tone;if(!r)continue;let i=t.at(-1);i&&i.end===n-1?(i.end=n,(r===`add`&&i.tone===`del`||r===`del`&&i.tone===`add`)&&(i.tone=`mixed`)):t.push({start:n,end:n,tone:r})}return t}let Ne={add:`bg-emerald-400`,del:`bg-red-400`,mixed:`bg-amber-400`};function Pe(e,t,n){I.replaceChildren(...e.map(e=>{let r=a(`button`,`map-mark pointer-events-auto absolute left-1/2 w-1 -translate-x-1/2 cursor-pointer rounded-full ${Ne[e.tone]}`);return r.style.top=`${e.start/t*100}%`,r.style.height=`${Math.max(.4,(e.end-e.start+1)/t*100)}%`,r.title=`line ${Fe(n,e.start)}`,r.onclick=()=>n[e.start].scrollIntoView({block:`center`}),r})),I.classList.remove(`hidden`)}let Fe=(e,t)=>e[t]?.querySelector(`span`)?.textContent?.trim()||String(t+1);function Ie(){let e=[],t=[],n=-1,r=a(`span`,`flex-none text-[10.5px] text-neutral-400`),s=i=>{e.length&&(n=(n+i+e.length)%e.length,t[e[n].start].scrollIntoView({block:`center`}),r.textContent=`${n+1}/${e.length}`)},c=()=>e.length>0&&!i.classList.contains(`hidden`),l=(e,t,n,r)=>{let i=a(`button`,`icon-btn flex-none`,h(e));return i.onclick=()=>s(t),o(i,r,n,()=>s(t),c),i};return{el:a(`div`,`flex flex-none items-center gap-1`,r,l(p,-1,`Previous change`,[`p`,`k`]),l(ae,1,`Next change`,[`n`,`j`])),set(i,a){e=i,t=a,n=-1,r.textContent=`${i.length} change${i.length===1?``:`s`}`}}}async function Le(e,t){K(),F.classList.remove(`flex`,`flex-col`);let n=w.get(e)?.status!==`D`;W(e,n),F.replaceChildren(z(`…`));try{let{diff:r}=await g(L(`diff`,{base:x,head:S,file:e,context:`99999`}),`could not read this diff`);if(!q(t))return;if(!r||/^Binary files /m.test(r))return Ae(e,t);let i=je(r);Oe(i);let a=await u(e);if(!q(t))return;let o=m(i,a),s=[...o.children],c=Me(i);G.set(c,s),W(e,n,G.el),F.replaceChildren(o),Pe(c,i.length,s)}catch(n){if(!q(t))return;W(e),F.replaceChildren(z(String(n),`text-red-600`))}}let Y=()=>S&&x===`${S}~1`?S:``,Re=()=>b.commits.find(e=>e.hash===Y())?.subject??``,ze=()=>Y()?`${Y()} — ${Re()||`commit`}`:`${x} ↔ ${S||`working tree`}`,Be=(e,t,n,r)=>{let i=a(`button`,`icon-btn ${n?`bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-700`:``}`);return i.append(h(e)),i.title=t,i.setAttribute(`aria-label`,t),i.setAttribute(`aria-pressed`,String(n)),i.onclick=r,i};function X(){let e=Be(se,`Show only files that differ`,T,()=>{T=!T,X(),H()}),t=Be(oe,E?`Restore folders`:`Collapse all folders`,E,()=>{if(E=!E,E)D=[...O],O.clear();else for(let e of D)O.add(e);X(),H()});if(!b.branch){M.replaceChildren(a(`div`,`flex items-center gap-1.5`,a(`span`,`min-w-0 flex-1 text-[11px] leading-snug text-neutral-400`,`Not a git repository — nothing to compare.`),t));return}let n=re(ze(),`${ne} font-mono`);if(Y()){let e=b.commits.find(e=>e.hash===Y());ue(n,()=>e?le(e):``)}else n.title=`Choose what to diff`;n.onclick=async()=>{await We(),de(n,b,{base:x,head:S},(e,t)=>{x=e,S=t,Z()},$())};let[r,i]=[0,0];for(let e of w.values())r+=e.add,i+=e.del;M.replaceChildren(n,a(`div`,`flex items-center gap-1.5 pl-0.5`,a(`span`,`text-[11px] text-neutral-400`,`${w.size} changed file${w.size===1?``:`s`}`),...U(r,i),a(`span`,`ml-auto`),e,t))}let Ve=()=>{y&&v&&he(y,{cwd:v,base:x,head:S})},He=e=>{let t=e.replace(/~\d+$/,``);return!t||t===`HEAD`||b.refs.some(e=>e.name===t)||b.commits.some(e=>e.hash===t)};async function Ue(){if(w=new Map,!b.branch)return;let{files:e}=await g(L(`diff`,{base:x,head:S}),`could not read what changed`);for(let t of e)w.set(t.path,{status:t.status,add:t.add,del:t.del});if(w.size<=pe)for(let e of w.keys()){let t=e.split(`/`);for(let e=1;e<t.length;e++)O.add(t.slice(0,e).join(`/`))}}async function Z(){Ve();try{await Ue()}catch(e){X(),N.replaceChildren(z(String(e),`text-red-600`));return}X(),H(),k&&J(k)}function Q(){let e=_e(``,`neutral`);e.className+=` max-w-72`,e.append(a(`span`,`truncate max-md:hidden`,v||`Choose a folder…`),a(`span`,`truncate md:hidden`,v?r(v):`Choose a folder…`)),e.title=v?`${v} — switch folder`:`Choose a folder`,e.onclick=()=>d(e,b.worktrees.map(e=>({path:e.path,...e.branch?{hint:e.branch}:{}})),v||void 0,ee);let n=a(`button`,`icon-btn ml-auto`,h(s));n.type=`button`,n.title=`Close Files`,n.setAttribute(`aria-label`,`Close Files`),n.onclick=_;let i=b.branch?[a(`span`,`${ge} bg-neutral-100 text-neutral-600`,h(ce),a(`span`,`truncate`,b.branch))]:[];ve.replaceChildren(t(`Files`),e,...i,n)}async function We(){if(v)try{b=await g(L(`git`,{}),`could not read this repository`),Q()}catch(e){F.replaceChildren(B(String(e)))}}let $=()=>b.refs.some(e=>e.name===`main`)?`main`:b.refs.some(e=>e.name===`master`)?`master`:`HEAD`;async function Ge(e){if(Q(),k=null,A=null,O.clear(),j=e||null,e){T=!1;let t=e.split(`/`);for(let e=1;e<t.length;e++)O.add(t.slice(0,e).join(`/`))}if(K(),F.classList.remove(`flex`,`flex-col`),W(),F.replaceChildren(B(`Select a file.`)),!v){M.replaceChildren(),N.replaceChildren(),F.replaceChildren(B(`No folder yet — pick one from the chip above.`));return}try{b=await g(L(`git`,{}),`could not read this repository`)}catch(e){b={branch:null,refs:[],commits:[],worktrees:[]},F.replaceChildren(B(String(e)))}let t=me(y),n=t&&t.cwd===v&&He(t.base)&&He(t.head)?t:null;x=n?.base??$(),S=n?.head??``,Q(),await Z()}return e(i,(e,t)=>{let n=c(),r=n?.id??``,i=new URLSearchParams(t).get(`select`)??void 0,a=e?.startsWith(`/`)?e:(r===y?v:``)||me(r)?.cwd||n?.cwd||``;if(a===v&&r===y&&N.childElementCount&&(!i||i===k)){Q(),We().then(Z);return}y=r,v=a,Ge(i)},()=>{K(),F.classList.remove(`flex`,`flex-col`),W(),F.replaceChildren(B(`Select a file.`))})}export{D as createExplorerView};