@timqi/pier 0.0.17 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/agent/config-sync.js +123 -0
  2. package/dist/agent/config.js +95 -3
  3. package/dist/agent/events.js +26 -5
  4. package/dist/agent/listing.js +12 -13
  5. package/dist/agent/pi.js +75 -27
  6. package/dist/boards/boards.js +7 -6
  7. package/dist/channels/attach.js +18 -4
  8. package/dist/channels/config.js +7 -2
  9. package/dist/channels/dedup.js +9 -1
  10. package/dist/channels/lark.js +2 -6
  11. package/dist/channels/receipts.js +21 -6
  12. package/dist/channels/slack-tool.js +86 -25
  13. package/dist/channels/slack.js +2 -10
  14. package/dist/channels/telegram.js +1 -0
  15. package/dist/config-sync-fetch.js +97 -0
  16. package/dist/config-sync-task.js +59 -0
  17. package/dist/config-sync.js +179 -0
  18. package/dist/core/hub.js +23 -6
  19. package/dist/core/inbox.js +1 -1
  20. package/dist/core/reply.js +76 -2
  21. package/dist/core/router.js +162 -37
  22. package/dist/db.js +50 -0
  23. package/dist/main.js +32 -1
  24. package/dist/secrets.js +18 -6
  25. package/dist/tasks/agent.js +25 -23
  26. package/dist/tasks/callbacks.js +1 -1
  27. package/dist/tasks/definitions.js +28 -17
  28. package/dist/tasks/execution.js +11 -3
  29. package/dist/tasks/messages.js +58 -16
  30. package/dist/tasks/routes.js +58 -10
  31. package/dist/tasks/runs.js +0 -5
  32. package/dist/tasks/service.js +14 -4
  33. package/dist/tasks/store.js +87 -10
  34. package/dist/tasks/tool.js +8 -4
  35. package/dist/tools.js +32 -2
  36. package/dist/web/auth.js +17 -23
  37. package/dist/web/config-sync.js +91 -0
  38. package/dist/web/config.js +61 -0
  39. package/dist/web/explorer.js +20 -70
  40. package/dist/web/fs.js +159 -0
  41. package/dist/web/instance.js +11 -0
  42. package/dist/web/public/assets/activity-DYo7QS7k.js +5 -0
  43. package/dist/web/public/assets/activity-DYo7QS7k.js.br +0 -0
  44. package/dist/web/public/assets/activity-DYo7QS7k.js.gz +0 -0
  45. package/dist/web/public/assets/boards-CwohTbtv.js +1 -0
  46. package/dist/web/public/assets/boards-CwohTbtv.js.br +0 -0
  47. package/dist/web/public/assets/boards-CwohTbtv.js.gz +0 -0
  48. package/dist/web/public/assets/code-p-Tq1bMK.js +1 -0
  49. package/dist/web/public/assets/explorer-50no_DrD.js +5 -0
  50. package/dist/web/public/assets/explorer-50no_DrD.js.br +0 -0
  51. package/dist/web/public/assets/explorer-50no_DrD.js.gz +0 -0
  52. package/dist/web/public/assets/{ghostty-web-CV7F9_iW.js → ghostty-web-Bvhh3ZFk.js} +1 -1
  53. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.br +0 -0
  54. package/dist/web/public/assets/ghostty-web-Bvhh3ZFk.js.gz +0 -0
  55. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  56. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  57. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  58. package/dist/web/public/assets/{index-CtR-Ob4T.css → index-DuZ2vERd.css} +1 -1
  59. package/dist/web/public/assets/index-DuZ2vERd.css.br +0 -0
  60. package/dist/web/public/assets/index-DuZ2vERd.css.gz +0 -0
  61. package/dist/web/public/assets/index-WrsNstrb.js +79 -0
  62. package/dist/web/public/assets/index-WrsNstrb.js.br +0 -0
  63. package/dist/web/public/assets/index-WrsNstrb.js.gz +0 -0
  64. package/dist/web/public/assets/runs-BAXZF-6r.js +1 -0
  65. package/dist/web/public/assets/runs-BAXZF-6r.js.br +0 -0
  66. package/dist/web/public/assets/runs-BAXZF-6r.js.gz +0 -0
  67. package/dist/web/public/assets/settings-BmNXgRne.js +6 -0
  68. package/dist/web/public/assets/settings-BmNXgRne.js.br +0 -0
  69. package/dist/web/public/assets/settings-BmNXgRne.js.gz +0 -0
  70. package/dist/web/public/assets/task-runs-CpgHxFLf.js +3 -0
  71. package/dist/web/public/assets/task-runs-CpgHxFLf.js.br +0 -0
  72. package/dist/web/public/assets/task-runs-CpgHxFLf.js.gz +0 -0
  73. package/dist/web/public/assets/tasks-DybtogPF.js +4 -0
  74. package/dist/web/public/assets/tasks-DybtogPF.js.br +0 -0
  75. package/dist/web/public/assets/tasks-DybtogPF.js.gz +0 -0
  76. package/dist/web/public/assets/terminal-m2_WYu5W.js +2 -0
  77. package/dist/web/public/assets/terminal-m2_WYu5W.js.br +0 -0
  78. package/dist/web/public/assets/terminal-m2_WYu5W.js.gz +0 -0
  79. package/dist/web/public/icon.svg.br +0 -0
  80. package/dist/web/public/icon.svg.gz +0 -0
  81. package/dist/web/public/index.html +13 -2
  82. package/dist/web/public/index.html.br +0 -0
  83. package/dist/web/public/index.html.gz +0 -0
  84. package/dist/web/public/manifest.webmanifest.br +0 -0
  85. package/dist/web/public/manifest.webmanifest.gz +0 -0
  86. package/dist/web/public/sw.js.br +0 -0
  87. package/dist/web/public/sw.js.gz +0 -0
  88. package/dist/web/route.js +17 -0
  89. package/dist/web/server.js +152 -66
  90. package/dist/web/terminal.js +2 -7
  91. package/package.json +2 -2
  92. package/skills/pier-slack/SKILL.md +4 -16
  93. package/skills/pier-tasks/SKILL.md +14 -9
  94. package/dist/web/files.js +0 -183
  95. package/dist/web/public/assets/index-DAxKtidH.js +0 -93
