@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/dist/agent/pi.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// The ONLY file allowed to import @earendil-works/pi-*. Implements the
|
|
2
|
+
// AgentFactory/AgentSession seam from src/core/types.ts on the Pi SDK.
|
|
3
|
+
// No Pi type may appear in an exported signature.
|
|
4
|
+
import { createAgentSession, DefaultResourceLoader, defineTool, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { logger } from "../log.js";
|
|
6
|
+
import { imageAt, toChatTurns, toSessionEvents, turnMetaAt, } from "./events.js";
|
|
7
|
+
import { defaultAgentDir } from "./config.js";
|
|
8
|
+
import { curateModels } from "./models.js";
|
|
9
|
+
const log = logger("agent");
|
|
10
|
+
const toImageContent = (images) => images?.map((i) => ({ type: "image", data: i.data, mimeType: i.mimeType }));
|
|
11
|
+
/** Pi's bash tool has no default timeout, so a hung command holds the turn
|
|
12
|
+
* until someone aborts it — nobody is watching in a scheduled task. Kept below
|
|
13
|
+
* the task-run timeout (tasks/definitions.ts) so a stuck command comes back as
|
|
14
|
+
* a tool error the agent can retry with an explicit longer timeout, instead of
|
|
15
|
+
* killing the whole run. */
|
|
16
|
+
const BASH_DEFAULT_TIMEOUT_SECONDS = 600;
|
|
17
|
+
/** Patching the call is cheaper than replacing the tool: the built-in keeps its
|
|
18
|
+
* shell settings, and the agent spends no tokens deciding a timeout. */
|
|
19
|
+
const bashTimeoutDefault = (pi) => {
|
|
20
|
+
pi.on("tool_call", (event) => {
|
|
21
|
+
if (event.toolName === "bash" && event.input.timeout === undefined) {
|
|
22
|
+
event.input.timeout = BASH_DEFAULT_TIMEOUT_SECONDS;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
class PiSession {
|
|
27
|
+
pi;
|
|
28
|
+
constructor(pi) {
|
|
29
|
+
this.pi = pi;
|
|
30
|
+
}
|
|
31
|
+
get id() {
|
|
32
|
+
return this.pi.sessionId;
|
|
33
|
+
}
|
|
34
|
+
get state() {
|
|
35
|
+
return this.pi.isStreaming ? "streaming" : "idle";
|
|
36
|
+
}
|
|
37
|
+
get model() {
|
|
38
|
+
const m = this.pi.model;
|
|
39
|
+
return m ? { provider: m.provider, id: m.id } : undefined;
|
|
40
|
+
}
|
|
41
|
+
get thinkingLevel() {
|
|
42
|
+
return this.pi.thinkingLevel;
|
|
43
|
+
}
|
|
44
|
+
get contextUsage() {
|
|
45
|
+
const u = this.pi.getContextUsage();
|
|
46
|
+
return u ? { tokens: u.tokens, contextWindow: u.contextWindow } : undefined;
|
|
47
|
+
}
|
|
48
|
+
async setModel(ref) {
|
|
49
|
+
const m = this.pi.modelRuntime.getModel(ref.provider, ref.id);
|
|
50
|
+
if (!m) {
|
|
51
|
+
// Lazy discovery: the failure itself documents what is selectable.
|
|
52
|
+
const available = (await this.availableModels())
|
|
53
|
+
.slice(0, 8).map((entry) => `${entry.provider}/${entry.id}`).join(", ");
|
|
54
|
+
throw new Error(`unknown model: ${ref.provider}/${ref.id}; available: ${available}`);
|
|
55
|
+
}
|
|
56
|
+
await this.pi.setModel(m);
|
|
57
|
+
}
|
|
58
|
+
async availableModels() {
|
|
59
|
+
const available = await this.pi.modelRuntime.getAvailable();
|
|
60
|
+
const curated = curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning })));
|
|
61
|
+
// The session's active model must stay selectable even when curation
|
|
62
|
+
// (or an older catalog) would hide it.
|
|
63
|
+
const current = this.model;
|
|
64
|
+
if (current && !curated.some((m) => m.provider === current.provider && m.id === current.id)) {
|
|
65
|
+
curated.unshift(current);
|
|
66
|
+
}
|
|
67
|
+
return curated;
|
|
68
|
+
}
|
|
69
|
+
availableThinkingLevels() {
|
|
70
|
+
return this.pi.getAvailableThinkingLevels();
|
|
71
|
+
}
|
|
72
|
+
setThinkingLevel(level) {
|
|
73
|
+
this.pi.setThinkingLevel(level);
|
|
74
|
+
}
|
|
75
|
+
async pendingQueue() {
|
|
76
|
+
return {
|
|
77
|
+
steering: [...this.pi.getSteeringMessages()],
|
|
78
|
+
followUp: [...this.pi.getFollowUpMessages()],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
async clearQueue() {
|
|
82
|
+
return this.pi.clearQueue();
|
|
83
|
+
}
|
|
84
|
+
async history() {
|
|
85
|
+
return toChatTurns(this.pi.messages);
|
|
86
|
+
}
|
|
87
|
+
async image(ordinal) {
|
|
88
|
+
return imageAt(this.pi.messages, ordinal);
|
|
89
|
+
}
|
|
90
|
+
async rewindToUserTurn(index) {
|
|
91
|
+
const total = (await this.history()).filter((t) => t.role === "user").length;
|
|
92
|
+
// Anchor at the tail: branch entries keep compacted-away history that
|
|
93
|
+
// history() no longer shows, so only end-relative indices line up.
|
|
94
|
+
const back = total - index;
|
|
95
|
+
const users = this.pi.sessionManager
|
|
96
|
+
.getBranch()
|
|
97
|
+
.filter((e) => e.type === "message" && e.message.role === "user");
|
|
98
|
+
const target = back >= 1 ? users[users.length - back] : undefined;
|
|
99
|
+
if (!target)
|
|
100
|
+
throw new Error(`no user turn at index ${index}`);
|
|
101
|
+
// navigateTree on a user message moves the leaf to its parent — the old
|
|
102
|
+
// branch stays in the file but leaves the context.
|
|
103
|
+
const { cancelled } = await this.pi.navigateTree(target.id);
|
|
104
|
+
if (cancelled)
|
|
105
|
+
throw new Error("rewind cancelled");
|
|
106
|
+
}
|
|
107
|
+
prompt(text, images) {
|
|
108
|
+
return this.pi.prompt(text, { images: toImageContent(images) });
|
|
109
|
+
}
|
|
110
|
+
steer(text, images) {
|
|
111
|
+
return this.pi.steer(text, toImageContent(images));
|
|
112
|
+
}
|
|
113
|
+
followUp(text, images) {
|
|
114
|
+
return this.pi.followUp(text, toImageContent(images));
|
|
115
|
+
}
|
|
116
|
+
systemInput(text, origin, mode) {
|
|
117
|
+
return this.pi.sendCustomMessage({ customType: "pier.system-input", content: text, display: true, details: origin }, { triggerTurn: true, deliverAs: mode === "prompt" ? undefined : mode });
|
|
118
|
+
}
|
|
119
|
+
abort() {
|
|
120
|
+
return this.pi.abort();
|
|
121
|
+
}
|
|
122
|
+
subscribe(fn) {
|
|
123
|
+
return this.pi.subscribe((event) => {
|
|
124
|
+
for (const payload of toSessionEvents(event)) {
|
|
125
|
+
fn(payload.type === "turn-end" ? { ...payload, meta: this.lastTurnMeta() } : payload);
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** Meta of the just-finished turn; live path, so "now" is the completion. */
|
|
130
|
+
lastTurnMeta() {
|
|
131
|
+
const messages = this.pi.messages;
|
|
132
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
133
|
+
if (messages[i]?.role === "assistant")
|
|
134
|
+
return turnMetaAt(messages, i, Date.now());
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
async dispose() {
|
|
139
|
+
this.pi.dispose();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
export class PiAgentFactory {
|
|
143
|
+
extraTools;
|
|
144
|
+
instructions;
|
|
145
|
+
skillPaths;
|
|
146
|
+
credentials;
|
|
147
|
+
constructor(extraTools = [],
|
|
148
|
+
/** Appended as a virtual context file, so Pi's own prompt stays intact.
|
|
149
|
+
* Read per session, not captured once: it carries settings a user can
|
|
150
|
+
* change while the process runs, and the next session must say the new
|
|
151
|
+
* ones. */
|
|
152
|
+
instructions = () => "",
|
|
153
|
+
/** Skills documenting Pier's own tools: loaded per session, never installed
|
|
154
|
+
* into the user's global or project skill directories. */
|
|
155
|
+
skillPaths = [],
|
|
156
|
+
/** Provider credentials from pier.db instead of <agentDir>/auth.json.
|
|
157
|
+
* Optional only for bare test factories; main.ts always passes one, and
|
|
158
|
+
* every runtime built here reads and writes through it — an OAuth refresh
|
|
159
|
+
* persists to the database, never to a plaintext file. */
|
|
160
|
+
credentials) {
|
|
161
|
+
this.extraTools = extraTools;
|
|
162
|
+
this.instructions = instructions;
|
|
163
|
+
this.skillPaths = skillPaths;
|
|
164
|
+
this.credentials = credentials;
|
|
165
|
+
}
|
|
166
|
+
/** One runtime for the whole process; catalogs are global, not per session. */
|
|
167
|
+
catalog;
|
|
168
|
+
/** Structural fit: CredentialStore mirrors pi-ai's interface of the same
|
|
169
|
+
* name, so the SDK accepts it without this file exporting any SDK type. */
|
|
170
|
+
createRuntime() {
|
|
171
|
+
return ModelRuntime.create(this.credentials ? { credentials: this.credentials } : {});
|
|
172
|
+
}
|
|
173
|
+
async availableModels() {
|
|
174
|
+
this.catalog ??= this.createRuntime();
|
|
175
|
+
const available = await (await this.catalog).getAvailable();
|
|
176
|
+
return curateModels(available.map((m) => ({ provider: m.provider, id: m.id, reasoning: m.reasoning })));
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Pi discovers AGENTS.md itself; we append one more, in memory, telling the
|
|
180
|
+
* agent what the surface it is talking to can render. Layered as a context
|
|
181
|
+
* file (not a systemPromptOverride) so the user's own instructions still win.
|
|
182
|
+
*/
|
|
183
|
+
async resourceLoader(cwd) {
|
|
184
|
+
const loader = new DefaultResourceLoader({
|
|
185
|
+
cwd,
|
|
186
|
+
agentDir: defaultAgentDir(), // same discovery Pi would have done itself
|
|
187
|
+
additionalSkillPaths: this.skillPaths,
|
|
188
|
+
extensionFactories: [{ name: "pier-bash-timeout", factory: bashTimeoutDefault, hidden: true }],
|
|
189
|
+
agentsFilesOverride: (current) => {
|
|
190
|
+
const content = this.instructions();
|
|
191
|
+
return {
|
|
192
|
+
agentsFiles: content
|
|
193
|
+
? [...current.agentsFiles, { path: "<pier>/AGENTS.md", content }]
|
|
194
|
+
: current.agentsFiles,
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
await loader.reload();
|
|
199
|
+
return loader;
|
|
200
|
+
}
|
|
201
|
+
async open(cwd, sessionManager, opts = { cwd }) {
|
|
202
|
+
let live;
|
|
203
|
+
// Generic translation only — tool contracts are data owned by their feature.
|
|
204
|
+
const customTools = this.extraTools.map((tool) => defineTool({
|
|
205
|
+
name: tool.name,
|
|
206
|
+
label: tool.label,
|
|
207
|
+
description: tool.description,
|
|
208
|
+
parameters: tool.parameters,
|
|
209
|
+
execute: async (_id, params, signal) => {
|
|
210
|
+
const caller = live?.sessionId ?? "unknown";
|
|
211
|
+
log.debug(`tool ${tool.name} called by ${caller}`);
|
|
212
|
+
try {
|
|
213
|
+
return {
|
|
214
|
+
content: [
|
|
215
|
+
{
|
|
216
|
+
type: "text",
|
|
217
|
+
// Compact, not indented: pretty-printing a nested result
|
|
218
|
+
// costs ~20% more tokens and buys the model nothing.
|
|
219
|
+
text: JSON.stringify(await tool.execute(params, caller, signal)),
|
|
220
|
+
},
|
|
221
|
+
],
|
|
222
|
+
details: {},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// Pi turns this into tool-result text the model reads, which is the
|
|
227
|
+
// right recovery and the wrong record: rethrown, but logged first.
|
|
228
|
+
log.warn(`tool ${tool.name} failed for ${caller}`, err);
|
|
229
|
+
throw err;
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
}));
|
|
233
|
+
if (opts.name)
|
|
234
|
+
sessionManager.appendSessionInfo(opts.name);
|
|
235
|
+
// Locked Secrets is a refusal with a reason, here — not "provider is not
|
|
236
|
+
// configured" three calls later, and never a fall back to auth.json.
|
|
237
|
+
this.credentials?.assertUnlocked();
|
|
238
|
+
const tools = opts.capabilities === "read"
|
|
239
|
+
? ["read", "grep", "find", "ls", ...this.extraTools.map((tool) => tool.name)]
|
|
240
|
+
: undefined;
|
|
241
|
+
const created = await createAgentSession({
|
|
242
|
+
cwd,
|
|
243
|
+
sessionManager,
|
|
244
|
+
customTools,
|
|
245
|
+
tools,
|
|
246
|
+
modelRuntime: await this.createRuntime(),
|
|
247
|
+
resourceLoader: await this.resourceLoader(cwd),
|
|
248
|
+
});
|
|
249
|
+
live = created.session;
|
|
250
|
+
const session = new PiSession(live);
|
|
251
|
+
if (opts.model)
|
|
252
|
+
await session.setModel(opts.model);
|
|
253
|
+
if (opts.thinking)
|
|
254
|
+
session.setThinkingLevel(opts.thinking);
|
|
255
|
+
log.info(`session ${session.id} open in ${cwd}${opts.name ? ` (${opts.name})` : ""}`);
|
|
256
|
+
return session;
|
|
257
|
+
}
|
|
258
|
+
async create(opts) {
|
|
259
|
+
return this.open(opts.cwd, SessionManager.create(opts.cwd), opts);
|
|
260
|
+
}
|
|
261
|
+
async fork(sourceSessionId, opts) {
|
|
262
|
+
const infos = await SessionManager.listAll();
|
|
263
|
+
const source = infos.find((session) => session.id === sourceSessionId);
|
|
264
|
+
if (!source)
|
|
265
|
+
throw new Error(`unknown session: ${sourceSessionId}`);
|
|
266
|
+
const targetDir = SessionManager.create(opts.cwd).getSessionDir();
|
|
267
|
+
const manager = SessionManager.open(source.path, targetDir, opts.cwd);
|
|
268
|
+
const branch = manager.getBranch();
|
|
269
|
+
const latest = branch.at(-1);
|
|
270
|
+
const hasPendingToolCall = latest?.type === "message" &&
|
|
271
|
+
latest.message.role === "assistant" &&
|
|
272
|
+
Array.isArray(latest.message.content) &&
|
|
273
|
+
latest.message.content.some((part) => part.type === "toolCall");
|
|
274
|
+
const leafId = hasPendingToolCall ? latest.parentId : latest?.id;
|
|
275
|
+
if (!leafId)
|
|
276
|
+
throw new Error("cannot fork a session before its first persisted input");
|
|
277
|
+
manager.createBranchedSession(leafId);
|
|
278
|
+
return this.open(opts.cwd, manager, opts);
|
|
279
|
+
}
|
|
280
|
+
async resume(sessionId) {
|
|
281
|
+
const infos = await SessionManager.listAll();
|
|
282
|
+
const info = infos.find((s) => s.id === sessionId);
|
|
283
|
+
if (!info)
|
|
284
|
+
throw new Error(`unknown session: ${sessionId}`);
|
|
285
|
+
return this.open(info.cwd || process.cwd(), SessionManager.open(info.path));
|
|
286
|
+
}
|
|
287
|
+
async list() {
|
|
288
|
+
const infos = await SessionManager.listAll();
|
|
289
|
+
return infos.map((s) => ({
|
|
290
|
+
id: s.id,
|
|
291
|
+
cwd: s.cwd,
|
|
292
|
+
createdAt: s.created.getTime(),
|
|
293
|
+
title: s.name ?? (s.firstMessage ? s.firstMessage.slice(0, 80) : undefined),
|
|
294
|
+
}));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Boards: a folder of static files an agent writes to present something at a
|
|
2
|
+
// stable URL. The filesystem is the source of truth — boards are derived by
|
|
3
|
+
// scanning $PIER_HOME/boards, never registered. See docs/design/05-boards.md.
|
|
4
|
+
//
|
|
5
|
+
// Only <board>/site is reachable over HTTP: sources, README and the manifest
|
|
6
|
+
// itself stay off the wire, so a public board leaks nothing about how it was
|
|
7
|
+
// made. `public` is a data state, not yet a security boundary — nothing in
|
|
8
|
+
// Pier authenticates today (docs/architecture.md, Open Questions).
|
|
9
|
+
import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
10
|
+
import { extname, join, resolve, sep } from "node:path";
|
|
11
|
+
import { logger } from "../log.js";
|
|
12
|
+
import { pierPath } from "../paths.js";
|
|
13
|
+
export const defaultBoardsDir = () => pierPath("boards");
|
|
14
|
+
/** Deleted boards keep their bytes under `<slug>.deleted-<ts>`, which this
|
|
15
|
+
* pattern excludes from every scan — one rename is the whole delete path. */
|
|
16
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
17
|
+
// A board ships fonts and images, so the list is wider than the attachment
|
|
18
|
+
// route's — but still a whitelist: an unlisted extension is not served at all.
|
|
19
|
+
const TYPES = {
|
|
20
|
+
".html": "text/html; charset=utf-8",
|
|
21
|
+
".css": "text/css; charset=utf-8",
|
|
22
|
+
".js": "text/javascript; charset=utf-8",
|
|
23
|
+
".json": "application/json; charset=utf-8",
|
|
24
|
+
".svg": "image/svg+xml",
|
|
25
|
+
".png": "image/png",
|
|
26
|
+
".jpg": "image/jpeg",
|
|
27
|
+
".jpeg": "image/jpeg",
|
|
28
|
+
".gif": "image/gif",
|
|
29
|
+
".webp": "image/webp",
|
|
30
|
+
".avif": "image/avif",
|
|
31
|
+
".ico": "image/x-icon",
|
|
32
|
+
".woff2": "font/woff2",
|
|
33
|
+
".txt": "text/plain; charset=utf-8",
|
|
34
|
+
".csv": "text/plain; charset=utf-8",
|
|
35
|
+
};
|
|
36
|
+
// A published board must not be able to phone home: no third-party fetches, no
|
|
37
|
+
// framing, no beacons. Inline style/script stay allowed — a single-file board
|
|
38
|
+
// is the normal shape.
|
|
39
|
+
const CSP = "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; " +
|
|
40
|
+
"script-src 'self' 'unsafe-inline'; connect-src 'none'; frame-ancestors 'none'";
|
|
41
|
+
/** Malformed boards are reported once, not on every scan. */
|
|
42
|
+
const warned = new Set();
|
|
43
|
+
/** The one choke point where a slug becomes a path, so the slug is validated
|
|
44
|
+
* here and nowhere else: an unvalidated `../../etc` would read outside the
|
|
45
|
+
* boards dir, and a NUL byte would throw instead of 404. Unknown fields get
|
|
46
|
+
* defaults, a broken file is skipped whole, and extra keys are the agent's
|
|
47
|
+
* business — they survive a write. */
|
|
48
|
+
async function readManifest(dir, slug) {
|
|
49
|
+
if (!SLUG.test(slug))
|
|
50
|
+
return null;
|
|
51
|
+
const file = join(dir, slug, "board.json");
|
|
52
|
+
let raw;
|
|
53
|
+
try {
|
|
54
|
+
raw = JSON.parse(await readFile(file, "utf8"));
|
|
55
|
+
}
|
|
56
|
+
catch (err) {
|
|
57
|
+
// Missing file = not a board; unparsable = a board someone broke.
|
|
58
|
+
if (err.code !== "ENOENT" && !warned.has(file)) {
|
|
59
|
+
warned.add(file);
|
|
60
|
+
logger("boards").warn(`ignoring unreadable manifest ${file}`, err);
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw))
|
|
65
|
+
return null;
|
|
66
|
+
const m = raw;
|
|
67
|
+
return {
|
|
68
|
+
...m,
|
|
69
|
+
title: typeof m.title === "string" && m.title ? m.title : slug,
|
|
70
|
+
description: typeof m.description === "string" ? m.description : "",
|
|
71
|
+
sessions: Array.isArray(m.sessions) ? m.sessions.filter((s) => typeof s === "string") : [],
|
|
72
|
+
public: m.public === true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** Freshness is the site's mtime, not a manifest field — the filesystem
|
|
76
|
+
* already knows, and an agent rewriting a page cannot forget to say so. */
|
|
77
|
+
async function updatedAt(dir, slug) {
|
|
78
|
+
const info = (await stat(join(dir, slug, "site")).catch(() => null)) ??
|
|
79
|
+
(await stat(join(dir, slug)).catch(() => null));
|
|
80
|
+
return (info?.mtime ?? new Date()).toISOString();
|
|
81
|
+
}
|
|
82
|
+
export async function listBoards(dir) {
|
|
83
|
+
let entries;
|
|
84
|
+
try {
|
|
85
|
+
entries = (await readdir(dir, { withFileTypes: true }))
|
|
86
|
+
.filter((e) => e.isDirectory() && SLUG.test(e.name))
|
|
87
|
+
.map((e) => e.name);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return []; // no boards yet
|
|
91
|
+
}
|
|
92
|
+
const boards = [];
|
|
93
|
+
for (const slug of entries.sort()) {
|
|
94
|
+
const manifest = await readManifest(dir, slug);
|
|
95
|
+
if (!manifest)
|
|
96
|
+
continue;
|
|
97
|
+
const { title, description, sessions, public: isPublic } = manifest;
|
|
98
|
+
boards.push({ slug, title, description, sessions, public: isPublic, updatedAt: await updatedAt(dir, slug) });
|
|
99
|
+
}
|
|
100
|
+
return boards;
|
|
101
|
+
}
|
|
102
|
+
/** Containment, not normalization: the resolved realpath must sit inside the
|
|
103
|
+
* board's own site dir or nothing is served. */
|
|
104
|
+
async function resolveFile(dir, slug, rest) {
|
|
105
|
+
let relative;
|
|
106
|
+
try {
|
|
107
|
+
relative = decodeURIComponent(rest);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (relative.includes("\0"))
|
|
113
|
+
return null;
|
|
114
|
+
if (!relative || relative.endsWith("/"))
|
|
115
|
+
relative += "index.html";
|
|
116
|
+
let root;
|
|
117
|
+
try {
|
|
118
|
+
root = await realpath(join(dir, slug, "site"));
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
let file;
|
|
124
|
+
try {
|
|
125
|
+
file = await realpath(resolve(root, relative));
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
if (file !== root && !file.startsWith(root + sep))
|
|
131
|
+
return null;
|
|
132
|
+
const info = await stat(file);
|
|
133
|
+
if (info.isDirectory())
|
|
134
|
+
return resolveFile(dir, slug, `${relative}/`);
|
|
135
|
+
return info.isFile() ? file : null;
|
|
136
|
+
}
|
|
137
|
+
async function serveFile(c, dir, slug, rest, publicOnly) {
|
|
138
|
+
const manifest = await readManifest(dir, slug);
|
|
139
|
+
// 404, never 403: a private board's existence is not public information.
|
|
140
|
+
if (!manifest || (publicOnly && !manifest.public))
|
|
141
|
+
return c.notFound();
|
|
142
|
+
const file = await resolveFile(dir, slug, rest);
|
|
143
|
+
if (!file)
|
|
144
|
+
return c.notFound();
|
|
145
|
+
const type = TYPES[extname(file).toLowerCase()];
|
|
146
|
+
if (!type)
|
|
147
|
+
return c.notFound();
|
|
148
|
+
const headers = {
|
|
149
|
+
"content-type": type,
|
|
150
|
+
"x-content-type-options": "nosniff",
|
|
151
|
+
// HTML is the page an agent keeps rewriting; assets are fingerprinted or
|
|
152
|
+
// rare enough that five minutes is safe.
|
|
153
|
+
"cache-control": type.startsWith("text/html") ? "no-store" : "max-age=300",
|
|
154
|
+
};
|
|
155
|
+
if (publicOnly)
|
|
156
|
+
headers["content-security-policy"] = CSP;
|
|
157
|
+
return c.body(await readFile(file), 200, headers);
|
|
158
|
+
}
|
|
159
|
+
export function registerBoardRoutes(app, dir = defaultBoardsDir()) {
|
|
160
|
+
app.get("/api/boards", async (c) => c.json(await listBoards(dir)));
|
|
161
|
+
// Publishing is the one decision a human owns; every other field belongs to
|
|
162
|
+
// the agent that wrote the board.
|
|
163
|
+
app.patch("/api/boards/:slug", async (c) => {
|
|
164
|
+
const slug = c.req.param("slug");
|
|
165
|
+
const body = (await c.req.json().catch(() => null));
|
|
166
|
+
if (typeof body?.public !== "boolean")
|
|
167
|
+
return c.json({ error: "public must be a boolean" }, 400);
|
|
168
|
+
const manifest = await readManifest(dir, slug);
|
|
169
|
+
if (!manifest)
|
|
170
|
+
return c.json({ error: "no such board" }, 404);
|
|
171
|
+
manifest.public = body.public;
|
|
172
|
+
await writeFile(join(dir, slug, "board.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
173
|
+
return c.json({ public: manifest.public });
|
|
174
|
+
});
|
|
175
|
+
app.delete("/api/boards/:slug", async (c) => {
|
|
176
|
+
const slug = c.req.param("slug");
|
|
177
|
+
if (!(await readManifest(dir, slug)))
|
|
178
|
+
return c.json({ error: "no such board" }, 404);
|
|
179
|
+
await rename(join(dir, slug), join(dir, `${slug}.deleted-${Date.now()}`));
|
|
180
|
+
return c.json({ deleted: slug });
|
|
181
|
+
});
|
|
182
|
+
// Declared before the wildcards below: `_assets` is not a slug.
|
|
183
|
+
app.get("/boards/_assets/pier.css", async (c) => {
|
|
184
|
+
const file = new URL("./pier.css", import.meta.url);
|
|
185
|
+
return c.body(await readFile(file), 200, {
|
|
186
|
+
"content-type": "text/css; charset=utf-8",
|
|
187
|
+
"cache-control": "max-age=300",
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
// Trailing slash matters: without it a board's relative asset paths resolve
|
|
191
|
+
// against /boards instead of the board.
|
|
192
|
+
for (const prefix of ["/boards", "/p"]) {
|
|
193
|
+
app.get(`${prefix}/:slug`, (c) => c.redirect(`${prefix}/${c.req.param("slug")}/`));
|
|
194
|
+
app.get(`${prefix}/:slug/*`, (c) => {
|
|
195
|
+
const slug = c.req.param("slug");
|
|
196
|
+
const rest = c.req.path.slice(`${prefix}/${slug}/`.length);
|
|
197
|
+
return serveFile(c, dir, slug, rest, prefix === "/p");
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|