@timqi/pier 0.0.8 → 0.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +26 -9
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +279 -32
  5. package/dist/boards/boards.js +65 -16
  6. package/dist/boards/pier.css +1 -1
  7. package/dist/channels/attach.js +87 -0
  8. package/dist/channels/control.js +2 -2
  9. package/dist/channels/conversations.js +10 -0
  10. package/dist/channels/lark-api.js +38 -0
  11. package/dist/channels/lark-outbound.js +11 -2
  12. package/dist/channels/slack-api.js +36 -0
  13. package/dist/channels/slack-outbound.js +12 -2
  14. package/dist/channels/slack-tool.js +49 -9
  15. package/dist/channels/telegram-api.js +21 -2
  16. package/dist/channels/telegram.js +23 -8
  17. package/dist/cli.js +34 -0
  18. package/dist/core/identity.js +18 -0
  19. package/dist/core/inbound-file.js +3 -1
  20. package/dist/core/reply.js +2 -1
  21. package/dist/core/router.js +99 -11
  22. package/dist/db.js +87 -0
  23. package/dist/extensions/index.js +37 -0
  24. package/dist/extensions/web/anthropic.js +118 -0
  25. package/dist/extensions/web/artifacts.js +62 -0
  26. package/dist/extensions/web/content.js +130 -0
  27. package/dist/extensions/web/http.js +106 -0
  28. package/dist/extensions/web/index.js +9 -0
  29. package/dist/extensions/web/json.js +5 -0
  30. package/dist/extensions/web/language.js +47 -0
  31. package/dist/extensions/web/openai.js +112 -0
  32. package/dist/extensions/web/provider.js +121 -0
  33. package/dist/extensions/web/tools.js +304 -0
  34. package/dist/limits.js +14 -0
  35. package/dist/main.js +76 -10
  36. package/dist/paths.js +21 -1
  37. package/dist/settings.js +112 -13
  38. package/dist/tasks/agent.js +18 -4
  39. package/dist/tasks/callbacks.js +20 -1
  40. package/dist/tasks/definitions.js +56 -12
  41. package/dist/tasks/execution.js +5 -1
  42. package/dist/tasks/groups.js +4 -4
  43. package/dist/tasks/messages.js +4 -2
  44. package/dist/tasks/runs.js +2 -2
  45. package/dist/tasks/service.js +16 -6
  46. package/dist/tasks/tool.js +0 -12
  47. package/dist/tools-task.js +155 -0
  48. package/dist/tools.js +875 -0
  49. package/dist/web/auth.js +5 -3
  50. package/dist/web/explorer.js +15 -2
  51. package/dist/web/files.js +1 -1
  52. package/dist/web/instance.js +175 -22
  53. package/dist/web/providers.js +16 -0
  54. package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
  55. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  56. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  57. package/dist/web/public/index.html +5 -8
  58. package/dist/web/public/sw.js +4 -0
  59. package/dist/web/push.js +33 -9
  60. package/dist/web/repos.js +75 -0
  61. package/dist/web/server.js +170 -52
  62. package/dist/web/session-state.js +57 -44
  63. package/dist/web/terminal.js +34 -4
  64. package/dist/web/types.js +5 -0
  65. package/package.json +1 -1
  66. package/skills/pier-boards/SKILL.md +23 -13
  67. package/skills/pier-help/SKILL.md +1 -1
  68. package/skills/pier-slack/SKILL.md +21 -1
  69. package/skills/pier-tasks/SKILL.md +2 -2
  70. package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
  71. package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
@@ -14,8 +14,11 @@ import { Router } from "../core/router.js";
14
14
  import { registerExplorerRoutes } from "./explorer.js";
15
15
  import { guarded, registerFileRoutes } from "./files.js";
16
16
  import { isThinkingLevel } from "../core/types.js";
17
+ import { SESSION_TITLE_MAX } from "../limits.js";
17
18
  import { saveInbound } from "../core/inbox.js";
18
19
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
20
+ import { RepoIndex } from "./repos.js";
21
+ import {} from "./session-state.js";
19
22
  import { registerInstanceRoutes } from "./instance.js";
20
23
  import { registerProviderRoutes } from "./providers.js";
21
24
  const log = logger("web");
@@ -37,7 +40,7 @@ export const withTabPrefix = (html, prefix) => prefix
37
40
  const HEARTBEAT_MS = 15_000;
