context-mode 1.0.148 → 1.0.150

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.
@@ -0,0 +1,254 @@
1
+ /**
2
+ * walkDirectory — bounded recursive directory walker for ctx_index (#687).
3
+ *
4
+ * Issue: ctx_index refused directory paths via the security gate at
5
+ * src/store.ts:845 ("refusing to index <path>: not a regular file"). The gate
6
+ * is a TOCTOU defense from #442 round-3 and MUST be preserved — directory
7
+ * support is layered as a separate concern here. Each file produced by
8
+ * walkDirectory is then read via the existing per-file
9
+ * `openSync + fstatSync.isFile()` invariant in `ContentStore.index()`.
10
+ *
11
+ * Reported by @matiasduartee across 4 clients × Windows 11.
12
+ * https://github.com/anthropic-experimental/context-mode/issues/687
13
+ *
14
+ * Design constraints:
15
+ * - No new dependencies (avoid the `ignore` package — issue #687 Diagnose).
16
+ * - Cross-OS: path.sep / path.join everywhere, never raw "/" string ops.
17
+ * - Symlink cycle detection via a resolved-path Set.
18
+ * - Symlink-escape rejection: refuse to follow symlinks that resolve outside
19
+ * the rootPath (defense-in-depth alongside per-file checkFilePathDenyPolicy).
20
+ * - FTS5-blowup guard: hard cap maxFiles (default 200, per Architect).
21
+ */
22
+ import { readdirSync, statSync, lstatSync, realpathSync, existsSync, readFileSync, } from "node:fs";
23
+ import { join, extname, relative, sep, resolve } from "node:path";
24
+ const DEFAULT_EXCLUDES = [
25
+ "node_modules",
26
+ ".git",
27
+ "dist",
28
+ "build",
29
+ ".next",
30
+ "coverage",
31
+ ".venv",
32
+ "__pycache__",
33
+ ".DS_Store",
34
+ ];
35
+ const DEFAULT_EXTENSIONS = [
36
+ ".md",
37
+ ".mdx",
38
+ ".txt",
39
+ ".json",
40
+ ".yaml",
41
+ ".yml",
42
+ ".ts",
43
+ ".tsx",
44
+ ".js",
45
+ ".jsx",
46
+ ".py",
47
+ ".rs",
48
+ ".go",
49
+ ".sh",
50
+ ];
51
+ const DEFAULT_MAX_DEPTH = 5;
52
+ const DEFAULT_MAX_FILES = 200;
53
+ /**
54
+ * Convert a simple glob pattern (`*`, `**`, `?`) to a RegExp. Anchors at
55
+ * boundaries so `node_modules` matches `node_modules` AND `node_modules/pkg`.
56
+ * Patterns are matched against POSIX-style relative paths (forward slashes)
57
+ * to give consistent behavior across macOS / Windows.
58
+ */
59
+ function globToRegExp(pattern) {
60
+ // Escape regex metachars except glob ones.
61
+ let re = "";
62
+ for (let i = 0; i < pattern.length; i++) {
63
+ const c = pattern[i];
64
+ if (c === "*") {
65
+ if (pattern[i + 1] === "*") {
66
+ re += ".*";
67
+ i++;
68
+ }
69
+ else {
70
+ re += "[^/]*";
71
+ }
72
+ }
73
+ else if (c === "?") {
74
+ re += "[^/]";
75
+ }
76
+ else if ("\\^$.|+()[]{}".includes(c)) {
77
+ re += "\\" + c;
78
+ }
79
+ else {
80
+ re += c;
81
+ }
82
+ }
83
+ return new RegExp(`^${re}$`);
84
+ }
85
+ /** Match a posix-style relative path against any of the patterns. */
86
+ function matchesAny(relPosix, patterns) {
87
+ if (patterns.length === 0)
88
+ return false;
89
+ const basename = relPosix.split("/").pop() ?? relPosix;
90
+ for (const p of patterns) {
91
+ // Bare names match basename OR any path segment.
92
+ if (!p.includes("/") && !p.includes("*")) {
93
+ if (basename === p)
94
+ return true;
95
+ if (relPosix.split("/").includes(p))
96
+ return true;
97
+ continue;
98
+ }
99
+ const re = globToRegExp(p);
100
+ if (re.test(relPosix))
101
+ return true;
102
+ if (re.test(basename))
103
+ return true;
104
+ }
105
+ return false;
106
+ }
107
+ /**
108
+ * Parse a .gitignore file into a list of patterns. Comments and blank lines
109
+ * are stripped. Negation (`!`) is not supported — kept conservative.
110
+ */
111
+ function parseGitignore(rootPath) {
112
+ const giPath = join(rootPath, ".gitignore");
113
+ if (!existsSync(giPath))
114
+ return [];
115
+ try {
116
+ const text = readFileSync(giPath, "utf-8");
117
+ return text
118
+ .split(/\r?\n/)
119
+ .map(l => l.trim())
120
+ .filter(l => l.length > 0 && !l.startsWith("#") && !l.startsWith("!"))
121
+ .map(l => l.replace(/^\//, "").replace(/\/$/, ""));
122
+ }
123
+ catch {
124
+ return [];
125
+ }
126
+ }
127
+ /**
128
+ * Convert an absolute path under rootPath to a POSIX-style relative path
129
+ * so glob matching is identical across macOS/Linux/Windows.
130
+ */
131
+ function toPosixRel(rootPath, absPath) {
132
+ const rel = relative(rootPath, absPath);
133
+ return rel.split(sep).join("/");
134
+ }
135
+ /**
136
+ * Walk `rootPath` recursively under the given bounds and return absolute file
137
+ * paths matching the filters. Pure synchronous traversal — no allocations
138
+ * beyond the result array. Symlink cycles are detected via a resolved-path
139
+ * Set; symlink escapes (resolving outside rootPath) are silently skipped.
140
+ */
141
+ export function walkDirectory(rootPath, opts = {}) {
142
+ return walkDirectoryDetailed(rootPath, opts).files;
143
+ }
144
+ /**
145
+ * Same as walkDirectory but returns capped + totalSeen so callers can surface
146
+ * a "capped at N files" notice in their response.
147
+ */
148
+ export function walkDirectoryDetailed(rootPath, opts = {}) {
149
+ const { include, exclude, maxDepth = DEFAULT_MAX_DEPTH, maxFiles = DEFAULT_MAX_FILES, extensions, respectGitignore = true, followSymlinks = false, } = opts;
150
+ // Normalize rootPath to its real path so symlink-escape detection is sound.
151
+ let rootReal;
152
+ try {
153
+ rootReal = realpathSync(rootPath);
154
+ }
155
+ catch {
156
+ return { files: [], capped: false, totalSeen: 0 };
157
+ }
158
+ const exts = (extensions && extensions.length > 0 ? extensions : DEFAULT_EXTENSIONS)
159
+ .map(e => (e.startsWith(".") ? e : "." + e).toLowerCase());
160
+ const excludes = [
161
+ ...DEFAULT_EXCLUDES,
162
+ ...(exclude ?? []),
163
+ ...(respectGitignore ? parseGitignore(rootReal) : []),
164
+ ];
165
+ const includes = include ?? [];
166
+ const out = [];
167
+ const visited = new Set([rootReal]);
168
+ let totalSeen = 0;
169
+ let capped = false;
170
+ function walk(absDir, depth) {
171
+ if (capped)
172
+ return;
173
+ if (depth > maxDepth)
174
+ return;
175
+ let entries;
176
+ try {
177
+ entries = readdirSync(absDir, { withFileTypes: true });
178
+ }
179
+ catch {
180
+ return; // unreadable directory — skip silently
181
+ }
182
+ for (const ent of entries) {
183
+ if (capped)
184
+ return;
185
+ const absChild = join(absDir, ent.name);
186
+ const relPosix = toPosixRel(rootReal, absChild);
187
+ // Exclude check applies to both files and dirs — early prune.
188
+ if (matchesAny(relPosix, excludes))
189
+ continue;
190
+ // Include filter applies to files only — see below.
191
+ // Resolve symlinks once; reject escapes; track for cycle detection.
192
+ let isDirChild = ent.isDirectory();
193
+ let isFileChild = ent.isFile();
194
+ let isSymlink = false;
195
+ try {
196
+ const lst = lstatSync(absChild);
197
+ isSymlink = lst.isSymbolicLink();
198
+ }
199
+ catch {
200
+ continue;
201
+ }
202
+ if (isSymlink) {
203
+ if (!followSymlinks)
204
+ continue;
205
+ let resolved;
206
+ try {
207
+ resolved = realpathSync(absChild);
208
+ }
209
+ catch {
210
+ continue; // dangling
211
+ }
212
+ // Symlink-escape: refuse to follow if the resolved target leaves rootReal.
213
+ const escapeRel = relative(rootReal, resolved);
214
+ if (escapeRel.startsWith("..") || resolve(escapeRel) === resolved) {
215
+ // resolve(absolute) === absolute → target is absolute outside root
216
+ if (escapeRel.startsWith(".."))
217
+ continue;
218
+ }
219
+ if (visited.has(resolved))
220
+ continue;
221
+ visited.add(resolved);
222
+ try {
223
+ const st = statSync(resolved);
224
+ isDirChild = st.isDirectory();
225
+ isFileChild = st.isFile();
226
+ }
227
+ catch {
228
+ continue;
229
+ }
230
+ }
231
+ if (isDirChild) {
232
+ walk(absChild, depth + 1);
233
+ continue;
234
+ }
235
+ if (!isFileChild)
236
+ continue;
237
+ // Extension filter.
238
+ const ext = extname(absChild).toLowerCase();
239
+ if (!exts.includes(ext))
240
+ continue;
241
+ // Include filter (if any): file must match at least one include pattern.
242
+ if (includes.length > 0 && !matchesAny(relPosix, includes))
243
+ continue;
244
+ totalSeen++;
245
+ if (out.length >= maxFiles) {
246
+ capped = true;
247
+ return;
248
+ }
249
+ out.push(absChild);
250
+ }
251
+ }
252
+ walk(rootReal, 0);
253
+ return { files: out, capped, totalSeen };
254
+ }
package/build/store.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Use for documentation, API references, and any content where
8
8
  * you need EXACT text later — not summaries.
9
9
  */
10
+ import { type WalkOptions } from "./store-directory.js";
10
11
  type SourceMatchMode = "like" | "exact";
11
12
  import type { IndexResult, SearchResult, StoreStats } from "./types.js";
12
13
  export type { IndexResult, SearchResult, StoreStats } from "./types.js";
@@ -51,6 +52,34 @@ export declare class ContentStore {
51
52
  eventId?: string;
52
53
  };
53
54
  }): IndexResult;
