@timqi/pier 0.0.17 → 0.0.20

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 (95) hide show
  1. package/dist/agent/config-sync.js +123 -0
  2. package/dist/agent/config.js +95 -3
  3. package/dist/agent/events.js +26 -5
  4. package/dist/agent/listing.js +12 -13
  5. package/dist/agent/pi.js +75 -27
  6. package/dist/boards/boards.js +7 -6
  7. package/dist/channels/attach.js +18 -4
  8. package/dist/channels/config.js +7 -2
  9. package/dist/channels/dedup.js +9 -1
  10. package/dist/channels/lark.js +2 -6
  11. package/dist/channels/receipts.js +21 -6
  12. package/dist/channels/slack-tool.js +86 -25
  13. package/dist/channels/slack.js +2 -10
  14. package/dist/channels/telegram.js +1 -0
  15. package/dist/config-sync-fetch.js +97 -0
  16. package/dist/config-sync-task.js +59 -0
  17. package/dist/config-sync.js +179 -0
  18. package/dist/core/hub.js +23 -6
  19. package/dist/core/inbox.js +1 -1
  20. package/dist/core/reply.js +76 -2
  21. package/dist/core/router.js +162 -37
  22. package/dist/db.js +50 -0
  23. package/dist/main.js +32 -1
  24. package/dist/secrets.js +18 -6
  25. package/dist/tasks/agent.js +25 -23
  26. package/dist/tasks/callbacks.js +1 -1
  27. package/dist/tasks/definitions.js +28 -17
  28. package/dist/tasks/execution.js +11 -3
  29. package/dist/tasks/messages.js +58 -16
  30. package/dist/tasks/routes.js +58 -10
  31. package/dist/tasks/runs.js +0 -5
  32. package/dist/tasks/service.js +14 -4
  33. package/dist/tasks/store.js +87 -10
  34. package/dist/tasks/tool.js +8 -4
  35. package/dist/tools.js +32 -2
  36. package/dist/web/auth.js +17 -23
  37. package/dist/web/config-sync.js +91 -0
  38. package/dist/web/config.js +61 -0
  39. package/dist/web/explorer.js +20 -70
  40. package/dist/web/fs.js +159 -0
  41. package/dist/web/instance.js +11 -0
  42. package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
  43. package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
  44. package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
  45. package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
  46. package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
  47. package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
  48. package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
  49. package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
  50. package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
  51. package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
  52. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
  53. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
  54. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
  55. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  56. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  57. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  58. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
  59. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  60. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  61. package/dist/web/public/assets/index-WrsNstrb.js +79 -0
  62. package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
  63. package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
  64. package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
  65. package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
  66. package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
  67. package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
  68. package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
  69. package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
  70. package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
  71. package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
  72. package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
  73. package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
  74. package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
  75. package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
  76. package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
  77. package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
  78. package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
  79. package/dist/web/public/icon.svg.br +0 -0
  80. package/dist/web/public/icon.svg.gz +0 -0
  81. package/dist/web/public/index.html +13 -2
  82. package/dist/web/public/index.html.br +0 -0
  83. package/dist/web/public/index.html.gz +0 -0
  84. package/dist/web/public/manifest.webmanifest.br +0 -0
  85. package/dist/web/public/manifest.webmanifest.gz +0 -0
  86. package/dist/web/public/sw.js.br +0 -0
  87. package/dist/web/public/sw.js.gz +0 -0
  88. package/dist/web/route.js +17 -0
  89. package/dist/web/server.js +152 -66
  90. package/dist/web/terminal.js +2 -7
  91. package/package.json +2 -2
  92. package/skills/pier-slack/SKILL.md +4 -16
  93. package/skills/pier-tasks/SKILL.md +14 -9
  94. package/dist/web/files.js +0 -183
  95. package/dist/web/public/assets/index-DAxKtidH.js +0 -93
@@ -44,7 +44,7 @@ export class TaskService {
44
44
  this.instance = instance;
45
45
  const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
46
46
  this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable);
47
- this.definitions = new TaskDefinitions(store, factory, router, hub);
47
+ this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
48
48
  this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
