context-mode 1.0.105 → 1.0.107

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 (45) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  4. package/.openclaw-plugin/package.json +1 -1
  5. package/build/adapters/copilot-base.d.ts +3 -3
  6. package/build/adapters/cursor/hooks.js +8 -0
  7. package/build/adapters/cursor/index.js +4 -1
  8. package/build/adapters/gemini-cli/hooks.d.ts +6 -1
  9. package/build/adapters/gemini-cli/hooks.js +7 -1
  10. package/build/adapters/gemini-cli/index.js +12 -0
  11. package/build/adapters/kiro/hooks.js +4 -0
  12. package/build/adapters/kiro/index.d.ts +9 -2
  13. package/build/adapters/kiro/index.js +49 -27
  14. package/build/adapters/opencode/index.js +6 -0
  15. package/build/adapters/qwen-code/index.js +18 -0
  16. package/build/adapters/vscode-copilot/hooks.d.ts +0 -4
  17. package/build/adapters/vscode-copilot/hooks.js +6 -6
  18. package/build/cli.js +1 -0
  19. package/build/openclaw/mcp-tools.d.ts +54 -0
  20. package/build/openclaw/mcp-tools.js +198 -0
  21. package/build/openclaw-plugin.d.ts +9 -0
  22. package/build/openclaw-plugin.js +132 -16
  23. package/build/opencode-plugin.d.ts +29 -4
  24. package/build/opencode-plugin.js +185 -11
  25. package/build/pi-extension.js +123 -29
  26. package/build/server.d.ts +1 -0
  27. package/build/server.js +28 -2
  28. package/build/session/db.d.ts +12 -3
  29. package/build/session/db.js +19 -4
  30. package/build/session/extract.d.ts +1 -1
  31. package/build/session/extract.js +46 -1
  32. package/cli.bundle.mjs +128 -127
  33. package/hooks/core/platform-detect.mjs +49 -0
  34. package/hooks/core/routing.mjs +13 -1
  35. package/hooks/cursor/afteragentresponse.mjs +74 -0
  36. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  37. package/hooks/kiro/agentspawn.mjs +97 -0
  38. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  39. package/hooks/session-db.bundle.mjs +4 -3
  40. package/hooks/session-extract.bundle.mjs +2 -2
  41. package/hooks/sessionstart.mjs +3 -1
  42. package/hooks/vscode-copilot/sessionstart.mjs +13 -14
  43. package/openclaw.plugin.json +1 -1
  44. package/package.json +1 -1
  45. package/server.bundle.mjs +72 -71
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * OpenCode / KiloCode TypeScript plugin entry point for context-mode.
3
3
  *
4
- * Provides three hooks:
4
+ * Provides five hooks (v1.0.107 — Mickey OC-1..OC-4 follow-up):
5
5
  * - tool.execute.before — Routing enforcement (deny/modify/passthrough)
6
- * - tool.execute.after — Session event capture
7
- * - experimental.session.compacting — Compaction snapshot generation
6
+ * - tool.execute.after — Session event capture + first-fire AGENTS.md scan (OC-4)
7
+ * - experimental.session.compacting — Compaction snapshot + budget-capped auto-injection (OC-3)
8
+ * - experimental.chat.system.transform — ROUTING_BLOCK + resume snapshot injection (OC-1)
9
+ * - chat.message — User-prompt capture w/ CCv2 inline filter (OC-2)
8
10
  *
9
11
  * KiloCode loads this via: import("context-mode") → expects default export
10
12
  * with shape { server: (input) => Promise<Hooks> } (PluginModule).
@@ -14,17 +16,46 @@
14
16
  *
15
17
  * Constraints:
16
18
  * - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
17
- * - No context injection (canInjectSessionContext: false)
19
+ * - context injection now via chat.system.transform surrogate (OC-1)
18
20
  * - No routing file auto-write (avoid dirtying project trees)
19
21
  * - Session cleanup happens at plugin init (no SessionStart)
20
22
  */
21
- import { dirname, resolve } from "node:path";
23
+ import { dirname, resolve, join } from "node:path";
22
24
  import { fileURLToPath, pathToFileURL } from "node:url";
25
+ import { existsSync, readFileSync } from "node:fs";
23
26
  import { SessionDB } from "./session/db.js";
