@timqi/pier 0.0.17 → 0.0.20

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 (95) hide show
  1. package/dist/agent/config-sync.js +123 -0
  2. package/dist/agent/config.js +95 -3
  3. package/dist/agent/events.js +26 -5
  4. package/dist/agent/listing.js +12 -13
  5. package/dist/agent/pi.js +75 -27
  6. package/dist/boards/boards.js +7 -6
  7. package/dist/channels/attach.js +18 -4
  8. package/dist/channels/config.js +7 -2
  9. package/dist/channels/dedup.js +9 -1
  10. package/dist/channels/lark.js +2 -6
  11. package/dist/channels/receipts.js +21 -6
  12. package/dist/channels/slack-tool.js +86 -25
  13. package/dist/channels/slack.js +2 -10
  14. package/dist/channels/telegram.js +1 -0
  15. package/dist/config-sync-fetch.js +97 -0
  16. package/dist/config-sync-task.js +59 -0
  17. package/dist/config-sync.js +179 -0
  18. package/dist/core/hub.js +23 -6
  19. package/dist/core/inbox.js +1 -1
  20. package/dist/core/reply.js +76 -2
  21. package/dist/core/router.js +162 -37
  22. package/dist/db.js +50 -0
  23. package/dist/main.js +32 -1
  24. package/dist/secrets.js +18 -6
  25. package/dist/tasks/agent.js +25 -23
  26. package/dist/tasks/callbacks.js +1 -1
  27. package/dist/tasks/definitions.js +28 -17
  28. package/dist/tasks/execution.js +11 -3
  29. package/dist/tasks/messages.js +58 -16
  30. package/dist/tasks/routes.js +58 -10
  31. package/dist/tasks/runs.js +0 -5
  32. package/dist/tasks/service.js +14 -4
  33. package/dist/tasks/store.js +87 -10
  34. package/dist/tasks/tool.js +8 -4
  35. package/dist/tools.js +32 -2
  36. package/dist/web/auth.js +17 -23
  37. package/dist/web/config-sync.js +91 -0
  38. package/dist/web/config.js +61 -0
  39. package/dist/web/explorer.js +20 -70
  40. package/dist/web/fs.js +159 -0
  41. package/dist/web/instance.js +11 -0
  42. package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
  43. package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
  44. package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
  45. package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
  46. package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
  47. package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
  48. package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
  49. package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
  50. package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
  51. package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
  52. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
  53. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
  54. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
  55. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  56. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  57. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  58. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
  59. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  60. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  61. package/dist/web/public/assets/index-WrsNstrb.js +79 -0
  62. package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
  63. package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
  64. package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
  65. package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
  66. package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
  67. package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
  68. package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
  69. package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
  70. package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
  71. package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
  72. package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
  73. package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
  74. package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
  75. package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
  76. package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
  77. package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
  78. package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
  79. package/dist/web/public/icon.svg.br +0 -0
  80. package/dist/web/public/icon.svg.gz +0 -0
  81. package/dist/web/public/index.html +13 -2
  82. package/dist/web/public/index.html.br +0 -0
  83. package/dist/web/public/index.html.gz +0 -0
  84. package/dist/web/public/manifest.webmanifest.br +0 -0
  85. package/dist/web/public/manifest.webmanifest.gz +0 -0
  86. package/dist/web/public/sw.js.br +0 -0
  87. package/dist/web/public/sw.js.gz +0 -0
  88. package/dist/web/route.js +17 -0
  89. package/dist/web/server.js +152 -66
  90. package/dist/web/terminal.js +2 -7
  91. package/package.json +2 -2
  92. package/skills/pier-slack/SKILL.md +4 -16
  93. package/skills/pier-tasks/SKILL.md +14 -9
  94. package/dist/web/files.js +0 -183
  95. package/dist/web/public/assets/index-DAxKtidH.js +0 -93
@@ -1,8 +1,9 @@
1
1
  // Web workbench backend: REST + SSE, a pure consumer of core.
2
2
  // See docs/design/03-web-workbench.md for the route contract.
3
3
  import { hostname } from "node:os";
