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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/.openclaw-plugin/openclaw.plugin.json +1 -1
- package/.openclaw-plugin/package.json +1 -1
- package/README.md +149 -30
- package/bin/statusline.mjs +24 -4
- package/build/adapters/antigravity/index.d.ts +1 -1
- package/build/adapters/antigravity-cli/index.d.ts +51 -0
- package/build/adapters/antigravity-cli/index.js +342 -0
- package/build/adapters/claude-code/hooks.d.ts +1 -0
- package/build/adapters/claude-code/hooks.js +3 -0
- package/build/adapters/claude-code/index.js +24 -5
- package/build/adapters/client-map.js +5 -0
- package/build/adapters/codex/hooks.d.ts +5 -1
- package/build/adapters/codex/hooks.js +5 -1
- package/build/adapters/codex/index.d.ts +9 -1
- package/build/adapters/codex/index.js +87 -5
- package/build/adapters/copilot-cli/hooks.d.ts +33 -0
- package/build/adapters/copilot-cli/hooks.js +64 -0
- package/build/adapters/copilot-cli/index.d.ts +48 -0
- package/build/adapters/copilot-cli/index.js +341 -0
- package/build/adapters/detect.d.ts +1 -1
- package/build/adapters/detect.js +71 -3
- package/build/adapters/openclaw/mcp-tools.js +1 -1
- package/build/adapters/opencode/index.js +31 -17
- package/build/adapters/opencode/zod3tov4.js +27 -6
- package/build/adapters/pi/extension.d.ts +2 -12
- package/build/adapters/pi/extension.js +128 -109
- package/build/adapters/types.d.ts +5 -4
- package/build/adapters/types.js +4 -3
- package/build/cache-heal.d.ts +48 -0
- package/build/cache-heal.js +150 -0
- package/build/cli.js +37 -97
- package/build/executor.d.ts +25 -0
- package/build/executor.js +143 -22
- package/build/lifecycle.d.ts +48 -0
- package/build/lifecycle.js +111 -0
- package/build/opencode-plugin.js +5 -2
- package/build/routing-block.d.ts +8 -0
- package/build/routing-block.js +86 -0
- package/build/runtime.d.ts +0 -36
- package/build/runtime.js +107 -27
- package/build/search/flood-guard.d.ts +57 -0
- package/build/search/flood-guard.js +80 -0
- package/build/security.d.ts +73 -3
- package/build/security.js +293 -33
- package/build/server.d.ts +14 -0
- package/build/server.js +441 -354
- package/build/session/analytics.d.ts +1 -1
- package/build/session/analytics.js +5 -1
- package/build/session/db.js +23 -3
- package/build/session/extract.js +78 -0
- package/build/store.d.ts +1 -1
- package/build/store.js +139 -25
- package/build/tool-naming.d.ts +4 -0
- package/build/tool-naming.js +24 -0
- package/build/util/jsonc.d.ts +14 -0
- package/build/util/jsonc.js +104 -0
- package/cli.bundle.mjs +253 -250
- package/configs/antigravity/GEMINI.md +2 -2
- package/configs/antigravity-cli/hooks/hooks.json +37 -0
- package/configs/antigravity-cli/hooks.json +37 -0
- package/configs/antigravity-cli/mcp_config.json +10 -0
- package/configs/antigravity-cli/plugin.json +14 -0
- package/configs/antigravity-cli/rules/context-mode.md +77 -0
- package/configs/antigravity-cli/skills/context-mode/SKILL.md +77 -0
- package/configs/claude-code/CLAUDE.md +2 -2
- package/configs/codex/AGENTS.md +2 -2
- package/configs/copilot-cli/.github/plugin/plugin.json +23 -0
- package/configs/copilot-cli/.mcp.json +12 -0
- package/configs/copilot-cli/README.md +47 -0
- package/configs/copilot-cli/hooks.json +41 -0
- package/configs/copilot-cli/skills/context-mode/SKILL.md +38 -0
- package/configs/gemini-cli/GEMINI.md +2 -2
- package/configs/jetbrains-copilot/copilot-instructions.md +2 -2
- package/configs/kilo/AGENTS.md +2 -2
- package/configs/kiro/KIRO.md +2 -2
- package/configs/omp/SYSTEM.md +2 -2
- package/configs/openclaw/AGENTS.md +2 -2
- package/configs/opencode/AGENTS.md +2 -2
- package/configs/qwen-code/QWEN.md +2 -2
- package/configs/vscode-copilot/copilot-instructions.md +2 -2
- package/configs/zed/AGENTS.md +2 -2
- package/hooks/antigravity-cli/payload.mjs +98 -0
- package/hooks/antigravity-cli/posttooluse.mjs +138 -0
- package/hooks/antigravity-cli/pretooluse.mjs +78 -0
- package/hooks/antigravity-cli/stop.mjs +58 -0
- package/hooks/codex/pretooluse.mjs +14 -4
- package/hooks/codex/stop.mjs +12 -4
- package/hooks/copilot-cli/posttooluse.mjs +79 -0
- package/hooks/copilot-cli/precompact.mjs +66 -0
- package/hooks/copilot-cli/pretooluse.mjs +41 -0
- package/hooks/copilot-cli/sessionstart.mjs +121 -0
- package/hooks/copilot-cli/stop.mjs +59 -0
- package/hooks/copilot-cli/userpromptsubmit.mjs +77 -0
- package/hooks/core/codex-caps.mjs +112 -0
- package/hooks/core/formatters.mjs +158 -7
- package/hooks/core/mcp-ready.mjs +37 -8
- package/hooks/core/routing.mjs +94 -8
- package/hooks/core/tool-naming.mjs +3 -0
- package/hooks/hooks.json +12 -1
- package/hooks/pretooluse.mjs +6 -2
- package/hooks/routing-block.mjs +3 -4
- package/hooks/security.bundle.mjs +2 -1
- package/hooks/session-db.bundle.mjs +5 -5
- package/hooks/session-directive.mjs +88 -20
- package/hooks/session-extract.bundle.mjs +2 -2
- package/hooks/session-helpers.mjs +21 -0
- package/hooks/sessionstart.mjs +37 -5
- package/hooks/stop.mjs +49 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -10
- package/server.bundle.mjs +206 -200
- package/skills/ctx-insight/SKILL.md +12 -17
- package/build/util/db-lock.d.ts +0 -65
- package/build/util/db-lock.js +0 -166
- package/insight/index.html +0 -13
- package/insight/package.json +0 -55
- package/insight/server.mjs +0 -1265
- package/insight/src/components/analytics.tsx +0 -112
- package/insight/src/components/ui/badge.tsx +0 -52
- package/insight/src/components/ui/button.tsx +0 -58
- package/insight/src/components/ui/card.tsx +0 -103
- package/insight/src/components/ui/chart.tsx +0 -371
- package/insight/src/components/ui/collapsible.tsx +0 -19
- package/insight/src/components/ui/input.tsx +0 -20
- package/insight/src/components/ui/progress.tsx +0 -83
- package/insight/src/components/ui/scroll-area.tsx +0 -55
- package/insight/src/components/ui/separator.tsx +0 -23
- package/insight/src/components/ui/table.tsx +0 -114
- package/insight/src/components/ui/tabs.tsx +0 -82
- package/insight/src/components/ui/tooltip.tsx +0 -64
- package/insight/src/lib/api.ts +0 -144
- package/insight/src/lib/utils.ts +0 -6
- package/insight/src/main.tsx +0 -22
- package/insight/src/routeTree.gen.ts +0 -189
- package/insight/src/router.tsx +0 -19
- package/insight/src/routes/__root.tsx +0 -55
- package/insight/src/routes/enterprise.tsx +0 -316
- package/insight/src/routes/index.tsx +0 -1482
- package/insight/src/routes/knowledge.tsx +0 -221
- package/insight/src/routes/knowledge_.$dbHash.$sourceId.tsx +0 -137
- package/insight/src/routes/search.tsx +0 -97
- package/insight/src/routes/sessions.tsx +0 -179
- package/insight/src/routes/sessions_.$dbHash.$sessionId.tsx +0 -181
- package/insight/src/styles.css +0 -104
- package/insight/tsconfig.json +0 -29
- 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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
case "
|
|
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
|
}
|
package/hooks/core/mcp-ready.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
73
|
-
|
|
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;
|