ccprofiles-core 0.6.0 → 0.7.1

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.
package/dist/manifest.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { z } from 'zod';
2
2
  import YAML from 'yaml';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { mkdir, readFile } from 'node:fs/promises';
4
4
  import { join } from 'node:path';
5
5
  import { execFile } from 'node:child_process';
6
6
  import { promisify } from 'node:util';
7
+ import { atomicWrite } from './fsutil.js';
7
8
  const exec = promisify(execFile);
8
9
  export class ManifestError extends Error {
9
10
  }
@@ -26,19 +27,39 @@ const ProfileSchema = z.object({
26
27
  settingsEnv: z.record(z.string()).default({}),
27
28
  skipPermissions: z.boolean().default(false),
28
29
  sharedSessions: z.boolean().default(false),
30
+ plugins: z.array(z.string()).default([]),
29
31
  });
32
+ const MarketplaceSchema = z.object({ source: z.string().min(1) });
30
33
  const ManifestSchema = z.object({
31
34
  version: z.literal(1),
32
35
  hub: z.string().nullable(),
33
36
  profiles: z.array(ProfileSchema),
34
37
  mcpServers: z.record(McpServerSchema),
38
+ marketplaces: z.record(MarketplaceSchema).default({}),
35
39
  });
36
40
  // identifiers that get interpolated into shell launcher code must be injection-safe
37
- const SAFE_NAME = /^[A-Za-z0-9_-]+$/; // profile names, launcher names, secret refs
41
+ // leading '-' is forbidden (but internal '-' still allowed) so a value can never be parsed as a
42
+ // CLI flag when passed as a positional argv entry (e.g. `claude plugin install <id>`)
43
+ const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; // profile names, launcher names, secret refs
38
44
  const SAFE_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; // POSIX/PowerShell env var names
39
45
  const SAFE_LINK_ENTRY = /^[A-Za-z0-9._-]+$/; // one profile-dir child; never a path
