@timqi/pier 0.0.9 → 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 (59) hide show
  1. package/dist/agent/events.js +53 -7
  2. package/dist/agent/listing.js +253 -0
  3. package/dist/agent/pi.js +177 -28
  4. package/dist/boards/boards.js +65 -16
  5. package/dist/boards/pier.css +1 -1
  6. package/dist/channels/attach.js +87 -0
  7. package/dist/channels/control.js +2 -2
  8. package/dist/channels/lark-api.js +38 -0
  9. package/dist/channels/lark-outbound.js +11 -2
  10. package/dist/channels/slack-api.js +36 -0
  11. package/dist/channels/slack-outbound.js +12 -2
  12. package/dist/channels/slack-tool.js +49 -9
  13. package/dist/channels/telegram-api.js +21 -2
  14. package/dist/channels/telegram.js +23 -8
  15. package/dist/cli.js +34 -0
  16. package/dist/core/identity.js +18 -0
  17. package/dist/core/inbound-file.js +3 -1
  18. package/dist/core/reply.js +2 -1
  19. package/dist/core/router.js +72 -0
  20. package/dist/db.js +78 -0
  21. package/dist/extensions/index.js +5 -2
  22. package/dist/extensions/web/artifacts.js +7 -2
  23. package/dist/extensions/web/tools.js +28 -8
  24. package/dist/limits.js +14 -0
  25. package/dist/main.js +47 -6
  26. package/dist/paths.js +6 -1
  27. package/dist/settings.js +44 -0
  28. package/dist/tasks/agent.js +18 -4
  29. package/dist/tasks/callbacks.js +20 -1
  30. package/dist/tasks/definitions.js +56 -12
  31. package/dist/tasks/execution.js +5 -1
  32. package/dist/tasks/groups.js +4 -4
  33. package/dist/tasks/messages.js +4 -2
  34. package/dist/tasks/runs.js +2 -2
  35. package/dist/tasks/service.js +16 -6
  36. package/dist/tasks/tool.js +0 -12
  37. package/dist/tools-task.js +155 -0
  38. package/dist/tools.js +875 -0
  39. package/dist/web/auth.js +5 -3
  40. package/dist/web/explorer.js +15 -2
  41. package/dist/web/files.js +1 -1
  42. package/dist/web/instance.js +165 -36
  43. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-xcUrfRRs.js} +1 -1
  44. package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
  45. package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
  46. package/dist/web/public/index.html +5 -8
  47. package/dist/web/public/sw.js +4 -0
  48. package/dist/web/push.js +22 -7
  49. package/dist/web/repos.js +75 -0
  50. package/dist/web/server.js +145 -64
  51. package/dist/web/session-state.js +33 -51
  52. package/dist/web/types.js +5 -0
  53. package/package.json +1 -1
  54. package/skills/pier-boards/SKILL.md +23 -13
  55. package/skills/pier-help/SKILL.md +1 -1
  56. package/skills/pier-slack/SKILL.md +21 -1
  57. package/skills/pier-tasks/SKILL.md +2 -2
  58. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  59. package/dist/web/public/assets/index-DYl1xk5y.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, extensions, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
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,39 +70,80 @@ 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
- // `order` is where the workbench was arranged to put this row, not a fact
81
- // about the Pi session — it rides along so one Projects read is enough.
82
- const present = (s, pinned, unread, order = {}) => ({
83
- ...s,
84
- ...order,
85
- state: router.stateOf(s.id) ?? "idle",
86
- pinned,
87
- unread,
88
- channel: channelOf?.(s.id) ?? "web",
89
- activeRuns: activeRuns(s.id),
90
- });
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 () => {
105
+ const sessions = await listSessions();
106
+ for (const s of sessions)
107
+ nascent.delete(s.id);
108
+ // A session created but never prompted would otherwise be listed forever.
109
+ for (const [id, n] of nascent)
110
+ if (Date.now() - n.createdAt > 86_400_000)
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.
91
142
  app.get("/api/projects", async (c) => {
92
- // Existing databases have pin booleans but no summaries. Pay one legacy
93
- // scan, fill those rows, then every later Projects read is SQLite-only.
94
- if (projectBackfillNeeded) {
95
- await listSessions();
96
- // Do not retry on every request, and do not clear a pin whose transcript
97
- // happened to be unreadable. A later explicit full listing can repair it.
98
- projectBackfillNeeded = false;
99
- }
100
- return c.json(state.projects().map(({ sort, projectSort, ...s }) => present(s, true, s.unread, { sort, projectSort })));
143
+ const flags = state.flags();
144
+ return c.json((await allSessions())
145
+ .map((s) => present(s, flags.get(s.id)))
146
+ .filter((row) => row.listed));
101
147
  });
102
148
  // One drag, one write of the list that changed: the projects, or one
103
149
  // project's sessions. Whole lists rather than a move — the client has just
@@ -120,21 +166,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
120
166
  return c.json({ ok: true });
121
167
  });