4
- import { readFile } from "node:fs/promises";
5
- import { join, relative } from "node:path";
4
+ import { randomUUID } from "node:crypto";
5
+ import { readFile, stat } from "node:fs/promises";
6
+ import { isAbsolute, join, relative } from "node:path";
6
7
  import { fileURLToPath } from "node:url";
7
8
  import { serveStatic } from "@hono/node-server/serve-static";
8
9
  import { Hono } from "hono";
@@ -10,18 +11,31 @@ import { compress } from "hono/compress";
10
11
  import { streamSSE } from "hono/streaming";
11
12
  import { EventHub } from "../core/hub.js";
12
13
  import { logger } from "../log.js";
13
- import { Router } from "../core/router.js";
14
+ import { QueueOperationError, Router } from "../core/router.js";
15
+ import { registerConfigRoutes } from "./config.js";
14
16
  import { registerExplorerRoutes } from "./explorer.js";
15
- import { guarded, registerFileRoutes } from "./files.js";
17
+ import { fileHeaders, MAX_FILE_BYTES, registerFsRoutes, scopedFile } from "./fs.js";
18
+ import { guarded } from "./route.js";
16
19
  import { isThinkingLevel } from "../core/types.js";
17
20
  import { SESSION_TITLE_MAX } from "../limits.js";
18
- import { saveInbound } from "../core/inbox.js";
21
+ import { INBOX_DIR, saveInbound } from "../core/inbox.js";
19
22
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
20
23
  import { RepoIndex } from "./repos.js";
21
24
  import {} from "./session-state.js";
22
25
  import { registerInstanceRoutes } from "./instance.js";
23
26
  import { registerProviderRoutes } from "./providers.js";
24
27
  const log = logger("web");
28
+ async function queueResponse(action, status = 200) {
29
+ try {
30
+ return Response.json(await action(), { status });
31
+ }
32
+ catch (err) {
33
+ const code = err instanceof QueueOperationError
34
+ ? err.reason === "draining" ? 503 : err.reason === "missing" ? 404 : 409
35
+ : 404;
36
+ return Response.json({ error: String(err) }, { status: code });
37
+ }
38
+ }
25
39
  /** A transcript without the bytes nobody has asked to see yet. A step's `args`
26
40
  * and `output` are ~90% of a long session's snapshot and sit inside a
27
41
  * collapsed group; the client fetches one turn's worth when it is opened. */
@@ -38,10 +52,26 @@ export const withTabPrefix = (html, prefix) => prefix
38
52
  ? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&amp;").replace(/</g, "&lt;")} - Pier</title>`)
39
53
  : html;
40
54
  const HEARTBEAT_MS = 15_000;
55
+ /** A reader that stopped reading must not queue frames without bound. Past
56
+ * this much frame text in flight the stream is dropped with a line in the
57
+ * log; EventSource reconnects and replays from its Last-Event-ID. */
58
+ const SSE_HIGH_WATER = 4 * 1024 * 1024;
41
59
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
42
60
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
43
- export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
61
+ export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, channelOf, }) {
44
62
  const app = new Hono();
63
+ const epoch = randomUUID();
64
+ // One frame shared by synchronous fan-out to every watching tab. The epoch
65
+ // and wire encoding stay here; core only stamps transport-blind events.
66
+ let lastEvent = null;
67
+ let lastFrame = "";
68
+ const sseFrame = (e) => {
69
+ if (e !== lastEvent) {
70
+ lastEvent = e;
71
+ lastFrame = `data: ${JSON.stringify(e)}\nid: ${epoch}:${e.seq}\n\n`;
72
+ }
73
+ return lastFrame;
74
+ };
45
75
  // A finished turn marks its session unread until some client reports it was
46
76
  // seen (session selected + tab visible → POST read below). Server-side so
47
77
  // every client shows the same attention state. Streaming → idle is the
@@ -62,8 +92,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
62
92
  });
63
93
  // Repository identity per project directory, refreshed off the request path.
64
94
  const repos = new RepoIndex(() => hub.emitWorkspace({ type: "sessions-changed" }));