24
- import { extractEvents } from "./session/extract.js";
27
+ import { extractEvents, extractUserEvents } from "./session/extract.js";
25
28
  import { buildResumeSnapshot } from "./session/snapshot.js";
26
29
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
27
30
  import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
31
+ // Read package.json version once at module load (not on every hook call).
32
+ // Used in the resume-injection visible signal so users can confirm in
33
+ // OPENCODE_DEBUG logs which plugin version actually injected.
34
+ const VERSION = (() => {
35
+ try {
36
+ const pkgRoot = dirname(fileURLToPath(import.meta.url));
37
+ for (const rel of ["../package.json", "./package.json"]) {
38
+ const p = resolve(pkgRoot, rel);
39
+ if (existsSync(p))
40
+ return JSON.parse(readFileSync(p, "utf8")).version ?? "unknown";
41
+ }
42
+ }
43
+ catch { /* fall through */ }
44
+ return "unknown";
45
+ })();
46
+ // Synthetic message tags emitted by harnesses (CCv2 inline filter). When the
47
+ // user "message" is actually a system-generated nudge (e.g. tool-result, system
48
+ // reminder), capturing it as user_prompt would flood the DB with noise.
49
+ const SYNTHETIC_MESSAGE_PREFIXES = [
50
+ "<task-notification>",
51
+ "<system-reminder>",
52
+ "<context_guidance>",
53
+ "<tool-result>",
54
+ ];
55
+ function isSyntheticMessage(text) {
56
+ const trimmed = text.trim();
57
+ return SYNTHETIC_MESSAGE_PREFIXES.some((p) => trimmed.startsWith(p));
58
+ }
28
59
  // ── Helpers ───────────────────────────────────────────────
29
60
  /**
30
61
  * Detect whether the plugin is running under KiloCode or OpenCode.
@@ -63,12 +94,27 @@ function getPlatform() {
63
94
  */
