@timqi/pier 0.0.1 → 0.0.2
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 +76 -12
- package/dist/agent/config.js +273 -27
- package/dist/agent/credentials.js +18 -12
- package/dist/agent/events.js +5 -41
- package/dist/agent/models.js +12 -0
- package/dist/agent/pi.js +182 -27
- package/dist/boards/boards.js +20 -10
- package/dist/channels/routes.js +1 -1
- package/dist/channels/runtime.js +36 -5
- package/dist/channels/slack-api.js +2 -4
- package/dist/channels/slack-outbound.js +4 -8
- package/dist/channels/slack-render.js +1 -4
- package/dist/channels/slack.js +20 -9
- package/dist/channels/telegram-api.js +3 -4
- package/dist/channels/telegram.js +37 -28
- package/dist/cli.js +177 -29
- package/dist/core/hub.js +36 -5
- package/dist/core/identity.js +5 -0
- package/dist/core/inbound-file.js +70 -0
- package/dist/core/inbox.js +32 -0
- package/dist/core/queue.js +9 -3
- package/dist/core/reply.js +20 -5
- package/dist/core/router.js +186 -14
- package/dist/core/types.js +53 -0
- package/dist/db.js +54 -8
- package/dist/drain.js +145 -0
- package/dist/main.js +86 -18
- package/dist/secrets.js +10 -6
- package/dist/service.js +142 -18
- package/dist/settings.js +69 -8
- package/dist/tasks/agent.js +41 -5
- package/dist/tasks/callbacks.js +29 -89
- package/dist/tasks/definitions.js +2 -6
- package/dist/tasks/execution.js +10 -1
- package/dist/tasks/groups.js +20 -49
- package/dist/tasks/messages.js +106 -21
- package/dist/tasks/outbox.js +157 -0
- package/dist/tasks/routes.js +6 -4
- package/dist/tasks/service.js +79 -22
- package/dist/tasks/store.js +48 -55
- package/dist/tasks/tool.js +19 -4
- package/dist/tasks/types.js +7 -0
- package/dist/update.js +94 -0
- package/dist/web/auth.js +75 -22
- package/dist/web/explorer.js +146 -0
- package/dist/web/files.js +26 -11
- package/dist/web/instance.js +99 -0
- package/dist/web/provider-flows.js +249 -0
- package/dist/web/providers.js +129 -0
- package/dist/web/public/assets/index-BK64pHmP.js +90 -0
- package/dist/web/public/assets/index-De4GlOq4.css +2 -0
- package/dist/web/public/icon-192.png +0 -0
- package/dist/web/public/icon-32.png +0 -0
- package/dist/web/public/icon-512.png +0 -0
- package/dist/web/public/icon-maskable-512.png +0 -0
- package/dist/web/public/icon-touch-192.png +0 -0
- package/dist/web/public/icon.svg +29 -11
- package/dist/web/public/index.html +43 -28
- package/dist/web/server.js +47 -120
- package/docs/deploy.md +120 -64
- package/package.json +1 -1
- package/skills/pier-help/SKILL.md +110 -0
- package/skills/pier-slack/SKILL.md +3 -2
- package/skills/pier-tasks/SKILL.md +19 -12
- package/dist/web/public/assets/index-8CinH1uR.css +0 -2
- package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
- package/dist/web/public/sw.js +0 -21
package/dist/web/server.js
CHANGED
|
@@ -7,36 +7,17 @@ import { Hono } from "hono";
|
|
|
7
7
|
import { streamSSE } from "hono/streaming";
|
|
8
8
|
import { EventHub } from "../core/hub.js";
|
|
9
9
|
import { Router } from "../core/router.js";
|
|
10
|
-
import {
|
|
10
|
+
import { registerExplorerRoutes } from "./explorer.js";
|
|
11
11
|
import { guarded, registerFileRoutes } from "./files.js";
|
|
12
12
|
import { isThinkingLevel } from "../core/types.js";
|
|
13
|
-
import {
|
|
13
|
+
import { saveInbound } from "../core/inbox.js";
|
|
14
|
+
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
15
|
+
import { registerInstanceRoutes } from "./instance.js";
|
|
16
|
+
import { registerProviderRoutes } from "./providers.js";
|
|
14
17
|
const HEARTBEAT_MS = 15_000;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
const MAX_IMAGES = 8;
|
|
19
|
-
const MAX_IMAGE_BYTES = 8 * 1024 * 1024; // per image, base64 length ≈ bytes × 4/3
|
|
20
|
-
/** Validate at the seam: malformed attachments are rejected, never half-sent. */
|
|
21
|
-
function parseImages(raw) {
|
|
22
|
-
if (raw === undefined)
|
|
23
|
-
return [];
|
|
24
|
-
if (!Array.isArray(raw) || raw.length > MAX_IMAGES)
|
|
25
|
-
return { error: "invalid images" };
|
|
26
|
-
const images = [];
|
|
27
|
-
for (const i of raw) {
|
|
28
|
-
if (typeof i?.data !== "string" ||
|
|
29
|
-
!i.data ||
|
|
30
|
-
i.data.length > (MAX_IMAGE_BYTES * 4) / 3 ||
|
|
31
|
-
typeof i?.mimeType !== "string" ||
|
|
32
|
-
!i.mimeType.startsWith("image/")) {
|
|
33
|
-
return { error: "invalid images" };
|
|
34
|
-
}
|
|
35
|
-
images.push({ data: i.data, mimeType: i.mimeType });
|
|
36
|
-
}
|
|
37
|
-
return images;
|
|
38
|
-
}
|
|
39
|
-
export function createServer({ factory, router, hub, sessions: state, config, settings, secrets, onUnlocked, backgroundRuns }) {
|
|
18
|
+
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
19
|
+
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, }) {
|
|
40
21
|
const app = new Hono();
|
|
41
22
|
// A finished turn marks its session unread until some client reports it was
|
|
42
23
|
// seen (session selected + tab visible → POST read below). Server-side so
|
|
@@ -68,6 +49,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
68
49
|
const sessions = await factory.list();
|
|
69
50
|
for (const s of sessions)
|
|
70
51
|
nascent.delete(s.id);
|
|
52
|
+
// A session created but never prompted would otherwise be listed forever.
|
|
53
|
+
for (const [id, n] of nascent)
|
|
54
|
+
if (Date.now() - n.createdAt > 86_400_000)
|
|
55
|
+
nascent.delete(id);
|
|
71
56
|
return c.json([...[...nascent].map(([id, n]) => ({ id, ...n })), ...sessions].map((s) => ({
|
|
72
57
|
...s,
|
|
73
58
|
state: router.stateOf(s.id) ?? "idle",
|
|
@@ -103,6 +88,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
103
88
|
const body = await c.req.json().catch(() => null);
|
|
104
89
|
if (typeof body?.pinned !== "boolean")
|
|
105
90
|
return c.json({ error: "pinned required" }, 400);
|
|
91
|
+
// The id is trusted: checking existence costs a session list per click,
|
|
92
|
+
// and the surface is operator-authenticated. Worst case is a stray row.
|
|
106
93
|
state.set("pinned", c.req.param("id"), body.pinned);
|
|
107
94
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
108
95
|
return c.json({ pinned: body.pinned });
|
|
@@ -123,20 +110,24 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
123
110
|
backgroundRuns: backgroundRuns?.(id) ?? [],
|
|
124
111
|
});
|
|
125
112
|
});
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
"
|
|
138
|
-
|
|
139
|
-
|
|
113
|
+
// Composer attachments: bytes land in the inbox, the message carries the
|
|
114
|
+
// path as a `[name](file:///…)` line the client builds itself — upload
|
|
115
|
+
// first, so the text it sends (and optimistically renders) is final.
|
|
116
|
+
guarded(app, "POST", "/api/inbox", 400, async (c) => {
|
|
117
|
+
const body = await c.req.json().catch(() => null);
|
|
118
|
+
if (typeof body?.data !== "string" ||
|
|
119
|
+
!body.data ||
|
|
120
|
+
// Cheap ceiling before decoding; the exact check is on the bytes.
|
|
121
|
+
body.data.length > Math.ceil(MAX_INBOUND_BYTES / 3) * 4 + 4 ||
|
|
122
|
+
!BASE64_RE.test(body.data) ||
|
|
123
|
+
typeof body?.mimeType !== "string") {
|
|
124
|
+
return c.json({ error: "invalid file" }, 400);
|
|
125
|
+
}
|
|
126
|
+
const name = typeof body.name === "string" ? body.name : undefined;
|
|
127
|
+
const bytes = Buffer.from(body.data, "base64");
|
|
128
|
+
if (bytes.length > MAX_INBOUND_BYTES)
|
|
129
|
+
return c.json({ error: "invalid file" }, 400);
|
|
130
|
+
return c.json({ path: await saveInbound("web", name, body.mimeType, bytes) });
|
|
140
131
|
});
|
|
141
132
|
// Backend model catalog, no session needed: surfaces that configure what a
|
|
142
133
|
// *future* session launches with (IM chats) have none to ask.
|
|
@@ -180,21 +171,14 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
180
171
|
app.post("/api/sessions/:id/messages", async (c) => {
|
|
181
172
|
const id = c.req.param("id");
|
|
182
173
|
const body = await c.req.json().catch(() => null);
|
|
183
|
-
if (!body || typeof body.text !== "string") {
|
|
174
|
+
if (!body || typeof body.text !== "string" || !body.text.trim()) {
|
|
184
175
|
return c.json({ error: "text required" }, 400);
|
|
185
176
|
}
|
|
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
177
|
const mode = body.mode === "steer" || body.mode === "followUp" ? body.mode : "auto";
|
|
193
178
|
const { sessionId } = await router.dispatch({
|
|
194
179
|
key: { channelId: "web", conversationId: id },
|
|
195
180
|
senderId: "web",
|
|
196
181
|
text: body.text,
|
|
197
|
-
images: images.length ? images : undefined,
|
|
198
182
|
mode,
|
|
199
183
|
});
|
|
200
184
|
return c.json({ sessionId }, 202);
|
|
@@ -209,6 +193,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
209
193
|
if (!Number.isInteger(index) || index < 0 || typeof body?.text !== "string" || !body.text.trim()) {
|
|
210
194
|
return c.json({ error: "index and text required" }, 400);
|
|
211
195
|
}
|
|
196
|
+
// Asked before touching anything: the dispatch below would be refused by
|
|
197
|
+
// the drain gate, and by then the transcript is already rewound.
|
|
198
|
+
if (router.isDraining())
|
|
199
|
+
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
212
200
|
const session = await ensure(id);
|
|
213
201
|
if (session.state === "streaming")
|
|
214
202
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
@@ -231,6 +219,9 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
231
219
|
if (mode !== "steer" && mode !== "restart") {
|
|
232
220
|
return c.json({ error: "mode must be steer or restart" }, 400);
|
|
233
221
|
}
|
|
222
|
+
// Same reason as edit above: a refused dispatch must not cost the queue.
|
|
223
|
+
if (router.isDraining())
|
|
224
|
+
return c.json({ error: "Pier is restarting — try again in a moment" }, 503);
|
|
234
225
|
const session = await ensure(id);
|
|
235
226
|
const { steering, followUp } = await session.clearQueue();
|
|
236
227
|
const text = [...steering, ...followUp].join("\n").trim();
|
|
@@ -252,30 +243,6 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
252
243
|
const { steering, followUp } = await session.clearQueue();
|
|
253
244
|
return c.json({ messages: [...steering, ...followUp] });
|
|
254
245
|
});
|
|
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
246
|
app.post("/api/sessions/:id/abort", async (c) => {
|
|
280
247
|
const id = c.req.param("id");
|
|
281
248
|
await router.abort(id);
|
|
@@ -284,7 +251,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
284
251
|
// Workspace stream: one per client, keeps every session list in sync
|
|
285
252
|
// (created/pinned → re-list, run state → patch) without polling.
|
|
286
253
|
app.get("/api/events", (c) => streamSSE(c, async (stream) => {
|
|
287
|
-
|
|
254
|
+
// A write to a torn-down stream must not become an unhandled rejection.
|
|
255
|
+
const unsubscribe = hub.subscribeWorkspace((e) => void stream.writeSSE({ data: JSON.stringify(e) }).catch(() => { }));
|
|
288
256
|
stream.onAbort(unsubscribe);
|
|
289
257
|
while (!stream.aborted) {
|
|
290
258
|
await stream.sleep(HEARTBEAT_MS);
|
|
@@ -300,7 +268,8 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
300
268
|
const send = (e) => stream.writeSSE({ id: String(e.seq), data: JSON.stringify(e) });
|
|
301
269
|
for (const e of hub.replay(id, lastId))
|
|
302
270
|
await send(e);
|
|
303
|
-
|
|
271
|
+
// Same as above: the client may be gone by the time an event fires.
|
|
272
|
+
const unsubscribe = hub.subscribe(id, (e) => void send(e).catch(() => { }));
|
|
304
273
|
stream.onAbort(unsubscribe);
|
|
305
274
|
// Heartbeat keeps proxies from closing the stream; loop ends on abort.
|
|
306
275
|
while (!stream.aborted) {
|
|
@@ -309,52 +278,10 @@ export function createServer({ factory, router, hub, sessions: state, config, se
|
|
|
309
278
|
}
|
|
310
279
|
});
|
|
311
280
|
});
|
|
312
|
-
|
|
313
|
-
|
|
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());
|
|
343
|
-
});
|
|
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());
|
|
356
|
-
});
|
|
281
|
+
registerInstanceRoutes(app, { settings, updates, secrets, onUnlocked });
|
|
282
|
+
registerProviderRoutes(app, providers);
|
|
357
283
|
registerFileRoutes(app, { factory, config, nascentCwd: (id) => nascent.get(id)?.cwd });
|
|
284
|
+
registerExplorerRoutes(app, { factory, nascentCwds: () => [...nascent.values()].map((n) => n.cwd) });
|
|
358
285
|
// serveStatic resolves `root` against the *working directory*, and an
|
|
359
286
|
// installed Pier is started from wherever the operator happens to be. The
|
|
360
287
|
// bundle sits beside this module in both trees — src/web/public when tsx
|
package/docs/deploy.md
CHANGED
|
@@ -24,17 +24,15 @@ you want the unit to say something different.
|
|
|
24
24
|
## Prerequisites
|
|
25
25
|
|
|
26
26
|
- Node 24 or newer (`node:sqlite` is used unflagged).
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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".
|
|
27
|
+
- A user-writable global npm prefix. The updater runs as you, so an initial
|
|
28
|
+
install that needed `sudo npm install -g` cannot later update itself.
|
|
29
|
+
- The `sqlite3` CLI is optional, for the off-machine backup and password steps
|
|
30
|
+
below. Pier itself and its automatic update backup do not need it.
|
|
31
|
+
- Pier installed globally: `npm install -g @timqi/pier`. The unit runs that
|
|
32
|
+
installed entry point, so a deploy is `pier update`. A checkout
|
|
33
|
+
(`git clone` + `npm ci && npm run build`) is the *develop* path; point the
|
|
34
|
+
unit's `ExecStart` at its `dist/main.js` if you run one as the service, and
|
|
35
|
+
update it with the "From a checkout" steps under Updating.
|
|
38
36
|
|
|
39
37
|
## The unit
|
|
40
38
|
|
|
@@ -44,7 +42,7 @@ or as a dedicated system user means an agent that cannot touch the files you
|
|
|
44
42
|
wanted it to work on.
|
|
45
43
|
|
|
46
44
|
`~/.config/systemd/user/pier.service` — what `pier service install` generates,
|
|
47
|
-
with your
|
|
45
|
+
with your absolute Node and package paths filled in:
|
|
48
46
|
|
|
49
47
|
```ini
|
|
50
48
|
[Unit]
|
|
@@ -55,29 +53,33 @@ Wants=network-online.target
|
|
|
55
53
|
|
|
56
54
|
[Service]
|
|
57
55
|
Type=simple
|
|
58
|
-
WorkingDirectory=%h
|
|
59
|
-
#
|
|
60
|
-
# installed by nvm/fnm/asdf is not on it
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
WorkingDirectory=%h
|
|
57
|
+
# Absolute paths on purpose: systemd starts with a minimal PATH, so a node
|
|
58
|
+
# installed by nvm/fnm/asdf is not on it — the installer fills in the node
|
|
59
|
+
# that installed Pier and the globally installed entry point.
|
|
60
|
+
ExecStart="/absolute/path/to/node" "/absolute/npm/prefix/lib/node_modules/@timqi/pier/dist/main.js"
|
|
61
|
+
Environment="NODE_ENV=production"
|
|
63
62
|
# Loopback by default. Put a reverse proxy in front before widening this —
|
|
64
63
|
# 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
|
|
64
|
+
Environment="HOST=127.0.0.1"
|
|
65
|
+
Environment="PORT=3141"
|
|
69
66
|
Restart=always
|
|
70
67
|
RestartSec=2
|
|
71
|
-
# The journal is where the first-run password is printed, so keep it readable.
|
|
72
68
|
StandardOutput=journal
|
|
73
69
|
StandardError=journal
|
|
74
|
-
# Otherwise every line is tagged "node"; this makes `journalctl -t pier` work.
|
|
75
70
|
SyslogIdentifier=pier
|
|
76
71
|
|
|
77
72
|
[Install]
|
|
78
73
|
WantedBy=default.target
|
|
79
74
|
```
|
|
80
75
|
|
|
76
|
+
`--pier-home` adds a quoted `PIER_HOME` environment line. Paths with spaces and
|
|
77
|
+
literal systemd `%` specifiers are escaped. `pier service install` also writes an
|
|
78
|
+
updater unit containing the exact npm executable currently on `PATH`. Re-run with
|
|
79
|
+
`--force` after changing service settings or the Node/npm installation; it
|
|
80
|
+
rewrites both units and restarts the running service. The limits drop-in remains
|
|
81
|
+
operator-owned and is never overwritten.
|
|
82
|
+
|
|
81
83
|
Enable it, and tell logind to keep your user manager alive after you log out —
|
|
82
84
|
without lingering, every scheduled task stops when your SSH session ends:
|
|
83
85
|
|
|
@@ -167,7 +169,9 @@ journalctl --user -u pier --since -1h | grep 'tasks:' # one area
|
|
|
167
169
|
```
|
|
168
170
|
|
|
169
171
|
Every line is `area: message` — `core`, `agent`, `tasks`, `slack`, `telegram`,
|
|
170
|
-
`channels`, `
|
|
172
|
+
`channels`, `slack.tool`, `auth`, `boards`, `client`, `db`, `drain`, `secrets`,
|
|
173
|
+
`settings`, `credentials`, `update`, `web.providers`, `pier` — so an area is a
|
|
174
|
+
grep
|
|
171
175
|
and a level is a `-p`. The level reaches journald as a syslog priority prefix, which Pier
|
|
172
176
|
emits only when systemd says the output is a journal (`$JOURNAL_STREAM`); run
|
|
173
177
|
in a terminal, the same lines carry a timestamp and a level word instead.
|
|
@@ -214,42 +218,83 @@ new password is generated and printed:
|
|
|
214
218
|
|
|
215
219
|
```sh
|
|
216
220
|
sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
|
|
217
|
-
|
|
221
|
+
pier restart
|
|
218
222
|
```
|
|
219
223
|
|
|
220
224
|
Changing the password invalidates every session cookie: the cookies are signed
|
|
221
225
|
with the stored hash.
|
|
222
226
|
|
|
227
|
+
## Restarting and reloading
|
|
228
|
+
|
|
229
|
+
```sh
|
|
230
|
+
pier restart # finish active work, then restart the service
|
|
231
|
+
pier reload # apply channel config and recycle idle sessions in place
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Both commands signal the installed systemd service; they are not foreground
|
|
235
|
+
process controls. `pier restart` refuses new messages and root Task runs, waits
|
|
236
|
+
up to five minutes for active work, then exits for `Restart=always` to start the
|
|
237
|
+
next process. At the deadline it records every aborted IM turn first, and the
|
|
238
|
+
next process posts that note after its adapter starts. Cleanup after the deadline
|
|
239
|
+
has one shared 10-second bound regardless of how many sessions are stuck.
|
|
240
|
+
|
|
241
|
+
`pier reload` does not stop active work. It reloads Slack and Telegram adapters
|
|
242
|
+
and immediately evicts idle sessions nobody is watching, so their next message
|
|
243
|
+
opens with current agent files and configuration. Streaming sessions and
|
|
244
|
+
sessions held by an open workbench stay attached until their normal eviction.
|
|
245
|
+
|
|
246
|
+
An ordinary `systemctl --user restart pier` and `pier update` remain fast,
|
|
247
|
+
hard-stop paths. Let active work finish first when using either one.
|
|
248
|
+
|
|
223
249
|
## Updating
|
|
224
250
|
|
|
225
|
-
|
|
251
|
+
```sh
|
|
252
|
+
pier update # installs the latest release; hard-stops/restarts Pier
|
|
253
|
+
pier update --check # only says whether one exists
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
The workbench footer says the same thing without being asked: the server checks
|
|
257
|
+
`registry.npmjs.org` every six hours in the background, and the version turns
|
|
258
|
+
into `v0.0.1 → 0.0.2` when there is something newer. A failed check is silent
|
|
259
|
+
by design — an offline box is not a broken one.
|
|
260
|
+
|
|
261
|
+
From a checkout instead, stop and back up before replacing the build:
|
|
226
262
|
|
|
227
263
|
```sh
|
|
264
|
+
systemctl --user stop pier
|
|
265
|
+
pier backup
|
|
228
266
|
cd ~/pier
|
|
229
|
-
git fetch --tags && git checkout v0.2
|
|
267
|
+
git fetch --tags && git checkout v0.0.2 # a tag, not a branch
|
|
230
268
|
npm ci && npm run build
|
|
231
|
-
systemctl --user
|
|
269
|
+
systemctl --user start pier
|
|
232
270
|
```
|
|
233
271
|
|
|
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
|
|
274
|
+
`~/.pier/db/pier.db.release.bak` before npm touches the package. This happens for
|
|
275
|
+
every release, including releases with no schema change. If installation or
|
|
276
|
+
backup fails, the updater unit still tries to start the previously installed
|
|
277
|
+
service and reports the failure in its journal.
|
|
278
|
+
|
|
234
279
|
A newer Pier brings its own schema up on the next start: the migrations run in
|
|
235
280
|
one transaction before the port opens, and the version they leave behind is
|
|
236
|
-
stamped in the database.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
281
|
+
stamped in the database. It also snapshots the immediately preceding schema to
|
|
282
|
+
`~/.pier/db/pier.db.v<N>.bak` (`N` = the schema it was at). **Upgrades only.**
|
|
283
|
+
Start an older Pier on a database a newer one has migrated and it refuses to run
|
|
284
|
+
rather than write tables it does not understand — the way back down is either
|
|
285
|
+
the release backup or that schema snapshot:
|
|
241
286
|
|
|
242
287
|
```sh
|
|
243
288
|
systemctl --user stop pier
|
|
244
|
-
cd ~/.pier/db && rm -f pier.db pier.db-wal pier.db-shm && cp pier.db.
|
|
245
|
-
# then
|
|
289
|
+
cd ~/.pier/db && rm -f pier.db pier.db-wal pier.db-shm && cp pier.db.release.bak pier.db
|
|
290
|
+
# then reinstall the Pier release that created that backup
|
|
246
291
|
```
|
|
247
292
|
|
|
248
|
-
The
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
therefore protect against a bad upgrade, not against a lost disk —
|
|
252
|
-
off-machine copy is still yours to take.
|
|
293
|
+
The release backup is replaced atomically on each update. The three newest
|
|
294
|
+
schema snapshots are also kept and older ones removed as later migrations
|
|
295
|
+
supersede them. Each is a full copy of the database. They sit next to the
|
|
296
|
+
database and therefore protect against a bad upgrade, not against a lost disk —
|
|
297
|
+
an off-machine copy is still yours to take.
|
|
253
298
|
|
|
254
299
|
### Can it update itself?
|
|
255
300
|
|
|
@@ -259,32 +304,33 @@ in `pier.service`'s cgroup, so an update script spawned by Pier dies halfway
|
|
|
259
304
|
through — sometimes after unpacking and before restarting, which is the one
|
|
260
305
|
outcome worse than not updating.
|
|
261
306
|
|
|
262
|
-
So
|
|
307
|
+
So installation writes a second unit and `pier update` starts it after recording
|
|
308
|
+
the running service's effective `PIER_HOME` in a runtime drop-in. That includes
|
|
309
|
+
an operator environment override, so the updater cannot back up one database and
|
|
310
|
+
migrate another. `~/.config/systemd/user/pier-update.service`:
|
|
263
311
|
|
|
264
312
|
```ini
|
|
265
313
|
[Unit]
|
|
266
|
-
Description=Update Pier to the latest
|
|
314
|
+
Description=Update Pier to the latest published version
|
|
267
315
|
|
|
268
316
|
[Service]
|
|
269
317
|
Type=oneshot
|
|
270
|
-
|
|
271
|
-
ExecStart=/
|
|
272
|
-
|
|
318
|
+
ExecStart=systemctl --user stop pier.service
|
|
319
|
+
ExecStart=/path/to/node /path/to/pier/dist/cli.js backup
|
|
320
|
+
ExecStart=/path/to/node /recorded/path/to/npm install -g @timqi/pier@latest
|
|
321
|
+
ExecStopPost=systemctl --user start pier.service
|
|
273
322
|
```
|
|
274
323
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
```sh
|
|
279
|
-
systemctl --user start pier-update.service
|
|
280
|
-
```
|
|
324
|
+
`pier update` triggers that unit with a call that survives Pier's restart because
|
|
325
|
+
the work happens in a different cgroup. Starting the unit directly is unsupported:
|
|
326
|
+
the command first records the effective database home used by the running service.
|
|
281
327
|
|
|
282
328
|
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
|
|
284
|
-
interrupts whatever session was mid-turn
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
329
|
+
rewrites its own code from the network while holding your API keys, and its hard
|
|
330
|
+
stop interrupts whatever session was mid-turn. Pier notices a newer release
|
|
331
|
+
and says so in the workbench footer; starting the update stays a decision someone
|
|
332
|
+
makes. The updater's `ExecStopPost` is what brings the service back after both
|
|
333
|
+
success and failure.
|
|
288
334
|
|
|
289
335
|
## Remote access
|
|
290
336
|
|
|
@@ -294,14 +340,24 @@ elsewhere, pick a tunnel rather than a wider bind:
|
|
|
294
340
|
- `ssh -L 3141:localhost:3141 server` — nothing to configure, nothing exposed.
|
|
295
341
|
- Tailscale, or Cloudflare Tunnel — no open port, and TLS terminates outside.
|
|
296
342
|
- A reverse proxy (Caddy, nginx) if you want a real hostname. Terminate TLS
|
|
297
|
-
there
|
|
298
|
-
|
|
299
|
-
|
|
343
|
+
there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
|
|
344
|
+
`X-Forwarded-For`; Pier uses the external host for write-origin checks and
|
|
345
|
+
counts login failures per forwarded client. Its session cookie is marked
|
|
346
|
+
`Secure` when the proxy reports `X-Forwarded-Proto: https`.
|
|
300
347
|
|
|
301
348
|
## Backups
|
|
302
349
|
|
|
303
|
-
|
|
304
|
-
session map, workbench state, settings, the password hash
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
350
|
+
Three paths hold everything: `~/.pier/db/pier.db` (tasks, channels, the chat →
|
|
351
|
+
session map, workbench state, settings, the password hash, and the sealed
|
|
352
|
+
provider credentials and channel tokens), `~/.pier/master.key` (the key that
|
|
353
|
+
seals them — without it the database's sealed values are unreadable), and
|
|
354
|
+
`~/.pier/boards/`. `pier.db.release.bak` is the latest automatic pre-update copy.
|
|
355
|
+
For off-machine backups, use `sqlite3 ... "VACUUM INTO '…'"` rather than `cp`,
|
|
356
|
+
which under WAL can miss the most recent commits. Pi's own session history lives
|
|
357
|
+
under `~/.pier/pi` (Pier sets `PI_CODING_AGENT_DIR` there unless the environment
|
|
358
|
+
already names another directory).
|
|
359
|
+
|
|
360
|
+
Inbound chat attachments (photos, uploads from any surface) accumulate under
|
|
361
|
+
`~/.pier/inbox/<channel>/` and are never deleted by Pier — a transcript may
|
|
362
|
+
reference them indefinitely. Prune old files by hand (or a cron) when disk
|
|
363
|
+
matters; a pruned file degrades to a broken attachment link, nothing else.
|
package/package.json
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pier-help
|
|
3
|
+
description: How Pier itself works — durable sessions and what survives a restart, how messages and files reach you from Slack and Telegram, in-chat commands (/stop, /settings, /bind), interrupting a running turn, and what only the operator's Console can change. Read before explaining Pier's behavior or advising a user on how to use it.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# How Pier works
|
|
7
|
+
|
|
8
|
+
Pier is the workspace this session runs in: agent sessions behind chat
|
|
9
|
+
surfaces — a web workbench and IM channels (Slack and Telegram today) — plus
|
|
10
|
+
scheduled tasks, subagents and boards. Answer questions about it from the
|
|
11
|
+
facts below. If the answer is not here, say you do not know how this instance
|
|
12
|
+
is configured rather than guessing: the Console (Pier's admin web UI) is the
|
|
13
|
+
operator's source of truth.
|
|
14
|
+
|
|
15
|
+
## Sessions and persistence
|
|
16
|
+
|
|
17
|
+
- One durable session per conversation: a web chat, a Slack thread, a
|
|
18
|
+
Telegram chat or topic. The mapping survives restarts — the next message
|
|
19
|
+
lands in the same transcript with its context intact.
|
|
20
|
+
- Idle sessions leave memory but keep their transcript; they resume
|
|
21
|
+
transparently on the next message. Never promise that a restart or a pause
|
|
22
|
+
wipes context.
|
|
23
|
+
- A fresh start is explicit: "New session" in the chat settings panel or the
|
|
24
|
+
web UI. The old transcript remains readable from the web workbench.
|
|
25
|
+
- The web workbench can also rewind to an earlier user turn and re-prompt;
|
|
26
|
+
IM surfaces cannot.
|
|
27
|
+
- A long session does not hit a wall: when the context fills, Pi compacts it
|
|
28
|
+
automatically — older turns become a summary. The transcript on disk keeps
|
|
29
|
+
everything, but detail can leave *your* context, so a very old turn is worth
|
|
30
|
+
re-reading rather than recalling. The web session header shows context used
|
|
31
|
+
and how much is left.
|
|
32
|
+
|
|
33
|
+
## Files and images the user sends
|
|
34
|
+
|
|
35
|
+
- A photo or file sent on any surface (web paste, Telegram photo/document,
|
|
36
|
+
Slack upload) is saved to `$PIER_HOME/inbox/` and reaches you as a trailing
|
|
37
|
+
`[name](file:///…)` line on the message — a path, not the content.
|
|
38
|
+
- Read it with the read tool only when it matters to the task: every read
|
|
39
|
+
puts the content in your context for good. An image you never read costs
|
|
40
|
+
nothing.
|
|
41
|
+
- The file stays on disk after the conversation moves on; link it back
|
|
42
|
+
(`[name](file:///…)`) whenever the user asks for it again.
|
|
43
|
+
|
|
44
|
+
## Messages while you are working
|
|
45
|
+
|
|
46
|
+
- On the web, a message sent mid-turn queues as a follow-up and lands after
|
|
47
|
+
the turn; a leading `!` interrupts instead — `!wrong file, stop` is injected
|
|
48
|
+
into the running turn as a steer.
|
|
49
|
+
- From an IM chat, every mid-turn message steers the running turn directly —
|
|
50
|
+
no `!` needed, and a leading `!` is just content.
|
|
51
|
+
- `/stop` aborts the current turn outright.
|
|
52
|
+
|
|
53
|
+
## In-chat commands and the settings panel
|
|
54
|
+
|
|
55
|
+
- `/settings` — or an addressed message with no text at all (a bare mention,
|
|
56
|
+
an empty DM) — opens a panel: model, reasoning level, new session
|
|
57
|
+
(optionally in a chosen directory), stop. Slack also accepts the bare words
|
|
58
|
+
`stop`, `settings`, `bind <code>`.
|
|
59
|
+
- Panel taps never reach you. The next-step buttons under your own replies
|
|
60
|
+
do — a click arrives as an ordinary user message with that label.
|
|
61
|
+
|
|
62
|
+
## What a turn looks like from outside
|
|
63
|
+
|
|
64
|
+
- Telegram and Slack put a 👀 on the message that started a turn and take it
|
|
65
|
+
off when the turn settles; a restart and a periodic sweep clear stragglers.
|
|
66
|
+
A 👀 that never clears means the turn died, not that you are still thinking.
|
|
67
|
+
- Every finished reply carries its cost: elapsed time and the context size at
|
|
68
|
+
completion (`1m14s · 32K tok`) — a running total, not this turn's spend. IM
|
|
69
|
+
shows it as a footer line, the web on hover.
|
|
70
|
+
- A reply past the platform's message cap is split across several messages
|
|
71
|
+
(Telegram ~3.8k chars); the footer and the next-step buttons ride the last
|
|
72
|
+
one.
|
|
73
|
+
|
|
74
|
+
## Who may talk (groups and binding)
|
|
75
|
+
|
|
76
|
+
- Group messages pass a per-chat gate the operator sets: it can require a
|
|
77
|
+
mention, require the sender to be bound, or both. Dropped messages are
|
|
78
|
+
logged, never answered.
|
|
79
|
+
- Binding: the operator issues a code in the Console; the user DMs the bot
|
|
80
|
+
`/bind <code>` (Slack: `bind <code>`). Codes expire after ~10 minutes.
|
|
81
|
+
- An unbound DM sender is told how to bind at most once per 10 minutes;
|
|
82
|
+
their other messages are dropped. "The bot ignores my DMs" usually means
|
|
83
|
+
not bound.
|
|
84
|
+
|
|
85
|
+
## Service restart, reload and update
|
|
86
|
+
|
|
87
|
+
- `pier restart` is the graceful systemd path: it refuses new work, waits up to
|
|
88
|
+
five minutes for active turns and Task runs, then restarts. If the deadline
|
|
89
|
+
aborts an IM turn, the next process tells that conversation.
|
|
90
|
+
- `pier reload` stays in-process: channel adapters re-read configuration and
|
|
91
|
+
idle, unwatched sessions reopen with current agent files on their next
|
|
92
|
+
message. Streaming or watched sessions are not interrupted.
|
|
93
|
+
- `pier update` is deliberately different: the separate updater hard-stops the
|
|
94
|
+
service, backs up the database, replaces the package, and starts it again.
|
|
95
|
+
It can interrupt active work. All three are operator shell commands for an
|
|
96
|
+
installed Linux systemd service, not tools available to the agent.
|
|
97
|
+
|
|
98
|
+
## Only the Console can change
|
|
99
|
+
|
|
100
|
+
Channel tokens and connections, per-chat gate policies, bind codes, provider
|
|
101
|
+
logins and credentials, the public address, security unlock. You have no tool
|
|
102
|
+
for any of these: point the user at the Console instead of improvising.
|
|
103
|
+
|
|
104
|
+
## The rest of the surface
|
|
105
|
+
|
|
106
|
+
- Chat conventions — next-step buttons, `file://` attachments, staying
|
|
107
|
+
silent, `[name<id> time]` sender headers — are in `<pier>/AGENTS.md`,
|
|
108
|
+
already in your context.
|
|
109
|
+
- Delegating and scheduling work: the pier-tasks skill. Reading and posting
|
|
110
|
+
Slack: pier-slack. Presenting a report as a page: pier-boards.
|