@timqi/pier 0.0.9 → 0.0.15

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/agent/events.js +53 -7
  2. package/dist/agent/listing.js +253 -0
  3. package/dist/agent/pi.js +177 -28
  4. package/dist/boards/boards.js +65 -16
  5. package/dist/boards/pier.css +1 -1
  6. package/dist/channels/attach.js +87 -0
  7. package/dist/channels/control.js +2 -2
  8. package/dist/channels/lark-api.js +38 -0
  9. package/dist/channels/lark-outbound.js +11 -2
  10. package/dist/channels/slack-api.js +36 -0
  11. package/dist/channels/slack-outbound.js +12 -2
  12. package/dist/channels/slack-tool.js +49 -9
  13. package/dist/channels/telegram-api.js +21 -2
  14. package/dist/channels/telegram.js +23 -8
  15. package/dist/cli.js +34 -0
  16. package/dist/core/identity.js +18 -0
  17. package/dist/core/inbound-file.js +3 -1
  18. package/dist/core/reply.js +2 -1
  19. package/dist/core/router.js +72 -0
  20. package/dist/db.js +78 -0
  21. package/dist/extensions/index.js +5 -2
  22. package/dist/extensions/web/artifacts.js +7 -2
  23. package/dist/extensions/web/tools.js +28 -8
  24. package/dist/limits.js +14 -0
  25. package/dist/main.js +47 -6
  26. package/dist/paths.js +6 -1
  27. package/dist/settings.js +44 -0
  28. package/dist/tasks/agent.js +18 -4
  29. package/dist/tasks/callbacks.js +20 -1
  30. package/dist/tasks/definitions.js +56 -12
  31. package/dist/tasks/execution.js +5 -1
  32. package/dist/tasks/groups.js +4 -4
  33. package/dist/tasks/messages.js +4 -2
  34. package/dist/tasks/runs.js +2 -2
  35. package/dist/tasks/service.js +16 -6
  36. package/dist/tasks/tool.js +0 -12
  37. package/dist/tools-task.js +155 -0
  38. package/dist/tools.js +875 -0
  39. package/dist/web/auth.js +5 -3
  40. package/dist/web/explorer.js +15 -2
  41. package/dist/web/files.js +1 -1
  42. package/dist/web/instance.js +165 -36
  43. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-xcUrfRRs.js} +1 -1
  44. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  45. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  46. package/dist/web/public/index.html +5 -8
  47. package/dist/web/public/sw.js +4 -0
  48. package/dist/web/push.js +22 -7
  49. package/dist/web/repos.js +75 -0
  50. package/dist/web/server.js +145 -64
  51. package/dist/web/session-state.js +33 -51
  52. package/dist/web/types.js +5 -0
  53. package/package.json +1 -1
  54. package/skills/pier-boards/SKILL.md +23 -13
  55. package/skills/pier-help/SKILL.md +1 -1
  56. package/skills/pier-slack/SKILL.md +21 -1
  57. package/skills/pier-tasks/SKILL.md +2 -2
  58. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  59. package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
package/dist/agent/pi.js CHANGED
@@ -6,14 +6,29 @@ import { inlineExtensions } from "../extensions/index.js";
6
6
  import { logger } from "../log.js";
7
7
  import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
8
8
  import { defaultAgentDir, PiConfigStore } from "./config.js";
9
+ import { IndexedListing } from "./listing.js";
9
10
  import { curateModels, pinFirst } from "./models.js";
10
11
  const log = logger("agent");
12
+ /** A listed record as the seam reports it. The one mapping, because `list` and
13
+ * `find` answer with the same shape and drifting would mean two answers about
14
+ * one session. */
15
+ const summaryOf = (s) => ({
16
+ id: s.id,
17
+ cwd: s.cwd,
18
+ createdAt: s.created,
19
+ modified: s.modified,
20
+ ...(s.title ? { title: s.title } : {}),
21
+ });
11
22
  /** Pi's bash tool has no default timeout, so a hung command holds the turn
12
23
  * until someone aborts it — nobody is watching in a scheduled task. Kept below
13
24
  * the default task-run timeout so a stuck command comes back as
14
25
  * a tool error the agent can retry with an explicit longer timeout, instead of
15
26
  * killing the whole run. */
16
27
  const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