package/dist/main.js CHANGED
@@ -4,6 +4,9 @@ import { fileURLToPath } from "node:url";
4
4
  import { serve } from "@hono/node-server";
5
5
  import { Hono } from "hono";
6
6
  import { PiConfigStore } from "./agent/config.js";
7
+ import { normalizeAgentSnapshot } from "./agent/config-sync.js";
8
+ import { ConfigSync } from "./config-sync.js";
9
+ import { configSyncTask } from "./config-sync-task.js";
7
10
  import { CredentialStore } from "./agent/credentials.js";
8
11
  import { PiAgentFactory } from "./agent/pi.js";
9
12
  import { defaultBoardsDir, registerBoardRoutes } from "./boards/boards.js";
@@ -35,6 +38,7 @@ import { startUpdate, unitPath, updaterProblem } from "./service.js";
35
38
  import { SettingsStore } from "./settings.js";
36
39
  import { startAutoUpdate, UpdateCheck } from "./update.js";
37
40
  import { AuthStore, registerAuthRoutes, requireAuth } from "./web/auth.js";
41
+ import { registerConfigShareRoute, registerConfigSyncRoutes } from "./web/config-sync.js";
38
42
  import { PushStore, registerPushRoutes } from "./web/push.js";
39
43
  import { SessionStateStore } from "./web/session-state.js";
40
44
  import { createServer } from "./web/server.js";
@@ -86,7 +90,12 @@ let channelStore;
86
90
  // Shared by the adapter and the tool: a display name is looked up once per
87
91
  // process, not once per message and again per transcript.
88
92
  const slackDirectory = new SlackDirectory((m) => logger("slack").warn(m));
93
+ let readyForConfigReload = false;
89
94
  const piConfig = new PiConfigStore();
