mulmoclaude 0.9.2 → 0.9.3

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 (40) hide show
  1. package/bin/mulmoclaude.js +2 -1
  2. package/client/assets/{index-Dc0R-HW5.js → index-40ErrJ4a.js} +23 -23
  3. package/client/assets/{index-Dxo1Zdd-.css → index-BXb--as6.css} +1 -1
  4. package/client/assets/{marp-CSq0PPfK.js → marp-Dh7C24F1.js} +1 -1
  5. package/client/index.html +2 -2
  6. package/package.json +8 -7
  7. package/server/api/routes/chat-index.ts +5 -1
  8. package/server/api/routes/collections.ts +136 -0
  9. package/server/index.ts +14 -2
  10. package/server/prompts/system/system.md +7 -1
  11. package/server/remoteHost/handlers/collectionPage.ts +15 -17
  12. package/server/remoteHost/handlers/getCollection.ts +2 -2
  13. package/server/remoteHost/handlers/getFeed.ts +2 -2
  14. package/server/remoteHost/handlers/getRemoteView.ts +37 -0
  15. package/server/remoteHost/handlers/getRemoteViewItems.ts +38 -0
  16. package/server/remoteHost/handlers/index.ts +6 -0
  17. package/server/remoteHost/handlers/mutateRemoteView.ts +40 -0
  18. package/server/utils/files/thumbnail-store.ts +97 -0
  19. package/server/workspace/chat-index/index.ts +8 -1
  20. package/server/workspace/chat-index/indexer.ts +142 -27
  21. package/server/workspace/collections/index.ts +1 -0
  22. package/server/workspace/collections/remoteView.ts +290 -0
  23. package/src/App.vue +57 -3
  24. package/src/composables/collections/uiHost.ts +15 -0
  25. package/src/composables/usePubSub.ts +40 -2
  26. package/src/composables/useSessionSync.ts +9 -0
  27. package/src/config/apiRoutes.ts +20 -0
  28. package/src/config/roles.ts +2 -2
  29. package/src/lang/de.ts +2 -0
  30. package/src/lang/en.ts +2 -0
  31. package/src/lang/es.ts +2 -0
  32. package/src/lang/fr.ts +2 -0
  33. package/src/lang/ja.ts +2 -0
  34. package/src/lang/ko.ts +2 -0
  35. package/src/lang/pt-BR.ts +2 -0
  36. package/src/lang/zh.ts +1 -0
  37. package/src/plugins/textResponse/View.vue +49 -14
  38. package/src/plugins/textResponse/utils.ts +37 -0
  39. package/src/utils/html/previewCsp.ts +7 -16
  40. package/src/utils/role/icon.ts +9 -2
