@timqi/pier 0.0.8 → 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 (71) hide show
  1. package/README.md +26 -9
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +279 -32
  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/conversations.js +10 -0
  10. package/dist/channels/lark-api.js +38 -0
  11. package/dist/channels/lark-outbound.js +11 -2
  12. package/dist/channels/slack-api.js +36 -0
  13. package/dist/channels/slack-outbound.js +12 -2
  14. package/dist/channels/slack-tool.js +49 -9
  15. package/dist/channels/telegram-api.js +21 -2
  16. package/dist/channels/telegram.js +23 -8
  17. package/dist/cli.js +34 -0
  18. package/dist/core/identity.js +18 -0
  19. package/dist/core/inbound-file.js +3 -1
  20. package/dist/core/reply.js +2 -1
  21. package/dist/core/router.js +99 -11
  22. package/dist/db.js +87 -0
  23. package/dist/extensions/index.js +37 -0
  24. package/dist/extensions/web/anthropic.js +118 -0
  25. package/dist/extensions/web/artifacts.js +62 -0
  26. package/dist/extensions/web/content.js +130 -0
  27. package/dist/extensions/web/http.js +106 -0
  28. package/dist/extensions/web/index.js +9 -0
  29. package/dist/extensions/web/json.js +5 -0
  30. package/dist/extensions/web/language.js +47 -0
  31. package/dist/extensions/web/openai.js +112 -0
  32. package/dist/extensions/web/provider.js +121 -0
  33. package/dist/extensions/web/tools.js +304 -0
  34. package/dist/limits.js +14 -0
  35. package/dist/main.js +76 -10
  36. package/dist/paths.js +21 -1
  37. package/dist/settings.js +112 -13
  38. package/dist/tasks/agent.js +18 -4
  39. package/dist/tasks/callbacks.js +20 -1
  40. package/dist/tasks/definitions.js +56 -12
  41. package/dist/tasks/execution.js +5 -1
  42. package/dist/tasks/groups.js +4 -4
  43. package/dist/tasks/messages.js +4 -2
  44. package/dist/tasks/runs.js +2 -2
  45. package/dist/tasks/service.js +16 -6
  46. package/dist/tasks/tool.js +0 -12
  47. package/dist/tools-task.js +155 -0
  48. package/dist/tools.js +875 -0
  49. package/dist/web/auth.js +5 -3
  50. package/dist/web/explorer.js +15 -2
  51. package/dist/web/files.js +1 -1
  52. package/dist/web/instance.js +175 -22
  53. package/dist/web/providers.js +16 -0
  54. package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
  55. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  56. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  57. package/dist/web/public/index.html +5 -8
  58. package/dist/web/public/sw.js +4 -0
  59. package/dist/web/push.js +33 -9
  60. package/dist/web/repos.js +75 -0
  61. package/dist/web/server.js +170 -52
  62. package/dist/web/session-state.js +57 -44
  63. package/dist/web/terminal.js +34 -4
  64. package/dist/web/types.js +5 -0
  65. package/package.json +1 -1
  66. package/skills/pier-boards/SKILL.md +23 -13
  67. package/skills/pier-help/SKILL.md +1 -1
  68. package/skills/pier-slack/SKILL.md +21 -1
  69. package/skills/pier-tasks/SKILL.md +2 -2
  70. package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
  71. package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
package/dist/agent/pi.js CHANGED
@@ -1,18 +1,41 @@
1
- // The ONLY file allowed to import @earendil-works/pi-*. Implements the
2
- // AgentFactory/AgentSession seam from src/core/types.ts on the Pi SDK.
3
- // No Pi type may appear in an exported signature.
1
+ // The only file outside src/extensions allowed to import @earendil-works/pi-*.
2
+ // Implements the AgentFactory/AgentSession seam from src/core/types.ts on the
3
+ // Pi SDK. No Pi type may appear in an exported signature.
4
4
  import { createAgentSession, CredentialSynchronizationError, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
5
+ import { inlineExtensions } from "../extensions/index.js";
5
6
  import { logger } from "../log.js";
6
7
  import { toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
7
8
  import { defaultAgentDir, PiConfigStore } from "./config.js";
9
+ import { IndexedListing } from "./listing.js";
8
10
  import { curateModels, pinFirst } from "./models.js";
9
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
+ });
10
22
  /** Pi's bash tool has no default timeout, so a hung command holds the turn
11
23
  * until someone aborts it — nobody is watching in a scheduled task. Kept below
12
24
  * the default task-run timeout so a stuck command comes back as
13
25
  * a tool error the agent can retry with an explicit longer timeout, instead of
14
26
  * killing the whole run. */
