@timqi/pier 0.0.16 → 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 (79) 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 +105 -0
  17. package/dist/main.js +1 -0
  18. package/dist/secrets.js +18 -6
  19. package/dist/settings.js +2 -11
  20. package/dist/tasks/agent.js +14 -1
  21. package/dist/tasks/definitions.js +3 -1
  22. package/dist/tasks/messages.js +1 -2
  23. package/dist/tasks/service.js +5 -0
  24. package/dist/tasks/store.js +44 -12
  25. package/dist/tools.js +42 -18
  26. package/dist/web/auth.js +212 -68
  27. package/dist/web/config.js +61 -0
  28. package/dist/web/explorer.js +20 -70
  29. package/dist/web/fs.js +159 -0
  30. package/dist/web/instance.js +11 -0
  31. package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
  32. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  33. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  34. package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
  35. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  36. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  37. package/dist/web/public/assets/code-DjWvqL94.js +1 -0
  38. package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
  39. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  40. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  41. package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-D8CLFi_l.js} +1 -1
  42. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  43. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  44. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  45. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  46. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  47. package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
  48. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  49. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  50. package/dist/web/public/assets/{index-2E9_cwpg.css → index-yNocr797.css} +1 -1
  51. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  52. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  53. package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
  54. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  55. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
  57. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  58. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  59. package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
  60. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  61. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
  62. package/dist/web/public/icon.svg.br +0 -0
  63. package/dist/web/public/icon.svg.gz +0 -0
  64. package/dist/web/public/index.html +2 -2
  65. package/dist/web/public/index.html.br +0 -0
  66. package/dist/web/public/index.html.gz +0 -0
  67. package/dist/web/public/manifest.webmanifest.br +0 -0
  68. package/dist/web/public/manifest.webmanifest.gz +0 -0
  69. package/dist/web/public/sw.js.br +0 -0
  70. package/dist/web/public/sw.js.gz +0 -0
  71. package/dist/web/push.js +30 -7
  72. package/dist/web/route.js +17 -0
  73. package/dist/web/server.js +104 -23
  74. package/dist/web/terminal.js +21 -16
  75. package/docs/deploy.md +12 -3
  76. package/package.json +1 -1
  77. package/skills/pier-slack/SKILL.md +4 -16
  78. package/dist/web/files.js +0 -183
  79. package/dist/web/public/assets/index-DVUvzNK1.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
@@ -253,8 +253,102 @@ const MIGRATIONS = [
253
253
  token TEXT NOT NULL,
254
254
  heartbeat_at INTEGER NOT NULL
255
255
  );