65
- /** Background runs this session launched that are still in flight. */
66
- const activeRuns = (id) => backgroundRuns?.(id).filter((r) => r.state === "queued" || r.state === "running").length ?? 0;
95
+ /** Background runs still in flight, per launching session. One query for a
96
+ * whole list: asking row by row loaded every run object of every session to
97
+ * draw one dot each. */
98
+ const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
67
99
  /** The web channel's session for `id` — every session route resolves here. */
68
100
  const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
69
101
  // Sessions created here that Pi doesn't list yet — it persists a session
@@ -118,7 +150,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
118
150
  // decided about it, and what is true of it right now.
119
151
  // The listing's `modified` is dropped rather than forwarded: it dates a
120
152
  // transcript, and no surface renders it.
121
- const present = ({ modified: _drop, ...s }, own) => {
153
+ const present = ({ modified: _drop, ...s }, own, active) => {
122
154
  return {
123
155
  ...s,
124
156
  ...(own?.sort === undefined ? {} : { sort: own.sort }),
@@ -133,7 +165,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
133
165
  listed: own?.pinned ?? false,
134
166
  unread: own?.unread ?? false,
135
167
  channel: channelOf?.(s.id) ?? "web",
136
- activeRuns: activeRuns(s.id),
168
+ activeRuns: active.get(s.id) ?? 0,
137
169
  };
138
170
  };
139
171
  // The rail: the same rows as the full list, minus everything Projects is not
@@ -141,8 +173,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
141
173
  // the summaries out of SQLite, which is what had to be kept in step.
142
174
  app.get("/api/projects", async (c) => {
143
175
  const flags = state.flags();
176
+ const active = activeRuns();
144
177
  return c.json((await allSessions())
145
- .map((s) => present(s, flags.get(s.id)))
178
+ .map((s) => present(s, flags.get(s.id), active))
146
179
  .filter((row) => row.listed));
147
180
  });
148
181
  // One drag, one write of the list that changed: the projects, or one
@@ -167,7 +200,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
167
200
  });
168
201
  app.get("/api/sessions", async (c) => {
169
202
  const flags = state.flags();
170
- return c.json((await allSessions()).map((s) => present(s, flags.get(s.id))));
203
+ const active = activeRuns();
204
+ return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
171
205
  });
172
206
  app.post("/api/sessions", async (c) => {
173
207
  const body = await c.req.json().catch(() => ({}));
@@ -219,16 +253,28 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
219
253
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
220
254
  const id = c.req.param("id");
221
255
  const session = await ensureLoadable(id);
222
- return c.json({
223
- turns: (await session.history()).map(slim),
224
- lastSeq: hub.lastSeq(id),
225
- model: session.model ?? null,
226
- state: session.state,
227
- context: session.contextUsage ?? null,
228
- thinkingLevel: session.thinkingLevel,
229
- queue: await session.pendingQueue(),
230
- backgroundRuns: backgroundRuns?.(id) ?? [],
231
- });
256
+ // Async seam reads can straddle an event. Never label older content with a
257
+ // newer cursor, and never spin indefinitely if the session stays busy.
258
+ for (let attempt = 0; attempt < 3; attempt++) {
259
+ const lastSeq = hub.lastSeq(id);
260
+ const turns = (await session.history()).map(slim);
261
+ const queue = await session.pendingQueue();
262
+ if (hub.lastSeq(id) !== lastSeq)
263
+ continue;
264
+ return c.json({
265
+ turns, lastSeq, epoch,
266
+ model: session.model ?? null,
267
+ state: session.state,
268
+ context: session.contextUsage ?? null,
269
+ thinkingLevel: session.thinkingLevel,
270
+ queue,
271
+ queueRecovery: router.recoveryOf(id),
272
+ queueUncertain: router.queueUncertain(id),
273
+ backgroundRuns: backgroundRuns?.(id) ?? [],
274
+ });
275
+ }
276
+ log.warn(`snapshot for ${id} changed during all 3 reads`);
277
+ return c.json({ error: "Session changed while loading history; retry loading the session" }, 503);
232
278
  });
