@timqi/pier 0.0.2 → 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.
@@ -16,8 +16,8 @@
16
16
  <meta name="apple-mobile-web-app-capable" content="yes" />
17
17
  <meta name="apple-mobile-web-app-title" content="Pier" />
18
18
  <title>Pier</title>
19
- <script type="module" crossorigin src="/assets/index-BK64pHmP.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-De4GlOq4.css">
19
+ <script type="module" crossorigin src="/assets/index-fASxMPr6.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-cCIuQnDr.css">
21
21
  </head>
22
22
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
23
23
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
@@ -31,7 +31,7 @@
31
31
  moves it between the rail's top-right (open) and main's top-left
32
32
  (closed), flipping the chevron, so expand and collapse are the same
33
33
  control in the same band. -->
34
- <button type="button" id="rail-toggle" title="Toggle sidebar" aria-label="Toggle sidebar"
34
+ <button type="button" id="rail-toggle" aria-label="Toggle sidebar"
35
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
36
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5">
37
37
  <path d="M9.5 4 5.5 8l4 4" stroke-linecap="round" stroke-linejoin="round" />
@@ -77,8 +77,8 @@
77
77
  </details>
78
78
  <div class="flex items-center gap-1 px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wide text-neutral-500">
79
79
  Projects
80
- <button type="button" id="open-archive" title="Search all sessions"
81
- 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">
82
82
  <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" class="h-3.5 w-3.5">
83
83
  <circle cx="7" cy="7" r="4.25" /><path d="M10.2 10.2 14 14" stroke-linecap="round" />
84
84
  </svg>
@@ -217,9 +217,12 @@
217
217
  </form>
218
218
  </dialog>
219
219
 
220
- <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">
221
224
  <div class="flex items-center gap-2 border-b border-neutral-200 px-3 py-2">
222
- <input id="archive-search" type="text" placeholder="Search all sessions…"
225
+ <input id="archive-search" type="text" placeholder="Search sessions and Console…"
223
226
  class="w-full bg-transparent text-[14px] focus:outline-none" />
224
227
  <span id="archive-count" class="flex-none rounded bg-neutral-100 px-1.5 text-[11px] text-neutral-500"></span>
225
228
  <button type="button" id="archive-close" class="btn flex-none text-[12px]">Close</button>
@@ -1,11 +1,14 @@
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";
11
+ import { logger } from "../log.js";
9
12
  import { Router } from "../core/router.js";
10
13
  import { registerExplorerRoutes } from "./explorer.js";
11
14
  import { guarded, registerFileRoutes } from "./files.js";
@@ -14,10 +17,20 @@ import { saveInbound } from "../core/inbox.js";
14
17
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
15
18
  import { registerInstanceRoutes } from "./instance.js";
16
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;
17
30
  const HEARTBEAT_MS = 15_000;
18
31
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
19
32
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
20
- export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, updates, backgroundRuns, }) {
33
+ export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, updates, updater, backgroundRuns, }) {
21
34
  const app = new Hono();
22
35
  // A finished turn marks its session unread until some client reports it was
23
36
  // seen (session selected + tab visible → POST read below). Server-side so
@@ -278,16 +291,66 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
278
291
  }
279
292
  });
280
293
  });
281
- registerInstanceRoutes(app, { settings, updates, secrets, onUnlocked });
282
- registerProviderRoutes(app, providers);
283
- registerFileRoutes(app, { factory, config, nascentCwd: (id) => nascent.get(id)?.cwd });
284
- registerExplorerRoutes(app, { factory, nascentCwds: () => [...nascent.values()].map((n) => n.cwd) });
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"),
317
+ });
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"),
324
+ });
325
+ registerExplorerRoutes(app);
285
326
  // serveStatic resolves `root` against the *working directory*, and an
286
327
  // installed Pier is started from wherever the operator happens to be. The
287
328
  // bundle sits beside this module in both trees — src/web/public when tsx
288
329
  // runs the source, dist/web/public in a build — so the path is derived from
289
330
  // the module and handed over as the relative form the option wants.
290
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
+ });
291
354
  app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
292
355
  return app;
293
356
  }
