@timqi/pier 0.1.0 → 0.1.2

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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
  91. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  92. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
  94. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  95. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
  97. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  98. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  99. package/dist/web/public/assets/index-DbFu15NN.js +85 -0
  100. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  101. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
  106. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
  109. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  110. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
  112. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
  115. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -16,8 +16,7 @@ import { registerConfigRoutes } from "./config.js";
16
16
  import { registerExplorerRoutes } from "./explorer.js";
17
17
  import { fileHeaders, MAX_FILE_BYTES, registerFsRoutes } from "./fs.js";
18
18
  import { guarded } from "./route.js";
19
- import { isThinkingLevel } from "../core/types.js";
20
- import { SESSION_TITLE_MAX } from "../limits.js";
19
+ import { isThinkingLevel, SESSION_TITLE_MAX } from "../core/types.js";
21
20
  import { saveInbound } from "../core/inbox.js";
22
21
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
23
22
  import {} from "./session-state.js";
@@ -35,33 +34,47 @@ async function queueResponse(action, status = 200) {
35
34
  return Response.json({ error: String(err) }, { status: code });
36
35
  }
37
36
  }
38
- /** A transcript without the bytes nobody has asked to see yet. A step's `args`
39
- * and `output` are ~90% of a long session's snapshot and sit inside a
40
- * collapsed group; the client fetches one turn's worth when it is opened. */
37
+ /** A step's `args` and `output` are ~90% of a long session's snapshot and sit
38
+ * in a collapsed group; the client fetches one turn's worth when opened. */
41
39
  const slim = (turn) => turn.steps
42
40
  ? { ...turn, steps: turn.steps.map(({ args: _args, output: _output, ...step }) => step) }
43
41
  : turn;
44
- /** What goes in front of `Pier` in the tab: `$PIER_TITLE`, then the machine.
45
- * The label leads because a tab is narrow and "which instance is this" is the
46
- * question it has to answer before the browser truncates — `staging - g1`. */
42
+ /** `$PIER_TITLE`, then the machine: the label leads because a tab is narrow
43
+ * and "which instance" must survive truncation. */
47
44
  export const tabPrefix = (title, host) => [title?.trim(), host.trim()].filter(Boolean).join(" - ").slice(0, 60);
48
- /** `<title>staging - g1 - Pier</title>`. Nothing to say, or a shell that does
49
- * not say `Pier`: leave it exactly as built. */
50
45
  export const withTabPrefix = (html, prefix) => prefix
51
46
  ? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&amp;").replace(/</g, "&lt;")} - Pier</title>`)
52
47
  : html;
53
48
  const HEARTBEAT_MS = 15_000;
54
- /** A reader that stopped reading must not queue frames without bound. Past
55
- * this much frame text in flight the stream is dropped with a line in the
56
- * log; EventSource reconnects and replays from its Last-Event-ID. */
49
+ /** Past this much frame text in flight a stream is dropped. */
57
50
  const SSE_HIGH_WATER = 4 * 1024 * 1024;
51
+ /** Both SSE routes: Hono queues every write, so backpressure alone never stops
52
+ * a caller from adding more. Neither stream needs durable replay to recover —
53
+ * the session stream resumes from Last-Event-ID, the workspace stream re-lists
54
+ * — so a reader past the ceiling is dropped instead of buffered. */
55
+ function boundedWriter(stream, who) {
56
+ let queued = 0; // frame chars written but not yet drained by the reader
57
+ return (frame) => {
58
+ if (stream.aborted || stream.closed)
59
+ return;
60
+ if (queued > SSE_HIGH_WATER) {
61
+ log.warn(`dropping slow event client for ${who} — ${queued} chars queued`);
62
+ stream.abort(); // unsubscribes; the client reconnects
63
+ return;
64
+ }
65
+ queued += frame.length;
66
+ void stream
67
+ .write(frame)
68
+ .catch((err) => log.warn(`event write for ${who} failed: ${String(err)}`))
69
+ .finally(() => (queued -= frame.length));
70
+ };
71
+ }
58
72
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
59
73
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
60
74
  export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, taskSessions, channelOf, }) {
61
75
  const app = new Hono();
62
76
  const epoch = randomUUID();
63
- // One frame shared by synchronous fan-out to every watching tab. The epoch
64
- // and wire encoding stay here; core only stamps transport-blind events.
77
+ // One frame shared by synchronous fan-out to every watching tab.
65
78
  let lastEvent = null;
66
79
  let lastFrame = "";
67
80
  const sseFrame = (e) => {
@@ -71,19 +84,11 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
71
84
  }
72
85
  return lastFrame;
73
86
  };
