context-mode 1.0.103 → 1.0.105

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 (98) 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/README.md +39 -7
  6. package/bin/statusline.mjs +321 -0
  7. package/build/adapters/antigravity/index.d.ts +6 -0
  8. package/build/adapters/antigravity/index.js +10 -0
  9. package/build/adapters/base.d.ts +23 -0
  10. package/build/adapters/base.js +29 -0
  11. package/build/adapters/codex/index.d.ts +10 -0
  12. package/build/adapters/codex/index.js +22 -4
  13. package/build/adapters/cursor/index.d.ts +7 -0
  14. package/build/adapters/cursor/index.js +11 -0
  15. package/build/adapters/detect.d.ts +12 -1
  16. package/build/adapters/detect.js +69 -7
  17. package/build/adapters/gemini-cli/index.d.ts +8 -1
  18. package/build/adapters/gemini-cli/index.js +19 -7
  19. package/build/adapters/jetbrains-copilot/index.d.ts +7 -0
  20. package/build/adapters/jetbrains-copilot/index.js +12 -0
  21. package/build/adapters/kiro/index.d.ts +8 -0
  22. package/build/adapters/kiro/index.js +12 -0
  23. package/build/adapters/openclaw/index.d.ts +17 -0
  24. package/build/adapters/openclaw/index.js +29 -4
  25. package/build/adapters/opencode/index.d.ts +8 -0
  26. package/build/adapters/opencode/index.js +18 -6
  27. package/build/adapters/qwen-code/index.d.ts +1 -0
  28. package/build/adapters/qwen-code/index.js +3 -0
  29. package/build/adapters/types.d.ts +33 -0
  30. package/build/adapters/vscode-copilot/index.d.ts +6 -0
  31. package/build/adapters/vscode-copilot/index.js +10 -0
  32. package/build/adapters/zed/index.d.ts +1 -0
  33. package/build/adapters/zed/index.js +3 -0
  34. package/build/cli.d.ts +15 -0
  35. package/build/cli.js +62 -16
  36. package/build/concurrency/runPool.d.ts +36 -0
  37. package/build/concurrency/runPool.js +51 -0
  38. package/build/executor.d.ts +11 -1
  39. package/build/executor.js +77 -21
  40. package/build/fetch-cache.d.ts +13 -0
  41. package/build/fetch-cache.js +15 -0
  42. package/build/lifecycle.d.ts +6 -2
  43. package/build/lifecycle.js +29 -2
  44. package/build/opencode-plugin.d.ts +23 -0
  45. package/build/opencode-plugin.js +80 -6
  46. package/build/routing-block.d.ts +8 -0
  47. package/build/routing-block.js +86 -0
  48. package/build/runtime.d.ts +1 -0
  49. package/build/runtime.js +54 -3
  50. package/build/search/auto-memory.d.ts +23 -10
  51. package/build/search/auto-memory.js +64 -26
  52. package/build/search/unified.d.ts +3 -0
  53. package/build/search/unified.js +2 -2
  54. package/build/server.d.ts +47 -0
  55. package/build/server.js +736 -188
  56. package/build/session/analytics.d.ts +49 -1
  57. package/build/session/analytics.js +278 -16
  58. package/build/session/db.d.ts +53 -8
  59. package/build/session/db.js +200 -19
  60. package/build/session/extract.js +124 -2
  61. package/build/tool-naming.d.ts +4 -0
  62. package/build/tool-naming.js +24 -0
  63. package/cli.bundle.mjs +208 -158
  64. package/configs/antigravity/GEMINI.md +11 -0
  65. package/configs/claude-code/CLAUDE.md +11 -0
  66. package/configs/codex/AGENTS.md +11 -0
  67. package/configs/cursor/context-mode.mdc +11 -0
  68. package/configs/gemini-cli/GEMINI.md +11 -0
  69. package/configs/jetbrains-copilot/copilot-instructions.md +3 -0
  70. package/configs/kilo/AGENTS.md +11 -0
  71. package/configs/kiro/KIRO.md +11 -0
  72. package/configs/openclaw/AGENTS.md +11 -0
  73. package/configs/opencode/AGENTS.md +11 -0
  74. package/configs/pi/AGENTS.md +11 -0
  75. package/configs/qwen-code/QWEN.md +11 -0
  76. package/configs/vscode-copilot/copilot-instructions.md +3 -0
  77. package/configs/zed/AGENTS.md +11 -0
  78. package/hooks/auto-injection.mjs +36 -10
  79. package/hooks/cache-heal-utils.mjs +231 -0
  80. package/hooks/codex/sessionstart.mjs +7 -4
  81. package/hooks/core/routing.mjs +8 -2
  82. package/hooks/cursor/sessionstart.mjs +7 -4
  83. package/hooks/formatters/claude-code.mjs +20 -0
  84. package/hooks/gemini-cli/sessionstart.mjs +7 -2
  85. package/hooks/jetbrains-copilot/sessionstart.mjs +7 -2
  86. package/hooks/normalize-hooks.mjs +184 -0
  87. package/hooks/session-db.bundle.mjs +41 -14
  88. package/hooks/session-extract.bundle.mjs +2 -2
  89. package/hooks/session-helpers.mjs +68 -20
  90. package/hooks/session-loaders.mjs +8 -2
  91. package/hooks/sessionstart.mjs +8 -2
  92. package/hooks/vscode-copilot/sessionstart.mjs +7 -2
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +2 -1
  95. package/server.bundle.mjs +181 -134
  96. package/skills/ctx-doctor/SKILL.md +3 -3
  97. package/skills/ctx-insight/SKILL.md +1 -1
  98. package/start.mjs +63 -3
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.103"
9
+ "version": "1.0.105"
10
10
  },
