@timqi/pier 0.1.0 → 0.1.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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
  91. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  92. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
  94. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  95. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
  97. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  98. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  99. package/dist/web/public/assets/index-DbFu15NN.js +85 -0
  100. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  101. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
  106. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
  109. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  110. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
  112. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
  115. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -37,8 +37,8 @@
37
37
  which style.css pays for with the safe-area inset. -->
38
38
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
39
39
  <title>Pier</title>
40
- <script type="module" crossorigin src="/assets/index-Dqdb-Eqt.js"></script>
41
- <link rel="stylesheet" crossorigin href="/assets/index-DzmMzvi_.css">
40
+ <script type="module" crossorigin src="/assets/index-DbFu15NN.js"></script>
41
+ <link rel="stylesheet" crossorigin href="/assets/index-eqQLVS8Q.css">
42
42
  </head>
43
43
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
44
44
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
@@ -159,7 +159,7 @@
159
159
  <div class="ml-auto flex items-center gap-0.5 text-amber-700">
160
160
  <button type="button" id="queue-steer" title="Send the whole queue now into the running turn" aria-label="Send queue now"
161
161
  class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-amber-100 active:bg-amber-200">
162
- <span data-icon="Send" class="h-3.5 w-3.5"></span>
162
+ <span data-icon="ArrowUp" class="h-3.5 w-3.5"></span>
163
163
  </button>
164
164
  <button type="button" id="queue-restart" title="Stop the running turn and send the whole queue as a new prompt" aria-label="Stop and send queue"
165
165
  class="flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-amber-100 active:bg-amber-200">
@@ -190,9 +190,9 @@
190
190
  class="hidden h-7 w-7 flex-none cursor-pointer items-center justify-center rounded-lg bg-red-50 text-red-600 hover:bg-red-100">
191
191
  <span data-icon="Square" class="h-3.5 w-3.5"></span>
192
192
  </button>
193
- <!-- Style + icon double as the state display: indigo plane = send, amber clock = queue -->
193
+ <!-- Style + icon double as the state display: indigo arrow = send, amber clock = queue -->
194
194
  <button type="submit" id="send" title="Send">
195
- <span id="send-plane" data-icon="Send" class="h-3.5 w-3.5"></span>
195
+ <span id="send-arrow" data-icon="ArrowUp" class="h-3.5 w-3.5"></span>
196
196
  <span id="send-queue" data-icon="Clock" class="hidden h-3.5 w-3.5"></span>
197
197
  </button>
198
198
  </div>
@@ -221,13 +221,14 @@
221
221
  </div>
222
222
  </dialog>
223
223
 
224
- <!-- Full-size view of a thumbnail; a single click (or Esc) closes, a double
225
- one zooms about the point. The arrows page through the gallery it was
226
- opened from, as do ← / →; they hide when there is only one image to look
227
- at. The dialog fills the viewport rather than hugging the image, so the
228
- arrows sit at the screen's edges and a tall shot and a wide one don't
229
- move the control under your thumb; #image-stage is the box a zoomed
230
- image scrolls inside (ui/attachments.ts). -->
224
+ <!-- Full-size view of a thumbnail; a click on the image zooms about the
225
+ point and a second one fits it again, dragging pans what is magnified, a
226
+ click beside it — or the ✕, or Esc — closes. The arrows page through the
227
+ gallery it was opened from, as do ← / →; they hide when there is only one
228
+ image to look at. The dialog fills the viewport rather than hugging the
229
+ image, so the controls sit at the screen's edges and a tall shot and a
230
+ wide one don't move the one under your thumb; #image-stage is the box a
231
+ zoomed image scrolls inside (ui/attachments.ts). -->
231
232
  <!-- The size is written out, not left to `inset-0`: a dialog is `width:
232
233
  fit-content` in the UA sheet, and an inset pair does not stretch a box
233
234
  that already has a width — it sized itself to the image and sat in the