46
+ const SAFE_SOURCE = /^[A-Za-z0-9._@:/][A-Za-z0-9._/@:-]*$/; // marketplace source (interpolated into `claude plugin` shell-out); no leading '-'
40
47
  const SHELL_META = /["`$;|&()\n\r<>]/; // chars that could break out of a quoted shell context
41
48
  const SECRET_PREFIX = 'secret://';
49
+ // plaintext provider tokens that must never be committed to manifest git history
50
+ const PLAINTEXT_TOKEN = /sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9]{20,}|AKIA[0-9A-Z]{16}/;
51
+ /**
52
+ * True if `yaml` (raw manifest.yaml text, or any string) contains a plaintext provider
53
+ * token. saveManifest() uses this to decide whether to skip the git commit; doctor checks
54
+ * (CLI + UI) use it to surface that skip as a visible problem instead of a stderr-only warning.
55
+ */
56
+ export function manifestHasPlaintextSecret(yaml) {
57
+ return PLAINTEXT_TOKEN.test(yaml);
58
+ }
59
+ /** Reject any path with a literal `..` path-traversal segment, on either `/` or `\` separators. */
60
+ function hasDotDotSegment(path) {
61
+ return path.split(/[/\\]/).includes('..');
62
+ }
42
63
  /**
43
64
  * Reject manifests whose identifiers could break out of the shell launcher context.
44
65
  * Applies to every manifest — an untrusted bundle/peer manifest reaches this before
@@ -52,6 +73,8 @@ export function assertSafeManifest(m) {
52
73
  throw new ManifestError(`unsafe launcher name in profile "${p.name}": ${JSON.stringify(p.launcher)}`);
53
74
  if (SHELL_META.test(p.dir))
54
75
  throw new ManifestError(`unsafe profile dir in profile "${p.name}": ${JSON.stringify(p.dir)}`);
76
+ if (hasDotDotSegment(p.dir))
77
+ throw new ManifestError(`unsafe profile dir in profile "${p.name}": ${JSON.stringify(p.dir)} (must not contain a ".." segment)`);
55
78
  for (const [k, v] of Object.entries(p.env)) {
56
79
  if (!SAFE_ENV_KEY.test(k))
57
80
  throw new ManifestError(`unsafe env var name in profile "${p.name}": ${JSON.stringify(k)}`);
@@ -75,6 +98,19 @@ export function assertSafeManifest(m) {
75
98
  throw new ManifestError(`unsafe link entry in profile "${p.name}": ${JSON.stringify(entry)} (must be one file or directory name)`);
76
99
  }
77
100
  }
101
+ for (const [name, mk] of Object.entries(m.marketplaces ?? {})) {
102
+ if (!SAFE_NAME.test(name))
103
+ throw new ManifestError(`unsafe marketplace name: ${JSON.stringify(name)}`);
104
+ if (!SAFE_SOURCE.test(mk.source))
105
+ throw new ManifestError(`unsafe marketplace source for "${name}": ${JSON.stringify(mk.source)}`);
106
+ }
107
+ for (const p of m.profiles)
108
+ for (const id of p.plugins) {
109
+ const at = id.lastIndexOf('@');
110
+ const nm = at > 0 ? id.slice(0, at) : id, mkt = at > 0 ? id.slice(at + 1) : '';
111
+ if (!SAFE_NAME.test(nm) || !SAFE_NAME.test(mkt))
112
+ throw new ManifestError(`unsafe plugin id in profile "${p.name}": ${JSON.stringify(id)}`);
113
+ }
78
114
  }
79
115
  export function parseManifest(text) {
80
116
  let raw;
@@ -92,6 +128,13 @@ export function parseManifest(text) {
92
128
  for (const name of p.mcp)
93
129
  if (!m.mcpServers[name])
94
130
  throw new ManifestError(`profile "${p.name}" references undefined mcp server "${name}"`);
131
+ for (const p of m.profiles)
132
+ for (const id of p.plugins) {
133
+ const at = id.lastIndexOf('@');
134
+ const mkt = at > 0 ? id.slice(at + 1) : '';
135
+ if (!mkt || !m.marketplaces[mkt])
136
+ throw new ManifestError(`profile "${p.name}" references undefined marketplace for plugin "${id}"`);
137
+ }
95
138
  assertSafeManifest(m);
96
139
  return m;
97
140
  }
@@ -104,12 +147,38 @@ export async function loadManifest(root) {
104
147
  export async function saveManifest(root, m) {
105
148
  const yaml = serializeManifest(m);
106
149
  parseManifest(yaml); // guard: never write a manifest that cannot be reloaded
107
- await mkdir(root, { recursive: true });
108
- await writeFile(join(root, 'manifest.yaml'), yaml, 'utf8');
150
+ // manifestRoot also holds secrets.enc/devices.json — lock it down here since this is often
151
+ // the first thing (adopt/manifest init) to create it.
152
+ await mkdir(root, { recursive: true, mode: 0o700 });
153
+ // settingsEnv can carry plaintext secrets until `secrets migrate` runs — write at 0600.
154
+ await atomicWrite(join(root, 'manifest.yaml'), yaml, { mode: 0o600 });
155
+ if (manifestHasPlaintextSecret(yaml)) {
156
+ // never let a plaintext token land in git history — the file is still written above
157
+ // (callers may still need it on disk), we just refuse to commit it.
158
+ process.stderr.write('warn: manifest contains a plaintext secret — skipping git commit; run: ccprofiles secrets migrate\n');
159
+ return;
160
+ }
109
161
  try {
110
162
  await exec('git', ['-C', root, 'rev-parse', '--git-dir']);
111
163
  await exec('git', ['-C', root, 'add', '-A']);
112
164
  await exec('git', ['-C', root, 'commit', '-m', 'ccprofiles: update manifest']);
113
165
  }
114
- catch { /* not a repo or nothing to commit — fine */ }
166
+ catch (e) {
167
+ if (!isBenignGitError(e)) {
168
+ // manifest.yaml is already written above — don't throw, but don't silently drop history
169
+ // either. Anything other than "not a repo" / "nothing to commit" is a real failure
170
+ // (hook rejection, disk full, corrupt index, ...) the user should know about.
171
+ process.stderr.write(`warn: manifest.yaml written but git commit failed: ${gitErrorMessage(e)}\n`);
172
+ }
173
+ // else: not a git repo, or nothing changed to commit — expected, nothing to report
174
+ }
175
+ }
176
+ /** True for the two git outcomes saveManifest treats as expected/benign, not a real failure. */
177
+ function isBenignGitError(e) {
178
+ const text = gitErrorMessage(e);
179
+ return /nothing to commit/i.test(text) || /not a git repository/i.test(text);
180
+ }
181
+ function gitErrorMessage(e) {
182
+ const err = e;
183
+ return [err?.stderr, err?.stdout, err?.message].filter(Boolean).join(' ').trim() || String(e);
115
184
  }
