@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  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 +8 -24
  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 +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  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 +6 -14
  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 +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  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 +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.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-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  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/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
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 {