context-mode 1.0.133 → 1.0.135

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.
@@ -6,14 +6,14 @@
6
6
  },
7
7
  "metadata": {
8
8
  "description": "Claude Code plugins by Mert Koseoğlu",
9
- "version": "1.0.133"
9
+ "version": "1.0.135"
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.133",
16
+ "version": "1.0.135",
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.133",
3
+ "version": "1.0.135",
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.133",
6
+ "version": "1.0.135",
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.133",
3
+ "version": "1.0.135",
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",
@@ -11,7 +11,9 @@
11
11
  * CLAUDE_PROJECT_DIR, CLAUDE_SESSION_ID | ~/.claude/
12
12
  * - Gemini CLI: GEMINI_PROJECT_DIR (hooks), GEMINI_CLI (MCP) | ~/.gemini/
13
13
  * - KiloCode: KILO, KILO_PID | ~/.config/kilo/
14
- * - OpenCode: OPENCODE, OPENCODE_PID | ~/.config/opencode/
14
+ * - OpenCode: OPENCODE_PROJECT_DIR, OPENCODE_CLIENT,
15
+ * OPENCODE_TERMINAL, OPENCODE, OPENCODE_PID |
16
+ * ~/.config/opencode/
15
17
  * - OpenClaw: OPENCLAW_HOME, OPENCLAW_CLI | ~/.openclaw/
16
18
  * - Codex CLI: CODEX_CI, CODEX_THREAD_ID | ~/.codex/
17
19
  * - Cursor: CURSOR_TRACE_ID (MCP), CURSOR_CLI (terminal) | ~/.cursor/
@@ -11,7 +11,9 @@
11
11
  * CLAUDE_PROJECT_DIR, CLAUDE_SESSION_ID | ~/.claude/
12
12
  * - Gemini CLI: GEMINI_PROJECT_DIR (hooks), GEMINI_CLI (MCP) | ~/.gemini/
13
13
  * - KiloCode: KILO, KILO_PID | ~/.config/kilo/
14
- * - OpenCode: OPENCODE, OPENCODE_PID | ~/.config/opencode/
14
+ * - OpenCode: OPENCODE_PROJECT_DIR, OPENCODE_CLIENT,
15
+ * OPENCODE_TERMINAL, OPENCODE, OPENCODE_PID |
16
+ * ~/.config/opencode/
15
17
  * - OpenClaw: OPENCLAW_HOME, OPENCLAW_CLI | ~/.openclaw/
16
18
  * - Codex CLI: CODEX_CI, CODEX_THREAD_ID | ~/.codex/
17
19
  * - Cursor: CURSOR_TRACE_ID (MCP), CURSOR_CLI (terminal) | ~/.cursor/
@@ -109,12 +111,15 @@ const _PLATFORM_ENV_VARS_RAW = [
109
111
  { name: "KILO_PID", role: "identification" },
110
112
  ]],
111
113
  // opencode — sst/opencode packages/opencode/src/index.ts:108-109 sets
112
- // OPENCODE=1 + OPENCODE_PID=<pid> on every CLI invocation.
114
+ // OPENCODE=1 + OPENCODE_PID=<pid> on CLI invocations. OpenCode desktop
115
+ // shells also expose OPENCODE_CLIENT=desktop and OPENCODE_TERMINAL=1.
113
116
  // OPENCODE_PROJECT_DIR is the documented workspace var (consumed by the
114
117
  // legacy resolver cascade) — listed first so the workspace cascade picks
115
118
  // it up under strict mode.
116
119
  ["opencode", [
117
120
  { name: "OPENCODE_PROJECT_DIR", role: "workspace" },
121
+ { name: "OPENCODE_CLIENT", role: "identification" },
122
+ { name: "OPENCODE_TERMINAL", role: "identification" },
118
123
  { name: "OPENCODE", role: "identification" },
119
124
  { name: "OPENCODE_PID", role: "identification" },
120
125
  ]],
