@timqi/pier 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/README.md +76 -12
  2. package/dist/agent/config.js +273 -27
  3. package/dist/agent/credentials.js +18 -12
  4. package/dist/agent/events.js +5 -41
  5. package/dist/agent/models.js +12 -0
  6. package/dist/agent/pi.js +182 -27
  7. package/dist/boards/boards.js +20 -10
  8. package/dist/channels/routes.js +1 -1
  9. package/dist/channels/runtime.js +36 -5
  10. package/dist/channels/slack-api.js +2 -4
  11. package/dist/channels/slack-outbound.js +4 -8
  12. package/dist/channels/slack-render.js +1 -4
  13. package/dist/channels/slack.js +20 -9
  14. package/dist/channels/telegram-api.js +3 -4
  15. package/dist/channels/telegram.js +37 -28
  16. package/dist/cli.js +177 -29
  17. package/dist/core/hub.js +36 -5
  18. package/dist/core/identity.js +5 -0
  19. package/dist/core/inbound-file.js +70 -0
  20. package/dist/core/inbox.js +32 -0
  21. package/dist/core/queue.js +9 -3
  22. package/dist/core/reply.js +20 -5
  23. package/dist/core/router.js +186 -14
  24. package/dist/core/types.js +53 -0
  25. package/dist/db.js +54 -8
  26. package/dist/drain.js +145 -0
  27. package/dist/main.js +86 -18
  28. package/dist/secrets.js +10 -6
  29. package/dist/service.js +142 -18
  30. package/dist/settings.js +69 -8
  31. package/dist/tasks/agent.js +41 -5
  32. package/dist/tasks/callbacks.js +29 -89
  33. package/dist/tasks/definitions.js +2 -6
  34. package/dist/tasks/execution.js +10 -1
  35. package/dist/tasks/groups.js +20 -49
  36. package/dist/tasks/messages.js +106 -21
  37. package/dist/tasks/outbox.js +157 -0
  38. package/dist/tasks/routes.js +6 -4
  39. package/dist/tasks/service.js +79 -22
  40. package/dist/tasks/store.js +48 -55
  41. package/dist/tasks/tool.js +19 -4
  42. package/dist/tasks/types.js +7 -0
  43. package/dist/update.js +94 -0
  44. package/dist/web/auth.js +75 -22
  45. package/dist/web/explorer.js +146 -0
  46. package/dist/web/files.js +26 -11
  47. package/dist/web/instance.js +99 -0
  48. package/dist/web/provider-flows.js +249 -0
  49. package/dist/web/providers.js +129 -0
  50. package/dist/web/public/assets/index-BK64pHmP.js +90 -0
  51. package/dist/web/public/assets/index-De4GlOq4.css +2 -0
  52. package/dist/web/public/icon-192.png +0 -0
  53. package/dist/web/public/icon-32.png +0 -0
  54. package/dist/web/public/icon-512.png +0 -0
  55. package/dist/web/public/icon-maskable-512.png +0 -0
  56. package/dist/web/public/icon-touch-192.png +0 -0
  57. package/dist/web/public/icon.svg +29 -11
  58. package/dist/web/public/index.html +43 -28
  59. package/dist/web/server.js +47 -120
  60. package/docs/deploy.md +120 -64
  61. package/package.json +1 -1
  62. package/skills/pier-help/SKILL.md +110 -0
  63. package/skills/pier-slack/SKILL.md +3 -2
  64. package/skills/pier-tasks/SKILL.md +19 -12
  65. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  66. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  67. package/dist/web/public/sw.js +0 -21
