@timqi/pier 0.0.9 → 0.0.16

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.
Files changed (67) hide show
  1. package/README.md +7 -1
  2. package/dist/agent/events.js +53 -7
  3. package/dist/agent/listing.js +253 -0
  4. package/dist/agent/pi.js +190 -31
  5. package/dist/boards/boards.js +65 -16
  6. package/dist/boards/pier.css +1 -1
  7. package/dist/channels/attach.js +87 -0
  8. package/dist/channels/control.js +2 -2
  9. package/dist/channels/lark-api.js +38 -0
  10. package/dist/channels/lark-outbound.js +11 -2
  11. package/dist/channels/slack-api.js +36 -0
  12. package/dist/channels/slack-outbound.js +12 -2
  13. package/dist/channels/slack-tool.js +49 -9
  14. package/dist/channels/telegram-api.js +21 -2
  15. package/dist/channels/telegram.js +23 -8
  16. package/dist/cli.js +34 -0
  17. package/dist/core/identity.js +18 -0
  18. package/dist/core/inbound-file.js +3 -1
  19. package/dist/core/reply.js +2 -1
  20. package/dist/core/router.js +72 -0
  21. package/dist/db.js +78 -0
  22. package/dist/extensions/index.js +5 -2
  23. package/dist/extensions/web/artifacts.js +7 -2
  24. package/dist/extensions/web/content.js +5 -0
  25. package/dist/extensions/web/language.js +5 -0
  26. package/dist/extensions/web/tools.js +33 -8
  27. package/dist/limits.js +14 -0
  28. package/dist/main.js +47 -6
  29. package/dist/paths.js +6 -1
  30. package/dist/settings.js +44 -0
  31. package/dist/tasks/agent.js +23 -4
  32. package/dist/tasks/callbacks.js +20 -1
  33. package/dist/tasks/command.js +15 -0
  34. package/dist/tasks/definitions.js +60 -12
  35. package/dist/tasks/execution.js +9 -1
  36. package/dist/tasks/groups.js +8 -4
  37. package/dist/tasks/messages.js +10 -2
  38. package/dist/tasks/routes.js +4 -0
  39. package/dist/tasks/runs.js +7 -2
  40. package/dist/tasks/service.js +22 -6
  41. package/dist/tasks/store.js +4 -0
  42. package/dist/tasks/tool.js +0 -12
  43. package/dist/tasks/types.js +4 -0
  44. package/dist/tools-task.js +155 -0
  45. package/dist/tools.js +875 -0
  46. package/dist/web/auth.js +5 -3
  47. package/dist/web/explorer.js +15 -2
  48. package/dist/web/files.js +1 -1
  49. package/dist/web/instance.js +165 -36
  50. package/dist/web/public/assets/{ghostty-web-C4N9kjtH.js → ghostty-web-C4ivXTBE.js} +1 -1
  51. package/dist/web/public/assets/index-2E9_cwpg.css +2 -0
  52. package/dist/web/public/assets/index-DVUvzNK1.js +93 -0
  53. package/dist/web/public/index.html +5 -8
  54. package/dist/web/public/sw.js +4 -0
  55. package/dist/web/push.js +22 -7
  56. package/dist/web/repos.js +75 -0
  57. package/dist/web/server.js +145 -64
  58. package/dist/web/session-state.js +33 -51
  59. package/dist/web/types.js +5 -0
  60. package/docs/deploy.md +12 -3
  61. package/package.json +1 -1
  62. package/skills/pier-boards/SKILL.md +23 -13
  63. package/skills/pier-help/SKILL.md +1 -1
  64. package/skills/pier-slack/SKILL.md +21 -1
  65. package/skills/pier-tasks/SKILL.md +2 -2
  66. package/dist/web/public/assets/index-DNCJJRSS.js +0 -91
  67. package/dist/web/public/assets/index-DYl1xk5y.css +0 -2
