ccprofiles-core 0.11.0 → 0.12.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/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/discovery.js CHANGED
@@ -31,8 +31,11 @@ function userScopeEntries(entries) {
31
31
  export function readInstalledPluginVersions(plugins) {
32
32
  const out = {};
33
33
  for (const [id, entries] of Object.entries(plugins)) {
34
- for (const { version } of userScopeEntries(entries)) {
35
- if (typeof version === 'string' && version && version !== 'unknown') {
34
+ for (const { version, gitCommitSha } of userScopeEntries(entries)) {
35
+ // Newer Claude Code writes the short sha INTO `version` instead of 'unknown' — same unusable
36
+ // identity, so it must be skipped the same way.
37
+ const isSha = typeof gitCommitSha === 'string' && typeof version === 'string' && gitCommitSha.startsWith(version);
38
+ if (typeof version === 'string' && version && version !== 'unknown' && !isSha) {
36
39
  out[id] = version;
37
40
  break;
38
41
  }
@@ -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<{
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) });
@@ -76,6 +81,8 @@ export function assertSafeManifest(m) {
76
81
  for (const p of m.profiles) {
77
82
  if (!SAFE_NAME.test(p.name))
78
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)}`);
79
86
  if (p.launcher !== null && !SAFE_NAME.test(p.launcher))
80
87
  throw new ManifestError(`unsafe launcher name in profile "${p.name}": ${JSON.stringify(p.launcher)}`);
81
88
  if (SHELL_META.test(p.dir))
@@ -135,6 +142,12 @@ export function parseManifest(text) {
135
142
  for (const name of p.mcp)
136
143
  if (!m.mcpServers[name])
137
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
+ }
138
151
  for (const p of m.profiles)
139
152
  for (const id of p.plugins) {
140
153
  const at = id.lastIndexOf('@');
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) {
@@ -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.11.0",
3
+ "version": "0.12.1",
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
  },