@cleocode/cleo-os 2026.4.13 → 2026.4.17
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/bin/postinstall.js +187 -73
- package/bin/postinstall.js.map +1 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +77 -0
- package/dist/cli.js.map +1 -0
- package/dist/keystore.d.ts +27 -0
- package/dist/keystore.d.ts.map +1 -0
- package/dist/keystore.js +35 -0
- package/dist/keystore.js.map +1 -0
- package/dist/postinstall.d.ts +23 -0
- package/dist/postinstall.d.ts.map +1 -0
- package/dist/postinstall.js +208 -0
- package/dist/postinstall.js.map +1 -0
- package/dist/xdg.d.ts +38 -0
- package/dist/xdg.d.ts.map +1 -0
- package/dist/xdg.js +39 -0
- package/dist/xdg.js.map +1 -0
- package/extensions/cleo-cant-bridge.d.ts +85 -0
- package/extensions/cleo-cant-bridge.d.ts.map +1 -0
- package/extensions/cleo-cant-bridge.js +451 -0
- package/extensions/cleo-cant-bridge.js.map +1 -0
- package/extensions/cleo-cant-bridge.ts +598 -0
- package/extensions/cleo-chatroom.d.ts +83 -0
- package/extensions/cleo-chatroom.d.ts.map +1 -0
- package/extensions/cleo-chatroom.js +344 -0
- package/extensions/cleo-chatroom.js.map +1 -0
- package/extensions/cleo-chatroom.ts +78 -7
- package/package.json +10 -7
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleoOS CANT bridge — Wave 2 Pi extension.
|
|
3
|
+
*
|
|
4
|
+
* CANONICAL LOCATION: `packages/cleo-os/extensions/cleo-cant-bridge.ts`
|
|
5
|
+
*
|
|
6
|
+
* This file was copied from
|
|
7
|
+
* `packages/cleo/templates/cleoos-hub/pi-extensions/cleo-cant-bridge.ts`
|
|
8
|
+
* (T393). The template path is kept for reference but this file is the
|
|
9
|
+
* authoritative source. A future cleanup wave (post-T381) should remove
|
|
10
|
+
* the template copy once all consumers have migrated.
|
|
11
|
+
*
|
|
12
|
+
* Installed to: $XDG_DATA_HOME/cleo/extensions/cleo-cant-bridge.js
|
|
13
|
+
* Loaded by: Pi via `--extension <path>` injected by CleoOS cli.ts
|
|
14
|
+
*
|
|
15
|
+
* This bridge discovers `.cant` files in the project's `.cleo/cant/`
|
|
16
|
+
* directory at session start, compiles them via `@cleocode/cant`'s
|
|
17
|
+
* `compileBundle()`, and appends the compiled declarations to Pi's
|
|
18
|
+
* system prompt on `before_agent_start`. This gives the LLM awareness
|
|
19
|
+
* of all declared agents, teams, and tools without hand-authored
|
|
20
|
+
* protocol text.
|
|
21
|
+
*
|
|
22
|
+
* Wave 2 scope:
|
|
23
|
+
* - Scans project tier only: `<cwd>/.cleo/cant/` (recursive)
|
|
24
|
+
* - Three-tier resolution (global, user, project) is Wave 5
|
|
25
|
+
* - Prompt strategy: APPEND (per ULTRAPLAN L6, never replace)
|
|
26
|
+
*
|
|
27
|
+
* Wave 8 additions (T420):
|
|
28
|
+
* - validate-on-load mental-model injection
|
|
29
|
+
* - When the spawned agent's CANT definition has a `mentalModel` block,
|
|
30
|
+
* fetches prior mental-model observations via memoryFind and injects
|
|
31
|
+
* them into the Pi system prompt with VALIDATE_ON_LOAD_PREAMBLE.
|
|
32
|
+
* - Exports `VALIDATE_ON_LOAD_PREAMBLE` and `buildMentalModelInjection`
|
|
33
|
+
* for testability (T421).
|
|
34
|
+
*
|
|
35
|
+
* Requirements:
|
|
36
|
+
* - `@cleocode/cant` must be installed (provides `compileBundle`)
|
|
37
|
+
* - Pi coding agent runtime (`@mariozechner/pi-coding-agent`)
|
|
38
|
+
*
|
|
39
|
+
* Guardrails:
|
|
40
|
+
* - Best-effort: if `@cleocode/cant` is not installed or `.cleo/cant`
|
|
41
|
+
* does not exist, the bridge is a no-op. NEVER crash Pi.
|
|
42
|
+
* - NO top-level await; all work happens inside event handlers.
|
|
43
|
+
* - APPEND to system prompt, never replace.
|
|
44
|
+
*
|
|
45
|
+
* @packageDocumentation
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
49
|
+
import { join } from "node:path";
|
|
50
|
+
import type {
|
|
51
|
+
ExtensionAPI,
|
|
52
|
+
ExtensionContext,
|
|
53
|
+
} from "@mariozechner/pi-coding-agent";
|
|
54
|
+
|
|
55
|
+
// ============================================================================
|
|
56
|
+
// T420: validate-on-load constants and pure helpers
|
|
57
|
+
// ============================================================================
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Preamble text injected into the Pi system prompt when an agent has a
|
|
61
|
+
* `mental_model:` CANT block. The agent MUST re-evaluate each observation
|
|
62
|
+
* against the current project state before acting.
|
|
63
|
+
*
|
|
64
|
+
* Exported so empirical tests (T421) can assert on its presence.
|
|
65
|
+
*/
|
|
66
|
+
export const VALIDATE_ON_LOAD_PREAMBLE =
|
|
67
|
+
"===== MENTAL MODEL (validate-on-load) =====\n" +
|
|
68
|
+
"These are your prior observations, patterns, and learnings for this project.\n" +
|
|
69
|
+
"Before acting, you MUST re-evaluate each entry against current project state.\n" +
|
|
70
|
+
"If an entry is stale, note it and proceed with fresh understanding.";
|
|
71
|
+
|
|
72
|
+
/** Minimal observation shape returned by memoryFind / searchBrainCompact. */
|
|
73
|
+
export interface MentalModelObservation {
|
|
74
|
+
id: string;
|
|
75
|
+
type: string;
|
|
76
|
+
title: string;
|
|
77
|
+
date?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Build the validate-on-load mental-model injection string.
|
|
82
|
+
*
|
|
83
|
+
* Pure function — no I/O, safe to call in tests without a real DB.
|
|
84
|
+
*
|
|
85
|
+
* @param agentName - Name of the spawned agent (used in the header line).
|
|
86
|
+
* @param observations - Prior mental-model observations to list.
|
|
87
|
+
* @returns System-prompt block containing the preamble and numbered observations,
|
|
88
|
+
* or an empty string when `observations` is empty.
|
|
89
|
+
*/
|
|
90
|
+
export function buildMentalModelInjection(
|
|
91
|
+
agentName: string,
|
|
92
|
+
observations: MentalModelObservation[],
|
|
93
|
+
): string {
|
|
94
|
+
if (observations.length === 0) return "";
|
|
95
|
+
|
|
96
|
+
const lines: string[] = [
|
|
97
|
+
"",
|
|
98
|
+
`// Agent: ${agentName}`,
|
|
99
|
+
VALIDATE_ON_LOAD_PREAMBLE,
|
|
100
|
+
"",
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < observations.length; i++) {
|
|
104
|
+
const obs = observations[i];
|
|
105
|
+
const datePart = obs.date ? ` [${obs.date}]` : "";
|
|
106
|
+
lines.push(`${i + 1}. [${obs.id}] (${obs.type})${datePart}: ${obs.title}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lines.push("===== END MENTAL MODEL =====");
|
|
110
|
+
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// T424: Path-ACL helpers (pure, no external deps)
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Path-scoped file permissions shape expected in an agentDef at runtime.
|
|
120
|
+
*
|
|
121
|
+
* Mirrors `PathPermissions` from `@cleocode/cant` (T423).
|
|
122
|
+
* Kept inline here to avoid a direct runtime import in the Pi extension context.
|
|
123
|
+
*
|
|
124
|
+
* @task T424
|
|
125
|
+
*/
|
|
126
|
+
interface AgentFilePermissions {
|
|
127
|
+
/** Glob patterns the agent may write to. Empty array = no writes allowed. */
|
|
128
|
+
write?: string[];
|
|
129
|
+
/** Glob patterns the agent may read from. */
|
|
130
|
+
read?: string[];
|
|
131
|
+
/** Glob patterns the agent may delete. */
|
|
132
|
+
delete?: string[];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Convert a glob pattern to a RegExp for path matching.
|
|
137
|
+
*
|
|
138
|
+
* Supports the subset of glob syntax used in CANT file permissions:
|
|
139
|
+
* - `**` matches any path segment sequence (including none)
|
|
140
|
+
* - `*` matches any characters within a single path segment
|
|
141
|
+
* - `?` matches a single character
|
|
142
|
+
* - All other characters are treated as literals
|
|
143
|
+
*
|
|
144
|
+
* @param glob - The glob pattern string.
|
|
145
|
+
* @returns A RegExp that tests absolute or relative file paths.
|
|
146
|
+
*/
|
|
147
|
+
function globToRegExp(glob: string): RegExp {
|
|
148
|
+
// Escape special regex characters except our glob specials
|
|
149
|
+
let regexStr = "";
|
|
150
|
+
let i = 0;
|
|
151
|
+
while (i < glob.length) {
|
|
152
|
+
const char = glob[i];
|
|
153
|
+
if (char === "*" && glob[i + 1] === "*") {
|
|
154
|
+
// ** matches everything including path separators
|
|
155
|
+
regexStr += ".*";
|
|
156
|
+
i += 2;
|
|
157
|
+
// Skip optional trailing slash after **
|
|
158
|
+
if (glob[i] === "/") i++;
|
|
159
|
+
} else if (char === "*") {
|
|
160
|
+
// * matches anything except path separator
|
|
161
|
+
regexStr += "[^/]*";
|
|
162
|
+
i++;
|
|
163
|
+
} else if (char === "?") {
|
|
164
|
+
regexStr += "[^/]";
|
|
165
|
+
i++;
|
|
166
|
+
} else if (/[.+^${}()|[\]\\]/.test(char)) {
|
|
167
|
+
regexStr += "\\" + char;
|
|
168
|
+
i++;
|
|
169
|
+
} else {
|
|
170
|
+
regexStr += char;
|
|
171
|
+
i++;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return new RegExp("^" + regexStr + "$");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Test whether a file path matches any of the provided glob patterns.
|
|
179
|
+
*
|
|
180
|
+
* Normalises the path to use forward slashes. Returns `false` immediately
|
|
181
|
+
* when `globs` is an empty array (default-deny for empty write lists).
|
|
182
|
+
*
|
|
183
|
+
* @param filePath - The file path to test (absolute or relative).
|
|
184
|
+
* @param globs - The glob patterns to test against.
|
|
185
|
+
* @returns `true` if `filePath` matches at least one glob pattern.
|
|
186
|
+
*/
|
|
187
|
+
function matchesAnyGlob(filePath: string, globs: string[]): boolean {
|
|
188
|
+
if (globs.length === 0) return false;
|
|
189
|
+
// Normalise separators; strip leading slash for relative matching
|
|
190
|
+
const normalized = filePath.replace(/\\/g, "/").replace(/^\//, "");
|
|
191
|
+
for (const glob of globs) {
|
|
192
|
+
if (globToRegExp(glob).test(normalized)) return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Attempt to extract the target file path from a Pi tool_call event.
|
|
199
|
+
*
|
|
200
|
+
* Handles the three writable tool shapes:
|
|
201
|
+
* - `Edit`: `{ input: { file_path: string } }` or `{ filePath: string }`
|
|
202
|
+
* - `Write`: `{ input: { file_path: string } }` or `{ filePath: string }`
|
|
203
|
+
* - `Bash`: best-effort scan of the command string for write destinations
|
|
204
|
+
*
|
|
205
|
+
* Returns `null` when the path cannot be determined (allow-by-default for Bash
|
|
206
|
+
* when the destination is ambiguous).
|
|
207
|
+
*
|
|
208
|
+
* @param toolName - The tool being invoked ("Edit", "Write", or "Bash").
|
|
209
|
+
* @param toolInput - The raw tool input object.
|
|
210
|
+
* @returns The extracted file path, or `null` if not determinable.
|
|
211
|
+
*/
|
|
212
|
+
function extractTargetPath(
|
|
213
|
+
toolName: string,
|
|
214
|
+
toolInput: Record<string, unknown> | undefined,
|
|
215
|
+
): string | null {
|
|
216
|
+
if (!toolInput) return null;
|
|
217
|
+
|
|
218
|
+
if (toolName === "Edit" || toolName === "Write") {
|
|
219
|
+
// Pi uses snake_case in the actual tool call input
|
|
220
|
+
if (typeof toolInput["file_path"] === "string") return toolInput["file_path"];
|
|
221
|
+
// camelCase fallback (bridge convention)
|
|
222
|
+
if (typeof toolInput["filePath"] === "string") return toolInput["filePath"];
|
|
223
|
+
// path fallback
|
|
224
|
+
if (typeof toolInput["path"] === "string") return toolInput["path"];
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (toolName === "Bash") {
|
|
229
|
+
const cmd = typeof toolInput["command"] === "string" ? toolInput["command"] : null;
|
|
230
|
+
if (!cmd) return null;
|
|
231
|
+
|
|
232
|
+
// Detect common write patterns: redirection, tee, cp/mv destination
|
|
233
|
+
// Best-effort: return the first detected destination path.
|
|
234
|
+
// If ambiguous, return null (allow-by-default for Bash).
|
|
235
|
+
const redirectMatch = cmd.match(/>\s*["']?([^\s"';&|]+)/);
|
|
236
|
+
if (redirectMatch?.[1]) return redirectMatch[1];
|
|
237
|
+
|
|
238
|
+
const teeMatch = cmd.match(/\btee\s+(?:-a\s+)?["']?([^\s"';&|]+)/);
|
|
239
|
+
if (teeMatch?.[1]) return teeMatch[1];
|
|
240
|
+
|
|
241
|
+
// cp/mv destination is the last argument — very heuristic
|
|
242
|
+
const cpMvMatch = cmd.match(/\b(?:cp|mv)\s+\S+\s+["']?([^\s"';&|]+)/);
|
|
243
|
+
if (cpMvMatch?.[1]) return cpMvMatch[1];
|
|
244
|
+
|
|
245
|
+
return null; // Cannot determine — allow (workers self-report)
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ============================================================================
|
|
252
|
+
// Internal state
|
|
253
|
+
// ============================================================================
|
|
254
|
+
|
|
255
|
+
/** Cached system prompt addendum from the last session_start compilation. */
|
|
256
|
+
let bundlePrompt: string | null = null;
|
|
257
|
+
|
|
258
|
+
/** Diagnostic summary cached for /cant:bundle-info. */
|
|
259
|
+
let lastDiagnosticSummary: string | null = null;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Recursively discover `.cant` files in a directory.
|
|
263
|
+
*
|
|
264
|
+
* @param dir - The directory to scan recursively.
|
|
265
|
+
* @returns An array of absolute paths to `.cant` files found.
|
|
266
|
+
*/
|
|
267
|
+
function discoverCantFiles(dir: string): string[] {
|
|
268
|
+
try {
|
|
269
|
+
const entries = readdirSync(dir, { recursive: true, withFileTypes: true });
|
|
270
|
+
const files: string[] = [];
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (entry.isFile() && entry.name.endsWith(".cant")) {
|
|
273
|
+
// Node 24+ recursive readdir returns entries with parentPath
|
|
274
|
+
const parent = (entry as unknown as { parentPath?: string }).parentPath ?? dir;
|
|
275
|
+
files.push(join(parent, entry.name));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return files;
|
|
279
|
+
} catch {
|
|
280
|
+
return [];
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ============================================================================
|
|
285
|
+
// T420: mental-model injection helper (async, calls memoryFind)
|
|
286
|
+
// ============================================================================
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Fetch prior mental-model observations for an agent and build the
|
|
290
|
+
* validate-on-load injection block.
|
|
291
|
+
*
|
|
292
|
+
* Called in `before_agent_start` when the agent has a `mentalModel` CANT block.
|
|
293
|
+
* Best-effort: returns empty string on any failure so Pi is never blocked.
|
|
294
|
+
*
|
|
295
|
+
* @param agentName - Name of the spawned agent.
|
|
296
|
+
* @param projectRoot - Project root directory for brain.db access.
|
|
297
|
+
* @returns The validate-on-load system-prompt block, or "" on failure/empty.
|
|
298
|
+
*/
|
|
299
|
+
async function fetchMentalModelInjection(
|
|
300
|
+
agentName: string,
|
|
301
|
+
projectRoot: string,
|
|
302
|
+
): Promise<string> {
|
|
303
|
+
try {
|
|
304
|
+
// Lazy import: @cleocode/core may not be present in all environments.
|
|
305
|
+
// memoryFind is the engine-compat wrapper (T418) that accepts `agent`.
|
|
306
|
+
const coreModule = (await import("@cleocode/core")) as {
|
|
307
|
+
memoryFind?: (
|
|
308
|
+
params: {
|
|
309
|
+
query: string;
|
|
310
|
+
agent?: string;
|
|
311
|
+
limit?: number;
|
|
312
|
+
tables?: string[];
|
|
313
|
+
},
|
|
314
|
+
projectRoot?: string,
|
|
315
|
+
) => Promise<{
|
|
316
|
+
success: boolean;
|
|
317
|
+
data?: {
|
|
318
|
+
results?: MentalModelObservation[];
|
|
319
|
+
};
|
|
320
|
+
}>;
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
if (typeof coreModule.memoryFind !== "function") return "";
|
|
324
|
+
|
|
325
|
+
// Fetch the 10 most recent mental-model observations for this agent.
|
|
326
|
+
// Use tables filter to avoid decisions/patterns/learnings which are
|
|
327
|
+
// not agent-scoped in the current schema.
|
|
328
|
+
const result = await coreModule.memoryFind(
|
|
329
|
+
{
|
|
330
|
+
query: agentName,
|
|
331
|
+
agent: agentName,
|
|
332
|
+
limit: 10,
|
|
333
|
+
tables: ["observations"],
|
|
334
|
+
},
|
|
335
|
+
projectRoot,
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
if (!result.success || !result.data?.results?.length) return "";
|
|
339
|
+
|
|
340
|
+
return buildMentalModelInjection(agentName, result.data.results);
|
|
341
|
+
} catch {
|
|
342
|
+
// Best-effort — never crash Pi
|
|
343
|
+
return "";
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// Pi extension factory
|
|
349
|
+
// ============================================================================
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Pi extension factory for the CleoOS CANT bridge.
|
|
353
|
+
*
|
|
354
|
+
* Registers event handlers for `session_start` (compile `.cant` files)
|
|
355
|
+
* and `before_agent_start` (append compiled bundle + mental-model injection
|
|
356
|
+
* to system prompt). Also registers a `/cant:bundle-info` command for
|
|
357
|
+
* introspection.
|
|
358
|
+
*
|
|
359
|
+
* @param pi - The Pi extension API instance.
|
|
360
|
+
*/
|
|
361
|
+
export default function (pi: ExtensionAPI): void {
|
|
362
|
+
// session_start: discover and compile .cant files from the project tier
|
|
363
|
+
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
364
|
+
bundlePrompt = null;
|
|
365
|
+
lastDiagnosticSummary = null;
|
|
366
|
+
|
|
367
|
+
try {
|
|
368
|
+
const cantDir = join(ctx.cwd, ".cleo", "cant");
|
|
369
|
+
if (!existsSync(cantDir)) return;
|
|
370
|
+
|
|
371
|
+
const files = discoverCantFiles(cantDir);
|
|
372
|
+
if (files.length === 0) return;
|
|
373
|
+
|
|
374
|
+
// Dynamic import: @cleocode/cant may not be installed in all environments
|
|
375
|
+
const cantModule = (await import("@cleocode/cant")) as {
|
|
376
|
+
compileBundle: (paths: string[]) => Promise<{
|
|
377
|
+
renderSystemPrompt: () => string;
|
|
378
|
+
diagnostics: Array<{ severity: string; message: string; sourcePath: string }>;
|
|
379
|
+
agents: Array<{ name: string }>;
|
|
380
|
+
teams: Array<{ name: string }>;
|
|
381
|
+
tools: Array<{ name: string }>;
|
|
382
|
+
valid: boolean;
|
|
383
|
+
}>;
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const bundle = await cantModule.compileBundle(files);
|
|
387
|
+
const prompt = bundle.renderSystemPrompt();
|
|
388
|
+
|
|
389
|
+
if (prompt.length > 0) {
|
|
390
|
+
bundlePrompt = prompt;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Build diagnostic summary
|
|
394
|
+
const errorDiags = bundle.diagnostics.filter((d) => d.severity === "error");
|
|
395
|
+
const warnDiags = bundle.diagnostics.filter((d) => d.severity === "warning");
|
|
396
|
+
lastDiagnosticSummary = [
|
|
397
|
+
`Files: ${files.length}`,
|
|
398
|
+
`Agents: ${bundle.agents.length}`,
|
|
399
|
+
`Teams: ${bundle.teams.length}`,
|
|
400
|
+
`Tools: ${bundle.tools.length}`,
|
|
401
|
+
`Valid: ${bundle.valid}`,
|
|
402
|
+
`Errors: ${errorDiags.length}`,
|
|
403
|
+
`Warnings: ${warnDiags.length}`,
|
|
404
|
+
].join(", ");
|
|
405
|
+
|
|
406
|
+
// Notify on errors
|
|
407
|
+
if (errorDiags.length > 0 && ctx.hasUI) {
|
|
408
|
+
ctx.ui.notify(
|
|
409
|
+
`CleoOS CANT bridge: ${errorDiags.length} validation error(s) in .cleo/cant/`,
|
|
410
|
+
"warning",
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Success notification
|
|
415
|
+
if (ctx.hasUI) {
|
|
416
|
+
ctx.ui.setStatus(
|
|
417
|
+
"cleo-cant-bridge",
|
|
418
|
+
`CANT: ${bundle.agents.length} agent(s), ${files.length} file(s)`,
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
} catch (err: unknown) {
|
|
422
|
+
// Best-effort: never crash Pi
|
|
423
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
424
|
+
if (ctx.hasUI) {
|
|
425
|
+
ctx.ui.notify(`CleoOS CANT bridge: ${message}`, "warning");
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// before_agent_start: APPEND compiled bundle prompt + mental-model injection
|
|
431
|
+
// to system prompt (per ULTRAPLAN L6, never replace)
|
|
432
|
+
pi.on(
|
|
433
|
+
"before_agent_start",
|
|
434
|
+
async (
|
|
435
|
+
event: {
|
|
436
|
+
systemPrompt?: string;
|
|
437
|
+
agentName?: string;
|
|
438
|
+
/** T420: agent CANT definition, if resolved by Pi runtime. */
|
|
439
|
+
agentDef?: {
|
|
440
|
+
/** mentalModel block presence signals validate-on-load injection. */
|
|
441
|
+
mentalModel?: unknown;
|
|
442
|
+
};
|
|
443
|
+
/** Project root injected by Pi when available. */
|
|
444
|
+
projectRoot?: string;
|
|
445
|
+
},
|
|
446
|
+
ctx?: ExtensionContext,
|
|
447
|
+
) => {
|
|
448
|
+
const existingPrompt = event.systemPrompt ?? "";
|
|
449
|
+
let appendix = "";
|
|
450
|
+
|
|
451
|
+
// APPEND CANT bundle prompt
|
|
452
|
+
if (bundlePrompt) {
|
|
453
|
+
appendix += "\n\n" + bundlePrompt;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// T420: validate-on-load mental-model injection.
|
|
457
|
+
// Inject when the agent has a `mentalModel` CANT block.
|
|
458
|
+
const agentName = event.agentName;
|
|
459
|
+
const hasMentalModel =
|
|
460
|
+
agentName !== undefined &&
|
|
461
|
+
agentName !== "" &&
|
|
462
|
+
event.agentDef?.mentalModel !== undefined;
|
|
463
|
+
|
|
464
|
+
if (hasMentalModel && agentName) {
|
|
465
|
+
// Resolve project root: prefer explicit field, fall back to ctx.cwd
|
|
466
|
+
const projectRoot = event.projectRoot ?? ctx?.cwd ?? "";
|
|
467
|
+
if (projectRoot) {
|
|
468
|
+
const mentalModelBlock = await fetchMentalModelInjection(agentName, projectRoot);
|
|
469
|
+
if (mentalModelBlock) {
|
|
470
|
+
appendix += mentalModelBlock;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (!appendix) return {};
|
|
476
|
+
|
|
477
|
+
return {
|
|
478
|
+
systemPrompt: existingPrompt + appendix,
|
|
479
|
+
};
|
|
480
|
+
},
|
|
481
|
+
);
|
|
482
|
+
|
|
483
|
+
// tool_call: ULTRAPLAN §10.3 — Lead agents MUST NOT execute Edit/Write/Bash.
|
|
484
|
+
// T424: Worker agents with declared file permissions are restricted to their
|
|
485
|
+
// declared write globs. Leads dispatch; workers execute within scope.
|
|
486
|
+
// Fires on every Pi tool_call event.
|
|
487
|
+
// The before_agent_start handler (T420 validate-on-load) is NOT touched here.
|
|
488
|
+
pi.on(
|
|
489
|
+
"tool_call",
|
|
490
|
+
async (event: {
|
|
491
|
+
/** CANT agent definition resolved by Pi at spawn time, if available. */
|
|
492
|
+
agentDef?: {
|
|
493
|
+
/** Tier role declared in the .cant file (e.g. "lead", "worker", "orchestrator"). */
|
|
494
|
+
role?: string;
|
|
495
|
+
/** Path-scoped file permissions declared in the .cant file (T423). */
|
|
496
|
+
filePermissions?: AgentFilePermissions;
|
|
497
|
+
/** Agent name for diagnostic messages. */
|
|
498
|
+
name?: string;
|
|
499
|
+
};
|
|
500
|
+
/** The tool name being invoked (e.g. "Edit", "Write", "Bash"). */
|
|
501
|
+
toolName?: string;
|
|
502
|
+
/** The raw tool input object (contains file_path for Edit/Write, command for Bash). */
|
|
503
|
+
toolInput?: Record<string, unknown>;
|
|
504
|
+
}) => {
|
|
505
|
+
const agentDef = event.agentDef;
|
|
506
|
+
// No agentDef = no restrictions (hook is a no-op).
|
|
507
|
+
if (!agentDef) return {};
|
|
508
|
+
|
|
509
|
+
const toolName = event.toolName ?? "";
|
|
510
|
+
const BLOCKED_TOOLS = ["Edit", "Write", "Bash"];
|
|
511
|
+
|
|
512
|
+
// ── W7b: Lead blocking ─────────────────────────────────────────────
|
|
513
|
+
// Only restrict agents whose CANT role is explicitly "lead".
|
|
514
|
+
// Non-lead roles (worker, orchestrator, undefined) pass this gate.
|
|
515
|
+
if (agentDef.role !== "lead") {
|
|
516
|
+
// Fall through to the T424 worker path ACL check below.
|
|
517
|
+
} else {
|
|
518
|
+
// Lead role: block Edit/Write/Bash entirely.
|
|
519
|
+
if (!BLOCKED_TOOLS.includes(toolName)) return {};
|
|
520
|
+
|
|
521
|
+
// Reject the tool call with a LAFS error envelope.
|
|
522
|
+
return {
|
|
523
|
+
rejected: true,
|
|
524
|
+
error: {
|
|
525
|
+
code: 70,
|
|
526
|
+
codeName: "E_LEAD_TOOL_BLOCKED",
|
|
527
|
+
message: `Lead agents cannot execute ${toolName} — dispatch to a worker instead`,
|
|
528
|
+
fix: "Use the delegate tool to spawn a worker agent for this work",
|
|
529
|
+
},
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ── T424: Worker path ACL ──────────────────────────────────────────
|
|
534
|
+
// Workers with declared file permissions can only write inside their
|
|
535
|
+
// declared globs. Applies to Edit, Write, and Bash (best-effort).
|
|
536
|
+
if (
|
|
537
|
+
agentDef.role === "worker" &&
|
|
538
|
+
agentDef.filePermissions !== undefined &&
|
|
539
|
+
BLOCKED_TOOLS.includes(toolName)
|
|
540
|
+
) {
|
|
541
|
+
const writeGlobs = agentDef.filePermissions.write;
|
|
542
|
+
// `undefined` write field = no declared write ACL = allow through.
|
|
543
|
+
// Empty array [] = explicit no-writes = default-deny.
|
|
544
|
+
if (writeGlobs !== undefined) {
|
|
545
|
+
const targetPath = extractTargetPath(toolName, event.toolInput);
|
|
546
|
+
if (targetPath !== null && !matchesAnyGlob(targetPath, writeGlobs)) {
|
|
547
|
+
const agentName = agentDef.name ?? "worker";
|
|
548
|
+
const scopeList =
|
|
549
|
+
writeGlobs.length > 0 ? writeGlobs.join(", ") : "(none — this worker is read-only)";
|
|
550
|
+
return {
|
|
551
|
+
rejected: true,
|
|
552
|
+
error: {
|
|
553
|
+
code: 71,
|
|
554
|
+
codeName: "E_WORKER_PATH_ACL_VIOLATION",
|
|
555
|
+
message: `Worker ${agentName} is not allowed to write to ${targetPath}`,
|
|
556
|
+
fix:
|
|
557
|
+
`This worker can only write inside: ${scopeList}. ` +
|
|
558
|
+
"Either update the worker's permissions.files.write glob in " +
|
|
559
|
+
".cleo/teams.cant, or dispatch to a different worker with matching scope.",
|
|
560
|
+
},
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {};
|
|
567
|
+
},
|
|
568
|
+
);
|
|
569
|
+
|
|
570
|
+
// /cant:bundle-info — introspection command
|
|
571
|
+
pi.registerCommand("cant:bundle-info", {
|
|
572
|
+
description: "Show the state of the CANT bundle compiled at session start",
|
|
573
|
+
handler: async (
|
|
574
|
+
_args: string,
|
|
575
|
+
ctx: ExtensionContext & { hasUI: boolean; signal?: AbortSignal },
|
|
576
|
+
) => {
|
|
577
|
+
const content = lastDiagnosticSummary
|
|
578
|
+
? `CANT Bundle: ${lastDiagnosticSummary}`
|
|
579
|
+
: "CANT Bundle: no .cant files compiled (check .cleo/cant/ directory)";
|
|
580
|
+
pi.sendMessage(
|
|
581
|
+
{ customType: "cleo-cant-bundle-info", content, display: true },
|
|
582
|
+
{ triggerTurn: false },
|
|
583
|
+
);
|
|
584
|
+
if (ctx.hasUI) {
|
|
585
|
+
ctx.ui.notify(
|
|
586
|
+
lastDiagnosticSummary ? "CANT bundle loaded" : "No CANT bundle",
|
|
587
|
+
"info",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
// session_shutdown: clear cached state
|
|
594
|
+
pi.on("session_shutdown", async () => {
|
|
595
|
+
bundlePrompt = null;
|
|
596
|
+
lastDiagnosticSummary = null;
|
|
597
|
+
});
|
|
598
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CleoOS chat room — inter-agent messaging TUI.
|
|
3
|
+
*
|
|
4
|
+
* Installed to: $CLEO_HOME/pi-extensions/cleo-chatroom.ts
|
|
5
|
+
* Loaded by: Pi via `-e <path>` or settings.json extensions array
|
|
6
|
+
*
|
|
7
|
+
* Wave 7 — surfaces inter-agent traffic as a TUI panel per ULTRAPLAN
|
|
8
|
+
* section 13. Agents communicate through four tools:
|
|
9
|
+
*
|
|
10
|
+
* - `send_to_lead` — worker sends a message to their lead.
|
|
11
|
+
* - `broadcast_to_team` — lead broadcasts to all group workers.
|
|
12
|
+
* - `report_to_orchestrator` — lead reports status to the orchestrator.
|
|
13
|
+
* - `query_peer` — worker queries another worker in the same group.
|
|
14
|
+
*
|
|
15
|
+
* Each tool appends a structured JSONL entry to the Pi session's message
|
|
16
|
+
* log. A TUI widget (registered on `session_start`) renders the last N
|
|
17
|
+
* messages in a scrollable panel below the editor.
|
|
18
|
+
*
|
|
19
|
+
* This is a TEMPLATE extension — it uses `import type` for Pi types and
|
|
20
|
+
* mirrors the patterns established by the existing extensions in
|
|
21
|
+
* `packages/cleo/templates/cleoos-hub/pi-extensions/`.
|
|
22
|
+
*
|
|
23
|
+
* @packageDocumentation
|
|
24
|
+
*/
|
|
25
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
26
|
+
/**
|
|
27
|
+
* Tier role of an agent in the 3-tier hierarchy (ULTRAPLAN §10).
|
|
28
|
+
* Used to apply distinct TUI styling per tier.
|
|
29
|
+
*/
|
|
30
|
+
export type AgentTierRole = "orchestrator" | "lead" | "worker";
|
|
31
|
+
/** A single inter-agent chat message. */
|
|
32
|
+
export interface ChatMessage {
|
|
33
|
+
/** ISO-8601 timestamp of when the message was created. */
|
|
34
|
+
timestamp: string;
|
|
35
|
+
/** Name of the sending agent. */
|
|
36
|
+
from: string;
|
|
37
|
+
/** Name of the receiving agent or group (e.g. "team:backend"). */
|
|
38
|
+
to: string;
|
|
39
|
+
/** Message channel identifying the tool that produced this message. */
|
|
40
|
+
channel: "send_to_lead" | "broadcast_to_team" | "report_to_orchestrator" | "query_peer";
|
|
41
|
+
/** The message text. */
|
|
42
|
+
text: string;
|
|
43
|
+
/**
|
|
44
|
+
* Optional tier role of the sending agent.
|
|
45
|
+
*
|
|
46
|
+
* When present, the TUI row is prefixed and (if ANSI is available)
|
|
47
|
+
* coloured by tier: orchestrator = green ([O]), lead = yellow ([L]),
|
|
48
|
+
* worker = blue ([W]). Defaults to "worker" when absent.
|
|
49
|
+
*/
|
|
50
|
+
role?: AgentTierRole;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return the single-character tier prefix for a chat message row.
|
|
54
|
+
*
|
|
55
|
+
* - `[O]` orchestrator (green in ANSI-capable terminals)
|
|
56
|
+
* - `[L]` lead (yellow)
|
|
57
|
+
* - `[W]` worker (blue, default)
|
|
58
|
+
*
|
|
59
|
+
* @param role - The sending agent's tier role, or `undefined` to default to worker.
|
|
60
|
+
* @returns The three-character prefix string.
|
|
61
|
+
*/
|
|
62
|
+
export declare function tierPrefix(role: AgentTierRole | undefined): string;
|
|
63
|
+
/**
|
|
64
|
+
* Format a chat message for TUI display.
|
|
65
|
+
*
|
|
66
|
+
* Each row is prefixed with a tier indicator ([O]/[L]/[W]) so orchestrator,
|
|
67
|
+
* lead, and worker traffic is visually distinct in the chat panel (ULTRAPLAN §13).
|
|
68
|
+
*
|
|
69
|
+
* @param msg - The message to format.
|
|
70
|
+
* @returns A single-line string representation.
|
|
71
|
+
*/
|
|
72
|
+
export declare function formatMessage(msg: ChatMessage): string;
|
|
73
|
+
/**
|
|
74
|
+
* Pi extension factory for the CleoOS chat room.
|
|
75
|
+
*
|
|
76
|
+
* Registers four inter-agent communication tools and a TUI widget that
|
|
77
|
+
* displays the message stream. Also registers `/cleo:chat-info` for
|
|
78
|
+
* introspection and clears state on session shutdown.
|
|
79
|
+
*
|
|
80
|
+
* @param pi - The Pi extension API instance.
|
|
81
|
+
*/
|
|
82
|
+
export default function (pi: ExtensionAPI): void;
|
|
83
|
+
//# sourceMappingURL=cleo-chatroom.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cleo-chatroom.d.ts","sourceRoot":"","sources":["cleo-chatroom.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAIH,OAAO,KAAK,EACV,YAAY,EAGb,MAAM,+BAA+B,CAAC;AAOvC;;;GAGG;AACH,MAAM,MAAM,aAAa,GAAG,cAAc,GAAG,MAAM,GAAG,QAAQ,CAAC;AAE/D,yCAAyC;AACzC,MAAM,WAAW,WAAW;IAC1B,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;IAClB,iCAAiC;IACjC,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,EAAE,EAAE,MAAM,CAAC;IACX,uEAAuE;IACvE,OAAO,EAAE,cAAc,GAAG,mBAAmB,GAAG,wBAAwB,GAAG,YAAY,CAAC;IACxF,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,IAAI,CAAC,EAAE,aAAa,CAAC;CACtB;AAqCD;;;;;;;;;GASG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,aAAa,GAAG,SAAS,GAAG,MAAM,CASlE;AAED;;;;;;;;GAQG;AACH,wBAAgB,aAAa,CAAC,GAAG,EAAE,WAAW,GAAG,MAAM,CAItD;AAmFD;;;;;;;;GAQG;AACH,MAAM,CAAC,OAAO,WAAW,EAAE,EAAE,YAAY,GAAG,IAAI,CAsN/C"}
|