74
- // A finished turn marks its session unread until some client reports it was
75
- // seen (session selected + tab visible → POST read below). Server-side so
76
- // every client shows the same attention state. Streaming → idle is the
77
- // trigger — same transition the client notification uses — and it needs a
78
- // start we witnessed, so a session that boots idle stays untouched.
79
- //
80
- // Only for the workbench's own sessions: an IM turn was already delivered to
81
- // the chat it came from and a subagent's to its supervisor by callback, so no
82
- // look here is owed for either — and none could clear the mark either, since
83
- // the ack needs the session on screen. Marked anyway, they were a flag that
84
- // only ever accumulated, and every reader had to subtract them again. The two
85
- // facts are the ones the list already draws a row from (`present` below): no
86
- // durable conversation row, and not a session a run made for itself.
87
+ // Server-side so every client shows the same attention state; it needs a
88
+ // start we witnessed, so a session that boots idle stays untouched. Only the
89
+ // workbench's own sessions: an IM turn or a subagent's was delivered
90
+ // elsewhere, and nothing could ever clear the mark (the ack needs the session
91
+ // on screen).
87
92
  const workbenchOwn = (id) => (channelOf?.(id) ?? "web") === "web" && !(taskSessions?.().has(id) ?? false);
88
93
  const runningNow = new Set();
89
94
  hub.subscribeWorkspace((e) => {
@@ -100,23 +105,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
100
105
  state.setUnread(e.sessionId, true);
101
106
  hub.emitWorkspace({ type: "sessions-changed" });
102
107
  });
103
- /** Background runs still in flight, per launching session. One query for a
104
- * whole list: asking row by row loaded every run object of every session to
105
- * draw one dot each. */
108
+ /** One query for a whole list, not one per row. */
106
109
  const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
107
- /** The web channel's session for `id` — every session route resolves here. */
108
110
  const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
109
- // Sessions created here that Pi doesn't list yet — it persists a session
110
- // only once the first assistant message lands. Merged into the list below
111
- // so every client sees a new session immediately; dropped once Pi lists it.
111
+ // Pi persists a session only once the first assistant message lands; until
112
+ // then the rail lists it from here.
112
113
  const nascent = new Map();
113
- /** `ensure`, plus ghost cleanup. A session created and never messaged does
114
- * not survive a restart or an eviction (Pi persisted nothing), but while
115
- * this process lives it is in `nascent` and therefore in the rail — left
116
- * alone, clicking it 404s forever. The load path is where a ghost is
117
- * discovered, so it is where the entry and its row are dropped and every
118
- * rail told; the 404 then says what happened instead of looking like a
119
- * crash (§5b). */
114
+ /** A session created and never messaged does not survive an eviction (Pi
115
+ * persisted nothing) but is still in `nascent`; left alone, clicking it 404s
116
+ * forever. Dropped here, and the 404 says what happened (§5). */
120
117
  const ensureLoadable = async (id) => {
121
118
  try {
122
119
  return await ensure(id);
@@ -131,23 +128,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
131
128
  throw err;
132
129
  }
133
130
  };
134
- // A listing stats every session file and parses whatever grew — milliseconds
135
- // warm, one scan cold. Concurrent consumers share it, whatever the factory
136
- // behind the seam retains of its own; nothing here is cached past the last
137
- // of them.
131
+ // Concurrent consumers share one scan; nothing is cached past the last of them.
138
132
  let listing;
139
133
  const listSessions = () => listing ??= factory.list().finally(() => {
140
134
  listing = undefined;
141
135
  });
