@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.
- package/README.md +18 -7
- package/dist/channels/slack-tool.js +28 -3
- package/dist/core/router.js +18 -4
- package/dist/main.js +98 -4
- package/dist/service.js +50 -0
- package/dist/settings.js +9 -1
- package/dist/tasks/service.js +13 -0
- package/dist/update.js +59 -7
- package/dist/web/auth.js +14 -4
- package/dist/web/explorer.js +12 -11
- package/dist/web/files.js +2 -1
- package/dist/web/instance.js +73 -7
- package/dist/web/providers.js +16 -4
- package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
- package/dist/web/public/assets/index-fASxMPr6.js +90 -0
- package/dist/web/public/index.html +10 -7
- package/dist/web/server.js +69 -6
- package/docs/deploy.md +23 -1
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +16 -7
- package/skills/pier-slack/SKILL.md +17 -1
- package/dist/web/public/assets/index-BK64pHmP.js +0 -90
- package/dist/web/public/assets/index-De4GlOq4.css +0 -2
|
@@ -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-
|
|
20
|
-
<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">
|
|
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"
|
|
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"
|
|
81
|
-
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">
|
|
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
|
-
|
|
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
|
|
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>
|
package/dist/web/server.js
CHANGED
|
@@ -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 {
|
|
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, "&").replace(/</g, "<")} - 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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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.
|
|
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
|
@@ -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
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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 | `
|
|
52
|
-
| a **public** board | `
|
|
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**`,
|