@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,989 @@
1
+ /**
2
+ * CleoOS CANT runtime bridge — Phase 4.
3
+ *
4
+ * Installed to: $CLEO_HOME/pi-extensions/cant-bridge.ts
5
+ * Loaded by: Pi via `-e <path>` or settings.json extensions array
6
+ *
7
+ * CLEO's CANT DSL (.cant files) mixes two tiers of constructs:
8
+ * 1. Deterministic pipelines — pure subprocess orchestration, executed
9
+ * by the Rust `cant-cli` binary via `cleo cant execute`.
10
+ * 2. Workflow-level constructs — LLM- or human-dependent (sessions,
11
+ * choices, discretions, approvals, parallel, try/catch, loops). These
12
+ * MUST execute in TypeScript inside Pi because they need the LLM
13
+ * harness or the operator.
14
+ *
15
+ * This bridge parses .cant files via `cleo cant parse`, validates via
16
+ * `cleo cant validate`, delegates pipelines back to `cleo cant execute`,
17
+ * and interprets Workflow statements using Pi's subagent spawning and UI
18
+ * primitives. When an Agent section declares `skills:`, the bridge fetches
19
+ * protocol text from SKILL.md via `cleo skills info` and injects it into
20
+ * subsequent Pi LLM turns through `before_agent_start`.
21
+ *
22
+ * Commands:
23
+ * /cant:load <file> — parse + validate + auto-load skills
24
+ * /cant:run <file> <workflowName> — interpret a Workflow body
25
+ * /cant:execute-pipeline <file> --name <n> — shortcut to cleo cant execute
26
+ * /cant:info — print bridge state
27
+ *
28
+ * Mock mode: `CLEOOS_MOCK=1` skips CLI calls and returns synthetic data.
29
+ *
30
+ * Guardrails (owner directive):
31
+ * - NO hand-authored protocol text; always shell out to `cleo skills info`.
32
+ * - NO imports from @cleocode/*; extension shells out via CLI only.
33
+ * - NO top-level await; all work happens inside handlers.
34
+ * - ALL commands/hooks registered synchronously in the factory.
35
+ * - Honor ctx.signal for cancellation.
36
+ * - Never pass shell metacharacters to pi.exec; always use the args array.
37
+ * - Wrap every CLI failure in try/catch + ctx.ui.notify; never crash.
38
+ */
39
+
40
+ import { spawn } from "node:child_process";
41
+ import { existsSync } from "node:fs";
42
+ import { homedir } from "node:os";
43
+ import { join } from "node:path";
44
+ import type {
45
+ ExecResult,
46
+ ExtensionAPI,
47
+ ExtensionCommandContext,
48
+ ExtensionContext,
49
+ } from "@mariozechner/pi-coding-agent";
50
+
51
+ // --- LAFS envelope + CANT AST shapes (all shapes match `cleo cant parse` output) ---
52
+
53
+ /** Minimal LAFS envelope shared by every `cleo` CLI command. */
54
+ interface LafsMinimalEnvelope<T = unknown> {
55
+ ok: boolean;
56
+ r?: T;
57
+ error?: { code: string | number; message: string };
58
+ _m?: { op: string; rid: string };
59
+ }
60
+
61
+ /** CANT property (key/value pair from frontmatter or sections). */
62
+ interface CantProperty { key: string; value: unknown }
63
+
64
+ /** CANT hook block attached to an agent. */
65
+ interface CantHook { event: string; body: CantStatement[] }
66
+
67
+ /** CANT agent section — top-level declaration carrying skills and permissions. */
68
+ interface CantAgentSection {
69
+ type: "Agent";
70
+ name: string;
71
+ properties: CantProperty[];
72
+ permissions?: Record<string, string[]>;
73
+ hooks?: CantHook[];
74
+ }
75
+
76
+ /** CANT workflow section — interpreted statement-by-statement by this bridge. */
77
+ interface CantWorkflowSection {
78
+ type: "Workflow";
79
+ name: string;
80
+ params: CantProperty[];
81
+ body: CantStatement[];
82
+ }
83
+
84
+ /** CANT pipeline section — delegated to the Rust cant-cli executor. */
85
+ interface CantPipelineSection {
86
+ type: "Pipeline";
87
+ name: string;
88
+ params: CantProperty[];
89
+ steps: unknown[];
90
+ }
91
+
92
+ /** CANT hook section (top-level hook, not nested inside an agent). */
93
+ interface CantHookSection { type: "Hook"; event: string; body: CantStatement[] }
94
+
95
+ /** Union of all top-level section types produced by `cleo cant parse`. */
96
+ type CantSection =
97
+ | CantAgentSection
98
+ | CantWorkflowSection
99
+ | CantPipelineSection
100
+ | CantHookSection;
101
+
102
+ /** Discretion payload — prose the LLM must interpret at runtime. */
103
+ interface CantDiscretion { prose: string }
104
+
105
+ /** Simple CANT expression — supports equality, boolean literals, and var refs. */
106
+ interface CantExpression {
107
+ kind: "literal" | "var" | "equals";
108
+ value?: boolean | string | number;
109
+ name?: string;
110
+ left?: CantExpression;
111
+ right?: CantExpression;
112
+ }
113
+
114
+ /** Condition used by Conditional / LoopUntil — either a pure expression or LLM-routed prose. */
115
+ type CantCondition = { Expression: CantExpression } | { Discretion: CantDiscretion };
116
+
117
+ /** Branch of a Conditional statement. */
118
+ interface CantConditionalElif { condition: CantCondition; body: CantStatement[] }
119
+
120
+ /** Target of a Session statement. */
121
+ type CantSessionTarget = { Prompt: string } | { Agent: string };
122
+
123
+ /** Supported CANT statements (fields we do not use are omitted). */
124
+ type CantStatement =
125
+ | { type: "Session"; target: CantSessionTarget; properties?: CantProperty[] }
126
+ | { type: "Parallel"; modifier: "Race" | "Settle" | null; arms: CantStatement[][] }
127
+ | {
128
+ type: "Conditional";
129
+ condition: CantCondition;
130
+ then_body: CantStatement[];
131
+ elif_branches: CantConditionalElif[];
132
+ else_body: CantStatement[];
133
+ }
134
+ | { type: "ApprovalGate"; properties: CantProperty[] }
135
+ | { type: "Repeat"; count: number; body: CantStatement[] }
136
+ | { type: "ForLoop"; variable: string; iterable: unknown; body: CantStatement[] }
137
+ | { type: "LoopUntil"; body: CantStatement[]; condition: CantCondition }
138
+ | {
139
+ type: "TryCatch";
140
+ try_body: CantStatement[];
141
+ catch_name?: string;
142
+ catch_body: CantStatement[];
143
+ finally_body: CantStatement[];
144
+ }
145
+ | { type: "Expression" }
146
+ | { type: "Property" }
147
+ | { type: "Binding" }
148
+ | { type: "Directive" };
149
+
150
+ /** Full CANT document — the `r.document` field from `cleo cant parse`. */
151
+ interface CantDocument {
152
+ kind: "Agent" | "Workflow" | "Pipeline" | null;
153
+ frontmatter: { kind?: string; version?: string; properties: CantProperty[] } | null;
154
+ sections: CantSection[];
155
+ span?: unknown;
156
+ }
157
+
158
+ /** Response shape from `cleo cant parse`. */
159
+ interface CantParseResult { document: CantDocument }
160
+
161
+ /** Response shape from `cleo cant validate`. */
162
+ interface CantValidateResult {
163
+ valid: boolean;
164
+ diagnostics?: Array<{ severity: string; message: string }>;
165
+ }
166
+
167
+ /** Response shape from `cleo skills info`. */
168
+ interface SkillInfoResult { name: string; description?: string; content?: string }
169
+
170
+ /**
171
+ * Module-level bridge state. One agent can be loaded at a time per Pi session;
172
+ * one workflow can be running at a time. Nested workflow spawns are handled
173
+ * by spawning a child `pi` process rather than reentering this machine.
174
+ */
175
+ interface BridgeState {
176
+ loadedAgent: {
177
+ file: string;
178
+ name: string;
179
+ declaredSkills: string[];
180
+ permissions: Record<string, string[]>;
181
+ } | null;
182
+ runningWorkflow: { file: string; name: string; startedAt: Date } | null;
183
+ }
184
+
185
+ const STATUS_KEY = "cleo-cant";
186
+ const state: BridgeState = { loadedAgent: null, runningWorkflow: null };
187
+
188
+ // --- CLI + mock helpers ---
189
+
190
+ /**
191
+ * Invoke the `cleo` CLI via pi.exec and parse stdout as a LAFS envelope.
192
+ * Returns the unwrapped result payload or undefined on any failure. Mirrors
193
+ * the helper in orchestrator.ts so behavior stays consistent.
194
+ */
195
+ async function cleoCli<T = unknown>(
196
+ pi: ExtensionAPI,
197
+ args: string[],
198
+ signal: AbortSignal | undefined,
199
+ ): Promise<T | undefined> {
200
+ let result: ExecResult;
201
+ try {
202
+ result = await pi.exec("cleo", args, { signal });
203
+ } catch {
204
+ return undefined;
205
+ }
206
+ if (result.code !== 0) return undefined;
207
+ const lines = result.stdout.trim().split("\n");
208
+ const envLine = [...lines].reverse().find((l) => l.trim().startsWith("{"));
209
+ if (!envLine) return undefined;
210
+ try {
211
+ const env = JSON.parse(envLine) as LafsMinimalEnvelope<T>;
212
+ if (env.ok && env.r !== undefined) return env.r;
213
+ return undefined;
214
+ } catch {
215
+ return undefined;
216
+ }
217
+ }
218
+
219
+ /** Whether `CLEOOS_MOCK=1` is set. Skips all CLI calls. */
220
+ function isMock(): boolean { return process.env.CLEOOS_MOCK === "1"; }
221
+
222
+ /** Synthetic CANT document for mock mode. */
223
+ function mockDocument(agentName: string): CantDocument {
224
+ return {
225
+ kind: "Agent",
226
+ frontmatter: { kind: "agent", version: "1.0", properties: [] },
227
+ sections: [
228
+ {
229
+ type: "Agent",
230
+ name: agentName,
231
+ properties: [],
232
+ permissions: { read: ["*"], write: ["./mock"] },
233
+ hooks: [],
234
+ },
235
+ {
236
+ type: "Workflow",
237
+ name: "default",
238
+ params: [],
239
+ body: [{ type: "Session", target: { Prompt: "mock prompt" } }],
240
+ },
241
+ ],
242
+ };
243
+ }
244
+
245
+ // --- AST + path utilities ---
246
+
247
+ /** Find the first Agent section in a parsed document. */
248
+ function findAgent(doc: CantDocument): CantAgentSection | undefined {
249
+ return doc.sections.find((s): s is CantAgentSection => s.type === "Agent");
250
+ }
251
+
252
+ /** Find a named Workflow section in a parsed document. */
253
+ function findWorkflow(doc: CantDocument, name: string): CantWorkflowSection | undefined {
254
+ return doc.sections.find(
255
+ (s): s is CantWorkflowSection => s.type === "Workflow" && s.name === name,
256
+ );
257
+ }
258
+
259
+ /** Extract the `skills:` declaration from an agent's properties. */
260
+ function extractSkills(agent: CantAgentSection): string[] {
261
+ const prop = agent.properties.find((p) => p.key === "skills");
262
+ if (!prop) return [];
263
+ const v = prop.value;
264
+ if (Array.isArray(v)) return v.filter((x): x is string => typeof x === "string");
265
+ if (typeof v === "string") {
266
+ return v.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
267
+ }
268
+ return [];
269
+ }
270
+
271
+ /** Extract a string-valued property by key. */
272
+ function propString(props: CantProperty[], key: string): string | undefined {
273
+ const p = props.find((x) => x.key === key);
274
+ return typeof p?.value === "string" ? p.value : undefined;
275
+ }
276
+
277
+ /**
278
+ * Resolve an agent reference to a .cant file on disk. Checks
279
+ * `$PWD/.cleo/agents/`, `$CLEO_HOME/agents/`, then `$HOME/.local/share/cleo/agents/`.
280
+ */
281
+ function resolveAgentFile(cwd: string, agentName: string): string | undefined {
282
+ const candidates = [
283
+ join(cwd, ".cleo", "agents", `${agentName}.cant`),
284
+ process.env.CLEO_HOME
285
+ ? join(process.env.CLEO_HOME, "agents", `${agentName}.cant`)
286
+ : undefined,
287
+ join(homedir(), ".local", "share", "cleo", "agents", `${agentName}.cant`),
288
+ ].filter((p): p is string => typeof p === "string");
289
+ for (const path of candidates) if (existsSync(path)) return path;
290
+ return undefined;
291
+ }
292
+
293
+ /**
294
+ * Minimal expression evaluator: boolean literals, var refs, equality.
295
+ * Returns false for unsupported shapes so malformed ASTs fall through
296
+ * to the else branch rather than throwing.
297
+ */
298
+ function evalExpression(
299
+ expr: CantExpression | undefined,
300
+ env: Record<string, unknown>,
301
+ ): boolean {
302
+ if (!expr) return false;
303
+ switch (expr.kind) {
304
+ case "literal":
305
+ return Boolean(expr.value);
306
+ case "var":
307
+ return Boolean(env[expr.name ?? ""]);
308
+ case "equals": {
309
+ const l = expr.left ? evalExpression(expr.left, env) : false;
310
+ const r = expr.right ? evalExpression(expr.right, env) : false;
311
+ return l === r;
312
+ }
313
+ default:
314
+ return false;
315
+ }
316
+ }
317
+
318
+ // --- Subagent spawn (Session { Prompt: ... }) ---
319
+
320
+ /**
321
+ * Spawn a Pi subagent for a Session.Prompt statement via
322
+ * `pi --mode json -p --no-session <prompt>`. Scans JSONL stdout for
323
+ * `message_end` to confirm the turn completed; honors ctx.signal by
324
+ * killing the child on abort.
325
+ */
326
+ function spawnSubagent(
327
+ prompt: string,
328
+ signal: AbortSignal | undefined,
329
+ ): Promise<{ code: number; sawMessageEnd: boolean }> {
330
+ return new Promise((resolve) => {
331
+ const child = spawn(
332
+ "pi",
333
+ ["--mode", "json", "-p", "--no-session", prompt],
334
+ { stdio: ["ignore", "pipe", "pipe"], shell: false },
335
+ );
336
+
337
+ let buffer = "";
338
+ let sawMessageEnd = false;
339
+
340
+ child.stdout.on("data", (chunk: Buffer) => {
341
+ buffer += chunk.toString();
342
+ const newlineIdx = buffer.lastIndexOf("\n");
343
+ if (newlineIdx < 0) return;
344
+ const complete = buffer.slice(0, newlineIdx).split("\n");
345
+ buffer = buffer.slice(newlineIdx + 1);
346
+ for (const line of complete) {
347
+ const trimmed = line.trim();
348
+ if (!trimmed.startsWith("{")) continue;
349
+ try {
350
+ const evt = JSON.parse(trimmed) as { type?: string };
351
+ if (evt.type === "message_end") sawMessageEnd = true;
352
+ } catch {
353
+ // Partial or non-JSON line — ignore.
354
+ }
355
+ }
356
+ });
357
+ child.stderr.on("data", () => {
358
+ // Intentionally discarded — Pi logs are out-of-band.
359
+ });
360
+
361
+ const onAbort = (): void => {
362
+ try {
363
+ child.kill("SIGTERM");
364
+ } catch {
365
+ // Child may already be dead.
366
+ }
367
+ };
368
+ if (signal) {
369
+ if (signal.aborted) onAbort();
370
+ else signal.addEventListener("abort", onAbort, { once: true });
371
+ }
372
+
373
+ child.on("error", () => resolve({ code: 1, sawMessageEnd }));
374
+ child.on("exit", (code) => {
375
+ if (signal) signal.removeEventListener("abort", onAbort);
376
+ resolve({ code: code ?? 1, sawMessageEnd });
377
+ });
378
+ });
379
+ }
380
+
381
+ // --- Workflow interpreter ---
382
+
383
+ /** Execution environment shared across statements in a single workflow run. */
384
+ interface RunEnv {
385
+ pi: ExtensionAPI;
386
+ ctx: ExtensionCommandContext;
387
+ cwd: string;
388
+ vars: Record<string, unknown>;
389
+ }
390
+
391
+ /** Execute statements in order; errors propagate to enclosing TryCatch. */
392
+ async function runBody(body: CantStatement[], env: RunEnv): Promise<void> {
393
+ for (const stmt of body) {
394
+ if (env.ctx.signal?.aborted) return;
395
+ await runStatement(stmt, env);
396
+ }
397
+ }
398
+
399
+ /** Interpret a single CANT statement. */
400
+ async function runStatement(stmt: CantStatement, env: RunEnv): Promise<void> {
401
+ switch (stmt.type) {
402
+ case "Session":
403
+ await runSession(stmt, env);
404
+ return;
405
+ case "Parallel":
406
+ await runParallel(stmt, env);
407
+ return;
408
+ case "Conditional":
409
+ await runConditional(stmt, env);
410
+ return;
411
+ case "ApprovalGate":
412
+ await runApprovalGate(stmt, env);
413
+ return;
414
+ case "Repeat":
415
+ for (let i = 0; i < stmt.count; i += 1) {
416
+ if (env.ctx.signal?.aborted) return;
417
+ await runBody(stmt.body, env);
418
+ }
419
+ return;
420
+ case "ForLoop":
421
+ await runForLoop(stmt, env);
422
+ return;
423
+ case "LoopUntil":
424
+ await runLoopUntil(stmt, env);
425
+ return;
426
+ case "TryCatch":
427
+ await runTryCatch(stmt, env);
428
+ return;
429
+ case "Expression":
430
+ case "Property":
431
+ case "Binding":
432
+ case "Directive":
433
+ if (env.ctx.hasUI) {
434
+ env.ctx.ui.notify(`cant: skipping ${stmt.type} (v1 no-op)`, "info");
435
+ }
436
+ return;
437
+ }
438
+ }
439
+
440
+ /**
441
+ * Execute a Session. Prompt targets spawn a Pi subagent; Agent targets
442
+ * resolve the agent's .cant file and load it (recursive load).
443
+ */
444
+ async function runSession(
445
+ stmt: Extract<CantStatement, { type: "Session" }>,
446
+ env: RunEnv,
447
+ ): Promise<void> {
448
+ if ("Prompt" in stmt.target) {
449
+ const prompt = stmt.target.Prompt;
450
+ if (env.ctx.hasUI) env.ctx.ui.setStatus(STATUS_KEY, "cant: session → pi subagent");
451
+ if (isMock()) {
452
+ if (env.ctx.hasUI) env.ctx.ui.notify(`[mock] subagent prompt: ${prompt}`, "info");
453
+ return;
454
+ }
455
+ const res = await spawnSubagent(prompt, env.ctx.signal);
456
+ if (res.code !== 0 && env.ctx.hasUI) {
457
+ const suffix = res.sawMessageEnd ? "" : " (no message_end seen)";
458
+ env.ctx.ui.notify(`cant: subagent exited ${res.code}${suffix}`, "warning");
459
+ }
460
+ return;
461
+ }
462
+
463
+ const agentName = stmt.target.Agent;
464
+ const file = resolveAgentFile(env.cwd, agentName);
465
+ if (!file) {
466
+ if (env.ctx.hasUI) {
467
+ env.ctx.ui.notify(`cant: could not resolve agent '${agentName}'`, "error");
468
+ }
469
+ return;
470
+ }
471
+ await loadAgentFile(file, env.pi, env.ctx);
472
+ }
473
+
474
+ /** Race resolves on first settled arm; Settle waits for all (null → Settle). */
475
+ async function runParallel(
476
+ stmt: Extract<CantStatement, { type: "Parallel" }>,
477
+ env: RunEnv,
478
+ ): Promise<void> {
479
+ const promises = stmt.arms.map((arm) => runBody(arm, env));
480
+ if (stmt.modifier === "Race") {
481
+ await Promise.race(promises);
482
+ return;
483
+ }
484
+ await Promise.allSettled(promises);
485
+ }
486
+
487
+ /**
488
+ * Execute a Conditional. Expression conditions evaluate deterministically;
489
+ * Discretion conditions route to the THEN arm in v1 so we never block
490
+ * waiting on LLM routing, surfacing the prose via sendMessage + notify.
491
+ */
492
+ async function runConditional(
493
+ stmt: Extract<CantStatement, { type: "Conditional" }>,
494
+ env: RunEnv,
495
+ ): Promise<void> {
496
+ if (await evaluateCondition(stmt.condition, env, "conditional")) {
497
+ await runBody(stmt.then_body, env);
498
+ return;
499
+ }
500
+ for (const elif of stmt.elif_branches) {
501
+ if (await evaluateCondition(elif.condition, env, "elif")) {
502
+ await runBody(elif.body, env);
503
+ return;
504
+ }
505
+ }
506
+ await runBody(stmt.else_body, env);
507
+ }
508
+
509
+ /**
510
+ * Evaluate a condition. Discretion routes to THEN (v1 default) after
511
+ * surfacing the prose to the operator and the LLM session.
512
+ */
513
+ async function evaluateCondition(
514
+ condition: CantCondition,
515
+ env: RunEnv,
516
+ label: string,
517
+ ): Promise<boolean> {
518
+ if ("Expression" in condition) {
519
+ return evalExpression(condition.Expression, env.vars);
520
+ }
521
+ const prose = condition.Discretion.prose;
522
+ if (env.ctx.hasUI) {
523
+ env.ctx.ui.notify(
524
+ `cant: ${label} discretion routed to THEN arm (v1 default) — ${prose.slice(0, 80)}`,
525
+ "info",
526
+ );
527
+ }
528
+ env.pi.sendMessage(
529
+ {
530
+ customType: "cleo-cant-discretion",
531
+ content: `Discretion: ${prose}`,
532
+ display: true,
533
+ },
534
+ { triggerTurn: false },
535
+ );
536
+ return true;
537
+ }
538
+
539
+ /**
540
+ * Pop a confirmation dialog for an ApprovalGate; if denied, throw so an
541
+ * enclosing TryCatch can handle it. Non-interactive mode approves in mock,
542
+ * denies otherwise.
543
+ */
544
+ async function runApprovalGate(
545
+ stmt: Extract<CantStatement, { type: "ApprovalGate" }>,
546
+ env: RunEnv,
547
+ ): Promise<void> {
548
+ const title = propString(stmt.properties, "title") ?? "CANT approval gate";
549
+ const message = propString(stmt.properties, "message") ?? "Approve to continue?";
550
+ if (!env.ctx.hasUI) {
551
+ if (isMock()) return;
552
+ throw new Error(`ApprovalGate '${title}' denied (no UI available)`);
553
+ }
554
+ const approved = await env.ctx.ui.confirm(title, message);
555
+ if (!approved) throw new Error(`ApprovalGate '${title}' denied by operator`);
556
+ }
557
+
558
+ /** ForLoop — v1 only supports hardcoded array-literal iterables. */
559
+ async function runForLoop(
560
+ stmt: Extract<CantStatement, { type: "ForLoop" }>,
561
+ env: RunEnv,
562
+ ): Promise<void> {
563
+ if (!Array.isArray(stmt.iterable)) {
564
+ if (env.ctx.hasUI) {
565
+ env.ctx.ui.notify("cant: ForLoop iterable is not an array literal (v1)", "warning");
566
+ }
567
+ return;
568
+ }
569
+ for (const item of stmt.iterable) {
570
+ if (env.ctx.signal?.aborted) return;
571
+ const previous = env.vars[stmt.variable];
572
+ env.vars[stmt.variable] = item;
573
+ try {
574
+ await runBody(stmt.body, env);
575
+ } finally {
576
+ env.vars[stmt.variable] = previous;
577
+ }
578
+ }
579
+ }
580
+
581
+ /**
582
+ * LoopUntil: run body, eval condition, repeat until true. Bounded by a
583
+ * safety cap so malformed .cant files cannot wedge the session.
584
+ */
585
+ async function runLoopUntil(
586
+ stmt: Extract<CantStatement, { type: "LoopUntil" }>,
587
+ env: RunEnv,
588
+ ): Promise<void> {
589
+ const MAX_ITERS = 1_000;
590
+ for (let i = 0; i < MAX_ITERS; i += 1) {
591
+ if (env.ctx.signal?.aborted) return;
592
+ await runBody(stmt.body, env);
593
+ if (await evaluateCondition(stmt.condition, env, "loop-until")) return;
594
+ }
595
+ if (env.ctx.hasUI) {
596
+ env.ctx.ui.notify(`cant: LoopUntil hit ${MAX_ITERS}-iteration safety cap`, "warning");
597
+ }
598
+ }
599
+
600
+ /**
601
+ * JS try/catch/finally around the body. The caught error is exposed to
602
+ * the catch arm via env.vars[catch_name] so the body can inspect it.
603
+ */
604
+ async function runTryCatch(
605
+ stmt: Extract<CantStatement, { type: "TryCatch" }>,
606
+ env: RunEnv,
607
+ ): Promise<void> {
608
+ try {
609
+ await runBody(stmt.try_body, env);
610
+ } catch (err) {
611
+ const name = stmt.catch_name ?? "error";
612
+ const previous = env.vars[name];
613
+ env.vars[name] = err instanceof Error ? err.message : String(err);
614
+ try {
615
+ await runBody(stmt.catch_body, env);
616
+ } finally {
617
+ env.vars[name] = previous;
618
+ }
619
+ } finally {
620
+ await runBody(stmt.finally_body, env);
621
+ }
622
+ }
623
+
624
+ // --- Parse + load helpers ---
625
+
626
+ /**
627
+ * Parse a .cant file into a document, honoring mock mode and catching any
628
+ * CLI throw. Returns undefined after surfacing an error notification; the
629
+ * caller should bail immediately on undefined.
630
+ */
631
+ async function parseDocument(
632
+ file: string,
633
+ pi: ExtensionAPI,
634
+ ctx: ExtensionContext,
635
+ ): Promise<CantDocument | undefined> {
636
+ if (isMock()) return mockDocument("mock-agent");
637
+ try {
638
+ const parsed = await cleoCli<CantParseResult>(pi, ["cant", "parse", file], ctx.signal);
639
+ if (!parsed?.document) {
640
+ if (ctx.hasUI) ctx.ui.notify(`cant: failed to parse ${file}`, "error");
641
+ return undefined;
642
+ }
643
+ return parsed.document;
644
+ } catch (err) {
645
+ const msg = err instanceof Error ? err.message : String(err);
646
+ if (ctx.hasUI) ctx.ui.notify(`cant: parse threw — ${msg}`, "error");
647
+ return undefined;
648
+ }
649
+ }
650
+
651
+ /**
652
+ * Validate a .cant file via `cleo cant validate`. Returns true on success or
653
+ * mock mode; false after surfacing a notification on any failure.
654
+ */
655
+ async function validateDocument(
656
+ file: string,
657
+ pi: ExtensionAPI,
658
+ ctx: ExtensionContext,
659
+ ): Promise<boolean> {
660
+ if (isMock()) return true;
661
+ try {
662
+ const validation = await cleoCli<CantValidateResult>(
663
+ pi,
664
+ ["cant", "validate", file],
665
+ ctx.signal,
666
+ );
667
+ if (validation && !validation.valid) {
668
+ const first = validation.diagnostics?.[0]?.message ?? "invalid";
669
+ if (ctx.hasUI) ctx.ui.notify(`cant: validation failed — ${first}`, "error");
670
+ return false;
671
+ }
672
+ return true;
673
+ } catch (err) {
674
+ const msg = err instanceof Error ? err.message : String(err);
675
+ if (ctx.hasUI) ctx.ui.notify(`cant: validate threw — ${msg}`, "error");
676
+ return false;
677
+ }
678
+ }
679
+
680
+ /**
681
+ * Parse + validate + populate bridge state for a .cant file. Used by both
682
+ * `/cant:load` and the Session { Agent } statement (recursive load).
683
+ */
684
+ async function loadAgentFile(
685
+ file: string,
686
+ pi: ExtensionAPI,
687
+ ctx: ExtensionContext,
688
+ ): Promise<void> {
689
+ const document = await parseDocument(file, pi, ctx);
690
+ if (!document) return;
691
+ if (!(await validateDocument(file, pi, ctx))) return;
692
+
693
+ const agent = findAgent(document);
694
+ if (!agent) {
695
+ if (ctx.hasUI) ctx.ui.notify(`cant: ${file} has no Agent section`, "warning");
696
+ return;
697
+ }
698
+
699
+ const skills = extractSkills(agent);
700
+ state.loadedAgent = {
701
+ file,
702
+ name: agent.name,
703
+ declaredSkills: skills,
704
+ permissions: agent.permissions ?? {},
705
+ };
706
+
707
+ if (ctx.hasUI) {
708
+ ctx.ui.setStatus(STATUS_KEY, `cant: ${agent.name} (${skills.length} skills)`);
709
+ ctx.ui.notify(`cant: loaded agent '${agent.name}' with ${skills.length} skills`, "info");
710
+ }
711
+ }
712
+
713
+ /**
714
+ * Parse a .cant file, locate the named workflow, and interpret its body.
715
+ * Rejects nested runs (one workflow at a time per session) and clears the
716
+ * running-workflow marker on exit.
717
+ */
718
+ async function runWorkflow(
719
+ file: string,
720
+ workflowName: string,
721
+ pi: ExtensionAPI,
722
+ ctx: ExtensionCommandContext,
723
+ ): Promise<void> {
724
+ if (state.runningWorkflow) {
725
+ if (ctx.hasUI) {
726
+ ctx.ui.notify(
727
+ `cant: workflow '${state.runningWorkflow.name}' is already running`,
728
+ "warning",
729
+ );
730
+ }
731
+ return;
732
+ }
733
+
734
+ const document = await parseDocument(file, pi, ctx);
735
+ if (!document) return;
736
+
737
+ const workflow = findWorkflow(document, workflowName);
738
+ if (!workflow) {
739
+ if (ctx.hasUI) {
740
+ ctx.ui.notify(`cant: workflow '${workflowName}' not found in ${file}`, "error");
741
+ }
742
+ return;
743
+ }
744
+
745
+ state.runningWorkflow = { file, name: workflowName, startedAt: new Date() };
746
+ if (ctx.hasUI) {
747
+ ctx.ui.setStatus(STATUS_KEY, `cant: running ${workflowName}`);
748
+ ctx.ui.notify(`cant: running workflow '${workflowName}'`, "info");
749
+ }
750
+
751
+ const env: RunEnv = { pi, ctx, cwd: ctx.cwd, vars: {} };
752
+
753
+ try {
754
+ await runBody(workflow.body, env);
755
+ if (ctx.hasUI) ctx.ui.notify(`cant: workflow '${workflowName}' complete`, "info");
756
+ } catch (err) {
757
+ const msg = err instanceof Error ? err.message : String(err);
758
+ if (ctx.hasUI) ctx.ui.notify(`cant: workflow '${workflowName}' threw — ${msg}`, "error");
759
+ } finally {
760
+ state.runningWorkflow = null;
761
+ if (ctx.hasUI && state.loadedAgent) {
762
+ ctx.ui.setStatus(
763
+ STATUS_KEY,
764
+ `cant: ${state.loadedAgent.name} (${state.loadedAgent.declaredSkills.length} skills)`,
765
+ );
766
+ } else if (ctx.hasUI) {
767
+ ctx.ui.setStatus(STATUS_KEY, undefined);
768
+ }
769
+ }
770
+ }
771
+
772
+ // --- before_agent_start skill injection ---
773
+
774
+ /**
775
+ * Fetch each declared skill's metadata from `cleo skills info` and stitch
776
+ * it into a system-prompt prefix. This is the SSoT enforcement point: the
777
+ * bridge never hand-authors protocol text.
778
+ */
779
+ async function composeSkillPrompt(
780
+ pi: ExtensionAPI,
781
+ agentName: string,
782
+ skills: string[],
783
+ signal: AbortSignal | undefined,
784
+ ): Promise<string | undefined> {
785
+ if (skills.length === 0) return undefined;
786
+
787
+ const parts: string[] = [`## Skills Loaded from .cant agent ${agentName}`, ""];
788
+ let injectedAny = false;
789
+
790
+ for (const skill of skills) {
791
+ if (signal?.aborted) return undefined;
792
+ if (isMock()) {
793
+ parts.push(`### ${skill}`, `[mock] ${skill} description`, "");
794
+ injectedAny = true;
795
+ continue;
796
+ }
797
+ try {
798
+ const info = await cleoCli<SkillInfoResult>(pi, ["skills", "info", skill], signal);
799
+ if (!info) continue;
800
+ parts.push(`### ${info.name}`);
801
+ if (info.description) parts.push(info.description);
802
+ if (info.content) parts.push(info.content);
803
+ parts.push("");
804
+ injectedAny = true;
805
+ } catch {
806
+ // Non-fatal — skip this skill and continue.
807
+ }
808
+ }
809
+
810
+ return injectedAny ? parts.join("\n") : undefined;
811
+ }
812
+
813
+ // --- Pi extension factory ---
814
+
815
+ /**
816
+ * Pi extension factory. Registers the four CANT bridge commands, the
817
+ * before_agent_start skill-injection hook, and the session_shutdown
818
+ * cleanup. Registration is synchronous so Pi discovers everything before
819
+ * the first event loop tick.
820
+ */
821
+ export default function (pi: ExtensionAPI): void {
822
+ // before_agent_start: inject the loaded agent's skills. If no agent is
823
+ // loaded, return {} so orchestrator.ts's own hook still fires and loads
824
+ // the Tier 0 baseline. Protocol text comes exclusively from SKILL.md via
825
+ // `cleo skills info` — never hand-authored.
826
+ pi.on("before_agent_start", async (_event, ctx: ExtensionContext) => {
827
+ if (!state.loadedAgent) return {};
828
+ try {
829
+ const prompt = await composeSkillPrompt(
830
+ pi,
831
+ state.loadedAgent.name,
832
+ state.loadedAgent.declaredSkills,
833
+ ctx.signal,
834
+ );
835
+ if (!prompt) return {};
836
+ return { systemPrompt: prompt };
837
+ } catch (err) {
838
+ const msg = err instanceof Error ? err.message : String(err);
839
+ if (ctx.hasUI) ctx.ui.notify(`cant: skill injection threw — ${msg}`, "error");
840
+ return {};
841
+ }
842
+ });
843
+
844
+ // /cant:load <file>
845
+ pi.registerCommand("cant:load", {
846
+ description: "Parse, validate, and load a .cant file (auto-injects agent skills)",
847
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
848
+ const file = args.trim();
849
+ if (!file) {
850
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cant:load <file>", "error");
851
+ return;
852
+ }
853
+ try {
854
+ await loadAgentFile(file, pi, ctx);
855
+ } catch (err) {
856
+ const msg = err instanceof Error ? err.message : String(err);
857
+ if (ctx.hasUI) ctx.ui.notify(`cant:load threw — ${msg}`, "error");
858
+ }
859
+ },
860
+ });
861
+
862
+ // /cant:run <file> <workflowName>
863
+ pi.registerCommand("cant:run", {
864
+ description: "Run a named workflow from a .cant file",
865
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
866
+ const parts = args.trim().split(/\s+/).filter((p) => p.length > 0);
867
+ if (parts.length < 2) {
868
+ if (ctx.hasUI) ctx.ui.notify("Usage: /cant:run <file> <workflowName>", "error");
869
+ return;
870
+ }
871
+ const [file, workflowName] = parts as [string, string];
872
+ try {
873
+ await runWorkflow(file, workflowName, pi, ctx);
874
+ } catch (err) {
875
+ const msg = err instanceof Error ? err.message : String(err);
876
+ if (ctx.hasUI) ctx.ui.notify(`cant:run threw — ${msg}`, "error");
877
+ }
878
+ },
879
+ });
880
+
881
+ // /cant:execute-pipeline <file> --name <pipelineName>
882
+ pi.registerCommand("cant:execute-pipeline", {
883
+ description: "Delegate to Rust cant-cli pipeline executor",
884
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
885
+ const tokens = args.trim().split(/\s+/).filter((p) => p.length > 0);
886
+ const nameIdx = tokens.indexOf("--name");
887
+ if (tokens.length === 0 || nameIdx === -1 || nameIdx === tokens.length - 1) {
888
+ if (ctx.hasUI) {
889
+ ctx.ui.notify("Usage: /cant:execute-pipeline <file> --name <pipelineName>", "error");
890
+ }
891
+ return;
892
+ }
893
+ const file = tokens[0];
894
+ const pipelineName = tokens[nameIdx + 1];
895
+ if (!file || file.startsWith("--") || !pipelineName) {
896
+ if (ctx.hasUI) {
897
+ ctx.ui.notify("Usage: /cant:execute-pipeline <file> --name <pipelineName>", "error");
898
+ }
899
+ return;
900
+ }
901
+
902
+ if (isMock()) {
903
+ pi.sendMessage(
904
+ {
905
+ customType: "cleo-cant-execute",
906
+ content: `[mock] executed pipeline '${pipelineName}' from ${file}`,
907
+ display: true,
908
+ },
909
+ { triggerTurn: false },
910
+ );
911
+ return;
912
+ }
913
+
914
+ try {
915
+ const result = await pi.exec(
916
+ "cleo",
917
+ ["cant", "execute", file, "--pipeline", pipelineName],
918
+ { signal: ctx.signal },
919
+ );
920
+ const body =
921
+ result.code === 0
922
+ ? result.stdout.trim() || "(no output)"
923
+ : `exit ${result.code}: ${result.stderr.trim() || result.stdout.trim()}`;
924
+ pi.sendMessage(
925
+ { customType: "cleo-cant-execute", content: body, display: true },
926
+ { triggerTurn: false },
927
+ );
928
+ } catch (err) {
929
+ const msg = err instanceof Error ? err.message : String(err);
930
+ if (ctx.hasUI) ctx.ui.notify(`cant:execute-pipeline threw — ${msg}`, "error");
931
+ }
932
+ },
933
+ });
934
+
935
+ // /cant:info
936
+ pi.registerCommand("cant:info", {
937
+ description: "Show the loaded .cant agent and running workflow state",
938
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
939
+ const lines: string[] = [];
940
+ if (state.loadedAgent) {
941
+ const skills =
942
+ state.loadedAgent.declaredSkills.length === 0
943
+ ? "(none)"
944
+ : state.loadedAgent.declaredSkills.join(", ");
945
+ const permKeys = Object.keys(state.loadedAgent.permissions);
946
+ const perms = permKeys.length === 0 ? "(none)" : permKeys.join(", ");
947
+ lines.push(
948
+ `Loaded agent: ${state.loadedAgent.name}`,
949
+ ` file: ${state.loadedAgent.file}`,
950
+ ` skills: ${skills}`,
951
+ ` permissions: ${perms}`,
952
+ );
953
+ } else {
954
+ lines.push("Loaded agent: (none)");
955
+ }
956
+
957
+ if (state.runningWorkflow) {
958
+ const elapsedS = Math.floor(
959
+ (Date.now() - state.runningWorkflow.startedAt.getTime()) / 1_000,
960
+ );
961
+ lines.push(
962
+ "",
963
+ `Running workflow: ${state.runningWorkflow.name}`,
964
+ ` file: ${state.runningWorkflow.file}`,
965
+ ` elapsed: ${elapsedS}s`,
966
+ );
967
+ } else {
968
+ lines.push("", "Running workflow: (none)");
969
+ }
970
+
971
+ pi.sendMessage(
972
+ { customType: "cleo-cant-info", content: lines.join("\n"), display: true },
973
+ { triggerTurn: false },
974
+ );
975
+ if (ctx.hasUI) {
976
+ ctx.ui.notify(
977
+ state.loadedAgent ? `cant: loaded ${state.loadedAgent.name}` : "cant: no agent loaded",
978
+ "info",
979
+ );
980
+ }
981
+ },
982
+ });
983
+
984
+ // session_shutdown: clear state so a reload starts clean.
985
+ pi.on("session_shutdown", async () => {
986
+ state.loadedAgent = null;
987
+ state.runningWorkflow = null;
988
+ });
989
+ }