@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  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 +8 -24
  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 +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  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 +6 -14
  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 +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  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 +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.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-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  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/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.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,11 +84,12 @@ 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.
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).
92
+ const workbenchOwn = (id) => (channelOf?.(id) ?? "web") === "web" && !(taskSessions?.().has(id) ?? false);
79
93
  const runningNow = new Set();
80
94
  hub.subscribeWorkspace((e) => {
81
95
  if (e.type !== "session-state")
@@ -86,26 +100,20 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
86
100
  }
87
101
  if (!runningNow.delete(e.sessionId))
88
102
  return;
103
+ if (!workbenchOwn(e.sessionId))
104
+ return;
89
105
  state.setUnread(e.sessionId, true);
90
106
  hub.emitWorkspace({ type: "sessions-changed" });
91
107
  });
92
- /** Background runs still in flight, per launching session. One query for a
93
- * whole list: asking row by row loaded every run object of every session to
94
- * draw one dot each. */
108
+ /** One query for a whole list, not one per row. */
95
109
  const activeRuns = () => activeBackgroundRunCounts?.() ?? new Map();
96
- /** The web channel's session for `id` — every session route resolves here. */
97
110
  const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
98
- // Sessions created here that Pi doesn't list yet — it persists a session
99
- // only once the first assistant message lands. Merged into the list below
100
- // 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.
101
113
  const nascent = new Map();
102
- /** `ensure`, plus ghost cleanup. A session created and never messaged does
103
- * not survive a restart or an eviction (Pi persisted nothing), but while
104
- * this process lives it is in `nascent` and therefore in the rail — left
105
- * alone, clicking it 404s forever. The load path is where a ghost is
106
- * discovered, so it is where the entry and its row are dropped and every
107
- * rail told; the 404 then says what happened instead of looking like a
108
- * 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). */
109
117
  const ensureLoadable = async (id) => {
110
118
  try {
111
119
  return await ensure(id);
@@ -120,35 +128,31 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
120
128
  throw err;
121
129
  }
122
130
  };
123
- // A listing stats every session file and parses whatever grew — milliseconds
124
- // warm, one scan cold. Concurrent consumers share it, whatever the factory
125
- // behind the seam retains of its own; nothing here is cached past the last
126
- // of them.
131
+ // Concurrent consumers share one scan; nothing is cached past the last of them.
127
132
  let listing;
128
133
  const listSessions = () => listing ??= factory.list().finally(() => {
129
134
  listing = undefined;
130
135
  });
131
- /** Every session the rail may show: what Pi has written, plus the ones
132
- * created here that it has not persisted yet, minus the ones task runs
133
- * made for themselves. */
134
136
  const allSessions = async () => {
135
137
  const sessions = await listSessions();
136
138
  for (const s of sessions)
137
139
  nascent.delete(s.id);
138
- // A session created but never prompted would otherwise be listed forever.
139
- for (const [id, n] of nascent)
140
- if (Date.now() - n.createdAt > 86_400_000)
140
+ // A session created but never prompted would otherwise hold a working-set
141
+ // slot forever.
142
+ for (const [id, n] of nascent) {
143
+ if (Date.now() - n.createdAt > 86_400_000) {
141
144
  nascent.delete(id);
145
+ state.forget(id);
146
+ }
147
+ }
142
148
  const owned = taskSessions?.() ?? new Set();
143
149
  return [
144
150
  ...[...nascent].map(([id, n]) => ({ id, ...n })),
145
151
  ...sessions.filter((s) => !owned.has(s.id)),
146
152
  ];
147
153
  };
148
- // One session as every list renders it: the summary, what the workbench
149
- // decided about it, and what is true of it right now. `rank` is the place in
150
- // the rail's working set, when it has one; `modified` rides along for the
151
- // 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.
152
156
  const present = (s, own, active) => ({
153
157
  ...s,
154
158
  ...(own?.rank === undefined ? {} : { rank: own.rank }),
@@ -158,9 +162,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
158
162
  activeRuns: active.get(s.id) ?? 0,
159
163
  });
160
164
  // The rail's top rows are maintained here and nowhere else: a session a
