@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.
Files changed (69) hide show
  1. package/README.md +87 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack-tool.js +28 -3
  14. package/dist/channels/slack.js +20 -9
  15. package/dist/channels/telegram-api.js +3 -4
  16. package/dist/channels/telegram.js +37 -28
  17. package/dist/cli.js +177 -29
  18. package/dist/core/hub.js +36 -5
  19. package/dist/core/identity.js +5 -0
  20. package/dist/core/inbound-file.js +70 -0
  21. package/dist/core/inbox.js +32 -0
  22. package/dist/core/queue.js +9 -3
  23. package/dist/core/reply.js +20 -5
  24. package/dist/core/router.js +200 -14
  25. package/dist/core/types.js +53 -0
  26. package/dist/db.js +54 -8
  27. package/dist/drain.js +145 -0
  28. package/dist/main.js +180 -18
  29. package/dist/secrets.js +10 -6
  30. package/dist/service.js +192 -18
  31. package/dist/settings.js +77 -8
  32. package/dist/tasks/agent.js +41 -5
  33. package/dist/tasks/callbacks.js +29 -89
  34. package/dist/tasks/definitions.js +2 -6
  35. package/dist/tasks/execution.js +10 -1
  36. package/dist/tasks/groups.js +20 -49
  37. package/dist/tasks/messages.js +106 -21
  38. package/dist/tasks/outbox.js +157 -0
  39. package/dist/tasks/routes.js +6 -4
  40. package/dist/tasks/service.js +92 -22
  41. package/dist/tasks/store.js +48 -55
  42. package/dist/tasks/tool.js +19 -4
  43. package/dist/tasks/types.js +7 -0
  44. package/dist/update.js +146 -0
  45. package/dist/web/auth.js +89 -26
  46. package/dist/web/explorer.js +147 -0
  47. package/dist/web/files.js +28 -12
  48. package/dist/web/instance.js +165 -0
  49. package/dist/web/provider-flows.js +249 -0
  50. package/dist/web/providers.js +141 -0
  51. package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
  52. package/dist/web/public/assets/index-fASxMPr6.js +90 -0
  53. package/dist/web/public/icon-192.png +0 -0
  54. package/dist/web/public/icon-32.png +0 -0
  55. package/dist/web/public/icon-512.png +0 -0
  56. package/dist/web/public/icon-maskable-512.png +0 -0
  57. package/dist/web/public/icon-touch-192.png +0 -0
  58. package/dist/web/public/icon.svg +29 -11
  59. package/dist/web/public/index.html +50 -32
  60. package/dist/web/server.js +110 -120
  61. package/docs/deploy.md +142 -64
  62. package/package.json +1 -1
  63. package/skills/pier-boards/SKILL.md +16 -7
  64. package/skills/pier-help/SKILL.md +110 -0
  65. package/skills/pier-slack/SKILL.md +20 -3
  66. package/skills/pier-tasks/SKILL.md +19 -12
  67. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  68. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  69. package/dist/web/public/sw.js +0 -21