@@ -235,12 +236,25 @@
235
236
  of on #image-stage. -->
236
237
  <dialog id="image-dialog" class="fixed inset-0 m-0 h-[100dvh] w-full max-h-none max-w-none bg-transparent p-0 backdrop:bg-black/70">
237
238
  <div id="image-stage" class="flex h-full w-full items-center justify-center overflow-hidden">
238
- <img id="image-full" alt="" class="max-h-[92dvh] max-w-[92vw] cursor-zoom-in rounded-xl object-contain" />
239
+ <!-- The width leaves the arrows their gutters wherever there is room for
240
+ them: a wide shot at 92vw ran under both, which is what "the buttons
241
+ are gone" was. Below md there is no such room — taking 9rem off a
242
+ 390px screen would shrink every portrait screenshot to fit controls
243
+ that are only sometimes there — so the arrows overlay the image and
244
+ the rim in style.css is what keeps them legible over it.
245
+ draggable=false: the browser's own image drag would race the pan. -->
246
+ <img id="image-full" alt="" draggable="false"
247
+ class="max-h-[92dvh] max-w-[92vw] cursor-zoom-in rounded-xl object-contain md:max-w-[calc(100vw-9rem)]" />
239
248
  </div>
240
249
  <!-- Siblings of the stage, not children of it: the stage is what scrolls
241
- when zoomed, and an arrow inside it would scroll away with the image. -->
242
- <button type="button" id="image-prev" aria-label="Previous image" class="lightbox-nav left-2"><span data-icon="ChevronLeft" class="h-6 w-6"></span></button>
243
- <button type="button" id="image-next" aria-label="Next image" class="lightbox-nav right-2"><span data-icon="ChevronRight" class="h-6 w-6"></span></button>
250
+ when zoomed, and a control inside it would scroll away with the image.
251
+ .lightbox-btn is the round scrim button; where each one sits is a
252
+ utility beside it, so no two of them set the same property. -->
253
+ <button type="button" id="image-prev" aria-label="Previous image" class="lightbox-btn left-2 top-1/2 -translate-y-1/2"><span data-icon="ChevronLeft" class="h-6 w-6"></span></button>
254
+ <button type="button" id="image-next" aria-label="Next image" class="lightbox-btn right-2 top-1/2 -translate-y-1/2"><span data-icon="ChevronRight" class="h-6 w-6"></span></button>
255
+ <!-- The way out on a touch screen: tapping the image now zooms it, and the
256
+ scrim beside a fitted shot is a 4vw strip — not a thumb's target. -->
257
+ <button type="button" id="image-close" aria-label="Close" class="lightbox-btn right-2 top-2"><span data-icon="X" class="h-6 w-6"></span></button>
244
258
  </dialog>
245
259
 
246
260
  <!-- Text preview of an agent attachment — the Files view's code pane fills