@@ -0,0 +1,37 @@
1
+ // getRemoteView command handler (remote-host phase 3 —
2
+ // plans/feat-remote-custom-view.md).
3
+ //
4
+ // Returns one mobile (`target: "mobile"`) custom view wrapped HOST-side into
5
+ // its sandboxed srcdoc (CSP + postMessage bootstrap), so the phone renders the
6
+ // artifact verbatim — the same builder the desktop phone-frame preview reads
7
+ // over HTTP, keeping preview === phone structural. The srcdoc travels inside
8
+ // the Firestore command document; the builder enforces the 1 MiB budget.
9
+ //
10
+ // Factory (createGetRemoteView) keeps the mapping unit-testable with the
11
+ // engine stubbed; the default export wires the real functions.
12
+ import { loadCollection } from "../../workspace/collections/index.js";
13
+ import { buildRemoteView, remoteViewFailureMessage } from "../../workspace/collections/remoteView.js";
14
+ import type { CommandHandler, JsonObject } from "../commandChannel.js";
15
+
16
+ export interface GetRemoteViewDeps {
17
+ loadCollection: typeof loadCollection;
18
+ buildRemoteView: typeof buildRemoteView;
19
+ }
20
+
21
+ export const createGetRemoteView =
22
+ (deps: GetRemoteViewDeps): CommandHandler =>
23
+ async (params: JsonObject) => {
24
+ const slug = String(params.slug ?? "");
25
+ const viewId = String(params.viewId ?? "");
26
+ const locale = typeof params.locale === "string" ? params.locale : "";
27
+ const collection = await deps.loadCollection(slug);
28
+ if (!collection) throw new Error(`collection '${slug}' not found`);
29
+ const result = await deps.buildRemoteView(collection, viewId, locale);
30
+ if (result.kind !== "ok") throw new Error(remoteViewFailureMessage(result, slug));
31
+ const { view, srcdoc, bytes } = result;
32
+ // Plain JSON, but the interface lacks an index signature — cast like the
33
+ // phase-2 handlers.
34
+ return { view, srcdoc, bytes } as unknown as JsonObject;
35
+ };
36
+
37
+ export const getRemoteView = createGetRemoteView({ loadCollection, buildRemoteView });
@@ -0,0 +1,38 @@
1
+ // getRemoteViewItems command handler (remote-host phase 5 —
2
+ // plans/feat-remote-view-images.md).
3
+ //
4
+ // One page of a mobile view's records, view-aware so the host can inline the
5
+ // view's declared `imageFields` as `data:` URL thumbnails (a phone can't reach
6
+ // the workspace to render an image path). Same builder the desktop phone-frame
7
+ // preview reads over HTTP, so preview === phone. Supersedes the phase-2
8
+ // getCollection for a custom view's getItems: the page is already projected +
9
+ // image-inlined, so the mulmoserver client passes it straight to the view.
10
+ //
11
+ // Factory (createGetRemoteViewItems) keeps the mapping unit-testable with the
12
+ // engine stubbed; the default export wires the real functions.
13
+ import { clampLimit, clampOffset, normalizeFields } from "@mulmoclaude/core/remote-view";
14
+ import { loadCollection } from "../../workspace/collections/index.js";
15
+ import { remoteViewItems, remoteViewItemsFailureMessage } from "../../workspace/collections/remoteView.js";
16
+ import type { CommandHandler, JsonObject } from "../commandChannel.js";
17
+
18
+ export interface GetRemoteViewItemsDeps {
19
+ loadCollection: typeof loadCollection;
20
+ remoteViewItems: typeof remoteViewItems;
21
+ }
22
+
23
+ export const createGetRemoteViewItems =
24
+ (deps: GetRemoteViewItemsDeps): CommandHandler =>
25
+ async (params: JsonObject) => {
26
+ const slug = String(params.slug ?? "");
27
+ const viewId = String(params.viewId ?? "");
28
+ const request = { offset: clampOffset(params.offset), limit: clampLimit(params.limit), fields: normalizeFields(params.fields) };
29
+ const collection = await deps.loadCollection(slug);
30
+ if (!collection) throw new Error(`collection '${slug}' not found`);
31
+ const result = await deps.remoteViewItems(collection, viewId, request);
32
+ if (result.kind !== "ok") throw new Error(remoteViewItemsFailureMessage(result, slug));
33
+ // Plain JSON, but the interface lacks an index signature — cast like the
34
+ // phase-2/3 handlers.
35
+ return { page: result.page, inlined: result.inlined, omitted: result.omitted } as unknown as JsonObject;
36
+ };
37
+
38
+ export const getRemoteViewItems = createGetRemoteViewItems({ loadCollection, remoteViewItems });
@@ -4,9 +4,12 @@
4
4
  import type { CommandHandlers } from "../commandChannel.js";
5
5
  import { getCollection } from "./getCollection.js";
6
6
  import { getFeed } from "./getFeed.js";
7
+ import { getRemoteView } from "./getRemoteView.js";
8
+ import { getRemoteViewItems } from "./getRemoteViewItems.js";
7
9
  import { listCollections } from "./listCollections.js";
8
10
  import { listFeeds } from "./listFeeds.js";
9
11
  import { listShortcuts } from "./listShortcuts.js";
12
+ import { mutateRemoteViewItem } from "./mutateRemoteView.js";
10
13
  import { startChat } from "./startChat.js";
11
14
 
