@kal-elsam/kairo-runtime 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/bin/kairo-runtime.js +0 -0
  3. package/bin/kairo.js +0 -0
  4. package/package.json +1 -1
  5. package/src/cli.js +182 -8
  6. package/src/global/check-resolutions.js +31 -0
  7. package/src/global/cli-help.js +21 -2
  8. package/src/global/component-ecosystem-checks.js +2 -0
  9. package/src/global/component-integration-cli.js +29 -10
  10. package/src/global/components-resolve-cli.js +246 -0
  11. package/src/global/connection-actions.js +147 -0
  12. package/src/global/connections.js +269 -0
  13. package/src/global/control-plane/attention.js +141 -0
  14. package/src/global/control-plane/build-report.js +146 -0
  15. package/src/global/control-plane/cli.js +36 -0
  16. package/src/global/control-plane/constants.js +38 -0
  17. package/src/global/control-plane/gentle-adapters.js +183 -0
  18. package/src/global/control-plane/provider.js +69 -0
  19. package/src/global/control-plane/review-status.js +115 -0
  20. package/src/global/control-plane/sdd-status.js +49 -0
  21. package/src/global/control-plane/team.js +63 -0
  22. package/src/global/fleet-configure-plan.js +123 -0
  23. package/src/global/fleet-configure.js +303 -0
  24. package/src/global/fleet-models.js +188 -0
  25. package/src/global/fleet-set.js +219 -0
  26. package/src/global/fleet-shared.js +38 -0
  27. package/src/global/ink/cockpit-controller.js +1 -1
  28. package/src/global/ink/cockpit-models.js +4 -1
  29. package/src/global/ink/orchestrator-app.js +21 -2
  30. package/src/global/ink/ux/live-overview.js +5 -11
  31. package/src/global/ink/ux/overview-needs.js +1 -1
  32. package/src/global/integrations/engram-evidence.js +7 -2
  33. package/src/global/integrations/sdd-apply.js +17 -7
  34. package/src/global/integrations/sdd-evidence.js +22 -3
  35. package/src/global/integrations/sdd-plan.js +21 -3
  36. package/src/global/integrations/sdd-resolutions.js +73 -0
  37. package/src/global/integrations/sdd-state.js +69 -0
  38. package/src/global/integrations/sdd-verify.js +9 -4
  39. package/src/global/mcp/kairo-mcp.js +56 -5
  40. package/src/global/mcp/resolve-mcp-workspace.js +51 -0
  41. package/src/global/mcp/work-snapshot-rule.js +89 -0
  42. package/src/global/mcp/work-snapshot-tool.js +49 -0
  43. package/src/global/mcp-install.js +239 -0
  44. package/src/global/next/next-cli.js +35 -0
  45. package/src/global/next/next-report.js +145 -0
  46. package/src/global/next/project-key.js +36 -0
  47. package/src/global/next/publish-work-snapshot.js +116 -0
  48. package/src/global/next/work-enroll.js +91 -0
  49. package/src/global/next/work-snapshot.js +216 -0
  50. package/src/global/observability/fleet-activity.js +197 -0
  51. package/src/global/observability/fleet-models-catalog.js +137 -0
  52. package/src/global/observability/fleet-platforms.js +166 -0
  53. package/src/global/observability/fleet-probe.js +229 -0
  54. package/src/global/observability/gentle-probe.js +30 -2
  55. package/src/global/observability/index.js +2 -1
  56. package/src/global/paths.js +2 -1