55
+ /**
56
+ * Index every file under a directory by walking it with `walkDirectory` and
57
+ * delegating each discovered file to `this.index({ path })`. The per-file
58
+ * `openSync + fstatSync.isFile()` security gate at line ~845 stays active
59
+ * for every file — directory support never bypasses the TOCTOU defense
60
+ * from #442 round-3.
61
+ *
62
+ * Reported by @matiasduartee in #687.
63
+ */
64
+ indexDirectory(opts: {
65
+ path: string;
66
+ source?: string;
67
+ attribution?: {
68
+ sessionId?: string;
69
+ eventId?: string;
70
+ };
71
+ /** Optional per-file deny check — runs INSIDE the walk loop so a denied
72
+ * file does not even open a fd. Returns true to deny. */
73
+ perFileDeny?: (absPath: string) => boolean;
74
+ } & WalkOptions): {
75
+ filesIndexed: number;
76
+ totalChunks: number;
77
+ capped: boolean;
78
+ totalSeen: number;
79
+ denied: number;
80
+ failed: number;
81
+ label: string;
82
+ };
54
83
  /**
55
84
  * Index plain-text output (logs, build output, test results) by splitting
56
85
  * into fixed-size line groups. Unlike markdown indexing, this does not
package/build/store.js CHANGED
@@ -13,6 +13,7 @@ import { readFileSync, readdirSync, unlinkSync, existsSync, statSync, openSync,
13
13
  import { createHash } from "node:crypto";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
+ import { walkDirectoryDetailed } from "./store-directory.js";
16
17
  // ─────────────────────────────────────────────────────────
17
18
  // Constants
18
19
  // ─────────────────────────────────────────────────────────
@@ -756,6 +757,51 @@ export class ContentStore {
756
757
  const contentHash = filePath ? createHash("sha256").update(text).digest("hex") : undefined;
757
758
  return withRetry(() => this.#insertChunks(chunks, label, text, filePath, contentHash, attribution));
758
759
  }
760
+ // ── Index Directory (#687) ──
761
+ /**
762
+ * Index every file under a directory by walking it with `walkDirectory` and
763
+ * delegating each discovered file to `this.index({ path })`. The per-file
764
+ * `openSync + fstatSync.isFile()` security gate at line ~845 stays active
765
+ * for every file — directory support never bypasses the TOCTOU defense
766
+ * from #442 round-3.
767
+ *
768
+ * Reported by @matiasduartee in #687.
769
+ */
770
+ indexDirectory(opts) {
771
+ const { path: rootPath, source, attribution, perFileDeny, ...walkOpts } = opts;
772
+ const walked = walkDirectoryDetailed(rootPath, walkOpts);
773
+ let filesIndexed = 0;
774
+ let totalChunks = 0;
775
+ let denied = 0;
776
+ let failed = 0;
777
+ for (const file of walked.files) {
778
+ if (perFileDeny && perFileDeny(file)) {
779
+ denied++;
780
+ continue;
781
+ }
782
+ try {
783
+ // Per-file source label so ctx_search(source: "<file>") still works.
784
+ const fileSource = source ? `${source}:${file}` : file;
785
+ const r = this.index({ path: file, source: fileSource, attribution });
786
+ filesIndexed++;
787
+ totalChunks += r.totalChunks;
788
+ }
789
+ catch {
790
+ // Per-file failure (e.g. fd-bound fstat rejection of a non-regular
791
+ // file that races between walk and read) — count + continue.
792
+ failed++;
793
+ }
794
+ }
795
+ return {
796
+ filesIndexed,
797
+ totalChunks,
798
+ capped: walked.capped,
799
+ totalSeen: walked.totalSeen,
800
+ denied,
801
+ failed,
802
+ label: source ?? rootPath,
803
+ };
804
+ }
759
805
  // ── Index Plain Text ──
