@timqi/pier 0.0.1 → 0.0.2

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 +76 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
@@ -7,36 +7,17 @@ import { Hono } from "hono";
7
7
  import { streamSSE } from "hono/streaming";
8
8
  import { EventHub } from "../core/hub.js";
9
9
  import { Router } from "../core/router.js";
10
- import { logger } from "../log.js";
10
+ import { registerExplorerRoutes } from "./explorer.js";
11
11
  import { guarded, registerFileRoutes } from "./files.js";
12
12
  import { isThinkingLevel } from "../core/types.js";
13
- import { normalizePublicUrl } from "../settings.js";
13
+ import { saveInbound } from "../core/inbox.js";
14
+ import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
15
+ import { registerInstanceRoutes } from "./instance.js";
16
+ import { registerProviderRoutes } from "./providers.js";
14
17
  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 }) {
18
+ // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
19
+ const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
20
+ export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, updates, backgroundRuns, }) {
40
21
  const app = new Hono();
41
22
  // A finished turn marks its session unread until some client reports it was
42
23
  // seen (session selected + tab visible → POST read below). Server-side so
@@ -68,6 +49,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
68
49
  const sessions = await factory.list();
69
50
  for (const s of sessions)
70
51
  nascent.delete(s.id);
52
+ // A session created but never prompted would otherwise be listed forever.
53
+ for (const [id, n] of nascent)
54
+ if (Date.now() - n.createdAt > 86_400_000)
55
+ nascent.delete(id);
71
56
  return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => ({
72
57
  ...s,
73
58
  state: router.stateOf(s.id) ?? "idle",
@@ -103,6 +88,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
103
88
  const body = await c.req.json().catch(() => null);
104
89
  if (typeof body?.pinned !== "boolean")
105
90
  return c.json({ error: "pinned required" }, 400);
91
+ // The id is trusted: checking existence costs a session list per click,
92
+ // and the surface is operator-authenticated. Worst case is a stray row.
106
93
  state.set("pinned", c.req.param("id"), body.pinned);
107
94
  hub.emitWorkspace({ type: "sessions-changed" });
108
95
  return c.json({ pinned: body.pinned });
@@ -123,20 +110,24 @@ export function createServer({ factory, router, hub, sessions: state, config, se
123
110
  backgroundRuns: backgroundRuns?.(id) ?? [],
124
111
  });
125
112
  });
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
- });
113
+ // Composer attachments: bytes land in the inbox, the message carries the
114
+ // path as a `[name](file:///…)` line the client builds itself upload
115
+ // first, so the text it sends (and optimistically renders) is final.
116
+ guarded(app, "POST", "/api/inbox", 400, async (c) => {
117
+ const body = await c.req.json().catch(() => null);
118
+ if (typeof body?.data !== "string" ||
119
+ !body.data ||
120
+ // Cheap ceiling before decoding; the exact check is on the bytes.
121
+ body.data.length > Math.ceil(MAX_INBOUND_BYTES / 3) * 4 + 4 ||
122
+ !BASE64_RE.test(body.data) ||
123
+ typeof body?.mimeType !== "string") {
124
+ return c.json({ error: "invalid file" }, 400);
125
+ }
126
+ const name = typeof body.name === "string" ? body.name : undefined;
127
+ const bytes = Buffer.from(body.data, "base64");
128
+ if (bytes.length > MAX_INBOUND_BYTES)
129
+ return c.json({ error: "invalid file" }, 400);
130
+ return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
140
131
  });
141
132
  // Backend model catalog, no session needed: surfaces that configure what a
142
133
  // *future* session launches with (IM chats) have none to ask.
