@timqi/pier 0.1.3 → 0.1.5

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 (136) hide show
  1. package/README.md +12 -7
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +13 -5
  4. package/dist/agent/listing.js +10 -2
  5. package/dist/agent/packages.js +4 -20
  6. package/dist/agent/pi.js +18 -86
  7. package/dist/channels/attach.js +2 -2
  8. package/dist/channels/commands.js +10 -8
  9. package/dist/channels/config.js +32 -24
  10. package/dist/channels/control.js +60 -14
  11. package/dist/channels/conversations.js +60 -13
  12. package/dist/channels/handoff.js +90 -0
  13. package/dist/channels/lark-api.js +7 -0
  14. package/dist/channels/lark-outbound.js +19 -0
  15. package/dist/channels/lark-panel.js +41 -26
  16. package/dist/channels/lark-render.js +2 -1
  17. package/dist/channels/lark.js +35 -26
  18. package/dist/channels/lines.js +1 -1
  19. package/dist/channels/panel.js +324 -115
  20. package/dist/channels/receipts.js +25 -12
  21. package/dist/channels/routes.js +21 -7
  22. package/dist/channels/runtime.js +21 -7
  23. package/dist/channels/slack-api.js +19 -10
  24. package/dist/channels/slack-cli.js +503 -0
  25. package/dist/channels/slack-directory.js +2 -2
  26. package/dist/channels/slack-outbound.js +11 -1
  27. package/dist/channels/slack-panel.js +81 -40
  28. package/dist/channels/slack-render.js +3 -1
  29. package/dist/channels/slack-thread.js +41 -0
  30. package/dist/channels/slack-transcript.js +107 -0
  31. package/dist/channels/slack.js +38 -31
  32. package/dist/channels/types.js +1 -3
  33. package/dist/cli.js +166 -7
  34. package/dist/core/identity.js +61 -17
  35. package/dist/core/reply.js +19 -15
  36. package/dist/core/router.js +23 -3
  37. package/dist/db.js +33 -0
  38. package/dist/main.js +61 -41
  39. package/dist/paths.js +3 -0
  40. package/dist/secrets.js +2 -1
  41. package/dist/settings.js +3 -12
  42. package/dist/socket.js +99 -0
  43. package/dist/tasks/agent.js +5 -6
  44. package/dist/tasks/callbacks.js +2 -2
  45. package/dist/tasks/cli.js +225 -0
  46. package/dist/tasks/definitions.js +3 -3
  47. package/dist/tasks/execution.js +1 -3
  48. package/dist/tasks/groups.js +2 -5
  49. package/dist/tasks/messages.js +18 -158
  50. package/dist/tasks/operations.js +335 -0
  51. package/dist/tasks/routes.js +1 -11
  52. package/dist/tasks/runs.js +5 -20
  53. package/dist/tasks/service.js +18 -43
  54. package/dist/tasks/store.js +15 -15
  55. package/dist/tools.js +18 -3
  56. package/dist/vault.js +107 -0
  57. package/dist/web/auth.js +2 -1
  58. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-CrybM-E8.js} +2 -2
  59. package/dist/web/public/assets/activity-CrybM-E8.js.br +0 -0
  60. package/dist/web/public/assets/activity-CrybM-E8.js.gz +0 -0
  61. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-Cw7_6J6L.js} +1 -1
  62. package/dist/web/public/assets/boards-Cw7_6J6L.js.br +0 -0
  63. package/dist/web/public/assets/boards-Cw7_6J6L.js.gz +0 -0
  64. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-DV066dUD.js} +1 -1
  65. package/dist/web/public/assets/explorer-DV066dUD.js.br +0 -0
  66. package/dist/web/public/assets/explorer-DV066dUD.js.gz +0 -0
  67. package/dist/web/public/assets/index-BrNHu2qj.js +85 -0
  68. package/dist/web/public/assets/index-BrNHu2qj.js.br +0 -0
  69. package/dist/web/public/assets/index-BrNHu2qj.js.gz +0 -0
  70. package/dist/web/public/assets/index-DLszkDUV.css +2 -0
  71. package/dist/web/public/assets/index-DLszkDUV.css.br +0 -0
  72. package/dist/web/public/assets/index-DLszkDUV.css.gz +0 -0
  73. package/dist/web/public/assets/{runs-DdERzeac.js → runs-DDagTNaM.js} +1 -1
  74. package/dist/web/public/assets/runs-DDagTNaM.js.br +0 -0
  75. package/dist/web/public/assets/runs-DDagTNaM.js.gz +0 -0
  76. package/dist/web/public/assets/settings-BEdSeXpm.js +5 -0
  77. package/dist/web/public/assets/settings-BEdSeXpm.js.br +0 -0
  78. package/dist/web/public/assets/settings-BEdSeXpm.js.gz +0 -0
  79. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-BnMack9t.js} +1 -1
  80. package/dist/web/public/assets/task-runs-BnMack9t.js.br +0 -0
  81. package/dist/web/public/assets/task-runs-BnMack9t.js.gz +0 -0
  82. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-BkW7YShZ.js} +1 -1
  83. package/dist/web/public/assets/tasks-BkW7YShZ.js.br +0 -0
  84. package/dist/web/public/assets/tasks-BkW7YShZ.js.gz +0 -0
  85. package/dist/web/public/index.html +2 -2
  86. package/dist/web/public/index.html.br +0 -0
  87. package/dist/web/public/index.html.gz +0 -0
  88. package/dist/web/push.js +4 -10
  89. package/dist/web/vault.js +68 -0
  90. package/dist/{extensions/web → websearch}/artifacts.js +2 -2
  91. package/dist/websearch/cli.js +73 -0
  92. package/dist/websearch/run.js +273 -0
  93. package/docs/deploy.md +34 -11
  94. package/package.json +2 -3
  95. package/skills/pier-help/SKILL.md +32 -16
  96. package/skills/pier-slack/SKILL.md +52 -71
  97. package/skills/pier-tasks/SKILL.md +60 -148
  98. package/skills/pier-vault/SKILL.md +36 -0
  99. package/skills/pier-web/SKILL.md +50 -0
  100. package/dist/channels/slack-tool.js +0 -416
  101. package/dist/channels/telegram-api.js +0 -86
  102. package/dist/channels/telegram-panel.js +0 -97
  103. package/dist/channels/telegram-render.js +0 -69
  104. package/dist/channels/telegram.js +0 -421
  105. package/dist/extensions/index.js +0 -10
  106. package/dist/extensions/web/index.js +0 -9
  107. package/dist/extensions/web/tools.js +0 -265
  108. package/dist/tasks/tool.js +0 -416
  109. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  110. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  111. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  112. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  113. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  114. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  115. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  116. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  117. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  118. package/dist/web/public/assets/index-DVIt5Gio.css +0 -2
  119. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  120. package/dist/web/public/assets/index-DVIt5Gio.css.gz +0 -0
  121. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  122. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  123. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  124. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  125. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  126. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  127. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  128. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  129. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
  130. /package/dist/{extensions/web → websearch}/anthropic.js +0 -0
  131. /package/dist/{extensions/web → websearch}/content.js +0 -0
  132. /package/dist/{extensions/web → websearch}/http.js +0 -0
  133. /package/dist/{extensions/web → websearch}/json.js +0 -0
  134. /package/dist/{extensions/web → websearch}/language.js +0 -0
  135. /package/dist/{extensions/web → websearch}/openai.js +0 -0
  136. /package/dist/{extensions/web → websearch}/provider.js +0 -0