760
806
  /**
761
807
  * Index plain-text output (logs, build output, test results) by splitting
@@ -0,0 +1,4 @@
1
+ export declare function getToolName(platform: string, bareTool: string): string;
2
+ export type ToolNamer = (bareTool: string) => string;
3
+ export declare function createToolNamer(platform: string): ToolNamer;
4
+ export declare const KNOWN_PLATFORMS: string[];
@@ -0,0 +1,24 @@
1
+ const TOOL_PREFIXES = {
2
+ "claude-code": (tool) => `mcp__plugin_context-mode_context-mode__${tool}`,
3
+ "gemini-cli": (tool) => `mcp__context-mode__${tool}`,
4
+ "antigravity": (tool) => `mcp__context-mode__${tool}`,
5
+ "opencode": (tool) => `context-mode_${tool}`,
6
+ "kilo": (tool) => `context-mode_${tool}`,
7
+ "vscode-copilot": (tool) => `context-mode_${tool}`,
8
+ "jetbrains-copilot": (tool) => `context-mode_${tool}`,
9
+ "kiro": (tool) => `@context-mode/${tool}`,
10
+ "zed": (tool) => `mcp:context-mode:${tool}`,
11
+ "cursor": (tool) => tool,
12
+ "codex": (tool) => tool,
13
+ "openclaw": (tool) => tool,
14
+ "pi": (tool) => tool,
15
+ "qwen-code": (tool) => `mcp__context-mode__${tool}`,
16
+ };
17
+ export function getToolName(platform, bareTool) {
18
+ const fn = TOOL_PREFIXES[platform] || TOOL_PREFIXES["claude-code"];
19
+ return fn(bareTool);
20
+ }
21
+ export function createToolNamer(platform) {
22
+ return (bareTool) => getToolName(platform, bareTool);
23
+ }
24
+ export const KNOWN_PLATFORMS = Object.keys(TOOL_PREFIXES);
@@ -22,6 +22,20 @@
22
22
  * (package.json files[]); a missing helper means the install is
23
23
  * fundamentally broken.
24
24
  */