15
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;
32
+ /** A probe nobody is watching is a hung page: the Console waits on this. */
33
+ const PROVIDER_CHECK_TIMEOUT_MS = 20_000;
34
+ /** An ordinary budget, not a token: a 1-token cap is a request no real turn
35
+ * ever makes, and answers about it are answers about a different request. */
36
+ const PROVIDER_CHECK_MAX_TOKENS = 8192;
37
+ /** Neither half of a probe is worth more than a screen. */
38
+ const clip = (text) => text.length > 4000 ? `${text.slice(0, 4000)}\n[… ${text.length - 4000} more characters]` : text;
16
39
  /** Pier's baseline replaces Pi's generic default; a user's SYSTEM.md follows it. */
17
40
  const PIER_SYSTEM_PROMPT = `You are a general-purpose agent with a live workspace: you can read and change files and run shell commands. Act with expert care — do the work, verify results, and state what you could not check.
18
41
 
@@ -35,14 +58,46 @@ const bashTimeoutDefault = (pi) => {
35
58
  }
36
59
  });
37
60
  };
61
+ /**
62
+ * A bundled extension stands down when a copy on disk already registers one of
63
+ * its tools. Pi loads both and reports the clash as a diagnostic nobody reads,
64
+ * leaving two tools of the same name and no way to tell which one answered;
65
+ * the copy the user put there wins, and the journal says so (§5b).
66
+ */
67
+ export const standDownShadowed = (base) => {
68
+ const inline = (ext) => ext.path.startsWith("<inline:");
69
+ const onDisk = new Set(base.extensions.filter((ext) => !inline(ext)).flatMap((ext) => [...ext.tools.keys()]));
70
+ if (!onDisk.size)
71
+ return base;
72
+ return {
73
+ ...base,
74
+ extensions: base.extensions.filter((ext) => {
75
+ const clash = inline(ext) && [...ext.tools.keys()].filter((tool) => onDisk.has(tool));
76
+ if (!clash || !clash.length)
77
+ return true;
78
+ log.info(`bundled ${ext.path} stood down — ${clash.join(", ")} already loaded from disk`);
79
+ return false;
80
+ }),
81
+ };
82
+ };
38
83
  export class PiSession {
39
84
  pi;
40
85
  pinned;
86
+ wrote;
87
+ retention;
41
88
  constructor(pi,
42
89
  /** Operator pins, read per call — the menu can change while we run. */
43
- 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" }) {
44
97
  this.pi = pi;
45
98
  this.pinned = pinned;
99
+ this.wrote = wrote;
100
+ this.retention = retention;
46
101
  }
47
102
  /** Pi's dispose unhooks the one listener that persists and emits, so a turn
48
103
  * started after it runs for real — model call, tools and all — and lands
@@ -98,6 +153,9 @@ export class PiSession {
98
153
  setThinkingLevel(level) {
99
154
  this.pi.setThinkingLevel(level);
100
155
  }
156
+ setCacheRetention(retention) {
157
+ this.retention.value = retention;
158
+ }
101
159
  async pendingQueue() {
102
160
  return {
103
161
  steering: [...this.pi.getSteeringMessages()],
@@ -128,11 +186,73 @@ export class PiSession {
128
186
  if (cancelled)
129
187
  throw new Error("rewind cancelled");
130
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
+ }
131
242
  // Async, so a refusal is a rejected promise: the seam promises callers they
132
243
  // may only `.catch()` (core/types.ts), and dispatch does exactly that.
133
244
  async prompt(text) {
134
245
  this.live();
135
- 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" });
136
256
  }
137
257
  async steer(text) {
138
258
  this.live();
@@ -143,6 +263,11 @@ export class PiSession {
143
263
  return this.pi.followUp(text);
144
264
  }
145
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();
146
271
  this.live();
147
272
  return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
148
273
  }
@@ -177,6 +302,8 @@ export class PiAgentFactory {
177
302
  credentials;
178
303
  providerConfig;
179
304
  pinned;
305
+ enabledExtensions;
306
+ listings;
180
307
  constructor(extraTools = [],
181
308
  /** Appended as a virtual context file, so Pi's own prompt stays intact.
182
309
  * Read per session, not captured once: it carries settings a user can
@@ -193,21 +320,34 @@ export class PiAgentFactory {
193
320
  credentials, providerConfig = new PiConfigStore(),
194
321
  /** Operator-pinned models (Console → Settings → Models), surfaced first in
195
322
  * every picker. A getter for the same reason `instructions` is one. */
196
- pinned = () => []) {
323
+ pinned = () => [],
324
+ /** Which bundled extensions the Console has switched on. A getter for the
325
+ * same reason again: the toggle takes effect on the next session open. */
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()) {
197
330
  this.extraTools = extraTools;
198
331
  this.instructions = instructions;
199
332
  this.skillPaths = skillPaths;
200
333
  this.credentials = credentials;
201
334
  this.providerConfig = providerConfig;
202
335
  this.pinned = pinned;
336
+ this.enabledExtensions = enabledExtensions;
337
+ this.listings = listings;
203
338
  }
204
339
  /** One runtime for the whole process; catalogs are global, not per session. */
205
340
  catalog;
206
- /** Where each listed session lives. `listAll` reads the head of every
207
- * session file on disk (~250ms at 200 sessions, and it only grows), which
208
- * `resume` paid on every cold open web selection, an IM message, a task
209
- * 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. */
210
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;
211
351
  refreshQueue = Promise.resolve();
212
352
  builtinProviderIds;
213
353
  /** Structural fit: CredentialStore mirrors pi-ai's interface of the same
@@ -276,6 +416,65 @@ export class PiAgentFactory {
276
416
  };
277
417
  });
278
418
  }
419
+ /**
420
+ * One real request on the model the operator named. `configured` only ever
421
+ * meant "a credential is stored", and a wrong base URL, a revoked key, a
422
+ * gateway rewriting the request and a model this endpoint has never heard of
423
+ * all look identical until a turn fails hours later.
424
+ *
425
+ * The request goes out through a fetch of our own for one reason: what a
426
+ * provider (or a proxy in front of it) was actually sent, and what it
427
+ * actually said, is the answer here — a summary of either would be Pier's
428
+ * word for someone else's.
429
+ */
430
+ async check(providerId, modelId) {
431
+ const started = Date.now();
432
+ const signal = AbortSignal.timeout(PROVIDER_CHECK_TIMEOUT_MS);
433
+ let request = "";
434
+ let body = Promise.resolve("");
435
+ const recorded = async (input, init) => {
436
+ request = typeof init?.body === "string" ? init.body : "";
437
+ const response = await globalThis.fetch(input, init);
438
+ // Cloned, not consumed: the SDK still needs to read the real stream.
439
+ body = response.clone().text().then(clip, () => "");
440
+ return response;
441
+ };
442
+ const answered = (text, ok) => ({
443
+ ok,
444
+ model: modelId,
445
+ ms: Date.now() - started,
446
+ request: clip(request),
447
+ response: text,
448
+ });
449
+ try {
450
+ const runtime = await this.refreshedRuntime();
451
+ const model = runtime.getModel(providerId, modelId);
452
+ if (!model)
453
+ throw new Error(`unknown model: ${providerId}/${modelId}`);
454
+ const answer = await runtime.completeSimple(model, { messages: [{ role: "user", content: "hi", timestamp: Date.now() }] }, { maxTokens: PROVIDER_CHECK_MAX_TOKENS, signal, fetch: recorded });
455
+ // A refusal can arrive as a message rather than a throw; the stop reason
456
+ // is the only thing separating it from an answer.
457
+ const refused = answer.stopReason === "error" || answer.stopReason === "aborted";
458
+ if (refused) {
459
+ throw new Error(answer.errorMessage ?? `the provider stopped: ${answer.stopReason}`);
460
+ }
461
+ const text = answer.content
462
+ .filter((part) => part.type === "text")
463
+ .map((part) => part.text)
464
+ .join("")
465
+ .trim();
466
+ // An empty answer is still an answer; say which kind of nothing it was.
467
+ return answered(clip(text) || `(no text; stop reason: ${answer.stopReason})`, true);
468
+ }
469
+ catch (err) {
470
+ const error = err instanceof Error ? err.message : String(err);
471
+ log.warn(`provider check failed for ${providerId}/${modelId}`, err);
472
+ const raw = await body;
473
+ return answered(signal.aborted
474
+ ? `no answer within ${PROVIDER_CHECK_TIMEOUT_MS / 1000}s (${error})`
475
+ : raw || error, false);
476
+ }
477
+ }
279
478
  async setup(input) {
280
479
  const builtins = await this.builtinIds();
281
480
  if (input.kind === "builtin" && !builtins.has(input.id)) {
@@ -344,7 +543,11 @@ export class PiAgentFactory {
344
543
  // Pi's generic default, preserving the user's later instruction layer.
345
544
  systemPromptOverride: pierSystemPrompt,
346
545
  additionalSkillPaths: this.skillPaths,
347
- extensionFactories: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
546
+ extensionFactories: [
547
+ { name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true },
548
+ ...inlineExtensions(this.enabledExtensions()),
549
+ ],
550
+ extensionsOverride: standDownShadowed,
348
551
  agentsFilesOverride: (current) => {
349
552
  const content = this.instructions();
350
553
  return {
@@ -359,8 +562,12 @@ export class PiAgentFactory {
359
562
  }
360
563
  async open(cwd, sessionManager, opts = { cwd }) {
361
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);
362
569
  // Generic translation only — tool contracts are data owned by their feature.
363
- const customTools = this.extraTools.map((tool) => defineTool({
570
+ const customTools = active.map((tool) => defineTool({
364
571
  name: tool.name,
365
572
  label: tool.label,
366
573
  description: tool.description,
@@ -395,19 +602,27 @@ export class PiAgentFactory {
395
602
  this.credentials?.assertUnlocked();
396
603
  if (opts.name)
397
604
  sessionManager.appendSessionInfo(opts.name);
398
- const tools = opts.capabilities === "read"
399
- ? ["read", "grep", "find", "ls", ...this.extraTools.map((tool) => tool.name)]
400
- : 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 }));
401
615
  const created = await createAgentSession({
402
616
  cwd,
403
617
  sessionManager,
404
618
  customTools,
405
- tools,
406
- modelRuntime: await this.createRuntime(),
619
+ modelRuntime: runtime,
407
620
  resourceLoader: await this.resourceLoader(cwd),
408
621
  });
409
622
  live = created.session;
410
- const session = new PiSession(live, this.pinned);
623
+ const session = new PiSession(live, this.pinned, () => {
624
+ this.listing = undefined;
625
+ }, retention);
411
626
  if (opts.model)
412
627
  await session.setModel(opts.model);
413
628
  if (opts.thinking)
@@ -416,6 +631,7 @@ export class PiAgentFactory {
416
631
  return session;
417
632
  }
418
633
  async create(opts) {
634
+ this.listing = undefined;
419
635
  return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
420
636
  }
421
637
  async fork(sourceSessionId, opts) {
@@ -435,6 +651,7 @@ export class PiAgentFactory {
435
651
  if (!leafId)
436
652
  throw new Error("cannot fork a session before its first persisted input");
437
653
  manager.createBranchedSession(leafId);
654
+ this.listing = undefined;
438
655
  return this.open(opts.cwd, manager, opts);
439
656
  }
440
657
  async resume(sessionId) {
@@ -450,27 +667,57 @@ export class PiAgentFactory {
450
667
  this.located.delete(sessionId);
451
668
  }
452
669
  }
453
- const infos = await this.listed();
454
- const info = infos.find((s) => s.id === sessionId);
670
+ const info = await this.locate(sessionId);
455
671
  if (!info)
456
672
  throw new Error(`unknown session: ${sessionId}`);
457
673
  return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
458
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;
459
697
  /** Every listing goes through here, so it also refreshes `located`. */
460
- async listed() {
461
- const infos = await SessionManager.listAll();
462
- for (const s of infos) {
463
- this.located.set(s.id, { path: s.path, cwd: s.cwd || process.cwd() });
464
- }
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 };
465
718
  return infos;
466
719
  }
467
720
  async list() {
468
- const infos = await this.listed();
469
- return infos.map((s) => ({
470
- id: s.id,
471
- cwd: s.cwd,
472
- createdAt: s.created.getTime(),
473
- title: s.name ?? (s.firstMessage ? s.firstMessage.slice(0, 80) : undefined),
474
- }));
721
+ return (await this.listed()).map(summaryOf);
475
722
  }
476
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