@timqi/pier 0.0.17 → 0.0.19

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 (76) hide show
  1. package/dist/agent/events.js +16 -4
  2. package/dist/agent/listing.js +12 -13
  3. package/dist/agent/pi.js +28 -2
  4. package/dist/boards/boards.js +7 -6
  5. package/dist/channels/attach.js +18 -4
  6. package/dist/channels/config.js +7 -2
  7. package/dist/channels/dedup.js +9 -1
  8. package/dist/channels/lark.js +2 -6
  9. package/dist/channels/receipts.js +21 -6
  10. package/dist/channels/slack-tool.js +86 -25
  11. package/dist/channels/slack.js +2 -10
  12. package/dist/channels/telegram.js +1 -0
  13. package/dist/core/hub.js +14 -4
  14. package/dist/core/inbox.js +1 -1
  15. package/dist/core/reply.js +64 -0
  16. package/dist/db.js +44 -0
  17. package/dist/main.js +1 -0
  18. package/dist/secrets.js +18 -6
  19. package/dist/tasks/agent.js +14 -1
  20. package/dist/tasks/definitions.js +3 -1
  21. package/dist/tasks/messages.js +1 -2
  22. package/dist/tasks/service.js +5 -0
  23. package/dist/tasks/store.js +44 -12
  24. package/dist/tools.js +32 -2
  25. package/dist/web/auth.js +17 -23
  26. package/dist/web/config.js +61 -0
  27. package/dist/web/explorer.js +20 -70
  28. package/dist/web/fs.js +159 -0
  29. package/dist/web/instance.js +11 -0
  30. package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
  31. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  32. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  33. package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
  34. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  35. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  36. package/dist/web/public/assets/code-DjWvqL94.js +1 -0
  37. package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
  38. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  39. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  40. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-D8CLFi_l.js} +1 -1
  41. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  42. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  43. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  44. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  45. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  46. package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
  47. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  48. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  49. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-yNocr797.css} +1 -1
  50. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  51. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  52. package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
  53. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  54. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  55. package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
  56. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  57. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  58. package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
  59. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  60. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
  61. package/dist/web/public/icon.svg.br +0 -0
  62. package/dist/web/public/icon.svg.gz +0 -0
  63. package/dist/web/public/index.html +2 -2
  64. package/dist/web/public/index.html.br +0 -0
  65. package/dist/web/public/index.html.gz +0 -0
  66. package/dist/web/public/manifest.webmanifest.br +0 -0
  67. package/dist/web/public/manifest.webmanifest.gz +0 -0
  68. package/dist/web/public/sw.js.br +0 -0
  69. package/dist/web/public/sw.js.gz +0 -0
  70. package/dist/web/route.js +17 -0
  71. package/dist/web/server.js +104 -23
  72. package/dist/web/terminal.js +2 -7
  73. package/package.json +1 -1
  74. package/skills/pier-slack/SKILL.md +4 -16
  75. package/dist/web/files.js +0 -183
  76. package/dist/web/public/assets/index-DAxKtidH.js +0 -93
@@ -216,3 +216,67 @@ export function splitReply(rawMarkdown, meta) {
216
216
  return { text: markdown, suggestions: [], meta, silence };
217
217
  return { text: markdown.slice(0, m.index).trimEnd(), suggestions, meta, silence };
218
218
  }
