context-mode 1.0.106 → 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 (42) 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 +154 -7
  25. package/build/pi-extension.js +123 -29
  26. package/build/server.d.ts +1 -0
  27. package/build/server.js +19 -1
  28. package/build/session/extract.d.ts +1 -1
  29. package/build/session/extract.js +46 -1
  30. package/cli.bundle.mjs +125 -125
  31. package/hooks/core/platform-detect.mjs +49 -0
  32. package/hooks/core/routing.mjs +13 -1
  33. package/hooks/cursor/afteragentresponse.mjs +74 -0
  34. package/hooks/gemini-cli/beforeagent.mjs +99 -0
  35. package/hooks/kiro/agentspawn.mjs +97 -0
  36. package/hooks/kiro/userpromptsubmit.mjs +88 -0
  37. package/hooks/session-extract.bundle.mjs +2 -2
  38. package/hooks/sessionstart.mjs +3 -1
  39. package/hooks/vscode-copilot/sessionstart.mjs +13 -14
  40. package/openclaw.plugin.json +1 -1
  41. package/package.json +1 -1
  42. package/server.bundle.mjs +68 -68
@@ -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,15 +16,15 @@
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";
23
25
  import { existsSync, readFileSync } from "node:fs";
24
26
  import { SessionDB } from "./session/db.js";
25
- import { extractEvents } from "./session/extract.js";
27
+ import { extractEvents, extractUserEvents } from "./session/extract.js";
26
28
  import { buildResumeSnapshot } from "./session/snapshot.js";
27
29
  import { OpenCodeAdapter } from "./adapters/opencode/index.js";
28
30
  import { PLATFORM_ENV_VARS } from "./adapters/detect.js";
@@ -41,6 +43,19 @@ const VERSION = (() => {
41
43
  catch { /* fall through */ }
42
44
  return "unknown";
43
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
+ }
44
59
  // ── Helpers ───────────────────────────────────────────────
45
60
  /**
46
61
  * Detect whether the plugin is running under KiloCode or OpenCode.
@@ -79,12 +94,27 @@ function getPlatform() {
79
94
  */
80
95
  async function createContextModePlugin(ctx) {
81
96
  // Resolve build dir from compiled JS location
82
- const adapter = new OpenCodeAdapter(getPlatform());
97
+ const platform = getPlatform();
98
+ const adapter = new OpenCodeAdapter(platform);
83
99
  const buildDir = dirname(fileURLToPath(import.meta.url));
84
100
  // Load routing module (ESM .mjs, lives outside build/ in hooks/)
85
101
  const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
86
102
  const routing = await import(pathToFileURL(routingPath).href);
87
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);
88
118
  // Initialize per-process state. We do NOT fabricate a sessionId here —
89
119
  // OpenCode/Kilo provide the real `input.sessionID` on every hook, and a
90
120
  // process-global UUID would (a) never match prior-session resume rows and
@@ -97,6 +127,51 @@ async function createContextModePlugin(ctx) {
97
127
  // many sessions, so the gate must be keyed by sessionID — NOT a single
98
128
  // boolean closure flag (Mickey #2 root cause).
99
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
+ }
100
175
  return {
101
176
  // ── PreToolUse: Routing enforcement ─────────────────
102
177
  "tool.execute.before": async (input, output) => {
@@ -128,6 +203,9 @@ async function createContextModePlugin(ctx) {
128
203
  return;
129
204
  try {
130
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);
131
209
  const hookInput = {
132
210
  tool_name: input.tool ?? "",
133
211
  tool_input: input.args ?? {},
@@ -144,6 +222,43 @@ async function createContextModePlugin(ctx) {
144
222
  // Silent — session capture must never break the tool call
145
223
  }
146
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
+ },
147
262
  // ── PreCompact: Snapshot generation ─────────────────
148
263
  "experimental.session.compacting": async (input, output) => {
149
264
  const sessionId = input.sessionID;
@@ -162,6 +277,19 @@ async function createContextModePlugin(ctx) {
162
277
  db.incrementCompactCount(sessionId);
163
278
  // Mutate output.context to inject the snapshot
164
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
+ }
165
293
  return snapshot;
166
294
  }
167
295
  catch {
@@ -180,6 +308,25 @@ async function createContextModePlugin(ctx) {
180
308
  const sessionId = input?.sessionID;
181
309
  if (!sessionId)
182
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
+ }
183
330
  if (resumeInjected.has(sessionId))
184
331
  return;
185
332
  try {
@@ -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, {
@@ -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
  *
@@ -850,14 +850,59 @@ export function resetIterationLoopState() {
850
850
  callHistory.length = 0;
851
851
  }
852
852
  // ── Public API ─────────────────────────────────────────────────────────────
853
+ /**
854
+ * Map platform-native tool names (Qwen Code, Gemini CLI, OpenCode, etc.) to the
855
+ * canonical Claude Code names this extractor branches on. Without this, Qwen's
856
+ * `run_shell_command` events would silently produce zero git/cwd/env extractions.
857
+ *
858
+ * Evidence: refs/platforms/qwen-code/packages/core/src/tools/tool-names.ts
859
+ */
860
+ const TOOL_NAME_NORMALIZE = {
861
+ // Qwen Code / Gemini CLI native names
862
+ run_shell_command: "Bash",
863
+ read_file: "Read",
864
+ read_many_files: "Read",
865
+ grep_search: "Grep",
866
+ search_file_content: "Grep",
867
+ web_fetch: "WebFetch",
868
+ write_file: "Write",
869
+ edit: "Edit",
870
+ glob: "Glob",
871
+ todo_write: "TodoWrite",
872
+ ask_user_question: "AskUserQuestion",
873
+ list_directory: "LS",
874
+ save_memory: "Memory",
875
+ skill: "Skill",
876
+ exit_plan_mode: "ExitPlanMode",
877
+ agent: "Agent",
878
+ // OpenCode native names
879
+ bash: "Bash",
880
+ view: "Read",
881
+ grep: "Grep",
882
+ fetch: "WebFetch",
883
+ // Codex CLI
884
+ shell: "Bash",
885
+ shell_command: "Bash",
886
+ exec_command: "Bash",
887
+ "container.exec": "Bash",
888
+ local_shell: "Bash",
889
+ grep_files: "Grep",
890
+ };
891
+ function normalizeHookInput(input) {
892
+ const normalized = TOOL_NAME_NORMALIZE[input.tool_name];
893
+ if (!normalized || normalized === input.tool_name)
894
+ return input;
895
+ return { ...input, tool_name: normalized };
896
+ }
853
897
  /**
854
898
  * Extract session events from a PostToolUse hook input.
855
899
  *
856
900
  * Accepts the raw hook JSON shape (snake_case keys) as received from stdin.
857
901
  * Returns an array of zero or more SessionEvents. Never throws.
858
902
  */
859
- export function extractEvents(input) {
903
+ export function extractEvents(rawInput) {
860
904
  try {
905
+ const input = normalizeHookInput(rawInput);
861
906
  const events = [];
862
907
  // File + Rule (handles Read/Edit/Write)
863
908
  events.push(...extractFileAndRule(input));