49
49
  this.groups = new TaskGroups(store, router, {
50
50
  getRun: (id) => this.getRun(id),
@@ -164,9 +164,6 @@ export class TaskService {
164
164
  archive(id, by) {
165
165
  return this.definitions.archive(id, by);
166
166
  }
167
- sessionExists(sessionId) {
168
- return this.definitions.sessionExists(sessionId);
169
- }
170
167
  listRuns(taskId, limit = 50, offset = 0) {
171
168
  this.get(taskId);
172
169
  return this.store.listRuns(taskId, Math.min(Math.max(limit, 1), 200), Math.max(offset, 0));
@@ -177,6 +174,11 @@ export class TaskService {
177
174
  throw new Error(`unknown task run: ${id}`);
178
175
  return run;
179
176
  }
177
+ getRunView(id) {
178
+ const run = this.getRun(id);
179
+ return { ...run, pendingDecisionId: this.openDecisionId(id),
180
+ groupCallbackState: run.groupId ? this.store.getGroup(run.groupId)?.callbackState ?? null : null };
181
+ }
180
182
  listMessages(runId) {
181
183
  this.getRun(runId);
182
184
  return this.messages.list(runId);
@@ -184,6 +186,9 @@ export class TaskService {
184
186
  openDecisionId(runId) {
185
187
  return this.messages.openDecisionId(runId);
186
188
  }
189
+ queryRuns(query = {}) {
190
+ return this.store.queryRuns(query);
191
+ }
187
192
  recentRuns(limit = 100) {
188
193
  return this.store.listRecentRuns(limit);
189
194
  }
@@ -198,6 +203,11 @@ export class TaskService {
198
203
  .reverse()
199
204
  .map((run) => this.backgroundRun(run));
200
205
  }
206
+ /** The same runs `backgroundRuns` reports as in flight, counted per session
207
+ * in one query — a list needs the number, not the runs. */
208
+ activeBackgroundRunCounts() {
209
+ return this.store.countActiveBackgroundRunsBySession();
210
+ }
201
211
  run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
202
212
  this.refusePaused(parentRunId);
203
213
  const task = this.get(taskId);
@@ -2,22 +2,25 @@
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
8
  db;
9
+ /** Every query below is a fixed string, so each is compiled once. */
10
+ sql;
9
11
  constructor(db = pierDb()) {
10
12
  this.db = db;
13
+ this.sql = statements(db);
11
14
  }
12
15
  // Every table is one JSON column plus query columns; these two are the only
13
16
  // readers, and the one seam where a future schema change normalizes old rows
14
17
  // (pre-v1 databases are refused outright in db.ts).
15
18
  #one(sql, ...params) {
16
- const row = this.db.prepare(sql).get(...params);
19
+ const row = this.sql(sql).get(...params);
17
20
  return row ? JSON.parse(row.json) : undefined;
18
21
  }
19
22
  #many(sql, ...params) {
20
- return this.db.prepare(sql).all(...params)
23
+ return this.sql(sql).all(...params)
21
24
  .map((row) => JSON.parse(row.json));
22
25
  }
23
26
  listTasks() {
@@ -27,11 +30,17 @@ export class TaskStore {
27
30
  return this.#one("SELECT json FROM tasks WHERE id = ?", id);
28
31
  }
29
32
  saveTask(task) {
30
- this.db.prepare(`
33
+ this.sql(`
31
34
  INSERT INTO tasks(id, updated_at, json) VALUES (?, ?, ?)
32
35
  ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
33
36
  `).run(task.id, task.updatedAt, JSON.stringify(task));
34
37
  }
38
+ /** The tick's whole question, asked of the index rather than of every
39
+ * document: a task with no next run (disabled, archived, manual) is not in
40
+ * it, so an idle second reads no row. */
41
+ listDueTasks(now) {
42
+ return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
43
+ }
35
44
  listRuns(taskId, limit = 50, offset = 0) {
36
45
  return this.#many(`
37
46
  SELECT json FROM task_runs WHERE task_id = ?
@@ -42,13 +51,56 @@ export class TaskStore {
42
51
  return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
43
52
  }
44
53
  saveRun(run) {
45
- this.db.prepare(`
54
+ this.sql(`
46
55
  INSERT INTO task_runs(id, task_id, queued_at, state, callback_state, json)
47
56
  VALUES (?, ?, ?, ?, ?, ?)
48
57
  ON CONFLICT(id) DO UPDATE SET
49
58
  state=excluded.state, callback_state=excluded.callback_state, json=excluded.json
50
59
  `).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
51
60
  }
61
+ /** The global list: filters precede the limit, and the id breaks timestamp
62
+ * ties so a page boundary never repeats or skips a row. The statement is
63
+ * built per call and not cached — the filter set makes it. */
64
+ queryRuns(query = {}) {
65
+ const where = [];
66
+ const params = [];
67
+ const add = (sql, value) => {
68
+ if (value !== undefined) {
69
+ where.push(sql);
70
+ params.push(value);
71
+ }
72
+ };
73
+ add("r.state = ?", query.state);
74
+ add("r.task_id = ?", query.taskId);
75
+ add("json_extract(r.json, '$.triggerSource') = ?", query.source);
76
+ add("r.queued_at >= ?", query.since);
77
+ add("r.queued_at <= ?", query.until);
78
+ // Keep this predicate identical to migration 17's partial index.
79
+ if (!query.showUnmatched)
80
+ where.push("NOT (r.state = 'succeeded' AND json_extract(r.json, '$.matched') IS 0)");
81
+ if (query.cursor) {
82
+ where.push("(r.queued_at, r.id) < (?, ?)");
83
+ params.push(query.cursor.queuedAt, query.cursor.id);
84
+ }
85
+ const limit = clamp(query.limit ?? 50, 200);
86
+ const rows = this.db.prepare(`
87
+ SELECT r.json, g.callback_state AS group_callback_state,
88
+ (SELECT m.id FROM task_messages m WHERE m.run_id = r.id
89
+ AND m.state IN ('pending', 'delivered') AND json_extract(m.json, '$.kind') = 'decision'
90
+ ORDER BY m.created_at, m.id LIMIT 1) AS decision_id
91
+ FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
92
+ ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
93
+ ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
94
+ `).all(...params, limit + 1);
95
+ const runs = rows.slice(0, limit).map((row) => ({
96
+ ...JSON.parse(row.json), pendingDecisionId: row.decision_id, groupCallbackState: row.group_callback_state,
97
+ }));
98
+ const last = runs.at(-1);
99
+ return {
100
+ runs,
101
+ nextCursor: rows.length > limit && last ? { queuedAt: last.queuedAt, id: last.id } : null,
102
+ };
103
+ }
52
104
  listRecentRuns(limit = 100) {
53
105
  return this.#many("SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?", clamp(limit, 500));
54
106
  }
@@ -60,7 +112,7 @@ export class TaskStore {
60
112
  `, rootRunId, clamp(limit, 500));
61
113
  }
62
114
  countActiveRuns() {
63
- const row = this.db.prepare("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
115
+ const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
64
116
  return row.n;
65
117
  }
66
118
  findActiveRun(taskId) {
@@ -77,6 +129,20 @@ export class TaskStore {
77
129
  ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
78
130
  `, sessionId);
79
131
  }
132
+ /** How many background runs each session has in flight, for a list that
133
+ * draws one dot per row: the state column narrows the scan to the handful
134
+ * of live runs, and no row's JSON is parsed. */
135
+ countActiveBackgroundRunsBySession() {
136
+ const rows = this.sql(`
137
+ SELECT json_extract(json, '$.invokedBySessionId') AS session_id, COUNT(*) AS n
138
+ FROM task_runs
139
+ WHERE state IN ('queued', 'running')
140
+ AND json_extract(json, '$.background') = 1
141
+ AND json_extract(json, '$.invokedBySessionId') IS NOT NULL
142
+ GROUP BY session_id
143
+ `).all();
144
+ return new Map(rows.map((row) => [row.session_id, row.n]));
145
+ }
80
146
  listRunsForSession(sessionId, limit = 50) {
81
147
  return this.#many(`
82
148
  SELECT json FROM task_runs
@@ -85,7 +151,7 @@ export class TaskStore {
85
151
  `, sessionId, clamp(limit, 200));
86
152
  }
87
153
  saveGroup(group) {
88
- this.db.prepare(`
154
+ this.sql(`
89
155
  INSERT INTO task_groups(id, created_at, callback_state, finished_at, json)
90
156
  VALUES (?, ?, ?, ?, ?)
91
157
  ON CONFLICT(id) DO UPDATE SET
@@ -107,7 +173,7 @@ export class TaskStore {
107
173
  `, now);
108
174
  }
109
175
  saveMessage(message) {
110
- this.db.prepare(`
176
+ this.sql(`
111
177
  INSERT INTO task_messages(id, run_id, state, created_at, json)
112
178
  VALUES (?, ?, ?, ?, ?)
113
179
  ON CONFLICT(id) DO UPDATE SET state=excluded.state, json=excluded.json
@@ -122,9 +188,20 @@ export class TaskStore {
122
188
  listRecentMessages(since) {
123
189
  return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
124
190
  }
125
- /** Messages whose injection never landed: the delivery sweep retries these. */
191
+ /** Messages whose injection never landed, each beside the run it belongs to:
192
+ * the delivery sweep needs both, and fetching the run per message made one
193
+ * sweep cost a query per undelivered message. A message whose run is gone
194
+ * comes back with `run: undefined` — the sweep drops it. */
126
195
  listUndeliveredMessages() {
127
- return this.#many("SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at");
196
+ const rows = this.sql(`
197
+ SELECT m.json AS json, r.json AS run_json
198
+ FROM task_messages m LEFT JOIN task_runs r ON r.id = m.run_id
199
+ WHERE m.state IN ('pending', 'failed') ORDER BY m.created_at
200
+ `).all();
201
+ return rows.map((row) => ({
202
+ message: JSON.parse(row.json),
203
+ run: row.run_json === null ? undefined : JSON.parse(row.run_json),
204
+ }));
128
205
  }
129
206
  /** Decisions are excluded: they have no timeout and stay answerable across
130
207
  * restarts — a reply to a terminal run resumes it. */
@@ -78,7 +78,6 @@ const DraftSchema = Type.Object({
78
78
  type: Type.Literal("agent"),
79
79
  session: Type.Union([
80
80
  Type.Object({ mode: Type.Literal("fresh"), cwd: Type.String() }),
81
- Type.Object({ mode: Type.Literal("fork"), cwd: Type.Optional(Type.String()) }),
82
81
  Type.Object({ mode: Type.Literal("reuse"), sessionId: Type.String() }),
83
82
  ]),
84
83
  prompt: Type.String(),
@@ -102,7 +101,7 @@ export function taskToolSpec(execute) {
102
101
  return {
103
102
  name: "task",
104
103
  label: "Pier Task",
105
- description: "Manage durable Pier tasks and subagents. Agent tasks support reused, fresh, or forked sessions. Run executes a stored task by task_id, a one-shot subagent from an inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions. models lists the deployment's model menu (operator pins with intent notes, else the live catalog).",
104
+ description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. Run executes a stored task by task_id, a one-shot subagent from an inline task draft, or a core-joined fan-out via tasks[] with join all|first. Get accepts run_id, group_id, or task_id for that task's recent runs. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages. Use steer/follow_up/resume for child control and contact/reply for supervisor decisions. models lists the deployment's model menu (operator pins with intent notes, else the live catalog).",
106
105
  parameters: Type.Object({
107
106
  operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
108
107
  task_id: Type.Optional(Type.String()),
@@ -111,7 +110,7 @@ export function taskToolSpec(execute) {
111
110
  message_id: Type.Optional(Type.String()),
112
111
  message: Type.Optional(Type.String()),
113
112
  reason: Type.Optional(strEnum("progress", "decision")),
114
- session_mode: Type.Optional(strEnum("fresh", "fork")),
113
+ session_mode: Type.Optional(strEnum("fresh")),
115
114
  task: Type.Optional(DraftSchema),
116
115
  // The same draft again, spelled out, cost more tokens in every session
117
116
  // than the whole rest of this contract. One copy is the guidance; this
@@ -173,7 +172,12 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
173
172
  const task = draft
174
173
  ? await resolveDraft(definitions, draft, active, callerSessionId)
175
174
  : resolveStored(definitions, input.task_id, active);
176
- const sessionMode = input.session_mode === "fresh" || input.session_mode === "fork" ? input.session_mode : undefined;
175
+ // Same as the HTTP route: a named mode the schema no longer offers is
176
+ // answered, not quietly swapped for the definition's own policy.
177
+ if (input.session_mode !== undefined && input.session_mode !== "fresh") {
178
+ throw new Error(`unsupported session_mode: ${String(input.session_mode)}`);
179
+ }
180
+ const sessionMode = input.session_mode;
177
181
  let callbackSessionId = input.callback === "none" ? null : callerSessionId;
178
182
  if (!active && callbackSessionId && typeof input.callback_session_id === "string") {
179
183
  callbackSessionId = requiredString(input.callback_session_id, "callback_session_id");
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,91 @@
1
+ // HTTP presentation of the injected configuration subscription. The sole
2
+ // public route is registered before auth; every management action is after it.
3
+ import { logger } from "../log.js";
4
+ const log = logger("config-sync");
5
+ export function registerConfigShareRoute(app, sync) {
6
+ app.get("/config-sync/:token", async (c) => {
7
+ c.header("cache-control", "no-store");
8
+ c.header("x-frame-options", "DENY");
9
+ c.header("referrer-policy", "no-referrer");
10
+ if (c.req.method !== "GET") {
11
+ c.header("allow", "GET");
12
+ return c.body(null, 405);
13
+ }
14
+ const token = c.req.param("token");
15
+ if (!/^[a-f0-9]{64}$/.test(token))
16
+ return c.json({ error: "not found" }, 404);
17
+ try {
18
+ const published = await sync.published(token);
19
+ if (!published)
20
+ return c.json({ error: "not found" }, 404);
21
+ c.header("etag", published.etag);
22
+ c.header("x-content-type-options", "nosniff");
23
+ const tags = c.req.header("if-none-match")?.split(",").map((value) => value.trim().replace(/^W\//, ""));
24
+ if (tags?.includes(published.etag) || tags?.includes("*"))
25
+ return c.body(null, 304);
26
+ c.header("content-type", "application/json; charset=utf-8");
27
+ return c.body(published.body);
28
+ }
29
+ catch {
30
+ // Never let the app's path logger print a capability URL.
31
+ log.error("Could not export shared configuration");
32
+ return c.json({ error: "Could not export configuration" }, 500);
33
+ }
34
+ });
35
+ // Reject other verbs here so neither auth redirects nor request-path logs
36
+ // can carry this capability to another surface.
37
+ app.all("/config-sync/:token", (c) => {
38
+ c.header("cache-control", "no-store");
39
+ c.header("allow", "GET");
40
+ return c.body(null, 405);
41
+ });
42
+ }
43
+ export function registerConfigSyncRoutes(app, deps) {
44
+ app.get("/api/config-sync", (c) => {
45
+ c.header("cache-control", "no-store");
46
+ return c.json(deps.status());
47
+ });
48
+ app.post("/api/config-sync", async (c) => {
49
+ c.header("cache-control", "no-store");
50
+ const body = await c.req.json().catch(() => null);
51
+ if (!body || typeof body.action !== "string")
52
+ return c.json({ error: "action required" }, 400);
53
+ try {
54
+ let result;
55
+ switch (body.action) {
56
+ case "publish":
57
+ await deps.sync.publish();
58
+ break;
59
+ case "revoke":
60
+ await deps.sync.revoke();
61
+ break;
62
+ case "subscribe": {
63
+ if (typeof body.url !== "string")
64
+ throw new Error("Source URL required");
65
+ result = await deps.sync.subscribe(body.url);
66
+ break;
67
+ }
68
+ case "pause":
69
+ await deps.sync.pause();
70
+ break;
71
+ case "sync":
72
+ result = await deps.run();
73
+ break;
74
+ default: return c.json({ error: "Unknown configuration sync action" }, 400);
75
+ }
76
+ await deps.reconcile();
77
+ return c.json({ ...deps.status(), ...(result ? { result } : {}) });
78
+ }
79
+ catch (err) {
80
+ // Application can succeed while reload fails; still reconcile the owned
81
+ // task to the persisted subscription switch in that case.
82
+ try {
83
+ await deps.reconcile();
84
+ }
85
+ catch {
86
+ log.error("Could not reconcile configuration sync task");
87
+ }
88
+ return c.json({ error: err instanceof Error ? err.message : "Configuration sync failed", status: deps.status() }, 409);
89
+ }
90
+ });
91
+ }
@@ -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
+ }