142
- /** Every session the rail may show: what Pi has written, plus the ones
143
- * created here that it has not persisted yet, minus the ones task runs
144
- * made for themselves. */
145
136
  const allSessions = async () => {
146
137
  const sessions = await listSessions();
147
138
  for (const s of sessions)
148
139
  nascent.delete(s.id);
149
- // A session created but never prompted would otherwise be listed forever —
150
- // and, since creation ranks it, hold a working-set slot forever too.
140
+ // A session created but never prompted would otherwise hold a working-set
141
+ // slot forever.
151
142
  for (const [id, n] of nascent) {
152
143
  if (Date.now() - n.createdAt > 86_400_000) {
153
144
  nascent.delete(id);
@@ -160,10 +151,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
160
151
  ...sessions.filter((s) => !owned.has(s.id)),
161
152
  ];
162
153
  };
163
- // One session as every list renders it: the summary, what the workbench
164
- // decided about it, and what is true of it right now. `rank` is the place in
165
- // the rail's working set, when it has one; `modified` rides along for the
166
- // row's tooltip, and orders nothing.
154
+ // `rank` is the place in the rail's working set; `modified` is for the
155
+ // row's tooltip and orders nothing.
167
156
  const present = (s, own, active) => ({
168
157
  ...s,
169
158
  ...(own?.rank === undefined ? {} : { rank: own.rank }),
@@ -185,6 +174,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
185
174
  const active = activeRuns();
186
175
  return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
187
176
  });
177
+ // One session by id, the listing's filters aside: a task run's own session is
178
+ // never a row (allSessions), and the pane that opened it from Runs still has
179
+ // a header to name and a session info panel to fill.
180
+ app.get("/api/sessions/:id", async (c) => {
181
+ const id = c.req.param("id");
182
+ const n = nascent.get(id);
183
+ const summary = n ? { id, ...n } : (await listSessions()).find((s) => s.id === id);
184
+ if (!summary)
185
+ return c.json({ error: `no session ${id}` }, 404);
186
+ return c.json(present(summary, state.flags().get(id), activeRuns()));
187
+ });
188
188
  // What was said, across every session: the palette's Messages section. The
189
189
  // factory owns the index and the ranking; an empty query is an empty answer,
190
190
  // not a listing of everything.
@@ -194,28 +194,22 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
194
194
  });
195
195
  app.post("/api/sessions", async (c) => {
196
196
  const body = await c.req.json().catch(() => ({}));
197
- // A session always starts in its project directory — never in pier's own.
197
+ // Never in pier's own directory.
198
198
  if (typeof body.cwd !== "string" || !body.cwd)
199
199
  return c.json({ error: "cwd required" }, 400);
200
- // The seam records the real path (agent/pi.ts), and the row below stands in
201
- // for a listing until Pi persists the session — up to a day for one never
202
- // prompted. Resolved here too, or that row is the one place a directory
203
- // reached through a symlink still looks like a project of its own.
200
+ // Resolved like the seam does (agent/pi.ts), or the nascent row is the one
201
+ // place a symlinked directory still looks like a project of its own.
204
202
  const cwd = await realpath(body.cwd).catch(() => body.cwd);
205
203
  const session = await factory.create({ cwd });
206
204
  const createdAt = Date.now();
207
205
  nascent.set(session.id, { cwd, createdAt });
208
206
  router.attach({ channelId: "web", conversationId: session.id }, session);
209
- // Created is as good as spoken to: the person who clicked New is about to
210
- // type into it, and a row born below the working set would jump on the
211
- // first message. Ghosts give the slot back (`ensureLoadable`, the expiry
212
- // above).
207
+ // Created is as good as spoken to: a row born below the working set would
208
+ // jump on the first message.
213
209
  state.promote(session.id);
214
210
  hub.emitWorkspace({ type: "sessions-changed" });
215
211
  return c.json({ id: session.id }, 201);
216
212
  });
217
- // Seen = read: a client with the session selected and the tab visible acks
218
- // here; the broadcast moves every other client's dot back to idle.
219
213
  app.post("/api/sessions/:id/read", (c) => {
220
214
  const id = c.req.param("id");
221
215
  if (state.unread(id)) {
@@ -224,13 +218,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
224
218
  }
225
219
  return c.json({ ok: true });
226
220
  });
227
- // The two responses big enough to matter: a transcript, and one turn's tool
228
- // detail. Scoped to these routes on purpose — compressing the SSE streams
229
- // would sit on events until the encoder's buffer filled.
221
+ // Only these two: compressing the SSE streams would sit on events until the
222
+ // encoder's buffer filled.
230
223
  app.use("/api/sessions/:id/history", compress());
