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.
- package/README.md +1 -1
- package/dist/client/assets/{CodeMirrorEditor-DXmxwE2Z.js → CodeMirrorEditor-BuLFJjB1.js} +13 -13
- package/dist/client/assets/CodeMirrorEditor-BuLFJjB1.js.map +1 -0
- package/dist/client/assets/index-CEqSkIuy.css +1 -0
- package/dist/client/assets/index-GubcPYw6.js +375 -0
- package/dist/client/assets/index-GubcPYw6.js.map +1 -0
- package/dist/client/assets/{workbox-window.prod.es5-Cch4wiA5.js → workbox-window.prod.es5-Bd17z0YL.js} +2 -2
- package/dist/client/assets/{workbox-window.prod.es5-Cch4wiA5.js.map → workbox-window.prod.es5-Bd17z0YL.js.map} +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/sw.js +1 -1
- package/dist/client/sw.js.map +1 -1
- package/dist/server/git-clone.js +364 -0
- package/dist/server/git-clone.js.map +1 -0
- package/dist/server/index.js +22 -0
- package/dist/server/index.js.map +1 -1
- package/dist/server/orchestration/config.js +61 -0
- package/dist/server/orchestration/config.js.map +1 -0
- package/dist/server/orchestration/event-bridge.js +93 -0
- package/dist/server/orchestration/event-bridge.js.map +1 -0
- package/dist/server/orchestration/inbox.js +199 -0
- package/dist/server/orchestration/inbox.js.map +1 -0
- package/dist/server/orchestration/init.js +39 -0
- package/dist/server/orchestration/init.js.map +1 -0
- package/dist/server/orchestration/store.js +352 -0
- package/dist/server/orchestration/store.js.map +1 -0
- package/dist/server/orchestration/tools.js +769 -0
- package/dist/server/orchestration/tools.js.map +1 -0
- package/dist/server/orchestration/types.js +57 -0
- package/dist/server/orchestration/types.js.map +1 -0
- package/dist/server/processes/manager.js +23 -1
- package/dist/server/processes/manager.js.map +1 -1
- package/dist/server/project-manager.js +46 -32
- package/dist/server/project-manager.js.map +1 -1
- package/dist/server/routes/control.js +9 -0
- package/dist/server/routes/control.js.map +1 -1
- package/dist/server/routes/health.js +14 -1
- package/dist/server/routes/health.js.map +1 -1
- package/dist/server/routes/orchestration.js +464 -0
- package/dist/server/routes/orchestration.js.map +1 -0
- package/dist/server/routes/projects.js +239 -14
- package/dist/server/routes/projects.js.map +1 -1
- package/dist/server/routes/sessions.js +53 -34
- package/dist/server/routes/sessions.js.map +1 -1
- package/dist/server/routes/webhooks.js +362 -0
- package/dist/server/routes/webhooks.js.map +1 -0
- package/dist/server/session-registry.js +226 -3
- package/dist/server/session-registry.js.map +1 -1
- package/dist/server/sse-bridge.js +85 -14
- package/dist/server/sse-bridge.js.map +1 -1
- package/dist/server/webhooks/dispatcher.js +254 -0
- package/dist/server/webhooks/dispatcher.js.map +1 -0
- package/dist/server/webhooks/event-bridge.js +185 -0
- package/dist/server/webhooks/event-bridge.js.map +1 -0
- package/dist/server/webhooks/init.js +55 -0
- package/dist/server/webhooks/init.js.map +1 -0
- package/dist/server/webhooks/store.js +394 -0
- package/dist/server/webhooks/store.js.map +1 -0
- package/dist/server/webhooks/types.js +32 -0
- package/dist/server/webhooks/types.js.map +1 -0
- package/package.json +4 -4
- package/dist/client/assets/CodeMirrorEditor-DXmxwE2Z.js.map +0 -1
- package/dist/client/assets/index-CMSjnWtF.js +0 -365
- package/dist/client/assets/index-CMSjnWtF.js.map +0 -1
- 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
|