context-mode 1.0.142 → 1.0.144
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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/build/adapters/pi/mcp-bridge.js +36 -12
- package/build/db-base.js +12 -1
- package/build/openclaw-plugin.d.ts +130 -0
- package/build/openclaw-plugin.js +626 -0
- package/build/opencode-plugin.d.ts +122 -0
- package/build/opencode-plugin.js +372 -0
- package/build/pi-extension.d.ts +14 -0
- package/build/pi-extension.js +451 -0
- package/build/server.d.ts +35 -0
- package/build/server.js +49 -1
- package/build/util/db-lock.d.ts +65 -0
- package/build/util/db-lock.js +166 -0
- package/cli.bundle.mjs +104 -104
- package/hooks/pretooluse.mjs +9 -1
- package/hooks/session-db.bundle.mjs +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server.bundle.mjs +80 -80
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pi coding agent extension for context-mode.
|
|
3
|
+
*
|
|
4
|
+
* Follows the OpenClaw adapter pattern: imports shared session modules,
|
|
5
|
+
* registers Pi-specific hooks. NO copy-paste of session logic.
|
|
6
|
+
* NO external npm dependencies beyond what Pi runtime provides.
|
|
7
|
+
*
|
|
8
|
+
* Entry point: `export default function(pi: ExtensionAPI) { ... }`
|
|
9
|
+
*
|
|
10
|
+
* Lifecycle: session_start, tool_call, tool_result, before_agent_start,
|
|
11
|
+
* session_before_compact, session_compact, session_shutdown.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { join, resolve, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
|
+
import { SessionDB } from "./session/db.js";
|
|
19
|
+
import { extractEvents, extractUserEvents } from "./session/extract.js";
|
|
20
|
+
import { buildResumeSnapshot } from "./session/snapshot.js";
|
|
21
|
+
// ── Pi Tool Name Mapping ─────────────────────────────────
|
|
22
|
+
// Pi uses lowercase; shared extractors expect PascalCase (Claude Code convention).
|
|
23
|
+
const PI_TOOL_MAP = {
|
|
24
|
+
bash: "Bash",
|
|
25
|
+
read: "Read",
|
|
26
|
+
write: "Write",
|
|
27
|
+
edit: "Edit",
|
|
28
|
+
grep: "Grep",
|
|
29
|
+
find: "Glob",
|
|
30
|
+
ls: "Glob",
|
|
31
|
+
};
|
|
32
|
+
// ── Routing patterns ─────────────────────────────────────
|
|
33
|
+
// Inline HTTP client patterns to block in bash — self-contained, no routing module needed.
|
|
34
|
+
const BLOCKED_BASH_PATTERNS = [
|
|
35
|
+
/\bcurl\s/,
|
|
36
|
+
/\bwget\s/,
|
|
37
|
+
/\bfetch\s*\(/,
|
|
38
|
+
/\brequests\.get\s*\(/,
|
|
39
|
+
/\brequests\.post\s*\(/,
|
|
40
|
+
/\bhttp\.get\s*\(/,
|
|
41
|
+
/\bhttp\.request\s*\(/,
|
|
42
|
+
/\burllib\.request/,
|
|
43
|
+
/\bInvoke-WebRequest\b/,
|
|
44
|
+
];
|
|
45
|
+
// ── Module-level DB singleton ────────────────────────────
|
|
46
|
+
let _db = null;
|
|
47
|
+
let _sessionId = "";
|
|
48
|
+
// Per-session gate: routing block injected at most once per session_id.
|
|
49
|
+
const _routingInjected = new Set();
|
|
50
|
+
// Cached routing-block string (built once per process from hooks/routing-block.mjs).
|
|
51
|
+
let _routingBlock = null;
|
|
52
|
+
async function getRoutingBlock(pluginRoot) {
|
|
53
|
+
if (_routingBlock !== null)
|
|
54
|
+
return _routingBlock;
|
|
55
|
+
try {
|
|
56
|
+
const routingMod = await import(pathToFileURL(join(pluginRoot, "hooks", "routing-block.mjs")).href);
|
|
57
|
+
const namingMod = await import(pathToFileURL(join(pluginRoot, "hooks", "core", "tool-naming.mjs")).href);
|
|
58
|
+
const t = namingMod.createToolNamer("pi");
|
|
59
|
+
_routingBlock = String(routingMod.createRoutingBlock(t));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
_routingBlock = "";
|
|
63
|
+
}
|
|
64
|
+
return _routingBlock;
|
|
65
|
+
}
|
|
66
|
+
// Cached buildAutoInjection (500-token cap, prioritized).
|
|
67
|
+
let _buildAutoInjection = undefined;
|
|
68
|
+
async function getAutoInjection(pluginRoot) {
|
|
69
|
+
if (_buildAutoInjection !== undefined)
|
|
70
|
+
return _buildAutoInjection;
|
|
71
|
+
try {
|
|
72
|
+
const mod = await import(pathToFileURL(join(pluginRoot, "hooks", "auto-injection.mjs")).href);
|
|
73
|
+
_buildAutoInjection = mod.buildAutoInjection;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
_buildAutoInjection = null;
|
|
77
|
+
}
|
|
78
|
+
return _buildAutoInjection ?? null;
|
|
79
|
+
}
|
|
80
|
+
// ── Helpers ──────────────────────────────────────────────
|
|
81
|
+
function getSessionDir() {
|
|
82
|
+
const dir = join(homedir(), ".pi", "context-mode", "sessions");
|
|
83
|
+
mkdirSync(dir, { recursive: true });
|
|
84
|
+
return dir;
|
|
85
|
+
}
|
|
86
|
+
function getDBPath() {
|
|
87
|
+
return join(getSessionDir(), "context-mode.db");
|
|
88
|
+
}
|
|
89
|
+
function getOrCreateDB() {
|
|
90
|
+
if (!_db) {
|
|
91
|
+
_db = new SessionDB({ dbPath: getDBPath() });
|
|
92
|
+
}
|
|
93
|
+
return _db;
|
|
94
|
+
}
|
|
95
|
+
/** Derive a stable session ID from Pi's session file path (SHA256, 16 hex chars). */
|
|
96
|
+
function deriveSessionId(ctx) {
|
|
97
|
+
try {
|
|
98
|
+
const sessionManager = ctx.sessionManager;
|
|
99
|
+
const sessionFile = sessionManager?.getSessionFile?.();
|
|
100
|
+
if (sessionFile && typeof sessionFile === "string") {
|
|
101
|
+
return createHash("sha256").update(sessionFile).digest("hex").slice(0, 16);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// best effort
|
|
106
|
+
}
|
|
107
|
+
return `pi-${Date.now()}`;
|
|
108
|
+
}
|
|
109
|
+
/** Build stats text for the /ctx-stats command. */
|
|
110
|
+
function buildStatsText(db, sessionId) {
|
|
111
|
+
try {
|
|
112
|
+
const events = db.getEvents(sessionId);
|
|
113
|
+
const stats = db.getSessionStats(sessionId);
|
|
114
|
+
const lines = [
|
|
115
|
+
"## context-mode stats (Pi)",
|
|
116
|
+
"",
|
|
117
|
+
`- Session: \`${sessionId.slice(0, 8)}...\``,
|
|
118
|
+
`- Events captured: ${events.length}`,
|
|
119
|
+
`- Compactions: ${stats?.compact_count ?? 0}`,
|
|
120
|
+
];
|
|
121
|
+
// Event breakdown by category
|
|
122
|
+
const byCategory = {};
|
|
123
|
+
for (const ev of events) {
|
|
124
|
+
const key = ev.category ?? "unknown";
|
|
125
|
+
byCategory[key] = (byCategory[key] ?? 0) + 1;
|
|
126
|
+
}
|
|
127
|
+
if (Object.keys(byCategory).length > 0) {
|
|
128
|
+
lines.push("- Event breakdown:");
|
|
129
|
+
for (const [category, count] of Object.entries(byCategory)) {
|
|
130
|
+
lines.push(` - ${category}: ${count}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Session age
|
|
134
|
+
if (stats?.started_at) {
|
|
135
|
+
const startedMs = new Date(stats.started_at).getTime();
|
|
136
|
+
const ageMinutes = Math.round((Date.now() - startedMs) / 60_000);
|
|
137
|
+
lines.push(`- Session age: ${ageMinutes}m`);
|
|
138
|
+
}
|
|
139
|
+
return lines.join("\n");
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return "context-mode stats unavailable (session DB error)";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function resolveCommandContext(argsOrCtx, ctx) {
|
|
146
|
+
if (ctx !== undefined)
|
|
147
|
+
return ctx;
|
|
148
|
+
if (argsOrCtx && typeof argsOrCtx === "object")
|
|
149
|
+
return argsOrCtx;
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
function handleCommandText(text, ctx) {
|
|
153
|
+
if (ctx?.hasUI) {
|
|
154
|
+
ctx.ui.notify(text, "info");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
return { text };
|
|
158
|
+
}
|
|
159
|
+
// ── Extension entry point ────────────────────────────────
|
|
160
|
+
/** Pi extension default export. Called once by Pi runtime with the extension API. */
|
|
161
|
+
export default function piExtension(pi) {
|
|
162
|
+
const buildDir = dirname(fileURLToPath(import.meta.url));
|
|
163
|
+
const pluginRoot = resolve(buildDir, "..");
|
|
164
|
+
const projectDir = process.env.PI_PROJECT_DIR || process.cwd();
|
|
165
|
+
const db = getOrCreateDB();
|
|
166
|
+
// ── 1. session_start — Initialize session ──────────────
|
|
167
|
+
pi.on("session_start", (ctx) => {
|
|
168
|
+
try {
|
|
169
|
+
_sessionId = deriveSessionId(ctx ?? {});
|
|
170
|
+
db.ensureSession(_sessionId, projectDir);
|
|
171
|
+
db.cleanupOldSessions(7);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// best effort — never break session start
|
|
175
|
+
if (!_sessionId) {
|
|
176
|
+
_sessionId = `pi-${Date.now()}`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
// ── 2. tool_call — PreToolUse routing enforcement ──────
|
|
181
|
+
// Block bash commands that contain curl/wget/fetch/requests patterns.
|
|
182
|
+
pi.on("tool_call", (event) => {
|
|
183
|
+
try {
|
|
184
|
+
const toolName = String(event?.toolName ?? "").toLowerCase();
|
|
185
|
+
if (toolName !== "bash")
|
|
186
|
+
return;
|
|
187
|
+
const command = String(event?.input?.command ?? "");
|
|
188
|
+
if (!command)
|
|
189
|
+
return;
|
|
190
|
+
const isBlocked = BLOCKED_BASH_PATTERNS.some((p) => p.test(command));
|
|
191
|
+
if (isBlocked) {
|
|
192
|
+
return {
|
|
193
|
+
block: true,
|
|
194
|
+
reason: "Use context-mode MCP tools (execute, fetch_and_index) instead of inline HTTP clients. " +
|
|
195
|
+
"Raw curl/wget/fetch output floods the context window.",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Routing failure — allow passthrough
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
// ── 3. tool_result — PostToolUse event capture ─────────
|
|
204
|
+
pi.on("tool_result", (event) => {
|
|
205
|
+
try {
|
|
206
|
+
if (!_sessionId)
|
|
207
|
+
return;
|
|
208
|
+
const rawToolName = String(event?.toolName ?? event?.tool_name ?? "");
|
|
209
|
+
const mappedToolName = PI_TOOL_MAP[rawToolName.toLowerCase()] ?? rawToolName;
|
|
210
|
+
// Normalize result to string
|
|
211
|
+
const rawResult = event?.result ?? event?.output;
|
|
212
|
+
const resultStr = typeof rawResult === "string"
|
|
213
|
+
? rawResult
|
|
214
|
+
: rawResult != null
|
|
215
|
+
? JSON.stringify(rawResult)
|
|
216
|
+
: undefined;
|
|
217
|
+
// Detect errors
|
|
218
|
+
const hasError = Boolean(event?.error || event?.isError);
|
|
219
|
+
const hookInput = {
|
|
220
|
+
tool_name: mappedToolName,
|
|
221
|
+
tool_input: event?.params ?? event?.input ?? {},
|
|
222
|
+
tool_response: resultStr,
|
|
223
|
+
tool_output: hasError ? { isError: true } : undefined,
|
|
224
|
+
};
|
|
225
|
+
const events = extractEvents(hookInput);
|
|
226
|
+
if (events.length > 0) {
|
|
227
|
+
for (const ev of events) {
|
|
228
|
+
db.insertEvent(_sessionId, ev, "PostToolUse");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
else if (rawToolName) {
|
|
232
|
+
// Fallback: record unrecognized tool call as generic event
|
|
233
|
+
const data = JSON.stringify({
|
|
234
|
+
tool: rawToolName,
|
|
235
|
+
params: event?.params ?? event?.input,
|
|
236
|
+
});
|
|
237
|
+
db.insertEvent(_sessionId, {
|
|
238
|
+
type: "tool_call",
|
|
239
|
+
category: "pi",
|
|
240
|
+
data,
|
|
241
|
+
priority: 1,
|
|
242
|
+
data_hash: createHash("sha256")
|
|
243
|
+
.update(data)
|
|
244
|
+
.digest("hex")
|
|
245
|
+
.slice(0, 16),
|
|
246
|
+
}, "PostToolUse");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// Silent — session capture must never break the tool call
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
// ── 4. before_agent_start — Routing + active_memory + resume injection ─
|
|
254
|
+
pi.on("before_agent_start", async (event) => {
|
|
255
|
+
try {
|
|
256
|
+
if (!_sessionId)
|
|
257
|
+
return;
|
|
258
|
+
const prompt = String(event?.prompt ?? "");
|
|
259
|
+
// Extract user events from the prompt text
|
|
260
|
+
if (prompt) {
|
|
261
|
+
const userEvents = extractUserEvents(prompt);
|
|
262
|
+
for (const ev of userEvents) {
|
|
263
|
+
db.insertEvent(_sessionId, ev, "UserPromptSubmit");
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const existingPrompt = String(event?.systemPrompt ?? "");
|
|
267
|
+
const parts = [];
|
|
268
|
+
if (existingPrompt)
|
|
269
|
+
parts.push(existingPrompt);
|
|
270
|
+
// Pi-1: Inject routing block once per session (gated by _routingInjected).
|
|
271
|
+
// v1.0.107 — visible marker so Pi users can verify the routing block
|
|
272
|
+
// reached the model (Mickey-class verification path; mirrors OpenCode).
|
|
273
|
+
if (!_routingInjected.has(_sessionId)) {
|
|
274
|
+
const routingBlock = await getRoutingBlock(pluginRoot);
|
|
275
|
+
if (routingBlock) {
|
|
276
|
+
const marker = `<!-- context-mode: routing block injected (sessionID=${String(_sessionId).slice(0, 8)}) -->`;
|
|
277
|
+
parts.push(marker + "\n" + routingBlock);
|
|
278
|
+
_routingInjected.add(_sessionId);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// Pi-3 + Pi-4: Always build active_memory (not just post-compact),
|
|
282
|
+
// capped at 500 tokens via buildAutoInjection. Falls back to inline
|
|
283
|
+
// budget loop if the helper is unavailable.
|
|
284
|
+
const activeEvents = db.getEvents(_sessionId, {
|
|
285
|
+
minPriority: 3,
|
|
286
|
+
limit: 50,
|
|
287
|
+
});
|
|
288
|
+
if (activeEvents.length > 0) {
|
|
289
|
+
const buildAuto = await getAutoInjection(pluginRoot);
|
|
290
|
+
let memoryContext = "";
|
|
291
|
+
if (buildAuto) {
|
|
292
|
+
memoryContext = buildAuto(activeEvents.map((e) => ({
|
|
293
|
+
category: String(e.category ?? ""),
|
|
294
|
+
data: String(e.data ?? ""),
|
|
295
|
+
})));
|
|
296
|
+
}
|
|
297
|
+
// Fallback (or if helper produced empty output): inline 500-token cap.
|
|
298
|
+
if (!memoryContext) {
|
|
299
|
+
const memoryLines = ["<active_memory>"];
|
|
300
|
+
let budget = 2000; // ~500 tokens at 4 chars/token
|
|
301
|
+
for (const ev of activeEvents) {
|
|
302
|
+
const line = ` <event type="${ev.type}" category="${ev.category}">${ev.data}</event>`;
|
|
303
|
+
if (line.length > budget)
|
|
304
|
+
break;
|
|
305
|
+
memoryLines.push(line);
|
|
306
|
+
budget -= line.length;
|
|
307
|
+
}
|
|
308
|
+
memoryLines.push("</active_memory>");
|
|
309
|
+
if (memoryLines.length > 2)
|
|
310
|
+
memoryContext = memoryLines.join("\n");
|
|
311
|
+
}
|
|
312
|
+
if (memoryContext)
|
|
313
|
+
parts.push(memoryContext);
|
|
314
|
+
}
|
|
315
|
+
// Resume snapshot (only when present and unconsumed).
|
|
316
|
+
const resume = db.getResume(_sessionId);
|
|
317
|
+
if (resume && !resume.consumed && resume.snapshot) {
|
|
318
|
+
parts.push(resume.snapshot);
|
|
319
|
+
db.markResumeConsumed(_sessionId);
|
|
320
|
+
}
|
|
321
|
+
// Return modified systemPrompt only if we added something beyond existing.
|
|
322
|
+
const baseLen = existingPrompt ? 1 : 0;
|
|
323
|
+
if (parts.length > baseLen) {
|
|
324
|
+
return { systemPrompt: parts.join("\n\n") };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
// best effort — never break agent start
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
// ── 4b. before_provider_response — capture response metadata ───
|
|
332
|
+
// Pi-2: Register the missing event so providers can record latency,
|
|
333
|
+
// model, and token usage when Pi exposes them. Best-effort only;
|
|
334
|
+
// the handler must never throw or modify the response.
|
|
335
|
+
pi.on("before_provider_response", (event) => {
|
|
336
|
+
try {
|
|
337
|
+
if (!_sessionId)
|
|
338
|
+
return;
|
|
339
|
+
const meta = {
|
|
340
|
+
model: event?.model ?? event?.providerModel,
|
|
341
|
+
provider: event?.provider,
|
|
342
|
+
latencyMs: event?.latencyMs ?? event?.latency,
|
|
343
|
+
tokens: event?.usage ?? event?.tokens,
|
|
344
|
+
};
|
|
345
|
+
// Skip when Pi gives us nothing useful — avoids noise in the DB.
|
|
346
|
+
if (meta.model == null &&
|
|
347
|
+
meta.provider == null &&
|
|
348
|
+
meta.latencyMs == null &&
|
|
349
|
+
meta.tokens == null) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
const data = JSON.stringify(meta);
|
|
353
|
+
db.insertEvent(_sessionId, {
|
|
354
|
+
type: "provider_response",
|
|
355
|
+
category: "pi",
|
|
356
|
+
data,
|
|
357
|
+
priority: 1,
|
|
358
|
+
data_hash: createHash("sha256").update(data).digest("hex").slice(0, 16),
|
|
359
|
+
}, "PostToolUse");
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
// best effort — never break provider response
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
// ── 5. session_before_compact — Build resume snapshot ──
|
|
366
|
+
pi.on("session_before_compact", () => {
|
|
367
|
+
try {
|
|
368
|
+
if (!_sessionId)
|
|
369
|
+
return;
|
|
370
|
+
const allEvents = db.getEvents(_sessionId);
|
|
371
|
+
if (allEvents.length === 0)
|
|
372
|
+
return;
|
|
373
|
+
const stats = db.getSessionStats(_sessionId);
|
|
374
|
+
const snapshot = buildResumeSnapshot(allEvents, {
|
|
375
|
+
compactCount: (stats?.compact_count ?? 0) + 1,
|
|
376
|
+
});
|
|
377
|
+
db.upsertResume(_sessionId, snapshot, allEvents.length);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// best effort — never break compaction
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
// ── 6. session_compact — Increment compact counter ─────
|
|
384
|
+
pi.on("session_compact", () => {
|
|
385
|
+
try {
|
|
386
|
+
if (!_sessionId)
|
|
387
|
+
return;
|
|
388
|
+
db.incrementCompactCount(_sessionId);
|
|
389
|
+
}
|
|
390
|
+
catch {
|
|
391
|
+
// best effort
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
// ── 7. session_shutdown — Cleanup old sessions ─────────
|
|
395
|
+
pi.on("session_shutdown", () => {
|
|
396
|
+
try {
|
|
397
|
+
if (_db) {
|
|
398
|
+
_db.cleanupOldSessions(7);
|
|
399
|
+
}
|
|
400
|
+
_db = null;
|
|
401
|
+
_routingInjected.clear();
|
|
402
|
+
_sessionId = "";
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// best effort — never throw during shutdown
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
// ── 8. Slash commands ──────────────────────────────────
|
|
409
|
+
pi.registerCommand("ctx-stats", {
|
|
410
|
+
description: "Show context-mode session statistics",
|
|
411
|
+
handler: async (argsOrCtx, maybeCtx) => {
|
|
412
|
+
const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
|
|
413
|
+
const text = !_db || !_sessionId
|
|
414
|
+
? "context-mode: no active session"
|
|
415
|
+
: buildStatsText(_db, _sessionId);
|
|
416
|
+
return handleCommandText(text, ctx);
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
pi.registerCommand("ctx-doctor", {
|
|
420
|
+
description: "Run context-mode diagnostics",
|
|
421
|
+
handler: async (argsOrCtx, maybeCtx) => {
|
|
422
|
+
const ctx = resolveCommandContext(argsOrCtx, maybeCtx);
|
|
423
|
+
const dbPath = getDBPath();
|
|
424
|
+
const dbExists = existsSync(dbPath);
|
|
425
|
+
const lines = [
|
|
426
|
+
"## ctx-doctor (Pi)",
|
|
427
|
+
"",
|
|
428
|
+
`- DB path: \`${dbPath}\``,
|
|
429
|
+
`- DB exists: ${dbExists}`,
|
|
430
|
+
`- Session ID: \`${_sessionId ? _sessionId.slice(0, 8) + "..." : "none"}\``,
|
|
431
|
+
`- Plugin root: \`${pluginRoot}\``,
|
|
432
|
+
`- Project dir: \`${projectDir}\``,
|
|
433
|
+
];
|
|
434
|
+
if (_db && _sessionId) {
|
|
435
|
+
try {
|
|
436
|
+
const stats = _db.getSessionStats(_sessionId);
|
|
437
|
+
const eventCount = _db.getEventCount(_sessionId);
|
|
438
|
+
lines.push(`- Events: ${eventCount}`);
|
|
439
|
+
lines.push(`- Compactions: ${stats?.compact_count ?? 0}`);
|
|
440
|
+
const resume = _db.getResume(_sessionId);
|
|
441
|
+
lines.push(`- Resume snapshot: ${resume ? (resume.consumed ? "consumed" : "available") : "none"}`);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
lines.push("- DB query error");
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
const text = lines.join("\n");
|
|
448
|
+
return handleCommandText(text, ctx);
|
|
449
|
+
},
|
|
450
|
+
});
|
|
451
|
+
}
|
package/build/server.d.ts
CHANGED
|
@@ -21,6 +21,41 @@ export declare function emitSuppressionDiagnostic(opts?: {
|
|
|
21
21
|
}): void;
|
|
22
22
|
/** Test-only: reset the one-shot emission flag so suites can re-exercise. */
|
|
23
23
|
export declare function __resetSuppressionDiagnosticForTests(): void;
|
|
24
|
+
/**
|
|
25
|
+
* Issue #637 — register an explicit empty `tools/list` handler on the McpServer.
|
|
26
|
+
*
|
|
27
|
+
* Background: when `suppressMcpToolsForNativePluginHost` is true, every
|
|
28
|
+
* `server.registerTool()` call is short-circuited (returns `undefined` above).
|
|
29
|
+
* The MCP SDK only installs the SDK-default `tools/list` handler when at least
|
|
30
|
+
* one `registerTool()` reaches `setToolRequestHandlers()` internally
|
|
31
|
+
* (mcp.js:56-67). Suppressing every registration leaves `tools/list`
|
|
32
|
+
* unregistered, and the framework's RPC layer answers it with
|
|
33
|
+
* `-32601 "Method not found"`.
|
|
34
|
+
*
|
|
35
|
+
* The reporter of #637 (SquirrelRat) inspected the suppressed child via
|
|
36
|
+
* `tools/list` and read the JSON-RPC error as "the plugin never registers any
|
|
37
|
+
* ctx_* tools" — when in fact the plugin DOES register all 11 tools natively
|
|
38
|
+
* (verified at `src/adapters/opencode/plugin.ts:469` and
|
|
39
|
+
* `tests/opencode-plugin.test.ts:88`). The misleading -32601 is the seed of
|
|
40
|
+
* the #637 perception.
|
|
41
|
+
*
|
|
42
|
+
* This helper installs an explicit handler that returns `{tools: []}` — a
|
|
43
|
+
* spec-compliant empty list. Paired with the existing #623 stderr diagnostic,
|
|
44
|
+
* an operator now sees:
|
|
45
|
+
* - wire response: `{tools: []}` (matches expectation, no JSON-RPC error)
|
|
46
|
+
* - stderr: `[context-mode] ctx_* tools/list intentionally empty… (#623)`
|
|
47
|
+
*
|
|
48
|
+
* Idempotent: throws inside SDK if called twice on the same server because
|
|
49
|
+
* `assertCanSetRequestHandler` (mcp.js:60) rejects duplicate registrations;
|
|
50
|
+
* we therefore install the SDK's default tool handlers FIRST (via a no-op
|
|
51
|
+
* registerTool of a fake tool, immediately removed) only if needed. To keep
|
|
52
|
+
* the public surface minimal, we just call `server.server.setRequestHandler`
|
|
53
|
+
* directly — that is the same low-level call used for prompts/resources at
|
|
54
|
+
* server.ts:259-261 and avoids the SDK guard entirely.
|
|
55
|
+
*
|
|
56
|
+
* Exported for test (#637 in-memory regression guard).
|
|
57
|
+
*/
|
|
58
|
+
export declare function registerEmptyToolsListHandler(target?: McpServer): void;
|
|
24
59
|
type ToolContextOverride = {
|
|
25
60
|
projectDir: string;
|
|
26
61
|
sessionId?: string;
|
package/build/server.js
CHANGED
|
@@ -186,6 +186,44 @@ export function emitSuppressionDiagnostic(opts = {}) {
|
|
|
186
186
|
export function __resetSuppressionDiagnosticForTests() {
|
|
187
187
|
__suppressionDiagnosticEmitted = false;
|
|
188
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* Issue #637 — register an explicit empty `tools/list` handler on the McpServer.
|
|
191
|
+
*
|
|
192
|
+
* Background: when `suppressMcpToolsForNativePluginHost` is true, every
|
|
193
|
+
* `server.registerTool()` call is short-circuited (returns `undefined` above).
|
|
194
|
+
* The MCP SDK only installs the SDK-default `tools/list` handler when at least
|
|
195
|
+
* one `registerTool()` reaches `setToolRequestHandlers()` internally
|
|
196
|
+
* (mcp.js:56-67). Suppressing every registration leaves `tools/list`
|
|
197
|
+
* unregistered, and the framework's RPC layer answers it with
|
|
198
|
+
* `-32601 "Method not found"`.
|
|
199
|
+
*
|
|
200
|
+
* The reporter of #637 (SquirrelRat) inspected the suppressed child via
|
|
201
|
+
* `tools/list` and read the JSON-RPC error as "the plugin never registers any
|
|
202
|
+
* ctx_* tools" — when in fact the plugin DOES register all 11 tools natively
|
|
203
|
+
* (verified at `src/adapters/opencode/plugin.ts:469` and
|
|
204
|
+
* `tests/opencode-plugin.test.ts:88`). The misleading -32601 is the seed of
|
|
205
|
+
* the #637 perception.
|
|
206
|
+
*
|
|
207
|
+
* This helper installs an explicit handler that returns `{tools: []}` — a
|
|
208
|
+
* spec-compliant empty list. Paired with the existing #623 stderr diagnostic,
|
|
209
|
+
* an operator now sees:
|
|
210
|
+
* - wire response: `{tools: []}` (matches expectation, no JSON-RPC error)
|
|
211
|
+
* - stderr: `[context-mode] ctx_* tools/list intentionally empty… (#623)`
|
|
212
|
+
*
|
|
213
|
+
* Idempotent: throws inside SDK if called twice on the same server because
|
|
214
|
+
* `assertCanSetRequestHandler` (mcp.js:60) rejects duplicate registrations;
|
|
215
|
+
* we therefore install the SDK's default tool handlers FIRST (via a no-op
|
|
216
|
+
* registerTool of a fake tool, immediately removed) only if needed. To keep
|
|
217
|
+
* the public surface minimal, we just call `server.server.setRequestHandler`
|
|
218
|
+
* directly — that is the same low-level call used for prompts/resources at
|
|
219
|
+
* server.ts:259-261 and avoids the SDK guard entirely.
|
|
220
|
+
*
|
|
221
|
+
* Exported for test (#637 in-memory regression guard).
|
|
222
|
+
*/
|
|
223
|
+
export function registerEmptyToolsListHandler(target = server) {
|
|
224
|
+
target.server.registerCapabilities({ tools: { listChanged: false } });
|
|
225
|
+
target.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [] }));
|
|
226
|
+
}
|
|
189
227
|
const originalRegisterTool = server.registerTool.bind(server);
|
|
190
228
|
server.registerTool = (...args) => {
|
|
191
229
|
const [name, config, handler] = args;
|
|
@@ -196,6 +234,16 @@ server.registerTool = (...args) => {
|
|
|
196
234
|
REGISTERED_CTX_TOOLS.push({ name, config, handler });
|
|
197
235
|
return originalRegisterTool(...args);
|
|
198
236
|
};
|
|
237
|
+
// Issue #637 — when suppression is active, install the empty tools/list handler
|
|
238
|
+
// once at module-init time so the suppressed MCP child responds with
|
|
239
|
+
// `{tools: []}` instead of JSON-RPC `-32601 Method not found`. Pair with the
|
|
240
|
+
// #623 stderr diagnostic that explains WHY the list is empty. Skipped for the
|
|
241
|
+
// embedded plugin-import path because the embedded process is not the stdio
|
|
242
|
+
// MCP child an operator would inspect — it lives inside the OpenCode/Kilo
|
|
243
|
+
// host and never speaks JSON-RPC over stdio.
|
|
244
|
+
if (suppressMcpToolsForNativePluginHost && process.env.CONTEXT_MODE_EMBEDDED_PLUGIN_TOOLS !== "1") {
|
|
245
|
+
registerEmptyToolsListHandler(server);
|
|
246
|
+
}
|
|
199
247
|
const projectDirOverride = new AsyncLocalStorage();
|
|
200
248
|
export async function withProjectDirOverride(projectDir, fn) {
|
|
201
249
|
const ctx = typeof projectDir === "string" ? { projectDir } : projectDir;
|
|
@@ -204,7 +252,7 @@ export async function withProjectDirOverride(projectDir, fn) {
|
|
|
204
252
|
// Register empty prompts/resources handlers so MCP clients don't get -32601 (#168).
|
|
205
253
|
// OpenCode calls listPrompts()/listResources() unconditionally — the error can poison
|
|
206
254
|
// the SDK transport layer, causing subsequent listTools() calls to fail permanently.
|
|
207
|
-
import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
255
|
+
import { ListPromptsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
208
256
|
server.server.registerCapabilities({ prompts: { listChanged: false }, resources: { listChanged: false } });
|
|
209
257
|
server.server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
210
258
|
server.server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* db-lock — Per-DB lockfile primitive for single-writer enforcement (#560).
|
|
3
|
+
*
|
|
4
|
+
* Issue #560: multiple context-mode MCP servers writing the same on-disk
|
|
5
|
+
* SQLite content store unbounded the WAL — readers held shared locks
|
|
6
|
+
* indefinitely so `wal_checkpoint(TRUNCATE)` never fired, and the only
|
|
7
|
+
* existing truncation path is `closeDB`'s checkpoint on graceful exit
|
|
8
|
+
* (which #559's zombie servers never reach). Result: 238MB+ WAL files
|
|
9
|
+
* and ctx_search hangs.
|
|
10
|
+
*
|
|
11
|
+
* This module provides a tiny atomic-write primitive sitting in front of
|
|
12
|
+
* `new Database(...)`. The first opener writes its PID into
|
|
13
|
+
* `<dbPath>.lock` via O_EXCL (`flag: 'wx'`). Subsequent openers either:
|
|
14
|
+
*
|
|
15
|
+
* - find the lockfile + see the PID is alive → throw
|
|
16
|
+
* DatabaseLockedError with the reporter's verbatim message;
|
|
17
|
+
* - find the lockfile + see the PID is dead → claim it, with a re-read
|
|
18
|
+
* check to resolve a same-instant race between two stale-claimers.
|
|
19
|
+
*
|
|
20
|
+
* The lockfile is the PRIMARY single-writer defense. The SQLiteBase ctor
|
|
21
|
+
* also applies `locking_mode = EXCLUSIVE` as a SECONDARY defense
|
|
22
|
+
* (belt-and-braces) — the lockfile owns the user-facing UX, EXCLUSIVE
|
|
23
|
+
* catches the narrow race window between the lockfile check and the
|
|
24
|
+
* actual `Database(...)` open.
|
|
25
|
+
*
|
|
26
|
+
* Per-process tmp DBs (those under `os.tmpdir()`) skip the lockfile
|
|
27
|
+
* entirely — those are the existing `defaultDBPath()` shape and embed
|
|
28
|
+
* `process.pid` already, so cross-instance contention is impossible.
|
|
29
|
+
*
|
|
30
|
+
* `isProcessAlive` is COPIED from `store.ts:187` — not imported — to
|
|
31
|
+
* keep `db-base.ts` (which imports this module) free of any dependency
|
|
32
|
+
* on `store.ts` (which itself imports from `db-base.ts`). See
|
|
33
|
+
* PR-559-560-FIX-DESIGN.md regression risks #4.
|
|
34
|
+
*/
|
|
35
|
+
/** User-facing failure used by SQLiteBase to surface the contention. */
|
|
36
|
+
export declare class DatabaseLockedError extends Error {
|
|
37
|
+
readonly pid: number;
|
|
38
|
+
readonly dbPath: string;
|
|
39
|
+
constructor(pid: number, dbPath: string);
|
|
40
|
+
}
|
|
41
|
+
export interface AcquireOptions {
|
|
42
|
+
dbPath: string;
|
|
43
|
+
}
|
|
44
|
+
export interface AcquireResult {
|
|
45
|
+
/** True when the lockfile was skipped because dbPath is under tmpdir. */
|
|
46
|
+
skipped: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Atomically claim the lockfile for `dbPath`. Throws `DatabaseLockedError`
|
|
50
|
+
* if another live process holds it. Silently claims stale lockfiles whose
|
|
51
|
+
* owning PID is dead.
|
|
52
|
+
*/
|
|
53
|
+
export declare function acquireDbLock(opts: AcquireOptions): AcquireResult;
|
|
54
|
+
export interface ReleaseOptions {
|
|
55
|
+
dbPath: string;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Drop the lockfile for `dbPath`. Swallows all errors so callers can
|
|
59
|
+
* always invoke this in a finally / cleanup path without try/catch —
|
|
60
|
+
* mirrors the shape of `db-base.ts closeDB`.
|
|
61
|
+
*
|
|
62
|
+
* Skipped (no-op) when `dbPath` is under tmpdir — symmetric with
|
|
63
|
+
* `acquireDbLock`'s skip-gate.
|
|
64
|
+
*/
|
|
65
|
+
export declare function releaseDbLock(opts: ReleaseOptions): void;
|