161
- // human speaks to and that is not in the working set already takes the front
162
- // slot (web/session-state.ts). No route — there is no gesture to make, and
163
- // an IM message has no browser to make it from.
165
+ // human speaks to — or creates, below — and that is not in the working set
166
+ // already takes the front slot (web/session-state.ts). No route — there is no
167
+ // gesture to make, and an IM message has no browser to make it from.
164
168
  router.onSpokenTo((id) => {
165
169
  if (state.promote(id))
166
170
  hub.emitWorkspace({ type: "sessions-changed" });
@@ -170,20 +174,42 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
170
174
  const active = activeRuns();
171
175
  return c.json((await allSessions()).map((s) => present(s, flags.get(s.id), active)));
172
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
+ // What was said, across every session: the palette's Messages section. The
189
+ // factory owns the index and the ranking; an empty query is an empty answer,
190
+ // not a listing of everything.
191
+ app.get("/api/search", async (c) => {
192
+ const q = (c.req.query("q") ?? "").trim();
193
+ return c.json({ hits: q ? await factory.search(q) : [] });
194
+ });
173
195
  app.post("/api/sessions", async (c) => {
174
196
  const body = await c.req.json().catch(() => ({}));
175
- // A session always starts in its project directory — never in pier's own.
197
+ // Never in pier's own directory.
176
198
  if (typeof body.cwd !== "string" || !body.cwd)
177
199
  return c.json({ error: "cwd required" }, 400);
178
- const session = await factory.create({ cwd: body.cwd });
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.
202
+ const cwd = await realpath(body.cwd).catch(() => body.cwd);
203
+ const session = await factory.create({ cwd });
179
204
  const createdAt = Date.now();
180
- nascent.set(session.id, { cwd: body.cwd, createdAt });
205
+ nascent.set(session.id, { cwd, createdAt });
181
206
  router.attach({ channelId: "web", conversationId: session.id }, session);
207
+ // Created is as good as spoken to: a row born below the working set would
208
+ // jump on the first message.
209
+ state.promote(session.id);
182
210
  hub.emitWorkspace({ type: "sessions-changed" });
183
211
  return c.json({ id: session.id }, 201);
184
212
  });
185
- // Seen = read: a client with the session selected and the tab visible acks
186
- // here; the broadcast moves every other client's dot back to idle.
187
213
  app.post("/api/sessions/:id/read", (c) => {
188
214
  const id = c.req.param("id");
189
215
  if (state.unread(id)) {
@@ -192,13 +218,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
192
218
  }
193
219
  return c.json({ ok: true });
194
220
  });
195
- // The two responses big enough to matter: a transcript, and one turn's tool
196
- // detail. Scoped to these routes on purpose — compressing the SSE streams
197
- // 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.
198
223
  app.use("/api/sessions/:id/history", compress());
199
224
  app.use("/api/sessions/:id/turns/:index/steps", compress());
200
- // Snapshot: everything a fresh client needs before it starts consuming
201
- // deltas from SSE — transcript, live state, pending queue, model.
202
225
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
203
226
  const id = c.req.param("id");
204
227
  const session = await ensureLoadable(id);
@@ -236,21 +259,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
236
259
  const turn = (await (await ensure(c.req.param("id"))).history())[index];
237
260
  if (!turn)
238
261
  return c.json({ error: `no turn at index ${index}` }, 404);
239
- // Already capped at MAX_STEP_OUTPUT by the transcript rebuild: this route
240
- // hands back what a surface shows, not the untruncated tool result.
241
262
  return c.json({ steps: turn.steps ?? [] });
242
263
  });
243
- // Attachments, both directions: the agent links a file it produced, the chat
244
- // renders a file the user sent. Read-only, and any readable file — the
245
- // boundary here is the Console password (web/fs.ts), not the session's cwd:
246
- // a cwd is chosen by whoever creates the session, so confining to it stopped
247
- // 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.
248
266
  guarded(app, "GET", "/api/sessions/:id/files", 400, async (c) => {
249
267
  const raw = c.req.query("path");
250
268
  if (!raw)
251
269
  return c.json({ error: "path required" }, 400);
252
- // Absolute only: a link into a session's files is written by the agent or
253
- // by Pier, and neither of them writes a path relative to anything.
270
+ // Absolute only: neither the agent nor Pier writes a relative link.
254
271
  const file = isAbsolute(raw) ? await realpath(raw).catch(() => null) : null;
255
272
  const info = file ? await stat(file).catch(() => null) : null;
256
273
  if (!file || !info?.isFile())
@@ -263,9 +280,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
263
280
  "cache-control": "private, max-age=60",
264
281
  });