@@ -85,6 +85,14 @@ export declare class MCPStdioClient {
85
85
  initialize(): Promise<void>;
86
86
  listTools(): Promise<MCPTool[]>;
87
87
  callTool(name: string, args: unknown): Promise<MCPCallResult>;
88
+ /**
89
+ * Respawn the MCP child after an exit (clean idle shutdown or crash).
90
+ * Resets state so a fresh `start()` + `initialize()` cycle runs, then
91
+ * the caller's pending request flows through the new child.
92
+ *
93
+ * Internal — exposed only via the public `callTool()` happy path.
94
+ */
95
+ private respawn;
88
96
  shutdown(): void;
89
97
  }
90
98
  /**
@@ -284,8 +284,40 @@ export class MCPStdioClient {
284
284
  return Array.isArray(result.tools) ? result.tools : [];
285
285
  }
286
286
  async callTool(name, args) {
287
+ // Respawn-on-idle-exit (#583). The MCP server gained an idle
288
+ // self-shutdown in 1.0.132 (#565/#568, src/lifecycle.ts). When the
289
+ // Pi-spawned child exits cleanly after the idle window, Pi keeps the
290
+ // tool handles registered, but the bridge client is `exited=true`
291
+ // and every subsequent request would reject with
292
+ // "MCP server has exited" — leaving Pi's ctx_* tools permanently
293
+ // broken until the user restarts Pi.
294
+ //
295
+ // The structural fix is here, not in lifecycle.ts: the bridge owns
296
+ // the child lifecycle, so it transparently respawns + re-initialises
297
+ // the server on the next call. Restores parity with adapters whose
298
+ // host MCP client respawns on EOF (Claude Code, Codex, etc.).
299
+ if (this.exited)
300
+ await this.respawn();
287
301
  return this.request("tools/call", { name, arguments: args ?? {} }, DEFAULT_CALL_TIMEOUT_MS);
288
302
  }
303
+ /**
304
+ * Respawn the MCP child after an exit (clean idle shutdown or crash).
305
+ * Resets state so a fresh `start()` + `initialize()` cycle runs, then
306
+ * the caller's pending request flows through the new child.
307
+ *
308
+ * Internal — exposed only via the public `callTool()` happy path.
309
+ */
310
+ async respawn() {
311
+ // Drop the dead child handle and clear stream buffer so leftover
312
+ // bytes from the previous incarnation don't get parsed as JSON-RPC
313
+ // for the new one. Pending map is already cleared by onExit().
314
+ this.child = null;
315
+ this.buffer = "";
316
+ this.exited = false;
317
+ this.initialized = false;
318
+ this.start();
319
+ await this.initialize();
320
+ }
289
321
  shutdown() {
290
322
  if (!this.child)
291
323
  return;
package/build/cli.js CHANGED
@@ -933,6 +933,22 @@ async function upgrade(opts) {
933
933
  const message = err instanceof Error ? err.message : String(err);
934
934
  throw new Error(`.mcp.json drift check failed: ${message}`);
935
935
  }
936
+ // v1.0.X — Layer 7 heal: update user-level ~/.claude.json MCP server
937
+ // registrations that point to old context-mode version dirs.
938
+ // (anthropics/claude-code#59310 workaround — see heal-installed-plugins.mjs)
939
+ try {
940
+ // @ts-expect-error — JS module, no TS declarations
941
+ const { healClaudeJsonMcpArgs } = await import("../scripts/heal-installed-plugins.mjs");
942
+ const dotClaudeJson = resolve(homedir(), ".claude.json");
943
+ const pluginCacheParent = resolve(resolveClaudeConfigDir(), "plugins", "cache", "context-mode", "context-mode");
944
+ const result = healClaudeJsonMcpArgs({ dotClaudeJsonPath: dotClaudeJson, pluginCacheParent, newPluginRoot: pluginRoot });
945
+ if (result.healed && result.healed.length > 0) {
946
+ p.log.info(color.dim(" ~/.claude.json user MCP registrations updated → " + newVersion));
947
+ }
948
+ }
949
+ catch {
950
+ /* best effort — never block upgrade */
951
+ }
936
952
  // v1.0.114 hotfix — marketplace post-pull assertion: clone (if
937
953
  // present) MUST be on newVersion. Mert's case showed marketplace
938
954
  // stuck at v1.0.89 — the sync block above swallowed that silently.
@@ -1151,6 +1167,7 @@ async function upgrade(opts) {
1151
1167
  stdio: "inherit",
1152
1168
  timeout: 30000,
1153
1169
  cwd: pluginRoot,
1170
+ env: { ...process.env, CONTEXT_MODE_PLATFORM: detection.platform },
1154
1171
  });
