pi-forge 1.2.5 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +1 -1
  2. package/dist/client/assets/{CodeMirrorEditor-DXmxwE2Z.js → CodeMirrorEditor-BuLFJjB1.js} +13 -13
  3. package/dist/client/assets/CodeMirrorEditor-BuLFJjB1.js.map +1 -0
  4. package/dist/client/assets/index-CEqSkIuy.css +1 -0
  5. package/dist/client/assets/index-GubcPYw6.js +375 -0
  6. package/dist/client/assets/index-GubcPYw6.js.map +1 -0
  7. package/dist/client/assets/{workbox-window.prod.es5-Cch4wiA5.js → workbox-window.prod.es5-Bd17z0YL.js} +2 -2
  8. package/dist/client/assets/{workbox-window.prod.es5-Cch4wiA5.js.map → workbox-window.prod.es5-Bd17z0YL.js.map} +1 -1
  9. package/dist/client/index.html +2 -2
  10. package/dist/client/sw.js +1 -1
  11. package/dist/client/sw.js.map +1 -1
  12. package/dist/server/git-clone.js +364 -0
  13. package/dist/server/git-clone.js.map +1 -0
  14. package/dist/server/index.js +22 -0
  15. package/dist/server/index.js.map +1 -1
  16. package/dist/server/orchestration/config.js +61 -0
  17. package/dist/server/orchestration/config.js.map +1 -0
  18. package/dist/server/orchestration/event-bridge.js +93 -0
  19. package/dist/server/orchestration/event-bridge.js.map +1 -0
  20. package/dist/server/orchestration/inbox.js +199 -0
  21. package/dist/server/orchestration/inbox.js.map +1 -0
  22. package/dist/server/orchestration/init.js +39 -0
  23. package/dist/server/orchestration/init.js.map +1 -0
  24. package/dist/server/orchestration/store.js +352 -0
  25. package/dist/server/orchestration/store.js.map +1 -0
  26. package/dist/server/orchestration/tools.js +769 -0
  27. package/dist/server/orchestration/tools.js.map +1 -0
  28. package/dist/server/orchestration/types.js +57 -0
  29. package/dist/server/orchestration/types.js.map +1 -0
  30. package/dist/server/processes/manager.js +23 -1
  31. package/dist/server/processes/manager.js.map +1 -1
  32. package/dist/server/project-manager.js +46 -32
  33. package/dist/server/project-manager.js.map +1 -1
  34. package/dist/server/routes/control.js +9 -0
  35. package/dist/server/routes/control.js.map +1 -1
  36. package/dist/server/routes/health.js +14 -1
  37. package/dist/server/routes/health.js.map +1 -1
  38. package/dist/server/routes/orchestration.js +464 -0
  39. package/dist/server/routes/orchestration.js.map +1 -0
  40. package/dist/server/routes/projects.js +239 -14
  41. package/dist/server/routes/projects.js.map +1 -1
  42. package/dist/server/routes/sessions.js +53 -34
  43. package/dist/server/routes/sessions.js.map +1 -1
  44. package/dist/server/routes/webhooks.js +362 -0
  45. package/dist/server/routes/webhooks.js.map +1 -0
  46. package/dist/server/session-registry.js +226 -3
  47. package/dist/server/session-registry.js.map +1 -1
  48. package/dist/server/sse-bridge.js +85 -14
  49. package/dist/server/sse-bridge.js.map +1 -1
  50. package/dist/server/webhooks/dispatcher.js +254 -0
  51. package/dist/server/webhooks/dispatcher.js.map +1 -0
  52. package/dist/server/webhooks/event-bridge.js +185 -0
  53. package/dist/server/webhooks/event-bridge.js.map +1 -0
  54. package/dist/server/webhooks/init.js +55 -0
  55. package/dist/server/webhooks/init.js.map +1 -0
  56. package/dist/server/webhooks/store.js +394 -0
  57. package/dist/server/webhooks/store.js.map +1 -0
  58. package/dist/server/webhooks/types.js +32 -0
  59. package/dist/server/webhooks/types.js.map +1 -0
  60. package/package.json +4 -4
  61. package/dist/client/assets/CodeMirrorEditor-DXmxwE2Z.js.map +0 -1
  62. package/dist/client/assets/index-CMSjnWtF.js +0 -365
  63. package/dist/client/assets/index-CMSjnWtF.js.map +0 -1
  64. package/dist/client/assets/index-Cp8qEy7Q.css +0 -1