122
168
  app.get("/api/sessions", async (c) => {
123
- const sessions = await listSessions();
124
- for (const s of sessions)
125
- nascent.delete(s.id);
126
- // A session created but never prompted would otherwise be listed forever.
127
- for (const [id, n] of nascent)
128
- if (Date.now() - n.createdAt > 86_400_000)
129
- nascent.delete(id);
130
169
  const flags = state.flags();
131
- return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => {
132
- const row = flags.get(s.id);
133
- return present(s, row?.pinned ?? false, row?.unread ?? false, {
134
- sort: row?.sort,
135
- projectSort: row?.projectSort,
136
- });
137
- }));
170
+ return c.json((await allSessions()).map((s) => present(s, flags.get(s.id))));
138
171
  });
139
172
  app.post("/api/sessions", async (c) => {
140
173
  const body = await c.req.json().catch(() => ({}));
@@ -146,7 +179,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
146
179
  nascent.set(session.id, { cwd: body.cwd, createdAt });
147
180
  router.attach({ channelId: "web", conversationId: session.id }, session);
148
181
  // Created here = part of the workspace; pinning is what Projects lists.
149
- state.pin({ id: session.id, cwd: body.cwd, createdAt }, true);
182
+ state.pin(session.id, body.cwd, true);
150
183
  hub.emitWorkspace({ type: "sessions-changed" });
151
184
  return c.json({ id: session.id }, 201);
152
185
  });
@@ -162,20 +195,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
162
195
  });
163
196
  app.post("/api/sessions/:id/pin", async (c) => {
164
197
  const body = await c.req.json().catch(() => null);
165
- if (typeof body?.pinned !== "boolean" ||
166
- typeof body.cwd !== "string" || !body.cwd ||
167
- typeof body.createdAt !== "number" || !Number.isFinite(body.createdAt) ||
168
- (body.title !== undefined && typeof body.title !== "string")) {
169
- return c.json({ error: "pinned and session summary required" }, 400);
170
- }
171
- // The summary came from this authenticated surface's own list. Persisting
172
- // it here makes the next Projects read independent of Pi's transcript scan.
173
- state.pin({
174
- id: c.req.param("id"),
175
- cwd: body.cwd,
176
- createdAt: body.createdAt,
177
- ...(body.title ? { title: body.title.slice(0, 80) } : {}),
178
- }, 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);
179
209
  hub.emitWorkspace({ type: "sessions-changed" });
180
210
  return c.json({ pinned: body.pinned });
181
211
  });
@@ -188,7 +218,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
188
218
  // deltas from SSE — transcript, live state, pending queue, model.
189
219
  guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
190
220
  const id = c.req.param("id");
191
- const session = await ensure(id);
221
+ const session = await ensureLoadable(id);
192
222
  return c.json({
193
223
  turns: (await session.history()).map(slim),
194
224
  lastSeq: hub.lastSeq(id),
@@ -283,11 +313,14 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
283
313
  const { sessionId } = await router.dispatch({
284
314
  key: { channelId: "web", conversationId: id },
285
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" },
286
321
  text: body.text,
287
322
  mode,
288
323
  });
289
- if (state.title(id, body.text))
290
- hub.emitWorkspace({ type: "sessions-changed" });
291
324
  return c.json({ sessionId }, 202);
292
325
  });
293
326
  // Edit a user turn: rewind the transcript to just before it, then re-send
@@ -308,9 +341,13 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
308
341
  if (session.state === "streaming")