28
+ /** How long a session listing stays usable: long enough that one workspace
29
+ * event, which several surfaces answer at once, scans disk once; short enough
30
+ * that a title no invalidation covers is never stale on screen. */
31
+ const LIST_TTL_MS = 3_000;
17
32
  /** A probe nobody is watching is a hung page: the Console waits on this. */
18
33
  const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
19
34
  /** An ordinary budget, not a token: a 1-token cap is a request no real turn
@@ -68,11 +83,21 @@ export const standDownShadowed = (base) => {
68
83
  export class PiSession {
69
84
  pi;
70
85
  pinned;
86
+ wrote;
87
+ retention;
71
88
  constructor(pi,
72
89
  /** Operator pins, read per call — the menu can change while we run. */
73
- pinned = () => []) {
90
+ pinned = () => [],
91
+ /** "What I just wrote is not in your listing yet." The factory retains a
92
+ * scan for a few seconds, which is exactly the window a rename lands in:
93
+ * every surface would re-read the old title and keep it until some
94
+ * unrelated event moved the list again. Same drop `create` and `fork`
95
+ * do — a callback only because the session is what knows it happened. */
96
+ wrote = () => { }, retention = { value: "long" }) {
74
97
  this.pi = pi;
75
98
  this.pinned = pinned;
99
+ this.wrote = wrote;
100
+ this.retention = retention;
76
101
  }
77
102
  /** Pi's dispose unhooks the one listener that persists and emits, so a turn
78
103
  * started after it runs for real — model call, tools and all — and lands
@@ -128,6 +153,9 @@ export class PiSession {
128
153
  setThinkingLevel(level) {
129
154
  this.pi.setThinkingLevel(level);
130
155
  }
156
+ setCacheRetention(retention) {
157
+ this.retention.value = retention;
158
+ }
131
159
  async pendingQueue() {
132
160
  return {
133
161
  steering: [...this.pi.getSteeringMessages()],
@@ -158,11 +186,73 @@ export class PiSession {
158
186
  if (cancelled)
159
187
  throw new Error("rewind cancelled");
160
188
  }
189
+ /** The compaction running right now, or null. Pi keeps no lock of its own —
190
+ * a second `compact()` aborts the first's turn and summarizes a transcript
191
+ * that is being replaced under it — and two POSTs a millisecond apart both
192
+ * pass the route's idle check, so the gate has to be here. */
193
+ compacting = null;
194
+ /** Pi's own compaction, minus its `CompactionResult`: the numbers reach
195
+ * surfaces as the `context-compacted` event the seam already emits for the
196
+ * automatic one, so a caller has nothing to do with them. Refused while one
197
+ * is running, rather than run twice over one context. */
198
+ async compact() {
199
+ this.live();
200
+ if (this.compacting)
201
+ throw new Error(`session ${this.pi.sessionId} is already compacting`);
202
+ // Started and recorded in the same tick, with no await between: that is
203
+ // what makes the check above a gate and not a hint.
204
+ const running = this.pi.compact().then(() => undefined);
205
+ this.compacting = running;
206
+ try {
207
+ await running;
208
+ }
209
+ finally {
210
+ this.compacting = null;
211
+ }
212
+ }
213
+ /** One `session_info` entry, which Pi's own reader takes the latest of — so
214
+ * a rename is an append like everything else in a transcript, and nothing
215
+ * has to be rewritten. Never refused for being busy: a name has nothing to
216
+ * do with the turn running.
217
+ *
218
+ * Returns nothing, because the transcript is the answer: what the session is
219
+ * called after this — the name, or the title a cleared one falls back to —
220
+ * is what the next listing reads off the file, and deriving it here as well
221
+ * was a second copy of a rule agent/listing.ts already owns.
222
+ *
223
+ * TODO: renaming a cold session costs a whole resume, because the route
224
+ * reaches it through `ensure` and this method needs a live Pi session to
225
+ * append through. The work is one line in a file. Revisit when Pi offers a
226
+ * lightweight append to a session it has not loaded. */
227
+ async rename(name) {
228
+ this.live();
229
+ this.pi.sessionManager.appendSessionInfo(name);
230
+ this.wrote();
231
+ }
232
+ /** Compaction replaces the context a turn would run against, so a dispatch
233
+ * that lands mid-compaction waits for the summary instead of starting a turn
234
+ * over it — the follow-up promise ("delivered when idle") without Pi's
235
+ * follow-up queue, which is only drained by the *next* turn: a message
236
+ * parked there while nothing is running would sit unsent, and Pi's own
237
+ * `prompt()` guard would have thrown the user's message away (§5b). */
238
+ async whenCompacted() {
239
+ while (this.compacting)
240
+ await this.compacting.catch(() => undefined);
241
+ }
161
242
  // Async, so a refusal is a rejected promise: the seam promises callers they