25
+ /**
26
+ * Files `start.mjs` needs to launch the MCP server, checked dependency-free
27
+ * (fs only) so this works even when the integrity helper
28
+ * (`scripts/plugin-cache-integrity.mjs`) is itself missing — a missing helper
29
+ * is itself a partial-install symptom, and the operator most needs to know
30
+ * whether the launch entrypoint survived.
31
+ *
32
+ * - `start.mjs` is the plugin `command` target (`.claude-plugin/plugin.json`)
33
+ * and has NO fallback: if absent, `node ${CLAUDE_PLUGIN_ROOT}/start.mjs`
34
+ * fails immediately and the MCP server never starts.
35
+ * - The server is loaded by start.mjs from `server.bundle.mjs`, falling back
36
+ * to `build/server.js`; it is only "missing" when BOTH are absent.
37
+ */
38
+ export declare function findMissingLaunchFiles(pluginRoot: string): string[];
25
39
  /**
26
40
  * Run the integrity check synchronously. If the helper module is
27
41
  * still loading (not yet cached) returns a FAIL with detail
@@ -22,6 +22,8 @@
22
22
  * (package.json files[]); a missing helper means the install is
23
23
  * fundamentally broken.
24
24
  */
25
+ import { existsSync } from "node:fs";
26
+ import { join } from "node:path";
25
27
  let cached = null;