231
224
  app.use("/api/sessions/:id/turns/:index/steps", compress());
232
- // Snapshot: everything a fresh client needs before it starts consuming
233
- // deltas from SSE — transcript, live state, pending queue, model.
234
225
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
235
226
  const id = c.req.param("id");
236
227
  const session = await ensureLoadable(id);
@@ -268,21 +259,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
268
259
  const turn = (await (await ensure(c.req.param("id"))).history())[index];
269
260
  if (!turn)
270
261
  return c.json({ error: `no turn at index ${index}` }, 404);
271
- // Already capped at MAX_STEP_OUTPUT by the transcript rebuild: this route
272
- // hands back what a surface shows, not the untruncated tool result.
273
262
  return c.json({ steps: turn.steps ?? [] });
274
263
  });
275
- // Attachments, both directions: the agent links a file it produced, the chat
276
- // renders a file the user sent. Read-only, and any readable file — the
277
- // boundary here is the Console password (web/fs.ts), not the session's cwd:
278
- // a cwd is chosen by whoever creates the session, so confining to it stopped
279
- // nothing and left a report the agent wrote elsewhere as a dead card.
264
+ // Any readable file: the boundary is the Console password (web/fs.ts), not
265
+ // the session's cwd, which is chosen by whoever creates the session.
280
266
  guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
281
267
  const raw = c.req.query("path");
282
268
  if (!raw)
283
269
  return c.json({ error: "path required" }, 400);
284
- // Absolute only: a link into a session's files is written by the agent or
285
- // by Pier, and neither of them writes a path relative to anything.
270
+ // Absolute only: neither the agent nor Pier writes a relative link.
286
271
  const file = isAbsolute(raw) ? await realpath(raw).catch(() => null) : null;
287
272
  const info = file ? await stat(file).catch(() => null) : null;
288
273
  if (!file || !info?.isFile())
@@ -295,9 +280,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
295
280
  "cache-control": "private, max-age=60",
296
281
  });
297
282
  });
298
- // Composer attachments: bytes land in the inbox, the message carries the
299
- // path as a `[name](file:///…)` line the client builds itself — upload
300
- // first, so the text it sends (and optimistically renders) is final.
283
+ // Upload first, so the text the client sends and optimistically renders is final.
301
284
  guarded(app, "POST", "/api/inbox", 400, async (c) => {
302
285
  const body = await c.req.json().catch(() => null);
303
286
  if (typeof body?.data !== "string" ||
@@ -314,8 +297,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
314
297
  return c.json({ error: "invalid file" }, 400);
315
298
  return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
316
299
  });
317
- // Backend model catalog, no session needed: surfaces that configure what a
318
- // *future* session launches with (IM chats) have none to ask.
300
+ // No session needed: surfaces configuring a *future* session have none to ask.
319
301
  app.get("/api/models", async (c) => {
320
302
  try {
321
303
  return c.json(await factory.availableModels());
@@ -363,19 +345,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
363
345
  const { sessionId } = await router.dispatch({
364
346
  key: { channelId: "web", conversationId: id },
365
347
  senderId: "web",
366
- // Named, not anonymous: a session reached from a group chat as well as
367
- // from here attributes an unheaded message to whoever spoke last
368
- // (core/identity.ts), which is the operator's own words in someone
369
- // else's mouth.
348
+ // Named: in a session also reached from a group chat, an unheaded message
349
+ // is attributed to whoever spoke last (core/identity.ts).
370
350
  sender: { id: "web", name: "operator" },
371
351
  text: body.text,
372
352
  mode,
373
353
  });
374
354
  return c.json({ sessionId }, 202);
375
355
  });
376
- // Edit a user turn: rewind the transcript to just before it, then re-send
377
- // the edited text as a fresh dispatch. Pi keeps the old branch in the
378
- // session file but out of context — the "deleted" message stops polluting.
356
+ // Edit a user turn: rewind to just before it, then re-dispatch the edited text.
379
357
  guarded(app, "POST", "/api/sessions/:id/turns/:index/edit", 400, async (c) => {
380
358
  const id = c.req.param("id");
381
359
  const index = Number(c.req.param("index"));
@@ -383,8 +361,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
383
361
  if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
384
362
  return c.json({ error: "index and text required" }, 400);
385
363
  }
386
- // Asked before touching anything: the dispatch below would be refused by
387
- // the drain gate, and by then the transcript is already rewound.
364
+ // Before touching anything: a refused dispatch must not cost a rewound transcript.
388
365
  if (router.isDraining())
389
366
  return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
390
367
  const session = await ensure(id);
@@ -396,8 +373,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
396
373
  if (session.state !== "idle")
397
374
  return c.json({ error: "busy — stop the turn first" }, 409);
398
375
  await session.rewindToUserTurn(index);
399
- // The rewind took the turns after this one out of the context, headers and
400
- // all; what the model was told about who is speaking went with them.
376
+ // The rewind took the speaker headers out of the context too.
401
377
  router.forgetSender(id);
402
378
  await router.dispatch({
403
379
  key: { channelId: "web", conversationId: id },
@@ -408,9 +384,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
408
384
  });
409
385
  return c.json({ ok: true }, 202);
410
386
  });
411
- // Promote queued messages: "steer" delivers them into the running turn,
412
- // "restart" aborts the turn and sends them as a fresh prompt. Core owns
413
- // exclusion and retains originals through the asynchronous handoff.
387
+ // Core owns exclusion and retains originals through the asynchronous handoff.
414
388
  guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
415
389
  const id = c.req.param("id");
416
390
  const body = await c.req.json().catch(() => null);
@@ -420,7 +394,6 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
420
394
  }