@@ -180,21 +171,14 @@ export function createServer({ factory, router, hub, sessions: state, config, se
180
171
  app.post("/api/sessions/:id/messages", async (c) => {
181
172
  const id = c.req.param("id");
182
173
  const body = await c.req.json().catch(() => null);
183
- if (!body || typeof body.text !== "string") {
174
+ if (!body || typeof body.text !== "string" || !body.text.trim()) {
184
175
  return c.json({ error: "text required" }, 400);
185
176
  }
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
177
  const mode = body.mode === "steer" || body.mode === "followUp" ? body.mode : "auto";
193
178
  const { sessionId } = await router.dispatch({
194
179
  key: { channelId: "web", conversationId: id },
195
180
  senderId: "web",
196
181
  text: body.text,
197
- images: images.length ? images : undefined,
198
182
  mode,
199
183
  });
200
184
  return c.json({ sessionId }, 202);
@@ -209,6 +193,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
209
193
  if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
210
194
  return c.json({ error: "index and text required" }, 400);
211
195
  }
196
+ // Asked before touching anything: the dispatch below would be refused by
197
+ // the drain gate, and by then the transcript is already rewound.
198
+ if (router.isDraining())
199
+ return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
212
200
  const session = await ensure(id);
213
201
  if (session.state === "streaming")
214
202
  return c.json({ error: "busy — stop the turn first" }, 409);
@@ -231,6 +219,9 @@ export function createServer({ factory, router, hub, sessions: state, config, se
231
219
  if (mode !== "steer" && mode !== "restart") {
232
220
  return c.json({ error: "mode must be steer or restart" }, 400);
233
221
  }
222
+ // Same reason as edit above: a refused dispatch must not cost the queue.
223
+ if (router.isDraining())
224
+ return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
234
225
  const session = await ensure(id);
235
226
  const { steering, followUp } = await session.clearQueue();
236
227
  const text = [...steering, ...followUp].join("\n").trim();
@@ -252,30 +243,6 @@ export function createServer({ factory, router, hub, sessions: state, config, se
252
243
  const { steering, followUp } = await session.clearQueue();
253
244
  return c.json({ messages: [...steering, ...followUp] });
254
245
  });
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
246
  app.post("/api/sessions/:id/abort", async (c) => {
280
247
  const id = c.req.param("id");
281
248
  await router.abort(id);
@@ -284,7 +251,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
284
251
  // Workspace stream: one per client, keeps every session list in sync
285
252
  // (created/pinned → re-list, run state → patch) without polling.
286
253
  app.get("/api/events", (c) => streamSSE(c, async (stream) => {
287
- const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }));
254
+ // A write to a torn-down stream must not become an unhandled rejection.
255
+ const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }).catch(() => { }));
288
256
  stream.onAbort(unsubscribe);
289
257
  while (!stream.aborted) {
290
258
  await stream.sleep(HEARTBEAT_MS);
@@ -300,7 +268,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
300
268
  const send = (e) => stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
301
269
  for (const e of hub.replay(id, lastId))
302
270
  await send(e);
303
- const unsubscribe = hub.subscribe(id, (e) => void send(e));
271
+ // Same as above: the client may be gone by the time an event fires.
272
+ const unsubscribe = hub.subscribe(id, (e) => void send(e).catch(() => { }));
304
273
  stream.onAbort(unsubscribe);
305
274
  // Heartbeat keeps proxies from closing the stream; loop ends on abort.
306
275
  while (!stream.aborted) {
@@ -309,52 +278,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
309
278
  }
310
279
  });
311
280
  });
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
- });
281
+ registerInstanceRoutes(app, { settings, updates, secrets, onUnlocked });
282
+ registerProviderRoutes(app, providers);
357
283
  registerFileRoutes(app, { factory, config, nascentCwd: (id) => nascent.get(id)?.cwd });
284
+ registerExplorerRoutes(app, { factory, nascentCwds: () => [...nascent.values()].map((n) => n.cwd) });
358
285
  // serveStatic resolves `root` against the *working directory*, and an
359
286
  // installed Pier is started from wherever the operator happens to be. The
360
287
  // bundle sits beside this module in both trees — src/web/public when tsx
package/docs/deploy.md CHANGED
@@ -24,17 +24,15 @@ you want the unit to say something different.
24
24
  ## Prerequisites
25
25
 
26
26
  - Node 24 or newer (`node:sqlite` is used unflagged).