309
342
  return c.json({ error: "busy — stop the turn first" }, 409);
310
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);
311
347
  await router.dispatch({
312
348
  key: { channelId: "web", conversationId: id },
313
349
  senderId: "web",
350
+ sender: { id: "web", name: "operator" },
314
351
  text: body.text,
315
352
  mode: "auto",
316
353
  });
@@ -336,6 +373,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
336
373
  return c.json({ error: "queue is empty" }, 409);
337
374
  if (mode === "restart")
338
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.
339
379
  await router.dispatch({
340
380
  key: { channelId: "web", conversationId: id },
341
381
  senderId: "web",
@@ -346,10 +386,48 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
346
386
  });
347
387
  // Recall: drop all pending queued messages and hand them back (composer restore).
348
388
  guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
349
- const session = await ensure(c.req.param("id"));
389
+ const id = c.req.param("id");
390
+ const session = await ensure(id);
350
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);
351
396
  return c.json({ messages: [...steering, ...followUp] });
352
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
+ });
353
431
  app.post("/api/sessions/:id/abort", async (c) => {
354
432
  const id = c.req.param("id");
355
433
  await router.abort(id);
@@ -423,7 +501,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
423
501
  updates,
424
502
  updater,
425
503
  secrets,
426
- extensions,
504
+ catalog,
505
+ names,
506
+ onToolsChanged,
507
+ validateCustomTools,
427
508
  onUnlocked,
428
509
  onSettingsChanged: () => recycle("instance settings"),
429
510
  });
@@ -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,20 +34,20 @@ 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) {
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) {
25
41
  // A new session joins a project that already has a place in the list.
26
42
  // Unranked it would sort on top — lifting the whole project with it, which
27
43
  // is the jump manual order exists to stop.
28
- const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(summary.cwd);
29
- this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at, project_sort)
30
- VALUES (?, ?, ?, ?, ?, ?)
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 (?, ?, ?, ?)
31
47
  ON CONFLICT(session_id) DO UPDATE SET
32
48
  pinned = excluded.pinned,
33
49
  cwd = excluded.cwd,
34
- title = COALESCE(excluded.title, session_state.title),
35
- created_at = excluded.created_at,
36
- project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt, sibling?.rank ?? null);
50
+ project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(sessionId, pinned ? 1 : 0, cwd, sibling?.rank ?? null);
37
51
  }
38
52
  /** One drag = one write of the whole list it reordered: index is the place.
39
53
  * `sessions` are ids (a session's place inside its project), `projects` are
@@ -46,41 +60,12 @@ export class SessionStateStore {
46
60
  order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
47
61
  });
48
62
  }
49
- /** Project rows only; unlike AgentFactory.list(), this never touches disk. */
50
- projects() {
51
- const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread,
52
- sort, project_sort AS projectSort
53
- FROM session_state
54
- WHERE pinned = 1 AND cwd IS NOT NULL AND created_at IS NOT NULL
55
- ORDER BY created_at DESC`).all();
56
- return rows.map(({ title, unread, sort, projectSort, ...row }) => ({
57
- ...row,
58
- ...(title ? { title } : {}),
59
- ...(sort === null ? {} : { sort }),
60
- ...(projectSort === null ? {} : { projectSort }),
61
- unread: unread === 1,
62
- }));
63
- }
64
- /** What to call a session where there is room for one line — a push
65
- * notification's title. Falls back to the project directory, then to the
66
- * fact that it is a session at all: a notification with no title reads as a
67
- * browser bug rather than as an unnamed session. */
68
- name(sessionId) {
69
- const row = this.#db.prepare("SELECT title, cwd FROM session_state WHERE session_id = ?").get(sessionId);
70
- return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
71
- }
72
- needsProjectBackfill() {
73
- 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;
74
- }
75
- /** A full listing is rare; use it to repair metadata for rows we already own. */
76
- remember(summaries) {
77
- const update = this.#db.prepare(`UPDATE session_state SET
78
- cwd = ?, title = COALESCE(?, title), created_at = ?
79
- WHERE session_id = ?`);
80
- this.#tx(() => {
81
- for (const s of summaries)
82
- update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
83
- });
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);
84
69
  }
85
70
  /** All-or-nothing: a half-written order is a list nobody arranged. */
86
71
  #tx(run) {
@@ -94,6 +79,8 @@ export class SessionStateStore {
94
79
  throw err;
95
80
  }
96
81
  }
82
+ /** What this store knows about the sessions it knows anything about, for a
83
+ * caller holding the listing. */
97
84
  flags() {
98
85
  const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
99
86
  FROM session_state WHERE pinned = 1 OR unread = 1`).all();