Binary file
Binary file
Binary file
Binary file
@@ -1,19 +1,37 @@
1
- <!-- Pier's mark: a dock in profile over water. Kept to four solid shapes so it
2
- still reads at 16px in a browser tab. Source of truth for the PNG icons
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
- <g fill="#fff">
12
- <rect x="11" y="23" width="42" height="6.5" rx="1.5" />
13
- <rect x="15" y="29.5" width="5.5" height="16" rx="1" />
14
- <rect x="29.25" y="29.5" width="5.5" height="16" rx="1" />
15
- <rect x="43.5" y="29.5" width="5.5" height="16" rx="1" />
16
- </g>
17
- <path d="M11 51q7-4 14 0t14 0 14 0" fill="none" stroke="#fff" stroke-width="4"
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, this is also what makes Web Push
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-DAgP1Gq8.js"></script>
21
- <link rel="stylesheet" crossorigin href="/assets/index-8CinH1uR.css">
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 justify-between gap-2 px-3 py-2">
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="block w-full cursor-pointer py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">Activity</button></li>
42
- <li><button type="button" id="open-config"
43
- class="block w-full cursor-pointer py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">Configuration</button></li>
44
- <li><button type="button" id="open-channels"
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="block w-full cursor-pointer py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">Boards</button></li>
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="block w-full cursor-pointer py-1.5 pl-6 pr-3 text-left hover:bg-neutral-100">Settings</button></li>
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" title="Search all sessions"
55
- class="icon-btn ml-auto" aria-label="Search all sessions">
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: a resident token chip; model + reasoning
106
- unfold to its left on hover. Costs the header no extra height. -->
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 image (or paste / drop)"
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" accept="image/*" multiple class="hidden" />
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
- <dialog id="archive-dialog" class="m-auto w-[35rem] max-w-[90vw] rounded-xl border border-neutral-200 p-0 shadow-xl backdrop:bg-black/20">
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 all sessions…"
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>
@@ -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 { relative } from "node:path";
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 { normalizePublicUrl } from "../settings.js";
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, "&amp;").replace(/</g, "&lt;")} - Pier</title>`)
29
+ : html;
14
30
  const HEARTBEAT_MS = 15_000;
15
- /** Client reports per minute, for the whole server: a browser bug can fire in
16
- * a loop, and the journal is shared with everything else Pier says. */
17
- const CLIENT_LOG_PER_MINUTE = 60;
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
- // Transcript images by their history ordinal: the snapshot ships refs, the
127
- // browser pulls (and caches) the bytes only for what it renders.
128
- guarded(app, "GET", "/api/sessions/:id/images/:ordinal", 404, async (c) => {
129
- const ordinal = Number(c.req.param("ordinal"));
130
- if (!Number.isInteger(ordinal) || ordinal < 0)
131
- return c.json({ error: "bad ordinal" }, 400);
132
- const session = await ensure(c.req.param("id"));
133
- const image = await session.image(ordinal);
134
- if (!image)
135
- return c.json({ error: "no such image" }, 404);
136
- return c.body(Buffer.from(image.data, "base64"), 200, {
137
- "content-type": image.mimeType,
138
- "cache-control": "private, max-age=3600",
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
- const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }));
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
- const unsubscribe = hub.subscribe(id, (e) => void send(e));
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
- // Instance settings. The password lives behind its own route (web/auth.ts):
313
- // it is a credential, and changing it takes the old one.
314
- app.get("/api/settings", (c) => c.json(settings.get()));
315
- app.put("/api/settings", async (c) => {
316
- const body = await c.req.json().catch(() => null);
317
- if (typeof body?.publicUrl !== "string")
318
- return c.json({ error: "publicUrl required" }, 400);
319
- const publicUrl = normalizePublicUrl(body.publicUrl);
320
- if (publicUrl === null) {
321
- return c.json({ error: "not a URL: expected http(s)://host, no query or fragment" }, 400);
322
- }
323
- return c.json(settings.setPublicUrl(publicUrl));
324
- });
325
- // Layer-1 key status and control (Console → Settings → Security). The GET
326
- // is what a locked instance shows; unlock is how it recovers without a
327
- // restart, and rotate is the only way to change how the KEK is protected.
328
- const secretsStatus = () => ({
329
- state: secrets.state,
330
- mode: secrets.mode ?? null,
331
- ...(secrets.state === "locked" ? { reason: secrets.lockedReason } : {}),
332
- });
333
- app.get("/api/secrets", (c) => c.json(secretsStatus()));
334
- app.post("/api/secrets/unlock", async (c) => {
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.post("/api/secrets/rotate", async (c) => {
345
- const body = (await c.req.json().catch(() => ({})));
346
- if (body.mode !== undefined && body.mode !== "vt" && body.mode !== "file") {
347
- return c.json({ error: "mode must be vt or file" }, 400);
348
- }
349
- try {
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
- registerFileRoutes(app, { factory, config, nascentCwd: (id) => nascent.get(id)?.cwd });
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
  }