1155
1172
  }
1156
1173
  catch {
package/build/runtime.js CHANGED
@@ -12,11 +12,14 @@ import { existsSync } from "node:fs";
12
12
  * Match is case-insensitive; `.exe` extension tolerated for Windows binaries.
13
13
  */
14
14
  const ALLOWED_SHELL_BASENAMES = /^(bash|sh|zsh|dash|pwsh|powershell|cmd)(\.exe)?$/i;
15
+ const BUN_BASENAME = /^bun(\.exe)?$/i;
16
+ function runtimeBasename(runtimePath) {
17
+ const segments = runtimePath.split(/[\\/]/);
18
+ return segments[segments.length - 1] ?? runtimePath;
19
+ }
15
20
  export function isAllowlistedShell(shellPath) {
16
21
  // Cross-OS basename: split on either separator, take the last segment.
17
- const segments = shellPath.split(/[\\/]/);
18
- const base = segments[segments.length - 1];
19
- return ALLOWED_SHELL_BASENAMES.test(base);
22
+ return ALLOWED_SHELL_BASENAMES.test(runtimeBasename(shellPath));
20
23
  }
21
24
  const isWindows = process.platform === "win32";
22
25
  function commandExists(cmd) {
@@ -305,14 +308,14 @@ export function getAvailableLanguages(runtimes) {
305
308
  export function buildCommand(runtimes, language, filePath) {
306
309
  switch (language) {
307
310
  case "javascript":
308
- return runtimes.javascript.endsWith("bun")
311
+ return BUN_BASENAME.test(runtimeBasename(runtimes.javascript))
309
312
  ? [runtimes.javascript, "run", filePath]
310
313
  : [runtimes.javascript, filePath];
311
314
  case "typescript":
312
315
  if (!runtimes.typescript) {
313
316
  throw new Error("No TypeScript runtime available. Install one of: bun (recommended), tsx (npm i -g tsx), or ts-node.");
314
317
  }
315
- if (runtimes.typescript?.endsWith("bun"))
318
+ if (BUN_BASENAME.test(runtimeBasename(runtimes.typescript)))
316
319
  return [runtimes.typescript, "run", filePath];
317
320
  if (runtimes.typescript === "tsx")
318
321
  return ["tsx", filePath];
package/build/server.d.ts CHANGED
@@ -1,6 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  import { type SpawnSyncOptions, type SpawnSyncReturns } from "node:child_process";
3
3
  import { ContentStore } from "./store.js";
4
+ /**
5
+ * Build the FK-attribution object passed to every ContentStore.index*() call
6
+ * in this process. CLAUDE_SESSION_ID is the only MCP-side handle we have on
7
+ * the current session — eventId stays undefined because MCP tool invocations
8
+ * are not paired with PostToolUse event rows at index time (the hook fires
9
+ * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
10
+ * legacy unattributed rows readable.
11
+ */
12
+ export declare function currentAttribution(): {
13
+ sessionId?: string;
14
+ } | undefined;
15
+ /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
16
+ export declare function resolveSessionIdFromSessionDB(opts?: {
17
+ projectDir?: string;
18
+ sessionsDir?: string;
19
+ bypassCache?: boolean;
20
+ }): string | undefined;
4
21
  /**
5
22
  * Parse FTS5 highlight markers to find match positions in the
6
23
  * original (marker-free) text. Returns character offsets into the
package/build/server.js CHANGED
@@ -29,7 +29,7 @@ import { getHookScriptPaths } from "./util/hook-config.js";
29
29
  import { resolveClaudeConfigDir } from "./util/claude-config.js";
30
30
  import { resolveProjectDir } from "./util/project-dir.js";
31
31
  import { loadDatabase } from "./db-base.js";
32
- import { AnalyticsEngine, formatReport, getConversationStats, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
32
+ import { AnalyticsEngine, formatReport, getConversationStats, getContentBytesAllSessions, getLifetimeStats, getMultiAdapterLifetimeStats, getRealBytesStats, OPUS_INPUT_PRICE_PER_TOKEN } from "./session/analytics.js";
33
33
  const __pkg_dir = dirname(fileURLToPath(import.meta.url));
34
34
  const VERSION = (() => {
35
35
  for (const rel of ["../package.json", "./package.json"]) {
@@ -86,12 +86,56 @@ let _store = null;
86
86
  * AFTER the tool returns). Empty-string fallback inside #insertChunks keeps
87
87
  * legacy unattributed rows readable.
88
88
  */
89
- function currentAttribution() {
90
- const sessionId = process.env.CLAUDE_SESSION_ID;
89
+ export function currentAttribution() {
90
+ // CLAUDE_SESSION_ID env var is NOT propagated to MCP servers (only to hooks).
91
+ // Cross-adapter resolution: every adapter (15 of them) sets *_PROJECT_DIR env
92
+ // and writes session_events via hooks. Read the most-recent session_id from
93
+ // THIS project's session DB. Works for claude-code/cursor/gemini-cli/codex/
94
+ // kiro/opencode/zed/kilo/openclaw/qwen-code/vscode-copilot/jetbrains-copilot/
95
+ // omp/pi/antigravity — no adapter-specific transcript path required.
96
+ const sessionId = process.env.CLAUDE_SESSION_ID ?? resolveSessionIdFromSessionDB();
91
97
  if (!sessionId)
92
98
  return undefined;
93
99
  return { sessionId };
94
100
  }
101
+ let __cachedSessionId;
102
+ /** v1.0.134 SLICE A: opts injection for testability. Production callers pass nothing. */
103
+ export function resolveSessionIdFromSessionDB(opts) {
104
+ // 2s cache — ctx_fetch_and_index can fire 5+ chunks/sec; DB open cost adds up.
105
+ const now = Date.now();
106
+ if (!opts?.bypassCache && __cachedSessionId && now - __cachedSessionId.checkedAt < 2000) {
107
+ return __cachedSessionId.sid;
108
+ }
109
+ try {
110
+ const projectDir = opts?.projectDir
111
+ ?? process.env.CLAUDE_PROJECT_DIR
112
+ ?? process.env.CONTEXT_MODE_PROJECT_DIR;
113
+ if (!projectDir)
114
+ return undefined;
115
+ const sessionsDir = opts?.sessionsDir ?? getSessionDir();
116
+ const dbPath = resolveSessionDbPath({ projectDir, sessionsDir });
117
+ if (!existsSync(dbPath))
118
+ return undefined;
119
+ const Database = loadDatabase();
120
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
121
+ try {
122
+ const row = db.prepare("SELECT session_id FROM session_events ORDER BY created_at DESC LIMIT 1").get();
123
+ const sid = row?.session_id;
124
+ if (sid)
125
+ __cachedSessionId = { sid, checkedAt: now };
126
+ return sid;
127
+ }
128
+ finally {
129
+ try {
130
+ db.close();
131
+ }
132
+ catch { /* best-effort */ }
133
+ }
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
95
139
  /**
96
140
  * Auto-index session events files written by SessionStart hook.
97
141
  * Scans ~/.claude/context-mode/sessions/ for *-events.md files.
@@ -2622,7 +2666,21 @@ server.registerTool("ctx_stats", {
2622
2666
  // Render-time read-only — no DB mutation, no backfill.
2623
2667
  const contentDbPath = getStorePath();
2624
2668
  const convReal = getRealBytesStats({ sessionId: sid, sessionsDir: getSessionDir(), worktreeHash: dbHash, contentDbPath });
2625
- const lifeReal = getRealBytesStats({ sessionsDir: getSessionDir() });
2669
+ const lifeRealBase = getRealBytesStats({ sessionsDir: getSessionDir() });
2670
+ // v1.0.134 SLICE C: lifetime tier sums ALL chunks (no
2671
+ // session_id filter). Without this fold, lifetime "kept out"
2672
+ // only counts session_events.bytes_avoided and ignores the
2673
+ // bulk of indexed payload across every prior conversation.
2674
+ const lifeContentBytes = getContentBytesAllSessions(contentDbPath);
2675
+ const lifeReal = {
2676
+ ...lifeRealBase,
2677
+ contentBytes: lifeRealBase.contentBytes + lifeContentBytes,
2678
+ bytesAvoided: lifeRealBase.bytesAvoided + lifeContentBytes,
2679
+ totalSavedTokens: Math.floor((lifeRealBase.eventDataBytes
2680
+ + lifeRealBase.bytesAvoided
2681
+ + lifeContentBytes
2682
+ + lifeRealBase.snapshotBytes) / 4),
2683
+ };
2626
2684
  realBytes = { conversation: convReal, lifetime: lifeReal };
2627
2685
  }
2628
2686
  }
@@ -391,6 +391,24 @@ export interface RealBytesStats {
391
391
  export declare function getContentBytesForSession(sessionId: string, contentDbPath: string, opts?: {
392
392
  loadDatabase?: () => unknown;
393
393
  }): number;
394
+ /**
395
+ * v1.0.134 SLICE C — lifetime tier all-chunks aggregate.
396
+ *
397
+ * Sibling of {@link getContentBytesForSession} that omits the session_id
398
+ * filter so the lifetime tier sees every chunk in the content store —
399
+ * including legacy unattributed rows (sessionId === '') and chunks
400
+ * attributed to other adapters' sessions. Without this, the lifetime
401
+ * "kept out" headline only counts session_events.bytes_avoided and
402
+ * misses the bulk of indexed payload.
403
+ *
404
+ * Best-effort: returns 0 when the DB file is missing, the schema lacks
405
+ * the `chunks` table, or the query fails. Never throws — same contract
406
+ * as the rest of the analytics module so a corrupt content DB cannot
407
+ * crash ctx_stats.
408
+ */
409
+ export declare function getContentBytesAllSessions(contentDbPath: string, opts?: {
410
+ loadDatabase?: () => unknown;
411
+ }): number;
394
412
  /**
395
413
  * Compute real-bytes stats across one session, one project (worktree
396
414
  * filter), or every session on disk (lifetime).
@@ -539,19 +557,6 @@ export declare const adapterLabels: Record<string, string>;
539
557
  * information. Scale awareness comes from the unit jump between rows.
540
558
  */
541
559
  export declare function kb(b: number): string;
542
- /**
543
- * Locale + IANA-timezone detection for the narrative renderer.
544
- *
545
- * Cascade (each level overrides the next):
546
- * 1. CONTEXT_MODE_LOCALE / CONTEXT_MODE_TZ env overrides
547
- * (used by tests + by users who want to pin output regardless of OS).
548
- * 2. macOS `defaults read -g AppleLocale` → `en_TR` style → `en-TR`.
549
- * 3. Linux `LANG` / `LC_TIME` env vars.
550
- * 4. Fallback: `Intl.DateTimeFormat().resolvedOptions().locale`.
551
- *
552
- * Timezone always uses `Intl.DateTimeFormat().resolvedOptions().timeZone`
553
- * — that one's always available and correct regardless of platform.
554
- */
555
560
  export declare function detectLocaleAndTz(): {
556
561
  locale: string;
557
562
  tz: string;
@@ -750,6 +750,52 @@ export function getContentBytesForSession(sessionId, contentDbPath, opts) {
750
750
  return 0;
751
751
  }
752
752
  }
753
+ /**
754
+ * v1.0.134 SLICE C — lifetime tier all-chunks aggregate.
755
+ *
756
+ * Sibling of {@link getContentBytesForSession} that omits the session_id
757
+ * filter so the lifetime tier sees every chunk in the content store —
758
+ * including legacy unattributed rows (sessionId === '') and chunks
759
+ * attributed to other adapters' sessions. Without this, the lifetime
760
+ * "kept out" headline only counts session_events.bytes_avoided and
761
+ * misses the bulk of indexed payload.
762
+ *
763
+ * Best-effort: returns 0 when the DB file is missing, the schema lacks
764
+ * the `chunks` table, or the query fails. Never throws — same contract
765
+ * as the rest of the analytics module so a corrupt content DB cannot
766
+ * crash ctx_stats.
767
+ */
768
+ export function getContentBytesAllSessions(contentDbPath, opts) {
769
+ if (!contentDbPath)
770
+ return 0;
771
+ if (!existsSync(contentDbPath))
772
+ return 0;
773
+ let DatabaseCtor = null;
774
+ try {
775
+ DatabaseCtor = opts?.loadDatabase
776
+ ? opts.loadDatabase()
777
+ : loadDatabaseImpl();
778
+ }
779
+ catch {
780
+ return 0;
781
+ }
782
+ if (!DatabaseCtor)
783
+ return 0;
784
+ try {
785
+ const db = new DatabaseCtor(contentDbPath, { readonly: true });
786
+ try {
787
+ const row = db.prepare(`SELECT COALESCE(SUM(LENGTH(content) + LENGTH(title)), 0) AS bytes
788
+ FROM chunks`).get();
789
+ return Number(row?.bytes ?? 0);
790
+ }
791
+ finally {
792
+ db.close();
793
+ }
794
+ }
795
+ catch {
796
+ return 0;
797
+ }
798
+ }
753
799
  /**
754
800
  * Compute real-bytes stats across one session, one project (worktree
755
801
  * filter), or every session on disk (lifetime).
@@ -1044,6 +1090,21 @@ export function getMultiAdapterRealBytesStats(opts) {
1044
1090
  worktreeHash: opts?.worktreeHash,
1045
1091
  loadDatabase: opts?.loadDatabase,
1046
1092
  });
1093
+ // ARCH-REVIEW-V134-ABC SLICE C: aggregate this adapter's content DB
1094
+ // bytes into the lifetime sum. `getRealBytesStats` operates on
1095
+ // session events only and never touches the sibling content/ tree —
1096
+ // without this step the lifetime tier in ctx_stats reports 0 for
1097
+ // every adapter except whichever one happens to share the
1098
+ // sessionsDir of the caller. Lifetime tier ignores sessionId so
1099
+ // the all-sessions aggregator is the right helper here.
1100
+ if (!opts?.sessionId) {
1101
+ const contentDbPath = join(entry.contentDir, "content.db");
1102
+ const adapterContentBytes = getContentBytesAllSessions(contentDbPath, {
1103
+ loadDatabase: opts?.loadDatabase,
1104
+ });
1105
+ one.contentBytes += adapterContentBytes;
1106
+ sum.contentBytes += adapterContentBytes;
1107
+ }
1047
1108
  perAdapter.push({ name: entry.name, ...one });
1048
1109
  sum.eventDataBytes += one.eventDataBytes;
1049
1110
  sum.bytesAvoided += one.bytesAvoided;
@@ -1156,9 +1217,45 @@ function formatDuration(uptimeMin) {
1156
1217
  * Timezone always uses `Intl.DateTimeFormat().resolvedOptions().timeZone`
1157
1218
  * — that one's always available and correct regardless of platform.
1158
1219
  */
1220
+ /**
1221
+ * Validate that a locale string is a usable BCP 47 tag.
1222
+ *
1223
+ * Ubuntu GHA runners default to `LANG=C.UTF-8`. The extractor below strips
1224
+ * that to `"C"` — a valid POSIX locale identifier but NOT a BCP 47 tag.
1225
+ * On macOS / Node 20, `new Intl.DateTimeFormat("C", …)` throws RangeError
1226
+ * outright. CI run 25887250971 caught this via the v1.0.134 SLICE B test.
1227
+ *
1228
+ * Earlier fix attempt used a permissive `supportedLocalesOf || construction`
1229
+ * OR check — that was wrong: on Linux + Node 22.5, `new Intl.DateTimeFormat
1230
+ * ("POSIX")` does NOT throw, it silently falls back to the root locale and
1231
+ * still emits garbage at format time. CI run 25904838577 surfaced that —
1232
+ * "POSIX" round-tripped through the validator unchanged.
1233
+ *
1234
+ * Strict gate: `Intl.DateTimeFormat.supportedLocalesOf(tag)` returns `[]` for
1235
+ * any tag that doesn't map to a real language (regardless of whether
1236
+ * construction with that tag throws). That's the contract we want — "is this
1237
+ * a BCP 47 tag the host actually has data for". Construction is an explicit
1238
+ * sanity check; both must pass.
1239
+ */
1240
+ function isUsableBcp47Locale(raw) {
1241
+ if (!raw)
1242
+ return false;
1243
+ try {
1244
+ if (Intl.DateTimeFormat.supportedLocalesOf(raw).length === 0)
1245
+ return false;
1246
+ // Belt: confirm construction doesn't throw on this host either.
1247
+ new Intl.DateTimeFormat(raw);
1248
+ return true;
1249
+ }
1250
+ catch {
1251
+ return false;
1252
+ }
1253
+ }
1159
1254
  export function detectLocaleAndTz() {
1160
1255
  const env = (process.env ?? {});
1161
1256
  let locale = env.CONTEXT_MODE_LOCALE ?? "";
1257
+ if (locale && !isUsableBcp47Locale(locale))
1258
+ locale = "";
1162
1259
  if (!locale) {
1163
1260
  if (process.platform === "darwin") {
1164
1261
  try {
@@ -1173,11 +1270,18 @@ export function detectLocaleAndTz() {
1173
1270
  locale = out.replace(/_/g, "-");
1174
1271
  }
1175
1272
  catch { /* defaults missing or sandbox */ }
1273
+ if (locale && !isUsableBcp47Locale(locale))
1274
+ locale = "";
1176
1275
  }
1177
1276
  if (!locale && (env.LC_TIME || env.LANG)) {
1178
1277
  const raw = (env.LC_TIME || env.LANG || "").split(".")[0];
1179
1278
  if (raw)
1180
1279
  locale = raw.replace(/_/g, "-");
1280
+ // POSIX locale identifiers (`C`, `POSIX`) survive the simple extraction
1281
+ // above but blow up `new Intl.DateTimeFormat(locale, ...)`. Drop and
1282
+ // fall through to the host-default branch below.
1283
+ if (locale && !isUsableBcp47Locale(locale))
1284
+ locale = "";
1181
1285
  }
1182
1286
  if (!locale) {
1183
1287
  try {
@@ -1197,7 +1301,13 @@ export function detectLocaleAndTz() {
1197
1301
  tz = "UTC";
1198
1302
  }
1199
1303
  }
1200
- return { locale: locale || "en-US", tz: tz || "UTC" };
1304
+ // Final belt-and-suspenders: if the locale we settled on is somehow still
1305
+ // unusable (env mutation between detection and return, contributor adding
1306
+ // a new extraction path that skips the validator), fall back to en-US so
1307
+ // formatLocalDateTime / monthDay / weekdayCap never throw at render time.
1308
+ if (!isUsableBcp47Locale(locale))
1309
+ locale = "en-US";
1310
+ return { locale, tz: tz || "UTC" };
1201
1311
  }
1202
1312
  /**
1203
1313
  * Format an absolute path as a human-friendly display string by
@@ -1396,19 +1506,32 @@ function renderNarrative5Section(args) {
1396
1506
  out.push("");
1397
1507
  // Without/With bars — measured from real per-event bytes_returned / bytes_avoided.
1398
1508
  //
1399
- // Honest definitions:
1400
- // Without = bytes the model WOULD have re-seen with no filtering = bytes_returned + bytes_avoided
1401
- // With = bytes the model ACTUALLY re-saw after context-mode = bytes_returned
1509
+ // Honest definitions (v1.0.134 SLICE B — eventDataBytes floor):
1510
+ // Without = bytes the model WOULD have re-seen with no filtering
1511
+ // = bytes_avoided + bytes_returned + eventDataBytes
1512
+ // With = bytes the model ACTUALLY re-saw after context-mode
1513
+ // = bytes_returned + eventDataBytes
1514
+ //
1515
+ // Why eventDataBytes belongs on BOTH sides:
1516
+ // `eventDataBytes` is the raw payload captured by the hook (tool args,
1517
+ // prompt body, etc). Those bytes were "kept out" — never inflated back
1518
+ // into context — but they still represent real measured signal. Pre-fix
1519
+ // the formula was `with = max(1, bytesReturned)`, which collapsed to 1
1520
+ // whenever the conversation hadn't accumulated any re-served bytes yet
1521
+ // (early in a session, or for tool-heavy work that never re-hits index).
1522
+ // That produced a degenerate ~100% kept-out bar even when the only
1523
+ // honest signal we had was a few KB of event payloads.
1402
1524
  //
1403
1525
  // No fallback to heuristic. If the schema has zero signal for this
1404
- // conversation (no hook ever populated bytes_avoided / bytes_returned),
1526
+ // conversation (no hook ever populated any of the three columns),
1405
1527
  // the section is skipped entirely. Honesty over decoration.
1406
1528
  const realConv = realBytes?.conversation;
1407
1529
  const measuredAvoided = realConv?.bytesAvoided ?? 0;
1408
1530
  const measuredReturned = realConv?.bytesReturned ?? 0;
1409
- if (measuredAvoided + measuredReturned > 0) {
1410
- const convBytesWithout = measuredReturned + measuredAvoided;
1411
- const convBytesWith = Math.max(1, measuredReturned);
1531
+ const measuredEvent = realConv?.eventDataBytes ?? 0;
1532
+ if (measuredAvoided + measuredReturned + measuredEvent > 0) {
1533
+ const convBytesWithout = measuredAvoided + measuredReturned + measuredEvent;
1534
+ const convBytesWith = Math.max(1, measuredReturned + measuredEvent);
1412
1535
  const convTokensWithout = Math.max(1, Math.floor(convBytesWithout / 4));
1413
1536
  const convTokensWith = Math.max(1, Math.floor(convBytesWith / 4));
1414
1537
  const withoutBar = dataBar(convTokensWithout, convTokensWithout, 32);
@@ -13,12 +13,18 @@ export declare function resolveClaudeGlobalSettingsPath(env?: NodeJS.ProcessEnv)
13
13
  * adapter is non-claude — claude is already covered by entry 2).
14
14
  * 2. The claude global settings.json (always — defense in depth).
15
15
  *
16
- * Lazy import of `./adapters/detect.js` keeps this file free of any direct
17
- * adapter dependency: the detect module itself only `import type`s adapter
18
- * types at the top level (concrete adapters are loaded dynamically inside
19
- * `getAdapter()`), so a static import is safe — but we use `createRequire`
20
- * to make the dependency direction crystal clear and to avoid surprising
21
- * future maintainers who add eager adapter imports to detect.ts.
16
+ * Static import of `../adapters/detect.js` is safe detect.ts only imports
17
+ * `node:` builtins, `./types.js` (type-only), and `./client-map.js` (pure
18
+ * data). It does NOT import claude-config back, so no cycle.
19
+ *
20
+ * History: this used `createRequire(import.meta.url).resolve(...)` to lazy-
21
+ * load detect at call time. That pattern requires `require(esm)`, which is
22
+ * flag-gated on Node 22.x before 22.12 (`--experimental-require-module`).
23
+ * CI run 25877550371 on Node 22.5 silently failed every detect.* call —
24
+ * the catch block ate the error and every cross-adapter deny-policy test
25
+ * returned an empty policy list. Static import sidesteps the require(esm)
26
+ * gate entirely, so the same code works on every supported Node version
27
+ * (20.x, 22.5, 22.12+, 24+) without needing the experimental flag.
22
28
  *
23
29
  * The returned array is deduplicated and order-stable: adapter-specific path
24
30
  * first (most specific), claude global second (fallback).