@timqi/pier 0.1.2 → 0.1.4

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 (113) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +63 -59
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/listing.js +38 -15
  5. package/dist/agent/packages.js +347 -0
  6. package/dist/agent/pi.js +33 -59
  7. package/dist/channels/config.js +30 -20
  8. package/dist/channels/routes.js +0 -3
  9. package/dist/channels/slack-api.js +19 -10
  10. package/dist/channels/slack-cli.js +503 -0
  11. package/dist/channels/slack-directory.js +2 -2
  12. package/dist/channels/slack-outbound.js +1 -1
  13. package/dist/channels/slack-thread.js +41 -0
  14. package/dist/channels/slack-transcript.js +107 -0
  15. package/dist/channels/slack.js +9 -17
  16. package/dist/channels/types.js +0 -1
  17. package/dist/cli.js +158 -7
  18. package/dist/core/identity.js +20 -11
  19. package/dist/core/reply.js +16 -15
  20. package/dist/core/router.js +4 -1
  21. package/dist/core/types.js +7 -0
  22. package/dist/db.js +28 -0
  23. package/dist/extensions/index.js +1 -20
  24. package/dist/main.js +34 -42
  25. package/dist/paths.js +3 -0
  26. package/dist/secrets.js +2 -1
  27. package/dist/settings.js +5 -0
  28. package/dist/socket.js +95 -0
  29. package/dist/tasks/agent.js +5 -6
  30. package/dist/tasks/callbacks.js +2 -2
  31. package/dist/tasks/cli.js +215 -0
  32. package/dist/tasks/definitions.js +3 -3
  33. package/dist/tasks/execution.js +1 -3
  34. package/dist/tasks/groups.js +2 -5
  35. package/dist/tasks/messages.js +18 -158
  36. package/dist/tasks/operations.js +323 -0
  37. package/dist/tasks/routes.js +1 -11
  38. package/dist/tasks/runs.js +5 -20
  39. package/dist/tasks/service.js +18 -43
  40. package/dist/tasks/store.js +15 -15
  41. package/dist/tools.js +63 -85
  42. package/dist/vault.js +107 -0
  43. package/dist/web/auth.js +2 -1
  44. package/dist/web/config.js +25 -11
  45. package/dist/web/explorer.js +5 -3
  46. package/dist/web/instance.js +6 -18
  47. package/dist/web/packages.js +84 -0
  48. package/dist/web/public/assets/{activity-DOr8dWeX.js → activity-NLS8W9yl.js} +2 -2
  49. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  50. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  51. package/dist/web/public/assets/boards-DleegoiC.js +1 -0
  52. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  53. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  54. package/dist/web/public/assets/explorer-vJvV1Sx0.js +4 -0
  55. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  56. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  57. package/dist/web/public/assets/index-Bs-gol9o.css +2 -0
  58. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  59. package/dist/web/public/assets/index-Bs-gol9o.css.gz +0 -0
  60. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  61. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  62. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  63. package/dist/web/public/assets/runs-C5FveZ75.js +1 -0
  64. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  65. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  66. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  67. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  68. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  69. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js +3 -0
  70. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  71. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  72. package/dist/web/public/assets/tasks-R2Pv0Abg.js +4 -0
  73. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  74. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  75. package/dist/web/public/index.html +2 -2
  76. package/dist/web/public/index.html.br +0 -0
  77. package/dist/web/public/index.html.gz +0 -0
  78. package/dist/web/server.js +3 -1
  79. package/dist/web/vault.js +68 -0
  80. package/docs/deploy.md +42 -8
  81. package/package.json +1 -1
  82. package/skills/pier-help/SKILL.md +10 -6
  83. package/skills/pier-slack/SKILL.md +52 -71
  84. package/skills/pier-tasks/SKILL.md +48 -145
  85. package/skills/pier-vault/SKILL.md +36 -0
  86. package/dist/channels/slack-tool.js +0 -416
  87. package/dist/tasks/tool.js +0 -416
  88. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  89. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  90. package/dist/web/public/assets/boards-CneyR23E.js +0 -1
  91. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  92. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  93. package/dist/web/public/assets/explorer-D0srXT1c.js +0 -4
  94. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  95. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  96. package/dist/web/public/assets/index-DbFu15NN.js +0 -85
  97. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  98. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  99. package/dist/web/public/assets/index-eqQLVS8Q.css +0 -2
  100. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  101. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  102. package/dist/web/public/assets/runs-Cv0A-e08.js +0 -1
  103. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  104. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  105. package/dist/web/public/assets/settings-VmCjhGBd.js +0 -5
  106. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  107. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  108. package/dist/web/public/assets/task-runs-CqpTV644.js +0 -3
  109. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  110. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  111. package/dist/web/public/assets/tasks-BffPVgXg.js +0 -4
  112. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  113. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
@@ -1,34 +1,22 @@
1
- // A definition plus an input becomes a queued run. The depth and per-root
2
- // limits are decided here because every caller enqueues through this one door.
1
+ // A definition plus an input becomes a queued run; every caller enqueues
2
+ // through this one door.
3
3
  import { logger } from "../log.js";
