@timqi/pier 0.0.1 → 0.0.3

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 (69) hide show
  1. package/README.md +87 -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-tool.js +28 -3
  14. package/dist/channels/slack.js +20 -9
  15. package/dist/channels/telegram-api.js +3 -4
  16. package/dist/channels/telegram.js +37 -28
  17. package/dist/cli.js +177 -29
  18. package/dist/core/hub.js +36 -5
  19. package/dist/core/identity.js +5 -0
  20. package/dist/core/inbound-file.js +70 -0
  21. package/dist/core/inbox.js +32 -0
  22. package/dist/core/queue.js +9 -3
  23. package/dist/core/reply.js +20 -5
  24. package/dist/core/router.js +200 -14
  25. package/dist/core/types.js +53 -0
  26. package/dist/db.js +54 -8
  27. package/dist/drain.js +145 -0
  28. package/dist/main.js +180 -18
  29. package/dist/secrets.js +10 -6
  30. package/dist/service.js +192 -18
  31. package/dist/settings.js +77 -8
  32. package/dist/tasks/agent.js +41 -5
  33. package/dist/tasks/callbacks.js +29 -89
  34. package/dist/tasks/definitions.js +2 -6
  35. package/dist/tasks/execution.js +10 -1
  36. package/dist/tasks/groups.js +20 -49
  37. package/dist/tasks/messages.js +106 -21
  38. package/dist/tasks/outbox.js +157 -0
  39. package/dist/tasks/routes.js +6 -4
  40. package/dist/tasks/service.js +92 -22
  41. package/dist/tasks/store.js +48 -55
  42. package/dist/tasks/tool.js +19 -4
  43. package/dist/tasks/types.js +7 -0
  44. package/dist/update.js +146 -0
  45. package/dist/web/auth.js +89 -26
  46. package/dist/web/explorer.js +147 -0
  47. package/dist/web/files.js +28 -12
  48. package/dist/web/instance.js +165 -0
  49. package/dist/web/provider-flows.js +249 -0
  50. package/dist/web/providers.js +141 -0
  51. package/dist/web/public/assets/index-cCIuQnDr.css +2 -0
  52. package/dist/web/public/assets/index-fASxMPr6.js +90 -0
  53. package/dist/web/public/icon-192.png +0 -0
  54. package/dist/web/public/icon-32.png +0 -0
  55. package/dist/web/public/icon-512.png +0 -0
  56. package/dist/web/public/icon-maskable-512.png +0 -0
  57. package/dist/web/public/icon-touch-192.png +0 -0
  58. package/dist/web/public/icon.svg +29 -11
  59. package/dist/web/public/index.html +50 -32
  60. package/dist/web/server.js +110 -120
  61. package/docs/deploy.md +142 -64
  62. package/package.json +1 -1
  63. package/skills/pier-boards/SKILL.md +16 -7
  64. package/skills/pier-help/SKILL.md +110 -0
  65. package/skills/pier-slack/SKILL.md +20 -3
  66. package/skills/pier-tasks/SKILL.md +19 -12
  67. package/dist/web/public/assets/index-8CinH1uR.css +0 -2
  68. package/dist/web/public/assets/index-DAgP1Gq8.js +0 -78
  69. package/dist/web/public/sw.js +0 -21