27
- - The `sqlite3` CLI, for the off-machine backup and password steps below. Pier
28
- itself does not need it.
29
- - A checkout, built once:
30
-
31
- ```sh
32
- git clone git@github.com:timqi/pier.git ~/pier
33
- cd ~/pier && npm ci && npm run build
34
- ```
35
-
36
- The unit below runs the build output, so a deploy is always "update the
37
- checkout, rebuild, restart" — never "run from source".
27
+ - A user-writable global npm prefix. The updater runs as you, so an initial
28
+ install that needed `sudo npm install -g` cannot later update itself.
29
+ - The `sqlite3` CLI is optional, for the off-machine backup and password steps
30
+ below. Pier itself and its automatic update backup do not need it.
31
+ - Pier installed globally: `npm install -g @timqi/pier`. The unit runs that
32
+ installed entry point, so a deploy is `pier update`. A checkout
33
+ (`git clone` + `npm ci && npm run build`) is the *develop* path; point the
34
+ unit's `ExecStart` at its `dist/main.js` if you run one as the service, and
35
+ update it with the "From a checkout" steps under Updating.
38
36
 
39
37
  ## The unit
40
38
 
@@ -44,7 +42,7 @@ or as a dedicated system user means an agent that cannot touch the files you
44
42
  wanted it to work on.
45
43
 
46
44
  `~/.config/systemd/user/pier.service` — what `pier service install` generates,
47
- with your node and your entry point filled in:
45
+ with your absolute Node and package paths filled in:
48
46
 
49
47
  ```ini
50
48
  [Unit]
@@ -55,29 +53,33 @@ Wants=network-online.target
55
53
 
56
54
  [Service]
57
55
  Type=simple
58
- WorkingDirectory=%h/pier
59
- # An absolute path on purpose: systemd starts with a minimal PATH, so a node
60
- # installed by nvm/fnm/asdf is not on it. `command -v node` gives you this.
61
- ExecStart=/usr/bin/node dist/main.js
62
- Environment=NODE_ENV=production
56
+ WorkingDirectory=%h
57
+ # Absolute paths on purpose: systemd starts with a minimal PATH, so a node
58
+ # installed by nvm/fnm/asdf is not on it the installer fills in the node
59
+ # that installed Pier and the globally installed entry point.
60
+ ExecStart="/absolute/path/to/node" "/absolute/npm/prefix/lib/node_modules/@timqi/pier/dist/main.js"
61
+ Environment="NODE_ENV=production"
63
62
  # Loopback by default. Put a reverse proxy in front before widening this —
64
63
  # whoever reaches this port can drive an agent that runs a shell.
65
- Environment=HOST=127.0.0.1
66
- Environment=PORT=3141
67
- # Where the database, the boards and the generated password hash live.
68
- Environment=PIER_HOME=%h/.pier
64
+ Environment="HOST=127.0.0.1"
65
+ Environment="PORT=3141"
69
66
  Restart=always
70
67
  RestartSec=2
71
- # The journal is where the first-run password is printed, so keep it readable.
72
68
  StandardOutput=journal
73
69
  StandardError=journal
74
- # Otherwise every line is tagged "node"; this makes `journalctl -t pier` work.
75
70
  SyslogIdentifier=pier
76
71
 
77
72
  [Install]
78
73
  WantedBy=default.target
79
74
  ```
80
75
 
76
+ `--pier-home` adds a quoted `PIER_HOME` environment line. Paths with spaces and
77
+ literal systemd `%` specifiers are escaped. `pier service install` also writes an
78
+ updater unit containing the exact npm executable currently on `PATH`. Re-run with
79
+ `--force` after changing service settings or the Node/npm installation; it
80
+ rewrites both units and restarts the running service. The limits drop-in remains
81
+ operator-owned and is never overwritten.
82
+
81
83
  Enable it, and tell logind to keep your user manager alive after you log out —
82
84
  without lingering, every scheduled task stops when your SSH session ends:
83
85
 