@@ -5,9 +5,11 @@ export function registerTaskRoutes(app, tasks, activity) {
5
5
  app.get("/api/activity", async (c) => {
6
6
  const now = Date.now();
7
7
  const recent = c.req.query("scope") === "recent";
8
- const runs = tasks.recentRuns(200).filter((run) => recent
9
- ? run.queuedAt >= now - 60 * 60 * 1000
10
- : run.state === "queued" || run.state === "running");
8
+ const windowStart = now - 24 * 60 * 60 * 1000;
9
+ // "recent" is a superset of "active": a run still going is part of the
10
+ // last 24h no matter when it was queued, so it never drops a live run.
11
+ const runs = tasks.recentRuns(200).filter((run) => run.state === "queued" || run.state === "running" ||
12
+ (recent && run.queuedAt >= windowStart));
11
13
  const listed = await activity.factory.list();
12
14
  const byId = new Map(listed.map((session) => [session.id, session]));
13
15
  const linkedIds = new Set();
@@ -21,7 +23,7 @@ export function registerTaskRoutes(app, tasks, activity) {
21
23
  if (activity.router.stateOf(session.id) === "streaming")
22
24
  linkedIds.add(session.id);
23
25
  }
24
- const messages = tasks.recentMessages(recent ? now - 60 * 60 * 1000 : now - 24 * 60 * 60 * 1000)
26
+ const messages = tasks.recentMessages(windowStart)
25
27
  .filter((message) => runs.some((run) => run.id === message.runId));
26
28
  for (const message of messages) {
27
29
  if (message.fromSessionId !== "console")
@@ -14,7 +14,10 @@ import { isTerminal } from "./types.js";
14
14
  const log = logger("tasks");
15
15
  export class TaskService {
16
16
  store;
17
+ factory;
18
+ router;
17
19
  hub;
20
+ instance;
18
21
  timer = null;
19
22
  ticking = false;
20
23
  waiters = new Map();
@@ -24,12 +27,19 @@ export class TaskService {
24
27
  groups;
25
28
  runs;
26
29
  execution;
27
- constructor(store, factory, router, hub) {
30
+ constructor(store, factory, router, hub,
31
+ /** Structural on purpose: tasks/ must not import settings.ts — main.ts
32
+ * hands in a closure over the store instead. Absent in bare test rigs. */
33
+ instance) {
28
34
  this.store = store;
35
+ this.factory = factory;
36
+ this.router = router;
29
37
  this.hub = hub;
30
- this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }));
38
+ this.instance = instance;
39
+ const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
40
+ this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.resume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable);
31
41
  this.definitions = new TaskDefinitions(store, factory, router, hub);
32
- this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run));
42
+ this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
33
43
  this.groups = new TaskGroups(store, router, {
34
44
  getRun: (id) => this.getRun(id),
35
45
  cancel: (id) => { this.cancel(id); },
@@ -41,7 +51,7 @@ export class TaskService {
41
51
  background: true,
42
52
  groupId,
43
53
  }),
44
- }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }));
54
+ }, (group) => this.hub.emitWorkspace({ type: "task-group-changed", groupId: group.id }), unreachable);
45
55
  const agent = new AgentTaskRunner(factory, router, store, this.messages, (run) => this.changed(run));
