@timqi/pier 0.1.3 → 0.1.4

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 (97) hide show
  1. package/README.md +8 -3
  2. package/dist/agent/config.js +9 -3
  3. package/dist/agent/events.js +1 -2
  4. package/dist/agent/packages.js +1 -7
  5. package/dist/agent/pi.js +6 -47
  6. package/dist/channels/config.js +30 -20
  7. package/dist/channels/routes.js +0 -3
  8. package/dist/channels/slack-api.js +19 -10
  9. package/dist/channels/slack-cli.js +503 -0
  10. package/dist/channels/slack-directory.js +2 -2
  11. package/dist/channels/slack-outbound.js +1 -1
  12. package/dist/channels/slack-thread.js +41 -0
  13. package/dist/channels/slack-transcript.js +107 -0
  14. package/dist/channels/slack.js +9 -17
  15. package/dist/channels/types.js +0 -1
  16. package/dist/cli.js +158 -7
  17. package/dist/core/identity.js +20 -11
  18. package/dist/core/reply.js +16 -15
  19. package/dist/core/router.js +4 -1
  20. package/dist/db.js +28 -0
  21. package/dist/main.js +19 -32
  22. package/dist/paths.js +3 -0
  23. package/dist/secrets.js +2 -1
  24. package/dist/socket.js +95 -0
  25. package/dist/tasks/agent.js +5 -6
  26. package/dist/tasks/callbacks.js +2 -2
  27. package/dist/tasks/cli.js +215 -0
  28. package/dist/tasks/definitions.js +3 -3
  29. package/dist/tasks/execution.js +1 -3
  30. package/dist/tasks/groups.js +2 -5
  31. package/dist/tasks/messages.js +18 -158
  32. package/dist/tasks/operations.js +323 -0
  33. package/dist/tasks/routes.js +1 -11
  34. package/dist/tasks/runs.js +5 -20
  35. package/dist/tasks/service.js +18 -43
  36. package/dist/tasks/store.js +15 -15
  37. package/dist/tools.js +17 -0
  38. package/dist/vault.js +107 -0
  39. package/dist/web/auth.js +2 -1
  40. package/dist/web/public/assets/{activity-BSMeRcN2.js → activity-NLS8W9yl.js} +2 -2
  41. package/dist/web/public/assets/activity-NLS8W9yl.js.br +0 -0
  42. package/dist/web/public/assets/activity-NLS8W9yl.js.gz +0 -0
  43. package/dist/web/public/assets/{boards-BCWQMZry.js → boards-DleegoiC.js} +1 -1
  44. package/dist/web/public/assets/boards-DleegoiC.js.br +0 -0
  45. package/dist/web/public/assets/boards-DleegoiC.js.gz +0 -0
  46. package/dist/web/public/assets/{explorer-Cr4XTi4j.js → explorer-vJvV1Sx0.js} +1 -1
  47. package/dist/web/public/assets/explorer-vJvV1Sx0.js.br +0 -0
  48. package/dist/web/public/assets/explorer-vJvV1Sx0.js.gz +0 -0
  49. package/dist/web/public/assets/{index-DVIt5Gio.css → index-Bs-gol9o.css} +1 -1
  50. package/dist/web/public/assets/index-Bs-gol9o.css.br +0 -0
  51. package/dist/web/public/assets/{index-DVIt5Gio.css.gz → index-Bs-gol9o.css.gz} +0 -0
  52. package/dist/web/public/assets/index-Cyp2DKKF.js +85 -0
  53. package/dist/web/public/assets/index-Cyp2DKKF.js.br +0 -0
  54. package/dist/web/public/assets/index-Cyp2DKKF.js.gz +0 -0
  55. package/dist/web/public/assets/{runs-DdERzeac.js → runs-C5FveZ75.js} +1 -1
  56. package/dist/web/public/assets/runs-C5FveZ75.js.br +0 -0
  57. package/dist/web/public/assets/runs-C5FveZ75.js.gz +0 -0
  58. package/dist/web/public/assets/settings-nJHIBbAz.js +5 -0
  59. package/dist/web/public/assets/settings-nJHIBbAz.js.br +0 -0
  60. package/dist/web/public/assets/settings-nJHIBbAz.js.gz +0 -0
  61. package/dist/web/public/assets/{task-runs-C-dGUDsH.js → task-runs-CJJ2j5Ks.js} +1 -1
  62. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.br +0 -0
  63. package/dist/web/public/assets/task-runs-CJJ2j5Ks.js.gz +0 -0
  64. package/dist/web/public/assets/{tasks-CY30H1u1.js → tasks-R2Pv0Abg.js} +1 -1
  65. package/dist/web/public/assets/tasks-R2Pv0Abg.js.br +0 -0
  66. package/dist/web/public/assets/tasks-R2Pv0Abg.js.gz +0 -0
  67. package/dist/web/public/index.html +2 -2
  68. package/dist/web/public/index.html.br +0 -0
  69. package/dist/web/public/index.html.gz +0 -0
  70. package/dist/web/vault.js +68 -0
  71. package/docs/deploy.md +33 -10
  72. package/package.json +1 -1
  73. package/skills/pier-help/SKILL.md +10 -6
  74. package/skills/pier-slack/SKILL.md +52 -71
  75. package/skills/pier-tasks/SKILL.md +48 -145
  76. package/skills/pier-vault/SKILL.md +36 -0
  77. package/dist/channels/slack-tool.js +0 -416
  78. package/dist/tasks/tool.js +0 -416
  79. package/dist/web/public/assets/activity-BSMeRcN2.js.br +0 -0
  80. package/dist/web/public/assets/activity-BSMeRcN2.js.gz +0 -0
  81. package/dist/web/public/assets/boards-BCWQMZry.js.br +0 -0
  82. package/dist/web/public/assets/boards-BCWQMZry.js.gz +0 -0
  83. package/dist/web/public/assets/explorer-Cr4XTi4j.js.br +0 -0
  84. package/dist/web/public/assets/explorer-Cr4XTi4j.js.gz +0 -0
  85. package/dist/web/public/assets/index-C7tA0Ufu.js +0 -85
  86. package/dist/web/public/assets/index-C7tA0Ufu.js.br +0 -0
  87. package/dist/web/public/assets/index-C7tA0Ufu.js.gz +0 -0
  88. package/dist/web/public/assets/index-DVIt5Gio.css.br +0 -0
  89. package/dist/web/public/assets/runs-DdERzeac.js.br +0 -0
  90. package/dist/web/public/assets/runs-DdERzeac.js.gz +0 -0
  91. package/dist/web/public/assets/settings-CQDAHoMM.js +0 -5
  92. package/dist/web/public/assets/settings-CQDAHoMM.js.br +0 -0
  93. package/dist/web/public/assets/settings-CQDAHoMM.js.gz +0 -0
  94. package/dist/web/public/assets/task-runs-C-dGUDsH.js.br +0 -0
  95. package/dist/web/public/assets/task-runs-C-dGUDsH.js.gz +0 -0
  96. package/dist/web/public/assets/tasks-CY30H1u1.js.br +0 -0
  97. package/dist/web/public/assets/tasks-CY30H1u1.js.gz +0 -0