421
395
  return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
422
396
  });
423
- // Recall: drop all pending queued messages and hand them back (composer restore).
424
397
  guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
425
398
  const id = c.req.param("id");
426
399
  return queueResponse(async () => {
@@ -432,36 +405,25 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
432
405
  router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
433
406
  return { ok: true };
434
407
  }));
435
- // Shrink the context on demand: Pi summarizes the older transcript away and
436
- // the session continues from the summary. Refused while streaming, like the
437
- // edit route above and for the same reason — Pi's own compaction aborts a
438
- // running turn to do it, and losing a turn is not what the button offered.
439
- // The result is not in this response: it arrives on the session's stream as
440
- // `context-compacted` (agent/events.ts), which is also the only place the
441
- // automatic compaction can be seen.
408
+ // Refused while streaming: Pi's compaction aborts a running turn, and losing
409
+ // one is not what the button offered. The result arrives on the stream as
410
+ // `context-compacted`.
442
411
  guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
443
412
  const session = await ensure(c.req.param("id"));
444
413
  if (session.state === "streaming")
445
414
  return c.json({ error: "busy — stop the turn first" }, 409);
446
- // The check above is a courtesy, not the lock: two clicks pass it on the
447
- // same tick, so the seam refuses the second one (agent/pi.ts) and its
448
- // refusal keeps the status this route already uses for "not now" — a 404
449
- // from `guarded` would have read as "no such session".
415
+ // The check above is not the lock: two clicks pass it on the same tick, and
416
+ // the seam's refusal must keep the "not now" status, not read as "no such session".
450
417
  return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
451
418
  });
452
- // A name, so a title is what you called it instead of the first 80
453
- // characters you happened to type. Not refused while streaming: a rename has
454
- // nothing to do with the turn running, and the transcript takes an append.
419
+ // Not refused while streaming: a rename has nothing to do with the turn running.
455
420
  guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
456
421
  const body = await c.req.json().catch(() => null);
457
422
  if (typeof body?.name !== "string")
458
423
  return c.json({ error: "name required" }, 400);
459
424
  const id = c.req.param("id");
460
425
  await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
461
- // Nothing to write and nothing to report: the name went into the
462
- // transcript, which is what every list reads. The event is how the
463
- // surfaces learn to re-read it, and the seam dropped its retained scan on
464
- // the way out so the re-read sees the new name.
426
+ // The transcript is the answer; the event tells surfaces to re-read it.
465
427
  hub.emitWorkspace({ type: "sessions-changed" });
466
428
  return c.json({ ok: true });
467
429
  });
@@ -470,11 +432,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
470
432
  await router.abort(id);
471
433
  return c.json({ ok: true }, 202);
472
434
  });
