@timqi/pier 0.0.4 → 0.0.6

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 (43) hide show
  1. package/README.md +9 -6
  2. package/dist/agent/events.js +6 -1
  3. package/dist/agent/pi.js +28 -3
  4. package/dist/channels/chunk.js +34 -0
  5. package/dist/channels/control.js +6 -12
  6. package/dist/channels/dedup.js +45 -0
  7. package/dist/channels/lark-api.js +233 -0
  8. package/dist/channels/lark-outbound.js +101 -0
  9. package/dist/channels/lark-panel.js +95 -0
  10. package/dist/channels/lark-render.js +107 -0
  11. package/dist/channels/lark.js +501 -0
  12. package/dist/channels/lines.js +19 -0
  13. package/dist/channels/panel.js +4 -0
  14. package/dist/channels/receipts.js +15 -0
  15. package/dist/channels/routes.js +0 -1
  16. package/dist/channels/runtime.js +5 -1
  17. package/dist/channels/slack-api.js +4 -2
  18. package/dist/channels/slack-panel.js +3 -6
  19. package/dist/channels/slack-render.js +3 -23
  20. package/dist/channels/slack.js +39 -72
  21. package/dist/channels/telegram-api.js +4 -2
  22. package/dist/channels/telegram-panel.js +3 -3
  23. package/dist/channels/telegram.js +55 -51
  24. package/dist/channels/types.js +9 -0
  25. package/dist/cli.js +3 -1
  26. package/dist/core/inbox.js +67 -1
  27. package/dist/core/types.js +4 -0
  28. package/dist/db.js +83 -20
  29. package/dist/main.js +5 -0
  30. package/dist/service.js +1 -1
  31. package/dist/web/auth.js +36 -9
  32. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  33. package/dist/web/public/assets/ghostty-web-ODXT71Ln.js +13 -0
  34. package/dist/web/public/assets/index-BUNGxtMe.css +2 -0
  35. package/dist/web/public/assets/index-QPYgeBhQ.js +90 -0
  36. package/dist/web/public/index.html +10 -2
  37. package/dist/web/server.js +86 -19
  38. package/dist/web/session-state.js +59 -25
  39. package/dist/web/terminal.js +334 -0
  40. package/docs/deploy.md +33 -21
  41. package/package.json +10 -2
  42. package/dist/web/public/assets/index-B3MvJUJP.js +0 -90
  43. package/dist/web/public/assets/index-CwBoxtXP.css +0 -2
@@ -10,7 +10,7 @@ import { mkdir, writeFile } from "node:fs/promises";
10
10
  import { randomBytes } from "node:crypto";
11
11
  import { basename, join } from "node:path";
12
12
  import { pierPath } from "../paths.js";
13
- import { safeName } from "./inbound-file.js";
13
+ import { fileMarker, lostMarker, MAX_INBOUND_BYTES, safeName } from "./inbound-file.js";
14
14
  /** Where every inbound file lives; web/files.ts allowlists this root. */
15
15
  export const INBOX_DIR = pierPath("inbox");