@@ -0,0 +1,335 @@
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.
5
+ import { isAbsolute, resolve } from "node:path";
6
+ import { logger } from "../log.js";
7
+ import { record, requiredString } from "./definitions.js";
8
+ import { isTerminal } from "./types.js";
9
+ const log = logger("tasks");
10
+ /** Absent instead of `null`: a model reads both the same way, and on a group
11
+ * summary the nulls are a third of the payload. The input names every field
12
+ * so a summary that forgot one cannot pass as "empty". */
13
+ const defined = (value) => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== null && v !== undefined));
14
+ const summarize = (run) => defined({
15
+ runId: run.id,
16
+ taskId: run.taskId,
17
+ taskName: run.context.definition.name,
18
+ state: run.state,
19
+ triggerSource: run.triggerSource,
20
+ groupId: run.groupId,
21
+ sessionMode: run.sessionMode,
22
+ targetSessionId: run.targetSessionId,
23
+ callbackSessionId: run.callbackSessionId,
24
+ callbackState: run.callbackState,
25
+ callbackMode: run.callbackMode ?? null,
26
+ queuedAt: run.queuedAt,
27
+ startedAt: run.startedAt,
28
+ finishedAt: run.finishedAt,
29
+ result: run.result,
30
+ error: run.error,
31
+ skipReason: run.skipReason,
32
+ next: null,
33
+ });
34
+ /** The receipt says the callback is the whole answer, or a model that just
35
+ * launched work reaches for a status call. */
36
+ const receipt = (summary, callbackSessionId, mode, callerSessionId) => ({
37
+ ...summary,
38
+ next: callbackSessionId === null
39
+ ? "callback none: the result is not delivered to anyone"
40
+ : callbackSessionId !== callerSessionId
41
+ ? `the result is delivered to session ${callbackSessionId}; this session will not receive a callback`
42
+ : mode === "steer"
43
+ ? "the result interrupts your running turn as a steer message; nothing to query"
44
+ : "the result arrives as a callback message once your turn ends; nothing to query",
45
+ });
46
+ /** A typo in an enum field must not silently mean its default. */
47
+ const oneOf = (input, key, values) => {
48
+ const value = input[key];
49
+ if (value === undefined || values.includes(value))
50
+ return value;
51
+ throw new Error(`${key} must be one of ${values.join(", ")}`);
52
+ };
53
+ const callbackModeOf = (input) => oneOf(input, "callback", ["origin", "none", "steer"]) === "steer" ? "steer" : "followUp";
54
+ /** Who a new run's result goes to — the caller, nobody, or a named session that
55
+ * must exist. Shared by `run` and `message` on a finished run: a resumed run is a new run. */
56
+ const callbackTarget = async (input, definitions, callerSessionId) => {
57
+ if (input.callback_session_id === undefined)
58
+ return input.callback === "none" ? null : callerSessionId;
59
+ if (input.callback === "none")
60
+ throw new Error("callback none and callback_session_id conflict: pick one delivery target");
61
+ const target = requiredString(input.callback_session_id, "callback_session_id");
62
+ if (!(await definitions.sessionExists(target)))
63
+ throw new Error(`unknown session: ${target}`);
64
+ return target;
65
+ };
66
+ /** A group echoes many results at once, so each is capped; a single-run
67
+ * `recover` stays whole — it is the escape hatch every truncation note points at. */
68
+ const trimResult = (summary) => {
69
+ if (summary.result?.type !== "agent" || summary.result.text.length <= 2000)
70
+ return summary;
71
+ return {
72
+ ...summary,
73
+ result: {
74
+ ...summary.result,
75
+ text: `${summary.result.text.slice(0, 2000)}\n[truncated — pier task recover --run ${summary.runId} --reason … for the full text]`,
76
+ },
77
+ };
78
+ };
79
+ /** Delivered, given up on and reported, or never owed (`callback:"none"`).
80
+ * Anything else is still on its way, and reading it here would be reading it twice. */
81
+ const settled = (callback) => callback.callbackState === null || callback.callbackState === "delivered" || callback.callbackState === "abandoned";
82
+ /** The same words for queued, running, pending and retrying: a refusal that
83
+ * named the state would be the status query this surface refuses. */
84
+ const notRecoverable = (what, callback) => {
85
+ throw new Error(callback.callbackSessionId === null
86
+ ? `${what} is not recoverable yet: it was launched with callback none, so nothing will be delivered; recover reads finished results only and cannot wait for work`
87
+ : `${what} is not recoverable yet: wait for automatic delivery to session ${callback.callbackSessionId}; recover cannot wait for work`);
88
+ };
89
+ const summarizeGroup = (group, members) => defined({
90
+ groupId: group.id,
91
+ join: group.join,
92
+ state: group.finishedAt ? "finished" : "running",
93
+ callbackState: group.callbackState,
94
+ callbackMode: group.callbackMode ?? null,
95
+ winnerRunId: group.winnerRunId,
96
+ members: members.map((run) => trimResult(summarize(run))),
97
+ next: null,
98
+ });
99
+ const CANCEL_WAIT_MS = 2000;
100
+ /** The run once terminal, or as it stands after `ms`. */
101
+ function cancelledOrCurrent(host, id, ms) {
102
+ return Promise.race([
103
+ host.waitForRun(id),
104
+ new Promise((resolve) => setTimeout(() => resolve(host.getRun(id)), ms).unref()),
105
+ ]);
106
+ }
107
+ export async function handleTask(host, definitions, store, raw, callerSessionId) {
108
+ const input = record(raw);
109
+ if (!input)
110
+ 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.
114
+ const active = store.findActiveRunForTarget(callerSessionId);
115
+ if (active?.state === "running" && store.supervised(active))
116
+ throw new Error("a delegated run cannot delegate; ask in your result and let your supervisor run it");
117
+ const menu = () => host.models().then((listed) => listed.models);
118
+ if (input.operation === "list")
119
+ return definitions.list().filter((task) => task.kind !== "subagent");
120
+ if (input.operation === "save") {
121
+ const draft = await expandDraft(definitions, menu, input.task, callerSessionId);
122
+ if (input.task_id === undefined)
123
+ 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);
129
+ }
130
+ if (input.operation === "run") {
131
+ // `--model ?`: the menu instead of a run, the one lookup the common case never pays.
132
+ if (record(input.launch)?.model === "?") {
133
+ const { source, models } = await host.models();
134
+ 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)}`;
136
+ }
137
+ const callbackMode = callbackModeOf(input);
138
+ if (Array.isArray(input.tasks)) {
139
+ // Core-joined fan-out: members run detached, one aggregated callback.
140
+ if (input.task !== undefined || input.task_id !== undefined)
141
+ throw new Error("use either task/task_id or tasks[]");
142
+ if (input.callback_session_id !== undefined)
143
+ throw new Error("callback_session_id applies to a single run only");
144
+ if (input.tasks.length < 2)
145
+ throw new Error("tasks[] needs at least 2 entries; use task for a single run");
146
+ const resolved = [];
147
+ for (const rawEntry of input.tasks) {
148
+ const entry = typeof rawEntry === "string" ? { prompt: rawEntry } : record(rawEntry);
149
+ if (!entry)
150
+ throw new Error("invalid tasks[] entry");
151
+ resolved.push(entry.task_id === undefined
152
+ ? await resolveDraft(definitions, menu, entry, callerSessionId)
153
+ : definitions.get(requiredString(entry.task_id, "task_id")));
154
+ }
155
+ const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
156
+ const { group, runs } = host.runGroup(resolved, oneOf(input, "join", ["all", "first"]) ?? "all", callerSessionId, groupCallbackSessionId, callbackMode);
157
+ return receipt(summarizeGroup(group, runs), groupCallbackSessionId, callbackMode, callerSessionId);
158
+ }
159
+ const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
160
+ const task = draft
161
+ ? await resolveDraft(definitions, menu, draft, callerSessionId)
162
+ : definitions.get(requiredString(input.task_id, "task_id"));
163
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
164
+ const run = host.run(task.id, null, "agent", null, {
165
+ invokedBySessionId: callerSessionId,
166
+ sourceSessionId: callerSessionId,
167
+ callbackSessionId,
168
+ callbackMode,
169
+ background: true,
170
+ });
171
+ return receipt(summarize(run), callbackSessionId, callbackMode, callerSessionId);
172
+ }
173
+ if (input.operation === "recover") {
174
+ // History only, never status: readable once the callback has said its last
175
+ // word. The required reason is the friction, and the operator sees it.
176
+ const reason = requiredString(input.reason, "reason");
177
+ // A race winner need not wait for losing members to finish cancelling.
178
+ const groupReady = (group) => {
179
+ if (!group.finishedAt || !settled(group))
180
+ notRecoverable(`group ${group.id}`, group);
181
+ };
182
+ if (typeof input.group_id === "string") {
183
+ const { group, members } = host.getGroup(input.group_id);
184
+ groupReady(group);
185
+ if (!members.every((run) => isTerminal(run.state))) {
186
+ throw new Error(`recover cannot inspect active members; the race has settled — recover its winning result with --run ${group.winnerRunId ?? "<id from the callback>"} and a reason`);
187
+ }
188
+ log.info(`recover group ${group.id} by ${callerSessionId}: ${reason}`);
189
+ return summarizeGroup(group, members);
190
+ }
191
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
192
+ if (run.groupId) {
193
+ const { group } = host.getGroup(run.groupId);
194
+ groupReady(group);
195
+ if (!isTerminal(run.state))
196
+ throw new Error("the group has reported its outcome; recover reads finished results only and cannot wait for this member");
197
+ }
198
+ else if (!isTerminal(run.state) || !settled(run)) {
199
+ notRecoverable(`run ${run.id}`, run);
200
+ }
201
+ log.info(`recover run ${run.id} by ${callerSessionId}: ${reason}`);
202
+ return summarize(run);
203
+ }
204
+ if (input.operation === "cancel") {
205
+ if (typeof input.group_id === "string") {
206
+ const { group, members } = host.getGroup(input.group_id);
207
+ for (const member of members)
208
+ assertOwns(callerSessionId, member);
209
+ const cancelled = host.cancelGroup(group.id);
210
+ return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)));
211
+ }
212
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
213
+ assertOwns(callerSessionId, run);
214
+ // cancel() only aborts; the row flips ~100 ms later. A receipt still saying
215
+ // `running` reads as "cancel failed", so wait for the flip, briefly.
216
+ host.cancel(run.id);
217
+ return summarize(await cancelledOrCurrent(host, run.id, CANCEL_WAIT_MS));
218
+ }
219
+ if (input.operation === "message") {
220
+ // The one request `pier task run --run` sends: the run's state, not the
221
+ // caller, decides whether the text steers, queues or resumes, so a status
222
+ // query never has to exist.
223
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
224
+ assertOwns(callerSessionId, run);
225
+ const message = requiredString(input.message, "message");
226
+ if (isTerminal(run.state)) {
227
+ // A resumed run is a new run with its own callback.
228
+ const callbackMode = callbackModeOf(input);
229
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
230
+ const resumed = host.resume(run.id, message, { invokedBySessionId: callerSessionId, callbackSessionId, callbackMode, background: true });
231
+ return { delivery: "resume", run: receipt(summarize(resumed), callbackSessionId, callbackMode, callerSessionId) };
232
+ }
233
+ if (input.callback !== undefined || input.callback_session_id !== undefined) {
234
+ throw new Error(`run ${run.id} is ${run.state}: callback options apply to a resumed run only; drop them to steer or follow up`);
235
+ }
236
+ const delivery = input.after === true ? "follow_up" : "steer";
237
+ return { delivery, message: await host.control(run.id, callerSessionId, delivery, message) };
238
+ }
239
+ throw new Error("unknown task operation");
240
+ }
241
+ /** A single run's draft: the top-level shorthand (`prompt` …) or `task`, never both. */
242
+ function inlineDraft(input) {
243
+ const { prompt, cwd, launch, name, timeoutSeconds } = input;
244
+ if (prompt === undefined)
245
+ return record(input.task) ?? undefined;
246
+ if (input.task !== undefined)
247
+ throw new Error("use either prompt or task");
248
+ return { prompt, cwd, launch, name, timeoutSeconds };
249
+ }
250
+ /** A label for the Console, not an identifier. */
251
+ function nameFrom(text) {
252
+ const line = text.split("\n")
253
+ .map((l) => l.replace(/^[\s#>*-]+/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim())
254
+ .find(Boolean) ?? "subagent";
255
+ return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
256
+ }
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. */
260
+ function resolveModel(name, menu) {
261
+ const needle = name.trim().toLowerCase();
262
+ 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 };
267
+ const slash = name.indexOf("/");
268
+ if (!hits.length && slash > 0 && slash < name.length - 1)
269
+ return { model: { provider: name.slice(0, slash), id: name.slice(slash + 1) } };
270
+ throw new Error(`model "${name}" matches ${String(hits.length)} of the menu:\n${menuLines(hits.length ? hits : menu)}`);
271
+ }
272
+ /** 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")
274
+ || "(no model is pinned or available)";
275
+ /** A `prompt` shorthand becomes a fresh Agent action in the caller's own
276
+ * directory; everything the caller did spell out passes through to parseDraft. */
277
+ async function expandDraft(definitions, menu, raw, callerSessionId) {
278
+ let draft = record(raw);
279
+ if (!draft)
280
+ return raw;
281
+ if (typeof draft.prompt === "string") {
282
+ if (draft.action !== undefined)
283
+ throw new Error("use either prompt (shorthand) or action");
284
+ const { prompt, cwd, launch, name, ...rest } = draft;
285
+ draft = { ...rest, name, action: { type: "agent", session: { mode: "fresh", cwd }, prompt, launch } };
286
+ }
287
+ const action = record(draft.action);
288
+ const session = record(action?.session);
289
+ const absolute = async (cwd) => {
290
+ const base = await definitions.sessionCwd(callerSessionId);
291
+ if (!base)
292
+ throw new Error(`cwd ${cwd === undefined ? "omitted" : `"${String(cwd)}" is relative`} and the calling session has no working directory; give an absolute path`);
293
+ return resolve(base, typeof cwd === "string" ? cwd : ".");
294
+ };
295
+ const relative = (cwd) => cwd === undefined || (typeof cwd === "string" && !isAbsolute(cwd));
296
+ if (action?.type === "agent" && session?.mode === "fresh" && relative(session.cwd)) {
297
+ draft = { ...draft, action: { ...action, session: { ...session, cwd: await absolute(session.cwd) } } };
298
+ }
299
+ if (action?.type === "bash" && relative(action.cwd)) {
300
+ draft = { ...draft, action: { ...action, cwd: await absolute(action.cwd) } };
301
+ }
302
+ const label = action?.prompt ?? action?.script;
303
+ if (draft.name === undefined && typeof label === "string")
304
+ draft = { ...draft, name: nameFrom(label) };
305
+ const launch = record(action?.launch);
306
+ if (typeof launch?.model === "string") {
307
+ const { model, thinking } = resolveModel(launch.model, await menu());
308
+ const resolved = { ...launch, model, ...(launch.thinking === undefined && thinking ? { thinking } : {}) };
309
+ draft = { ...draft, action: { ...record(draft.action), launch: resolved } };
310
+ }
311
+ return draft;
312
+ }
313
+ /** Persisted like any task (kind "subagent", filtered from default lists) so
314
+ * runs stay auditable and resumable. */
315
+ async function resolveDraft(definitions, menu, raw, callerSessionId) {
316
+ const draft = record(await expandDraft(definitions, menu, raw, callerSessionId));
317
+ if (!draft)
318
+ throw new Error("task definition required");
319
+ if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
320
+ throw new Error("inline subagent tasks must use a manual trigger");
321
+ }
322
+ // Delivery of a one-off run is the top-level fields' business; a nested
323
+ // callback only means anything on a stored definition's schedule.
324
+ if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
325
+ throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
326
+ }
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
+ }
335
+ }
@@ -1,5 +1,5 @@
1
1
  // The area's HTTP surface. A route names the caller and hands the decision to
2
- // TaskService: policy here would be policy the task tool does not get.
2
+ // TaskService: policy here would be policy `pier task` does not get.
3
3
  import { record, requiredString } from "./definitions.js";
4
4
  /** Reject malformed filters instead of silently widening a global query. */
5
5
  function runQuery(params) {
@@ -184,16 +184,6 @@ export function registerTaskRoutes(app, tasks, activity) {
184
184
  return c.json({ error: String(err) }, 400);
185
185
  }
186
186
  });
187
- app.post("/api/task-messages/:id/reply", async (c) => {
188
- const body = record(await jsonBody(c.req));
189
- try {
190
- const source = requiredString(body?.sourceSessionId, "sourceSessionId");
191
- return c.json(await tasks.reply(c.req.param("id"), source, requiredString(body?.message, "message")), 202);
192
- }
193
- catch (err) {
194
- return c.json({ error: String(err) }, 400);
195
- }
196
- });
197
187
  app.post("/api/task-runs/:id/cancel", (c) => {
198
188
  try {
199
189
  return c.json(tasks.cancel(c.req.param("id")), 202);
@@ -1,34 +1,22 @@
1
- // A definition plus an input becomes a queued run. The depth and per-root
2
- // limits are decided here because every caller enqueues through this one door.
1
+ // A definition plus an input becomes a queued run; every caller enqueues
2
+ // through this one door.
3
3
  import { logger } from "../log.js";
4
4
  import { newId } from "./definitions.js";
5
5
  const log = logger("tasks");
6
- const MAX_DEPTH = 2;
7
- const MAX_CHILDREN_PER_ROOT = 16;
8
6
  export class TaskRunQueue {
9
7
  store;
10
8
  callbacks;
11
- getRun;
12
9
  execute;
13
10
  changed;
14
- constructor(store, callbacks, getRun, execute, changed) {
11
+ constructor(store, callbacks, execute, changed) {
15
12
  this.store = store;
16
13
  this.callbacks = callbacks;
17
- this.getRun = getRun;
18
14
  this.execute = execute;
19
15
  this.changed = changed;
20
16
  }
21
17
  /** Validate and persist only; callers may stage several runs in a transaction. */
22
18
  prepare(definition, input, source, parentRunId, provenance) {
23
19
  const id = newId();
24
- const parent = parentRunId ? this.getRun(parentRunId) : null;
25
- const depth = provenance.depth ?? (parent ? parent.depth + 1 : 0);
26
- const rootRunId = provenance.rootRunId ?? parent?.rootRunId ?? id;
27
- if (depth > MAX_DEPTH)
28
- throw new Error(`subagent depth limit is ${MAX_DEPTH}`);
29
- if (depth > 0 && this.store.listRunsByRoot(rootRunId, MAX_CHILDREN_PER_ROOT + 1).filter((run) => run.depth > 0).length >= MAX_CHILDREN_PER_ROOT) {
30
- throw new Error(`subagent child limit is ${MAX_CHILDREN_PER_ROOT} per root run`);
31
- }
32
20
  const invokedBySessionId = provenance.invokedBySessionId ?? null;
33
21
  const sourceSessionId = provenance.sourceSessionId ?? invokedBySessionId;
34
22
  const sessionMode = definition.action.type === "agent"
@@ -50,8 +38,6 @@ export class TaskRunQueue {
50
38
  taskRevision: definition.revision,
51
39
  parentRunId,
52
40
  groupId: provenance.groupId ?? null,
53
- rootRunId,
54
- depth,
55
41
  resumedFromRunId: provenance.resumedFromRunId ?? null,
56
42
  triggerSource: source,
57
43
  invokedBySessionId,
@@ -86,13 +72,12 @@ export class TaskRunQueue {
86
72
  /** Publish and execute only after all records this run relies on committed. */
87
73
  start(run) {
88
74
  this.changed(run);
89
- const { id, depth, triggerSource: source } = run;
75
+ const { id, triggerSource: source } = run;
90
76
  const overlapped = run.skipReason === "overlap";
91
77
  const definition = run.context.definition;
92
78
  // Why a run exists is answerable only here: the row keeps the ids, not the
93
79
  // reason. A watch probe queues every interval, so it logs at debug.
94
- const queued = `run ${id} ${run.state}: ${definition.name} via ${source}` +
95
- `${overlapped ? " (overlapped)" : ""}${depth > 0 ? ` depth ${String(depth)}` : ""}`;
80
+ const queued = `run ${id} ${run.state}: ${definition.name} via ${source}${overlapped ? " (overlapped)" : ""}`;
96
81
  if (source === "watch" && !overlapped)
97
82
  log.debug(queued);
98
83
  else
@@ -9,7 +9,7 @@ import { TaskExecution } from "./execution.js";
9
9
  import { TaskGroups } from "./groups.js";
10
10
  import { TaskMessenger } from "./messages.js";
11
11
  import { TaskRunQueue } from "./runs.js";
12
- import { handleTaskTool } from "./tool.js";
12
+ import { handleTask } from "./operations.js";
13
13
  import { isTerminal } from "./types.js";
14
14
  const log = logger("tasks");
15
15
  /** Not `renderedPrompt`: that carries the preamble and input wrapper, which the
@@ -44,14 +44,13 @@ export class TaskService {
44
44
  this.hub = hub;
45
45
  this.instance = instance;
46
46
  const unreachable = (sessionId, what, why) => this.unreachable(sessionId, what, why);
47
- this.messages = new TaskMessenger(store, router, hub, (runId, prompt, fromSessionId) => this.prepareResume(runId, prompt, { invokedBySessionId: fromSessionId, callbackSessionId: fromSessionId, background: true }), unreachable, (run) => this.runs.start(run));
47
+ this.messages = new TaskMessenger(store, router, hub, unreachable);
48
48
  this.definitions = new TaskDefinitions(store, factory, router, hub, instance?.systemActions);
49
49
  this.callbacks = new TaskCallbacks(store, router, (run) => this.changed(run), unreachable);
50
50
  this.groups = new TaskGroups(store, router, {
51
51
  getRun: (id) => this.getRun(id),
52
52
  cancel: (id) => { this.cancel(id); },
53
- openDecisionId: (runId) => this.messages.openDecisionId(runId),
54
- prepareMember: (taskId, groupId, callerSessionId, parentRunId) => this.prepareRun(taskId, null, "agent", parentRunId, {
53
+ prepareMember: (taskId, groupId, callerSessionId) => this.prepareRun(taskId, null, "agent", null, {
55
54
  invokedBySessionId: callerSessionId,
56
55
  sourceSessionId: callerSessionId,
57
56
  callbackSessionId: null,
@@ -72,9 +71,8 @@ export class TaskService {
72
71
  cancel: (id) => { this.cancel(id); },
73
72
  settled: (run) => this.settled(run),
74
73
  changed: (run) => this.changed(run),
75
- openDecisionId: (runId) => this.messages.openDecisionId(runId),
76
74
  });
77
- this.runs = new TaskRunQueue(store, this.callbacks, (id) => this.getRun(id), (run) => this.execution.start(run), (run) => this.changed(run));
75
+ this.runs = new TaskRunQueue(store, this.callbacks, (run) => this.execution.start(run), (run) => this.changed(run));
78
76
  }
79
77
  start(tickMs = 1000) {
80
78
  if (this.timer)
@@ -147,7 +145,7 @@ export class TaskService {
147
145
  create(raw, creator = "http") {
148
146
  return this.definitions.create(raw, creator);
149
147
  }
150
- /** `by` is how owning code says so; the routes and the tool have none (definitions.ts). */
148
+ /** `by` is how owning code says so; the routes and `pier task` have none (definitions.ts). */
151
149
  update(id, raw, by) {
152
150
  return this.definitions.update(id, raw, by);
153
151
  }
@@ -169,16 +167,13 @@ export class TaskService {
169
167
  }
170
168
  getRunView(id) {
171
169
  const run = this.getRun(id);
172
- return { ...run, pendingDecisionId: this.openDecisionId(id),
170
+ return { ...run,
173
171
  groupCallbackState: run.groupId ? this.store.getGroup(run.groupId)?.callbackState ?? null : null };
174
172
  }
175
173
  listMessages(runId) {
176
174
  this.getRun(runId);
177
175
  return this.messages.list(runId);
178
176
  }
179
- openDecisionId(runId) {
180
- return this.messages.openDecisionId(runId);
181
- }
182
177
  queryRuns(query = {}) {
183
178
  return this.store.queryRuns(query);
184
179
  }
@@ -227,7 +222,8 @@ export class TaskService {
227
222
  set.add(resolve);
228
223
  });
229
224
  }
230
- /** Cascades: orphans must not outlive the delegation that wanted them. */
225
+ /** Cascades down a `task` action's chain: a child must not outlive the run
226
+ * that waits on it. */
231
227
  cancel(id) {
232
228
  const run = this.getRun(id);
233
229
  for (const target of [run, ...this.descendants(run)]) {
@@ -242,23 +238,15 @@ export class TaskService {
242
238
  getGroup(id) {
243
239
  return this.groups.members(id);
244
240
  }
245
- runGroup(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode) {
246
- this.refusePaused(parentRunId);
247
- return this.groups.runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId, callbackMode);
241
+ runGroup(definitions, join, callerSessionId, callbackSessionId, callbackMode) {
242
+ this.refusePaused();
243
+ return this.groups.runAll(definitions, join, callerSessionId, callbackSessionId, callbackMode);
248
244
  }
249
245
  descendants(run) {
250
- const byParent = new Map();
251
- for (const member of this.store.listRunsByRoot(run.rootRunId, 500)) {
252
- if (!member.parentRunId)
253
- continue;
254
- const siblings = byParent.get(member.parentRunId) ?? [];
255
- siblings.push(member);
256
- byParent.set(member.parentRunId, siblings);
257
- }
258
246
  const collected = [];
259
247
  const queue = [run.id];
260
248
  while (queue.length > 0) {
261
- for (const child of byParent.get(queue.shift()) ?? []) {
249
+ for (const child of this.store.listChildRuns(queue.shift())) {
262
250
  collected.push(child);
263
251
  queue.push(child.id);
264
252
  }
@@ -273,20 +261,7 @@ export class TaskService {
273
261
  throw new Error("terminal run cannot be steered; resume it instead");
274
262
  return this.messages.control(run, fromSessionId, mode, message);
275
263
  }
276
- reply(messageId, fromSessionId, message) {
277
- return this.messages.reply(messageId, fromSessionId, message);
278
- }
279
264
  resume(id, message, provenance = {}) {
280
- const { run, expired } = this.store.transact(() => ({
281
- run: this.prepareResume(id, message, provenance),
282
- expired: this.messages.expireDecisions(id, "superseded by a manual resume"),
283
- }));
284
- for (const message of expired)
285
- this.messages.changed(message);
286
- this.runs.start(run);
287
- return run;
288
- }
289
- prepareResume(id, message, provenance) {
290
265
  this.refusePaused();
291
266
  const prior = this.getRun(id);
292
267
  if (!isTerminal(prior.state))
@@ -295,19 +270,20 @@ export class TaskService {
295
270
  throw new Error("only persisted Agent runs can be resumed");
296
271
  }
297
272
  const prompt = requiredString(message, "message");
298
- return this.runs.prepare(prior.context.definition, null, "agent", null, {
273
+ const run = this.runs.prepare(prior.context.definition, null, "agent", null, {
299
274
  ...provenance,
300
275
  sourceSessionId: provenance.invokedBySessionId ?? prior.invokedBySessionId,
301
276
  targetSessionId: prior.targetSessionId,
302
277
  sessionMode: "reuse",
303
278
  resumedFromRunId: prior.id,
304
- rootRunId: prior.rootRunId,
305
- depth: prior.depth,
306
279
  resumePrompt: prompt,
307
280
  });
281
+ this.runs.start(run);
282
+ return run;
308
283
  }
309
- tool(raw, callerSessionId) {
310
- return handleTaskTool(this, this.definitions, this.store, this.messages, raw, callerSessionId);
284
+ /** What `pier task` asks, under the calling session's identity. */
285
+ handle(raw, callerSessionId) {
286
+ return handleTask(this, this.definitions, this.store, raw, callerSessionId);
311
287
  }
312
288
  /** An agent picks from names that exist right now, never from memory. */
313
289
  async models() {
@@ -375,7 +351,6 @@ export class TaskService {
375
351
  state: run.state,
376
352
  targetSessionId: run.targetSessionId,
377
353
  sessionMode: run.sessionMode,
378
- depth: run.depth,
379
354
  prompt: runPrompt(run),
380
355
  queuedAt: run.queuedAt,
381
356
  startedAt: run.startedAt,
@@ -104,16 +104,13 @@ export class TaskStore {
104
104
  }
105
105
  const limit = clamp(query.limit ?? 50, 200);
106
106
  const rows = this.db.prepare(`