@@ -167,7 +169,9 @@ journalctl --user -u pier --since -1h | grep 'tasks:' # one area
167
169
  ```
168
170
 
169
171
  Every line is `area: message` — `core`, `agent`, `tasks`, `slack`, `telegram`,
170
- `channels`, `web`, `auth`, `boards`, `client`, `pier` — so an area is a grep
172
+ `channels`, `slack.tool`, `auth`, `boards`, `client`, `db`, `drain`, `secrets`,
173
+ `settings`, `credentials`, `update`, `web.providers`, `pier` — so an area is a
174
+ grep
171
175
  and a level is a `-p`. The level reaches journald as a syslog priority prefix, which Pier
172
176
  emits only when systemd says the output is a journal (`$JOURNAL_STREAM`); run
173
177
  in a terminal, the same lines carry a timestamp and a level word instead.
@@ -214,42 +218,83 @@ new password is generated and printed:
214
218
 
215
219
  ```sh
216
220
  sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
217
- systemctl --user restart pier
221
+ pier restart
218
222
  ```
219
223
 
220
224
  Changing the password invalidates every session cookie: the cookies are signed
221
225
  with the stored hash.
222
226
 
227
+ ## Restarting and reloading
228
+
229
+ ```sh
230
+ pier restart # finish active work, then restart the service
231
+ pier reload # apply channel config and recycle idle sessions in place
232
+ ```
233
+
234
+ Both commands signal the installed systemd service; they are not foreground
235
+ process controls. `pier restart` refuses new messages and root Task runs, waits
236
+ up to five minutes for active work, then exits for `Restart=always` to start the
237
+ next process. At the deadline it records every aborted IM turn first, and the
238
+ next process posts that note after its adapter starts. Cleanup after the deadline
239
+ has one shared 10-second bound regardless of how many sessions are stuck.
240
+
241
+ `pier reload` does not stop active work. It reloads Slack and Telegram adapters
242
+ and immediately evicts idle sessions nobody is watching, so their next message
243
+ opens with current agent files and configuration. Streaming sessions and
244
+ sessions held by an open workbench stay attached until their normal eviction.
245
+
246
+ An ordinary `systemctl --user restart pier` and `pier update` remain fast,
247
+ hard-stop paths. Let active work finish first when using either one.
248
+
223
249
  ## Updating
224
250
 
225
- Manually, which is also exactly what an automated update has to do:
251
+ ```sh
252
+ pier update # installs the latest release; hard-stops/restarts Pier
253
+ pier update --check # only says whether one exists
254
+ ```
255
+
256
+ The workbench footer says the same thing without being asked: the server checks
257
+ `registry.npmjs.org` every six hours in the background, and the version turns
258
+ into `v0.0.1 → 0.0.2` when there is something newer. A failed check is silent
259
+ by design — an offline box is not a broken one.
260
+
261
+ From a checkout instead, stop and back up before replacing the build:
226
262
 
227
263
  ```sh
264
+ systemctl --user stop pier
265
+ pier backup
228
266
  cd ~/pier
229
- git fetch --tags && git checkout v0.2.0 # a tag, not a branch
267
+ git fetch --tags && git checkout v0.0.2 # a tag, not a branch
230
268
  npm ci && npm run build
231
- systemctl --user restart pier
269
+ systemctl --user start pier
232
270
  ```
233
271
 
272
+ For a service install, `pier update` stops Pier first; it does not use the
273
+ graceful `pier restart` path. It snapshots the database to
274
+ `~/.pier/db/pier.db.release.bak` before npm touches the package. This happens for
275
+ every release, including releases with no schema change. If installation or
276
+ backup fails, the updater unit still tries to start the previously installed
277
+ service and reports the failure in its journal.
278
+
234
279
  A newer Pier brings its own schema up on the next start: the migrations run in
235
280
  one transaction before the port opens, and the version they leave behind is
236
- stamped in the database. Before touching an existing database it snapshots it
237
- to `~/.pier/db/pier.db.v<N>.bak` (`N` = the schema it was at), so the step you
238
- cannot forget is the one you never take. **Upgrades only.** Start an older Pier
239
- on a database a newer one has migrated and it refuses to run rather than write
240
- tables it does not understand — the way back down is that `.bak`:
281
+ stamped in the database. It also snapshots the immediately preceding schema to
282
+ `~/.pier/db/pier.db.v<N>.bak` (`N` = the schema it was at). **Upgrades only.**
283
+ Start an older Pier on a database a newer one has migrated and it refuses to run
284
+ rather than write tables it does not understand the way back down is either
285
+ the release backup or that schema snapshot:
241
286
 
242
287
  ```sh