26
28
  let cachedError = null;
27
29
  async function loadHelper() {
@@ -67,6 +69,30 @@ async function loadHelper() {
67
69
  // intentionally — by the time any HealthCheck.check() runs (doctor
68
70
  // command, well after MCP server boot), the import has resolved.
69
71
  void loadHelper();
72
+ /**
73
+ * Files `start.mjs` needs to launch the MCP server, checked dependency-free
74
+ * (fs only) so this works even when the integrity helper
75
+ * (`scripts/plugin-cache-integrity.mjs`) is itself missing — a missing helper
76
+ * is itself a partial-install symptom, and the operator most needs to know
77
+ * whether the launch entrypoint survived.
78
+ *
79
+ * - `start.mjs` is the plugin `command` target (`.claude-plugin/plugin.json`)
80
+ * and has NO fallback: if absent, `node ${CLAUDE_PLUGIN_ROOT}/start.mjs`
81
+ * fails immediately and the MCP server never starts.
82
+ * - The server is loaded by start.mjs from `server.bundle.mjs`, falling back
83
+ * to `build/server.js`; it is only "missing" when BOTH are absent.
84
+ */
85
+ export function findMissingLaunchFiles(pluginRoot) {
86
+ const missing = [];
87
+ if (!existsSync(join(pluginRoot, "start.mjs"))) {
88
+ missing.push("start.mjs");
89
+ }
90
+ if (!existsSync(join(pluginRoot, "server.bundle.mjs")) &&
91
+ !existsSync(join(pluginRoot, "build", "server.js"))) {
92
+ missing.push("server.bundle.mjs (or build/server.js)");
93
+ }
94
+ return missing;
95
+ }
70
96
  /**
71
97
  * Run the integrity check synchronously. If the helper module is
72
98
  * still loading (not yet cached) returns a FAIL with detail
@@ -89,6 +115,21 @@ export function checkPluginCacheIntegritySync(pluginRoot) {
89
115
  };
90
116
  }
91
117
  if (cachedError) {
118
+ // The integrity helper (scripts/plugin-cache-integrity.mjs) ships in
119
+ // package.json files[]; if it failed to load, the install is already
120
+ // partial. Don't stop at "helper unavailable" — directly surface whether
121
+ // the launch entrypoint survived, because a missing start.mjs / server
122
+ // bundle is exactly what stops the MCP server from starting (and is what
123
+ // an interrupted /ctx-upgrade swap leaves behind).
124
+ const launchMissing = findMissingLaunchFiles(pluginRoot);
125
+ if (launchMissing.length > 0) {
126
+ return {
127
+ status: "FAIL",
128
+ detail: `partial install — critical launch files missing: ${launchMissing.join(", ")} ` +
129
+ `(integrity helper also missing: ${cachedError}); the MCP server cannot start. ` +
130
+ `Reinstall: npm install -g context-mode@latest`,
131
+ };
132
+ }
92
133
  return {
93
134
  status: "FAIL",
94
135
  detail: `integrity helper unavailable: ${cachedError}`,
@@ -60,6 +60,42 @@ export declare function resolveProjectDirFromTranscript(opts: {
60
60
  /** Test seam for maxAgeMs. Defaults to Date.now(). */
61
61
  nowMs?: number;
62
62
  }): string | undefined;