473
- // Workspace stream: one per client, keeps every session list in sync
474
- // (created/promoted → re-list, run state → patch) without polling.
435
+ // One per client; keeps every session list in sync without polling.
475
436
  app.get("/api/events", (c) => streamSSE(c, async (stream) => {
476
- // A write to a torn-down stream must not become an unhandled rejection.
477
- const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }).catch(() => { }));
437
+ const send = boundedWriter(stream, "workspace");
438
+ const unsubscribe = hub.subscribeWorkspace((e) => send(`data: ${JSON.stringify(e)}\n\n`));
478
439
  stream.onAbort(unsubscribe);
479
440
  while (!stream.aborted) {
480
441
  await stream.sleep(HEARTBEAT_MS);
@@ -491,29 +452,12 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
491
452
  await stream.writeSSE({ event: "reset", data: "snapshot required" });
492
453
  return;
493
454
  }
494
- let queued = 0; // frame chars written but not yet drained by the reader
495
- const send = (frame) => {
496
- if (stream.aborted || stream.closed)
497
- return;
498
- if (queued > SSE_HIGH_WATER) {
499
- log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
500
- stream.abort(); // unsubscribes; the client reconnects and replays
501
- return;
502
- }
503
- queued += frame.length;
504
- void stream
505
- .write(frame)
506
- .catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
507
- .finally(() => (queued -= frame.length));
508
- };
509
- // Subscribe before the replay write can wait on its reader: an event that
510
- // arrives while that write is backpressured must queue behind it, not fall
511
- // between replay() and subscribe(). Both snapshots happen synchronously,
512
- // so live writes cannot overtake the replay write.
455
+ const send = boundedWriter(stream, id);
456
+ // Subscribe before the replay write can wait on its reader, or an event
457
+ // arriving during backpressure falls between replay() and subscribe().
513
458
  const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
514
459
  stream.onAbort(unsubscribe);
515
- // One write for the whole replay: a reconnect after a busy turn used to
516
- // cost an await per event before the client saw any of them.
460
+ // One write for the whole replay, not an await per event.
517
461
  const missed = hub.replay(id, lastId);
518
462
  if (missed.length)
519
463
  await stream.write(missed.map(sseFrame).join(""));
@@ -524,14 +468,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
524
468
  }
525
469
  });
526
470
  });
527
- // Provider credentials, the agent files and the surface prompt are read when
528
- // a session *opens*: a live one keeps what it opened with, so a Console save
529
- // would otherwise reach nothing until the idle sweep got around to it half an
530
- // hour later. Letting the idle sessions go is what `pier reload` does — the
531
- // next message re-opens them with the configuration just written. Watched
532
- // included, unlike the background sweep: the session open in the tab that
533
- // just saved is the likeliest one to need it. A turn in flight is still never
534
- // interrupted; it picks the change up at its next natural eviction.
471
+ // Credentials, agent files and the surface prompt are read when a session
472
+ // opens, so a Console save recycles idle sessions — watched included, since
473
+ // the tab that just saved is the likeliest to need it. A turn in flight is
474
+ // never interrupted.
535
475
  const recycle = (what) => {
536
476
  void router.evictIdle(0, Date.now(), { includeWatched: true })
537
477
  .then((n) => {
@@ -540,12 +480,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
540
480
  })
541
481
  .catch((err) => log.error(`recycling sessions after ${what} failed`, err));
542
482
  };
