@timqi/pier 0.0.8 → 0.0.15
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 +26 -9
- package/dist/agent/events.js +53 -7
- package/dist/agent/listing.js +253 -0
- package/dist/agent/pi.js +279 -32
- package/dist/boards/boards.js +65 -16
- package/dist/boards/pier.css +1 -1
- package/dist/channels/attach.js +87 -0
- package/dist/channels/control.js +2 -2
- package/dist/channels/conversations.js +10 -0
- package/dist/channels/lark-api.js +38 -0
- package/dist/channels/lark-outbound.js +11 -2
- package/dist/channels/slack-api.js +36 -0
- package/dist/channels/slack-outbound.js +12 -2
- package/dist/channels/slack-tool.js +49 -9
- package/dist/channels/telegram-api.js +21 -2
- package/dist/channels/telegram.js +23 -8
- package/dist/cli.js +34 -0
- package/dist/core/identity.js +18 -0
- package/dist/core/inbound-file.js +3 -1
- package/dist/core/reply.js +2 -1
- package/dist/core/router.js +99 -11
- package/dist/db.js +87 -0
- package/dist/extensions/index.js +37 -0
- package/dist/extensions/web/anthropic.js +118 -0
- package/dist/extensions/web/artifacts.js +62 -0
- package/dist/extensions/web/content.js +130 -0
- package/dist/extensions/web/http.js +106 -0
- package/dist/extensions/web/index.js +9 -0
- package/dist/extensions/web/json.js +5 -0
- package/dist/extensions/web/language.js +47 -0
- package/dist/extensions/web/openai.js +112 -0
- package/dist/extensions/web/provider.js +121 -0
- package/dist/extensions/web/tools.js +304 -0
- package/dist/limits.js +14 -0
- package/dist/main.js +76 -10
- package/dist/paths.js +21 -1
- package/dist/settings.js +112 -13
- package/dist/tasks/agent.js +18 -4
- package/dist/tasks/callbacks.js +20 -1
- package/dist/tasks/definitions.js +56 -12
- package/dist/tasks/execution.js +5 -1
- package/dist/tasks/groups.js +4 -4
- package/dist/tasks/messages.js +4 -2
- package/dist/tasks/runs.js +2 -2
- package/dist/tasks/service.js +16 -6
- package/dist/tasks/tool.js +0 -12
- package/dist/tools-task.js +155 -0
- package/dist/tools.js +875 -0
- package/dist/web/auth.js +5 -3
- package/dist/web/explorer.js +15 -2
- package/dist/web/files.js +1 -1
- package/dist/web/instance.js +175 -22
- package/dist/web/providers.js +16 -0
- package/dist/web/public/assets/{ghostty-web-CcIc8O2I.js → ghostty-web-xcUrfRRs.js} +1 -1
- package/dist/web/public/assets/index-BWDlAMK2.js +93 -0
- package/dist/web/public/assets/index-DHqZnZr7.css +2 -0
- package/dist/web/public/index.html +5 -8
- package/dist/web/public/sw.js +4 -0
- package/dist/web/push.js +33 -9
- package/dist/web/repos.js +75 -0
- package/dist/web/server.js +170 -52
- package/dist/web/session-state.js +57 -44
- package/dist/web/terminal.js +34 -4
- package/dist/web/types.js +5 -0
- package/package.json +1 -1
- package/skills/pier-boards/SKILL.md +23 -13
- package/skills/pier-help/SKILL.md +1 -1
- package/skills/pier-slack/SKILL.md +21 -1
- package/skills/pier-tasks/SKILL.md +2 -2
- package/dist/web/public/assets/index-DmDJKOLH.js +0 -90
- package/dist/web/public/assets/index-gcSJ9QZ5.css +0 -2
package/dist/web/server.js
CHANGED
|
@@ -14,8 +14,11 @@ import { Router } from "../core/router.js";
|
|
|
14
14
|
import { registerExplorerRoutes } from "./explorer.js";
|
|
15
15
|
import { guarded, registerFileRoutes } from "./files.js";
|
|
16
16
|
import { isThinkingLevel } from "../core/types.js";
|
|
17
|
+
import { SESSION_TITLE_MAX } from "../limits.js";
|
|
17
18
|
import { saveInbound } from "../core/inbox.js";
|
|
18
19
|
import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
|
|
20
|
+
import { RepoIndex } from "./repos.js";
|
|
21
|
+
import {} from "./session-state.js";
|
|
19
22
|
import { registerInstanceRoutes } from "./instance.js";
|
|
20
23
|
import { registerProviderRoutes } from "./providers.js";
|
|
21
24
|
const log = logger("web");
|
|
@@ -37,7 +40,7 @@ export const withTabPrefix = (html, prefix) => prefix
|
|
|
37
40
|
const HEARTBEAT_MS = 15_000;
|
|
38
41
|
// Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
|
|
39
42
|
const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
|
|
40
|
-
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, secrets, onUnlocked, reload, updates, updater, backgroundRuns, }) {
|
|
43
|
+
export function createServer({ factory, router, hub, sessions: state, config, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, channelOf, }) {
|
|
41
44
|
const app = new Hono();
|
|
42
45
|
// A finished turn marks its session unread until some client reports it was
|
|
43
46
|
// seen (session selected + tab visible → POST read below). Server-side so
|
|
@@ -57,6 +60,8 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
57
60
|
state.setUnread(e.sessionId, true);
|
|
58
61
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
59
62
|
});
|
|
63
|
+
// Repository identity per project directory, refreshed off the request path.
|
|
64
|
+
const repos = new RepoIndex(() => hub.emitWorkspace({ type: "sessions-changed" }));
|
|
60
65
|
/** Background runs this session launched that are still in flight. */
|
|
61
66
|
const activeRuns = (id) => backgroundRuns?.(id).filter((r) => r.state === "queued" || r.state === "running").length ?? 0;
|
|
62
67
|
/** The web channel's session for `id` — every session route resolves here. */
|
|
@@ -65,37 +70,38 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
65
70
|
// only once the first assistant message lands. Merged into the list below
|
|
66
71
|
// so every client sees a new session immediately; dropped once Pi lists it.
|
|
67
72
|
const nascent = new Map();
|
|
68
|
-
|
|
69
|
-
|
|
73
|
+
/** `ensure`, plus ghost cleanup. A session created and never messaged does
|
|
74
|
+
* not survive a restart or an eviction (Pi persisted nothing), but while
|
|
75
|
+
* this process lives it is in `nascent` and therefore in the rail — left
|
|
76
|
+
* alone, clicking it 404s forever. The load path is where a ghost is
|
|
77
|
+
* discovered, so it is where the entry and its pin are dropped and every
|
|
78
|
+
* rail told; the 404 then says what happened instead of looking like a
|
|
79
|
+
* crash (§5b). */
|
|
80
|
+
const ensureLoadable = async (id) => {
|
|
81
|
+
try {
|
|
82
|
+
return await ensure(id);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (String(err).includes("unknown session")) {
|
|
86
|
+
nascent.delete(id);
|
|
87
|
+
state.forget(id);
|
|
88
|
+
hub.emitWorkspace({ type: "sessions-changed" });
|
|
89
|
+
throw new Error(`session ${id} no longer exists — it never got a first reply, so nothing was persisted; its rail entry was removed`);
|
|
90
|
+
}
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
// A listing stats every session file and parses whatever grew — milliseconds
|
|
95
|
+
// warm, one scan cold. Concurrent consumers share it, whatever the factory
|
|
96
|
+
// behind the seam retains of its own; nothing here is cached past the last
|
|
97
|
+
// of them.
|
|
70
98
|
let listing;
|
|
71
|
-
|
|
72
|
-
const listSessions = () => listing ??= factory.list()
|
|
73
|
-
.then((rows) => {
|
|
74
|
-
state.remember(rows);
|
|
75
|
-
return rows;
|
|
76
|
-
})
|
|
77
|
-
.finally(() => {
|
|
99
|
+
const listSessions = () => listing ??= factory.list().finally(() => {
|
|
78
100
|
listing = undefined;
|
|
79
101
|
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
pinned,
|
|
84
|
-
unread,
|
|
85
|
-
activeRuns: activeRuns(s.id),
|
|
86
|
-
});
|
|
87
|
-
app.get("/api/projects", async (c) => {
|
|
88
|
-
// Existing databases have pin booleans but no summaries. Pay one legacy
|
|
89
|
-
// scan, fill those rows, then every later Projects read is SQLite-only.
|
|
90
|
-
if (projectBackfillNeeded) {
|
|
91
|
-
await listSessions();
|
|
92
|
-
// Do not retry on every request, and do not clear a pin whose transcript
|
|
93
|
-
// happened to be unreadable. A later explicit full listing can repair it.
|
|
94
|
-
projectBackfillNeeded = false;
|
|
95
|
-
}
|
|
96
|
-
return c.json(state.projects().map((s) => present(s, true, s.unread)));
|
|
97
|
-
});
|
|
98
|
-
app.get("/api/sessions", async (c) => {
|
|
102
|
+
/** Every session a surface may show: what Pi has written, plus the ones
|
|
103
|
+
* created here that it has not persisted yet. */
|
|
104
|
+
const allSessions = async () => {
|
|
99
105
|
const sessions = await listSessions();
|
|
100
106
|
for (const s of sessions)
|
|
101
107
|
nascent.delete(s.id);
|
|
@@ -103,11 +109,65 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
103
109
|
for (const [id, n] of nascent)
|
|
104
110
|
if (Date.now() - n.createdAt > 86_400_000)
|
|
105
111
|
nascent.delete(id);
|
|
112
|
+
return [
|
|
113
|
+
...[...nascent].map(([id, n]) => ({ id, ...n })),
|
|
114
|
+
...sessions,
|
|
115
|
+
];
|
|
116
|
+
};
|
|
117
|
+
// One session as every list renders it: the summary, what the workbench
|
|
118
|
+
// decided about it, and what is true of it right now.
|
|
119
|
+
// The listing's `modified` is dropped rather than forwarded: it dates a
|
|
120
|
+
// transcript, and no surface renders it.
|
|
121
|
+
const present = ({ modified: _drop, ...s }, own) => {
|
|
122
|
+
return {
|
|
123
|
+
...s,
|
|
124
|
+
...(own?.sort === undefined ? {} : { sort: own.sort }),
|
|
125
|
+
...(own?.projectSort === undefined ? {} : { projectSort: own.projectSort }),
|
|
126
|
+
// Which repository the directory belongs to, on every list and not only
|
|
127
|
+
// on the rail's: All sessions replaces the client's whole list, so a row
|
|
128
|
+
// without it there un-groups the worktrees the rail had just grouped.
|
|
129
|
+
// Whatever is known now — the probe is never waited on, and its answer
|
|
130
|
+
// arrives as a `sessions-changed`.
|
|
131
|
+
...repos.get(s.cwd),
|
|
132
|
+
state: router.stateOf(s.id) ?? "idle",
|
|
133
|
+
listed: own?.pinned ?? false,
|
|
134
|
+
unread: own?.unread ?? false,
|
|
135
|
+
channel: channelOf?.(s.id) ?? "web",
|
|
136
|
+
activeRuns: activeRuns(s.id),
|
|
137
|
+
};
|
|
138
|
+
};
|
|
139
|
+
// The rail: the same rows as the full list, minus everything Projects is not
|
|
140
|
+
// showing. One source, one shape — the rail used to read a second copy of
|
|
141
|
+
// the summaries out of SQLite, which is what had to be kept in step.
|
|
142
|
+
app.get("/api/projects", async (c) => {
|
|
106
143
|
const flags = state.flags();
|
|
107
|
-
return c.json(
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
144
|
+
return c.json((await allSessions())
|
|
145
|
+
.map((s) => present(s, flags.get(s.id)))
|
|
146
|
+
.filter((row) => row.listed));
|
|
147
|
+
});
|
|
148
|
+
// One drag, one write of the list that changed: the projects, or one
|
|
149
|
+
// project's sessions. Whole lists rather than a move — the client has just
|
|
150
|
+
// rendered the result, and replaying a move on top of a stale list would put
|
|
151
|
+
// the row somewhere nobody dropped it.
|
|
152
|
+
app.post("/api/projects/order", async (c) => {
|
|
153
|
+
const body = await c.req.json().catch(() => null);
|
|
154
|
+
const list = (raw) => raw === undefined
|
|
155
|
+
? undefined
|
|
156
|
+
: Array.isArray(raw) && raw.every((x) => typeof x === "string" && x)
|
|
157
|
+
? raw
|
|
158
|
+
: null;
|
|
159
|
+
const sessions = list(body?.sessions);
|
|
160
|
+
const projects = list(body?.projects);
|
|
161
|
+
if (sessions === null || projects === null || (!sessions && !projects)) {
|
|
162
|
+
return c.json({ error: "sessions and/or projects must be lists of ids" }, 400);
|
|
163
|
+
}
|
|
164
|
+
state.reorder({ sessions, projects });
|
|
165
|
+
hub.emitWorkspace({ type: "sessions-changed" });
|
|
166
|
+
return c.json({ ok: true });
|
|
167
|
+
});
|
|
168
|
+
app.get("/api/sessions", async (c) => {
|
|
169
|
+
const flags = state.flags();
|
|
170
|
+
return c.json((await allSessions()).map((s) => present(s, flags.get(s.id))));
|
|
111
171
|
});
|
|
112
172
|
app.post("/api/sessions", async (c) => {
|
|
113
173
|
const body = await c.req.json().catch(() => ({}));
|
|
@@ -119,7 +179,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
119
179
|
nascent.set(session.id, { cwd: body.cwd, createdAt });
|
|
120
180
|
router.attach({ channelId: "web", conversationId: session.id }, session);
|
|
121
181
|
// Created here = part of the workspace; pinning is what Projects lists.
|
|
122
|
-
state.pin(
|
|
182
|
+
state.pin(session.id, body.cwd, true);
|
|
123
183
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
124
184
|
return c.json({ id: session.id }, 201);
|
|
125
185
|
});
|
|
@@ -135,20 +195,17 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
135
195
|
});
|
|
136
196
|
app.post("/api/sessions/:id/pin", async (c) => {
|
|
137
197
|
const body = await c.req.json().catch(() => null);
|
|
138
|
-
if (typeof body?.pinned !== "boolean"
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
createdAt: body.createdAt,
|
|
150
|
-
...(body.title ? { title: body.title.slice(0, 80) } : {}),
|
|
151
|
-
}, body.pinned);
|
|
198
|
+
if (typeof body?.pinned !== "boolean")
|
|
199
|
+
return c.json({ error: "pinned required" }, 400);
|
|
200
|
+
const id = c.req.param("id");
|
|
201
|
+
// The directory comes from the listing, not from the client that clicked:
|
|
202
|
+
// it is the one fact this row keeps about the session, it is the key a
|
|
203
|
+
// project's manual place is stamped on, and the browser is not where a
|
|
204
|
+
// path should come from when the server already knows it.
|
|
205
|
+
const cwd = (await factory.find(id))?.cwd ?? nascent.get(id)?.cwd;
|
|
206
|
+
if (!cwd)
|
|
207
|
+
return c.json({ error: `session ${id} has no directory Pier can find` }, 404);
|
|
208
|
+
state.pin(id, cwd, body.pinned);
|
|
152
209
|
hub.emitWorkspace({ type: "sessions-changed" });
|
|
153
210
|
return c.json({ pinned: body.pinned });
|
|
154
211
|
});
|
|
@@ -161,7 +218,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
161
218
|
// deltas from SSE — transcript, live state, pending queue, model.
|
|
162
219
|
guarded(app, "GET", "/api/sessions/:id/history", 404, async (c) => {
|
|
163
220
|
const id = c.req.param("id");
|
|
164
|
-
const session = await
|
|
221
|
+
const session = await ensureLoadable(id);
|
|
165
222
|
return c.json({
|
|
166
223
|
turns: (await session.history()).map(slim),
|
|
167
224
|
lastSeq: hub.lastSeq(id),
|
|
@@ -256,11 +313,14 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
256
313
|
const { sessionId } = await router.dispatch({
|
|
257
314
|
key: { channelId: "web", conversationId: id },
|
|
258
315
|
senderId: "web",
|
|
316
|
+
// Named, not anonymous: a session reached from a group chat as well as
|
|
317
|
+
// from here attributes an unheaded message to whoever spoke last
|
|
318
|
+
// (core/identity.ts), which is the operator's own words in someone
|
|
319
|
+
// else's mouth.
|
|
320
|
+
sender: { id: "web", name: "operator" },
|
|
259
321
|
text: body.text,
|
|
260
322
|
mode,
|
|
261
323
|
});
|
|
262
|
-
if (state.title(id, body.text))
|
|
263
|
-
hub.emitWorkspace({ type: "sessions-changed" });
|
|
264
324
|
return c.json({ sessionId }, 202);
|
|
265
325
|
});
|
|
266
326
|
// Edit a user turn: rewind the transcript to just before it, then re-send
|
|
@@ -281,9 +341,13 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
281
341
|
if (session.state === "streaming")
|
|
282
342
|
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
283
343
|
await session.rewindToUserTurn(index);
|
|
344
|
+
// The rewind took the turns after this one out of the context, headers and
|
|
345
|
+
// all; what the model was told about who is speaking went with them.
|
|
346
|
+
router.forgetSender(id);
|
|
284
347
|
await router.dispatch({
|
|
285
348
|
key: { channelId: "web", conversationId: id },
|
|
286
349
|
senderId: "web",
|
|
350
|
+
sender: { id: "web", name: "operator" },
|
|
287
351
|
text: body.text,
|
|
288
352
|
mode: "auto",
|
|
289
353
|
});
|
|
@@ -309,6 +373,9 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
309
373
|
return c.json({ error: "queue is empty" }, 409);
|
|
310
374
|
if (mode === "restart")
|
|
311
375
|
await router.abort(id); // resolves once idle
|
|
376
|
+
// No sender here, unlike the other dispatches: the queued texts were
|
|
377
|
+
// headed when they were first dispatched — and in a shared session they
|
|
378
|
+
// are other speakers' words, which an operator header would claim.
|
|
312
379
|
await router.dispatch({
|
|
313
380
|
key: { channelId: "web", conversationId: id },
|
|
314
381
|
senderId: "web",
|
|
@@ -319,10 +386,48 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
319
386
|
});
|
|
320
387
|
// Recall: drop all pending queued messages and hand them back (composer restore).
|
|
321
388
|
guarded(app, "POST", "/api/sessions/:id/queue/recall", 404, async (c) => {
|
|
322
|
-
const
|
|
389
|
+
const id = c.req.param("id");
|
|
390
|
+
const session = await ensure(id);
|
|
323
391
|
const { steering, followUp } = await session.clearQueue();
|
|
392
|
+
// Those messages carried the header that told the session who is speaking,
|
|
393
|
+
// and they are going back to the composer instead of to the model.
|
|
394
|
+
if (steering.length || followUp.length)
|
|
395
|
+
router.forgetSender(id);
|
|
324
396
|
return c.json({ messages: [...steering, ...followUp] });
|
|
325
397
|
});
|
|
398
|
+
// Shrink the context on demand: Pi summarizes the older transcript away and
|
|
399
|
+
// the session continues from the summary. Refused while streaming, like the
|
|
400
|
+
// edit route above and for the same reason — Pi's own compaction aborts a
|
|
401
|
+
// running turn to do it, and losing a turn is not what the button offered.
|
|
402
|
+
// The result is not in this response: it arrives on the session's stream as
|
|
403
|
+
// `context-compacted` (agent/events.ts), which is also the only place the
|
|
404
|
+
// automatic compaction can be seen.
|
|
405
|
+
guarded(app, "POST", "/api/sessions/:id/compact", 404, async (c) => {
|
|
406
|
+
const session = await ensure(c.req.param("id"));
|
|
407
|
+
if (session.state === "streaming")
|
|
408
|
+
return c.json({ error: "busy — stop the turn first" }, 409);
|
|
409
|
+
// The check above is a courtesy, not the lock: two clicks pass it on the
|
|
410
|
+
// same tick, so the seam refuses the second one (agent/pi.ts) and its
|
|
411
|
+
// refusal keeps the status this route already uses for "not now" — a 404
|
|
412
|
+
// from `guarded` would have read as "no such session".
|
|
413
|
+
return await session.compact().then(() => c.json({ ok: true }, 202), (err) => c.json({ error: String(err) }, 409));
|
|
414
|
+
});
|
|
415
|
+
// A name, so a title is what you called it instead of the first 80
|
|
416
|
+
// characters you happened to type. Not refused while streaming: a rename has
|
|
417
|
+
// nothing to do with the turn running, and the transcript takes an append.
|
|
418
|
+
guarded(app, "POST", "/api/sessions/:id/rename", 404, async (c) => {
|
|
419
|
+
const body = await c.req.json().catch(() => null);
|
|
420
|
+
if (typeof body?.name !== "string")
|
|
421
|
+
return c.json({ error: "name required" }, 400);
|
|
422
|
+
const id = c.req.param("id");
|
|
423
|
+
await (await ensure(id)).rename(body.name.trim().slice(0, SESSION_TITLE_MAX));
|
|
424
|
+
// Nothing to write and nothing to report: the name went into the
|
|
425
|
+
// transcript, which is what every list reads. The event is how the
|
|
426
|
+
// surfaces learn to re-read it, and the seam dropped its retained scan on
|
|
427
|
+
// the way out so the re-read sees the new name.
|
|
428
|
+
hub.emitWorkspace({ type: "sessions-changed" });
|
|
429
|
+
return c.json({ ok: true });
|
|
430
|
+
});
|
|
326
431
|
app.post("/api/sessions/:id/abort", async (c) => {
|
|
327
432
|
const id = c.req.param("id");
|
|
328
433
|
await router.abort(id);
|
|
@@ -396,6 +501,10 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
396
501
|
updates,
|
|
397
502
|
updater,
|
|
398
503
|
secrets,
|
|
504
|
+
catalog,
|
|
505
|
+
names,
|
|
506
|
+
onToolsChanged,
|
|
507
|
+
validateCustomTools,
|
|
399
508
|
onUnlocked,
|
|
400
509
|
onSettingsChanged: () => recycle("instance settings"),
|
|
401
510
|
});
|
|
@@ -445,6 +554,15 @@ export function createServer({ factory, router, hub, sessions: state, config, pr
|
|
|
445
554
|
c.header("cache-control", "private, no-cache");
|
|
446
555
|
await next();
|
|
447
556
|
});
|
|
557
|
+
// Hashed bundles never change under their name — a release writes new names,
|
|
558
|
+
// and the shell above is what re-points at them. Without this they carry only
|
|
559
|
+
// the auth layer's bare `private`, so a browser revalidates each one before it
|
|
560
|
+
// may reuse it: three round trips on a remote instance, one of them in front
|
|
561
|
+
// of the 636KB terminal emulator, every time the workbench is opened.
|
|
562
|
+
app.get("/assets/*", async (c, next) => {
|
|
563
|
+
c.header("cache-control", "private, max-age=31536000, immutable");
|
|
564
|
+
await next();
|
|
565
|
+
});
|
|
448
566
|
app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || "." }));
|
|
449
567
|
return app;
|
|
450
568
|
}
|
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// What the workbench decided about a session, and nothing a transcript already
|
|
2
|
+
// knows. Ownership of a row in Projects (`pinned`), a finished turn nobody has
|
|
3
|
+
// looked at (`unread`), and the two places a hand put it in (`sort`,
|
|
4
|
+
// `project_sort`).
|
|
5
|
+
//
|
|
6
|
+
// It used to mirror the summary too — cwd, title, created_at, last_active —
|
|
7
|
+
// because listing sessions meant parsing every transcript on disk (~237ms) and
|
|
8
|
+
// the rail could not pay that on every read. agent/listing.ts made a listing
|
|
9
|
+
// cheap, so the mirror bought nothing and cost two stores kept in step: a
|
|
10
|
+
// backfill gate, a repair pass on every full listing, a touch at the end of
|
|
11
|
+
// every turn, a title write on the first prompt, a second write on rename.
|
|
12
|
+
// Deriving beats syncing; all of it is gone.
|
|
13
|
+
//
|
|
14
|
+
// `cwd` stayed, and is not a mirror: it is the key a project's manual place is
|
|
15
|
+
// stamped on (`reorder`, and the sibling lookup in `pin`), and a session's
|
|
16
|
+
// working directory is fixed when its transcript is created — an immutable key
|
|
17
|
+
// needs no synchronising.
|
|
4
18
|
//
|
|
5
19
|
// One row per session rather than two JSON files: the unread flag is written at
|
|
6
20
|
// the end of every turn, and rewriting a whole file on each of those writes
|
|
@@ -20,48 +34,44 @@ export class SessionStateStore {
|
|
|
20
34
|
this.#db.prepare(`INSERT INTO session_state(session_id, unread) VALUES (?, ?)
|
|
21
35
|
ON CONFLICT(session_id) DO UPDATE SET unread = excluded.unread`).run(sessionId, unread ? 1 : 0);
|
|
22
36
|
}
|
|
23
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
37
|
+
/** Projects takes the session, or gives it up. Membership lasts until a hand
|
|
38
|
+
* ends it: nothing expires, so what is pinned is what the rail draws until
|
|
39
|
+
* the ✓ on the row says otherwise. */
|
|
40
|
+
pin(sessionId, cwd, pinned) {
|
|
41
|
+
// A new session joins a project that already has a place in the list.
|
|
42
|
+
// Unranked it would sort on top — lifting the whole project with it, which
|
|
43
|
+
// is the jump manual order exists to stop.
|
|
44
|
+
const sibling = this.#db.prepare("SELECT project_sort AS rank FROM session_state WHERE cwd = ? AND project_sort IS NOT NULL LIMIT 1").get(cwd);
|
|
45
|
+
this.#db.prepare(`INSERT INTO session_state(session_id, pinned, cwd, project_sort)
|
|
46
|
+
VALUES (?, ?, ?, ?)
|
|
27
47
|
ON CONFLICT(session_id) DO UPDATE SET
|
|
28
48
|
pinned = excluded.pinned,
|
|
29
49
|
cwd = excluded.cwd,
|
|
30
|
-
|
|
31
|
-
created_at = excluded.created_at`).run(summary.id, pinned ? 1 : 0, summary.cwd, summary.title ?? null, summary.createdAt);
|
|
50
|
+
project_sort = COALESCE(session_state.project_sort, excluded.project_sort)`).run(sessionId, pinned ? 1 : 0, cwd, sibling?.rank ?? null);
|
|
32
51
|
}
|
|
33
|
-
/**
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}));
|
|
52
|
+
/** One drag = one write of the whole list it reordered: index is the place.
|
|
53
|
+
* `sessions` are ids (a session's place inside its project), `projects` are
|
|
54
|
+
* cwds, whose place is stamped on every session that has that cwd. */
|
|
55
|
+
reorder(order) {
|
|
56
|
+
const bySession = this.#db.prepare("UPDATE session_state SET sort = ? WHERE session_id = ?");
|
|
57
|
+
const byCwd = this.#db.prepare("UPDATE session_state SET project_sort = ? WHERE cwd = ?");
|
|
58
|
+
this.#tx(() => {
|
|
59
|
+
order.sessions?.forEach((id, i) => bySession.run(i, id));
|
|
60
|
+
order.projects?.forEach((cwd, i) => byCwd.run(i, cwd));
|
|
61
|
+
});
|
|
44
62
|
}
|
|
45
|
-
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return row?.title || row?.cwd?.split("/").filter(Boolean).at(-1) || "Pier session";
|
|
63
|
+
/** Drop a session's organization row entirely — pin, order, unread. For
|
|
64
|
+
* ghosts: Pi persists a session only once its first assistant reply lands,
|
|
65
|
+
* so a created-and-never-messaged one cannot be resumed, and its remembered
|
|
66
|
+
* row would otherwise sit in the rail 404ing forever. */
|
|
67
|
+
forget(sessionId) {
|
|
68
|
+
this.#db.prepare("DELETE FROM session_state WHERE session_id = ?").run(sessionId);
|
|
52
69
|
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
/** A full listing is rare; use it to repair metadata for rows we already own. */
|
|
57
|
-
remember(summaries) {
|
|
58
|
-
const update = this.#db.prepare(`UPDATE session_state SET
|
|
59
|
-
cwd = ?, title = COALESCE(?, title), created_at = ?
|
|
60
|
-
WHERE session_id = ?`);
|
|
70
|
+
/** All-or-nothing: a half-written order is a list nobody arranged. */
|
|
71
|
+
#tx(run) {
|
|
61
72
|
this.#db.exec("BEGIN");
|
|
62
73
|
try {
|
|
63
|
-
|
|
64
|
-
update.run(s.cwd, s.title ?? null, s.createdAt, s.id);
|
|
74
|
+
run();
|
|
65
75
|
this.#db.exec("COMMIT");
|
|
66
76
|
}
|
|
67
77
|
catch (err) {
|
|
@@ -69,13 +79,16 @@ export class SessionStateStore {
|
|
|
69
79
|
throw err;
|
|
70
80
|
}
|
|
71
81
|
}
|
|
82
|
+
/** What this store knows about the sessions it knows anything about, for a
|
|
83
|
+
* caller holding the listing. */
|
|
72
84
|
flags() {
|
|
73
|
-
const rows = this.#db.prepare(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
85
|
+
const rows = this.#db.prepare(`SELECT session_id AS id, pinned, unread, sort, project_sort AS projectSort
|
|
86
|
+
FROM session_state WHERE pinned = 1 OR unread = 1`).all();
|
|
87
|
+
return new Map(rows.map((r) => [r.id, {
|
|
88
|
+
pinned: r.pinned === 1,
|
|
89
|
+
unread: r.unread === 1,
|
|
90
|
+
...(r.sort === null ? {} : { sort: r.sort }),
|
|
91
|
+
...(r.projectSort === null ? {} : { projectSort: r.projectSort }),
|
|
92
|
+
}]));
|
|
80
93
|
}
|
|
81
94
|
}
|
package/dist/web/terminal.js
CHANGED
|
@@ -28,6 +28,15 @@ const MAX_FRAME_BYTES = 1024 * 1024;
|
|
|
28
28
|
// start under. Inheriting these can attach the shell back into Pier's parent
|
|
29
29
|
// tmux session; SSH_AUTH_SOCK deliberately stays so git/ssh keep working.
|
|
30
30
|
const PARENT_TERMINAL_ENV = ["TMUX", "TMUX_PANE", "SSH_TTY", "SSH_CLIENT", "SSH_CONNECTION"];
|
|
31
|
+
// Pier's own configuration is Pier's, not this shell's. `NODE_ENV=production`
|
|
32
|
+
// alone turns an `npm i` typed here into an install with no dev dependencies,
|
|
33
|
+
// and every `PI_*`/`PIER_*` would point a `pi` started here at Pier's own
|
|
34
|
+
// instance rather than the person's. Everything else is inherited on purpose:
|
|
35
|
+
// PATH, LANG, SSH_AUTH_SOCK and the session's XDG/DBUS handles are what make
|
|
36
|
+
// the shell usable, and on a service-managed instance nothing else supplies
|
|
37
|
+
// them.
|
|
38
|
+
const PIER_OWN_ENV = ["NODE_ENV", "PORT", "HOST"];
|
|
39
|
+
const PIER_OWN_PREFIX = /^PI(ER)?_/;
|
|
31
40
|
const send = (sock, data) => {
|
|
32
41
|
try {
|
|
33
42
|
sock.send(data);
|
|
@@ -44,6 +53,7 @@ export class TerminalHub {
|
|
|
44
53
|
#shell;
|
|
45
54
|
#idleMs;
|
|
46
55
|
#maxTerms;
|
|
56
|
+
#initCommand;
|
|
47
57
|
#sweeper;
|
|
48
58
|
#closeListeners = new Set();
|
|
49
59
|
#closed = false;
|
|
@@ -51,6 +61,7 @@ export class TerminalHub {
|
|
|
51
61
|
this.#shell = opts.shell ?? process.env.SHELL ?? "/bin/bash";
|
|
52
62
|
this.#idleMs = opts.idleMs ?? IDLE_MS;
|
|
53
63
|
this.#maxTerms = opts.maxTerms ?? MAX_TERMS;
|
|
64
|
+
this.#initCommand = opts.initCommand ?? (() => "");
|
|
54
65
|
this.#sweeper = setInterval(() => this.sweep(Date.now()), SWEEP_MS);
|
|
55
66
|
this.#sweeper.unref();
|
|
56
67
|
}
|
|
@@ -117,8 +128,11 @@ export class TerminalHub {
|
|
|
117
128
|
}
|
|
118
129
|
#spawn(cwd) {
|
|
119
130
|
const env = { ...process.env };
|
|
120
|
-
for (const key of
|
|
121
|
-
|
|
131
|
+
for (const key of Object.keys(env)) {
|
|
132
|
+
if (PARENT_TERMINAL_ENV.includes(key) || PIER_OWN_ENV.includes(key) || PIER_OWN_PREFIX.test(key)) {
|
|
133
|
+
delete env[key];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
122
136
|
const pty = spawn(this.#shell, [], {
|
|
123
137
|
name: "xterm-256color",
|
|
124
138
|
cols: 120,
|
|
@@ -129,6 +143,14 @@ export class TerminalHub {
|
|
|
129
143
|
const term = { pty, ring: [], ringBytes: 0, clients: new Set(), idleSince: Infinity };
|
|
130
144
|
this.#terms.set(cwd, term);
|
|
131
145
|
log.info(`shell ${pty.pid} for ${cwd}`);
|
|
146
|
+
// Typed in, not exec'd: the shell stays the parent, so quitting whatever
|
|
147
|
+
// this starts leaves a usable prompt, and the echo plus any error is in
|
|
148
|
+
// the ring where the person can see what ran. The tty buffers it until the
|
|
149
|
+
// shell's first read, so no wait is needed. A reattach never repeats it —
|
|
150
|
+
// this runs once per pty, which is once per cwd.
|
|
151
|
+
const init = this.#initCommand().trim();
|
|
152
|
+
if (init)
|
|
153
|
+
pty.write(`${init}\r`);
|
|
132
154
|
pty.onData((data) => {
|
|
133
155
|
const chunk = Buffer.from(data);
|
|
134
156
|
term.ring.push(chunk);
|
|
@@ -179,6 +201,13 @@ export class TerminalHub {
|
|
|
179
201
|
term.pty.write(msg.d);
|
|
180
202
|
return;
|
|
181
203
|
}
|
|
204
|
+
// The only way a page can end a shell: everything attached to it is told by
|
|
205
|
+
// the exit path below, exactly as if the shell had exited on its own.
|
|
206
|
+
if (msg.t === "restart") {
|
|
207
|
+
log.info(`shell ${term.pty.pid} for ${cwd} killed on request`);
|
|
208
|
+
term.pty.kill();
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
182
211
|
if (msg.t === "resize" &&
|
|
183
212
|
typeof msg.cols === "number" && typeof msg.rows === "number" &&
|
|
184
213
|
Number.isInteger(msg.cols) && Number.isInteger(msg.rows) &&
|
|
@@ -225,8 +254,9 @@ export class TerminalHub {
|
|
|
225
254
|
/** The upgrade seam: `/api/terminal?cwd=…` behind the same password boundary
|
|
226
255
|
* as every route. SameSite=Lax already withholds the cookie cross-site; the
|
|
227
256
|
* Origin check is the explicit copy of that fact. */
|
|
228
|
-
export function attachTerminal(server, auth,
|
|
229
|
-
const
|
|
257
|
+
export function attachTerminal(server, auth, opts = {}) {
|
|
258
|
+
const heartbeatMs = opts.heartbeatMs ?? HEARTBEAT_MS;
|
|
259
|
+
const hub = new TerminalHub(opts);
|
|
230
260
|
const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
|
|
231
261
|
const alive = new WeakSet();
|
|
232
262
|
const heartbeat = setInterval(() => {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The shapes web's HTTP answers carry beyond the stores they come from — the
|
|
2
|
+
// area's own wire vocabulary, and the one file in web/ the browser may import
|
|
3
|
+
// type-only (docs/architecture.md, same rule as tasks/types.ts). Nothing here
|
|
4
|
+
// runs: no imports, no values, so a page pays nothing for reading it.
|
|
5
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pier-boards
|
|
3
|
-
description: Publish a Board — a folder of static HTML Pier serves at a stable URL
|
|
3
|
+
description: Publish a Board — a folder of static HTML Pier serves at a stable URL. Read before building any page-shaped deliverable (report, digest, dashboard) or editing an existing board.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Building a Pier board
|
|
@@ -27,23 +27,33 @@ session may read or rewrite any board, and closing this one changes nothing.
|
|
|
27
27
|
}
|
|
28
28
|
```
|
|
29
29
|
|
|
30
|
-
Those four fields are the whole manifest.
|
|
30
|
+
Those four fields are the whole manifest (publishing adds a fifth — below).
|
|
31
31
|
|
|
32
|
-
- `slug`: `[a-z0-9][a-z0-9-]{0,63}`, and it is the URL — short and stable.
|
|
32
|
+
- `slug`: `[a-z0-9][a-z0-9-]{0,63}`, and it is the URL — short and stable. Do
|
|
33
|
+
not add random characters of your own; publishing adds them (see below).
|
|
33
34
|
- `description` is the Console list entry: write it for someone who has
|
|
34
35
|
forgotten this conversation.
|
|
35
36
|
- `sessions`: append your own id, never replace — other ids are provenance too.
|
|
36
37
|
|
|
37
38
|
## Publish, then hand over the link
|
|
38
39
|
|
|
39
|
-
`"public": true` serves the board
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
`"public": true` serves the board **with no password**. Set it only if the user
|
|
41
|
+
asked for a public or shareable board *in this request*; otherwise leave it
|
|
42
|
+
`false` and say the board is private. Never publish personal data or anything
|
|
43
|
+
the user has not seen.
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
The published address is `/p/<slug>-<token>/`, not `/p/<slug>/`, so a public
|
|
46
|
+
board's URL cannot be guessed from its name. `token` is a fifth manifest field
|
|
47
|
+
you write next to `"public": true` — eight hex characters from
|
|
48
|
+
`openssl rand -hex 4`, never invented in your head, never reused between
|
|
49
|
+
boards. Leave it out and Pier mints one on the first request, but then the link
|
|
50
|
+
is only visible in the Console, so write it yourself and you can hand it over
|
|
51
|
+
in the same message.
|
|
52
|
+
|
|
53
|
+
Asked to make an existing board public? Set `"public": true` and a fresh
|
|
54
|
+
`token` in `board.json`, then reply with the `/p/<slug>-<token>/` link — that is
|
|
55
|
+
the whole answer. Already has a token? Keep it: the link may be out there. No
|
|
56
|
+
verification step, no narrating the edit, no restating what the page holds.
|
|
47
57
|
|
|
48
58
|
The message announcing the board carries **one bare URL** — paste the address
|
|
49
59
|
itself, never `[title](url)`: link labels get mangled or truncated on some chat
|
|
@@ -54,10 +64,10 @@ surfaces, and the title is already on the page. No filesystem paths either —
|
|
|
54
64
|
| The user asked for | Send |
|
|
55
65
|
| --- | --- |
|
|
56
66
|
| 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 |
|
|
67
|
+
| a **public** board | `https://pier.example.com/p/weekly-digest-3f9ac128/` — no password; the suffix is the manifest's `token`, copied verbatim |
|
|
58
68
|
|
|
59
69
|
Never both: the pair invites pasting the password-free URL of a board that was
|
|
60
|
-
never meant to leave the workspace, and `/p/<slug>/` 404s unless the manifest
|
|
70
|
+
never meant to leave the workspace, and `/p/<slug>-<token>/` 404s unless the manifest
|
|
61
71
|
says `"public": true`. No address configured? Give the path, say Console →
|
|
62
72
|
Settings turns it into a link, and never guess a host.
|
|
63
73
|
|
|
@@ -74,7 +84,7 @@ build, no npm, no framework:
|
|
|
74
84
|
<meta charset="utf-8">
|
|
75
85
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
86
|
<title>Weekly digest — infra</title>
|
|
77
|
-
<link rel="stylesheet" href="/
|
|
87
|
+
<link rel="stylesheet" href="/p/_assets/pier.css">
|
|
78
88
|
</head>
|
|
79
89
|
<body>
|
|
80
90
|
<div class="hero">
|