233
279
  // One turn's activity in full, for the group the user just opened. Indexed
234
280
  // like the edit route below; the steps carry their own id and tool name so a
@@ -245,6 +291,29 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
245
291
  // hands back what a surface shows, not the untruncated tool result.
246
292
  return c.json({ steps: turn.steps ?? [] });
247
293
  });
294
+ // Attachments, both directions: the agent links a file it produced, the chat
295
+ // renders a file the user sent. Read-only, and only from the session's own
296
+ // cwd or the inbox inbound files land in (core/inbox.ts) — a session route
297
+ // rather than an /api/fs one, because the session *is* the scope here.
298
+ guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
299
+ const raw = c.req.query("path");
300
+ if (!raw)
301
+ return c.json({ error: "path required" }, 400);
302
+ const id = c.req.param("id");
303
+ const cwd = nascent.get(id)?.cwd ?? (await factory.find(id))?.cwd;
304
+ // Absolute only: a link into a session's files is written by the agent or
305
+ // by Pier, and neither of them writes a path relative to anything.
306
+ const file = cwd && isAbsolute(raw) ? await scopedFile([cwd, INBOX_DIR], raw) : null;
307
+ if (!file)
308
+ return c.json({ error: "no such file" }, 404);
309
+ if ((await stat(file)).size > MAX_FILE_BYTES)
310
+ return c.json({ error: "file too large" }, 413);
311
+ const bytes = await readFile(file);
312
+ return c.body(bytes, 200, {
313
+ ...fileHeaders(file, bytes, c.req.query("download") === "1"),
314
+ "cache-control": "private, max-age=60",
315
+ });
316
+ });
248
317
  // Composer attachments: bytes land in the inbox, the message carries the
249
318
  // path as a `[name](file:///…)` line the client builds itself — upload
250
319
  // first, so the text it sends (and optimistically renders) is final.
@@ -354,8 +423,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
354
423
  return c.json({ ok: true }, 202);
355
424
  });
356
425
  // Promote queued messages: "steer" delivers them into the running turn,
357
- // "restart" aborts the turn and sends them as a fresh prompt. Pi has no
358
- // promote primitive, so this is clear-queue + re-dispatch through core.
426
+ // "restart" aborts the turn and sends them as a fresh prompt. Core owns
427
+ // exclusion and retains originals through the asynchronous handoff.
359
428
  guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
360
429
  const id = c.req.param("id");
361
430
  const body = await c.req.json().catch(() => null);
@@ -363,38 +432,20 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
363
432
  if (mode !== "steer" && mode !== "restart") {
364
433
  return c.json({ error: "mode must be steer or restart" }, 400);
365
434
  }
366
- // Same reason as edit above: a refused dispatch must not cost the queue.
367
- if (router.isDraining())
368
- return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
369
- const session = await ensure(id);
370
- const { steering, followUp } = await session.clearQueue();
371
- const text = [...steering, ...followUp].join("\n").trim();
372
- if (!text)
373
- return c.json({ error: "queue is empty" }, 409);
374
- if (mode === "restart")
375
- await router.abort(id); // resolves once idle
376
- // No sender here, unlike the other dispatches: the queued texts were
377
- // headed when they were first dispatched — and in a shared session they
378
- // are other speakers' words, which an operator header would claim.
379
- await router.dispatch({
380
- key: { channelId: "web", conversationId: id },
381
- senderId: "web",
382
- text,
383
- mode: mode === "steer" ? "steer" : "auto",
384
- });
385
- return c.json({ delivered: text }, 202);
435
+ return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
386
436
  });
387
437
  // Recall: drop all pending queued messages and hand them back (composer restore).
388
438
  guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
389
439
  const id = c.req.param("id");
390
- const session = await ensure(id);
391
- const { steering, followUp } = await session.clearQueue();
392
- // Those messages carried the header that told the session who is speaking,
393
- // and they are going back to the composer instead of to the model.
394
- if (steering.length || followUp.length)
395
- router.forgetSender(id);
396
- return c.json({ messages: [...steering, ...followUp] });
440
+ return queueResponse(async () => {
441
+ const { steering, followUp } = await router.recallQueue(id);
442
+ return { messages: [...steering, ...followUp] };
443
+ });
397
444
  });