265
282
  });
266
- // Composer attachments: bytes land in the inbox, the message carries the
267
- // path as a `[name](file:///…)` line the client builds itself — upload
268
- // 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.
269
284
  guarded(app, "POST", "/api/inbox", 400, async (c) => {
270
285
  const body = await c.req.json().catch(() => null);
271
286
  if (typeof body?.data !== "string" ||
@@ -282,8 +297,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
282
297
  return c.json({ error: "invalid file" }, 400);
283
298
  return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
284
299
  });
285
- // Backend model catalog, no session needed: surfaces that configure what a
286
- // *future* session launches with (IM chats) have none to ask.
300
+ // No session needed: surfaces configuring a *future* session have none to ask.
287
301
  app.get("/api/models", async (c) => {
288
302
  try {
289
303
  return c.json(await factory.availableModels());
@@ -331,19 +345,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
331
345
  const { sessionId } = await router.dispatch({
332
346
  key: { channelId: "web", conversationId: id },
333
347
  senderId: "web",
334
- // Named, not anonymous: a session reached from a group chat as well as
335
- // from here attributes an unheaded message to whoever spoke last
336
- // (core/identity.ts), which is the operator's own words in someone
337
- // 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).
338
350
  sender: { id: "web", name: "operator" },
339
351
  text: body.text,
340
352
  mode,
341
353
  });
342
354
  return c.json({ sessionId }, 202);
343
355
  });
344
- // Edit a user turn: rewind the transcript to just before it, then re-send
345
- // the edited text as a fresh dispatch. Pi keeps the old branch in the
346
- // 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.
347
357
  guarded(app, "POST", "/api/sessions/:id/turns/:index/edit", 400, async (c) => {
348
358
  const id = c.req.param("id");
349
359
  const index = Number(c.req.param("index"));
@@ -351,8 +361,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
351
361
  if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
352
362
  return c.json({ error: "index and text required" }, 400);
353
363
  }
354
- // Asked before touching anything: the dispatch below would be refused by
355
- // the drain gate, and by then the transcript is already rewound.
364
+ // Before touching anything: a refused dispatch must not cost a rewound transcript.
356
365
  if (router.isDraining())
357
366
  return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
358
367
  const session = await ensure(id);
@@ -364,8 +373,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
364
373
  if (session.state !== "idle")
365
374
  return c.json({ error: "busy — stop the turn first" }, 409);
366
375
  await session.rewindToUserTurn(index);
367
- // The rewind took the turns after this one out of the context, headers and
368
- // 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.
369
377
  router.forgetSender(id);
370
378
  await router.dispatch({
371
379
  key: { channelId: "web", conversationId: id },
@@ -376,9 +384,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
376
384
  });
377
385
  return c.json({ ok: true }, 202);
378
386
  });
379
- // Promote queued messages: "steer" delivers them into the running turn,
380
- // "restart" aborts the turn and sends them as a fresh prompt. Core owns
381
- // exclusion and retains originals through the asynchronous handoff.
387
+ // Core owns exclusion and retains originals through the asynchronous handoff.
382
388
  guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
383
389
  const id = c.req.param("id");
384
390
  const body = await c.req.json().catch(() => null);
@@ -388,7 +394,6 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
388
394
  }
389
395
  return queueResponse(async () => ({ submitted: await router.deliverQueue(id, mode) }), 202);
390
396
  });
391
- // Recall: drop all pending queued messages and hand them back (composer restore).
392
397
  guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
393
398
  const id = c.req.param("id");
394
399
  return queueResponse(async () => {
@@ -400,36 +405,25 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
400
405
  router.acknowledgeRecovery(c.req.param("id"), c.req.param("batchId"));
401
406
  return { ok: true };
402
407
  }));
