@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.
- package/dist/cli/index.js +6601 -5780
- package/dist/cli/index.js.map +4 -4
- package/package.json +9 -8
- package/templates/cleoos-hub/README.md +34 -0
- package/templates/cleoos-hub/global-recipes/README.md +46 -0
- package/templates/cleoos-hub/global-recipes/justfile +68 -0
- package/templates/cleoos-hub/pi-extensions/cant-bridge.ts +989 -0
- package/templates/cleoos-hub/pi-extensions/orchestrator.ts +624 -0
- package/templates/cleoos-hub/pi-extensions/stage-guide.ts +164 -0
|
@@ -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
|
+
}
|