context-mode 1.0.151 → 1.0.152

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 (106) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/mcp.json +5 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.openclaw-plugin/openclaw.plugin.json +16 -1
  6. package/.openclaw-plugin/package.json +1 -1
  7. package/README.md +89 -3
  8. package/build/adapters/claude-code/hooks.js +2 -2
  9. package/build/adapters/claude-code/index.js +14 -13
  10. package/build/adapters/client-map.js +3 -0
  11. package/build/adapters/detect.js +13 -1
  12. package/build/adapters/gemini-cli/hooks.d.ts +10 -0
  13. package/build/adapters/gemini-cli/hooks.js +12 -2
  14. package/build/adapters/gemini-cli/index.d.ts +21 -1
  15. package/build/adapters/gemini-cli/index.js +37 -1
  16. package/build/adapters/kimi/config.d.ts +8 -0
  17. package/build/adapters/kimi/config.js +8 -0
  18. package/build/adapters/kimi/hooks.d.ts +28 -0
  19. package/build/adapters/kimi/hooks.js +34 -0
  20. package/build/adapters/kimi/index.d.ts +66 -0
  21. package/build/adapters/kimi/index.js +537 -0
  22. package/build/adapters/kimi/paths.d.ts +1 -0
  23. package/build/adapters/kimi/paths.js +12 -0
  24. package/build/adapters/kiro/hooks.js +2 -2
  25. package/build/adapters/openclaw/plugin.d.ts +14 -13
  26. package/build/adapters/openclaw/plugin.js +140 -40
  27. package/build/adapters/opencode/plugin.js +4 -3
  28. package/build/adapters/opencode/zod3tov4.js +8 -8
  29. package/build/adapters/pi/extension.js +9 -24
  30. package/build/adapters/pi/mcp-bridge.js +37 -0
  31. package/build/adapters/qwen-code/index.js +7 -7
  32. package/build/adapters/types.d.ts +39 -2
  33. package/build/adapters/types.js +55 -2
  34. package/build/cli.js +433 -25
  35. package/build/executor.js +6 -3
  36. package/build/runtime.d.ts +81 -1
  37. package/build/runtime.js +195 -9
  38. package/build/search/ctx-search-schema.d.ts +90 -0
  39. package/build/search/ctx-search-schema.js +135 -0
  40. package/build/search/unified.d.ts +12 -0
  41. package/build/search/unified.js +17 -2
  42. package/build/server.d.ts +2 -1
  43. package/build/server.js +378 -97
  44. package/build/session/analytics.d.ts +36 -13
  45. package/build/session/analytics.js +123 -26
  46. package/build/session/db.d.ts +24 -0
  47. package/build/session/db.js +41 -0
  48. package/build/session/extract.js +30 -0
  49. package/build/session/snapshot.js +24 -0
  50. package/build/store.d.ts +12 -1
  51. package/build/store.js +72 -20
  52. package/build/types.d.ts +7 -0
  53. package/build/util/project-dir.d.ts +19 -16
  54. package/build/util/project-dir.js +80 -45
  55. package/cli.bundle.mjs +371 -320
  56. package/configs/kimi/hooks.json +54 -0
  57. package/configs/pi/AGENTS.md +3 -85
  58. package/hooks/cache-heal-utils.mjs +148 -0
  59. package/hooks/core/formatters.mjs +26 -0
  60. package/hooks/core/routing.mjs +9 -1
  61. package/hooks/core/stdin.mjs +74 -3
  62. package/hooks/core/tool-naming.mjs +1 -0
  63. package/hooks/heal-partial-install.mjs +712 -0
  64. package/hooks/kimi/platform.mjs +1 -0
  65. package/hooks/kimi/posttooluse.mjs +72 -0
  66. package/hooks/kimi/precompact.mjs +80 -0
  67. package/hooks/kimi/pretooluse.mjs +42 -0
  68. package/hooks/kimi/sessionend.mjs +61 -0
  69. package/hooks/kimi/sessionstart.mjs +113 -0
  70. package/hooks/kimi/stop.mjs +61 -0
  71. package/hooks/kimi/userpromptsubmit.mjs +90 -0
  72. package/hooks/normalize-hooks.mjs +66 -12
  73. package/hooks/routing-block.mjs +8 -2
  74. package/hooks/security.bundle.mjs +1 -1
  75. package/hooks/session-db.bundle.mjs +6 -4
  76. package/hooks/session-extract.bundle.mjs +2 -2
  77. package/hooks/session-helpers.mjs +93 -3
  78. package/hooks/session-snapshot.bundle.mjs +20 -19
  79. package/hooks/sessionstart.mjs +64 -0
  80. package/insight/server.mjs +15 -3
  81. package/openclaw.plugin.json +16 -1
  82. package/package.json +1 -1
  83. package/scripts/heal-installed-plugins.mjs +31 -10
  84. package/scripts/postinstall.mjs +10 -0
  85. package/server.bundle.mjs +206 -157
  86. package/skills/ctx-index/SKILL.md +46 -0
  87. package/skills/ctx-search/SKILL.md +35 -0
  88. package/start.mjs +84 -11
  89. package/build/cache-heal.d.ts +0 -48
  90. package/build/cache-heal.js +0 -150
  91. package/build/concurrency/runPool.d.ts +0 -36
  92. package/build/concurrency/runPool.js +0 -51
  93. package/build/openclaw/mcp-tools.d.ts +0 -54
  94. package/build/openclaw/mcp-tools.js +0 -198
  95. package/build/openclaw/workspace-router.d.ts +0 -29
  96. package/build/openclaw/workspace-router.js +0 -64
  97. package/build/openclaw-plugin.d.ts +0 -130
  98. package/build/openclaw-plugin.js +0 -626
  99. package/build/opencode-plugin.d.ts +0 -122
  100. package/build/opencode-plugin.js +0 -375
  101. package/build/pi-extension.d.ts +0 -14
  102. package/build/pi-extension.js +0 -451
  103. package/build/routing-block.d.ts +0 -8
  104. package/build/routing-block.js +0 -86
  105. package/build/tool-naming.d.ts +0 -4
  106. package/build/tool-naming.js +0 -24
