@timqi/pier 0.0.1
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/LICENSE +661 -0
- package/README.md +97 -0
- package/dist/agent/config.js +133 -0
- package/dist/agent/credentials.js +179 -0
- package/dist/agent/events.js +253 -0
- package/dist/agent/models.js +15 -0
- package/dist/agent/pi.js +296 -0
- package/dist/boards/boards.js +200 -0
- package/dist/boards/pier.css +445 -0
- package/dist/channels/chains.js +67 -0
- package/dist/channels/chunk.js +28 -0
- package/dist/channels/commands.js +28 -0
- package/dist/channels/config.js +172 -0
- package/dist/channels/control.js +71 -0
- package/dist/channels/conversations.js +65 -0
- package/dist/channels/gatekeeper.js +63 -0
- package/dist/channels/panel.js +233 -0
- package/dist/channels/receipts.js +104 -0
- package/dist/channels/routes.js +110 -0
- package/dist/channels/runtime.js +76 -0
- package/dist/channels/slack-api.js +296 -0
- package/dist/channels/slack-directory.js +77 -0
- package/dist/channels/slack-outbound.js +121 -0
- package/dist/channels/slack-panel.js +122 -0
- package/dist/channels/slack-render.js +214 -0
- package/dist/channels/slack-tool.js +334 -0
- package/dist/channels/slack.js +510 -0
- package/dist/channels/telegram-api.js +78 -0
- package/dist/channels/telegram-panel.js +113 -0
- package/dist/channels/telegram-render.js +96 -0
- package/dist/channels/telegram.js +473 -0
- package/dist/channels/types.js +27 -0
- package/dist/cli.js +101 -0
- package/dist/core/hub.js +53 -0
- package/dist/core/identity.js +66 -0
- package/dist/core/queue.js +11 -0
- package/dist/core/reply.js +202 -0
- package/dist/core/router.js +189 -0
- package/dist/core/types.js +7 -0
- package/dist/db.js +268 -0
- package/dist/log.js +55 -0
- package/dist/main.js +183 -0
- package/dist/paths.js +17 -0
- package/dist/secrets.js +191 -0
- package/dist/service.js +134 -0
- package/dist/settings.js +57 -0
- package/dist/tasks/agent.js +197 -0
- package/dist/tasks/callbacks.js +140 -0
- package/dist/tasks/command.js +74 -0
- package/dist/tasks/definitions.js +316 -0
- package/dist/tasks/execution.js +141 -0
- package/dist/tasks/groups.js +187 -0
- package/dist/tasks/messages.js +248 -0
- package/dist/tasks/routes.js +219 -0
- package/dist/tasks/runs.js +104 -0
- package/dist/tasks/service.js +282 -0
- package/dist/tasks/store.js +168 -0
- package/dist/tasks/tool.js +281 -0
- package/dist/tasks/types.js +5 -0
- package/dist/web/auth.js +280 -0
- package/dist/web/files.js +167 -0
- package/dist/web/public/assets/index-8CinH1uR.css +2 -0
- package/dist/web/public/assets/index-DAgP1Gq8.js +78 -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 +19 -0
- package/dist/web/public/index.html +251 -0
- package/dist/web/public/manifest.webmanifest +16 -0
- package/dist/web/public/sw.js +21 -0
- package/dist/web/server.js +366 -0
- package/dist/web/session-state.js +39 -0
- package/docs/deploy.md +307 -0
- package/package.json +55 -0
- package/skills/pier-boards/SKILL.md +210 -0
- package/skills/pier-slack/SKILL.md +135 -0
- package/skills/pier-tasks/SKILL.md +120 -0
package/docs/deploy.md
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
# Running Pier as a service (Linux, systemd)
|
|
2
|
+
|
|
3
|
+
Pier is one Node process that binds the loopback. Running it in a terminal is
|
|
4
|
+
a complete installation — nothing below is required to *use* Pier. What systemd
|
|
5
|
+
adds is the thing a terminal cannot: scheduled tasks fire and IM channels stay
|
|
6
|
+
connected while nobody is logged in.
|
|
7
|
+
|
|
8
|
+
That is also why this document is Linux-only. A laptop closes its lid; the
|
|
9
|
+
machine you want always-on is a server, and on that machine systemd is already
|
|
10
|
+
there.
|
|
11
|
+
|
|
12
|
+
## The short version
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install -g @timqi/pier
|
|
16
|
+
pier service install
|
|
17
|
+
journalctl --user -u pier -e # the password, printed once
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That writes the unit below, enables linger, and starts the service. The rest of
|
|
21
|
+
this page is what it wrote and why — read it before widening the bind, and when
|
|
22
|
+
you want the unit to say something different.
|
|
23
|
+
|
|
24
|
+
## Prerequisites
|
|
25
|
+
|
|
26
|
+
- Node 24 or newer (`node:sqlite` is used unflagged).
|
|
27
|
+
- The `sqlite3` CLI, for the off-machine backup and password steps below. Pier
|
|
28
|
+
itself does not need it.
|
|
29
|
+
- A checkout, built once:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
git clone git@github.com:timqi/pier.git ~/pier
|
|
33
|
+
cd ~/pier && npm ci && npm run build
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The unit below runs the build output, so a deploy is always "update the
|
|
37
|
+
checkout, rebuild, restart" — never "run from source".
|
|
38
|
+
|
|
39
|
+
## The unit
|
|
40
|
+
|
|
41
|
+
A **user** unit, not a system one: Pier runs as you, reads your Pi
|
|
42
|
+
configuration, and drives sessions in your own directories. Running it as root
|
|
43
|
+
or as a dedicated system user means an agent that cannot touch the files you
|
|
44
|
+
wanted it to work on.
|
|
45
|
+
|
|
46
|
+
`~/.config/systemd/user/pier.service` — what `pier service install` generates,
|
|
47
|
+
with your node and your entry point filled in:
|
|
48
|
+
|
|
49
|
+
```ini
|
|
50
|
+
[Unit]
|
|
51
|
+
Description=Pier — agent workspace
|
|
52
|
+
Documentation=https://github.com/timqi/pier
|
|
53
|
+
After=network-online.target
|
|
54
|
+
Wants=network-online.target
|
|
55
|
+
|
|
56
|
+
[Service]
|
|
57
|
+
Type=simple
|
|
58
|
+
WorkingDirectory=%h/pier
|
|
59
|
+
# An absolute path on purpose: systemd starts with a minimal PATH, so a node
|
|
60
|
+
# installed by nvm/fnm/asdf is not on it. `command -v node` gives you this.
|
|
61
|
+
ExecStart=/usr/bin/node dist/main.js
|
|
62
|
+
Environment=NODE_ENV=production
|
|
63
|
+
# Loopback by default. Put a reverse proxy in front before widening this —
|
|
64
|
+
# whoever reaches this port can drive an agent that runs a shell.
|
|
65
|
+
Environment=HOST=127.0.0.1
|
|
66
|
+
Environment=PORT=3141
|
|
67
|
+
# Where the database, the boards and the generated password hash live.
|
|
68
|
+
Environment=PIER_HOME=%h/.pier
|
|
69
|
+
Restart=always
|
|
70
|
+
RestartSec=2
|
|
71
|
+
# The journal is where the first-run password is printed, so keep it readable.
|
|
72
|
+
StandardOutput=journal
|
|
73
|
+
StandardError=journal
|
|
74
|
+
# Otherwise every line is tagged "node"; this makes `journalctl -t pier` work.
|
|
75
|
+
SyslogIdentifier=pier
|
|
76
|
+
|
|
77
|
+
[Install]
|
|
78
|
+
WantedBy=default.target
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Enable it, and tell logind to keep your user manager alive after you log out —
|
|
82
|
+
without lingering, every scheduled task stops when your SSH session ends:
|
|
83
|
+
|
|
84
|
+
```sh
|
|
85
|
+
loginctl enable-linger "$USER"
|
|
86
|
+
systemctl --user daemon-reload
|
|
87
|
+
systemctl --user enable --now pier
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Memory limits
|
|
91
|
+
|
|
92
|
+
Worth setting: an agent runs whatever a turn decided to run, so the failure to
|
|
93
|
+
plan for is a build or a test suite eating the machine, not Pier itself
|
|
94
|
+
leaking. A cgroup limit turns "the box OOMs and sshd dies with it" into "the
|
|
95
|
+
heaviest process inside this unit is killed".
|
|
96
|
+
|
|
97
|
+
User units can do this without root as long as the memory controller is
|
|
98
|
+
delegated to your user manager, which it is by default on current systemd:
|
|
99
|
+
|
|
100
|
+
```sh
|
|
101
|
+
systemctl show "user@$(id -u).service" -p DelegateControllers
|
|
102
|
+
# DelegateControllers=cpu memory pids ← memory listed means these work
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`~/.config/systemd/user/pier.service.d/limits.conf` — a drop-in, so the unit
|
|
106
|
+
above stays about what Pier is and this file is about what it may consume:
|
|
107
|
+
|
|
108
|
+
The limit is on the **whole unit**: `node`, every Pi subagent, and every
|
|
109
|
+
command a turn ran, added together — plus the page cache those processes
|
|
110
|
+
touched, which is why the soft ceiling should be the one that bites. So size it
|
|
111
|
+
as a share of the machine, not as "how much should Pier need". Percentages are
|
|
112
|
+
relative to installed physical memory, which also keeps this file portable:
|
|
113
|
+
|
|
114
|
+
```ini
|
|
115
|
+
[Service]
|
|
116
|
+
# Soft ceiling: past this the kernel reclaims hard and lets the unit crawl
|
|
117
|
+
# instead of killing anything. This is the one that should bite first.
|
|
118
|
+
MemoryHigh=60%
|
|
119
|
+
# Hard ceiling: the kernel OOM-kills *inside this cgroup*. The number exists to
|
|
120
|
+
# protect everything outside it — the OS, sshd, your other services — so what
|
|
121
|
+
# it should leave behind is a few GB for them, not a small share for Pier.
|
|
122
|
+
MemoryMax=75%
|
|
123
|
+
# Swapping an agent is worse than failing it — the machine stops responding
|
|
124
|
+
# long before the limit is reached.
|
|
125
|
+
MemorySwapMax=0
|
|
126
|
+
# A runaway command an agent ran can fork as well as allocate.
|
|
127
|
+
TasksMax=512
|
|
128
|
+
# The unit's own processes are the preferred victims if the *machine* still
|
|
129
|
+
# runs out, e.g. before these limits are tuned. Works with no cgroup limit at
|
|
130
|
+
# all, which makes it the cheapest half of this file.
|
|
131
|
+
OOMScoreAdjust=200
|
|
132
|
+
# A child being OOM-killed must not take the service with it: the turn that
|
|
133
|
+
# ran it fails, Pier keeps serving. (Delegated units default to this; set
|
|
134
|
+
# explicitly because the default depends on system configuration.)
|
|
135
|
+
OOMPolicy=continue
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
On a dedicated 4–8 GB VPS those percentages land around 2.5–6 GB, which is
|
|
139
|
+
roughly what one agent doing ordinary work needs. On a big shared box, prefer
|
|
140
|
+
absolute values sized to what you are willing to lose to a runaway build —
|
|
141
|
+
`MemoryHigh=8G` / `MemoryMax=12G` on 32 GB, say — because 75% of a large
|
|
142
|
+
machine is no longer a meaningful ceiling.
|
|
143
|
+
|
|
144
|
+
Pick the numbers by measuring, not by guessing — Pier idles in the hundreds of
|
|
145
|
+
MB, and what varies is what the agent runs:
|
|
146
|
+
|
|
147
|
+
```sh
|
|
148
|
+
systemctl --user show pier -p MemoryCurrent -p MemoryPeak
|
|
149
|
+
systemd-cgtop --depth=3 "user.slice/user-$(id -u).slice"
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
A limit that fires during ordinary work is worse than none: the turn dies with
|
|
153
|
+
a signal and the cause is a kernel message nobody reads. Set it to protect the
|
|
154
|
+
machine, then raise it the first time it kills something legitimate.
|
|
155
|
+
|
|
156
|
+
## Logs
|
|
157
|
+
|
|
158
|
+
Pier writes to stdout and stderr and to nothing else — no log file, no
|
|
159
|
+
rotation, no log configuration. Under the unit above that *is* the log:
|
|
160
|
+
journald stamps the time, keeps the history and rotates it, which is why
|
|
161
|
+
nothing in Pier reimplements any of that.
|
|
162
|
+
|
|
163
|
+
```sh
|
|
164
|
+
journalctl --user -u pier -f # follow
|
|
165
|
+
journalctl --user -u pier -p warning # only what went wrong
|
|
166
|
+
journalctl --user -u pier --since -1h | grep 'tasks:' # one area
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Every line is `area: message` — `core`, `agent`, `tasks`, `slack`, `telegram`,
|
|
170
|
+
`channels`, `web`, `auth`, `boards`, `client`, `pier` — so an area is a grep
|
|
171
|
+
and a level is a `-p`. The level reaches journald as a syslog priority prefix, which Pier
|
|
172
|
+
emits only when systemd says the output is a journal (`$JOURNAL_STREAM`); run
|
|
173
|
+
in a terminal, the same lines carry a timestamp and a level word instead.
|
|
174
|
+
|
|
175
|
+
What is logged by default: process start and shutdown, sessions opening,
|
|
176
|
+
conversation → session routing, every turn ending, every task run queued and
|
|
177
|
+
settled, callback and message delivery failures with their retries, adapters
|
|
178
|
+
starting (and failing to start or stop), dropped inbound messages, failed
|
|
179
|
+
logins, and any request that threw. A watch probe that matched nothing is the
|
|
180
|
+
one routine event kept at `debug` — it fires on every interval.
|
|
181
|
+
|
|
182
|
+
`client:` is the browser's half, posted back by the workbench (`ui/report.ts`)
|
|
183
|
+
and written into this same stream: script errors, unhandled rejections and a
|
|
184
|
+
dead SSE stream, each with the view and the user agent. It comes from signed-in
|
|
185
|
+
tabs only — the route sits behind the password like the rest of `/api`, so
|
|
186
|
+
nobody who cannot already reach Pier can write into this journal. The person sees the
|
|
187
|
+
same sentence in the chat pane, so "I clicked and nothing happened" has a line
|
|
188
|
+
on both ends:
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
journalctl --user -u pier | grep 'client:'
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
```sh
|
|
195
|
+
systemctl --user set-environment PIER_LOG=debug # + per-message tracing
|
|
196
|
+
systemctl --user restart pier
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
`PIER_LOG` takes `debug`, `info` (default), `warn`, `error` or `silent`. Prefer
|
|
200
|
+
turning `debug` on for a session and back off: it logs one line per inbound
|
|
201
|
+
message and per tool call.
|
|
202
|
+
|
|
203
|
+
## First login
|
|
204
|
+
|
|
205
|
+
Pier generates a password on an empty database and prints it once, so the
|
|
206
|
+
journal is where you read it:
|
|
207
|
+
|
|
208
|
+
```sh
|
|
209
|
+
journalctl --user -u pier | grep -A2 'no password'
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Only its scrypt hash is stored. If you lose it, drop the row and restart — a
|
|
213
|
+
new password is generated and printed:
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
|
|
217
|
+
systemctl --user restart pier
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
Changing the password invalidates every session cookie: the cookies are signed
|
|
221
|
+
with the stored hash.
|
|
222
|
+
|
|
223
|
+
## Updating
|
|
224
|
+
|
|
225
|
+
Manually, which is also exactly what an automated update has to do:
|
|
226
|
+
|
|
227
|
+
```sh
|
|
228
|
+
cd ~/pier
|
|
229
|
+
git fetch --tags && git checkout v0.2.0 # a tag, not a branch
|
|
230
|
+
npm ci && npm run build
|
|
231
|
+
systemctl --user restart pier
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
A newer Pier brings its own schema up on the next start: the migrations run in
|
|
235
|
+
one transaction before the port opens, and the version they leave behind is
|
|
236
|
+
stamped in the database. Before touching an existing database it snapshots it
|
|
237
|
+
to `~/.pier/db/pier.db.v<N>.bak` (`N` = the schema it was at), so the step you
|
|
238
|
+
cannot forget is the one you never take. **Upgrades only.** Start an older Pier
|
|
239
|
+
on a database a newer one has migrated and it refuses to run rather than write
|
|
240
|
+
tables it does not understand — the way back down is that `.bak`:
|
|
241
|
+
|
|
242
|
+
```sh
|
|
243
|
+
systemctl --user stop pier
|
|
244
|
+
cd ~/.pier/db && rm -f pier.db pier.db-wal pier.db-shm && cp pier.db.v1.bak pier.db
|
|
245
|
+
# then check out the Pier that speaks schema 1 again
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
The three newest snapshots are kept and older ones removed as later upgrades
|
|
249
|
+
supersede them; each is a full copy of the database, so size grows with the
|
|
250
|
+
database, not with the number of releases. They sit next to the database and
|
|
251
|
+
therefore protect against a bad upgrade, not against a lost disk — an
|
|
252
|
+
off-machine copy is still yours to take.
|
|
253
|
+
|
|
254
|
+
### Can it update itself?
|
|
255
|
+
|
|
256
|
+
Yes, with one caveat that decides the shape: **the updater must not be a child
|
|
257
|
+
of the service it restarts.** `systemctl --user restart pier` kills everything
|
|
258
|
+
in `pier.service`'s cgroup, so an update script spawned by Pier dies halfway
|
|
259
|
+
through — sometimes after unpacking and before restarting, which is the one
|
|
260
|
+
outcome worse than not updating.
|
|
261
|
+
|
|
262
|
+
So the update runs as its own unit. `~/.config/systemd/user/pier-update.service`:
|
|
263
|
+
|
|
264
|
+
```ini
|
|
265
|
+
[Unit]
|
|
266
|
+
Description=Update Pier to the latest tag
|
|
267
|
+
|
|
268
|
+
[Service]
|
|
269
|
+
Type=oneshot
|
|
270
|
+
WorkingDirectory=%h/pier
|
|
271
|
+
ExecStart=/bin/sh -lc 'git fetch --tags && git checkout "$(git describe --tags --abbrev=0 origin/main)" && npm ci && npm run build'
|
|
272
|
+
ExecStartPost=/bin/sh -lc 'systemctl --user restart pier'
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
Pier can then trigger an update with a single call that survives its own
|
|
276
|
+
restart, because the work happens in a different cgroup:
|
|
277
|
+
|
|
278
|
+
```sh
|
|
279
|
+
systemctl --user start pier-update.service
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
Deliberately **not** a `systemd.timer`. An unattended update is a machine that
|
|
283
|
+
rewrites its own code from the network while holding your API keys, and it
|
|
284
|
+
interrupts whatever session was mid-turn to do it. The intended shape is that
|
|
285
|
+
Pier notices a newer release and says so (not built yet), while starting the
|
|
286
|
+
update stays a decision someone makes. `Restart=always` above is what makes
|
|
287
|
+
that decision cheap — the service comes back on its own.
|
|
288
|
+
|
|
289
|
+
## Remote access
|
|
290
|
+
|
|
291
|
+
The unit binds the loopback, and that is the intended posture. To reach it from
|
|
292
|
+
elsewhere, pick a tunnel rather than a wider bind:
|
|
293
|
+
|
|
294
|
+
- `ssh -L 3141:localhost:3141 server` — nothing to configure, nothing exposed.
|
|
295
|
+
- Tailscale, or Cloudflare Tunnel — no open port, and TLS terminates outside.
|
|
296
|
+
- A reverse proxy (Caddy, nginx) if you want a real hostname. Terminate TLS
|
|
297
|
+
there and pass `X-Forwarded-For`; Pier's login throttle counts per forwarded
|
|
298
|
+
client, and its session cookie is marked `Secure` when the proxy reports
|
|
299
|
+
`X-Forwarded-Proto: https`.
|
|
300
|
+
|
|
301
|
+
## Backups
|
|
302
|
+
|
|
303
|
+
Two paths hold everything: `~/.pier/db/pier.db` (tasks, channels, the chat →
|
|
304
|
+
session map, workbench state, settings, the password hash) and
|
|
305
|
+
`~/.pier/boards/`. Copy the database with `sqlite3 ... "VACUUM INTO '…'"`
|
|
306
|
+
rather than `cp`, which under WAL can miss the most recent commits. Pi's own session history lives under its
|
|
307
|
+
config directory (`PI_CODING_AGENT_DIR`, `~/.pi/agent` by default).
|
package/package.json
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@timqi/pier",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": "github:timqi/pier",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"pier": "dist/cli.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"!dist/**/*.test.js",
|
|
17
|
+
"skills",
|
|
18
|
+
"docs/deploy.md"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public",
|
|
22
|
+
"provenance": true
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "npm run build:web && tsx watch src/main.ts",
|
|
26
|
+
"dev:web": "vite",
|
|
27
|
+
"build": "tsc && npm run build:web && npm run build:assets",
|
|
28
|
+
"build:assets": "mkdir -p dist/boards dist/web && cp src/boards/pier.css dist/boards/ && rm -rf dist/web/public && cp -r src/web/public dist/web/public",
|
|
29
|
+
"prepack": "npm run build",
|
|
30
|
+
"build:web": "vite build",
|
|
31
|
+
"check": "tsc --noEmit && tsc -p tsconfig.web.json --noEmit",
|
|
32
|
+
"lint": "oxlint src",
|
|
33
|
+
"test": "vitest run --passWithNoTests"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@tailwindcss/typography": "^0.5.20",
|
|
37
|
+
"@tailwindcss/vite": "^4.3.3",
|
|
38
|
+
"oxlint": "^1.79.0",
|
|
39
|
+
"tailwindcss": "^4.3.3",
|
|
40
|
+
"tsx": "^4.23.12",
|
|
41
|
+
"typescript": "^7.0.2",
|
|
42
|
+
"vite": "^8.2.1",
|
|
43
|
+
"vitest": "^4.1.11"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "^0.84.2",
|
|
47
|
+
"@hono/node-server": "^2.1.1",
|
|
48
|
+
"croner": "^10.0.1",
|
|
49
|
+
"dompurify": "^3.4.14",
|
|
50
|
+
"highlight.js": "^11.12.0",
|
|
51
|
+
"hono": "^4.13.3",
|
|
52
|
+
"marked": "^18.0.10",
|
|
53
|
+
"typebox": "^1.3.7"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pier-boards
|
|
3
|
+
description: Publish a Board — a folder of static HTML Pier serves at a stable URL — to present a report, digest, dashboard or handover note to the user. Read before building any page-shaped deliverable, or before editing an existing board.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Building a Pier board
|
|
7
|
+
|
|
8
|
+
A **board** is a directory in the boards folder `<pier>/AGENTS.md` names under
|
|
9
|
+
"This Pier instance" — use that path verbatim; `~/.pier` is only the default.
|
|
10
|
+
Pier serves `<board>/site/` and nothing else. Boards outlive sessions: any
|
|
11
|
+
session may read or rewrite any board, and closing this one changes nothing.
|
|
12
|
+
|
|
13
|
+
## Create one
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
<boards folder>/weekly-digest/
|
|
17
|
+
board.json
|
|
18
|
+
site/index.html
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
```json
|
|
22
|
+
{
|
|
23
|
+
"title": "Weekly digest — infra",
|
|
24
|
+
"description": "What changed in infra this week and what needs a decision.",
|
|
25
|
+
"sessions": ["<your session id>"],
|
|
26
|
+
"public": false
|
|
27
|
+
}
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Those four fields are the whole manifest.
|
|
31
|
+
|
|
32
|
+
- `slug`: `[a-z0-9][a-z0-9-]{0,63}`, and it is the URL — short and stable.
|
|
33
|
+
- `description` is the Console list entry: write it for someone who has
|
|
34
|
+
forgotten this conversation.
|
|
35
|
+
- `sessions`: append your own id, never replace — other ids are provenance too.
|
|
36
|
+
|
|
37
|
+
## Publish, then hand over the link
|
|
38
|
+
|
|
39
|
+
`"public": true` serves the board at `/p/<slug>/` **with no password**. Set it
|
|
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.
|
|
44
|
+
|
|
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.
|
|
47
|
+
`<pier>/AGENTS.md` gives you the address, so there is nothing to look up:
|
|
48
|
+
|
|
49
|
+
| The user asked for | Send |
|
|
50
|
+
| --- | --- |
|
|
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 |
|
|
53
|
+
|
|
54
|
+
Never both: the pair invites pasting the password-free URL of a board that was
|
|
55
|
+
never meant to leave the workspace, and `/p/<slug>/` 404s unless the manifest
|
|
56
|
+
says `"public": true`. No address configured? Give the path, say Console →
|
|
57
|
+
Settings turns it into a link, and never guess a host.
|
|
58
|
+
|
|
59
|
+
## Writing the page
|
|
60
|
+
|
|
61
|
+
A board is a **presentation**, not a text file: someone opens it to get an
|
|
62
|
+
answer fast. Link the shipped stylesheet and write plain semantic HTML — no
|
|
63
|
+
build, no npm, no framework:
|
|
64
|
+
|
|
65
|
+
```html
|
|
66
|
+
<!doctype html>
|
|
67
|
+
<html lang="en">
|
|
68
|
+
<head>
|
|
69
|
+
<meta charset="utf-8">
|
|
70
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
71
|
+
<title>Weekly digest — infra</title>
|
|
72
|
+
<link rel="stylesheet" href="/boards/_assets/pier.css">
|
|
73
|
+
</head>
|
|
74
|
+
<body>
|
|
75
|
+
<div class="hero">
|
|
76
|
+
<h1>Weekly digest — infra</h1>
|
|
77
|
+
<p class="lede">Throughput is up, but two migrations need a decision before Friday.</p>
|
|
78
|
+
<p class="muted">18 Feb 2026 · covers 12 repos</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div class="grid">
|
|
82
|
+
<div class="card"><span class="kpi good">12</span> PRs merged <span class="muted">+3 vs last week</span></div>
|
|
83
|
+
<div class="card"><span class="kpi warn">2</span> awaiting decision</div>
|
|
84
|
+
<div class="card"><span class="kpi">98.9%</span> uptime <span class="muted">30 d</span></div>
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
<div class="callout warn">
|
|
88
|
+
<strong>Needs you:</strong> the payments migration blocks two teams — approve or defer by Friday.
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<h2>Payments is the only degraded service</h2>
|
|
92
|
+
<table>
|
|
93
|
+
<thead><tr><th>Service</th><th>Status</th><th class="num">p95</th><th>Error budget</th></tr></thead>
|
|
94
|
+
<tbody>
|
|
95
|
+
<tr><td><strong>payments</strong></td><td><span class="tag bad">degraded</span></td><td class="num">910 ms</td>
|
|
96
|
+
<td><span class="bar bad" style="--v:18%"></span></td></tr>
|
|
97
|
+
<tr><td>api</td><td><span class="tag good">healthy</span></td><td class="num">142 ms</td>
|
|
98
|
+
<td><span class="bar good" style="--v:82%"></span></td></tr>
|
|
99
|
+
</tbody>
|
|
100
|
+
</table>
|
|
101
|
+
|
|
102
|
+
<details><summary>All 12 merged PRs</summary>
|
|
103
|
+
<table><thead><tr><th>PR</th><th>Author</th><th>Merged</th></tr></thead>
|
|
104
|
+
<tbody><tr><td>#412</td><td>ana</td><td>Mon</td></tr></tbody></table>
|
|
105
|
+
</details>
|
|
106
|
+
|
|
107
|
+
<footer>Written by Pier · data as of 18 Feb 09:00 · ask for an update to refresh</footer>
|
|
108
|
+
</body>
|
|
109
|
+
</html>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
What makes it read as designed rather than generated:
|
|
113
|
+
|
|
114
|
+
- **The lede is the verdict, not the topic.** "Two migrations need a decision by
|
|
115
|
+
Friday" answers; "This digest covers infra activity" restates the prompt. A
|
|
116
|
+
reader who stops after the hero must still leave with the point.
|
|
117
|
+
- **Headings are findings.** "Payments is the only degraded service", not
|
|
118
|
+
"Services". A heading that could top any report — Overview, Summary, Details —
|
|
119
|
+
says nothing about this one.
|
|
120
|
+
- **Every number carries its unit and its baseline**: "142 ms p95, was 120",
|
|
121
|
+
"12 of 40". A bare number is decoration; so is fake precision (98.8724% →
|
|
122
|
+
98.9%).
|
|
123
|
+
- **End at the last useful block.** No closing summary, no filler section. The
|
|
124
|
+
footer holds provenance: data as-of, source, how to refresh.
|
|
125
|
+
|
|
126
|
+
## What `pier.css` gives you
|
|
127
|
+
|
|
128
|
+
Headings, paragraphs, lists, tables, `pre`/`code`, `blockquote`, `details` and
|
|
129
|
+
`footer` are styled with **no classes at all**, dark mode included. The page
|
|
130
|
+
sizes itself: a wide desktop canvas (62rem, 76rem on a very large screen) that
|
|
131
|
+
reflows to one column on a phone, prose held to a readable measure while tables,
|
|
132
|
+
`.grid`, `.split` and `.hero` use the full width — don't add a `max-width` of
|
|
133
|
+
your own. Zebra striping, tables that scroll inside themselves, and `<details>`
|
|
134
|
+
that print open are free. On top of that:
|
|
135
|
+
|
|
136
|
+
| Class | Use it for |
|
|
137
|
+
| --- | --- |
|
|
138
|
+
| `.lede` | the one-sentence answer under the title |
|
|
139
|
+
| `.hero` | the opening block: title + lede + date/scope, on a tinted panel |
|
|
140
|
+
| `.grid` + `.card` | a KPI row (auto-fits to one column on a phone) |
|
|
141
|
+
| `.kpi` | the headline number inside a card |
|
|
142
|
+
| `.card` + `.good/.warn/.bad/.info` | a status card: accent edge and tinted fill |
|
|
143
|
+
| `.callout` (`.good` `.warn` `.bad`) | the takeaway or the ask |
|
|
144
|
+
| `.tag` (`.good` `.warn` `.bad` `.info`) | status pills in tables and lists |
|
|
145
|
+
| `.good` `.warn` `.bad` | colour on a number or a word |
|
|
146
|
+
| `.num` | right-aligned, tabular numeric table cells |
|
|
147
|
+
| `.bar` (`.good` `.warn` `.bad`) | share-of-total inside a table: `style="--v:62%"` |
|
|
148
|
+
| `.split` | two columns that stack on a phone: before/after, text + aside |
|
|
149
|
+
| `.muted` | secondary text: dates, deltas, units, scope |
|
|
150
|
+
|
|
151
|
+
Need something it lacks? A `<style>` block or your own CSS file inside `site/`
|
|
152
|
+
is normal, and so is a custom colour or a hand-written layout when the content
|
|
153
|
+
calls for one.
|
|
154
|
+
|
|
155
|
+
## Pick the form from the content
|
|
156
|
+
|
|
157
|
+
| What you have | How to present it |
|
|
158
|
+
| --- | --- |
|
|
159
|
+
| The single most important fact | `.hero` lede, or one `.kpi` on its own |
|
|
160
|
+
| 2–4 headline metrics | `.grid` of `.card`s, each with `.kpi` + a `.muted` delta |
|
|
161
|
+
| Something the reader must act on | `.callout warn` near the top, naming the deadline |
|
|
162
|
+
| Items with a state | table with `.tag` pills, worst rows first |
|
|
163
|
+
| Ranked or compared numbers | table, `.num` columns, `.bar` for share of total |
|
|
164
|
+
| Progress toward a goal | `.bar` per row, or `.kpi` + "of 40 done" in `.muted` |
|
|
165
|
+
| Two alternatives | `.split` with a `.card` each, verdict in the lede above |
|
|
166
|
+
| A sequence of events | ordered list, date in `.muted` at the start of each item |
|
|
167
|
+
| A trend over time | first/last + delta in words; inline SVG only if the shape *is* the news |
|
|
168
|
+
| 200 rows | aggregate, show the 5–10 that matter, rest in `<details>` with the count in its summary |
|
|
169
|
+
| 1–2 data points | a sentence or a `.callout` — a one-row table is a table costume |
|
|
170
|
+
| Long raw output, logs, code | `<details>` at the bottom, or `<pre><code>` trimmed to the lines that matter |
|
|
171
|
+
| Nothing to report | say so in the lede ("all 14 checks green") and stop — short is finished, not thin |
|
|
172
|
+
|
|
173
|
+
Vary the forms: never two blocks of the same kind in a row, and prefer number →
|
|
174
|
+
ask → proof → fold over six paragraphs. A status or decision board earns one
|
|
175
|
+
screenful before the first `<details>`; only a handover or postmortem earns a
|
|
176
|
+
long scroll. Colour is part of the message — green healthy/done, amber
|
|
177
|
+
attention/pending, red broken/blocked, `.info` neutral emphasis — on the numbers
|
|
178
|
+
and status cells that carry the point, never on ordinary prose; that contrast is
|
|
179
|
+
what makes it read as signal.
|
|
180
|
+
|
|
181
|
+
## Rules
|
|
182
|
+
|
|
183
|
+
- **Static and self-contained.** Everything the page needs lives under `site/`
|
|
184
|
+
with relative paths. No CDN, no external fonts, no analytics, no `fetch()` —
|
|
185
|
+
a published board is served under a CSP that blocks all of it, so an external
|
|
186
|
+
reference is a broken page, not a slow one.
|
|
187
|
+
- **Content, not an app.** Interaction is `<details>` and anchors; the page
|
|
188
|
+
stays readable with JavaScript off.
|
|
189
|
+
- **Show, don't narrate.** A sentence describing numbers should have been a KPI
|
|
190
|
+
row or a table. Prose is for judgement — what it means, what to do.
|
|
191
|
+
- **Structure before graphics.** A chart is inline SVG (no library) and only
|
|
192
|
+
when the *shape* of the data is the message. A three-row table beats any
|
|
193
|
+
picture of three numbers; no chart beats a decorative one.
|
|
194
|
+
- **Only real data.** Every figure traces to something you saw this session, and
|
|
195
|
+
a gap is shown as a gap ("no data since Tue", "~3 weeks", "n=3") — never
|
|
196
|
+
smoothed into a clean number.
|
|
197
|
+
- **Rewrite in place.** Updating a board means editing its files, not creating
|
|
198
|
+
`weekly-digest-v2`. The URL is the point.
|
|
199
|
+
- Top-down — what this is → the answer → detail → raw data in `<details>` —
|
|
200
|
+
prose in the user's language, short headings, no emoji chrome.
|
|
201
|
+
|
|
202
|
+
## If a board needs a build
|
|
203
|
+
|
|
204
|
+
Pier ships no toolchain and the default is no build. If one is genuinely needed
|
|
205
|
+
(a bundled charting library, a component layout), you own it: keep sources
|
|
206
|
+
outside `site/` (e.g. `<board>/src/`), emit into `site/`, and write a
|
|
207
|
+
`<board>/README.md` a future session can follow cold — install command, build
|
|
208
|
+
command, output path, where the data came from. Never leave `site/`
|
|
209
|
+
inconsistent with its sources; on an existing board look for that README first
|
|
210
|
+
and rebuild, because hand-patching `site/` is lost on the next build.
|