@@ -0,0 +1,216 @@
1
+ /**
2
+ * kairo.work-snapshot/v1 — semantic work state for observability.
3
+ * Never stores prompts, transcripts, or agent-supplied workspace identity.
4
+ */
5
+ import { createHash } from "node:crypto";
6
+ import { mkdir, readFile, readdir } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { harnessHomePaths } from "../paths.js";
9
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
10
+ import { projectKeyForPath } from "./project-key.js";
11
+
12
+ export const WORK_SNAPSHOT_SCHEMA = "kairo.work-snapshot/v1";
13
+
14
+ /** Known smoke/fixture conversation ids — ignored on read, never auto-deleted. */
15
+ export const IGNORED_SMOKE_CONVERSATION_IDS = Object.freeze([
16
+ "echopilot-visual-smoke"
17
+ ]);
18
+
19
+ const PRIVATE_KEYS = Object.freeze([
20
+ "prompt", "prompts", "transcript", "transcripts", "response", "messages"
21
+ ]);
22
+
23
+ const TECH_LEAK_RE =
24
+ /\bks_[a-f0-9]{8,}\b|kairo\.(?:work|next|session)\b|call kairo_|engramRef|schema\s*[:=]/i;
25
+
26
+ export function isIgnoredSmokeConversationId(id) {
27
+ return Boolean(id) && IGNORED_SMOKE_CONVERSATION_IDS.includes(String(id));
28
+ }
29
+
30
+ export function looksLikeTechnicalLeak(text) {
31
+ return TECH_LEAK_RE.test(String(text ?? ""));
32
+ }
33
+
34
+ export function assertNoWorkPrivatePayload(input) {
35
+ if (!input || typeof input !== "object") return;
36
+ for (const key of PRIVATE_KEYS) {
37
+ if (Object.prototype.hasOwnProperty.call(input, key)) {
38
+ throw new Error(`Private field "${key}" is not allowed on work payloads.`);
39
+ }
40
+ }
41
+ }
42
+
43
+ function snapshotsDir(homeDir, projectKey) {
44
+ return join(harnessHomePaths(homeDir).sessionsDir, projectKey, "snapshots");
45
+ }
46
+
47
+ /** Stable file id — avoids collisions from sanitized path characters. */
48
+ export function snapshotFileId(conversationId) {
49
+ return createHash("sha256").update(String(conversationId)).digest("hex").slice(0, 32);
50
+ }
51
+
52
+ function snapshotFilePath(homeDir, projectKey, conversationId) {
53
+ return join(snapshotsDir(homeDir, projectKey), `${snapshotFileId(conversationId)}.json`);
54
+ }
55
+
56
+ function resolveConversationId(conversationId, snapshot) {
57
+ const raw = conversationId ?? snapshot?.conversationId ?? null;
58
+ if (typeof raw !== "string" || !raw.trim()) {
59
+ throw new Error("conversationId is required to save a work snapshot.");
60
+ }
61
+ return raw.trim().slice(0, 160);
62
+ }
63
+
64
+ function cleanText(value, max) {
65
+ if (typeof value !== "string") return null;
66
+ const trimmed = value.trim();
67
+ if (!trimmed || looksLikeTechnicalLeak(trimmed)) return null;
68
+ return trimmed.slice(0, max);
69
+ }
70
+
71
+ function cleanTextList(list, maxItem, maxItems) {
72
+ if (!Array.isArray(list)) return [];
73
+ return list.map((item) => cleanText(item, maxItem)).filter(Boolean).slice(0, maxItems);
74
+ }
75
+
76
+ function sanitizeDelegations(list) {
77
+ if (!Array.isArray(list)) return [];
78
+ return list
79
+ .map((row) => {
80
+ if (!row || typeof row !== "object") return null;
81
+ const title = cleanText(row.title ?? row.goal, 160);
82
+ const workId = typeof row.workId === "string" && /^kw_[a-f0-9]{32}$/i.test(row.workId)
83
+ ? row.workId
84
+ : null;
85
+ if (!title && !workId) return null;
86
+ const role = row.role === "orchestrator" || row.role === "worker" ? row.role : null;
87
+ const state = ["assigned", "working", "blocked", "completed", "failed"].includes(row.state)
88
+ ? row.state
89
+ : null;
90
+ return {
91
+ ...(workId ? { workId } : {}),
92
+ ...(title ? { title } : {}),
93
+ ...(role ? { role } : {}),
94
+ ...(state ? { state } : {})
95
+ };
96
+ })
97
+ .filter(Boolean)
98
+ .slice(0, 12);
99
+ }
100
+
101
+ export function isWorkSnapshotSchema(schema) {
102
+ return schema === WORK_SNAPSHOT_SCHEMA;
103
+ }
104
+
105
+ /** Build a sanitized v1 snapshot. Incomplete inputs keep nulls — never invent text. */
106
+ export function createWorkSnapshot(input = {}) {
107
+ assertNoWorkPrivatePayload(input);
108
+ const goal = cleanText(input.goal, 160);
109
+ const now = cleanText(input.now, 240);
110
+ const next = cleanText(input.next, 240);
111
+ const progress = cleanTextList(input.progress, 160, 3);
112
+ const blockers = cleanTextList(input.blockers, 200, 12);
113
+ const delegations = sanitizeDelegations(input.delegations);
114
+ const conversationId = input.conversationId
115
+ ? String(input.conversationId).slice(0, 160)
116
+ : null;
117
+ const provider = input.provider ? String(input.provider).slice(0, 40) : null;
118
+
119
+ return {
120
+ schema: WORK_SNAPSHOT_SCHEMA,
121
+ goal,
122
+ progress,
123
+ now,
124
+ blockers,
125
+ next,
126
+ ...(delegations.length > 0 ? { delegations } : {}),
127
+ conversationId,
128
+ provider,
129
+ updatedAt: input.updatedAt ?? new Date().toISOString()
130
+ };
131
+ }
132
+
133
+ export function snapshotIsComplete(snapshot) {
134
+ return Boolean(
135
+ snapshot
136
+ && isWorkSnapshotSchema(snapshot.schema)
137
+ && snapshot.goal
138
+ && snapshot.now
139
+ && snapshot.next
140
+ );
141
+ }
142
+
143
+ function acceptStoredSnapshot(raw) {
144
+ if (!isWorkSnapshotSchema(raw?.schema)) return null;
145
+ if (isIgnoredSmokeConversationId(raw.conversationId)) return null;
146
+ return raw;
147
+ }
148
+
149
+ export async function saveWorkSnapshot(
150
+ homeDir,
151
+ projectPath,
152
+ conversationId,
153
+ snapshot,
154
+ deps = {}
155
+ ) {
156
+ const resolvedId = resolveConversationId(conversationId, snapshot);
157
+ const projectKey = projectKeyForPath(projectPath);
158
+ await mkdir(snapshotsDir(homeDir, projectKey), { recursive: true });
159
+ const nowIso = deps.now ? deps.now() : new Date().toISOString();
160
+ const payload = {
161
+ ...createWorkSnapshot({
162
+ ...snapshot,
163
+ conversationId: resolvedId,
164
+ updatedAt: nowIso
165
+ }),
166
+ projectKey,
167
+ updatedAt: nowIso
168
+ };
169
+ await (deps.writeAtomic ?? writeAtomicJson)(
170
+ snapshotFilePath(homeDir, projectKey, resolvedId),
171
+ payload
172
+ );
173
+ return payload;
174
+ }
175
+
176
+ export async function loadWorkSnapshot(homeDir, projectPath, conversationId) {
177
+ if (typeof conversationId !== "string" || !conversationId.trim()) return null;
178
+ const projectKey = projectKeyForPath(projectPath);
179
+ try {
180
+ const raw = JSON.parse(
181
+ await readFile(snapshotFilePath(homeDir, projectKey, conversationId.trim()), "utf8")
182
+ );
183
+ return acceptStoredSnapshot(raw);
184
+ } catch {
185
+ return null;
186
+ }
187
+ }
188
+
189
+ export async function listWorkSnapshots(homeDir, projectPath) {
190
+ const projectKey = projectKeyForPath(projectPath);
191
+ const dir = snapshotsDir(homeDir, projectKey);
192
+ let names = [];
193
+ try {
194
+ names = await readdir(dir);
195
+ } catch {
196
+ return [];
197
+ }
198
+ const out = [];
199
+ for (const name of names) {
200
+ if (!name.endsWith(".json")) continue;
201
+ try {
202
+ const raw = JSON.parse(await readFile(join(dir, name), "utf8"));
203
+ const accepted = acceptStoredSnapshot(raw);
204
+ if (accepted) out.push(accepted);
205
+ } catch {
206
+ // corrupt files are skipped — callers see absence, not invented content
207
+ }
208
+ }
209
+ return out.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
210
+ }
211
+
212
+ /** Most recently updated real snapshot for this workspace. */
213
+ export async function selectLatestWorkSnapshot(homeDir, projectPath) {
214
+ const [latest] = await listWorkSnapshots(homeDir, projectPath);
215
+ return latest ?? null;
216
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Live OpenCode fleet activity from opencode.db (read-only).
3
+ * Parent→child sessions via session.parent_id — not token telemetry.
4
+ */
5
+ import { access } from "node:fs/promises";
6
+ import { constants as fsConstants } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { resolveHomeDir } from "../paths.js";
9
+
10
+ const DEFAULT_LIMIT = 40;
11
+ const ACTIVE_WINDOW_MS = 15 * 60 * 1000;
12
+
13
+ async function pathExists(path) {
14
+ try {
15
+ await access(path, fsConstants.F_OK);
16
+ return true;
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ export function resolveOpenCodeDbPath(homeDir = resolveHomeDir()) {
23
+ return join(homeDir, ".local", "share", "opencode", "opencode.db");
24
+ }
25
+
26
+ export function parseSessionModel(raw) {
27
+ if (raw == null || raw === "") return { model: null, modelShort: null, providerId: null };
28
+ if (typeof raw === "object" && !Array.isArray(raw)) {
29
+ const id = typeof raw.id === "string" ? raw.id : null;
30
+ const providerID = typeof raw.providerID === "string" ? raw.providerID : null;
31
+ const model = id && providerID ? `${providerID}/${id}` : id;
32
+ return { model, modelShort: id, providerId: providerID };
33
+ }
34
+ if (typeof raw !== "string") return { model: null, modelShort: null, providerId: null };
35
+ try {
36
+ return parseSessionModel(JSON.parse(raw));
37
+ } catch {
38
+ const slash = raw.lastIndexOf("/");
39
+ return {
40
+ model: raw,
41
+ modelShort: slash >= 0 ? raw.slice(slash + 1) : raw,
42
+ providerId: slash >= 0 ? raw.slice(0, slash) : null
43
+ };
44
+ }
45
+ }
46
+
47
+ function sessionState(row, nowMs, activeWindowMs) {
48
+ if (row.time_archived) return "archived";
49
+ if (typeof row.time_updated === "number" && nowMs - row.time_updated <= activeWindowMs) {
50
+ return "active";
51
+ }
52
+ return "idle";
53
+ }
54
+
55
+ /**
56
+ * Pure mapper: DB rows → activity tree nodes.
57
+ */
58
+ export function mapSessionRowsToActivity(rows = [], {
59
+ nowMs = Date.now(),
60
+ activeWindowMs = ACTIVE_WINDOW_MS
61
+ } = {}) {
62
+ const sessions = rows.map((row) => {
63
+ const parsed = parseSessionModel(row.model);
64
+ return {
65
+ id: String(row.id),
66
+ parentId: row.parent_id ? String(row.parent_id) : null,
67
+ title: typeof row.title === "string" ? row.title : "",
68
+ agent: typeof row.agent === "string" ? row.agent : null,
69
+ model: parsed.model,
70
+ modelShort: parsed.modelShort,
71
+ providerId: parsed.providerId,
72
+ timeCreated: row.time_created ?? null,
73
+ timeUpdated: row.time_updated ?? null,
74
+ state: sessionState(row, nowMs, activeWindowMs),
75
+ platform: "opencode"
76
+ };
77
+ });
78
+
79
+ const byAgent = new Map();
80
+ for (const s of sessions) {
81
+ const key = s.agent ?? "unknown";
82
+ const prev = byAgent.get(key);
83
+ if (!prev || (s.timeUpdated ?? 0) > (prev.timeUpdated ?? 0)) {
84
+ byAgent.set(key, s);
85
+ }
86
+ }
87
+
88
+ const agents = [...byAgent.entries()]
89
+ .map(([id, session]) => ({
90
+ id,
91
+ state: session.state === "archived" ? "idle" : session.state,
92
+ model: session.model,
93
+ modelShort: session.modelShort,
94
+ sessionId: session.id,
95
+ parentId: session.parentId,
96
+ title: session.title,
97
+ timeUpdated: session.timeUpdated
98
+ }))
99
+ .sort((a, b) => {
100
+ if (a.state === "active" && b.state !== "active") return -1;
101
+ if (b.state === "active" && a.state !== "active") return 1;
102
+ return (b.timeUpdated ?? 0) - (a.timeUpdated ?? 0);
103
+ });
104
+
105
+ return {
106
+ platform: "opencode",
107
+ sessions,
108
+ agents,
109
+ activeCount: agents.filter((a) => a.state === "active").length,
110
+ source: "opencode.db"
111
+ };
112
+ }
113
+
114
+ function openReadonlyDb(dbPath, DatabaseSync) {
115
+ return new DatabaseSync(dbPath, { readOnly: true });
116
+ }
117
+
118
+ /**
119
+ * Read recent OpenCode sessions (read-only). Inject openDatabase for tests.
120
+ */
121
+ export async function buildOpenCodeActivity({
122
+ homeDir = resolveHomeDir(),
123
+ limit = DEFAULT_LIMIT,
124
+ activeWindowMs = ACTIVE_WINDOW_MS,
125
+ nowMs = Date.now(),
126
+ exists = pathExists,
127
+ openDatabase = null,
128
+ DatabaseSync = null
129
+ } = {}) {
130
+ const dbPath = resolveOpenCodeDbPath(homeDir);
131
+ if (!(await exists(dbPath))) {
132
+ return {
133
+ ok: true,
134
+ available: false,
135
+ note: "OpenCode session DB not found.",
136
+ dbPath,
137
+ platform: "opencode",
138
+ sessions: [],
139
+ agents: [],
140
+ activeCount: 0,
141
+ generatedAt: new Date(nowMs).toISOString()
142
+ };
143
+ }
144
+
145
+ let rows = [];
146
+ try {
147
+ let Database = DatabaseSync;
148
+ if (!Database) {
149
+ const prior = process.emitWarning;
150
+ process.emitWarning = function muted(warning, ...rest) {
151
+ const msg = typeof warning === "string" ? warning : (warning?.message ?? "");
152
+ if (/SQLite is an experimental feature/i.test(String(msg))) return;
153
+ return prior.apply(process, [warning, ...rest]);
154
+ };
155
+ try {
156
+ ({ DatabaseSync: Database } = await import("node:sqlite"));
157
+ } finally {
158
+ process.emitWarning = prior;
159
+ }
160
+ }
161
+ const open = openDatabase ?? ((path) => openReadonlyDb(path, Database));
162
+ const db = open(dbPath);
163
+ try {
164
+ const stmt = db.prepare(`
165
+ SELECT id, parent_id, title, agent, model, time_created, time_updated, time_archived
166
+ FROM session
167
+ ORDER BY time_updated DESC
168
+ LIMIT ?
169
+ `);
170
+ rows = stmt.all(Math.max(1, Math.min(Number(limit) || DEFAULT_LIMIT, 200)));
171
+ } finally {
172
+ db.close?.();
173
+ }
174
+ } catch (error) {
175
+ return {
176
+ ok: false,
177
+ available: false,
178
+ note: `Could not read OpenCode DB: ${error?.message ?? error}`,
179
+ dbPath,
180
+ platform: "opencode",
181
+ sessions: [],
182
+ agents: [],
183
+ activeCount: 0,
184
+ generatedAt: new Date(nowMs).toISOString()
185
+ };
186
+ }
187
+
188
+ const mapped = mapSessionRowsToActivity(rows, { nowMs, activeWindowMs });
189
+ return {
190
+ ok: true,
191
+ available: true,
192
+ note: "Live OpenCode sessions (declared parent→child). Not Cursor/Claude live.",
193
+ dbPath,
194
+ ...mapped,
195
+ generatedAt: new Date(nowMs).toISOString()
196
+ };
197
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Declared / known model catalogs per platform (read-only discovery).
3
+ * Not a live marketplace scrape — what is configured or known-safe on disk.
4
+ */
5
+ import { readFile, readdir } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { resolveHomeDir } from "../paths.js";
8
+ import { parseCodexDefaultModel, parseFrontmatterModel } from "./fleet-platforms.js";
9
+
10
+ export const CLAUDE_TIERS = Object.freeze(["opus", "sonnet", "haiku"]);
11
+
12
+ export const CURSOR_AGENT_MODELS = Object.freeze(["inherit", "fast"]);
13
+
14
+ async function readJson(path, read) {
15
+ try {
16
+ return JSON.parse(await read(path, "utf8"));
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ async function collectOpenCodeModels(homeDir, read) {
23
+ const path = join(homeDir, ".config", "opencode", "opencode.json");
24
+ const config = await readJson(path, read);
25
+ const ids = new Set();
26
+ if (typeof config?.model === "string") ids.add(config.model);
27
+ for (const raw of Object.values(config?.agent ?? config?.agents ?? {})) {
28
+ if (typeof raw?.model === "string") ids.add(raw.model);
29
+ }
30
+ return {
31
+ platform: "opencode",
32
+ kind: "multi",
33
+ path,
34
+ available: [...ids].sort(),
35
+ enabled: typeof config?.model === "string" ? [config.model] : [],
36
+ note: "From opencode.json (declared agents + default)."
37
+ };
38
+ }
39
+
40
+ async function collectClaudeModels(homeDir, read, list = readdir) {
41
+ const settingsPath = join(homeDir, ".claude", "settings.json");
42
+ const settings = await readJson(settingsPath, read);
43
+ const enabled = [];
44
+ if (typeof settings?.model === "string") enabled.push(settings.model);
45
+ try {
46
+ const dir = join(homeDir, ".claude", "agents");
47
+ for (const name of await list(dir)) {
48
+ if (!name.endsWith(".md")) continue;
49
+ const meta = parseFrontmatterModel(await read(join(dir, name), "utf8"));
50
+ if (meta.model) enabled.push(meta.model);
51
+ }
52
+ } catch { /* missing */ }
53
+ return {
54
+ platform: "claude",
55
+ kind: "multi",
56
+ path: settingsPath,
57
+ available: [...CLAUDE_TIERS],
58
+ enabled: [...new Set(enabled)],
59
+ note: "Claude Code tiers (opus/sonnet/haiku) + agents frontmatter."
60
+ };
61
+ }
62
+
63
+ async function collectCodexModels(homeDir, read) {
64
+ const path = join(homeDir, ".codex", "config.toml");
65
+ let current = null;
66
+ try {
67
+ current = parseCodexDefaultModel(await read(path, "utf8"));
68
+ } catch { /* missing */ }
69
+ return {
70
+ platform: "codex",
71
+ kind: "single",
72
+ path,
73
+ available: current ? [current] : [],
74
+ enabled: current ? [current] : [],
75
+ note: "Single default model in config.toml (no per-phase minions)."
76
+ };
77
+ }
78
+
79
+ async function collectCursorModels(homeDir, read, list = readdir) {
80
+ const cliPath = join(homeDir, ".cursor", "cli-config.json");
81
+ const cli = await readJson(cliPath, read);
82
+ const cliModel = cli?.model?.modelId ?? cli?.model?.displayModelId ?? null;
83
+ const agentModels = new Set();
84
+ try {
85
+ const dir = join(homeDir, ".cursor", "agents");
86
+ for (const name of await list(dir)) {
87
+ if (!name.endsWith(".md")) continue;
88
+ const meta = parseFrontmatterModel(await read(join(dir, name), "utf8"));
89
+ if (meta.model) agentModels.add(meta.model);
90
+ }
91
+ } catch { /* missing */ }
92
+ return {
93
+ platform: "cursor",
94
+ kind: "multi",
95
+ path: join(homeDir, ".cursor", "agents"),
96
+ available: [...CURSOR_AGENT_MODELS, ...(cliModel ? [cliModel] : [])],
97
+ enabled: [...agentModels],
98
+ cliDefault: cliModel,
99
+ note: "Agent frontmatter usually inherit/fast. Auto chat model is IDE-managed (see cli-config)."
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Catalog of available + enabled models for each tooling surface.
105
+ */
106
+ export async function buildFleetModelsCatalog({
107
+ homeDir = resolveHomeDir(),
108
+ read = readFile,
109
+ list = readdir
110
+ } = {}) {
111
+ const platforms = await Promise.all([
112
+ collectOpenCodeModels(homeDir, read),
113
+ collectClaudeModels(homeDir, read, list),
114
+ collectCodexModels(homeDir, read),
115
+ collectCursorModels(homeDir, read, list)
116
+ ]);
117
+ return {
118
+ ok: true,
119
+ kind: "catalog",
120
+ note: "Available = known/declared for that tool. Enabled = currently referenced on disk.",
121
+ platforms,
122
+ generatedAt: new Date().toISOString()
123
+ };
124
+ }
125
+
126
+ export function formatFleetModelsText(catalog) {
127
+ const lines = ["Fleet models (available · enabled)", ""];
128
+ for (const p of catalog.platforms ?? []) {
129
+ lines.push(`${p.platform} · ${p.kind}${p.cliDefault ? ` · cli ${p.cliDefault}` : ""}`);
130
+ lines.push(` available · ${(p.available ?? []).join(", ") || "—"}`);
131
+ lines.push(` enabled · ${(p.enabled ?? []).join(", ") || "—"}`);
132
+ if (p.note) lines.push(` ${p.note}`);
133
+ lines.push("");
134
+ }
135
+ lines.push(catalog.note ?? "");
136
+ return lines.join("\n").trimEnd();
137
+ }