package/dist/platform.js CHANGED
@@ -16,5 +16,12 @@ export function renderPath(template, p) {
16
16
  export function toTemplate(absPath, p) {
17
17
  const norm = absPath.replaceAll('\\', '/');
18
18
  const home = p.home.replaceAll('\\', '/');
19
- return norm.startsWith(home) ? '{home}' + norm.slice(home.length) : norm;
19
+ // require a path-separator boundary — a raw startsWith would also match sibling dirs
20
+ // that merely share a string prefix with home (e.g. home "/Users/tm" matching
21
+ // "/Users/tmfadhlul-old/...")
22
+ if (norm === home)
23
+ return '{home}';
24
+ if (norm.startsWith(home + '/'))
25
+ return '{home}' + norm.slice(home.length);
26
+ return norm;
20
27
  }
@@ -0,0 +1,21 @@
1
+ export interface PluginRunner {
2
+ marketplaceAdd(configDir: string, source: string): Promise<void>;
3
+ install(configDir: string, id: string): Promise<void>;
4
+ uninstall(configDir: string, id: string): Promise<void>;
5
+ }
6
+ export declare function marketplaceOf(id: string): string | null;
7
+ export declare function planPluginReconcile(desired: string[], current: string[]): {
8
+ install: string[];
9
+ uninstall: string[];
10
+ };
11
+ export declare function reconcileProfilePlugins(opts: {
12
+ configDir: string;
13
+ desired: string[];
14
+ current: string[];
15
+ marketplaces: Record<string, {
16
+ source: string;
17
+ }>;
18
+ runner: PluginRunner;
19
+ }): Promise<string[]>;
20
+ /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
21
+ export declare function restoreLegacyPluginSymlink(pluginsDir: string): Promise<boolean>;
@@ -0,0 +1,56 @@
1
+ import { cp, lstat, mkdir, readlink, unlink } from 'node:fs/promises';
2
+ import { existsSync } from 'node:fs';
3
+ export function marketplaceOf(id) {
4
+ const at = id.lastIndexOf('@');
5
+ return at > 0 ? id.slice(at + 1) : null;
6
+ }
7
+ export function planPluginReconcile(desired, current) {
8
+ const d = new Set(desired), c = new Set(current);
9
+ return { install: desired.filter(id => !c.has(id)), uninstall: current.filter(id => !d.has(id)) };
10
+ }
11
+ export async function reconcileProfilePlugins(opts) {
12
+ const log = [];
13
+ const { install, uninstall } = planPluginReconcile(opts.desired, opts.current);
14
+ for (const id of uninstall) {
15
+ await opts.runner.uninstall(opts.configDir, id);
16
+ log.push(`uninstall ${id}`);
17
+ }
18
+ const added = new Set();
19
+ for (const id of install) {
20
+ const mkt = marketplaceOf(id);
21
+ if (mkt && !added.has(mkt)) {
22
+ const src = opts.marketplaces[mkt]?.source;
23
+ if (src) {
24
+ await opts.runner.marketplaceAdd(opts.configDir, src);
25
+ added.add(mkt);
26
+ log.push(`add ${src}`);
27
+ }
28
+ }
29
+ await opts.runner.install(opts.configDir, id);
30
+ log.push(`install ${id}`);
31
+ }
32
+ return log;
33
+ }
34
+ /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
35
+ export async function restoreLegacyPluginSymlink(pluginsDir) {
36
+ let st = null;
37
+ try {
38
+ st = await lstat(pluginsDir);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ if (!st.isSymbolicLink())
44
+ return false;
45
+ const target = await readlink(pluginsDir);
46
+ // Never unlink-and-empty when the pool target is gone — that would silently leave the
47
+ // profile with a permanently empty plugins/ and no way to recover the original content.
48
+ // Leave the symlink in place and fail loudly instead.
49
+ if (!existsSync(target)) {
50
+ throw new Error(`cannot restore ${pluginsDir}: symlink target ${target} does not exist — leaving the symlink in place`);
51
+ }
52
+ await unlink(pluginsDir);
53
+ await mkdir(pluginsDir, { recursive: true });
54
+ await cp(target, pluginsDir, { recursive: true, force: false, errorOnExist: false });
55
+ return true;
56
+ }
@@ -0,0 +1,10 @@
1
+ export type AnthropicAuthMode = 'login' | 'api-key' | 'auth-token';
2
+ /** Derive the current Anthropic auth mode from a settingsEnv map. */
3
+ export declare function anthropicAuthMode(env: Record<string, string>): AnthropicAuthMode;
4
+ /**
5
+ * New settingsEnv with `mode` applied. Non-auth keys preserved. `login` removes
6
+ * both token vars; `api-key`/`auth-token` set that var to `tokenRef` and remove
7
+ * the other. Throws if env has a custom ANTHROPIC_BASE_URL (non-Anthropic provider)
8
+ * or if a token mode is chosen without a tokenRef.
9
+ */
10
+ export declare function setAnthropicAuthMode(env: Record<string, string>, mode: AnthropicAuthMode, tokenRef?: string): Record<string, string>;
@@ -0,0 +1,30 @@
1
+ const API_KEY = 'ANTHROPIC_API_KEY';
2
+ const AUTH_TOKEN = 'ANTHROPIC_AUTH_TOKEN';
3
+ const BASE_URL = 'ANTHROPIC_BASE_URL';
4
+ /** Derive the current Anthropic auth mode from a settingsEnv map. */
5
+ export function anthropicAuthMode(env) {
6
+ if (env[API_KEY] !== undefined)
7
+ return 'api-key';
8
+ if (env[AUTH_TOKEN] !== undefined)
9
+ return 'auth-token';
10
+ return 'login';
11
+ }
12
+ /**
13
+ * New settingsEnv with `mode` applied. Non-auth keys preserved. `login` removes
14
+ * both token vars; `api-key`/`auth-token` set that var to `tokenRef` and remove
15
+ * the other. Throws if env has a custom ANTHROPIC_BASE_URL (non-Anthropic provider)
16
+ * or if a token mode is chosen without a tokenRef.
17
+ */
18
+ export function setAnthropicAuthMode(env, mode, tokenRef) {
19
+ if (env[BASE_URL] !== undefined && env[BASE_URL].trim())
20
+ throw new Error(`profile uses a custom provider base URL (${env[BASE_URL]}) — manage its token in the Provider editor, not as an Anthropic auth mode`);
21
+ const out = { ...env };
22
+ delete out[API_KEY];
23
+ delete out[AUTH_TOKEN];
24
+ if (mode === 'login')
25
+ return out;
26
+ if (!tokenRef || !tokenRef.trim())
27
+ throw new Error(`${mode} mode requires a token reference`);
28
+ out[mode === 'api-key' ? API_KEY : AUTH_TOKEN] = tokenRef.trim();
29
+ return out;
30
+ }
package/dist/rcblock.js CHANGED
@@ -18,6 +18,7 @@ function profileDirExpr(pr, p) {
18
18
  }
19
19
  function renderPosix(pr, p) {
20
20
  const lines = [`${pr.launcher}() {`];
21
+ lines.push(` if [ "$1" = handoff ]; then shift; command ccprofiles handoff --from ${pr.name} --to "$1"; return; fi`);
21
22
  for (const [k, v] of Object.entries(pr.env)) {
22
23
  lines.push(v.startsWith(SECRET_PREFIX)
23
24
  ? ` export ${k}="$(ccprofiles secrets get ${v.slice(SECRET_PREFIX.length)})"`
@@ -32,6 +33,7 @@ function renderPosix(pr, p) {
32
33
  }
33
34
  function renderPwsh(pr, p) {
34
35
  const lines = [`function ${pr.launcher} {`];
36
+ lines.push(` if ($args[0] -eq 'handoff') { ccprofiles handoff --from ${pr.name} --to $args[1]; return }`);
35
37
  for (const [k, v] of Object.entries(pr.env)) {
36
38
  lines.push(v.startsWith(SECRET_PREFIX)
37
39
  ? ` $env:${k} = (ccprofiles secrets get ${v.slice(SECRET_PREFIX.length)})`
package/dist/secrets.js CHANGED
@@ -35,12 +35,12 @@ export class FileBackend {
35
35
  const c = createCipheriv('aes-256-gcm', this.key(f.salt), iv);
36
36
  const data = Buffer.concat([c.update(value, 'utf8'), c.final()]);
37
37
  f.entries[key] = { iv: iv.toString('base64'), tag: c.getAuthTag().toString('base64'), data: data.toString('base64') };
38
- await atomicWrite(this.filePath, JSON.stringify(f));
38
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
39
39
  }
40
40
  async delete(key) {
41
41
  const f = await this.load();
42
42
  delete f.entries[key];
43
- await atomicWrite(this.filePath, JSON.stringify(f));
43
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
44
44
  }
45
45
  }
46
46
  const realExec = promisify(execFile);
@@ -150,12 +150,12 @@ export class DpapiBackend {
150
150
  async set(key, value) {
151
151
  const f = await this.load();
152
152
  f.entries[key] = await this.crypt.protect(value);
153
- await atomicWrite(this.filePath, JSON.stringify(f));
153
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
154
154
  }
155
155
  async delete(key) {
156
156
  const f = await this.load();
157
157
  delete f.entries[key];
158
- await atomicWrite(this.filePath, JSON.stringify(f));
158
+ await atomicWrite(this.filePath, JSON.stringify(f), { mode: 0o600 });
159
159
  }
160
160
  }
161
161
  export class SecretsStore {
@@ -171,7 +171,7 @@ export class SecretsStore {
171
171
  return JSON.parse(await readFile(this.indexPath, 'utf8'));
172
172
  }
173
173
  async writeIndex(names) {
174
- await atomicWrite(this.indexPath, JSON.stringify([...new Set(names)].sort()));
174
+ await atomicWrite(this.indexPath, JSON.stringify([...new Set(names)].sort()), { mode: 0o600 });
175
175
  }
176
176
  get backendName() { return this.backend.name; }
177
177
  async get(key) { return this.backend.get(key); }
@@ -6,6 +6,9 @@ export interface SessionMeta {
6
6
  gitBranch: string | null;
7
7
  model: string | null;
8
8
  sizeBytes: number;
9
+ /** true when the transcript was too large to fully scan (per-file cap) or the per-request
10
+ * byte budget ran out before reaching it — metadata is best-effort / incomplete. */
11
+ truncated?: boolean;
9
12
  }
10
13
  export interface ProjectSessions {
11
14
  agent: 'claude' | 'codex';
@@ -27,6 +30,12 @@ export interface SessionTranscript {
27
30
  project: string;
28
31
  messages: TranscriptEntry[];
29
32
  }
33
+ /** Test/observability hook: cumulative cache hit/miss counts across all scanSessions() calls
34
+ * in this process. Not reset automatically — callers that need deltas should diff two reads. */
35
+ export declare function sessionScanCacheStats(): {
36
+ hits: number;
37
+ misses: number;
38
+ };
30
39
  /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
31
40
  export declare function decodeProjectDir(name: string): string;
32
41
  export declare function scanSessions(opts: {
package/dist/sessions.js CHANGED
@@ -3,11 +3,52 @@ import { existsSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,160}$/;
5
5
  const MAX_ENTRY_CHARS = 100_000;
6
+ const claudeSessionCache = new Map();
7
+ const codexSessionCache = new Map();
8
+ /** Per-file cap: a transcript above this size is summarized without reading its content. */
9
+ const MAX_FILE_SCAN_BYTES = 10 * 1024 * 1024;
10
+ /** Per-request cap: once this many bytes have been read from disk in one scanSessions() call,
11
+ * remaining (uncached) files are summarized without reading their content either. */
12
+ const MAX_TOTAL_SCAN_BYTES = 50 * 1024 * 1024;
13
+ let scanCacheHits = 0;
14
+ let scanCacheMisses = 0;
15
+ /** Test/observability hook: cumulative cache hit/miss counts across all scanSessions() calls
16
+ * in this process. Not reset automatically — callers that need deltas should diff two reads. */
17
+ export function sessionScanCacheStats() {
18
+ return { hits: scanCacheHits, misses: scanCacheMisses };
19
+ }
6
20
  /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
7
21
  export function decodeProjectDir(name) {
8
22
  return name.replace(/^-/, '/').replace(/-/g, '/');
9
23
  }
10
- async function parseClaudeSession(file) {
24
+ /**
25
+ * Shared mtime+size cache lookup for the two session-file formats. `parseFull` runs only on a
26
+ * cache miss with a fresh read; `makeTruncated` builds a summary-only result when the file is
27
+ * over the per-file cap or the per-request byte budget is exhausted. A size-cap truncation is
28
+ * deterministic (depends only on the file) and gets cached; a budget-cap truncation depends on
29
+ * request ordering, so it is *not* cached — a later request with a fresh budget may read it fully.
30
+ */
31
+ async function cachedParse(file, cache, budget, parseFull, makeTruncated) {
32
+ let st;
33
+ try {
34
+ st = await stat(file);
35
+ }
36
+ catch {
37
+ return null;
38
+ }
39
+ const cached = cache.get(file);
40
+ if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
41
+ scanCacheHits++;
42
+ return cached.result;
43
+ }
44
+ scanCacheMisses++;
45
+ if (st.size > MAX_FILE_SCAN_BYTES) {
46
+ const result = makeTruncated(st);
47
+ cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, result });
48
+ return result;
49
+ }
50
+ if (st.size > budget.bytesLeft)
51
+ return makeTruncated(st); // not cached — budget is per-request
11
52
  let raw;
12
53
  try {
13
54
  raw = await readFile(file, 'utf8');
@@ -15,36 +56,43 @@ async function parseClaudeSession(file) {
15
56
  catch {
16
57
  return null;
17
58
  }
18
- const st = await stat(file);
19
- const lines = raw.split('\n').filter(l => l.trim());
20
- let firstPrompt = null, gitBranch = null, model = null, cwd = null;
21
- for (const line of lines) {
22
- let rec;
23
- try {
24
- rec = JSON.parse(line);
25
- }
26
- catch {
27
- continue;
28
- }
29
- if (cwd === null && typeof rec.cwd === 'string')
30
- cwd = rec.cwd;
31
- if (gitBranch === null && typeof rec.gitBranch === 'string')
32
- gitBranch = rec.gitBranch;
33
- if (firstPrompt === null && rec.type === 'user') {
34
- const c = rec.message?.content;
35
- const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null;
36
- if (typeof text === 'string' && text.trim() && !text.trimStart().startsWith('<'))
37
- firstPrompt = text.trim().slice(0, 200);
38
- }
39
- if (model === null && typeof rec.message?.model === 'string')
40
- model = rec.message.model;
41
- if (firstPrompt && model && gitBranch && cwd)
42
- break;
43
- }
59
+ budget.bytesLeft -= st.size;
60
+ const result = parseFull(raw, st);
61
+ cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, result });
62
+ return result;
63
+ }
64
+ async function parseClaudeSession(file, budget) {
44
65
  const id = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
45
- return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
66
+ return cachedParse(file, claudeSessionCache, budget, (raw, st) => {
67
+ const lines = raw.split('\n').filter(l => l.trim());
68
+ let firstPrompt = null, gitBranch = null, model = null, cwd = null;
69
+ for (const line of lines) {
70
+ let rec;
71
+ try {
72
+ rec = JSON.parse(line);
73
+ }
74
+ catch {
75
+ continue;
76
+ }
77
+ if (cwd === null && typeof rec.cwd === 'string')
78
+ cwd = rec.cwd;
79
+ if (gitBranch === null && typeof rec.gitBranch === 'string')
80
+ gitBranch = rec.gitBranch;
81
+ if (firstPrompt === null && rec.type === 'user') {
82
+ const c = rec.message?.content;
83
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null;
84
+ if (typeof text === 'string' && text.trim() && !text.trimStart().startsWith('<'))
85
+ firstPrompt = text.trim().slice(0, 200);
86
+ }
87
+ if (model === null && typeof rec.message?.model === 'string')
88
+ model = rec.message.model;
89
+ if (firstPrompt && model && gitBranch && cwd)
90
+ break;
91
+ }
92
+ return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
93
+ }, st => ({ meta: { id, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd: null }));
46
94
  }
47
- async function scanProjectsDir(projectsDir, scope) {
95
+ async function scanProjectsDir(projectsDir, scope, budget) {
48
96
  if (!existsSync(projectsDir))
49
97
  return [];
50
98
  const out = [];
@@ -71,7 +119,7 @@ async function scanProjectsDir(projectsDir, scope) {
71
119
  for (const f of files) {
72
120
  if (!f.endsWith('.jsonl'))
73
121
  continue;
74
- const parsed = await parseClaudeSession(join(pdir, f));
122
+ const parsed = await parseClaudeSession(join(pdir, f), budget);
75
123
  if (!parsed)
76
124
  continue;
77
125
  sessions.push(parsed.meta);
@@ -218,60 +266,55 @@ async function parseCodexTranscript(file, scope) {
218
266
  }
219
267
  return { id, agent: 'codex', scope, project, messages };
220
268
  }
221
- async function parseCodexSession(file) {
222
- let raw;
223
- try {
224
- raw = await readFile(file, 'utf8');
225
- }
226
- catch {
227
- return null;
228
- }
229
- const st = await stat(file);
230
- const lines = raw.split('\n').filter(l => l.trim());
231
- let id = null, firstPrompt = null, gitBranch = null;
232
- let model = null, cwd = null, messageCount = 0;
233
- for (const line of lines) {
234
- let rec;
235
- try {
236
- rec = JSON.parse(line);
237
- }
238
- catch {
239
- continue;
240
- }
241
- const payload = rec?.payload;
242
- if (rec?.type === 'session_meta') {
243
- if (typeof payload?.id === 'string')
244
- id = payload.id;
245
- else if (typeof payload?.session_id === 'string')
246
- id = payload.session_id;
247
- if (typeof payload?.cwd === 'string')
248
- cwd = payload.cwd;
249
- if (typeof payload?.git?.branch === 'string')
250
- gitBranch = payload.git.branch;
251
- }
252
- if (rec?.type === 'turn_context') {
253
- if (cwd === null && typeof payload?.cwd === 'string')
254
- cwd = payload.cwd;
255
- if (model === null && typeof payload?.model === 'string')
256
- model = payload.model;
257
- }
258
- if (rec?.type === 'response_item' && payload?.type === 'message'
259
- && (payload?.role === 'user' || payload?.role === 'assistant')) {
260
- messageCount++;
261
- if (firstPrompt === null && payload.role === 'user') {
262
- const text = messageText(payload.content);
263
- if (text?.trim() && !text.trimStart().startsWith('<'))
264
- firstPrompt = text.trim().slice(0, 200);
269
+ async function parseCodexSession(file, budget) {
270
+ const filename = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
271
+ const fallbackId = filename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? filename;
272
+ return cachedParse(file, codexSessionCache, budget, (raw, st) => {
273
+ const lines = raw.split('\n').filter(l => l.trim());
274
+ let id = null, firstPrompt = null, gitBranch = null;
275
+ let model = null, cwd = null, messageCount = 0;
276
+ for (const line of lines) {
277
+ let rec;
278
+ try {
279
+ rec = JSON.parse(line);
280
+ }
281
+ catch {
282
+ continue;
283
+ }
284
+ const payload = rec?.payload;
285
+ if (rec?.type === 'session_meta') {
286
+ if (typeof payload?.id === 'string')
287
+ id = payload.id;
288
+ else if (typeof payload?.session_id === 'string')
289
+ id = payload.session_id;
290
+ if (typeof payload?.cwd === 'string')
291
+ cwd = payload.cwd;
292
+ if (typeof payload?.git?.branch === 'string')
293
+ gitBranch = payload.git.branch;
294
+ }
295
+ if (rec?.type === 'turn_context') {
296
+ if (cwd === null && typeof payload?.cwd === 'string')
297
+ cwd = payload.cwd;
298
+ if (model === null && typeof payload?.model === 'string')
299
+ model = payload.model;
300
+ }
301
+ if (rec?.type === 'response_item' && payload?.type === 'message'
302
+ && (payload?.role === 'user' || payload?.role === 'assistant')) {
303
+ messageCount++;
304
+ if (firstPrompt === null && payload.role === 'user') {
305
+ const text = messageText(payload.content);
306
+ if (text?.trim() && !text.trimStart().startsWith('<'))
307
+ firstPrompt = text.trim().slice(0, 200);
308
+ }
309
+ }
310
+ if (firstPrompt === null && rec?.type === 'event_msg' && payload?.type === 'user_message'
311
+ && typeof payload?.message === 'string' && payload.message.trim()) {
312
+ firstPrompt = payload.message.trim().slice(0, 200);
265
313
  }
266
314
  }
267
- if (firstPrompt === null && rec?.type === 'event_msg' && payload?.type === 'user_message'
268
- && typeof payload?.message === 'string' && payload.message.trim()) {
269
- firstPrompt = payload.message.trim().slice(0, 200);
270
- }
271
- }
272
- const filename = file.split(/[\\/]/).pop().replace(/\.jsonl$/, '');
273
- id ??= filename.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i)?.[0] ?? filename;
274
- return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
315
+ id ??= fallbackId;
316
+ return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
317
+ }, st => ({ meta: { id: fallbackId, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd: null }));
275
318
  }
276
319
  async function codexSessionFiles(dir) {
277
320
  let entries;
@@ -311,10 +354,10 @@ async function claudeSessionFile(projectsDir, id) {
311
354
  }
312
355
  return null;
313
356
  }
314
- async function scanCodexSessionsDir(sessionsDir, scope) {
357
+ async function scanCodexSessionsDir(sessionsDir, scope, budget) {
315
358
  const byProject = new Map();
316
359
  for (const file of await codexSessionFiles(sessionsDir)) {
317
- const parsed = await parseCodexSession(file);
360
+ const parsed = await parseCodexSession(file, budget);
318
361
  if (!parsed)
319
362
  continue;
320
363
  const project = parsed.cwd ?? '(unknown project)';
@@ -328,9 +371,10 @@ async function scanCodexSessionsDir(sessionsDir, scope) {
328
371
  }));
329
372
  }
330
373
  export async function scanSessions(opts) {
374
+ const budget = { bytesLeft: MAX_TOTAL_SCAN_BYTES };
331
375
  const out = [];
332
- out.push(...await scanProjectsDir(join(opts.sharedRoot, 'projects'), 'shared'));
333
- out.push(...await scanCodexSessionsDir(join(opts.sharedRoot, 'sessions'), 'shared'));
376
+ out.push(...await scanProjectsDir(join(opts.sharedRoot, 'projects'), 'shared', budget));
377
+ out.push(...await scanCodexSessionsDir(join(opts.sharedRoot, 'sessions'), 'shared', budget));
334
378
  for (const p of opts.profiles) {
335
379
  const agent = p.agent ?? 'claude';
336
380
  const sessionDir = join(p.dir, agent === 'codex' ? 'sessions' : 'projects');
@@ -342,8 +386,8 @@ export async function scanSessions(opts) {
342
386
  continue;
343
387
  }
344
388
  out.push(...agent === 'codex'
345
- ? await scanCodexSessionsDir(sessionDir, p.name)
346
- : await scanProjectsDir(sessionDir, p.name));
389
+ ? await scanCodexSessionsDir(sessionDir, p.name, budget)
390
+ : await scanProjectsDir(sessionDir, p.name, budget));
347
391
  }
348
392
  return out;
349
393
  }