@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.
- package/README.md +58 -125
- package/dist/agent/config.js +6 -15
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +42 -64
- package/dist/agent/listing.js +39 -92
- package/dist/agent/pi.js +103 -252
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +7 -23
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +12 -34
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -264
- package/dist/core/types.js +4 -0
- package/dist/db.js +88 -272
- package/dist/drain.js +57 -50
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +4 -12
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +84 -183
- package/dist/paths.js +10 -26
- package/dist/secrets.js +18 -45
- package/dist/service.js +31 -73
- package/dist/settings.js +19 -63
- package/dist/tasks/agent.js +24 -45
- package/dist/tasks/callbacks.js +8 -16
- package/dist/tasks/command.js +2 -6
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +41 -39
- package/dist/tasks/groups.js +8 -11
- package/dist/tasks/messages.js +88 -155
- package/dist/tasks/outbox.js +33 -54
- package/dist/tasks/routes.js +4 -7
- package/dist/tasks/runs.js +4 -9
- package/dist/tasks/service.js +29 -42
- package/dist/tasks/store.js +36 -27
- package/dist/tasks/tool.js +59 -60
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +98 -325
- package/dist/update.js +20 -43
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +35 -82
- package/dist/web/providers.js +5 -11
- package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
- package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
- package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
- package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
- package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
- package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
- package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js +85 -0
- package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
- package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
- package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
- package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
- package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
- package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
- package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
- package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
- package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
- package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
- package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
- package/dist/web/public/index.html +30 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +109 -190
- package/dist/web/session-state.js +13 -53
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +23 -15
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
- package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
- package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
- package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
- package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
- package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
- package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
- package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
- package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
- package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
- package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
- package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
- package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
- package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
- 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-
|
|
41
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
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="
|
|
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
|
|
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-
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
image
|
|
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
|
-
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
package/dist/web/public/sw.js
CHANGED
|
@@ -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 =
|
|
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
|
package/dist/web/public/sw.js.br
CHANGED
|
Binary file
|
package/dist/web/public/sw.js.gz
CHANGED
|
Binary file
|
package/dist/web/push.js
CHANGED
|
@@ -1,71 +1,73 @@
|
|
|
1
|
-
// Notifications for the workbench that is not on screen
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
-
/**
|
|
17
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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"
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
67
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
//
|
|
159
|
-
//
|
|
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
|
-
//
|
|
189
|
-
//
|
|
190
|
-
//
|
|
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
|
|
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
|
|
233
|
-
//
|
|
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
|
|
242
|
-
//
|
|
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
|
-
//
|
|
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
|
|
2
|
-
//
|
|
3
|
-
|
|
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 {
|