@timqi/pier 0.1.14 → 0.2.0

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 (126) hide show
  1. package/README.md +44 -22
  2. package/dist/agent/config.js +2 -2
  3. package/dist/agent/events.js +32 -3
  4. package/dist/agent/packages.js +6 -10
  5. package/dist/agent/pi.js +111 -68
  6. package/dist/agent/roles.js +103 -0
  7. package/dist/agent/types.js +78 -0
  8. package/dist/boards/boards.js +42 -32
  9. package/dist/channels/chains.js +0 -4
  10. package/dist/channels/commands.js +7 -12
  11. package/dist/channels/config.js +27 -1
  12. package/dist/channels/control.js +5 -16
  13. package/dist/channels/conversations.js +0 -5
  14. package/dist/channels/lark-api.js +20 -26
  15. package/dist/channels/lark-outbound.js +16 -29
  16. package/dist/channels/lark-panel.js +1 -1
  17. package/dist/channels/lark.js +61 -39
  18. package/dist/channels/lines.js +33 -1
  19. package/dist/channels/panel.js +17 -107
  20. package/dist/channels/routes.js +11 -21
  21. package/dist/channels/runtime.js +34 -25
  22. package/dist/channels/slack-outbound.js +6 -15
  23. package/dist/channels/slack-panel.js +1 -1
  24. package/dist/channels/slack-render.js +1 -1
  25. package/dist/channels/slack.js +63 -57
  26. package/dist/channels/types.js +1 -1
  27. package/dist/config-sync.js +42 -37
  28. package/dist/core/chain.js +202 -0
  29. package/dist/core/identity.js +34 -11
  30. package/dist/core/inbound-file.js +4 -3
  31. package/dist/core/reply.js +96 -80
  32. package/dist/core/router.js +111 -118
  33. package/dist/core/types.js +36 -77
  34. package/dist/db.js +33 -2
  35. package/dist/main.js +46 -37
  36. package/dist/settings.js +25 -18
  37. package/dist/tasks/agent.js +11 -5
  38. package/dist/tasks/callbacks.js +55 -5
  39. package/dist/tasks/cli.js +22 -11
  40. package/dist/tasks/definitions.js +30 -17
  41. package/dist/tasks/execution.js +2 -2
  42. package/dist/tasks/groups.js +5 -2
  43. package/dist/tasks/messages.js +7 -7
  44. package/dist/tasks/open-items.js +70 -0
  45. package/dist/tasks/operations.js +76 -39
  46. package/dist/tasks/outbox.js +19 -6
  47. package/dist/tasks/routes.js +19 -249
  48. package/dist/tasks/service.js +145 -34
  49. package/dist/tasks/store.js +119 -56
  50. package/dist/tasks/types.js +5 -0
  51. package/dist/tools.js +2 -2
  52. package/dist/web/auth.js +2 -2
  53. package/dist/web/config-sync.js +4 -5
  54. package/dist/web/config.js +9 -2
  55. package/dist/web/explorer.js +2 -2
  56. package/dist/web/fs.js +16 -1
  57. package/dist/web/instance.js +8 -5
  58. package/dist/web/packages.js +1 -1
  59. package/dist/web/passkeys.js +3 -3
  60. package/dist/web/providers.js +3 -3
  61. package/dist/web/public/assets/code-BZwvPani.js +2 -0
  62. package/dist/web/public/assets/code-BZwvPani.js.br +0 -0
  63. package/dist/web/public/assets/code-BZwvPani.js.gz +0 -0
  64. package/dist/web/public/assets/explorer-DDoVzMLQ.js +5 -0
  65. package/dist/web/public/assets/explorer-DDoVzMLQ.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DDoVzMLQ.js.gz +0 -0
  67. package/dist/web/public/assets/highlight-5Bl8zZ8q.js +72 -0
  68. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.br +0 -0
  69. package/dist/web/public/assets/highlight-5Bl8zZ8q.js.gz +0 -0
  70. package/dist/web/public/assets/{hljs-tWqyD59G.js → hljs-BRz6a8Dq.js} +2 -2
  71. package/dist/web/public/assets/hljs-BRz6a8Dq.js.br +0 -0
  72. package/dist/web/public/assets/hljs-BRz6a8Dq.js.gz +0 -0
  73. package/dist/web/public/assets/index-BpFzCwlo.js +7 -0
  74. package/dist/web/public/assets/index-BpFzCwlo.js.br +0 -0
  75. package/dist/web/public/assets/index-BpFzCwlo.js.gz +0 -0
  76. package/dist/web/public/assets/index-wAGxFFPj.css +2 -0
  77. package/dist/web/public/assets/index-wAGxFFPj.css.br +0 -0
  78. package/dist/web/public/assets/index-wAGxFFPj.css.gz +0 -0
  79. package/dist/web/public/assets/report-zjE8Bt8S.js +8 -0
  80. package/dist/web/public/assets/report-zjE8Bt8S.js.br +0 -0
  81. package/dist/web/public/assets/report-zjE8Bt8S.js.gz +0 -0
  82. package/dist/web/public/assets/settings-Cv7rbeqf.js +9 -0
  83. package/dist/web/public/assets/settings-Cv7rbeqf.js.br +0 -0
  84. package/dist/web/public/assets/settings-Cv7rbeqf.js.gz +0 -0
  85. package/dist/web/public/index.html +40 -109
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/public/manifest.webmanifest +0 -2
  89. package/dist/web/push.js +1 -1
  90. package/dist/web/server.js +91 -116
  91. package/dist/web/session-state.js +5 -40
  92. package/docs/deploy.md +5 -3
  93. package/package.json +2 -1
  94. package/skills/pier-boards/SKILL.md +14 -4
  95. package/skills/pier-help/SKILL.md +57 -23
  96. package/skills/pier-tasks/SKILL.md +58 -18
  97. package/dist/channels/handoff.js +0 -94
  98. package/dist/web/public/assets/activity-Ds6fCHnb.js +0 -5
  99. package/dist/web/public/assets/activity-Ds6fCHnb.js.br +0 -0
  100. package/dist/web/public/assets/activity-Ds6fCHnb.js.gz +0 -0
  101. package/dist/web/public/assets/boards-BKCj6EwK.js +0 -1
  102. package/dist/web/public/assets/boards-BKCj6EwK.js.br +0 -0
  103. package/dist/web/public/assets/boards-BKCj6EwK.js.gz +0 -0
  104. package/dist/web/public/assets/explorer-DnTm975c.js +0 -4
  105. package/dist/web/public/assets/explorer-DnTm975c.js.br +0 -0
  106. package/dist/web/public/assets/explorer-DnTm975c.js.gz +0 -0
  107. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  108. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  109. package/dist/web/public/assets/index-BQo-haPN.js +0 -85
  110. package/dist/web/public/assets/index-BQo-haPN.js.br +0 -0
  111. package/dist/web/public/assets/index-BQo-haPN.js.gz +0 -0
  112. package/dist/web/public/assets/index-DiHj0w1i.css +0 -2
  113. package/dist/web/public/assets/index-DiHj0w1i.css.br +0 -0
  114. package/dist/web/public/assets/index-DiHj0w1i.css.gz +0 -0
  115. package/dist/web/public/assets/runs-C_AthWcW.js +0 -1
  116. package/dist/web/public/assets/runs-C_AthWcW.js.br +0 -0
  117. package/dist/web/public/assets/runs-C_AthWcW.js.gz +0 -0
  118. package/dist/web/public/assets/settings-DDaAtFlc.js +0 -5
  119. package/dist/web/public/assets/settings-DDaAtFlc.js.br +0 -0
  120. package/dist/web/public/assets/settings-DDaAtFlc.js.gz +0 -0
  121. package/dist/web/public/assets/task-runs-0pvdITiV.js +0 -3
  122. package/dist/web/public/assets/task-runs-0pvdITiV.js.br +0 -0
  123. package/dist/web/public/assets/task-runs-0pvdITiV.js.gz +0 -0
  124. package/dist/web/public/assets/tasks-Bz29caHJ.js +0 -4
  125. package/dist/web/public/assets/tasks-Bz29caHJ.js.br +0 -0
  126. package/dist/web/public/assets/tasks-Bz29caHJ.js.gz +0 -0
