@timqi/pier 0.0.7 → 0.0.9

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 (36) hide show
  1. package/README.md +26 -9
  2. package/dist/agent/pi.js +103 -5
  3. package/dist/channels/conversations.js +10 -0
  4. package/dist/core/router.js +27 -11
  5. package/dist/db.js +30 -0
  6. package/dist/extensions/index.js +34 -0
  7. package/dist/extensions/web/anthropic.js +118 -0
  8. package/dist/extensions/web/artifacts.js +57 -0
  9. package/dist/extensions/web/content.js +130 -0
  10. package/dist/extensions/web/http.js +106 -0
  11. package/dist/extensions/web/index.js +9 -0
  12. package/dist/extensions/web/json.js +5 -0
  13. package/dist/extensions/web/language.js +47 -0
  14. package/dist/extensions/web/openai.js +112 -0
  15. package/dist/extensions/web/provider.js +121 -0
  16. package/dist/extensions/web/tools.js +284 -0
  17. package/dist/main.js +44 -6
  18. package/dist/paths.js +15 -0
  19. package/dist/settings.js +68 -13
  20. package/dist/web/instance.js +32 -8
  21. package/dist/web/providers.js +16 -0
  22. package/dist/web/public/assets/{ghostty-web-BhZV0Vvv.js → ghostty-web-C4N9kjtH.js} +1 -1
  23. package/dist/web/public/assets/index-DNCJJRSS.js +91 -0
  24. package/dist/web/public/assets/index-DYl1xk5y.css +2 -0
  25. package/dist/web/public/index.html +27 -4
  26. package/dist/web/public/manifest.webmanifest +11 -1
  27. package/dist/web/public/sw.js +109 -0
  28. package/dist/web/push.js +233 -0
  29. package/dist/web/server.js +51 -4
  30. package/dist/web/session-state.js +48 -9
  31. package/dist/web/terminal.js +34 -4
  32. package/dist/web/webpush.js +131 -0
  33. package/package.json +1 -1
  34. package/skills/pier-help/SKILL.md +12 -0
  35. package/dist/web/public/assets/index-BbwoGR-O.js +0 -90
  36. package/dist/web/public/assets/index-BlHvP59B.css +0 -2
@@ -37,7 +37,7 @@ export const withTabPrefix = (html, prefix) => prefix
37
37
  const HEARTBEAT_MS = 15_000;