107
- SELECT r.json, g.callback_state AS group_callback_state,
108
- (SELECT m.id FROM task_messages m WHERE m.run_id = r.id
109
- AND m.state IN ('pending', 'delivered') AND json_extract(m.json, '$.kind') = 'decision'
110
- ORDER BY m.created_at, m.id LIMIT 1) AS decision_id
107
+ SELECT r.json, g.callback_state AS group_callback_state
111
108
  FROM task_runs r LEFT JOIN task_groups g ON g.id = json_extract(r.json, '$.groupId')
112
109
  ${where.length ? `WHERE ${where.join(" AND ")}` : ""}
113
110
  ORDER BY r.queued_at DESC, r.id DESC LIMIT ?
114
111
  `).all(...params, limit + 1);
115
112
  const runs = rows.slice(0, limit).map((row) => ({
116
- ...JSON.parse(row.json), pendingDecisionId: row.decision_id, groupCallbackState: row.group_callback_state,
113
+ ...JSON.parse(row.json), groupCallbackState: row.group_callback_state,
117
114
  }));
118
115
  const last = runs.at(-1);
119
116
  return {
@@ -134,12 +131,20 @@ export class TaskStore {
134
131
  `, since);
135
132
  return [...active, ...recent].sort((a, b) => b.queuedAt - a.queuedAt || b.id.localeCompare(a.id));
