@timqi/pier 0.0.9 → 0.0.16

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 (67) hide show
  1. package/README.md +7 -1
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +190 -31
  5. package/dist/boards/boards.js +65 -16
  6. package/dist/boards/pier.css +1 -1
  7. package/dist/channels/attach.js +87 -0
  8. package/dist/channels/control.js +2 -2
  9. package/dist/channels/lark-api.js +38 -0
  10. package/dist/channels/lark-outbound.js +11 -2
  11. package/dist/channels/slack-api.js +36 -0
  12. package/dist/channels/slack-outbound.js +12 -2
  13. package/dist/channels/slack-tool.js +49 -9
  14. package/dist/channels/telegram-api.js +21 -2
  15. package/dist/channels/telegram.js +23 -8
  16. package/dist/cli.js +34 -0
  17. package/dist/core/identity.js +18 -0
  18. package/dist/core/inbound-file.js +3 -1
  19. package/dist/core/reply.js +2 -1
  20. package/dist/core/router.js +72 -0
  21. package/dist/db.js +78 -0
  22. package/dist/extensions/index.js +5 -2
  23. package/dist/extensions/web/artifacts.js +7 -2
  24. package/dist/extensions/web/content.js +5 -0
  25. package/dist/extensions/web/language.js +5 -0
  26. package/dist/extensions/web/tools.js +33 -8
  27. package/dist/limits.js +14 -0
  28. package/dist/main.js +47 -6
  29. package/dist/paths.js +6 -1
  30. package/dist/settings.js +44 -0
  31. package/dist/tasks/agent.js +23 -4
  32. package/dist/tasks/callbacks.js +20 -1
  33. package/dist/tasks/command.js +15 -0
  34. package/dist/tasks/definitions.js +60 -12
  35. package/dist/tasks/execution.js +9 -1
  36. package/dist/tasks/groups.js +8 -4
  37. package/dist/tasks/messages.js +10 -2
  38. package/dist/tasks/routes.js +4 -0
  39. package/dist/tasks/runs.js +7 -2
  40. package/dist/tasks/service.js +22 -6
  41. package/dist/tasks/store.js +4 -0
  42. package/dist/tasks/tool.js +0 -12
  43. package/dist/tasks/types.js +4 -0
  44. package/dist/tools-task.js +155 -0
  45. package/dist/tools.js +875 -0
  46. package/dist/web/auth.js +5 -3
  47. package/dist/web/explorer.js +15 -2
  48. package/dist/web/files.js +1 -1
  49. package/dist/web/instance.js +165 -36
  50. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
  51. package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
  52. package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
  53. package/dist/web/public/index.html +5 -8
  54. package/dist/web/public/sw.js +4 -0
  55. package/dist/web/push.js +22 -7
  56. package/dist/web/repos.js +75 -0
  57. package/dist/web/server.js +145 -64
  58. package/dist/web/session-state.js +33 -51
  59. package/dist/web/types.js +5 -0
  60. package/docs/deploy.md +12 -3
  61. package/package.json +1 -1
  62. package/skills/pier-boards/SKILL.md +23 -13
  63. package/skills/pier-help/SKILL.md +1 -1
  64. package/skills/pier-slack/SKILL.md +21 -1
  65. package/skills/pier-tasks/SKILL.md +2 -2
  66. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  67. 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
@@ -27,12 +42,22 @@ const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspac
27
42
  # Communication
28
43
  These rules govern conversational replies. When the reply *is* the deliverable — a report that was asked for, a review, a task run whose result another agent reads — the work sets the length: complete beats brief, and nothing below caps it.
29
44
  - Answer with the conclusion only. Reasons, process, trade-offs, alternatives: only when asked.
