@timqi/pier 0.0.1 → 0.0.3
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 +87 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack-tool.js +28 -3
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +200 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +180 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +192 -18
- package/dist/settings.js +77 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +92 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +146 -0
- package/dist/web/auth.js +89 -26
- package/dist/web/explorer.js +147 -0
- package/dist/web/files.js +28 -12
- package/dist/web/instance.js +165 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +141 -0
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +50 -32
- package/dist/web/server.js +110 -120
- package/docs/deploy.md +142 -64
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +20 -3
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/web/public/icon.svg
CHANGED
|
@@ -1,19 +1,37 @@
|
|
|
1
|
-
<!-- Pier's mark: a dock in profile over water.
|
|
2
|
-
still reads at 16px in a browser tab. Source of truth for
|
|
3
|
-
next to it (favicon fallback, apple-touch, manifest).
|
|
1
|
+
<!-- Pier's mark: a dock in profile over water. Two shapes only — the dock and
|
|
2
|
+
the wave — so it still reads at 16px in a browser tab. Source of truth for
|
|
3
|
+
the PNG icons next to it (favicon fallback, apple-touch, manifest).
|
|
4
4
|
|
|
5
5
|
The plate is a squircle, not a rect: a superellipse outline (the iOS /
|
|
6
6
|
Android app-icon silhouette) reads as an app rather than as a coloured
|
|
7
|
-
tile. Platforms that apply their own mask get the square PNGs instead.
|
|
7
|
+
tile. Platforms that apply their own mask get the square PNGs instead.
|
|
8
|
+
|
|
9
|
+
Deck and piers are one closed path, and the joint is where every earlier
|
|
10
|
+
version failed: three rects under a bar read as loose pills whatever their
|
|
11
|
+
corner radius, and a concave fillet only softened the seam. So the gap
|
|
12
|
+
carries the shape instead — each opening is a flat-topped slot (shoulders
|
|
13
|
+
of 2.3, not the half-circle this started as, which bulged the openings
|
|
14
|
+
into eyes) between vertical pier flanks. The deck is the subject: 8 deep
|
|
15
|
+
against pier width 7, and dead flat — bowing it either way turned it into
|
|
16
|
+
a face (crowned it was a brow, sagged it was a frown). Cuteness is spent
|
|
17
|
+
on the parts instead: nearly round feet, flanks that splay 0.3, a wave
|
|
18
|
+
with the same weight as the piers.
|
|
19
|
+
|
|
20
|
+
The white mark is centred on the plate as one bounding box (hence the
|
|
21
|
+
translate, the deck's top edge at y=0 and the wave's stroke edge ending
|
|
22
|
+
the box) — not shape by shape, which is how it first shipped sitting
|
|
23
|
+
visibly low. -->
|
|
8
24
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
9
25
|
<path fill="#4f46e5"
|
|
10
26
|
d="M32 0c16 0 24 0 28 4s4 12 4 28-0 24-4 28-12 4-28 4-24 0-28-4-4-12-4-28 0-24 4-28 12-4 28-4Z" />
|
|
11
|
-
<
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
27
|
+
<path fill="#fff" transform="translate(0 14.5)" d="M10 3 A3 3 0 0 1 13 0 L51 0
|
|
28
|
+
A3 3 0 0 1 54 3 L54 5 A3 3 0 0 1 51 8
|
|
29
|
+
L51.3 19.3 A3.2 3.2 0 0 1 48.1 22.5 L47.2 22.5 A3.2 3.2 0 0 1 44 19.3
|
|
30
|
+
L44 10.3 A2.3 2.3 0 0 0 41.7 8 L37.8 8 A2.3 2.3 0 0 0 35.5 10.3
|
|
31
|
+
L35.5 19.3 A3.2 3.2 0 0 1 32.3 22.5 L31.7 22.5 A3.2 3.2 0 0 1 28.5 19.3
|
|
32
|
+
L28.5 10.3 A2.3 2.3 0 0 0 26.2 8 L22.3 8 A2.3 2.3 0 0 0 20 10.3
|
|
33
|
+
L20 19.3 A3.2 3.2 0 0 1 16.8 22.5 L15.9 22.5 A3.2 3.2 0 0 1 12.7 19.3
|
|
34
|
+
L13 8 A3 3 0 0 1 10 5 Z" />
|
|
35
|
+
<path d="M12.5 44.5q6.5-5 13 0t13 0 13 0" fill="none" stroke="#fff" stroke-width="5"
|
|
18
36
|
stroke-linecap="round" opacity=".85" />
|
|
19
37
|
</svg>
|
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
under an iPhone's home indicator. -->
|
|
7
7
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
8
8
|
<meta name="theme-color" content="#fafafa" />
|
|
9
|
-
<!-- Installed as a home-screen app
|
|
10
|
-
possible on iOS — notifications there require a standalone web app. -->
|
|
9
|
+
<!-- Installed as a home-screen app: standalone window, own icon. -->
|
|
11
10
|
<link rel="manifest" href="/manifest.webmanifest" />
|
|
12
11
|
<!-- SVG first (scales to any tab/bookmark size), 32px PNG for the engines
|
|
13
12
|
that still ignore it. iOS wants an opaque square and rounds it itself. -->
|
|
@@ -17,8 +16,8 @@
|
|
|
17
16
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
18
17
|
<meta name="apple-mobile-web-app-title" content="Pier" />
|
|
19
18
|
<title>Pier</title>
|
|
20
|
-
<script type="module" crossorigin src="/assets/index-
|
|
21
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
19
|
+
<script type="module" crossorigin src="/assets/index-fASxMPr6.js"></script>
|
|
20
|
+
<link rel="stylesheet" crossorigin href="/assets/index-cCIuQnDr.css">
|
|
22
21
|
</head>
|
|
23
22
|
<!-- The document never scrolls: this is a fixed-viewport workbench, and every
|
|
24
23
|
scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
|
|
@@ -28,9 +27,29 @@
|
|
|
28
27
|
<!-- Below md the aside leaves the grid and slides in as a drawer (style.css);
|
|
29
28
|
the scrim is its dismiss target and never paints on desktop. -->
|
|
30
29
|
<div id="drawer-scrim" class="fixed inset-0 z-30 hidden bg-black/25 md:hidden"></div>
|
|
30
|
+
<!-- Desktop only: one sidebar toggle. Fixed in the title band — style.css
|
|
31
|
+
moves it between the rail's top-right (open) and main's top-left
|
|
32
|
+
(closed), flipping the chevron, so expand and collapse are the same
|
|
33
|
+
control in the same band. -->
|
|
34
|
+
<button type="button" id="rail-toggle" aria-label="Toggle sidebar"
|
|
35
|
+
class="fixed top-1.5 z-40 hidden h-7 w-7 cursor-pointer items-center justify-center rounded-md text-neutral-400 hover:bg-neutral-200 hover:text-neutral-700 md:flex">
|
|
36
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5">
|
|
37
|
+
<path d="M9.5 4 5.5 8l4 4" stroke-linecap="round" stroke-linejoin="round" />
|
|
38
|
+
</svg>
|
|
39
|
+
</button>
|
|
31
40
|
<aside id="sidebar" class="flex flex-col overflow-hidden border-r border-neutral-200 bg-neutral-50">
|
|
32
|
-
<header class="flex items-center
|
|
41
|
+
<header class="flex items-center gap-2 px-3 py-2">
|
|
42
|
+
<!-- The same mark as the tab and the home-screen icon, so an installed
|
|
43
|
+
Pier looks like the thing you installed. -->
|
|
44
|
+
<img src="/icon.svg" alt="" width="20" height="20" class="h-5 w-5 flex-none" />
|
|
33
45
|
<h1 class="text-[15px] font-semibold tracking-tight">Pier</h1>
|
|
46
|
+
<!-- A link, not a label: AGPL-3.0 section 13 asks a network service to
|
|
47
|
+
point its users at the source it is running. Beside the title
|
|
48
|
+
because a version is something you look up, not something you find
|
|
49
|
+
by scrolling to the bottom. -->
|
|
50
|
+
<a id="version" href="https://github.com/timqi/pier" target="_blank" rel="noreferrer"
|
|
51
|
+
title="Pier is free software (AGPL-3.0) — the source of what you are using"
|
|
52
|
+
class="font-mono text-[11px] text-neutral-400 hover:text-neutral-700 hover:underline"></a>
|
|
34
53
|
</header>
|
|
35
54
|
<details id="console-section" open class="flex-none border-b border-neutral-200/70">
|
|
36
55
|
<summary class="flex cursor-pointer select-none items-center gap-1 px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-neutral-500 hover:bg-neutral-100">
|
|
@@ -38,21 +57,28 @@
|
|
|
38
57
|
</summary>
|
|
39
58
|
<ul class="pb-1 text-[13.5px]">
|
|
40
59
|
<li><button type="button" id="open-activity"
|
|
41
|
-
class="
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
class="block w-full cursor-pointer py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">Channels</button></li>
|
|
60
|
+
class="flex w-full cursor-pointer items-center gap-2 py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">
|
|
61
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5 flex-none text-neutral-400">
|
|
62
|
+
<path d="M1.5 8h3l2-4.5 3 9L11.5 8h3" stroke-linecap="round" stroke-linejoin="round" />
|
|
63
|
+
</svg>Activity</button></li>
|
|
46
64
|
<li><button type="button" id="open-boards"
|
|
47
|
-
class="
|
|
65
|
+
class="flex w-full cursor-pointer items-center gap-2 py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">
|
|
66
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5 flex-none text-neutral-400">
|
|
67
|
+
<rect x="2" y="2" width="12" height="12" rx="1.5" /><path d="M2 6.5h12M6.5 6.5V14" />
|
|
68
|
+
</svg>Boards</button></li>
|
|
48
69
|
<li><button type="button" id="open-settings"
|
|
49
|
-
class="
|
|
70
|
+
class="flex w-full cursor-pointer items-center gap-2 py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">
|
|
71
|
+
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" class="h-3.5 w-3.5 flex-none text-neutral-400">
|
|
72
|
+
<!-- Teeth every 60° about (8,8); the hub is that same centre. -->
|
|
73
|
+
<path d="M6.8 2.2h2.4l.32 1.6a4.9 4.9 0 0 1 1.36.79l1.55-.53 1.2 2.08-1.23 1.07a4.9 4.9 0 0 1 0 1.58l1.23 1.07-1.2 2.08-1.55-.53a4.9 4.9 0 0 1-1.36.79l-.32 1.6H6.8l-.32-1.6a4.9 4.9 0 0 1-1.36-.79l-1.55.53-1.2-2.08 1.23-1.07a4.9 4.9 0 0 1 0-1.58L2.37 6.14l1.2-2.08 1.55.53a4.9 4.9 0 0 1 1.36-.79z" stroke-linejoin="round" />
|
|
74
|
+
<circle cx="8" cy="8" r="1.9" />
|
|
75
|
+
</svg>Settings</button></li>
|
|
50
76
|
</ul>
|
|
51
77
|
</details>
|
|
52
78
|
<div class="flex items-center gap-1 px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
|
|
53
79
|
Projects
|
|
54
|
-
<button type="button" id="open-archive"
|
|
55
|
-
class="icon-btn ml-auto" aria-label="Search
|
|
80
|
+
<button type="button" id="open-archive"
|
|
81
|
+
class="icon-btn ml-auto" aria-label="Search sessions and Console">
|
|
56
82
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5">
|
|
57
83
|
<circle cx="7" cy="7" r="4.25" /><path d="M10.2 10.2 14 14" stroke-linecap="round" />
|
|
58
84
|
</svg>
|
|
@@ -66,15 +92,6 @@
|
|
|
66
92
|
</button>
|
|
67
93
|
</div>
|
|
68
94
|
<div id="project-tree" class="flex-1 overflow-y-auto text-[13.5px]"></div>
|
|
69
|
-
<footer class="flex flex-none items-center gap-2 border-t border-neutral-200 px-3 py-1.5 text-[11px] text-neutral-400">
|
|
70
|
-
<button type="button" id="notify-toggle" title="Notify me when a turn finishes"
|
|
71
|
-
class="cursor-pointer rounded px-1 hover:bg-neutral-200 hover:text-neutral-700">Notify</button>
|
|
72
|
-
<!-- A link, not a label: AGPL-3.0 section 13 asks a network service to
|
|
73
|
-
point its users at the source it is running. -->
|
|
74
|
-
<a id="version" href="https://github.com/timqi/pier" target="_blank" rel="noreferrer"
|
|
75
|
-
title="Pier is free software (AGPL-3.0) — the source of what you are using"
|
|
76
|
-
class="ml-auto font-mono hover:text-neutral-700 hover:underline"></a>
|
|
77
|
-
</footer>
|
|
78
95
|
</aside>
|
|
79
96
|
|
|
80
97
|
<main class="flex min-w-0 flex-col overflow-hidden">
|
|
@@ -93,18 +110,16 @@
|
|
|
93
110
|
</header>
|
|
94
111
|
<section id="activity-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
95
112
|
<section id="tasks-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
96
|
-
<section id="config-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
97
|
-
<section id="channels-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
98
113
|
<section id="boards-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
99
114
|
<section id="settings-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
115
|
+
<section id="files-view" class="hidden min-h-0 flex-1 flex-col"></section>
|
|
100
116
|
<header id="chat-header" class="flex h-10 flex-none items-center gap-2 border-b border-neutral-200 px-4 max-md:hidden">
|
|
101
117
|
<span id="chat-title" class="truncate font-medium"></span>
|
|
102
118
|
<!-- Right cluster: meta chips sit flush against the ⋯ button. Two
|
|
103
119
|
ml-autos would split the free space, so one wrapper owns it. -->
|
|
104
120
|
<div class="ml-auto flex flex-none items-center gap-1.5">
|
|
105
|
-
<!-- Inline session meta:
|
|
106
|
-
|
|
107
|
-
<div id="session-meta" class="group hidden flex-none items-center gap-1.5 text-[11px]"></div>
|
|
121
|
+
<!-- Inline session meta: model · reasoning · current context size. -->
|
|
122
|
+
<div id="session-meta" class="hidden flex-none items-center gap-1 text-[11px]"></div>
|
|
108
123
|
<button type="button" id="chat-menu" title="Session actions" aria-label="Session actions"
|
|
109
124
|
class="icon-btn">⋯</button>
|
|
110
125
|
</div>
|
|
@@ -155,9 +170,9 @@
|
|
|
155
170
|
<div id="image-strip" class="hidden flex-wrap gap-2 px-1 pb-1.5 pt-1"></div>
|
|
156
171
|
<!-- items-end keeps the icons on the bottom row while the textarea grows upward -->
|
|
157
172
|
<div class="flex items-end gap-1.5">
|
|
158
|
-
<button type="button" id="attach" title="Attach
|
|
173
|
+
<button type="button" id="attach" title="Attach file (or paste / drop)"
|
|
159
174
|
class="flex h-7 w-7 flex-none cursor-pointer items-center justify-center rounded-lg text-[17px] leading-none text-neutral-400 hover:bg-neutral-100 hover:text-neutral-600">+</button>
|
|
160
|
-
<input id="attach-input" type="file"
|
|
175
|
+
<input id="attach-input" type="file" multiple class="hidden" />
|
|
161
176
|
<textarea id="input" rows="1"
|
|
162
177
|
class="block max-h-48 min-w-0 flex-1 resize-none bg-transparent px-1 py-0.5 focus:outline-none"
|
|
163
178
|
placeholder="Message…"></textarea>
|
|
@@ -202,9 +217,12 @@
|
|
|
202
217
|
</form>
|
|
203
218
|
</dialog>
|
|
204
219
|
|
|
205
|
-
|
|
220
|
+
<!-- Not m-auto: a palette that grows downward as you type should not also
|
|
221
|
+
drift upward to stay centred. Pinned near the top instead, which is
|
|
222
|
+
where the eye already is and where every other ⌘K lives. -->
|
|
223
|
+
<dialog id="archive-dialog" class="mx-auto mb-auto mt-[12vh] w-[35rem] max-w-[90vw] rounded-xl border border-neutral-200 p-0 shadow-xl backdrop:bg-black/20">
|
|
206
224
|
<div class="flex items-center gap-2 border-b border-neutral-200 px-3 py-2">
|
|
207
|
-
<input id="archive-search" type="text" placeholder="Search
|
|
225
|
+
<input id="archive-search" type="text" placeholder="Search sessions and Console…"
|
|
208
226
|
class="w-full bg-transparent text-[14px] focus:outline-none" />
|
|
209
227
|
<span id="archive-count" class="flex-none rounded bg-neutral-100 px-1.5 text-[11px] text-neutral-500"></span>
|
|
210
228
|
<button type="button" id="archive-close" class="btn flex-none text-[12px]">Close</button>
|
package/dist/web/server.js
CHANGED
|
@@ -1,42 +1,36 @@
|
|
|
1
1
|
// Web workbench backend: REST + SSE, a pure consumer of core.
|
|
2
2
|
// See docs/design/03-web-workbench.md for the route contract.
|
|
3
|
-
import {
|
|
3
|
+
import { hostname } from "node:os";
|
|
4
|
+
import { readFile } from "node:fs/promises";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
import { serveStatic } from "@hono/node-server/serve-static";
|
|
6
8
|
import { Hono } from "hono";
|
|
7
9
|
import { streamSSE } from "hono/streaming";
|
|
8
10
|
import { EventHub } from "../core/hub.js";
|
|
9
|
-
import { Router } from "../core/router.js";
|
|
10
11
|
import { logger } from "../log.js";
|
|
12
|
+
import { Router } from "../core/router.js";
|
|
13
|
+
import { registerExplorerRoutes } from "./explorer.js";
|
|
11
14
|
import { guarded, registerFileRoutes } from "./files.js";
|
|
12
15
|
import { isThinkingLevel } from "../core/types.js";
|
|
13
|
-
import {
|
|
16
|
+
import { saveInbound } from "../core/inbox.js";
|
|
17
|
+
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
18
|
+
import { registerInstanceRoutes } from "./instance.js";
|
|
19
|
+
import { registerProviderRoutes } from "./providers.js";
|
|
20
|
+
const log = logger("web");
|
|
21
|
+
/** What goes in front of `Pier` in the tab: `$PIER_TITLE`, then the machine.
|
|
22
|
+
* The label leads because a tab is narrow and "which instance is this" is the
|
|
23
|
+
* question it has to answer before the browser truncates — `staging - g1`. */
|
|
24
|
+
export const tabPrefix = (title, host) => [title?.trim(), host.trim()].filter(Boolean).join(" - ").slice(0, 60);
|
|
25
|
+
/** `<title>staging - g1 - Pier</title>`. Nothing to say, or a shell that does
|
|
26
|
+
* not say `Pier`: leave it exactly as built. */
|
|
27
|
+
export const withTabPrefix = (html, prefix) => prefix
|
|
28
|
+
? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&").replace(/</g, "<")} - Pier</title>`)
|
|
29
|
+
: html;
|
|
14
30
|
const HEARTBEAT_MS = 15_000;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const MAX_IMAGES = 8;
|
|
19
|
-
const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // per image, base64 length ≈ bytes × 4/3
|
|
20
|
-
/** Validate at the seam: malformed attachments are rejected, never half-sent. */
|
|
21
|
-
function parseImages(raw) {
|
|
22
|
-
if (raw === undefined)
|
|
23
|
-
return [];
|
|
24
|
-
if (!Array.isArray(raw) || raw.length > MAX_IMAGES)
|
|
25
|
-
return { error: "invalid images" };
|
|
26
|
-
const images = [];
|
|
27
|
-
for (const i of raw) {
|
|
28
|
-
if (typeof i?.data !== "string" ||
|
|
29
|
-
!i.data ||
|
|
30
|
-
i.data.length > (MAX_IMAGE_BYTES * 4) / 3 ||
|
|
31
|
-
typeof i?.mimeType !== "string" ||
|
|
32
|
-
!i.mimeType.startsWith("image/")) {
|
|
33
|
-
return { error: "invalid images" };
|
|
34
|
-
}
|
|
35
|
-
images.push({ data: i.data, mimeType: i.mimeType });
|
|
36
|
-
}
|
|
37
|
-
return images;
|
|
38
|
-
}
|
|
39
|
-
export function createServer({ factory, router, hub, sessions: state, config, settings, secrets, onUnlocked, backgroundRuns }) {
|
|
31
|
+
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
32
|
+
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
33
|
+
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, updates, updater, backgroundRuns, }) {
|
|
40
34
|
const app = new Hono();
|
|
41
35
|
// A finished turn marks its session unread until some client reports it was
|
|
42
36
|
// seen (session selected + tab visible → POST read below). Server-side so
|
|
@@ -68,6 +62,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
68
62
|
const sessions = await factory.list();
|
|
69
63
|
for (const s of sessions)
|
|
70
64
|
nascent.delete(s.id);
|
|
65
|
+
// A session created but never prompted would otherwise be listed forever.
|
|
66
|
+
for (const [id, n] of nascent)
|
|
67
|
+
if (Date.now() - n.createdAt > 86_400_000)
|
|
68
|
+
nascent.delete(id);
|
|
71
69
|
return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => ({
|
|
72
70
|
...s,
|
|
73
71
|
state: router.stateOf(s.id) ?? "idle",
|
|
@@ -103,6 +101,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
103
101
|
const body = await c.req.json().catch(() => null);
|
|
104
102
|
if (typeof body?.pinned !== "boolean")
|
|
105
103
|
return c.json({ error: "pinned required" }, 400);
|
|
104
|
+
// The id is trusted: checking existence costs a session list per click,
|
|
105
|
+
// and the surface is operator-authenticated. Worst case is a stray row.
|
|
106
106
|
state.set("pinned", c.req.param("id"), body.pinned);
|
|
107
107
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
108
108
|
return c.json({ pinned: body.pinned });
|
|
@@ -123,20 +123,24 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
123
123
|
backgroundRuns: backgroundRuns?.(id) ?? [],
|
|
124
124
|
});
|
|
125
125
|
});
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
|
|
139
|
-
|
|
126
|
+
// Composer attachments: bytes land in the inbox, the message carries the
|
|
127
|
+
// path as a `[name](file:///…)` line the client builds itself — upload
|
|
128
|
+
// first, so the text it sends (and optimistically renders) is final.
|
|
129
|
+
guarded(app, "POST", "/api/inbox", 400, async (c) => {
|
|
130
|
+
const body = await c.req.json().catch(() => null);
|
|
131
|
+
if (typeof body?.data !== "string" ||
|
|
132
|
+
!body.data ||
|
|
133
|
+
// Cheap ceiling before decoding; the exact check is on the bytes.
|
|
134
|
+
body.data.length > Math.ceil(MAX_INBOUND_BYTES / 3) * 4 + 4 ||
|
|
135
|
+
!BASE64_RE.test(body.data) ||
|
|
136
|
+
typeof body?.mimeType !== "string") {
|
|
137
|
+
return c.json({ error: "invalid file" }, 400);
|
|
138
|
+
}
|
|
139
|
+
const name = typeof body.name === "string" ? body.name : undefined;
|
|
140
|
+
const bytes = Buffer.from(body.data, "base64");
|
|
141
|
+
if (bytes.length > MAX_INBOUND_BYTES)
|
|
142
|
+
return c.json({ error: "invalid file" }, 400);
|
|
143
|
+
return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
|
|
140
144
|
});
|
|
141
145
|
// Backend model catalog, no session needed: surfaces that configure what a
|
|
142
146
|
// *future* session launches with (IM chats) have none to ask.
|
|
@@ -180,21 +184,14 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
180
184
|
app.post("/api/sessions/:id/messages", async (c) => {
|
|
181
185
|
const id = c.req.param("id");
|
|
182
186
|
const body = await c.req.json().catch(() => null);
|
|
183
|
-
if (!body || typeof body.text !== "string") {
|
|
187
|
+
if (!body || typeof body.text !== "string" || !body.text.trim()) {
|
|
184
188
|
return c.json({ error: "text required" }, 400);
|
|
185
189
|
}
|
|
186
|
-
const images = parseImages(body.images);
|
|
187
|
-
if ("error" in images)
|
|
188
|
-
return c.json({ error: images.error }, 400);
|
|
189
|
-
if (!body.text.trim() && images.length === 0) {
|
|
190
|
-
return c.json({ error: "text or images required" }, 400);
|
|
191
|
-
}
|
|
192
190
|
const mode = body.mode === "steer" || body.mode === "followUp" ? body.mode : "auto";
|
|
193
191
|
const { sessionId } = await router.dispatch({
|
|
194
192
|
key: { channelId: "web", conversationId: id },
|
|
195
193
|
senderId: "web",
|
|
196
194
|
text: body.text,
|
|
197
|
-
images: images.length ? images : undefined,
|
|
198
195
|
mode,
|
|
199
196
|
});
|
|
200
197
|
return c.json({ sessionId }, 202);
|
|
@@ -209,6 +206,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
209
206
|
if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
|
|
210
207
|
return c.json({ error: "index and text required" }, 400);
|
|
211
208
|
}
|
|
209
|
+
// Asked before touching anything: the dispatch below would be refused by
|
|
210
|
+
// the drain gate, and by then the transcript is already rewound.
|
|
211
|
+
if (router.isDraining())
|
|
212
|
+
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
212
213
|
const session = await ensure(id);
|
|
213
214
|
if (session.state === "streaming")
|
|
214
215
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
@@ -231,6 +232,9 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
231
232
|
if (mode !== "steer" && mode !== "restart") {
|
|
232
233
|
return c.json({ error: "mode must be steer or restart" }, 400);
|
|
233
234
|
}
|
|
235
|
+
// Same reason as edit above: a refused dispatch must not cost the queue.
|
|
236
|
+
if (router.isDraining())
|
|
237
|
+
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
234
238
|
const session = await ensure(id);
|
|
235
239
|
const { steering, followUp } = await session.clearQueue();
|
|
236
240
|
const text = [...steering, ...followUp].join("\n").trim();
|
|
@@ -252,30 +256,6 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
252
256
|
const { steering, followUp } = await session.clearQueue();
|
|
253
257
|
return c.json({ messages: [...steering, ...followUp] });
|
|
254
258
|
});
|
|
255
|
-
// The browser's half of the log. A workbench that threw after the response
|
|
256
|
-
// left the server is otherwise invisible here (ui/report.ts) — this is the
|
|
257
|
-
// one route whose entire purpose is to make it visible.
|
|
258
|
-
const clientLog = logger("client");
|
|
259
|
-
let reports = [];
|
|
260
|
-
app.post("/api/client-log", async (c) => {
|
|
261
|
-
const body = (await c.req.json().catch(() => null));
|
|
262
|
-
if (typeof body?.message !== "string" || !body.message.trim()) {
|
|
263
|
-
return c.json({ error: "message required" }, 400);
|
|
264
|
-
}
|
|
265
|
-
const now = Date.now();
|
|
266
|
-
reports = reports.filter((at) => now - at < 60_000);
|
|
267
|
-
if (reports.length >= CLIENT_LOG_PER_MINUTE)
|
|
268
|
-
return c.body(null, 429);
|
|
269
|
-
reports.push(now);
|
|
270
|
-
const cap = (value, max) => typeof value === "string" ? value.slice(0, max) : "";
|
|
271
|
-
const where = cap(body.view, 120);
|
|
272
|
-
const stack = cap(body.stack, 2000);
|
|
273
|
-
// One line, ua included: "only on iOS" is the answer half these questions
|
|
274
|
-
// have, and the report is the only place it exists.
|
|
275
|
-
clientLog.warn(`${cap(body.message, 500)} [${where || "/"}] ${cap(c.req.header("user-agent"), 160)}` +
|
|
276
|
-
(stack ? `\n${stack}` : ""));
|
|
277
|
-
return c.body(null, 204);
|
|
278
|
-
});
|
|
279
259
|
app.post("/api/sessions/:id/abort", async (c) => {
|
|
280
260
|
const id = c.req.param("id");
|
|
281
261
|
await router.abort(id);
|
|
@@ -284,7 +264,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
284
264
|
// Workspace stream: one per client, keeps every session list in sync
|
|
285
265
|
// (created/pinned → re-list, run state → patch) without polling.
|
|
286
266
|
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
287
|
-
|
|
267
|
+
// A write to a torn-down stream must not become an unhandled rejection.
|
|
268
|
+
const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }).catch(() => { }));
|
|
288
269
|
stream.onAbort(unsubscribe);
|
|
289
270
|
while (!stream.aborted) {
|
|
290
271
|
await stream.sleep(HEARTBEAT_MS);
|
|
@@ -300,7 +281,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
300
281
|
const send = (e) => stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
|
|
301
282
|
for (const e of hub.replay(id, lastId))
|
|
302
283
|
await send(e);
|
|
303
|
-
|
|
284
|
+
// Same as above: the client may be gone by the time an event fires.
|
|
285
|
+
const unsubscribe = hub.subscribe(id, (e) => void send(e).catch(() => { }));
|
|
304
286
|
stream.onAbort(unsubscribe);
|
|
305
287
|
// Heartbeat keeps proxies from closing the stream; loop ends on abort.
|
|
306
288
|
while (!stream.aborted) {
|
|
@@ -309,58 +291,66 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
309
291
|
}
|
|
310
292
|
});
|
|
311
293
|
});
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
try {
|
|
336
|
-
await secrets.unlock();
|
|
337
|
-
}
|
|
338
|
-
catch (err) {
|
|
339
|
-
return c.json({ error: String(err) }, 500);
|
|
340
|
-
}
|
|
341
|
-
onUnlocked?.();
|
|
342
|
-
return c.json(secretsStatus());
|
|
294
|
+
// Provider credentials, the agent files and the surface prompt are read when
|
|
295
|
+
// a session *opens*: a live one keeps what it opened with, so a Console save
|
|
296
|
+
// would otherwise reach nothing until the idle sweep got around to it half an
|
|
297
|
+
// hour later. Letting the idle sessions go is what `pier reload` does — the
|
|
298
|
+
// next message re-opens them with the configuration just written. Watched
|
|
299
|
+
// included, unlike the background sweep: the session open in the tab that
|
|
300
|
+
// just saved is the likeliest one to need it. A turn in flight is still never
|
|
301
|
+
// interrupted; it picks the change up at its next natural eviction.
|
|
302
|
+
const recycle = (what) => {
|
|
303
|
+
void router.evictIdle(0, Date.now(), { includeWatched: true })
|
|
304
|
+
.then((n) => {
|
|
305
|
+
if (n)
|
|
306
|
+
log.info(`${what} changed — recycled ${n} idle session(s)`);
|
|
307
|
+
})
|
|
308
|
+
.catch((err) => log.error(`recycling sessions after ${what} failed`, err));
|
|
309
|
+
};
|
|
310
|
+
registerInstanceRoutes(app, {
|
|
311
|
+
settings,
|
|
312
|
+
updates,
|
|
313
|
+
updater,
|
|
314
|
+
secrets,
|
|
315
|
+
onUnlocked,
|
|
316
|
+
onSettingsChanged: () => recycle("instance settings"),
|
|
343
317
|
});
|
|
344
|
-
app
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
await secrets.rotateKek(body.mode);
|
|
351
|
-
}
|
|
352
|
-
catch (err) {
|
|
353
|
-
return c.json({ error: String(err) }, 500);
|
|
354
|
-
}
|
|
355
|
-
return c.json(secretsStatus());
|
|
318
|
+
registerProviderRoutes(app, providers, () => recycle("provider configuration"));
|
|
319
|
+
registerFileRoutes(app, {
|
|
320
|
+
factory,
|
|
321
|
+
config,
|
|
322
|
+
nascentCwd: (id) => nascent.get(id)?.cwd,
|
|
323
|
+
onConfigWritten: () => recycle("an agent file"),
|
|
356
324
|
});
|
|
357
|
-
|
|
325
|
+
registerExplorerRoutes(app);
|
|
358
326
|
// serveStatic resolves `root` against the *working directory*, and an
|
|
359
327
|
// installed Pier is started from wherever the operator happens to be. The
|
|
360
328
|
// bundle sits beside this module in both trees — src/web/public when tsx
|
|
361
329
|
// runs the source, dist/web/public in a build — so the path is derived from
|
|
362
330
|
// the module and handed over as the relative form the option wants.
|
|
363
331
|
const bundle = fileURLToPath(new URL("./public", import.meta.url));
|
|
332
|
+
// The tab says which instance this is (`staging - g1 - Pier`): an operator
|
|
333
|
+
// keeps a workbench open per environment and they are otherwise identical,
|
|
334
|
+
// and mistaking the test one for production is the mistake worth a few lines.
|
|
335
|
+
// Both facts are known only at runtime, so they are patched into the shell
|
|
336
|
+
// here rather than built in — and served behind the auth guard, so a stranger
|
|
337
|
+
// at /login learns neither. Read once: neither can change under a process.
|
|
338
|
+
const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
|
|
339
|
+
let shell = null;
|
|
340
|
+
app.get("/", async (c, next) => {
|
|
341
|
+
if (shell === null) {
|
|
342
|
+
try {
|
|
343
|
+
shell = withTabPrefix(await readFile(join(bundle, "index.html"), "utf8"), prefix);
|
|
344
|
+
}
|
|
345
|
+
catch (err) {
|
|
346
|
+
// A workbench that will not load is not worth a nicer tab: hand the
|
|
347
|
+
// request back to the static handler, which answers as it always did.
|
|
348
|
+
log.warn(`shell unreadable, serving it unpatched: ${String(err)}`);
|
|
349
|
+
return next();
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
return c.html(shell);
|
|
353
|
+
});
|
|
364
354
|
app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
|
|
365
355
|
return app;
|
|
366
356
|
}
|