136
133
  }
137
- listRunsByRoot(rootRunId, limit = 100) {
134
+ /** A `task` action's child runs; a cancel walks them. */
135
+ listChildRuns(parentRunId) {
138
136
  return this.#many(`
139
137
  SELECT json FROM task_runs
140
- WHERE json_extract(json, '$.rootRunId') = ?
141
- ORDER BY queued_at LIMIT ?
142
- `, rootRunId, clamp(limit, 500));
138
+ WHERE json_extract(json, '$.parentRunId') = ?
139
+ ORDER BY queued_at LIMIT 500
140
+ `, parentRunId);
141
+ }
142
+ /** Someone waits on this run's result — its own callback or its group's — so
143
+ * the run is one turn of its session and may not delegate (design 09). */
144
+ supervised(run) {
145
+ if (run.callbackSessionId !== null)
146
+ return true;
147
+ return run.groupId !== null && (this.getGroup(run.groupId)?.callbackSessionId ?? null) !== null;
143
148
  }
144
149
  countActiveRuns() {
145
150
  const row = this.sql("SELECT COUNT(*) AS n FROM task_runs WHERE state IN ('queued', 'running')").get();
@@ -239,13 +244,8 @@ export class TaskStore {
239
244
  run: row.run_json === null ? undefined : JSON.parse(row.run_json),
240
245
  }));
241
246
  }
242
- /** Decisions are excluded: they have no timeout and stay answerable across
243
- * restarts — a reply to a terminal run resumes it. */
244
247
  expirePendingMessages() {
245
- return this.#many(`
246
- SELECT json FROM task_messages
247
- WHERE state = 'pending' AND json_extract(json, '$.kind') != 'decision'
248
- `).map((message) => {
248
+ return this.#many("SELECT json FROM task_messages WHERE state = 'pending'").map((message) => {
249
249
  message.state = "expired";
250
250
  // "Confirmed", not "completed": the proof lives in a transcript this
251
251
  // layer cannot see.