package/dist/web/auth.js CHANGED
@@ -118,15 +118,17 @@ const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString(
118
118
  * What a logged-out visitor must still reach: the login form, published
119
119
  * boards, and the stylesheet those boards link — a published board rendering
120
120
  * unstyled for the person it was published to is the same bug as not serving
121
- * it. `/boards/*` stays behind the boundary; `/p/*` is the published mirror,
122
- * the single exempt prefix `docs/architecture.md` reserved for this.
121
+ * it. Both live under `/p/*` (the stylesheet at `/p/_assets/pier.css`), the
122
+ * single exempt prefix `docs/architecture.md` reserved for this, so the rule
123
+ * is one prefix here and one prefix in anything fronting Pier; `/boards/*`
124
+ * stays behind the boundary.
123
125
  */
124
126
  function isPublic(method, path) {
125
127
  if (path === "/login")
126
128
  return method === "GET" || method === "HEAD" || method === "POST";
127
129
  if (method !== "GET" && method !== "HEAD")
128
130
  return false;
129
- return path.startsWith("/p/") || path === "/boards/_assets/pier.css";
131
+ return path.startsWith("/p/");
130
132
  }
131
133
  const sign = (secret, expiresAt) => createHmac("sha256", secret).update(String(expiresAt)).digest("base64url");
132
134
  /** Constant-time equality that also hides length: both sides are digested. */
@@ -82,8 +82,21 @@ export function registerExplorerRoutes(app) {
82
82
  branch = (await git(root, "rev-parse", "--abbrev-ref", "HEAD")).trim();
83
83
  }
84
84
  catch {
85
- return c.json({ branch: null, refs: [], commits: [] }); // not a repo, or no commits yet
85
+ // not a repo, or no commits yet
86
+ return c.json({ branch: null, refs: [], commits: [], worktrees: [] });
86
87
  }
88
+ // Every checkout of this repository, from git rather than from the
89
+ // sessions that happen to live in one: a worktree created ten seconds ago
90
+ // has no session in it yet, and that is exactly when its files are worth
91
+ // opening. Detached heads have no `branch` line, so the path stands alone.
92
+ const worktrees = (await git(root, "worktree", "list", "--porcelain"))
93
+ .split("\n\n")
94
+ .map((block) => {
95
+ const path = /^worktree (.+)$/m.exec(block)?.[1];
96
+ const on = /^branch refs\/heads\/(.+)$/m.exec(block)?.[1];
97
+ return path ? { path, ...(on ? { branch: on } : {}) } : null;
98
+ })
99
+ .filter((w) => w !== null);
87
100
  const refs = (await git(root, "for-each-ref", "--format=%(refname:short)\t%(subject)", "refs/heads", "refs/tags"))
88
101
  .split("\n")
89
102
  .filter(Boolean)
@@ -100,7 +113,7 @@ export function registerExplorerRoutes(app) {
100
113
  const [hash = "", at = "", author = "", email = "", subject = "", body = ""] = r.split("\u001f");
101
114
  return { hash, at: Number(at) * 1000, author, email, subject, body: body.trim() };
102
115
  });
103
- return c.json({ branch, refs, commits });
116
+ return c.json({ branch, refs, commits, worktrees });
104
117
  });
105
118
  // One endpoint, two shapes: without `file` the changed-file list
106
119
  // (name-status), with it that file's unified diff. `head` empty or absent