445
+ guarded(app, "POST", "/api/sessions/:id/queue/recovery/:batchId/ack", 404, async (c) => queueResponse(async () => {
446
+ router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
447
+ return { ok: true };
448
+ }));
398
449
  // Shrink the context on demand: Pi summarizes the older transcript away and
399
450
  // the session continues from the summary. Refused while streaming, like the
400
451
  // edit route above and for the same reason — Pi's own compaction aborts a
@@ -446,16 +497,40 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
446
497
  }));
447
498
  app.get("/api/sessions/:id/events", (c) => {
448
499
  const id = c.req.param("id");
449
- const lastId = Number(c.req.header("Last-Event-ID") ?? "") ||
450
- Number(c.req.query("after") ?? "") ||
451
- 0;
500
+ const cursor = c.req.header("Last-Event-ID") ?? c.req.query("after") ?? "";
501
+ const match = /^([^:]+):(0|[1-9]\d*)$/.exec(cursor);
502
+ const lastId = match ? Number(match[2]) : NaN;
452
503
  return streamSSE(c, async (stream) => {
453
- const send = (e) => stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
454
- for (const e of hub.replay(id, lastId))
455
- await send(e);
456
- // Same as above: the client may be gone by the time an event fires.
457
- const unsubscribe = hub.subscribe(id, (e) => void send(e).catch(() => { }));
504
+ if (match?.[1] !== epoch || !hub.covers(id, lastId)) {
505
+ await stream.writeSSE({ event: "reset", data: "snapshot required" });
506
+ return;
507
+ }
508
+ let queued = 0; // frame chars written but not yet drained by the reader
509
+ const send = (frame) => {
510
+ if (stream.aborted || stream.closed)
511
+ return;
512
+ if (queued > SSE_HIGH_WATER) {
513
+ log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
514
+ stream.abort(); // unsubscribes; the client reconnects and replays
515
+ return;
516
+ }
517
+ queued += frame.length;
518
+ void stream
519
+ .write(frame)
520
+ .catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
521
+ .finally(() => (queued -= frame.length));
522
+ };
523
+ // Subscribe before the replay write can wait on its reader: an event that
524
+ // arrives while that write is backpressured must queue behind it, not fall
525
+ // between replay() and subscribe(). Both snapshots happen synchronously,
526
+ // so live writes cannot overtake the replay write.
527
+ const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
458
528
  stream.onAbort(unsubscribe);
529
+ // One write for the whole replay: a reconnect after a busy turn used to
530
+ // cost an await per event before the client saw any of them.
531
+ const missed = hub.replay(id, lastId);
532
+ if (missed.length)
533
+ await stream.write(missed.map(sseFrame).join(""));
459
534
  // Heartbeat keeps proxies from closing the stream; loop ends on abort.
460
535
  while (!stream.aborted) {
461
536
  await stream.sleep(HEARTBEAT_MS);
@@ -509,12 +584,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
509
584
  onSettingsChanged: () => recycle("instance settings"),
510
585
  });
511
586
  registerProviderRoutes(app, providers, () => recycle("provider configuration"));
512
- registerFileRoutes(app, {
513
- factory,
514
- config,
515
- nascentCwd: (id) => nascent.get(id)?.cwd,
516
- onConfigWritten: () => recycle("an agent file"),
517
- });
587
+ registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
588
+ registerFsRoutes(app);
518
589
  registerExplorerRoutes(app);
519
590
  // serveStatic resolves `root` against the *working directory*, and an
520
591
  // installed Pier is started from wherever the operator happens to be. The
@@ -530,6 +601,11 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
530
601
  // at /login learns neither. Read once: neither can change under a process.
531
602
  const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
532
603
  let shell = null;
604
+ // The one file the precompressed siblings below cannot cover: this route
605
+ // answers from the patched string, not from disk, and it is re-fetched on
606
+ // every navigation because it may not be cached. Exact path, like the two
607
+ // API routes above — never the SSE streams.
608
+ app.use("/", compress());
533
609
  app.get("/", async (c, next) => {
534
610
  // A release replaces hashed assets. Revalidate the shell on every
535
611
  // navigation so a cached index cannot name bundles that no longer exist.
@@ -563,6 +639,16 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
563
639
  c.header("cache-control", "private, max-age=31536000, immutable");
564
640
  await next();
565
641
  });
566
- app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
642
+ // `precompressed` looks for a `.br`/`.gz` sibling of the file it is about to
643
+ // serve and hands that over when the request accepts the encoding; the build
644
+ // writes them (vite.config.ts). Without it the 325 kB bundle, the 87 kB
645
+ // stylesheet and the 636 kB terminal emulator all go out verbatim.
646
+ // serveStatic only sets Vary when it selects a sibling. Identity must carry
647
+ // it too, or a cache can reuse that response for a later Brotli request.
648
+ app.use("/*", async (c, next) => {
649
+ await next();
650
+ c.header("Vary", "Accept-Encoding");
651
+ });
652
+ app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || ".", precompressed: true }));
567
653
  return app;