38
41
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
39
42
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
40
- export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, reload, updates, updater, backgroundRuns, }) {
43
+ export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
41
44
  const app = new Hono();
42
45
  // A finished turn marks its session unread until some client reports it was
43
46
  // seen (session selected + tab visible → POST read below). Server-side so
@@ -57,6 +60,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
57
60
  state.setUnread(e.sessionId, true);
58
61
  hub.emitWorkspace({ type: "sessions-changed" });
59
62
  });
63
+ // Repository identity per project directory, refreshed off the request path.
64
+ const repos = new RepoIndex(() => hub.emitWorkspace({ type: "sessions-changed" }));
60
65
  /** Background runs this session launched that are still in flight. */
61
66
  const activeRuns = (id) => backgroundRuns?.(id).filter((r) => r.state === "queued" || r.state === "running").length ?? 0;
62
67
  /** The web channel's session for `id` — every session route resolves here. */
@@ -65,37 +70,38 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
65
70
  // only once the first assistant message lands. Merged into the list below
66
71
  // so every client sees a new session immediately; dropped once Pi lists it.
67
72
  const nascent = new Map();
68
- // listAll parses every transcript. Concurrent consumers share that work,
69
- // but the result is not retained: an explicit All-sessions open stays fresh.
73
+ /** `ensure`, plus ghost cleanup. A session created and never messaged does
74
+ * not survive a restart or an eviction (Pi persisted nothing), but while
75
+ * this process lives it is in `nascent` and therefore in the rail — left
76
+ * alone, clicking it 404s forever. The load path is where a ghost is
77
+ * discovered, so it is where the entry and its pin are dropped and every
78
+ * rail told; the 404 then says what happened instead of looking like a
79
+ * crash (§5b). */
80
+ const ensureLoadable = async (id) => {
81
+ try {
82
+ return await ensure(id);
83
+ }
84
+ catch (err) {
85
+ if (String(err).includes("unknown session")) {
86
+ nascent.delete(id);
87
+ state.forget(id);
88
+ hub.emitWorkspace({ type: "sessions-changed" });
89
+ throw new Error(`session ${id} no longer exists — it never got a first reply, so nothing was persisted; its rail entry was removed`);
90
+ }
91
+ throw err;
92
+ }
93
+ };
94
+ // A listing stats every session file and parses whatever grew — milliseconds
95
+ // warm, one scan cold. Concurrent consumers share it, whatever the factory
96
+ // behind the seam retains of its own; nothing here is cached past the last
97
+ // of them.
70
98
  let listing;