package/docs/deploy.md CHANGED
@@ -270,12 +270,34 @@ systemctl --user start pier
270
270
  ```
271
271
 
272
272
  For a service install, `pier update` stops Pier first; it does not use the
273
- graceful `pier restart` path. It snapshots the database to
273
+ graceful `pier restart` path. The Console's **Update now** and the automatic
274
+ path do: both drain (new work refused, running turns finished, the rest
275
+ ledgered for the next boot to report) before the updater unit is started.
276
+
277
+ Either way the updater snapshots the database to
274
278
  `~/.pier/db/pier.db.release.bak` before npm touches the package. This happens for
275
279
  every release, including releases with no schema change. If installation or
276
280
  backup fails, the updater unit still tries to start the previously installed
277
281
  service and reports the failure in its journal.
278
282
 
283
+ ### Automatic updates
284
+
285
+ Off by default. Switched on from the version panel, it checks every 15 minutes
286
+ and hands over only when all three hold: the switch is on, a newer release
287
+ exists, and the instance is idle (nothing streaming, no task run in flight).
288
+ systemd only — without the unit there is nothing to hand the install to.
289
+
290
+ The unit records **absolute** paths to the node and npm that installed Pier,
291
+ because systemd's PATH has neither. That pins it to one directory of one version
292
+ manager: with fnm or nvm, `fnm uninstall v24` deletes the Node that `ExecStart`
293
+ names while the running process survives (Linux keeps a deleted binary mapped).
294
+ Pier checks those paths at boot and before every handover, and reports it in the
295
+ journal and in the version panel rather than letting the next restart fail:
296
+
297
+ ```
298
+ pier service install --force # re-records the current node and npm
299
+ ```
300
+
279
301
  A newer Pier brings its own schema up on the next start: the migrations run in
280
302
  one transaction before the port opens, and the version they leave behind is
281
303
  stamped in the database. It also snapshots the immediately preceding schema to
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.2",
3
+ "version": "0.0.3",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",
@@ -38,18 +38,23 @@ Those four fields are the whole manifest.
38
38
 
39
39
  `"public": true` serves the board at `/p/<slug>/` **with no password**. Set it
40
40
  only if the user asked for a public or shareable board *in this request*;
41
- otherwise leave it `false`, say the board is private, and mention that Console →
42
- Boards flips it. Never publish credentials, internal paths, personal data or
43
- anything the user has not seen.
41
+ otherwise leave it `false` and say the board is private. Never publish personal
42
+ data or anything the user has not seen.
44
43
 
45
- The message announcing the board carries **one clickable link** a bare path is
46
- not something a person can open, and a board nobody reached was not delivered.
44
+ Asked to make an existing board public? Flip `"public"` to `true` in
45
+ `board.json` and reply with the `/p/<slug>/` link that is the whole answer.
46
+ No verification step, no narrating the edit, no restating what the page holds.
47
+
48
+ The message announcing the board carries **one bare URL** — paste the address
49
+ itself, never `[title](url)`: link labels get mangled or truncated on some chat
50
+ surfaces, and the title is already on the page. No filesystem paths either —
51
+ `…/boards/<slug>/board.json` means nothing to the reader.
47
52
  `<pier>/AGENTS.md` gives you the address, so there is nothing to look up:
48
53
 
49
54
  | The user asked for | Send |
50
55
  | --- | --- |
51
- | a board, nothing about sharing | `[Weekly digest](https://pier.example.com/boards/weekly-digest/)` — behind the Pier password |
52
- | a **public** board | `[Weekly digest](https://pier.example.com/p/weekly-digest/)` — no password |
56
+ | a board, nothing about sharing | `https://pier.example.com/boards/weekly-digest/` — behind the Pier password; Console → Boards makes it public |
57
+ | a **public** board | `https://pier.example.com/p/weekly-digest/` — no password |
53
58
 
54
59
  Never both: the pair invites pasting the password-free URL of a board that was
55
60
  never meant to leave the workspace, and `/p/<slug>/` 404s unless the manifest
@@ -180,6 +185,10 @@ what makes it read as signal.
180
185
 
181
186
  ## Rules
182
187
 
188
+ - **No secrets, ever.** Tokens, API keys, credentials, internal hostnames and
189
+ private paths never go into a board — not in the page, not in a `<details>`
190
+ fold, not in a code sample. A private board is one Console toggle from
191
+ public, so write every page as if it already were.
183
192
  - **Static and self-contained.** Everything the page needs lives under `site/`
184
193
  with relative paths. No CDN, no external fonts, no analytics, no `fetch()` —
185
194
  a published board is served under a CSP that blocks all of it, so an external
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pier-slack
3
- description: Read and write Slack through Pier's slack tool — a channel's history for a time range, one thread, posting into a thread, and the Slack-specific syntax for mentions and links. Read before answering questions about Slack conversations or posting anything to a workspace.
3
+ description: Read and write Slack through Pier's slack tool — a channel's history for a time range, one thread, posting into a thread, deleting a message Pier posted, and the Slack-specific syntax for mentions and links. Read before answering questions about Slack conversations or posting anything to a workspace.
4
4
  ---
5
5
 
6
6
  # Reading and writing Slack
@@ -97,6 +97,22 @@ Going top-level takes the explicit `"none"`: a channel's main flow is wider
97
97
  than a thread. A `thread_ts` is never inherited across a change of `channel`.
98
98
  The response carries `ts` and `threadTs` for replying under what you posted.
99
99
 
100
+ ## Deleting
101
+
102
+ ```json
103
+ {"operation":"delete","channel":"#ops","ts":"1717243800.000100"}
104
+ ```
105
+
106
+ Slack only lets Pier delete what **its own bot** posted — a person's message
107
+ answers `cant_delete_message`, and the answer is to ask them, not to retry.
108
+
109
+ - `ts` is always explicit. There is no "delete the last one" and no default
110
+ from the thread you are in: the wrong `ts` cannot be undone.
111
+ - A `ts` means nothing outside the conversation it came from — pass the
112
+ `channel` it was read in.
113
+ - Deleting a thread parent does not remove its replies. Say what you removed;
114
+ a message vanishing with no word looks like a Slack bug to everyone watching.
115
+
100
116
  ## Message syntax
101
117
 
102
118
  `text` is **standard markdown** and Slack renders it natively: `**bold**`,