16
16
  /**
@@ -30,3 +30,69 @@ export async function saveInbound(channelId, name, mimeType, bytes) {
30
30
  await writeFile(path, bytes, { mode: 0o600, flag: "wx" });
31
31
  return path;
32
32
  }
33
+ /**
34
+ * Collect a fetch response's body, refusing past `maxBytes` mid-stream. The
35
+ * metadata size gate in saveInboundAll is only as honest as the platform's
36
+ * metadata — absent or wrong, `arrayBuffer()` buffers whatever arrives — so
37
+ * the read itself is bounded too. Throws with "too large" in the message,
38
+ * which the loop below translates into the honest lost-marker reason.
39
+ */
40
+ export async function readCapped(body, maxBytes) {
41
+ if (!body)
42
+ return new Uint8Array(0);
43
+ const parts = [];
44
+ let size = 0;
45
+ const reader = body.getReader();
46
+ try {
47
+ for (;;) {
48
+ const { done, value } = await reader.read();
49
+ if (done)
50
+ break;
51
+ size += value.byteLength;
52
+ if (size > maxBytes)
53
+ throw new Error(`attachment too large (>${maxBytes} bytes)`);
54
+ parts.push(value);
55
+ }
56
+ }
57
+ finally {
58
+ // Also cancels the transfer on the too-large throw.
59
+ reader.releaseLock();
60
+ await body.cancel().catch(() => { });
61
+ }
62
+ const bytes = new Uint8Array(size);
63
+ let at = 0;
64
+ for (const part of parts) {
65
+ bytes.set(part, at);
66
+ at += part.byteLength;
67
+ }
68
+ return bytes;
69
+ }
70
+ /**
71
+ * Save a message's attachments; each becomes a marker line for the prompt —
72
+ * and a failed or oversized one becomes a lost-marker line, never silence
73
+ * (5b). Written three times, once per adapter, before landing here: the
74
+ * size gate before the fetch (an unauthorized sender is already filtered by
75
+ * then, but a movie must not be buffered whole either) and the never-silent
76
+ * failure path are invariants, and invariants drift when copied.
77
+ */
78
+ export async function saveInboundAll(channelId, files, log) {
79
+ const markers = [];
80
+ for (const file of files) {
81
+ if (file.size !== undefined && file.size > MAX_INBOUND_BYTES) {
82
+ markers.push(lostMarker(file.label, "too large"));
83
+ continue;
84
+ }
85
+ try {
86
+ const got = await file.fetch();
87
+ const path = await saveInbound(channelId, file.name ?? got.name, got.mimeType ?? file.mimeType, got.bytes);
88
+ markers.push(fileMarker(path));
89
+ }
90
+ catch (err) {
91
+ log(`attachment download failed: ${String(err)}`);
92
+ // A fetch that refused mid-stream names its reason; keep it honest.
93
+ const why = String(err).includes("too large") ? "too large" : "download failed";
94
+ markers.push(lostMarker(file.label, why));
95
+ }
96
+ }
97
+ return markers;
98
+ }
@@ -1,6 +1,10 @@
1
1
  // Normative seam types — THIS FILE is the system contract (docs/architecture.md
2
2
  // documents the rules around it). Changing a seam is a design decision, not a
3
3
  // refactor; keep it implementable over RPC (no Pi types may appear here).
4
+ /** How much of a tool result any surface ever shows. A transcript replay
5
+ * carries no more than that: a session's tool output is most of its history
6
+ * payload, and the bytes past this point were downloaded to be sliced off. */
7
+ export const MAX_STEP_OUTPUT = 8_000;
4
8
  /** Every level Pi accepts, in order. The union is derived so the two cannot
5
9
  * drift, and boundary validators use isThinkingLevel instead of their own copy. */
6
10
  export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
package/dist/db.js CHANGED
@@ -11,15 +11,16 @@
11
11
  // So: one connection, one ordered list of migrations, applied in one
12
12
  // transaction before any store exists. A store receives the handle and owns
13
13
  // only its queries.
14
- import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync } from "node:fs";
14
+ import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, rmSync, statSync } from "node:fs";
15
15
  import { basename, dirname, join } from "node:path";
16
16
  import { DatabaseSync } from "node:sqlite";
17
17
  import { logger } from "./log.js";
18
18
  import { PIER_DB } from "./paths.js";
19
19
  const log = logger("db");
20
- /** Pre-migration snapshots to keep. Three is two upgrades of regret plus one:
20
+ /** Snapshots to keep *of each kind*. Three is two upgrades of regret plus one:
21
21
  * they are full copies of the database, and the one that matters is the
22
- * newest. */
22
+ * newest. Counted per kind because the two kinds answer different questions —
23
+ * a run of releases must not evict the pre-migration copies. */
23
24
  const KEEP_BACKUPS = 3;
24
25
  /** How long a second process may wait for the write lock before failing. Two
25
26
  * Pier processes on one PIER_HOME contend exactly once — at boot, when both
@@ -138,6 +139,12 @@ const MIGRATIONS = [
138
139
  note TEXT NOT NULL,
139
140
  created_at INTEGER NOT NULL
140
141
  );
142
+ `,
143
+ // 4 — Projects can render from SQLite without scanning every Pi transcript.
144
+ `
145
+ ALTER TABLE session_state ADD COLUMN cwd TEXT;
146
+ ALTER TABLE session_state ADD COLUMN title TEXT;
147
+ ALTER TABLE session_state ADD COLUMN created_at INTEGER;
141
148
  `,
142
149
  ];
143
150
  let shared;
@@ -147,14 +154,25 @@ let shared;
147
154
  */
148
155
  export const pierDb = () => (shared ??= openDb(PIER_DB));