@@ -104,9 +91,4 @@ export class SessionStateStore {
104
91
  ...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
105
92
  }]));
106
93
  }
107
- /** The first prompt supplies the title of a newly-created pinned session. */
108
- title(sessionId, text) {
109
- 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);
110
- return result.changes > 0;
111
- }
112
94
  }
@@ -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.9",
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">
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pier-help
3
- description: How Pier itself works — durable sessions and what survives a restart, how messages and files reach you from Slack and Telegram, in-chat commands (/stop, /settings, /bind), interrupting a running turn, and what only the operator's Console can change. Read before explaining Pier's behavior or advising a user on how to use it.
3
+ description: How Pier itself works — durable sessions, what survives a restart, how messages and files reach you from IM, in-chat commands, and what only the operator's Console can change. Read before explaining Pier's behavior or advising a user on it.
4
4
  ---
5
5
 
6
6
  # How Pier works
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pier-slack
3
- description: Read and write Slack through Pier's slack tool — a channel's history for a time range, one thread, posting into a thread, deleting a message Pier posted, and the Slack-specific syntax for mentions and links. Read before answering questions about Slack conversations or posting anything to a workspace.
3
+ description: Read and write Slack through Pier's slack tool, including the Slack-specific syntax for mentions and links. Read before answering questions about Slack conversations or posting anything to a workspace.
4
4
  ---
5
5
 
6
6
  # Reading and writing Slack
@@ -97,6 +97,26 @@ Going top-level takes the explicit `"none"`: a channel's main flow is wider
97
97
  than a thread. A `thread_ts` is never inherited across a change of `channel`.
98
98
  The response carries `ts` and `threadTs` for replying under what you posted.
99
99
 
100
+ ## Editing
101
+
102
+ ```json
103
+ {"operation":"edit","channel":"#ops","ts":"1717243800.000100",
104
+ "text":"**Deploy done** — 3 services, 1 rollback."}
105
+ ```
106
+
107
+ `text` replaces the message outright; there is no partial edit. Read the
108
+ message first if you are changing part of it.
109
+
110
+ - Slack only lets Pier edit what **its own bot** posted — anyone else's message
111
+ answers `cant_update_message`, and the answer is to reply, not to retry.
112
+ - `ts` is always explicit, as with `delete`, and means nothing outside the
113
+ channel it was read in.
114
+ - The old text is gone — Slack keeps no version a reader can open, and may not
115
+ mark the message as edited at all. When the previous wording mattered to
116
+ people reading, say what changed rather than quietly rewriting history.
117
+ - A long-running update — a status line, a running tally — is better as one
118
+ message edited in place than as a new message per change.
119
+
100
120
  ## Deleting
101
121
 
102
122
  ```json
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pier-tasks
3
- description: Delegate work to Pier subagents with the task tool — one-shot delegation, core-joined parallel fan-out, chained steps, and mid-run control (steer/resume/contact). Read before delegating to a subagent, coordinating multiple agents, or running long background work.
3
+ description: Delegate work to Pier subagents with the task tool — one-shot, parallel fan-out, chains, mid-run control. Read before delegating to a subagent, coordinating agents, or running long background work.
4
4
  ---
5
5
 
6
6
  # Orchestrating Pier tasks
@@ -38,7 +38,7 @@ schedule policy, where `manual` means on-demand — by a human or by you.
38
38
  - The child inherits your current model unless `launch.model` is set.
39
39
  Unknown models fail with the available list in the error. `launch` applies to
40
40
  `fresh` and `fork` only — a `reuse` session owns its own model and tools.
41
- - The child has the same tools you do; there is no capability knob to pick.
41
+ - The child has the same tools you do.
42
42
  - Runs time out after 900s by default; set `timeoutSeconds` in the draft for
43
43
  longer jobs.
44
44