30
- - Cap per reply: 100 words (or 100 Chinese chars), max 3 bullets; 300 when explicitly asked why or how. Code blocks, diffs and commands don't count.
45
+ - Cap per reply: 60 words (90 Chinese chars), max 3 bullets; 180 words (270 Chinese chars) when explicitly asked why or how. Code blocks, diffs and commands don't count.
46
+ - Reply in the language of the request; code, paths, identifiers and quoted output stay verbatim.
31
47
  - Never: preamble, restating the question, closing summaries, "I'm going to..." narration, listing changes already visible in the diff.
32
48
  - After edits, say only: file(s) touched + one line on the result. Don't explain self-evident code.
33
- - Show file paths as \`path:line\`.
49
+ - Show file paths as \`path:line\`, or the path alone when no single line is the point — never invent a number.
34
50
  - If the honest answer needs more than the cap, give the conclusion plus one short "want the details?" — don't dump it.
35
- - Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.`;
51
+ - Blocked on a decision only the person you work for can make? Ask one short question. Otherwise pick the sensible default and note it.
52
+
53
+ # Working style (any machine)
54
+ These hold wherever Pier runs; a user's SYSTEM.md adds the local ones (which tools exist, which hosts, which paths).
55
+ - Orient first — list and search before you act. Never guess a path.
56
+ - Read before you edit. Match the surrounding code's style, naming, and comment density.
57
+ - Do exactly what was asked. No unrequested refactors, no extra files, no README updates.
58
+ - Destructive or irreversible actions (rm, force push, migrations, deploys): ask first.
59
+ - Say plainly when something failed, was skipped, or is unverified. Never claim a test passed without running it.
60
+ - Every bash call already runs in the working directory this prompt names — don't prefix \`cd <cwd> &&\`, \`cd\` only to go somewhere else. Each call is a fresh shell: \`cd\`, \`export\`, \`source\` never carry over, so chain what must share state into one command.`;
36
61
  export const pierSystemPrompt = (userPrompt) => userPrompt ? `${PIER_SYSTEM_PROMPT}\n\n${userPrompt}` : PIER_SYSTEM_PROMPT;
37
62
  /** Patching the call is cheaper than replacing the tool: the built-in keeps its
38
63
  * shell settings, and the agent spends no tokens deciding a timeout. */