38
38
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
39
39
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
40
- export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, reload, updates, updater, backgroundRuns, }) {
40
+ export function createServer({ factory, router, hub, sessions: state, config, providers, settings, extensions, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
41
41
  const app = new Hono();
42
42
  // A finished turn marks its session unread until some client reports it was
43
43
  // seen (session selected + tab visible → POST read below). Server-side so
@@ -77,11 +77,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
77
77
  .finally(() => {
78
78
  listing = undefined;
79
79
  });
80
- const present = (s, pinned, unread) => ({
80
+ // `order` is where the workbench was arranged to put this row, not a fact
81
+ // about the Pi session — it rides along so one Projects read is enough.
82
+ const present = (s, pinned, unread, order = {}) => ({
81
83
  ...s,
84
+ ...order,
82
85
  state: router.stateOf(s.id) ?? "idle",
83
86
  pinned,
84
87
  unread,
88
+ channel: channelOf?.(s.id) ?? "web",
85
89
  activeRuns: activeRuns(s.id),
86
90
  });
87
91
  app.get("/api/projects", async (c) => {
@@ -93,7 +97,27 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
93
97
  // happened to be unreadable. A later explicit full listing can repair it.
94
98
  projectBackfillNeeded = false;
95
99
  }
96
- return c.json(state.projects().map((s) => present(s, true, s.unread)));
100
+ return c.json(state.projects().map(({ sort, projectSort, ...s }) => present(s, true, s.unread, { sort, projectSort })));
101
+ });
102
+ // One drag, one write of the list that changed: the projects, or one
103
+ // project's sessions. Whole lists rather than a move — the client has just
104
+ // rendered the result, and replaying a move on top of a stale list would put
105
+ // the row somewhere nobody dropped it.
106
+ app.post("/api/projects/order", async (c) => {
107
+ const body = await c.req.json().catch(() => null);
108
+ const list = (raw) => raw === undefined
109
+ ? undefined
110
+ : Array.isArray(raw) && raw.every((x) => typeof x === "string" && x)
111
+ ? raw
112
+ : null;
113
+ const sessions = list(body?.sessions);
114
+ const projects = list(body?.projects);
115
+ if (sessions === null || projects === null || (!sessions && !projects)) {
116
+ return c.json({ error: "sessions and/or projects must be lists of ids" }, 400);
117
+ }
118
+ state.reorder({ sessions, projects });
119
+ hub.emitWorkspace({ type: "sessions-changed" });
120
+ return c.json({ ok: true });
97
121
  });
98
122
  app.get("/api/sessions", async (c) => {
99
123
  const sessions = await listSessions();
@@ -106,7 +130,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
106
130
  const flags = state.flags();
107
131
  return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => {
108
132
  const row = flags.get(s.id);
109
- return present(s, row?.pinned ?? false, row?.unread ?? false);
133
+ return present(s, row?.pinned ?? false, row?.unread ?? false, {
134
+ sort: row?.sort,
135
+ projectSort: row?.projectSort,
136
+ });
110
137
  }));
111
138
  });
112
139
  app.post("/api/sessions", async (c) => {
@@ -396,6 +423,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
396
423
  updates,
397
424
  updater,
398
425
  secrets,
426
+ extensions,
399
427
  onUnlocked,
400
428
  onSettingsChanged: () => recycle("instance settings"),
401
429
  });
@@ -422,6 +450,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
422
450
  const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
423
451
  let shell = null;
424
452
  app.get("/", async (c, next) => {
453
+ // A release replaces hashed assets. Revalidate the shell on every
454
+ // navigation so a cached index cannot name bundles that no longer exist.
455
+ c.header("cache-control", "private, no-cache");
425
456
  if (shell === null) {
426
457
  try {
427
458
  shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
@@ -435,6 +466,22 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
435
466
  }
436
467
  return c.html(shell);
437
468
  });
469
+ // Same reasoning as the shell above, for the one asset that is not hashed:
470
+ // an installed app keeps its worker until the script it re-fetches differs,
471
+ // so a cached copy is a released fix that never ships.
472
+ app.get("/sw.js", async (c, next) => {
473
+ c.header("cache-control", "private, no-cache");
474
+ await next();
475
+ });
476
+ // Hashed bundles never change under their name — a release writes new names,
477
+ // and the shell above is what re-points at them. Without this they carry only
478
+ // the auth layer's bare `private`, so a browser revalidates each one before it
479
+ // may reuse it: three round trips on a remote instance, one of them in front
480
+ // of the 636KB terminal emulator, every time the workbench is opened.
481
+ app.get("/assets/*", async (c, next) => {
482
+ c.header("cache-control", "private, max-age=31536000, immutable");
483
+ await next();
484
+ });
438
485
  app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
439
486
  return app;
440
487
  }
@@ -22,26 +22,53 @@ export class SessionStateStore {
22
22
  }
23
23
  /** Pin plus the summary Projects needs, atomically in one row. */
24
24
  pin(summary, pinned) {
25
- this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at)
26
- VALUES (?, ?, ?, ?, ?)
25
+ // A new session joins a project that already has a place in the list.
26
+ // Unranked it would sort on top — lifting the whole project with it, which
27
+ // is the jump manual order exists to stop.
28
+ const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(summary.cwd);
29
+ this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, title, created_at, project_sort)
30
+ VALUES (?, ?, ?, ?, ?, ?)
27
31
  ON CONFLICT(session_id) DO UPDATE SET
28
32
  pinned = excluded.pinned,
29
33
  cwd = excluded.cwd,
30
34
  title = COALESCE(excluded.title, session_state.title),
31
- created_at = excluded.created_at`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt);
35
+ created_at = excluded.created_at,
36
+ project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt, sibling?.rank ?? null);
37
+ }
38
+ /** One drag = one write of the whole list it reordered: index is the place.
39
+ * `sessions` are ids (a session's place inside its project), `projects` are
40
+ * cwds, whose place is stamped on every session that has that cwd. */
41
+ reorder(order) {
42
+ const bySession = this.#db.prepare("UPDATE session_state SET sort = ? WHERE session_id = ?");
43
+ const byCwd = this.#db.prepare("UPDATE session_state SET project_sort = ? WHERE cwd = ?");
44
+ this.#tx(() => {
45
+ order.sessions?.forEach((id, i) => bySession.run(i, id));
46
+ order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
47
+ });
32
48
  }
33
49
  /** Project rows only; unlike AgentFactory.list(), this never touches disk. */
34
50
  projects() {
35
- const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread
51
+ const rows = this.#db.prepare(`SELECT session_id AS id, cwd, title, created_at AS createdAt, unread,
52
+ sort, project_sort AS projectSort
36
53
  FROM session_state
37
54
  WHERE pinned = 1 AND cwd IS NOT NULL AND created_at IS NOT NULL
38
55
  ORDER BY created_at DESC`).all();
39
- return rows.map(({ title, unread, ...row }) => ({
56
+ return rows.map(({ title, unread, sort, projectSort, ...row }) => ({
40
57
  ...row,
41
58
  ...(title ? { title } : {}),
59
+ ...(sort === null ? {} : { sort }),
60
+ ...(projectSort === null ? {} : { projectSort }),
42
61
  unread: unread === 1,
43
62
  }));
44
63
  }
64
+ /** What to call a session where there is room for one line — a push
65
+ * notification's title. Falls back to the project directory, then to the
66
+ * fact that it is a session at all: a notification with no title reads as a
67
+ * browser bug rather than as an unnamed session. */
68
+ name(sessionId) {
69
+ const row = this.#db.prepare("SELECT title, cwd FROM session_state WHERE session_id = ?").get(sessionId);
70
+ return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
71
+ }
45
72
  needsProjectBackfill() {
46
73
  return this.#db.prepare("SELECT 1 FROM session_state WHERE pinned = 1 AND (cwd IS NULL OR created_at IS NULL) LIMIT 1").get() !== undefined;
47
74
  }
@@ -50,10 +77,16 @@ export class SessionStateStore {
50
77
  const update = this.#db.prepare(`UPDATE session_state SET
51
78
  cwd = ?, title = COALESCE(?, title), created_at = ?
52
79
  WHERE session_id = ?`);
53
- this.#db.exec("BEGIN");
54
- try {
80
+ this.#tx(() => {
55
81
  for (const s of summaries)
56
82
  update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
83
+ });
84
+ }
85
+ /** All-or-nothing: a half-written order is a list nobody arranged. */
86
+ #tx(run) {
87
+ this.#db.exec("BEGIN");
88
+ try {
89
+ run();
57
90
  this.#db.exec("COMMIT");
58
91
  }
59
92
  catch (err) {
@@ -62,8 +95,14 @@ export class SessionStateStore {
62
95
  }
63
96
  }
64
97
  flags() {
65
- const rows = this.#db.prepare("SELECT session_id AS id, pinned, unread FROM session_state WHERE pinned = 1 OR unread = 1").all();
66
- return new Map(rows.map((r) => [r.id, { pinned: r.pinned === 1, unread: r.unread === 1 }]));
98
+ const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
99
+ FROM session_state WHERE pinned = 1 OR unread = 1`).all();
100
+ return new Map(rows.map((r) => [r.id, {
101
+ pinned: r.pinned === 1,
102
+ unread: r.unread === 1,
103
+ ...(r.sort === null ? {} : { sort: r.sort }),
104
+ ...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
105
+ }]));
67
106
  }
