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 +1 -0
- package/dist/discovery.js +5 -2
- package/dist/manifest.d.ts +15 -0
- package/dist/manifest.js +13 -0
- package/dist/rcblock.js +8 -2
- package/dist/sessions.d.ts +40 -0
- package/dist/sessions.js +127 -20
- package/package.json +2 -2
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
|
-
|
|
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
|
}
|
package/dist/manifest.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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/sessions.d.ts
CHANGED
|
@@ -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
|
|
11
|
-
|
|
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
|
|
32
|
-
|
|
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
|
|
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
|
|
71
|
-
*
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
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.
|
|
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
|
-
|
|
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() &&
|
|
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
|
-
}
|
|
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.
|
|
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.
|
|
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.
|
|
41
|
+
"smol-toml": "^1.9.0",
|
|
42
42
|
"yaml": "^2.5.0",
|
|
43
43
|
"zod": "^3.23.0"
|
|
44
44
|
},
|