@timqi/pier 0.1.0 → 0.1.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 (158) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +6 -15
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +42 -64
  5. package/dist/agent/listing.js +39 -92
  6. package/dist/agent/pi.js +103 -252
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +7 -23
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +12 -34
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -264
  48. package/dist/core/types.js +4 -0
  49. package/dist/db.js +88 -272
  50. package/dist/drain.js +57 -50
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +4 -12
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +84 -183
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +18 -45
  65. package/dist/service.js +31 -73
  66. package/dist/settings.js +19 -63
  67. package/dist/tasks/agent.js +24 -45
  68. package/dist/tasks/callbacks.js +8 -16
  69. package/dist/tasks/command.js +2 -6
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +41 -39
  72. package/dist/tasks/groups.js +8 -11
  73. package/dist/tasks/messages.js +88 -155
  74. package/dist/tasks/outbox.js +33 -54
  75. package/dist/tasks/routes.js +4 -7
  76. package/dist/tasks/runs.js +4 -9
  77. package/dist/tasks/service.js +29 -42
  78. package/dist/tasks/store.js +36 -27
  79. package/dist/tasks/tool.js +59 -60
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +98 -325
  82. package/dist/update.js +20 -43
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +35 -82
  89. package/dist/web/providers.js +5 -11
  90. package/dist/web/public/assets/{activity-Bl3vZukb.js → activity-DOr8dWeX.js} +1 -1
  91. package/dist/web/public/assets/activity-DOr8dWeX.js.br +0 -0
  92. package/dist/web/public/assets/activity-DOr8dWeX.js.gz +0 -0
  93. package/dist/web/public/assets/{boards-DYuf4Mlj.js → boards-CneyR23E.js} +1 -1
  94. package/dist/web/public/assets/boards-CneyR23E.js.br +0 -0
  95. package/dist/web/public/assets/boards-CneyR23E.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-D0srXT1c.js +4 -0
  97. package/dist/web/public/assets/explorer-D0srXT1c.js.br +0 -0
  98. package/dist/web/public/assets/explorer-D0srXT1c.js.gz +0 -0
  99. package/dist/web/public/assets/index-DbFu15NN.js +85 -0
  100. package/dist/web/public/assets/index-DbFu15NN.js.br +0 -0
  101. package/dist/web/public/assets/index-DbFu15NN.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/{runs-BLJu7EXN.js → runs-Cv0A-e08.js} +1 -1
  106. package/dist/web/public/assets/runs-Cv0A-e08.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cv0A-e08.js.gz +0 -0
  108. package/dist/web/public/assets/{settings-BrdVh-Zi.js → settings-VmCjhGBd.js} +1 -1
  109. package/dist/web/public/assets/settings-VmCjhGBd.js.br +0 -0
  110. package/dist/web/public/assets/settings-VmCjhGBd.js.gz +0 -0
  111. package/dist/web/public/assets/{task-runs-CeQS1rxa.js → task-runs-CqpTV644.js} +1 -1
  112. package/dist/web/public/assets/task-runs-CqpTV644.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-CqpTV644.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BffPVgXg.js +4 -0
  115. package/dist/web/public/assets/tasks-BffPVgXg.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BffPVgXg.js.gz +0 -0
  117. package/dist/web/public/index.html +30 -16
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/sw.js +14 -2
  121. package/dist/web/public/sw.js.br +0 -0
  122. package/dist/web/public/sw.js.gz +0 -0
  123. package/dist/web/push.js +55 -77
  124. package/dist/web/route.js +3 -7
  125. package/dist/web/server.js +109 -190
  126. package/dist/web/session-state.js +13 -53
  127. package/dist/web/types.js +2 -4
  128. package/dist/web/webpush.js +10 -25
  129. package/docs/deploy.md +115 -330
  130. package/package.json +1 -1
  131. package/skills/pier-boards/SKILL.md +81 -160
  132. package/skills/pier-help/SKILL.md +23 -20
  133. package/skills/pier-slack/SKILL.md +2 -2
  134. package/skills/pier-tasks/SKILL.md +23 -15
  135. package/dist/config-sync-fetch.js +0 -84
  136. package/dist/limits.js +0 -14
  137. package/dist/web/public/assets/activity-Bl3vZukb.js.br +0 -0
  138. package/dist/web/public/assets/activity-Bl3vZukb.js.gz +0 -0
  139. package/dist/web/public/assets/boards-DYuf4Mlj.js.br +0 -0
  140. package/dist/web/public/assets/boards-DYuf4Mlj.js.gz +0 -0
  141. package/dist/web/public/assets/explorer-qJH_9nTE.js +0 -4
  142. package/dist/web/public/assets/explorer-qJH_9nTE.js.br +0 -0
  143. package/dist/web/public/assets/explorer-qJH_9nTE.js.gz +0 -0
  144. package/dist/web/public/assets/index-Dqdb-Eqt.js +0 -85
  145. package/dist/web/public/assets/index-Dqdb-Eqt.js.br +0 -0
  146. package/dist/web/public/assets/index-Dqdb-Eqt.js.gz +0 -0
  147. package/dist/web/public/assets/index-DzmMzvi_.css +0 -2
  148. package/dist/web/public/assets/index-DzmMzvi_.css.br +0 -0
  149. package/dist/web/public/assets/index-DzmMzvi_.css.gz +0 -0
  150. package/dist/web/public/assets/runs-BLJu7EXN.js.br +0 -0
  151. package/dist/web/public/assets/runs-BLJu7EXN.js.gz +0 -0
  152. package/dist/web/public/assets/settings-BrdVh-Zi.js.br +0 -0
  153. package/dist/web/public/assets/settings-BrdVh-Zi.js.gz +0 -0
  154. package/dist/web/public/assets/task-runs-CeQS1rxa.js.br +0 -0
  155. package/dist/web/public/assets/task-runs-CeQS1rxa.js.gz +0 -0
  156. package/dist/web/public/assets/tasks-bcb3fYdK.js +0 -4
  157. package/dist/web/public/assets/tasks-bcb3fYdK.js.br +0 -0
  158. package/dist/web/public/assets/tasks-bcb3fYdK.js.gz +0 -0
