@timqi/pier 0.0.1

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 (79) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +97 -0
  3. package/dist/agent/config.js +133 -0
  4. package/dist/agent/credentials.js +179 -0
  5. package/dist/agent/events.js +253 -0
  6. package/dist/agent/models.js +15 -0
  7. package/dist/agent/pi.js +296 -0
  8. package/dist/boards/boards.js +200 -0
  9. package/dist/boards/pier.css +445 -0
  10. package/dist/channels/chains.js +67 -0
  11. package/dist/channels/chunk.js +28 -0
  12. package/dist/channels/commands.js +28 -0
  13. package/dist/channels/config.js +172 -0
  14. package/dist/channels/control.js +71 -0
  15. package/dist/channels/conversations.js +65 -0
  16. package/dist/channels/gatekeeper.js +63 -0
  17. package/dist/channels/panel.js +233 -0
  18. package/dist/channels/receipts.js +104 -0
  19. package/dist/channels/routes.js +110 -0
  20. package/dist/channels/runtime.js +76 -0
  21. package/dist/channels/slack-api.js +296 -0
  22. package/dist/channels/slack-directory.js +77 -0
  23. package/dist/channels/slack-outbound.js +121 -0
  24. package/dist/channels/slack-panel.js +122 -0
  25. package/dist/channels/slack-render.js +214 -0
  26. package/dist/channels/slack-tool.js +334 -0
  27. package/dist/channels/slack.js +510 -0
  28. package/dist/channels/telegram-api.js +78 -0
  29. package/dist/channels/telegram-panel.js +113 -0
  30. package/dist/channels/telegram-render.js +96 -0
  31. package/dist/channels/telegram.js +473 -0
  32. package/dist/channels/types.js +27 -0
  33. package/dist/cli.js +101 -0
  34. package/dist/core/hub.js +53 -0
  35. package/dist/core/identity.js +66 -0
  36. package/dist/core/queue.js +11 -0
  37. package/dist/core/reply.js +202 -0
  38. package/dist/core/router.js +189 -0
  39. package/dist/core/types.js +7 -0
  40. package/dist/db.js +268 -0
  41. package/dist/log.js +55 -0
  42. package/dist/main.js +183 -0
  43. package/dist/paths.js +17 -0
  44. package/dist/secrets.js +191 -0
  45. package/dist/service.js +134 -0
  46. package/dist/settings.js +57 -0
  47. package/dist/tasks/agent.js +197 -0
  48. package/dist/tasks/callbacks.js +140 -0
  49. package/dist/tasks/command.js +74 -0
  50. package/dist/tasks/definitions.js +316 -0
  51. package/dist/tasks/execution.js +141 -0
  52. package/dist/tasks/groups.js +187 -0
  53. package/dist/tasks/messages.js +248 -0
  54. package/dist/tasks/routes.js +219 -0
  55. package/dist/tasks/runs.js +104 -0
  56. package/dist/tasks/service.js +282 -0
  57. package/dist/tasks/store.js +168 -0
  58. package/dist/tasks/tool.js +281 -0
  59. package/dist/tasks/types.js +5 -0
  60. package/dist/web/auth.js +280 -0
  61. package/dist/web/files.js +167 -0
  62. package/dist/web/public/assets/index-8CinH1uR.css +2 -0
  63. package/dist/web/public/assets/index-DAgP1Gq8.js +78 -0
  64. package/dist/web/public/icon-192.png +0 -0
  65. package/dist/web/public/icon-32.png +0 -0
  66. package/dist/web/public/icon-512.png +0 -0
  67. package/dist/web/public/icon-maskable-512.png +0 -0
  68. package/dist/web/public/icon-touch-192.png +0 -0
  69. package/dist/web/public/icon.svg +19 -0
  70. package/dist/web/public/index.html +251 -0
  71. package/dist/web/public/manifest.webmanifest +16 -0
  72. package/dist/web/public/sw.js +21 -0
  73. package/dist/web/server.js +366 -0
  74. package/dist/web/session-state.js +39 -0
  75. package/docs/deploy.md +307 -0
  76. package/package.json +55 -0
  77. package/skills/pier-boards/SKILL.md +210 -0
  78. package/skills/pier-slack/SKILL.md +135 -0
  79. package/skills/pier-tasks/SKILL.md +120 -0
