@cairnkeep/cli 2.17.3 → 2.19.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 (47) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/docs/harness-compatibility.md +48 -0
  4. package/docs/learning/CURRICULUM-MAP.md +2 -1
  5. package/docs/learning/FEATURE-GUIDE.md +2 -1
  6. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  7. package/docs/learning/lessons/L01-safe-trial.md +1 -1
  8. package/docs/learning/lessons/L02-installation.md +1 -1
  9. package/docs/learning/lessons/L03-first-project.md +1 -1
  10. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  11. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  12. package/docs/learning/lessons/L21-context-packs.md +1 -1
  13. package/docs/learning/lessons/L22-native-windows.md +1 -1
  14. package/docs/learning/lessons/L23-guided-setup.md +18 -7
  15. package/docs/learning/lessons/L24-okf-exchange.md +1 -1
  16. package/docs/learning/lessons/L25-playbooks.md +1 -1
  17. package/docs/operating.md +13 -6
  18. package/examples/eval/bundled-fake.json +1 -1
  19. package/mcp-memory-server/dist/artifact-schema.js +1 -1
  20. package/mcp-memory-server/dist/pi-bridge-child.js +109 -0
  21. package/mcp-memory-server/dist/pi-mcp-bridge.js +15 -3
  22. package/mcp-memory-server/dist/work-evidence-schema.js +1 -1
  23. package/mcp-memory-server/dist/work-evidence-store.js +1 -1
  24. package/mcp-memory-server/package.json +5 -3
  25. package/omp/extensions/cairnkeep-capture.ts +226 -0
  26. package/omp/extensions/cairnkeep-memory.ts +288 -0
  27. package/package.json +77 -76
  28. package/schemas/artifact.schema.json +1 -1
  29. package/schemas/cairnkeep-setup-policy.schema.json +3 -3
  30. package/schemas/cairnkeep-setup.schema.json +1 -1
  31. package/scripts/cairn-cli.mjs +2 -0
  32. package/scripts/completion.sh +8 -3
  33. package/scripts/harness-registry.mjs +15 -3
  34. package/scripts/lib/omp-capture-stage.mjs +103 -0
  35. package/scripts/setup.mjs +10 -5
  36. package/scripts/sync-omp-assets.sh +96 -0
  37. package/scripts/test-bootstrap-untracked.sh +1 -1
  38. package/scripts/test-cli-dispatch.sh +2 -0
  39. package/scripts/test-completion.sh +4 -2
  40. package/scripts/test-harness-registry.sh +28 -1
  41. package/scripts/test-setup-output.sh +20 -1
  42. package/scripts/test-setup-preflight.mjs +1 -1
  43. package/scripts/test-work-evidence-cli.sh +1 -1
  44. package/scripts/uninstall.sh +1 -0
  45. package/scripts/verify-omp-capture.mjs +370 -0
  46. package/scripts/verify-omp-mcp-bridge.mjs +333 -0
  47. package/templates/start-omp.sh.template +47 -0