46
56
  this.execution = new TaskExecution(store, this.definitions, this.callbacks, agent, {
47
57
  runChild: (taskId, parent) => this.run(taskId, parent.input, "task", parent.id, {
@@ -61,6 +71,9 @@ export class TaskService {
61
71
  start(tickMs = 1000) {
62
72
  if (this.timer)
63
73
  return;
74
+ // A service started again after pause()/stop() takes work again; without
75
+ // this, the refusal would outlive the drain that justified it.
76
+ this.paused = false;
64
77
  const now = Date.now();
65
78
  // A run that was running when the process died: it is being written off
66
79
  // here, and the previous boot's log is where its work stopped.
@@ -80,10 +93,29 @@ export class TaskService {
80
93
  this.timer.unref();
81
94
  }
82
95
  stop() {
96
+ this.pause();
97
+ this.execution.stop();
98
+ }
99
+ /** Stop taking new work but leave running runs alone — a graceful restart
100
+ * (src/drain.ts) waits for them, where stop() would abort them. The
101
+ * scheduler timer goes, and new root runs are refused; children of a run
102
+ * that is still finishing stay allowed, because refusing them would fail
103
+ * the very work the drain is waiting for. */
104
+ pause() {
105
+ this.paused = true;
83
106
  if (this.timer)
84
107
  clearInterval(this.timer);
85
108
  this.timer = null;
86
- this.execution.stop();
109
+ }
110
+ paused = false;
111
+ refusePaused(parentRunId = null) {
112
+ if (this.paused && parentRunId === null) {
113
+ throw new Error("Pier is restarting — new task runs are not accepted; retry after the restart");
114
+ }
115
+ }
116
+ /** Runs a drain still has to wait for (queued ones start when a slot frees). */
117
+ activeRunCount() {
118
+ return this.store.countActiveRuns();
87
119
  }
88
120
  list() {
89
121
  return this.definitions.list();
@@ -126,8 +158,8 @@ export class TaskService {
126
158
  recentRuns(limit = 100) {
127
159
  return this.store.listRecentRuns(limit);
128
160
  }
129
- recentMessages(since, limit = 200) {
130
- return this.messages.recent(since, limit);
161
+ recentMessages(since) {
162
+ return this.messages.recent(since);
131
163
  }
132
164
  backgroundRuns(sessionId) {
133
165
  const cutoff = Date.now() - 60 * 60 * 1000;
@@ -138,6 +170,7 @@ export class TaskService {
138
170
  .map((run) => this.backgroundRun(run));
139
171
  }
140
172
  run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
173
+ this.refusePaused(parentRunId);
141
174
  const task = this.get(taskId);
142
175
  // `enabled:false` pauses scheduling only; manual and agent triggers still
143
176
  // run a paused task on demand. Archiving is the terminal state.
@@ -145,22 +178,15 @@ export class TaskService {
145
178
  throw new Error("archived tasks cannot run");
146
179
  return this.runs.enqueue(task, input, source, parentRunId, provenance);
147
180
  }
148
- async waitForRun(id, signal) {
181
+ async waitForRun(id) {
149
182
  const current = this.getRun(id);
150
183
  if (isTerminal(current.state))
151
184
  return current;
152
- if (signal?.aborted)
153
- throw new Error("wait cancelled");
154
- return new Promise((resolve, reject) => {
185
+ return new Promise((resolve) => {
155
186
  let set = this.waiters.get(id);
156
187
  if (!set)
157
188
  this.waiters.set(id, (set = new Set()));
158
189
  set.add(resolve);
159
- // An aborted caller must not leave its waiter behind forever.
160
- signal?.addEventListener("abort", () => {
161
- set.delete(resolve);
162
- reject(new Error("wait cancelled"));
163
- }, { once: true });
164
190
  });
165
191
  }
166
192
  /** Cascades: orphans must not outlive the delegation that wanted them. */
@@ -179,6 +205,7 @@ export class TaskService {
179
205
  return this.groups.members(id);
180
206
  }
181
207
  runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
208
+ this.refusePaused(parentRunId);
182
209
  return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
183
210
  }
184
211
  descendants(run) {
@@ -212,6 +239,7 @@ export class TaskService {
212
239
  return this.messages.reply(messageId, fromSessionId, message);
213
240
  }
214
241
  resume(id, message, provenance = {}) {
242
+ this.refusePaused();
215
243
  const prior = this.getRun(id);
216
244
  if (!isTerminal(prior.state))
217
245
  throw new Error("run must be terminal before resume");
@@ -234,23 +262,52 @@ export class TaskService {
234
262
  tool(raw, callerSessionId) {
235
263
  return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
236
264
  }
265
+ /** The deployment's model advice: the operator's pinned menu when one is
266
+ * set, the curated live catalog otherwise — an agent picks from names that
267
+ * exist right now, never from memory. */
268
+ async models() {
269
+ const menu = this.instance?.modelMenu() ?? [];
270
+ if (menu.length)
271
+ return { source: "menu", models: menu };
272
+ return { source: "catalog", models: await this.factory.availableModels() };
273
+ }
237
274
  async tick() {
238
275
  if (this.ticking)
239
276
  return;
240
277
  this.ticking = true;
241
278
  try {
242
279
  const now = Date.now();
243
- for (const task of this.definitions.claimDue(now)) {
244
- this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
245
- }
246
- this.callbacks.recover(now);
247
- this.groups.recover(now);
248
- this.messages.retryUndelivered(now);
280
+ this.sweep("schedule", () => {
281
+ for (const task of this.definitions.claimDue(now)) {
282
+ this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
283
+ }
284
+ });
285
+ this.sweep("run callbacks", () => this.callbacks.recover(now));
286
+ this.sweep("group callbacks", () => this.groups.recover(now));
287
+ this.sweep("messages", () => this.messages.retryUndelivered(now));
249
288
  }
250
289
  finally {
251
290
  this.ticking = false;
252
291
  }
253
292
  }
293
+ /** A delivery nobody can complete. Retrying it forever costs the same
294
+ * silence as dropping it, so it stops here and says so on three surfaces:
295
+ * the operator's log, the record the tool and Console read, and the event
296
+ * stream of the session that was supposed to receive it (§5b). */
297
+ unreachable(sessionId, what, why) {
298
+ log.error(`gave up delivering ${what} to session ${sessionId}: ${why}`);
299
+ this.router.reportTo(sessionId, `${what} could not be delivered — ${why}`);
300
+ }
301
+ /** Four independent sweeps, isolated: one throwing (a group whose member row
302
+ * is gone throws on every pass) must not starve the retries behind it. */
303
+ sweep(what, run) {
304
+ try {
305
+ run();
306
+ }
307
+ catch (err) {
308
+ log.error(`${what} sweep failed`, err);
309
+ }
310
+ }
254
311
  settled(run) {
255
312
  const waiters = this.waiters.get(run.id);
256
313
  if (waiters)
@@ -1,18 +1,26 @@
1
1
  import { pierDb } from "../db.js";
2
- const parseTask = (json) => JSON.parse(json);
3
- const parseRun = (json) => JSON.parse(json);
2
+ const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
4
3
  export class TaskStore {
5
4
  db;
6
5
  constructor(db = pierDb()) {
7
6
  this.db = db;
8
7
  }
8
+ // Every table is one JSON column plus query columns; these two are the only
9
+ // readers, and the one seam where a future schema change normalizes old rows
10
+ // (pre-v1 databases are refused outright in db.ts).
11
+ #one(sql, ...params) {
12
+ const row = this.db.prepare(sql).get(...params);
13
+ return row ? JSON.parse(row.json) : undefined;
14
+ }
15
+ #many(sql, ...params) {
16
+ return this.db.prepare(sql).all(...params)
17
+ .map((row) => JSON.parse(row.json));
18
+ }
9
19
  listTasks() {
10
- return this.db.prepare("SELECT json FROM tasks ORDER BY updated_at DESC").all()
11
- .map((r) => parseTask(r.json));
20
+ return this.#many("SELECT json FROM tasks ORDER BY updated_at DESC");
12
21
  }
13
22
  getTask(id) {
14
- const row = this.db.prepare("SELECT json FROM tasks WHERE id = ?").get(id);
15
- return row ? parseTask(row.json) : undefined;
23
+ return this.#one("SELECT json FROM tasks WHERE id = ?", id);
16
24
  }
17
25
  saveTask(task) {
18
26
  this.db.prepare(`
@@ -21,15 +29,13 @@ export class TaskStore {
21
29
  `).run(task.id, task.updatedAt, JSON.stringify(task));
22
30
  }
23
31
  listRuns(taskId, limit = 50, offset = 0) {
24
- return this.db.prepare(`
32
+ return this.#many(`
25
33
  SELECT json FROM task_runs WHERE task_id = ?
26
34
  ORDER BY queued_at DESC LIMIT ? OFFSET ?
27
- `).all(taskId, limit, offset)
28
- .map((r) => parseRun(r.json));
35
+ `, taskId, limit, offset);
29
36
  }
30
37
  getRun(id) {
31
- const row = this.db.prepare("SELECT json FROM task_runs WHERE id = ?").get(id);
32
- return row ? parseRun(row.json) : undefined;
38
+ return this.#one("SELECT json FROM task_runs WHERE id = ?", id);
33
39
  }
34
40
  saveRun(run) {
35
41
  this.db.prepare(`
@@ -40,42 +46,39 @@ export class TaskStore {
40
46
  `).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
41
47
  }
42
48
  listRecentRuns(limit = 100) {
43
- return this.db.prepare(`
44
- SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?
45
- `).all(Math.min(Math.max(limit, 1), 500))
46
- .map((row) => parseRun(row.json));
49
+ return this.#many("SELECT json FROM task_runs ORDER BY queued_at DESC LIMIT ?", clamp(limit, 500));
47
50
  }
48
51
  listRunsByRoot(rootRunId, limit = 100) {
49
- return this.db.prepare(`
52
+ return this.#many(`
50
53
  SELECT json FROM task_runs
51
54
  WHERE json_extract(json, '$.rootRunId') = ?
52
55
  ORDER BY queued_at LIMIT ?
53
- `).all(rootRunId, Math.min(Math.max(limit, 1), 500))
54
- .map((row) => parseRun(row.json));
56
+ `, rootRunId, clamp(limit, 500));
57
+ }
58
+ countActiveRuns() {
59
+ const row = this.db.prepare("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
60
+ return row.n;
55
61
  }
56
62
  findActiveRun(taskId) {
57
- const row = this.db.prepare(`
63
+ return this.#one(`
58
64
  SELECT json FROM task_runs
59
65
  WHERE task_id = ? AND state IN ('queued', 'running') LIMIT 1
60
- `).get(taskId);
61
- return row ? parseRun(row.json) : undefined;
66
+ `, taskId);
62
67
  }
63
68
  findActiveRunForTarget(sessionId) {
64
- const row = this.db.prepare(`
69
+ return this.#one(`
65
70
  SELECT json FROM task_runs
66
71
  WHERE state IN ('queued', 'running')
67
72
  AND json_extract(json, '$.targetSessionId') = ?
68
73
  ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
69
- `).get(sessionId);
70
- return row ? parseRun(row.json) : undefined;
74
+ `, sessionId);
71
75
  }
72
76
  listRunsForSession(sessionId, limit = 50) {
73
- return this.db.prepare(`
77
+ return this.#many(`
74
78
  SELECT json FROM task_runs
75
79
  WHERE json_extract(json, '$.invokedBySessionId') = ?
76
80
  ORDER BY queued_at DESC LIMIT ?
77
- `).all(sessionId, Math.min(Math.max(limit, 1), 200))
78
- .map((row) => parseRun(row.json));
81
+ `, sessionId, clamp(limit, 200));
79
82
  }
80
83
  saveGroup(group) {
81
84
  this.db.prepare(`
@@ -86,19 +89,18 @@ export class TaskStore {
86
89
  `).run(group.id, group.createdAt, group.callbackState, group.finishedAt, JSON.stringify(group));
87
90
  }
88
91
  getGroup(id) {
89
- const row = this.db.prepare("SELECT json FROM task_groups WHERE id = ?").get(id);
90
- return row ? JSON.parse(row.json) : undefined;
92
+ return this.#one("SELECT json FROM task_groups WHERE id = ?", id);
91
93
  }
92
94
  /** Unfinished joins plus deliverable group callbacks, for settle and recovery. */
93
95
  listOpenGroups(now = Date.now()) {
94
- return this.db.prepare(`
96
+ return this.#many(`
95
97
  SELECT json FROM task_groups
96
98
  WHERE finished_at IS NULL
97
99
  OR (callback_state IN ('pending', 'failed')
98
100
  AND (json_extract(json, '$.callbackNextAttemptAt') IS NULL
99
101
  OR json_extract(json, '$.callbackNextAttemptAt') <= ?))
100
102
  ORDER BY created_at
101
- `).all(now).map((row) => JSON.parse(row.json));
103
+ `, now);
102
104
  }
103
105
  saveMessage(message) {
104
106
  this.db.prepare(`
@@ -108,54 +110,45 @@ export class TaskStore {
108
110
  `).run(message.id, message.runId, message.state, message.createdAt, JSON.stringify(message));
109
111
  }
110
112
  getMessage(id) {
111
- const row = this.db.prepare("SELECT json FROM task_messages WHERE id = ?").get(id);
112
- return row ? JSON.parse(row.json) : undefined;
113
+ return this.#one("SELECT json FROM task_messages WHERE id = ?", id);
113
114
  }
114
115
  listMessages(runId) {
115
- return this.db.prepare(`
116
- SELECT json FROM task_messages WHERE run_id = ? ORDER BY created_at
117
- `).all(runId).map((row) => JSON.parse(row.json));
118
- }
119
- listRecentMessages(since, limit = 200) {
120
- return this.db.prepare(`
121
- SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT ?
122
- `).all(since, Math.min(Math.max(limit, 1), 500))
123
- .map((row) => JSON.parse(row.json));
116
+ return this.#many("SELECT json FROM task_messages WHERE run_id = ? ORDER BY created_at", runId);
117
+ }
118
+ listRecentMessages(since) {
119
+ return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
124
120
  }
125
121
  /** Messages whose injection never landed: the delivery sweep retries these. */
126
122
  listUndeliveredMessages() {
127
- return this.db.prepare(`
128
- SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at
129
- `).all().map((row) => JSON.parse(row.json));
123
+ return this.#many("SELECT json FROM task_messages WHERE state IN ('pending', 'failed') ORDER BY created_at");
130
124
  }
131
125
  /** Decisions are excluded: they have no timeout and stay answerable across
132
126
  * restarts — a reply to a terminal run resumes it. */
133
127
  expirePendingMessages() {
134
- const rows = this.db.prepare(`
128
+ return this.#many(`
135
129
  SELECT json FROM task_messages
136
130
  WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
137
- `).all();
138
- return rows.map((row) => {
139
- const message = JSON.parse(row.json);
131
+ `).map((message) => {
140
132
  message.state = "expired";
141
- message.error = "Pier restarted before delivery completed";
133
+ // "Confirmed", not "completed": the input may well have been read — the
134
+ // proof of it lives in the recipient's transcript, which this layer
135
+ // cannot see, and the run it steered is interrupted by the same restart.
136
+ message.error = "Pier restarted before delivery could be confirmed";
142
137
  this.saveMessage(message);
143
138
  return message;
144
139
  });
145
140
  }
146
141
  listPendingCallbacks(now = Date.now()) {
147
- return this.db.prepare(`
142
+ return this.#many(`
148
143
  SELECT json FROM task_runs
149
144
  WHERE callback_state IN ('pending', 'failed')
150
145
  AND (json_extract(json, '$.callbackNextAttemptAt') IS NULL
151
146
  OR json_extract(json, '$.callbackNextAttemptAt') <= ?)
152
147
  ORDER BY queued_at
153
- `).all(now).map((row) => parseRun(row.json));
148
+ `, now);
154
149
  }
155
150
  interruptRunning(now = Date.now()) {
156
- const rows = this.db.prepare("SELECT json FROM task_runs WHERE state IN ('queued', 'running')").all();
157
- return rows.map((row) => {
158
- const run = parseRun(row.json);
151
+ return this.#many("SELECT json FROM task_runs WHERE state IN ('queued', 'running')").map((run) => {
159
152
  run.state = "interrupted";
160
153
  run.error = "Pier restarted while the run was active";
161
154
  run.finishedAt = now;
@@ -36,13 +36,26 @@ const summarize = (run, pendingDecisionId) => defined({
36
36
  error: run.error,
37
37
  skipReason: run.skipReason,
38
38
  });
39
+ /** A list echoes many results at once, so each is capped; a single-run `get`
40
+ * stays whole — it is the escape hatch every truncation note points at. */
41
+ const trimResult = (summary) => {
42
+ if (summary.result?.type !== "agent" || summary.result.text.length <= 2000)
43
+ return summary;
44
+ return {
45
+ ...summary,
46
+ result: {
47
+ ...summary.result,
48
+ text: `${summary.result.text.slice(0, 2000)}\n[truncated — get run_id ${summary.runId} for the full text]`,
49
+ },
50
+ };
51
+ };
39
52
  const summarizeGroup = (group, members, messages) => defined({
40
53
  groupId: group.id,
41
54
  join: group.join,
42
55
  state: group.finishedAt ? "finished" : "running",
43
56
  callbackState: group.callbackState,
44
57
  winnerRunId: group.winnerRunId,
45
- members: members.map((run) => summarize(run, messages.openDecisionId(run.id))),
58
+ members: members.map((run) => trimResult(summarize(run, messages.openDecisionId(run.id)))),
46
59
  });
47
60
  // Model-facing draft shape. Guidance only: runtime truth stays in parseDraft,
48
61
  // so schema drift can never loosen boundary validation.
@@ -92,9 +105,9 @@ export function taskToolSpec(execute) {
92
105
  return {
93
106
  name: "task",
94
107
  label: "Pier Task",
95
- 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.",
108
+ 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).",
96
109
  parameters: Type.Object({
97
- operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply"),
110
+ operation: strEnum("list", "create", "update", "run", "get", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
98
111
  task_id: Type.Optional(Type.String()),
99
112
  run_id: Type.Optional(Type.String()),
100
113
  group_id: Type.Optional(Type.String()),
@@ -126,6 +139,8 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
126
139
  const active = store.findActiveRunForTarget(callerSessionId);
127
140
  if (input.operation === "list")
128
141
  return definitions.list().filter((task) => task.kind !== "subagent");
142
+ if (input.operation === "models")
143
+ return host.models();
129
144
  if (input.operation === "create") {
130
145
  if (active)
131
146
  throw new Error("subagents cannot create task definitions");
@@ -188,7 +203,7 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
188
203
  // Run history by task: without it, checking what a task did (or whether a
189
204
  // cascade landed) means leaving the tool for the database.
190
205
  if (input.run_id === undefined && typeof input.task_id === "string") {
191
- return host.listRuns(input.task_id, 10).map((run) => summarize(run, messages.openDecisionId(run.id)));
206
+ return host.listRuns(input.task_id, 10).map((run) => trimResult(summarize(run, messages.openDecisionId(run.id))));
192
207
  }
193
208
  const run = host.getRun(requiredString(input.run_id, "run_id"));
194
209
  return summarize(run, messages.openDecisionId(run.id));
@@ -1,3 +1,10 @@
1
+ export const retryDelay = (attempts) => Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6));
2
+ /** Attempts before a delivery is given up on and reported. With the backoff
3
+ * above that is ~4 minutes: long enough to outlast a busy or restarting
4
+ * recipient, short enough that whoever is waiting still cares. */
5
+ export const MAX_DELIVERY_ATTEMPTS = 8;
6
+ /** What a delivery says when it stops trying. */
7
+ export const undeliverable = (attempts, error) => `undeliverable after ${String(attempts)} attempts${error ? `: ${error}` : ""}`;
1
8
  export const isTerminal = (state) => state === "succeeded" ||
2
9
  state === "failed" ||
3
10
  state === "cancelled" ||
package/dist/update.js ADDED
@@ -0,0 +1,94 @@
1
+ // Whether a newer Pier exists. Checking, and nothing more.
2
+ //
3
+ // The registry is asked, not the GitHub API: `registry.npmjs.org` is a CDN with
4
+ // no rate limit and no token, and it is the same place `npm i -g` would look,
5
+ // so what it reports is what an update would actually get.
6
+ //
7
+ // Deliberately only an answer. Applying it is `pier update`, a command someone
8
+ // types: this process holds provider keys and can run a shell, so a service
9
+ // that rewrites its own code on a timer is a supply-chain surface (AGENTS.md 8)
10
+ // — and the updater's hard stop would kill whatever turn was mid-flight.
11
+ import { createRequire } from "node:module";
12
+ import { logger } from "./log.js";
13
+ const log = logger("update");
14
+ const PACKAGE = "@timqi/pier";
15
+ const ENDPOINT = `https://registry.npmjs.org/${PACKAGE}/latest`;
16
+ /** Long, because the answer changes on release days and never in between. */
17
+ const TTL_MS = 6 * 60 * 60_000;
18
+ const TIMEOUT_MS = 5_000;
19
+ export const currentVersion = () => createRequire(import.meta.url)("../package.json").version;
20
+ export const isValidVersion = (version) => /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.test(version);
21
+ /**
22
+ * `1.2.3` against `1.10.0`, numerically per field: a string compare would call
23
+ * the second one older. A prerelease suffix loses to the release it precedes,
24
+ * which is all the semver this needs.
25
+ */
26
+ export function isNewer(candidate, than) {
27
+ const parse = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
28
+ const [a, b] = [parse(candidate), parse(than)];
29
+ for (let i = 0; i < 3; i++) {
30
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
31
+ return (a[i] ?? 0) > (b[i] ?? 0);
32
+ }
33
+ // Equal numbers: a prerelease is older than the release of the same version.
34
+ const pre = (v) => v.split("-")[1] ?? "";
35
+ return pre(candidate) === "" && pre(than) !== "";
36
+ }
37
+ /** One registry answer, cached, shared by every caller. */
38
+ export class UpdateCheck {
39
+ current;
40
+ fetchLatest;
41
+ now;
42
+ #latest = null;
43
+ #checkedAt = 0;
44
+ #inFlight;
45
+ constructor(current = currentVersion(), fetchLatest = fetchLatestVersion, now = Date.now) {
46
+ this.current = current;
47
+ this.fetchLatest = fetchLatest;
48
+ this.now = now;
49
+ }
50
+ /** The last answer, and a refresh in the background when it is stale. Never
51
+ * awaits the network: a workbench that loads is worth more than a fresh
52
+ * version number. */
53
+ status() {
54
+ if (this.now() - this.#checkedAt >= TTL_MS)
55
+ void this.refresh();
56
+ return {
57
+ current: this.current,
58
+ latest: this.#latest,
59
+ available: this.#latest !== null && isNewer(this.#latest, this.current),
60
+ };
61
+ }
62
+ /** Ask now. Concurrent callers share the one request. */
63
+ refresh() {
64
+ this.#inFlight ??= this.fetchLatest()
65
+ .then((latest) => {
66
+ this.#latest = latest;
67
+ if (isNewer(latest, this.current))
68
+ log.info(`${latest} is available (running ${this.current})`);
69
+ })
70
+ // A failed check is not a failure: no network, an offline box, a registry
71
+ // hiccup. It is reported once at debug and retried at the next TTL.
72
+ .catch((err) => log.debug(`registry check failed: ${String(err)}`))
73
+ .finally(() => {
74
+ this.#checkedAt = this.now();
75
+ this.#inFlight = undefined;
76
+ });
77
+ return this.#inFlight;
78
+ }
79
+ }
80
+ export async function fetchLatestVersion() {
81
+ // Plain JSON: the abbreviated-packument content type npm uses for a whole
82
+ // package is a 406 on this endpoint, which answers one version already.
83
+ const res = await fetch(ENDPOINT, {
84
+ signal: AbortSignal.timeout(TIMEOUT_MS),
85
+ headers: { accept: "application/json" },
86
+ });
87
+ if (!res.ok)
88
+ throw new Error(`registry answered ${res.status}`);
89
+ const body = (await res.json());
90
+ if (typeof body.version !== "string" || !isValidVersion(body.version)) {
91
+ throw new Error("registry answered without a valid version");
92
+ }
93
+ return body.version;
94
+ }