@@ -0,0 +1,187 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { Router } from "../core/router.js";
3
+ import { logger } from "../log.js";
4
+ import { runResultText } from "./callbacks.js";
5
+ import { TaskStore } from "./store.js";
6
+ import { isTerminal } from "./types.js";
7
+ const log = logger("tasks");
8
+ /** Core-owned fan-out join: members run detached, the group delivers one
9
+ * aggregated callback when the join condition is met (design 04). */
10
+ export class TaskGroups {
11
+ store;
12
+ router;
13
+ host;
14
+ changed;
15
+ delivering = new Set();
16
+ constructor(store, router, host, changed) {
17
+ this.store = store;
18
+ this.router = router;
19
+ this.host = host;
20
+ this.changed = changed;
21
+ }
22
+ /** Enqueues every member or none: a partially started group is worse than
23
+ * a rejected one. */
24
+ runAll(definitions, join, callerSessionId, parentRunId, callbackSessionId) {
25
+ const group = this.create(join, callerSessionId, callbackSessionId);
26
+ const runs = [];
27
+ try {
28
+ for (const definition of definitions) {
29
+ runs.push(this.host.startMember(definition.id, group.id, callerSessionId, parentRunId));
30
+ }
31
+ }
32
+ catch (error) {
33
+ log.warn(`group ${group.id} rolled back after ${String(runs.length)} members`, error);
34
+ for (const run of runs)
35
+ this.host.cancel(run.id);
36
+ throw error;
37
+ }
38
+ this.setMembers(group, runs.map((run) => run.id));
39
+ return { group: this.get(group.id), runs };
40
+ }
41
+ members(id) {
42
+ const group = this.get(id);
43
+ return { group, members: group.memberRunIds.map((runId) => this.host.getRun(runId)) };
44
+ }
45
+ cancelAll(id) {
46
+ for (const runId of this.get(id).memberRunIds)
47
+ this.host.cancel(runId);
48
+ return this.get(id);
49
+ }
50
+ create(join, invokedBySessionId, callbackSessionId) {
51
+ const group = {
52
+ id: randomUUID(),
53
+ join,
54
+ invokedBySessionId,
55
+ callbackSessionId,
56
+ memberRunIds: [],
57
+ winnerRunId: null,
58
+ callbackState: null,
59
+ callbackAttempts: 0,
60
+ callbackError: null,
61
+ callbackNextAttemptAt: null,
62
+ createdAt: Date.now(),
63
+ finishedAt: null,
64
+ };
65
+ this.store.saveGroup(group);
66
+ return group;
67
+ }
68
+ setMembers(group, runIds) {
69
+ group.memberRunIds = runIds;
70
+ this.store.saveGroup(group);
71
+ this.changed(group);
72
+ }
73
+ get(id) {
74
+ const group = this.store.getGroup(id);
75
+ if (!group)
76
+ throw new Error(`unknown task group: ${id}`);
77
+ return group;
78
+ }
79
+ onSettled(run) {
80
+ if (!run.groupId)
81
+ return;
82
+ const group = this.store.getGroup(run.groupId);
83
+ if (group && !group.finishedAt && group.memberRunIds.length > 0)
84
+ this.evaluate(group);
85
+ }
86
+ recover(now = Date.now()) {
87
+ for (const group of this.store.listOpenGroups(now)) {
88
+ if (!group.finishedAt)
89
+ this.evaluate(group);
90
+ else
91
+ void this.deliver(group);
92
+ }
93
+ }
94
+ evaluate(group) {
95
+ const members = group.memberRunIds.map((id) => this.host.getRun(id));
96
+ if (group.join === "first") {
97
+ const winner = members.find((run) => isTerminal(run.state));
98
+ if (!winner)
99
+ return;
100
+ group.winnerRunId = winner.id;
101
+ // Losers are cancelled, not erased: their sessions stay resumable.
102
+ for (const run of members)
103
+ if (!isTerminal(run.state))
104
+ this.host.cancel(run.id);
105
+ }
106
+ else if (!members.every((run) => isTerminal(run.state))) {
107
+ return;
108
+ }
109
+ group.finishedAt = Date.now();
110
+ group.callbackState = group.callbackSessionId ? "pending" : null;
111
+ this.store.saveGroup(group);
112
+ this.changed(group);
113
+ if (group.callbackState === "pending")
114
+ void this.deliver(group);
115
+ }
116
+ /** Same outbox semantics as run callbacks: busy defer, transcript dedupe on
117
+ * the group id, backoff retry, restart recovery. */
118
+ async deliver(candidate) {
119
+ if (this.delivering.has(candidate.id))
120
+ return;
121
+ this.delivering.add(candidate.id);
122
+ try {
123
+ const group = this.store.getGroup(candidate.id);
124
+ if (!group?.callbackSessionId || (group.callbackState !== "pending" && group.callbackState !== "failed"))
125
+ return;
126
+ const session = await this.router.ensure({ channelId: "task", conversationId: group.callbackSessionId });
127
+ const alreadyDelivered = (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-callback" && turn.origin.runId === group.id);
128
+ // Busy target: waiting is not an attempt (see TaskCallbacks.deliver).
129
+ if (!alreadyDelivered && session.state === "streaming") {
130
+ group.callbackNextAttemptAt = Date.now() + 1000;
131
+ this.store.saveGroup(group);
132
+ return;
133
+ }
134
+ group.callbackAttempts += 1;
135
+ group.callbackState = "pending";
136
+ group.callbackError = null;
137
+ this.store.saveGroup(group);
138
+ // Delivered means Pi accepted the input, not that the recipient's turn
139
+ // ended (see TaskCallbacks.deliver); a rejection flips it to failed.
140
+ const sent = alreadyDelivered
141
+ ? Promise.resolve()
142
+ : session.systemInput(this.text(group), { kind: "task-callback", taskId: group.id, runId: group.id, sourceSessionId: null }, "followUp");
143
+ group.callbackState = "delivered";
144
+ group.callbackNextAttemptAt = null;
145
+ this.store.saveGroup(group);
146
+ this.changed(group);
147
+ await sent;
148
+ }
149
+ catch (error) {
150
+ log.warn(`group ${candidate.id} callback failed, will retry`, error);
151
+ const group = this.store.getGroup(candidate.id);
152
+ if (!group)
153
+ return;
154
+ group.callbackState = "failed";
155
+ group.callbackError = String(error);
156
+ group.callbackNextAttemptAt = Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(group.callbackAttempts, 6));
157
+ this.store.saveGroup(group);
158
+ this.changed(group);
159
+ }
160
+ finally {
161
+ this.delivering.delete(candidate.id);
162
+ }
163
+ }
164
+ text(group) {
165
+ const members = group.memberRunIds.map((id) => this.host.getRun(id));
166
+ const sections = members.map((run) => {
167
+ const head = [
168
+ `- "${run.context.definition.name}" \u2014 state: ${run.state}`,
169
+ ` Run: ${run.id}${run.targetSessionId ? ` / Session: ${run.targetSessionId}` : ""}`,
170
+ ];
171
+ const decision = this.host.openDecisionId(run.id);
172
+ if (decision)
173
+ head.push(` Needs a decision: reply to message ${decision}`);
174
+ if (group.join === "first" && run.id !== group.winnerRunId) {
175
+ head.push(" Cancelled after the winning run; resume its session to recover partial work.");
176
+ return head.join("\n");
177
+ }
178
+ return [...head, "", runResultText(run)].join("\n");
179
+ });
180
+ return [
181
+ `Task group finished (join: ${group.join}) with ${String(members.length)} runs`,
182
+ `Group: ${group.id}`,
183
+ "",
184
+ sections.join("\n\n---\n\n"),
185
+ ].join("\n");
186
+ }
187
+ }
@@ -0,0 +1,248 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { EventHub } from "../core/hub.js";
3
+ import { Router } from "../core/router.js";
4
+ import { logger } from "../log.js";
5
+ import { TaskStore } from "./store.js";
6
+ import { isTerminal } from "./types.js";
7
+ const log = logger("tasks");
8
+ const MAX_MESSAGE_LENGTH = 16 * 1024;
9
+ function bounded(content) {
10
+ const text = content.trim();
11
+ if (!text)
12
+ throw new Error("message required");
13
+ if (Buffer.byteLength(text, "utf8") > MAX_MESSAGE_LENGTH)
14
+ throw new Error("message exceeds 16 KiB");
15
+ return text;
16
+ }
17
+ export class TaskMessenger {
18
+ store;
19
+ router;
20
+ hub;
21
+ resumeRun;
22
+ /** Retry schedule for failed injections. In-memory on purpose: a restart
23
+ * expires undelivered control messages and re-offers decisions anyway. */
24
+ retries = new Map();
25
+ constructor(store, router, hub,
26
+ /** Continues a terminal child with a supervisor reply as its prompt. */
27
+ resumeRun) {
28
+ this.store = store;
29
+ this.router = router;
30
+ this.hub = hub;
31
+ this.resumeRun = resumeRun;
32
+ }
33
+ expirePending() {
34
+ for (const message of this.store.expirePendingMessages())
35
+ this.changed(message);
36
+ }
37
+ /** The unanswered decision on a run, if any. */
38
+ openDecisionId(runId) {
39
+ return this.store.listMessages(runId).find((m) => m.kind === "decision" && (m.state === "pending" || m.state === "delivered"))?.id ?? null;
40
+ }
41
+ /** A manual continuation supersedes an unanswered decision (design 04):
42
+ * one continuation per run, never two racing ones. */
43
+ expireDecisions(runId, reason) {
44
+ for (const message of this.store.listMessages(runId)) {
45
+ if (message.kind !== "decision" || (message.state !== "pending" && message.state !== "delivered"))
46
+ continue;
47
+ message.state = "expired";
48
+ message.error = reason;
49
+ message.answeredAt = Date.now();
50
+ this.store.saveMessage(message);
51
+ this.changed(message);
52
+ }
53
+ }
54
+ list(runId) {
55
+ return this.store.listMessages(runId);
56
+ }
57
+ recent(since, limit = 200) {
58
+ return this.store.listRecentMessages(since, limit);
59
+ }
60
+ async control(run, fromSessionId, kind, content) {
61
+ const message = this.create(run, kind, fromSessionId, run.targetSessionId ?? "", content, null);
62
+ if (run.targetSessionId)
63
+ this.deliver(message, run, run.targetSessionId);
64
+ return this.require(message.id);
65
+ }
66
+ deliverPendingControls(run) {
67
+ if (!run.targetSessionId)
68
+ return;
69
+ for (const message of this.store.listMessages(run.id)) {
70
+ if (message.state !== "pending" || (message.kind !== "steer" && message.kind !== "follow_up"))
71
+ continue;
72
+ this.deliver(message, run, run.targetSessionId);
73
+ }
74
+ }
75
+ /** Injection is fire-and-forget, so this sweep is what closes a failed one.
76
+ * `inject` dedupes on the recipient transcript, so a retry cannot double
77
+ * deliver. Controls aimed at a finished run are dead and expire here. */
78
+ retryUndelivered(now = Date.now()) {
79
+ for (const message of this.store.listUndeliveredMessages()) {
80
+ const run = this.store.getRun(message.runId);
81
+ if (!run)
82
+ continue;
83
+ // Expiring a dead control is not a retry, so it ignores the backoff.
84
+ if ((message.kind === "steer" || message.kind === "follow_up") && isTerminal(run.state)) {
85
+ message.state = "expired";
86
+ message.error = "run finished before delivery completed";
87
+ this.store.saveMessage(message);
88
+ this.changed(message);
89
+ continue;
90
+ }
91
+ if ((this.retries.get(message.id)?.nextAt ?? 0) > now)
92
+ continue;
93
+ const target = message.toSessionId || run.targetSessionId;
94
+ if (target)
95
+ this.deliver(message, run, target);
96
+ }
97
+ }
98
+ /** Asynchronous by design: returns the receipt immediately. A decision
99
+ * child states what it awaits and ends its turn; the reply arrives as a
100
+ * follow-up (active run) or resumes the session (terminal run).
101
+ * A decision steers the supervisor: a follow-up only lands once the
102
+ * supervisor has no tool calls left, so a blocked child would wait out the
103
+ * whole turn. Progress stays a follow-up — nobody waits on it. */
104
+ async contact(run, fromSessionId, reason, content) {
105
+ if (!run.invokedBySessionId)
106
+ throw new Error("run has no supervisor session");
107
+ if (reason === "decision" && this.openDecisionId(run.id)) {
108
+ throw new Error("run already has a pending supervisor decision");
109
+ }
110
+ const message = this.create(run, reason, fromSessionId, run.invokedBySessionId, content, null);
111
+ this.deliver(message, run, run.invokedBySessionId);
112
+ return this.require(message.id);
113
+ }
114
+ async reply(questionId, fromSessionId, content) {
115
+ const question = this.require(questionId);
116
+ if (question.kind !== "decision")
117
+ throw new Error("message is not a decision request");
118
+ if (question.toSessionId !== fromSessionId)
119
+ throw new Error("only the addressed supervisor may reply");
120
+ const existing = this.store.listMessages(question.runId).find((m) => m.kind === "reply" && m.replyTo === question.id);
121
+ const text = bounded(content);
122
+ if (existing) {
123
+ if (existing.content !== text)
124
+ throw new Error("decision already answered with different content");
125
+ return existing;
126
+ }
127
+ if (question.state !== "delivered" && question.state !== "pending") {
128
+ throw new Error(`decision is ${question.state}`);
129
+ }
130
+ const run = this.store.getRun(question.runId);
131
+ if (!run)
132
+ throw new Error(`unknown task run: ${question.runId}`);
133
+ const reply = this.create(run, "reply", fromSessionId, question.fromSessionId, text, question.id);
134
+ question.state = "answered";
135
+ question.answeredAt = Date.now();
136
+ this.store.saveMessage(question);
137
+ this.changed(question);
138
+ // Core routes the reply: follow-up into an active run, auto-resume of a
139
+ // terminal one — the replier gets the continuation's callback.
140
+ if (run.targetSessionId && (run.state === "queued" || run.state === "running")) {
141
+ this.deliver(reply, run, run.targetSessionId);
142
+ }
143
+ else if (isTerminal(run.state)) {
144
+ this.resumeRun(run.id, this.format(reply, run), fromSessionId);
145
+ reply.state = "delivered";
146
+ reply.deliveredAt = Date.now();
147
+ this.store.saveMessage(reply);
148
+ this.changed(reply);
149
+ }
150
+ return this.require(reply.id);
151
+ }
152
+ create(run, kind, fromSessionId, toSessionId, content, replyTo) {
153
+ const message = {
154
+ id: randomUUID(),
155
+ runId: run.id,
156
+ kind,
157
+ fromSessionId,
158
+ toSessionId,
159
+ replyTo,
160
+ state: "pending",
161
+ content: bounded(content),
162
+ createdAt: Date.now(),
163
+ deliveredAt: null,
164
+ answeredAt: null,
165
+ error: null,
166
+ };
167
+ this.store.saveMessage(message);
168
+ this.changed(message);
169
+ return message;
170
+ }
171
+ /** Never awaits the recipient: the seam's `systemInput` settles with the turn
172
+ * the input triggers, so awaiting it would block the sender — a child's
173
+ * `contact` on its supervisor's whole answer turn — which the design forbids.
174
+ * Delivery is therefore recorded on hand-off and corrected to `failed` by the
175
+ * catch; the tick sweep retries from there. */
176
+ deliver(candidate, run, targetSessionId) {
177
+ const message = this.require(candidate.id);
178
+ if (message.state !== "pending" && message.state !== "failed")
179
+ return;
180
+ if (message.toSessionId !== targetSessionId)
181
+ message.toSessionId = targetSessionId;
182
+ message.state = "delivered";
183
+ message.deliveredAt = Date.now();
184
+ message.error = null;
185
+ this.store.saveMessage(message);
186
+ this.changed(message);
187
+ void this.inject(message, run, targetSessionId, this.mode(message))
188
+ .catch((error) => { this.failed(message.id, error); });
189
+ }
190
+ /** A decision steers — a follow-up would land only after the supervisor runs
191
+ * out of tool calls, leaving the child waiting out the whole turn. */
192
+ mode(message) {
193
+ return message.kind === "steer" || message.kind === "decision" ? "steer" : "follow_up";
194
+ }
195
+ failed(id, error) {
196
+ const message = this.store.getMessage(id);
197
+ if (!message || message.state === "answered" || message.state === "expired")
198
+ return;
199
+ // Both ends are waiting on this one: the sender for an answer, the
200
+ // recipient for a message it never got told about.
201
+ log.warn(`${message.kind} ${id} to session ${message.toSessionId} failed`, error);
202
+ message.state = "failed";
203
+ message.error = String(error);
204
+ this.store.saveMessage(message);
205
+ this.changed(message);
206
+ const attempts = (this.retries.get(id)?.attempts ?? 0) + 1;
207
+ this.retries.set(id, { attempts, nextAt: Date.now() + Math.min(60_000, 1000 * 2 ** Math.min(attempts, 6)) });
208
+ }
209
+ async inject(message, run, targetSessionId, mode) {
210
+ const session = await this.router.ensure({ channelId: "task", conversationId: targetSessionId });
211
+ const delivered = (await session.history()).some((turn) => turn.role === "system" && turn.origin?.kind === "task-message" && turn.origin.messageId === message.id);
212
+ if (delivered)
213
+ return;
214
+ await session.systemInput(this.format(message, run), this.origin(message, run), mode === "follow_up" ? "followUp" : mode);
215
+ }
216
+ format(message, run) {
217
+ const title = run.context.definition.name;
218
+ if (message.kind === "progress") {
219
+ return `Subagent progress for task "${title}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
220
+ }
221
+ if (message.kind === "decision") {
222
+ return `Subagent needs a decision for task "${title}"\nRun: ${run.id}\nMessage: ${message.id}\nReply with the Task reply operation.\n\n${message.content}`;
223
+ }
224
+ if (message.kind === "reply") {
225
+ return `Supervisor reply for task "${title}"\nRun: ${run.id}\nReply to: ${message.replyTo ?? "-"}\n\n${message.content}`;
226
+ }
227
+ return `Task guidance for "${title}"\nRun: ${run.id}\nMessage: ${message.id}\n\n${message.content}`;
228
+ }
229
+ origin(message, run) {
230
+ return {
231
+ kind: "task-message",
232
+ taskId: run.taskId,
233
+ runId: run.id,
234
+ sourceSessionId: message.fromSessionId,
235
+ messageId: message.id,
236
+ messageKind: message.kind,
237
+ };
238
+ }
239
+ require(id) {
240
+ const message = this.store.getMessage(id);
241
+ if (!message)
242
+ throw new Error(`unknown task message: ${id}`);
243
+ return message;
244
+ }
245
+ changed(message) {
246
+ this.hub.emitWorkspace({ type: "task-message-changed", runId: message.runId, messageId: message.id });
247
+ }
248
+ }
@@ -0,0 +1,219 @@
1
+ import { record, requiredString } from "./definitions.js";
2
+ const jsonBody = async (req) => req.json().catch(() => null);
3
+ export function registerTaskRoutes(app, tasks, activity) {
4
+ if (activity)
5
+ app.get("/api/activity", async (c) => {
6
+ const now = Date.now();
7
+ const recent = c.req.query("scope") === "recent";
8
+ const runs = tasks.recentRuns(200).filter((run) => recent
9
+ ? run.queuedAt >= now - 60 * 60 * 1000
10
+ : run.state === "queued" || run.state === "running");
11
+ const listed = await activity.factory.list();
12
+ const byId = new Map(listed.map((session) => [session.id, session]));
13
+ const linkedIds = new Set();
14
+ for (const run of runs) {
15
+ for (const id of [run.invokedBySessionId, run.targetSessionId, run.callbackSessionId]) {
16
+ if (id)
17
+ linkedIds.add(id);
18
+ }
19
+ }
20
+ for (const session of listed) {
21
+ if (activity.router.stateOf(session.id) === "streaming")
22
+ linkedIds.add(session.id);
23
+ }
24
+ const messages = tasks.recentMessages(recent ? now - 60 * 60 * 1000 : now - 24 * 60 * 60 * 1000)
25
+ .filter((message) => runs.some((run) => run.id === message.runId));
26
+ for (const message of messages) {
27
+ if (message.fromSessionId !== "console")
28
+ linkedIds.add(message.fromSessionId);
29
+ if (message.toSessionId !== "console")
30
+ linkedIds.add(message.toSessionId);
31
+ }
32
+ return c.json({
33
+ sessions: [...linkedIds].map((id) => {
34
+ const session = byId.get(id);
35
+ return {
36
+ id,
37
+ cwd: session?.cwd ?? "",
38
+ title: session?.title,
39
+ state: activity.router.stateOf(id) ?? "idle",
40
+ stateSince: activity.router.stateSinceOf(id) ?? null,
41
+ };
42
+ }),
43
+ runs,
44
+ messages,
45
+ });
46
+ });
47
+ app.get("/api/tasks", (c) => {
48
+ const trigger = c.req.query("trigger");
49
+ const state = c.req.query("state");
50
+ const kind = c.req.query("kind");
51
+ let rows = tasks.list();
52
+ // Subagent one-shots are hidden unless explicitly requested via ?kind=subagent.
53
+ rows = kind ? rows.filter((task) => task.kind === kind) : rows.filter((task) => task.kind !== "subagent");
54
+ if (trigger)
55
+ rows = rows.filter((task) => task.trigger.type === trigger);
56
+ if (state === "archived")
57
+ rows = rows.filter((task) => task.archived);
58
+ else if (state === "active")
59
+ rows = rows.filter((task) => !task.archived);
60
+ return c.json(rows.map((task) => ({
61
+ ...task,
62
+ lastRun: tasks.listRuns(task.id, 1)[0] ?? null,
63
+ })));
64
+ });
65
+ app.post("/api/tasks", async (c) => {
66
+ const body = await jsonBody(c.req);
67
+ const runNow = typeof body === "object" && body !== null && "runNow" in body && body.runNow === true;
68
+ const definition = typeof body === "object" && body !== null && "task" in body ? body.task : body;
69
+ try {
70
+ const task = await tasks.create(definition);
71
+ const run = runNow ? tasks.run(task.id) : null;
72
+ return c.json({ task, runId: run?.id ?? null }, 201);
73
+ }
74
+ catch (err) {
75
+ return c.json({ error: String(err) }, 400);
76
+ }
77
+ });
78
+ app.get("/api/tasks/:id/runs", (c) => {
79
+ try {
80
+ const limit = Number(c.req.query("limit") ?? 50);
81
+ const offset = Number(c.req.query("offset") ?? 0);
82
+ return c.json(tasks.listRuns(c.req.param("id"), limit, offset));
83
+ }
84
+ catch (err) {
85
+ return c.json({ error: String(err) }, 404);
86
+ }
87
+ });
88
+ app.get("/api/task-runs/:id", (c) => {
89
+ try {
90
+ const run = tasks.getRun(c.req.param("id"));
91
+ // Same view as the task tool: a pending decision is the one run fact that
92
+ // lives on the messages, and HTTP callers need it too.
93
+ return c.json({ ...run, pendingDecisionId: tasks.openDecisionId(run.id) });
94
+ }
95
+ catch (err) {
96
+ return c.json({ error: String(err) }, 404);
97
+ }
98
+ });
99
+ app.get("/api/task-groups/:id", (c) => {
100
+ try {
101
+ return c.json(tasks.getGroup(c.req.param("id")));
102
+ }
103
+ catch (err) {
104
+ return c.json({ error: String(err) }, 404);
105
+ }
106
+ });
107
+ app.get("/api/task-runs/:id/messages", (c) => {
108
+ try {
109
+ return c.json(tasks.listMessages(c.req.param("id")));
110
+ }
111
+ catch (err) {
112
+ return c.json({ error: String(err) }, 404);
113
+ }
114
+ });
115
+ app.post("/api/task-runs/:id/steer", async (c) => {
116
+ const body = record(await jsonBody(c.req));
117
+ try {
118
+ 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);
119
+ }
120
+ catch (err) {
121
+ return c.json({ error: String(err) }, 400);
122
+ }
123
+ });
124
+ app.post("/api/task-runs/:id/resume", async (c) => {
125
+ const body = record(await jsonBody(c.req));
126
+ try {
127
+ const wait = body?.wait === true;
128
+ const source = typeof body?.sourceSessionId === "string" ? body.sourceSessionId : null;
129
+ const run = tasks.resume(c.req.param("id"), requiredString(body?.message, "message"), {
130
+ invokedBySessionId: source,
131
+ callbackSessionId: wait ? null : source,
132
+ background: !wait,
133
+ });
134
+ return c.json(wait ? await tasks.waitForRun(run.id) : run, wait ? 200 : 202);
135
+ }
136
+ catch (err) {
137
+ return c.json({ error: String(err) }, 400);
138
+ }
139
+ });
140
+ app.post("/api/task-messages/:id/reply", async (c) => {
141
+ const body = record(await jsonBody(c.req));
142
+ try {
143
+ const source = requiredString(body?.sourceSessionId, "sourceSessionId");
144
+ return c.json(await tasks.reply(c.req.param("id"), source, requiredString(body?.message, "message")), 202);
145
+ }
146
+ catch (err) {
147
+ return c.json({ error: String(err) }, 400);
148
+ }
149
+ });
150
+ app.post("/api/task-runs/:id/cancel", (c) => {
151
+ try {
152
+ return c.json(tasks.cancel(c.req.param("id")), 202);
153
+ }
154
+ catch (err) {
155
+ return c.json({ error: String(err) }, 404);
156
+ }
157
+ });
158
+ app.get("/api/tasks/:id", (c) => {
159
+ try {
160
+ return c.json(tasks.get(c.req.param("id")));
161
+ }
162
+ catch (err) {
163
+ return c.json({ error: String(err) }, 404);
164
+ }
165
+ });
166
+ app.patch("/api/tasks/:id", async (c) => {
167
+ try {
168
+ return c.json(await tasks.update(c.req.param("id"), await jsonBody(c.req)));
169
+ }
170
+ catch (err) {
171
+ return c.json({ error: String(err) }, 400);
172
+ }
173
+ });
174
+ app.post("/api/tasks/:id/run", async (c) => {
175
+ const body = record(await jsonBody(c.req));
176
+ const input = body && "input" in body ? body.input : null;
177
+ try {
178
+ const sessionMode = body?.sessionMode === "fresh" || body?.sessionMode === "fork" ? body.sessionMode : undefined;
179
+ const sourceSessionId = typeof body?.sourceSessionId === "string" && body.sourceSessionId ? body.sourceSessionId : null;
180
+ const task = tasks.get(c.req.param("id"));
181
+ const effectiveMode = task.action.type === "agent" ? sessionMode ?? task.action.session.mode : null;
182
+ if (effectiveMode === "fork" && (!sourceSessionId || !(await tasks.sessionExists(sourceSessionId)))) {
183
+ throw new Error("fork requires a known sourceSessionId");
184
+ }
185
+ return c.json({ runId: tasks.run(task.id, input, "manual", null, {
186
+ invokedBySessionId: sourceSessionId,
187
+ sourceSessionId,
188
+ sessionMode,
189
+ }).id }, 202);
190
+ }
191
+ catch (err) {
192
+ return c.json({ error: String(err) }, 400);
193
+ }
194
+ });
195
+ app.post("/api/tasks/:id/pause", (c) => {
196
+ try {
197
+ return c.json(tasks.setEnabled(c.req.param("id"), false));
198
+ }
199
+ catch (err) {
200
+ return c.json({ error: String(err) }, 400);
201
+ }
202
+ });
203
+ app.post("/api/tasks/:id/resume", (c) => {
204
+ try {
205
+ return c.json(tasks.setEnabled(c.req.param("id"), true));
206
+ }
207
+ catch (err) {
208
+ return c.json({ error: String(err) }, 400);
209
+ }
210
+ });
211
+ app.post("/api/tasks/:id/archive", (c) => {
212
+ try {
213
+ return c.json(tasks.archive(c.req.param("id")));
214
+ }
215
+ catch (err) {
216
+ return c.json({ error: String(err) }, 400);
217
+ }
218
+ });
219
+ }