64
95
  async function createContextModePlugin(ctx) {
65
96
  // Resolve build dir from compiled JS location
66
- const adapter = new OpenCodeAdapter(getPlatform());
97
+ const platform = getPlatform();
98
+ const adapter = new OpenCodeAdapter(platform);
67
99
  const buildDir = dirname(fileURLToPath(import.meta.url));
68
100
  // Load routing module (ESM .mjs, lives outside build/ in hooks/)
69
101
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
70
102
  const routing = await import(pathToFileURL(routingPath).href);
71
103
  await routing.initSecurity(buildDir);
104
+ // OC-1 / OC-3: Load hook helpers once at plugin init. Dynamic import keeps
105
+ // the .mjs ESM islands isolated from the .ts compile graph.
106
+ const routingBlockPath = resolve(buildDir, "..", "hooks", "routing-block.mjs");
107
+ const routingBlockMod = await import(pathToFileURL(routingBlockPath).href);
108
+ const toolNamingPath = resolve(buildDir, "..", "hooks", "core", "tool-naming.mjs");
109
+ const toolNamingMod = await import(pathToFileURL(toolNamingPath).href);
110
+ const autoInjectionPath = resolve(buildDir, "..", "hooks", "auto-injection.mjs");
111
+ const autoInjectionMod = await import(pathToFileURL(autoInjectionPath).href);
112
+ // Pre-build the routing block once per process — it is platform-specific
113
+ // (tool naming differs between opencode and kilo) but does NOT depend on
114
+ // sessionID, so we cache it. createToolNamer accepts both "opencode" and
115
+ // "kilo" per hooks/core/tool-naming.mjs:25-26.
116
+ const toolNamer = toolNamingMod.createToolNamer(platform);
117
+ const routingBlock = routingBlockMod.createRoutingBlock(toolNamer);
72
118
  // Initialize per-process state. We do NOT fabricate a sessionId here —
73
119
  // OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
74
120
  // process-global UUID would (a) never match prior-session resume rows and
@@ -81,6 +127,51 @@ async function createContextModePlugin(ctx) {
81
127
  // many sessions, so the gate must be keyed by sessionID — NOT a single
82
128
  // boolean closure flag (Mickey #2 root cause).
83
129
  const resumeInjected = new Set();
130
+ // OC-1: Routing block first-fire gate per session. Distinct from
131
+ // resumeInjected because routing block must always inject (regardless of
132
+ // whether a resume row exists), but resume only on rows present.
133
+ const routingInjected = new Set();
134
+ // OC-4: AGENTS.md/CLAUDE.md captured-once-per-projectDir gate. Idempotent
135
+ // across many sessions reusing the same plugin process + project tree.
136
+ const agentsCaptured = new Set();
137
+ /**
138
+ * OC-4: Read AGENTS.md (and CLAUDE.md fallback if both exist) from the
139
+ * project directory and persist as `rule` + `rule_content` events. Mirrors
140
+ * the CC SessionStart pattern at hooks/sessionstart.mjs:121-132. Idempotent
141
+ * via `agentsCaptured` Set keyed by projectDir.
142
+ */
143
+ function captureAgentsMd(sessionId) {
144
+ if (agentsCaptured.has(projectDir))
145
+ return;
146
+ agentsCaptured.add(projectDir);
147
+ // Mirror OpenCode's instruction.ts FILES order: AGENTS.md, CLAUDE.md, CONTEXT.md.
148
+ const candidates = ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"];
149
+ for (const name of candidates) {
150
+ try {
151
+ const p = join(projectDir, name);
152
+ if (!existsSync(p))
153
+ continue;
154
+ const content = readFileSync(p, "utf-8");
155
+ if (!content.trim())
156
+ continue;
157
+ db.insertEvent(sessionId, {
158
+ type: "rule",
159
+ category: "rule",
160
+ data: p,
161
+ priority: 1,
162
+ }, "PluginInit");
163
+ db.insertEvent(sessionId, {
164
+ type: "rule_content",
165
+ category: "rule",
166
+ data: content,
167
+ priority: 1,
168
+ }, "PluginInit");
169
+ }
170
+ catch {
171
+ // file missing or unreadable — skip silently
172
+ }
173
+ }
174
+ }
84
175
  return {
85
176
  // ── PreToolUse: Routing enforcement ─────────────────
86
177
  "tool.execute.before": async (input, output) => {
@@ -112,6 +203,9 @@ async function createContextModePlugin(ctx) {
112
203
  return;
113
204
  try {
114
205
  db.ensureSession(sessionId, projectDir);
206
+ // OC-4: Capture AGENTS.md/CLAUDE.md as rule events on first hook
207
+ // fire per projectDir. Idempotent via `agentsCaptured` Set.
208
+ captureAgentsMd(sessionId);
115
209
  const hookInput = {
116
210
  tool_name: input.tool ?? "",
117
211
  tool_input: input.args ?? {},
@@ -128,6 +222,43 @@ async function createContextModePlugin(ctx) {
128
222
  // Silent — session capture must never break the tool call
129
223
  }
130
224
  },
225
+ // ── chat.message: User-prompt capture (OC-2 / Z2) ───
226
+ // SDK signature verified at refs/platforms/opencode/packages/plugin/src/
227
+ // index.ts:233. Orchestrator reference at refs/plugin-examples/opencode/
228
+ // opencode-orchestrator/src/plugin-handlers/chat-message-handler.ts:41-65.
229
+ // CCv2 inline filter: skip synthetic harness messages (system reminders,
230
+ // tool results, etc.) so we don't pollute the user-prompt event stream.
231
+ "chat.message": async (input, output) => {
232
+ const sessionId = input?.sessionID;
233
+ if (!sessionId)
234
+ return;
235
+ try {
236
+ const parts = Array.isArray(output?.parts) ? output.parts : [];
237
+ const textPart = parts.find((p) => p && p.type === "text" && typeof p.text === "string" && p.text.length > 0);
238
+ if (!textPart || !textPart.text)
239
+ return;
240
+ const message = textPart.text;
241
+ if (isSyntheticMessage(message))
242
+ return;
243
+ db.ensureSession(sessionId, projectDir);
244
+ captureAgentsMd(sessionId);
245
+ // 1. Always save the raw prompt
246
+ db.insertEvent(sessionId, {
247
+ type: "user_prompt",
248
+ category: "user-prompt",
249
+ data: message,
250
+ priority: 1,
251
+ }, "UserPromptSubmit");
252
+ // 2. Extract role/decision/intent/skill events from the prompt body
253
+ const userEvents = extractUserEvents(message);
254
+ for (const ev of userEvents) {
255
+ db.insertEvent(sessionId, ev, "UserPromptSubmit");
256
+ }
257
+ }
258
+ catch {
259
+ // Silent — chat.message must never break the turn
260
+ }
261
+ },
131
262
  // ── PreCompact: Snapshot generation ─────────────────
132
263
  "experimental.session.compacting": async (input, output) => {
133
264
  const sessionId = input.sessionID;
@@ -146,6 +277,19 @@ async function createContextModePlugin(ctx) {
146
277
  db.incrementCompactCount(sessionId);
147
278
  // Mutate output.context to inject the snapshot
148
279
  output.context.push(snapshot);
280
+ // OC-3 / Z3: Add budget-capped auto-injection (P1 role / P2 rules /
281
+ // P3 skills / P4 intent — ≤500 tokens / ~2000 chars per
282
+ // hooks/auto-injection.mjs). Pushed as a separate context entry so
283
+ // OpenCode can fold it independently from the verbose snapshot.
284
+ try {
285
+ const autoBlock = autoInjectionMod.buildAutoInjection(events);
286
+ if (autoBlock && autoBlock.length > 0) {
287
+ output.context.push(autoBlock);
288
+ }
289
+ }
290
+ catch {
291
+ // Auto-injection failure must NOT break the snapshot path.
292
+ }
149
293
  return snapshot;
150
294
  }
151
295
  catch {
@@ -164,14 +308,42 @@ async function createContextModePlugin(ctx) {
164
308
  const sessionId = input?.sessionID;
165
309
  if (!sessionId)
166
310
  return;
311
+ // ── OC-1 / CCv1: ROUTING_BLOCK injection ──────────────
312
+ // Inject the <context_window_protection> XML block on the first
313
+ // chat.system.transform per session. This is INDEPENDENT of the
314
+ // resume snapshot path below — routing block must fire even when
315
+ // no prior session row exists. Splice at index 1 (NOT unshift) for
316
+ // the same OpenCode llm.ts:117-128 cache-fold reason as resume.
317
+ if (!routingInjected.has(sessionId) && Array.isArray(output?.system)) {
318
+ try {
319
+ // Visible marker — mirror the resume-snapshot pattern below so
320
+ // users can grep OPENCODE_DEBUG logs to confirm the routing block
321
+ // reached the model (Mickey-class verification path).
322
+ const marker = `<!-- context-mode v${VERSION}: routing block injected (sessionID=${sessionId.slice(0, 8)}) -->\n`;
323
+ output.system.splice(1, 0, marker + routingBlock);
324
+ routingInjected.add(sessionId);
325
+ }
326
+ catch {
327
+ // Never break the chat turn on routing-block injection failure.
328
+ }
329
+ }
167
330
  if (resumeInjected.has(sessionId))
168
331
  return;
169
- resumeInjected.add(sessionId);
170
332
  try {
171
- const row = db.claimLatestUnconsumedResume();
333
+ // Pass current sessionId so SQL excludes self-injection (v1.0.106 — Mickey #376
334
+ // follow-up): if Session B compacts mid-flight and produces its own row,
335
+ // B's next system.transform must NOT claim that row back into B's prompt.
336
+ const row = db.claimLatestUnconsumedResume(sessionId);
172
337
  if (!row || !row.snapshot)
173
- return;
338
+ return; // no row → leave `resumeInjected` unset → retry on next turn
174
339
  if (Array.isArray(output?.system)) {
340
+ // Visible signal — without this, the injection is silent and users
341
+ // cannot tell the feature is active (Mickey: "I can't find use case
342
+ // for it"). The XML comment is harmless to the model and shows up in
343
+ // OPENCODE_DEBUG logs as proof the snapshot landed.
344
+ const eventCount = row.snapshot.match(/events="(\d+)"/)?.[1] ?? "?";
345
+ const marker = `<!-- context-mode v${VERSION}: resumed prior session ${row.sessionId.slice(0, 8)} ` +
346
+ `(${eventCount} events, ${row.snapshot.length} chars) -->\n`;
175
347
  // Insert at index 1 (after the header) — NOT unshift.
176
348
  // OpenCode's llm.ts:117-128 saves `header = system[0]` BEFORE this
177
349
  // hook runs and then folds the rest into a 2-part structure
@@ -182,7 +354,9 @@ async function createContextModePlugin(ctx) {
182
354
  // provider prompt cache is invalidated on every resume injection.
183
355
  // Inserting at index 1 keeps the header invariant and lets the
184
356
  // snapshot ride along inside the cached body block.
185
- output.system.splice(1, 0, row.snapshot);
357
+ output.system.splice(1, 0, marker + row.snapshot);
358
+ // Mark consumed only AFTER successful splice so failed paths can retry
359
+ resumeInjected.add(sessionId);
186
360
  }
187
361
  }
188
362
  catch {
@@ -14,7 +14,7 @@ import { createHash } from "node:crypto";
14
14
  import { existsSync, mkdirSync } from "node:fs";
15
15
  import { homedir } from "node:os";
16
16
  import { join, resolve, dirname } from "node:path";
17
- import { fileURLToPath } from "node:url";
17
+ import { fileURLToPath, pathToFileURL } from "node:url";
18
18
  import { SessionDB } from "./session/db.js";
19
19
  import { extractEvents, extractUserEvents } from "./session/extract.js";
20
20
  import { buildResumeSnapshot } from "./session/snapshot.js";
@@ -45,6 +45,38 @@ const BLOCKED_BASH_PATTERNS = [
45
45
  // ── Module-level DB singleton ────────────────────────────
46
46
  let _db = null;
47
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
+ }
48
80
  // ── Helpers ──────────────────────────────────────────────
49
81
  function getSessionDir() {
50
82
  const dir = join(homedir(), ".pi", "context-mode", "sessions");
@@ -218,8 +250,8 @@ export default function piExtension(pi) {
218
250
  // Silent — session capture must never break the tool call
219
251
  }
220
252
  });
221
- // ── 4. before_agent_start — Resume injection + user events
222
- pi.on("before_agent_start", (event) => {
253
+ // ── 4. before_agent_start — Routing + active_memory + resume injection
254
+ pi.on("before_agent_start", async (event) => {
223
255
  try {
224
256
  if (!_sessionId)
225
257
  return;
@@ -231,37 +263,64 @@ export default function piExtension(pi) {
231
263
  db.insertEvent(_sessionId, ev, "UserPromptSubmit");
232
264
  }
233
265
  }
234
- // Check for unconsumed resume snapshot
235
- const resume = db.getResume(_sessionId);
236
- if (!resume || resume.consumed)
237
- return;
238
- // Build FTS5 active memory from the current prompt
239
- const stats = db.getSessionStats(_sessionId);
240
- if ((stats?.compact_count ?? 0) === 0)
241
- return;
242
- // Mark resume as consumed so it is not re-injected
243
- db.markResumeConsumed(_sessionId);
244
- // Build memory context from recent high-priority events
245
- const allEvents = db.getEvents(_sessionId, { minPriority: 3, limit: 50 });
246
- let memoryContext = "";
247
- if (allEvents.length > 0) {
248
- const memoryLines = ["<active_memory>"];
249
- for (const ev of allEvents) {
250
- memoryLines.push(` <event type="${ev.type}" category="${ev.category}">${ev.data}</event>`);
251
- }
252
- memoryLines.push("</active_memory>");
253
- memoryContext = memoryLines.join("\n");
254
- }
255
- // Compose the augmented system prompt
256
266
  const existingPrompt = String(event?.systemPrompt ?? "");
257
267
  const parts = [];
258
268
  if (existingPrompt)
259
269
  parts.push(existingPrompt);
260
- if (resume.snapshot)
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) {
261
318
  parts.push(resume.snapshot);
262
- if (memoryContext)
263
- parts.push(memoryContext);
264
- if (parts.length > (existingPrompt ? 1 : 0)) {
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) {
265
324
  return { systemPrompt: parts.join("\n\n") };
266
325
  }
267
326
  }
@@ -269,6 +328,40 @@ export default function piExtension(pi) {
269
328
  // best effort — never break agent start
270
329
  }
271
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
+ });
272
365
  // ── 5. session_before_compact — Build resume snapshot ──
273
366
  pi.on("session_before_compact", () => {
274
367
  try {
@@ -305,6 +398,7 @@ export default function piExtension(pi) {
305
398
  _db.cleanupOldSessions(7);
306
399
  }
307
400
  _db = null;
401
+ _routingInjected.clear();
308
402
  _sessionId = "";
309
403
  }
310
404
  catch {
package/build/server.d.ts CHANGED
@@ -37,6 +37,7 @@ interface BatchExecutor {
37
37
  timedOut?: boolean;
38
38
  }>;
39
39
  }
40
+ export declare function buildBatchNodeOptionsPrefix(shellPath: string, preloadPath: string): string;
40
41
  /**
41
42
  * Execute batch commands. concurrency=1 preserves the legacy serial path
42
43
  * (shared timeout budget + cascading skip-on-timeout). concurrency>1 runs
package/build/server.js CHANGED
@@ -675,6 +675,24 @@ export function formatBatchQueryResults(store, queries, source, maxOutput = 80 *
675
675
  sections.push(`\n> **Tip:** Results are scoped to this batch only. To search across all indexed sources, use \`ctx_search(queries: [...])\`.`);
676
676
  return sections;
677
677
  }
678
+ function quotePosixSingle(value) {
679
+ return `'${value.replace(/'/g, "'\\''")}'`;
680
+ }
681
+ function quotePowerShellSingle(value) {
682
+ return `'${value.replace(/'/g, "''")}'`;
683
+ }
684
+ export function buildBatchNodeOptionsPrefix(shellPath, preloadPath) {
685
+ const option = `--require ${preloadPath}`;
686
+ const shell = shellPath.toLowerCase();
687
+ const base = shell.split(/[\\/]/).pop() ?? shell;
688
+ if (shell.includes("powershell") || shell.includes("pwsh")) {
689
+ return `$env:NODE_OPTIONS=${quotePowerShellSingle(option)}; `;
690
+ }
691
+ if (base === "cmd" || base === "cmd.exe") {
692
+ return `set "NODE_OPTIONS=${option.replace(/"/g, '""')}" && `;
693
+ }
694
+ return `NODE_OPTIONS=${quotePosixSingle(option)} `;
695
+ }
678
696
  function formatCommandOutput(label, raw, onFsBytes) {
679
697
  let output = raw || "(no output)";
680
698
  const fsMatches = output.matchAll(/__CM_FS__:(\d+)/g);
@@ -2070,7 +2088,7 @@ server.registerTool("ctx_batch_execute", {
2070
2088
  // Inject NODE_OPTIONS for FS read tracking in spawned Node processes.
2071
2089
  // The executor denies NODE_OPTIONS in its env (security), so we set it
2072
2090
  // as an inline shell prefix. This only affects child `node` invocations.
2073
- const nodeOptsPrefix = `NODE_OPTIONS="--require ${CM_FS_PRELOAD}" `;
2091
+ const nodeOptsPrefix = buildBatchNodeOptionsPrefix(runtimes.shell, CM_FS_PRELOAD);
2074
2092
  // Full stdout is preserved per-command and indexed into FTS5 (Issue #61, #197).
2075
2093
  // Concurrency>1 switches to a worker pool with per-command timeouts.
2076
2094
  const { outputs: perCommandOutputs, timedOut } = await runBatchCommands(commands, {
@@ -2805,9 +2823,17 @@ async function main() {
2805
2823
  }
2806
2824
  }
2807
2825
  catch { /* best effort — _detectedAdapter stays null, falls back to .claude */ }
2808
- // Non-blocking version check — result stored for trackResponse warnings
2826
+ // Non-blocking version check — result stored for trackResponse warnings.
2827
+ // First fetch at startup, then refresh every hour so long-running sessions
2828
+ // (some users keep the MCP server alive 24h+) catch new releases without a
2829
+ // restart. `.unref()` lets the process exit normally on SIGTERM regardless
2830
+ // of pending intervals.
2809
2831
  fetchLatestVersion().then(v => { if (v !== "unknown")
2810
2832
  _latestVersion = v; });
2833
+ setInterval(() => {
2834
+ fetchLatestVersion().then(v => { if (v !== "unknown")
2835
+ _latestVersion = v; });
2836
+ }, 60 * 60 * 1000).unref();
2811
2837
  console.error(`Context Mode MCP server v${VERSION} running on stdio`);
2812
2838
  console.error(`Detected runtimes:\n${getRuntimeSummary(runtimes)}`);
2813
2839
  if (!hasBunRuntime()) {
@@ -149,16 +149,25 @@ export declare class SessionDB extends SQLiteBase {
149
149
  */
150
150
  markResumeConsumed(sessionId: string): void;
151
151
  /**
152
- * Atomically claim the most recent unconsumed resume snapshot in this DB.
152
+ * Atomically claim the most recent unconsumed resume snapshot in this DB,
153
+ * EXCLUDING any row that belongs to `currentSessionId`.
153
154
  *
154
155
  * `SessionDB` is sharded per project (see `getSessionDBPath` — SHA-256 of
155
156
  * project dir), so "this DB" already implies "this project". The atomic
156
157
  * `UPDATE … RETURNING` ensures concurrent processes for the same project
157
158
  * cannot both inject the same snapshot (Mickey / PR #376 race).
158
159
  *
159
- * Returns null when no unconsumed snapshot exists.
160
+ * The `currentSessionId` parameter prevents self-injection: when a session
161
+ * compacts mid-flight and produces its own row, that session's next chat
162
+ * turn must NOT claim that row back (wasted tokens AND it would consume
163
+ * the snapshot meant for the next fresh session).
164
+ *
165
+ * Pass an empty string to allow self-claim (legacy behaviour, only useful
166
+ * in tests or one-off harnesses).
167
+ *
168
+ * Returns null when no unconsumed snapshot exists for any other session.
160
169
  */
161
- claimLatestUnconsumedResume(): {
170
+ claimLatestUnconsumedResume(currentSessionId: string): {
162
171
  sessionId: string;
163
172
  snapshot: string;
164
173
  } | null;
@@ -256,11 +256,17 @@ export class SessionDB extends SQLiteBase {
256
256
  // statement". Required for race-safe cross-session resume injection
257
257
  // (Mickey / PR #376) — two parallel chat-turn hooks must not both read
258
258
  // the same row before either one writes consumed=1.
259
+ //
260
+ // The `session_id != ?` clause prevents self-injection (v1.0.106): when
261
+ // Session B compacts mid-flight and produces its own row, B's next chat
262
+ // turn must NOT claim that row back into its own prompt — that's wasted
263
+ // tokens and steals the snapshot meant for the next fresh session.
259
264
  p(S.claimLatestUnconsumedResume, `UPDATE session_resume
260
265
  SET consumed = 1
261
266
  WHERE id = (
262
267
  SELECT id FROM session_resume
263
268
  WHERE consumed = 0
269
+ AND session_id != ?
264
270
  ORDER BY created_at DESC, id DESC
265
271
  LIMIT 1
266
272
  )
@@ -493,17 +499,26 @@ export class SessionDB extends SQLiteBase {
493
499
  this.stmt(S.markResumeConsumed).run(sessionId);
494
500
  }
495
501
  /**
496
- * Atomically claim the most recent unconsumed resume snapshot in this DB.
502
+ * Atomically claim the most recent unconsumed resume snapshot in this DB,
503
+ * EXCLUDING any row that belongs to `currentSessionId`.
497
504
  *
498
505
  * `SessionDB` is sharded per project (see `getSessionDBPath` — SHA-256 of
499
506
  * project dir), so "this DB" already implies "this project". The atomic
500
507
  * `UPDATE … RETURNING` ensures concurrent processes for the same project
501
508
  * cannot both inject the same snapshot (Mickey / PR #376 race).
502
509
  *
503
- * Returns null when no unconsumed snapshot exists.
510
+ * The `currentSessionId` parameter prevents self-injection: when a session
511
+ * compacts mid-flight and produces its own row, that session's next chat
512
+ * turn must NOT claim that row back (wasted tokens AND it would consume
513
+ * the snapshot meant for the next fresh session).
514
+ *
515
+ * Pass an empty string to allow self-claim (legacy behaviour, only useful
516
+ * in tests or one-off harnesses).
517
+ *
518
+ * Returns null when no unconsumed snapshot exists for any other session.
504
519
  */
505
- claimLatestUnconsumedResume() {
506
- const row = this.stmt(S.claimLatestUnconsumedResume).get();
520
+ claimLatestUnconsumedResume(currentSessionId) {
521
+ const row = this.stmt(S.claimLatestUnconsumedResume).get(currentSessionId);
507
522
  if (!row)
508
523
  return null;
509
524
  return { sessionId: row.session_id, snapshot: row.snapshot };
@@ -45,7 +45,7 @@ export declare function resetIterationLoopState(): void;
45
45
  * Accepts the raw hook JSON shape (snake_case keys) as received from stdin.
46
46
  * Returns an array of zero or more SessionEvents. Never throws.
47
47
  */
48
- export declare function extractEvents(input: HookInput): SessionEvent[];
48
+ export declare function extractEvents(rawInput: HookInput): SessionEvent[];
49
49
  /**
50
50
  * Extract session events from a UserPromptSubmit hook input (user message text).
51
51
  *