@timqi/pier 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,366 @@
1
+ // Web workbench backend: REST + SSE, a pure consumer of core.
2
+ // See docs/design/03-web-workbench.md for the route contract.
3
+ import { relative } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { serveStatic } from "@hono/node-server/serve-static";
6
+ import { Hono } from "hono";
7
+ import { streamSSE } from "hono/streaming";
8
+ import { EventHub } from "../core/hub.js";
9
+ import { Router } from "../core/router.js";
10
+ import { logger } from "../log.js";
11
+ import { guarded, registerFileRoutes } from "./files.js";
12
+ import { isThinkingLevel } from "../core/types.js";
13
+ import { normalizePublicUrl } from "../settings.js";
14
+ const HEARTBEAT_MS = 15_000;
15
+ /** Client reports per minute, for the whole server: a browser bug can fire in
16
+ * a loop, and the journal is shared with everything else Pier says. */
17
+ const CLIENT_LOG_PER_MINUTE = 60;
18
+ const MAX_IMAGES = 8;
19
+ const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // per image, base64 length ≈ bytes × 4/3
20
+ /** Validate at the seam: malformed attachments are rejected, never half-sent. */
21
+ function parseImages(raw) {
22
+ if (raw === undefined)
23
+ return [];
24
+ if (!Array.isArray(raw) || raw.length > MAX_IMAGES)
25
+ return { error: "invalid images" };
26
+ const images = [];
27
+ for (const i of raw) {
28
+ if (typeof i?.data !== "string" ||
29
+ !i.data ||
30
+ i.data.length > (MAX_IMAGE_BYTES * 4) / 3 ||
31
+ typeof i?.mimeType !== "string" ||
32
+ !i.mimeType.startsWith("image/")) {
33
+ return { error: "invalid images" };
34
+ }
35
+ images.push({ data: i.data, mimeType: i.mimeType });
36
+ }
37
+ return images;
38
+ }
39
+ export function createServer({ factory, router, hub, sessions: state, config, settings, secrets, onUnlocked, backgroundRuns }) {
40
+ const app = new Hono();
41
+ // A finished turn marks its session unread until some client reports it was
42
+ // seen (session selected + tab visible → POST read below). Server-side so
43
+ // every client shows the same attention state. Streaming → idle is the
44
+ // trigger — same transition the client notification uses — and it needs a
45
+ // start we witnessed, so a session that boots idle stays untouched.
46
+ const runningNow = new Set();
47
+ hub.subscribeWorkspace((e) => {
48
+ if (e.type !== "session-state")
49
+ return;
50
+ if (e.state === "streaming") {
51
+ runningNow.add(e.sessionId);
52
+ return;
53
+ }
54
+ if (!runningNow.delete(e.sessionId))
55
+ return;
56
+ state.set("unread", e.sessionId, true);
57
+ hub.emitWorkspace({ type: "sessions-changed" });
58
+ });
59
+ /** Background runs this session launched that are still in flight. */
60
+ const activeRuns = (id) => backgroundRuns?.(id).filter((r) => r.state === "queued" || r.state === "running").length ?? 0;
61
+ /** The web channel's session for `id` — every session route resolves here. */
62
+ const ensure = (id) => router.ensure({ channelId: "web", conversationId: id });
63
+ // Sessions created here that Pi doesn't list yet — it persists a session
64
+ // only once the first assistant message lands. Merged into the list below
65
+ // so every client sees a new session immediately; dropped once Pi lists it.
66
+ const nascent = new Map();
67
+ app.get("/api/sessions", async (c) => {
68
+ const sessions = await factory.list();
69
+ for (const s of sessions)
70
+ nascent.delete(s.id);
71
+ return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => ({
72
+ ...s,
73
+ state: router.stateOf(s.id) ?? "idle",
74
+ pinned: state.has("pinned", s.id),
75
+ unread: state.has("unread", s.id),
76
+ activeRuns: activeRuns(s.id),
77
+ })));
78
+ });
79
+ app.post("/api/sessions", async (c) => {
80
+ const body = await c.req.json().catch(() => ({}));
81
+ // A session always starts in its project directory — never in pier's own.
82
+ if (typeof body.cwd !== "string" || !body.cwd)
83
+ return c.json({ error: "cwd required" }, 400);
84
+ const session = await factory.create({ cwd: body.cwd });
85
+ nascent.set(session.id, { cwd: body.cwd, createdAt: Date.now() });
86
+ router.attach({ channelId: "web", conversationId: session.id }, session);
87
+ // Created here = part of the workspace; pinning is what Projects lists.
88
+ state.set("pinned", session.id, true);
89
+ hub.emitWorkspace({ type: "sessions-changed" });
90
+ return c.json({ id: session.id }, 201);
91
+ });
92
+ // Seen = read: a client with the session selected and the tab visible acks
93
+ // here; the broadcast moves every other client's dot back to idle.
94
+ app.post("/api/sessions/:id/read", (c) => {
95
+ const id = c.req.param("id");
96
+ if (state.has("unread", id)) {
97
+ state.set("unread", id, false);
98
+ hub.emitWorkspace({ type: "sessions-changed" });
99
+ }
100
+ return c.json({ ok: true });
101
+ });
102
+ app.post("/api/sessions/:id/pin", async (c) => {
103
+ const body = await c.req.json().catch(() => null);
104
+ if (typeof body?.pinned !== "boolean")
105
+ return c.json({ error: "pinned required" }, 400);
106
+ state.set("pinned", c.req.param("id"), body.pinned);
107
+ hub.emitWorkspace({ type: "sessions-changed" });
108
+ return c.json({ pinned: body.pinned });
109
+ });
110
+ // Snapshot: everything a fresh client needs before it starts consuming
111
+ // deltas from SSE — transcript, live state, pending queue, model.
112
+ guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
113
+ const id = c.req.param("id");
114
+ const session = await ensure(id);
115
+ return c.json({
116
+ turns: await session.history(),
117
+ lastSeq: hub.lastSeq(id),
118
+ model: session.model ?? null,
119
+ state: session.state,
120
+ context: session.contextUsage ?? null,
121
+ thinkingLevel: session.thinkingLevel,
122
+ queue: await session.pendingQueue(),
123
+ backgroundRuns: backgroundRuns?.(id) ?? [],
124
+ });
125
+ });
126
+ // Transcript images by their history ordinal: the snapshot ships refs, the
127
+ // browser pulls (and caches) the bytes only for what it renders.
128
+ guarded(app, "GET", "/api/sessions/:id/images/:ordinal", 404, async (c) => {
129
+ const ordinal = Number(c.req.param("ordinal"));
130
+ if (!Number.isInteger(ordinal) || ordinal < 0)
131
+ return c.json({ error: "bad ordinal" }, 400);
132
+ const session = await ensure(c.req.param("id"));
133
+ const image = await session.image(ordinal);
134
+ if (!image)
135
+ return c.json({ error: "no such image" }, 404);
136
+ return c.body(Buffer.from(image.data, "base64"), 200, {
137
+ "content-type": image.mimeType,
138
+ "cache-control": "private, max-age=3600",
139
+ });
140
+ });
141
+ // Backend model catalog, no session needed: surfaces that configure what a
142
+ // *future* session launches with (IM chats) have none to ask.
143
+ app.get("/api/models", async (c) => {
144
+ try {
145
+ return c.json(await factory.availableModels());
146
+ }
147
+ catch (err) {
148
+ return c.json({ error: String(err) }, 500);
149
+ }
150
+ });
151
+ guarded(app, "GET", "/api/sessions/:id/models", 404, async (c) => {
152
+ const session = await ensure(c.req.param("id"));
153
+ return c.json(await session.availableModels());
154
+ });
155
+ guarded(app, "GET", "/api/sessions/:id/thinking", 404, async (c) => {
156
+ const session = await ensure(c.req.param("id"));
157
+ return c.json({
158
+ level: session.thinkingLevel,
159
+ levels: session.availableThinkingLevels(),
160
+ });
161
+ });
162
+ guarded(app, "POST", "/api/sessions/:id/model", 400, async (c) => {
163
+ const body = await c.req.json().catch(() => null);
164
+ if (!body || typeof body.provider !== "string" || typeof body.id !== "string") {
165
+ return c.json({ error: "provider and id required" }, 400);
166
+ }
167
+ const session = await ensure(c.req.param("id"));
168
+ await session.setModel({ provider: body.provider, id: body.id });
169
+ return c.json({ model: session.model });
170
+ });
171
+ guarded(app, "POST", "/api/sessions/:id/thinking", 400, async (c) => {
172
+ const body = await c.req.json().catch(() => null);
173
+ if (!body || !isThinkingLevel(body.level)) {
174
+ return c.json({ error: "valid thinking level required" }, 400);
175
+ }
176
+ const session = await ensure(c.req.param("id"));
177
+ session.setThinkingLevel(body.level);
178
+ return c.json({ level: session.thinkingLevel });
179
+ });
180
+ app.post("/api/sessions/:id/messages", async (c) => {
181
+ const id = c.req.param("id");
182
+ const body = await c.req.json().catch(() => null);
183
+ if (!body || typeof body.text !== "string") {
184
+ return c.json({ error: "text required" }, 400);
185
+ }
186
+ const images = parseImages(body.images);
187
+ if ("error" in images)
188
+ return c.json({ error: images.error }, 400);
189
+ if (!body.text.trim() && images.length === 0) {
190
+ return c.json({ error: "text or images required" }, 400);
191
+ }
192
+ const mode = body.mode === "steer" || body.mode === "followUp" ? body.mode : "auto";
193
+ const { sessionId } = await router.dispatch({
194
+ key: { channelId: "web", conversationId: id },
195
+ senderId: "web",
196
+ text: body.text,
197
+ images: images.length ? images : undefined,
198
+ mode,
199
+ });
200
+ return c.json({ sessionId }, 202);
201
+ });
202
+ // Edit a user turn: rewind the transcript to just before it, then re-send
203
+ // the edited text as a fresh dispatch. Pi keeps the old branch in the
204
+ // session file but out of context — the "deleted" message stops polluting.
205
+ guarded(app, "POST", "/api/sessions/:id/turns/:index/edit", 400, async (c) => {
206
+ const id = c.req.param("id");
207
+ const index = Number(c.req.param("index"));
208
+ const body = await c.req.json().catch(() => null);
209
+ if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
210
+ return c.json({ error: "index and text required" }, 400);
211
+ }
212
+ const session = await ensure(id);
213
+ if (session.state === "streaming")
214
+ return c.json({ error: "busy — stop the turn first" }, 409);
215
+ await session.rewindToUserTurn(index);
216
+ await router.dispatch({
217
+ key: { channelId: "web", conversationId: id },
218
+ senderId: "web",
219
+ text: body.text,
220
+ mode: "auto",
221
+ });
222
+ return c.json({ ok: true }, 202);
223
+ });
224
+ // Promote queued messages: "steer" delivers them into the running turn,
225
+ // "restart" aborts the turn and sends them as a fresh prompt. Pi has no
226
+ // promote primitive, so this is clear-queue + re-dispatch through core.
227
+ guarded(app, "POST", "/api/sessions/:id/queue/deliver", 404, async (c) => {
228
+ const id = c.req.param("id");
229
+ const body = await c.req.json().catch(() => null);
230
+ const mode = body?.mode;
231
+ if (mode !== "steer" && mode !== "restart") {
232
+ return c.json({ error: "mode must be steer or restart" }, 400);
233
+ }
234
+ const session = await ensure(id);
235
+ const { steering, followUp } = await session.clearQueue();
236
+ const text = [...steering, ...followUp].join("\n").trim();
237
+ if (!text)
238
+ return c.json({ error: "queue is empty" }, 409);
239
+ if (mode === "restart")
240
+ await router.abort(id); // resolves once idle
241
+ await router.dispatch({
242
+ key: { channelId: "web", conversationId: id },
243
+ senderId: "web",
244
+ text,
245
+ mode: mode === "steer" ? "steer" : "auto",
246
+ });
247
+ return c.json({ delivered: text }, 202);
248
+ });
249
+ // Recall: drop all pending queued messages and hand them back (composer restore).
250
+ guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
251
+ const session = await ensure(c.req.param("id"));
252
+ const { steering, followUp } = await session.clearQueue();
253
+ return c.json({ messages: [...steering, ...followUp] });
254
+ });
255
+ // The browser's half of the log. A workbench that threw after the response
256
+ // left the server is otherwise invisible here (ui/report.ts) — this is the
257
+ // one route whose entire purpose is to make it visible.
258
+ const clientLog = logger("client");
259
+ let reports = [];
260
+ app.post("/api/client-log", async (c) => {
261
+ const body = (await c.req.json().catch(() => null));
262
+ if (typeof body?.message !== "string" || !body.message.trim()) {
263
+ return c.json({ error: "message required" }, 400);
264
+ }
265
+ const now = Date.now();
266
+ reports = reports.filter((at) => now - at < 60_000);
267
+ if (reports.length >= CLIENT_LOG_PER_MINUTE)
268
+ return c.body(null, 429);
269
+ reports.push(now);
270
+ const cap = (value, max) => typeof value === "string" ? value.slice(0, max) : "";
271
+ const where = cap(body.view, 120);
272
+ const stack = cap(body.stack, 2000);
273
+ // One line, ua included: "only on iOS" is the answer half these questions
274
+ // have, and the report is the only place it exists.
275
+ clientLog.warn(`${cap(body.message, 500)} [${where || "/"}] ${cap(c.req.header("user-agent"), 160)}` +
276
+ (stack ? `\n${stack}` : ""));
277
+ return c.body(null, 204);
278
+ });
279
+ app.post("/api/sessions/:id/abort", async (c) => {
280
+ const id = c.req.param("id");
281
+ await router.abort(id);
282
+ return c.json({ ok: true }, 202);
283
+ });
284
+ // Workspace stream: one per client, keeps every session list in sync
285
+ // (created/pinned → re-list, run state → patch) without polling.
286
+ app.get("/api/events", (c) => streamSSE(c, async (stream) => {
287
+ const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }));
288
+ stream.onAbort(unsubscribe);
289
+ while (!stream.aborted) {
290
+ await stream.sleep(HEARTBEAT_MS);
291
+ await stream.write(": ping\n\n");
292
+ }
293
+ }));
294
+ app.get("/api/sessions/:id/events", (c) => {
295
+ const id = c.req.param("id");
296
+ const lastId = Number(c.req.header("Last-Event-ID") ?? "") ||
297
+ Number(c.req.query("after") ?? "") ||
298
+ 0;
299
+ return streamSSE(c, async (stream) => {
300
+ const send = (e) => stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
301
+ for (const e of hub.replay(id, lastId))
302
+ await send(e);
303
+ const unsubscribe = hub.subscribe(id, (e) => void send(e));
304
+ stream.onAbort(unsubscribe);
305
+ // Heartbeat keeps proxies from closing the stream; loop ends on abort.
306
+ while (!stream.aborted) {
307
+ await stream.sleep(HEARTBEAT_MS);
308
+ await stream.write(": ping\n\n");
309
+ }
310
+ });
311
+ });
312
+ // Instance settings. The password lives behind its own route (web/auth.ts):
313
+ // it is a credential, and changing it takes the old one.
314
+ app.get("/api/settings", (c) => c.json(settings.get()));
315
+ app.put("/api/settings", async (c) => {
316
+ const body = await c.req.json().catch(() => null);
317
+ if (typeof body?.publicUrl !== "string")
318
+ return c.json({ error: "publicUrl required" }, 400);
319
+ const publicUrl = normalizePublicUrl(body.publicUrl);
320
+ if (publicUrl === null) {
321
+ return c.json({ error: "not a URL: expected http(s)://host, no query or fragment" }, 400);
322
+ }
323
+ return c.json(settings.setPublicUrl(publicUrl));
324
+ });
325
+ // Layer-1 key status and control (Console → Settings → Security). The GET
326
+ // is what a locked instance shows; unlock is how it recovers without a
327
+ // restart, and rotate is the only way to change how the KEK is protected.
328
+ const secretsStatus = () => ({
329
+ state: secrets.state,
330
+ mode: secrets.mode ?? null,
331
+ ...(secrets.state === "locked" ? { reason: secrets.lockedReason } : {}),
332
+ });
333
+ app.get("/api/secrets", (c) => c.json(secretsStatus()));
334
+ app.post("/api/secrets/unlock", async (c) => {
335
+ try {
336
+ await secrets.unlock();
337
+ }
338
+ catch (err) {
339
+ return c.json({ error: String(err) }, 500);
340
+ }
341
+ onUnlocked?.();
342
+ return c.json(secretsStatus());
343
+ });
344
+ app.post("/api/secrets/rotate", async (c) => {
345
+ const body = (await c.req.json().catch(() => ({})));
346
+ if (body.mode !== undefined && body.mode !== "vt" && body.mode !== "file") {
347
+ return c.json({ error: "mode must be vt or file" }, 400);
348
+ }
349
+ try {
350
+ await secrets.rotateKek(body.mode);
351
+ }
352
+ catch (err) {
353
+ return c.json({ error: String(err) }, 500);
354
+ }
355
+ return c.json(secretsStatus());
356
+ });
357
+ registerFileRoutes(app, { factory, config, nascentCwd: (id) => nascent.get(id)?.cwd });
358
+ // serveStatic resolves `root` against the *working directory*, and an
359
+ // installed Pier is started from wherever the operator happens to be. The
360
+ // bundle sits beside this module in both trees — src/web/public when tsx
361
+ // runs the source, dist/web/public in a build — so the path is derived from
362
+ // the module and handed over as the relative form the option wants.
363
+ const bundle = fileURLToPath(new URL("./public", import.meta.url));
364
+ app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
365
+ return app;
366
+ }
@@ -0,0 +1,39 @@
1
+ // Workbench organization state: which sessions show up under Projects (created
2
+ // in Pier means pinned, everything else waits in All sessions) and which have
3
+ // a finished turn no client has looked at yet.
4
+ //
5
+ // One row per session rather than two JSON files: the unread flag is written at
6
+ // the end of every turn, and rewriting a whole file on each of those writes
7
+ // loses the entire set when the process dies mid-write — a truncated file reads
8
+ // back as "no pins", which is indistinguishable from a fresh install.
9
+ import { pierDb } from "../db.js";
10
+ // Written out per flag rather than interpolated: the union type only holds at
11
+ // compile time, and a cast at some future route is all it would take to put
12
+ // request text into SQL.
13
+ const SQL = {
14
+ pinned: {
15
+ has: "SELECT pinned AS on_ FROM session_state WHERE session_id = ?",
16
+ set: `INSERT INTO session_state(session_id, pinned) VALUES (?, ?)
17
+ ON CONFLICT(session_id) DO UPDATE SET pinned = excluded.pinned`,
18
+ },
19
+ unread: {
20
+ has: "SELECT unread AS on_ FROM session_state WHERE session_id = ?",
21
+ set: `INSERT INTO session_state(session_id, unread) VALUES (?, ?)
22
+ ON CONFLICT(session_id) DO UPDATE SET unread = excluded.unread`,
23
+ },
24
+ };
25
+ export class SessionStateStore {
26
+ #db;
27
+ constructor(db = pierDb()) {
28
+ this.#db = db;
29
+ }
30
+ has(flag, sessionId) {
31
+ const row = this.#db.prepare(SQL[flag].has).get(sessionId);
32
+ return row?.on_ === 1;
33
+ }
34
+ set(flag, sessionId, on) {
35
+ // Upsert on the flag alone: the row may already exist for the other one,
36
+ // and a session's two flags are set from unrelated places.
37
+ this.#db.prepare(SQL[flag].set).run(sessionId, on ? 1 : 0);
38
+ }
39
+ }