@@ -68,11 +93,21 @@ export const standDownShadowed = (base) => {
68
93
  export class PiSession {
69
94
  pi;
70
95
  pinned;
96
+ wrote;
97
+ retention;
71
98
  constructor(pi,
72
99
  /** Operator pins, read per call — the menu can change while we run. */
73
- pinned = () => []) {
100
+ pinned = () => [],
101
+ /** "What I just wrote is not in your listing yet." The factory retains a
102
+ * scan for a few seconds, which is exactly the window a rename lands in:
103
+ * every surface would re-read the old title and keep it until some
104
+ * unrelated event moved the list again. Same drop `create` and `fork`
105
+ * do — a callback only because the session is what knows it happened. */
106
+ wrote = () => { }, retention = { value: "long" }) {
74
107
  this.pi = pi;
75
108
  this.pinned = pinned;
109
+ this.wrote = wrote;
110
+ this.retention = retention;
76
111
  }
77
112
  /** Pi's dispose unhooks the one listener that persists and emits, so a turn
78
113
  * started after it runs for real — model call, tools and all — and lands
@@ -128,6 +163,9 @@ export class PiSession {
128
163
  setThinkingLevel(level) {
129
164
  this.pi.setThinkingLevel(level);
130
165
  }
166
+ setCacheRetention(retention) {
167
+ this.retention.value = retention;
168
+ }
131
169
  async pendingQueue() {
132
170
  return {
133
171
  steering: [...this.pi.getSteeringMessages()],
@@ -158,11 +196,73 @@ export class PiSession {
158
196
  if (cancelled)
159
197
  throw new Error("rewind cancelled");
160
198
  }
199
+ /** The compaction running right now, or null. Pi keeps no lock of its own —
200
+ * a second `compact()` aborts the first's turn and summarizes a transcript
201
+ * that is being replaced under it — and two POSTs a millisecond apart both
202
+ * pass the route's idle check, so the gate has to be here. */
203
+ compacting = null;
204
+ /** Pi's own compaction, minus its `CompactionResult`: the numbers reach
205
+ * surfaces as the `context-compacted` event the seam already emits for the
206
+ * automatic one, so a caller has nothing to do with them. Refused while one
207
+ * is running, rather than run twice over one context. */
208
+ async compact() {
209
+ this.live();
210
+ if (this.compacting)
211
+ throw new Error(`session ${this.pi.sessionId} is already compacting`);
212
+ // Started and recorded in the same tick, with no await between: that is
213
+ // what makes the check above a gate and not a hint.
214
+ const running = this.pi.compact().then(() => undefined);
215
+ this.compacting = running;
216
+ try {
217
+ await running;
218
+ }
219
+ finally {
220
+ this.compacting = null;
221
+ }
222
+ }
223
+ /** One `session_info` entry, which Pi's own reader takes the latest of — so
224
+ * a rename is an append like everything else in a transcript, and nothing
225
+ * has to be rewritten. Never refused for being busy: a name has nothing to
226
+ * do with the turn running.
227
+ *
228
+ * Returns nothing, because the transcript is the answer: what the session is
229
+ * called after this — the name, or the title a cleared one falls back to —
230
+ * is what the next listing reads off the file, and deriving it here as well
231
+ * was a second copy of a rule agent/listing.ts already owns.
232
+ *
233
+ * TODO: renaming a cold session costs a whole resume, because the route
234
+ * reaches it through `ensure` and this method needs a live Pi session to
235
+ * append through. The work is one line in a file. Revisit when Pi offers a
236
+ * lightweight append to a session it has not loaded. */
237
+ async rename(name) {
238
+ this.live();
239
+ this.pi.sessionManager.appendSessionInfo(name);
240
+ this.wrote();
241
+ }
242
+ /** Compaction replaces the context a turn would run against, so a dispatch
243
+ * that lands mid-compaction waits for the summary instead of starting a turn
244
+ * over it — the follow-up promise ("delivered when idle") without Pi's
245
+ * follow-up queue, which is only drained by the *next* turn: a message
246
+ * parked there while nothing is running would sit unsent, and Pi's own
247
+ * `prompt()` guard would have thrown the user's message away (§5b). */
248
+ async whenCompacted() {
249
+ while (this.compacting)
250
+ await this.compacting.catch(() => undefined);
251
+ }
161
252
  // Async, so a refusal is a rejected promise: the seam promises callers they
162
253
  // may only `.catch()` (core/types.ts), and dispatch does exactly that.
163
254
  async prompt(text) {
164
255
  this.live();
165
- return this.pi.prompt(text);
256
+ await this.whenCompacted();
257
+ // Re-checked: the wait above is long enough for a dispose to land.
258
+ this.live();
259
+ // A turn may have started since the caller read the state this prompt was
260
+ // decided against — two messages arriving together, or several released at
261
+ // once by the wait above. Bare, Pi throws that back as "already
262
+ // processing" and the message is gone (§5b); queued, it is the same
263
+ // "delivered when idle" the core's own policy picks for an auto message
264
+ // that lands mid-turn (core/queue.ts).
265
+ return this.pi.prompt(text, { streamingBehavior: "followUp" });
166
266
  }
167
267
  async steer(text) {
168
268
  this.live();
@@ -173,6 +273,11 @@ export class PiSession {
173
273
  return this.pi.followUp(text);
174
274
  }
175
275
  async systemInput(text, origin, mode) {
276
+ this.live();
277
+ // Same gate as prompt(): an idle session takes a system input as a turn
278
+ // whatever the mode says, so a callback landing mid-compaction would race
279
+ // the summary too.
280
+ await this.whenCompacted();
176
281
  this.live();
177
282
  return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
178
283
  }
@@ -208,6 +313,7 @@ export class PiAgentFactory {
208
313
  providerConfig;
209
314
  pinned;
210
315
  enabledExtensions;
316
+ listings;
211
317
  constructor(extraTools = [],
212
318
  /** Appended as a virtual context file, so Pi's own prompt stays intact.
213
319
  * Read per session, not captured once: it carries settings a user can
@@ -227,7 +333,10 @@ export class PiAgentFactory {
227
333
  pinned = () => [],
228
334
  /** Which bundled extensions the Console has switched on. A getter for the
229
335
  * same reason again: the toggle takes effect on the next session open. */
230
- enabledExtensions = () => []) {
336
+ enabledExtensions = () => [],
337
+ /** What exists on disk. Injected so a test can hand this factory a listing
338
+ * instead of a session directory and a database. */
339
+ listings = new IndexedListing()) {
231
340
  this.extraTools = extraTools;
232
341
  this.instructions = instructions;
233
342
  this.skillPaths = skillPaths;
@@ -235,14 +344,20 @@ export class PiAgentFactory {
235
344
  this.providerConfig = providerConfig;
236
345
  this.pinned = pinned;
237
346
  this.enabledExtensions = enabledExtensions;
347
+ this.listings = listings;
238
348
  }
239
349
  /** One runtime for the whole process; catalogs are global, not per session. */
240
350
  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. */
351
+ /** Where each listed session lives. A scan still stats every session file on
352
+ * disk, which `resume` would pay on every cold open web selection, an IM
353
+ * message, a task run. The sidebar's own listing keeps this warm; a miss
354
+ * still lists. */
245
355
  located = new Map();
356
+ /** That same scan, retained for LIST_TTL_MS instead of paid once per asking
357
+ * surface — one workspace event has three (sidebar, Activity, task lookups).
358
+ * Dropped on create/fork; ids appear for reasons this factory never sees, so
359
+ * a miss that decides something re-lists rather than trusts it (`resume`). */
360
+ listing;
246
361
  refreshQueue = Promise.resolve();
247
362
  builtinProviderIds;
248
363
  /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
@@ -457,8 +572,12 @@ export class PiAgentFactory {
457
572
  }
458
573
  async open(cwd, sessionManager, opts = { cwd }) {
459
574
  let live;
575
+ // Asked per open, not captured at wiring: a tool whose channel is not
576
+ // configured yet would otherwise cost context on every turn of every
577
+ // session and be able to answer nothing.
578
+ const active = this.extraTools.filter((tool) => tool.available?.() ?? true);
460
579
  // Generic translation only — tool contracts are data owned by their feature.
461
- const customTools = this.extraTools.map((tool) => defineTool({
580
+ const customTools = active.map((tool) => defineTool({
462
581
  name: tool.name,
463
582
  label: tool.label,
464
583
  description: tool.description,
@@ -493,19 +612,27 @@ export class PiAgentFactory {
493
612
  this.credentials?.assertUnlocked();
494
613
  if (opts.name)
495
614
  sessionManager.appendSessionInfo(opts.name);
496
- const tools = opts.capabilities === "read"
497
- ? ["read", "grep", "find", "ls", ...this.extraTools.map((tool) => tool.name)]
498
- : undefined;
615
+ // This runtime serves exactly this one session, so shadowing its
616
+ // streamSimple is the per-session seam for the Anthropic cache TTL:
617
+ // interactive sessions keep "long" (1h — turns arrive minutes apart),
618
+ // tasks downgrade to "short" (5m) via setCacheRetention. The default sits
619
+ // before the spread so an explicit per-request value still wins —
620
+ // compaction passes cacheRetention: "none" and must keep it.
621
+ const runtime = await this.createRuntime();
622
+ const retention = { value: "long" };
623
+ const stream = runtime.streamSimple.bind(runtime);
624
+ runtime.streamSimple = ((model, context, options) => stream(model, context, { cacheRetention: retention.value, ...options }));
499
625
  const created = await createAgentSession({
500
626
  cwd,
501
627
  sessionManager,
502
628
  customTools,
503
- tools,
504
- modelRuntime: await this.createRuntime(),
629
+ modelRuntime: runtime,
505
630
  resourceLoader: await this.resourceLoader(cwd),
506
631
  });
507
632
  live = created.session;
508
- const session = new PiSession(live, this.pinned);
633
+ const session = new PiSession(live, this.pinned, () => {
634
+ this.listing = undefined;
635
+ }, retention);
509
636
  if (opts.model)
510
637
  await session.setModel(opts.model);
511
638
  if (opts.thinking)
@@ -514,6 +641,7 @@ export class PiAgentFactory {
514
641
  return session;
515
642
  }
516
643
  async create(opts) {
644
+ this.listing = undefined;
517
645
  return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
518
646
  }
519
647
  async fork(sourceSessionId, opts) {
@@ -533,6 +661,7 @@ export class PiAgentFactory {
533
661
  if (!leafId)
534
662
  throw new Error("cannot fork a session before its first persisted input");
535
663
  manager.createBranchedSession(leafId);
664
+ this.listing = undefined;
536
665
  return this.open(opts.cwd, manager, opts);
537
666
  }
538
667
  async resume(sessionId) {
@@ -548,27 +677,57 @@ export class PiAgentFactory {
548
677
  this.located.delete(sessionId);
549
678
  }
550
679
  }
551
- const infos = await this.listed();
552
- const info = infos.find((s) => s.id === sessionId);
680
+ const info = await this.locate(sessionId);
553
681
  if (!info)
554
682
  throw new Error(`unknown session: ${sessionId}`);
555
683
  return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
556
684
  }
685
+ /** The listed record for one id, and the one place "no such session" is
686
+ * decided. A retained listing is not evidence that a session is gone: it may
687
+ * have been written since — by another Pier, or by the first turn of a
688
+ * session this factory opened. The miss is what earns a fresh scan, because
689
+ * callers read it as permission to start a replacement session
690
+ * (channels/conversations.ts), which costs a conversation its history, or as
691
+ * a session that no longer exists (tasks/, web/files.ts). `reused` is how we
692
+ * know a scan is owed: same entry back, same disk state. */
693
+ async locate(sessionId) {
694
+ const find = (infos) => infos.find((s) => s.id === sessionId);
695
+ const reused = this.listing;
696
+ return find(await this.listed()) ??
697
+ (reused && this.listing === reused ? find(await this.listed(true)) : undefined);
698
+ }
699
+ async find(sessionId) {
700
+ const info = await this.locate(sessionId);
701
+ return info ? summaryOf(info) : undefined;
702
+ }
703
+ /** Once per process, after the first listing: agent/listing.ts reads Pi's
704
+ * transcripts with a parser of its own, and only a comparison notices when
705
+ * that format moves under it. */
706
+ audited = false;
557
707
  /** 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
- }
708
+ listed(force = false) {
709
+ const now = Date.now();
710
+ if (!force && this.listing && now - this.listing.at < LIST_TTL_MS)
711
+ return this.listing.infos;
712
+ const infos = this.listings.scan().then((listed) => {
713
+ for (const s of listed) {
714
+ this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
715
+ }
716
+ if (!this.audited && this.listings.audit) {
717
+ this.audited = true;
718
+ 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));
719
+ }
720
+ return listed;
721
+ });
722
+ // A failed scan is not an answer to hand the next caller for three seconds.
723
+ void infos.catch(() => {
724
+ if (this.listing?.infos === infos)
725
+ this.listing = undefined;
726
+ });
727
+ this.listing = { at: now, infos };
563
728
  return infos;
564
729
  }
565
730
  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
- }));
731
+ return (await this.listed()).map(summaryOf);
573
732
  }
574
733
  }
@@ -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 },