12
15
  export const handlers: CommandHandlers = {
@@ -15,5 +18,8 @@ export const handlers: CommandHandlers = {
15
18
  listShortcuts,
16
19
  listFeeds,
17
20
  getFeed,
21
+ getRemoteView,
22
+ getRemoteViewItems,
23
+ mutateRemoteViewItem,
18
24
  startChat,
19
25
  };
@@ -0,0 +1,40 @@
1
+ // mutateRemoteViewItem command handler (remote-host phase 4 —
2
+ // plans/feat-remote-writable-view.md).
3
+ //
4
+ // Applies one update/delete requested by a `target: "mobile"` custom view on
5
+ // the phone, authorized by that view's OWN declared surface
6
+ // (editableFields / allowDelete) and enforced HOST-side (createMutateRemoteView)
7
+ // — the sandboxed view is never trusted. The parent (mulmoserver) supplies the
8
+ // `viewId` it mounted; the sandboxed document cannot spoof a different view's
9
+ // policy. Shares the builder with the desktop preview's HTTP route so both
10
+ // transports apply identical policy.
11
+ //
12
+ // Factory (createMutateRemoteView-backed) keeps the mapping unit-testable with
13
+ // the engine stubbed; the default export wires the real functions.
14
+ import { normalizeMutate } from "@mulmoclaude/core/remote-view";
15
+ import { loadCollection } from "../../workspace/collections/index.js";
16
+ import { mutateRemoteView, mutateRemoteViewFailureMessage } from "../../workspace/collections/remoteView.js";
17
+ import type { CommandHandler, JsonObject } from "../commandChannel.js";
18
+
19
+ export interface MutateRemoteViewHandlerDeps {
20
+ loadCollection: typeof loadCollection;
21
+ mutateRemoteView: typeof mutateRemoteView;
22
+ }
23
+
24
+ export const createMutateRemoteViewHandler =
25
+ (deps: MutateRemoteViewHandlerDeps): CommandHandler =>
26
+ async (params: JsonObject) => {
27
+ const slug = String(params.slug ?? "");
28
+ const viewId = String(params.viewId ?? "");
29
+ const request = normalizeMutate({ op: params.op, id: params.id, patch: params.patch });
30
+ if (!request) throw new Error("invalid mutate request — expected { op: 'update'|'delete', id, patch? }");
31
+ const collection = await deps.loadCollection(slug);
32
+ if (!collection) throw new Error(`collection '${slug}' not found`);
33
+ const result = await deps.mutateRemoteView(collection, viewId, request);
34
+ if (result.kind !== "ok") throw new Error(mutateRemoteViewFailureMessage(result, slug));
35
+ // Plain JSON, but the interface lacks an index signature — cast like the
36
+ // other phase-2/3 handlers.
37
+ return (result.op === "delete" ? { op: "delete", id: result.id } : { op: "update", item: result.item }) as unknown as JsonObject;
38
+ };
39
+
40
+ export const mutateRemoteViewItem = createMutateRemoteViewHandler({ loadCollection, mutateRemoteView });
@@ -0,0 +1,97 @@
1
+ // Downscaled `data:` URL thumbnails for remote (mobile) custom views
2
+ // (plans/feat-remote-view-images.md). A phone can't reach the host's localhost,
3
+ // so an `image`-type field's workspace path is unrenderable there; a view that
4
+ // lists the field in `imageFields` gets it inlined as a small JPEG data URL the
5
+ // host produces here. Kept a leaf util (no collection/remote-view imports) so
6
+ // the builder in remoteView.ts is the only wiring point.
7
+ //
8
+ // Reads are workspace-containment-guarded (resolveWithinRoot, same discipline as
9
+ // image-store.ts). Results are cached by (path, mtime, maxEdge) so repeated
10
+ // pages / "load more" scrolls never re-decode the same source.
11
+ import { readFile, realpath, stat } from "fs/promises";
12
+ import { workspacePath } from "../../workspace/paths.js";
13
+ import { resolveWithinRoot } from "./safe.js";
14
+ import { log } from "../../system/logger/index.js";
15
+
16
+ /** Decode → downscale to fit `maxEdge` (never enlarge) → re-encode JPEG.
17
+ * Injected so tests exercise the resolver without the native `sharp` binary. */
18
+ export type ResizeToJpeg = (input: Buffer, maxEdge: number) => Promise<Buffer>;
19
+
20
+ // JPEG quality for inlined thumbnails — a size/fidelity knob; 72 keeps a
21
+ // 512px thumbnail in the low tens of KB, well within the page budget.
22
+ const THUMBNAIL_JPEG_QUALITY = 72;
23
+
24
+ const sharpResize: ResizeToJpeg = async (input, maxEdge) => {
25
+ // Dynamic import: only the default resolver pulls the native binary, so tests
26
+ // (which inject their own resize) and code paths that never make a thumbnail
27
+ // don't load it.
28
+ const sharp = (await import("sharp")).default;
29
+ // `.rotate()` bakes in EXIF orientation so portrait phone photos aren't sideways.
30
+ return sharp(input).rotate().resize(maxEdge, maxEdge, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: THUMBNAIL_JPEG_QUALITY }).toBuffer();
31
+ };
32
+
33
+ interface CacheEntry {
34
+ mtimeMs: number;
35
+ maxEdge: number;
36
+ dataUrl: string;
37
+ }
38
+
39
+ // In-memory, per host process. Bounded LRU (Map keeps insertion order; a get
40
+ // re-inserts to mark recency, a set past the cap evicts the oldest).
41
+ const MAX_CACHE_ENTRIES = 256;
42
+ const cache = new Map<string, CacheEntry>();
43
+
44
+ function cacheGet(relPath: string, mtimeMs: number, maxEdge: number): string | null {
45
+ const entry = cache.get(relPath);
46
+ if (!entry || entry.mtimeMs !== mtimeMs || entry.maxEdge !== maxEdge) return null;
47
+ cache.delete(relPath);
48
+ cache.set(relPath, entry);
49
+ return entry.dataUrl;
50
+ }
51
+
52
+ function cacheSet(relPath: string, entry: CacheEntry): void {
53
+ cache.set(relPath, entry);
54
+ if (cache.size > MAX_CACHE_ENTRIES) {
55
+ const oldest = cache.keys().next().value;
56
+ if (oldest !== undefined) cache.delete(oldest);
57
+ }
58
+ }
59
+
60
+ /** Test-only: drop the cache so a spy on the resize fn sees a fresh encode. */
61
+ export function clearThumbnailCache(): void {
62
+ cache.clear();
63
+ }
64
+
65
+ /** Resolve a workspace-relative image path to a downscaled JPEG `data:` URL, or
66
+ * `null` when the path escapes the workspace, is missing, or can't be decoded —
67
+ * the caller then leaves the field as its original path (rendered as a
68
+ * placeholder by the view). `maxEdge` should already be clamped by the caller
69
+ * (`clampImageMaxEdge`). */
70
+ export function createThumbnailResolver(resize: ResizeToJpeg = sharpResize) {
71
+ return async function resolveThumbnail(relPath: string, maxEdge: number): Promise<string | null> {
72
+ if (typeof relPath !== "string" || relPath.length === 0) return null;
73
+ let root: string;
74
+ try {
75
+ root = await realpath(workspacePath);
76
+ } catch {
77
+ return null;
78
+ }
79
+ const abs = resolveWithinRoot(root, relPath);
80
+ if (!abs) return null;
81
+ const info = await stat(abs).catch(() => null);
82
+ if (!info?.isFile()) return null;
83
+ const cached = cacheGet(relPath, info.mtimeMs, maxEdge);
84
+ if (cached) return cached;
85
+ try {
86
+ const out = await resize(await readFile(abs), maxEdge);
87
+ const dataUrl = `data:image/jpeg;base64,${out.toString("base64")}`;
88
+ cacheSet(relPath, { mtimeMs: info.mtimeMs, maxEdge, dataUrl });
89
+ return dataUrl;
90
+ } catch (err) {
91
+ log.warn("thumbnail", "resolve failed", { relPath, error: String(err) });
92
+ return null;
93
+ }
94
+ };
95
+ }
96
+
97
+ export const resolveThumbnail = createThumbnailResolver();
@@ -99,6 +99,12 @@ export async function backfillAllSessions(
99
99
  opts: {
100
100
  workspaceRoot?: string;
101
101
  deps?: IndexerDeps;
102
+ // Opt-in to "regenerate every summary, even those still current".
103
+ // Default false — the scheduled tick uses that so unchanged
104
+ // sessions cost only a stat + entry read, not a Claude CLI call.
105
+ // The manual rebuild endpoint and CHAT_INDEX_FORCE_RUN_ON_STARTUP
106
+ // opt in explicitly, matching their debug / rollout intent (#1929).
107
+ force?: boolean;
102
108
  } = {},
103
109
  ): Promise<BackfillResult> {
104
110
  const workspaceRoot = opts.workspaceRoot ?? defaultWorkspacePath;
@@ -108,6 +114,7 @@ export async function backfillAllSessions(
108
114
  indexed: 0,
109
115
  skipped: 0,
110
116
  };
117
+ const force = opts.force === true;
111
118
  for (const sessionId of ids) {
112
119
  if (disabled) {
113
120
  result.skipped++;
@@ -116,7 +123,7 @@ export async function backfillAllSessions(
116
123
  try {
117
124
  const entry = await indexSession(workspaceRoot, sessionId, {
118
125
  ...(opts.deps ?? {}),
119
- force: true,
126
+ ...(force ? { force: true } : {}),
120
127
  });
121
128
  if (entry) {
122
129
  result.indexed++;
@@ -8,7 +8,8 @@
8
8
  // at a `mkdtempSync` directory without touching the real
9
9
  // ~/mulmoclaude.
10
10
 
11
- import { readdir, readFile, rm } from "node:fs/promises";
11
+ import { readdir, readFile, rm, stat } from "node:fs/promises";
12
+ import path from "node:path";
12
13
  import { defaultSummarize, loadJsonlInput, type SummarizeFn } from "./summarizer.js";
13
14
  import { chatDirFor, indexEntryPathFor, manifestPathFor, sessionJsonlPathFor, sessionMetaPathFor } from "./paths.js";
14
15
  import type { ChatIndexEntry, ChatIndexManifest } from "./types.js";
@@ -22,6 +23,12 @@ import { isRecord } from "../../utils/types.js";
22
23
  // — long enough that a single conversation doesn't re-summarize
23
24
  // every turn, short enough that a user who leaves for lunch and
24
25
  // comes back sees the title refresh.
26
+ //
27
+ // Complementary to the content-change gate below: `isFresh` says
28
+ // "we JUST indexed, don't retry mid-conversation", while
29
+ // `sessionJsonlChangedSinceIndex` says "the jsonl hasn't been
30
+ // touched since we last indexed, no work to do". Both run when
31
+ // `force` is false; `force: true` bypasses both.
25
32
  export const MIN_INDEX_INTERVAL_MS = 15 * ONE_MINUTE_MS;
26
33
 
27
34
  // Injection points for tests. Defaults are the production spawn +
@@ -30,10 +37,13 @@ export interface IndexerDeps {
30
37
  summarize?: SummarizeFn;
31
38
  now?: () => number;
32
39
  minIntervalMs?: number;
33
- // Bypass the `isFresh` freshness throttle. Used by the
34
- // backfill helper and the debug trigger endpoint so a manual
35
- // "rebuild everything" run doesn't silently skip entries that
36
- // happen to be within the 15-minute window.
40
+ // Bypass both the `isFresh` freshness throttle AND the content-
41
+ // changed gate (`sessionJsonlChangedSinceIndex`). Used by the
42
+ // manual rebuild endpoint and the `CHAT_INDEX_FORCE_RUN_ON_STARTUP`
43
+ // startup path so "regenerate everything" semantics keep working
44
+ // even when the summariser is unchanged — e.g. the summariser
45
+ // prompt was edited and existing summaries are stale by design,
46
+ // not by content.
37
47
  force?: boolean;
38
48
  }
39
49
 
@@ -110,34 +120,106 @@ export async function updateManifest(workspaceRoot: string, mutator: (m: ChatInd
110
120
  // disk. Both removals tolerate "missing" — sessions that were never
111
121
  // indexed have no entry to prune.
112
122
  export async function removeSessionFromIndex(workspaceRoot: string, sessionId: string): Promise<void> {
113
- await rm(indexEntryPathFor(workspaceRoot, sessionId), { force: true });
123
+ const safeId = safeSessionIdOrNull(sessionId);
124
+ if (safeId === null) return;
125
+ await rm(indexEntryPathFor(workspaceRoot, safeId), { force: true });
114
126
  await updateManifest(workspaceRoot, (manifest) => ({
115
127
  ...manifest,
116
- entries: manifest.entries.filter((entry) => entry.id !== sessionId),
128
+ entries: manifest.entries.filter((entry) => entry.id !== safeId),
117
129
  }));
118
130
  }
119
131
 
120
132
  // --- freshness check ------------------------------------------------
121
133
 
134
+ // Shared parsing of `<indexDir>/<sessionId>.json` → `indexedAt` as
135
+ // milliseconds since epoch. Returns null on any failure (file
136
+ // missing, unreadable, JSON parse error, wrong shape, unparseable
137
+ // timestamp) so callers can each pick their own "no entry" semantic
138
+ // (skip vs reindex vs throttle) without duplicating the read.
139
+ // Exported so `isFresh` / `sessionJsonlChangedSinceIndex` share one
140
+ // canonical parse — CodeRabbit review on #1930.
141
+ export async function readIndexedAtMs(workspaceRoot: string, sessionId: string): Promise<number | null> {
142
+ const safeId = safeSessionIdOrNull(sessionId);
143
+ if (safeId === null) return null;
144
+ try {
145
+ const raw = await readFile(indexEntryPathFor(workspaceRoot, safeId), "utf-8");
146
+ const entry: unknown = JSON.parse(raw);
147
+ if (!isRecord(entry)) return null;
148
+ const { indexedAt } = entry as Record<string, unknown>;
149
+ if (typeof indexedAt !== "string") return null;
150
+ const parsed = Date.parse(indexedAt);
151
+ return Number.isNaN(parsed) ? null : parsed;
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
122
157
  // A session is "fresh" when its per-session index file exists and
123
158
  // was written less than `minIntervalMs` ago. Fresh sessions are
124
159
  // skipped so a long conversation doesn't spam the CLI on every
125
- // turn.
160
+ // turn. Delegates disk / parse work to `readIndexedAtMs` so the
161
+ // two throttles stay in lockstep on entry-file semantics.
126
162
  export async function isFresh(workspaceRoot: string, sessionId: string, now: number, minIntervalMs: number): Promise<boolean> {
163
+ const indexedMs = await readIndexedAtMs(workspaceRoot, sessionId);
164
+ if (indexedMs === null) return false;
165
+ return now - indexedMs < minIntervalMs;
166
+ }
167
+
168
+ // Returns true when the session's jsonl was written / appended AFTER
169
+ // its last index entry — i.e. there IS new content to summarize.
170
+ //
171
+ // Complements `isFresh`: without this gate, the hourly scheduler
172
+ // tick re-summarizes every session even when nothing new was
173
+ // written since the last index (issue #1929). With it, the tick's
174
+ // per-session cost drops to O(stat + file read) for unchanged
175
+ // sessions and only spends a Claude CLI call on the ones that
176
+ // actually saw a new turn.
177
+ //
178
+ // Return-value contract, for each unknown state:
179
+ // - Hostile / malformed sessionId → false (skip; something upstream
180
+ // handed us a value that could escape the chat dir via `..`).
181
+ // - Entry file missing/malformed → true (we've never captured this
182
+ // session; index it).
183
+ // - Jsonl file missing → false (nothing to reindex; the caller
184
+ // would return null downstream anyway, but we short-circuit).
185
+ // - Otherwise → jsonl mtime > entry.indexedAt.
186
+ export async function sessionJsonlChangedSinceIndex(workspaceRoot: string, sessionId: string): Promise<boolean> {
187
+ const safeId = safeSessionIdOrNull(sessionId);
188
+ if (safeId === null) return false;
189
+ const indexedMs = await readIndexedAtMs(workspaceRoot, safeId);
190
+ if (indexedMs === null) return true;
127
191
  try {
128
- const raw = await readFile(indexEntryPathFor(workspaceRoot, sessionId), "utf-8");
129
- const entry: unknown = JSON.parse(raw);
130
- if (!isRecord(entry)) return false;
131
- const { indexedAt } = entry as Record<string, unknown>;
132
- if (typeof indexedAt !== "string") return false;
133
- const indexedTimestamp = Date.parse(indexedAt);
134
- if (Number.isNaN(indexedTimestamp)) return false;
135
- return now - indexedTimestamp < minIntervalMs;
192
+ const info = await stat(sessionJsonlPathFor(workspaceRoot, safeId));
193
+ return info.mtimeMs > indexedMs;
136
194
  } catch {
137
195
  return false;
138
196
  }
139
197
  }
140
198
 
199
+ // Path-traversal guard for session IDs used to derive on-disk paths.
200
+ // Two-step check so both a static analyser (CodeQL taints
201
+ // `sessionId` as a possible route-param input) AND a human reader
202
+ // can convince themselves the derived path stays inside the chat
203
+ // dir:
204
+ //
205
+ // 1. `path.basename()` collapses any `..` / separator to the last
206
+ // segment. CodeQL recognises this as a barrier — a value whose
207
+ // taint originates upstream is neutralised here.
208
+ // 2. `SAFE_SESSION_ID_RE` narrows the character class further
209
+ // (word chars, `.`, `-`, up to 200 long) and rejects any
210
+ // `..` substring, mirroring `server/api/bridge/sessionRole.ts`.
211
+ //
212
+ // Returns the cleaned id on success (identical to the input for
213
+ // legit ids) or `null` on any hostile / malformed input.
214
+ const SAFE_SESSION_ID_RE = /^[\w.-]{1,200}$/;
215
+ export function safeSessionIdOrNull(sessionId: string): string | null {
216
+ const basename = path.basename(sessionId);
217
+ if (basename !== sessionId) return null;
218
+ if (!SAFE_SESSION_ID_RE.test(basename)) return null;
219
+ if (basename.includes("..")) return null;
220
+ return basename;
221
+ }
222
+
141
223
  // --- session metadata ----------------------------------------------
142
224
 
143
225
  interface SessionMeta {
@@ -146,8 +228,14 @@ interface SessionMeta {
146
228
  }
147
229
 
148
230
  async function readSessionMeta(workspaceRoot: string, sessionId: string): Promise<SessionMeta> {
231
+ // Defence-in-depth: only `indexSession` calls this (with an
232
+ // already-sanitized id), but a future caller wiring the helper
233
+ // into a new code path shouldn't be able to escape the chat dir
234
+ // by mistake.
235
+ const safeId = safeSessionIdOrNull(sessionId);
236
+ if (safeId === null) return {};
149
237
  try {
150
- const raw = await readFile(sessionMetaPathFor(workspaceRoot, sessionId), "utf-8");
238
+ const raw = await readFile(sessionMetaPathFor(workspaceRoot, safeId), "utf-8");
151
239
  const parsed: unknown = JSON.parse(raw);
152
240
  if (!isRecord(parsed)) return {};
153
241
  const metaRecord = parsed as Record<string, unknown>;
@@ -161,11 +249,22 @@ async function readSessionMeta(workspaceRoot: string, sessionId: string): Promis
161
249
  }
162
250
 
163
251
  // List every session id that has a .jsonl file in the workspace
164
- // chat dir. Used by the backfill helper.
252
+ // chat dir. Used by the backfill helper. Filenames from disk are
253
+ // filtered through `safeSessionIdOrNull` so a stray file with a
254
+ // hostile name (`.` / `..` / a slash-containing name that survived
255
+ // readdir on some FS) can never flow through the backfill loop
256
+ // into `indexSession`.
165
257
  export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
166
258
  try {
167
259
  const files = await readdir(chatDirFor(workspaceRoot));
168
- return files.filter((fileName) => fileName.endsWith(".jsonl")).map((fileName) => fileName.slice(0, -".jsonl".length));
260
+ const ids: string[] = [];
261
+ for (const fileName of files) {
262
+ if (!fileName.endsWith(".jsonl")) continue;
263
+ const stem = fileName.slice(0, -".jsonl".length);
264
+ const safeId = safeSessionIdOrNull(stem);
265
+ if (safeId !== null) ids.push(safeId);
266
+ }
267
+ return ids;
169
268
  } catch {
170
269
  return [];
171
270
  }
@@ -175,27 +274,43 @@ export async function listSessionIds(workspaceRoot: string): Promise<string[]> {
175
274
 
176
275
  // Index (or re-index) a single session. Returns the entry on
177
276
  // success, or null if the session was skipped (fresh, empty,
178
- // missing). The only exception that escapes is
277
+ // missing, or hostile id). The only exception that escapes is
179
278
  // `ClaudeCliNotFoundError` — the caller uses it to disable the
180
279
  // module for the rest of the process lifetime.
280
+ //
281
+ // `sessionId` is sanitized at entry (`safeSessionIdOrNull`) and the
282
+ // resulting `safeId` is threaded through every downstream file
283
+ // access, so `force: true` — a debug / rollout knob — cannot become
284
+ // a path-injection escape hatch (Codex review on #1930).
181
285
  export async function indexSession(workspaceRoot: string, sessionId: string, deps: IndexerDeps = {}): Promise<ChatIndexEntry | null> {
286
+ const safeId = safeSessionIdOrNull(sessionId);
287
+ if (safeId === null) return null;
182
288
  const summarize = deps.summarize ?? defaultSummarize;
183
289
  const now = (deps.now ?? Date.now)();
184
290
  const minInterval = deps.minIntervalMs ?? MIN_INDEX_INTERVAL_MS;
185
291
  const force = deps.force === true;
186
292
 
187
- if (!force && (await isFresh(workspaceRoot, sessionId, now, minInterval))) {
188
- return null;
293
+ if (!force) {
294
+ if (await isFresh(workspaceRoot, safeId, now, minInterval)) {
295
+ return null;
296
+ }
297
+ // Second gate: even past the freshness window, if the jsonl has
298
+ // NOT been written since the last index there's no new content
299
+ // to summarize. Cuts idle scheduler tick cost from O(sessions)
300
+ // Claude CLI calls to O(actual updates) — see #1929.
301
+ if (!(await sessionJsonlChangedSinceIndex(workspaceRoot, safeId))) {
302
+ return null;
303
+ }
189
304
  }
190
305
 
191
- const input = await loadJsonlInput(sessionJsonlPathFor(workspaceRoot, sessionId));
306
+ const input = await loadJsonlInput(sessionJsonlPathFor(workspaceRoot, safeId));
192
307
  if (!input.trim()) return null;
193
308
 
194
309
  const summary = await summarize(input);
195
- const meta = await readSessionMeta(workspaceRoot, sessionId);
310
+ const meta = await readSessionMeta(workspaceRoot, safeId);
196
311
 
197
312
  const entry: ChatIndexEntry = {
198
- id: sessionId,
313
+ id: safeId,
199
314
  roleId: meta.roleId ?? DEFAULT_ROLE_ID,
200
315
  startedAt: meta.startedAt ?? new Date(now).toISOString(),
201
316
  indexedAt: new Date(now).toISOString(),
@@ -207,12 +322,12 @@ export async function indexSession(workspaceRoot: string, sessionId: string, dep
207
322
  // Per-session file is written first so partial progress survives
208
323
  // a crash between the two writes: the next run can still observe
209
324
  // the fresh entry via isFresh and skip it.
210
- await writeJsonAtomic(indexEntryPathFor(workspaceRoot, sessionId), entry);
325
+ await writeJsonAtomic(indexEntryPathFor(workspaceRoot, safeId), entry);
211
326
 
212
327
  // Upsert into manifest under the in-process lock: replace any
213
328
  // prior entry with the same id, sort newest-first by startedAt.
214
329
  await updateManifest(workspaceRoot, (current) => {
215
- const filtered = current.entries.filter((entryItem) => entryItem.id !== sessionId);
330
+ const filtered = current.entries.filter((entryItem) => entryItem.id !== safeId);
216
331
  filtered.push(entry);
217
332
  filtered.sort((leftEntry, rightEntry) => Date.parse(rightEntry.startedAt) - Date.parse(leftEntry.startedAt));
218
333
  return { version: 1, entries: filtered };
@@ -16,6 +16,7 @@ export {
16
16
  readItem,
17
17
  writeItem,
18
18
  deleteItem,
19
+ safeRecordId,
19
20
  generateItemId,
20
21
  resolveCreateItemId,
21
22
  readSkillTemplate,