@cleocode/cleo 2026.4.5 → 2026.4.6

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.
@@ -0,0 +1,624 @@
1
+ /**
2
+ * CleoOS orchestrator — the Conductor Loop.
3
+ *
4
+ * Installed to: $CLEO_HOME/pi-extensions/orchestrator.ts
5
+ * Loaded by: Pi via `-e <path>` or settings.json extensions array
6
+ *
7
+ * Phase 3 — autonomous epic execution via CLEO CLI dispatch.
8
+ *
9
+ * Protocol:
10
+ * 1. Operator invokes `/cleo:auto <epicId>` from Pi.
11
+ * 2. The loop polls CLEO's kernel for ready tasks and lifecycle state,
12
+ * spawns subagents via `cleo orchestrate spawn <taskId>`, waits
13
+ * for each subagent to settle, validates the output, and continues
14
+ * until the epic reports `done`/`completed` or the safety cap trips.
15
+ * 3. Every CLI call is a LAFS envelope parse — on any failure the loop
16
+ * logs via `ctx.ui.notify` and backs off rather than crashing.
17
+ *
18
+ * Supporting commands:
19
+ * - `/cleo:stop` — gracefully halts the active loop between iterations.
20
+ * - `/cleo:status` — prints loop state (iterations, task, stage, elapsed).
21
+ *
22
+ * Mock mode: set `CLEOOS_MOCK=1` to skip all `cleo` CLI calls and run
23
+ * three synthetic iterations. Used by CI smoke tests.
24
+ */
25
+
26
+ import type {
27
+ ExecResult,
28
+ ExtensionAPI,
29
+ ExtensionCommandContext,
30
+ ExtensionContext,
31
+ } from "@mariozechner/pi-coding-agent";
32
+
33
+ // ============================================================================
34
+ // LAFS envelope shapes
35
+ // ============================================================================
36
+
37
+ /**
38
+ * Minimal LAFS envelope shape shared by every `cleo` CLI command.
39
+ * Only the fields this extension consumes are typed.
40
+ */
41
+ interface LafsMinimalEnvelope<T = unknown> {
42
+ ok: boolean;
43
+ r?: T;
44
+ error?: { code: string | number; message: string };
45
+ _m?: { op: string; rid: string };
46
+ }
47
+
48
+ /** Result of `cleo show <id>`. */
49
+ interface TaskShowResult {
50
+ id: string;
51
+ status: string;
52
+ title?: string;
53
+ }
54
+
55
+ /** Result of `cleo current`. */
56
+ interface CurrentTaskResult {
57
+ active?: boolean;
58
+ taskId?: string;
59
+ task?: { id: string; title?: string };
60
+ }
61
+
62
+ /** Result of `cleo orchestrate next <epicId>`. */
63
+ interface OrchestrateNextResult {
64
+ nextTask: { id: string; title: string; priority?: string } | null;
65
+ totalReady?: number;
66
+ }
67
+
68
+ /** Result of `cleo lifecycle guidance --epicId <id>`. */
69
+ interface LifecycleGuidanceResult {
70
+ stage: string;
71
+ name: string;
72
+ order: number;
73
+ prompt: string;
74
+ }
75
+
76
+ /** Result of `cleo orchestrate spawn <taskId>`. */
77
+ interface OrchestrateSpawnResult {
78
+ instanceId?: string;
79
+ status?: string;
80
+ spawnContext?: {
81
+ taskId: string;
82
+ protocol: string;
83
+ protocolType: string;
84
+ tier: string;
85
+ };
86
+ tokenResolution?: {
87
+ fullyResolved: boolean;
88
+ unresolvedTokens: string[];
89
+ };
90
+ }
91
+
92
+ /** Result of `cleo orchestrate validate <taskId>`. */
93
+ interface OrchestrateValidateResult {
94
+ valid: boolean;
95
+ taskId: string;
96
+ reason?: string;
97
+ }
98
+
99
+ // ============================================================================
100
+ // Loop state
101
+ // ============================================================================
102
+
103
+ /**
104
+ * Runtime state of a single Conductor Loop invocation. A loop is bound to an
105
+ * epic; only one loop may run at a time per Pi session.
106
+ */
107
+ interface LoopState {
108
+ epicId: string;
109
+ iterations: number;
110
+ currentTask: string | null;
111
+ currentStage: string | null;
112
+ startedAt: Date;
113
+ stopped: boolean;
114
+ }
115
+
116
+ const WIDGET_KEY = "cleo-conductor";
117
+ const STATUS_KEY = "cleo-conductor";
118
+ const MAX_ITERATIONS = 100;
119
+ const POLL_INTERVAL_MS = 5_000;
120
+ const ERROR_BACKOFF_MS = 3_000;
121
+ const SUBAGENT_TIMEOUT_MS = 10 * 60 * 1_000;
122
+ const SUBAGENT_POLL_INTERVAL_MS = 5_000;
123
+
124
+ /** Module-level state so `cleo:stop` and `cleo:status` can reach the active loop. */
125
+ let activeLoop: LoopState | null = null;
126
+
127
+ // ============================================================================
128
+ // CLI helper
129
+ // ============================================================================
130
+
131
+ /**
132
+ * Invoke the `cleo` CLI via the Pi `exec` API and parse stdout as a LAFS
133
+ * envelope. Returns the unwrapped result payload or undefined on any failure
134
+ * (non-zero exit, non-JSON stdout, `ok:false`, abort, etc.).
135
+ */
136
+ async function cleoCli<T = unknown>(
137
+ pi: ExtensionAPI,
138
+ args: string[],
139
+ signal: AbortSignal | undefined,
140
+ ): Promise<T | undefined> {
141
+ let result: ExecResult;
142
+ try {
143
+ result = await pi.exec("cleo", args, { signal });
144
+ } catch {
145
+ return undefined;
146
+ }
147
+ if (result.code !== 0) return undefined;
148
+
149
+ // CLI may log warnings above the envelope; find the last JSON-looking line.
150
+ const lines = result.stdout.trim().split("\n");
151
+ const envLine = [...lines].reverse().find((l) => l.trim().startsWith("{"));
152
+ if (!envLine) return undefined;
153
+
154
+ try {
155
+ const env = JSON.parse(envLine) as LafsMinimalEnvelope<T>;
156
+ if (env.ok && env.r !== undefined) return env.r;
157
+ return undefined;
158
+ } catch {
159
+ return undefined;
160
+ }
161
+ }
162
+
163
+ // ============================================================================
164
+ // Timing utilities
165
+ // ============================================================================
166
+
167
+ /**
168
+ * Sleep for `ms` milliseconds, resolving early when the signal aborts.
169
+ * Never throws — callers should check `signal.aborted` after awaiting.
170
+ */
171
+ function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
172
+ return new Promise((resolve) => {
173
+ const timer = setTimeout(() => {
174
+ if (signal) signal.removeEventListener("abort", onAbort);
175
+ resolve();
176
+ }, ms);
177
+ const onAbort = (): void => {
178
+ clearTimeout(timer);
179
+ resolve();
180
+ };
181
+ if (signal) {
182
+ if (signal.aborted) {
183
+ clearTimeout(timer);
184
+ resolve();
185
+ return;
186
+ }
187
+ signal.addEventListener("abort", onAbort, { once: true });
188
+ }
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Format an elapsed millisecond duration as `HhMmSs` for status display.
194
+ */
195
+ function formatElapsed(startedAt: Date): string {
196
+ const ms = Date.now() - startedAt.getTime();
197
+ const totalSeconds = Math.floor(ms / 1_000);
198
+ const hours = Math.floor(totalSeconds / 3_600);
199
+ const minutes = Math.floor((totalSeconds % 3_600) / 60);
200
+ const seconds = totalSeconds % 60;
201
+ if (hours > 0) return `${hours}h${minutes}m${seconds}s`;
202
+ if (minutes > 0) return `${minutes}m${seconds}s`;
203
+ return `${seconds}s`;
204
+ }
205
+
206
+ // ============================================================================
207
+ // UI rendering
208
+ // ============================================================================
209
+
210
+ /**
211
+ * Render (or clear) the Conductor widget below the editor. Passing `undefined`
212
+ * clears the widget — used on loop exit.
213
+ */
214
+ function renderWidget(ctx: ExtensionContext, state: LoopState | null): void {
215
+ if (!ctx.hasUI) return;
216
+ if (!state) {
217
+ ctx.ui.setWidget(WIDGET_KEY, undefined, { placement: "belowEditor" });
218
+ ctx.ui.setStatus(STATUS_KEY, undefined);
219
+ return;
220
+ }
221
+ const stage = state.currentStage ?? "-";
222
+ const task = state.currentTask ?? "-";
223
+ const line = `🎼 Conductor: ${state.epicId} — iter ${state.iterations} — ${stage} — ${task}`;
224
+ ctx.ui.setWidget(WIDGET_KEY, [line], { placement: "belowEditor" });
225
+ ctx.ui.setStatus(STATUS_KEY, `🎼 ${state.epicId} i${state.iterations}`);
226
+ }
227
+
228
+ // ============================================================================
229
+ // Mock mode
230
+ // ============================================================================
231
+
232
+ /**
233
+ * Run three synthetic iterations without touching the CLEO CLI.
234
+ * Used for CI smoke tests via `CLEOOS_MOCK=1`.
235
+ */
236
+ async function runMockLoop(state: LoopState, ctx: ExtensionContext): Promise<void> {
237
+ const mockStages = ["discover", "plan", "execute"];
238
+ for (let i = 0; i < 3; i += 1) {
239
+ if (state.stopped || ctx.signal?.aborted) break;
240
+ state.iterations += 1;
241
+ state.currentStage = mockStages[i] ?? "mock";
242
+ state.currentTask = `T-MOCK-${i + 1}`;
243
+ renderWidget(ctx, state);
244
+ if (ctx.hasUI) {
245
+ ctx.ui.notify(`[mock] iteration ${state.iterations}: ${state.currentTask}`, "info");
246
+ }
247
+ await sleep(500, ctx.signal);
248
+ }
249
+ }
250
+
251
+ // ============================================================================
252
+ // Wait-for-subagent helper
253
+ // ============================================================================
254
+
255
+ /**
256
+ * Poll `cleo current` until no task is in flight, the deadline elapses, or
257
+ * the signal aborts. Returns when the subagent has settled (one way or
258
+ * another) so the caller can validate the outcome.
259
+ */
260
+ async function waitForSubagentSettle(
261
+ pi: ExtensionAPI,
262
+ taskId: string,
263
+ state: LoopState,
264
+ ctx: ExtensionContext,
265
+ ): Promise<void> {
266
+ const deadline = Date.now() + SUBAGENT_TIMEOUT_MS;
267
+ while (Date.now() < deadline) {
268
+ if (state.stopped || ctx.signal?.aborted) return;
269
+ const current = await cleoCli<CurrentTaskResult>(pi, ["current"], ctx.signal);
270
+ // If the CLI reports no active task (or a different task), the subagent
271
+ // we spawned has handed control back to the kernel.
272
+ const currentId = current?.taskId ?? current?.task?.id;
273
+ if (!current || current.active === false || (currentId && currentId !== taskId)) {
274
+ return;
275
+ }
276
+ await sleep(SUBAGENT_POLL_INTERVAL_MS, ctx.signal);
277
+ }
278
+ if (ctx.hasUI) {
279
+ ctx.ui.notify(
280
+ `Conductor: subagent for ${taskId} exceeded ${Math.floor(SUBAGENT_TIMEOUT_MS / 60_000)}m timeout`,
281
+ "warning",
282
+ );
283
+ }
284
+ }
285
+
286
+ // ============================================================================
287
+ // Single loop iteration
288
+ // ============================================================================
289
+
290
+ /**
291
+ * Outcome of one iteration of the Conductor Loop body — drives the outer
292
+ * loop's control flow explicitly instead of relying on bare booleans.
293
+ */
294
+ type IterationOutcome = "continue" | "idle" | "done" | "error";
295
+
296
+ /**
297
+ * Execute a single iteration of the Conductor Loop. Every CLI failure is
298
+ * caught and reported; the function itself never throws.
299
+ */
300
+ async function runIteration(
301
+ pi: ExtensionAPI,
302
+ state: LoopState,
303
+ ctx: ExtensionContext,
304
+ ): Promise<IterationOutcome> {
305
+ // (a) Epic completion check
306
+ const show = await cleoCli<TaskShowResult>(pi, ["show", state.epicId], ctx.signal);
307
+ if (!show) {
308
+ if (ctx.hasUI) ctx.ui.notify(`Conductor: cleo show ${state.epicId} failed`, "error");
309
+ return "error";
310
+ }
311
+ const status = show.status?.toLowerCase();
312
+ if (status === "done" || status === "completed") return "done";
313
+
314
+ // (b) Skip if another task is already in flight
315
+ const current = await cleoCli<CurrentTaskResult>(pi, ["current"], ctx.signal);
316
+ const currentId = current?.taskId ?? current?.task?.id;
317
+ if (current && current.active !== false && currentId) {
318
+ state.currentTask = currentId;
319
+ renderWidget(ctx, state);
320
+ return "idle";
321
+ }
322
+
323
+ // (c) Fetch next ready task
324
+ const next = await cleoCli<OrchestrateNextResult>(
325
+ pi,
326
+ ["orchestrate", "next", state.epicId],
327
+ ctx.signal,
328
+ );
329
+ if (!next || !next.nextTask) {
330
+ return "idle";
331
+ }
332
+ const taskId = next.nextTask.id;
333
+ state.currentTask = taskId;
334
+
335
+ // (d) Refresh lifecycle stage for the widget and operator visibility
336
+ const guidance = await cleoCli<LifecycleGuidanceResult>(
337
+ pi,
338
+ ["lifecycle", "guidance", "--epicId", state.epicId],
339
+ ctx.signal,
340
+ );
341
+ if (guidance) {
342
+ state.currentStage = `${guidance.name} ${guidance.order}/9`;
343
+ }
344
+ renderWidget(ctx, state);
345
+
346
+ // (e) Spawn subagent via CLEO's adapter
347
+ const spawn = await cleoCli<OrchestrateSpawnResult>(
348
+ pi,
349
+ ["orchestrate", "spawn", taskId],
350
+ ctx.signal,
351
+ );
352
+ if (!spawn || !spawn.instanceId) {
353
+ if (ctx.hasUI) {
354
+ ctx.ui.notify(`Conductor: spawn failed for ${taskId}`, "warning");
355
+ }
356
+ // (f) Best-effort validation to surface the reason, then move on.
357
+ const validation = await cleoCli<OrchestrateValidateResult>(
358
+ pi,
359
+ ["orchestrate", "validate", taskId],
360
+ ctx.signal,
361
+ );
362
+ if (validation && ctx.hasUI && validation.reason) {
363
+ ctx.ui.notify(`Conductor: validate(${taskId}) — ${validation.reason}`, "warning");
364
+ }
365
+ return "error";
366
+ }
367
+
368
+ // (g) Wait for the subagent to settle (timeout-bounded)
369
+ await waitForSubagentSettle(pi, taskId, state, ctx);
370
+ if (state.stopped || ctx.signal?.aborted) return "continue";
371
+
372
+ // (h) Validate the task's output
373
+ const validation = await cleoCli<OrchestrateValidateResult>(
374
+ pi,
375
+ ["orchestrate", "validate", taskId],
376
+ ctx.signal,
377
+ );
378
+ if (!validation || !validation.valid) {
379
+ if (ctx.hasUI) {
380
+ const reason = validation?.reason ?? "unknown";
381
+ ctx.ui.notify(`Conductor: validate(${taskId}) failed — ${reason}`, "warning");
382
+ }
383
+ return "error";
384
+ }
385
+
386
+ // Mark complete and continue. `cleo complete` itself emits a LAFS envelope
387
+ // but we do not need its payload.
388
+ await cleoCli(pi, ["complete", taskId], ctx.signal);
389
+ return "continue";
390
+ }
391
+
392
+ // ============================================================================
393
+ // Conductor Loop entry point
394
+ // ============================================================================
395
+
396
+ /**
397
+ * Run the Conductor Loop for an epic until it completes, the safety cap
398
+ * trips, the operator stops it, or the signal aborts.
399
+ */
400
+ async function runConductorLoop(
401
+ pi: ExtensionAPI,
402
+ epicId: string,
403
+ ctx: ExtensionCommandContext,
404
+ ): Promise<void> {
405
+ if (activeLoop && !activeLoop.stopped) {
406
+ if (ctx.hasUI) {
407
+ ctx.ui.notify(
408
+ `Conductor already running for ${activeLoop.epicId}. Use /cleo:stop first.`,
409
+ "warning",
410
+ );
411
+ }
412
+ return;
413
+ }
414
+
415
+ const state: LoopState = {
416
+ epicId,
417
+ iterations: 0,
418
+ currentTask: null,
419
+ currentStage: null,
420
+ startedAt: new Date(),
421
+ stopped: false,
422
+ };
423
+ activeLoop = state;
424
+ renderWidget(ctx, state);
425
+ if (ctx.hasUI) {
426
+ ctx.ui.notify(`Conductor: starting loop for ${epicId}`, "info");
427
+ }
428
+
429
+ const isMock = process.env.CLEOOS_MOCK === "1";
430
+
431
+ try {
432
+ if (isMock) {
433
+ await runMockLoop(state, ctx);
434
+ if (ctx.hasUI) {
435
+ ctx.ui.notify(`Conductor: mock loop finished (${state.iterations} iterations)`, "info");
436
+ }
437
+ return;
438
+ }
439
+
440
+ while (state.iterations < MAX_ITERATIONS) {
441
+ if (state.stopped) {
442
+ if (ctx.hasUI) ctx.ui.notify("Conductor: stopped by operator", "info");
443
+ break;
444
+ }
445
+ if (ctx.signal?.aborted) {
446
+ if (ctx.hasUI) ctx.ui.notify("Conductor: aborted", "info");
447
+ break;
448
+ }
449
+
450
+ let outcome: IterationOutcome;
451
+ try {
452
+ outcome = await runIteration(pi, state, ctx);
453
+ } catch (err) {
454
+ const msg = err instanceof Error ? err.message : String(err);
455
+ if (ctx.hasUI) ctx.ui.notify(`Conductor: iteration threw — ${msg}`, "error");
456
+ outcome = "error";
457
+ }
458
+
459
+ if (outcome === "done") {
460
+ if (ctx.hasUI) {
461
+ ctx.ui.notify(`Conductor: epic ${epicId} complete`, "info");
462
+ }
463
+ break;
464
+ }
465
+
466
+ if (outcome === "continue") {
467
+ state.iterations += 1;
468
+ renderWidget(ctx, state);
469
+ continue;
470
+ }
471
+
472
+ if (outcome === "idle") {
473
+ // Nothing ready — back off and re-poll.
474
+ await sleep(POLL_INTERVAL_MS, ctx.signal);
475
+ continue;
476
+ }
477
+
478
+ // outcome === "error"
479
+ await sleep(ERROR_BACKOFF_MS, ctx.signal);
480
+ }
481
+
482
+ if (state.iterations >= MAX_ITERATIONS && ctx.hasUI) {
483
+ ctx.ui.notify(
484
+ `Conductor: safety cap of ${MAX_ITERATIONS} iterations reached for ${epicId}`,
485
+ "warning",
486
+ );
487
+ }
488
+ } finally {
489
+ renderWidget(ctx, null);
490
+ if (activeLoop === state) activeLoop = null;
491
+ }
492
+ }
493
+
494
+ // ============================================================================
495
+ // Pi extension factory
496
+ // ============================================================================
497
+
498
+ /**
499
+ * Pi extension factory.
500
+ *
501
+ * Registers the three Conductor commands (`cleo:auto`, `cleo:stop`,
502
+ * `cleo:status`) and clears any lingering widget state on session shutdown.
503
+ * Registration is performed synchronously so Pi discovers the commands
504
+ * before the first event loop tick.
505
+ */
506
+ export default function (pi: ExtensionAPI): void {
507
+ // ---------------------------------------------------------------------
508
+ // Session start: load ct-orchestrator + ct-cleo into LLM system prompt
509
+ // via `cleo lifecycle guidance`. This ensures the Pi session always
510
+ // operates under ORC-001..009 constraints from the real SKILL.md files,
511
+ // not hand-authored prose. If there is no active epic, we still load
512
+ // the Tier-0 skills (ct-cleo, ct-orchestrator) so the operator can
513
+ // invoke `/cleo:auto` and /cleo:status immediately with the right
514
+ // protocol grounding.
515
+ // ---------------------------------------------------------------------
516
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
517
+ try {
518
+ if (!ctx.hasUI) return;
519
+ ctx.ui.setStatus("cleoos", "⚙ CleoOS: ct-orchestrator + ct-cleo loaded");
520
+ } catch {
521
+ // Non-fatal
522
+ }
523
+ });
524
+
525
+ // Inject ct-orchestrator/ct-cleo (Tier 0) into the LLM system prompt on
526
+ // EVERY agent turn, so even without an active epic the session runs
527
+ // under the skill-backed protocol. The CLEO stage-guide.ts extension
528
+ // ALSO fires `before_agent_start` — Pi chains their returns, so the
529
+ // stage-specific skill (if present) stacks on top of Tier 0.
530
+ pi.on("before_agent_start", async (_event, ctx: ExtensionContext) => {
531
+ try {
532
+ // Default to 'implementation' — the cleo CLI resolves the
533
+ // stage-specific skill via STAGE_SKILL_MAP and composes it with
534
+ // Tier 0 (ct-cleo, ct-orchestrator) via prepareSpawnMulti.
535
+ //
536
+ // Pi chains `before_agent_start` returns from multiple extensions,
537
+ // so stage-guide.ts's more-specific injection (derived from the
538
+ // active epic's current stage) can override this baseline when an
539
+ // epic is actively in a different stage.
540
+ const guidance = await cleoCli<{ prompt?: string }>(
541
+ pi,
542
+ ["lifecycle", "guidance", "implementation"],
543
+ ctx.signal,
544
+ );
545
+ if (!guidance?.prompt) return {};
546
+ return { systemPrompt: guidance.prompt };
547
+ } catch {
548
+ return {};
549
+ }
550
+ });
551
+
552
+ pi.registerCommand("cleo:auto", {
553
+ description: "Run the CleoOS Conductor Loop for an epic (arg: epicId)",
554
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
555
+ const epicId = args.trim();
556
+ if (!epicId) {
557
+ if (ctx.hasUI) {
558
+ ctx.ui.notify("Usage: /cleo:auto <epicId>", "error");
559
+ }
560
+ return;
561
+ }
562
+ await runConductorLoop(pi, epicId, ctx);
563
+ },
564
+ });
565
+
566
+ pi.registerCommand("cleo:stop", {
567
+ description: "Stop the active CleoOS Conductor Loop",
568
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
569
+ if (!activeLoop || activeLoop.stopped) {
570
+ if (ctx.hasUI) ctx.ui.notify("Conductor: no active loop", "info");
571
+ return;
572
+ }
573
+ activeLoop.stopped = true;
574
+ if (ctx.hasUI) {
575
+ ctx.ui.notify(`Conductor: stop requested for ${activeLoop.epicId}`, "info");
576
+ }
577
+ },
578
+ });
579
+
580
+ pi.registerCommand("cleo:status", {
581
+ description: "Print CleoOS Conductor Loop state",
582
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
583
+ if (!activeLoop) {
584
+ pi.sendMessage(
585
+ {
586
+ customType: "cleoos-status",
587
+ content: "Conductor: idle (no active loop)",
588
+ display: true,
589
+ },
590
+ { triggerTurn: false },
591
+ );
592
+ return;
593
+ }
594
+ const elapsed = formatElapsed(activeLoop.startedAt);
595
+ const lines = [
596
+ `Conductor: ${activeLoop.epicId}`,
597
+ ` iterations: ${activeLoop.iterations}`,
598
+ ` currentTask: ${activeLoop.currentTask ?? "-"}`,
599
+ ` currentStage: ${activeLoop.currentStage ?? "-"}`,
600
+ ` elapsed: ${elapsed}`,
601
+ ` stopped: ${activeLoop.stopped}`,
602
+ ];
603
+ pi.sendMessage(
604
+ {
605
+ customType: "cleoos-status",
606
+ content: lines.join("\n"),
607
+ display: true,
608
+ },
609
+ { triggerTurn: false },
610
+ );
611
+ if (ctx.hasUI) {
612
+ ctx.ui.notify(
613
+ `Conductor: ${activeLoop.epicId} iter ${activeLoop.iterations} (${elapsed})`,
614
+ "info",
615
+ );
616
+ }
617
+ },
618
+ });
619
+
620
+ pi.on("session_shutdown", async () => {
621
+ if (activeLoop) activeLoop.stopped = true;
622
+ activeLoop = null;
623
+ });
624
+ }