256
+ `,
257
+ // 13 — a signed-in browser can be signed out on its own (web/auth.ts).
258
+ `
259
+ -- One row per signed-in browser. The cookie carries "<id>.<token>" and only
260
+ -- the token's SHA-256 is stored, so a copy of this database cannot be turned
261
+ -- into a session — and deleting a row is what revocation is. seen_at is the
262
+ -- whole lifetime: the session ends one TTL after it, so there is no second
263
+ -- column that can disagree about when.
264
+ CREATE TABLE web_sessions (
265
+ id TEXT PRIMARY KEY,
266
+ token_hash TEXT NOT NULL,
267
+ created_at INTEGER NOT NULL,
268
+ seen_at INTEGER NOT NULL,
269
+ ip TEXT NOT NULL,
270
+ agent TEXT NOT NULL
271
+ );
272
+ `,
273
+ // 14 — signing a browser out also stops notifying it (web/push.ts).
274
+ `
275
+ -- A subscription belongs to the web session that made it, and dies with it:
276
+ -- the cascade is the rule, so no code has to remember to run it — revoking a
277
+ -- session, changing the password and recovering it all reach here for free.
278
+ -- Rebuilt rather than altered because a foreign key cannot be added to an
279
+ -- existing table; nothing is carried over, since migration 13 invalidated
280
+ -- every cookie and each of these rows belongs to a browser that is now
281
+ -- signed out. A browser re-subscribes on its next load.
282
+ DROP TABLE push_subscriptions;
283
+ CREATE TABLE push_subscriptions (
284
+ endpoint TEXT PRIMARY KEY,
285
+ p256dh TEXT NOT NULL,
286
+ auth TEXT NOT NULL,
287
+ label TEXT NOT NULL,
288
+ created_at INTEGER NOT NULL,
289
+ session_id TEXT NOT NULL REFERENCES web_sessions(id) ON DELETE CASCADE
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;
256
312
  `,
257
313
  ];
314
+ /**
315
+ * Several writes as one, or none. `BEGIN IMMEDIATE` because every writer here
316
+ * competes with another Pier process on the same file: taking the write lock
317
+ * up front turns a race into a wait, where deferred would turn it into
318
+ * SQLITE_BUSY halfway through. The rollback is the reason this is shared —
319
+ * three modules had written the same seven lines, and a `catch` that forgets
320
+ * to roll back leaves the connection in a transaction forever.
321
+ */
322
+ export function transact(db, work) {
323
+ db.exec("BEGIN IMMEDIATE");
324
+ try {
325
+ const result = work();
326
+ db.exec("COMMIT");
327
+ return result;
328
+ }
329
+ catch (err) {
330
+ db.exec("ROLLBACK");
331
+ throw err;
332
+ }
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
+ }
258
352
  let shared;
259
353
  /**
260
354
  * The process's one connection, opened and migrated on first use. Every store
@@ -295,6 +389,17 @@ export function openDb(path, migrations = MIGRATIONS) {
295
389
  // file, and SQLite refuses to change it inside one.
296
390
  db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
297
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");
397
+ // Off by default in SQLite, and a declared relationship nothing enforces is
398
+ // a comment. Set before migrate(): it is a per-connection switch and a no-op
399
+ // inside a transaction. Nothing older declares a key, so this changes the
400
+ // behaviour of exactly one table — push_subscriptions, whose rows must not
401
+ // outlive the session that made them.
402
+ db.exec("PRAGMA foreign_keys = ON");
298
403
  migrate(db, path, migrations);
299
404
  if (path !== ":memory:")
300
405
  restrict(path);
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)
package/dist/settings.js CHANGED
@@ -7,7 +7,7 @@
7
7
  // too" — the agent is told the URL in its system prompt (core/reply.ts), and
8
8
  // nothing outside this process ever opened that file.
9
9
  import { isThinkingLevel } from "./core/types.js";
10
- import { pierDb } from "./db.js";
10
+ import { pierDb, transact } from "./db.js";
11
11
  import { logger } from "./log.js";
12
12
  // The one place the custom-tool vocabulary lives (names, ubix sources, the
13
13
  // names Pier already owns). Imported rather than copied: a second validator
@@ -201,16 +201,7 @@ export class SettingsStore {
201
201
  * declared and invisible.
202
202
  */
203
203
  transact(work) {
204
- this.#db.exec("BEGIN IMMEDIATE");
205
- try {
206
- const result = work();
207
- this.#db.exec("COMMIT");
208
- return result;
209
- }
210
- catch (err) {
211
- this.#db.exec("ROLLBACK");
212
- throw err;
213
- }
204
+ return transact(this.#db, work);
214
205
  }
215
206
  #set(key, value) {
216
207
  this.#db.prepare(`
@@ -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
@@ -16,7 +16,7 @@ import { execFile } from "node:child_process";
16
16
  import { createHash, randomUUID } from "node:crypto";
17
17
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
18
18
  import { delimiter, join } from "node:path";
19
- import { pierDb } from "./db.js";
19
+ import { pierDb, transact } from "./db.js";
20
20
  import { logger } from "./log.js";
21
21
  import { pierPath, resolveAgentDir } from "./paths.js";
22
22
  const log = logger("tools");
@@ -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 };
@@ -253,21 +257,15 @@ export class SyncLock {
253
257
  * in one immediate transaction, so two waiters cannot both win. */
254
258
  #acquire(token) {
255
259
  const now = Date.now();
256
- this.#db.exec("BEGIN IMMEDIATE");
257
- try {
258
- const stale = this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
259
- .run(now - this.#timing.staleMs);
260
- const taken = this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
261
- .run(token, now);
262
- this.#db.exec("COMMIT");
263
- if (stale.changes && taken.changes)
264
- log.warn("took over a tools sync lock whose holder stopped beating");
265
- return taken.changes === 1;
266
- }
267
- catch (err) {
268
- this.#db.exec("ROLLBACK");
269
- throw err;
270
- }
260
+ const { stale, taken } = transact(this.#db, () => ({
261
+ stale: this.#db.prepare("DELETE FROM tools_sync_lock WHERE heartbeat_at <= ?")
262
+ .run(now - this.#timing.staleMs),
263
+ taken: this.#db.prepare("INSERT OR IGNORE INTO tools_sync_lock (id, token, heartbeat_at) VALUES (1, ?, ?)")
264
+ .run(token, now),
265
+ }));
266
+ if (stale.changes && taken.changes)
267
+ log.warn("took over a tools sync lock whose holder stopped beating");
268
+ return taken.changes === 1;
271
269
  }
272
270
  /** Still ours? The authority is the row, asked now — not the heartbeat's own
273
271
  * bookkeeping, which a stopped process does not get to run either. */
@@ -612,7 +610,15 @@ export class ManagedTools {
612
610
  this.#lock ??= new SyncLock(this.#db());
613
611
  return this.#lock.run(async (fence) => {
614
612
  const { tools, customTools } = read();
615
- 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
+ }
616
622
  });
617
623
  }
618
624
  async #converge(enabled, custom, fence) {
@@ -704,7 +710,7 @@ export class ManagedTools {
704
710
  return base;
705
711
  let states;
706
712
  try {
707
- states = await this.#states(this.ubixPath, this.#env(), ["list", "--json"]);
713
+ states = await this.#listedTools();
708
714
  }
709
715
  catch (err) {
710
716
  // The page says why it cannot answer rather than answering wrongly: a
@@ -739,6 +745,24 @@ export class ManagedTools {
739
745
  });
740
746
  });
741
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
+ }
742
766
  /** Pier's ubix config and state, never the operator's. `UBIX_CONFIG_DIR` /
743
767
  * `UBIX_DATA_DIR` name the directories that hold config.toml / state.toml
744
768
  * directly — not XDG parents, which every child ubix spawns (uv, fnm,