@timqi/pier 0.0.9 → 0.0.16

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 (67) hide show
  1. package/README.md +7 -1
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +190 -31
  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/lark-api.js +38 -0
  10. package/dist/channels/lark-outbound.js +11 -2
  11. package/dist/channels/slack-api.js +36 -0
  12. package/dist/channels/slack-outbound.js +12 -2
  13. package/dist/channels/slack-tool.js +49 -9
  14. package/dist/channels/telegram-api.js +21 -2
  15. package/dist/channels/telegram.js +23 -8
  16. package/dist/cli.js +34 -0
  17. package/dist/core/identity.js +18 -0
  18. package/dist/core/inbound-file.js +3 -1
  19. package/dist/core/reply.js +2 -1
  20. package/dist/core/router.js +72 -0
  21. package/dist/db.js +78 -0
  22. package/dist/extensions/index.js +5 -2
  23. package/dist/extensions/web/artifacts.js +7 -2
  24. package/dist/extensions/web/content.js +5 -0
  25. package/dist/extensions/web/language.js +5 -0
  26. package/dist/extensions/web/tools.js +33 -8
  27. package/dist/limits.js +14 -0
  28. package/dist/main.js +47 -6
  29. package/dist/paths.js +6 -1
  30. package/dist/settings.js +44 -0
  31. package/dist/tasks/agent.js +23 -4
  32. package/dist/tasks/callbacks.js +20 -1
  33. package/dist/tasks/command.js +15 -0
  34. package/dist/tasks/definitions.js +60 -12
  35. package/dist/tasks/execution.js +9 -1
  36. package/dist/tasks/groups.js +8 -4
  37. package/dist/tasks/messages.js +10 -2
  38. package/dist/tasks/routes.js +4 -0
  39. package/dist/tasks/runs.js +7 -2
  40. package/dist/tasks/service.js +22 -6
  41. package/dist/tasks/store.js +4 -0
  42. package/dist/tasks/tool.js +0 -12
  43. package/dist/tasks/types.js +4 -0
  44. package/dist/tools-task.js +155 -0
  45. package/dist/tools.js +875 -0
  46. package/dist/web/auth.js +5 -3
  47. package/dist/web/explorer.js +15 -2
  48. package/dist/web/files.js +1 -1
  49. package/dist/web/instance.js +165 -36
  50. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
  51. package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
  52. package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
  53. package/dist/web/public/index.html +5 -8
  54. package/dist/web/public/sw.js +4 -0
  55. package/dist/web/push.js +22 -7
  56. package/dist/web/repos.js +75 -0
  57. package/dist/web/server.js +145 -64
  58. package/dist/web/session-state.js +33 -51
  59. package/dist/web/types.js +5 -0
  60. package/docs/deploy.md +12 -3
  61. package/package.json +1 -1
  62. package/skills/pier-boards/SKILL.md +23 -13
  63. package/skills/pier-help/SKILL.md +1 -1
  64. package/skills/pier-slack/SKILL.md +21 -1
  65. package/skills/pier-tasks/SKILL.md +2 -2
  66. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  67. package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
@@ -33,8 +33,8 @@
33
33
  which style.css pays for with the safe-area inset. -->
34
34
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
35
35
  <title>Pier</title>
36
- <script type="module" crossorigin src="/assets/index-DNCJJRSS.js"></script>
37
- <link rel="stylesheet" crossorigin href="/assets/index-DYl1xk5y.css">
36
+ <script type="module" crossorigin src="/assets/index-DVUvzNK1.js"></script>
37
+ <link rel="stylesheet" crossorigin href="/assets/index-2E9_cwpg.css">
38
38
  </head>
39
39
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
40
40
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
@@ -129,7 +129,7 @@
129
129
  Console view keeps the same way back to the drawer as a chat does. -->
130
130
  <header id="mobile-bar" class="flex h-12 flex-none items-center gap-1 border-b border-neutral-200 px-1.5 md:hidden">
131
131
  <button type="button" id="drawer-toggle" aria-label="Open sidebar"
132
- class="flex h-10 w-10 flex-none cursor-pointer items-center justify-center rounded-md text-neutral-500 active:bg-neutral-200">
132
+ class="relative flex h-10 w-10 flex-none cursor-pointer items-center justify-center rounded-md text-neutral-500 active:bg-neutral-200">
133
133
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" class="h-5 w-5">
134
134
  <path d="M2.5 4h11M2.5 8h11M2.5 12h11" />