403
- // Shrink the context on demand: Pi summarizes the older transcript away and
404
- // the session continues from the summary. Refused while streaming, like the
405
- // edit route above and for the same reason — Pi's own compaction aborts a
406
- // running turn to do it, and losing a turn is not what the button offered.
407
- // The result is not in this response: it arrives on the session's stream as
408
- // `context-compacted` (agent/events.ts), which is also the only place the
409
- // 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`.
410
411
  guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
411
412
  const session = await ensure(c.req.param("id"));
412
413
  if (session.state === "streaming")
413
414
  return c.json({ error: "busy — stop the turn first" }, 409);
414
- // The check above is a courtesy, not the lock: two clicks pass it on the
415
- // same tick, so the seam refuses the second one (agent/pi.ts) and its
416
- // refusal keeps the status this route already uses for "not now" — a 404
417
- // 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".
418
417
  return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
419
418
  });
420
- // A name, so a title is what you called it instead of the first 80
421
- // characters you happened to type. Not refused while streaming: a rename has
422
- // 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.
423
420
  guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
424
421
  const body = await c.req.json().catch(() => null);
425
422
  if (typeof body?.name !== "string")
426
423
  return c.json({ error: "name required" }, 400);
427
424
  const id = c.req.param("id");
428
425
  await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
429
- // Nothing to write and nothing to report: the name went into the
430
- // transcript, which is what every list reads. The event is how the
431
- // surfaces learn to re-read it, and the seam dropped its retained scan on
432
- // 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.
433
427
  hub.emitWorkspace({ type: "sessions-changed" });
434
428
  return c.json({ ok: true });
435
429
  });
@@ -438,11 +432,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
438
432
  await router.abort(id);
439
433
  return c.json({ ok: true }, 202);
440
434
  });