@@ -0,0 +1,769 @@
1
+ /**
2
+ * Agent-facing tool surface for supervisor sessions.
3
+ *
4
+ * Eight `orchestrate_*` tools, registered onto a session ONLY when
5
+ * that session has supervisor mode enabled AND the instance-level
6
+ * `ORCHESTRATION_ENABLED` flag is on AND MINIMAL_UI is off. Wired
7
+ * through `createAgentSession({ customTools })` in session-registry.
8
+ *
9
+ * Topology is hub-and-spoke by tool surface: workers don't get
10
+ * these tools, so there's no way to express worker→worker comms.
11
+ * Same-project enforcement: spawn_worker creates in the supervisor's
12
+ * project; cross-project is intentionally out of scope for v1.
13
+ *
14
+ * Every tool that names a workerId verifies ownership against the
15
+ * store before acting — defense in depth so a confused supervisor
16
+ * LLM can't reach into another supervisor's worker by id-guessing.
17
+ */
18
+ import { Type } from "typebox";
19
+ import { createSession, disposeSession, findSessionLocation, getSession, resumeSessionById, SessionNotFoundError, deleteColdSession, } from "../session-registry.js";
20
+ import { maxWorkersPerSupervisor } from "./config.js";
21
+ import { drainInbox } from "./inbox.js";
22
+ import { getWorkerIds, getWorkerRecord, OrchestrationError, registerWorker, unregisterWorker, } from "./store.js";
23
+ // ---- result shape helpers ----
24
+ /**
25
+ * Build a tool result. CRITICAL: the `text` field is what the
26
+ * supervisor LLM actually sees on its next turn. `details` is
27
+ * structured metadata for downstream consumers (REST, tests) but is
28
+ * NOT in the agent's context window. So every tool that wants the
29
+ * orchestrator to make decisions on real data has to encode that
30
+ * data into the text — putting it only in `details` is the same as
31
+ * not returning it at all from the LLM's perspective.
32
+ */
33
+ function ok(payload, text) {
34
+ return {
35
+ content: [{ type: "text", text }],
36
+ details: payload,
37
+ };
38
+ }
39
+ function err(code, message) {
40
+ return {
41
+ content: [{ type: "text", text: `[error: ${code}] ${message}` }],
42
+ details: { error: code, message },
43
+ };
44
+ }
45
+ // ---- message serialization for the supervisor LLM ----
46
+ /**
47
+ * Per-message hard cap when serializing a worker transcript for the
48
+ * supervisor. Long bash outputs / write tool results blow up the
49
+ * supervisor's context fast otherwise. 1.2k chars ≈ 400 tokens is
50
+ * enough for the model to see the gist of any single step; the
51
+ * supervisor can always call `orchestrate_read_worker` with a
52
+ * tighter `limit` to focus on fewer messages in full.
53
+ */
54
+ const PER_MESSAGE_CAP = 1_200;
55
+ /**
56
+ * Total cap across all serialized messages in one read_worker call.
57
+ * 24k chars ≈ 8k tokens. Bigger than `PER_MESSAGE_CAP × default
58
+ * limit (20)`, so the default-limit case fits comfortably; if the
59
+ * caller bumps limit, we still bound the total to protect the
60
+ * supervisor's context budget.
61
+ */
62
+ const TOTAL_TRANSCRIPT_CAP = 24_000;
63
+ function truncate(s, max) {
64
+ if (s.length <= max)
65
+ return s;
66
+ return s.slice(0, max - 1) + "…";
67
+ }
68
+ function previewArgs(input) {
69
+ try {
70
+ const j = JSON.stringify(input);
71
+ return truncate(j, 200);
72
+ }
73
+ catch {
74
+ return "(unserializable)";
75
+ }
76
+ }
77
+ function extractFromContent(content) {
78
+ const out = {};
79
+ if (typeof content === "string") {
80
+ out.text = content;
81
+ return out;
82
+ }
83
+ if (!Array.isArray(content))
84
+ return out;
85
+ const textParts = [];
86
+ const toolCalls = [];
87
+ const toolResults = [];
88
+ let imageCount = 0;
89
+ for (const raw of content) {
90
+ const b = raw;
91
+ if (b.type === "text" && typeof b.text === "string") {
92
+ textParts.push(b.text);
93
+ continue;
94
+ }
95
+ if (b.type === "tool_use" && typeof b.name === "string") {
96
+ toolCalls.push(`${b.name}(${previewArgs(b.input)})`);
97
+ continue;
98
+ }
99
+ if (b.type === "tool_result") {
100
+ // tool_result.content can itself be a string or an array of
101
+ // content blocks. Flatten to a short preview either way so the
102
+ // supervisor sees what the worker's tool actually returned —
103
+ // that's often the load-bearing signal for "did the worker
104
+ // succeed."
105
+ let resultText = "";
106
+ if (typeof b.content === "string")
107
+ resultText = b.content;
108
+ else if (Array.isArray(b.content)) {
109
+ const inner = [];
110
+ for (const c of b.content) {
111
+ if (c.type === "text" && typeof c.text === "string")
112
+ inner.push(c.text);
113
+ }
114
+ resultText = inner.join("\n");
115
+ }
116
+ const prefix = b.is_error === true ? "[error] " : "";
117
+ toolResults.push(prefix + truncate(resultText.trim(), 400));
118
+ continue;
119
+ }
120
+ if (b.type === "image") {
121
+ imageCount += 1;
122
+ continue;
123
+ }
124
+ }
125
+ if (textParts.length > 0)
126
+ out.text = textParts.join("\n");
127
+ if (toolCalls.length > 0)
128
+ out.toolCalls = toolCalls;
129
+ if (toolResults.length > 0)
130
+ out.toolResults = toolResults;
131
+ if (imageCount > 0)
132
+ out.imageCount = imageCount;
133
+ return out;
134
+ }
135
+ /**
136
+ * Render one worker message as plain text the supervisor LLM can
137
+ * read. Squashes the SDK's AgentMessage union into:
138
+ *
139
+ * [role]
140
+ * <text>
141
+ * → tool_use: bash(...)
142
+ * ← tool_result: <preview>
143
+ * (+ N image(s))
144
+ *
145
+ * Caller is responsible for capping total transcript size; this
146
+ * function only caps the per-message body so individual messages
147
+ * stay readable when one of them is very long.
148
+ */
149
+ function formatMessageForOrchestrator(msg, index, total) {
150
+ const m = msg;
151
+ const role = m.role ?? m.type ?? "unknown";
152
+ const blocks = extractFromContent(m.content);
153
+ const lines = [`[${index + 1}/${total}] ${role}`];
154
+ if (blocks.text !== undefined && blocks.text.trim().length > 0) {
155
+ lines.push(truncate(blocks.text.trim(), PER_MESSAGE_CAP));
156
+ }
157
+ for (const tc of blocks.toolCalls ?? [])
158
+ lines.push(`→ tool_use: ${tc}`);
159
+ for (const tr of blocks.toolResults ?? [])
160
+ lines.push(`← tool_result: ${tr}`);
161
+ if ((blocks.imageCount ?? 0) > 0)
162
+ lines.push(`(+${blocks.imageCount} image(s))`);
163
+ if (lines.length === 1)
164
+ lines.push("(no readable content)");
165
+ return lines.join("\n");
166
+ }
167
+ /**
168
+ * Concatenate per-message renders with a total-size budget. If we'd
169
+ * blow past `TOTAL_TRANSCRIPT_CAP`, drop messages from the FRONT
170
+ * (oldest) — the supervisor cares most about what the worker did
171
+ * recently, and the caller can always re-call with a smaller `limit`
172
+ * to see fewer messages in full.
173
+ */
174
+ /**
175
+ * Pick the most useful one-line detail from an inbox item's
176
+ * `data` payload, based on its event type. Keeps the inbox summary
177
+ * compact while still surfacing the load-bearing signal — the
178
+ * supervisor LLM shouldn't have to guess what happened from just
179
+ * a type name.
180
+ */
181
+ function summarizeInboxData(type, data) {
182
+ if (type === "worker.ended") {
183
+ const stop = typeof data.stopReason === "string" ? data.stopReason : "unknown";
184
+ const err = typeof data.errorMessage === "string" ? data.errorMessage : "";
185
+ const preview = typeof data.assistantTextPreview === "string" ? truncate(data.assistantTextPreview, 200) : "";
186
+ const parts = [`stop=${stop}`];
187
+ if (err !== "")
188
+ parts.push(`error="${truncate(err, 120)}"`);
189
+ if (preview !== "")
190
+ parts.push(`said: ${preview}`);
191
+ return parts.join(" ");
192
+ }
193
+ if (type === "worker.ask_user") {
194
+ const header = typeof data.firstQuestionHeader === "string" ? data.firstQuestionHeader : "";
195
+ const text = typeof data.firstQuestionText === "string" ? data.firstQuestionText : "";
196
+ const count = typeof data.questionCount === "number" ? data.questionCount : 1;
197
+ return `${count} question(s)${header !== "" ? `, first: "${header}"` : ""}${text !== "" ? ` (${truncate(text, 120)})` : ""}`;
198
+ }
199
+ if (type === "worker.auto_retry_failed") {
200
+ const attempt = typeof data.attempt === "number" ? data.attempt : "?";
201
+ const maxA = typeof data.maxAttempts === "number" ? data.maxAttempts : "?";
202
+ const finalErr = typeof data.finalError === "string" ? data.finalError : "";
203
+ return `attempts=${attempt}/${maxA}${finalErr !== "" ? ` err="${truncate(finalErr, 120)}"` : ""}`;
204
+ }
205
+ if (type === "worker.process_alert") {
206
+ const reason = typeof data.reason === "string" ? data.reason : "unknown";
207
+ const name = typeof data.name === "string" ? data.name : "(unnamed)";
208
+ const exit = typeof data.exitCode === "number" ? data.exitCode : "?";
209
+ return `${reason} process="${name}" exit=${exit}`;
210
+ }
211
+ if (type === "worker.deleted") {
212
+ const wasLive = data.wasLive === true;
213
+ return wasLive ? "was live" : "was cold";
214
+ }
215
+ // Unknown event type — fall back to a compact JSON preview so the
216
+ // supervisor at least sees something actionable.
217
+ try {
218
+ return truncate(JSON.stringify(data), 200);
219
+ }
220
+ catch {
221
+ return "";
222
+ }
223
+ }
224
+ function renderTranscript(messages, total) {
225
+ const rendered = [];
226
+ let used = 0;
227
+ // Walk newest-to-oldest, prepend in render order at the end.
228
+ for (let i = messages.length - 1; i >= 0; i--) {
229
+ const block = formatMessageForOrchestrator(messages[i], total - (messages.length - i), total);
230
+ if (used + block.length + 2 > TOTAL_TRANSCRIPT_CAP)
231
+ break;
232
+ rendered.unshift(block);
233
+ used += block.length + 2;
234
+ }
235
+ if (rendered.length < messages.length) {
236
+ rendered.unshift(`[truncated — older ${messages.length - rendered.length} message(s) omitted to keep the transcript under ${TOTAL_TRANSCRIPT_CAP} chars]`);
237
+ }
238
+ return rendered.join("\n\n");
239
+ }
240
+ // ---- ownership guard ----
241
+ async function assertOwns(supervisorId, workerId) {
242
+ const rec = await getWorkerRecord(workerId);
243
+ if (rec === undefined) {
244
+ return err("worker_not_found", `No worker registered with id ${workerId}.`);
245
+ }
246
+ if (rec.supervisorId !== supervisorId) {
247
+ return err("not_owner", `Worker ${workerId} is linked to a different supervisor; refusing to act on it.`);
248
+ }
249
+ return undefined;
250
+ }
251
+ // ---- spawn_worker ----
252
+ const spawnSchema = {
253
+ type: "object",
254
+ required: ["name", "initialPrompt"],
255
+ additionalProperties: false,
256
+ properties: {
257
+ name: {
258
+ type: "string",
259
+ minLength: 1,
260
+ maxLength: 200,
261
+ description: "Required short, descriptive label shown in the session picker — " +
262
+ "this is how the user (and you, on later turns) will recognise the " +
263
+ "worker among others. Concrete task names work best: " +
264
+ "'Implement /auth route', 'Add tests for orders module', " +
265
+ "'Audit RLS policies'. AVOID generic placeholders ('helper', " +
266
+ "'worker 1', 'task') — those defeat the whole point of having " +
267
+ "named workers.",
268
+ },
269
+ initialPrompt: {
270
+ type: "string",
271
+ minLength: 1,
272
+ description: "The TASK assigned to this worker. The worker is a fresh autonomous " +
273
+ "agent — it does not see your transcript or memory. Write a self-" +
274
+ "contained task brief: what to do, where (file paths), constraints, " +
275
+ "and what 'done' looks like. Instruct, don't collaborate.",
276
+ },
277
+ contextSummary: {
278
+ type: "string",
279
+ maxLength: 8_000,
280
+ description: "Optional handoff context summary. When present, prepended " +
281
+ "to `initialPrompt` so the worker starts with relevant " +
282
+ "background. Use this for the 'A finishes → B picks up' " +
283
+ "pipeline pattern. Cap is 8k chars to keep the worker's " +
284
+ "first-turn token cost predictable.",
285
+ },
286
+ },
287
+ };
288
+ function createSpawnWorker(supervisorId) {
289
+ return {
290
+ name: "orchestrate_spawn_worker",
291
+ label: "Spawn worker session",
292
+ description: "Create a new worker session in the same project as the supervisor and " +
293
+ "assign it a task. Workers are autonomous task-running agents — NOT " +
294
+ "conversational helpers. Each spawn delegates a discrete unit of " +
295
+ "work; the worker executes against the task in `initialPrompt`, " +
296
+ "reports completion via its inbox, then waits for the next task " +
297
+ "(or shutdown). ALWAYS pass a descriptive `name` so the user (and " +
298
+ "you, on later turns) can tell workers apart in the picker — " +
299
+ "generic placeholders make the multi-worker case unusable. " +
300
+ "Worker events (turn-end, ask-user-question, etc.) feed back into the " +
301
+ "supervisor's inbox; check with `orchestrate_read_inbox`. " +
302
+ "Same-project only in v1 — cross-project orchestration is intentionally " +
303
+ "disabled. Subject to the per-supervisor fan-out cap (default 8).",
304
+ parameters: Type.Unsafe(spawnSchema),
305
+ async execute(_toolCallId, params) {
306
+ const p = params;
307
+ const supLive = getSession(supervisorId);
308
+ if (supLive === undefined) {
309
+ return err("supervisor_not_live", "Supervisor session is not currently live.");
310
+ }
311
+ // Enforce fan-out cap on LIVE workers — a worker that was killed
312
+ // earlier (registry-gone) shouldn't count against the cap even
313
+ // though the store may still list it transiently.
314
+ const workerIds = await getWorkerIds(supervisorId);
315
+ const liveWorkers = workerIds.filter((id) => getSession(id) !== undefined);
316
+ const cap = maxWorkersPerSupervisor();
317
+ if (liveWorkers.length >= cap) {
318
+ return err("fanout_limit_exceeded", `Supervisor already has ${liveWorkers.length} live workers (cap ${cap}). ` +
319
+ `Kill or detach an existing worker before spawning another.`);
320
+ }
321
+ // Spawn into the supervisor's project — never cross-project.
322
+ let worker;
323
+ try {
324
+ worker = await createSession(supLive.projectId, supLive.workspacePath);
325
+ }
326
+ catch (e) {
327
+ return err("spawn_failed", `createSession threw: ${e instanceof Error ? e.message : String(e)}`);
328
+ }
329
+ // Register the link AFTER successful session creation so a
330
+ // createSession failure doesn't leave a dangling store entry.
331
+ try {
332
+ await registerWorker({
333
+ supervisorId,
334
+ workerId: worker.sessionId,
335
+ spawnedFrom: {
336
+ sessionId: supervisorId,
337
+ mode: p.contextSummary !== undefined ? "summary" : "fresh",
338
+ },
339
+ });
340
+ }
341
+ catch (e) {
342
+ // Roll back the session create on registration failure —
343
+ // otherwise we leak a session the user can't see linked
344
+ // anywhere.
345
+ await disposeSession(worker.sessionId).catch(() => undefined);
346
+ await deleteColdSession(worker.sessionId).catch(() => undefined);
347
+ if (e instanceof OrchestrationError) {
348
+ return err(e.code, e.message);
349
+ }
350
+ return err("register_failed", e instanceof Error ? e.message : String(e));
351
+ }
352
+ // Apply the required name. Best-effort — the worker is fully
353
+ // functional even if naming fails, so we don't roll back the
354
+ // spawn on this. The user just sees the SDK default in the
355
+ // picker until they rename it manually.
356
+ try {
357
+ worker.session.setSessionName(p.name);
358
+ }
359
+ catch (e) {
360
+ process.stderr.write(JSON.stringify({
361
+ level: "warn",
362
+ time: new Date().toISOString(),
363
+ msg: "orchestration-worker-rename-failed",
364
+ workerId: worker.sessionId,
365
+ requestedName: p.name,
366
+ err: e instanceof Error ? e.message : String(e),
367
+ }) + "\n");
368
+ }
369
+ // Build the initial prompt: optional context summary + the
370
+ // caller's prompt. The context summary is prepended as its
371
+ // own paragraph so the worker LLM can clearly distinguish
372
+ // background from the task.
373
+ const initialPrompt = p.contextSummary !== undefined && p.contextSummary.length > 0
374
+ ? `# Handoff context\n${p.contextSummary}\n\n# Task\n${p.initialPrompt}`
375
+ : p.initialPrompt;
376
+ // Fire the initial prompt. Fire-and-forget — the supervisor
377
+ // will see the turn outcome via its inbox; making the tool
378
+ // wait here would block the supervisor's loop for the entire
379
+ // worker turn.
380
+ worker.session.prompt(initialPrompt).catch((e) => {
381
+ process.stderr.write(JSON.stringify({
382
+ level: "warn",
383
+ time: new Date().toISOString(),
384
+ msg: "orchestration-worker-initial-prompt-failed",
385
+ workerId: worker.sessionId,
386
+ err: e instanceof Error ? e.message : String(e),
387
+ }) + "\n");
388
+ });
389
+ return ok({
390
+ workerId: worker.sessionId,
391
+ name: worker.session.sessionName ?? p.name,
392
+ projectId: worker.projectId,
393
+ }, `Spawned worker "${p.name}" (${worker.sessionId}). Initial prompt delivered. ` +
394
+ `Monitor via orchestrate_read_inbox or orchestrate_read_worker.`);
395
+ },
396
+ };
397
+ }
398
+ // ---- list_workers ----
399
+ function createListWorkers(supervisorId) {
400
+ return {
401
+ name: "orchestrate_list_workers",
402
+ label: "List workers",
403
+ description: "Return every worker registered under this supervisor with its " +
404
+ "current state (live / idle / streaming / cold), last activity " +
405
+ "timestamp, and message count. Cheap to call repeatedly.",
406
+ parameters: Type.Unsafe({ type: "object", properties: {} }),
407
+ async execute() {
408
+ const ids = await getWorkerIds(supervisorId);
409
+ const workers = ids.map((workerId) => {
410
+ const live = getSession(workerId);
411
+ if (live === undefined) {
412
+ return {
413
+ workerId,
414
+ state: "cold",
415
+ isLive: false,
416
+ isStreaming: false,
417
+ messageCount: null,
418
+ lastActivityAt: null,
419
+ name: null,
420
+ };
421
+ }
422
+ return {
423
+ workerId,
424
+ state: live.session.isStreaming ? "streaming" : "idle",
425
+ isLive: true,
426
+ isStreaming: live.session.isStreaming,
427
+ messageCount: live.session.messages.length,
428
+ lastActivityAt: live.lastActivityAt.toISOString(),
429
+ name: live.session.sessionName ?? null,
430
+ };
431
+ });
432
+ const summary = `${workers.length} worker(s) registered. ` +
433
+ `${workers.filter((w) => w.state === "streaming").length} streaming, ` +
434
+ `${workers.filter((w) => w.state === "idle").length} idle, ` +
435
+ `${workers.filter((w) => w.state === "cold").length} cold.`;
436
+ const rows = workers.map((w) => {
437
+ const label = w.name ?? "(unnamed)";
438
+ const msgs = w.messageCount !== null ? `${w.messageCount} msgs` : "no live state";
439
+ const last = w.lastActivityAt !== null ? `last activity ${w.lastActivityAt}` : "";
440
+ return `- ${w.state.padEnd(9)} "${label}" (${w.workerId}) — ${msgs}${last !== "" ? `, ${last}` : ""}`;
441
+ });
442
+ const body = rows.length === 0 ? "(no workers spawned yet)" : rows.join("\n");
443
+ return ok({ workers }, `${summary}\n${body}`);
444
+ },
445
+ };
446
+ }
447
+ // ---- read_worker ----
448
+ const readWorkerSchema = {
449
+ type: "object",
450
+ required: ["workerId"],
451
+ additionalProperties: false,
452
+ properties: {
453
+ workerId: { type: "string", minLength: 1 },
454
+ limit: {
455
+ type: "integer",
456
+ minimum: 1,
457
+ maximum: 100,
458
+ description: "How many of the most recent messages to return. Default 1 — " +
459
+ "the worker's single latest message is enough context for most " +
460
+ "supervisor decisions (typically the assistant's last turn or the " +
461
+ "last user-side handoff). Bump this only when one-message context " +
462
+ "isn't enough — e.g. you need to see the worker's reasoning chain " +
463
+ "across several turns, or you're auditing a long tool-call sequence. " +
464
+ "Bigger `limit` burns more of YOUR context window per call.",
465
+ },
466
+ },
467
+ };
468
+ function createReadWorker(supervisorId) {
469
+ return {
470
+ name: "orchestrate_read_worker",
471
+ label: "Read worker transcript",
472
+ description: "Fetch the most recent messages from a worker's transcript. Returns the " +
473
+ "last N messages newest-last (chronological order, ready to read). " +
474
+ "Default `limit` is 1: most supervisor decisions only need the worker's " +
475
+ "latest message. Only bump `limit` when one message doesn't give you " +
476
+ "enough context. Auto-resumes cold workers from disk so the tool works " +
477
+ "regardless of whether the worker is currently live.",
478
+ parameters: Type.Unsafe(readWorkerSchema),
479
+ async execute(_toolCallId, params) {
480
+ const p = params;
481
+ const guard = await assertOwns(supervisorId, p.workerId);
482
+ if (guard !== undefined)
483
+ return guard;
484
+ let live = getSession(p.workerId);
485
+ if (live === undefined) {
486
+ try {
487
+ live = await resumeSessionById(p.workerId);
488
+ }
489
+ catch (e) {
490
+ if (e instanceof SessionNotFoundError) {
491
+ return err("worker_session_missing", `Worker session ${p.workerId} not on disk.`);
492
+ }
493
+ return err("resume_failed", e instanceof Error ? e.message : String(e));
494
+ }
495
+ }
496
+ const limit = Math.min(Math.max(p.limit ?? 1, 1), 100);
497
+ const all = live.session.messages;
498
+ const tail = all.slice(Math.max(0, all.length - limit));
499
+ const name = live.session.sessionName ?? "(unnamed)";
500
+ const header = `Worker "${name}" (${p.workerId}) — ` +
501
+ `${live.session.isStreaming ? "streaming" : "idle"}. ` +
502
+ `Showing the last ${tail.length} of ${all.length} message(s).`;
503
+ const transcript = tail.length === 0
504
+ ? "(no messages yet — worker hasn't started its first turn)"
505
+ : renderTranscript(tail, all.length);
506
+ return ok({
507
+ workerId: p.workerId,
508
+ totalMessages: all.length,
509
+ returned: tail.length,
510
+ isStreaming: live.session.isStreaming,
511
+ messages: tail,
512
+ }, `${header}\n\n${transcript}`);
513
+ },
514
+ };
515
+ }
516
+ // ---- send_to_worker ----
517
+ const sendSchema = {
518
+ type: "object",
519
+ required: ["workerId", "message"],
520
+ additionalProperties: false,
521
+ properties: {
522
+ workerId: { type: "string", minLength: 1 },
523
+ message: {
524
+ type: "string",
525
+ minLength: 1,
526
+ description: "The next task or directive — concrete instruction, not " +
527
+ "conversational filler (every send spends a worker turn). " +
528
+ "Typical uses: assign follow-up work, course-correct mid-" +
529
+ "execution (with mode='steer'), or answer a pending " +
530
+ "ask_user_question.",
531
+ },
532
+ mode: {
533
+ type: "string",
534
+ enum: ["prompt", "steer", "followUp"],
535
+ description: "`prompt` (default): new turn, or queue if busy. " +
536
+ "`steer`: interrupt the current turn — for course-correction. " +
537
+ "`followUp`: wait for idle, then send — for queued next tasks.",
538
+ },
539
+ },
540
+ };
541
+ function createSendToWorker(supervisorId) {
542
+ return {
543
+ name: "orchestrate_send_to_worker",
544
+ label: "Send message to worker",
545
+ description: "Assign a follow-up task or directive to a running worker. The message " +
546
+ "is tagged as supervisor-sourced in the worker's transcript so the " +
547
+ "worker LLM can distinguish it from a human user message. Frame each " +
548
+ "send as a concrete instruction (next task, course-correction, " +
549
+ "specific answer to a pending question) — NOT chitchat. Every send " +
550
+ "spends a worker turn.",
551
+ parameters: Type.Unsafe(sendSchema),
552
+ async execute(_toolCallId, params) {
553
+ const p = params;
554
+ const guard = await assertOwns(supervisorId, p.workerId);
555
+ if (guard !== undefined)
556
+ return guard;
557
+ const live = getSession(p.workerId);
558
+ if (live === undefined) {
559
+ return err("worker_not_live", `Worker ${p.workerId} is not currently live. Resume it first (open in the UI or call orchestrate_read_worker).`);
560
+ }
561
+ // Tag the message so the client can render it with a
562
+ // supervisor badge. The marker is part of the message text
563
+ // — not a separate metadata channel — because the SDK's
564
+ // prompt/steer/followUp signature only accepts text. Same
565
+ // pattern as the [orchestration] wake-up prefix in inbox.ts.
566
+ const tagged = `[supervisor:${supervisorId}] ${p.message}`;
567
+ const mode = p.mode ?? "prompt";
568
+ try {
569
+ if (mode === "prompt") {
570
+ live.session.prompt(tagged).catch(() => undefined);
571
+ }
572
+ else if (mode === "steer") {
573
+ live.session.steer(tagged).catch(() => undefined);
574
+ }
575
+ else {
576
+ live.session.followUp(tagged).catch(() => undefined);
577
+ }
578
+ }
579
+ catch (e) {
580
+ return err("send_failed", e instanceof Error ? e.message : String(e));
581
+ }
582
+ return ok({ workerId: p.workerId, mode, accepted: true }, `Queued ${mode} message to worker ${p.workerId}.`);
583
+ },
584
+ };
585
+ }
586
+ // ---- interrupt_worker ----
587
+ const interruptSchema = {
588
+ type: "object",
589
+ required: ["workerId"],
590
+ additionalProperties: false,
591
+ properties: { workerId: { type: "string", minLength: 1 } },
592
+ };
593
+ function createInterruptWorker(supervisorId) {
594
+ return {
595
+ name: "orchestrate_interrupt_worker",
596
+ label: "Interrupt worker",
597
+ description: "Abort the worker's current turn. Idempotent on idle workers. " +
598
+ "The worker session itself stays live; use `orchestrate_kill_worker` " +
599
+ "to fully terminate.",
600
+ parameters: Type.Unsafe(interruptSchema),
601
+ async execute(_toolCallId, params) {
602
+ const p = params;
603
+ const guard = await assertOwns(supervisorId, p.workerId);
604
+ if (guard !== undefined)
605
+ return guard;
606
+ const live = getSession(p.workerId);
607
+ if (live === undefined) {
608
+ return err("worker_not_live", `Worker ${p.workerId} is not currently live.`);
609
+ }
610
+ try {
611
+ await live.session.abort();
612
+ }
613
+ catch (e) {
614
+ return err("abort_failed", e instanceof Error ? e.message : String(e));
615
+ }
616
+ return ok({ workerId: p.workerId, aborted: true }, `Aborted worker ${p.workerId}'s current turn.`);
617
+ },
618
+ };
619
+ }
620
+ // ---- kill_worker ----
621
+ const killSchema = {
622
+ type: "object",
623
+ required: ["workerId"],
624
+ additionalProperties: false,
625
+ properties: {
626
+ workerId: { type: "string", minLength: 1 },
627
+ deleteOnDisk: {
628
+ type: "boolean",
629
+ description: "When true, also delete the worker's .jsonl from disk (the session " +
630
+ "is gone from the sidebar). Default false — keeps the transcript " +
631
+ "for later inspection.",
632
+ },
633
+ },
634
+ };
635
+ function createKillWorker(supervisorId) {
636
+ return {
637
+ name: "orchestrate_kill_worker",
638
+ label: "Kill worker",
639
+ description: "Dispose the worker session (terminate any in-flight turn, close " +
640
+ "SSE clients) and unregister it from this supervisor. By default " +
641
+ "the .jsonl transcript stays on disk; pass `deleteOnDisk: true` " +
642
+ "to also remove it.",
643
+ parameters: Type.Unsafe(killSchema),
644
+ async execute(_toolCallId, params) {
645
+ const p = params;
646
+ const guard = await assertOwns(supervisorId, p.workerId);
647
+ if (guard !== undefined)
648
+ return guard;
649
+ const wasLive = await disposeSession(p.workerId);
650
+ let diskDeleted = false;
651
+ if (p.deleteOnDisk === true) {
652
+ const r = await deleteColdSession(p.workerId).catch(() => "not_found");
653
+ diskDeleted = r === "deleted";
654
+ }
655
+ await unregisterWorker(p.workerId);
656
+ return ok({ workerId: p.workerId, wasLive, diskDeleted }, `Killed worker ${p.workerId}${diskDeleted ? " (and deleted from disk)" : ""}.`);
657
+ },
658
+ };
659
+ }
660
+ // ---- detach_worker ----
661
+ const detachSchema = {
662
+ type: "object",
663
+ required: ["workerId"],
664
+ additionalProperties: false,
665
+ properties: { workerId: { type: "string", minLength: 1 } },
666
+ };
667
+ function createDetachWorker(supervisorId) {
668
+ return {
669
+ name: "orchestrate_detach_worker",
670
+ label: "Detach worker",
671
+ description: "Drop the supervisor↔worker link. The worker session stays live " +
672
+ "(transcript untouched) but its events no longer feed this " +
673
+ "supervisor's inbox. Use when the worker is done and should " +
674
+ "continue as a standalone session.",
675
+ parameters: Type.Unsafe(detachSchema),
676
+ async execute(_toolCallId, params) {
677
+ const p = params;
678
+ const guard = await assertOwns(supervisorId, p.workerId);
679
+ if (guard !== undefined)
680
+ return guard;
681
+ await unregisterWorker(p.workerId);
682
+ return ok({ workerId: p.workerId, detached: true }, `Detached worker ${p.workerId}. It remains live as a standalone session.`);
683
+ },
684
+ };
685
+ }
686
+ // ---- read_inbox ----
687
+ function createReadInbox(supervisorId) {
688
+ return {
689
+ name: "orchestrate_read_inbox",
690
+ label: "Read inbox",
691
+ description: "Drain pending worker events: turn-ends, ask-user-question requests, " +
692
+ "auto-retry failures, process alerts, and deletions. Items are " +
693
+ "returned oldest-first and marked delivered — calling again returns " +
694
+ "only NEW items unless you also call `orchestrate_list_workers` to " +
695
+ "re-survey state. Items stay in the audit history (visible in the " +
696
+ "REST UI) regardless.",
697
+ parameters: Type.Unsafe({ type: "object", properties: {} }),
698
+ async execute() {
699
+ const items = await drainInbox(supervisorId);
700
+ if (items.length === 0) {
701
+ return ok({ items: [] }, "No new inbox items.");
702
+ }
703
+ // Render each item as a readable line. The structured items
704
+ // also go in `details` for the REST layer, but the supervisor
705
+ // LLM reads them from this text — `details` doesn't reach the
706
+ // model's context. Per-item key fields are picked based on
707
+ // the event type so the supervisor sees the load-bearing
708
+ // signal without needing a follow-up read_worker call for
709
+ // every event.
710
+ const lines = items.map((it) => {
711
+ const d = it.data;
712
+ const detail = summarizeInboxData(it.type, d);
713
+ return `- [${it.occurredAt}] ${it.type} worker=${it.workerId}${detail !== "" ? ` — ${detail}` : ""}`;
714
+ });
715
+ return ok({
716
+ items: items.map((it) => ({
717
+ id: it.id,
718
+ type: it.type,
719
+ workerId: it.workerId,
720
+ occurredAt: it.occurredAt,
721
+ data: it.data,
722
+ })),
723
+ }, `Drained ${items.length} inbox item(s):\n${lines.join("\n")}`);
724
+ },
725
+ };
726
+ }
727
+ // ---- public factory ----
728
+ /**
729
+ * Build the complete orchestration tool set for a supervisor session.
730
+ * Returns 8 tools. Caller (session-registry) is responsible for
731
+ * checking `isOrchestrationEnabled()` and the per-session supervisor
732
+ * flag BEFORE calling — this factory just builds the tools.
733
+ *
734
+ * Workers do NOT call this; their customTools array doesn't include
735
+ * any orchestration tools by design (hub-and-spoke enforcement via
736
+ * tool surface).
737
+ */
738
+ export function createOrchestrationTools(supervisorId) {
739
+ return [
740
+ createSpawnWorker(supervisorId),
741
+ createListWorkers(supervisorId),
742
+ createReadWorker(supervisorId),
743
+ createSendToWorker(supervisorId),
744
+ createInterruptWorker(supervisorId),
745
+ createKillWorker(supervisorId),
746
+ createDetachWorker(supervisorId),
747
+ createReadInbox(supervisorId),
748
+ ];
749
+ }
750
+ /** Public for the allowlist machinery in session-registry. */
751
+ export const ORCHESTRATION_TOOL_NAMES = [
752
+ "orchestrate_spawn_worker",
753
+ "orchestrate_list_workers",
754
+ "orchestrate_read_worker",
755
+ "orchestrate_send_to_worker",
756
+ "orchestrate_interrupt_worker",
757
+ "orchestrate_kill_worker",
758
+ "orchestrate_detach_worker",
759
+ "orchestrate_read_inbox",
760
+ ];
761
+ /** Helper: best-effort sanity check that `findSessionLocation` can
762
+ * reach the worker. Used by tests; not used by the tools themselves
763
+ * because every tool that needs the session already calls
764
+ * `getSession`/`resumeSessionById` directly. */
765
+ export async function workerLocationExists(workerId) {
766
+ const loc = await findSessionLocation(workerId);
767
+ return loc !== undefined;
768
+ }
769
+ //# sourceMappingURL=tools.js.map