context-mode 1.0.162 → 1.0.164

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 (149) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/.openclaw-plugin/openclaw.plugin.json +1 -1
  5. package/.openclaw-plugin/package.json +1 -1
  6. package/README.md +149 -30
  7. package/bin/statusline.mjs +24 -4
  8. package/build/adapters/antigravity/index.d.ts +1 -1
  9. package/build/adapters/antigravity-cli/index.d.ts +51 -0
  10. package/build/adapters/antigravity-cli/index.js +342 -0
  11. package/build/adapters/claude-code/hooks.d.ts +1 -0
  12. package/build/adapters/claude-code/hooks.js +3 -0
  13. package/build/adapters/claude-code/index.js +24 -5
  14. package/build/adapters/client-map.js +5 -0
  15. package/build/adapters/codex/hooks.d.ts +5 -1
  16. package/build/adapters/codex/hooks.js +5 -1
  17. package/build/adapters/codex/index.d.ts +9 -1
  18. package/build/adapters/codex/index.js +87 -5
  19. package/build/adapters/copilot-cli/hooks.d.ts +33 -0
  20. package/build/adapters/copilot-cli/hooks.js +64 -0
  21. package/build/adapters/copilot-cli/index.d.ts +48 -0
  22. package/build/adapters/copilot-cli/index.js +341 -0
  23. package/build/adapters/detect.d.ts +1 -1
  24. package/build/adapters/detect.js +71 -3
  25. package/build/adapters/openclaw/mcp-tools.js +1 -1
  26. package/build/adapters/opencode/index.js +31 -17
  27. package/build/adapters/opencode/zod3tov4.js +27 -6
  28. package/build/adapters/pi/extension.d.ts +2 -12
  29. package/build/adapters/pi/extension.js +128 -109
  30. package/build/adapters/types.d.ts +5 -4
  31. package/build/adapters/types.js +4 -3
  32. package/build/cache-heal.d.ts +48 -0
  33. package/build/cache-heal.js +150 -0
  34. package/build/cli.js +37 -97
  35. package/build/executor.d.ts +25 -0
  36. package/build/executor.js +143 -22
  37. package/build/lifecycle.d.ts +48 -0
  38. package/build/lifecycle.js +111 -0
  39. package/build/opencode-plugin.js +5 -2
  40. package/build/routing-block.d.ts +8 -0
  41. package/build/routing-block.js +86 -0
  42. package/build/runtime.d.ts +0 -36
  43. package/build/runtime.js +107 -27
  44. package/build/search/flood-guard.d.ts +57 -0
  45. package/build/search/flood-guard.js +80 -0
  46. package/build/security.d.ts +73 -3
  47. package/build/security.js +293 -33
  48. package/build/server.d.ts +14 -0
  49. package/build/server.js +441 -354
  50. package/build/session/analytics.d.ts +1 -1
  51. package/build/session/analytics.js +5 -1
  52. package/build/session/db.js +23 -3
  53. package/build/session/extract.js +78 -0
  54. package/build/store.d.ts +1 -1
  55. package/build/store.js +139 -25
  56. package/build/tool-naming.d.ts +4 -0
  57. package/build/tool-naming.js +24 -0
  58. package/build/util/jsonc.d.ts +14 -0
  59. package/build/util/jsonc.js +104 -0
  60. package/cli.bundle.mjs +253 -250
  61. package/configs/antigravity/GEMINI.md +2 -2
  62. package/configs/antigravity-cli/hooks/hooks.json +37 -0
  63. package/configs/antigravity-cli/hooks.json +37 -0
  64. package/configs/antigravity-cli/mcp_config.json +10 -0
  65. package/configs/antigravity-cli/plugin.json +14 -0
  66. package/configs/antigravity-cli/rules/context-mode.md +77 -0
  67. package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
  68. package/configs/claude-code/CLAUDE.md +2 -2
  69. package/configs/codex/AGENTS.md +2 -2
  70. package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
  71. package/configs/copilot-cli/.mcp.json +12 -0
  72. package/configs/copilot-cli/README.md +47 -0
  73. package/configs/copilot-cli/hooks.json +41 -0
  74. package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
  75. package/configs/gemini-cli/GEMINI.md +2 -2
  76. package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
  77. package/configs/kilo/AGENTS.md +2 -2
  78. package/configs/kiro/KIRO.md +2 -2
  79. package/configs/omp/SYSTEM.md +2 -2
  80. package/configs/openclaw/AGENTS.md +2 -2
  81. package/configs/opencode/AGENTS.md +2 -2
  82. package/configs/qwen-code/QWEN.md +2 -2
  83. package/configs/vscode-copilot/copilot-instructions.md +2 -2
  84. package/configs/zed/AGENTS.md +2 -2
  85. package/hooks/antigravity-cli/payload.mjs +98 -0
  86. package/hooks/antigravity-cli/posttooluse.mjs +138 -0
  87. package/hooks/antigravity-cli/pretooluse.mjs +78 -0
  88. package/hooks/antigravity-cli/stop.mjs +58 -0
  89. package/hooks/codex/pretooluse.mjs +14 -4
  90. package/hooks/codex/stop.mjs +12 -4
  91. package/hooks/copilot-cli/posttooluse.mjs +79 -0
  92. package/hooks/copilot-cli/precompact.mjs +66 -0
  93. package/hooks/copilot-cli/pretooluse.mjs +41 -0
  94. package/hooks/copilot-cli/sessionstart.mjs +121 -0
  95. package/hooks/copilot-cli/stop.mjs +59 -0
  96. package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
  97. package/hooks/core/codex-caps.mjs +112 -0
  98. package/hooks/core/formatters.mjs +158 -7
  99. package/hooks/core/mcp-ready.mjs +37 -8
  100. package/hooks/core/routing.mjs +94 -8
  101. package/hooks/core/tool-naming.mjs +3 -0
  102. package/hooks/hooks.json +12 -1
  103. package/hooks/pretooluse.mjs +6 -2
  104. package/hooks/routing-block.mjs +3 -4
  105. package/hooks/security.bundle.mjs +2 -1
  106. package/hooks/session-db.bundle.mjs +5 -5
  107. package/hooks/session-directive.mjs +88 -20
  108. package/hooks/session-extract.bundle.mjs +2 -2
  109. package/hooks/session-helpers.mjs +21 -0
  110. package/hooks/sessionstart.mjs +37 -5
  111. package/hooks/stop.mjs +49 -0
  112. package/openclaw.plugin.json +1 -1
  113. package/package.json +2 -10
  114. package/server.bundle.mjs +206 -200
  115. package/skills/ctx-insight/SKILL.md +12 -17
  116. package/build/util/db-lock.d.ts +0 -65
  117. package/build/util/db-lock.js +0 -166
  118. package/insight/index.html +0 -13
  119. package/insight/package.json +0 -55
  120. package/insight/server.mjs +0 -1265
  121. package/insight/src/components/analytics.tsx +0 -112
  122. package/insight/src/components/ui/badge.tsx +0 -52
  123. package/insight/src/components/ui/button.tsx +0 -58
  124. package/insight/src/components/ui/card.tsx +0 -103
  125. package/insight/src/components/ui/chart.tsx +0 -371
  126. package/insight/src/components/ui/collapsible.tsx +0 -19
  127. package/insight/src/components/ui/input.tsx +0 -20
  128. package/insight/src/components/ui/progress.tsx +0 -83
  129. package/insight/src/components/ui/scroll-area.tsx +0 -55
  130. package/insight/src/components/ui/separator.tsx +0 -23
  131. package/insight/src/components/ui/table.tsx +0 -114
  132. package/insight/src/components/ui/tabs.tsx +0 -82
  133. package/insight/src/components/ui/tooltip.tsx +0 -64
  134. package/insight/src/lib/api.ts +0 -144
  135. package/insight/src/lib/utils.ts +0 -6
  136. package/insight/src/main.tsx +0 -22
  137. package/insight/src/routeTree.gen.ts +0 -189
  138. package/insight/src/router.tsx +0 -19
  139. package/insight/src/routes/__root.tsx +0 -55
  140. package/insight/src/routes/enterprise.tsx +0 -316
  141. package/insight/src/routes/index.tsx +0 -1482
  142. package/insight/src/routes/knowledge.tsx +0 -221
  143. package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
  144. package/insight/src/routes/search.tsx +0 -97
  145. package/insight/src/routes/sessions.tsx +0 -179
  146. package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
  147. package/insight/src/styles.css +0 -104
  148. package/insight/tsconfig.json +0 -29
  149. package/insight/vite.config.ts +0 -19