568
654
  }
@@ -6,11 +6,10 @@
6
6
  // to survive that — durability is tmux's job, not ours). This is the one
7
7
  // WebSocket surface: a keystroke is a round trip, which SSE cannot carry
8
8
  // upstream. Everything else stays on the event stream.
9
- import { realpath, stat } from "node:fs/promises";
10
- import { isAbsolute } from "node:path";
11
9
  import { spawn } from "node-pty";
12
10
  import { WebSocketServer } from "ws";
13
11
  import { logger } from "../log.js";
12
+ import { scoped } from "./fs.js";
14
13
  import { ALL, upgradeAuthorized } from "./auth.js";
15
14
  const log = logger("terminal");
16
15
  /** Live shells at once — a guard against forgotten spawns, not a quota. */
@@ -71,11 +70,7 @@ export class TerminalHub {
71
70
  async attach(cwd, sock) {
72
71
  let key;
73
72
  try {
74
- if (!isAbsolute(cwd))
75
- throw new Error("cwd must be an absolute directory");
76
- key = await realpath(cwd);
77
- if (!(await stat(key)).isDirectory())
78
- throw new Error("cwd must be an absolute directory");
73
+ key = await scoped(cwd); // absolute, real, and a directory — or it throws
79
74
  }
80
75
  catch (err) {
81
76
  control(sock, { t: "error", message: String(err) });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.17",
3
+ "version": "0.0.20",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",
@@ -44,7 +44,7 @@
44
44
  "vitest": "^4.1.11"
45
45
  },
46
46
  "dependencies": {
47
- "@earendil-works/pi-coding-agent": "^0.84.2",
47
+ "@earendil-works/pi-coding-agent": "^0.85.1",
48
48
  "@hono/node-server": "^2.1.1",
49
49
  "@larksuiteoapi/node-sdk": "^1.73.0",
50
50
  "croner": "^10.0.1",
@@ -24,20 +24,13 @@ this: there is no current conversation and `channel` is required.
24
24
 
25
25
  `{"operation":"channels"}` lists what Pier can reach (`id`, `name`, `kind`,
26
26
  `respondsToMessages`); pass either an id or a `#name` anywhere a channel is
27
- wanted. `not_in_channel` means someone must run `/invite @Pier`.
27
+ wanted.
28
28
 
29
29
  ## Never ask for an id
30
30
 
31
- You already have them:
32
-
33
- - **The person talking to you** a message may start with `[name<id> time]`,
34
- added by Pier, not typed by them. It appears only on a change — new speaker,
35
- a time gap, a new day — so the last one you saw still applies (a gap alone
36
- shows as time only, like `[14:23]`).
37
- - **Anyone in a transcript** — every line carries `name[id]`.
38
- - **This channel and thread** — from `context`.
39
-
40
- Asking a human to paste their own user ID is never acceptable.
31
+ You already have them: the sender header on the message you are answering,
32
+ `name[id]` on every line of a transcript, and `context` for this channel and
33
+ thread. Asking a human to paste their own user ID is never acceptable.
41
34
 
42
35
  ## Reading
43
36
 
@@ -107,8 +100,6 @@ The response carries `ts` and `threadTs` for replying under what you posted.
107
100
  `text` replaces the message outright; there is no partial edit. Read the
108
101
  message first if you are changing part of it.
109
102
 
110
- - Slack only lets Pier edit what **its own bot** posted — anyone else's message
111
- answers `cant_update_message`, and the answer is to reply, not to retry.
112
103
  - `ts` is always explicit, as with `delete`, and means nothing outside the
113
104
  channel it was read in.
114
105
  - The old text is gone — Slack keeps no version a reader can open, and may not
@@ -123,9 +114,6 @@ message first if you are changing part of it.
123
114
  {"operation":"delete","channel":"#ops","ts":"1717243800.000100"}
124
115
  ```
125
116
 
126
- Slack only lets Pier delete what **its own bot** posted — a person's message
127
- answers `cant_delete_message`, and the answer is to ask them, not to retry.
128
-
129
117
  - `ts` is always explicit. There is no "delete the last one" and no default
130
118
  from the thread you are in: the wrong `ts` cannot be undone.
131
119
  - A `ts` means nothing outside the conversation it came from — pass the
@@ -32,12 +32,18 @@ A summary's `triggerSource` is who fired that run (`agent` when you did, plus
32
32
  `manual` / `cron` / `watch` / `task`); a definition's `trigger` is only its
33
33
  schedule policy, where `manual` means on-demand — by a human or by you.
34
34
 
35
- - `fresh`: clean context, requires `cwd`. `fork`: child starts with a copy of
36
- your conversation use when the task needs your context. `reuse`: sends
37
- work to an existing session by id.
35
+ - `fresh`: clean context, requires `cwd` how a child normally starts.
36
+ `reuse`: sends work to an existing session by id, continuing its history.
37
+ There is no way to copy your context into a child: what the child needs, you
38
+ write down.
39
+ - **The handoff is the prompt.** A child cannot see your discussion, so state:
40
+ the goal; the decisions *currently* in force (not the ones you argued out of);
41
+ the constraints the user added late; absolute paths; what "done" means and how
42
+ to verify it. Writing it is also the check — a decision you cannot state in a
43
+ sentence was not settled.
38
44
  - The child inherits your current model unless `launch.model` is set.
39
45
  Unknown models fail with the available list in the error. `launch` applies to
40
- `fresh` and `fork` only — a `reuse` session owns its own model and tools.
46
+ `fresh` only — a `reuse` session owns its own model and tools.
41
47
  - The child has the same tools you do.
42
48
  - Runs time out after 900s by default; set `timeoutSeconds` in the draft for
43
49
  longer jobs.
@@ -65,9 +71,8 @@ group in core — you never track run ids across turns:
65
71
  ## Chains
66
72
 
67
73
  Run step 1, end your turn, receive its callback, splice the needed part into
68
- step 2's prompt. Children share nothing implicitly (except fork's copied
69
- history) — every prompt must be self-contained: paths, acceptance criteria,
70
- expected output format. Branching and retries are your own logic between
74
+ step 2's prompt. Children share nothing implicitly every prompt must be
75
+ self-contained: paths, acceptance criteria, expected output format. Branching and retries are your own logic between
71
76
  turns.
72
77
 
73
78
  ## Choosing a model and thinking level
@@ -103,8 +108,8 @@ list in the error, so a stale guess costs one call.
103
108
 
104
109
  `{"operation":"contact","reason":"decision","message":"..."}` returns a
105
110
  receipt immediately. State what you are waiting for and **end your turn** —
106
- never spin or poll. The answer arrives as a follow-up if your run is still
107
- active; if your run already finished, Pier resumes your session with the
111
+ never spin or poll. The answer is steered into your current turn if your run is
112
+ still active; if your run already finished, Pier resumes your session with the
108
113
  reply as the prompt. One open decision per run.
109
114
 
110
115
  As a parent: the question arrives as a system input with a message id, steered