Binary file
Binary file
@@ -18,6 +18,18 @@ button{margin-top:1rem;padding:.5rem 1rem;border:1px solid light-dark(#d4d4d4,#3
18
18
  <p class="dim">This device is offline, or the server is not answering.</p>
19
19
  <button onclick="location.reload()">Try again</button></main></body></html>`;
20
20
 
21
+ /** A payload URL is data: resolved against this origin, and replaced by the
22
+ * root if it lands anywhere else — a notification may not open a foreign page. */
23
+ const here = (url) => {
24
+ const root = new URL("/", self.location.origin);
25
+ try {
26
+ const target = new URL(url || "/", self.location.origin);
27
+ return target.origin === root.origin ? target : root;
28
+ } catch {
29
+ return root;
30
+ }
31
+ };
32
+
21
33
  self.addEventListener("install", () => self.skipWaiting());
22
34
 
23
35
  self.addEventListener("activate", (event) => {
@@ -66,7 +78,7 @@ self.addEventListener("push", (event) => {
66
78
  icon: "/icon-192.png",
67
79
  badge: "/icon-32.png",
68
80
  timestamp: Date.now(),
69
- data: { url: data.url || "/" },
81
+ data: { url: here(data.url).href },
70
82
  });
71
83
  // The home-screen badge is the only trace left once a notification is
72
84
  // dismissed; the page recomputes it from the session list when it opens.
@@ -76,7 +88,7 @@ self.addEventListener("push", (event) => {
76
88
 
77
89
  self.addEventListener("notificationclick", (event) => {
78
90
  event.notification.close();
79
- const target = new URL(event.notification.data?.url || "/", self.location.origin);
91
+ const target = here(event.notification.data?.url);
80
92
  event.waitUntil((async () => {
81
93
  const clients = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
82
94
  // An open workbench is focused and pointed at the session, rather than
Binary file
Binary file
package/dist/web/push.js CHANGED
@@ -1,71 +1,73 @@
1
- // Notifications for the workbench that is not on screen: which browsers asked
2
- // to be told, and the one rule that decides a push — a turn that finished and
3
- // that nobody looked at. The wire format is webpush.ts.
4
- //
5
- // The rule is deliberately the same one the sidebar's unread dot uses, read a
6
- // few seconds late: a client with that session visible acks immediately, so
7
- // "still unread when the dust settled" is precisely "nobody saw it". One rule,
8
- // one place — a second notion of attention would drift from the dot within a
9
- // release.
1
+ // Notifications for the workbench that is not on screen. The rule is the
2
+ // sidebar's unread dot, read a few seconds late: a client with the session
3
+ // visible acks immediately, so "still unread" is precisely "nobody saw it".
4
+ // The wire format is webpush.ts.
10
5
  import { readableTitle } from "../core/identity.js";
11
6
  import { pierDb } from "../db.js";
12
7
  import { logger } from "../log.js";
8
+ import { isSealed } from "../secrets.js";
13
9
  import { sessionIdOf } from "./auth.js";
14
10
  import { generateVapidKeys, sendPush, } from "./webpush.js";
15
11
  const log = logger("push");
16
- /** Devices kept at once. A browser mints a new subscription whenever the old
17
- * one expires, so the table grows by itself; the oldest rows are the ones
18
- * already dead. */
12
+ /** A browser mints a new subscription whenever the old one expires, so the
13
+ * table grows by itself; the oldest rows are the dead ones. */
19
14
  const MAX_SUBSCRIPTIONS = 20;
20
- /** How long a finished turn waits for a client to say it was seen. Long enough
21
- * to cross a heartbeat and a slow phone, short enough to still be a
22
- * notification about something that just happened. */
15
+ /** Long enough to cross a heartbeat and a slow phone. */
23
16
  const SETTLE_MS = 6_000;
24
17
  const MAX_BODY_CHARS = 160;
25
- /** What to call the session where there is room for one line. The title is put
26
- * through the same reader the sidebar uses — untouched, a session titled by
27
- * its first prompt announces itself on the lock screen as
28
- * `[operator<web> 12:01]`. Falls back to the project directory, then to the
29
- * fact that it is a session at all: a notification with no title reads as a
30
- * browser bug rather than as an unnamed session — and a listing that could
31
- * not answer must not silence the push. */
18
+ /** Untouched, a session titled by its first prompt announces itself as
19
+ * `[operator<web> 12:01]`. Never empty: a notification with no title reads as
20
+ * a browser bug, and a listing that could not answer must not silence the push. */
32
21
  const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
33
- /** SQLite's "that parent row does not exist" — the session ended. Anything
34
- * else that goes wrong here is a broken database, not a signed-out browser. */
22
+ /** SQLite's "that parent row does not exist": the session ended. */
35
23
  const FOREIGN_KEY_VIOLATION = 787;
36
24
  const sessionGone = (err) => err.errcode === FOREIGN_KEY_VIOLATION;
37
- /** Subscriptions and the instance's VAPID identity. Both are per-instance
38
- * facts nobody edits by hand, so they live beside every other one. */
39
25
  export class PushStore {
26
+ secrets;
40
27
  #db;
41
- constructor(db = pierDb()) {
28
+ /** Without `secrets` (tests), the private key persists as given. A locked
29
+ * store throws rather than serving a key it cannot read. */
30
+ constructor(db = pierDb(), secrets) {
31
+ this.secrets = secrets;
42
32
  this.#db = db;
43
33
  }
44
- /** The key pair every push is signed with, minted on first use. Losing it
45
- * would invalidate every subscription made with it, so it is created once
46
- * and never rotated on its own. */
34
+ /** Rotating it would invalidate every subscription made with it. */
47
35
  identity() {
48
36
  const row = this.#db
49
37
  .prepare("SELECT public_key AS publicKey, private_key AS privateKey FROM push_identity WHERE id = 1")
50
38
  .get();
51
39
  if (row)
52
- return row;
40
+ return { ...row, privateKey: this.#unsealed(row.privateKey) };
53
41
  const keys = generateVapidKeys();
54
42
  this.#db
55
43
  .prepare("INSERT INTO push_identity(id, public_key, private_key, created_at) VALUES (1, ?, ?, ?)")
56
- .run(keys.publicKey, keys.privateKey, Date.now());
44
+ .run(keys.publicKey, this.#sealed(keys.privateKey), Date.now());
57
45
  log.info("minted this instance's VAPID key pair");
58
46
  return keys;
59
47
  }
48
+ /** A key minted before sealing is honored once and sealed in place — the pair
49
+ * cannot be replaced without invalidating every subscription. */
50
+ #unsealed(stored) {
51
+ if (!this.secrets)
52
+ return stored;
53
+ if (isSealed(stored))
54
+ return this.secrets.decrypt(stored);
55
+ this.#db.prepare("UPDATE push_identity SET private_key = ? WHERE id = 1")
56
+ .run(this.secrets.encrypt(stored));
57
+ log.info("sealed this instance's VAPID private key");
58
+ return stored;
59
+ }
60
+ #sealed(privateKey) {
61
+ return this.secrets ? this.secrets.encrypt(privateKey) : privateKey;
62
+ }
60
63
  list() {
61
64
  return this.#db
62
65
  .prepare(`SELECT endpoint, p256dh, auth, label, created_at AS createdAt
63
66
  FROM push_subscriptions ORDER BY created_at DESC`)
64
67
  .all();
65
68
  }
66
- /** Upsert: a browser re-posts the same subscription on every load, which is
67
- * what repairs a row this instance lost — and what re-attaches one to the
68
- * session that is signed in now. */
69
+ /** Upsert: a browser re-posts on every load, which repairs a lost row and
70
+ * re-attaches one to the session signed in now. */
69
71
  save(target, label, sessionId) {
70
72
  this.#db
71
73
  .prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at, session_id)
@@ -84,13 +86,11 @@ export class PushStore {
84
86
  .run(endpoint).changes > 0;
85
87
  }
86
88
  }
87
- /** One line of what the agent said, for a notification shade. */
88
89
  const preview = (text) => {
89
90
  const line = text.replace(/```[\s\S]*?```/g, "…").replace(/\s+/g, " ").trim();
90
91
  return line.length > MAX_BODY_CHARS ? `${line.slice(0, MAX_BODY_CHARS - 1)}…` : line;
91
92
  };
92
- /** A subscription is only accepted in the exact shape the Push API produces;
93
- * a half-valid one would fail later, inside a background send nobody watches. */
93
+ /** A half-valid subscription would fail later, inside a send nobody watches. */
94
94
  function parseTarget(body) {
95
95
  const { endpoint, keys } = (body ?? {});
96
96
  const p256dh = keys?.p256dh;
@@ -117,15 +117,12 @@ function parseTarget(body) {
117
117
  }
118
118
  export function registerPushRoutes(app, deps) {
119
119
  const { store, hub, unread, channelOf, summary, publicUrl, settleMs = SETTLE_MS } = deps;
120
- /** Who a push service should complain to. It has to be a mailto: or https:
121
- * URL or Apple rejects the token outright, so an instance that never had
122
- * its public URL set still needs an answer. */
120
+ /** Must be a mailto: or https: URL or Apple rejects the token outright. */
123
121
  const subject = () => {
124
122
  const url = publicUrl();
125
123
  return url.startsWith("https://") ? url : "mailto:pier@localhost";
126
124
  };
127
- /** Send to every device, and prune the ones the service says are gone. A
128
- * failure is logged with what the service said — a notification that never
125
+ /** A failure is logged with what the service said: a notification that never
129
126
  * arrives is otherwise indistinguishable from one nobody tapped. */
130
127
  async function deliver(payload) {
131
128
  const targets = store.list();
@@ -143,8 +140,7 @@ export function registerPushRoutes(app, deps) {
143
140
  return;
144
141
  }
145
142
  failed += 1;
146
- // 404/410 is the push service saying this subscription is dead for good
147
- // — the only status that may cost a row.
143
+ // 404/410 is "dead for good", the only status that may cost a row.
148
144
  if (status === 404 || status === 410) {
149
145
  store.remove(target.endpoint);
150
146
  log.info(`dropped an expired subscription (${target.label})`);
@@ -155,12 +151,8 @@ export function registerPushRoutes(app, deps) {
155
151
  return { sent, failed };
156
152
  }
157
153
  // --- the trigger ----------------------------------------------------------------
158
- // A session is watched only while it streams: the ring buffer of a session
159
- // nobody watches is released on eviction (core/hub.ts), and a permanent
160
- // subscriber would keep every one of them alive.
161
- /** Per streaming session: stop watching, and hand back what the turn said.
162
- * The text lives in the watcher's own closure, so it cannot outlive the
163
- * subscription that collected it. */
154
+ // Watched only while streaming: a permanent subscriber would keep every
155
+ // session's ring buffer alive (core/hub.ts).
164
156
  const watching = new Map();
165
157
  hub.subscribeWorkspace((e) => {
166
158
  if (e.type !== "session-state")
@@ -169,9 +161,11 @@ export function registerPushRoutes(app, deps) {
169
161
  if (watching.has(e.sessionId))
170
162
  return;
171
163
  let text = "";
164
+ // The last turn that said something: a run ends one turn per answer, and
165
+ // a silence after the answer would replace it with "Turn finished."
172
166
  const stop = hub.subscribe(e.sessionId, (ev) => {
173
167
  if (ev.type === "turn-end")
174
- text = ev.text;
168
+ text = ev.text || text;
175
169
  });
176
170
  watching.set(e.sessionId, () => {
177
171
  stop();
@@ -179,20 +173,14 @@ export function registerPushRoutes(app, deps) {
179
173
  });
180
174
  return;
181
175
  }
182
- // No start witnessed → a session that booted idle, not a finished turn.
183
176
  const finish = watching.get(e.sessionId);
184
177
  if (!finish)
185
178
  return;
186
179
  watching.delete(e.sessionId);
187
180
  const text = finish();
188
- // The workbench's own sessions only. A turn that came from Slack, Telegram
189
- // or Lark was already delivered to the chat it came from — the person has
190
- // it, and their phone buzzing twice for one answer is what a notification
191
- // budget gets spent on. Read now, not in the timer: this is the state that
192
- // produced the turn.
193
- // Every outcome says which one it was: a push that was never sent and one
194
- // that arrived look identical from here otherwise, and "why did my phone
195
- // stay quiet" is the only question this feature is ever asked (§5b).
181
+ // An IM turn was already delivered to its chat. Read now, not in the
182
+ // timer: this is the state that produced the turn. Every outcome is logged:
183
+ // "why did my phone stay quiet" is the only question this is asked (§5).
196
184
  const channel = channelOf(e.sessionId);
197
185
  if (channel !== "web") {
198
186
  log.debug(`no push for ${e.sessionId}: answering ${channel ?? "nothing"}, not the workbench`);
@@ -203,10 +191,7 @@ export function registerPushRoutes(app, deps) {
203
191
  log.debug(`no push for ${e.sessionId}: a client reported the turn as seen`);
204
192
  return; // somebody has it on screen
205
193
  }
206
- // One async step before the send, so a failure in *either* half is
207
- // reported: a notification nobody received and one nobody sent look
208
- // identical from here, and "why did my phone stay quiet" is the only
209
- // question this feature is ever asked (§5b).
194
+ // One async step before the send, so a failure in either half is reported.
210
195
  void (async () => {
211
196
  await deliver({
212
197
  title: label(await summary(e.sessionId)),
@@ -220,8 +205,6 @@ export function registerPushRoutes(app, deps) {
220
205
  timer.unref?.();
221
206
  });
222
207
  // --- routes ---------------------------------------------------------------------
223
- // The key a browser subscribes with. Public by nature — it is what a push
224
- // service checks our signature against.
225
208
  app.get("/api/push", (c) => c.json({ publicKey: store.identity().publicKey }));
226
209
  app.post("/api/push/subscribe", async (c) => {
227
210
  const body = await c.req.json().catch(() => null);
@@ -229,18 +212,14 @@ export function registerPushRoutes(app, deps) {
229
212
  if (!target)
230
213
  return c.json({ error: "not a push subscription" }, 400);
231
214
  const label = String(body.label ?? "a browser").slice(0, 80);
232
- // The session that is asking owns the subscription, and the foreign key is
233
- // what makes signing this browser out take the subscription with it. It can
234
- // refuse: the boundary let this request in and the browser was signed out
235
- // while its body was still arriving. Say so rather than 500 — the browser
236
- // is about to be sent to the login form by its next request anyway.
215
+ // The foreign key can refuse: the browser was signed out while its body
216
+ // was still arriving. Say so rather than 500.
237
217
  try {
238
218
  store.save(target, label, sessionIdOf(c));
239
219
  }
240
220
  catch (err) {
241
- // Only that. A full or read-only database answering 401 would send a
242
- // signed-in browser to the login form, where the password it types will
243
- // not help either.
221
+ // Only that: a full database answering 401 would send a signed-in browser
222
+ // to the login form.
244
223
  if (!sessionGone(err))
245
224
  throw err;
246
225
  log.warn(`subscription refused for a session that ended: ${String(err)}`);
@@ -255,8 +234,7 @@ export function registerPushRoutes(app, deps) {
255
234
  return c.json({ error: "endpoint required" }, 400);
256
235
  return c.json({ removed: store.remove(endpoint) });
257
236
  });
258
- // "Did that actually work?" — the only way to answer it on a phone, where a
259
- // permission granted to the wrong context looks exactly like a granted one.
237
+ // On a phone, a permission granted to the wrong context looks like a granted one.
260
238
  app.post("/api/push/test", async (c) => {
261
239
  const { sent, failed } = await deliver({
262
240
  title: "Pier",
package/dist/web/route.js CHANGED
@@ -1,10 +1,6 @@
1
- // A route body that throws answers as JSON — the one shape every view's
2
- // fetch helper already knows how to read, with the status the route owns.
3
- // It lives alone because all three route families use it and none of them is
4
- // where the other two would look for it.
5
- /** Register a route whose body may throw: the error becomes the JSON shape
6
- * the UI expects, with the status the route owns. Takes the literal path so
7
- * `c.req.param()` keeps its typed keys. */
1
+ // A route body that throws answers as JSON, the shape every view's fetch
2
+ // helper reads, with the status the route owns.
3
+ /** Takes the literal path so `c.req.param()` keeps its typed keys. */
8
4
  export function guarded(app, method, path, status, fn) {
9
5
  app.on(method, path, async (c) => {
10
6
  try {