@@ -0,0 +1,121 @@
1
+ #!/usr/bin/env node
2
+ import "../suppress-stderr.mjs";
3
+ import "../ensure-deps.mjs";
4
+
5
+ import { createSessionLoaders } from "../session-loaders.mjs";
6
+ import { createRoutingBlock } from "../routing-block.mjs";
7
+ import { createToolNamer } from "../core/tool-naming.mjs";
8
+ import { writeSessionEventsFile, buildSessionDirective, getSessionEvents } from "../session-directive.mjs";
9
+ import {
10
+ readStdin,
11
+ parseStdin,
12
+ getSessionId,
13
+ getSessionDBPath,
14
+ getSessionEventsPath,
15
+ getCleanupFlagPath,
16
+ getInputProjectDir,
17
+ COPILOT_OPTS,
18
+ resolveConfigDir,
19
+ } from "../session-helpers.mjs";
20
+ import { join } from "node:path";
21
+ import { readFileSync, unlinkSync } from "node:fs";
22
+ import { fileURLToPath } from "node:url";
23
+
24
+ const toolNamer = createToolNamer("copilot-cli");
25
+ const ROUTING_BLOCK = createRoutingBlock(toolNamer);
26
+ const HOOK_DIR = fileURLToPath(new URL(".", import.meta.url));
27
+ const { loadSessionDB } = createSessionLoaders(HOOK_DIR);
28
+ const OPTS = COPILOT_OPTS;
29
+
30
+ let additionalContext = ROUTING_BLOCK;
31
+
32
+ try {
33
+ const raw = await readStdin();
34
+ const input = parseStdin(raw);
35
+ const source = input.source ?? "startup";
36
+ const projectDir = getInputProjectDir(input, OPTS);
37
+
38
+ if (source === "compact") {
39
+ const { SessionDB } = await loadSessionDB();
40
+ const dbPath = getSessionDBPath(OPTS, projectDir);
41
+ const db = new SessionDB({ dbPath });
42
+ const sessionId = getSessionId(input, OPTS);
43
+ const resume = db.getResume(sessionId);
44
+
45
+ if (resume && !resume.consumed) {
46
+ db.markResumeConsumed(sessionId);
47
+ }
48
+
49
+ const events = getSessionEvents(db, sessionId);
50
+ if (events.length > 0) {
51
+ const eventMeta = writeSessionEventsFile(events, getSessionEventsPath(OPTS, projectDir));
52
+ additionalContext += buildSessionDirective("compact", eventMeta, toolNamer);
53
+ }
54
+
55
+ db.close();
56
+ } else if (source === "resume") {
57
+ try { unlinkSync(getCleanupFlagPath(OPTS, projectDir)); } catch { /* no flag */ }
58
+
59
+ const { SessionDB } = await loadSessionDB();
60
+ const dbPath = getSessionDBPath(OPTS, projectDir);
61
+ const db = new SessionDB({ dbPath });
62
+
63
+ const sessionId = getSessionId(input, OPTS);
64
+ const events = sessionId ? getSessionEvents(db, sessionId) : [];
65
+ if (events.length > 0) {
66
+ const eventMeta = writeSessionEventsFile(events, getSessionEventsPath(OPTS, projectDir));
67
+ additionalContext += buildSessionDirective("resume", eventMeta, toolNamer);
68
+ }
69
+
70
+ db.close();
71
+ } else if (source === "startup" || source === "new") {
72
+ const { SessionDB } = await loadSessionDB();
73
+ const dbPath = getSessionDBPath(OPTS, projectDir);
74
+ const db = new SessionDB({ dbPath });
75
+ try { unlinkSync(getSessionEventsPath(OPTS, projectDir)); } catch { /* no stale file */ }
76
+
77
+ db.cleanupOldSessions(7);
78
+ db.db.exec(`DELETE FROM session_events WHERE session_id NOT IN (SELECT session_id FROM session_meta)`);
79
+
80
+ const sessionId = getSessionId(input, OPTS);
81
+ db.ensureSession(sessionId, projectDir);
82
+
83
+ const ruleFilePaths = [
84
+ join(projectDir, ".github", "copilot-instructions.md"),
85
+ join(projectDir, "AGENTS.md"),
86
+ ];
87
+ for (const p of ruleFilePaths) {
88
+ try {
89
+ const content = readFileSync(p, "utf-8");
90
+ if (content.trim()) {
91
+ db.insertEvent(sessionId, { type: "rule", category: "rule", data: p, priority: 1 });
92
+ db.insertEvent(sessionId, { type: "rule_content", category: "rule", data: content, priority: 1 });
93
+ }
94
+ } catch {
95
+ /* file does not exist - skip */
96
+ }
97
+ }
98
+
99
+ db.close();
100
+ }
101
+ } catch (err) {
102
+ // Error telemetry is opt-in via CONTEXT_MODE_DEBUG (same pattern as the kimi
103
+ // hooks) so we don't write an append-only log to every user's config dir on a
104
+ // transient SessionStart error. See #787 review.
105
+ if (process.env.CONTEXT_MODE_DEBUG) {
106
+ try {
107
+ const { appendFileSync, mkdirSync } = await import("node:fs");
108
+ const { join: pjoin, dirname: pdirname } = await import("node:path");
109
+ const logPath = pjoin(resolveConfigDir(OPTS), "context-mode", "sessionstart-debug.log");
110
+ mkdirSync(pdirname(logPath), { recursive: true });
111
+ appendFileSync(
112
+ logPath,
113
+ `[${new Date().toISOString()}] ${err?.message || err}\n${err?.stack || ""}\n`,
114
+ );
115
+ } catch {
116
+ /* ignore logging failure */
117
+ }
118
+ }
119
+ }
120
+
121
+ console.log(JSON.stringify({ additionalContext }));
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env node
2
+ import "../suppress-stderr.mjs";
3
+ import "../ensure-deps.mjs";
4
+ /**
5
+ * GitHub Copilot CLI Stop hook — record session-end state for continuity.
6
+ * Capture-only (emits no output). Parsed via the shared session helpers with
7
+ * COPILOT_OPTS.
8
+ */
9
+
10
+ import {
11
+ readStdin,
12
+ parseStdin,
13
+ getSessionId,
14
+ getSessionDBPath,
15
+ getInputProjectDir,
16
+ COPILOT_OPTS,
17
+ } from "../session-helpers.mjs";
18
+ import { createSessionLoaders } from "../session-loaders.mjs";
19
+ import { dirname } from "node:path";
20
+ import { fileURLToPath } from "node:url";
21
+
22
+ const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
23
+ const { loadSessionDB } = createSessionLoaders(HOOK_DIR);
24
+ const OPTS = COPILOT_OPTS;
25
+
26
+ try {
27
+ const raw = await readStdin();
28
+ const input = parseStdin(raw);
29
+ const projectDir = getInputProjectDir(input, OPTS);
30
+
31
+ const { SessionDB } = await loadSessionDB();
32
+ const dbPath = getSessionDBPath(OPTS, projectDir);
33
+ const db = new SessionDB({ dbPath });
34
+ const sessionId = getSessionId(input, OPTS);
35
+
36
+ db.ensureSession(sessionId, projectDir);
37
+ // insertEvent hashes event.data, so type/category/priority/data are all
38
+ // required — a session_end with only `type` throws on createHash(undefined)
39
+ // and is silently dropped (the latent codex/stop.mjs bug). Provide real
40
+ // fields so the session-end row actually persists.
41
+ const lastMessage =
42
+ typeof input.last_assistant_message === "string"
43
+ ? input.last_assistant_message.slice(0, 2000)
44
+ : "";
45
+ db.insertEvent(
46
+ sessionId,
47
+ {
48
+ type: "session_end",
49
+ category: "session",
50
+ priority: 1,
51
+ data: lastMessage || "session ended",
52
+ },
53
+ "Stop",
54
+ );
55
+
56
+ db.close();
57
+ } catch {
58
+ /* a hook must never fail the host */
59
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ import "../suppress-stderr.mjs";
3
+ import "../ensure-deps.mjs";
4
+ /**
5
+ * GitHub Copilot CLI UserPromptSubmit hook — capture genuine user prompts for
6
+ * session continuity. Capture-only (emits no output; Copilot CLI ignores hook
7
+ * stdout in auto-run mode). Copilot fires this with a snake_case payload, parsed
8
+ * via the shared session helpers with COPILOT_OPTS.
9
+ */
10
+
11
+ import {
12
+ readStdin,
13
+ parseStdin,
14
+ getSessionId,
15
+ getSessionDBPath,
16
+ getInputProjectDir,
17
+ COPILOT_OPTS,
18
+ } from "../session-helpers.mjs";
19
+ import { createSessionLoaders, attributeAndInsertEvents } from "../session-loaders.mjs";
20
+ import { dirname } from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ const HOOK_DIR = dirname(fileURLToPath(import.meta.url));
24
+ const { loadSessionDB, loadExtract, loadProjectAttribution } = createSessionLoaders(HOOK_DIR);
25
+ const OPTS = COPILOT_OPTS;
26
+
27
+ try {
28
+ const raw = await readStdin();
29
+ const input = parseStdin(raw);
30
+ const projectDir = getInputProjectDir(input, OPTS);
31
+
32
+ const prompt = input.prompt ?? input.user_prompt ?? input.message ?? "";
33
+ const trimmed = (typeof prompt === "string" ? prompt : "").trim();
34
+
35
+ // Skip host-injected system/tool envelopes — only capture genuine user input.
36
+ const isSystemMessage =
37
+ trimmed.startsWith("<task-notification>") ||
38
+ trimmed.startsWith("<system-reminder>") ||
39
+ trimmed.startsWith("<context_guidance>") ||
40
+ trimmed.startsWith("<tool-result>");
41
+
42
+ if (trimmed.length > 0 && !isSystemMessage) {
43
+ const { SessionDB } = await loadSessionDB();
44
+ const { extractUserEvents } = await loadExtract();
45
+ const { resolveProjectAttributions } = await loadProjectAttribution();
46
+ const dbPath = getSessionDBPath(OPTS, projectDir);
47
+ const db = new SessionDB({ dbPath });
48
+ const sessionId = getSessionId(input, OPTS);
49
+
50
+ db.ensureSession(sessionId, projectDir);
51
+
52
+ const promptEvent = { type: "user_prompt", category: "user-prompt", data: prompt, priority: 1 };
53
+ const promptAttributions = attributeAndInsertEvents(
54
+ db, sessionId, [promptEvent], input, projectDir, "UserPromptSubmit", resolveProjectAttributions,
55
+ );
56
+
57
+ const userEvents = extractUserEvents(trimmed);
58
+ const sessionStats = db.getSessionStats?.(sessionId);
59
+ const lastKnownProjectDir =
60
+ typeof db.getLatestAttributedProjectDir === "function"
61
+ ? db.getLatestAttributedProjectDir(sessionId)
62
+ : null;
63
+ const userAttributions = resolveProjectAttributions(userEvents, {
64
+ sessionOriginDir: sessionStats?.project_dir || projectDir,
65
+ inputProjectDir: projectDir,
66
+ workspaceRoots: Array.isArray(input.workspace_roots) ? input.workspace_roots : [],
67
+ lastKnownProjectDir: promptAttributions[0]?.projectDir || lastKnownProjectDir,
68
+ });
69
+ for (let i = 0; i < userEvents.length; i++) {
70
+ db.insertEvent(sessionId, userEvents[i], "UserPromptSubmit", userAttributions[i]);
71
+ }
72
+
73
+ db.close();
74
+ }
75
+ } catch {
76
+ /* a hook must never fail the host */
77
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Codex capability detection for the PreToolUse formatter (#845).
3
+ *
4
+ * Recent Codex builds honor PreToolUse `permissionDecision:"allow" + updatedInput`
5
+ * (command rewrite) and `additionalContext`. Older builds reject/ignore those
6
+ * fields. context-mode must emit the rewrite shape ONLY when the running Codex
7
+ * supports it and otherwise fail closed (deny) — it must never silently pass a
8
+ * redirect through.
9
+ *
10
+ * Detection parses `codex --version` and compares it against the first version
11
+ * verified to honor the contract. The result is cached to a temp file with a
12
+ * short TTL so the hot PreToolUse path does not spawn a process on every tool
13
+ * call. Any failure (no codex on PATH, parse error) → false → fail closed.
14
+ *
15
+ * There is intentionally NO opt-in env flag — those rot into dead code because
16
+ * nobody exercises the off-by-default path. The correct behavior is detected at
17
+ * runtime so it is always the default.
18
+ */
19
+ import { execFileSync } from "node:child_process";
20
+ import { readFileSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ /**
25
+ * First Codex release verified to honor PreToolUse allow+updatedInput and
26
+ * additionalContext: codex-cli 0.141.0 (#845, validated against the shipped
27
+ * binary's output_parser). Below this we fail closed.
28
+ */
29
+ export const MIN_REWRITE_VERSION = [0, 141, 0];
30
+
31
+ const CACHE_TTL_MS = 60 * 60 * 1000; // re-probe at most hourly
32
+ const CACHE_FILE = "context-mode-codex-caps.json";
33
+
34
+ /** Parse a `codex --version` line ("codex-cli 0.141.0") → [major, minor, patch]. */
35
+ export function parseCodexVersion(raw) {
36
+ const s = String(raw ?? "");
37
+ const isDigit = (c) => c >= "0" && c <= "9";
38
+ for (let i = 0; i < s.length; i++) {
39
+ let j = i;
40
+ const parts = [];
41
+ while (parts.length < 3) {
42
+ const start = j;
43
+ while (j < s.length && isDigit(s[j])) j++;
44
+ if (j === start) break; // no digits where a number was expected
45
+ parts.push(Number(s.slice(start, j)));
46
+ if (parts.length < 3) {
47
+ if (s[j] !== ".") break; // separator must be a dot
48
+ j++;
49
+ }
50
+ }
51
+ if (parts.length === 3) return parts;
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** Semantic ">=" over [major, minor, patch] tuples. */
57
+ export function versionGte(a, b) {
58
+ for (let i = 0; i < 3; i++) {
59
+ const x = a[i] ?? 0;
60
+ const y = b[i] ?? 0;
61
+ if (x > y) return true;
62
+ if (x < y) return false;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ function defaultRunVersion() {
68
+ const opts = { encoding: "utf8", timeout: 2000, stdio: ["ignore", "pipe", "ignore"] };
69
+ // Mirror the adapter's cross-platform probe (src/adapters/codex/index.ts):
70
+ // on Windows `codex` resolves to a .cmd shim that execFile cannot launch
71
+ // directly, so route through cmd.exe.
72
+ return process.platform === "win32"
73
+ ? execFileSync("cmd.exe", ["/d", "/s", "/c", "codex --version"], opts)
74
+ : execFileSync("codex", ["--version"], opts);
75
+ }
76
+
77
+ /**
78
+ * Whether the running Codex honors PreToolUse allow+updatedInput /
79
+ * additionalContext. Fails closed (false) on any error. Cached to a temp file
80
+ * with a TTL so the hot path avoids per-call process spawns.
81
+ *
82
+ * @param {object} [io] test seams
83
+ * @param {() => string} [io.runVersion] returns `codex --version` stdout
84
+ * @param {() => number} [io.now] clock in ms
85
+ * @param {string} [io.cachePath] cache file path
86
+ * @returns {boolean}
87
+ */
88
+ export function codexSupportsUpdatedInput(io = {}) {
89
+ const now = io.now ?? Date.now;
90
+ const cachePath = io.cachePath ?? join(tmpdir(), CACHE_FILE);
91
+ const runVersion = io.runVersion ?? defaultRunVersion;
92
+
93
+ // Fast path: a non-expired cache entry.
94
+ try {
95
+ const cached = JSON.parse(readFileSync(cachePath, "utf8"));
96
+ if (cached && typeof cached.at === "number" && now() - cached.at < CACHE_TTL_MS) {
97
+ return cached.supported === true;
98
+ }
99
+ } catch { /* cache miss / corrupt — re-detect below */ }
100
+
101
+ let supported = false;
102
+ try {
103
+ const version = parseCodexVersion(runVersion());
104
+ supported = version ? versionGte(version, MIN_REWRITE_VERSION) : false;
105
+ } catch {
106
+ supported = false; // no codex on PATH / probe failed → fail closed
107
+ }
108
+
109
+ try { writeFileSync(cachePath, JSON.stringify({ at: now(), supported })); } catch { /* best effort */ }
110
+
111
+ return supported;
112
+ }
@@ -105,6 +105,29 @@ export const formatters = {
105
105
  }),
106
106
  },
107
107
 
108
+ // GitHub Copilot CLI uses top-level decision fields (NOT the VS Code
109
+ // hookSpecificOutput wrapper) — matches CopilotCliAdapter.format*Response.
110
+ "copilot-cli": {
111
+ deny: (reason) => ({
112
+ permissionDecision: "deny",
113
+ permissionDecisionReason: reason,
114
+ }),
115
+ // Carry the reason on `ask` too, so the user sees WHY confirmation is
116
+ // requested (Copilot CLI honors permissionDecisionReason; matches the
117
+ // adapter's formatPreToolUseResponse ask branch). Fall back when the
118
+ // routing decision carries no reason, so the prompt is never bare.
119
+ ask: (reason) => ({
120
+ permissionDecision: "ask",
121
+ permissionDecisionReason: reason ?? "Action requires user confirmation",
122
+ }),
123
+ modify: (updatedInput) => ({
124
+ modifiedArgs: updatedInput,
125
+ }),
126
+ context: (additionalContext) => ({
127
+ additionalContext,
128
+ }),
129
+ },
130
+
108
131
  "jetbrains-copilot": {
109
132
  deny: (reason) => ({
110
133
  permissionDecision: "deny",
@@ -137,9 +160,44 @@ export const formatters = {
137
160
  permissionDecisionReason: reason,
138
161
  },
139
162
  }),
140
- ask: () => null, // Codex rejects permissionDecision: "ask" in PreToolUse
141
- modify: () => null, // Codex rejects updatedInput in PreToolUse
142
- context: () => null, // Codex rejects additionalContext in PreToolUse (fails open)
163
+ // Codex still rejects permissionDecision:"ask" in PreToolUse (verified
164
+ // against codex-cli 0.141.0 output_parser.rs). Keep dropping it.
165
+ ask: () => null,
166
+ // #845: modern Codex (>= 0.141.0) honors permissionDecision:"allow" +
167
+ // updatedInput (command rewrite). Emit it when the running Codex supports
168
+ // it; otherwise FAIL CLOSED — turn the redirect into an enforceable deny
169
+ // carrying the same guidance, so the bytes-flood guard never silently
170
+ // passes through. `codexSupportsRewrite` is detected at runtime by the
171
+ // codex hook (hooks/core/codex-caps.mjs) and threaded in via formatDecision.
172
+ modify: (updatedInput, { codexSupportsRewrite } = {}) => {
173
+ if (codexSupportsRewrite) {
174
+ return {
175
+ hookSpecificOutput: {
176
+ hookEventName: "PreToolUse",
177
+ permissionDecision: "allow",
178
+ updatedInput,
179
+ },
180
+ };
181
+ }
182
+ const ui = updatedInput ?? {};
183
+ // Only command redirects must fail closed. Non-command rewrites (e.g.
184
+ // Agent prompt injection) are advisory — drop rather than block the tool.
185
+ if (!("command" in ui)) return null;
186
+ return {
187
+ hookSpecificOutput: {
188
+ hookEventName: "PreToolUse",
189
+ permissionDecision: "deny",
190
+ permissionDecisionReason: codexRedirectReason(ui.command),
191
+ },
192
+ };
193
+ },
194
+ // #845: surface additionalContext on Codex builds that support it; older
195
+ // builds ignore the field, so drop the advisory nudge rather than emit a
196
+ // shape they reject.
197
+ context: (additionalContext, { codexSupportsRewrite } = {}) =>
198
+ codexSupportsRewrite
199
+ ? { hookSpecificOutput: { hookEventName: "PreToolUse", additionalContext } }
200
+ : null,
143
201
  },
144
202
 
145
203
  "kimi": {
@@ -168,6 +226,36 @@ export const formatters = {
168
226
  context: () => null, // Kimi HookResult has no additionalContext field
169
227
  },
170
228
 
229
+ "antigravity-cli": {
230
+ // agy PreToolUse accepts the Claude-compatible top-level decision shape.
231
+ // agy 1.0.6 does NOT honor PreToolUse additionalContext (verified by
232
+ // transcript probe), so context guidance must become an enforceable deny
233
+ // or it disappears and the native tool runs unchanged.
234
+ deny: (reason) => ({ decision: "deny", reason }),
235
+ // Carry a fallback reason on `ask` so a security-policy ask (routing emits
236
+ // {action:"ask"} with no reason) never shows a bare, unexplained prompt.
237
+ ask: (reason) => ({ decision: "ask", reason: reason ?? "Action requires user confirmation" }),
238
+ // agy cannot modify tool args, so a routing `modify` becomes a deny. Surface
239
+ // the per-tool redirect guidance routing carried in `updatedInput.command`
240
+ // (an `echo "<guidance>"` payload that already uses agy's context-mode/<tool>
241
+ // surface) instead of a generic line; fall back to the generic redirect.
242
+ modify: (updatedInput) => {
243
+ const cmd = updatedInput?.command ?? updatedInput?.CommandLine ?? "";
244
+ const m = String(cmd).match(/^echo\s+"([\s\S]*)"\s*$/);
245
+ const guidance = m ? m[1].replace(/\\(["\\])/g, "$1") : "";
246
+ return {
247
+ decision: "deny",
248
+ reason:
249
+ guidance ||
250
+ "context-mode: redirected. Use the context-mode MCP tools (ctx_execute / ctx_fetch_and_index / ctx_search) so raw bytes stay out of the conversation.",
251
+ };
252
+ },
253
+ context: (additionalContext) => ({
254
+ decision: "deny",
255
+ reason: agyContextReason(additionalContext),
256
+ }),
257
+ },
258
+
171
259
  "cursor": {
172
260
  deny: (reason) => ({
173
261
  permission: "deny",
@@ -185,11 +273,72 @@ export const formatters = {
185
273
  },
186
274
  };
187
275
 
276
+ // Keep in sync with the identical agyContextReason in
277
+ // src/adapters/antigravity-cli/index.ts: this bundled .mjs formatter (runtime
278
+ // hook path) and the TS adapter are separate layers; the text must not drift.
279
+ function agyContextReason(additionalContext) {
280
+ const text = String(additionalContext ?? "")
281
+ .replace(/<\/?context_guidance>/g, " ")
282
+ .replace(/<\/?tip>/g, " ")
283
+ .replace(/\s+/g, " ")
284
+ .trim();
285
+ return text
286
+ ? `context-mode: use the context-mode MCP tools instead of this native tool. ${text}`
287
+ : "context-mode: use the context-mode MCP tools instead of this native tool so raw bytes stay out of the conversation.";
288
+ }
289
+
290
+ // #845: routing wraps redirect guidance as `echo "<guidance>"`. Unwrap a command
291
+ // that is exactly `echo "<inner>"` (with optional surrounding whitespace) and
292
+ // return the inner string, or null when the shape doesn't match. Greedy: inner
293
+ // runs from the first `"` after `echo` to the last `"` before trailing space.
294
+ function unwrapEcho(command) {
295
+ const s = String(command ?? "");
296
+ // Match the regex `\s` class exactly: space, tab, newline, carriage return,
297
+ // form feed, vertical tab (so behavior is identical to /^echo\s+"…"\s*$/).
298
+ const isWs = (c) =>
299
+ c === " " || c === "\t" || c === "\n" || c === "\r" || c === "\f" || c === "\v";
300
+ if (!s.startsWith("echo")) return null;
301
+ let i = 4;
302
+ if (i >= s.length || !isWs(s[i])) return null; // `echo` must be followed by whitespace
303
+ while (i < s.length && isWs(s[i])) i++;
304
+ if (s[i] !== "\"") return null; // payload must open with a quote
305
+ let end = s.length;
306
+ while (end > 0 && isWs(s[end - 1])) end--; // drop trailing whitespace
307
+ if (end <= i + 1 || s[end - 1] !== "\"") return null; // must close with a quote
308
+ return s.slice(i + 1, end - 1);
309
+ }
310
+
311
+ // Reverse the shell double-quote escaping routing applied: `\"` → `"`, `\\` → `\`.
312
+ function unescapeDquote(s) {
313
+ let out = "";
314
+ for (let i = 0; i < s.length; i++) {
315
+ if (s[i] === "\\" && (s[i + 1] === "\"" || s[i + 1] === "\\")) {
316
+ out += s[i + 1];
317
+ i++;
318
+ } else {
319
+ out += s[i];
320
+ }
321
+ }
322
+ return out;
323
+ }
324
+
325
+ // When Codex cannot rewrite the command we surface that guidance as the deny
326
+ // reason instead (mirrors the claude-code / antigravity-cli echo extraction).
327
+ function codexRedirectReason(command) {
328
+ const inner = unwrapEcho(command);
329
+ if (inner !== null) return unescapeDquote(inner);
330
+ return "context-mode: command redirected. Use the context-mode MCP tools (ctx_execute / ctx_fetch_and_index / ctx_search) so raw output stays out of the conversation.";
331
+ }
332
+
188
333
  /**
189
334
  * Apply a formatter to a normalized routing decision.
190
335
  * Returns the platform-specific JSON response, or null for passthrough.
336
+ *
337
+ * `opts` carries optional per-platform capability hints (e.g. codex
338
+ * `codexSupportsRewrite`). Formatters that ignore the extra argument are
339
+ * unaffected.
191
340
  */
192
- export function formatDecision(platform, decision) {
341
+ export function formatDecision(platform, decision, opts = {}) {
193
342
  if (!decision) return null;
194
343
 
195
344
  const fmt = formatters[platform];
@@ -197,9 +346,11 @@ export function formatDecision(platform, decision) {
197
346
 
198
347
  switch (decision.action) {
199
348
  case "deny": return fmt.deny(decision.reason);
200
- case "ask": return fmt.ask();
201
- case "modify": return fmt.modify(decision.updatedInput);
202
- case "context": return fmt.context(decision.additionalContext);
349
+ // Pass the reason to ask() too — platforms whose ask formatter ignores it
350
+ // (legacy `ask: () => …`) are unaffected; copilot-cli surfaces it.
351
+ case "ask": return fmt.ask(decision.reason);
352
+ case "modify": return fmt.modify(decision.updatedInput, opts);
353
+ case "context": return fmt.context(decision.additionalContext, opts);
203
354
  default: return null;
204
355
  }
205
356
  }
@@ -11,12 +11,22 @@
11
11
  * Sentinel path: <tmpRoot>/context-mode-mcp-ready-<MCP_PID>
12
12
  * Scan: glob all context-mode-mcp-ready-* files, probe each PID.
13
13
  */
14
- import { readFileSync, readdirSync, unlinkSync } from "node:fs";
14
+ import { readFileSync, readdirSync, statSync, unlinkSync } from "node:fs";
15
15
  import { tmpdir } from "node:os";
16
16
  import { join } from "node:path";
17
17
 
18
18
  const SENTINEL_PREFIX = "context-mode-mcp-ready-";
19
19
 
20
+ /**
21
+ * Sentinel freshness window (#844). The MCP server refreshes its sentinel's
22
+ * mtime every 30s while alive (see `main()` in src/server.ts). A sentinel
23
+ * touched within this window is treated as a live server even when
24
+ * `process.kill(pid, 0)` cannot see the PID — e.g. a sandbox sharing /tmp
25
+ * across an isolated PID namespace, where the live host PID is invisible.
26
+ * 90s = 3x the server refresh interval, tolerant of scheduler jitter / load.
27
+ */
28
+ const SENTINEL_FRESH_MS = 90_000;
29
+
20
30
  /**
21
31
  * Resolve the temp root — hardcoded /tmp on Unix to avoid TMPDIR mismatch.
22
32
  * Tests may override via CONTEXT_MODE_MCP_SENTINEL_DIR to isolate scan from
@@ -54,23 +64,42 @@ export function sentinelPath() {
54
64
  *
55
65
  * Handles:
56
66
  * - PPID mismatch (WSL2 shell wrappers) — no ppid dependency
57
- * - Stale sentinels (SIGKILL, OOM) — PID liveness check
67
+ * - Stale sentinels (SIGKILL, OOM) — PID liveness check + age threshold
58
68
  * - TMPDIR mismatch — hardcoded /tmp on Unix
69
+ * - Shared /tmp across isolated PID namespaces (#844) — a live host PID is
70
+ * invisible to `kill(pid, 0)` from a sandbox, so a recently-refreshed
71
+ * sentinel is trusted instead of being deleted.
59
72
  */
60
73
  export function isMCPReady() {
61
74
  try {
62
75
  const dir = sentinelDir();
63
76
  const files = readdirSync(dir).filter(f => f.startsWith(SENTINEL_PREFIX));
77
+ const now = Date.now();
64
78
  for (const f of files) {
65
79
  const fullPath = join(dir, f);
80
+ let pid;
66
81
  try {
67
- const pid = parseInt(readFileSync(fullPath, "utf8"), 10);
68
- if (isNaN(pid)) continue;
69
- process.kill(pid, 0); // throws if process doesn't exist
70
- return true;
82
+ pid = parseInt(readFileSync(fullPath, "utf8"), 10);
71
83
  } catch {
72
- // Dead PID or unreadable clean up stale sentinel
73
- try { unlinkSync(fullPath); } catch {}
84
+ // Unreadable (torn mid-write)leave it for the owner / a later scan.
85
+ continue;
86
+ }
87
+ if (isNaN(pid)) continue;
88
+ try {
89
+ process.kill(pid, 0); // throws if the PID is not signalable from here
90
+ return true; // same-namespace liveness confirmed
91
+ } catch (err) {
92
+ // EPERM: the process exists but is owned by another user → alive.
93
+ if (err && err.code === "EPERM") return true;
94
+ // ESRCH (or anything else): the PID is invisible from THIS namespace.
95
+ // That is NOT proof the server is dead — a shared /tmp across isolated
96
+ // PID namespaces (#844) hides a live host PID. Trust a recently
97
+ // refreshed sentinel rather than delete a live server's marker.
98
+ let ageMs = Infinity;
99
+ try { ageMs = now - statSync(fullPath).mtimeMs; } catch { /* stat failed → treat as stale */ }
100
+ if (ageMs < SENTINEL_FRESH_MS) return true;
101
+ // Old AND unprobeable → genuinely stale (crash / OOM / SIGKILL) → clean up.
102
+ try { unlinkSync(fullPath); } catch { /* best effort */ }
74
103
  }
75
104
  }
76
105
  return false;