@@ -1,9 +1,6 @@
1
- // The one object the rest of Pier talks to about tasks, and the clock behind
2
- // it: the tick that finds what is due, the boot recovery that writes off runs
3
- // a restart interrupted, and the pause a drain needs. Every decision it looks
4
- // like it makes belongs to a file beside it (definitions, runs, execution,
5
- // groups, messages, callbacks) — what is genuinely here is scheduling and the
6
- // facade, so the HTTP routes and the task tool cannot drift apart.
1
+ // The facade the rest of Pier talks to about tasks, and the clock behind it:
2
+ // the tick, the boot recovery that writes off interrupted runs, and the pause a
3
+ // drain needs. Decisions belong to the files beside it.
7
4
  import { logger } from "../log.js";
8
5
  import { AgentTaskRunner } from "./agent.js";
9
6
  import { TaskCallbacks } from "./callbacks.js";
@@ -15,9 +12,8 @@ import { TaskRunQueue } from "./runs.js";
15
12
  import { handleTaskTool } from "./tool.js";
16
13
  import { isTerminal } from "./types.js";
17
14
  const log = logger("tasks");
18
- /** The text a run was given, as the operator wrote it: a resume's follow-up,
19
- * else the action's own prompt or script. Not `renderedPrompt` — that is the
20
- * preamble and the input wrapper, which the delegating session did not send. */
15
+ /** Not `renderedPrompt`: that carries the preamble and input wrapper, which the
16
+ * delegating session did not send. */
21
17
  const runPrompt = (run) => {
22
18
  if (run.context.resumePrompt)
23
19
  return run.context.resumePrompt;
@@ -40,8 +36,7 @@ export class TaskService {
40
36
  runs;
41
37
  execution;
42
38
  constructor(store, factory, router, hub,
43
- /** Structural on purpose: tasks/ must not import settings.ts — main.ts
44
- * hands in a closure over the store instead. Absent in bare test rigs. */
39
+ /** Structural: tasks/ must not import settings.ts. Absent in bare test rigs. */
45
40
  instance) {
46
41
  this.store = store;
47
42
  this.factory = factory;
@@ -108,10 +103,8 @@ export class TaskService {
108
103
  }, tickMs);
109
104
  this.timer.unref();
110
105
  }
111
- /** Undo a `pause()` that was not followed by an exit — the auto-updater
112
- * drains before handing over, and a handover that never started must not
113
- * leave the scheduler switched off. Deliberately not `start()`: the boot
114
- * recovery in there would write off runs this process is still running. */
106
+ /** For a handover that never started. Not `start()`: its boot recovery would
107
+ * write off runs this process is still running. */
115
108
  unpause(tickMs = 1000) {
116
109
  if (this.timer)
117
110
  return;
@@ -122,11 +115,8 @@ export class TaskService {
122
115
  this.pause();
123
116
  this.execution.stop();
124
117
  }
125
- /** Stop taking new work but leave running runs alone — a graceful restart
126
- * (src/drain.ts) waits for them, where stop() would abort them. The
127
- * scheduler timer goes, and new root runs are refused; children of a run
128
- * that is still finishing stay allowed, because refusing them would fail
129
- * the very work the drain is waiting for. */
118
+ /** New root runs are refused, running ones left for the drain to wait on;
119
+ * children of a finishing run stay allowed, or the drain fails its own work. */
130
120
  pause() {
131
121
  this.paused = true;
132
122
  if (this.timer)
@@ -143,11 +133,8 @@ export class TaskService {
143
133
  activeRunCount() {
144
134
  return this.store.countActiveRuns();
145
135
  }
146
- /** The run this task has in flight, if any. The store already answers this
147
- * for the overlap guard (runs.ts); a caller that has just been refused as
148
- * an overlap needs the same answer to know what to wait for, and scanning
149
- * run history for it finds nothing once the skipped rows outnumber the
150
- * window. */
136
+ /** A caller refused as an overlap needs this to know what to wait for;
137
+ * scanning run history finds nothing once skipped rows outnumber the window. */
151
138
  activeRun(taskId) {
152
139
  return this.store.findActiveRun(taskId);
153
140
  }
@@ -160,8 +147,7 @@ export class TaskService {
160
147
  create(raw, creator = "http") {
161
148
  return this.definitions.create(raw, creator);
162
149
  }
163
- /** `by` is how the code that owns a definition says so; the HTTP routes and
164
- * the task tool have none, which is what closes both (definitions.ts). */
150
+ /** `by` is how owning code says so; the routes and the tool have none (definitions.ts). */
165
151
  update(id, raw, by) {
166
152
  return this.definitions.update(id, raw, by);
167
153
  }
@@ -202,16 +188,13 @@ export class TaskService {
202
188
  recentMessages(since) {
203
189
  return this.messages.recent(since);
204
190
  }
205
- /** Every run this session delegated: the card is the message that started
206
- * it, and a message does not leave the transcript because the run ended. */
191
+ /** Every run, not the last hour's: the card is a message in the transcript. */
207
192
  backgroundRuns(sessionId) {
208
193
  return this.store.listRunsForSession(sessionId, 200)
209
194
  .filter((run) => run.background)
210
195
  .reverse()
211
196
  .map((run) => this.backgroundRun(run));
212
197
  }
213
- /** The same runs `backgroundRuns` reports as in flight, counted per session
214
- * in one query — a list needs the number, not the runs. */
215
198
  activeBackgroundRunCounts() {
216
199
  return this.store.countActiveBackgroundRunsBySession();
217
200
  }
@@ -326,9 +309,7 @@ export class TaskService {
326
309
  tool(raw, callerSessionId) {
327
310
  return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
328
311
  }
329
- /** The deployment's model advice: the operator's pinned menu when one is
330
- * set, the curated live catalog otherwise — an agent picks from names that
331
- * exist right now, never from memory. */
312
+ /** An agent picks from names that exist right now, never from memory. */
332
313
  async models() {
333
314
  const menu = this.instance?.modelMenu() ?? [];
334
315
  if (menu.length)
@@ -342,8 +323,17 @@ export class TaskService {
342
323
  try {
343
324
  const now = Date.now();
344
325
  this.sweep("schedule", () => {
345
- for (const task of this.definitions.claimDue(now)) {
346
- this.run(task.id, null, task.trigger.type === "watch" ? "watch" : "cron");
326
+ // Per task: one enqueue that throws must not spend the other due tasks'
327
+ // occurrence, and its own stays due for the next tick.
328
+ for (const task of this.definitions.due(now)) {
329
+ this.sweep(`schedule ${task.name}`, () => {
330
+ const run = this.store.transact(() => {
331
+ this.definitions.advance(task, now);
332
+ return this.prepareRun(task.id, null, task.trigger.type === "watch" ? "watch" : "cron", null, {});
333
+ });
334
+ this.hub.emitWorkspace({ type: "tasks-changed" });
335
+ this.runs.start(run);
336
+ });
347
337
  }
348
338
  });
349
339
  this.sweep("run callbacks", () => this.callbacks.recover(now));
@@ -354,16 +344,13 @@ export class TaskService {
354
344
  this.ticking = false;
355
345
  }
356
346
  }
357
- /** A delivery nobody can complete. Retrying it forever costs the same
358
- * silence as dropping it, so it stops here and says so on three surfaces:
359
- * the operator's log, the record the tool and Console read, and the event
360
- * stream of the session that was supposed to receive it (§5b). */
347
+ /** Retrying forever costs the same silence as dropping, so it stops and says
348
+ * so on the log, the record and the recipient's event stream (§5). */
361
349
  unreachable(sessionId, what, why) {
362
350
  log.error(`gave up delivering ${what} to session ${sessionId}: ${why}`);
363
351
  this.router.reportTo(sessionId, `${what} could not be delivered — ${why}`);
364
352
  }
365
- /** Four independent sweeps, isolated: one throwing (a group whose member row
366
- * is gone throws on every pass) must not starve the retries behind it. */
353
+ /** Isolated: one sweep throwing on every pass must not starve the others. */
367
354
  sweep(what, run) {
368
355
  try {
369
356
  run();
@@ -1,9 +1,10 @@
1
- // Every query this area makes against pier.db, and nothing else: definitions,
2
- // runs, groups and messages are rows here, read and written through one
3
- // connection db.ts opened. A store owns its queries, never its own tables or
4
- // its own handle — the schema is db.ts's migration list.
1
+ // Every query this area makes against pier.db, and nothing else; the schema is
2
+ // db.ts's migration list.
5
3
  import { pierDb, statements, transact } from "../db.js";
6
4
  const clamp = (limit, cap) => Math.min(Math.max(limit, 1), cap);
5
+ /** Kept unmatched probes per watch: one page of history, which is all any
6
+ * surface lists of a probe that found nothing. */
7
+ const KEPT_PROBES = 50;
7
8
  export class TaskStore {
8
9
  db;
9
10
  /** Every query below is a fixed string, so each is compiled once. */
@@ -16,9 +17,7 @@ export class TaskStore {
16
17
  transact(work) {
17
18
  return transact(this.db, work);
18
19
  }
19
- // Every table is one JSON column plus query columns; these two are the only
20
- // readers, and the one seam where a future schema change normalizes old rows
21
- // (pre-v1 databases are refused outright in db.ts).
20
+ // The only readers of the JSON columns, and where a schema change normalizes old rows.
22
21
  #one(sql, ...params) {
23
22
  const row = this.sql(sql).get(...params);
24
23
  return row ? JSON.parse(row.json) : undefined;
@@ -39,9 +38,7 @@ export class TaskStore {
39
38
  ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, json=excluded.json
40
39
  `).run(task.id, task.updatedAt, JSON.stringify(task));
41
40
  }
42
- /** The tick's whole question, asked of the index rather than of every
43
- * document: a task with no next run (disabled, archived, manual) is not in
44
- * it, so an idle second reads no row. */
41
+ /** Asked of the index, not every document: an idle second reads no row. */
45
42
  listDueTasks(now) {
46
43
  return this.#many("SELECT json FROM tasks WHERE next_run_at IS NOT NULL AND next_run_at <= ?", now);
47
44
  }
@@ -62,9 +59,28 @@ export class TaskStore {
62
59
  state=excluded.state, callback_state=excluded.callback_state, json=excluded.json
63
60
  `).run(run.id, run.taskId, run.queuedAt, run.state, run.callbackState, JSON.stringify(run));
64
61
  }
65
- /** The global list: filters precede the limit, and the id breaks timestamp
66
- * ties so a page boundary never repeats or skips a row. The statement is
67
- * built per call and not cached — the filter set makes it. */
62
+ /** A watch at the five-second floor mints 17k rows a day, ~1.6 kB each, that
63
+ * no list shows; only rows nothing can dangle from are dropped — a child
64
+ * run is named in its parent's stored result. */
65
+ pruneUnmatchedProbes(taskId) {
66
+ this.sql(`
67
+ DELETE FROM task_runs WHERE id IN (
68
+ SELECT r.id FROM task_runs r
69
+ WHERE r.task_id = ? AND r.state = 'succeeded' AND json_extract(r.json, '$.matched') IS 0
70
+ AND r.callback_state IS NULL AND json_extract(r.json, '$.groupId') IS NULL
71
+ AND json_extract(r.json, '$.parentRunId') IS NULL
72
+ AND NOT EXISTS (SELECT 1 FROM task_messages m WHERE m.run_id = r.id)
73
+ -- A NULL in this list would make NOT IN unknown for every candidate.
74
+ AND r.id NOT IN (
75
+ SELECT json_extract(c.json, '$.resumedFromRunId') FROM task_runs c
76
+ WHERE c.task_id = ? AND json_extract(c.json, '$.resumedFromRunId') IS NOT NULL
77
+ )
78
+ ORDER BY r.queued_at DESC, r.id DESC LIMIT -1 OFFSET ?
79
+ )
80
+ `).run(taskId, taskId, KEPT_PROBES);
81
+ }
82
+ /** The id breaks timestamp ties so a page boundary never repeats or skips a
83
+ * row. Built per call, not cached: the filter set makes it. */
68
84
  queryRuns(query = {}) {
69
85
  const where = [];
70
86
  const params = [];
@@ -143,9 +159,7 @@ export class TaskStore {
143
159
  ORDER BY CASE state WHEN 'running' THEN 0 ELSE 1 END, queued_at DESC LIMIT 1
144
160
  `, sessionId);
145
161
  }
146
- /** How many background runs each session has in flight, for a list that
147
- * draws one dot per row: the state column narrows the scan to the handful
148
- * of live runs, and no row's JSON is parsed. */
162
+ /** The state column narrows the scan to live runs; no JSON is parsed. */
149
163
  countActiveBackgroundRunsBySession() {
150
164
  const rows = this.sql(`
151
165
  SELECT json_extract(json, '$.invokedBySessionId') AS session_id, COUNT(*) AS n
@@ -157,10 +171,8 @@ export class TaskStore {
157
171
  `).all();
158
172
  return new Map(rows.map((row) => [row.session_id, row.n]));
159
173
  }
160
- /** Every session a run created for itself — `fresh` is the one mode that
161
- * makes a session rather than borrowing one, and a resumed run reuses the
162
- * session its `fresh` predecessor already stamped. These are the agent's
163
- * conversations with itself, which the rail does not list. */
174
+ /** `fresh` is the one mode that makes a session rather than borrowing one;
175
+ * these are the agent's conversations with itself, which the rail does not list. */
164
176
  taskOwnedSessionIds() {
165
177
  const rows = this.sql(`
166
178
  SELECT DISTINCT json_extract(json, '$.context.sessionId') AS id
@@ -214,10 +226,8 @@ export class TaskStore {
214
226
  listRecentMessages(since) {
215
227
  return this.#many("SELECT json FROM task_messages WHERE created_at >= ? ORDER BY created_at DESC LIMIT 200", since);
216
228
  }
217
- /** Messages whose injection never landed, each beside the run it belongs to:
218
- * the delivery sweep needs both, and fetching the run per message made one
219
- * sweep cost a query per undelivered message. A message whose run is gone
220
- * comes back with `run: undefined` — the sweep drops it. */
229
+ /** Each beside its run, so the sweep costs one query. A message whose run is
230
+ * gone comes back with `run: undefined`. */
221
231
  listUndeliveredMessages() {
222
232
  const rows = this.sql(`
223
233
  SELECT m.json AS json, r.json AS run_json
@@ -237,9 +247,8 @@ export class TaskStore {
237
247
  WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
238
248
  `).map((message) => {
239
249
  message.state = "expired";
240
- // "Confirmed", not "completed": the input may well have been read — the
241
- // proof of it lives in the recipient's transcript, which this layer
242
- // cannot see, and the run it steered is interrupted by the same restart.
250
+ // "Confirmed", not "completed": the proof lives in a transcript this
251
+ // layer cannot see.
243
252
  message.error = "Pier restarted before delivery could be confirmed";
244
253
  this.saveMessage(message);
245
254
  return message;
@@ -6,17 +6,9 @@ import { isTerminal } from "./types.js";
6
6
  const log = logger("tasks");
7
7
  // JSON-Schema enum emits ~1/3 the tokens of typebox's anyOf-of-consts.
8
8
  const strEnum = (...values) => Type.Unsafe({ type: "string", enum: [...values] });
9
- /**
10
- * Drop the fields with nothing in them instead of sending `null`.
11
- *
12
- * A run summary has eighteen fields and most are empty for most of a run's
13
- * life; a model reads "absent" and "null" the same way. On a group summary
14
- * that lists several runs this is a third of the payload.
15
- *
16
- * The input names every field — a summary that forgot one would otherwise pass
17
- * as "that field was empty" — and the result is the type with the empty ones
18
- * gone, which is why those are declared optional above.
19
- */
9
+ /** Absent instead of `null`: a model reads both the same way, and on a group
10
+ * summary the nulls are a third of the payload. The input names every field
11
+ * so a summary that forgot one cannot pass as "empty". */
20
12
  const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined));
21
13
  const summarize = (run, pendingDecisionId) => defined({
22
14
  runId: run.id,
@@ -40,9 +32,8 @@ const summarize = (run, pendingDecisionId) => defined({
40
32
  skipReason: run.skipReason,
41
33
  next: null,
42
34
  });
43
- /** What a run receipt says happens next. The callback is the whole answer, so
44
- * the receipt says so — a model that just launched work otherwise reaches
45
- * for a status call. `none` is stated as what it is: no delivery at all. */
35
+ /** The receipt says the callback is the whole answer, or a model that just
36
+ * launched work reaches for a status call. */
46
37
  const receipt = (summary, callbackSessionId, mode, callerSessionId) => ({
47
38
  ...summary,
48
39
  next: callbackSessionId === null
@@ -53,6 +44,19 @@ const receipt = (summary, callbackSessionId, mode, callerSessionId) => ({
53
44
  ? "the result interrupts your running turn as a steer message; nothing to query"
54
45
  : "the result arrives as a callback message once your turn ends; nothing to query",
55
46
  });
47
+ const SUBAGENT_REDIRECT = "subagents cannot redirect callbacks (callback_session_id)";
48
+ /** Who a new run's result goes to — the caller, nobody, or a named session that
49
+ * must exist. Shared by `run` and `resume`: a resumed run is a new run. */
50
+ const callbackTarget = async (input, definitions, callerSessionId) => {
51
+ if (input.callback_session_id === undefined)
52
+ return input.callback === "none" ? null : callerSessionId;
53
+ if (input.callback === "none")
54
+ throw new Error("callback none and callback_session_id conflict: pick one delivery target");
55
+ const target = requiredString(input.callback_session_id, "callback_session_id");
56
+ if (!(await definitions.sessionExists(target)))
57
+ throw new Error(`unknown session: ${target}`);
58
+ return target;
59
+ };
56
60
  /** A group echoes many results at once, so each is capped; a single-run
57
61
  * `recover` stays whole — it is the escape hatch every truncation note points at. */
58
62
  const trimResult = (summary) => {
@@ -66,13 +70,10 @@ const trimResult = (summary) => {
66
70
  },
67
71
  };
68
72
  };
69
- /** Whether a callback record has said its last word: the input is in the
70
- * recipient's transcript, delivery was given up on and reported, or there
71
- * was never one to wait for (`callback:"none"`). Anything else is still on
72
- * its way, and reading the result here would be reading it twice. */
73
+ /** Delivered, given up on and reported, or never owed (`callback:"none"`).
74
+ * Anything else is still on its way, and reading it here would be reading it twice. */
73
75
  const settled = (callback) => callback.callbackState === null || callback.callbackState === "delivered" || callback.callbackState === "abandoned";
74
- /** The one refusal `recover` gives before a result is readable. Deliberately
75
- * the same words for queued, running, pending and retrying: a refusal that
76
+ /** The same words for queued, running, pending and retrying: a refusal that
76
77
  * named the state would be the status query this operation replaced. */
77
78
  const notRecoverable = (what, callback) => {
78
79
  throw new Error(callback.callbackSessionId === null
@@ -93,8 +94,8 @@ const LaunchSchema = Type.Object({
93
94
  model: Type.Optional(Type.Object({ provider: Type.String(), id: Type.String() })),
94
95
  thinking: Type.Optional(Type.String()),
95
96
  });
96
- // Model-facing draft shape. Guidance only: runtime truth stays in parseDraft,
97
- // so schema drift can never loosen boundary validation.
97
+ // Guidance only: runtime truth stays in parseDraft, so schema drift cannot
98
+ // loosen boundary validation.
98
99
  const DraftSchema = Type.Object({
99
100
  name: Type.Optional(Type.String({ description: "Defaults to the prompt's first line." })),
100
101
  description: Type.Optional(Type.String()),
@@ -134,7 +135,7 @@ export function taskToolSpec(execute) {
134
135
  return {
135
136
  name: "task",
136
137
  label: "Pier Task",
137
- description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. create files a definition the operator sees in the Console — only for schedules or roles you will run again; a one-off is run with a prompt. Run executes a stored task by task_id, a one-shot subagent from a prompt (shorthand: prompt + optional cwd/launch/name — cwd defaults to your own directory, relative paths resolve against it, name comes from the prompt) or from a full inline task draft, or a core-joined fan-out via tasks[] with join all|first. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages once your turn ends — there is no status query; pass callback 'steer' to have a result interrupt your running turn instead, or 'none' for no callback at all. recover (run_id or group_id, plus a reason) re-reads a finished result after its callback has settled — for truncated text or lost context, never to check progress. 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).",
138
+ description: "Manage durable Pier tasks and subagents. Agent tasks run in a fresh session or a reused one. create files a definition the operator sees in the Console — only for schedules or roles you will run again; a one-off is run with a prompt. Run executes a stored task by task_id, a one-shot subagent from a prompt (shorthand: prompt + optional cwd/launch/name/timeoutSeconds — cwd defaults to your own directory, relative paths resolve against it, name comes from the prompt) or from a full inline task draft, or a core-joined fan-out via tasks[] with join all|first. Every operation returns immediately: results, group joins, and decision replies arrive as callback messages once your turn ends — there is no status query; pass callback 'steer' to have a result interrupt your running turn instead, or 'none' for no callback at all. recover (run_id or group_id, plus a reason) re-reads a finished result after its callback has settled — for truncated text or lost context, never to check progress. 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).",
138
139
  parameters: Type.Object({
139
140
  operation: strEnum("list", "create", "update", "run", "recover", "cancel", "steer", "follow_up", "resume", "contact", "reply", "models"),
140
141
  task_id: Type.Optional(Type.String()),
@@ -144,19 +145,17 @@ export function taskToolSpec(execute) {
144
145
  message: Type.Optional(Type.String()),
145
146
  reason: Type.Optional(Type.String({ description: "contact: progress | decision. recover: why the delivered callback is not enough (required)." })),
146
147
  session_mode: Type.Optional(strEnum("fresh")),
147
- // The one-shot shorthand: a prompt is the whole delegation, and the
148
- // fresh session in the caller's own directory is what it means.
149
148
  prompt: Type.Optional(Type.String()),
150
149
  cwd: Type.Optional(Type.String()),
151
150
  launch: Type.Optional(LaunchSchema),
152
151
  name: Type.Optional(Type.String()),
152
+ timeoutSeconds: Type.Optional(Type.Number({ description: "1–86400; defaults to 3600." })),
153
153
  task: Type.Optional(DraftSchema),
154
- // The same draft again, spelled out, cost more tokens in every session
155
- // than the whole rest of this contract. One copy is the guidance; this
156
- // one points at it, and `parseDraft` is what actually validates either.
154
+ // Spelled out, the draft schema costs more tokens per session than the
155
+ // rest of this contract; `parseDraft` validates either shape.
157
156
  tasks: Type.Optional(Type.Unsafe({
158
157
  type: "array",
159
- description: "2+ entries, each a prompt string, {prompt, cwd?, launch?, name?}, a task draft shaped exactly like `task`, or {task_id}.",
158
+ description: "2+ entries, each a prompt string, {prompt, cwd?, launch?, name?, timeoutSeconds?}, a task draft shaped exactly like `task`, or {task_id}.",
160
159
  items: { type: "object" },
161
160
  })),
162
161
  join: Type.Optional(strEnum("all", "first")),
@@ -187,15 +186,19 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
187
186
  return definitions.update(requiredString(input.task_id, "task_id"), await expandDraft(definitions, input.task, callerSessionId));
188
187
  }
189
188
  if (input.operation === "run") {
190
- // One reading of `callback` for both shapes below: a run and a fan-out
191
- // choose the same way, and two readings are two things to keep in step.
192
189
  const callbackMode = input.callback === "steer" ? "steer" : "followUp";
190
+ // A run's own callback is its parent's link back; a child that could point
191
+ // it elsewhere would strand the supervisor waiting for a result.
192
+ if (active && input.callback_session_id !== undefined)
193
+ throw new Error(SUBAGENT_REDIRECT);
193
194
  if (Array.isArray(input.tasks)) {
194
195
  // Core-joined fan-out: members run detached, one aggregated callback.
195
196
  if (input.task !== undefined || input.task_id !== undefined)
196
197
  throw new Error("use either task/task_id or tasks[]");
197
198
  if (input.session_mode !== undefined)
198
199
  throw new Error("session_mode applies to a single run only");
200
+ if (input.callback_session_id !== undefined)
201
+ throw new Error("callback_session_id applies to a single run only");
199
202
  if (input.tasks.length < 2)
200
203
  throw new Error("tasks[] needs at least 2 entries; use task for a single run");
201
204
  const resolved = [];
@@ -221,12 +224,7 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
221
224
  throw new Error(`unsupported session_mode: ${String(input.session_mode)}`);
222
225
  }
223
226
  const sessionMode = input.session_mode;
224
- let callbackSessionId = input.callback === "none" ? null : callerSessionId;
225
- if (!active && callbackSessionId && typeof input.callback_session_id === "string") {
226
- callbackSessionId = requiredString(input.callback_session_id, "callback_session_id");
227
- if (!(await definitions.sessionExists(callbackSessionId)))
228
- throw new Error(`unknown session: ${callbackSessionId}`);
229
- }
227
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
230
228
  const run = host.run(task.id, input.input, "agent", active?.id ?? null, {
231
229
  invokedBySessionId: callerSessionId,
232
230
  sourceSessionId: callerSessionId,
@@ -238,18 +236,14 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
238
236
  return receipt(summarize(run, null), callbackSessionId, callbackMode, callerSessionId);
239
237
  }
240
238
  if (input.operation === "recover") {
241
- // History only, never status: a result is readable here once its callback
242
- // has said its last word, so nothing a caller could learn by asking is
243
- // something it would not have been told. The reason is the friction — a
244
- // caller states why the callback did not suffice, and the operator sees it.
239
+ // History only, never status: readable once the callback has said its last
240
+ // word. The required reason is the friction, and the operator sees it.
245
241
  const reason = requiredString(input.reason, "reason");
246
- // A finished run with an open decision sends no completion callback — the
247
- // question is the notification, and the reply's continuation reports.
242
+ // The open question is the notification; the reply's continuation reports.
248
243
  const decisionOpen = (run) => {
249
244
  throw new Error(`run ${run.id} finished awaiting your decision ${messages.openDecisionId(run.id) ?? ""}; reply to it — the continuation's callback brings the result`);
250
245
  };
251
- // A member waits for its group's callback, but a race winner need not
252
- // wait for losing members to finish cancelling before recovering its text.
246
+ // A race winner need not wait for losing members to finish cancelling.
253
247
  const groupReady = (group) => {
254
248
  if (!group.finishedAt || !settled(group))
255
249
  notRecoverable(`group ${group.id}`, group);
@@ -302,13 +296,19 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
302
296
  if (input.operation === "resume") {
303
297
  const prior = host.getRun(requiredString(input.run_id, "run_id"));
304
298
  assertOwns(store, callerSessionId, active, prior);
305
- const callbackSessionId = input.callback === "none" ? null : callerSessionId;
299
+ // The resumed run is a new run, so it carries its own callback options,
300
+ // under the same rule as `run`: a subagent may not redirect them.
301
+ if (active && input.callback_session_id !== undefined)
302
+ throw new Error(SUBAGENT_REDIRECT);
303
+ const callbackMode = input.callback === "steer" ? "steer" : "followUp";
304
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
306
305
  const run = host.resume(prior.id, requiredString(input.message, "message"), {
307
306
  invokedBySessionId: callerSessionId,
308
307
  callbackSessionId,
308
+ callbackMode,
309
309
  background: true,
310
310
  });
311
- return receipt(summarize(run, null), callbackSessionId, "followUp", callerSessionId);
311
+ return receipt(summarize(run, null), callbackSessionId, callbackMode, callerSessionId);
312
312
  }
313
313
  if (input.operation === "contact") {
314
314
  if (!active)
@@ -327,28 +327,22 @@ export async function handleTaskTool(host, definitions, store, messages, raw, ca
327
327
  }
328
328
  /** A single run's draft: the top-level shorthand (`prompt` …) or `task`, never both. */
329
329
  function inlineDraft(input) {
330
- const { prompt, cwd, launch, name } = input;
330
+ const { prompt, cwd, launch, name, timeoutSeconds } = input;
331
331
  if (prompt === undefined)
332
332
  return record(input.task) ?? undefined;
333
333
  if (input.task !== undefined)
334
334
  throw new Error("use either prompt or task");
335
- return { prompt, cwd, launch, name };
335
+ return { prompt, cwd, launch, name, timeoutSeconds };
336
336
  }
337
- /** The prompt's first line, unmarked and cut short: a label for the Console,
338
- * not an identifier — the run's id is what anything addresses. */
337
+ /** A label for the Console, not an identifier. */
339
338
  function nameFromPrompt(prompt) {
340
339
  const line = prompt.split("\n")
341
340
  .map((l) => l.replace(/^[\s#>*-]+/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim())
342
341
  .find(Boolean) ?? "subagent";
343
342
  return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
344
343
  }
345
- /**
346
- * The shape a draft is validated in, from the shapes a caller may write it in.
347
- * A `prompt` shorthand becomes a fresh Agent action; a fresh session's cwd
348
- * resolves against the caller's own directory (and is that directory when
349
- * omitted); a missing name is the prompt's first line. Everything the caller
350
- * did spell out passes through untouched — parseDraft still judges it.
351
- */
344
+ /** A `prompt` shorthand becomes a fresh Agent action in the caller's own
345
+ * directory; everything the caller did spell out passes through to parseDraft. */
352
346
  async function expandDraft(definitions, raw, callerSessionId) {
353
347
  let draft = record(raw);
354
348
  if (!draft)
@@ -371,8 +365,8 @@ async function expandDraft(definitions, raw, callerSessionId) {
371
365
  draft = { ...draft, name: nameFromPrompt(action.prompt) };
372
366
  return draft;
373
367
  }
374
- /** Inline one-shot subagent: persisted like any task (kind "subagent",
375
- * filtered from default lists) so runs stay auditable and resumable. */
368
+ /** Persisted like any task (kind "subagent", filtered from default lists) so
369
+ * runs stay auditable and resumable. */
376
370
  async function resolveDraft(definitions, raw, active, callerSessionId) {
377
371
  const draft = record(await expandDraft(definitions, raw, callerSessionId));
378
372
  if (!draft)
@@ -380,6 +374,11 @@ async function resolveDraft(definitions, raw, active, callerSessionId) {
380
374
  if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
381
375
  throw new Error("inline subagent tasks must use a manual trigger");
382
376
  }
377
+ // Delivery of a one-off run is the top-level fields' business; a nested
378
+ // callback only means anything on a stored definition's schedule.
379
+ if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
380
+ throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
381
+ }
383
382
  if (active) {
384
383
  const action = record(draft.action);
385
384
  if (action?.type !== "agent")