pi-worker-graph 0.1.0-dev.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 (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +455 -0
  3. package/SECURITY.md +54 -0
  4. package/dist/config.d.ts +25 -0
  5. package/dist/config.d.ts.map +1 -0
  6. package/dist/config.js +181 -0
  7. package/dist/config.js.map +1 -0
  8. package/dist/context.d.ts +22 -0
  9. package/dist/context.d.ts.map +1 -0
  10. package/dist/context.js +81 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/coordination.d.ts +19 -0
  13. package/dist/coordination.d.ts.map +1 -0
  14. package/dist/coordination.js +267 -0
  15. package/dist/coordination.js.map +1 -0
  16. package/dist/execution-failure.d.ts +42 -0
  17. package/dist/execution-failure.d.ts.map +1 -0
  18. package/dist/execution-failure.js +90 -0
  19. package/dist/execution-failure.js.map +1 -0
  20. package/dist/extension.d.ts +8 -0
  21. package/dist/extension.d.ts.map +1 -0
  22. package/dist/extension.js +641 -0
  23. package/dist/extension.js.map +1 -0
  24. package/dist/graph.d.ts +44 -0
  25. package/dist/graph.d.ts.map +1 -0
  26. package/dist/graph.js +292 -0
  27. package/dist/graph.js.map +1 -0
  28. package/dist/index.d.ts +16 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/index.js +8 -0
  31. package/dist/index.js.map +1 -0
  32. package/dist/json.d.ts +9 -0
  33. package/dist/json.d.ts.map +1 -0
  34. package/dist/json.js +66 -0
  35. package/dist/json.js.map +1 -0
  36. package/dist/orchestrator.d.ts +15 -0
  37. package/dist/orchestrator.d.ts.map +1 -0
  38. package/dist/orchestrator.js +473 -0
  39. package/dist/orchestrator.js.map +1 -0
  40. package/dist/output.d.ts +61 -0
  41. package/dist/output.d.ts.map +1 -0
  42. package/dist/output.js +248 -0
  43. package/dist/output.js.map +1 -0
  44. package/dist/pi-subprocess.d.ts +92 -0
  45. package/dist/pi-subprocess.d.ts.map +1 -0
  46. package/dist/pi-subprocess.js +897 -0
  47. package/dist/pi-subprocess.js.map +1 -0
  48. package/dist/run.d.ts +89 -0
  49. package/dist/run.d.ts.map +1 -0
  50. package/dist/run.js +562 -0
  51. package/dist/run.js.map +1 -0
  52. package/dist/store.d.ts +331 -0
  53. package/dist/store.d.ts.map +1 -0
  54. package/dist/store.js +1993 -0
  55. package/dist/store.js.map +1 -0
  56. package/dist/usage.d.ts +35 -0
  57. package/dist/usage.d.ts.map +1 -0
  58. package/dist/usage.js +88 -0
  59. package/dist/usage.js.map +1 -0
  60. package/docs/DECISIONS.md +221 -0
  61. package/docs/DESIGN.md +392 -0
  62. package/docs/NEXT.md +229 -0
  63. package/docs/PLAN.md +203 -0
  64. package/docs/worker-graph.example.json +12 -0
  65. package/extensions/index.ts +1 -0
  66. package/extensions/tsconfig.json +11 -0
  67. package/package.json +66 -0
@@ -0,0 +1,641 @@
1
+ import { getAgentDir, } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { loadWorkerGraphConfiguration } from "./config.js";
4
+ import { registerWorkerCoordinationTools, workerCoordinationContext, } from "./coordination.js";
5
+ import { isRecord } from "./json.js";
6
+ import { registerWorkerGraphOrchestratorTool, WORKER_GRAPH_TOOL_NAME, } from "./orchestrator.js";
7
+ import { NODE_OUTPUT_LIMITS, NODE_OUTPUT_SCHEMA_VERSION, parseNodeArtifact, parseNodeOutput, splitReportArtifact, } from "./output.js";
8
+ import { deleteRun, listRetainedRuns, readRunUsage } from "./store.js";
9
+ const WORKER_ROLE_VARIABLE = "PI_WORKER_GRAPH_ROLE";
10
+ const WORKER_ROLE = "worker";
11
+ const MODE_ENTRY_TYPE = "worker-graph-mode";
12
+ const MODE_STATE_SCHEMA_VERSION = 1;
13
+ const MAX_MODE_TOOL_COUNT = 256;
14
+ const MAX_MODE_NAME_BYTES = 256;
15
+ const DISABLED_PARENT_TOOLS = new Set(["bash", "edit", "write"]);
16
+ const MODE_STATE_FIELDS = new Set([
17
+ "schemaVersion",
18
+ "enabled",
19
+ "toolsBeforeMode",
20
+ "modelBeforeMode",
21
+ "thinkingLevelBeforeMode",
22
+ ]);
23
+ const MODE_MODEL_FIELDS = new Set(["provider", "modelId"]);
24
+ const SESSION_THINKING_LEVELS = new Set([
25
+ "minimal",
26
+ "low",
27
+ "medium",
28
+ "high",
29
+ "xhigh",
30
+ "max",
31
+ ]);
32
+ const SWARM_USAGE = "Usage: /swarm on|status|off|runs|usage <run-id>|delete <run-id>";
33
+ const SWARM_DELETE_USAGE = "Usage: /swarm delete <run-id>";
34
+ const SWARM_RUN_USAGE_USAGE = "Usage: /swarm usage <run-id>";
35
+ const UNRESTORABLE_TOOLS_MESSAGE = "Worker-graph mode not enabled: the active tool set cannot be restored later";
36
+ const UNRESTORABLE_MODEL_MESSAGE = "Worker-graph mode not enabled: the session's current model cannot be restored later";
37
+ const UNKNOWN_MODEL_MESSAGE = "Worker-graph mode not enabled: the configured orchestrator model is not available";
38
+ const UNAUTHENTICATED_MODEL_MESSAGE = "Worker-graph mode not enabled: the configured orchestrator model has no configured authentication";
39
+ const UNREADABLE_CONFIGURATION_MESSAGE = "Worker-graph mode not enabled: the worker-graph configuration could not be read";
40
+ const MODEL_NOT_RESTORED_MESSAGE = "Worker-graph mode left the session on the orchestrator model: the model it started from is no longer available";
41
+ export const WORKER_REPORT_TOOL_NAME = "worker_graph_report";
42
+ export const SWARM_COMMAND_NAME = "swarm";
43
+ export const SWARM_FLAG_NAME = "swarm";
44
+ function modeToolSnapshot(value) {
45
+ if (!Array.isArray(value) ||
46
+ Object.getPrototypeOf(value) !== Array.prototype ||
47
+ value.length > MAX_MODE_TOOL_COUNT) {
48
+ return undefined;
49
+ }
50
+ const tools = [];
51
+ for (const tool of value) {
52
+ if (typeof tool !== "string" ||
53
+ tool.trim().length === 0 ||
54
+ tool !== tool.trim() ||
55
+ Buffer.byteLength(tool) > MAX_MODE_NAME_BYTES ||
56
+ tool === WORKER_GRAPH_TOOL_NAME ||
57
+ tools.includes(tool)) {
58
+ return undefined;
59
+ }
60
+ tools.push(tool);
61
+ }
62
+ return Object.freeze(tools);
63
+ }
64
+ function boundedName(value) {
65
+ return (typeof value === "string" &&
66
+ value.trim().length > 0 &&
67
+ value === value.trim() &&
68
+ Buffer.byteLength(value) <= MAX_MODE_NAME_BYTES);
69
+ }
70
+ /**
71
+ * The parent's model is recorded as the two identifiers that find it again,
72
+ * not as Pi's model object: only a provider and an id can be read back through
73
+ * the same bounds after a reload.
74
+ */
75
+ function modeModelSnapshot(value) {
76
+ if (!isRecord(value) ||
77
+ Object.keys(value).some((field) => !MODE_MODEL_FIELDS.has(field)) ||
78
+ !boundedName(value.provider) ||
79
+ !boundedName(value.modelId)) {
80
+ return undefined;
81
+ }
82
+ return Object.freeze({ provider: value.provider, modelId: value.modelId });
83
+ }
84
+ function parseModeState(value) {
85
+ if (!isRecord(value))
86
+ return undefined;
87
+ if (Object.keys(value).some((field) => !MODE_STATE_FIELDS.has(field)) ||
88
+ value.schemaVersion !== MODE_STATE_SCHEMA_VERSION ||
89
+ typeof value.enabled !== "boolean") {
90
+ return undefined;
91
+ }
92
+ if (!value.enabled) {
93
+ return value.toolsBeforeMode === undefined
94
+ ? { schemaVersion: MODE_STATE_SCHEMA_VERSION, enabled: false }
95
+ : undefined;
96
+ }
97
+ const toolsBeforeMode = modeToolSnapshot(value.toolsBeforeMode);
98
+ if (toolsBeforeMode === undefined)
99
+ return undefined;
100
+ // The model and its thinking level were captured together and are restored
101
+ // together, so a record carrying one without the other is not restorable.
102
+ const hasModel = value.modelBeforeMode !== undefined;
103
+ const hasLevel = value.thinkingLevelBeforeMode !== undefined;
104
+ if (hasModel !== hasLevel)
105
+ return undefined;
106
+ if (!hasModel) {
107
+ return Object.freeze({
108
+ schemaVersion: MODE_STATE_SCHEMA_VERSION,
109
+ enabled: true,
110
+ toolsBeforeMode,
111
+ });
112
+ }
113
+ const modelBeforeMode = modeModelSnapshot(value.modelBeforeMode);
114
+ if (modelBeforeMode === undefined ||
115
+ typeof value.thinkingLevelBeforeMode !== "string" ||
116
+ !SESSION_THINKING_LEVELS.has(value.thinkingLevelBeforeMode)) {
117
+ return undefined;
118
+ }
119
+ return Object.freeze({
120
+ schemaVersion: MODE_STATE_SCHEMA_VERSION,
121
+ enabled: true,
122
+ toolsBeforeMode,
123
+ modelBeforeMode,
124
+ thinkingLevelBeforeMode: value.thinkingLevelBeforeMode,
125
+ });
126
+ }
127
+ function latestModeState(entries) {
128
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
129
+ const entry = entries[index];
130
+ if (isRecord(entry) &&
131
+ entry.type === "custom" &&
132
+ entry.customType === MODE_ENTRY_TYPE) {
133
+ return parseModeState(entry.data);
134
+ }
135
+ }
136
+ return undefined;
137
+ }
138
+ /**
139
+ * `parseNodeOutput` measures every limit in UTF-8 bytes, but JSON Schema can
140
+ * only express `maxLength` in characters. `maxLength` therefore stays a coarse
141
+ * upper bound and the byte limit is stated in the field description, so the
142
+ * caller sees the real contract instead of discovering it through a rejection.
143
+ */
144
+ const boundedText = (purpose) => Type.String({
145
+ minLength: 1,
146
+ maxLength: NODE_OUTPUT_LIMITS.maxTextBytes,
147
+ description: `${purpose} At most ${NODE_OUTPUT_LIMITS.maxTextBytes} bytes of UTF-8.`,
148
+ });
149
+ const nodeOutputProperties = {
150
+ // Expressed as a bounded integer rather than a literal: a `const` keyword
151
+ // is rejected by some providers' strict function-schema validation.
152
+ schemaVersion: Type.Integer({
153
+ minimum: NODE_OUTPUT_SCHEMA_VERSION,
154
+ maximum: NODE_OUTPUT_SCHEMA_VERSION,
155
+ description: `Report format version. Always ${NODE_OUTPUT_SCHEMA_VERSION}.`,
156
+ }),
157
+ summary: boundedText("What was done, and whether the assignment is met."),
158
+ changedFiles: Type.Array(Type.Object({
159
+ path: Type.String({
160
+ minLength: 1,
161
+ maxLength: NODE_OUTPUT_LIMITS.maxPathBytes,
162
+ description: `Repository-relative path. At most ${NODE_OUTPUT_LIMITS.maxPathBytes} bytes of UTF-8.`,
163
+ }),
164
+ description: boundedText("What changed in this file, and why."),
165
+ }, { additionalProperties: false }), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
166
+ interfaces: Type.Array(boundedText("One interface downstream tasks must build against."), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
167
+ decisions: Type.Array(boundedText("One decision downstream tasks need to know about."), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
168
+ validation: Type.Array(Type.Object({
169
+ command: boundedText("The command that was run."),
170
+ result: boundedText("What the command reported."),
171
+ }, { additionalProperties: false }), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
172
+ blockers: Type.Array(boundedText("One reason the assignment could not be completed."), { maxItems: NODE_OUTPUT_LIMITS.maxItemsPerSection }),
173
+ };
174
+ /**
175
+ * The artifact is a sibling of the report, not a field of it: it is retained
176
+ * under the run for later review and never reaches a dependent task, so it is
177
+ * bounded on its own and leaves the report envelope at schema version 1.
178
+ */
179
+ const workerReportSchema = Type.Object({
180
+ ...nodeOutputProperties,
181
+ artifact: Type.Optional(Type.String({
182
+ minLength: 1,
183
+ maxLength: NODE_OUTPUT_LIMITS.maxArtifactBytes,
184
+ description: [
185
+ "Optional supplemental long-form text retained beside the report:",
186
+ "logs, investigation notes, or detailed review findings.",
187
+ "It is stored under the run for later review and is never given to",
188
+ "dependent tasks, so the structured report above must still stand",
189
+ "on its own.",
190
+ `At most ${NODE_OUTPUT_LIMITS.maxArtifactBytes} bytes of UTF-8.`,
191
+ ].join(" "),
192
+ })),
193
+ }, { additionalProperties: false });
194
+ function registerWorkerReportTool(pi) {
195
+ let submitted = false;
196
+ pi.registerTool({
197
+ name: WORKER_REPORT_TOOL_NAME,
198
+ label: "Worker Graph Report",
199
+ description: [
200
+ "Submit the required final structured task report.",
201
+ "Call this exactly once as your final action.",
202
+ `The whole report must serialize to at most ${NODE_OUTPUT_LIMITS.maxBytes} bytes of UTF-8,`,
203
+ "which is a smaller budget than the per-field limits allow together;",
204
+ "a rejected report can be corrected and resubmitted.",
205
+ "Supplemental long-form material belongs in the optional artifact field,",
206
+ "which is retained separately and never shortens the report.",
207
+ ].join(" "),
208
+ promptSnippet: "Submit the final worker report and end the task",
209
+ promptGuidelines: [
210
+ "Use worker_graph_report exactly once as the final action after completing or blocking the assigned task.",
211
+ "Report blockers honestly; do not claim success when required work or validation is incomplete.",
212
+ `Keep the whole report within ${NODE_OUTPUT_LIMITS.maxBytes} bytes by summarizing rather than pasting file contents or command transcripts.`,
213
+ "Put logs, investigation notes, and detailed findings in artifact rather than in the report, and never at the cost of a complete report: every blocker, interface, decision, changed file, and validation result belongs in the structured fields, because dependent tasks receive those and never the artifact.",
214
+ ],
215
+ parameters: workerReportSchema,
216
+ async execute(_toolCallId, params) {
217
+ if (submitted)
218
+ throw new Error("A final worker report was already submitted");
219
+ // Thrown validation errors reach the model as a tool error, so the
220
+ // message must say what to change. The parent treats a rejected report
221
+ // as recoverable and waits for a corrected resubmission. The artifact
222
+ // is separated before validation, so it is never mistaken for an
223
+ // undeclared report field, and the split is defensive rather than a
224
+ // destructuring: the submitted value is never read through an accessor.
225
+ const parts = splitReportArtifact(params);
226
+ const output = parseNodeOutput(parts.report);
227
+ const artifact = parseNodeArtifact(parts.artifact);
228
+ submitted = true;
229
+ return {
230
+ content: [{ type: "text", text: "Final worker report submitted." }],
231
+ details: {
232
+ kind: "worker-graph-node-output",
233
+ output,
234
+ ...(artifact === undefined ? {} : { artifact }),
235
+ },
236
+ terminate: true,
237
+ };
238
+ },
239
+ });
240
+ }
241
+ /**
242
+ * One line per unit of retained capacity. A run with no slot, and a slot with
243
+ * no readable run, are both shown rather than omitted: they are the states an
244
+ * operator is reading this to find.
245
+ */
246
+ function retainedRunsText(runs) {
247
+ if (runs.length === 0)
248
+ return "The run store retains no runs";
249
+ return [
250
+ `The run store retains ${runs.length} run(s), oldest first:`,
251
+ ...runs.map((run) => [
252
+ run.runId ?? "(slot names no run)",
253
+ `slot ${run.slot ?? "none"}`,
254
+ run.createdAt ?? "unreadable",
255
+ run.owned ? "owned" : "free",
256
+ ].join(" ")),
257
+ ].join("\n");
258
+ }
259
+ /**
260
+ * Tokens come from the provider's telemetry and are the sturdy number; cost
261
+ * is Pi's pricing of those tokens and only as good as its pricing table, so it
262
+ * is labelled as an estimate. Tasks that ran and recorded nothing are named
263
+ * rather than folded into the total as zero.
264
+ */
265
+ function runUsageText(usage) {
266
+ const money = (value) => value.toFixed(4);
267
+ return [
268
+ `Run ${usage.runId} spent ${usage.total.totalTokens} token(s) over ${usage.total.turns} turn(s)`,
269
+ ` input ${usage.total.input} output ${usage.total.output} cache read ${usage.total.cacheRead} cache write ${usage.total.cacheWrite}`,
270
+ ` estimated cost ${money(usage.total.cost.total)} (Pi's pricing of the reported tokens)`,
271
+ ...usage.tasks.map((task) => [
272
+ ` ${task.taskId}`,
273
+ task.status,
274
+ task.usage === undefined
275
+ ? task.accounting === "not_started"
276
+ ? "did not run"
277
+ : "no recorded usage"
278
+ : `${task.usage.totalTokens} token(s), ${money(task.usage.cost.total)}`,
279
+ ].join(" ")),
280
+ ...(usage.unaccounted.length === 0
281
+ ? []
282
+ : [
283
+ `The total excludes ${usage.unaccounted.length} task(s) that recorded no usage: ${usage.unaccounted.join(", ")}`,
284
+ ]),
285
+ ].join("\n");
286
+ }
287
+ function orchestratorDependencies(dependencies) {
288
+ return {
289
+ getAgentDirectory: dependencies.getAgentDirectory ?? getAgentDir,
290
+ ...(dependencies.loadConfiguration === undefined
291
+ ? {}
292
+ : { loadConfiguration: dependencies.loadConfiguration }),
293
+ ...(dependencies.createExecutor === undefined
294
+ ? {}
295
+ : { createExecutor: dependencies.createExecutor }),
296
+ ...(dependencies.executeGraph === undefined
297
+ ? {}
298
+ : { executeGraph: dependencies.executeGraph }),
299
+ ...(dependencies.readOutput === undefined
300
+ ? {}
301
+ : { readOutput: dependencies.readOutput }),
302
+ };
303
+ }
304
+ /** Pi package entry point with mutually exclusive worker and parent roles. */
305
+ export default function registerWorkerGraph(pi, dependencies = {}) {
306
+ if (process.env[WORKER_ROLE_VARIABLE] === WORKER_ROLE) {
307
+ registerWorkerReportTool(pi);
308
+ const coordination = workerCoordinationContext();
309
+ if (coordination !== undefined) {
310
+ registerWorkerCoordinationTools(pi, coordination);
311
+ }
312
+ return;
313
+ }
314
+ pi.registerFlag(SWARM_FLAG_NAME, {
315
+ description: "Start with worker-graph orchestration enabled",
316
+ type: "boolean",
317
+ default: false,
318
+ });
319
+ const resolved = orchestratorDependencies(dependencies);
320
+ const loadConfiguration = resolved.loadConfiguration ?? loadWorkerGraphConfiguration;
321
+ registerWorkerGraphOrchestratorTool(pi, resolved);
322
+ let enabled = false;
323
+ let toolsBeforeMode;
324
+ let modelBeforeMode;
325
+ let thinkingLevelBeforeMode;
326
+ /** The profile the mode applied, for `/swarm status`. */
327
+ let appliedOrchestrator;
328
+ const refuse = (message) => ({ ok: false, message });
329
+ /**
330
+ * Puts the session back on a recorded model. A model Pi can no longer find,
331
+ * or one whose provider lost its authentication, leaves the session where it
332
+ * is rather than throwing at whoever was leaving the mode.
333
+ */
334
+ const applyModel = async (ctx, snapshot, level) => {
335
+ const model = ctx.modelRegistry.find(snapshot.provider, snapshot.modelId);
336
+ if (model === undefined || !(await pi.setModel(model)))
337
+ return false;
338
+ pi.setThinkingLevel(level);
339
+ return true;
340
+ };
341
+ /**
342
+ * The snapshot is captured through the same bounds that restore it. A tool set
343
+ * this extension could not read back must never enable the mode, because the
344
+ * suppressed parent tools would then be unrecoverable after a reload. The
345
+ * parent's model is held to the same rule: the mode only moves a session onto
346
+ * a configured model once it knows the identifiers that move it back.
347
+ */
348
+ const activate = async (ctx, restored) => {
349
+ const snapshot = restored?.toolsBeforeMode ??
350
+ toolsBeforeMode ??
351
+ modeToolSnapshot(pi.getActiveTools().filter((name) => name !== WORKER_GRAPH_TOOL_NAME));
352
+ if (snapshot === undefined)
353
+ return refuse(UNRESTORABLE_TOOLS_MESSAGE);
354
+ let restoredModelFailed = false;
355
+ let orchestrator;
356
+ try {
357
+ orchestrator = (await loadConfiguration({
358
+ agentDirectory: resolved.getAgentDirectory(),
359
+ workingDirectory: ctx.cwd,
360
+ })).orchestrator;
361
+ }
362
+ catch (error) {
363
+ return refuse(error instanceof Error
364
+ ? `Worker-graph mode not enabled: ${error.message}`
365
+ : UNREADABLE_CONFIGURATION_MESSAGE);
366
+ }
367
+ const applyTools = () => {
368
+ pi.setActiveTools([
369
+ ...snapshot.filter((name) => !DISABLED_PARENT_TOOLS.has(name)),
370
+ WORKER_GRAPH_TOOL_NAME,
371
+ ]);
372
+ };
373
+ if (orchestrator === undefined) {
374
+ /**
375
+ * The configuration named an orchestrator when this branch was recorded
376
+ * and no longer does. The recorded model is the only way back, so it is
377
+ * restored here rather than discarded: dropping it would strand the
378
+ * session on a model the operator has since removed from the file.
379
+ */
380
+ const stranded = restored?.modelBeforeMode ?? modelBeforeMode;
381
+ const strandedLevel = restored?.thinkingLevelBeforeMode ?? thinkingLevelBeforeMode;
382
+ if (stranded !== undefined && strandedLevel !== undefined) {
383
+ restoredModelFailed = !(await applyModel(ctx, stranded, strandedLevel));
384
+ }
385
+ toolsBeforeMode = snapshot;
386
+ modelBeforeMode = undefined;
387
+ thinkingLevelBeforeMode = undefined;
388
+ appliedOrchestrator = undefined;
389
+ applyTools();
390
+ return {
391
+ ok: true,
392
+ ...(restoredModelFailed ? { warning: MODEL_NOT_RESTORED_MESSAGE } : {}),
393
+ };
394
+ }
395
+ /**
396
+ * A restored branch already recorded the model the operator started from.
397
+ * The live session may by then be running the orchestrator model, so the
398
+ * record is what gets carried forward rather than the current model.
399
+ */
400
+ let previousModel = restored?.modelBeforeMode ?? modelBeforeMode;
401
+ let previousLevel = restored?.thinkingLevelBeforeMode ?? thinkingLevelBeforeMode;
402
+ if (previousModel === undefined || previousLevel === undefined) {
403
+ const current = ctx.model;
404
+ const level = pi.getThinkingLevel();
405
+ if (current === undefined ||
406
+ !boundedName(current.provider) ||
407
+ !boundedName(current.id) ||
408
+ ctx.modelRegistry.find(current.provider, current.id) === undefined ||
409
+ !SESSION_THINKING_LEVELS.has(level)) {
410
+ return refuse(UNRESTORABLE_MODEL_MESSAGE);
411
+ }
412
+ previousModel = Object.freeze({
413
+ provider: current.provider,
414
+ modelId: current.id,
415
+ });
416
+ previousLevel = level;
417
+ }
418
+ const target = ctx.modelRegistry.find(orchestrator.provider, orchestrator.model);
419
+ if (target === undefined)
420
+ return refuse(UNKNOWN_MODEL_MESSAGE);
421
+ applyTools();
422
+ if (!(await pi.setModel(target))) {
423
+ // Nothing is left half-applied: the tool set goes back before the
424
+ // refusal, because the mode is not being entered.
425
+ pi.setActiveTools([...snapshot]);
426
+ return refuse(UNAUTHENTICATED_MODEL_MESSAGE);
427
+ }
428
+ pi.setThinkingLevel(orchestrator.thinkingLevel);
429
+ toolsBeforeMode = snapshot;
430
+ modelBeforeMode = previousModel;
431
+ thinkingLevelBeforeMode = previousLevel;
432
+ appliedOrchestrator = orchestrator;
433
+ return { ok: true };
434
+ };
435
+ /** True when a recorded model could not be put back. */
436
+ const deactivate = async (ctx) => {
437
+ let failed = false;
438
+ if (modelBeforeMode !== undefined &&
439
+ thinkingLevelBeforeMode !== undefined) {
440
+ failed = !(await applyModel(ctx, modelBeforeMode, thinkingLevelBeforeMode));
441
+ modelBeforeMode = undefined;
442
+ thinkingLevelBeforeMode = undefined;
443
+ }
444
+ appliedOrchestrator = undefined;
445
+ if (toolsBeforeMode !== undefined) {
446
+ pi.setActiveTools([...toolsBeforeMode]);
447
+ toolsBeforeMode = undefined;
448
+ return failed;
449
+ }
450
+ const active = pi.getActiveTools();
451
+ if (active.includes(WORKER_GRAPH_TOOL_NAME)) {
452
+ pi.setActiveTools(active.filter((name) => name !== WORKER_GRAPH_TOOL_NAME));
453
+ }
454
+ return failed;
455
+ };
456
+ const persistModeState = () => {
457
+ pi.appendEntry(MODE_ENTRY_TYPE, {
458
+ schemaVersion: MODE_STATE_SCHEMA_VERSION,
459
+ enabled,
460
+ ...(enabled && toolsBeforeMode !== undefined
461
+ ? { toolsBeforeMode: [...toolsBeforeMode] }
462
+ : {}),
463
+ ...(enabled &&
464
+ modelBeforeMode !== undefined &&
465
+ thinkingLevelBeforeMode !== undefined
466
+ ? {
467
+ modelBeforeMode: { ...modelBeforeMode },
468
+ thinkingLevelBeforeMode,
469
+ }
470
+ : {}),
471
+ });
472
+ };
473
+ /**
474
+ * The startup flag applies only to the launch that carried it. Navigating the
475
+ * session tree restores what the branch recorded, so `/swarm off` is never
476
+ * undone by the flag that started the session.
477
+ */
478
+ const restoreModeState = async (ctx, applyStartupFlag) => {
479
+ const restoreFailed = await deactivate(ctx);
480
+ const state = latestModeState(ctx.sessionManager.getBranch());
481
+ const enabledByFlag = applyStartupFlag && pi.getFlag(SWARM_FLAG_NAME) === true;
482
+ const requested = enabledByFlag || state?.enabled === true;
483
+ if (!requested) {
484
+ enabled = false;
485
+ return {
486
+ persist: false,
487
+ ...(restoreFailed ? { warning: MODEL_NOT_RESTORED_MESSAGE } : {}),
488
+ };
489
+ }
490
+ const result = await activate(ctx, state?.enabled === true ? state : undefined);
491
+ enabled = result.ok;
492
+ const warning = restoreFailed
493
+ ? MODEL_NOT_RESTORED_MESSAGE
494
+ : result.ok
495
+ ? result.warning
496
+ : undefined;
497
+ return {
498
+ persist: enabled && enabledByFlag && state?.enabled !== true,
499
+ ...(result.ok ? {} : { refused: result.message }),
500
+ ...(warning === undefined ? {} : { warning }),
501
+ };
502
+ };
503
+ pi.on("session_start", async (_event, ctx) => {
504
+ const restored = await restoreModeState(ctx, true);
505
+ if (restored.persist)
506
+ persistModeState();
507
+ if (restored.warning !== undefined) {
508
+ ctx.ui.notify(restored.warning, "warning");
509
+ }
510
+ if (restored.refused !== undefined) {
511
+ ctx.ui.notify(restored.refused, "error");
512
+ }
513
+ });
514
+ pi.on("session_tree", async (_event, ctx) => {
515
+ const restored = await restoreModeState(ctx, false);
516
+ if (restored.warning !== undefined) {
517
+ ctx.ui.notify(restored.warning, "warning");
518
+ }
519
+ if (restored.refused !== undefined) {
520
+ ctx.ui.notify(restored.refused, "error");
521
+ }
522
+ });
523
+ pi.on("session_shutdown", async (_event, ctx) => {
524
+ /**
525
+ * A failed restore is discarded rather than notified here. Pi stops the
526
+ * TUI before it emits this event on the interactive quit path, so a
527
+ * notification would go nowhere. Nothing is lost by staying quiet: the
528
+ * branch still records the model the session started from, so a resumed
529
+ * session carries that record forward and `/swarm off` there restores it,
530
+ * reporting a failure where it can actually be seen.
531
+ */
532
+ await deactivate(ctx);
533
+ });
534
+ /**
535
+ * Resolved from the same agent directory and the same working directory a
536
+ * graph run resolves it from, so the command and the orchestration tool can
537
+ * never disagree about which store they are acting on.
538
+ */
539
+ const resolveStateRoot = async (workingDirectory) => {
540
+ const configuration = await loadConfiguration({
541
+ agentDirectory: resolved.getAgentDirectory(),
542
+ workingDirectory,
543
+ });
544
+ return configuration.stateRoot;
545
+ };
546
+ /**
547
+ * Retention cleanup is a command rather than a tool. Deleting a run destroys
548
+ * the diagnostic state it was kept for, so it is an operator's act: the
549
+ * parent model exposes one static orchestration tool and no way to reach
550
+ * this. Both subcommands work whether or not the mode is enabled, because
551
+ * the state they report outlives any one session, and a store that cannot
552
+ * be read or changed is reported rather than thrown at the session.
553
+ */
554
+ const runStore = async (ctx, act) => {
555
+ try {
556
+ ctx.ui.notify(await act(await resolveStateRoot(ctx.cwd)), "info");
557
+ }
558
+ catch (error) {
559
+ ctx.ui.notify(error instanceof Error ? error.message : "The run store failed", "error");
560
+ }
561
+ };
562
+ pi.registerCommand(SWARM_COMMAND_NAME, {
563
+ description: "Enable, disable, or inspect worker-graph orchestration and its run store",
564
+ async handler(args, ctx) {
565
+ const [action = "", ...operands] = args
566
+ .trim()
567
+ .split(/\s+/)
568
+ .filter((word) => word.length > 0);
569
+ // Operands are checked before the store is touched, so a mistyped
570
+ // invocation answers with its usage and not with a store failure.
571
+ if (action === "runs") {
572
+ if (operands.length !== 0) {
573
+ ctx.ui.notify(SWARM_USAGE, "warning");
574
+ return;
575
+ }
576
+ await runStore(ctx, async (stateRoot) => retainedRunsText(await listRetainedRuns(stateRoot)));
577
+ return;
578
+ }
579
+ if (action === "usage") {
580
+ const [runId] = operands;
581
+ if (runId === undefined || operands.length !== 1) {
582
+ ctx.ui.notify(SWARM_RUN_USAGE_USAGE, "warning");
583
+ return;
584
+ }
585
+ await runStore(ctx, async (stateRoot) => runUsageText(await readRunUsage(stateRoot, runId)));
586
+ return;
587
+ }
588
+ if (action === "delete") {
589
+ const [runId] = operands;
590
+ if (runId === undefined || operands.length !== 1) {
591
+ ctx.ui.notify(SWARM_DELETE_USAGE, "warning");
592
+ return;
593
+ }
594
+ await runStore(ctx, async (stateRoot) => (await deleteRun(stateRoot, runId))
595
+ ? `Deleted run ${runId} and released its capacity`
596
+ : `No run ${runId} is retained`);
597
+ return;
598
+ }
599
+ if (action === "on") {
600
+ // Re-entering would reload the configuration and could report "not
601
+ // enabled" for a mode that is enabled and applied. The mode is
602
+ // already what was asked for, so say that instead.
603
+ if (enabled) {
604
+ ctx.ui.notify("Worker-graph mode is already enabled", "info");
605
+ return;
606
+ }
607
+ const activated = await activate(ctx);
608
+ if (!activated.ok) {
609
+ ctx.ui.notify(activated.message, "error");
610
+ return;
611
+ }
612
+ enabled = true;
613
+ persistModeState();
614
+ if (activated.warning !== undefined) {
615
+ ctx.ui.notify(activated.warning, "warning");
616
+ }
617
+ ctx.ui.notify("Worker-graph mode enabled", "info");
618
+ }
619
+ else if (action === "off") {
620
+ enabled = false;
621
+ const restoreFailed = await deactivate(ctx);
622
+ persistModeState();
623
+ // Reported before the confirmation: leaving the mode on a model the
624
+ // operator did not choose is the more important half of the outcome.
625
+ if (restoreFailed) {
626
+ ctx.ui.notify(MODEL_NOT_RESTORED_MESSAGE, "warning");
627
+ }
628
+ ctx.ui.notify("Worker-graph mode disabled", "info");
629
+ }
630
+ else if (action === "status") {
631
+ ctx.ui.notify(`Worker-graph mode is ${enabled ? "enabled" : "disabled"}${appliedOrchestrator === undefined
632
+ ? ""
633
+ : `; the parent is on ${appliedOrchestrator.provider}/${appliedOrchestrator.model} at ${appliedOrchestrator.thinkingLevel} thinking`}`, "info");
634
+ }
635
+ else {
636
+ ctx.ui.notify(SWARM_USAGE, "warning");
637
+ }
638
+ },
639
+ });
640
+ }
641
+ //# sourceMappingURL=extension.js.map