71
- let projectBackfillNeeded = state.needsProjectBackfill();
72
- const listSessions = () => listing ??= factory.list()
73
- .then((rows) => {
74
- state.remember(rows);
75
- return rows;
76
- })
77
- .finally(() => {
99
+ const listSessions = () => listing ??= factory.list().finally(() => {
78
100
  listing = undefined;
79
101
  });
80
- const present = (s, pinned, unread) => ({
81
- ...s,
82
- state: router.stateOf(s.id) ?? "idle",
83
- pinned,
84
- unread,
85
- activeRuns: activeRuns(s.id),
86
- });
87
- app.get("/api/projects", async (c) => {
88
- // Existing databases have pin booleans but no summaries. Pay one legacy
89
- // scan, fill those rows, then every later Projects read is SQLite-only.
90
- if (projectBackfillNeeded) {
91
- await listSessions();
92
- // Do not retry on every request, and do not clear a pin whose transcript
93
- // happened to be unreadable. A later explicit full listing can repair it.
94
- projectBackfillNeeded = false;
95
- }
96
- return c.json(state.projects().map((s) => present(s, true, s.unread)));
97
- });
98
- app.get("/api/sessions", async (c) => {
102
+ /** Every session a surface may show: what Pi has written, plus the ones
103
+ * created here that it has not persisted yet. */
104
+ const allSessions = async () => {
99
105
  const sessions = await listSessions();
100
106
  for (const s of sessions)
101
107
  nascent.delete(s.id);
@@ -103,11 +109,65 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
103
109
  for (const [id, n] of nascent)
104
110
  if (Date.now() - n.createdAt > 86_400_000)
105
111
  nascent.delete(id);
112
+ return [
113
+ ...[...nascent].map(([id, n]) => ({ id, ...n })),
114
+ ...sessions,
115
+ ];
116
+ };
117
+ // One session as every list renders it: the summary, what the workbench
118
+ // decided about it, and what is true of it right now.
119
+ // The listing's `modified` is dropped rather than forwarded: it dates a
120
+ // transcript, and no surface renders it.
121
+ const present = ({ modified: _drop, ...s }, own) => {
122
+ return {
123
+ ...s,
124
+ ...(own?.sort === undefined ? {} : { sort: own.sort }),
125
+ ...(own?.projectSort === undefined ? {} : { projectSort: own.projectSort }),
126
+ // Which repository the directory belongs to, on every list and not only
127
+ // on the rail's: All sessions replaces the client's whole list, so a row
128
+ // without it there un-groups the worktrees the rail had just grouped.
129
+ // Whatever is known now — the probe is never waited on, and its answer
130
+ // arrives as a `sessions-changed`.
131
+ ...repos.get(s.cwd),
132
+ state: router.stateOf(s.id) ?? "idle",
133
+ listed: own?.pinned ?? false,
134
+ unread: own?.unread ?? false,
135
+ channel: channelOf?.(s.id) ?? "web",
136
+ activeRuns: activeRuns(s.id),
137
+ };
138
+ };
139
+ // The rail: the same rows as the full list, minus everything Projects is not
140
+ // showing. One source, one shape — the rail used to read a second copy of
141
+ // the summaries out of SQLite, which is what had to be kept in step.
142
+ app.get("/api/projects", async (c) => {
106
143
  const flags = state.flags();
107
- return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => {
108
- const row = flags.get(s.id);
109
- return present(s, row?.pinned ?? false, row?.unread ?? false);
110
- }));
144
+ return c.json((await allSessions())
145
+ .map((s) => present(s, flags.get(s.id)))
146
+ .filter((row) => row.listed));
147
+ });
148
+ // One drag, one write of the list that changed: the projects, or one
149
+ // project's sessions. Whole lists rather than a move — the client has just
150
+ // rendered the result, and replaying a move on top of a stale list would put
151
+ // the row somewhere nobody dropped it.
152
+ app.post("/api/projects/order", async (c) => {
153
+ const body = await c.req.json().catch(() => null);
154
+ const list = (raw) => raw === undefined
155
+ ? undefined
156
+ : Array.isArray(raw) && raw.every((x) => typeof x === "string" && x)
157
+ ? raw
158
+ : null;
159
+ const sessions = list(body?.sessions);
160
+ const projects = list(body?.projects);
161
+ if (sessions === null || projects === null || (!sessions && !projects)) {
162
+ return c.json({ error: "sessions and/or projects must be lists of ids" }, 400);
163
+ }
164
+ state.reorder({ sessions, projects });
165
+ hub.emitWorkspace({ type: "sessions-changed" });
166
+ return c.json({ ok: true });
167
+ });
168
+ app.get("/api/sessions", async (c) => {
169
+ const flags = state.flags();
170
+ return c.json((await allSessions()).map((s) => present(s, flags.get(s.id))));
111
171
  });
112
172
  app.post("/api/sessions", async (c) => {
113
173
  const body = await c.req.json().catch(() => ({}));
@@ -119,7 +179,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
119
179
  nascent.set(session.id, { cwd: body.cwd, createdAt });
120
180
  router.attach({ channelId: "web", conversationId: session.id }, session);
121
181
  // Created here = part of the workspace; pinning is what Projects lists.
122
- state.pin({ id: session.id, cwd: body.cwd, createdAt }, true);
182
+ state.pin(session.id, body.cwd, true);
123
183
  hub.emitWorkspace({ type: "sessions-changed" });
124
184
  return c.json({ id: session.id }, 201);
125
185
  });
@@ -135,20 +195,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
135
195
  });