68
107
  /** The first prompt supplies the title of a newly-created pinned session. */
69
108
  title(sessionId, text) {
@@ -28,6 +28,15 @@ const MAX_FRAME_BYTES = 1024 * 1024;
28
28
  // start under. Inheriting these can attach the shell back into Pier's parent
29
29
  // tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
30
30
  const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
31
+ // Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
32
+ // alone turns an `npm i` typed here into an install with no dev dependencies,
33
+ // and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
34
+ // instance rather than the person's. Everything else is inherited on purpose:
35
+ // PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
36
+ // the shell usable, and on a service-managed instance nothing else supplies
37
+ // them.
38
+ const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
39
+ const PIER_OWN_PREFIX = /^PI(ER)?_/;
31
40
  const send = (sock, data) => {
32
41
  try {
33
42
  sock.send(data);
@@ -44,6 +53,7 @@ export class TerminalHub {
44
53
  #shell;
45
54
  #idleMs;
46
55
  #maxTerms;
56
+ #initCommand;
47
57
  #sweeper;
48
58
  #closeListeners = new Set();
49
59
  #closed = false;
@@ -51,6 +61,7 @@ export class TerminalHub {
51
61
  this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
52
62
  this.#idleMs = opts.idleMs ?? IDLE_MS;
53
63
  this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
64
+ this.#initCommand = opts.initCommand ?? (() => "");
54
65
  this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
55
66
  this.#sweeper.unref();
56
67
  }
@@ -117,8 +128,11 @@ export class TerminalHub {
117
128
  }
118
129
  #spawn(cwd) {
119
130
  const env = { ...process.env };
120
- for (const key of PARENT_TERMINAL_ENV)
121
- delete env[key];
131
+ for (const key of Object.keys(env)) {
132
+ if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
133
+ delete env[key];
134
+ }
135
+ }
122
136
  const pty = spawn(this.#shell, [], {
123
137
  name: "xterm-256color",
124
138
  cols: 120,
@@ -129,6 +143,14 @@ export class TerminalHub {
129
143
  const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
130
144
  this.#terms.set(cwd, term);
131
145
  log.info(`shell ${pty.pid} for ${cwd}`);
146
+ // Typed in, not exec'd: the shell stays the parent, so quitting whatever
147
+ // this starts leaves a usable prompt, and the echo plus any error is in
148
+ // the ring where the person can see what ran. The tty buffers it until the
149
+ // shell's first read, so no wait is needed. A reattach never repeats it —
150
+ // this runs once per pty, which is once per cwd.
151
+ const init = this.#initCommand().trim();
152
+ if (init)
153
+ pty.write(`${init}\r`);
132
154
  pty.onData((data) => {
133
155
  const chunk = Buffer.from(data);
134
156
  term.ring.push(chunk);
@@ -179,6 +201,13 @@ export class TerminalHub {
179
201
  term.pty.write(msg.d);
180
202
  return;
181
203
  }
204
+ // The only way a page can end a shell: everything attached to it is told by
205
+ // the exit path below, exactly as if the shell had exited on its own.
206
+ if (msg.t === "restart") {
207
+ log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
208
+ term.pty.kill();
209
+ return;
210
+ }
182
211
  if (msg.t === "resize" &&
183
212
  typeof msg.cols === "number" && typeof msg.rows === "number" &&
184
213
  Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
@@ -225,8 +254,9 @@ export class TerminalHub {
225
254
  /** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
226
255
  * as every route. SameSite=Lax already withholds the cookie cross-site; the
227
256
  * Origin check is the explicit copy of that fact. */
228
- export function attachTerminal(server, auth, heartbeatMs = HEARTBEAT_MS) {
229
- const hub = new TerminalHub();
257
+ export function attachTerminal(server, auth, opts = {}) {
258
+ const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
259
+ const hub = new TerminalHub(opts);
230
260
  const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
231
261
  const alive = new WeakSet();
232
262
  const heartbeat = setInterval(() => {
@@ -0,0 +1,131 @@
1
+ // The Web Push wire format, and only that: RFC 8291 message encryption
2
+ // (ECDH P-256 → HKDF → one aes128gcm record) and RFC 8292 VAPID
3
+ // authorization. Who is notified, and why, is push.ts.
4
+ //
5
+ // Written on node:crypto instead of pulled in: the whole format is one ECDH,
6
+ // two HKDFs, one AES-GCM record and a JWT, and every step of it has a
7
+ // published test vector (webpush.test.ts runs the RFC's). A dependency here
8
+ // would be new transitive code inside the process that holds the operator's
9
+ // provider keys, buying ~120 lines (principle 8).
10
+ import { createCipheriv, createECDH, createPrivateKey, hkdfSync, randomBytes, sign } from "node:crypto";
11
+ /** RFC 8291 §4: one record, and a push service need not accept more than 4096
12
+ * octets of body. Header (86) + padding (1) + GCM tag (16) leaves this. */
13
+ export const MAX_PUSH_PLAINTEXT = 3993;
14
+ const RECORD_SIZE = 4096;
15
+ /** VAPID token lifetime. Apple refuses anything past 24h; half a day is well
16
+ * inside every push service's limit and still outlives a slow retry. */
17
+ const TOKEN_TTL_S = 12 * 60 * 60;
18
+ const REQUEST_TIMEOUT_MS = 10_000;
19
+ const b64 = (b) => Buffer.from(b instanceof ArrayBuffer ? new Uint8Array(b) : b).toString("base64url");
20
+ const unb64 = (s) => Buffer.from(s, "base64url");
21
+ /** A P-256 scalar is 32 octets; OpenSSL hands back the minimal encoding, so a
22
+ * key with a zero high byte is one octet short of what JWK accepts. */
23
+ const pad32 = (b) => b.length >= 32 ? b : Buffer.concat([Buffer.alloc(32 - b.length), b]);
24
+ export function generateVapidKeys() {
25
+ const ecdh = createECDH("prime256v1");
26
+ ecdh.generateKeys();
27
+ return {
28
+ publicKey: b64(ecdh.getPublicKey()),
29
+ privateKey: b64(pad32(ecdh.getPrivateKey())),
30
+ };
31
+ }
32
+ /**
33
+ * Encrypt one push message for `target` (RFC 8291 §3.4, RFC 8188 header).
34
+ *
35
+ * `salt` and `serverKey` are injectable for exactly one reason: the RFC's
36
+ * worked example is the only way to prove this implementation is right, and it
37
+ * fixes both. Nothing else may pass them — a reused salt is a broken cipher.
38
+ */
39
+ export function encryptPush(plaintext, target, { salt = randomBytes(16), serverKey } = {}) {
40
+ const body = Buffer.from(plaintext);
41
+ if (body.length > MAX_PUSH_PLAINTEXT) {
42
+ throw new Error(`push payload is ${String(body.length)} bytes, over ${String(MAX_PUSH_PLAINTEXT)}`);
43
+ }
44
+ const uaPublic = unb64(target.p256dh);
45
+ const ecdh = createECDH("prime256v1");
46
+ // computeSecret() rejects a point that is not on the curve, which is the
47
+ // validation RFC 8291 §7 asks for before a private key touches it.
48
+ if (serverKey)
49
+ ecdh.setPrivateKey(serverKey);
50
+ else
51
+ ecdh.generateKeys();
52
+ const asPublic = ecdh.getPublicKey();
53
+ const shared = ecdh.computeSecret(uaPublic);
54
+ const keyInfo = Buffer.concat([Buffer.from("WebPush: info\0"), uaPublic, asPublic]);
55
+ const ikm = Buffer.from(hkdfSync("sha256", shared, unb64(target.auth), keyInfo, 32));
56
+ const cek = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: aes128gcm\0"), 16));
57
+ const nonce = Buffer.from(hkdfSync("sha256", ikm, salt, Buffer.from("Content-Encoding: nonce\0"), 12));
58
+ const cipher = createCipheriv("aes-128-gcm", cek, nonce);
59
+ // 0x02 is the padding delimiter of the last (here: only) record.
60
+ const sealed = Buffer.concat([
61
+ cipher.update(Buffer.concat([body, Buffer.of(2)])),
62
+ cipher.final(),
63
+ cipher.getAuthTag(),
64
+ ]);
65
+ const header = Buffer.alloc(21);
66
+ salt.copy(header);
67
+ header.writeUInt32BE(RECORD_SIZE, 16);
68
+ header.writeUInt8(asPublic.length, 20);
69
+ return Buffer.concat([header, asPublic, sealed]);
70
+ }
71
+ /** The `Authorization` a push service checks before it accepts anything: a
72
+ * short-lived ES256 JWT bound to the service's own origin, plus the public
73
+ * key the subscription was created with (RFC 8292 §3). */
74
+ export function vapidAuthorization(endpoint, keys, subject, now = Date.now()) {
75
+ const token = [
76
+ b64(Buffer.from(JSON.stringify({ typ: "JWT", alg: "ES256" }))),
77
+ b64(Buffer.from(JSON.stringify({
78
+ aud: new URL(endpoint).origin,
79
+ exp: Math.floor(now / 1000) + TOKEN_TTL_S,
80
+ sub: subject,
81
+ }))),
82
+ ].join(".");
83
+ const pub = unb64(keys.publicKey);
84
+ const key = createPrivateKey({
85
+ format: "jwk",
86
+ key: {
87
+ kty: "EC",
88
+ crv: "P-256",
89
+ x: b64(pub.subarray(1, 33)),
90
+ y: b64(pub.subarray(33, 65)),
91
+ d: b64(pad32(unb64(keys.privateKey))),
92
+ },
93
+ });
94
+ // JOSE wants the raw r||s pair; node's default for EC keys is DER.
95
+ const signature = sign("sha256", Buffer.from(token), { key, dsaEncoding: "ieee-p1363" });
96
+ return `vapid t=${token}.${b64(signature)}, k=${keys.publicKey}`;
97
+ }
98
+ /** POST one encrypted message. Never throws: every outcome is a result the
99
+ * caller can log or act on. */
100
+ export async function sendPush(target, payload, keys, subject, ttlSeconds = 4 * 60 * 60, fetchImpl = fetch) {
101
+ let body;
102
+ try {
103
+ body = encryptPush(payload, target);
104
+ }
105
+ catch (err) {
106
+ return { status: 0, error: `encrypting failed: ${String(err)}` };
107
+ }
108
+ try {
109
+ const res = await fetchImpl(target.endpoint, {
110
+ method: "POST",
111
+ headers: {
112
+ authorization: vapidAuthorization(target.endpoint, keys, subject),
113
+ "content-encoding": "aes128gcm",
114
+ "content-type": "application/octet-stream",
115
+ ttl: String(ttlSeconds),
116
+ urgency: "normal",
117
+ },
118
+ body: new Uint8Array(body),
119
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
120
+ });
121
+ if (res.ok)
122
+ return { status: res.status };
123
+ // The service's own sentence is the only thing that explains a 400 from
124
+ // Apple or a 403 from FCM; without it the operator sees a bare number.
125
+ const said = (await res.text().catch(() => "")).slice(0, 200);
126
+ return { status: res.status, error: said || res.statusText };
127
+ }
128
+ catch (err) {
129
+ return { status: 0, error: String(err) };
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.7",
3
+ "version": "0.0.9",
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",
@@ -71,6 +71,18 @@ operator's source of truth.
71
71
  (Telegram ~3.8k chars); the footer and the next-step buttons ride the last
72
72
  one.
73
73
 
74
+ ## Notifications on the web
75
+
76
+ - The workbench can push a notification when a turn finishes and no client
77
+ had that session on screen — Settings → Instance → Notifications, per
78
+ browser. Chrome and Edge on desktop work in a tab; **iPhone and iPad only
79
+ notify the installed app**, so it is Share → Add to Home Screen first, then
80
+ enable it from the icon's window. A "Send a test notification" button in the
81
+ same card answers whether it actually arrives.
82
+ - Pier is installable (an **Install Pier** button appears in that same card on
83
+ Chrome and Edge; elsewhere it is the address-bar icon), and an installed
84
+ Pier badges its icon with the number of sessions carrying an unread turn.
85
+
74
86
  ## Who may talk (groups and binding)
75
87
 
76
88
  - Group messages pass a per-chat gate the operator sets: it can require a