@@ -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.
@@ -72,6 +85,9 @@ export class TaskService {
72
85
  this.definitions.resetNextRuns(now);
73
86
  this.callbacks.recover(now);
74
87
  this.groups.recover(now);
88
+ this.runTimer(tickMs);
89
+ }
90
+ runTimer(tickMs) {
75
91
  this.timer = setInterval(() => {
76
92
  // The scheduler's own loop: a throw here would stop nothing (the next
77
93
  // tick still fires) and say nothing, so due tasks would just stop.
@@ -79,11 +95,40 @@ export class TaskService {
79
95
  }, tickMs);
80
96
  this.timer.unref();
81
97
  }
98
+ /** Undo a `pause()` that was not followed by an exit — the auto-updater
99
+ * drains before handing over, and a handover that never started must not
100
+ * leave the scheduler switched off. Deliberately not `start()`: the boot
101
+ * recovery in there would write off runs this process is still running. */
102
+ unpause(tickMs = 1000) {
103
+ if (this.timer)
104
+ return;
105
+ this.paused = false;
106
+ this.runTimer(tickMs);
107
+ }
82
108
  stop() {
109
+ this.pause();
110
+ this.execution.stop();
111
+ }
112
+ /** Stop taking new work but leave running runs alone — a graceful restart
113
+ * (src/drain.ts) waits for them, where stop() would abort them. The
114
+ * scheduler timer goes, and new root runs are refused; children of a run
115
+ * that is still finishing stay allowed, because refusing them would fail
116
+ * the very work the drain is waiting for. */
117
+ pause() {
118
+ this.paused = true;
83
119
  if (this.timer)
84
120
  clearInterval(this.timer);
85
121
  this.timer = null;
86
- this.execution.stop();
122
+ }
123
+ paused = false;
124
+ refusePaused(parentRunId = null) {
125
+ if (this.paused && parentRunId === null) {
126
+ throw new Error("Pier is restarting — new task runs are not accepted; retry after the restart");
127
+ }
128
+ }
129
+ /** Runs a drain still has to wait for (queued ones start when a slot frees). */
130
+ activeRunCount() {
131
+ return this.store.countActiveRuns();
87
132
  }
88
133
  list() {
89
134
  return this.definitions.list();
@@ -126,8 +171,8 @@ export class TaskService {
126
171
  recentRuns(limit = 100) {
127
172
  return this.store.listRecentRuns(limit);
128
173
  }
129
- recentMessages(since, limit = 200) {
130
- return this.messages.recent(since, limit);
174
+ recentMessages(since) {
175
+ return this.messages.recent(since);
131
176
  }
132
177
  backgroundRuns(sessionId) {
133
178
  const cutoff = Date.now() - 60 * 60 * 1000;
@@ -138,6 +183,7 @@ export class TaskService {
138
183
  .map((run) => this.backgroundRun(run));
139
184
  }
140
185
  run(taskId, input = null, source = "manual", parentRunId = null, provenance = {}) {
186
+ this.refusePaused(parentRunId);
141
187
  const task = this.get(taskId);
142
188
  // `enabled:false` pauses scheduling only; manual and agent triggers still
143
189
  // run a paused task on demand. Archiving is the terminal state.
@@ -145,22 +191,15 @@ export class TaskService {
145
191
  throw new Error("archived tasks cannot run");
146
192
  return this.runs.enqueue(task, input, source, parentRunId, provenance);
147
193
  }
148
- async waitForRun(id, signal) {
194
+ async waitForRun(id) {
149
195
  const current = this.getRun(id);
150
196
  if (isTerminal(current.state))
151
197
  return current;
152
- if (signal?.aborted)
153
- throw new Error("wait cancelled");
154
- return new Promise((resolve, reject) => {
198
+ return new Promise((resolve) => {
155
199
  let set = this.waiters.get(id);
156
200
  if (!set)
157
201
  this.waiters.set(id, (set = new Set()));
158
202
  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
203
  });
165
204
  }
166
205
  /** Cascades: orphans must not outlive the delegation that wanted them. */
@@ -179,6 +218,7 @@ export class TaskService {
179
218
  return this.groups.members(id);
180
219
  }
181
220
  runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
221
+ this.refusePaused(parentRunId);
182
222
  return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId);
183
223
  }
184
224
  descendants(run) {
@@ -212,6 +252,7 @@ export class TaskService {
212
252
  return this.messages.reply(messageId, fromSessionId, message);
213
253
  }
214
254
  resume(id, message, provenance = {}) {
255
+ this.refusePaused();
215
256
  const prior = this.getRun(id);
216
257
  if (!isTerminal(prior.state))
217
258
  throw new Error("run must be terminal before resume");
@@ -234,23 +275,52 @@ export class TaskService {
234
275
  tool(raw, callerSessionId) {
235
276
  return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
236
277
  }
278
+ /** The deployment's model advice: the operator's pinned menu when one is
279
+ * set, the curated live catalog otherwise — an agent picks from names that
280
+ * exist right now, never from memory. */
281
+ async models() {
282
+ const menu = this.instance?.modelMenu() ?? [];
283
+ if (menu.length)
284
+ return { source: "menu", models: menu };
285
+ return { source: "catalog", models: await this.factory.availableModels() };
286
+ }
237
287
  async tick() {
238
288
  if (this.ticking)
239
289
  return;
240
290
  this.ticking = true;
241
291
  try {
242
292
  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);
293
+ this.sweep("schedule", () => {
294
+ for (const task of this.definitions.claimDue(now)) {
295
+ this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
296
+ }
297
+ });
298
+ this.sweep("run callbacks", () => this.callbacks.recover(now));
299
+ this.sweep("group callbacks", () => this.groups.recover(now));
300
+ this.sweep("messages", () => this.messages.retryUndelivered(now));
249
301
  }
250
302
  finally {
251
303
  this.ticking = false;
252
304
  }
253
305
  }
306
+ /** A delivery nobody can complete. Retrying it forever costs the same
307
+ * silence as dropping it, so it stops here and says so on three surfaces:
308
+ * the operator's log, the record the tool and Console read, and the event
309
+ * stream of the session that was supposed to receive it (§5b). */
310
+ unreachable(sessionId, what, why) {
311
+ log.error(`gave up delivering ${what} to session ${sessionId}: ${why}`);
312
+ this.router.reportTo(sessionId, `${what} could not be delivered — ${why}`);
313
+ }
314
+ /** Four independent sweeps, isolated: one throwing (a group whose member row
315
+ * is gone throws on every pass) must not starve the retries behind it. */
316
+ sweep(what, run) {
317
+ try {
318
+ run();
319
+ }
320
+ catch (err) {
321
+ log.error(`${what} sweep failed`, err);
322
+ }
323
+ }
254
324
  settled(run) {
255
325
  const waiters = this.waiters.get(run.id);
256
326
  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,146 @@
1
+ // The newer Pier: whether one exists, and when this one may become it.
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
+ // Nothing here installs anything. Applying is handed to whatever supervises
8
+ // this process (service.ts's oneshot unit, injected as `apply`): a web server
9
+ // holding provider keys must not npm-install as its own child, and the two
10
+ // gates below — the operator switched it on, and nothing is running — are why
11
+ // a self-replacing timer is not simply a supply-chain surface (AGENTS.md 8).
12
+ import { createRequire } from "node:module";
13
+ import { logger } from "./log.js";
14
+ const log = logger("update");
15
+ const PACKAGE = "@timqi/pier";
16
+ const ENDPOINT = `https://registry.npmjs.org/${PACKAGE}/latest`;
17
+ /** One conditional-GET-sized request against a CDN, so the cost of asking is
18
+ * not what sets this — how long a released fix may sit unnoticed is. */
19
+ const TTL_MS = 30 * 60_000;
20
+ const TIMEOUT_MS = 5_000;
21
+ export const currentVersion = () => createRequire(import.meta.url)("../package.json").version;
22
+ 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);
23
+ /**
24
+ * `1.2.3` against `1.10.0`, numerically per field: a string compare would call
25
+ * the second one older. A prerelease suffix loses to the release it precedes,
26
+ * which is all the semver this needs.
27
+ */
28
+ export function isNewer(candidate, than) {
29
+ const parse = (v) => (v.split("-")[0] ?? "").split(".").map((n) => Number.parseInt(n, 10) || 0);
30
+ const [a, b] = [parse(candidate), parse(than)];
31
+ for (let i = 0; i < 3; i++) {
32
+ if ((a[i] ?? 0) !== (b[i] ?? 0))
33
+ return (a[i] ?? 0) > (b[i] ?? 0);
34
+ }
35
+ // Equal numbers: a prerelease is older than the release of the same version.
36
+ const pre = (v) => v.split("-")[1] ?? "";
37
+ return pre(candidate) === "" && pre(than) !== "";
38
+ }
39
+ /** One registry answer, cached, shared by every caller. */
40
+ export class UpdateCheck {
41
+ current;
42
+ fetchLatest;
43
+ now;
44
+ #latest = null;
45
+ #checkedAt = 0;
46
+ #inFlight;
47
+ constructor(current = currentVersion(), fetchLatest = fetchLatestVersion, now = Date.now) {
48
+ this.current = current;
49
+ this.fetchLatest = fetchLatest;
50
+ this.now = now;
51
+ }
52
+ /** The last answer, and a refresh in the background when it is stale. Never
53
+ * awaits the network: a workbench that loads is worth more than a fresh
54
+ * version number. */
55
+ status() {
56
+ if (this.now() - this.#checkedAt >= TTL_MS)
57
+ void this.refresh();
58
+ return {
59
+ current: this.current,
60
+ latest: this.#latest,
61
+ available: this.#latest !== null && isNewer(this.#latest, this.current),
62
+ };
63
+ }
64
+ /** The answer, waiting for the very first check instead of reporting "no
65
+ * idea". A browser asks once per page load, so a process that had never
66
+ * checked told every one of them `latest: null` — which is exactly how a
67
+ * published release looked undetected. Later loads are served from cache. */
68
+ async statusNow() {
69
+ if (this.#checkedAt === 0)
70
+ await this.refresh();
71
+ return this.status();
72
+ }
73
+ /** Ask now. Concurrent callers share the one request. */
74
+ refresh() {
75
+ this.#inFlight ??= this.fetchLatest()
76
+ .then((latest) => {
77
+ this.#latest = latest;
78
+ if (isNewer(latest, this.current))
79
+ log.info(`${latest} is available (running ${this.current})`);
80
+ })
81
+ // A failed check is not a failure: no network, an offline box, a registry
82
+ // hiccup. It is reported once at debug and retried at the next TTL.
83
+ .catch((err) => log.debug(`registry check failed: ${String(err)}`))
84
+ .finally(() => {
85
+ this.#checkedAt = this.now();
86
+ this.#inFlight = undefined;
87
+ });
88
+ return this.#inFlight;
89
+ }
90
+ }
91
+ /** Often enough to catch an idle window on a busy box, and cheap: the registry
92
+ * itself is still only asked once per TTL (`status()` owns that). */
93
+ const AUTO_POLL_MS = 15 * 60_000;
94
+ /** Watch for the moment all three conditions hold. Returns its own stop. */
95
+ export function startAutoUpdate(check, auto, pollMs = AUTO_POLL_MS) {
96
+ // A handover drains first, which can outlast a poll interval; a second
97
+ // attempt on top of it would drain an already-draining Pier.
98
+ let handingOver = false;
99
+ const tick = async () => {
100
+ if (handingOver || !auto.enabled())
101
+ return;
102
+ // Refreshes in the background when stale; today's answer is good enough,
103
+ // because the next tick is a quarter of an hour away either way.
104
+ const { latest, available } = check.status();
105
+ if (!available || !auto.idle())
106
+ return;
107
+ log.info(`auto-update: idle and ${latest ?? "a newer version"} is out — handing over to the updater`);
108
+ handingOver = true;
109
+ try {
110
+ const started = await auto.apply();
111
+ // Not silent (§5b): an update that never happens must not look like an
112
+ // update that was never wanted. `busy` is the one non-start that is
113
+ // fine — someone else is already restarting this Pier.
114
+ if (started === "busy")
115
+ log.info("auto-update: a handover or restart is already in progress");
116
+ else if (started !== "started")
117
+ log.error(`auto-update could not start: ${started}`);
118
+ }
119
+ catch (err) {
120
+ log.error("auto-update failed", err);
121
+ }
122
+ finally {
123
+ // Only reached when the handover did *not* take the process with it, so
124
+ // the next tick is allowed to try again.
125
+ handingOver = false;
126
+ }
127
+ };
128
+ const timer = setInterval(() => void tick(), pollMs);
129
+ timer.unref(); // a pending check must never be what keeps the process alive
130
+ return () => clearInterval(timer);
131
+ }
132
+ export async function fetchLatestVersion() {
133
+ // Plain JSON: the abbreviated-packument content type npm uses for a whole
134
+ // package is a 406 on this endpoint, which answers one version already.
135
+ const res = await fetch(ENDPOINT, {
136
+ signal: AbortSignal.timeout(TIMEOUT_MS),
137
+ headers: { accept: "application/json" },
138
+ });
139
+ if (!res.ok)
140
+ throw new Error(`registry answered ${res.status}`);
141
+ const body = (await res.json());
142
+ if (typeof body.version !== "string" || !isValidVersion(body.version)) {
143
+ throw new Error("registry answered without a valid version");
144
+ }
145
+ return body.version;
146
+ }