136
196
  app.post("/api/sessions/:id/pin", async (c) => {
137
197
  const body = await c.req.json().catch(() => null);
138
- if (typeof body?.pinned !== "boolean" ||
139
- typeof body.cwd !== "string" || !body.cwd ||
140
- typeof body.createdAt !== "number" || !Number.isFinite(body.createdAt) ||
141
- (body.title !== undefined && typeof body.title !== "string")) {
142
- return c.json({ error: "pinned and session summary required" }, 400);
143
- }
144
- // The summary came from this authenticated surface's own list. Persisting
145
- // it here makes the next Projects read independent of Pi's transcript scan.
146
- state.pin({
147
- id: c.req.param("id"),
148
- cwd: body.cwd,
149
- createdAt: body.createdAt,
150
- ...(body.title ? { title: body.title.slice(0, 80) } : {}),
151
- }, body.pinned);
198
+ if (typeof body?.pinned !== "boolean")
199
+ return c.json({ error: "pinned required" }, 400);
200
+ const id = c.req.param("id");
201
+ // The directory comes from the listing, not from the client that clicked:
202
+ // it is the one fact this row keeps about the session, it is the key a
203
+ // project's manual place is stamped on, and the browser is not where a
204
+ // path should come from when the server already knows it.
205
+ const cwd = (await factory.find(id))?.cwd ?? nascent.get(id)?.cwd;
206
+ if (!cwd)
207
+ return c.json({ error: `session ${id} has no directory Pier can find` }, 404);
208
+ state.pin(id, cwd, body.pinned);
152
209
  hub.emitWorkspace({ type: "sessions-changed" });
153
210
  return c.json({ pinned: body.pinned });
154
211
  });
@@ -161,7 +218,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
161
218
  // deltas from SSE — transcript, live state, pending queue, model.
162
219
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
163
220
  const id = c.req.param("id");
164
- const session = await ensure(id);
221
+ const session = await ensureLoadable(id);
165
222
  return c.json({
166
223
  turns: (await session.history()).map(slim),
167
224
  lastSeq: hub.lastSeq(id),
@@ -256,11 +313,14 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
256
313
  const { sessionId } = await router.dispatch({
257
314
  key: { channelId: "web", conversationId: id },
258
315
  senderId: "web",
316
+ // Named, not anonymous: a session reached from a group chat as well as
317
+ // from here attributes an unheaded message to whoever spoke last
318
+ // (core/identity.ts), which is the operator's own words in someone
319
+ // else's mouth.
320
+ sender: { id: "web", name: "operator" },
259
321
  text: body.text,
260
322
  mode,
261
323
  });
262
- if (state.title(id, body.text))
263
- hub.emitWorkspace({ type: "sessions-changed" });
264
324
  return c.json({ sessionId }, 202);
265
325
  });
266
326
  // Edit a user turn: rewind the transcript to just before it, then re-send
@@ -281,9 +341,13 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
281
341
  if (session.state === "streaming")
282
342
  return c.json({ error: "busy — stop the turn first" }, 409);
283
343
  await session.rewindToUserTurn(index);
344
+ // The rewind took the turns after this one out of the context, headers and
345
+ // all; what the model was told about who is speaking went with them.
346
+ router.forgetSender(id);
284
347
  await router.dispatch({
285
348
  key: { channelId: "web", conversationId: id },
286
349
  senderId: "web",
350
+ sender: { id: "web", name: "operator" },
287
351
  text: body.text,
288
352
  mode: "auto",
289
353
  });
@@ -309,6 +373,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
309
373
  return c.json({ error: "queue is empty" }, 409);
310
374
  if (mode === "restart")
311
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.
312
379
  await router.dispatch({
313
380
  key: { channelId: "web", conversationId: id },
314
381
  senderId: "web",
@@ -319,10 +386,48 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
319
386
  });
320
387
  // Recall: drop all pending queued messages and hand them back (composer restore).
321
388
  guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
322
- const session = await ensure(c.req.param("id"));
389
+ const id = c.req.param("id");
390
+ const session = await ensure(id);
323
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);
324
396
  return c.json({ messages: [...steering, ...followUp] });
325
397
  });