149
156
  /** A release-level restore point, taken while the service is stopped even when
150
- * the release has no schema migration. The previous complete copy stays put if
151
- * writing its replacement fails. */
152
- export function backupDb(path = PIER_DB) {
157
+ * the release has no schema migration. The previous complete copies stay put if
158
+ * writing this one fails.
159
+ *
160
+ * `version` is the Pier that produced this database, not the one being
161
+ * installed: the updater runs this from the tree it is about to replace, and
162
+ * restoring a database means reinstalling the code that speaks its schema
163
+ * (`migrate` refuses one from a newer Pier). So the name carries the other half
164
+ * of the pair. Backing up twice at one version replaces that version's copy —
165
+ * the pairing is identical, so a second name for it would say nothing. */
166
+ export function backupDb(version, path = PIER_DB) {
153
167
  if (!existsSync(path))
154
168
  return undefined;
155
- const bak = `${path}.release.bak`;
169
+ // In a filename, so it may not carry a separator or a traversal; a version
170
+ // this malformed is a broken install, not something to guess at.
171
+ const safe = version.replaceAll(/[^0-9A-Za-z.+-]/g, "_") || "unknown";
172
+ const bak = join(backupsDir(path, true), `${basename(path)}.release-${safe}.bak`);
156
173
  copyDatabase(path, bak);
157
174
  log.info(`pre-update backup: ${bak}`);
175
+ prune(releases(path));
158
176
  return bak;
159
177
  }
160
178
  /** Open a database, bring it to the current schema, and lock down its files.
@@ -186,9 +204,9 @@ function migrate(db, path, migrations) {
186
204
  if (at > target) {
187
205
  // Name the snapshot that exists rather than a pattern: the operator is
188
206
  // reading this because the service will not start.
189
- const newest = path === ":memory:" ? undefined : backups(path)[0]?.file;
207
+ const newest = path === ":memory:" ? undefined : snapshots(path)[0]?.file;
190
208
  throw new Error(`${path} is at schema ${at}, this Pier speaks ${target}: a database is ` +
191
- `never downgraded. Restore ${newest ?? `${path}.v*.bak`}, or run the newer Pier.`);
209
+ `never downgraded. Restore ${newest ?? `a copy from ${backupsDir(path)}`}, or run the newer Pier.`);
192
210
  }
193
211
  // Version 0 with tables is a database from before versioning existed.
194
212
  // Migration 1 assumes an empty file, so the collision it would hit says
@@ -244,7 +262,7 @@ function migrate(db, path, migrations) {
244
262
  }
245
263
  log.info(locked === 0 ? `schema created at version ${target}` : `schema ${locked} → ${target}`);
246
264
  if (path !== ":memory:")
247
- prune(path);
265
+ prune(snapshots(path).map(({ file }) => file));
248
266
  }
249
267
  /**
250
268
  * The copy that exists because `user_version` only counts up: the transaction
@@ -259,7 +277,7 @@ function migrate(db, path, migrations) {
259
277
  * ever refers to a finished copy.
260
278
  */
261
279
  function snapshot(path, at) {
262
- const bak = `${path}.v${at}.bak`;
280
+ const bak = join(backupsDir(path, true), `${basename(path)}.v${at}.bak`);
263
281
  copyDatabase(path, bak);
264
282
  log.info(`pre-migration backup: ${bak}`);
265
283
  }
@@ -276,22 +294,64 @@ function copyDatabase(path, bak) {
276
294
  chmodSync(tmp, 0o600); // it holds everything the 0600 database holds
277
295
  renameSync(tmp, bak);
278
296
  }
279
- /** Snapshots beside the database, newest schema first. */
280
- function backups(path) {
297
+ /**
298
+ * One directory for every copy of this database, `db/backups/`. Beside the
299
+ * database was fine while there was one snapshot per schema; a restore point
300
+ * per release turns that into a listing where the live file and its sidecars
301
+ * are hard to pick out, and "which of these do I not delete" is the wrong
302
+ * question to make an operator answer under pressure.
303
+ *
304
+ * `create` also adopts what an older Pier wrote next to the database, so the
305
+ * restore procedure names one location instead of two forever.
306
+ */
307
+ function backupsDir(path, create = false) {
308
+ const dir = join(dirname(path), "backups");
309
+ if (!create)
310
+ return dir;
311
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
312
+ const prefix = `${basename(path)}.`;
313
+ for (const name of readdirSync(dirname(path))) {
314
+ if (!name.startsWith(prefix) || !name.endsWith(".bak"))
315
+ continue;
316
+ renameSync(join(dirname(path), name), join(dir, name));
317
+ log.info(`moved ${name} into ${dir}`);
318
+ }
319
+ return dir;
320
+ }
321
+ /** The copies of one kind: `v<schema>` or `release-<version>`. Disjoint
322
+ * prefixes, so each kind is counted and pruned on its own. */
323
+ function listBackups(path, kind) {
324
+ const dir = backupsDir(path);
325
+ if (!existsSync(dir))
326
+ return [];
327
+ const prefix = `${basename(path)}.${kind}`;
328
+ return readdirSync(dir).filter((name) => name.startsWith(prefix) && name.endsWith(".bak"));
329
+ }
330
+ /** Pre-migration snapshots, newest schema first — the number in the name is an
331
+ * ordinal, so it orders them without asking the filesystem. */
332
+ function snapshots(path) {
281
333
  const prefix = `${basename(path)}.v`;
282
- return readdirSync(dirname(path))
283
- .filter((name) => name.startsWith(prefix) && name.endsWith(".bak"))
334
+ return listBackups(path, "v")
284
335
  .map((name) => ({
285
336
  version: Number(name.slice(prefix.length, -".bak".length)),
286
- file: join(dirname(path), name),
337
+ file: join(backupsDir(path), name),
287
338
  }))
288
339
  .filter(({ version }) => Number.isInteger(version))
289
340
  .sort((a, b) => b.version - a.version);
290
341
  }
291
- /** Keep the newest few. Nobody restores a database from four upgrades ago, and
292
- * every one of these is the size of the whole database. */
293
- function prune(path) {
294
- for (const { file } of backups(path).slice(KEEP_BACKUPS)) {
342
+ /** Release restore points, newest copy first. Ordered by mtime: the name holds
343
+ * a Pier version, and comparing those means reimplementing semver here while
344
+ * two updates of one instance are never in flight at the same moment. Legacy
345
+ * `pier.db.release.bak` shares the prefix, so it ages out like the rest. */
346
+ function releases(path) {
347
+ return listBackups(path, "release")
348
+ .map((name) => join(backupsDir(path), name))
349
+ .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs);
350
+ }
351
+ /** Keep the newest few, oldest first out. Nobody restores a database from four
352
+ * upgrades ago, and every one of these is the size of the whole database. */
353
+ function prune(newestFirst) {
354
+ for (const file of newestFirst.slice(KEEP_BACKUPS)) {
295
355
  rmSync(file, { force: true });
296
356
  log.info(`removed superseded backup: ${file}`);
297
357
  }
@@ -311,4 +371,7 @@ function restrict(path) {
311
371
  chmodSync(file, 0o600);
312
372
  }
313
373
  chmodSync(dirname(path), 0o700);
374
+ // Full copies of the same secrets, one directory down.
375
+ if (existsSync(backupsDir(path)))
376
+ chmodSync(backupsDir(path), 0o700);
314
377
  }
package/dist/main.js CHANGED
@@ -34,6 +34,7 @@ import { startAutoUpdate, UpdateCheck } from "./update.js";
34
34
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
35
35
  import { SessionStateStore } from "./web/session-state.js";
36
36
  import { createServer } from "./web/server.js";
37
+ import { attachTerminal } from "./web/terminal.js";
37
38
  const log = logger("pier");
38
39
  // Pier owns the Pi runtime dir. Set before any SDK call resolves a path, so
39
40
  // everything Pi derives from its agent dir (auth.json, models.json, sessions,
@@ -280,6 +281,9 @@ const server = serve({ fetch: app.fetch, port, hostname }, () => {
280
281
  log.info(`workbench on http://${hostname}:${port}`);
281
282
  log.info(`pid ${process.pid}, node ${process.version}, home ${PIER_HOME}`);
282
283
  });
284
+ // The one WebSocket surface (see web/terminal.ts); `serve` above builds a
285
+ // plain node:http server, which is the only shape with an upgrade event.
286
+ const terminals = attachTerminal(server, auth);
283
287
  // A crash and a clean stop must be distinguishable after the fact, and both
284
288
  // left nothing behind before this.
285
289
  process.on("uncaughtException", (err) => {
@@ -302,6 +306,7 @@ const shutdown = (stopTasks = true) => {
302
306
  // `systemctl restart` into a 90-second wait for SIGKILL.
303
307
  setTimeout(() => process.exit(0), 3000).unref();
304
308
  stopEviction();
309
+ terminals.close(); // no shell outlives the workbench
305
310
  // The drain path leaves task runs alone: aborting them here would record
306
311
  // them cancelled and race their callbacks against dying channels, when the
307
312
  // boot-time interrupted marking is the recovery that was promised.
package/dist/service.js CHANGED
@@ -300,7 +300,7 @@ export function startUpdate(options) {
300
300
  if (!run(["systemctl", "--user", "start", "--no-block", UPDATE_UNIT_NAME]))
301
301
  return "failed";
302
302
  say(`updating in the background — follow it with: journalctl --user -u ${UPDATE_UNIT_NAME} -f`);
303
- say(`Pier stops, snapshots pier.db.release.bak, installs, then starts again.`);
303
+ say(`Pier stops, snapshots the database into db/backups/, installs, then starts again.`);
304
304
  return "started";
305
305
  }
306
306
  export function uninstall(home = homedir(), say = console.log, exec) {
package/dist/web/auth.js CHANGED
@@ -59,6 +59,7 @@ export class AuthStore {
59
59
  #db;
60
60
  /** HMAC key for cookies: the hash, so rotating the password expires them. */
61
61
  #key;
62
+ #rotationListeners = new Set();
62
63
  constructor(db = pierDb(), print = (m) => log.info(m)) {
63
64
  this.#db = db;
64
65
  let row = this.#row();
@@ -99,6 +100,13 @@ export class AuthStore {
99
100
  .prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
100
101
  .run(salt, next, Date.now());
101
102
  this.#key = next;
103
+ for (const listener of this.#rotationListeners)
104
+ listener();
105
+ }
106
+ /** A long-lived authenticated surface closes itself when every cookie is
107
+ * revoked. The store and listeners share the process lifetime. */
108
+ onRotation(listener) {
109
+ this.#rotationListeners.add(listener);
102
110
  }
103
111
  /** Cookie signing key. Never the password: that is not stored anywhere. */
104
112
  get cookieKey() {
@@ -181,19 +189,14 @@ function noteFailure(client) {
181
189
  failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
182
190
  }
183
191
  /** Browsers name the source of unsafe requests. Compare hosts rather than
184
- * schemes because TLS commonly terminates at the reverse proxy. */
185
- function sameOrigin(c) {
186
- const origin = c.req.header("origin");
192
+ * schemes because TLS commonly terminates at the reverse proxy. Shared by HTTP
193
+ * and WebSocket so the password boundary cannot disagree with itself. */
194
+ function originMatches(origin, host) {
187
195
  if (!origin)
188
196
  return true; // curl and other non-browser clients
189
197
  try {
190
198
  const parsed = new URL(origin);
191
- const remote = remoteOf(c);
192
- const forwarded = remote && loopback(remote)
193
- ? c.req.header("x-forwarded-host")?.split(",").at(-1)?.trim()
194
- : undefined;
195
- const host = forwarded || c.req.header("host") || new URL(c.req.url).host;
196
- const external = new URL(`${parsed.protocol}//${host}`);
199
+ const external = new URL(`${parsed.protocol}//${host ?? ""}`);
197
200
  return parsed.origin === origin && external.origin === parsed.origin &&
198
201
  external.pathname === "/" && !external.search && !external.hash;
199
202
  }
@@ -201,6 +204,30 @@ function sameOrigin(c) {
201
204
  return false;
202
205
  }
203
206
  }
207
+ function sameOrigin(c) {
208
+ const remote = remoteOf(c);
209
+ const forwarded = remote && loopback(remote)
210
+ ? c.req.header("x-forwarded-host")?.split(",").at(-1)?.trim()
211
+ : undefined;
212
+ return originMatches(c.req.header("origin"), forwarded || c.req.header("host") || new URL(c.req.url).host);
213
+ }
214
+ /** The same cookie + Origin boundary for a WebSocket upgrade, where no Hono
215
+ * context exists before the handshake completes. */
216
+ export function upgradeAuthorized(store, req) {
217
+ const raw = req.headers.cookie
218
+ ?.split(";")
219
+ .map((part) => part.trim())
220
+ .find((part) => part.startsWith(`${COOKIE}=`))
221
+ ?.slice(COOKIE.length + 1);
222
+ if (!valid(store.cookieKey, raw))
223
+ return false;
224
+ const remote = req.socket.remoteAddress ?? "";
225
+ const forwardedHeader = req.headers["x-forwarded-host"];
226
+ const forwarded = loopback(remote) && typeof forwardedHeader === "string"
227
+ ? forwardedHeader.split(",").at(-1)?.trim()
228
+ : undefined;
229
+ return originMatches(req.headers.origin, forwarded || req.headers.host);
230
+ }
204
231
  /** Every route, in one place — no per-route opt-in to forget on the next one. */
205
232
  export function requireAuth(store) {
206
233
  return async (c, next) => {