95
+ const configSync = new ConfigSync({
96
+ db, settings, config: piConfig, normalizeAgent: normalizeAgentSnapshot,
97
+ reload: () => readyForConfigReload ? reloadInstance() : Promise.resolve(),
98
+ });
90
99
  const factory = new PiAgentFactory([
91
100
  taskToolSpec((params, callerSessionId) => tasks.tool(params, callerSessionId)),
92
101
  slackToolSpec((params, callerSessionId) => handleSlackTool({
@@ -143,8 +152,9 @@ const router = new Router(hub, (key) => {
143
152
  const stopEviction = router.startIdleEviction();
144
153
  tasks = new TaskService(new TaskStore(db), factory, router, hub, {
145
154
  modelMenu: () => settings.get().modelMenu,
155
+ systemActions: { "config-sync": (signal) => configSync.sync(signal) },
146
156
  });
147
- tasks.start();
157
+ const configurationSync = configSyncTask(tasks, configSync);
148
158
  // The managed CLI tools (src/tools.ts), and the daily task that keeps them
149
159
  // current (src/tools-task.ts) — an ordinary bash task on an ordinary cron,
150
160
  // wired here because tools.ts may not import tasks/.
@@ -181,6 +191,19 @@ const reloadInstance = async (includeWatched = false) => {
181
191
  await channels.reload();
182
192
  return router.evictIdle(0, Date.now(), { includeWatched });
183
193
  };
194
+ // Bound the startup check before sessions and scheduled work can start.
195
+ // Remote outages keep the last local configuration available.
196
+ if (configSync.status().enabled) {
197
+ try {
198
+ await configSync.sync();
199
+ }
200
+ catch {
201
+ log.error("Startup configuration sync failed; using the last local configuration");
202
+ }
203
+ }
204
+ await configurationSync.reconcile();
205
+ tasks.start();
206
+ readyForConfigReload = true;
184
207
  void secrets.unlock().then(startChannels, (err) => log.error("secrets locked — channels not started; unlock from Console → Settings → Security, or repair master.key", err));
185
208
  // Replacing Pier is systemd's job, not this process's: the oneshot unit stops
186
209
  // the service, snapshots the database, installs and starts it again. Without
@@ -289,8 +312,15 @@ app.onError((err, c) => {
289
312
  // so a surface added later is covered without knowing this exists. Built
290
313
  // before the listener: a first run generates and prints its password here.
291
314
  const auth = new AuthStore(db);
315
+ registerConfigShareRoute(app, configSync);
292
316
  app.use("*", requireAuth(auth));
293
317
  registerAuthRoutes(app, auth);
318
+ registerConfigSyncRoutes(app, {
319
+ sync: configSync,
320
+ status: () => ({ ...configurationSync.status(), publicUrl: settings.get().publicUrl }),
321
+ reconcile: configurationSync.reconcile,
322
+ run: configurationSync.run,
323
+ });
294
324
  registerTaskRoutes(app, tasks, { factory, router });
295
325
  registerChannelRoutes(app, channelStore, channels);
296
326
  registerBoardRoutes(app);
@@ -345,6 +375,7 @@ app.route("/", createServer({
345
375
  onUnlocked: () => void startChannels(),
346
376
  reload: () => reloadInstance(true),
347
377
  backgroundRuns: (id) => tasks.backgroundRuns(id),
378
+ activeBackgroundRunCounts: () => tasks.activeBackgroundRunCounts(),
348
379
  channelOf: (id) => conversations.channelOf(id),
349
380
  }));
350
381
  const port = Number(process.env.PORT ?? 3141);
package/dist/secrets.js CHANGED
@@ -119,6 +119,15 @@ export class Secrets {
119
119
  this.#file = next;
120
120
  log.info(`KEK rotated (${mode} mode, dek ${file.dekId} unchanged)`);
121
121
  }
122
+ /**
123
+ * What vt says about itself. A refused or impossible unlock is almost never
124
+ * Pier's fault — missing binary, no agent listening, config pointing
125
+ * elsewhere — and `lockedReason` only carries the last error. Read-only, so
126
+ * it is safe to run while locked; vt's own report is the repair instruction.
127
+ */
128
+ doctor() {
129
+ return this.vt.doctor();
130
+ }
122
131
  /** First boot: random KEK and DEK, file mode. */
123
132
  #create() {
124
133
  const kek = randomBytes(KEY_BYTES);
@@ -172,20 +181,23 @@ export const vtCli = {
172
181
  throw new Error("vt create printed no vt:// record");
173
182
  return record;
174
183
  },
184
+ // Bounded: doctor probes an agent socket and a worker over the network, and
185
+ // a hung probe would leave the Console waiting on a diagnosis forever.
186
+ doctor: () => run("vt", ["doctor"], undefined, 15_000),
175
187
  };
176
- function run(cmd, args, stdin) {
188
+ function run(cmd, args, stdin, timeoutMs) {
177
189
  return new Promise((resolvePromise, reject) => {
178
- const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"] });
190
+ const child = spawn(cmd, args, { stdio: ["pipe", "pipe", "pipe"], timeout: timeoutMs });
179
191
  let out = "";
180
192
  let err = "";
181
193
  child.stdout.on("data", (d) => (out += d.toString()));
182
194
  child.stderr.on("data", (d) => (err += d.toString()));
183
195
  child.on("error", reject);
184
- child.on("close", (code) => {
196
+ child.on("close", (code, signal) => {
185
197
  if (code === 0)
186
- resolvePromise(out.trim());
187
- else
188
- reject(new Error(`${cmd} ${args[0]} exited ${code}: ${err.trim() || out.trim()}`));
198
+ return resolvePromise(out.trim());
199
+ const how = signal ? `timed out after ${timeoutMs}ms (${signal})` : `exited ${code}`;
200
+ reject(new Error(`${cmd} ${args[0]} ${how}: ${err.trim() || out.trim()}`));
189
201
  });
190
202
  child.stdin.on("error", reject); // EPIPE if vt exits before reading
191
203
  if (stdin !== undefined)
@@ -1,4 +1,4 @@
1
- // A run that *is* a Pi session: which session it opens (reuse, fresh, fork),
1
+ // A run that *is* a Pi session: which session it opens (fresh or reused),
2
2
  // what the child is told before the prompt, and how many may run at once. The
3
3
  // concurrency caps are here rather than in execution.ts because they bound
4
4
  // agents specifically — a bash run costs a process, an agent run costs a
@@ -52,7 +52,7 @@ export class AgentTaskRunner {
52
52
  await this.waitUntilIdle(session, signal);
53
53
  // Task requests come seconds apart — the 1h Anthropic cache-write premium
54
54
  // never earns its 2× back, so task runs use the 5m TTL. Set here, not in
55
- // resolveSession: this covers create, fork, reuse and resume alike, on
55
+ // resolveSession: this covers create, reuse and resume alike, on
56
56
  // every attempt — and only after the session is idle, so a reused
57
57
  // interactive session's in-flight turn keeps its 1h writes.
58
58
  session.setCacheRetention("short");
@@ -74,9 +74,17 @@ export class AgentTaskRunner {
74
74
  run.context.renderedPrompt = prompt;
75
75
  this.store.saveRun(run);
76
76
  let text = "";
77
+ // Not only what the turn said but how it ended: a provider outage ends
78
+ // it with an empty reply, and a run that settles on that reports "no
79
+ // reply" — the one wording for a turn that *chose* to say nothing, so
80
+ // the outage arrives at the caller looking like an answer (§5b). Pi has
81
+ // already retried by the time this lands; the run only reports.
82
+ let failure;
77
83
  const unsubscribe = session.subscribe((event) => {
78
- if (event.type === "turn-end")
84
+ if (event.type === "turn-end") {
79
85
  text = event.text;
86
+ failure = event.error;
87
+ }
80
88
  });
81
89
  // The race below also settles this attempt if Pi ignores the abort:
82
90
  // a hung turn must not hold one of the 4 slots (and its waiters)
@@ -106,6 +114,11 @@ export class AgentTaskRunner {
106
114
  await Promise.race([turn, abortedTurn]);
107
115
  if (signal.aborted)
108
116
  throw new Error("cancelled");
117
+ // Before the fallback below: on a reused session that reads the
118
+ // *previous* turn's answer back out of history and reports it as
119
+ // this run's result.
120
+ if (failure)
121
+ throw new Error(failure);
109
122
  if (!text) {
110
123
  const history = await session.history();
111
124
  text = [...history].reverse().find((turn) => turn.role === "assistant")?.text ?? "";
@@ -136,25 +149,16 @@ export class AgentTaskRunner {
136
149
  if (run.targetSessionId) {
137
150
  return this.router.ensure({ channelId: "task", conversationId: run.targetSessionId });
138
151
  }
139
- const source = run.sourceSessionId
140
- ? await this.factory.find(run.sourceSessionId)
141
- : undefined;
142
152
  const policy = action.session;
143
- let cwd;
144
- if (run.sessionMode === "fork") {
145
- if (!run.sourceSessionId)
146
- throw new Error("fork requires a source session");
147
- cwd = policy.mode === "fork" && policy.cwd ? policy.cwd : source?.cwd ?? "";
148
- }
149
- else if (policy.mode === "fresh") {
150
- cwd = policy.cwd;
151
- }
152
- else if (policy.mode === "reuse") {
153
- cwd = (await this.factory.find(policy.sessionId))?.cwd ?? "";
154
- }
155
- else {
156
- cwd = policy.cwd ?? source?.cwd ?? "";
153
+ // Definitions stored before fork was removed still say `"fork"`. Refused by
154
+ // name: every directory this could pick instead is a guess at what the
155
+ // author meant, and a child in the wrong tree edits real files.
156
+ if (run.sessionMode === "fork" || policy.mode === "fork") {
157
+ throw new Error(`task "${run.context.definition.name}" uses the removed fork session mode; recreate it with {"mode":"fresh","cwd":"/abs/path"}`);
157
158
  }
159
+ const cwd = policy.mode === "fresh"
160
+ ? policy.cwd
161
+ : (await this.factory.find(policy.sessionId))?.cwd ?? "";
158
162
  if (!cwd)
159
163
  throw new Error("could not resolve child working directory");
160
164
  const opts = {
@@ -166,9 +170,7 @@ export class AgentTaskRunner {
166
170
  (run.sourceSessionId ? this.router.modelOf(run.sourceSessionId) : undefined),
167
171
  thinking: action.launch?.thinking,
168
172
  };
169
- const session = run.sessionMode === "fork"
170
- ? await this.factory.fork(run.sourceSessionId, opts)
171
- : await this.factory.create(opts);
173
+ const session = await this.factory.create(opts);
172
174
  run.targetSessionId = session.id;
173
175
  run.context.sessionId = session.id;
174
176
  run.context.cwd = cwd;
@@ -19,7 +19,7 @@ export const runSource = (run) => ({
19
19
  });
20
20
  export function runResultText(run) {
21
21
  let result = run.error ?? "No result";
22
- if (run.result?.type === "agent")
22
+ if (run.result?.type === "agent" || run.result?.type === "system")
23
23
  result = run.result.text;
24
24
  if (run.result?.type === "bash")
25
25
  result = run.result.stdout || run.result.stderr || `exit ${String(run.result.exitCode)}`;
@@ -127,11 +127,13 @@ export class TaskDefinitions {
127
127
  factory;
128
128
  router;
129
129
  hub;
130
- constructor(store, factory, router, hub) {
130
+ systemActions;
131
+ constructor(store, factory, router, hub, systemActions = {}) {
131
132
  this.store = store;
132
133
  this.factory = factory;
133
134
  this.router = router;
134
135
  this.hub = hub;
136
+ this.systemActions = systemActions;
135
137
  }
136
138
  list() { return this.store.listTasks(); }
137
139
  get(id) {
@@ -145,7 +147,7 @@ export class TaskDefinitions {
145
147
  // means manual. Update keeps requiring it: replacing a cron task with a
146
148
  // draft that forgot its trigger must not silently unschedule it.
147
149
  const value = record(raw);
148
- const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw);
150
+ const draft = await this.parseDraft(value && value.trigger === undefined ? { ...value, trigger: { type: "manual" } } : raw, creator);
149
151
  const now = Date.now();
150
152
  const task = {
151
153
  id: newId(),
@@ -176,7 +178,7 @@ export class TaskDefinitions {
176
178
  this.assertOwner(old, by, "edited");
177
179
  if (old.archived)
178
180
  throw new Error("archived tasks cannot be edited");
179
- const draft = await this.parseDraft(raw);
181
+ const draft = await this.parseDraft(raw, ownerOf(old) ? by : undefined);
180
182
  const now = Date.now();
181
183
  const task = {
182
184
  ...old,
@@ -228,7 +230,9 @@ export class TaskDefinitions {
228
230
  }
229
231
  claimDue(now) {
230
232
  const due = [];
231
- for (const task of this.store.listTasks()) {
233
+ // The store narrows to the tasks with a next run at or before `now`; the
234
+ // JSON is still the record, so its own fields decide.
235
+ for (const task of this.store.listDueTasks(now)) {
232
236
  if (!task.enabled || task.archived || task.nextRunAt === null || task.nextRunAt > now)
233
237
  continue;
234
238
  task.nextRunAt = nextRunAt(task.trigger, now);
@@ -250,7 +254,17 @@ export class TaskDefinitions {
250
254
  throw new Error(`"${task.name}" is Pier's own ${owner} task: it is reconciled by Pier, not ${what}`);
251
255
  }
252
256
  }
253
- async parseDraft(raw) {
257
+ /** Checked again at execution: persisted definitions can outlive registration. */
258
+ systemAction(name, owner) {
259
+ if (!owner || owner === "http" || owner.startsWith("session:") || owner !== name) {
260
+ throw new Error(`system action "${name}" requires its trusted owner`);
261
+ }
262
+ if (!Object.hasOwn(this.systemActions, name) || typeof this.systemActions[name] !== "function") {
263
+ throw new Error(`unregistered system action: ${name}`);
264
+ }
265
+ return this.systemActions[name];
266
+ }
267
+ async parseDraft(raw, owner) {
254
268
  const value = record(raw);
255
269
  if (!value)
256
270
  throw new Error("task definition required");
@@ -277,8 +291,13 @@ export class TaskDefinitions {
277
291
  this.get(taskId);
278
292
  action = { type: "task", taskId };
279
293
  }
294
+ else if (actionRaw.type === "system") {
295
+ const name = requiredString(actionRaw.name, "system action name");
296
+ this.systemAction(name, owner);
297
+ action = { type: "system", name };
298
+ }
280
299
  else if (actionRaw.type === "agent") {
281
- action = await this.parseAgentAction(actionRaw, trigger);
300
+ action = await this.parseAgentAction(actionRaw);
282
301
  }
283
302
  else
284
303
  throw new Error("unknown action type");
@@ -292,7 +311,7 @@ export class TaskDefinitions {
292
311
  timeoutSeconds,
293
312
  };
294
313
  }
295
- async parseAgentAction(raw, trigger) {
314
+ async parseAgentAction(raw) {
296
315
  const prompt = requiredString(raw.prompt, "agent prompt");
297
316
  const launch = parseLaunch(raw.launch);
298
317
  const input = record(raw.session);
@@ -308,21 +327,13 @@ export class TaskDefinitions {
308
327
  await this.assertDirectory(cwd);
309
328
  session = { mode: "fresh", cwd };
310
329
  }
311
- else if (input?.mode === "fork") {
312
- if (trigger.type !== "manual")
313
- throw new Error("fork Agent tasks must use a manual trigger");
314
- const cwd = typeof input.cwd === "string" && input.cwd.trim() ? input.cwd.trim() : undefined;
315
- if (cwd)
316
- await this.assertDirectory(cwd);
317
- session = { mode: "fork", ...(cwd ? { cwd } : {}) };
318
- }
319
330
  else {
320
331
  // Validation never mutates: a dedicated session is created explicitly
321
332
  // (POST /api/sessions) and then referenced with mode:"reuse".
322
- throw new Error('agent session policy required, e.g. {"mode":"fresh","cwd":"/abs/path"} or {"mode":"fork"} or {"mode":"reuse","sessionId":"..."}');
333
+ throw new Error('agent session policy required, e.g. {"mode":"fresh","cwd":"/abs/path"} or {"mode":"reuse","sessionId":"..."}');
323
334
  }
324
335
  if (session.mode === "reuse" && launch)
325
- throw new Error("launch policy only applies to fresh or fork sessions");
336
+ throw new Error("launch policy only applies to fresh sessions");
326
337
  return { type: "agent", session, prompt, ...(launch ? { launch } : {}) };
327
338
  }
328
339
  async parseCallback(raw) {
@@ -1,7 +1,7 @@
1
1
  // One queued run carried to a result: dispatched by action kind (a bash
2
- // script, an agent session, another task), abortable while it goes, settled
3
- // exactly once. What the two action kinds actually do lives in command.ts and
4
- // agent.ts; this file owns only the lifecycle they share.
2
+ // script, an agent session, another task or an owned system action), abortable
3
+ // while it goes, settled exactly once. Command and agent work lives in
4
+ // command.ts and agent.ts; this file owns the lifecycle they share.
5
5
  import { logger } from "../log.js";
6
6
  import { AgentTaskRunner } from "./agent.js";
7
7
  import { TaskCallbacks } from "./callbacks.js";
@@ -125,6 +125,14 @@ export class TaskExecution {
125
125
  }
126
126
  return output;
127
127
  }
128
+ if (action.type === "system") {
129
+ this.markRunning(run);
130
+ const handler = this.definitions.systemAction(action.name, run.context.definition.creator);
131
+ signal.throwIfAborted();
132
+ const text = await handler(signal);
133
+ signal.throwIfAborted();
134
+ return { type: "system", text };
135
+ }
128
136
  if (action.type === "task") {
129
137
  this.markRunning(run);
130
138
  const child = this.host.runChild(action.taskId, run);
@@ -84,8 +84,7 @@ export class TaskMessenger {
84
84
  * `inject` dedupes on the recipient transcript, so a retry cannot double
85
85
  * deliver. Controls aimed at a finished run are dead and expire here. */
86
86
  retryUndelivered(now = Date.now()) {
87
- for (const message of this.store.listUndeliveredMessages()) {
88
- const run = this.store.getRun(message.runId);
87
+ for (const { message, run } of this.store.listUndeliveredMessages()) {
89
88
  if (!run)
90
89
  continue;
91
90
  // Expiring a dead control is not a retry, so it ignores the backoff.
@@ -96,6 +95,30 @@ export class TaskMessenger {
96
95
  this.changed(message);
97
96
  continue;
98
97
  }
98
+ // A reply that resumed a terminal run was never a system input: its text
99
+ // is the continuation's prompt, so the transcript read below would find
100
+ // nothing and re-inject a second copy, starting a turn no run owns. The
101
+ // continuation is the proof instead. `startedAt` is written one statement
102
+ // before the prompt reaches the session (agent.ts), so `delivered` here
103
+ // means "handed to the run that reports for it" — a `systemInput` that
104
+ // then throws fails *that* run, and the failure reaches the replier
105
+ // through its callback.
106
+ if (message.kind === "reply" && isTerminal(run.state)) {
107
+ const resumed = message.resumeRunId ? this.store.getRun(message.resumeRunId) : undefined;
108
+ if (resumed?.startedAt)
109
+ this.confirmed(message.id);
110
+ else if (!resumed)
111
+ this.abandon(message, `continuation ${message.resumeRunId ?? "(none)"} is gone`);
112
+ else if (isTerminal(resumed.state)) {
113
+ // Never started, and never will: say so rather than wait for a
114
+ // ceiling that would only report the same thing four minutes later.
115
+ // Both ends hear it here; the supervisor may also get the
116
+ // continuation's own cancelled callback, and a duplicate beats a
117
+ // special case that could suppress the only report either gets.
118
+ this.abandon(message, `continuation ${resumed.id} ${resumed.state} before it started`);
119
+ }
120
+ continue;
121
+ }
99
122
  if ((message.nextAttemptAt ?? 0) > now)
100
123
  continue;
101
124
  const target = message.toSessionId || run.targetSessionId;
@@ -149,9 +172,11 @@ export class TaskMessenger {
149
172
  this.deliver(reply, run, run.targetSessionId);
150
173
  }
151
174
  else if (isTerminal(run.state)) {
152
- this.resumeRun(run.id, this.format(reply, run), fromSessionId);
153
- reply.state = "delivered";
154
- reply.deliveredAt = Date.now();
175
+ // Creating the continuation is not delivering the reply: the run is
176
+ // queued, and a restart or a cancel before it starts loses the prompt
177
+ // that carries the text. So the id is recorded and the sweep below
178
+ // settles it against the continuation's own `startedAt`.
179
+ reply.resumeRunId = this.resumeRun(run.id, this.format(reply, run), fromSessionId).id;
155
180
  this.store.saveMessage(reply);
156
181
  this.changed(reply);
157
182
  }
@@ -209,6 +234,17 @@ export class TaskMessenger {
209
234
  this.store.saveMessage(message);
210
235
  this.changed(message);
211
236
  }
237
+ /** Waiting is not a failed attempt: the message is in flight or the turn it
238
+ * has to wait for is still running, so it is tried again shortly and the
239
+ * ceiling is left for the deliveries that actually failed (the rule
240
+ * outbox.ts keeps for callbacks). */
241
+ defer(id) {
242
+ const message = this.store.getMessage(id);
243
+ if (!message || message.state !== "pending")
244
+ return;
245
+ message.nextAttemptAt = Date.now() + 1000;
246
+ this.store.saveMessage(message);
247
+ }
212
248
  /** Counts one hand-off and says whether it may happen: a recipient that
213
249
  * never records the message must not be re-sent once a second forever.
214
250
  * False means the ceiling was reached and the message is now expired. */
@@ -248,10 +284,12 @@ export class TaskMessenger {
248
284
  this.store.saveRun(run);
249
285
  }
250
286
  }
251
- /** A decision steers a follow-up would land only after the supervisor runs
252
- * out of tool calls, leaving the child waiting out the whole turn. */
287
+ /** Steer whatever someone is blocked on: a decision, and the reply that
288
+ * answers it a follow-up lands only once the recipient runs out of tool
289
+ * calls, so each would wait out a whole turn (the reply waited 3 minutes
290
+ * behind one on 2026-09-07). Progress is a follow-up because nobody waits. */
253
291
  mode(message) {
254
- return message.kind === "steer" || message.kind === "decision" ? "steer" : "follow_up";
292
+ return message.kind === "follow_up" || message.kind === "progress" ? "follow_up" : "steer";
255
293
  }
256
294
  failed(id, error, spent) {
257
295
  const message = this.store.getMessage(id);
@@ -286,14 +324,13 @@ export class TaskMessenger {
286
324
  const recorded = async () => (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
287
325
  if (await recorded())
288
326
  return this.confirmed(message.id);
289
- // Accepted and waiting in Pi's queue for the running turn to drain it:
290
- // not recorded yet, and sending again would deliver the same steer twice.
291
- // Unlike a callback this is not deferred a steer's whole point is to
292
- // reach the turn already running so the queue is where it sits, and
293
- // waiting for it to drain costs no attempt.
294
- const queue = await session.pendingQueue();
295
- if ([...queue.steering, ...queue.followUp].some((text) => text.includes(message.id)))
296
- return;
327
+ // Accepted and waiting in the recipient's queue for the running turn to
328
+ // drain it: not recorded yet, and sending again would deliver the same
329
+ // guidance twice. Unlike a callback this is not deferred on a busy
330
+ // target a steer's whole point is to reach the turn already running
331
+ // so the queue is where it sits, and waiting for it costs no attempt.
332
+ if (await this.queued(session, message.id))
333
+ return this.defer(message.id);
297
334
  spent = this.spend(message.id);
298
335
  if (!spent)
299
336
  return;
@@ -305,6 +342,11 @@ export class TaskMessenger {
305
342
  this.failed(message.id, error, spent);
306
343
  }
307
344
  }
345
+ /** In the recipient's queue, by the id its origin carries — the transcript
346
+ * read above answers "landed", this answers "handed over and waiting". */
347
+ async queued(session, id) {
348
+ return (await session.pendingSystemInputs()).some((origin) => origin.kind === "task-message" && origin.messageId === id);
349
+ }
308
350
  format(message, run) {
309
351
  const title = run.context.definition.name;
310
352
  if (message.kind === "progress") {
@@ -3,6 +3,47 @@
3
3
  // its body, names the caller and hands the decision to TaskService — policy
4
4
  // that lives here would be policy the task tool does not get.
5
5
  import { record, requiredString } from "./definitions.js";
6
+ /** Reject malformed filters instead of silently widening a global query. */
7
+ function runQuery(params) {
8
+ const query = {};
9
+ const states = ["queued", "running", "succeeded", "failed", "cancelled", "interrupted", "skipped"];
10
+ const sources = ["manual", "cron", "watch", "agent", "task"];
11
+ if (params.state) {
12
+ if (!states.includes(params.state))
13
+ throw new Error("invalid state");
14
+ query.state = params.state;
15
+ }
16
+ if (params.source) {
17
+ if (!sources.includes(params.source))
18
+ throw new Error("invalid source");
19
+ query.source = params.source;
20
+ }
21
+ if (params.taskId)
22
+ query.taskId = params.taskId;
23
+ for (const key of ["since", "until", "limit"]) {
24
+ if (params[key] === undefined)
25
+ continue;
26
+ const value = Number(params[key]);
27
+ if (!params[key] || !Number.isSafeInteger(value) || value < 0 || (key === "limit" && (value < 1 || value > 200)))
28
+ throw new Error(`invalid ${key}`);
29
+ query[key] = value;
30
+ }
31
+ if (query.since !== undefined && query.until !== undefined && query.since > query.until)
32
+ throw new Error("since exceeds until");
33
+ if (params.showUnmatched !== undefined) {
34
+ if (!["true", "false"].includes(params.showUnmatched))
35
+ throw new Error("invalid showUnmatched");
36
+ query.showUnmatched = params.showUnmatched === "true";
37
+ }
38
+ if (params.cursor) {
39
+ const cursor = JSON.parse(params.cursor);
40
+ const value = record(cursor);
41
+ if (!value || !Number.isSafeInteger(value.queuedAt) || Number(value.queuedAt) < 0 || typeof value.id !== "string" || !value.id)
42
+ throw new Error("invalid cursor");
43
+ query.cursor = { queuedAt: Number(value.queuedAt), id: value.id };
44
+ }
45
+ return query;
46
+ }
6
47
  const jsonBody = async (req) => req.json().catch(() => null);
7
48
  export function registerTaskRoutes(app, tasks, activity) {
8
49
  if (activity)
@@ -50,13 +91,21 @@ export function registerTaskRoutes(app, tasks, activity) {
50
91
  messages,
51
92
  });
52
93
  });
94
+ app.get("/api/task-runs", (c) => {
95
+ try {
96
+ return c.json(tasks.queryRuns(runQuery(c.req.query())));
97
+ }
98
+ catch (err) {
99
+ return c.json({ error: String(err) }, 400);
100
+ }
101
+ });
53
102
  app.get("/api/tasks", (c) => {
54
103
  const trigger = c.req.query("trigger");
55
104
  const state = c.req.query("state");
56
105
  const kind = c.req.query("kind");
57
106
  let rows = tasks.list();
58
107
  // Subagent one-shots are hidden unless explicitly requested via ?kind=subagent.
59
- rows = kind ? rows.filter((task) => task.kind === kind) : rows.filter((task) => task.kind !== "subagent");
108
+ rows = rows.filter((task) => task.kind === (kind ?? "task"));
60
109
  if (trigger)
61
110
  rows = rows.filter((task) => task.trigger.type === trigger);
62
111
  if (state === "archived")
@@ -93,10 +142,7 @@ export function registerTaskRoutes(app, tasks, activity) {
93
142
  });
94
143
  app.get("/api/task-runs/:id", (c) => {
95
144
  try {
96
- const run = tasks.getRun(c.req.param("id"));
97
- // Same view as the task tool: a pending decision is the one run fact that
98
- // lives on the messages, and HTTP callers need it too.
99
- return c.json({ ...run, pendingDecisionId: tasks.openDecisionId(run.id) });
145
+ return c.json(tasks.getRunView(c.req.param("id")));
100
146
  }
101
147
  catch (err) {
102
148
  return c.json({ error: String(err) }, 404);
@@ -181,13 +227,15 @@ export function registerTaskRoutes(app, tasks, activity) {
181
227
  const body = record(await jsonBody(c.req));
182
228
  const input = body && "input" in body ? body.input : null;
183
229
  try {
184
- const sessionMode = body?.sessionMode === "fresh" || body?.sessionMode === "fork" ? body.sessionMode : undefined;
230
+ // A caller that names a mode gets an answer about it: dropping an
231
+ // unsupported override would run the definition's own policy instead —
232
+ // on a reuse definition, work injected into a live session.
233
+ if (body?.sessionMode !== undefined && body.sessionMode !== "fresh") {
234
+ throw new Error(`unsupported sessionMode: ${String(body.sessionMode)}`);
235
+ }
236
+ const sessionMode = body?.sessionMode;
185
237
  const sourceSessionId = typeof body?.sourceSessionId === "string" && body.sourceSessionId ? body.sourceSessionId : null;
186
238
  const task = tasks.get(c.req.param("id"));
187
- const effectiveMode = task.action.type === "agent" ? sessionMode ?? task.action.session.mode : null;
188
- if (effectiveMode === "fork" && (!sourceSessionId || !(await tasks.sessionExists(sourceSessionId)))) {
189
- throw new Error("fork requires a known sourceSessionId");
190
- }
191
239
  return c.json({ runId: tasks.run(task.id, input, "manual", null, {
192
240
  invokedBySessionId: sourceSessionId,
193
241
  sourceSessionId,
@@ -38,11 +38,6 @@ export class TaskRunQueue {
38
38
  const sessionMode = definition.action.type === "agent"
39
39
  ? provenance.sessionMode ?? definition.action.session.mode
40
40
  : null;
41
- if (sessionMode === "fork" && !sourceSessionId)
42
- throw new Error("fork requires a source session");
43
- if (sessionMode === "fork" && (source === "cron" || source === "watch")) {
44
- throw new Error("scheduled and watch runs cannot fork a caller session");
45
- }
46
41
  const targetSessionId = provenance.targetSessionId ?? (definition.action.type === "agent" && sessionMode === "reuse" && definition.action.session.mode === "reuse"
47
42
  ? definition.action.session.sessionId
48
43
  : null);