398
+ // Shrink the context on demand: Pi summarizes the older transcript away and
399
+ // the session continues from the summary. Refused while streaming, like the
400
+ // edit route above and for the same reason — Pi's own compaction aborts a
401
+ // running turn to do it, and losing a turn is not what the button offered.
402
+ // The result is not in this response: it arrives on the session's stream as
403
+ // `context-compacted` (agent/events.ts), which is also the only place the
404
+ // automatic compaction can be seen.
405
+ guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
406
+ const session = await ensure(c.req.param("id"));
407
+ if (session.state === "streaming")
408
+ return c.json({ error: "busy — stop the turn first" }, 409);
409
+ // The check above is a courtesy, not the lock: two clicks pass it on the
410
+ // same tick, so the seam refuses the second one (agent/pi.ts) and its
411
+ // refusal keeps the status this route already uses for "not now" — a 404
412
+ // from `guarded` would have read as "no such session".
413
+ return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
414
+ });
415
+ // A name, so a title is what you called it instead of the first 80
416
+ // characters you happened to type. Not refused while streaming: a rename has
417
+ // nothing to do with the turn running, and the transcript takes an append.
418
+ guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
419
+ const body = await c.req.json().catch(() => null);
420
+ if (typeof body?.name !== "string")
421
+ return c.json({ error: "name required" }, 400);
422
+ const id = c.req.param("id");
423
+ await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
424
+ // Nothing to write and nothing to report: the name went into the
425
+ // transcript, which is what every list reads. The event is how the
426
+ // surfaces learn to re-read it, and the seam dropped its retained scan on
427
+ // the way out so the re-read sees the new name.
428
+ hub.emitWorkspace({ type: "sessions-changed" });
429
+ return c.json({ ok: true });
430
+ });
326
431
  app.post("/api/sessions/:id/abort", async (c) => {
327
432
  const id = c.req.param("id");
328
433
  await router.abort(id);
@@ -396,6 +501,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
396
501
  updates,
397
502
  updater,
398
503
  secrets,
504
+ catalog,
505
+ names,
506
+ onToolsChanged,
507
+ validateCustomTools,
399
508
  onUnlocked,
400
509
  onSettingsChanged: () => recycle("instance settings"),
401
510
  });
@@ -445,6 +554,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
445
554
  c.header("cache-control", "private, no-cache");
446
555
  await next();
447
556
  });
557
+ // Hashed bundles never change under their name — a release writes new names,
558
+ // and the shell above is what re-points at them. Without this they carry only
559
+ // the auth layer's bare `private`, so a browser revalidates each one before it
560
+ // may reuse it: three round trips on a remote instance, one of them in front
561
+ // of the 636KB terminal emulator, every time the workbench is opened.
562
+ app.get("/assets/*", async (c, next) => {
563
+ c.header("cache-control", "private, max-age=31536000, immutable");
564
+ await next();
565
+ });
448
566
  app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
449
567
  return app;
450
568
  }
@@ -1,6 +1,20 @@
1
- // Workbench organization state: which sessions show up under Projects, the
2
- // summaries needed to render them without scanning Pi, and which have a
3
- // finished turn no client has looked at yet.
1
+ // What the workbench decided about a session, and nothing a transcript already
2
+ // knows. Ownership of a row in Projects (`pinned`), a finished turn nobody has
3
+ // looked at (`unread`), and the two places a hand put it in (`sort`,
4
+ // `project_sort`).
5
+ //
6
+ // It used to mirror the summary too — cwd, title, created_at, last_active —
7
+ // because listing sessions meant parsing every transcript on disk (~237ms) and
8
+ // the rail could not pay that on every read. agent/listing.ts made a listing
9
+ // cheap, so the mirror bought nothing and cost two stores kept in step: a
10
+ // backfill gate, a repair pass on every full listing, a touch at the end of
11
+ // every turn, a title write on the first prompt, a second write on rename.
12
+ // Deriving beats syncing; all of it is gone.
13
+ //
14
+ // `cwd` stayed, and is not a mirror: it is the key a project's manual place is
15
+ // stamped on (`reorder`, and the sibling lookup in `pin`), and a session's
16
+ // working directory is fixed when its transcript is created — an immutable key
17
+ // needs no synchronising.
4
18
  //
5
19
  // One row per session rather than two JSON files: the unread flag is written at
6
20
  // the end of every turn, and rewriting a whole file on each of those writes
@@ -20,48 +34,44 @@ export class SessionStateStore {
20
34
  this.#db.prepare(`INSERT INTO session_state(session_id, unread) VALUES (?, ?)
21
35
  ON CONFLICT(session_id) DO UPDATE SET unread = excluded.unread`).run(sessionId, unread ? 1 : 0);
22
36
  }
23
- /** Pin plus the summary Projects needs, atomically in one row. */
24
- pin(summary, pinned) {
25
- this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at)
26
- VALUES (?, ?, ?, ?, ?)
37
+ /** Projects takes the session, or gives it up. Membership lasts until a hand
38
+ * ends it: nothing expires, so what is pinned is what the rail draws until
39
+ * the on the row says otherwise. */
40
+ pin(sessionId, cwd, pinned) {
41
+ // A new session joins a project that already has a place in the list.
42
+ // Unranked it would sort on top — lifting the whole project with it, which
43
+ // is the jump manual order exists to stop.
44
+ const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(cwd);
45
+ this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, project_sort)
46
+ VALUES (?, ?, ?, ?)
27
47
  ON CONFLICT(session_id) DO UPDATE SET
28
48
  pinned = excluded.pinned,
29
49
  cwd = excluded.cwd,
30
- title = COALESCE(excluded.title, session_state.title),
31
- created_at = excluded.created_at`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt);
50
+ project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(sessionId, pinned ? 1 : 0, cwd, sibling?.rank ?? null);
32
51
  }