243
288
  systemctl --user stop pier
244
- cd ~/.pier/db && rm -f pier.db pier.db-wal pier.db-shm && cp pier.db.v1.bak pier.db
245
- # then check out the Pier that speaks schema 1 again
289
+ cd ~/.pier/db && rm -f pier.db pier.db-wal pier.db-shm && cp pier.db.release.bak pier.db
290
+ # then reinstall the Pier release that created that backup
246
291
  ```
247
292
 
248
- The three newest snapshots are kept and older ones removed as later upgrades
249
- supersede them; each is a full copy of the database, so size grows with the
250
- database, not with the number of releases. They sit next to the database and
251
- therefore protect against a bad upgrade, not against a lost disk — an
252
- off-machine copy is still yours to take.
293
+ The release backup is replaced atomically on each update. The three newest
294
+ schema snapshots are also kept and older ones removed as later migrations
295
+ supersede them. Each is a full copy of the database. They sit next to the
296
+ database and therefore protect against a bad upgrade, not against a lost disk —
297
+ an off-machine copy is still yours to take.
253
298
 
254
299
  ### Can it update itself?
255
300
 
@@ -259,32 +304,33 @@ in `pier.service`'s cgroup, so an update script spawned by Pier dies halfway
259
304
  through — sometimes after unpacking and before restarting, which is the one
260
305
  outcome worse than not updating.
261
306
 
262
- So the update runs as its own unit. `~/.config/systemd/user/pier-update.service`:
307
+ So installation writes a second unit and `pier update` starts it after recording
308
+ the running service's effective `PIER_HOME` in a runtime drop-in. That includes
309
+ an operator environment override, so the updater cannot back up one database and
310
+ migrate another. `~/.config/systemd/user/pier-update.service`:
263
311
 
264
312
  ```ini
265
313
  [Unit]
266
- Description=Update Pier to the latest tag
314
+ Description=Update Pier to the latest published version
267
315
 
268
316
  [Service]
269
317
  Type=oneshot