package/build/store.js CHANGED
@@ -262,7 +262,7 @@ function findMinSpan(positionLists) {
262
262
  return Infinity;
263
263
  if (positionLists.length === 1)
264
264
  return 0;
265
- const sorted = positionLists.map((p) => [...p].sort((a, b) => a - b));
265
+ const sorted = positionLists;
266
266
  const ptrs = new Array(sorted.length).fill(0);
267
267
  let minSpan = Infinity;
268
268
  while (true) {
@@ -507,7 +507,8 @@ export class ContentStore {
507
507
  chunks.timestamp,
508
508
  sources.label,
509
509
  bm25(chunks, 5.0, 1.0) AS rank,
510
- highlight(chunks, 1, char(2), char(3)) AS highlighted
510
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
511
+ chunks.session_id
511
512
  FROM chunks
512
513
  JOIN sources ON sources.id = chunks.source_id
513
514
  WHERE chunks MATCH ?
@@ -522,7 +523,8 @@ export class ContentStore {
522
523
  chunks.timestamp,
523
524
  sources.label,
524
525
  bm25(chunks, 5.0, 1.0) AS rank,
525
- highlight(chunks, 1, char(2), char(3)) AS highlighted
526
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
527
+ chunks.session_id
526
528
  FROM chunks
527
529
  JOIN sources ON sources.id = chunks.source_id
528
530
  WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\'
@@ -537,7 +539,8 @@ export class ContentStore {
537
539
  chunks.timestamp,
538
540
  sources.label,
539
541
  bm25(chunks, 5.0, 1.0) AS rank,
540
- highlight(chunks, 1, char(2), char(3)) AS highlighted
542
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
543
+ chunks.session_id
541
544
  FROM chunks
542
545
  JOIN sources ON sources.id = chunks.source_id
543
546
  WHERE chunks MATCH ? AND sources.label = ?
@@ -552,7 +555,8 @@ export class ContentStore {
552
555
  chunks_trigram.timestamp,
553
556
  sources.label,
554
557
  bm25(chunks_trigram, 5.0, 1.0) AS rank,
555
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
558
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
559
+ chunks_trigram.session_id
556
560
  FROM chunks_trigram
557
561
  JOIN sources ON sources.id = chunks_trigram.source_id
558
562
  WHERE chunks_trigram MATCH ?
@@ -567,7 +571,8 @@ export class ContentStore {
567
571
  chunks_trigram.timestamp,
568
572
  sources.label,
569
573
  bm25(chunks_trigram, 5.0, 1.0) AS rank,
570
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
574
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
575
+ chunks_trigram.session_id
571
576
  FROM chunks_trigram
572
577
  JOIN sources ON sources.id = chunks_trigram.source_id
573
578
  WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\'
@@ -582,7 +587,8 @@ export class ContentStore {
582
587
  chunks_trigram.timestamp,
583
588
  sources.label,
584
589
  bm25(chunks_trigram, 5.0, 1.0) AS rank,
585
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
590
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
591
+ chunks_trigram.session_id
586
592
  FROM chunks_trigram
587
593
  JOIN sources ON sources.id = chunks_trigram.source_id
588
594
  WHERE chunks_trigram MATCH ? AND sources.label = ?
@@ -598,7 +604,8 @@ export class ContentStore {
598
604
  chunks.timestamp,
599
605
  sources.label,
600
606
  bm25(chunks, 5.0, 1.0) AS rank,
601
- highlight(chunks, 1, char(2), char(3)) AS highlighted
607
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
608
+ chunks.session_id
602
609
  FROM chunks
603
610
  JOIN sources ON sources.id = chunks.source_id
604
611
  WHERE chunks MATCH ? AND chunks.content_type = ?
@@ -613,7 +620,8 @@ export class ContentStore {
613
620
  chunks.timestamp,
614
621
  sources.label,
615
622
  bm25(chunks, 5.0, 1.0) AS rank,
616
- highlight(chunks, 1, char(2), char(3)) AS highlighted
623
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
624
+ chunks.session_id
617
625
  FROM chunks
618
626
  JOIN sources ON sources.id = chunks.source_id
619
627
  WHERE chunks MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks.content_type = ?
@@ -628,7 +636,8 @@ export class ContentStore {
628
636
  chunks.timestamp,
629
637
  sources.label,
630
638
  bm25(chunks, 5.0, 1.0) AS rank,
631
- highlight(chunks, 1, char(2), char(3)) AS highlighted
639
+ highlight(chunks, 1, char(2), char(3)) AS highlighted,
640
+ chunks.session_id
632
641
  FROM chunks
633
642
  JOIN sources ON sources.id = chunks.source_id
634
643
  WHERE chunks MATCH ? AND sources.label = ? AND chunks.content_type = ?
@@ -643,7 +652,8 @@ export class ContentStore {
643
652
  chunks_trigram.timestamp,
644
653
  sources.label,
645
654
  bm25(chunks_trigram, 5.0, 1.0) AS rank,
646
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
655
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
656
+ chunks_trigram.session_id
647
657
  FROM chunks_trigram
648
658
  JOIN sources ON sources.id = chunks_trigram.source_id
649
659
  WHERE chunks_trigram MATCH ? AND chunks_trigram.content_type = ?
@@ -658,7 +668,8 @@ export class ContentStore {
658
668
  chunks_trigram.timestamp,
659
669
  sources.label,
660
670
  bm25(chunks_trigram, 5.0, 1.0) AS rank,
661
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
671
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
672
+ chunks_trigram.session_id
662
673
  FROM chunks_trigram
663
674
  JOIN sources ON sources.id = chunks_trigram.source_id
664
675
  WHERE chunks_trigram MATCH ? AND sources.label LIKE ? ESCAPE '\\' AND chunks_trigram.content_type = ?
@@ -673,7 +684,8 @@ export class ContentStore {
673
684
  chunks_trigram.timestamp,
674
685
  sources.label,
675
686
  bm25(chunks_trigram, 5.0, 1.0) AS rank,
676
- highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted
687
+ highlight(chunks_trigram, 1, char(2), char(3)) AS highlighted,
688
+ chunks_trigram.session_id
677
689
  FROM chunks_trigram
678
690
  JOIN sources ON sources.id = chunks_trigram.source_id
679
691
  WHERE chunks_trigram MATCH ? AND sources.label = ? AND chunks_trigram.content_type = ?
@@ -902,6 +914,7 @@ export class ContentStore {
902
914
  contentType: r.content_type,
903
915
  highlighted: r.highlighted,
904
916
  timestamp: r.timestamp ?? undefined,
917
+ sessionId: r.session_id ?? "",
905
918
  }));
906
919
  }
907
920
  #sourceFilterParam(source, sourceMatchMode) {
@@ -1088,13 +1101,21 @@ export class ContentStore {
1088
1101
  .map(({ result }) => result);
1089
1102
  }
1090
1103
  // ── Unified Fallback Search ──
1091
- searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like") {
1104
+ searchWithFallback(query, limit = 3, source, contentType, sourceMatchMode = "like", sessionIdAllowSet) {
1092
1105
  // Step 0: Auto-refresh stale file-backed sources before searching
1093
1106
  this.#refreshStaleSources();
1107
+ // When a session-id allow-set is in play (issue #737 project filter),
1108
+ // fetch a larger candidate pool from the FTS5 layers so the post-filter
1109
+ // can still deliver `limit` matches even if many candidates are excluded.
1110
+ // The cap is bounded — even at the largest installs the chunk count
1111
+ // dwarfs `limit * 8`, and the surplus is dropped on the post-filter.
1112
+ const fetchLimit = sessionIdAllowSet ? Math.max(limit * 8, 40) : limit;
1113
+ const sessionFilter = this.#makeSessionFilter(sessionIdAllowSet);
1094
1114
  // Step 1: RRF fusion (porter OR + trigram OR → merge)
1095
- const rrfResults = this.#rrfSearch(query, limit, source, contentType, sourceMatchMode);
1096
- if (rrfResults.length > 0) {
1097
- const reranked = this.#applyProximityReranking(rrfResults, query);
1115
+ const rrfResults = this.#rrfSearch(query, fetchLimit, source, contentType, sourceMatchMode);
1116
+ const rrfFiltered = sessionFilter ? rrfResults.filter(sessionFilter) : rrfResults;
1117
+ if (rrfFiltered.length > 0) {
1118
+ const reranked = this.#applyProximityReranking(rrfFiltered.slice(0, limit), query);
1098
1119
  return reranked.map((r) => ({ ...r, matchLayer: "rrf" }));
1099
1120
  }
1100
1121
  // Step 2: Fuzzy correction → RRF re-run
@@ -1109,14 +1130,29 @@ export class ContentStore {
1109
1130
  const correctedWords = words.map((w) => this.fuzzyCorrect(w) ?? w);
1110
1131
  const correctedQuery = correctedWords.join(" ");
1111
1132
  if (correctedQuery !== original) {
1112
- const fuzzyResults = this.#rrfSearch(correctedQuery, limit, source, contentType, sourceMatchMode);
1113
- if (fuzzyResults.length > 0) {
1114
- const reranked = this.#applyProximityReranking(fuzzyResults, correctedQuery);
1133
+ const fuzzyResults = this.#rrfSearch(correctedQuery, fetchLimit, source, contentType, sourceMatchMode);
1134
+ const fuzzyFiltered = sessionFilter ? fuzzyResults.filter(sessionFilter) : fuzzyResults;
1135
+ if (fuzzyFiltered.length > 0) {
1136
+ const reranked = this.#applyProximityReranking(fuzzyFiltered.slice(0, limit), correctedQuery);
1115
1137
  return reranked.map((r) => ({ ...r, matchLayer: "rrf-fuzzy" }));
1116
1138
  }
1117
1139
  }
1118
1140
  return [];
1119
1141
  }
1142
+ /**
1143
+ * Build the session-id post-filter for the FTS5 candidate pool. Legacy
1144
+ * chunks indexed before per-session attribution carry `session_id=''` and
1145
+ * stay visible across projects so user-indexed content remains reachable
1146
+ * after opting into the shared-DB mode (#737).
1147
+ */
1148
+ #makeSessionFilter(allowSet) {
1149
+ if (!allowSet)
1150
+ return null;
1151
+ return (r) => {
1152
+ const sid = r.sessionId ?? "";
1153
+ return sid === "" || allowSet.has(sid);
1154
+ };
1155
+ }
1120
1156
  /** Number of sources auto-refreshed in the last searchWithFallback call. */
1121
1157
  lastRefreshCount = 0;
1122
1158
  /**
@@ -1181,6 +1217,22 @@ export class ContentStore {
1181
1217
  listSources() {
1182
1218
  return this.#stmtListSources.all();
1183
1219
  }
1220
+ /**
1221
+ * Aggregate snapshot of the persistent content store. Returns total
1222
+ * chunk count, source count, and the most recent indexed_at timestamp.
1223
+ * Used by ctx_stats so callers can see observability state in the same
1224
+ * round trip instead of inferring it from snapshot diffs.
1225
+ */
1226
+ getIndexState() {
1227
+ const row = this.#db
1228
+ .prepare("SELECT COALESCE(SUM(chunk_count), 0) AS total_chunks, COUNT(*) AS total_sources, MAX(indexed_at) AS last_indexed_at FROM sources")
1229
+ .get();
1230
+ return {
1231
+ totalChunks: row.total_chunks ?? 0,
1232
+ totalSources: row.total_sources ?? 0,
1233
+ lastIndexedAt: row.last_indexed_at ?? undefined,
1234
+ };
1235
+ }
1184
1236
  /**
1185
1237
  * Get all chunks for a given source by ID — bypasses FTS5 MATCH entirely.
1186
1238
  * Use this for inventory/listing where you need all sections, not search.
package/build/types.d.ts CHANGED
@@ -75,6 +75,13 @@ export interface SearchResult {
75
75
  matchLayer?: "porter" | "trigram" | "fuzzy" | "rrf" | "rrf-fuzzy";
76
76
  highlighted?: string;
77
77
  timestamp?: string;
78
+ /**
79
+ * Session-id attribution copied from the `chunks.session_id` column
80
+ * (legacy unattributed chunks carry an empty string). Used by the
81
+ * `ctx_search` per-project filter (#737) to scope shared-DB results
82
+ * to the current project via the 2-step IN-clause strategy.
83
+ */
84
+ sessionId?: string;
78
85
  }
79
86
  /**
80
87
  * Aggregate statistics for a ContentStore instance.
@@ -18,12 +18,13 @@ import type { PlatformId } from "../adapters/types.js";
18
18
  * (shell-set, survives `process.chdir`) before falling back.
19
19
  */
20
20
  /**
21
- * Detect whether a path lives inside the Claude Code plugin install tree —
22
- * specifically `<home>/.claude/plugins/cache/<plugin>/<plugin>/<version>/`
23
- * or the marketplace mirror `<home>/.claude/plugins/marketplaces/...`.
21
+ * Detect whether a path lives inside an agent plugin install tree —
22
+ * specifically `<home>/.claude/plugins/cache/<plugin>/<plugin>/<version>/`,
23
+ * `<home>/.codex/plugins/cache/<plugin>/<plugin>/<version>/`, or the
24
+ * marketplace mirror under `<home>/.{claude,codex}/plugins/marketplaces/...`.
24
25
  *
25
26
  * Cross-OS: matches both POSIX (`/`) and Windows (`\`) path separators.
26
- * Independent of `home` location — we only care about the `.claude/plugins/`
27
+ * Independent of `home` location — we only care about the agent plugin
27
28
  * suffix pattern.
28
29
  */
29
30
  export declare function isPluginInstallPath(p: string): boolean;
@@ -65,24 +66,26 @@ export declare function resolveProjectDirFromTranscript(opts: {
65
66
  * session log when the spawned MCP child inherits a non-project cwd
66
67
  * (e.g. $HOME when Codex was launched from anywhere outside the project).
67
68
  *
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.
69
+ * Codex writes its session transcripts to either
70
+ * `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl` (CLI) or a dated desktop
71
+ * layout such as
72
+ * `${CODEX_HOME ?? ~/.codex}/sessions/YYYY/MM/DD/rollout-*.jsonl`.
73
+ * The cwd appears on `meta.cwd` for the CLI shape and on
74
+ * `payload.cwd` in `type: "session_meta"` records for Codex Desktop. Codex
75
+ * publishes NO workspace env var to its child MCP processes — so unlike
76
+ * Claude/Pi/Cursor, we have no env signal at all. The session log is the
77
+ * strongest available signal.
75
78
  *
76
79
  * 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
+ * • Sessions may live flat or in a dated hierarchy (no per-project encoded
81
+ * subdir like Claude's `~/.claude/projects/<encoded>/`).
82
+ * • The cwd is nested on `meta.cwd` or `payload.cwd`, not top-level `cwd`.
80
83
  *
81
84
  * Returns `null` when:
82
85
  * • `codexHome` or its `sessions/` subdir does not exist.
83
- * • No `.jsonl` files exist or none has a parseable `meta.cwd` string.
86
+ * • No `.jsonl` files exist or none has a parseable cwd string.
84
87
  * • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
85
- * • The resolved `meta.cwd` points at a plugin install path (poisoned).
88
+ * • The resolved cwd points at a plugin install path (poisoned).
86
89
  */
87
90
  export declare function resolveCodexSessionCwd(opts?: {
88
91
  /** Defaults to `process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")`. */
@@ -47,18 +47,19 @@ const LEGACY_NON_STRICT_CANDIDATES = [
47
47
  * (shell-set, survives `process.chdir`) before falling back.
48
48
  */
49
49
  /**
50
- * Detect whether a path lives inside the Claude Code plugin install tree —
51
- * specifically `<home>/.claude/plugins/cache/<plugin>/<plugin>/<version>/`
52
- * or the marketplace mirror `<home>/.claude/plugins/marketplaces/...`.
50
+ * Detect whether a path lives inside an agent plugin install tree —
51
+ * specifically `<home>/.claude/plugins/cache/<plugin>/<plugin>/<version>/`,
52
+ * `<home>/.codex/plugins/cache/<plugin>/<plugin>/<version>/`, or the
53
+ * marketplace mirror under `<home>/.{claude,codex}/plugins/marketplaces/...`.
53
54
  *
54
55
  * Cross-OS: matches both POSIX (`/`) and Windows (`\`) path separators.
55
- * Independent of `home` location — we only care about the `.claude/plugins/`
56
+ * Independent of `home` location — we only care about the agent plugin
56
57
  * suffix pattern.
57
58
  */
58
59
  export function isPluginInstallPath(p) {
59
60
  if (!p)
60
61
  return false;
61
- return /[/\\]\.claude[/\\]plugins[/\\](cache|marketplaces)[/\\]/.test(p);
62
+ return /[/\\]\.(claude|codex)[/\\]plugins[/\\](cache|marketplaces)[/\\]/.test(p);
62
63
  }
63
64
  /**
64
65
  * Read the per-session project dir from Claude Code's transcript files.
@@ -162,46 +163,76 @@ export function resolveProjectDirFromTranscript(opts) {
162
163
  * session log when the spawned MCP child inherits a non-project cwd
163
164
  * (e.g. $HOME when Codex was launched from anywhere outside the project).
164
165
  *
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.
166
+ * Codex writes its session transcripts to either
167
+ * `${CODEX_HOME ?? ~/.codex}/sessions/<uuid>.jsonl` (CLI) or a dated desktop
168
+ * layout such as
169
+ * `${CODEX_HOME ?? ~/.codex}/sessions/YYYY/MM/DD/rollout-*.jsonl`.
170
+ * The cwd appears on `meta.cwd` for the CLI shape and on
171
+ * `payload.cwd` in `type: "session_meta"` records for Codex Desktop. Codex
172
+ * publishes NO workspace env var to its child MCP processes — so unlike
173
+ * Claude/Pi/Cursor, we have no env signal at all. The session log is the
174
+ * strongest available signal.
172
175
  *
173
176
  * 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
+ * • Sessions may live flat or in a dated hierarchy (no per-project encoded
178
+ * subdir like Claude's `~/.claude/projects/<encoded>/`).
179
+ * • The cwd is nested on `meta.cwd` or `payload.cwd`, not top-level `cwd`.
177
180
  *
178
181
  * Returns `null` when:
179
182
  * • `codexHome` or its `sessions/` subdir does not exist.
180
- * • No `.jsonl` files exist or none has a parseable `meta.cwd` string.
183
+ * • No `.jsonl` files exist or none has a parseable cwd string.
181
184
  * • The newest log is older than `transcriptMaxAgeMs` (multi-window guard).
182
- * • The resolved `meta.cwd` points at a plugin install path (poisoned).
185
+ * • The resolved cwd points at a plugin install path (poisoned).
183
186
  */
184
187
  export function resolveCodexSessionCwd(opts) {
185
188
  const codexHome = opts?.codexHome ?? process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex");
186
189
  const sessionsDir = path.join(codexHome, "sessions");
187
190
  if (!fs.existsSync(sessionsDir))
188
191
  return null;
192
+ const MAX_SCAN_DEPTH = 4; // sessions/YYYY/MM/DD/<file>.jsonl plus one spare.
193
+ const MAX_SCAN_ENTRIES = 10_000;
194
+ let visitedEntries = 0;
189
195
  let bestPath;
190
196
  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);
197
+ const visit = (dir, depth) => {
198
+ if (visitedEntries >= MAX_SCAN_ENTRIES)
199
+ return;
200
+ let entries;
201
+ try {
202
+ entries = fs.readdirSync(dir);
203
+ }
204
+ catch {
205
+ return;
206
+ }
207
+ entries.sort().reverse();
208
+ for (const entry of entries) {
209
+ if (visitedEntries >= MAX_SCAN_ENTRIES)
210
+ return;
211
+ visitedEntries++;
212
+ const fp = path.join(dir, entry);
213
+ let stat;
196
214
  try {
197
- const m = fs.statSync(fp).mtimeMs;
198
- if (m > bestMtime) {
199
- bestMtime = m;
200
- bestPath = fp;
201
- }
215
+ stat = fs.statSync(fp);
216
+ }
217
+ catch {
218
+ continue;
219
+ }
220
+ if (stat.isDirectory()) {
221
+ if (depth < MAX_SCAN_DEPTH)
222
+ visit(fp, depth + 1);
223
+ continue;
224
+ }
225
+ if (!stat.isFile() || !entry.endsWith(".jsonl"))
226
+ continue;
227
+ const m = stat.mtimeMs;
228
+ if (m > bestMtime) {
229
+ bestMtime = m;
230
+ bestPath = fp;
202
231
  }
203
- catch { /* skip */ }
204
232
  }
233
+ };
234
+ try {
235
+ visit(sessionsDir, 0);
205
236
  }
206
237
  catch {
207
238
  return null;
@@ -213,28 +244,31 @@ export function resolveCodexSessionCwd(opts) {
213
244
  if (nowMs - bestMtime > opts.transcriptMaxAgeMs)
214
245
  return null;
215
246
  }
216
- // Read first ~8KB; the SessionMeta JSON is line 1 and small. Stream-cap
217
- // mirrors `resolveProjectDirFromTranscript` for memory safety on long logs.
247
+ // Read a bounded head chunk. Codex Desktop's first session_meta line can be
248
+ // larger than Claude/Codex CLI metadata because it includes dynamic tool and
249
+ // instruction fields, but the full transcript can still be tens of MB.
218
250
  try {
219
251
  const fd = fs.openSync(bestPath, "r");
220
252
  try {
221
- const buf = Buffer.alloc(8192);
253
+ const buf = Buffer.alloc(1024 * 1024);
222
254
  const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
223
255
  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 */
256
+ for (const line of text.split("\n").slice(0, 10)) {
257
+ if (!line.trim())
258
+ continue;
259
+ try {
260
+ const obj = JSON.parse(line);
261
+ const cwd = obj?.meta?.cwd ??
262
+ (obj?.type === "session_meta" ? obj?.payload?.cwd : undefined);
263
+ if (typeof cwd !== "string" || cwd.length === 0)
264
+ continue;
265
+ if (isPluginInstallPath(cwd))
266
+ return null;
267
+ return cwd;
268
+ }
269
+ catch {
270
+ return null; /* malformed session metadata line */
271
+ }
238
272
  }
239
273
  }
240
274
  finally {
@@ -244,6 +278,7 @@ export function resolveCodexSessionCwd(opts) {
244
278
  catch {
245
279
  return null; /* file vanished mid-read */
246
280
  }
281
+ return null;
247
282
  }
248
283
  /**
249
284
  * Pure project-dir resolver. Mirror of the env-var chain inside