11
11
  "plugins": [
12
12
  {
13
13
  "name": "context-mode",
14
14
  "source": "./",
15
15
  "description": "Claude Code MCP plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
16
- "version": "1.0.103",
16
+ "version": "1.0.105",
17
17
  "author": {
18
18
  "name": "Mert Koseoğlu"
19
19
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.103",
3
+ "version": "1.0.105",
4
4
  "description": "MCP server that saves 98% of your context window with session continuity. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and automatic state restore across compactions.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
@@ -3,7 +3,7 @@
3
3
  "name": "Context Mode",
4
4
  "kind": "tool",
5
5
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
6
- "version": "1.0.103",
6
+ "version": "1.0.105",
7
7
  "sandbox": {
8
8
  "mode": "permissive",
9
9
  "filesystem_access": "full",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "context-mode",
3
- "version": "1.0.103",
3
+ "version": "1.0.105",
4
4
  "description": "OpenClaw plugin that saves 98% of your context window. Sandboxed code execution in 11 languages, FTS5 knowledge base with BM25 ranking, and intent-driven search.",
5
5
  "author": {
6
6
  "name": "Mert Koseoğlu",
package/README.md CHANGED
@@ -83,7 +83,7 @@ Restart Claude Code (or run `/reload-plugins`).
83
83
 
84
84
  All checks should show `[x]`. The doctor validates runtimes, hooks, FTS5, and plugin registration.
85
85
 
86
- **Routing:** Automatic. The SessionStart hook injects routing instructions at runtime — no file is written to your project. The plugin registers all hooks (PreToolUse, PostToolUse, PreCompact, SessionStart) and 6 sandbox tools (`ctx_batch_execute`, `ctx_execute`, `ctx_execute_file`, `ctx_index`, `ctx_search`, `ctx_fetch_and_index`) plus meta-tools (`ctx_stats`, `ctx_doctor`, `ctx_upgrade`, `ctx_purge`, `ctx_insight`).
86
+ **Routing:** Automatic. The SessionStart hook injects routing instructions at runtime — no file is written to your project. The plugin registers all hooks (PreToolUse, PostToolUse, PreCompact, SessionStart) and 11 MCP tools — six sandbox tools (`ctx_batch_execute`, `ctx_execute`, `ctx_execute_file`, `ctx_index`, `ctx_search`, `ctx_fetch_and_index`) plus five meta-tools (`ctx_stats`, `ctx_doctor`, `ctx_upgrade`, `ctx_purge`, `ctx_insight`).
87
87
 
88
88
  | Slash Command | What it does |
89
89
  |---|---|
@@ -95,6 +95,19 @@ All checks should show `[x]`. The doctor validates runtimes, hooks, FTS5, and pl
95
95
 
96
96
  > **Note:** Slash commands are a Claude Code plugin feature. On other platforms, type `ctx stats`, `ctx doctor`, `ctx upgrade`, or `ctx insight` in the chat — the model calls the MCP tool automatically. See [Utility Commands](#utility-commands).
97
97
 
98
+ **Status line (optional):** Claude Code's plugin manifest cannot declare a status line, so this is a one-time manual edit to `~/.claude/settings.json`:
99
+
100
+ ```json
101
+ {
102
+ "statusLine": {
103
+ "type": "command",
104
+ "command": "context-mode statusline"
105
+ }
106
+ }
107
+ ```
108
+
109
+ After saving, restart Claude Code. The bar shows `$ saved this session · $ saved across sessions · % efficient` so you can see savings accumulate in real time. The wiring is path-free — `context-mode statusline` resolves through the bundled CLI regardless of where the plugin cache lives.
110
+
98
111
  <details>
99
112
  <summary>Alternative — MCP-only install (no hooks or slash commands)</summary>
100
113
 
@@ -102,7 +115,7 @@ All checks should show `[x]`. The doctor validates runtimes, hooks, FTS5, and pl
102
115
  claude mcp add context-mode -- npx -y context-mode
103
116
  ```
104
117
 
105
- This gives you the 6 sandbox tools without automatic routing. The model can still use them — it just won't be nudged to prefer them over raw Bash/Read/WebFetch. Good for trying it out before committing to the full plugin.
118
+ This gives you all 11 MCP tools without automatic routing. The model can still use them — it just won't be nudged to prefer them over raw Bash/Read/WebFetch. Good for trying it out before committing to the full plugin.
106
119
 
107
120
  </details>
108
121
 
@@ -389,7 +402,7 @@ Full configs: [`configs/cursor/hooks.json`](configs/cursor/hooks.json) | [`confi
389
402
  }
390
403
  ```
391
404
 
392
- The `mcp` entry registers the 6 sandbox tools. The `plugin` entry enables hooks — OpenCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
405
+ The `mcp` entry registers all 11 MCP tools. The `plugin` entry enables hooks — OpenCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
393
406
 
394
407
  3. *(Optional)* Copy the routing rules file. OpenCode lacks a SessionStart hook, so the model needs an `AGENTS.md` file for routing awareness:
395
408
 
@@ -439,7 +452,7 @@ Full configs: [`configs/opencode/opencode.json`](configs/opencode/opencode.json)
439
452
  }
440
453
  ```
441
454
 
442
- The `mcp` entry registers the 6 sandbox tools. The `plugin` entry enables hooks — KiloCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
455
+ The `mcp` entry registers all 11 MCP tools. The `plugin` entry enables hooks — KiloCode calls the plugin's TypeScript functions directly before and after each tool execution, blocking dangerous commands and enforcing sandbox routing.
443
456
 
444
457
  3. *(Optional)* Copy the routing rules file. KiloCode shares the OpenCode plugin architecture and lacks SessionStart, so the model needs an `AGENTS.md` file for routing awareness:
445
458
 
@@ -835,12 +848,12 @@ npm install -g context-mode
835
848
 
836
849
  | Tool | What it does | Context saved |
837
850
  |---|---|---|
838
- | `ctx_batch_execute` | Run multiple commands + search multiple queries in ONE call. | 986 KB → 62 KB |
851
+ | `ctx_batch_execute` | Run multiple commands + search multiple queries in ONE call. Opt-in `concurrency: 1-8` for I/O-bound batches. | 986 KB → 62 KB |
839
852
  | `ctx_execute` | Run code in 11 languages. Only stdout enters context. | 56 KB → 299 B |
840
853
  | `ctx_execute_file` | Process files in sandbox. Raw content never leaves. | 45 KB → 155 B |
841
854
  | `ctx_index` | Chunk markdown into FTS5 with BM25 ranking. | 60 KB → 40 B |
842
855
  | `ctx_search` | Query indexed content with multiple queries in one call. | On-demand retrieval |
843
- | `ctx_fetch_and_index` | Fetch URL, chunk and index. 24h TTL cache — repeat calls skip network. `force: true` to bypass. | 60 KB → 40 B |
856
+ | `ctx_fetch_and_index` | Fetch URL, chunk and index. 24h TTL cache — repeat calls skip network. `force: true` to bypass. Pass `requests: [{url, source}, ...]` + `concurrency: 1-8` for parallel multi-URL. | 60 KB → 40 B |
844
857
  | `ctx_stats` | Show context savings, call counts, and session statistics. | — |
845
858
  | `ctx_doctor` | Diagnose installation: runtimes, hooks, FTS5, versions. | — |
846
859
  | `ctx_upgrade` | Upgrade to latest version from GitHub, rebuild, reconfigure hooks. | — |
@@ -1055,7 +1068,7 @@ Detailed event data is also indexed into FTS5 for on-demand retrieval via `ctx_s
1055
1068
  >
1056
1069
  > **Kiro** supports native `preToolUse` and `postToolUse` hooks for routing enforcement and tool event capture. `agentSpawn` (SessionStart equivalent) and `stop` are not yet wired. Requires manually copying `KIRO.md` to your project root. Kiro is auto-detected via MCP protocol handshake (`clientInfo.name`).
1057
1070
  >
1058
- > **Pi Coding Agent** runs context-mode as an extension with full hook support. The extension registers `tool_call`, `tool_result`, `session_start`, and `session_before_compact` events, providing high session continuity coverage. The MCP server provides the 6 sandbox tools.
1071
+ > **Pi Coding Agent** runs context-mode as an extension with full hook support. The extension registers `tool_call`, `tool_result`, `session_start`, and `session_before_compact` events, providing high session continuity coverage. The MCP server provides all 11 MCP tools.
1059
1072
 
1060
1073
  ### Routing Enforcement
1061
1074
 
@@ -1207,6 +1220,25 @@ Commands chained with `&&`, `;`, or `|` are split — each part is checked separ
1207
1220
 
1208
1221
  **deny** always wins over **allow**. More specific (project-level) rules override global ones.
1209
1222
 
1223
+ ### Network fetch hardening
1224
+
1225
+ `ctx_fetch_and_index` blocks dangerous URL targets by default:
1226
+
1227
+ - **Schemes**: only `http:` and `https:` allowed (no `file://`, `gopher://`, `javascript:`, `data:`).
1228
+ - **Cloud metadata + link-local**: `169.254.0.0/16` (incl. AWS/GCP/Azure IMDS endpoint `169.254.169.254`) hard-blocked even if a hostname resolves to it (DNS-rebinding defense).
1229
+ - **Multicast / reserved**: `224.0.0.0/4`, `0.0.0.0/8`, IPv6 `ff00::/8`, `fe80::/10` blocked.
1230
+ - **Loopback + RFC1918** (`localhost`, `127.x`, `10.x`, `172.16-31.x`, `192.168.x`, IPv6 `::1`, `fc00::/7`) **allowed by default** so local dev servers + internal-network fetches keep working.
1231
+
1232
+ For hosted/CI environments where you want to block private targets too, set:
1233
+
1234
+ ```bash
1235
+ export CTX_FETCH_STRICT=1
1236
+ ```
1237
+
1238
+ That blocks loopback + RFC1918 + ULA in addition to the always-blocked ranges. Useful when context-mode runs as a shared service, not on a developer's own machine.
1239
+
1240
+ `tool_input` for any `mcp__*` tool call is also redacted before persistence — keys matching `authorization`, `token`, `secret`, `password`, `api_key`, `cookie`, `signature`, `private_key` get masked to `[REDACTED]` so credentials in MCP arguments don't end up in the session DB.
1241
+
1210
1242
  ## Contributing
1211
1243
 
1212
1244
  See [CONTRIBUTING.md](CONTRIBUTING.md) for the development workflow and TDD guidelines.
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * context-mode status line — Claude Code statusLine integration.
4
+ *
5
+ * Reads the persisted stats file written by the MCP server and prints a
6
+ * single-line, value-first status string designed for enterprise dev
7
+ * surfaces (Loom demos, Slack screen shares, over-the-shoulder closes).
8
+ *
9
+ * Discipline (Datadog / Stripe / Vercel pattern):
10
+ * - "context-mode" full brand label, never abbreviated
11
+ * - ONE chromatic accent (status dot ●), everything else monochrome
12
+ * - Bold for KPI numbers ($, %), dim for context
13
+ * - No counts (calls / tokens / events) — only $ and % pass the
14
+ * value-per-pixel test
15
+ *
16
+ * Wire it up in ~/.claude/settings.json (path-free — uses the bundled CLI
17
+ * forwarder so users don't have to know the absolute install path):
18
+ * {
19
+ * "statusLine": {
20
+ * "type": "command",
21
+ * "command": "context-mode statusline"
22
+ * }
23
+ * }
24
+ *
25
+ * Or, if you prefer to skip the CLI shim, point directly at this file:
26
+ * "command": "node /absolute/path/to/context-mode/bin/statusline.mjs"
27
+ */
28
+
29
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
30
+ import { join } from "node:path";
31
+ import { homedir } from "node:os";
32
+ import { execFileSync } from "node:child_process";
33
+
34
+ // ── Schema versioning ───────────────────────────────────────────────────
35
+ // Bumped by the MCP writer (src/server.ts) when the persisted stats payload
36
+ // shape changes. Statusline reads `schemaVersion` from the payload:
37
+ // - missing → legacy v1.0.103 era, proceed with sensible defaults
38
+ // - <= KNOWN → safe to render fully
39
+ // - > KNOWN → newer writer than this reader; warn once + render what we
40
+ // still understand (graceful degrade rather than blank bar)
41
+ const KNOWN_SCHEMA_VERSION = 1;
42
+
43
+ // Test seams — keep production behaviour identical when env vars unset.
44
+ // CTX_TEST_PLATFORM — override process.platform for cross-OS resolver tests
45
+ // CTX_TEST_PROC_DIR — override /proc base dir for Linux PID-walk tests
46
+ const TEST_PLATFORM = process.env.CTX_TEST_PLATFORM;
47
+ const PROC_DIR = process.env.CTX_TEST_PROC_DIR || "/proc";
48
+ function platform() {
49
+ return TEST_PLATFORM || process.platform;
50
+ }
51
+
52
+ // Single-shot stderr warning latch — keep noise out of Claude Code's
53
+ // statusline output even when our parent runs us repeatedly per session.
54
+ let __winWarned = false;
55
+ function warnOnce(key, msg) {
56
+ if (key === "win" && __winWarned) return;
57
+ if (key === "win") __winWarned = true;
58
+ try { process.stderr.write(`context-mode statusline: ${msg}\n`); } catch { /* ignore */ }
59
+ }
60
+
61
+ // ── ANSI palette (single chromatic accent on the status dot) ────────────
62
+ const NO_COLOR = process.env.NO_COLOR || !process.stdout.isTTY;
63
+ const ansi = (code, text) => (NO_COLOR ? text : `\x1b[${code}m${text}\x1b[0m`);
64
+ const brand = (t) => ansi("1;36", t); // bold cyan — brand presence
65
+ const bold = (t) => ansi("1", t); // bold default fg — KPI numbers
66
+ const dim = (t) => ansi("2", t); // dim default fg — context
67
+ const green = (t) => ansi("32", t); // healthy dot
68
+ const yellow = (t) => ansi("33", t); // degraded dot
69
+ const red = (t) => ansi("31", t); // stale dot
70
+ const SEP = dim("·");
71
+
72
+ // ── Stats file lookup ────────────────────────────────────────────────────
73
+ function readStdinJson() {
74
+ try {
75
+ const raw = readFileSync(0, "utf-8");
76
+ if (!raw.trim()) return {};
77
+ return JSON.parse(raw);
78
+ } catch {
79
+ return {};
80
+ }
81
+ }
82
+
83
+ function resolveSessionDir() {
84
+ if (process.env.CONTEXT_MODE_SESSION_DIR) {
85
+ return process.env.CONTEXT_MODE_SESSION_DIR;
86
+ }
87
+ return join(homedir(), ".claude", "context-mode", "sessions");
88
+ }
89
+
90
+ /**
91
+ * Walk up the parent process chain to find the Claude Code PID.
92
+ *
93
+ * Claude Code spawns the status line through a shell, so process.ppid is
94
+ * the intermediate shell, not Claude Code itself. We walk up until we find
95
+ * a process whose name matches /claude/i.
96
+ *
97
+ * Per-OS resolver:
98
+ * - linux: read PPid + Name from /proc/<pid>/status
99
+ * - darwin: ps -o ppid=,comm= -p <pid> (BSD ps; works without /proc)
100
+ * - win32: degraded — process.ppid only, with a one-shot stderr warning
101
+ *
102
+ * Without this walk, multiple concurrent Claude sessions all see the same
103
+ * shell ppid and collide on the fuzzy mtime fallback in findStatsFile.
104
+ */
105
+ function findClaudePid() {
106
+ const plat = platform();
107
+ if (plat === "linux") return findClaudePidLinux();
108
+ if (plat === "darwin") return findClaudePidDarwin();
109
+ if (plat === "win32") {
110
+ warnOnce(
111
+ "win",
112
+ "Windows process-tree walk unsupported; multiple concurrent Claude sessions may collide. Set CLAUDE_SESSION_ID for deterministic resolution.",
113
+ );
114
+ return process.ppid;
115
+ }
116
+ return process.ppid;
117
+ }
118
+
119
+ function findClaudePidLinux() {
120
+ let pid = process.ppid;
121
+ for (let i = 0; i < 8 && pid && pid > 1; i++) {
122
+ try {
123
+ const status = readFileSync(`${PROC_DIR}/${pid}/status`, "utf-8");
124
+ const nameMatch = status.match(/^Name:\s+(.+)$/m);
125
+ const ppidMatch = status.match(/^PPid:\s+(\d+)/m);
126
+ const name = nameMatch?.[1]?.trim() ?? "";
127
+ if (/claude/i.test(name)) return pid;
128
+ pid = ppidMatch ? Number(ppidMatch[1]) : 0;
129
+ } catch {
130
+ return process.ppid;
131
+ }
132
+ }
133
+ return process.ppid;
134
+ }
135
+
136
+ function findClaudePidDarwin() {
137
+ let pid = process.ppid;
138
+ for (let i = 0; i < 8 && pid && pid > 1; i++) {
139
+ try {
140
+ // `ps -o ppid=,comm= -p <pid>` → " 12345 /path/to/claude"
141
+ const out = execFileSync(
142
+ "ps",
143
+ ["-o", "ppid=,comm=", "-p", String(pid)],
144
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
145
+ ).trim();
146
+ if (!out) return process.ppid;
147
+ const m = out.match(/^\s*(\d+)\s+(.+)$/);
148
+ if (!m) return process.ppid;
149
+ const parentPid = Number(m[1]);
150
+ const comm = m[2].trim();
151
+ // comm may be a path; check basename for claude
152
+ const base = comm.split("/").pop() || comm;
153
+ if (/claude/i.test(base)) return pid;
154
+ pid = parentPid;
155
+ } catch {
156
+ return process.ppid;
157
+ }
158
+ }
159
+ return process.ppid;
160
+ }
161
+
162
+ function resolveSessionId() {
163
+ if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
164
+ return `pid-${findClaudePid()}`;
165
+ }
166
+
167
+ function findStatsFile(sessionDir, sessionId) {
168
+ const direct = join(sessionDir, `stats-${sessionId}.json`);
169
+ if (existsSync(direct)) return direct;
170
+
171
+ try {
172
+ const candidates = readdirSync(sessionDir)
173
+ .filter((f) => f.startsWith("stats-") && f.endsWith(".json"))
174
+ .map((f) => {
175
+ const full = join(sessionDir, f);
176
+ try {
177
+ return { full, mtime: statSync(full).mtimeMs };
178
+ } catch {
179
+ return null;
180
+ }
181
+ })
182
+ .filter(Boolean)
183
+ .sort((a, b) => b.mtime - a.mtime);
184
+
185
+ // Only fall back to a file modified within the last 30 minutes —
186
+ // older files almost always belong to a stopped MCP server.
187
+ const fresh = candidates.find(
188
+ (c) => Date.now() - c.mtime < 30 * 60 * 1000,
189
+ );
190
+ if (fresh) return fresh.full;
191
+ } catch { /* ignore — sessionDir might not exist yet */ }
192
+
193
+ return null;
194
+ }
195
+
196
+ function loadStats(path) {
197
+ try {
198
+ const parsed = JSON.parse(readFileSync(path, "utf-8"));
199
+ if (parsed && typeof parsed === "object") {
200
+ // schemaVersion is optional — legacy v1.0.103 payloads omit it.
201
+ // Default to 0 so unknown-newer detection still has a clean compare.
202
+ const version = Number.isFinite(parsed.schemaVersion)
203
+ ? parsed.schemaVersion
204
+ : 0;
205
+ if (version > KNOWN_SCHEMA_VERSION) {
206
+ try {
207
+ process.stderr.write(
208
+ `context-mode statusline: stats schemaVersion=${version} newer than known=${KNOWN_SCHEMA_VERSION}; rendering known fields only. Upgrade context-mode to suppress this warning.\n`,
209
+ );
210
+ } catch { /* ignore */ }
211
+ }
212
+ }
213
+ return parsed;
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+
219
+ // ── Formatters ───────────────────────────────────────────────────────────
220
+ function fmtUsd(n) {
221
+ const safe = Number.isFinite(n) && n >= 0 ? n : 0;
222
+ if (safe >= 100) return `$${safe.toFixed(0)}`;
223
+ if (safe >= 10) return `$${safe.toFixed(2)}`;
224
+ return `$${safe.toFixed(2)}`;
225
+ }
226
+
227
+ function fmtUptime(ms) {
228
+ const sec = Math.floor(ms / 1000);
229
+ if (sec < 60) return `${sec}s`;
230
+ const min = Math.floor(sec / 60);
231
+ if (min < 60) return `${min}m`;
232
+ const hr = Math.floor(min / 60);
233
+ const remMin = min % 60;
234
+ return remMin > 0 ? `${hr}h${remMin}m` : `${hr}h`;
235
+ }
236
+
237
+ // ── Status dot — the ONE accent ──────────────────────────────────────────
238
+ function statusDot(pct, isStale) {
239
+ if (isStale) return red("●");
240
+ if (pct >= 50) return green("●");
241
+ if (pct >= 1) return yellow("●");
242
+ return green("●");
243
+ }
244
+
245
+ // ── Main render ──────────────────────────────────────────────────────────
246
+ function main() {
247
+ readStdinJson(); // drain stdin even if unused, keeps Claude Code happy
248
+ const sessionDir = resolveSessionDir();
249
+ const sessionId = resolveSessionId();
250
+ const statsFile = findStatsFile(sessionDir, sessionId);
251
+
252
+ // BRAND-NEW — no stats file. Use only the substantiated README headline
253
+ // claim ("saves ~98% of context window"). No fabricated $/dev/month or
254
+ // social-proof numbers we cannot back with data.
255
+ if (!statsFile) {
256
+ process.stdout.write(
257
+ `${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
258
+ );
259
+ return;
260
+ }
261
+
262
+ const stats = loadStats(statsFile);
263
+ if (!stats) {
264
+ process.stdout.write(
265
+ `${brand("context-mode")} ${green("●")} ${dim("saves ~98% of context window")}`,
266
+ );
267
+ return;
268
+ }
269
+
270
+ // STALE — stats file >30min old, MCP likely stopped
271
+ const ageMs = Date.now() - (stats.updated_at || 0);
272
+ const stale = ageMs > 30 * 60 * 1000;
273
+ if (stale) {
274
+ process.stdout.write(
275
+ `${brand("context-mode")} ${red("●")} ${dim("stale — restart to resume saving")}`,
276
+ );
277
+ return;
278
+ }
279
+
280
+ const sessionUsd = stats.dollars_saved_session ?? 0;
281
+ const lifetimeUsd = stats.dollars_saved_lifetime ?? 0;
282
+ const pct = stats.reduction_pct ?? 0;
283
+ const uptime = fmtUptime(stats.uptime_ms ?? 0);
284
+ const dot = statusDot(pct, false);
285
+
286
+ // FRESH — no session $ yet, lead with persistence value
287
+ if (sessionUsd === 0) {
288
+ if (lifetimeUsd > 0) {
289
+ // Lifetime $ exists — persistence as primary value, brand-poem echo
290
+ process.stdout.write(
291
+ `${brand("context-mode")} ${dot} ${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")} ${SEP} ${dim("preserved across compact, restart & upgrade")}`,
292
+ );
293
+ } else {
294
+ // First-ever session, no lifetime data yet — substantiated headline only
295
+ process.stdout.write(
296
+ `${brand("context-mode")} ${dot} ${dim("ready — saves ~98% of context window")}`,
297
+ );
298
+ }
299
+ return;
300
+ }
301
+
302
+ // ACTIVE / DEGRADED — session $ · [lifetime $ when present] · % efficient · uptime
303
+ // Status dot color encodes degraded vs healthy via pct.
304
+ // Lifetime block is conditional: persistStats omits dollars_saved_lifetime
305
+ // when no analytics aggregator is available, so we degrade gracefully to
306
+ // a session-only render rather than printing "$0.00 saved across sessions".
307
+ const valueBlocks = [
308
+ `${bold(fmtUsd(sessionUsd))} ${dim("saved this session")}`,
309
+ ];
310
+ if (lifetimeUsd > 0) {
311
+ valueBlocks.push(`${bold(fmtUsd(lifetimeUsd))} ${dim("saved across sessions")}`);
312
+ }
313
+ valueBlocks.push(`${bold(`${pct}%`)} ${dim("efficient")}`);
314
+ valueBlocks.push(dim(uptime));
315
+
316
+ const head = `${brand("context-mode")} ${dot} `;
317
+ const tail = valueBlocks.join(` ${SEP} `);
318
+ process.stdout.write(head + tail);
319
+ }
320
+
321
+ main();
@@ -32,6 +32,12 @@ export declare class AntigravityAdapter extends BaseAdapter implements HookAdapt
32
32
  formatPreCompactResponse(_response: PreCompactResponse): unknown;
33
33
  formatSessionStartResponse(_response: SessionStartResponse): unknown;
34
34
  getSettingsPath(): string;
35
+ /**
36
+ * Antigravity nests under ~/.gemini/antigravity/. Always absolute.
37
+ * `_projectDir` accepted for interface symmetry but unused — home-rooted.
38
+ */
39
+ getConfigDir(_projectDir?: string): string;
40
+ getInstructionFiles(): string[];
35
41
  generateHookConfig(_pluginRoot: string): HookRegistration;
36
42
  readSettings(): Record<string, unknown> | null;
37
43
  writeSettings(settings: Record<string, unknown>): void;
@@ -72,6 +72,16 @@ export class AntigravityAdapter extends BaseAdapter {
72
72
  getSettingsPath() {
73
73
  return resolve(homedir(), ".gemini", "antigravity", "mcp_config.json");
74
74
  }
75
+ /**
76
+ * Antigravity nests under ~/.gemini/antigravity/. Always absolute.
77
+ * `_projectDir` accepted for interface symmetry but unused — home-rooted.
78
+ */
79
+ getConfigDir(_projectDir) {
80
+ return resolve(homedir(), ".gemini", "antigravity");
81
+ }
82
+ getInstructionFiles() {
83
+ return ["GEMINI.md"];
84
+ }
75
85
  generateHookConfig(_pluginRoot) {
76
86
  return {};
77
87
  }
@@ -22,6 +22,29 @@ export declare abstract class BaseAdapter {
22
22
  getSessionDir(): string;
23
23
  getSessionDBPath(projectDir: string): string;
24
24
  getSessionEventsPath(projectDir: string): string;
25
+ /**
26
+ * Default: build config dir from sessionDirSegments rooted at $HOME.
27
+ *
28
+ * Contract: ALWAYS returns an absolute path. Adapters with project-scoped
29
+ * or non-home-rooted config dirs (cursor, vscode-copilot, jetbrains-copilot,
30
+ * openclaw, opencode) override this and resolve their segments against
31
+ * `projectDir` (or `process.cwd()` when omitted).
32
+ *
33
+ * @param _projectDir Unused by the home-rooted default — accepted so
34
+ * project-scoped overrides honor the same signature.
35
+ */
36
+ getConfigDir(_projectDir?: string): string;
37
+ /**
38
+ * Default: Claude Code convention. Most adapters override with their
39
+ * own platform-specific instruction file name (AGENTS.md, GEMINI.md, ...).
40
+ */
41
+ getInstructionFiles(): string[];
42
+ /**
43
+ * Default: <configDir>/memory. Always absolute (configDir is absolute by
44
+ * contract). Adapters with a different memory dir name (e.g., codex uses
45
+ * "memories" plural) override this.
46
+ */
47
+ getMemoryDir(): string;
25
48
  backupSettings(): string | null;
26
49
  abstract getSettingsPath(): string;
27
50
  }
@@ -44,6 +44,35 @@ export class BaseAdapter {
44
44
  .slice(0, 16);
45
45
  return join(this.getSessionDir(), `${hash}-events.md`);
46
46
  }
47
+ /**
48
+ * Default: build config dir from sessionDirSegments rooted at $HOME.
49
+ *
50
+ * Contract: ALWAYS returns an absolute path. Adapters with project-scoped
51
+ * or non-home-rooted config dirs (cursor, vscode-copilot, jetbrains-copilot,
52
+ * openclaw, opencode) override this and resolve their segments against
53
+ * `projectDir` (or `process.cwd()` when omitted).
54
+ *
55
+ * @param _projectDir Unused by the home-rooted default — accepted so
56
+ * project-scoped overrides honor the same signature.
57
+ */
58
+ getConfigDir(_projectDir) {
59
+ return join(homedir(), ...this.sessionDirSegments);
60
+ }
61
+ /**
62
+ * Default: Claude Code convention. Most adapters override with their
63
+ * own platform-specific instruction file name (AGENTS.md, GEMINI.md, ...).
64
+ */
65
+ getInstructionFiles() {
66
+ return ["CLAUDE.md"];
67
+ }
68
+ /**
69
+ * Default: <configDir>/memory. Always absolute (configDir is absolute by
70
+ * contract). Adapters with a different memory dir name (e.g., codex uses
71
+ * "memories" plural) override this.
72
+ */
73
+ getMemoryDir() {
74
+ return join(this.getConfigDir(), "memory");
75
+ }
47
76
  backupSettings() {
48
77
  const settingsPath = this.getSettingsPath();
49
78
  try {
@@ -29,6 +29,8 @@ export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
29
29
  formatPreCompactResponse(response: PreCompactResponse): unknown;
30
30
  formatSessionStartResponse(response: SessionStartResponse): unknown;
31
31
  getSettingsPath(): string;
32
+ getInstructionFiles(): string[];
33
+ getMemoryDir(): string;
32
34
  generateHookConfig(pluginRoot: string): HookRegistration;
33
35
  readSettings(): Record<string, unknown> | null;
34
36
  writeSettings(_settings: Record<string, unknown>): void;
@@ -39,6 +41,14 @@ export declare class CodexAdapter extends BaseAdapter implements HookAdapter {
39
41
  setHookPermissions(_pluginRoot: string): string[];
40
42
  updatePluginRegistry(_pluginRoot: string, _version: string): void;
41
43
  getRoutingInstructions(): string;
44
+ /**
45
+ * Resolve the project directory for a Codex hook input.
46
+ * Priority: input.cwd > CODEX_PROJECT_DIR env > process.cwd().
47
+ * Mirrors the cursor / opencode pattern so downstream hooks always
48
+ * receive a defined projectDir even under worktrees or when the
49
+ * platform omits cwd from the wire payload.
50
+ */
51
+ private getProjectDir;
42
52
  /**
43
53
  * Extract session ID from Codex CLI hook input.
44
54
  * Priority: session_id field > fallback to ppid.
@@ -44,7 +44,7 @@ export class CodexAdapter extends BaseAdapter {
44
44
  toolName: input.tool_name ?? "",
45
45
  toolInput: input.tool_input ?? {},
46
46
  sessionId: this.extractSessionId(input),
47
- projectDir: input.cwd,
47
+ projectDir: this.getProjectDir(input),
48
48
  raw,
49
49
  };
50
50
  }
@@ -55,7 +55,7 @@ export class CodexAdapter extends BaseAdapter {
55
55
  toolInput: input.tool_input ?? {},
56
56
  toolOutput: input.tool_response,
57
57
  sessionId: this.extractSessionId(input),
58
- projectDir: input.cwd,
58
+ projectDir: this.getProjectDir(input),
59
59
  raw,
60
60
  };
61
61
  }
@@ -63,7 +63,7 @@ export class CodexAdapter extends BaseAdapter {
63
63
  const input = raw;
64
64
  return {
65
65
  sessionId: this.extractSessionId(input),
66
- projectDir: input.cwd,
66
+ projectDir: this.getProjectDir(input),
67
67
  raw,
68
68
  };
69
69
  }
@@ -87,7 +87,7 @@ export class CodexAdapter extends BaseAdapter {
87
87
  return {
88
88
  sessionId: this.extractSessionId(input),
89
89
  source,
90
- projectDir: input.cwd,
90
+ projectDir: this.getProjectDir(input),
91
91
  raw,
92
92
  };
93
93
  }
@@ -148,6 +148,14 @@ export class CodexAdapter extends BaseAdapter {
148
148
  getSettingsPath() {
149
149
  return resolve(homedir(), ".codex", "config.toml");
150
150
  }
151
+ getInstructionFiles() {
152
+ // Codex CLI honors AGENTS.md plus an optional override file.
153
+ return ["AGENTS.md", "AGENTS.override.md"];
154
+ }
155
+ getMemoryDir() {
156
+ // Codex uses "memories" (plural), not the default "memory".
157
+ return resolve(homedir(), ".codex", "memories");
158
+ }
151
159
  generateHookConfig(pluginRoot) {
152
160
  return {
153
161
  PreToolUse: [
@@ -298,6 +306,16 @@ export class CodexAdapter extends BaseAdapter {
298
306
  }
299
307
  }
300
308
  // ── Internal helpers ───────────────────────────────────
309
+ /**
310
+ * Resolve the project directory for a Codex hook input.
311
+ * Priority: input.cwd > CODEX_PROJECT_DIR env > process.cwd().
312
+ * Mirrors the cursor / opencode pattern so downstream hooks always
313
+ * receive a defined projectDir even under worktrees or when the
314
+ * platform omits cwd from the wire payload.
315
+ */
316
+ getProjectDir(input) {
317
+ return input.cwd ?? process.env.CODEX_PROJECT_DIR ?? process.cwd();
318
+ }
301
319
  /**
302
320
  * Extract session ID from Codex CLI hook input.
303
321
  * Priority: session_id field > fallback to ppid.