63
+ /**
64
+ * Issue #45 / c4529042182 — recover the project-cwd from a Codex CLI
65
+ * session log when the spawned MCP child inherits a non-project cwd
66
+ * (e.g. $HOME when Codex was launched from anywhere outside the project).
67
+ *
68
+ * Codex writes its session transcripts to
69
+ * `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl`. The first line is a
70
+ * `SessionMeta` JSON struct whose `meta.cwd` field carries the literal
71
+ * project directory the CLI was launched from (see refs/platforms/codex/
72
+ * codex-rs SessionMeta). Codex publishes NO workspace env var to its child
73
+ * MCP processes — so unlike Claude/Pi/Cursor, we have no env signal at all.
74
+ * The session log is the strongest available signal.
75
+ *
76
+ * Mirror of `resolveProjectDirFromTranscript` for Claude Code; differences:
77
+ * • Sessions live flat in `${codexHome}/sessions/*.jsonl` (no per-project
78
+ * encoded subdir like Claude's `~/.claude/projects/<encoded>/`).
79
+ * • The cwd is on `meta.cwd` (nested), not top-level `cwd`.
80
+ *
81
+ * Returns `null` when:
82
+ * • `codexHome` or its `sessions/` subdir does not exist.
83
+ * • No `.jsonl` files exist or none has a parseable `meta.cwd` string.
84
+ * • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
85
+ * • The resolved `meta.cwd` points at a plugin install path (poisoned).
86
+ */
87
+ export declare function resolveCodexSessionCwd(opts?: {
88
+ /** Defaults to `process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")`. */
89
+ codexHome?: string;
90
+ /**
91
+ * Optional freshness guard — Codex appends to the active log while the
92
+ * session is running, so a stale log from days ago must not become a
93
+ * global project-dir signal.
94
+ */
95
+ transcriptMaxAgeMs?: number;
96
+ /** Test seam for transcriptMaxAgeMs. Defaults to Date.now(). */
97
+ now?: number;
98
+ }): string | null;
63
99
  /**
64
100
  * Pure project-dir resolver. Mirror of the env-var chain inside
65
101
  * `src/server.ts getProjectDir()`, but takes its inputs explicitly so the
@@ -100,4 +136,11 @@ export declare function resolveProjectDir(opts: {
100
136
  * for `start.mjs` and any non-strict consumer).
101
137
  */
102
138
  strictPlatform?: PlatformId;
139
+ /**
140
+ * Issue #45 — override `${CODEX_HOME ?? ~/.codex}` for tests. When
141
+ * `strictPlatform === "codex"` and the env cascade yields nothing, the
142
+ * resolver reads `meta.cwd` from the newest session.jsonl under
143
+ * `${codexHome}/sessions/`.
144
+ */
145
+ codexHome?: string;
103
146
  }): string;
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs";
2
+ import * as os from "node:os";
2
3
  import * as path from "node:path";
3
4
  import { workspaceEnvVarsFor } from "../adapters/detect.js";
4
5
  /**
@@ -156,6 +157,94 @@ export function resolveProjectDirFromTranscript(opts) {
156
157
  catch { /* file vanished mid-read */ }
157
158
  return undefined;
158
159
  }