@@ -1,15 +1,15 @@
1
- // What a parent and a child say to each other while a run is going. Every
2
- // message is a durable row before it is a delivery: either end may be mid-turn
3
- // or gone, and an undelivered message is retried, expired and said, never
4
- // dropped (§5). Delivery itself belongs to outbox.ts.
1
+ // What a parent says to a child while a run is going. Every message is a
2
+ // durable row before it is a delivery: the child may be mid-turn or gone, and
3
+ // an undelivered message is retried, expired and said, never dropped (§5).
4
+ // Delivery itself belongs to outbox.ts.
5
5
  import { runSource } from "./callbacks.js";
6
6
  import { newId } from "./definitions.js";
7
7
  import { Outbox } from "./outbox.js";
8
8
  import { isTerminal } from "./types.js";
9
9
  const MAX_MESSAGE_LENGTH = 16 * 1024;
10
- /** `answered` is a delivery that was read; `expired` is one given up on. */
10
+ /** `expired` is a delivery given up on. */
11
11
  const ENGINE_STATE = {
12
- pending: "pending", failed: "failed", delivered: "delivered", answered: "delivered", expired: "abandoned",
12
+ pending: "pending", failed: "failed", delivered: "delivered", expired: "abandoned",
13
13
  };
14
14
  function bounded(content) {
15
15
  const text = content.trim();
@@ -22,20 +22,14 @@ function bounded(content) {
22
22
  export class TaskMessenger {
23
23
  store;
24
24
  hub;
25
- prepareResume;
26
25
  unreachable;
27
- startRun;
28
26
  outbox;
29
27
  constructor(store, router, hub,
30
- /** Prepares a continuation; it starts only after the reply commits. */
31
- prepareResume,
32
28
  /** Reports a delivery nobody can complete (service.ts owns the surfaces). */
33
- unreachable, startRun) {
29
+ unreachable) {
34
30
  this.store = store;
35
31
  this.hub = hub;
36
- this.prepareResume = prepareResume;
37
32
  this.unreachable = unreachable;
38
- this.startRun = startRun;
39
33
  this.outbox = new Outbox(router, {
40
34
  id: ({ message }) => message.id,
41
35
  reload: (id) => {
@@ -50,10 +44,8 @@ export class TaskMessenger {
50
44
  message.state = "delivered";
51
45
  message.deliveredAt = Date.now();
52
46
  }
53
- else if (callbackState === "abandoned") {
47
+ else if (callbackState === "abandoned")
54
48
  message.state = "expired";
55
- message.answeredAt = Date.now();
56
- }
57
49
  else
58
50
  message.state = callbackState ?? "pending";
59
51
  this.store.saveMessage(message);
@@ -76,25 +68,6 @@ export class TaskMessenger {
76
68
  for (const message of this.store.expirePendingMessages())
77
69
  this.changed(message);
78
70
  }
79
- /** The unanswered decision on a run, if any. */
80
- openDecisionId(runId) {
81
- return this.store.listMessages(runId).find((m) => m.kind === "decision" && (m.state === "pending" || m.state === "delivered"))?.id ?? null;
82
- }
83
- /** A manual continuation supersedes an unanswered decision (design 04):
84
- * one continuation per run, never two racing ones. */
85
- expireDecisions(runId, reason) {
86
- const expired = [];
87
- for (const message of this.store.listMessages(runId)) {
88
- if (message.kind !== "decision" || (message.state !== "pending" && message.state !== "delivered"))
89
- continue;
90
- message.state = "expired";
91
- message.error = reason;
92
- message.answeredAt = Date.now();
93
- this.store.saveMessage(message);
94
- expired.push(message);
95
- }
96
- return expired;
97
- }
98
71
  list(runId) {
99
72
  return this.store.listMessages(runId);
100
73
  }
@@ -102,7 +75,7 @@ export class TaskMessenger {
102
75
  return this.store.listRecentMessages(since);
103
76
  }
104
77
  async control(run, fromSessionId, kind, content) {
105
- const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
78
+ const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content);
106
79
  this.changed(message);
107
80
  if (run.targetSessionId)
108
81
  this.deliver(message, run.targetSessionId);
@@ -112,9 +85,8 @@ export class TaskMessenger {
112
85
  if (!run.targetSessionId)
113
86
  return;
114
87
  for (const message of this.store.listMessages(run.id)) {
115
- if (message.state !== "pending" || (message.kind !== "steer" && message.kind !== "follow_up"))
116
- continue;
117
- this.deliver(message, run.targetSessionId);
88
+ if (message.state === "pending")
89
+ this.deliver(message, run.targetSessionId);
118
90
  }
119
91
  }
120
92
  /** Delivery is fire-and-forget, so this sweep is what closes a failed one.
@@ -124,30 +96,13 @@ export class TaskMessenger {
124
96
  if (!run)
125
97
  continue;
126
98
  // Expiring a dead control is not a retry, so it ignores the backoff.
127
- if ((message.kind === "steer" || message.kind === "follow_up") && isTerminal(run.state)) {
99
+ if (isTerminal(run.state)) {
128
100
  message.state = "expired";
129
101
  message.error = "run finished before delivery completed";
130
102
  this.store.saveMessage(message);
131
103
  this.changed(message);
132
104
  continue;
133
105
  }
134
- // A reply that resumed a terminal run is the continuation's prompt, not a
135
- // system input: the transcript read would re-inject it and start a turn no
136
- // run owns. The continuation's `startedAt` is the proof instead.
137
- if (message.kind === "reply" && isTerminal(run.state)) {
138
- const resumed = message.resumeRunId ? this.store.getRun(message.resumeRunId) : undefined;
139
- if (resumed?.startedAt)
140
- this.delivered(message);
141
- else if (!resumed)
142
- this.abandon(message, `continuation ${message.resumeRunId ?? "(none)"} is gone`);
143
- else if (isTerminal(resumed.state)) {
144
- // Never started and never will: said now, not four minutes later at
145
- // the ceiling. A duplicate report beats a special case that could
146
- // suppress the only one.
147
- this.abandon(message, `continuation ${resumed.id} ${resumed.state} before it started`);
148
- }
149
- continue;
150
- }
151
106
  if ((message.nextAttemptAt ?? 0) > now)
152
107
  continue;
153
108
  const target = message.toSessionId || run.targetSessionId;
@@ -155,76 +110,17 @@ export class TaskMessenger {
155
110
  this.deliver(message, target);
156
111
  }
157
112
  }
158
- /** Returns the receipt immediately; the reply arrives as a follow-up (active
159
- * run) or resumes the session (terminal run). A decision steers the
160
- * supervisor, or a blocked child would wait out its whole turn. */
161
- async contact(run, fromSessionId, reason, content) {
162
- if (!run.invokedBySessionId)
163
- throw new Error("run has no supervisor session");
164
- if (reason === "decision" && this.openDecisionId(run.id)) {
165
- throw new Error("run already has a pending supervisor decision");
166
- }
167
- const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
168
- this.changed(message);
169
- this.deliver(message, run.invokedBySessionId);
170
- return this.require(message.id);
171
- }
172
- async reply(questionId, fromSessionId, content) {
173
- const question = this.require(questionId);
174
- if (question.kind !== "decision")
175
- throw new Error("message is not a decision request");
176
- if (question.toSessionId !== fromSessionId)
177
- throw new Error("only the addressed supervisor may reply");
178
- const existing = this.store.listMessages(question.runId).find((m) => m.kind === "reply" && m.replyTo === question.id);
179
- const text = bounded(content);
180
- if (existing) {
181
- if (existing.content !== text)
182
- throw new Error("decision already answered with different content");
183
- return existing;
184
- }
185
- if (question.state !== "delivered" && question.state !== "pending") {
186
- throw new Error(`decision is ${question.state}`);
187
- }
188
- const run = this.store.getRun(question.runId);
189
- if (!run)
190
- throw new Error(`unknown task run: ${question.runId}`);
191
- // A rejected continuation must leave the question answerable. Persist the
192
- // answer and its queued run together, before either publishes or executes.
193
- const { reply, continuation } = this.store.transact(() => {
194
- const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
195
- question.state = "answered";
196
- question.answeredAt = Date.now();
197
- this.store.saveMessage(question);
198
- const continuation = isTerminal(run.state)
199
- ? this.prepareResume(run.id, this.format(reply, run), fromSessionId)
200
- : undefined;
201
- if (continuation) {
202
- reply.resumeRunId = continuation.id;
203
- this.store.saveMessage(reply);
204
- }
205
- return { reply, continuation };
206
- });
207
- this.changed(question);
208
- this.changed(reply);
209
- if (continuation)
210
- this.startRun(continuation);
211
- else if (run.targetSessionId)
212
- this.deliver(reply, run.targetSessionId);
213
- return this.require(reply.id);
214
- }
215
- create(run, kind, fromSessionId, toSessionId, content, replyTo) {
113
+ create(run, kind, fromSessionId, toSessionId, content) {
216
114
  const message = {
217
115
  id: newId(),
218
116
  runId: run.id,
219
117
  kind,
220
118
  fromSessionId,
221
119
  toSessionId,
222
- replyTo,
223
120
  state: "pending",
224
121
  content: bounded(content),
225
122
  createdAt: Date.now(),
226
123
  deliveredAt: null,
227
- answeredAt: null,
228
124
  error: null,
229
125
  attempts: 0,
230
126
  nextAttemptAt: null,
@@ -247,56 +143,20 @@ export class TaskMessenger {
247
143
  callbackAttempts: message.attempts,
248
144
  callbackError: message.error,
249
145
  callbackNextAttemptAt: message.nextAttemptAt,
250
- // Steer whatever someone is blocked on: a follow-up lands only once the
251
- // recipient runs out of tool calls. Progress is a follow-up because nobody waits.
252
- ...(message.kind === "follow_up" || message.kind === "progress" ? {} : { callbackMode: "steer" }),
146
+ // A steer interrupts; a follow-up lands only once the recipient runs out
147
+ // of tool calls.
148
+ ...(message.kind === "follow_up" ? {} : { callbackMode: "steer" }),
253
149
  };
254
150
  }
255
- /** Proven by something other than the recipient's transcript. */
256
- delivered(message) {
257
- message.state = "delivered";
258
- message.deliveredAt = Date.now();
259
- message.error = null;
260
- message.nextAttemptAt = null;
261
- this.store.saveMessage(message);
262
- this.changed(message);
263
- }
264
- abandon(message, why) {
265
- message.state = "expired";
266
- message.error = why;
267
- message.answeredAt = Date.now();
268
- message.nextAttemptAt = null;
269
- this.store.saveMessage(message);
270
- this.changed(message);
271
- this.told(message, why);
272
- }
273
- /** Both ends are told, and an expired decision stops suppressing its run's
274
- * completion callback, which `execution.ts` decided once and never revisits. */
151
+ /** Both ends are told. */
275
152
  told(message, why) {
276
153
  this.unreachable(message.toSessionId, `a ${message.kind} from run ${message.runId}`, why);
277
154
  if (message.fromSessionId && message.fromSessionId !== message.toSessionId) {
278
155
  this.unreachable(message.fromSessionId, `your ${message.kind} on run ${message.runId}`, why);
279
156
  }
280
- const run = this.store.getRun(message.runId);
281
- if (message.kind !== "decision" || !run?.callbackSessionId)
282
- return;
283
- if (run.callbackState === null && isTerminal(run.state)) {
284
- run.callbackState = "pending"; // the tick sweep delivers it
285
- this.store.saveRun(run);
286
- }
287
157
  }
288
158
  format(message, run) {
289
- const title = run.context.definition.name;
290
- if (message.kind === "progress") {
291
- return `Subagent progress for task "${title}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
292
- }
293
- if (message.kind === "decision") {
294
- return `Subagent needs a decision for task "${title}"\nRun: ${run.id}\nMessage: ${message.id}\nReply with the Task reply operation.\n\n${message.content}`;
295
- }
296
- if (message.kind === "reply") {
297
- return `Supervisor reply for task "${title}"\nRun: ${run.id}\nReply to: ${message.replyTo ?? "-"}\n\n${message.content}`;
298
- }
299
- return `Task guidance for "${title}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
159
+ return `Task guidance for "${run.context.definition.name}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
300
160
  }
301
161
  origin(message, run) {
302
162
  return {
@@ -0,0 +1,323 @@
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
+ return host.models();
134
+ const callbackMode = callbackModeOf(input);
135
+ if (Array.isArray(input.tasks)) {
136
+ // Core-joined fan-out: members run detached, one aggregated callback.
137
+ if (input.task !== undefined || input.task_id !== undefined)
138
+ throw new Error("use either task/task_id or tasks[]");
139
+ if (input.callback_session_id !== undefined)
140
+ throw new Error("callback_session_id applies to a single run only");
141
+ if (input.tasks.length < 2)
142
+ throw new Error("tasks[] needs at least 2 entries; use task for a single run");
143
+ const resolved = [];
144
+ for (const rawEntry of input.tasks) {
145
+ const entry = typeof rawEntry === "string" ? { prompt: rawEntry } : record(rawEntry);
146
+ if (!entry)
147
+ throw new Error("invalid tasks[] entry");
148
+ resolved.push(entry.task_id === undefined
149
+ ? await resolveDraft(definitions, menu, entry, callerSessionId)
150
+ : definitions.get(requiredString(entry.task_id, "task_id")));
151
+ }
152
+ const groupCallbackSessionId = input.callback === "none" ? null : callerSessionId;
153
+ const { group, runs } = host.runGroup(resolved, oneOf(input, "join", ["all", "first"]) ?? "all", callerSessionId, groupCallbackSessionId, callbackMode);
154
+ return receipt(summarizeGroup(group, runs), groupCallbackSessionId, callbackMode, callerSessionId);
155
+ }
156
+ const draft = input.task_id === undefined ? inlineDraft(input) : undefined;
157
+ const task = draft
158
+ ? await resolveDraft(definitions, menu, draft, callerSessionId)
159
+ : definitions.get(requiredString(input.task_id, "task_id"));
160
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
161
+ const run = host.run(task.id, null, "agent", null, {
162
+ invokedBySessionId: callerSessionId,
163
+ sourceSessionId: callerSessionId,
164
+ callbackSessionId,
165
+ callbackMode,
166
+ background: true,
167
+ });
168
+ return receipt(summarize(run), callbackSessionId, callbackMode, callerSessionId);
169
+ }
170
+ if (input.operation === "recover") {
171
+ // History only, never status: readable once the callback has said its last
172
+ // word. The required reason is the friction, and the operator sees it.
173
+ const reason = requiredString(input.reason, "reason");
174
+ // A race winner need not wait for losing members to finish cancelling.
175
+ const groupReady = (group) => {
176
+ if (!group.finishedAt || !settled(group))
177
+ notRecoverable(`group ${group.id}`, group);
178
+ };
179
+ if (typeof input.group_id === "string") {
180
+ const { group, members } = host.getGroup(input.group_id);
181
+ groupReady(group);
182
+ if (!members.every((run) => isTerminal(run.state))) {
183
+ 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`);
184
+ }
185
+ log.info(`recover group ${group.id} by ${callerSessionId}: ${reason}`);
186
+ return summarizeGroup(group, members);
187
+ }
188
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
189
+ if (run.groupId) {
190
+ const { group } = host.getGroup(run.groupId);
191
+ groupReady(group);
192
+ if (!isTerminal(run.state))
193
+ throw new Error("the group has reported its outcome; recover reads finished results only and cannot wait for this member");
194
+ }
195
+ else if (!isTerminal(run.state) || !settled(run)) {
196
+ notRecoverable(`run ${run.id}`, run);
197
+ }
198
+ log.info(`recover run ${run.id} by ${callerSessionId}: ${reason}`);
199
+ return summarize(run);
200
+ }
201
+ if (input.operation === "cancel") {
202
+ if (typeof input.group_id === "string") {
203
+ const { group, members } = host.getGroup(input.group_id);
204
+ for (const member of members)
205
+ assertOwns(callerSessionId, member);
206
+ const cancelled = host.cancelGroup(group.id);
207
+ return summarizeGroup(cancelled, cancelled.memberRunIds.map((id) => host.getRun(id)));
208
+ }
209
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
210
+ assertOwns(callerSessionId, run);
211
+ // cancel() only aborts; the row flips ~100 ms later. A receipt still saying
212
+ // `running` reads as "cancel failed", so wait for the flip, briefly.
213
+ host.cancel(run.id);
214
+ return summarize(await cancelledOrCurrent(host, run.id, CANCEL_WAIT_MS));
215
+ }
216
+ if (input.operation === "message") {
217
+ // The one request `pier task run --run` sends: the run's state, not the
218
+ // caller, decides whether the text steers, queues or resumes, so a status
219
+ // query never has to exist.
220
+ const run = host.getRun(requiredString(input.run_id, "run_id"));
221
+ assertOwns(callerSessionId, run);
222
+ const message = requiredString(input.message, "message");
223
+ if (isTerminal(run.state)) {
224
+ // A resumed run is a new run with its own callback.
225
+ const callbackMode = callbackModeOf(input);
226
+ const callbackSessionId = await callbackTarget(input, definitions, callerSessionId);
227
+ const resumed = host.resume(run.id, message, { invokedBySessionId: callerSessionId, callbackSessionId, callbackMode, background: true });
228
+ return { delivery: "resume", run: receipt(summarize(resumed), callbackSessionId, callbackMode, callerSessionId) };
229
+ }
230
+ if (input.callback !== undefined || input.callback_session_id !== undefined) {
231
+ throw new Error(`run ${run.id} is ${run.state}: callback options apply to a resumed run only; drop them to steer or follow up`);
232
+ }
233
+ const delivery = input.after === true ? "follow_up" : "steer";
234
+ return { delivery, message: await host.control(run.id, callerSessionId, delivery, message) };
235
+ }
236
+ throw new Error("unknown task operation");
237
+ }
238
+ /** A single run's draft: the top-level shorthand (`prompt` …) or `task`, never both. */
239
+ function inlineDraft(input) {
240
+ const { prompt, cwd, launch, name, timeoutSeconds } = input;
241
+ if (prompt === undefined)
242
+ return record(input.task) ?? undefined;
243
+ if (input.task !== undefined)
244
+ throw new Error("use either prompt or task");
245
+ return { prompt, cwd, launch, name, timeoutSeconds };
246
+ }
247
+ /** A label for the Console, not an identifier. */
248
+ function nameFromPrompt(prompt) {
249
+ const line = prompt.split("\n")
250
+ .map((l) => l.replace(/^[\s#>*-]+/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim())
251
+ .find(Boolean) ?? "subagent";
252
+ return line.length > 60 ? `${line.slice(0, 59).trimEnd()}…` : line;
253
+ }
254
+ /** `launch.model` by name (docs/design/09-tasks-cli.md §Models). Anything but
255
+ * one pin or an unpinned `provider/id` is refused with the lines to pick
256
+ * from, so the agent never guesses an id. */
257
+ function resolveModel(name, menu) {
258
+ const needle = name.trim().toLowerCase();
259
+ const full = (pin) => `${pin.provider}/${pin.id}`;
260
+ const line = (pin) => `${full(pin)}${pin.thinking ? ` · ${pin.thinking}` : ""}${pin.note ? ` — ${pin.note}` : ""}`;
261
+ const exact = menu.find((pin) => full(pin).toLowerCase() === needle);
262
+ const hits = exact ? [exact] : menu.filter((pin) => `${full(pin)} ${pin.note ?? ""}`.toLowerCase().includes(needle));
263
+ if (hits.length === 1)
264
+ return { model: { provider: hits[0].provider, id: hits[0].id }, thinking: hits[0].thinking };
265
+ const slash = name.indexOf("/");
266
+ if (!hits.length && slash > 0 && slash < name.length - 1)
267
+ return { model: { provider: name.slice(0, slash), id: name.slice(slash + 1) } };
268
+ const lines = (hits.length ? hits : menu).map(line).join("\n") || "(no model is pinned or available)";
269
+ throw new Error(`model "${name}" matches ${String(hits.length)} of the menu:\n${lines}`);
270
+ }
271
+ /** A `prompt` shorthand becomes a fresh Agent action in the caller's own
272
+ * directory; everything the caller did spell out passes through to parseDraft. */
273
+ async function expandDraft(definitions, menu, raw, callerSessionId) {
274
+ let draft = record(raw);
275
+ if (!draft)
276
+ return raw;
277
+ if (typeof draft.prompt === "string") {
278
+ if (draft.action !== undefined)
279
+ throw new Error("use either prompt (shorthand) or action");
280
+ const { prompt, cwd, launch, name, ...rest } = draft;
281
+ draft = { ...rest, name, action: { type: "agent", session: { mode: "fresh", cwd }, prompt, launch } };
282
+ }
283
+ const action = record(draft.action);
284
+ const session = record(action?.session);
285
+ if (action?.type === "agent" && session?.mode === "fresh" && (session.cwd === undefined || (typeof session.cwd === "string" && !isAbsolute(session.cwd)))) {
286
+ const base = await definitions.sessionCwd(callerSessionId);
287
+ if (!base)
288
+ throw new Error(`cwd ${session.cwd === undefined ? "omitted" : `"${session.cwd}" is relative`} and the calling session has no working directory; give an absolute path`);
289
+ draft = { ...draft, action: { ...action, session: { ...session, cwd: resolve(base, session.cwd ?? ".") } } };
290
+ }
291
+ if (draft.name === undefined && typeof action?.prompt === "string")
292
+ draft = { ...draft, name: nameFromPrompt(action.prompt) };
293
+ const launch = record(action?.launch);
294
+ if (typeof launch?.model === "string") {
295
+ const { model, thinking } = resolveModel(launch.model, await menu());
296
+ const resolved = { ...launch, model, ...(launch.thinking === undefined && thinking ? { thinking } : {}) };
297
+ draft = { ...draft, action: { ...record(draft.action), launch: resolved } };
298
+ }
299
+ return draft;
300
+ }
301
+ /** Persisted like any task (kind "subagent", filtered from default lists) so
302
+ * runs stay auditable and resumable. */
303
+ async function resolveDraft(definitions, menu, raw, callerSessionId) {
304
+ const draft = record(await expandDraft(definitions, menu, raw, callerSessionId));
305
+ if (!draft)
306
+ throw new Error("task definition required");
307
+ if (draft.trigger !== undefined && record(draft.trigger)?.type !== "manual") {
308
+ throw new Error("inline subagent tasks must use a manual trigger");
309
+ }
310
+ // Delivery of a one-off run is the top-level fields' business; a nested
311
+ // callback only means anything on a stored definition's schedule.
312
+ if (draft.callback !== undefined || draft.callback_session_id !== undefined) {
313
+ throw new Error("an inline task draft cannot set callback; use the top-level callback / callback_session_id");
314
+ }
315
+ return definitions.create({ ...draft, trigger: { type: "manual" } }, `session:${callerSessionId}`, "subagent");
316
+ }
317
+ /** The session that launched a run controls it, and so does the run's own
318
+ * session; a `task` action's child inherits its parent's launcher. */
319
+ function assertOwns(callerSessionId, target) {
320
+ if (target.invokedBySessionId !== callerSessionId && target.targetSessionId !== callerSessionId) {
321
+ throw new Error("session does not own this run");
322
+ }
323
+ }
@@ -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);