@timqi/pier 0.0.5 → 0.0.7

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 (41) hide show
  1. package/README.md +1 -0
  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/core/identity.js +21 -0
  26. package/dist/core/inbox.js +67 -1
  27. package/dist/core/types.js +4 -0
  28. package/dist/db.js +6 -0
  29. package/dist/main.js +5 -0
  30. package/dist/web/auth.js +36 -9
  31. package/dist/web/public/assets/__vite-browser-external-2447137e-BvRk9kiK.js +0 -0
  32. package/dist/web/public/assets/ghostty-web-BhZV0Vvv.js +13 -0
  33. package/dist/web/public/assets/{index-BAW9Nhaa.js → index-BbwoGR-O.js} +17 -17
  34. package/dist/web/public/assets/index-BlHvP59B.css +2 -0
  35. package/dist/web/public/index.html +16 -6
  36. package/dist/web/server.js +86 -19
  37. package/dist/web/session-state.js +59 -25
  38. package/dist/web/terminal.js +334 -0
  39. package/docs/deploy.md +5 -1
  40. package/package.json +10 -2
  41. 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
@@ -139,6 +139,12 @@ const MIGRATIONS = [
139
139
  note TEXT NOT NULL,
140
140
  created_at INTEGER NOT NULL
141
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;
142
148
  `,
143
149
  ];
144
150
  let shared;
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/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) => {