@@ -0,0 +1,226 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { spawn } from "node:child_process";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ // omp port of the claude/hooks memory-capture + memory-wakeup pair (D-03/D-04,
7
+ // OCP-01/OCP-05 contracts).
8
+ //
9
+ // CAPTURE: on the first `session_stop` of a main session (the event never
10
+ // fires for task/subagent sessions and is awaited while the process is fully
11
+ // alive), the session branch is converted to transcript text and piped to a
12
+ // DETACHED, unref'd `node scripts/lib/omp-capture-stage.mjs` child that runs
13
+ // the shared `extract` subcommand and stages candidates under
14
+ // .planning/memory-staging/ — so staging survives even if the agent process
15
+ // exits before the extraction call returns. Extraction candidates are staged
16
+ // for review only; writes to AgentFS stay agent-gated via the memory_* tools
17
+ // from cairnkeep-memory.ts.
18
+ //
19
+ // WAKEUP: on `session_start` of the main session (and only when the extension
20
+ // runtime is initialized — omp's title-generation ephemeral agents rebind
21
+ // extensions with uninitialized action stubs), staged candidates are surfaced
22
+ // via a UI notification and a `deliverAs: "nextTurn"` context message injected
23
+ // at the next real user turn. `/cairn-staged` lists staged files on demand.
24
+ //
25
+ // Fail-open everywhere: every guard is a cheap check and every path is
26
+ // try/caught so a throw can never reach the extension runner.
27
+ const CAIRN_ROOT = "@@INFRA_ROOT@@";
28
+ const SERVER_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "index.js");
29
+ const STAGE_ENTRY = join(CAIRN_ROOT, "scripts", "lib", "omp-capture-stage.mjs");
30
+
31
+ const MAX_TEXT_CHARS = 12000;
32
+ const STAGED_MAX_FILES = 5;
33
+ const STAGED_FILE_READ_CAP = 8192;
34
+ const STAGED_CUSTOM_TYPE = "sh.cairnkeep.staged-memory";
35
+
36
+ // Minimal structural view of omp's persisted SessionEntry (entry.type
37
+ // "message", camelCase role per docs/extensions.md); anything else is skipped.
38
+ type BranchEntry = {
39
+ type?: string;
40
+ message?: {
41
+ role?: string;
42
+ content?: unknown;
43
+ };
44
+ };
45
+
46
+ type SessionStopLike = {
47
+ type: "session_stop";
48
+ session_id?: string;
49
+ };
50
+
51
+ function transcriptText(branch: BranchEntry[]): string {
52
+ const turns: string[] = [];
53
+ for (const entry of branch) {
54
+ if (entry?.type !== "message") continue;
55
+ const role = entry.message?.role;
56
+ if (role !== "user" && role !== "assistant") continue;
57
+ if (!Array.isArray(entry.message?.content)) continue;
58
+ const text = (entry.message.content as Array<{ type?: string; text?: unknown }>)
59
+ .filter((block) => block && block.type === "text" && typeof block.text === "string")
60
+ .map((block) => block.text as string)
61
+ .join("\n")
62
+ .trim();
63
+ if (!text) continue;
64
+ turns.push(`${role === "user" ? "USER" : "ASSISTANT"}: ${text}`);
65
+ }
66
+ let out = turns.join("\n\n");
67
+ if (out.length > MAX_TEXT_CHARS) out = out.slice(-MAX_TEXT_CHARS);
68
+ return out;
69
+ }
70
+
71
+ // Same detection as cairnkeep-memory.ts: omp rebinds extensions to ephemeral
72
+ // side-turn runtimes whose action methods are uninitialized throwing stubs.
73
+ function runtimeUninitialized(pi: ExtensionAPI): boolean {
74
+ try {
75
+ pi.getAllTools();
76
+ return false;
77
+ } catch (error) {
78
+ if (error instanceof Error && error.message.includes("Extension runtime not initialized")) return true;
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ type StagedCandidate = {
84
+ file: string;
85
+ count: number;
86
+ };
87
+
88
+ // Bounded staged-candidate scan: the STAGED_MAX_FILES most recent *.json
89
+ // files, each read capped, malformed JSON skipped (fail-open).
90
+ function stagedCandidates(repo: string): StagedCandidate[] {
91
+ const stagingDir = join(repo, ".planning", "memory-staging");
92
+ if (!existsSync(stagingDir)) return [];
93
+ const files = readdirSync(stagingDir)
94
+ .filter((name) => name.endsWith(".json"))
95
+ .map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
96
+ .sort((a, b) => b.mtime - a.mtime)
97
+ .slice(0, STAGED_MAX_FILES)
98
+ .map(({ name }) => name);
99
+ const staged: StagedCandidate[] = [];
100
+ for (const file of files) {
101
+ try {
102
+ const path = join(stagingDir, file);
103
+ if (statSync(path).size > STAGED_FILE_READ_CAP) continue;
104
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
105
+ const count = Array.isArray((parsed as { candidates?: unknown })?.candidates)
106
+ ? ((parsed as { candidates: unknown[] }).candidates.length)
107
+ : 0;
108
+ if (count > 0) staged.push({ file, count });
109
+ } catch {
110
+ // Malformed staged file — skip it, never wedge session start.
111
+ }
112
+ }
113
+ return staged;
114
+ }
115
+
116
+ export default function cairnCaptureExtension(pi: ExtensionAPI): void {
117
+ const capturedSessions = new Set<string>();
118
+
119
+ pi.on("session_stop", async (event: SessionStopLike, ctx: ExtensionContext) => {
120
+ try {
121
+ if (ctx.agent?.kind !== "main") return;
122
+ const sessionId = typeof event?.session_id === "string" ? event.session_id : "";
123
+ // One capture attempt per session, no matter how many times
124
+ // session_stop fires for it (continuation cap).
125
+ if (!sessionId || capturedSessions.has(sessionId)) return;
126
+
127
+ const repo = ctx.cwd;
128
+ // Cheap guards first, mirroring the claude hook's ordering.
129
+ if (!existsSync(join(repo, ".agentfs", "project.db"))) return;
130
+ if (!existsSync(SERVER_ENTRY)) return;
131
+ if (!existsSync(STAGE_ENTRY)) return;
132
+ const apiKey = process.env.CAIRN_LLM_API_KEY;
133
+ const model = process.env.CAIRN_LLM_EXTRACTION_MODEL;
134
+ if (!apiKey || !model) return;
135
+
136
+ // Mark before the async boundary so a re-fire never double-stages.
137
+ capturedSessions.add(sessionId);
138
+
139
+ let text = "";
140
+ try {
141
+ text = transcriptText(ctx.sessionManager.getBranch() as BranchEntry[]);
142
+ } catch {
143
+ return;
144
+ }
145
+ if (!text) return;
146
+
147
+ // Detached + unref'd: the staging child outlives the agent process,
148
+ // so the result lands even if omp exits right after settle.
149
+ const child = spawn(
150
+ process.execPath,
151
+ [STAGE_ENTRY, repo, SERVER_ENTRY, model],
152
+ { detached: true, stdio: ["pipe", "ignore", "ignore"] },
153
+ );
154
+ child.on("error", () => undefined);
155
+ child.stdin.on("error", () => undefined);
156
+ child.stdin.write(text);
157
+ child.stdin.end();
158
+ child.unref();
159
+ } catch {
160
+ // Fail open — never block session settle because capture failed.
161
+ }
162
+ });
163
+
164
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
165
+ try {
166
+ if (ctx.agent?.kind !== "main") return;
167
+ if (runtimeUninitialized(pi)) return;
168
+ const staged = stagedCandidates(ctx.cwd);
169
+ if (staged.length === 0) return;
170
+
171
+ const notice =
172
+ `${staged.length} memory candidate file(s) staged for review in ` +
173
+ `.planning/memory-staging/ (${staged.reduce((total, { count }) => total + count, 0)} candidate(s) from ` +
174
+ "the previous session's transcript). Review them and write accepted ones with the memory_write tool " +
175
+ "(agent-gated), then remove or rename the staged file. Do not write to AgentFS without that review.";
176
+ if (ctx.hasUI) ctx.ui.notify(notice, "info");
177
+ pi.sendMessage(
178
+ {
179
+ customType: STAGED_CUSTOM_TYPE,
180
+ content: notice,
181
+ attribution: "agent",
182
+ display: false,
183
+ },
184
+ { deliverAs: "nextTurn" },
185
+ );
186
+ } catch {
187
+ // Fail open — never block session start because wakeup failed.
188
+ }
189
+ });
190
+
191
+ pi.registerCommand("cairn-staged", {
192
+ description: "List staged memory candidates awaiting review",
193
+ handler: async (_args, ctx: ExtensionContext) => {
194
+ try {
195
+ const stagingDir = join(ctx.cwd, ".planning", "memory-staging");
196
+ if (!existsSync(stagingDir)) {
197
+ if (ctx.hasUI) ctx.ui.notify("No staged memory candidates (.planning/memory-staging is absent).", "info");
198
+ return;
199
+ }
200
+ const files = readdirSync(stagingDir)
201
+ .filter((name) => name.endsWith(".json"))
202
+ .map((name) => ({ name, mtime: statSync(join(stagingDir, name)).mtimeMs }))
203
+ .sort((a, b) => b.mtime - a.mtime)
204
+ .slice(0, STAGED_MAX_FILES)
205
+ .map(({ name }) => name);
206
+ if (files.length === 0) {
207
+ if (ctx.hasUI) ctx.ui.notify("No staged memory candidates.", "info");
208
+ return;
209
+ }
210
+ const sections = files.map((file) => {
211
+ let body: string;
212
+ try {
213
+ body = readFileSync(join(stagingDir, file), "utf8").slice(0, STAGED_FILE_READ_CAP);
214
+ } catch {
215
+ body = "(unreadable)";
216
+ }
217
+ return `### ${file}\n${body}`;
218
+ });
219
+ const listing = `Staged memory candidates — review, then accept via memory_write or discard.\n\n${sections.join("\n\n")}`;
220
+ if (ctx.hasUI) ctx.ui.notify(listing, "info");
221
+ } catch {
222
+ // Fail open — command inspection is best-effort.
223
+ }
224
+ },
225
+ });
226
+ }
@@ -0,0 +1,288 @@
1
+ import type { AgentToolResult, ExtensionAPI, ExtensionContext, ToolDefinition } from "@oh-my-pi/pi-coding-agent";
2
+ import { spawn, type ChildProcess } from "node:child_process";
3
+ import { appendFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ // The bridge runs as a node child process because compiled Bun standalone
7
+ // binaries cannot resolve node_modules for runtime-imported files.
8
+ const CAIRN_ROOT = "@@INFRA_ROOT@@";
9
+ const CHILD_ENTRY = join(CAIRN_ROOT, "mcp-memory-server", "dist", "pi-bridge-child.js");
10
+
11
+ const STARTUP_TIMEOUT_MS = 10_000;
12
+ const CALL_TIMEOUT_MS = 30_000;
13
+ const STDERR_LIMIT_BYTES = 16 * 1024;
14
+ const SHUTDOWN_GRACE_MS = 3_000;
15
+
16
+ type CatalogTool = {
17
+ name: string;
18
+ title?: string;
19
+ description?: string;
20
+ inputSchema: Record<string, unknown>;
21
+ };
22
+
23
+ type ChildResult =
24
+ | { type: "ready" }
25
+ | { type: "result"; id: number; ok: true; tools?: CatalogTool[]; result?: unknown }
26
+ | { type: "result"; id: number; ok: false; error: string; stderr?: string };
27
+
28
+ type PendingRequest = {
29
+ resolve: (value: unknown) => void;
30
+ reject: (error: Error) => void;
31
+ timer: ReturnType<typeof setTimeout>;
32
+ };
33
+
34
+ // Minimal line-delimited JSON client for pi-bridge-child.js: requests carry
35
+ // an id, results route back by id, and an unexpected child exit rejects
36
+ // everything pending with the captured stderr tail.
37
+ class BridgeChildClient {
38
+ #child: ChildProcess;
39
+ #nextId = 1;
40
+ #stdoutBuffer = "";
41
+ #stderrTail = "";
42
+ #pending = new Map<number, PendingRequest>();
43
+ #readyPending: PendingRequest | undefined;
44
+ #exitPromise: Promise<void>;
45
+ #closed = false;
46
+
47
+ private constructor(child: ChildProcess) {
48
+ this.#child = child;
49
+ this.#exitPromise = new Promise((resolvePromise) => {
50
+ child.once("exit", () => resolvePromise());
51
+ child.once("error", () => resolvePromise());
52
+ });
53
+ child.stdout?.on("data", (chunk: Buffer | string) => this.#onStdout(chunk));
54
+ child.stderr?.on("data", (chunk: Buffer | string) => this.#appendStderr(chunk));
55
+ child.on("error", () => this.#failAll(new Error("Cairnkeep bridge child failed to start.")));
56
+ child.once("exit", (code, signal) => {
57
+ const detail = this.#stderrTail.trim();
58
+ const suffix = detail ? ` (child stderr: ${detail})` : "";
59
+ this.#failAll(new Error(`Cairnkeep bridge child exited (${signal ?? code}).${suffix}`));
60
+ this.#readyPending?.reject(new Error(`Cairnkeep bridge child exited before ready (${signal ?? code}).${suffix}`));
61
+ this.#readyPending = undefined;
62
+ });
63
+ const pidLog = process.env.CAIRN_BRIDGE_CHILD_PID_LOG;
64
+ if (pidLog && child.pid) appendFileSync(pidLog, `${child.pid}\n`);
65
+ }
66
+
67
+ static spawn(cwd: string): BridgeChildClient {
68
+ const child = spawn("node", [CHILD_ENTRY, cwd], { stdio: ["pipe", "pipe", "pipe"] });
69
+ child.stdin.on("error", () => undefined);
70
+ return new BridgeChildClient(child);
71
+ }
72
+
73
+ ready(): Promise<void> {
74
+ return new Promise((resolvePromise, rejectPromise) => {
75
+ const timer = setTimeout(() => {
76
+ this.#readyPending = undefined;
77
+ rejectPromise(new Error("Cairnkeep bridge child timed out waiting for ready."));
78
+ }, STARTUP_TIMEOUT_MS);
79
+ this.#readyPending = {
80
+ resolve: () => {
81
+ clearTimeout(timer);
82
+ resolvePromise();
83
+ },
84
+ reject: (error) => {
85
+ clearTimeout(timer);
86
+ rejectPromise(error);
87
+ },
88
+ timer,
89
+ };
90
+ });
91
+ }
92
+
93
+ request(message: Omit<{ type: "list" | "close" }, "id"> | { type: "call"; tool: unknown; args: Record<string, unknown> }, timeoutMs: number): Promise<unknown> {
94
+ const id = this.#nextId++;
95
+ return new Promise((resolvePromise, rejectPromise) => {
96
+ const timer = setTimeout(() => {
97
+ this.#pending.delete(id);
98
+ rejectPromise(new Error(`Cairnkeep bridge child request ${message.type} timed out after ${timeoutMs}ms.`));
99
+ }, timeoutMs);
100
+ this.#pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer });
101
+ this.#write({ ...message, id });
102
+ });
103
+ }
104
+
105
+ // One MCP tool call: 30s backstop timer plus cancellation forwarded to the
106
+ // child as a protocol-level cancel.
107
+ call(tool: unknown, args: Record<string, unknown>, signal: AbortSignal | undefined): Promise<unknown> {
108
+ const id = this.#nextId++;
109
+ return new Promise((resolvePromise, rejectPromise) => {
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ const cancel = (): void => {
112
+ if (timer) clearTimeout(timer);
113
+ this.#pending.delete(id);
114
+ this.#write({ type: "cancel", id });
115
+ rejectPromise(new Error("cancelled"));
116
+ };
117
+ timer = setTimeout(() => {
118
+ this.#pending.delete(id);
119
+ this.#write({ type: "cancel", id });
120
+ rejectPromise(new Error(`Cairnkeep MCP call timed out after ${CALL_TIMEOUT_MS}ms.`));
121
+ }, CALL_TIMEOUT_MS);
122
+ this.#pending.set(id, { resolve: resolvePromise, reject: rejectPromise, timer });
123
+ if (signal) {
124
+ if (signal.aborted) {
125
+ cancel();
126
+ return;
127
+ }
128
+ signal.addEventListener("abort", cancel, { once: true });
129
+ }
130
+ this.#write({ type: "call", id, tool, args });
131
+ });
132
+ }
133
+
134
+ async close(): Promise<void> {
135
+ if (this.#closed) return;
136
+ this.#closed = true;
137
+ try {
138
+ this.#write({ type: "close" });
139
+ } catch {
140
+ // Child already gone — the exit handler rejected everything pending.
141
+ }
142
+ const grace = setTimeout(() => {
143
+ try {
144
+ this.#child.kill("SIGKILL");
145
+ } catch {
146
+ // best-effort kill only
147
+ }
148
+ }, SHUTDOWN_GRACE_MS);
149
+ await this.#exitPromise;
150
+ clearTimeout(grace);
151
+ }
152
+
153
+ #write(message: unknown): void {
154
+ this.#child.stdin.write(`${JSON.stringify(message)}\n`);
155
+ }
156
+
157
+ #appendStderr(chunk: Buffer | string): void {
158
+ const next = this.#stderrTail + (Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk);
159
+ this.#stderrTail = next.length <= STDERR_LIMIT_BYTES ? next : next.slice(next.length - STDERR_LIMIT_BYTES);
160
+ }
161
+
162
+ #onStdout(chunk: Buffer | string): void {
163
+ this.#stdoutBuffer += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : chunk;
164
+ let newline = this.#stdoutBuffer.indexOf("\n");
165
+ while (newline >= 0) {
166
+ const line = this.#stdoutBuffer.slice(0, newline);
167
+ this.#stdoutBuffer = this.#stdoutBuffer.slice(newline + 1);
168
+ newline = this.#stdoutBuffer.indexOf("\n");
169
+ if (!line.trim()) continue;
170
+ let message: ChildResult;
171
+ try {
172
+ message = JSON.parse(line) as ChildResult;
173
+ } catch {
174
+ continue;
175
+ }
176
+ if (message.type === "ready") {
177
+ this.#readyPending?.resolve(undefined);
178
+ this.#readyPending = undefined;
179
+ continue;
180
+ }
181
+ if (message.type !== "result") continue;
182
+ const pending = this.#pending.get(message.id);
183
+ if (!pending) continue;
184
+ this.#pending.delete(message.id);
185
+ clearTimeout(pending.timer);
186
+ if (message.ok) pending.resolve(message.tools ?? message.result);
187
+ else {
188
+ const detail = typeof message.stderr === "string" && message.stderr.trim() ? ` (child stderr: ${message.stderr.trim()})` : "";
189
+ pending.reject(new Error(`${message.error}${detail}`));
190
+ }
191
+ }
192
+ }
193
+
194
+ #failAll(error: Error): void {
195
+ for (const pending of this.#pending.values()) {
196
+ clearTimeout(pending.timer);
197
+ pending.reject(error);
198
+ }
199
+ this.#pending.clear();
200
+ }
201
+ }
202
+
203
+ // AgentToolResult.content accepts text and base64 image blocks; the bridge
204
+ // child already reduces MCP content to exactly those two shapes.
205
+ function toAgentToolResult(result: unknown): AgentToolResult {
206
+ const { content, details } = result as {
207
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
208
+ details?: unknown;
209
+ };
210
+ return {
211
+ content: content.map((item) =>
212
+ item.type === "text"
213
+ ? { type: "text" as const, text: item.text ?? "" }
214
+ : { type: "image" as const, data: item.data ?? "", mimeType: item.mimeType ?? "" },
215
+ ),
216
+ details,
217
+ };
218
+ }
219
+
220
+ // omp rebinds extensions to ephemeral side-turn runtimes (e.g. title
221
+ // generation) whose action methods are uninitialized stubs that throw; bridge
222
+ // startup is skipped there and the real session starts it later.
223
+ function runtimeUninitialized(pi: ExtensionAPI): boolean {
224
+ try {
225
+ pi.getAllTools();
226
+ return false;
227
+ } catch (error) {
228
+ if (error instanceof Error && error.message.includes("Extension runtime not initialized")) return true;
229
+ throw error;
230
+ }
231
+ }
232
+
233
+ function existingToolNames(pi: ExtensionAPI): Set<string> {
234
+ return new Set(pi.getAllTools().map((tool) => tool.name));
235
+ }
236
+
237
+ export default function cairnMemoryExtension(pi: ExtensionAPI): void {
238
+ let client: BridgeChildClient | undefined;
239
+ let startPromise: Promise<void> | undefined;
240
+ let shutdownPromise: Promise<void> | undefined;
241
+
242
+ const start = async (ctx: ExtensionContext): Promise<void> => {
243
+ const connected = BridgeChildClient.spawn(ctx.cwd);
244
+ try {
245
+ await connected.ready();
246
+ const tools = (await connected.request({ type: "list" }, STARTUP_TIMEOUT_MS)) as CatalogTool[];
247
+ const existing = existingToolNames(pi);
248
+ const collisions = tools.map(({ name }) => name).filter((name) => existing.has(name));
249
+ if (collisions.length) {
250
+ throw new Error(`Cairnkeep MCP tool collision would override an existing omp tool: ${collisions.join(", ")}.`);
251
+ }
252
+ for (const tool of tools) {
253
+ pi.registerTool({
254
+ name: tool.name,
255
+ label: tool.title ?? tool.name,
256
+ description: tool.description ?? tool.title ?? tool.name,
257
+ // omp accepts a plain JSON Schema document (TJsonSchema) for
258
+ // extension-authored tools; MCP inputSchema is already one.
259
+ parameters: tool.inputSchema as ToolDefinition["parameters"],
260
+ strict: false,
261
+ execute: async (_toolCallId, args, signal) =>
262
+ toAgentToolResult(await connected.call(tool, args as Record<string, unknown>, signal)),
263
+ });
264
+ }
265
+ client = connected;
266
+ } catch (error) {
267
+ await connected.close().catch(() => undefined);
268
+ throw error;
269
+ }
270
+ };
271
+
272
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
273
+ if (shutdownPromise) throw new Error("Cairnkeep MCP bridge is already shutting down.");
274
+ if (runtimeUninitialized(pi)) return;
275
+ if (!startPromise) startPromise = start(ctx);
276
+ await startPromise;
277
+ });
278
+
279
+ pi.on("session_shutdown", async () => {
280
+ if (!shutdownPromise) {
281
+ shutdownPromise = (async () => {
282
+ await startPromise?.catch(() => undefined);
283
+ await client?.close();
284
+ })();
285
+ }
286
+ await shutdownPromise;
287
+ });
288
+ }
package/package.json CHANGED
@@ -1,78 +1,79 @@
1
1
  {
2
- "name": "@cairnkeep/cli",
3
- "version": "2.17.3",
4
- "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
- "license": "Apache-2.0",
6
- "type": "module",
7
- "publishConfig": {
8
- "access": "public"
9
- },
10
- "bin": {
11
- "cairn": "bin/cairn",
12
- "cairn-container": "bin/cairn-container"
13
- },
14
- "files": [
15
- "bin/",
16
- "scripts/",
17
- "templates/",
18
- "claude/",
19
- "opencode/",
20
- "pi/",
21
- "kimi/",
22
- "examples/anythingllm/",
23
- "examples/overlay-distribution/",
24
- "examples/eval/",
25
- "containers/",
26
- "schemas/",
27
- "docs/",
28
- "CHANGELOG.md",
29
- "CONTRIBUTING.md",
30
- "SECURITY.md",
31
- "mcp-memory-server/dist/",
32
- "mcp-memory-server/scripts/fixtures/compaction-*.json",
33
- "mcp-memory-server/package.json",
34
- "AGENTS.md"
35
- ],
36
- "engines": {
37
- "node": ">=22"
38
- },
39
- "dependencies": {
40
- "@modelcontextprotocol/sdk": "^1.30.0",
41
- "agentfs-sdk": "^0.6.4",
42
- "yaml": "2.9.0",
43
- "zod": "^4.4.3"
44
- },
45
- "scripts": {
46
- "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
47
- "fix:perms": "node scripts/fix-permissions.mjs",
48
- "prepack": "npm run build:server && npm run fix:perms",
49
- "test": "node scripts/run-repository-tests.mjs",
50
- "test:windows": "node scripts/test-windows-native.mjs",
51
- "audit:runtime": "node scripts/verify-dependency-security.mjs && npm audit --omit=dev --audit-level=high && npm --prefix mcp-memory-server audit --omit=dev --audit-level=high",
52
- "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run build:server && npm test",
53
- "prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server test --global=false --dry-run=false"
54
- },
55
- "repository": {
56
- "type": "git",
57
- "url": "git+https://github.com/cairnkeep/cairnkeep.git"
58
- },
59
- "homepage": "https://github.com/cairnkeep/cairnkeep#readme",
60
- "bugs": {
61
- "url": "https://github.com/cairnkeep/cairnkeep/issues"
62
- },
63
- "keywords": [
64
- "mcp",
65
- "memory",
66
- "agent",
67
- "claude-code",
68
- "codex-cli",
69
- "opencode",
70
- "qwen-code",
71
- "pi-coding-agent",
72
- "context",
73
- "rag"
74
- ],
75
- "devDependencies": {
76
- "@cyclonedx/cyclonedx-npm": "6.0.1"
77
- }
2
+ "name": "@cairnkeep/cli",
3
+ "version": "2.19.0",
4
+ "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "bin": {
11
+ "cairn": "bin/cairn",
12
+ "cairn-container": "bin/cairn-container"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "scripts/",
17
+ "templates/",
18
+ "claude/",
19
+ "opencode/",
20
+ "pi/",
21
+ "kimi/",
22
+ "omp/",
23
+ "examples/anythingllm/",
24
+ "examples/overlay-distribution/",
25
+ "examples/eval/",
26
+ "containers/",
27
+ "schemas/",
28
+ "docs/",
29
+ "CHANGELOG.md",
30
+ "CONTRIBUTING.md",
31
+ "SECURITY.md",
32
+ "mcp-memory-server/dist/",
33
+ "mcp-memory-server/scripts/fixtures/compaction-*.json",
34
+ "mcp-memory-server/package.json",
35
+ "AGENTS.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=22"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.30.0",
42
+ "agentfs-sdk": "^0.6.4",
43
+ "yaml": "2.9.0",
44
+ "zod": "^4.4.3"
45
+ },
46
+ "scripts": {
47
+ "build:server": "npm --prefix mcp-memory-server ci --include=dev --global=false --dry-run=false && npm --prefix mcp-memory-server run build --global=false --dry-run=false",
48
+ "fix:perms": "node scripts/fix-permissions.mjs",
49
+ "prepack": "npm run build:server && npm run fix:perms",
50
+ "test": "node scripts/run-repository-tests.mjs",
51
+ "test:windows": "node scripts/test-windows-native.mjs",
52
+ "audit:runtime": "node scripts/verify-dependency-security.mjs && npm audit --omit=dev --audit-level=high && npm --prefix mcp-memory-server audit --omit=dev --audit-level=high",
53
+ "check:public": "scripts/verify-no-private-references.sh && scripts/verify-docs-parity.sh && npm run audit:runtime && npm run build:server && npm test",
54
+ "prepublishOnly": "npm run check:public && npm --prefix mcp-memory-server test --global=false --dry-run=false"
55
+ },
56
+ "repository": {
57
+ "type": "git",
58
+ "url": "git+https://github.com/cairnkeep/cairnkeep.git"
59
+ },
60
+ "homepage": "https://github.com/cairnkeep/cairnkeep#readme",
61
+ "bugs": {
62
+ "url": "https://github.com/cairnkeep/cairnkeep/issues"
63
+ },
64
+ "keywords": [
65
+ "mcp",
66
+ "memory",
67
+ "agent",
68
+ "claude-code",
69
+ "codex-cli",
70
+ "opencode",
71
+ "qwen-code",
72
+ "pi-coding-agent",
73
+ "context",
74
+ "rag"
75
+ ],
76
+ "devDependencies": {
77
+ "@cyclonedx/cyclonedx-npm": "6.0.1"
78
+ }
78
79
  }