441
- // Workspace stream: one per client, keeps every session list in sync
442
- // (created/promoted → re-list, run state → patch) without polling.
435
+ // One per client; keeps every session list in sync without polling.
443
436
  app.get("/api/events", (c) => streamSSE(c, async (stream) => {
444
- // A write to a torn-down stream must not become an unhandled rejection.
445
- 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`));
446
439
  stream.onAbort(unsubscribe);
447
440
  while (!stream.aborted) {
448
441
  await stream.sleep(HEARTBEAT_MS);
@@ -459,29 +452,12 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
459
452
  await stream.writeSSE({ event: "reset", data: "snapshot required" });
460
453
  return;
461
454
  }
462
- let queued = 0; // frame chars written but not yet drained by the reader
463
- const send = (frame) => {
464
- if (stream.aborted || stream.closed)
465
- return;
466
- if (queued > SSE_HIGH_WATER) {
467
- log.warn(`dropping slow event client for ${id} — ${queued} chars queued`);
468
- stream.abort(); // unsubscribes; the client reconnects and replays
469
- return;
470
- }
471
- queued += frame.length;
472
- void stream
473
- .write(frame)
474
- .catch((err) => log.warn(`event write for ${id} failed: ${String(err)}`))
475
- .finally(() => (queued -= frame.length));
476
- };
477
- // Subscribe before the replay write can wait on its reader: an event that
478
- // arrives while that write is backpressured must queue behind it, not fall
479
- // between replay() and subscribe(). Both snapshots happen synchronously,
480
- // 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().
481
458
  const unsubscribe = hub.subscribe(id, (e) => send(sseFrame(e)));
482
459
  stream.onAbort(unsubscribe);
483
- // One write for the whole replay: a reconnect after a busy turn used to
484
- // cost an await per event before the client saw any of them.
460
+ // One write for the whole replay, not an await per event.
485
461
  const missed = hub.replay(id, lastId);
486
462
  if (missed.length)
487
463
  await stream.write(missed.map(sseFrame).join(""));
@@ -492,14 +468,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
492
468
  }
493
469
  });
494
470
  });
495
- // Provider credentials, the agent files and the surface prompt are read when
496
- // a session *opens*: a live one keeps what it opened with, so a Console save
497
- // would otherwise reach nothing until the idle sweep got around to it half an
498
- // hour later. Letting the idle sessions go is what `pier reload` does — the
499
- // next message re-opens them with the configuration just written. Watched
500
- // included, unlike the background sweep: the session open in the tab that
501
- // just saved is the likeliest one to need it. A turn in flight is still never
502
- // 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.
503
475
  const recycle = (what) => {
504
476
  void router.evictIdle(0, Date.now(), { includeWatched: true })
505
477
  .then((n) => {
@@ -508,12 +480,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
508
480
  })
509
481
  .catch((err) => log.error(`recycling sessions after ${what} failed`, err));
510
482
  };
511
- // `pier reload` on a button. The callbacks below already recycle when the
512
- // Console is what changed the configuration; an agent editing AGENTS.md or a
513
- // file edited over ssh has nothing to trigger them, and this is that trigger.
514
- // `busy` is reported rather than waited on: a streaming session is never
515
- // interrupted (core/router.ts evictIdle), so that count is the honest answer
516
- // 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".
517
485
  app.post("/api/reload", async (c) => {
518
486
  try {
519
487
  const recycled = (await reload?.()) ?? 0;
@@ -541,64 +509,47 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
541
509
  registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
542
510
  registerFsRoutes(app);
543
511
  registerExplorerRoutes(app);
544
- // serveStatic resolves `root` against the *working directory*, and an
545
- // installed Pier is started from wherever the operator happens to be. The
546
- // bundle sits beside this module in both trees — src/web/public when tsx
547
- // runs the source, dist/web/public in a build — so the path is derived from
548
- // 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.
549
514
  const bundle = fileURLToPath(new URL("./public", import.meta.url));
550
- // The tab says which instance this is (`staging - g1 - Pier`): an operator
551
- // keeps a workbench open per environment and they are otherwise identical,
552
- // and mistaking the test one for production is the mistake worth a few lines.
553
- // Both facts are known only at runtime, so they are patched into the shell
554
- // here rather than built in — and served behind the auth guard, so a stranger
555
- // 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.
556
518
  const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
557
519
  let shell = null;
558
- // The one file the precompressed siblings below cannot cover: this route
559
- // answers from the patched string, not from disk, and it is re-fetched on
560
- // every navigation because it may not be cached. Exact path, like the two
561
- // 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.
562
522
  app.use("/", compress());
563
523
  app.get("/", async (c, next) => {
564
- // A release replaces hashed assets. Revalidate the shell on every
565
- // navigation so a cached index cannot name bundles that no longer exist.
524
+ // A cached index must not name bundles a release has replaced.
566
525
  c.header("cache-control", "private, no-cache");
567
526
  if (shell === null) {
568
527
  try {
569
528
  shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
570
529
  }
571
530
  catch (err) {
572
- // A workbench that will not load is not worth a nicer tab: hand the
573
- // 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.
574
532
  log.warn(`shell unreadable, serving it unpatched: ${String(err)}`);
575
533
  return next();
576
534
  }
577
535
  }
578
536
  return c.html(shell);
579
537
  });
580
- // Same reasoning as the shell above, for the one asset that is not hashed:
581
- // an installed app keeps its worker until the script it re-fetches differs,
582
- // 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.
583
540
  app.get("/sw.js", async (c, next) => {
584
541
  c.header("cache-control", "private, no-cache");
585
542
  await next();
586
543
  });
587
- // Hashed bundles never change under their name — a release writes new names,
588
- // and the shell above is what re-points at them. Without this they carry only
589
- // the auth layer's bare `private`, so a browser revalidates each one before it
590
- // may reuse it: a round trip per bundle on a remote instance, every time the
591
- // 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.
592
546
  app.get("/assets/*", async (c, next) => {
593
547
  c.header("cache-control", "private, max-age=31536000, immutable");
594
548
  await next();
595
549
  });
596
- // `precompressed` looks for a `.br`/`.gz` sibling of the file it is about to
597
- // serve and hands that over when the request accepts the encoding; the build
598
- // writes them (vite.config.ts). Without it the 325 kB bundle and the 87 kB
599
- // stylesheet go out verbatim.
600
- // serveStatic only sets Vary when it selects a sibling. Identity must carry
601
- // 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.
602
553
  app.use("/*", async (c, next) => {
603
554
  await next();
604
555
  c.header("Vary", "Accept-Encoding");