543
- // `pier reload` on a button. The callbacks below already recycle when the
544
- // Console is what changed the configuration; an agent editing AGENTS.md or a
545
- // file edited over ssh has nothing to trigger them, and this is that trigger.
546
- // `busy` is reported rather than waited on: a streaming session is never
547
- // interrupted (core/router.ts evictIdle), so that count is the honest answer
548
- // to "why is my change not live yet".
483
+ // `pier reload` on a button, for a file edited outside the Console. `busy`
484
+ // is the honest answer to "why is my change not live yet".
549
485
  app.post("/api/reload", async (c) => {
550
486
  try {
551
487
  const recycled = (await reload?.()) ?? 0;
@@ -573,64 +509,47 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
573
509
  registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
574
510
  registerFsRoutes(app);
575
511
  registerExplorerRoutes(app);
576
- // serveStatic resolves `root` against the *working directory*, and an
577
- // installed Pier is started from wherever the operator happens to be. The
578
- // bundle sits beside this module in both trees — src/web/public when tsx
579
- // runs the source, dist/web/public in a build — so the path is derived from
580
- // the module and handed over as the relative form the option wants.
512
+ // serveStatic resolves `root` against the working directory, and an installed
513
+ // Pier is started from wherever the operator happens to be.
581
514
  const bundle = fileURLToPath(new URL("./public", import.meta.url));
582
- // The tab says which instance this is (`staging - g1 - Pier`): an operator
583
- // keeps a workbench open per environment and they are otherwise identical,
584
- // and mistaking the test one for production is the mistake worth a few lines.
585
- // Both facts are known only at runtime, so they are patched into the shell
586
- // here rather than built in — and served behind the auth guard, so a stranger
587
- // at /login learns neither. Read once: neither can change under a process.
515
+ // The tab says which instance this is: mistaking staging for production is
516
+ // the mistake worth a few lines. Behind the auth guard, so a stranger at
517
+ // /login learns neither fact.
588
518
  const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
589
519
  let shell = null;
590
- // The one file the precompressed siblings below cannot cover: this route
591
- // answers from the patched string, not from disk, and it is re-fetched on
592
- // every navigation because it may not be cached. Exact path, like the two
593
- // API routes above — never the SSE streams.
520
+ // Answers from the patched string, not from disk, so the precompressed
521
+ // siblings below cannot cover it. Exact path: never the SSE streams.
594
522
  app.use("/", compress());
595
523
  app.get("/", async (c, next) => {
596
- // A release replaces hashed assets. Revalidate the shell on every
597
- // navigation so a cached index cannot name bundles that no longer exist.
524
+ // A cached index must not name bundles a release has replaced.
598
525
  c.header("cache-control", "private, no-cache");
599
526
  if (shell === null) {
600
527
  try {
601
528
  shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
602
529
  }
603
530
  catch (err) {
604
- // A workbench that will not load is not worth a nicer tab: hand the
605
- // request back to the static handler, which answers as it always did.
531
+ // A workbench that will not load is not worth a nicer tab.
606
532
  log.warn(`shell unreadable, serving it unpatched: ${String(err)}`);
607
533
  return next();
608
534
  }
609
535
  }
610
536
  return c.html(shell);
611
537
  });
612
- // Same reasoning as the shell above, for the one asset that is not hashed:
613
- // an installed app keeps its worker until the script it re-fetches differs,
614
- // so a cached copy is a released fix that never ships.
538
+ // The one unhashed asset: an installed app keeps its worker until the
539
+ // re-fetched script differs, so a cached copy is a fix that never ships.
615
540
  app.get("/sw.js", async (c, next) => {
616
541
  c.header("cache-control", "private, no-cache");
617
542
  await next();
618
543
  });
619
- // Hashed bundles never change under their name — a release writes new names,
620
- // and the shell above is what re-points at them. Without this they carry only
621
- // the auth layer's bare `private`, so a browser revalidates each one before it
622
- // may reuse it: a round trip per bundle on a remote instance, every time the
623
- // workbench is opened.
544
+ // Hashed bundles never change under their name; without this the auth
545
+ // layer's bare `private` costs a revalidation round trip per bundle per open.
624
546
  app.get("/assets/*", async (c, next) => {
625
547
  c.header("cache-control", "private, max-age=31536000, immutable");
626
548
  await next();
627
549
  });
628
- // `precompressed` looks for a `.br`/`.gz` sibling of the file it is about to
629
- // serve and hands that over when the request accepts the encoding; the build
630
- // writes them (vite.config.ts). Without it the 325 kB bundle and the 87 kB
631
- // stylesheet go out verbatim.
632
- // serveStatic only sets Vary when it selects a sibling. Identity must carry
633
- // it too, or a cache can reuse that response for a later Brotli request.
550
+ // The build writes `.br`/`.gz` siblings (vite.config.ts). serveStatic sets
551
+ // Vary only when it selects one; identity must carry it too, or a cache can
552
+ // reuse that response for a later Brotli request.
634
553
  app.use("/*", async (c, next) => {
635
554
  await next();
636
555
  c.header("Vary", "Accept-Encoding");