33
- /** Project rows only; unlike AgentFactory.list(), this never touches disk. */
34
- projects() {
35
- const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread
36
- FROM session_state
37
- WHERE pinned = 1 AND cwd IS NOT NULL AND created_at IS NOT NULL
38
- ORDER BY created_at DESC`).all();
39
- return rows.map(({ title, unread, ...row }) => ({
40
- ...row,
41
- ...(title ? { title } : {}),
42
- unread: unread === 1,
43
- }));
52
+ /** One drag = one write of the whole list it reordered: index is the place.
53
+ * `sessions` are ids (a session's place inside its project), `projects` are
54
+ * cwds, whose place is stamped on every session that has that cwd. */
55
+ reorder(order) {
56
+ const bySession = this.#db.prepare("UPDATE session_state SET sort = ? WHERE session_id = ?");
57
+ const byCwd = this.#db.prepare("UPDATE session_state SET project_sort = ? WHERE cwd = ?");
58
+ this.#tx(() => {
59
+ order.sessions?.forEach((id, i) => bySession.run(i, id));
60
+ order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
61
+ });
44
62
  }
45
- /** What to call a session where there is room for one line — a push
46
- * notification's title. Falls back to the project directory, then to the
47
- * fact that it is a session at all: a notification with no title reads as a
48
- * browser bug rather than as an unnamed session. */
49
- name(sessionId) {
50
- const row = this.#db.prepare("SELECT title, cwd FROM session_state WHERE session_id = ?").get(sessionId);
51
- return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
63
+ /** Drop a session's organization row entirely pin, order, unread. For
64
+ * ghosts: Pi persists a session only once its first assistant reply lands,
65
+ * so a created-and-never-messaged one cannot be resumed, and its remembered
66
+ * row would otherwise sit in the rail 404ing forever. */
67
+ forget(sessionId) {
68
+ this.#db.prepare("DELETE FROM session_state WHERE session_id = ?").run(sessionId);
52
69
  }
53
- needsProjectBackfill() {
54
- return this.#db.prepare("SELECT 1 FROM session_state WHERE pinned = 1 AND (cwd IS NULL OR created_at IS NULL) LIMIT 1").get() !== undefined;
55
- }
56
- /** A full listing is rare; use it to repair metadata for rows we already own. */
57
- remember(summaries) {
58
- const update = this.#db.prepare(`UPDATE session_state SET
59
- cwd = ?, title = COALESCE(?, title), created_at = ?
60
- WHERE session_id = ?`);
70
+ /** All-or-nothing: a half-written order is a list nobody arranged. */
71
+ #tx(run) {
61
72
  this.#db.exec("BEGIN");
62
73
  try {
63
- for (const s of summaries)
64
- update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
74
+ run();
65
75
  this.#db.exec("COMMIT");
66
76
  }
67
77
  catch (err) {
@@ -69,13 +79,16 @@ export class SessionStateStore {
69
79
  throw err;
70
80
  }
71
81
  }
82
+ /** What this store knows about the sessions it knows anything about, for a
83
+ * caller holding the listing. */
72
84
  flags() {
73
- const rows = this.#db.prepare("SELECT session_id AS id, pinned, unread FROM session_state WHERE pinned = 1 OR unread = 1").all();
74
- return new Map(rows.map((r) => [r.id, { pinned: r.pinned === 1, unread: r.unread === 1 }]));
75
- }
76
- /** The first prompt supplies the title of a newly-created pinned session. */
77
- title(sessionId, text) {
78
- const result = this.#db.prepare("UPDATE session_state SET title = ? WHERE session_id = ? AND pinned = 1 AND title IS NULL").run(text.trim().slice(0, 80), sessionId);
79
- return result.changes > 0;
85
+ const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
86
+ FROM session_state WHERE pinned = 1 OR unread = 1`).all();
87
+ return new Map(rows.map((r) => [r.id, {
88
+ pinned: r.pinned === 1,
89
+ unread: r.unread === 1,
90
+ ...(r.sort === null ? {} : { sort: r.sort }),
91
+ ...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
92
+ }]));
80
93
  }