@@ -23,13 +23,17 @@ export class TaskMessenger {
23
23
  store;
24
24
  hub;
25
25
  unreachable;
26
+ runChanged;
26
27
  outbox;
27
28
  constructor(store, router, hub,
28
29
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
29
- unreachable) {
30
+ unreachable,
31
+ /** A follow-up's run card counts it while it is pending (service.ts). */
32
+ runChanged) {
30
33
  this.store = store;
31
34
  this.hub = hub;
32
35
  this.unreachable = unreachable;
36
+ this.runChanged = runChanged;
33
37
  this.outbox = new Outbox(router, {
34
38
  id: ({ message }) => message.id,
35
39
  reload: (id) => {
@@ -68,12 +72,6 @@ export class TaskMessenger {
68
72
  for (const message of this.store.expirePendingMessages())
69
73
  this.changed(message);
70
74
  }
71
- list(runId) {
72
- return this.store.listMessages(runId);
73
- }
74
- recent(since) {
75
- return this.store.listRecentMessages(since);
76
- }
77
75
  async control(run, fromSessionId, kind, content) {
78
76
  const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content);
79
77
  this.changed(message);
@@ -178,5 +176,7 @@ export class TaskMessenger {
178
176
  /** Publish only after any transaction changing this message has committed. */
179
177
  changed(message) {
180
178
  this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
179
+ if (message.kind === "follow_up")
180
+ this.runChanged(message.runId);
181
181
  }
182
182
  }
@@ -0,0 +1,70 @@
1
+ // The continuous conversation's open items in task-run words: main's markers
2
+ // joined to the run ledger by session, a lead's workers counted by state, and
3
+ // the one text every surface shows (docs/design/10-continuous-session.md#open-items).
4
+ import { openItemMarkers, runStatus, workerCounts } from "../core/reply.js";
5
+ import { LEDGER_WINDOW_MS, TASK_RUN_STATES } from "../core/types.js";
6
+ import { logger } from "../log.js";
7
+ import { NOT_IN_LEDGER } from "./types.js";
8
+ const log = logger("tasks");
9
+ const IN_FLIGHT = new Set(["queued", "running"]);
10
+ /** `members`: the chain, newest first; `designs`: the open designs of sessions not closed. */
11
+ export function openItems(tasks, router, members, designs, now) {
12
+ const since = now - LEDGER_WINDOW_MS;
13
+ const runs = members.length ? tasks.ledger(members, since) : [];
14
+ const withWorkers = (r) => {
15
+ if (!r.targetSessionId || tasks.store.roleOf(r.targetSessionId) !== "lead")
16
+ return r;
17
+ const workers = Object.fromEntries(TASK_RUN_STATES.map((s) => [s, 0]));
18
+ for (const w of tasks.ledger([r.targetSessionId], since)) {
19
+ if (w.state in workers)
20
+ workers[w.state] += 1;
21
+ }
22
+ return { ...r, workers };
23
+ };
24
+ // An item names a session through any of its runs: the session's newest run stands for it.
25
+ const tracked = new Set();
26
+ const items = tasks.store.openItems().map((row) => {
27
+ const named = row.runIds.map((id) => {
28
+ const session = runs.find((r) => r.runId === id)?.targetSessionId ?? tasks.store.getRun(id)?.targetSessionId ?? null;
29
+ tracked.add(session ?? id);
30
+ const run = runs.find((r) => (session ? r.targetSessionId === session : r.runId === id));
31
+ return run
32
+ ? withWorkers(run)
33
+ : { runId: id, name: id, state: NOT_IN_LEDGER, targetSessionId: session, cwd: null, queuedAt: 0, finishedAt: null };
34
+ }).filter((r, i, all) => all.findIndex((o) => o.runId === r.runId) === i);
35
+ const busy = named.some((r) => IN_FLIGHT.has(r.state) || (r.targetSessionId && router.stateOf(r.targetSessionId) === "streaming"));
36
+ return { problem: row.problem, stage: row.stage, runs: named, ...(named.length ? { live: busy ? "running" : "idle" } : {}) };
37
+ });
38
+ const unlisted = runs.filter((r) => IN_FLIGHT.has(r.state) && !tracked.has(r.targetSessionId ?? r.runId)).map(withWorkers);
39
+ return { items, unlisted, designs };
40
+ }
41
+ /** The head's reply's markers, written; answers whether a row changed. */
42
+ export function recordOpenItems(store, text, now) {
43
+ const { markers, dropped } = openItemMarkers(text);
44
+ for (const marker of dropped)
45
+ log.warn(`open items: dropped a marker with no problem text: ${marker}`);
46
+ return markers.length > 0 && store.markOpenItems(markers, now) > 0;
47
+ }
48
+ const workersText = (workers) => workers ? ` · workers: ${workerCounts(workers)}` : "";
49
+ const runText = (r, now) => r.state === NOT_IN_LEDGER
50
+ ? `run ${r.runId} — ${NOT_IN_LEDGER}`
51
+ : `run ${r.runId.length > 8 ? `${r.runId.slice(0, 8)}…` : r.runId} ${runStatus(r, now)}${workersText(r.workers)}`;
52
+ /** The one string every surface shows for the open items: `/status` and the seed. */
53
+ export function renderOpenItems({ items, unlisted, designs }, now) {
54
+ if (!items.length && !unlisted.length && !designs.length)
55
+ return "Nothing open.";
56
+ const open = items.map((i) => `- ${i.problem}${i.stage ? ` — ${i.stage}` : ""}${i.live ? ` (${i.live})` : ""}${i.runs.map((r) => ` · ${runText(r, now)}`).join("")}`);
57
+ const rest = unlisted.map((r) => `- ${r.name} — ${runStatus(r, now)}${workersText(r.workers)}`);
58
+ const decide = designs.map((r) => `- ${r.name} · ${runText(r, now)}`);
59
+ return [
60
+ ...(open.length ? ["Open", ...open] : []),
61
+ ...(rest.length ? ["Not on the list", ...rest] : []),
62
+ ...(decide.length ? ["Designs for you to finalize", ...decide] : []),
63
+ ].join("\n");
64
+ }
65
+ /** `/status`'s card: the text, and run id → session id for every named run and listed design that has one. */
66
+ export function openItemsStatus(open, now) {
67
+ const sessions = Object.fromEntries([...open.items.flatMap((i) => i.runs), ...open.designs]
68
+ .flatMap((r) => (r.targetSessionId ? [[r.runId, r.targetSessionId]] : [])));
69
+ return { text: renderOpenItems(open, now), sessions };
70
+ }
@@ -1,8 +1,9 @@
1
1
  // The `/task` socket route: every `pier task` operation — run, message, save,
2
- // list, cancel, recover — validated here once, answered with the summaries a
3
- // model reads. Scheduling and delivery stay in the service; this file decides
4
- // who may ask for what.
2
+ // list, pause, resume, archive, cancel, recover — validated here once, answered
3
+ // with the summaries a model reads. Scheduling and delivery stay in the
4
+ // service; this file decides who may ask for what.
5
5
  import { isAbsolute, resolve } from "node:path";
6
+ import { isModelTier, LEDGER_WINDOW_MS } from "../core/types.js";
6
7
  import { logger } from "../log.js";
7
8
  import { record, requiredString } from "./definitions.js";
8
9
  import { isTerminal } from "./types.js";
@@ -104,35 +105,67 @@ function cancelledOrCurrent(host, id, ms) {
104
105
  new Promise((resolve) => setTimeout(() => resolve(host.getRun(id)), ms).unref()),
105
106
  ]);
106
107
  }
107
- export async function handleTask(host, definitions, store, raw, callerSessionId) {
108
+ export async function handleTask(host, definitions, store, raw, callerSessionId, chain) {
109
+ // The launching session controls a run, and so does the run's own; every
110
+ // member of the continuous conversation counts as the one that launched it.
111
+ const launchers = () => chain.chainOf(callerSessionId) ?? [callerSessionId];
112
+ const assertOwns = (target) => {
113
+ if (!launchers().includes(target.invokedBySessionId ?? "") && target.targetSessionId !== callerSessionId) {
114
+ throw new Error("session does not own this run");
115
+ }
116
+ };
108
117
  const input = record(raw);
109
118
  if (!input)
110
119
  throw new Error("task parameters required");
111
- // Delegation is one level (docs/design/09-tasks-cli.md §Two levels, no tree):
112
- // what a supervised run launched would report to a session no run owns. A
113
- // queued run has not taken the session's turn, so it gates nothing yet.
120
+ // Depth stays 2 (docs/design/09-tasks-cli.md §Two levels, no tree); a queued
121
+ // run has not taken the session's turn, so it gates nothing yet.
122
+ const role = store.roleOf(callerSessionId);
123
+ const lead = role === "lead";
114
124
  const active = store.findActiveRunForTarget(callerSessionId);
115
- if (active?.state === "running" && store.supervised(active))
125
+ if (active?.state === "running" && store.supervised(active) && !lead)
116
126
  throw new Error("a delegated run cannot delegate; ask in your result and let your supervisor run it");
127
+ if (role === "worker")
128
+ throw new Error("a worker's session never delegates, in a run or after it; say what needs another agent and let the session that launched it run it");
129
+ // Read off a draft before it is filed; a saved definition has the same shape under `action`.
130
+ const notLead = (draft) => {
131
+ const role = record(record(draft)?.launch)?.role ?? record(record(record(draft)?.action)?.launch)?.role;
132
+ if (lead && role === "lead")
133
+ throw new Error("a feature lead cannot launch a lead; a build lead is your supervisor's to launch, from your milestone");
134
+ return draft;
135
+ };
117
136
  const menu = () => host.models().then((listed) => listed.models);
118
- if (input.operation === "list")
119
- return definitions.list().filter((task) => task.kind !== "subagent");
137
+ if (input.operation === "list") {
138
+ // What the schedule answer needs: the next occurrence and the last run's state, not the run.
139
+ return definitions.list().filter((task) => task.kind !== "subagent").map(({ nextRunAt, ...task }) => {
140
+ const last = host.listRuns(task.id, 1)[0];
141
+ return { ...task, nextRun: nextRunAt, lastRun: last ? defined({ runId: last.id, state: last.state, startedAt: last.startedAt, finishedAt: last.finishedAt }) : null };
142
+ });
143
+ }
144
+ if (input.operation === "runs")
145
+ return host.ledger(launchers(), Date.now() - LEDGER_WINDOW_MS);
146
+ // A one-shot's hidden definition is a run's record, not a task anyone filed.
147
+ const filed = () => {
148
+ const id = requiredString(input.task_id, "task_id");
149
+ if (definitions.get(id).kind === "subagent")
150
+ throw new Error(`${id} is a one-shot run's own definition; save without --task-id to file a task`);
151
+ return id;
152
+ };
120
153
  if (input.operation === "save") {
121
154
  const draft = await expandDraft(definitions, menu, input.task, callerSessionId);
122
155
  if (input.task_id === undefined)
123
156
  return definitions.create(draft, `session:${callerSessionId}`);
124
- const id = requiredString(input.task_id, "task_id");
125
- // A one-shot's hidden definition is a run's record, not a task anyone filed.
126
- if (definitions.get(id).kind === "subagent")
127
- throw new Error(`${id} is a one-shot run's own definition; save without --task-id to file a task`);
128
- return definitions.update(id, draft);
157
+ return definitions.update(filed(), draft);
129
158
  }
159
+ if (input.operation === "pause" || input.operation === "resume")
160
+ return host.setEnabled(filed(), input.operation === "resume");
161
+ if (input.operation === "archive")
162
+ return host.archive(filed());
130
163
  if (input.operation === "run") {
131
164
  // `--model ?`: the menu instead of a run, the one lookup the common case never pays.
132
165
  if (record(input.launch)?.model === "?") {
133
166
  const { source, models } = await host.models();
134
167
  const head = source === "menu" ? "the operator's menu" : "the live catalog (no model pinned)";
135
- return `${head} — --model takes a provider/id or a unique substring of one:\n${menuLines(models)}`;
168
+ return `${head} — --model takes a tier, a provider/id or a unique substring of one:\n${menuLines(models)}`;
136
169
  }
137
170
  const callbackMode = callbackModeOf(input);
138
171
  if (Array.isArray(input.tasks)) {
@@ -149,8 +182,8 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
149
182
  if (!entry)
150
183
  throw new Error("invalid tasks[] entry");
151
184
  resolved.push(entry.task_id === undefined
152
- ? await resolveDraft(definitions, menu, entry, callerSessionId)
153
- : definitions.get(requiredString(entry.task_id, "task_id")));
185
+ ? await resolveDraft(definitions, menu, notLead(entry), callerSessionId)
186
+ : notLead(definitions.get(requiredString(entry.task_id, "task_id"))));
154
187
  }
155
188
  const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
156
189
  const { group, runs } = host.runGroup(resolved, oneOf(input, "join", ["all", "first"]) ?? "all", callerSessionId, groupCallbackSessionId, callbackMode);
@@ -158,8 +191,8 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
158
191
  }
159
192
  const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
160
193
  const task = draft
161
- ? await resolveDraft(definitions, menu, draft, callerSessionId)
162
- : definitions.get(requiredString(input.task_id, "task_id"));
194
+ ? await resolveDraft(definitions, menu, notLead(draft), callerSessionId)
195
+ : notLead(definitions.get(requiredString(input.task_id, "task_id")));
163
196
  const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
164
197
  const run = host.run(task.id, null, "agent", null, {
165
198
  invokedBySessionId: callerSessionId,
@@ -205,12 +238,12 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
205
238
  if (typeof input.group_id === "string") {
206
239
  const { group, members } = host.getGroup(input.group_id);
207
240
  for (const member of members)
208
- assertOwns(callerSessionId, member);
241
+ assertOwns(member);
209
242
  const cancelled = host.cancelGroup(group.id);
210
243
  return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)));
211
244
  }
212
245
  const run = host.getRun(requiredString(input.run_id, "run_id"));
213
- assertOwns(callerSessionId, run);
246
+ assertOwns(run);
214
247
  // cancel() only aborts; the row flips ~100 ms later. A receipt still saying
215
248
  // `running` reads as "cancel failed", so wait for the flip, briefly.
216
249
  host.cancel(run.id);
@@ -221,7 +254,7 @@ export async function handleTask(host, definitions, store, raw, callerSessionId)
221
254
  // caller, decides whether the text steers, queues or resumes, so a status
222
255
  // query never has to exist.
223
256
  const run = host.getRun(requiredString(input.run_id, "run_id"));
224
- assertOwns(callerSessionId, run);
257
+ assertOwns(run);
225
258
  const message = requiredString(input.message, "message");
226
259
  if (isTerminal(run.state)) {
227
260
  // A resumed run is a new run with its own callback.
@@ -254,23 +287,33 @@ function nameFrom(text) {
254
287
  .find(Boolean) ?? "subagent";
255
288
  return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
256
289
  }
257
- /** `launch.model` by name (docs/design/09-tasks-cli.md §Models). Anything but
258
- * one pin or an unpinned `provider/id` is refused with the lines to pick
259
- * from, so the agent never guesses an id. */
290
+ /** `launch.model` by name (docs/design/09-tasks-cli.md §Models): the one pin
291
+ * it names, a tier its first. Anything but a tier, one pin or an unpinned
292
+ * `provider/id` is refused with the lines to pick from, so the agent never
293
+ * guesses an id. A tier is never a substring: "cheap" matching an id would
294
+ * pick a pin the operator did not assign. */
260
295
  function resolveModel(name, menu) {
261
296
  const needle = name.trim().toLowerCase();
262
297
  const full = (pin) => `${pin.provider}/${pin.id}`;
263
- const exact = menu.find((pin) => full(pin).toLowerCase() === needle);
264
- const hits = exact ? [exact] : menu.filter((pin) => `${full(pin)} ${pin.note ?? ""}`.toLowerCase().includes(needle));
265
- if (hits.length === 1)
266
- return { model: { provider: hits[0].provider, id: hits[0].id }, thinking: hits[0].thinking };
298
+ const pick = (pin) => ({ model: { provider: pin.provider, id: pin.id }, thinking: pin.thinking });
299
+ if (isModelTier(needle)) {
300
+ const pin = menu.find((p) => p.tier === needle);
301
+ if (pin)
302
+ return pick(pin);
303
+ throw new Error(`model "${name}": tier ${needle} is unassigned — the operator's menu:\n${menuLines(menu)}`);
304
+ }
305
+ const exact = menu.filter((pin) => full(pin).toLowerCase() === needle);
306
+ const hits = exact.length ? exact : menu.filter((pin) => full(pin).toLowerCase().includes(needle));
307
+ // One model pinned at several levels is still one model: its first row, as with a tier.
308
+ if (hits.length && hits.every((pin) => full(pin) === full(hits[0])))
309
+ return pick(hits[0]);
267
310
  const slash = name.indexOf("/");
268
311
  if (!hits.length && slash > 0 && slash < name.length - 1)
269
312
  return { model: { provider: name.slice(0, slash), id: name.slice(slash + 1) } };
270
313
  throw new Error(`model "${name}" matches ${String(hits.length)} of the menu:\n${menuLines(hits.length ? hits : menu)}`);
271
314
  }
272
315
  /** One pin per line, the only shape a menu is ever printed in. */
273
- const menuLines = (menu) => menu.map((pin) => `${pin.provider}/${pin.id}${pin.thinking ? ` · ${pin.thinking}` : ""}${pin.note ? ` — ${pin.note}` : ""}`).join("\n")
316
+ const menuLines = (menu) => menu.map((pin) => `${pin.tier ? `${pin.tier} · ` : ""}${pin.provider}/${pin.id}${pin.thinking ? ` · ${pin.thinking}` : ""}`).join("\n")
274
317
  || "(no model is pinned or available)";
275
318
  /** A `prompt` shorthand becomes a fresh Agent action in the caller's own
276
319
  * directory; everything the caller did spell out passes through to parseDraft. */
@@ -305,6 +348,7 @@ async function expandDraft(definitions, menu, raw, callerSessionId) {
305
348
  const launch = record(action?.launch);
306
349
  if (typeof launch?.model === "string") {
307
350
  const { model, thinking } = resolveModel(launch.model, await menu());
351
+ // A pin's level only where the caller named none.
308
352
  const resolved = { ...launch, model, ...(launch.thinking === undefined && thinking ? { thinking } : {}) };
309
353
  draft = { ...draft, action: { ...record(draft.action), launch: resolved } };
310
354
  }
@@ -324,12 +368,5 @@ async function resolveDraft(definitions, menu, raw, callerSessionId) {
324
368
  if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
325
369
  throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
326
370
  }
327
- return definitions.create({ ...draft, trigger: { type: "manual" } }, `session:${callerSessionId}`, "subagent");
328
- }
329
- /** The session that launched a run controls it, and so does the run's own
330
- * session; a `task` action's child inherits its parent's launcher. */
331
- function assertOwns(callerSessionId, target) {
332
- if (target.invokedBySessionId !== callerSessionId && target.targetSessionId !== callerSessionId) {
333
- throw new Error("session does not own this run");
334
- }
371
+ return definitions.create({ ...draft, trigger: { type: "manual" }, callback: { type: "none" } }, `session:${callerSessionId}`, "subagent");
335
372
  }
@@ -3,6 +3,11 @@
3
3
  import { logger } from "../log.js";
4
4
  import { MAX_DELIVERY_ATTEMPTS, retryDelay, undeliverable } from "./types.js";
5
5
  const log = logger("tasks");
6
+ /** A result waiting on a lead's own running turn is asked again this often, not
7
+ * every tick: a wait spends no attempt, so the attempt backoff does not apply. */
8
+ const MILESTONE_WAIT_MS = 10_000;
9
+ /** The marks a delivered record carries: nothing left to retry. */
10
+ const DELIVERED = { callbackState: "delivered", callbackError: null, callbackNextAttemptAt: null };
6
11
  /** The records a delivery names, from either side of the seam: a batch names
7
12
  * every one of them, a group names itself, a message names its id. */
8
13
  const recordIds = (origin) => {
@@ -24,6 +29,17 @@ export class Outbox {
24
29
  * written only against the input visible in the recipient's transcript: Pi's
25
30
  * queues are memory, so a resolved `systemInput` proves nothing. */
26
31
  async deliver(sessionId, batch) {
32
+ const marked = batch.map((record) => ({ ...record, ...DELIVERED }));
33
+ const taken = this.kind.milestone?.(sessionId, () => this.kind.input(batch).text, () => { for (const record of marked)
34
+ this.kind.save(record); });
35
+ if (taken === "resumed")
36
+ for (const record of marked)
37
+ this.kind.changed(record);
38
+ if (taken === "wait")
39
+ for (const record of batch)
40
+ this.defer(record, MILESTONE_WAIT_MS);
41
+ if (taken === "resumed" || taken === "wait")
42
+ return;
27
43
  const mine = batch.filter((record) => !this.delivering.has(this.kind.id(record)));
28
44
  if (mine.length === 0)
29
45
  return;
@@ -128,8 +144,8 @@ export class Outbox {
128
144
  return true;
129
145
  }
130
146
  /** Busy target: try again shortly, and do not count it. */
131
- defer(record) {
132
- record.callbackNextAttemptAt = Date.now() + 1000;
147
+ defer(record, ms = 1000) {
148
+ record.callbackNextAttemptAt = Date.now() + ms;
133
149
  this.kind.save(record);
134
150
  }
135
151
  /** Backs off like a failure, so an input never recorded is re-sent on a
@@ -142,10 +158,7 @@ export class Outbox {
142
158
  this.kind.save(record);
143
159
  }
144
160
  delivered(record) {
145
- record.callbackState = "delivered";
146
- record.callbackError = null;
147
- record.callbackNextAttemptAt = null;
148
- this.write(record);
161
+ this.write(Object.assign(record, DELIVERED));
149
162
  }
150
163
  /** A failed pass costs one attempt, whether it died before or after send. */
151
164
  failed(record, error, counted) {
@@ -1,258 +1,28 @@
1
- // The area's HTTP surface. A route names the caller and hands the decision to
2
- // TaskService: policy here would be policy `pier task` does not get.
3
- import { record, requiredString } from "./definitions.js";
4
- /** Reject malformed filters instead of silently widening a global query. */
5
- function runQuery(params) {
6
- const query = {};
7
- const states = ["queued", "running", "succeeded", "failed", "cancelled", "interrupted", "skipped"];
8
- const sources = ["manual", "cron", "watch", "agent", "task"];
9
- if (params.state) {
10
- if (!states.includes(params.state))
11
- throw new Error("invalid state");
12
- query.state = params.state;
13
- }
14
- if (params.source) {
15
- if (!sources.includes(params.source))
16
- throw new Error("invalid source");
17
- query.source = params.source;
18
- }
19
- if (params.taskId)
20
- query.taskId = params.taskId;
21
- for (const key of ["since", "until", "limit"]) {
22
- if (params[key] === undefined)
23
- continue;
24
- const value = Number(params[key]);
25
- if (!params[key] || !Number.isSafeInteger(value) || value < 0 || (key === "limit" && (value < 1 || value > 200)))
26
- throw new Error(`invalid ${key}`);
27
- query[key] = value;
28
- }
29
- if (query.since !== undefined && query.until !== undefined && query.since > query.until)
30
- throw new Error("since exceeds until");
31
- if (params.showUnmatched !== undefined) {
32
- if (!["true", "false"].includes(params.showUnmatched))
33
- throw new Error("invalid showUnmatched");
34
- query.showUnmatched = params.showUnmatched === "true";
35
- }
36
- if (params.cursor) {
37
- const cursor = JSON.parse(params.cursor);
38
- const value = record(cursor);
39
- if (!value || !Number.isSafeInteger(value.queuedAt) || Number(value.queuedAt) < 0 || typeof value.id !== "string" || !value.id)
40
- throw new Error("invalid cursor");
41
- query.cursor = { queuedAt: Number(value.queuedAt), id: value.id };
42
- }
43
- return query;
44
- }
45
- const jsonBody = async (req) => req.json().catch(() => null);
46
- export function registerTaskRoutes(app, tasks, activity) {
47
- if (activity)
48
- app.get("/api/activity", async (c) => {
49
- const now = Date.now();
50
- const recent = c.req.query("scope") === "recent";
51
- const windowStart = now - 24 * 60 * 60 * 1000;
52
- const runs = tasks.activityRuns(recent ? windowStart : undefined);
53
- const listed = await activity.factory.list();
54
- const byId = new Map(listed.map((session) => [session.id, session]));
55
- const linkedIds = new Set();
56
- for (const run of runs) {
57
- for (const id of [run.invokedBySessionId, run.targetSessionId, run.callbackSessionId]) {
58
- if (id)
59
- linkedIds.add(id);
60
- }
61
- }
62
- for (const session of listed) {
63
- if (activity.router.stateOf(session.id) === "streaming")
64
- linkedIds.add(session.id);
65
- }
66
- const messages = tasks.recentMessages(windowStart)
67
- .filter((message) => runs.some((run) => run.id === message.runId));
68
- for (const message of messages) {
69
- if (message.fromSessionId !== "console")
70
- linkedIds.add(message.fromSessionId);
71
- if (message.toSessionId !== "console")
72
- linkedIds.add(message.toSessionId);
73
- }
74
- return c.json({
75
- sessions: [...linkedIds].map((id) => {
76
- const session = byId.get(id);
77
- return {
78
- id,
79
- cwd: session?.cwd ?? "",
80
- createdAt: session?.createdAt ?? null,
81
- title: session?.title,
82
- state: activity.router.stateOf(id) ?? "idle",
83
- stateSince: activity.router.stateSinceOf(id) ?? null,
84
- };
85
- }),
86
- runs,
87
- messages,
88
- });
89
- });
90
- app.get("/api/task-runs", (c) => {
91
- try {
92
- return c.json(tasks.queryRuns(runQuery(c.req.query())));
93
- }
94
- catch (err) {
95
- return c.json({ error: String(err) }, 400);
96
- }
97
- });
98
- app.get("/api/tasks", (c) => {
99
- const trigger = c.req.query("trigger");
100
- const state = c.req.query("state");
101
- const kind = c.req.query("kind");
102
- let rows = tasks.list();
103
- // Subagent one-shots are hidden unless explicitly requested via ?kind=subagent.
104
- rows = rows.filter((task) => task.kind === (kind ?? "task"));
105
- if (trigger)
106
- rows = rows.filter((task) => task.trigger.type === trigger);
107
- if (state === "archived")
108
- rows = rows.filter((task) => task.archived);
109
- else if (state === "active")
110
- rows = rows.filter((task) => !task.archived);
111
- return c.json(rows.map((task) => ({
112
- ...task,
113
- lastRun: tasks.listRuns(task.id, 1)[0] ?? null,
114
- })));
115
- });
116
- app.post("/api/tasks", async (c) => {
117
- const body = await jsonBody(c.req);
118
- const runNow = typeof body === "object" && body !== null && "runNow" in body && body.runNow === true;
119
- const definition = typeof body === "object" && body !== null && "task" in body ? body.task : body;
120
- try {
121
- const task = await tasks.create(definition);
122
- const run = runNow ? tasks.run(task.id) : null;
123
- return c.json({ task, runId: run?.id ?? null }, 201);
124
- }
125
- catch (err) {
126
- return c.json({ error: String(err) }, 400);
127
- }
128
- });
1
+ // Settings → Tasks' HTTP surface: the list, pause/resume, a task's runs. A
2
+ // route names the caller and hands the decision to TaskService: policy here
3
+ // would be policy `pier task` does not get.
4
+ const RUNS_LIMIT = 20;
5
+ export function registerTaskRoutes(app, tasks) {
6
+ // Subagent one-shots and archived tasks are history, not something to manage.
7
+ app.get("/api/tasks", (c) => c.json(tasks.list()
8
+ .filter((task) => task.kind === "task" && !task.archived)
9
+ .map((task) => ({ ...task, lastRun: tasks.listRuns(task.id, 1)[0] ?? null }))));
129
10
  app.get("/api/tasks/:id/runs", (c) => {
130
11
  try {
131
- const limit = Number(c.req.query("limit") ?? 50);
132
- const offset = Number(c.req.query("offset") ?? 0);
133
- return c.json(tasks.listRuns(c.req.param("id"), limit, offset));
12
+ return c.json(tasks.listRuns(tasks.get(c.req.param("id")).id, RUNS_LIMIT));
134
13
  }
135
14
  catch (err) {
136
15
  return c.json({ error: String(err) }, 404);
137
16
  }
138
17
  });
139
- app.get("/api/task-runs/:id", (c) => {
140
- try {
141
- return c.json(tasks.getRunView(c.req.param("id")));
142
- }
143
- catch (err) {
144
- return c.json({ error: String(err) }, 404);
145
- }
146
- });
147
- app.get("/api/task-groups/:id", (c) => {
148
- try {
149
- return c.json(tasks.getGroup(c.req.param("id")));
150
- }
151
- catch (err) {
152
- return c.json({ error: String(err) }, 404);
153
- }
154
- });
155
- app.get("/api/task-runs/:id/messages", (c) => {
156
- try {
157
- return c.json(tasks.listMessages(c.req.param("id")));
158
- }
159
- catch (err) {
160
- return c.json({ error: String(err) }, 404);
161
- }
162
- });
163
- app.post("/api/task-runs/:id/steer", async (c) => {
164
- const body = record(await jsonBody(c.req));
165
- try {
166
- return c.json(await tasks.control(c.req.param("id"), typeof body?.sourceSessionId === "string" ? body.sourceSessionId : "console", body?.mode === "followUp" ? "follow_up" : "steer", requiredString(body?.message, "message")), 202);
167
- }
168
- catch (err) {
169
- return c.json({ error: String(err) }, 400);
170
- }
171
- });
172
- app.post("/api/task-runs/:id/resume", async (c) => {
173
- const body = record(await jsonBody(c.req));
174
- try {
175
- const wait = body?.wait === true;
176
- const source = typeof body?.sourceSessionId === "string" ? body.sourceSessionId : null;
177
- const run = tasks.resume(c.req.param("id"), requiredString(body?.message, "message"), {
178
- invokedBySessionId: source,
179
- callbackSessionId: wait ? null : source,
180
- background: !wait,
181
- });
182
- return c.json(wait ? await tasks.waitForRun(run.id) : run, wait ? 200 : 202);
183
- }
184
- catch (err) {
185
- return c.json({ error: String(err) }, 400);
186
- }
187
- });
188
- app.post("/api/task-runs/:id/cancel", (c) => {
189
- try {
190
- return c.json(tasks.cancel(c.req.param("id")), 202);
191
- }
192
- catch (err) {
193
- return c.json({ error: String(err) }, 404);
194
- }
195
- });
196
- app.get("/api/tasks/:id", (c) => {
197
- try {
198
- return c.json(tasks.get(c.req.param("id")));
199
- }
200
- catch (err) {
201
- return c.json({ error: String(err) }, 404);
202
- }
203
- });
204
- app.patch("/api/tasks/:id", async (c) => {
205
- try {
206
- return c.json(await tasks.update(c.req.param("id"), await jsonBody(c.req)));
207
- }
208
- catch (err) {
209
- return c.json({ error: String(err) }, 400);
210
- }
211
- });
212
- app.post("/api/tasks/:id/run", async (c) => {
213
- const body = record(await jsonBody(c.req));
214
- const input = body && "input" in body ? body.input : null;
215
- try {
216
- // Dropping an unsupported override would run the definition's own policy
217
- // instead — on a reuse definition, work injected into a live session.
218
- if (body?.sessionMode !== undefined && body.sessionMode !== "fresh") {
219
- throw new Error(`unsupported sessionMode: ${String(body.sessionMode)}`);
18
+ for (const [verb, enabled] of [["pause", false], ["resume", true]]) {
19
+ app.post(`/api/tasks/:id/${verb}`, (c) => {
20
+ try {
21
+ return c.json(tasks.setEnabled(c.req.param("id"), enabled));
220
22
  }
221
- const sessionMode = body?.sessionMode;
222
- const sourceSessionId = typeof body?.sourceSessionId === "string" && body.sourceSessionId ? body.sourceSessionId : null;
223
- const task = tasks.get(c.req.param("id"));
224
- return c.json({ runId: tasks.run(task.id, input, "manual", null, {
225
- invokedBySessionId: sourceSessionId,
226
- sourceSessionId,
227
- sessionMode,
228
- }).id }, 202);
229
- }
230
- catch (err) {
231
- return c.json({ error: String(err) }, 400);
232
- }
233
- });
234
- app.post("/api/tasks/:id/pause", (c) => {
235
- try {
236
- return c.json(tasks.setEnabled(c.req.param("id"), false));
237
- }
238
- catch (err) {
239
- return c.json({ error: String(err) }, 400);
240
- }
241
- });
242
- app.post("/api/tasks/:id/resume", (c) => {
243
- try {
244
- return c.json(tasks.setEnabled(c.req.param("id"), true));
245
- }
246
- catch (err) {
247
- return c.json({ error: String(err) }, 400);
248
- }
249
- });
250
- app.post("/api/tasks/:id/archive", (c) => {
251
- try {
252
- return c.json(tasks.archive(c.req.param("id")));
253
- }
254
- catch (err) {
255
- return c.json({ error: String(err) }, 400);
256
- }
257
- });
23
+ catch (err) {
24
+ return c.json({ error: String(err) }, 400);
25
+ }
26
+ });
27
+ }
258
28
  }