4
4
  import { newId } from "./definitions.js";
5
5
  const log = logger("tasks");
6
- const MAX_DEPTH = 2;
7
- const MAX_CHILDREN_PER_ROOT = 16;
8
6
  export class TaskRunQueue {
9
7
  store;
10
8
  callbacks;
11
- getRun;
12
9
  execute;
13
10
  changed;
14
- constructor(store, callbacks, getRun, execute, changed) {
11
+ constructor(store, callbacks, execute, changed) {
15
12
  this.store = store;
16
13
  this.callbacks = callbacks;
17
- this.getRun = getRun;
18
14
  this.execute = execute;
19
15
  this.changed = changed;
20
16
  }
21
17
  /** Validate and persist only; callers may stage several runs in a transaction. */
22
18
  prepare(definition, input, source, parentRunId, provenance) {
23
19
  const id = newId();
24
- const parent = parentRunId ? this.getRun(parentRunId) : null;
25
- const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
26
- const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
27
- if (depth > MAX_DEPTH)
28
- throw new Error(`subagent depth limit is ${MAX_DEPTH}`);
29
- if (depth > 0 && this.store.listRunsByRoot(rootRunId, MAX_CHILDREN_PER_ROOT + 1).filter((run) => run.depth > 0).length >= MAX_CHILDREN_PER_ROOT) {
30
- throw new Error(`subagent child limit is ${MAX_CHILDREN_PER_ROOT} per root run`);
31
- }
32
20
  const invokedBySessionId = provenance.invokedBySessionId ?? null;
33
21
  const sourceSessionId = provenance.sourceSessionId ?? invokedBySessionId;
34
22
  const sessionMode = definition.action.type === "agent"
@@ -50,8 +38,6 @@ export class TaskRunQueue {
50
38
  taskRevision: definition.revision,
51
39
  parentRunId,
52
40
  groupId: provenance.groupId ?? null,
53
- rootRunId,
54
- depth,
55
41
  resumedFromRunId: provenance.resumedFromRunId ?? null,
56
42
  triggerSource: source,
57
43
  invokedBySessionId,
@@ -86,13 +72,12 @@ export class TaskRunQueue {
86
72
  /** Publish and execute only after all records this run relies on committed. */
87
73
  start(run) {
88
74
  this.changed(run);
89
- const { id, depth, triggerSource: source } = run;
75
+ const { id, triggerSource: source } = run;
90
76
  const overlapped = run.skipReason === "overlap";
91
77
  const definition = run.context.definition;
92
78
  // Why a run exists is answerable only here: the row keeps the ids, not the
93
79
  // reason. A watch probe queues every interval, so it logs at debug.
94
- const queued = `run ${id} ${run.state}: ${definition.name} via ${source}` +
95
- `${overlapped ? " (overlapped)" : ""}${depth > 0 ? ` depth ${String(depth)}` : ""}`;
80
+ const queued = `run ${id} ${run.state}: ${definition.name} via ${source}${overlapped ? " (overlapped)" : ""}`;
96
81
  if (source === "watch" && !overlapped)
97
82
  log.debug(queued);
98
83
  else
@@ -9,7 +9,7 @@ import { TaskExecution } from "./execution.js";
9
9
  import { TaskGroups } from "./groups.js";
10
10
  import { TaskMessenger } from "./messages.js";
11
11
  import { TaskRunQueue } from "./runs.js";
12
- import { handleTaskTool } from "./tool.js";
12
+ import { handleTask } from "./operations.js";
13
13
  import { isTerminal } from "./types.js";
14
14
  const log = logger("tasks");
15
15
  /** Not `renderedPrompt`: that carries the preamble and input wrapper, which the
@@ -44,14 +44,13 @@ export class TaskService {
44
44
  this.hub = hub;
45
45
  this.instance = instance;
46
46
  const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
47
- this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.prepareResume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable, (run) => this.runs.start(run));
47
+ this.messages = new TaskMessenger(store, router, hub, unreachable);
48
48
  this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
49
49
  this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
50
50
  this.groups = new TaskGroups(store, router, {
51
51
  getRun: (id) => this.getRun(id),
52
52
  cancel: (id) => { this.cancel(id); },
53
- openDecisionId: (runId) => this.messages.openDecisionId(runId),
54
- prepareMember: (taskId, groupId, callerSessionId, parentRunId) => this.prepareRun(taskId, null, "agent", parentRunId, {
53
+ prepareMember: (taskId, groupId, callerSessionId) => this.prepareRun(taskId, null, "agent", null, {
55
54
  invokedBySessionId: callerSessionId,
56
55
  sourceSessionId: callerSessionId,
57
56
  callbackSessionId: null,
@@ -72,9 +71,8 @@ export class TaskService {
72
71
  cancel: (id) => { this.cancel(id); },
73
72
  settled: (run) => this.settled(run),
74
73
  changed: (run) => this.changed(run),
75
- openDecisionId: (runId) => this.messages.openDecisionId(runId),
76
74
  });
77
- this.runs = new TaskRunQueue(store, this.callbacks, (id) => this.getRun(id), (run) => this.execution.start(run), (run) => this.changed(run));
75
+ this.runs = new TaskRunQueue(store, this.callbacks, (run) => this.execution.start(run), (run) => this.changed(run));
78
76
  }
79
77
  start(tickMs = 1000) {
80
78
  if (this.timer)
@@ -147,7 +145,7 @@ export class TaskService {
147
145
  create(raw, creator = "http") {
148
146
  return this.definitions.create(raw, creator);
149
147
  }
150
- /** `by` is how owning code says so; the routes and the tool have none (definitions.ts). */
148
+ /** `by` is how owning code says so; the routes and `pier task` have none (definitions.ts). */
151
149
  update(id, raw, by) {
152
150
  return this.definitions.update(id, raw, by);
153
151
  }
@@ -169,16 +167,13 @@ export class TaskService {
169
167
  }
170
168
  getRunView(id) {
171
169
  const run = this.getRun(id);
172
- return { ...run, pendingDecisionId: this.openDecisionId(id),
170
+ return { ...run,
173
171
  groupCallbackState: run.groupId ? this.store.getGroup(run.groupId)?.callbackState ?? null : null };
174
172
  }
175
173
  listMessages(runId) {
176
174
  this.getRun(runId);
177
175
  return this.messages.list(runId);
178
176
  }
179
- openDecisionId(runId) {
180
- return this.messages.openDecisionId(runId);
181
- }
182
177
  queryRuns(query = {}) {
183
178
  return this.store.queryRuns(query);
184
179
  }
@@ -227,7 +222,8 @@ export class TaskService {
227
222
  set.add(resolve);
228
223
  });
229
224
  }
230
- /** Cascades: orphans must not outlive the delegation that wanted them. */
225
+ /** Cascades down a `task` action's chain: a child must not outlive the run
226
+ * that waits on it. */
231
227
  cancel(id) {
232
228
  const run = this.getRun(id);
233
229
  for (const target of [run, ...this.descendants(run)]) {
@@ -242,23 +238,15 @@ export class TaskService {
242
238
  getGroup(id) {
243
239
  return this.groups.members(id);
244
240
  }
245
- runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
246
- this.refusePaused(parentRunId);
247
- return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode);
241
+ runGroup(definitions, join, callerSessionId, callbackSessionId, callbackMode) {
242
+ this.refusePaused();
243
+ return this.groups.runAll(definitions, join, callerSessionId, callbackSessionId, callbackMode);
248
244
  }
249
245
  descendants(run) {
250
- const byParent = new Map();
251
- for (const member of this.store.listRunsByRoot(run.rootRunId, 500)) {
252
- if (!member.parentRunId)
253
- continue;
254
- const siblings = byParent.get(member.parentRunId) ?? [];
255
- siblings.push(member);
256
- byParent.set(member.parentRunId, siblings);
257
- }
258
246
  const collected = [];
259
247
  const queue = [run.id];
260
248
  while (queue.length > 0) {
261
- for (const child of byParent.get(queue.shift()) ?? []) {
249
+ for (const child of this.store.listChildRuns(queue.shift())) {
262
250
  collected.push(child);
263
251
  queue.push(child.id);
264
252
  }
@@ -273,20 +261,7 @@ export class TaskService {
273
261
  throw new Error("terminal run cannot be steered; resume it instead");
274
262
  return this.messages.control(run, fromSessionId, mode, message);
275
263
  }
276
- reply(messageId, fromSessionId, message) {
277
- return this.messages.reply(messageId, fromSessionId, message);
278
- }
279
264
  resume(id, message, provenance = {}) {
280
- const { run, expired } = this.store.transact(() => ({
281
- run: this.prepareResume(id, message, provenance),
282
- expired: this.messages.expireDecisions(id, "superseded by a manual resume"),
283
- }));
284
- for (const message of expired)
285
- this.messages.changed(message);
286
- this.runs.start(run);
287
- return run;
288
- }
289
- prepareResume(id, message, provenance) {
290
265
  this.refusePaused();
291
266
  const prior = this.getRun(id);
292
267
  if (!isTerminal(prior.state))
@@ -295,19 +270,20 @@ export class TaskService {
295
270
  throw new Error("only persisted Agent runs can be resumed");
296
271
  }
297
272
  const prompt = requiredString(message, "message");
298
- return this.runs.prepare(prior.context.definition, null, "agent", null, {
273
+ const run = this.runs.prepare(prior.context.definition, null, "agent", null, {
299
274
  ...provenance,
300
275
  sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
301
276
  targetSessionId: prior.targetSessionId,
302
277
  sessionMode: "reuse",
303
278
  resumedFromRunId: prior.id,
304
- rootRunId: prior.rootRunId,
305
- depth: prior.depth,
306
279
  resumePrompt: prompt,
307
280
  });
281
+ this.runs.start(run);
282
+ return run;
308
283
  }
309
- tool(raw, callerSessionId) {
310
- return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
284
+ /** What `pier task` asks, under the calling session's identity. */
285
+ handle(raw, callerSessionId) {
286
+ return handleTask(this, this.definitions, this.store, raw, callerSessionId);
311
287
  }
312
288
  /** An agent picks from names that exist right now, never from memory. */
313
289
  async models() {
@@ -375,7 +351,6 @@ export class TaskService {
375
351
  state: run.state,
376
352
  targetSessionId: run.targetSessionId,
377
353
  sessionMode: run.sessionMode,
378
- depth: run.depth,
379
354
  prompt: runPrompt(run),
380
355
  queuedAt: run.queuedAt,
381
356
  startedAt: run.startedAt,
@@ -104,16 +104,13 @@ export class TaskStore {
104
104
  }
105
105
  const limit = clamp(query.limit ?? 50, 200);
106
106
  const rows = this.db.prepare(`
107
- SELECT r.json, g.callback_state AS group_callback_state,
108
- (SELECT m.id FROM task_messages m WHERE m.run_id = r.id
109
- AND m.state IN ('pending', 'delivered') AND json_extract(m.json, '$.kind') = 'decision'
110
- ORDER BY m.created_at, m.id LIMIT 1) AS decision_id
107
+ SELECT r.json, g.callback_state AS group_callback_state
111
108
  FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
112
109
  ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
113
110
  ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
114
111
  `).all(...params, limit + 1);
115
112
  const runs = rows.slice(0, limit).map((row) => ({
116
- ...JSON.parse(row.json), pendingDecisionId: row.decision_id, groupCallbackState: row.group_callback_state,
113
+ ...JSON.parse(row.json), groupCallbackState: row.group_callback_state,
117
114
  }));
118
115
  const last = runs.at(-1);
119
116
  return {
@@ -134,12 +131,20 @@ export class TaskStore {
134
131
  `, since);
135
132
  return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
136
133
  }
137
- listRunsByRoot(rootRunId, limit = 100) {
134
+ /** A `task` action's child runs; a cancel walks them. */
135
+ listChildRuns(parentRunId) {
138
136
  return this.#many(`
139
137
  SELECT json FROM task_runs
140
- WHERE json_extract(json, '$.rootRunId') = ?
141
- ORDER BY queued_at LIMIT ?
142
- `, rootRunId, clamp(limit, 500));
138
+ WHERE json_extract(json, '$.parentRunId') = ?
139
+ ORDER BY queued_at LIMIT 500
140
+ `, parentRunId);
141
+ }
142
+ /** Someone waits on this run's result — its own callback or its group's — so
143
+ * the run is one turn of its session and may not delegate (design 09). */
144
+ supervised(run) {
145
+ if (run.callbackSessionId !== null)
146
+ return true;
147
+ return run.groupId !== null && (this.getGroup(run.groupId)?.callbackSessionId ?? null) !== null;
143
148
  }
144
149
  countActiveRuns() {
145
150
  const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
@@ -239,13 +244,8 @@ export class TaskStore {
239
244
  run: row.run_json === null ? undefined : JSON.parse(row.run_json),
240
245
  }));
241
246
  }
242
- /** Decisions are excluded: they have no timeout and stay answerable across
243
- * restarts — a reply to a terminal run resumes it. */
244
247
  expirePendingMessages() {
245
- return this.#many(`
246
- SELECT json FROM task_messages
247
- WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
248
- `).map((message) => {
248
+ return this.#many("SELECT json FROM task_messages WHERE state = 'pending'").map((message) => {
249
249
  message.state = "expired";
250
250
  // "Confirmed", not "completed": the proof lives in a transcript this
251
251
  // layer cannot see.
package/dist/tools.js CHANGED
@@ -5,6 +5,7 @@ import { execFile } from "node:child_process";
5
5
  import { createHash, randomUUID } from "node:crypto";
6
6
  import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { delimiter, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
8
9
  import { pierDb, transact } from "./db.js";
9
10
  import { logger } from "./log.js";
10
11
  import { pierPath, resolveAgentDir } from "./paths.js";
@@ -13,37 +14,34 @@ const log = logger("tools");
13
14
  const UBIX_LATEST = "https://api.github.com/repos/timqi/ubix/releases/latest";
14
15
  export const MANAGED = [
15
16
  {
16
- kind: "extension",
17
17
  name: "rtk",
18
- toml: `spec = "github:rtk-ai/rtk"`,
19
- summary: "Compresses long bash output before it reaches the model. Ships as a " +
20
- "command, and installs its own Pi extension into Pier's agent dir — " +
21
- "refreshed on every update.",
22
- // Write-if-changed inside rtk: re-running after an upgrade is the extension-update path.
23
- provision: ["init", "-g", "--agent", "pi"],
24
- deprovision: ["init", "--uninstall", "--agent", "pi", "--global"],
18
+ // The hooks register and unregister its Pi extension; `--auto-patch` keeps
19
+ // `rtk init` non-interactive and overwrites a stale copy, which is right
20
+ // because nothing else writes Pier's agent dir.
21
+ toml: `spec = "github:rtk-ai/rtk"\n` +
22
+ `post_install = ["rtk", "init", "-g", "--agent", "pi", "--auto-patch"]\n` +
23
+ `pre_remove = ["rtk", "init", "--uninstall", "--agent", "pi", "--global", "--auto-patch"]`,
24
+ summary: "Compresses long bash output before it reaches the model. Its install hook " +
25
+ "writes its own Pi extension (extensions/rtk.ts, listed under the local " +
26
+ "package) — refreshed on every update.",
25
27
  },
26
28
  {
27
- kind: "tool",
28
29
  name: "rg",
29
30
  // `exe`: ubi looks for files named after the project, and ripgrep ships `rg`.
30
31
  toml: `spec = "github:BurntSushi/ripgrep"\nexe = "rg"`,
31
32
  summary: "ripgrep: searches a tree by content, fast enough to be the default.",
32
33
  },
33
34
  {
34
- kind: "tool",
35
35
  name: "fd",
36
36
  toml: `spec = "github:sharkdp/fd"`,
37
37
  summary: "Finds files by name, respecting .gitignore — what `find` should feel like.",
38
38
  },
39
39
  {
40
- kind: "tool",
41
40
  name: "wt",
42
41
  toml: `spec = "github:max-sixty/worktrunk"\nexe = "wt"`,
43
42
  summary: "worktrunk: git worktrees as one command — branch, switch, merge, clean up.",
44
43
  },
45
44
  {
46
- kind: "tool",
47
45
  name: "jq",
48
46
  // No `exe`: jq publishes bare per-platform binaries, which ubi installs
49
47
  // under the tool's own name.
@@ -138,6 +136,22 @@ export function prependPath(env = process.env, bin = toolsBin()) {
138
136
  mkdirSync(bin, { recursive: true }); // a PATH entry that does not exist is a shell's problem
139
137
  env.PATH = current ? `${bin}${delimiter}${current}` : bin;
140
138
  }
139
+ /** `pier` on an agent's PATH is this process's own cli — the same source, the
140
+ * same loader (`tsx` in dev) — not whichever install `npm i -g` left behind.
141
+ * Placed from this module, not argv[1]: `pier serve` enters through cli.*.
142
+ * The one line naming the harness variable: the cli reads `PIER_SESSION_ID`
143
+ * only, and an explicit one wins. */
144
+ export function writePierShim(bin = toolsBin(), proc = process, cli = fileURLToPath(import.meta.url).replace(/tools(\.[cm]?[jt]s)$/, "cli$1")) {
145
+ const quote = (s) => `'${s.replaceAll("'", String.raw `'\''`)}'`;
146
+ const script = `#!/bin/sh\nexport PIER_SESSION_ID="\${PIER_SESSION_ID:-$PI_SESSION_ID}"\nexec ${[proc.execPath, ...proc.execArgv, cli].map(quote).join(" ")} "$@"\n`;
147
+ mkdirSync(bin, { recursive: true });
148
+ const path = join(bin, "pier");
149
+ const tmp = `${path}.${randomUUID()}`;
150
+ writeFileSync(tmp, script);
151
+ chmodSync(tmp, 0o755);
152
+ renameSync(tmp, path); // a shell mid-exec never sees a half-written file
153
+ return path;
154
+ }
141
155
  /** Long enough that one Console page open spawns `ubix list` once, short
142
156
  * enough that an install done outside Pier shows up while they look. */
143
157
  const LIST_TTL_MS = 3_000;
@@ -339,17 +353,14 @@ export function ubixConfigToml(tools, installDir) {
339
353
  lines.push("", `[tools.${tool.name}]`, tool.toml.trim());
340
354
  return `${lines.join("\n")}\n`;
341
355
  }
342
- /** clap's unknown-argument message, and ubix's own refusal on a command that
343
- * takes no JSON. Nothing else counts as too old. */
344
- const refusesJson = (stderr) => /unexpected argument\s+'?--json|unrecognized (?:option|argument)\s+'?--json|`--json` is not supported/i
345
- .test(stderr);
346
- /** Pier put that binary in `bin/`, so `sync` re-bootstraps rather than reports. */
347
- class UbixTooOld extends Error {
348
- }
356
+ /** The first ubix that runs `post_install` / `pre_remove`. An older one parses
357
+ * the keys as unknown and ignores them, so rtk would install without its
358
+ * extension and nothing would say so. Release tags are `vYYYYMMDD-<sha>`. */
359
+ const UBIX_MIN = "v20260910-61f07ae";
349
360
  /** A name in neither list is not an error: a row a future release drops must
350
361
  * not stop the sync of everything else. */
351
362
  function rows(custom) {
352
- return [...MANAGED, ...custom.map((tool) => ({ kind: "tool", summary: "", custom: true, ...tool }))];
363
+ return [...MANAGED, ...custom.map((tool) => ({ summary: "", custom: true, ...tool }))];
353
364
  }
354
365
  export function ubixAsset(tag, platform, arch) {
355
366
  const os = platform === "linux" || platform === "darwin" ? platform : null;
@@ -465,26 +476,11 @@ export class ManagedTools {
465
476
  return { entries: [], failed: false, summary: "no tools switched on" };
466
477
  }
467
478
  fence();
468
- const ubix = await this.bootstrapUbix();
469
479
  const env = this.#env();
480
+ const ubix = await this.#current(await this.bootstrapUbix(), env);
470
481
  const entries = [];
471
- // ubix cannot know that rtk must uninstall its own Pi extension *before*
472
- // its binary goes; tools leaving the set undo their footprint first.
473
- const kept = [];
474
- for (const state of await this.#listing(ubix, env)) {
475
- const leaving = all.find((tool) => tool.name === state.name);
476
- if (!leaving?.deprovision || wanted.some((tool) => tool.name === state.name))
477
- continue;
478
- fence(); // a tool's own uninstall is a change to the machine
479
- const error = await this.#provision(env, leaving, leaving.deprovision);
480
- if (error) {
481
- // Removing it now would orphan what deprovision failed to remove.
482
- kept.push(leaving);
483
- entries.push({ name: leaving.name, action: "kept", version: null, error });
484
- }
485
- }
486
482
  fence();
487
- this.#writeConfig([...wanted, ...kept]);
483
+ this.#writeConfig(wanted);
488
484
  // `--prune` removes what the config no longer declares; `--wait` lets a
489
485
  // hand-typed `pier tools sync` converge behind the managed run.
490
486
  fence();
@@ -492,25 +488,19 @@ export class ManagedTools {
492
488
  for (const state of states) {
493
489
  if (wanted.some((tool) => tool.name === state.name))
494
490
  continue;
495
- if (entries.some((entry) => entry.name === state.name))
496
- continue;
497
- // A failure to remove is as much a failure as one to install.
491
+ // A failure to remove (a `pre_remove` hook included) is as much a failure as one to install.
498
492
  entries.push({ name: state.name, action: state.action ?? "removed", version: null, error: state.error });
499
493
  }
500
494
  for (const tool of wanted) {
501
495
  const state = states.find((s) => s.name === tool.name);
502
- const entry = {
496
+ entries.push({
503
497
  name: tool.name,
504
498
  action: state?.action ?? "missing",
505
499
  version: state?.to ?? state?.version ?? null,
506
- // A tool ubix never mentioned is not a tool that is fine.
500
+ // A tool ubix never mentioned is not a tool that is fine; a failed
501
+ // `post_install` arrives here as `error` beside an `installed` action.
507
502
  error: state?.error ?? (state ? null : "ubix reported nothing about it"),
508
- };
509
- if (!entry.error && tool.provision) {
510
- fence();
511
- entry.error = await this.#provision(env, tool, tool.provision);
512
- }
513
- entries.push(entry);
503
+ });
514
504
  }
515
505
  const failed = entries.some((entry) => entry.error !== null);
516
506
  return { entries, failed, summary: summarize(entries) };
@@ -519,8 +509,6 @@ export class ManagedTools {
519
509
  * version is unknown (§5). */
520
510
  async status(enabled, custom = []) {
521
511
  const base = rows(custom).map((tool) => ({
522
- source: "binary",
523
- kind: tool.kind,
524
512
  name: tool.name,
525
513
  summary: tool.summary,
526
514
  enabled: enabled.includes(tool.name),
@@ -577,12 +565,20 @@ export class ManagedTools {
577
565
  return states;
578
566
  }
579
567
  /** `UBIX_CONFIG_DIR` / `UBIX_DATA_DIR` name the directories directly — not
580
- * XDG parents, which every child ubix spawns (uv, fnm, cargo) would read too. */
568
+ * XDG parents, which every child ubix spawns (uv, fnm, cargo) would read too.
569
+ * Hooks inherit this env: `pier tools sync` typed in a shell has no main.ts
570
+ * parent exporting PI_CODING_AGENT_DIR, and rtk would then write its
571
+ * extension into ~/.pi. */
581
572
  #env() {
573
+ const agentDir = resolveAgentDir(process.env);
574
+ if (process.env.PI_CODING_AGENT_DIR !== agentDir) {
575
+ log.info(`PI_CODING_AGENT_DIR was ${process.env.PI_CODING_AGENT_DIR ?? "unset"} — ubix gets ${agentDir}`);
576
+ }
582
577
  const env = {
583
578
  ...process.env,
584
579
  UBIX_CONFIG_DIR: this.#configDir,
585
580
  UBIX_DATA_DIR: join(this.#root, "state"),
581
+ PI_CODING_AGENT_DIR: agentDir,
586
582
  };
587
583
  prependPath(env, this.bin);
588
584
  return env;
@@ -599,8 +595,9 @@ export class ManagedTools {
599
595
  renameSync(`${path}.writing`, path);
600
596
  }
601
597
  /** A non-zero exit still parses: under `--json` a failed tool is in the
602
- * document as `action: "failed"`, and the run exits non-zero. The two must
603
- * agree — an exit code with no failed entry is a failure this file cannot
598
+ * document as `action: "failed"` — or as `installed`/`upgraded` with an
599
+ * `error`, when its `post_install` hook failed — and the run exits non-zero.
600
+ * The two must agree — an exit code with no failed entry is a failure this file cannot
604
601
  * attribute, and passing it on as clean is the one thing it may never do. */
605
602
  async #states(ubix, env, args) {
606
603
  const result = await this.#exec(ubix, args, env);
@@ -610,43 +607,24 @@ export class ManagedTools {
610
607
  }
611
608
  catch (err) {
612
609
  const failure = failedRun(`ubix ${args.join(" ")}`, result);
613
- // Only the flag being unknown means "too old"; re-bootstrapping over a
614
- // config error would fix nothing and say something false.
615
- if (result.code !== 0 && refusesJson(result.stderr))
616
- throw new UbixTooOld(failure);
617
610
  throw new Error(result.code === 0 ? String(err) : `${failure} (${String(err)})`);
618
611
  }
619
- if (result.code !== 0 && !states.some((state) => state.action === "failed")) {
612
+ if (result.code !== 0 && !states.some((state) => state.error !== null)) {
620
613
  throw new Error(`${failedRun(`ubix ${args.join(" ")}`, result)} — and its report names no failure`);
621
614
  }
622
615
  return states;
623
616
  }
624
- /** The one place a too-old ubix is repaired rather than reported. */
625
- async #listing(ubix, env) {
626
- try {
627
- return await this.#states(ubix, env, ["list", "--json"]);
628
- }
629
- catch (err) {
630
- if (!(err instanceof UbixTooOld))
631
- throw err;
632
- log.warn(`the ubix in ${this.bin} is too old for --json — replacing it`);
633
- return this.#states(await this.bootstrapUbix(true), env, ["list", "--json"]);
634
- }
635
- }
636
- /** Returns the failure text, or null. */
637
- async #provision(env, tool, args) {
638
- const exe = join(this.bin, tool.name);
639
- if (!existsSync(exe))
640
- return `${tool.name} is not in ${this.bin} — ${args.join(" ")} was not run`;
641
- // `pier tools sync` typed in a shell has no main.ts parent exporting
642
- // PI_CODING_AGENT_DIR; rtk would then write its extension into ~/.pi.
643
- const agentDir = resolveAgentDir(env);
644
- if (env.PI_CODING_AGENT_DIR !== agentDir) {
645
- log.info(`PI_CODING_AGENT_DIR was ${env.PI_CODING_AGENT_DIR ?? "unset"} — ${tool.name} gets ${agentDir}`);
646
- }
647
- mkdirSync(join(agentDir, "extensions"), { recursive: true });
648
- const result = await this.#exec(exe, args, { ...env, PI_CODING_AGENT_DIR: agentDir });
649
- return result.code === 0 ? null : failedRun(`${tool.name} ${args.join(" ")}`, result);
617
+ /** The one place a too-old ubix is repaired rather than reported: Pier put
618
+ * that binary in `bin/`, and one below UBIX_MIN would run the config and
619
+ * skip its hooks without a word. */
620
+ async #current(ubix, env) {
621
+ const result = await this.#exec(ubix, ["--version"], env);
622
+ const date = /\bv(\d{8})-/.exec(result.stdout)?.[1];
623
+ if (result.code === 0 && date && date >= UBIX_MIN.slice(1, 9))
624
+ return ubix;
625
+ const found = result.code === 0 ? result.stdout.trim() || "(no version)" : failedRun("ubix --version", result);
626
+ log.warn(`the ubix in ${this.bin} is ${found} and Pier needs ${UBIX_MIN} or newer — replacing it`);
627
+ return this.bootstrapUbix(true);
650
628
  }
651
629
  async #getText(url) {
652
630
  return new TextDecoder().decode(await this.#getBytes(url));
@@ -662,7 +640,7 @@ export class ManagedTools {
662
640
  return new Uint8Array(await res.arrayBuffer());
663
641
  }
664
642
  }
665
- const withBinary = (entry, patch) => entry.source === "binary" ? { ...entry, binary: { ...entry.binary, ...patch } } : entry;
643
+ const withBinary = (entry, patch) => ({ ...entry, binary: { ...entry.binary, ...patch } });
666
644
  /** Never empty: an exit code with no words is not a report. */
667
645
  const failedRun = (what, result) => `${what} exited ${String(result.code)}: ${result.stderr.trim().slice(0, 300) || "(no output)"}`;
668
646
  /** `<sha256> <bare filename>` lines, as `sha256sum` writes them. */
package/dist/vault.js ADDED
@@ -0,0 +1,107 @@
1
+ // Named secrets for `pier vault run`: the vault table and its resolution. The
2
+ // value's shape is the level — a sealed envelope is `auto`, a `vt://` record is
3
+ // `approve` — so no column can disagree with the value beside it. Plaintext is
4
+ // a parameter and a return value here, never a log line or a field.
5
+ import { pierDb } from "./db.js";
6
+ import { logger } from "./log.js";
7
+ import { isSealed, vtCli } from "./secrets.js";
8
+ const log = logger("vault");
9
+ /** An env-var name, so the common case needs no mapping. */
10
+ export const isVaultName = (name) => /^[A-Z][A-Z0-9_]{0,63}$/.test(name);
11
+ /** Named, never partial: the caller learns which name to file, and gets no
12
+ * values for the others. */
13
+ export class UnknownSecret extends Error {
14
+ secret;
15
+ constructor(secret) {
16
+ super(`no secret named ${secret}`);
17
+ this.secret = secret;
18
+ }
19
+ }
20
+ /** Only sealed rows need the key; the reason is the operator's repair instruction. */
21
+ export class VaultLocked extends Error {
22
+ constructor(reason) {
23
+ super(`locked — ${reason}`);
24
+ }
25
+ }
26
+ const levelOf = (value) => (isSealed(value) ? "auto" : "approve");
27
+ function check(name, plaintext) {
28
+ if (!isVaultName(name))
29
+ throw new Error(`${name} is not a vault name (A-Z, 0-9, _; starts with a letter)`);
30
+ if (!plaintext)
31
+ throw new Error("empty value");
32
+ }
33
+ export class Vault {
34
+ secrets;
35
+ db;
36
+ vt;
37
+ constructor(secrets, db = pierDb(), vt = vtCli) {
38
+ this.secrets = secrets;
39
+ this.db = db;
40
+ this.vt = vt;
41
+ }
42
+ list() {
43
+ const rows = this.db.prepare("SELECT name, value, updated_at FROM vault ORDER BY name").all();
44
+ return rows.map((row) => ({ name: row.name, level: levelOf(row.value), updatedAt: row.updated_at }));
45
+ }
46
+ /** Overwrite by name is rotation: a command already running keeps the env it
47
+ * was given, the next `run` gets the new value. */
48
+ async put(name, level, plaintext) {
49
+ if (level === "auto")
50
+ return this.seal(name, plaintext);
51
+ check(name, plaintext);
52
+ const record = await this.vt.create(plaintext);
53
+ if (!record.startsWith("vt://"))
54
+ throw new Error("vt create did not return a vt:// record");
55
+ this.#store(name, level, record);
56
+ }
57
+ /** The `auto` half of put, synchronous: a channel save runs on the message
58
+ * path and cannot await. */
59
+ seal(name, plaintext) {
60
+ check(name, plaintext);
61
+ this.#store(name, "auto", this.secrets.encrypt(plaintext));
62
+ }
63
+ #store(name, level, value) {
64
+ this.db.prepare(`
65
+ INSERT INTO vault(name, value, updated_at) VALUES (?, ?, ?)
66
+ ON CONFLICT(name) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at
67
+ `).run(name, value, Date.now());
68
+ log.info(`vault put ${name} (${level})`);
69
+ }
70
+ /** False when there was nothing to remove. */
71
+ remove(name) {
72
+ const removed = this.db.prepare("DELETE FROM vault WHERE name = ?").run(name).changes > 0;
73
+ if (removed)
74
+ log.info(`vault remove ${name}`);
75
+ return removed;
76
+ }
77
+ /** One of Pier's own credentials, `undefined` when unfiled — a state there,
78
+ * not an error. A sealed row still needs the key. */
79
+ get(name) {
80
+ const row = this.db.prepare("SELECT value FROM vault WHERE name = ?").get(name);
81
+ if (!row)
82
+ return undefined;
83
+ return isSealed(row.value) ? this.secrets.decrypt(row.value) : row.value;
84
+ }
85
+ /** `by` names the caller in the one log line a resolve leaves; values never
86
+ * appear at any level. `approve` rows resolve while locked — they need no key. */
87
+ resolve(names, by = "unknown") {
88
+ const select = this.db.prepare("SELECT value FROM vault WHERE name = ?");
89
+ const rows = names.map((name) => {
90
+ const row = select.get(name);
91
+ if (!row)
92
+ throw new UnknownSecret(name);
93
+ return [name, row.value];
94
+ });
95
+ if (rows.some(([, value]) => isSealed(value)) && this.secrets.state === "locked") {
96
+ throw new VaultLocked(this.secrets.lockedReason);
97
+ }
98
+ const resolved = {};
99
+ for (const [name, value] of rows) {
100
+ resolved[name] = isSealed(value)
101
+ ? { kind: "plain", value: this.secrets.decrypt(value) }
102
+ : { kind: "record", value };
103
+ }
104
+ log.info(`vault resolve ${names.join(",")} by ${by}`);
105
+ return resolved;
106
+ }
107
+ }