81
94
  }
@@ -28,6 +28,15 @@ const MAX_FRAME_BYTES = 1024 * 1024;
28
28
  // start under. Inheriting these can attach the shell back into Pier's parent
29
29
  // tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
30
30
  const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
31
+ // Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
32
+ // alone turns an `npm i` typed here into an install with no dev dependencies,
33
+ // and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
34
+ // instance rather than the person's. Everything else is inherited on purpose:
35
+ // PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
36
+ // the shell usable, and on a service-managed instance nothing else supplies
37
+ // them.
38
+ const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
39
+ const PIER_OWN_PREFIX = /^PI(ER)?_/;
31
40
  const send = (sock, data) => {
32
41
  try {
33
42
  sock.send(data);
@@ -44,6 +53,7 @@ export class TerminalHub {
44
53
  #shell;
45
54
  #idleMs;
46
55
  #maxTerms;
56
+ #initCommand;
47
57
  #sweeper;
48
58
  #closeListeners = new Set();
49
59
  #closed = false;
@@ -51,6 +61,7 @@ export class TerminalHub {
51
61
  this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
52
62
  this.#idleMs = opts.idleMs ?? IDLE_MS;
53
63
  this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
64
+ this.#initCommand = opts.initCommand ?? (() => "");
54
65
  this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
55
66
  this.#sweeper.unref();
56
67
  }
@@ -117,8 +128,11 @@ export class TerminalHub {
117
128
  }
118
129
  #spawn(cwd) {
119
130
  const env = { ...process.env };
120
- for (const key of PARENT_TERMINAL_ENV)
121
- delete env[key];
131
+ for (const key of Object.keys(env)) {
132
+ if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
133
+ delete env[key];
134
+ }
135
+ }
122
136
  const pty = spawn(this.#shell, [], {
123
137
  name: "xterm-256color",
124
138
  cols: 120,
@@ -129,6 +143,14 @@ export class TerminalHub {
129
143
  const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
130
144
  this.#terms.set(cwd, term);
131
145
  log.info(`shell ${pty.pid} for ${cwd}`);
146
+ // Typed in, not exec'd: the shell stays the parent, so quitting whatever
147
+ // this starts leaves a usable prompt, and the echo plus any error is in
148
+ // the ring where the person can see what ran. The tty buffers it until the
149
+ // shell's first read, so no wait is needed. A reattach never repeats it —
150
+ // this runs once per pty, which is once per cwd.
151
+ const init = this.#initCommand().trim();
152
+ if (init)
153
+ pty.write(`${init}\r`);
132
154
  pty.onData((data) => {
133
155
  const chunk = Buffer.from(data);
134
156
  term.ring.push(chunk);
@@ -179,6 +201,13 @@ export class TerminalHub {
179
201
  term.pty.write(msg.d);
180
202
  return;
181
203
  }
204
+ // The only way a page can end a shell: everything attached to it is told by
205
+ // the exit path below, exactly as if the shell had exited on its own.
206
+ if (msg.t === "restart") {
207
+ log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
208
+ term.pty.kill();
209
+ return;
210
+ }
182
211
  if (msg.t === "resize" &&
183
212
  typeof msg.cols === "number" && typeof msg.rows === "number" &&
184
213
  Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
@@ -225,8 +254,9 @@ export class TerminalHub {
225
254
  /** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
226
255
  * as every route. SameSite=Lax already withholds the cookie cross-site; the
227
256
  * Origin check is the explicit copy of that fact. */
228
- export function attachTerminal(server, auth, heartbeatMs = HEARTBEAT_MS) {
229
- const hub = new TerminalHub();
257
+ export function attachTerminal(server, auth, opts = {}) {
258
+ const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
259
+ const hub = new TerminalHub(opts);
230
260
  const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
231
261
  const alive = new WeakSet();
232
262
  const heartbeat = setInterval(() => {
@@ -0,0 +1,5 @@
1
+ // The shapes web's HTTP answers carry beyond the stores they come from — the
2
+ // area's own wire vocabulary, and the one file in web/ the browser may import
3
+ // type-only (docs/architecture.md, same rule as tasks/types.ts). Nothing here
4
+ // runs: no imports, no values, so a page pays nothing for reading it.
5
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.8",
3
+ "version": "0.0.15",
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",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pier-boards
3
- description: Publish a Board — a folder of static HTML Pier serves at a stable URL — to present a report, digest, dashboard or handover note to the user. Read before building any page-shaped deliverable, or before editing an existing board.
3
+ description: Publish a Board — a folder of static HTML Pier serves at a stable URL. Read before building any page-shaped deliverable (report, digest, dashboard) or editing an existing board.
4
4
  ---
5
5
 
6
6
  # Building a Pier board
@@ -27,23 +27,33 @@ session may read or rewrite any board, and closing this one changes nothing.
27
27
  }
28
28
  ```
29
29
 
30
- Those four fields are the whole manifest.
30
+ Those four fields are the whole manifest (publishing adds a fifth — below).
31
31
 
32
- - `slug`: `[a-z0-9][a-z0-9-]{0,63}`, and it is the URL — short and stable.
32
+ - `slug`: `[a-z0-9][a-z0-9-]{0,63}`, and it is the URL — short and stable. Do
33
+ not add random characters of your own; publishing adds them (see below).
33
34
  - `description` is the Console list entry: write it for someone who has
34
35
  forgotten this conversation.
35
36
  - `sessions`: append your own id, never replace — other ids are provenance too.
36
37
 
37
38
  ## Publish, then hand over the link
38
39
 
39
- `"public": true` serves the board at `/p/<slug>/` **with no password**. Set it
40
- only if the user asked for a public or shareable board *in this request*;
41
- otherwise leave it `false` and say the board is private. Never publish personal
42
- data or anything the user has not seen.
40
+ `"public": true` serves the board **with no password**. Set it only if the user
41
+ asked for a public or shareable board *in this request*; otherwise leave it
42
+ `false` and say the board is private. Never publish personal data or anything
43
+ the user has not seen.
43
44
 
44
- Asked to make an existing board public? Flip `"public"` to `true` in
45
- `board.json` and reply with the `/p/<slug>/` link that is the whole answer.
46
- No verification step, no narrating the edit, no restating what the page holds.
45
+ The published address is `/p/<slug>-<token>/`, not `/p/<slug>/`, so a public
46
+ board's URL cannot be guessed from its name. `token` is a fifth manifest field
47
+ you write next to `"public": true` eight hex characters from
48
+ `openssl rand -hex 4`, never invented in your head, never reused between
49
+ boards. Leave it out and Pier mints one on the first request, but then the link
50
+ is only visible in the Console, so write it yourself and you can hand it over
51
+ in the same message.
52
+
53
+ Asked to make an existing board public? Set `"public": true` and a fresh
54
+ `token` in `board.json`, then reply with the `/p/<slug>-<token>/` link — that is
55
+ the whole answer. Already has a token? Keep it: the link may be out there. No
56
+ verification step, no narrating the edit, no restating what the page holds.
47
57
 
48
58
  The message announcing the board carries **one bare URL** — paste the address
49
59
  itself, never `[title](url)`: link labels get mangled or truncated on some chat
@@ -54,10 +64,10 @@ surfaces, and the title is already on the page. No filesystem paths either —
54
64
  | The user asked for | Send |
55
65
  | --- | --- |
56
66
  | a board, nothing about sharing | `https://pier.example.com/boards/weekly-digest/` — behind the Pier password; Console → Boards makes it public |
57
- | a **public** board | `https://pier.example.com/p/weekly-digest/` — no password |
67
+ | a **public** board | `https://pier.example.com/p/weekly-digest-3f9ac128/` — no password; the suffix is the manifest's `token`, copied verbatim |
58
68
 
59
69
  Never both: the pair invites pasting the password-free URL of a board that was
60
- never meant to leave the workspace, and `/p/<slug>/` 404s unless the manifest
70
+ never meant to leave the workspace, and `/p/<slug>-<token>/` 404s unless the manifest
61
71
  says `"public": true`. No address configured? Give the path, say Console →
62
72
  Settings turns it into a link, and never guess a host.
63
73
 
@@ -74,7 +84,7 @@ build, no npm, no framework:
74
84
  <meta charset="utf-8">
75
85
  <meta name="viewport" content="width=device-width, initial-scale=1">
76
86
  <title>Weekly digest — infra</title>
77
- <link rel="stylesheet" href="/boards/_assets/pier.css">
87
+ <link rel="stylesheet" href="/p/_assets/pier.css">
78
88
  </head>
79
89
  <body>
80
90
  <div class="hero">