162
243
  // may only `.catch()` (core/types.ts), and dispatch does exactly that.
163
244
  async prompt(text) {
164
245
  this.live();
165
- return this.pi.prompt(text);
246
+ await this.whenCompacted();
247
+ // Re-checked: the wait above is long enough for a dispose to land.
248
+ this.live();
249
+ // A turn may have started since the caller read the state this prompt was
250
+ // decided against — two messages arriving together, or several released at
251
+ // once by the wait above. Bare, Pi throws that back as "already
252
+ // processing" and the message is gone (§5b); queued, it is the same
253
+ // "delivered when idle" the core's own policy picks for an auto message
254
+ // that lands mid-turn (core/queue.ts).
255
+ return this.pi.prompt(text, { streamingBehavior: "followUp" });
166
256
  }
167
257
  async steer(text) {
168
258
  this.live();
@@ -173,6 +263,11 @@ export class PiSession {
173
263
  return this.pi.followUp(text);
174
264
  }
175
265
  async systemInput(text, origin, mode) {
266
+ this.live();
267
+ // Same gate as prompt(): an idle session takes a system input as a turn
268
+ // whatever the mode says, so a callback landing mid-compaction would race
269
+ // the summary too.
270
+ await this.whenCompacted();
176
271
  this.live();
177
272
  return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
178
273
  }
@@ -208,6 +303,7 @@ export class PiAgentFactory {
208
303
  providerConfig;
209
304
  pinned;
210
305
  enabledExtensions;
306
+ listings;
211
307
  constructor(extraTools = [],
212
308
  /** Appended as a virtual context file, so Pi's own prompt stays intact.
213
309
  * Read per session, not captured once: it carries settings a user can
@@ -227,7 +323,10 @@ export class PiAgentFactory {
227
323
  pinned = () => [],
228
324
  /** Which bundled extensions the Console has switched on. A getter for the
229
325
  * same reason again: the toggle takes effect on the next session open. */
230
- enabledExtensions = () => []) {
326
+ enabledExtensions = () => [],
327
+ /** What exists on disk. Injected so a test can hand this factory a listing
328
+ * instead of a session directory and a database. */
329
+ listings = new IndexedListing()) {
231
330
  this.extraTools = extraTools;
232
331
  this.instructions = instructions;
233
332
  this.skillPaths = skillPaths;
@@ -235,14 +334,20 @@ export class PiAgentFactory {
235
334
  this.providerConfig = providerConfig;
236
335
  this.pinned = pinned;
237
336
  this.enabledExtensions = enabledExtensions;
337
+ this.listings = listings;
238
338
  }
239
339
  /** One runtime for the whole process; catalogs are global, not per session. */
240
340
  catalog;
241
- /** Where each listed session lives. `listAll` reads the head of every
242
- * session file on disk (~250ms at 200 sessions, and it only grows), which
243
- * `resume` paid on every cold open web selection, an IM message, a task
244
- * run. The sidebar's own listing keeps this warm; a miss still lists. */
341
+ /** Where each listed session lives. A scan still stats every session file on
342
+ * disk, which `resume` would pay on every cold open web selection, an IM
343
+ * message, a task run. The sidebar's own listing keeps this warm; a miss
344
+ * still lists. */
245
345
  located = new Map();
346
+ /** That same scan, retained for LIST_TTL_MS instead of paid once per asking
347
+ * surface — one workspace event has three (sidebar, Activity, task lookups).
348
+ * Dropped on create/fork; ids appear for reasons this factory never sees, so
349
+ * a miss that decides something re-lists rather than trusts it (`resume`). */
350
+ listing;
246
351
  refreshQueue = Promise.resolve();
247
352
  builtinProviderIds;
248
353
  /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
@@ -457,8 +562,12 @@ export class PiAgentFactory {
457
562
  }
458
563
  async open(cwd, sessionManager, opts = { cwd }) {
459
564
  let live;
565
+ // Asked per open, not captured at wiring: a tool whose channel is not
566
+ // configured yet would otherwise cost context on every turn of every
567
+ // session and be able to answer nothing.
568
+ const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
460
569
  // Generic translation only — tool contracts are data owned by their feature.
461
- const customTools = this.extraTools.map((tool) => defineTool({
570
+ const customTools = active.map((tool) => defineTool({
462
571
  name: tool.name,
463
572
  label: tool.label,
464
573
  description: tool.description,
@@ -493,19 +602,27 @@ export class PiAgentFactory {
493
602
  this.credentials?.assertUnlocked();
494
603
  if (opts.name)
495
604
  sessionManager.appendSessionInfo(opts.name);
496
- const tools = opts.capabilities === "read"
497
- ? ["read", "grep", "find", "ls", ...this.extraTools.map((tool) => tool.name)]
498
- : undefined;
605
+ // This runtime serves exactly this one session, so shadowing its
606
+ // streamSimple is the per-session seam for the Anthropic cache TTL:
607
+ // interactive sessions keep "long" (1h — turns arrive minutes apart),
608
+ // tasks downgrade to "short" (5m) via setCacheRetention. The default sits
609
+ // before the spread so an explicit per-request value still wins —
610
+ // compaction passes cacheRetention: "none" and must keep it.
611
+ const runtime = await this.createRuntime();
612
+ const retention = { value: "long" };
613
+ const stream = runtime.streamSimple.bind(runtime);
614
+ runtime.streamSimple = ((model, context, options) => stream(model, context, { cacheRetention: retention.value, ...options }));
499
615
  const created = await createAgentSession({
500
616
  cwd,
501
617
  sessionManager,
502
618
  customTools,
503
- tools,
504
- modelRuntime: await this.createRuntime(),
619
+ modelRuntime: runtime,
505
620
  resourceLoader: await this.resourceLoader(cwd),
506
621
  });
507
622
  live = created.session;
508
- const session = new PiSession(live, this.pinned);
623
+ const session = new PiSession(live, this.pinned, () => {
624
+ this.listing = undefined;
625
+ }, retention);
509
626
  if (opts.model)
510
627
  await session.setModel(opts.model);
511
628
  if (opts.thinking)
@@ -514,6 +631,7 @@ export class PiAgentFactory {
514
631
  return session;
515
632
  }
516
633
  async create(opts) {
634
+ this.listing = undefined;
517
635
  return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
518
636
  }
519
637
  async fork(sourceSessionId, opts) {
@@ -533,6 +651,7 @@ export class PiAgentFactory {
533
651
  if (!leafId)
534
652
  throw new Error("cannot fork a session before its first persisted input");
535
653
  manager.createBranchedSession(leafId);
654
+ this.listing = undefined;
536
655
  return this.open(opts.cwd, manager, opts);
537
656
  }
538
657
  async resume(sessionId) {
@@ -548,27 +667,57 @@ export class PiAgentFactory {
548
667
  this.located.delete(sessionId);
549
668
  }
550
669
  }
551
- const infos = await this.listed();
552
- const info = infos.find((s) => s.id === sessionId);
670
+ const info = await this.locate(sessionId);
553
671
  if (!info)
554
672
  throw new Error(`unknown session: ${sessionId}`);
555
673
  return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
556
674
  }
675
+ /** The listed record for one id, and the one place "no such session" is
676
+ * decided. A retained listing is not evidence that a session is gone: it may
677
+ * have been written since — by another Pier, or by the first turn of a
678
+ * session this factory opened. The miss is what earns a fresh scan, because
679
+ * callers read it as permission to start a replacement session
680
+ * (channels/conversations.ts), which costs a conversation its history, or as
681
+ * a session that no longer exists (tasks/, web/files.ts). `reused` is how we
682
+ * know a scan is owed: same entry back, same disk state. */
683
+ async locate(sessionId) {
684
+ const find = (infos) => infos.find((s) => s.id === sessionId);
685
+ const reused = this.listing;
686
+ return find(await this.listed()) ??
687
+ (reused && this.listing === reused ? find(await this.listed(true)) : undefined);
688
+ }
689
+ async find(sessionId) {
690
+ const info = await this.locate(sessionId);
691
+ return info ? summaryOf(info) : undefined;
692
+ }
693
+ /** Once per process, after the first listing: agent/listing.ts reads Pi's
694
+ * transcripts with a parser of its own, and only a comparison notices when
695
+ * that format moves under it. */
696
+ audited = false;
557
697
  /** Every listing goes through here, so it also refreshes `located`. */
558
- async listed() {
559
- const infos = await SessionManager.listAll();
560
- for (const s of infos) {
561
- this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
562
- }
698
+ listed(force = false) {
699
+ const now = Date.now();
700
+ if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
701
+ return this.listing.infos;
702
+ const infos = this.listings.scan().then((listed) => {
703
+ for (const s of listed) {
704
+ this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
705
+ }
706
+ if (!this.audited && this.listings.audit) {
707
+ this.audited = true;
708
+ void this.listings.audit(() => SessionManager.listAll()).then((wrong) => wrong || log.debug("session index agrees with Pi's own listing"), (err) => log.warn("session index cross-check failed", err));
709
+ }
710
+ return listed;
711
+ });
712
+ // A failed scan is not an answer to hand the next caller for three seconds.
713
+ void infos.catch(() => {
714
+ if (this.listing?.infos === infos)
715
+ this.listing = undefined;
716
+ });
717
+ this.listing = { at: now, infos };
563
718
  return infos;
564
719
  }
565
720
  async list() {
566
- const infos = await this.listed();
567
- return infos.map((s) => ({
568
- id: s.id,
569
- cwd: s.cwd,
570
- createdAt: s.created.getTime(),
571
- title: s.name ?? (s.firstMessage ? s.firstMessage.slice(0, 80) : undefined),
572
- }));
721
+ return (await this.listed()).map(summaryOf);
573
722
  }
574
723
  }
@@ -5,7 +5,10 @@
5
5
  // Only <board>/site is reachable over HTTP: sources, README and the manifest
6
6
  // itself stay off the wire, so a public board leaks nothing about how it was
7
7
  // made. `/boards/*` is authenticated; `/p/*` additionally requires the
8
- // manifest's `public` flag and runs as sandboxed active content.
8
+ // manifest's `public` flag and runs as sandboxed active content — and is the
9
+ // only password-free prefix, stylesheet included, so one firewall rule covers
10
+ // everything a logged-out reader may fetch.
11
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
9
12
  import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
10
13
  import { extname, join, resolve, sep } from "node:path";
11
14
  import { logger } from "../log.js";
@@ -14,6 +17,13 @@ export const defaultBoardsDir = () => pierPath("boards");
14
17
  /** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
15
18
  * pattern excludes from every scan — one rename is the whole delete path. */
16
19
  const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
20
+ /** A published board is addressed by `<slug>-<token>`: the slug alone is a
21
+ * guessable word, so without the 32 bits after it `/p/` could be walked with
22
+ * a dictionary. Minted the first time a manifest is seen public, by whichever
23
+ * path published it — the Console's toggle or an agent editing board.json —
24
+ * so a board cannot be public and enumerable at the same time. */
25
+ const TOKEN = /^[a-f0-9]{8}$/;
26
+ const mintToken = () => randomBytes(4).toString("hex");
17
27
  // A board ships fonts and images, so the list is wider than the attachment
18
28
  // route's — but still a whitelist: an unlisted extension is not served at all.
19
29
  const TYPES = {
@@ -49,7 +59,10 @@ const warned = new Set();
49
59
  * here and nowhere else: an unvalidated `../../etc` would read outside the
50
60
  * boards dir, and a NUL byte would throw instead of 404. Unknown fields get
51
61
  * defaults, a broken file is skipped whole, and extra keys are the agent's
52
- * business — they survive a write. */
62
+ * business — they survive a write. The one manifest this writes back is a
63
+ * public board that arrived without a token: minting is the same decision as
64
+ * reading `public`, and doing it anywhere else leaves the agent's own publish
65
+ * path — editing `board.json` — with no URL. */
53
66
  async function readManifest(dir, slug) {
54
67
  if (!SLUG.test(slug))
55
68
  return null;
@@ -69,14 +82,29 @@ async function readManifest(dir, slug) {
69
82
  if (typeof raw !== "object" || raw === null || Array.isArray(raw))
70
83
  return null;
71
84
  const m = raw;
72
- return {
85
+ const manifest = {
73
86
  ...m,
74
87
  title: typeof m.title === "string" && m.title ? m.title : slug,
75
88
  description: typeof m.description === "string" ? m.description : "",
76
89
  sessions: Array.isArray(m.sessions) ? m.sessions.filter((s) => typeof s === "string") : [],
77
90
  public: m.public === true,
91
+ token: typeof m.token === "string" && TOKEN.test(m.token) ? m.token : "",
78
92
  };
93
+ if (manifest.public && !manifest.token) {
94
+ manifest.token = mintToken();
95
+ try {
96
+ await writeManifest(dir, slug, manifest);
97
+ }
98
+ catch (err) {
99
+ // A token that cannot be stored would differ on the next request, so the
100
+ // board stays unreachable on /p/ rather than handing out a dead link.
101
+ logger("boards").warn(`cannot mint a public token for ${slug}`, err);
102
+ manifest.token = "";
103
+ }
104
+ }
105
+ return manifest;
79
106
  }
107
+ const writeManifest = (dir, slug, manifest) => writeFile(join(dir, slug, "board.json"), `${JSON.stringify(manifest, null, 2)}\n`);
80
108
  /** Freshness is the site's mtime, not a manifest field — the filesystem
81
109
  * already knows, and an agent rewriting a page cannot forget to say so. */
82
110
  async function updatedAt(dir, slug) {
@@ -99,8 +127,8 @@ export async function listBoards(dir) {
99
127
  const manifest = await readManifest(dir, slug);
100
128
  if (!manifest)
101
129
  continue;
102
- const { title, description, sessions, public: isPublic } = manifest;
103
- boards.push({ slug, title, description, sessions, public: isPublic, updatedAt: await updatedAt(dir, slug) });
130
+ const { title, description, sessions, public: isPublic, token } = manifest;
131
+ boards.push({ slug, title, description, sessions, public: isPublic, token, updatedAt: await updatedAt(dir, slug) });
104
132
  }
105
133
  return boards;
106
134
  }
@@ -139,11 +167,28 @@ async function resolveFile(dir, slug, rest) {
139
167
  return resolveFile(dir, slug, `${relative}/`);
140
168
  return info.isFile() ? file : null;
141
169
  }
142
- async function serveFile(c, dir, slug, rest, publicOnly) {
170
+ /** `/p/` addresses a board as `<slug>-<token>`; a slug may itself contain
171
+ * hyphens, so the last one is the cut. */
172
+ function publicKey(key) {
173
+ const cut = key.lastIndexOf("-");
174
+ return cut < 1 ? { slug: "", token: "" } : { slug: key.slice(0, cut), token: key.slice(cut + 1) };
175
+ }
176
+ /** Digested first: the token is a secret, and a URL's half may be any length
177
+ * or encoding, which a raw comparison would either leak or throw on. */
178
+ const sameToken = (want, got) => {
179
+ if (!want)
180
+ return false;
181
+ const digest = (s) => createHash("sha256").update(s).digest();
182
+ return timingSafeEqual(digest(want), digest(got));
183
+ };
184
+ async function serveFile(c, dir, key, rest, publicOnly) {
185
+ const { slug, token } = publicOnly ? publicKey(key) : { slug: key, token: "" };
143
186
  const manifest = await readManifest(dir, slug);
144
- // 404, never 403: a private board's existence is not public information.
145
- if (!manifest || (publicOnly && !manifest.public))
187
+ // 404, never 403: a private board's existence is not public information, and
188
+ // a wrong token is the same non-answer as a wrong name.
189
+ if (!manifest || (publicOnly && (!manifest.public || !sameToken(manifest.token, token)))) {
146
190
  return c.notFound();
191
+ }
147
192
  const file = await resolveFile(dir, slug, rest);
148
193
  if (!file)
149
194
  return c.notFound();
@@ -179,8 +224,12 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
179
224
  if (!manifest)
180
225
  return c.json({ error: "no such board" }, 404);
181
226
  manifest.public = body.public;
182
- await writeFile(join(dir, slug, "board.json"), `${JSON.stringify(manifest, null, 2)}\n`);
183
- return c.json({ public: manifest.public });
227
+ // Publishing an unpublished board is the case readManifest cannot mint for:
228
+ // it read the manifest while it was still private.
229
+ if (manifest.public && !manifest.token)
230
+ manifest.token = mintToken();
231
+ await writeManifest(dir, slug, manifest);
232
+ return c.json({ public: manifest.public, token: manifest.token });
184
233
  });
185
234
  app.delete("/api/boards/:slug", async (c) => {
186
235
  const slug = c.req.param("slug");
@@ -190,7 +239,7 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
190
239
  return c.json({ deleted: slug });
191
240
  });
192
241
  // Declared before the wildcards below: `_assets` is not a slug.
193
- app.get("/boards/_assets/pier.css", async (c) => {
242
+ app.get("/p/_assets/pier.css", async (c) => {
194
243
  const file = new URL("./pier.css", import.meta.url);
195
244
  return c.body(await readFile(file), 200, {
196
245
  "content-type": "text/css; charset=utf-8",
@@ -200,11 +249,11 @@ export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
200
249
  // Trailing slash matters: without it a board's relative asset paths resolve
201
250
  // against /boards instead of the board.
202
251
  for (const prefix of ["/boards", "/p"]) {
203
- app.get(`${prefix}/:slug`, (c) => c.redirect(`${prefix}/${c.req.param("slug")}/`));
204
- app.get(`${prefix}/:slug/*`, (c) => {
205
- const slug = c.req.param("slug");
206
- const rest = c.req.path.slice(`${prefix}/${slug}/`.length);
207
- return serveFile(c, dir, slug, rest, prefix === "/p");
252
+ app.get(`${prefix}/:key`, (c) => c.redirect(`${prefix}/${c.req.param("key")}/`));
253
+ app.get(`${prefix}/:key/*`, (c) => {
254
+ const key = c.req.param("key");
255
+ const rest = c.req.path.slice(`${prefix}/${key}/`.length);
256
+ return serveFile(c, dir, key, rest, prefix === "/p");
208
257
  });
209
258
  }
210
259
  }
@@ -1,5 +1,5 @@
1
1
  /* pier.css — the one stylesheet every Board can link, served at
2
- /boards/_assets/pier.css. Classless on purpose: plain semantic HTML must
2
+ /p/_assets/pier.css. Classless on purpose: plain semantic HTML must
3
3
  look finished, so a board needs no build step and no framework. A handful of
4
4
  named helpers cover the shapes a report actually asks for. */
5
5
 
@@ -0,0 +1,87 @@
1
+ // Outbound attachments: which links in a turn are files the platform has to
2
+ // carry, and the bytes behind them.
3
+ //
4
+ // The agent links a file it produced by absolute `file://` URL — the
5
+ // convention core/reply.ts hands it, spelled the same way inbound
6
+ // (core/inbound-file.ts). The web chat renders that link as a card because the
7
+ // browser can fetch the bytes back over an authenticated route; an IM client
8
+ // cannot, and a `file:///…` link in Slack is a dead path on someone else's
9
+ // machine. So an adapter uploads the file to the platform instead, and the
10
+ // link's label stays behind as the words around it.
11
+ //
12
+ // The upload itself is per-platform and stays in each `*-api.ts`; what is
13
+ // shared — the grammar, the caps, and the line a failed attachment still owes
14
+ // the conversation — lives here so three adapters do not each have a copy.
15
+ import { readFile, stat } from "node:fs/promises";
16
+ import { basename, extname } from "node:path";
17
+ import { lostMarker } from "../core/inbound-file.js";
18
+ /**
19
+ * One cap for every platform: Telegram refuses a photo past 10 MB, which is
20
+ * the smallest of the three, and a turn that lands on one chat and not on
21
+ * another is worse than a turn that is honest everywhere.
22
+ */
23
+ export const MAX_ATTACH_BYTES = 10 * 1024 * 1024;
24
+ /** Per turn. Linking a directory's worth of files is a mistake, not a plan. */
25
+ const MAX_ATTACHMENTS = 5;
26
+ /** Extensions the platforms show inline. Everything else goes as a document —
27
+ * svg included, deliberately: it is markup, and it renders as a file. */
28
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
29
+ /** `[label](file:///abs/path)`, inline or on a line of its own. The optional
30
+ * `!` is an image embed, which is the same request with a different sigil. */
31
+ const LINK = /!?\[([^\]\n]*)\]\(\s*<?file:\/\/(\/[^)>\s]*)>?\s*\)/g;
32
+ /** A trailing slash or a bare root would otherwise leave an unnamed file. */
33
+ const nameOf = (path) => basename(path) || "file";
34
+ /**
35
+ * Split a turn's markdown into the text an IM chat should show and the files
36
+ * it linked. Each link collapses to its label — or to the file's name when the
37
+ * agent wrote none — so the sentence it sat in still reads, and the turn never
38
+ * becomes empty just because its only content was an attachment.
39
+ */
40
+ export function splitAttachments(markdown) {
41
+ const paths = [];
42
+ const text = markdown.replace(LINK, (_m, label, raw) => {
43
+ let path = raw;
44
+ try {
45
+ path = decodeURIComponent(raw);
46
+ }
47
+ catch {
48
+ /* not percent-encoded — take the path as written */
49
+ }
50
+ if (!paths.includes(path))
51
+ paths.push(path);
52
+ return label || nameOf(path);
53
+ });
54
+ return { text, paths };
55
+ }
56
+ /**
57
+ * Upload every file a turn linked, and return the line the conversation still
58
+ * owes: an attachment that never arrived must not look like an attachment that
59
+ * was never mentioned (AGENTS.md 5b), so each failure is named in the chat as
60
+ * well as in the log. Empty string when everything landed.
61
+ */
62
+ export async function sendAttachments(paths, upload, log) {
63
+ const lost = [];
64
+ const fail = (path, reason) => {
65
+ log(`attachment ${path} not sent: ${reason}`);
66
+ lost.push(lostMarker(nameOf(path), reason));
67
+ };
68
+ for (const path of paths.slice(0, MAX_ATTACHMENTS)) {
69
+ try {
70
+ const info = await stat(path);
71
+ if (!info.isFile())
72
+ throw new Error("not a file");
73
+ if (info.size > MAX_ATTACH_BYTES)
74
+ throw new Error(`too large (>${MAX_ATTACH_BYTES} bytes)`);
75
+ const name = nameOf(path);
76
+ const ext = extname(name).slice(1).toLowerCase();
77
+ await upload({ name, bytes: await readFile(path), image: IMAGE_EXT.has(ext) });
78
+ }
79
+ catch (err) {
80
+ fail(path, err instanceof Error ? err.message : String(err));
81
+ }
82
+ }
83
+ for (const path of paths.slice(MAX_ATTACHMENTS)) {
84
+ fail(path, `more than ${MAX_ATTACHMENTS} files in one turn`);
85
+ }
86
+ return lost.join("\n");
87
+ }
@@ -29,11 +29,11 @@ export function createControl({ router, factory, conversations, store }) {
29
29
  if (!session)
30
30
  return null;
31
31
  // AgentSession has no cwd; the factory's listing is where it lives.
32
- const listed = await factory.list();
32
+ const summary = await factory.find(session.id);
33
33
  const usage = session.contextUsage;
34
34
  return {
35
35
  sessionId: session.id,
36
- cwd: listed.find((s) => s.id === session.id)?.cwd ?? "",
36
+ cwd: summary?.cwd ?? "",
37
37
  state: session.state,
38
38
  model: session.model,
39
39
  thinking: session.thinkingLevel,
@@ -136,6 +136,44 @@ export class LarkApi {
136
136
  });
137
137
  return { messageId: ok("message.reply", res).data?.message_id ?? "" };
138
138
  }
139
+ /**
140
+ * Two calls: the bytes go to the platform first and come back as a key,
141
+ * then the key is posted as a message. Images take the image endpoint so
142
+ * they render inline; everything else is a `stream` file, which is Lark's
143
+ * name for "a file whose type I am not claiming to know".
144
+ *
145
+ * The SDK unwraps an upload response to its `data`, so a business failure
146
+ * arrives as a missing key rather than as a code — hence the explicit throw
147
+ * instead of `ok()`.
148
+ */
149
+ async uploadFile(rootId, file) {
150
+ const bytes = Buffer.from(file.bytes);
151
+ let content;
152
+ if (file.image) {
153
+ const res = await this.client.im.v1.image.create({
154
+ data: { image_type: "message", image: bytes },
155
+ });
156
+ if (!res?.image_key)
157
+ throw new Error(`lark image.create: no image_key for ${file.name}`);
158
+ content = { image_key: res.image_key };
159
+ }
160
+ else {
161
+ const res = await this.client.im.v1.file.create({
162
+ data: { file_type: "stream", file_name: file.name, file: bytes },
163
+ });
164
+ if (!res?.file_key)
165
+ throw new Error(`lark file.create: no file_key for ${file.name}`);
166
+ content = { file_key: res.file_key };
167
+ }
168
+ ok("message.reply", await this.client.im.v1.message.reply({
169
+ path: { message_id: rootId },
170
+ data: {
171
+ msg_type: file.image ? "image" : "file",
172
+ content: JSON.stringify(content),
173
+ reply_in_thread: true,
174
+ },
175
+ }));
176
+ }
139
177
  async patchCard(messageId, card) {
140
178
  ok("message.patch", await this.client.im.v1.message.patch({
141
179
  path: { message_id: messageId },