package/dist/web/files.js CHANGED
@@ -96,7 +96,7 @@ export function registerFileRoutes(app, { factory, config, nascentCwd, onConfigW
96
96
  /** Real path of a file a session may expose: inside its cwd or the inbox
97
97
  * (where inbound user attachments land — core/inbox.ts), nothing else. */
98
98
  const resolveFile = async (id, raw) => {
99
- const cwd = nascentCwd(id) ?? (await factory.list()).find((s) => s.id === id)?.cwd;
99
+ const cwd = nascentCwd(id) ?? (await factory.find(id))?.cwd;
100
100
  if (!cwd || !isAbsolute(raw))
101
101
  return null;
102
102
  try {
@@ -2,12 +2,24 @@
2
2
  // layer-1 secrets control, the browser's error reports. Nothing here touches
3
3
  // a session; server.ts stays the session/event surface.
4
4
  import { logger } from "../log.js";
5
- import { normalizeExtensions, normalizeModelMenu, normalizePublicUrl, normalizeTerminalInitCommand, } from "../settings.js";
5
+ import { normalizeModelMenu, normalizePublicUrl, normalizeTerminalInitCommand, } from "../settings.js";
6
+ /** A rule that failed inside the transaction — its own class so the route can
7
+ * tell it from a real fault. 400 when the request named something this Pier
8
+ * does not have, 409 when it collided with the state as it stands. */
9
+ class Refusal extends Error {
10
+ status;
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.status = status;
14
+ }
15
+ }
16
+ /** One name added to or removed from a stored set, order preserved. */
17
+ const withName = (current, { name, on }) => on ? [...new Set([...current, name])] : current.filter((each) => each !== name);
6
18
  /** Client reports per minute, for the whole server: a browser bug can fire in
7
19
  * a loop, and the journal is shared with everything else Pier says. */
8
20
  const CLIENT_LOG_PER_MINUTE = 60;
9
21
  export function registerInstanceRoutes(app, deps) {
10
- const { settings, updates, updater = null, secrets, extensions, onUnlocked, onSettingsChanged } = deps;
22
+ const { settings, updates, updater = null, secrets, catalog, names = { extensions: [], tools: [] }, onUnlocked, onSettingsChanged, onToolsChanged, validateCustomTools, } = deps;
11
23
  const updateLog = logger("update");
12
24
  // How long POST /api/update may hold its response open. A busy Pier drains
13
25
  // first, which can take minutes, and a response held that long dies at every
@@ -42,8 +54,17 @@ export function registerInstanceRoutes(app, deps) {
42
54
  // The catalog rides along: one round trip for the whole page, and the
43
55
  // switches cannot disagree with the setting they are drawn from. One shape
44
56
  // for both the read and the write, or the page reconciles two answers.
45
- const instanceSettings = () => ({ ...settings.get(), extensionCatalog: extensions?.() ?? [] });
46
- app.get("/api/settings", (c) => c.json(instanceSettings()));
57
+ const instanceSettings = async () => {
58
+ const shown = await catalog?.();
59
+ return {
60
+ ...settings.get(),
61
+ catalog: shown?.entries ?? [],
62
+ /** Where the runs are: the install and every daily update is one task's
63
+ * history, not a second status surface this route invented. */
64
+ toolsTaskId: shown?.toolsTaskId ?? null,
65
+ };
66
+ };
67
+ app.get("/api/settings", async (c) => c.json(await instanceSettings()));
47
68
  // What the version badge reads: the two versions, whether this instance can
48
69
  // do anything about the gap, and whether it is allowed to do it unattended.
49
70
  // `statusNow` so a browser opened seconds after a restart is told the truth
@@ -103,56 +124,164 @@ export function registerInstanceRoutes(app, deps) {
103
124
  });
104
125
  // Partial on purpose: each surface sends only the setting it edits, and a
105
126
  // malformed field is rejected before anything is written.
127
+ //
128
+ // A switch sends a *delta* (`tool`/`extension`), not the list it computed:
129
+ // two quick clicks each carry a list built from what the page knew a moment
130
+ // ago, so the second silently drops the first.
106
131
  app.put("/api/settings", async (c) => {
107
132
  const body = await c.req.json().catch(() => null);
108
- const given = body &&
109
- [body.publicUrl, body.modelMenu, body.autoUpdate, body.terminalInitCommand, body.extensions]
110
- .some((v) => v !== undefined);
111
- if (!given) {
133
+ const fields = body
134
+ ? [body.publicUrl, body.modelMenu, body.autoUpdate, body.terminalInitCommand, body.customTools, body.extension, body.tool]
135
+ : [];
136
+ if (!fields.some((v) => v !== undefined)) {
112
137
  return c.json({
113
- error: "publicUrl, modelMenu, autoUpdate, terminalInitCommand or extensions required",
138
+ error: "publicUrl, modelMenu, autoUpdate, terminalInitCommand, customTools, extension or tool required",
114
139
  }, 400);
115
140
  }
116
- if (body.publicUrl !== undefined) {
141
+ // Everything is validated before anything is written, and everything is
142
+ // written in one transaction: a request carrying a new custom tool *and*
143
+ // the switch that turns it on must not leave one of the two stored.
144
+ const writes = [];
145
+ const refuse = (error) => c.json({ error }, 400);
146
+ if (body?.publicUrl !== undefined) {
117
147
  if (typeof body.publicUrl !== "string")
118
- return c.json({ error: "publicUrl must be a string" }, 400);
148
+ return refuse("publicUrl must be a string");
119
149
  const publicUrl = normalizePublicUrl(body.publicUrl);
120
- if (publicUrl === null) {
121
- return c.json({ error: "not a URL: expected http(s)://host, no query or fragment" }, 400);
122
- }
123
- settings.setPublicUrl(publicUrl);
150
+ if (publicUrl === null)
151
+ return refuse("not a URL: expected http(s)://host, no query or fragment");
152
+ writes.push(() => settings.setPublicUrl(publicUrl));
124
153
  }
125
- if (body.modelMenu !== undefined) {
154
+ if (body?.modelMenu !== undefined) {
126
155
  const menu = normalizeModelMenu(body.modelMenu);
127
- if (menu === null) {
128
- return c.json({ error: "modelMenu must be [{provider, id, note?}] (≤32 entries)" }, 400);
129
- }
130
- settings.setModelMenu(menu);
156
+ if (menu === null)
157
+ return refuse("modelMenu must be [{provider, id, note?}] (≤32 entries)");
158
+ writes.push(() => settings.setModelMenu(menu));
131
159
  }
132
- if (body.autoUpdate !== undefined) {
133
- if (typeof body.autoUpdate !== "boolean")
134
- return c.json({ error: "autoUpdate must be a boolean" }, 400);
135
- settings.setAutoUpdate(body.autoUpdate);
160
+ if (body?.autoUpdate !== undefined) {
161
+ const { autoUpdate } = body;
162
+ if (typeof autoUpdate !== "boolean")
163
+ return refuse("autoUpdate must be a boolean");
164
+ writes.push(() => settings.setAutoUpdate(autoUpdate));
136
165
  }
137
- if (body.terminalInitCommand !== undefined) {
166
+ if (body?.terminalInitCommand !== undefined) {
138
167
  const command = normalizeTerminalInitCommand(body.terminalInitCommand);
139
- if (command === null) {
140
- return c.json({ error: "terminalInitCommand must be one line of at most 500 characters" }, 400);
141
- }
142
- settings.setTerminalInitCommand(command);
168
+ if (command === null)
169
+ return refuse("terminalInitCommand must be one line of at most 500 characters");
170
+ writes.push(() => settings.setTerminalInitCommand(command));
171
+ }
172
+ /** The blocks this request declares, or null when it does not touch them.
173
+ * Adding a tool is declaring it *and* switching it on, so a name declared
174
+ * here is switchable in the same write. */
175
+ let declared = null;
176
+ if (body?.customTools !== undefined) {
177
+ const validated = validateCustomTools?.(body.customTools) ??
178
+ { error: "this Pier cannot store custom tools" };
179
+ if ("error" in validated)
180
+ return refuse(validated.error);
181
+ declared = validated.tools;
182
+ const custom = validated.tools;
183
+ writes.push(() => settings.setCustomTools(custom));
184
+ }
185
+ /** A single switch, applied to the set as it is *now* rather than to the
186
+ * list the browser had. Returns the name, or the refusal. */
187
+ const delta = (raw) => {
188
+ const given = typeof raw === "object" && raw !== null ? raw : null;
189
+ const name = typeof given?.name === "string" ? given.name.trim() : "";
190
+ if (!name || name.length > 64 || typeof given?.on !== "boolean")
191
+ return "expected {name, on}";
192
+ return { name, on: given.on };
193
+ };
194
+ let extensionOne = null;
195
+ if (body?.extension !== undefined) {
196
+ const one = delta(body.extension);
197
+ if (typeof one === "string")
198
+ return refuse(`extension: ${one}`);
199
+ extensionOne = one;
200
+ writes.push(() => settings.setExtensions(withName(settings.get().extensions, one)));
143
201
  }
144
- if (body.extensions !== undefined) {
145
- const names = normalizeExtensions(body.extensions);
146
- if (names === null) {
147
- return c.json({ error: "extensions must be a list of names (≤32)" }, 400);
202
+ // Whether the enabled set moved, for the install below. The delta itself
203
+ // is resolved inside the transaction, against the set as it was stored.
204
+ let toolsChanged = false;
205
+ let toolOne = null;
206
+ if (body?.tool !== undefined) {
207
+ const one = delta(body.tool);
208
+ if (typeof one === "string")
209
+ return refuse(`tool: ${one}`);
210
+ toolOne = one;
211
+ toolsChanged = true;
212
+ writes.push(() => settings.setTools(withName(settings.get().tools, one)));
213
+ }
214
+ // What ubix says about the blocks this request would undeclare. Asked out
215
+ // here because answering it spawns a subprocess, and a transaction may not
216
+ // wait on one; the *sets* below are read inside the transaction, where
217
+ // nothing can move them.
218
+ const dropping = declared !== null &&
219
+ settings.get().customTools.some((tool) => !declared?.some((kept) => kept.name === tool.name));
220
+ const shown = dropping ? await catalog?.() : undefined;
221
+ const binaryOf = (name) => {
222
+ const entry = shown?.entries.find((row) => row.source === "binary" && row.name === name);
223
+ return entry?.source === "binary" ? entry.binary : null;
224
+ };
225
+ /**
226
+ * The rules that are about *sets*, applied inside the transaction against
227
+ * the settings as they are at that instant. Outside it they are a
228
+ * time-of-check: a request dropping a declaration and another switching
229
+ * that tool on both passed, and left an enabled tool nothing declares.
230
+ *
231
+ * What may be switched on comes from `names`, which is code and cannot
232
+ * change while this runs, plus what this request declares. What may be
233
+ * undeclared is the second rule: a block is the only thing that can
234
+ * uninstall its binary, so it may not go while the tool is on, installed,
235
+ * broken, or while ubix's answer about it could not be read. Switching a
236
+ * name *off* is never refused — that is the repair, and it is the first
237
+ * half of removing one.
238
+ */
239
+ const check = () => {
240
+ const current = settings.get();
241
+ const after = (declared ?? current.customTools).map((tool) => tool.name);
242
+ const toolsAfter = toolOne ? withName(current.tools, toolOne) : current.tools;
243
+ if (extensionOne?.on && !names.extensions.includes(extensionOne.name)) {
244
+ throw new Refusal(`extension: this Pier has no extension called ${extensionOne.name}`, 400);
245
+ }
246
+ if (toolOne?.on && !names.tools.includes(toolOne.name) && !after.includes(toolOne.name)) {
247
+ throw new Refusal(`tool: this Pier manages no tool called ${toolOne.name}`, 400);
248
+ }
249
+ for (const gone of current.customTools.filter((tool) => !after.includes(tool.name))) {
250
+ if (toolsAfter.includes(gone.name)) {
251
+ throw new Refusal(`${gone.name} is still switched on — switch it off first, so the next sync uninstalls it`, 409);
252
+ }
253
+ const binary = binaryOf(gone.name);
254
+ if (!binary)
255
+ throw new Refusal(`${gone.name}: Pier cannot read what is installed, so its block stays for now`, 409);
256
+ if (binary.installed) {
257
+ throw new Refusal(`${gone.name} is still installed — its block stays until ubix reports the binary gone`, 409);
258
+ }
259
+ if (binary.error)
260
+ throw new Refusal(`${gone.name}: ${binary.error} — its block stays until that is resolved`, 409);
148
261
  }
149
- settings.setExtensions(names);
262
+ };
263
+ try {
264
+ settings.transact(() => {
265
+ check();
266
+ for (const write of writes)
267
+ write();
268
+ });
269
+ }
270
+ catch (err) {
271
+ // Nothing was written: the transaction rolled back with it.
272
+ if (err instanceof Refusal)
273
+ return c.json({ error: err.message }, err.status);
274
+ throw err;
150
275
  }
276
+ // Stored first, then acted on: the switch shows what was written even when
277
+ // the install cannot start — and then says why, here, rather than leaving
278
+ // "saved" as the last thing anyone was told.
279
+ const note = toolsChanged ? await onToolsChanged?.() : null;
151
280
  // The URL and the extension set are both read when a session opens; the
152
281
  // model menu is read per picker call, so it needs no recycle.
153
- if (body.publicUrl !== undefined || body.extensions !== undefined)
282
+ if (body?.publicUrl !== undefined || body?.extension !== undefined)
154
283
  onSettingsChanged?.();
155
- return c.json(instanceSettings());
284
+ return c.json({ ...(await instanceSettings()), ...(note ? { toolsSync: note } : {}) });
156
285
  });
157
286
  // Layer-1 key status and control (Console → Settings → Security). The GET
158
287
  // is what a locked instance shows; unlock is how it recovers without a