160
+ /**
161
+ * Issue #45 / c4529042182 — recover the project-cwd from a Codex CLI
162
+ * session log when the spawned MCP child inherits a non-project cwd
163
+ * (e.g. $HOME when Codex was launched from anywhere outside the project).
164
+ *
165
+ * Codex writes its session transcripts to
166
+ * `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl`. The first line is a
167
+ * `SessionMeta` JSON struct whose `meta.cwd` field carries the literal
168
+ * project directory the CLI was launched from (see refs/platforms/codex/
169
+ * codex-rs SessionMeta). Codex publishes NO workspace env var to its child
170
+ * MCP processes — so unlike Claude/Pi/Cursor, we have no env signal at all.
171
+ * The session log is the strongest available signal.
172
+ *
173
+ * Mirror of `resolveProjectDirFromTranscript` for Claude Code; differences:
174
+ * • Sessions live flat in `${codexHome}/sessions/*.jsonl` (no per-project
175
+ * encoded subdir like Claude's `~/.claude/projects/<encoded>/`).
176
+ * • The cwd is on `meta.cwd` (nested), not top-level `cwd`.
177
+ *
178
+ * Returns `null` when:
179
+ * • `codexHome` or its `sessions/` subdir does not exist.
180
+ * • No `.jsonl` files exist or none has a parseable `meta.cwd` string.
181
+ * • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
182
+ * • The resolved `meta.cwd` points at a plugin install path (poisoned).
183
+ */
184
+ export function resolveCodexSessionCwd(opts) {
185
+ const codexHome = opts?.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
186
+ const sessionsDir = path.join(codexHome, "sessions");
187
+ if (!fs.existsSync(sessionsDir))
188
+ return null;
189
+ let bestPath;
190
+ let bestMtime = 0;
191
+ try {
192
+ for (const f of fs.readdirSync(sessionsDir)) {
193
+ if (!f.endsWith(".jsonl"))
194
+ continue;
195
+ const fp = path.join(sessionsDir, f);
196
+ try {
197
+ const m = fs.statSync(fp).mtimeMs;
198
+ if (m > bestMtime) {
199
+ bestMtime = m;
200
+ bestPath = fp;
201
+ }
202
+ }
203
+ catch { /* skip */ }
204
+ }
205
+ }
206
+ catch {
207
+ return null;
208
+ }
209
+ if (!bestPath)
210
+ return null;
211
+ if (typeof opts?.transcriptMaxAgeMs === "number") {
212
+ const nowMs = opts.now ?? Date.now();
213
+ if (nowMs - bestMtime > opts.transcriptMaxAgeMs)
214
+ return null;
215
+ }
216
+ // Read first ~8KB; the SessionMeta JSON is line 1 and small. Stream-cap
217
+ // mirrors `resolveProjectDirFromTranscript` for memory safety on long logs.
218
+ try {
219
+ const fd = fs.openSync(bestPath, "r");
220
+ try {
221
+ const buf = Buffer.alloc(8192);
222
+ const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
223
+ const text = buf.subarray(0, bytes).toString("utf-8");
224
+ const firstLine = text.split("\n", 1)[0];
225
+ if (!firstLine || !firstLine.trim())
226
+ return null;
227
+ try {
228
+ const obj = JSON.parse(firstLine);
229
+ const cwd = obj?.meta?.cwd;
230
+ if (typeof cwd !== "string" || cwd.length === 0)
231
+ return null;
232
+ if (isPluginInstallPath(cwd))
233
+ return null;
234
+ return cwd;
235
+ }
236
+ catch {
237
+ return null; /* malformed first line */
238
+ }
239
+ }
240
+ finally {
241
+ fs.closeSync(fd);
242
+ }
243
+ }
244
+ catch {
245
+ return null; /* file vanished mid-read */
246
+ }
247
+ }
159
248
  /**
160
249
  * Pure project-dir resolver. Mirror of the env-var chain inside
161
250
  * `src/server.ts getProjectDir()`, but takes its inputs explicitly so the
@@ -177,7 +266,7 @@ export function resolveProjectDirFromTranscript(opts) {
177
266
  * operation of project-independent tools (sandbox execute, fetch).
178
267
  */
179
268
  export function resolveProjectDir(opts) {
180
- const { env, cwd, pwd, transcriptsRoot, transcriptMaxAgeMs, nowMs, strictPlatform } = opts;
269
+ const { env, cwd, pwd, transcriptsRoot, transcriptMaxAgeMs, nowMs, strictPlatform, codexHome, } = opts;
181
270
  // Build candidate list. Strict path: own workspace vars + universal escape
182
271
  // hatch — NO foreign workspace vars, in any order, can win. Non-strict
183
272
  // path: frozen legacy literal order for backwards compatibility.
@@ -198,6 +287,18 @@ export function resolveProjectDir(opts) {
198
287
  if (fromTranscript && !isPluginInstallPath(fromTranscript))
199
288
  return fromTranscript;
200
289
  }
290
+ // Issue #45 — Codex has no workspace env var, so when running under
291
+ // strictPlatform="codex" we fall back to the session-log heuristic
292
+ // between env and PWD. Non-codex platforms skip this branch entirely.
293
+ if (strictPlatform === "codex") {
294
+ const fromCodex = resolveCodexSessionCwd({
295
+ codexHome,
296
+ transcriptMaxAgeMs,
297
+ now: nowMs,
298
+ });
299
+ if (fromCodex)
300
+ return fromCodex;
301
+ }
201
302
  if (pwd && !isPluginInstallPath(pwd))
202
303
  return pwd;
203
304
  return cwd;