219
+ /**
220
+ * The body of a block that is still being written, for a surface rendering it
221
+ * before the turn ends: everything `splitReply` repairs, minus the next-step
222
+ * block. That block is only a next-step block at the very *end* of a turn, so
223
+ * text a later paragraph has already closed behind keeps it as body — which is
224
+ * what the final render will do with it too.
225
+ */
226
+ export const streamBody = (markdown) => cjkFriendly(markdown.replace(SILENT, "").trim());
227
+ /** Lines a blank line does not necessarily separate: a list item, a quote and
228
+ * an indented continuation all survive one — a loose list is still one list.
229
+ * Deliberately over-matching (`*` and `-` also catch emphasis and a rule):
230
+ * claiming one boundary too few costs nothing but a repaint. */
231
+ const CONTINUES = /^(?:\s|[-*+>]|\d+[.)])/;
232
+ /**
233
+ * How much of `markdown` after `from` can no longer change as more text
234
+ * arrives: the offset just past the last blank line that closes a block, or
235
+ * `from` if there is none.
236
+ *
237
+ * Streaming markdown is the caller. Re-parsing the whole reply on every
238
+ * repaint costs O(N²) over a turn, so the web chat renders each closed prefix
239
+ * once and keeps its DOM. A boundary is therefore only claimed where parsing
240
+ * the two sides separately renders the same as parsing them together: never
241
+ * inside a backtick or tilde fence, inside a block stripped on completion, or
242
+ * between two lines a blank line leaves in the same list or quote. Fence runs
243
+ * keep their length as in `channels/chunk.ts`: a ```` fence is not closed by
244
+ * the ``` it quotes.
245
+ */
246
+ export function stableBlockEnd(markdown, from = 0) {
247
+ // The last line is still growing, so it decides nothing — not the fence
248
+ // state, and not whether the blank line above it ended a block.
249
+ const end = markdown.lastIndexOf("\n") + 1;
250
+ /** Marker run of the fence currently open; empty = closed. */
251
+ let open = "";
252
+ let silent = false;
253
+ let cut = from;
254
+ /** Start of the block after a blank line, waiting for its first line. */
255
+ let pending = -1;
256
+ let prev = "";
257
+ for (let at = from; at < end;) {
258
+ const nl = markdown.indexOf("\n", at);
259
+ const line = markdown.slice(at, nl);
260
+ at = nl + 1;
261
+ const fence = /^\s*(`{3,}|~{3,})(.*)$/.exec(line);
262
+ if (!open && !silent && !line.trim()) {
263
+ if (pending < 0 && prev)
264
+ pending = at;
265
+ continue;
266
+ }
267
+ if (!open && !silent && pending >= 0 && !(CONTINUES.test(prev) && CONTINUES.test(line)))
268
+ cut = pending;
269
+ const run = fence?.[1] ?? "";
270
+ if (!open && run && !silent)
271
+ open = run;
272
+ else if (open && run[0] === open[0] && run.length >= open.length && !fence?.[2]?.trim())
273
+ open = "";
274
+ if (!open && (!run || silent)) {
275
+ for (const tag of line.matchAll(/<(\/?)silent>/gi))
276
+ silent = !tag[1];
277
+ }
278
+ pending = -1;
279
+ prev = line;
280
+ }
281
+ return cut;
282
+ }
package/dist/db.js CHANGED
@@ -288,6 +288,27 @@ const MIGRATIONS = [
288
288
  created_at INTEGER NOT NULL,
289
289
  session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
290
290
  );
291
+ `,
292
+ // 15 — the scheduler's tick stops reading the rows it cannot deliver.
293
+ `
294
+ -- Once a second, tasks/ asks for the runs whose callback is still owed and
295
+ -- the messages whose injection has not landed. Both are a handful of rows
296
+ -- filtered on one low-cardinality column, and without an index both are a
297
+ -- full scan of a table that only grows — the sweep got slower with every
298
+ -- run that finished cleanly and can never match again.
299
+ CREATE INDEX task_runs_callback_state ON task_runs(callback_state);
300
+ CREATE INDEX task_messages_state ON task_messages(state);
301
+ `,
302
+ // 16 — the same tick stops parsing every task document to find none due.
303
+ `
304
+ -- When a task is next due is the one field the scheduler asks about once a
305
+ -- second, and it lived only inside the JSON: answering meant reading and
306
+ -- parsing every definition, due or not. A generated column keeps the JSON
307
+ -- as the only record while giving the query planner a value it can index; a
308
+ -- disabled or archived task has no next run, so NULLs stay out of the index.
309
+ ALTER TABLE tasks ADD COLUMN next_run_at INTEGER
310
+ GENERATED ALWAYS AS (json_extract(json, '$.nextRunAt')) VIRTUAL;
311
+ CREATE INDEX tasks_due ON tasks(next_run_at) WHERE next_run_at IS NOT NULL;
291
312
  `,
292
313
  ];
293
314
  /**
@@ -310,6 +331,24 @@ export function transact(db, work) {
310
331
  throw err;
311
332
  }
312
333
  }
334
+ /**
335
+ * Prepared statements, memoized by their SQL. `prepare()` compiles, and the
336
+ * callers here hand it the same handful of strings forever — twice per
337
+ * authenticated request, once a second per scheduler sweep. A `StatementSync`
338
+ * is reusable with different bound parameters, so one per SQL string per
339
+ * connection is the whole cache. Bound to the connection because a statement
340
+ * belongs to the database that compiled it; SQL built per call does not
341
+ * belong in here, since the cache would then grow without a bound.
342
+ */
343
+ export function statements(db) {
344
+ const cache = new Map();
345
+ return (sql) => {
346
+ let stmt = cache.get(sql);
347
+ if (!stmt)
348
+ cache.set(sql, (stmt = db.prepare(sql)));
349
+ return stmt;
350
+ };
351
+ }
313
352
  let shared;
314
353
  /**
315
354
  * The process's one connection, opened and migrated on first use. Every store
@@ -350,6 +389,11 @@ export function openDb(path, migrations = MIGRATIONS) {
350
389
  // file, and SQLite refuses to change it inside one.
351
390
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
352
391
  db.exec("PRAGMA journal_mode = WAL");
392
+ // WAL's default leaves every commit waiting on an fsync, and DatabaseSync is
393
+ // synchronous — that wait is the event loop's. NORMAL still survives a
394
+ // process crash; only a power loss can cost the last transactions, which for
395
+ // routing state and task bookkeeping is a fair trade for not blocking.
396
+ db.exec("PRAGMA synchronous = NORMAL");
353
397
  // Off by default in SQLite, and a declared relationship nothing enforces is
354
398
  // a comment. Set before migrate(): it is a per-connection switch and a no-op
355
399
  // inside a transaction. Nothing older declares a key, so this changes the
package/dist/main.js CHANGED
@@ -345,6 +345,7 @@ app.route("/", createServer({
345
345
  onUnlocked: () => void startChannels(),
346
346
  reload: () => reloadInstance(true),
347
347
  backgroundRuns: (id) => tasks.backgroundRuns(id),
348
+ activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
348
349
  channelOf: (id) => conversations.channelOf(id),
349
350
  }));
350
351
  const port = Number(process.env.PORT ?? 3141);
package/dist/secrets.js CHANGED
@@ -119,6 +119,15 @@ export class Secrets {
119
119
  this.#file = next;
120
120
  log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
121
121
  }
122
+ /**
123
+ * What vt says about itself. A refused or impossible unlock is almost never
124
+ * Pier's fault — missing binary, no agent listening, config pointing
125
+ * elsewhere — and `lockedReason` only carries the last error. Read-only, so
126
+ * it is safe to run while locked; vt's own report is the repair instruction.
127
+ */
128
+ doctor() {
129
+ return this.vt.doctor();
130
+ }
122
131
  /** First boot: random KEK and DEK, file mode. */
123
132
  #create() {
124
133
  const kek = randomBytes(KEY_BYTES);
@@ -172,20 +181,23 @@ export const vtCli = {
172
181
  throw new Error("vt create printed no vt:// record");
173
182
  return record;
174
183
  },
184
+ // Bounded: doctor probes an agent socket and a worker over the network, and
185
+ // a hung probe would leave the Console waiting on a diagnosis forever.
186
+ doctor: () => run("vt", ["doctor"], undefined, 15_000),
175
187
  };
176
- function run(cmd, args, stdin) {
188
+ function run(cmd, args, stdin, timeoutMs) {
177
189
  return new Promise((resolvePromise, reject) => {
178
- const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
190
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs });
179
191
  let out = "";
180
192
  let err = "";
181
193
  child.stdout.on("data", (d) => (out += d.toString()));
182
194
  child.stderr.on("data", (d) => (err += d.toString()));
183
195
  child.on("error", reject);
184
- child.on("close", (code) => {
196
+ child.on("close", (code, signal) => {
185
197
  if (code === 0)
186
- resolvePromise(out.trim());
187
- else
188
- reject(new Error(`${cmd} ${args[0]} exited ${code}: ${err.trim() || out.trim()}`));
198
+ return resolvePromise(out.trim());
199
+ const how = signal ? `timed out after ${timeoutMs}ms (${signal})` : `exited ${code}`;
200
+ reject(new Error(`${cmd} ${args[0]} ${how}: ${err.trim() || out.trim()}`));
189
201
  });
190
202
  child.stdin.on("error", reject); // EPIPE if vt exits before reading
191
203
  if (stdin !== undefined)
@@ -74,9 +74,17 @@ export class AgentTaskRunner {
74
74
  run.context.renderedPrompt = prompt;
75
75
  this.store.saveRun(run);
76
76
  let text = "";
77
+ // Not only what the turn said but how it ended: a provider outage ends
78
+ // it with an empty reply, and a run that settles on that reports "no
79
+ // reply" — the one wording for a turn that *chose* to say nothing, so
80
+ // the outage arrives at the caller looking like an answer (§5b). Pi has
81
+ // already retried by the time this lands; the run only reports.
82
+ let failure;
77
83
  const unsubscribe = session.subscribe((event) => {
78
- if (event.type === "turn-end")
84
+ if (event.type === "turn-end") {
79
85
  text = event.text;
86
+ failure = event.error;
87
+ }
80
88
  });
81
89
  // The race below also settles this attempt if Pi ignores the abort:
82
90
  // a hung turn must not hold one of the 4 slots (and its waiters)
@@ -106,6 +114,11 @@ export class AgentTaskRunner {
106
114
  await Promise.race([turn, abortedTurn]);
107
115
  if (signal.aborted)
108
116
  throw new Error("cancelled");
117
+ // Before the fallback below: on a reused session that reads the
118
+ // *previous* turn's answer back out of history and reports it as
119
+ // this run's result.
120
+ if (failure)
121
+ throw new Error(failure);
109
122
  if (!text) {
110
123
  const history = await session.history();
111
124
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
@@ -228,7 +228,9 @@ export class TaskDefinitions {
228
228
  }
229
229
  claimDue(now) {
230
230
  const due = [];
231
- for (const task of this.store.listTasks()) {
231
+ // The store narrows to the tasks with a next run at or before `now`; the
232
+ // JSON is still the record, so its own fields decide.
233
+ for (const task of this.store.listDueTasks(now)) {
232
234
  if (!task.enabled || task.archived || task.nextRunAt === null || task.nextRunAt > now)
233
235
  continue;
234
236
  task.nextRunAt = nextRunAt(task.trigger, now);
@@ -84,8 +84,7 @@ export class TaskMessenger {
84
84
  * `inject` dedupes on the recipient transcript, so a retry cannot double
85
85
  * deliver. Controls aimed at a finished run are dead and expire here. */
86
86
  retryUndelivered(now = Date.now()) {
87
- for (const message of this.store.listUndeliveredMessages()) {
88
- const run = this.store.getRun(message.runId);
87
+ for (const { message, run } of this.store.listUndeliveredMessages()) {
89
88
  if (!run)
90
89
  continue;
91
90
  // Expiring a dead control is not a retry, so it ignores the backoff.
@@ -198,6 +198,11 @@ export class TaskService {
198
198
  .reverse()
199
199
  .map((run) => this.backgroundRun(run));
200
200
  }
201
+ /** The same runs `backgroundRuns` reports as in flight, counted per session
202
+ * in one query — a list needs the number, not the runs. */
203
+ activeBackgroundRunCounts() {
204
+ return this.store.countActiveBackgroundRunsBySession();
205
+ }
201
206
  run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
202
207
  this.refusePaused(parentRunId);
203
208
  const task = this.get(taskId);
@@ -2,22 +2,23 @@
2
2
  // runs, groups and messages are rows here, read and written through one
3
3
  // connection db.ts opened. A store owns its queries, never its own tables or
4
4
  // its own handle — the schema is db.ts's migration list.
5
- import { pierDb } from "../db.js";
5
+ import { pierDb, statements } from "../db.js";
6
6
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
7
7
  export class TaskStore {
8
- db;
8
+ /** Every query below is a fixed string, so each is compiled once. */
9
+ sql;
9
10
  constructor(db = pierDb()) {
10
- this.db = db;
11
+ this.sql = statements(db);
11
12
  }
12
13
  // Every table is one JSON column plus query columns; these two are the only
13
14
  // readers, and the one seam where a future schema change normalizes old rows
14
15
  // (pre-v1 databases are refused outright in db.ts).
15
16
  #one(sql, ...params) {
16
- const row = this.db.prepare(sql).get(...params);
17
+ const row = this.sql(sql).get(...params);
17
18
  return row ? JSON.parse(row.json) : undefined;
18
19
  }
19
20
  #many(sql, ...params) {
20
- return this.db.prepare(sql).all(...params)
21
+ return this.sql(sql).all(...params)
21
22
  .map((row) => JSON.parse(row.json));
22
23
  }
23
24
  listTasks() {
@@ -27,11 +28,17 @@ export class TaskStore {
27
28
  return this.#one("SELECT json FROM tasks WHERE id = ?", id);
28
29
  }
29
30
  saveTask(task) {
30
- this.db.prepare(`
31
+ this.sql(`
31
32
  INSERT INTO tasks(id, updated_at, json) VALUES (?, ?, ?)
32
33
  ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
33
34
  `).run(task.id, task.updatedAt, JSON.stringify(task));
34
35
  }
36
+ /** The tick's whole question, asked of the index rather than of every
37
+ * document: a task with no next run (disabled, archived, manual) is not in
38
+ * it, so an idle second reads no row. */
39
+ listDueTasks(now) {
40
+ return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
41
+ }
35
42
  listRuns(taskId, limit = 50, offset = 0) {
36
43
  return this.#many(`
37
44
  SELECT json FROM task_runs WHERE task_id = ?
@@ -42,7 +49,7 @@ export class TaskStore {
42
49
  return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
43
50
  }
44
51
  saveRun(run) {
45
- this.db.prepare(`
52
+ this.sql(`
46
53
  INSERT INTO task_runs(id, task_id, queued_at, state, callback_state, json)
47
54
  VALUES (?, ?, ?, ?, ?, ?)
48
55
  ON CONFLICT(id) DO UPDATE SET
@@ -60,7 +67,7 @@ export class TaskStore {
60
67
  `, rootRunId, clamp(limit, 500));
61
68
  }
62
69
  countActiveRuns() {
63
- const row = this.db.prepare("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
70
+ const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
64
71
  return row.n;
65
72
  }
66
73
  findActiveRun(taskId) {
@@ -77,6 +84,20 @@ export class TaskStore {
77
84
  ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
78
85
  `, sessionId);
79
86
  }
87
+ /** How many background runs each session has in flight, for a list that
88
+ * draws one dot per row: the state column narrows the scan to the handful
89
+ * of live runs, and no row's JSON is parsed. */
90
+ countActiveBackgroundRunsBySession() {
91
+ const rows = this.sql(`
92
+ SELECT json_extract(json, '$.invokedBySessionId') AS session_id, COUNT(*) AS n
93
+ FROM task_runs
94
+ WHERE state IN ('queued', 'running')
95
+ AND json_extract(json, '$.background') = 1
96
+ AND json_extract(json, '$.invokedBySessionId') IS NOT NULL
97
+ GROUP BY session_id
98
+ `).all();
99
+ return new Map(rows.map((row) => [row.session_id, row.n]));
100
+ }
80
101
  listRunsForSession(sessionId, limit = 50) {
81
102
  return this.#many(`
82
103
  SELECT json FROM task_runs
@@ -85,7 +106,7 @@ export class TaskStore {
85
106
  `, sessionId, clamp(limit, 200));
86
107
  }
87
108
  saveGroup(group) {
88
- this.db.prepare(`
109
+ this.sql(`
89
110
  INSERT INTO task_groups(id, created_at, callback_state, finished_at, json)
90
111
  VALUES (?, ?, ?, ?, ?)
91
112
  ON CONFLICT(id) DO UPDATE SET
@@ -107,7 +128,7 @@ export class TaskStore {
107
128
  `, now);
108
129
  }
109
130
  saveMessage(message) {
110
- this.db.prepare(`
131
+ this.sql(`
111
132
  INSERT INTO task_messages(id, run_id, state, created_at, json)
112
133
  VALUES (?, ?, ?, ?, ?)
113
134
  ON CONFLICT(id) DO UPDATE SET state=excluded.state, json=excluded.json
@@ -122,9 +143,20 @@ export class TaskStore {
122
143
  listRecentMessages(since) {
123
144
  return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
124
145
  }
125
- /** Messages whose injection never landed: the delivery sweep retries these. */
146
+ /** Messages whose injection never landed, each beside the run it belongs to:
147
+ * the delivery sweep needs both, and fetching the run per message made one
148
+ * sweep cost a query per undelivered message. A message whose run is gone
149
+ * comes back with `run: undefined` — the sweep drops it. */
126
150
  listUndeliveredMessages() {
127
- return this.#many("SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at");
151
+ const rows = this.sql(`
152
+ SELECT m.json AS json, r.json AS run_json
153
+ FROM task_messages m LEFT JOIN task_runs r ON r.id = m.run_id
154
+ WHERE m.state IN ('pending', 'failed') ORDER BY m.created_at
155
+ `).all();
156
+ return rows.map((row) => ({
157
+ message: JSON.parse(row.json),
158
+ run: row.run_json === null ? undefined : JSON.parse(row.run_json),
159
+ }));
128
160
  }
129
161
  /** Decisions are excluded: they have no timeout and stay answerable across
130
162
  * restarts — a reply to a terminal run resumes it. */
package/dist/tools.js CHANGED
@@ -188,6 +188,10 @@ export function prependPath(env = process.env, bin = toolsBin()) {
188
188
  mkdirSync(bin, { recursive: true }); // a PATH entry that does not exist is a shell's problem
189
189
  env.PATH = current ? `${bin}${delimiter}${current}` : bin;
190
190
  }
191
+ /** How long `ubix list --json` stays usable for `status()`: long enough that
192
+ * one Console page open spawns it once, short enough that an install done
193
+ * outside Pier shows up while the operator is still looking at the page. */
194
+ const LIST_TTL_MS = 3_000;
191
195
  /** `stale` is heartbeat age, never how long the work has taken; `wait` is what
192
196
  * a waiter gives a live holder before giving up with a reason. */
193
197
  const LOCK_TIMING = { heartbeatMs: 5_000, staleMs: 30_000, waitMs: 20 * 60_000, pollMs: 200 };
@@ -606,7 +610,15 @@ export class ManagedTools {
606
610
  this.#lock ??= new SyncLock(this.#db());
607
611
  return this.#lock.run(async (fence) => {
608
612
  const { tools, customTools } = read();
609
- return this.#converge(tools, customTools, fence);
613
+ // This run is the only thing here that changes what `list` answers, so
614
+ // the memo `status()` reads is dropped on both sides of it.
615
+ this.#listed = undefined;
616
+ try {
617
+ return await this.#converge(tools, customTools, fence);
618
+ }
619
+ finally {
620
+ this.#listed = undefined;
621
+ }
610
622
  });
611
623
  }
612
624
  async #converge(enabled, custom, fence) {
@@ -698,7 +710,7 @@ export class ManagedTools {
698
710
  return base;
699
711
  let states;
700
712
  try {
701
- states = await this.#states(this.ubixPath, this.#env(), ["list", "--json"]);
713
+ states = await this.#listedTools();
702
714
  }
703
715
  catch (err) {
704
716
  // The page says why it cannot answer rather than answering wrongly: a
@@ -733,6 +745,24 @@ export class ManagedTools {
733
745
  });
734
746
  });
735
747
  }
748
+ /** What `ubix list --json` last said, retained for LIST_TTL_MS. It is a
749
+ * subprocess, and the Console asks for the catalog on every settings read —
750
+ * one page open is several, each of which spawned its own ubix. A failed
751
+ * read is not retained: it is not an answer to hand the next caller for
752
+ * three seconds. */
753
+ #listed;
754
+ #listedTools() {
755
+ const now = Date.now();
756
+ if (this.#listed && now - this.#listed.at < LIST_TTL_MS)
757
+ return this.#listed.states;
758
+ const states = this.#states(this.ubixPath, this.#env(), ["list", "--json"]);
759
+ void states.catch(() => {
760
+ if (this.#listed?.states === states)
761
+ this.#listed = undefined;
762
+ });
763
+ this.#listed = { at: now, states };
764
+ return states;
765
+ }
736
766
  /** Pier's ubix config and state, never the operator's. `UBIX_CONFIG_DIR` /
737
767
  * `UBIX_DATA_DIR` name the directories that hold config.toml / state.toml
738
768
  * directly — not XDG parents, which every child ubix spawns (uv, fnm,
package/dist/web/auth.js CHANGED
@@ -20,7 +20,7 @@
20
20
  import { createHash, randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
21
21
  import { getConnInfo } from "@hono/node-server/conninfo";
22
22
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
23
- import { pierDb, transact } from "../db.js";
23
+ import { pierDb, statements, transact } from "../db.js";
24
24
  import { logger } from "../log.js";
25
25
  const log = logger("auth");
26
26
  const COOKIE = "pier_session";
@@ -67,9 +67,13 @@ function generatePassword() {
67
67
  */
68
68
  export class AuthStore {
69
69
  #db;
70
+ /** Compiled once each: `check()` runs two of these on every request that
71
+ * carries a cookie, which is every request the workbench makes. */
72
+ #sql;
70
73
  #revokeListeners = new Set();
71
74
  constructor(db = pierDb(), print = (m) => log.info(m)) {
72
75
  this.#db = db;
76
+ this.#sql = statements(db);
73
77
  let row = this.#row();
74
78
  if (!row) {
75
79
  const password = generatePassword();
@@ -81,8 +85,7 @@ export class AuthStore {
81
85
  // in one transaction: half of this leaves a new password and live old
82
86
  // cookies, which is the state recovery exists to end.
83
87
  transact(this.#db, () => {
84
- this.#db
85
- .prepare("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
88
+ this.#sql("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
86
89
  .run(salt, hash(password, salt), Date.now());
87
90
  this.#dropSessions();
88
91
  });
@@ -96,14 +99,13 @@ export class AuthStore {
96
99
  this.sweep();
97
100
  }
98
101
  #row() {
99
- return this.#db
100
- .prepare("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
102
+ return this.#sql("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
101
103
  .get();
102
104
  }
103
105
  /** Every signed-in browser at once. Private: the callers that mean it also
104
106
  * have to tell the listeners, and `revoke(ALL)` is that pair in public. */
105
107
  #dropSessions() {
106
- this.#db.prepare("DELETE FROM web_sessions").run();
108
+ this.#sql("DELETE FROM web_sessions").run();
107
109
  }
108
110
  /** Sessions nobody may use any more, deleted rather than merely refused: a
109
111
  * row is what a push subscription hangs off, so "expired" has to become
@@ -111,8 +113,7 @@ export class AuthStore {
111
113
  * boot and whenever somebody signs in — the two moments the process has a
112
114
  * reason to look at this table at all. */
113
115
  sweep() {
114
- const swept = this.#db
115
- .prepare("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
116
+ const swept = this.#sql("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
116
117
  .all(Date.now() - TTL_MS);
117
118
  for (const row of swept)
118
119
  this.#revoked(row.id);
@@ -135,8 +136,7 @@ export class AuthStore {
135
136
  // Credential and sessions change together or not at all — a crash between
136
137
  // the two writes is exactly the state "everyone signs in again" denies.
137
138
  transact(this.#db, () => {
138
- this.#db
139
- .prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
139
+ this.#sql("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
140
140
  .run(salt, hash(password, salt), Date.now());
141
141
  this.#dropSessions();
142
142
  });
@@ -150,10 +150,8 @@ export class AuthStore {
150
150
  // name, and the 256-bit token is the only part that has to resist guessing.
151
151
  const id = randomBytes(9).toString("base64url");
152
152
  const token = randomBytes(32).toString("base64url");
153
- this.#db
154
- .prepare("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
155
- " VALUES (?, ?, ?, ?, ?, ?)")
156
- .run(id, digest(token), now, now, ip, agent.slice(0, 200));
153
+ this.#sql("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
154
+ " VALUES (?, ?, ?, ?, ?, ?)").run(id, digest(token), now, now, ip, agent.slice(0, 200));
157
155
  return `${id}.${token}`;
158
156
  }
159
157
  /**
@@ -166,9 +164,7 @@ export class AuthStore {
166
164
  const [id, token] = (cookie ?? "").split(".");
167
165
  if (!id || !token)
168
166
  return undefined;
169
- const row = this.#db
170
- .prepare("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?")
171
- .get(id);
167
+ const row = this.#sql("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?").get(id);
172
168
  const now = Date.now();
173
169
  // One clock: last use is the deadline, so there is no second column that
174
170
  // can disagree with it about when this session ends. An expired row is
@@ -184,7 +180,7 @@ export class AuthStore {
184
180
  return undefined;
185
181
  if (now - row.seenAt < TOUCH_MS)
186
182
  return { id, renewed: false };
187
- this.#db.prepare("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
183
+ this.#sql("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
188
184
  return { id, renewed: true };
189
185
  }
190
186
  /** Sign out one browser, or every one of them (`ALL`). Listeners hear the
@@ -193,16 +189,14 @@ export class AuthStore {
193
189
  if (id === ALL)
194
190
  this.#dropSessions();
195
191
  else
196
- this.#db.prepare("DELETE FROM web_sessions WHERE id = ?").run(id);
192
+ this.#sql("DELETE FROM web_sessions WHERE id = ?").run(id);
197
193
  this.#revoked(id);
198
194
  }
199
195
  /** Signed-in browsers, most recently seen first. Never the token — the list
200
196
  * is shown to whoever is signed in, and it is not a set of credentials. */
201
197
  list() {
202
- return this.#db
203
- .prepare("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
204
- " FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC")
205
- .all(Date.now() - TTL_MS);
198
+ return this.#sql("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
199
+ " FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC").all(Date.now() - TTL_MS);
206
200
  }
207
201
  /** A long-lived authenticated surface closes itself when a cookie is
208
202
  * revoked. The store and listeners share the process lifetime. */
@@ -0,0 +1,61 @@
1
+ // The agent files a scope is configured by — Pi's own config, skills and
2
+ // extensions — read and written through the ConfigStore, never as paths. A
3
+ // scope is "global" or a project cwd Pi already knows, which is why this is
4
+ // the one filesystem-shaped surface that does not go through web/fs.ts: it
5
+ // never takes a path from the browser at all.
6
+ import { guarded } from "./route.js";
7
+ export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
8
+ // Scope comes from the client as "global" or a project cwd; only cwds Pi
9
+ // already knows (the session list) are accepted — never an arbitrary path.
10
+ const parseScope = async (raw) => {
11
+ if (!raw || raw === "global")
12
+ return { kind: "global" };
13
+ const known = await factory.list();
14
+ return known.some((s) => s.cwd === raw) ? { kind: "project", cwd: raw } : null;
15
+ };
16
+ app.get("/api/config", async (c) => {
17
+ c.header("cache-control", "no-store");
18
+ const scope = await parseScope(c.req.query("scope"));
19
+ if (!scope)
20
+ return c.json({ error: "unknown scope" }, 400);
21
+ return c.json({
22
+ // Where this scope's files live on disk — the UI labels "Global" with it.
23
+ dir: scope.kind === "global" ? config.globalDir : scope.cwd,
24
+ files: await config.listFiles(scope),
25
+ resources: await config.listResources(scope),
26
+ });
27
+ });
28
+ guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
29
+ c.header("cache-control", "no-store");
30
+ const scope = await parseScope(c.req.query("scope"));
31
+ if (!scope)
32
+ return c.json({ error: "unknown scope" }, 400);
33
+ return c.json({ content: await config.readFile(scope, c.req.param("name")) });
34
+ });
35
+ guarded(app, "PUT", "/api/config/files/:name", 400, async (c) => {
36
+ const scope = await parseScope(c.req.query("scope"));
37
+ if (!scope)
38
+ return c.json({ error: "unknown scope" }, 400);
39
+ const body = await c.req.json().catch(() => null);
40
+ if (typeof body?.content !== "string" || typeof body?.expected !== "string") {
41
+ return c.json({ error: "content and expected content required" }, 400);
42
+ }
43
+ const name = c.req.param("name");
44
+ await config.writeFile(scope, name, body.content, body.expected);
45
+ onConfigWritten?.();
46
+ return c.json({ ok: true, content: await config.readFile(scope, name) });
47
+ });
48
+ // Resource names may contain slashes — query params, not path params.
49
+ guarded(app, "GET", "/api/config/resource", 400, async (c) => {
50
+ c.header("cache-control", "no-store");
51
+ const scope = await parseScope(c.req.query("scope"));
52
+ if (!scope)
53
+ return c.json({ error: "unknown scope" }, 400);
54
+ const kind = c.req.query("kind");
55
+ const name = c.req.query("name");
56
+ if ((kind !== "extensions" && kind !== "skills") || !name) {
57
+ return c.json({ error: "kind and name required" }, 400);
58
+ }
59
+ return c.json({ content: await config.readResource(scope, kind, name) });
60
+ });
61
+ }