ccprofiles-core 0.10.3 → 0.12.0

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/adopt.js CHANGED
@@ -82,6 +82,7 @@ export async function preserveSecretRefs(newM, oldM, getSecret) {
82
82
  if (!old)
83
83
  continue;
84
84
  pr.skipPermissions = old.skipPermissions;
85
+ pr.fallback = old.fallback;
85
86
  // buildManifest derives `cl-<dirname>`, which silently renames a hand-picked alias
86
87
  // (cl-plb -> cl-data-plb) and makes the next apply rewrite the user's rc block.
87
88
  pr.launcher = old.launcher;
package/dist/apply.js CHANGED
@@ -116,7 +116,9 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
116
116
  actions.push({ kind: 'unshare-session-dir', from, to });
117
117
  }
118
118
  const senv = pr.settingsEnv ?? {}; // literals in older tests may omit the field
119
- if (agent === 'claude' && Object.keys(senv).length > 0) {
119
+ // No length guard: an empty settingsEnv is a declaration ("no env"), so it must still clear
120
+ // whatever is live — otherwise `provider anthropic --login` leaves the old token in place.
121
+ if (agent === 'claude') {
120
122
  let desired = resolvedSettingsEnv?.[pr.name];
121
123
  if (!desired) {
122
124
  if (Object.values(senv).some(v => v.startsWith(SECRET_PREFIX)))
@@ -124,7 +126,10 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
124
126
  desired = senv;
125
127
  }
126
128
  const currentEnv = lp?.settingsEnv ?? null;
127
- if (!currentEnv || JSON.stringify(sortKeys(currentEnv)) !== JSON.stringify(sortKeys(desired))) {
129
+ const drift = currentEnv
130
+ ? JSON.stringify(sortKeys(currentEnv)) !== JSON.stringify(sortKeys(desired))
131
+ : Object.keys(desired).length > 0; // no live dir yet: don't create settings.json just to write {}
132
+ if (drift) {
128
133
  actions.push({ kind: 'set-settings-env', settingsPath: join(dir, 'settings.json'), env: desired });
129
134
  }
130
135
  }
@@ -1,6 +1,13 @@
1
1
  import { z } from 'zod';
2
2
  export declare class ManifestError extends Error {
3
3
  }
4
+ /** Bundled status line used when a Claude profile has no existing statusLine setting. */
5
+ export declare const DEFAULT_STATUS_LINE: {
6
+ readonly type: "command";
7
+ readonly command: "clp statusline";
8
+ readonly padding: 0;
9
+ readonly refreshInterval: 10;
10
+ };
4
11
  declare const McpServerSchema: z.ZodEffects<z.ZodObject<{
5
12
  command: z.ZodOptional<z.ZodString>;
6
13
  args: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
@@ -44,6 +51,7 @@ declare const ProfileSchema: z.ZodObject<{
44
51
  settingsEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
45
52
  skipPermissions: z.ZodDefault<z.ZodBoolean>;
46
53
  sharedSessions: z.ZodDefault<z.ZodBoolean>;
54
+ fallback: z.ZodOptional<z.ZodString>;
47
55
  plugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
48
56
  }, "strip", z.ZodTypeAny, {
49
57
  env: Record<string, string>;
@@ -58,6 +66,7 @@ declare const ProfileSchema: z.ZodObject<{
58
66
  sharedSessions: boolean;
59
67
  plugins: string[];
60
68
  agent?: "claude" | "codex" | undefined;
69
+ fallback?: string | undefined;
61
70
  }, {
62
71
  name: string;
63
72
  dir: string;
@@ -70,6 +79,7 @@ declare const ProfileSchema: z.ZodObject<{
70
79
  settingsEnv?: Record<string, string> | undefined;
71
80
  skipPermissions?: boolean | undefined;
72
81
  sharedSessions?: boolean | undefined;
82
+ fallback?: string | undefined;
73
83
  plugins?: string[] | undefined;
74
84
  }>;
75
85
  declare const MarketplaceSchema: z.ZodObject<{
@@ -94,6 +104,7 @@ declare const ManifestSchema: z.ZodObject<{
94
104
  settingsEnv: z.ZodDefault<z.ZodRecord<z.ZodString, z.ZodString>>;
95
105
  skipPermissions: z.ZodDefault<z.ZodBoolean>;
96
106
  sharedSessions: z.ZodDefault<z.ZodBoolean>;
107
+ fallback: z.ZodOptional<z.ZodString>;
97
108
  plugins: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
98
109
  }, "strip", z.ZodTypeAny, {
99
110
  env: Record<string, string>;
@@ -108,6 +119,7 @@ declare const ManifestSchema: z.ZodObject<{
108
119
  sharedSessions: boolean;
109
120
  plugins: string[];
110
121
  agent?: "claude" | "codex" | undefined;
122
+ fallback?: string | undefined;
111
123
  }, {
112
124
  name: string;
113
125
  dir: string;
@@ -120,6 +132,7 @@ declare const ManifestSchema: z.ZodObject<{
120
132
  settingsEnv?: Record<string, string> | undefined;
121
133
  skipPermissions?: boolean | undefined;
122
134
  sharedSessions?: boolean | undefined;
135
+ fallback?: string | undefined;
123
136
  plugins?: string[] | undefined;
124
137
  }>, "many">;
125
138
  mcpServers: z.ZodRecord<z.ZodString, z.ZodEffects<z.ZodObject<{
@@ -181,6 +194,7 @@ declare const ManifestSchema: z.ZodObject<{
181
194
  sharedSessions: boolean;
182
195
  plugins: string[];
183
196
  agent?: "claude" | "codex" | undefined;
197
+ fallback?: string | undefined;
184
198
  }[];
185
199
  mcpServers: Record<string, z.objectOutputType<{
186
200
  command: z.ZodOptional<z.ZodString>;
@@ -208,6 +222,7 @@ declare const ManifestSchema: z.ZodObject<{
208
222
  settingsEnv?: Record<string, string> | undefined;
209
223
  skipPermissions?: boolean | undefined;
210
224
  sharedSessions?: boolean | undefined;
225
+ fallback?: string | undefined;
211
226
  plugins?: string[] | undefined;
212
227
  }[];
213
228
  mcpServers: Record<string, z.objectInputType<{
@@ -226,6 +241,7 @@ export type McpServerDef = z.infer<typeof McpServerSchema>;
226
241
  export type ProfileDecl = z.infer<typeof ProfileSchema>;
227
242
  export type Manifest = z.infer<typeof ManifestSchema>;
228
243
  export type MarketplaceDef = z.infer<typeof MarketplaceSchema>;
244
+ export declare const SAFE_NAME: RegExp;
229
245
  /**
230
246
  * True if `yaml` (raw manifest.yaml text, or any string) contains a plaintext provider
231
247
  * token. saveManifest() uses this to decide whether to skip the git commit; doctor checks
package/dist/manifest.js CHANGED
@@ -8,6 +8,10 @@ import { atomicWrite } from './fsutil.js';
8
8
  const exec = promisify(execFile);
9
9
  export class ManifestError extends Error {
10
10
  }
11
+ /** Bundled status line used when a Claude profile has no existing statusLine setting. */
12
+ export const DEFAULT_STATUS_LINE = {
13
+ type: 'command', command: 'clp statusline', padding: 0, refreshInterval: 10,
14
+ };
11
15
  const McpServerSchema = z.object({
12
16
  command: z.string().optional(),
13
17
  args: z.array(z.string()).optional(),
@@ -27,6 +31,7 @@ const ProfileSchema = z.object({
27
31
  settingsEnv: z.record(z.string()).default({}),
28
32
  skipPermissions: z.boolean().default(false),
29
33
  sharedSessions: z.boolean().default(false),
34
+ fallback: z.string().optional(),
30
35
  plugins: z.array(z.string()).default([]),
31
36
  });
32
37
  const MarketplaceSchema = z.object({ source: z.string().min(1) });
@@ -45,7 +50,9 @@ const ManifestSchema = z.object({
45
50
  // identifiers that get interpolated into shell launcher code must be injection-safe
46
51
  // leading '-' is forbidden (but internal '-' still allowed) so a value can never be parsed as a
47
52
  // CLI flag when passed as a positional argv entry (e.g. `claude plugin install <id>`)
48
- const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; // profile names, launcher names, secret refs
53
+ // exported: SecretsStore.set() rejects names this forbids, so a secret can never be stored under
54
+ // a name no manifest could ever reference (the failure used to surface only at attach time)
55
+ export const SAFE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_-]*$/; // profile names, launcher names, secret refs
49
56
  const SAFE_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; // POSIX/PowerShell env var names
50
57
  const SAFE_LINK_ENTRY = /^[A-Za-z0-9._-]+$/; // one profile-dir child; never a path
51
58
  const SAFE_SOURCE = /^[A-Za-z0-9._@:/][A-Za-z0-9._/@:-]*$/; // marketplace source (interpolated into `claude plugin` shell-out); no leading '-'
@@ -74,6 +81,8 @@ export function assertSafeManifest(m) {
74
81
  for (const p of m.profiles) {
75
82
  if (!SAFE_NAME.test(p.name))
76
83
  throw new ManifestError(`unsafe profile name: ${JSON.stringify(p.name)} (allowed: letters, digits, - _)`);
84
+ if (p.fallback !== undefined && !SAFE_NAME.test(p.fallback))
85
+ throw new ManifestError(`unsafe fallback profile in profile "${p.name}": ${JSON.stringify(p.fallback)}`);
77
86
  if (p.launcher !== null && !SAFE_NAME.test(p.launcher))
78
87
  throw new ManifestError(`unsafe launcher name in profile "${p.name}": ${JSON.stringify(p.launcher)}`);
79
88
  if (SHELL_META.test(p.dir))
@@ -133,6 +142,12 @@ export function parseManifest(text) {
133
142
  for (const name of p.mcp)
134
143
  if (!m.mcpServers[name])
135
144
  throw new ManifestError(`profile "${p.name}" references undefined mcp server "${name}"`);
145
+ for (const p of m.profiles)
146
+ if (p.fallback !== undefined) {
147
+ const target = m.profiles.find(q => q.name === p.fallback);
148
+ if (!target || (target.agent ?? 'claude') !== 'claude' || (p.agent ?? 'claude') !== 'claude' || target.name === p.name)
149
+ throw new ManifestError(`profile "${p.name}" has invalid Claude fallback "${p.fallback}"`);
150
+ }
136
151
  for (const p of m.profiles)
137
152
  for (const id of p.plugins) {
138
153
  const at = id.lastIndexOf('@');
@@ -181,7 +196,9 @@ export async function saveManifest(root, m) {
181
196
  /** True for the two git outcomes saveManifest treats as expected/benign, not a real failure. */
182
197
  function isBenignGitError(e) {
183
198
  const text = gitErrorMessage(e);
184
- return /nothing to commit/i.test(text) || /not a git repository/i.test(text);
199
+ // "no changes added to commit" is git's wording when the only dirty paths are ones `add -A`
200
+ // cannot stage (nested repos) — still nothing for us to commit.
201
+ return /nothing to commit/i.test(text) || /no changes added to commit/i.test(text) || /not a git repository/i.test(text);
185
202
  }
186
203
  function gitErrorMessage(e) {
187
204
  const err = e;
package/dist/plugins.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export interface PluginRunner {
2
2
  marketplaceAdd(configDir: string, source: string): Promise<void>;
3
+ /** `claude plugin marketplace update` — refetch the marketplace so "latest" is actually latest.
4
+ * Without this, `update` is a silent no-op in any profile whose marketplace clone is stale. */
5
+ marketplaceUpdate(configDir: string, name: string): Promise<void>;
3
6
  install(configDir: string, id: string): Promise<void>;
4
7
  uninstall(configDir: string, id: string): Promise<void>;
5
8
  /** `claude plugin update` — moves a plugin to the marketplace's latest. There is deliberately no
@@ -76,3 +79,23 @@ export declare function claudeMemCacheDirs(configDir: string): Promise<string[]>
76
79
  export declare function pruneStaleVersionDirs(cacheDir: string): Promise<string[]>;
77
80
  /** If a profile's plugins/ is a legacy symlink into the old shared pool, restore it to a real dir. */
78
81
  export declare function restoreLegacyPluginSymlink(pluginsDir: string): Promise<boolean>;
82
+ /**
83
+ * Repair installed_plugins.json entries whose installPath no longer exists on disk.
84
+ *
85
+ * This is the other half of pruneStaleVersionDirs. Pruning removes superseded cache dirs but leaves
86
+ * the bookkeeping untouched, and installed_plugins.json holds one entry PER SCOPE — a `user` entry
87
+ * plus a `project` entry for every directory Claude was ever launched from. `claude plugin update`
88
+ * only moves the user-scope pin, so the project-scope entries stay on an old version, and the next
89
+ * prune deletes the dir underneath them. Claude Code then refuses to load their hooks outright:
90
+ * "Plugin directory does not exist: .../claude-mem/13.16.1 — run /plugin to reinstall", on every
91
+ * Stop hook, forever. Six of eight profiles were in that state on 2026-08-30.
92
+ *
93
+ * Re-point rather than drop where a sibling version dir survived: the surviving dir is the one
94
+ * prune kept, i.e. the latest, which is exactly where `claude plugin update` would have moved the
95
+ * entry anyway — so the repair converges scopes onto one version instead of disabling the plugin.
96
+ * Only numeric version dirs qualify; a versionless plugin's sha/"unknown" siblings are unrelated
97
+ * builds, so those entries are dropped and left for `plugins apply` to reinstall.
98
+ */
99
+ export declare function repairInstalledPlugins(configDir: string, opts?: {
100
+ dryRun?: boolean;
101
+ }): Promise<string[]>;
package/dist/plugins.js CHANGED
@@ -1,6 +1,7 @@
1
- import { cp, lstat, mkdir, readdir, readlink, rm, unlink } from 'node:fs/promises';
2
- import { existsSync } from 'node:fs';
3
- import { join } from 'node:path';
1
+ import { cp, lstat, mkdir, readdir, readFile, readlink, rm, unlink } from 'node:fs/promises';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { basename, dirname, join } from 'node:path';
4
+ import { atomicWrite } from './fsutil.js';
4
5
  export function marketplaceOf(id) {
5
6
  const at = id.lastIndexOf('@');
6
7
  return at > 0 ? id.slice(at + 1) : null;
@@ -67,6 +68,18 @@ export async function reconcileProfilePlugins(opts) {
67
68
  await opts.runner.install(opts.configDir, id);
68
69
  log.push(`install ${id}`);
69
70
  }
71
+ // `claude plugin update` moves to the marketplace clone's latest, and that clone is per-profile
72
+ // and only refreshed on demand. A profile whose clone is months old therefore "updates" to the
73
+ // version it already has, and cross-profile drift never converges — refetch first.
74
+ const refreshed = new Set();
75
+ for (const id of update) {
76
+ const mkt = marketplaceOf(id);
77
+ if (mkt && !refreshed.has(mkt)) {
78
+ await opts.runner.marketplaceUpdate(opts.configDir, mkt);
79
+ refreshed.add(mkt);
80
+ log.push(`marketplace update ${mkt}`);
81
+ }
82
+ }
70
83
  for (const id of update) {
71
84
  await opts.runner.update(opts.configDir, id);
72
85
  log.push(`update ${id}`);
@@ -166,3 +179,75 @@ export async function restoreLegacyPluginSymlink(pluginsDir) {
166
179
  await cp(target, pluginsDir, { recursive: true, force: false, errorOnExist: false });
167
180
  return true;
168
181
  }
182
+ /** Highest-semver cache dir that still exists beside a missing one, or null if none survived. */
183
+ function survivingVersionDir(missingPath) {
184
+ const parent = dirname(missingPath);
185
+ let names;
186
+ try {
187
+ names = readdirSync(parent);
188
+ }
189
+ catch {
190
+ return null;
191
+ }
192
+ const versions = names.filter(n => /^\d/.test(n) && existsSync(join(parent, n))).sort(compareVersionDir);
193
+ return versions.length ? join(parent, versions[versions.length - 1]) : null;
194
+ }
195
+ /**
196
+ * Repair installed_plugins.json entries whose installPath no longer exists on disk.
197
+ *
198
+ * This is the other half of pruneStaleVersionDirs. Pruning removes superseded cache dirs but leaves
199
+ * the bookkeeping untouched, and installed_plugins.json holds one entry PER SCOPE — a `user` entry
200
+ * plus a `project` entry for every directory Claude was ever launched from. `claude plugin update`
201
+ * only moves the user-scope pin, so the project-scope entries stay on an old version, and the next
202
+ * prune deletes the dir underneath them. Claude Code then refuses to load their hooks outright:
203
+ * "Plugin directory does not exist: .../claude-mem/13.16.1 — run /plugin to reinstall", on every
204
+ * Stop hook, forever. Six of eight profiles were in that state on 2026-08-30.
205
+ *
206
+ * Re-point rather than drop where a sibling version dir survived: the surviving dir is the one
207
+ * prune kept, i.e. the latest, which is exactly where `claude plugin update` would have moved the
208
+ * entry anyway — so the repair converges scopes onto one version instead of disabling the plugin.
209
+ * Only numeric version dirs qualify; a versionless plugin's sha/"unknown" siblings are unrelated
210
+ * builds, so those entries are dropped and left for `plugins apply` to reinstall.
211
+ */
212
+ export async function repairInstalledPlugins(configDir, opts = {}) {
213
+ const file = join(configDir, 'plugins', 'installed_plugins.json');
214
+ let doc;
215
+ // A corrupt file must not be rewritten from a half-parse — bail and leave it for a human.
216
+ try {
217
+ doc = JSON.parse(await readFile(file, 'utf8'));
218
+ }
219
+ catch {
220
+ return [];
221
+ }
222
+ if (!doc?.plugins || typeof doc.plugins !== 'object')
223
+ return [];
224
+ const log = [];
225
+ for (const [id, entries] of Object.entries(doc.plugins)) {
226
+ if (!Array.isArray(entries))
227
+ continue;
228
+ const kept = [];
229
+ for (const e of entries) {
230
+ const path = e?.installPath;
231
+ if (typeof path !== 'string' || existsSync(path)) {
232
+ kept.push(e);
233
+ continue;
234
+ }
235
+ const scope = e.scope ?? '?';
236
+ const survivor = survivingVersionDir(path);
237
+ if (survivor) {
238
+ kept.push({ ...e, installPath: survivor, version: basename(survivor) });
239
+ log.push(`${id} (${scope}): ${e.version} → ${basename(survivor)} — cache dir was pruned`);
240
+ }
241
+ else {
242
+ log.push(`${id} (${scope}): dropped — ${path} is gone and no version survived`);
243
+ }
244
+ }
245
+ if (kept.length)
246
+ doc.plugins[id] = kept;
247
+ else
248
+ delete doc.plugins[id];
249
+ }
250
+ if (log.length && !opts.dryRun)
251
+ await atomicWrite(file, JSON.stringify(doc, null, 2) + '\n');
252
+ return log;
253
+ }
package/dist/rcblock.js CHANGED
@@ -28,7 +28,10 @@ function renderPosix(pr, p) {
28
28
  const flag = pr.skipPermissions ? codex ? ' --dangerously-bypass-approvals-and-sandbox' : ' --dangerously-skip-permissions' : '';
29
29
  const home = codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
30
30
  const command = codex ? 'codex' : 'claude';
31
- lines.push(` ${home}="${profileDirExpr(pr, p)}" ${command}${flag} "$@"`, '}');
31
+ if (!codex && pr.fallback)
32
+ lines.push(` command ccprofiles fallback run --from ${pr.name} -- "$@"`, '}');
33
+ else
34
+ lines.push(` ${home}="${profileDirExpr(pr, p)}" ${command}${flag} "$@"`, '}');
32
35
  return lines.join('\n');
33
36
  }
34
37
  function renderPwsh(pr, p) {
@@ -43,7 +46,10 @@ function renderPwsh(pr, p) {
43
46
  const flag = pr.skipPermissions ? codex ? ' --dangerously-bypass-approvals-and-sandbox' : ' --dangerously-skip-permissions' : '';
44
47
  const home = codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
45
48
  const command = codex ? 'codex' : 'claude';
46
- lines.push(` $env:${home} = "${profileDirExpr(pr, p)}"`, ` ${command}${flag} @args`, '}');
49
+ if (!codex && pr.fallback)
50
+ lines.push(` ccprofiles fallback run --from ${pr.name} -- @args`, '}');
51
+ else
52
+ lines.push(` $env:${home} = "${profileDirExpr(pr, p)}"`, ` ${command}${flag} @args`, '}');
47
53
  return lines.join('\n');
48
54
  }
49
55
  export function renderRcBlock(m, p) {
package/dist/secrets.js CHANGED
@@ -4,6 +4,7 @@ import { existsSync } from 'node:fs';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { atomicWrite } from './fsutil.js';
7
+ import { SAFE_NAME } from './manifest.js';
7
8
  export class FileBackend {
8
9
  filePath;
9
10
  passphrase;
@@ -176,6 +177,8 @@ export class SecretsStore {
176
177
  get backendName() { return this.backend.name; }
177
178
  async get(key) { return this.backend.get(key); }
178
179
  async set(key, value) {
180
+ if (!SAFE_NAME.test(key))
181
+ throw new Error(`unsafe secret name: ${JSON.stringify(key)} (allowed: letters, digits, - _; must not start with -)`);
179
182
  await this.backend.set(key, value);
180
183
  await this.writeIndex([...(await this.readIndex()), key]);
181
184
  }
@@ -1,7 +1,12 @@
1
1
  export interface SessionMeta {
2
2
  id: string;
3
3
  mtime: number;
4
+ /** Timestamp (ms) of the last user/assistant record — when you last chatted, not when the
5
+ * file was created or last copied. Falls back to mtime when no record carries a timestamp. */
6
+ lastActivity: number;
4
7
  messageCount: number;
8
+ /** Session name: your `/rename` title if set, else Claude's auto-generated one. */
9
+ title?: string | null;
5
10
  firstPrompt: string | null;
6
11
  gitBranch: string | null;
7
12
  model: string | null;
@@ -9,6 +14,8 @@ export interface SessionMeta {
9
14
  /** true when the transcript was too large to fully scan (per-file cap) or the per-request
10
15
  * byte budget ran out before reaching it — metadata is best-effort / incomplete. */
11
16
  truncated?: boolean;
17
+ /** Driven by the Agent SDK (e.g. a plugin's background worker), not a chat you'd resume. */
18
+ automated?: boolean;
12
19
  }
13
20
  export interface ProjectSessions {
14
21
  agent: 'claude' | 'codex';
@@ -48,6 +55,13 @@ export declare function decodeProjectDir(name: string): string;
48
55
  * simply fails JSON.parse and is skipped — no need to guess which line is incomplete.
49
56
  */
50
57
  export declare function headCwd(head: string, pick: (rec: any) => string | undefined): string | null;
58
+ /**
59
+ * Latest chat-record timestamp in a JSONL slice, or null. Partial lines at either edge of a
60
+ * slice fail JSON.parse and are skipped.
61
+ */
62
+ export declare function lastChatTime(text: string): number | null;
63
+ /** Latest custom title in a Claude JSONL slice, else the latest AI-generated one, else null. */
64
+ export declare function sessionTitle(text: string): string | null;
51
65
  export declare function scanSessions(opts: {
52
66
  sharedRoot: string;
53
67
  profiles: {
@@ -56,6 +70,32 @@ export declare function scanSessions(opts: {
56
70
  agent?: 'claude' | 'codex';
57
71
  }[];
58
72
  }): Promise<ProjectSessions[]>;
73
+ export interface RecentSession {
74
+ agent: 'claude' | 'codex';
75
+ scope: string;
76
+ project: string;
77
+ session: SessionMeta;
78
+ }
79
+ /** The `limit` sessions you chatted in most recently, across every project and profile. */
80
+ export declare function recentSessions(rows: ProjectSessions[], limit?: number): RecentSession[];
81
+ /**
82
+ * Paste-ready command that reopens a session in its own project under the profile that owns it.
83
+ * Prefers the profile's launcher (it carries provider env and secrets); otherwise points the
84
+ * agent at the profile dir directly. `owner` is null when no declared profile matches the scope.
85
+ */
86
+ export declare function resumeCommand(r: Pick<RecentSession, 'agent' | 'project' | 'session'>, owner: {
87
+ launcher: string | null;
88
+ dir: string;
89
+ } | null): string;
90
+ /**
91
+ * Which profile reopens a session: the one named by its scope, or — for the shared pool — the
92
+ * first profile of the same agent that shares sessions.
93
+ */
94
+ export declare function sessionOwner<P extends {
95
+ name: string;
96
+ agent?: 'claude' | 'codex';
97
+ sharedSessions?: boolean;
98
+ }>(r: Pick<RecentSession, 'agent' | 'scope'>, profiles: P[]): P | null;
59
99
  /** Read visible conversation and tool activity for one known managed session. */
60
100
  export declare function readSessionTranscript(opts: {
61
101
  sharedRoot: string;
package/dist/sessions.js CHANGED
@@ -7,8 +7,9 @@ const claudeSessionCache = new Map();
7
7
  const codexSessionCache = new Map();
8
8
  /** Per-file cap: a transcript above this size is summarized without reading its content. */
9
9
  const MAX_FILE_SCAN_BYTES = 10 * 1024 * 1024;
10
- // enough to reach the first record carrying cwd, without reading a multi-MB transcript
11
- const HEAD_SCAN_BYTES = 64 * 1024;
10
+ // enough to reach the first real prompt (Codex Desktop front-loads ~140KB of AGENTS.md and
11
+ // instructions), without reading a multi-MB transcript
12
+ const HEAD_SCAN_BYTES = 256 * 1024;
12
13
  /** Per-request cap: once this many bytes have been read from disk in one scanSessions() call,
13
14
  * remaining (uncached) files are summarized without reading their content either. */
14
15
  const MAX_TOTAL_SCAN_BYTES = 50 * 1024 * 1024;
@@ -28,13 +29,15 @@ export function sessionScanCacheStats() {
28
29
  export function decodeProjectDir(name) {
29
30
  return name.replace(/^-/, '/').replace(/-/g, '/');
30
31
  }
31
- /** First `bytes` of a file, for cheap field recovery without reading a multi-MB transcript. */
32
- async function readHead(file, bytes = HEAD_SCAN_BYTES) {
32
+ /** First (or, with `fromEnd`, last) `bytes` of a file, for cheap field recovery without
33
+ * reading a multi-MB transcript. */
34
+ async function readHead(file, bytes = HEAD_SCAN_BYTES, fromEnd = false) {
33
35
  let fh = null;
34
36
  try {
35
37
  fh = await open(file, 'r');
36
38
  const buf = Buffer.alloc(bytes);
37
- const { bytesRead } = await fh.read(buf, 0, bytes, 0);
39
+ const pos = fromEnd ? Math.max(0, (await fh.stat()).size - bytes) : 0;
40
+ const { bytesRead } = await fh.read(buf, 0, bytes, pos);
38
41
  return buf.subarray(0, bytesRead).toString('utf8');
39
42
  }
40
43
  catch {
@@ -65,14 +68,76 @@ export function headCwd(head, pick) {
65
68
  }
66
69
  return null;
67
70
  }
71
+ /** Is this record a chat turn (user or assistant), in either the Claude or Codex format? */
72
+ function isChatRecord(rec) {
73
+ if (rec?.type === 'user' || rec?.type === 'assistant')
74
+ return true;
75
+ const p = rec?.payload;
76
+ if (rec?.type === 'response_item')
77
+ return p?.type === 'message' && (p.role === 'user' || p.role === 'assistant');
78
+ return rec?.type === 'event_msg' && (p?.type === 'user_message' || p?.type === 'agent_message');
79
+ }
80
+ /**
81
+ * Latest chat-record timestamp in a JSONL slice, or null. Partial lines at either edge of a
82
+ * slice fail JSON.parse and are skipped.
83
+ */
84
+ export function lastChatTime(text) {
85
+ let last = null;
86
+ for (const line of text.split('\n')) {
87
+ if (!line.trim())
88
+ continue;
89
+ let rec;
90
+ try {
91
+ rec = JSON.parse(line);
92
+ }
93
+ catch {
94
+ continue;
95
+ }
96
+ if (!isChatRecord(rec) || typeof rec.timestamp !== 'string')
97
+ continue;
98
+ const t = Date.parse(rec.timestamp);
99
+ if (!Number.isNaN(t) && (last === null || t > last))
100
+ last = t;
101
+ }
102
+ return last;
103
+ }
104
+ /** Latest custom title in a Claude JSONL slice, else the latest AI-generated one, else null. */
105
+ export function sessionTitle(text) {
106
+ let custom = null, ai = null;
107
+ for (const line of text.split('\n')) {
108
+ if (!line.includes('-title"'))
109
+ continue;
110
+ let rec;
111
+ try {
112
+ rec = JSON.parse(line);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ if (rec.type === 'custom-title' && typeof rec.customTitle === 'string' && rec.customTitle.trim())
118
+ custom = rec.customTitle.trim();
119
+ if (rec.type === 'ai-title' && typeof rec.aiTitle === 'string' && rec.aiTitle.trim())
120
+ ai = rec.aiTitle.trim();
121
+ }
122
+ return custom ?? ai;
123
+ }
68
124
  /**
69
125
  * Shared mtime+size cache lookup for the two session-file formats. `parseFull` runs only on a
70
- * cache miss with a fresh read; `makeTruncated` builds a summary-only result when the file is
71
- * over the per-file cap or the per-request byte budget is exhausted. A size-cap truncation is
126
+ * cache miss with a fresh read. When the file is over the per-file cap or the per-request byte
127
+ * budget is exhausted, `parseFull` runs on just the head slice (prompt, cwd, branch, model all
128
+ * live near the top) and the last chat time comes from the tail slice. A size-cap truncation is
72
129
  * deterministic (depends only on the file) and gets cached; a budget-cap truncation depends on
73
130
  * request ordering, so it is *not* cached — a later request with a fresh budget may read it fully.
74
131
  */
75
- async function cachedParse(file, cache, budget, parseFull, makeTruncated) {
132
+ async function cachedParse(file, cache, budget, parseFull) {
133
+ const truncated = async () => {
134
+ const r = parseFull(await readHead(file), st);
135
+ const tail = await readHead(file, HEAD_SCAN_BYTES, true);
136
+ // a single record can outgrow the head slice, so look for the SDK marker in the tail too
137
+ const automated = r.meta.automated || /"entrypoint":"sdk/.test(tail);
138
+ const title = sessionTitle(tail) ?? r.meta.title;
139
+ return { ...r, meta: { ...r.meta, ...(title && { title }), messageCount: 0, lastActivity: lastChatTime(tail) ?? st.mtimeMs, truncated: true, ...(automated && { automated }) } };
140
+ };
76
141
  let st;
77
142
  try {
78
143
  st = await stat(file);
@@ -87,12 +152,12 @@ async function cachedParse(file, cache, budget, parseFull, makeTruncated) {
87
152
  }
88
153
  scanCacheMisses++;
89
154
  if (st.size > MAX_FILE_SCAN_BYTES) {
90
- const result = makeTruncated(st, await readHead(file));
155
+ const result = await truncated();
91
156
  cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, result });
92
157
  return result;
93
158
  }
94
159
  if (st.size > budget.bytesLeft)
95
- return makeTruncated(st, await readHead(file)); // not cached — budget is per-request
160
+ return truncated(); // not cached — budget is per-request
96
161
  let raw;
97
162
  try {
98
163
  raw = await readFile(file, 'utf8');
@@ -110,6 +175,7 @@ async function parseClaudeSession(file, budget) {
110
175
  return cachedParse(file, claudeSessionCache, budget, (raw, st) => {
111
176
  const lines = raw.split('\n').filter(l => l.trim());
112
177
  let firstPrompt = null, gitBranch = null, model = null, cwd = null;
178
+ let automated = false;
113
179
  for (const line of lines) {
114
180
  let rec;
115
181
  try {
@@ -118,13 +184,15 @@ async function parseClaudeSession(file, budget) {
118
184
  catch {
119
185
  continue;
120
186
  }
187
+ if (typeof rec.entrypoint === 'string' && rec.entrypoint.startsWith('sdk'))
188
+ automated = true;
121
189
  if (cwd === null && typeof rec.cwd === 'string')
122
190
  cwd = rec.cwd;
123
191
  if (gitBranch === null && typeof rec.gitBranch === 'string')
124
192
  gitBranch = rec.gitBranch;
125
193
  if (firstPrompt === null && rec.type === 'user') {
126
194
  const c = rec.message?.content;
127
- const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text')?.text : null;
195
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.find((x) => x?.type === 'text' && !String(x.text).trimStart().startsWith('<'))?.text : null;
128
196
  if (typeof text === 'string' && text.trim() && !text.trimStart().startsWith('<'))
129
197
  firstPrompt = text.trim().slice(0, 200);
130
198
  }
@@ -133,8 +201,9 @@ async function parseClaudeSession(file, budget) {
133
201
  if (firstPrompt && model && gitBranch && cwd)
134
202
  break;
135
203
  }
136
- return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
137
- }, (st, head) => ({ meta: { id, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd: headCwd(head, r => r.cwd) }));
204
+ const lastActivity = lastChatTime(raw) ?? st.mtimeMs;
205
+ return { meta: { id, mtime: st.mtimeMs, lastActivity, messageCount: lines.length, title: sessionTitle(raw), firstPrompt, gitBranch, model, sizeBytes: st.size, ...(automated && { automated }) }, cwd };
206
+ });
138
207
  }
139
208
  async function scanProjectsDir(projectsDir, scope, budget) {
140
209
  if (!existsSync(projectsDir))
@@ -172,7 +241,7 @@ async function scanProjectsDir(projectsDir, scope, budget) {
172
241
  }
173
242
  if (!sessions.length)
174
243
  continue;
175
- sessions.sort((a, b) => b.mtime - a.mtime);
244
+ sessions.sort((a, b) => b.lastActivity - a.lastActivity);
176
245
  out.push({ agent: 'claude', scope, project: cwd ?? decodeProjectDir(proj.name), sessions });
177
246
  }
178
247
  return out;
@@ -280,9 +349,14 @@ async function parseCodexTranscript(file, scope) {
280
349
  let project = '(unknown project)';
281
350
  const hasResponseMessages = records.some(rec => rec?.type === 'response_item' && rec?.payload?.type === 'message');
282
351
  const messages = [];
352
+ let seenMeta = false;
283
353
  for (const rec of records) {
284
354
  const payload = rec?.payload;
285
355
  if (rec?.type === 'session_meta') {
356
+ // a sub-agent thread replays its parent's session_meta after its own — the first is ours
357
+ if (seenMeta)
358
+ continue;
359
+ seenMeta = true;
286
360
  if (typeof payload?.id === 'string')
287
361
  id = payload.id;
288
362
  else if (typeof payload?.session_id === 'string')
@@ -317,6 +391,7 @@ async function parseCodexSession(file, budget) {
317
391
  const lines = raw.split('\n').filter(l => l.trim());
318
392
  let id = null, firstPrompt = null, gitBranch = null;
319
393
  let model = null, cwd = null, messageCount = 0;
394
+ let seenMeta = false, automated = false;
320
395
  for (const line of lines) {
321
396
  let rec;
322
397
  try {
@@ -326,7 +401,10 @@ async function parseCodexSession(file, budget) {
326
401
  continue;
327
402
  }
328
403
  const payload = rec?.payload;
329
- if (rec?.type === 'session_meta') {
404
+ // a sub-agent thread replays its parent's session_meta after its own — the first is ours
405
+ if (rec?.type === 'session_meta' && !seenMeta) {
406
+ seenMeta = true;
407
+ automated = payload?.thread_source === 'subagent' || payload?.source?.subagent !== undefined;
330
408
  if (typeof payload?.id === 'string')
331
409
  id = payload.id;
332
410
  else if (typeof payload?.session_id === 'string')
@@ -347,7 +425,7 @@ async function parseCodexSession(file, budget) {
347
425
  messageCount++;
348
426
  if (firstPrompt === null && payload.role === 'user') {
349
427
  const text = messageText(payload.content);
350
- if (text?.trim() && !text.trimStart().startsWith('<'))
428
+ if (text?.trim() && !/^\s*(<|# AGENTS\.md instructions)/.test(text))
351
429
  firstPrompt = text.trim().slice(0, 200);
352
430
  }
353
431
  }
@@ -357,8 +435,8 @@ async function parseCodexSession(file, budget) {
357
435
  }
358
436
  }
359
437
  id ??= fallbackId;
360
- return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
361
- }, (st, head) => ({ meta: { id: fallbackId, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd: headCwd(head, r => r?.payload?.cwd) }));
438
+ return { meta: { id, mtime: st.mtimeMs, lastActivity: lastChatTime(raw) ?? st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size, ...(automated && { automated }) }, cwd };
439
+ });
362
440
  }
363
441
  async function codexSessionFiles(dir) {
364
442
  let entries;
@@ -411,7 +489,7 @@ async function scanCodexSessionsDir(sessionsDir, scope, budget) {
411
489
  }
412
490
  return [...byProject.entries()].map(([project, sessions]) => ({
413
491
  agent: 'codex', scope, project,
414
- sessions: sessions.sort((a, b) => b.mtime - a.mtime),
492
+ sessions: sessions.sort((a, b) => b.lastActivity - a.lastActivity),
415
493
  }));
416
494
  }
417
495
  export async function scanSessions(opts) {
@@ -433,7 +511,36 @@ export async function scanSessions(opts) {
433
511
  ? await scanCodexSessionsDir(sessionDir, p.name, budget)
434
512
  : await scanProjectsDir(sessionDir, p.name, budget));
435
513
  }
436
- return out;
514
+ return out.sort((a, b) => b.sessions[0].lastActivity - a.sessions[0].lastActivity);
515
+ }
516
+ /** The `limit` sessions you chatted in most recently, across every project and profile. */
517
+ export function recentSessions(rows, limit = 10) {
518
+ return rows
519
+ .flatMap(p => p.sessions.filter(s => !s.automated).map(session => ({ agent: p.agent, scope: p.scope, project: p.project, session })))
520
+ .sort((a, b) => b.session.lastActivity - a.session.lastActivity)
521
+ .slice(0, limit);
522
+ }
523
+ const shq = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
524
+ /**
525
+ * Paste-ready command that reopens a session in its own project under the profile that owns it.
526
+ * Prefers the profile's launcher (it carries provider env and secrets); otherwise points the
527
+ * agent at the profile dir directly. `owner` is null when no declared profile matches the scope.
528
+ */
529
+ export function resumeCommand(r, owner) {
530
+ const resume = r.agent === 'codex' ? `resume ${r.session.id}` : `--resume ${r.session.id}`;
531
+ const bin = r.agent === 'codex' ? 'codex' : 'claude';
532
+ const run = owner?.launcher ? `${owner.launcher} ${resume}`
533
+ : owner ? `${r.agent === 'codex' ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR'}=${shq(owner.dir)} ${bin} ${resume}`
534
+ : `${bin} ${resume}`;
535
+ return r.project.startsWith('/') ? `cd ${shq(r.project)} && ${run}` : run;
536
+ }
537
+ /**
538
+ * Which profile reopens a session: the one named by its scope, or — for the shared pool — the
539
+ * first profile of the same agent that shares sessions.
540
+ */
541
+ export function sessionOwner(r, profiles) {
542
+ const same = profiles.filter(p => (p.agent ?? 'claude') === r.agent);
543
+ return (r.scope === 'shared' ? same.find(p => p.sharedSessions) : same.find(p => p.name === r.scope)) ?? null;
437
544
  }
438
545
  /** Read visible conversation and tool activity for one known managed session. */
439
546
  export async function readSessionTranscript(opts) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.10.3",
3
+ "version": "0.12.0",
4
4
  "description": "Core library for ccprofiles — discovery, manifest, secrets, apply, and LAN sync for Claude Code multi-account setups",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -38,7 +38,7 @@
38
38
  "node": ">=20"
39
39
  },
40
40
  "dependencies": {
41
- "smol-toml": "^1.7.0",
41
+ "smol-toml": "^1.9.0",
42
42
  "yaml": "^2.5.0",
43
43
  "zod": "^3.23.0"
44
44
  },