135
135
  </svg>
@@ -237,11 +237,8 @@
237
237
  <h3 class="font-semibold">New session</h3>
238
238
  <label class="flex flex-col gap-1 text-[13px] text-neutral-600">
239
239
  Working directory
240
- <span id="new-cwd-row" class="flex items-center gap-1.5">
241
- <input id="new-cwd" type="text" required placeholder="/path/to/project" list="known-projects"
242
- class="min-w-0 flex-1 rounded-md border border-neutral-300 px-2 py-1.5 font-mono text-[13px] focus:border-indigo-400 focus:outline-none" />
243
- </span>
244
- <datalist id="known-projects"></datalist>
240
+ <input id="new-cwd" type="text" required placeholder="/path/to/project"
241
+ class="w-full rounded-md border border-neutral-300 px-2 py-1.5 font-mono text-[13px] focus:border-indigo-400 focus:outline-none" />
245
242
  </label>
246
243
  <div class="flex justify-end gap-2">
247
244
  <button type="button" id="new-cancel" class="btn text-[13px]">Cancel</button>
@@ -59,6 +59,10 @@ self.addEventListener("push", (event) => {
59
59
  await self.registration.showNotification(title, {
60
60
  body: data.body || "A turn finished.",
61
61
  tag: data.tag || "pier",
62
+ // The tag collapses a session's turns into one banner; without this the
63
+ // second turn silently replaces the first — no sound, no re-alert, which
64
+ // reads as "notifications stopped working". Ignored where unsupported.
65
+ renotify: true,
62
66
  icon: "/icon-192.png",
63
67
  badge: "/icon-32.png",
64
68
  timestamp: Date.now(),
package/dist/web/push.js CHANGED
@@ -7,6 +7,7 @@
7
7
  // "still unread when the dust settled" is precisely "nobody saw it". One rule,
8
8
  // one place — a second notion of attention would drift from the dot within a
9
9
  // release.
10
+ import { readableTitle } from "../core/identity.js";
10
11
  import { pierDb } from "../db.js";
11
12
  import { logger } from "../log.js";
12
13
  import { generateVapidKeys, sendPush, } from "./webpush.js";
@@ -20,6 +21,14 @@ const MAX_SUBSCRIPTIONS = 20;
20
21
  * notification about something that just happened. */
21
22
  const SETTLE_MS = 6_000;
22
23
  const MAX_BODY_CHARS = 160;
24
+ /** What to call the session where there is room for one line. The title is put
25
+ * through the same reader the sidebar uses — untouched, a session titled by
26
+ * its first prompt announces itself on the lock screen as
27
+ * `[operator<web> 12:01]`. Falls back to the project directory, then to the
28
+ * fact that it is a session at all: a notification with no title reads as a
29
+ * browser bug rather than as an unnamed session — and a listing that could
30
+ * not answer must not silence the push. */
31
+ const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
23
32
  /** Subscriptions and the instance's VAPID identity. Both are per-instance
24
33
  * facts nobody edits by hand, so they live beside every other one. */
25
34
  export class PushStore {
@@ -100,7 +109,7 @@ function parseTarget(body) {
100
109
  return { endpoint, p256dh, auth };
101
110
  }
102
111
  export function registerPushRoutes(app, deps) {
103
- const { store, hub, unread, channelOf, name, publicUrl, settleMs = SETTLE_MS } = deps;
112
+ const { store, hub, unread, channelOf, summary, publicUrl, settleMs = SETTLE_MS } = deps;
104
113
  /** Who a push service should complain to. It has to be a mailto: or https:
105
114
  * URL or Apple rejects the token outright, so an instance that never had
106
115
  * its public URL set still needs an answer. */
@@ -187,12 +196,18 @@ export function registerPushRoutes(app, deps) {
187
196
  log.debug(`no push for ${e.sessionId}: a client reported the turn as seen`);
188
197
  return; // somebody has it on screen
189
198
  }
190
- void deliver({
191
- title: name(e.sessionId),
192
- body: preview(text) || "Turn finished.",
193
- url: `/#/session/${encodeURIComponent(e.sessionId)}`,
194
- tag: e.sessionId,
195
- }).catch((err) => log.error("delivering a push failed", err));
199
+ // One async step before the send, so a failure in *either* half is
200
+ // reported: a notification nobody received and one nobody sent look
201
+ // identical from here, and "why did my phone stay quiet" is the only
202
+ // question this feature is ever asked (§5b).
203
+ void (async () => {
204
+ await deliver({
205
+ title: label(await summary(e.sessionId)),
206
+ body: preview(text) || "Turn finished.",
207
+ url: `/#/session/${encodeURIComponent(e.sessionId)}`,
208
+ tag: e.sessionId,
209
+ });
210
+ })().catch((err) => log.error(`delivering a push for ${e.sessionId} failed`, err));
196
211
  }, settleMs);
197
212
  // A pending notification must never hold a shutting-down process open.
198
213
  timer.unref?.();
@@ -0,0 +1,75 @@
1
+ // Which repository a project directory belongs to, and which branch it has
2
+ // checked out.
3
+ //
4
+ // One reason: worktrees. A parallel-work checkout is a sibling directory with
5
+ // the same repository behind it, so the rail drew one project per directory and
6
+ // a repo worked on three branches looked like three unrelated projects. The
7
+ // grouping fact is not the path — `<repo>.<branch>` is a naming convention
8
+ // nobody is obliged to follow — it is the common git dir every worktree of a
9
+ // repository shares, and git is the only thing that knows it.
10
+ import { execFile } from "node:child_process";
11
+ import { promisify } from "node:util";
12
+ import { logger } from "../log.js";
13
+ const run = promisify(execFile);
14
+ const log = logger("web");
15
+ /** How long an answer stands. Repository identity never moves; the branch does,
16
+ * and a checkout switched under a session should say so within a minute. */
17
+ const TTL_MS = 60_000;
18
+ /** A probe that hangs must not hold a slot forever: this is a `rev-parse`. */
19
+ const TIMEOUT_MS = 5_000;
20
+ /** One `rev-parse` for both facts. `--path-format=absolute` applies to the
21
+ * path option that follows it, so the two answers come back in argument
22
+ * order: common dir, then branch. */
23
+ const probeGit = async (cwd) => {
24
+ const { stdout } = await run("git", ["-C", cwd, "rev-parse", "--path-format=absolute", "--git-common-dir", "--abbrev-ref", "HEAD"], { timeout: TIMEOUT_MS });
25
+ const [repo, branch] = stdout.split("\n").map((line) => line.trim());
26
+ if (!repo)
27
+ return null;
28
+ return { repo, ...(branch && branch !== "HEAD" ? { branch } : {}) };
29
+ };
30
+ export class RepoIndex {
31
+ onChange;
32
+ probe;
33
+ #cache = new Map();
34
+ #probing = new Set();
35
+ constructor(
36
+ /** Called when a probe changed what `get` answers, so the surface that
37
+ * already rendered the old grouping is told to ask again. */
38
+ onChange = () => { }, probe = probeGit) {
39
+ this.onChange = onChange;
40
+ this.probe = probe;
41
+ }
42
+ /**
43
+ * What is known right now, and never a promise: the rail renders on the first
44
+ * paint, with one project per directory until the answer lands and `onChange`
45
+ * regroups it. A directory that is not a repository answers `undefined`
46
+ * forever, at the cost of one `rev-parse` a minute.
47
+ */
48
+ get(cwd) {
49
+ const hit = this.#cache.get(cwd);
50
+ if (!hit || Date.now() - hit.at > TTL_MS)
51
+ void this.#refresh(cwd);
52
+ return hit?.info ?? undefined;
53
+ }
54
+ async #refresh(cwd) {
55
+ if (this.#probing.has(cwd))
56
+ return;
57
+ this.#probing.add(cwd);
58
+ try {
59
+ const info = await this.probe(cwd).catch(() => null);
60
+ const before = this.#cache.get(cwd)?.info;
61
+ this.#cache.set(cwd, { at: Date.now(), info });
62
+ if (before?.repo !== info?.repo || before?.branch !== info?.branch)
63
+ this.onChange();
64
+ }
65
+ catch (err) {
66
+ // Not a git failure — those are the `null` above. Something in the probe
67
+ // itself, which would otherwise leave the directory retried every read.
68
+ log.warn(`repository probe for ${cwd} failed`, err);
69
+ this.#cache.set(cwd, { at: Date.now(), info: null });
70
+ }
71
+ finally {
72
+ this.#probing.delete(cwd);
73
+ }
74
+ }
75
+ }
@@ -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 {};