270
- WorkingDirectory=%h/pier
271
- ExecStart=/bin/sh -lc 'git fetch --tags && git checkout "$(git describe --tags --abbrev=0 origin/main)" && npm ci && npm run build'
272
- ExecStartPost=/bin/sh -lc 'systemctl --user restart pier'
318
+ ExecStart=systemctl --user stop pier.service
319
+ ExecStart=/path/to/node /path/to/pier/dist/cli.js backup
320
+ ExecStart=/path/to/node /recorded/path/to/npm install -g @timqi/pier@latest
321
+ ExecStopPost=systemctl --user start pier.service
273
322
  ```
274
323
 
275
- Pier can then trigger an update with a single call that survives its own
276
- restart, because the work happens in a different cgroup:
277
-
278
- ```sh
279
- systemctl --user start pier-update.service
280
- ```
324
+ `pier update` triggers that unit with a call that survives Pier's restart because
325
+ the work happens in a different cgroup. Starting the unit directly is unsupported:
326
+ the command first records the effective database home used by the running service.
281
327
 
282
328
  Deliberately **not** a `systemd.timer`. An unattended update is a machine that
283
- rewrites its own code from the network while holding your API keys, and it
284
- interrupts whatever session was mid-turn to do it. The intended shape is that
285
- Pier notices a newer release and says so (not built yet), while starting the
286
- update stays a decision someone makes. `Restart=always` above is what makes
287
- that decision cheap — the service comes back on its own.
329
+ rewrites its own code from the network while holding your API keys, and its hard
330
+ stop interrupts whatever session was mid-turn. Pier notices a newer release
331
+ and says so in the workbench footer; starting the update stays a decision someone
332
+ makes. The updater's `ExecStopPost` is what brings the service back after both
333
+ success and failure.
288
334
 
289
335
  ## Remote access
290
336
 
@@ -294,14 +340,24 @@ elsewhere, pick a tunnel rather than a wider bind:
294
340
  - `ssh -L 3141:localhost:3141 server` — nothing to configure, nothing exposed.
295
341
  - Tailscale, or Cloudflare Tunnel — no open port, and TLS terminates outside.
296
342
  - A reverse proxy (Caddy, nginx) if you want a real hostname. Terminate TLS
297
- there and pass `X-Forwarded-For`; Pier's login throttle counts per forwarded
298
- client, and its session cookie is marked `Secure` when the proxy reports
299
- `X-Forwarded-Proto: https`.
343
+ there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
344
+ `X-Forwarded-For`; Pier uses the external host for write-origin checks and
345
+ counts login failures per forwarded client. Its session cookie is marked
346
+ `Secure` when the proxy reports `X-Forwarded-Proto: https`.
300
347
 
301
348
  ## Backups
302
349
 
303
- Two paths hold everything: `~/.pier/db/pier.db` (tasks, channels, the chat →
304
- session map, workbench state, settings, the password hash) and
305
- `~/.pier/boards/`. Copy the database with `sqlite3 ... "VACUUM INTO '…'"`
306
- rather than `cp`, which under WAL can miss the most recent commits. Pi's own session history lives under its
307
- config directory (`PI_CODING_AGENT_DIR`, `~/.pi/agent` by default).
350
+ Three paths hold everything: `~/.pier/db/pier.db` (tasks, channels, the chat →
351
+ session map, workbench state, settings, the password hash, and the sealed
352
+ provider credentials and channel tokens), `~/.pier/master.key` (the key that
353
+ seals them without it the database's sealed values are unreadable), and
354
+ `~/.pier/boards/`. `pier.db.release.bak` is the latest automatic pre-update copy.
355
+ For off-machine backups, use `sqlite3 ... "VACUUM INTO '…'"` rather than `cp`,
356
+ which under WAL can miss the most recent commits. Pi's own session history lives
357
+ under `~/.pier/pi` (Pier sets `PI_CODING_AGENT_DIR` there unless the environment
358
+ already names another directory).
359
+
360
+ Inbound chat attachments (photos, uploads from any surface) accumulate under
361
+ `~/.pier/inbox/<channel>/` and are never deleted by Pier — a transcript may
362
+ reference them indefinitely. Prune old files by hand (or a cron) when disk
363
+ matters; a pruned file degrades to a broken attachment link, nothing else.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
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",
@@ -0,0 +1,110 @@
1
+ ---
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.
4
+ ---
5
+
6
+ # How Pier works
7
+
8
+ Pier is the workspace this session runs in: agent sessions behind chat
9
+ surfaces — a web workbench and IM channels (Slack and Telegram today) — plus
10
+ scheduled tasks, subagents and boards. Answer questions about it from the
11
+ facts below. If the answer is not here, say you do not know how this instance
12
+ is configured rather than guessing: the Console (Pier's admin web UI) is the
13
+ operator's source of truth.
14
+
15
+ ## Sessions and persistence
16
+
17
+ - One durable session per conversation: a web chat, a Slack thread, a
18
+ Telegram chat or topic. The mapping survives restarts — the next message
19
+ lands in the same transcript with its context intact.
20
+ - Idle sessions leave memory but keep their transcript; they resume
21
+ transparently on the next message. Never promise that a restart or a pause
22
+ wipes context.
23
+ - A fresh start is explicit: "New session" in the chat settings panel or the
24
+ web UI. The old transcript remains readable from the web workbench.
25
+ - The web workbench can also rewind to an earlier user turn and re-prompt;
26
+ IM surfaces cannot.
27
+ - A long session does not hit a wall: when the context fills, Pi compacts it
28
+ automatically — older turns become a summary. The transcript on disk keeps
29
+ everything, but detail can leave *your* context, so a very old turn is worth
30
+ re-reading rather than recalling. The web session header shows context used
31
+ and how much is left.
32
+
33
+ ## Files and images the user sends
34
+
35
+ - A photo or file sent on any surface (web paste, Telegram photo/document,
36
+ Slack upload) is saved to `$PIER_HOME/inbox/` and reaches you as a trailing
37
+ `[name](file:///…)` line on the message — a path, not the content.
38
+ - Read it with the read tool only when it matters to the task: every read
39
+ puts the content in your context for good. An image you never read costs
40
+ nothing.
41
+ - The file stays on disk after the conversation moves on; link it back
42
+ (`[name](file:///…)`) whenever the user asks for it again.
43
+
44
+ ## Messages while you are working
45
+
46
+ - On the web, a message sent mid-turn queues as a follow-up and lands after
47
+ the turn; a leading `!` interrupts instead — `!wrong file, stop` is injected
48
+ into the running turn as a steer.
49
+ - From an IM chat, every mid-turn message steers the running turn directly —
50
+ no `!` needed, and a leading `!` is just content.
51
+ - `/stop` aborts the current turn outright.
52
+
53
+ ## In-chat commands and the settings panel
54
+
55
+ - `/settings` — or an addressed message with no text at all (a bare mention,
56
+ an empty DM) — opens a panel: model, reasoning level, new session
57
+ (optionally in a chosen directory), stop. Slack also accepts the bare words
58
+ `stop`, `settings`, `bind <code>`.
59
+ - Panel taps never reach you. The next-step buttons under your own replies
60
+ do — a click arrives as an ordinary user message with that label.
61
+
62
+ ## What a turn looks like from outside
63
+
64
+ - Telegram and Slack put a 👀 on the message that started a turn and take it
65
+ off when the turn settles; a restart and a periodic sweep clear stragglers.
66
+ A 👀 that never clears means the turn died, not that you are still thinking.
67
+ - Every finished reply carries its cost: elapsed time and the context size at
68
+ completion (`1m14s · 32K tok`) — a running total, not this turn's spend. IM
69
+ shows it as a footer line, the web on hover.
70
+ - A reply past the platform's message cap is split across several messages
71
+ (Telegram ~3.8k chars); the footer and the next-step buttons ride the last
72
+ one.
73
+
74
+ ## Who may talk (groups and binding)
75
+
76
+ - Group messages pass a per-chat gate the operator sets: it can require a
77
+ mention, require the sender to be bound, or both. Dropped messages are
78
+ logged, never answered.
79
+ - Binding: the operator issues a code in the Console; the user DMs the bot
80
+ `/bind <code>` (Slack: `bind <code>`). Codes expire after ~10 minutes.
81
+ - An unbound DM sender is told how to bind at most once per 10 minutes;
82
+ their other messages are dropped. "The bot ignores my DMs" usually means
83
+ not bound.
84
+
85
+ ## Service restart, reload and update
86
+
87
+ - `pier restart` is the graceful systemd path: it refuses new work, waits up to
88
+ five minutes for active turns and Task runs, then restarts. If the deadline
89
+ aborts an IM turn, the next process tells that conversation.
90
+ - `pier reload` stays in-process: channel adapters re-read configuration and
91
+ idle, unwatched sessions reopen with current agent files on their next
92
+ message. Streaming or watched sessions are not interrupted.
93
+ - `pier update` is deliberately different: the separate updater hard-stops the
94
+ service, backs up the database, replaces the package, and starts it again.
95
+ It can interrupt active work. All three are operator shell commands for an
96
+ installed Linux systemd service, not tools available to the agent.
97
+
98
+ ## Only the Console can change
99
+
100
+ Channel tokens and connections, per-chat gate policies, bind codes, provider
101
+ logins and credentials, the public address, security unlock. You have no tool
102
+ for any of these: point the user at the Console instead of improvising.
103
+
104
+ ## The rest of the surface
105
+
106
+ - Chat conventions — next-step buttons, `file://` attachments, staying
107
+ silent, `[name<id> time]` sender headers — are in `<pier>/AGENTS.md`,
108
+ already in your context.
109
+ - Delegating and scheduling work: the pier-tasks skill. Reading and posting
110
+ Slack: pier-slack. Presenting a report as a page: pier-boards.