ccprofiles-core 0.9.0 → 0.10.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.d.ts +3 -3
- package/dist/adopt.js +11 -4
- package/dist/apply.d.ts +4 -0
- package/dist/apply.js +35 -2
- package/dist/delegate.d.ts +37 -0
- package/dist/delegate.js +65 -0
- package/dist/discovery.d.ts +3 -0
- package/dist/discovery.js +4 -0
- package/dist/handoff.d.ts +9 -1
- package/dist/handoff.js +14 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/manifest.d.ts +7 -0
- package/dist/manifest.js +5 -0
- package/dist/sessions.d.ts +11 -1
- package/dist/sessions.js +50 -6
- package/package.json +1 -1
package/dist/adopt.d.ts
CHANGED
|
@@ -9,8 +9,8 @@ export declare function buildManifest(live: LiveProfile[], platform: Platform, s
|
|
|
9
9
|
* if the old manifest had a secret:// ref whose resolved value matches the newly-discovered
|
|
10
10
|
* plaintext value, restore the ref instead of the plaintext.
|
|
11
11
|
*
|
|
12
|
-
* Also carries forward declared-only fields (skipPermissions, launcher env)
|
|
13
|
-
* state cannot represent — buildManifest resets them to defaults, so without this
|
|
14
|
-
* snapshot/adopt would silently turn them off.
|
|
12
|
+
* Also carries forward declared-only fields (skipPermissions, launcher name, launcher env)
|
|
13
|
+
* that live state cannot represent — buildManifest resets them to defaults, so without this
|
|
14
|
+
* every snapshot/adopt would silently turn them off.
|
|
15
15
|
*/
|
|
16
16
|
export declare function preserveSecretRefs(newM: Manifest, oldM: Manifest, getSecret: (name: string) => Promise<string | null>): Promise<void>;
|
package/dist/adopt.js
CHANGED
|
@@ -59,7 +59,11 @@ export function buildManifest(live, platform, sharedRoot = join(platform.home, '
|
|
|
59
59
|
.map(([k]) => k).sort(),
|
|
60
60
|
};
|
|
61
61
|
});
|
|
62
|
-
|
|
62
|
+
// Whichever profile already has a statusline wins; ~/.claude first, since that is where a
|
|
63
|
+
// `ccstatusline`-style installer writes when run without CLAUDE_CONFIG_DIR set.
|
|
64
|
+
const statusLine = (live.find(lp => lp.dirName === '.claude' && lp.statusLine) ?? live.find(lp => lp.statusLine))
|
|
65
|
+
?.statusLine;
|
|
66
|
+
return { version: 1, hub, profiles, mcpServers, marketplaces, ...(statusLine ? { statusLine } : {}) };
|
|
63
67
|
}
|
|
64
68
|
/**
|
|
65
69
|
* Re-migrate secret refs after a snapshot/adopt rebuilds the manifest from live state.
|
|
@@ -68,9 +72,9 @@ export function buildManifest(live, platform, sharedRoot = join(platform.home, '
|
|
|
68
72
|
* if the old manifest had a secret:// ref whose resolved value matches the newly-discovered
|
|
69
73
|
* plaintext value, restore the ref instead of the plaintext.
|
|
70
74
|
*
|
|
71
|
-
* Also carries forward declared-only fields (skipPermissions, launcher env)
|
|
72
|
-
* state cannot represent — buildManifest resets them to defaults, so without this
|
|
73
|
-
* snapshot/adopt would silently turn them off.
|
|
75
|
+
* Also carries forward declared-only fields (skipPermissions, launcher name, launcher env)
|
|
76
|
+
* that live state cannot represent — buildManifest resets them to defaults, so without this
|
|
77
|
+
* every snapshot/adopt would silently turn them off.
|
|
74
78
|
*/
|
|
75
79
|
export async function preserveSecretRefs(newM, oldM, getSecret) {
|
|
76
80
|
for (const pr of newM.profiles) {
|
|
@@ -78,6 +82,9 @@ export async function preserveSecretRefs(newM, oldM, getSecret) {
|
|
|
78
82
|
if (!old)
|
|
79
83
|
continue;
|
|
80
84
|
pr.skipPermissions = old.skipPermissions;
|
|
85
|
+
// buildManifest derives `cl-<dirname>`, which silently renames a hand-picked alias
|
|
86
|
+
// (cl-plb -> cl-data-plb) and makes the next apply rewrite the user's rc block.
|
|
87
|
+
pr.launcher = old.launcher;
|
|
81
88
|
if (Object.keys(pr.env).length === 0)
|
|
82
89
|
pr.env = old.env;
|
|
83
90
|
for (const [k, v] of Object.entries(old.settingsEnv ?? {})) {
|
package/dist/apply.d.ts
CHANGED
|
@@ -21,6 +21,10 @@ export type ApplyAction = {
|
|
|
21
21
|
kind: 'set-settings-env';
|
|
22
22
|
settingsPath: string;
|
|
23
23
|
env: Record<string, string>;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'set-status-line';
|
|
26
|
+
settingsPath: string;
|
|
27
|
+
statusLine: Record<string, unknown>;
|
|
24
28
|
} | {
|
|
25
29
|
kind: 'share-session-dir';
|
|
26
30
|
from: string;
|
package/dist/apply.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cp, lstat, mkdir, readFile, rm, symlink, unlink } from 'node:fs/promises';
|
|
1
|
+
import { cp, lstat, mkdir, readFile, rm, stat, symlink, unlink } from 'node:fs/promises';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, posix as posixPath, win32 as win32Path } from 'node:path';
|
|
4
4
|
import { renderPath } from './platform.js';
|
|
@@ -128,6 +128,14 @@ export function planApply(m, live, p, resolvedSettingsEnv, sharedRoot = join(p.h
|
|
|
128
128
|
actions.push({ kind: 'set-settings-env', settingsPath: join(dir, 'settings.json'), env: desired });
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
|
+
// Same normalization as the mcp check — comparing raw JSON would re-emit this action on
|
|
132
|
+
// every apply whenever key order differed, which is the unfixable-drift-loop shape.
|
|
133
|
+
if (agent === 'claude' && m.statusLine) {
|
|
134
|
+
const currentSl = lp?.statusLine ?? null;
|
|
135
|
+
if (!currentSl || JSON.stringify(sortKeys(currentSl)) !== JSON.stringify(sortKeys(m.statusLine))) {
|
|
136
|
+
actions.push({ kind: 'set-status-line', settingsPath: join(dir, 'settings.json'), statusLine: m.statusLine });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
131
139
|
}
|
|
132
140
|
const block = renderRcBlock(m, p);
|
|
133
141
|
let rcCurrent = '';
|
|
@@ -146,7 +154,8 @@ export async function executeApply(actions, opts) {
|
|
|
146
154
|
const touched = actions.flatMap(a => a.kind === 'set-mcp-servers' ? [a.configPath]
|
|
147
155
|
: a.kind === 'rc-block' ? [a.rcFile]
|
|
148
156
|
: a.kind === 'set-settings-env' ? [a.settingsPath]
|
|
149
|
-
: []
|
|
157
|
+
: a.kind === 'set-status-line' ? [a.settingsPath]
|
|
158
|
+
: []);
|
|
150
159
|
let backupDir = touched.length ? await backupFiles(touched, opts.backupRoot, opts.stamp) : null;
|
|
151
160
|
for (const a of actions) {
|
|
152
161
|
if (a.kind === 'create-profile-dir') {
|
|
@@ -222,6 +231,29 @@ export async function executeApply(actions, opts) {
|
|
|
222
231
|
// Resolved secret:// refs land here as plaintext env values — keep it 0600.
|
|
223
232
|
await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2), { mode: 0o600 });
|
|
224
233
|
}
|
|
234
|
+
else if (a.kind === 'set-status-line') {
|
|
235
|
+
let cfg = {};
|
|
236
|
+
try {
|
|
237
|
+
cfg = JSON.parse(await readFile(a.settingsPath, 'utf8'));
|
|
238
|
+
}
|
|
239
|
+
catch (e) {
|
|
240
|
+
if (e.code !== 'ENOENT') {
|
|
241
|
+
await backupFiles([a.settingsPath], opts.backupRoot, opts.stamp);
|
|
242
|
+
throw new Error(`refusing to overwrite unreadable ${a.settingsPath} — back it up and fix it, then re-apply (${e.message})`);
|
|
243
|
+
}
|
|
244
|
+
// ENOENT: genuinely new file, cfg stays {}
|
|
245
|
+
}
|
|
246
|
+
cfg.statusLine = a.statusLine;
|
|
247
|
+
// Preserve the existing mode. set-settings-env may have just written this same file at
|
|
248
|
+
// 0600 because it holds resolved secrets; atomicWrite's 0644 default would widen it.
|
|
249
|
+
let mode = 0o644;
|
|
250
|
+
try {
|
|
251
|
+
mode = (await stat(a.settingsPath)).mode & 0o777;
|
|
252
|
+
}
|
|
253
|
+
catch { /* new file: 0644 */ }
|
|
254
|
+
await mkdir(dirname(a.settingsPath), { recursive: true });
|
|
255
|
+
await atomicWrite(a.settingsPath, JSON.stringify(cfg, null, 2), { mode });
|
|
256
|
+
}
|
|
225
257
|
else if (a.kind === 'share-session-dir') {
|
|
226
258
|
await mkdir(a.to, { recursive: true });
|
|
227
259
|
let st = null;
|
|
@@ -263,6 +295,7 @@ function describe(a) {
|
|
|
263
295
|
case 'link': return `link ${a.from} -> ${a.to}`;
|
|
264
296
|
case 'rc-block': return `update managed block in ${a.rcFile}`;
|
|
265
297
|
case 'set-settings-env': return `set settings env (${Object.keys(a.env).length}) in ${a.settingsPath}`;
|
|
298
|
+
case 'set-status-line': return `set statusLine in ${a.settingsPath}`;
|
|
266
299
|
case 'share-session-dir': return `share ${a.from} -> ${a.to}`;
|
|
267
300
|
case 'unshare-session-dir': return `unshare ${a.from} (seed from ${a.to})`;
|
|
268
301
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run another profile's agent headlessly as a subagent of the current session.
|
|
3
|
+
*
|
|
4
|
+
* `handoff` moves you to another profile interactively; this keeps you where you are and
|
|
5
|
+
* calls the other profile for one scoped task, returning its output. That is the only way
|
|
6
|
+
* to reach a different provider from inside a session: ANTHROPIC_BASE_URL / _AUTH_TOKEN are
|
|
7
|
+
* process-wide, and a native subagent's `model` is validated against the *current* provider,
|
|
8
|
+
* so a Claude session cannot dispatch a native subagent onto Kimi, GLM, or Codex.
|
|
9
|
+
*/
|
|
10
|
+
/** Env that pins a session to one account/provider. Must never survive into a delegate. */
|
|
11
|
+
export declare const PROVIDER_ENV_KEYS: readonly ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"];
|
|
12
|
+
export interface DelegateLaunch {
|
|
13
|
+
command: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
env: Record<string, string>;
|
|
16
|
+
cwd: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Parent env minus every provider pin, plus the target's own.
|
|
20
|
+
*
|
|
21
|
+
* Without the strip, delegating from a gateway profile (kimi/z/mimo, which export
|
|
22
|
+
* ANTHROPIC_BASE_URL) to a Claude profile would leave the gateway's base URL in place: the
|
|
23
|
+
* target would authenticate as the Claude profile but send its traffic to the gateway. The
|
|
24
|
+
* delegate would appear to work while silently running on the wrong model.
|
|
25
|
+
*/
|
|
26
|
+
export declare function delegateEnv(parentEnv: Record<string, string | undefined>, homeVar: 'CLAUDE_CONFIG_DIR' | 'CODEX_HOME', targetDir: string, targetEnv: Record<string, string>): Record<string, string>;
|
|
27
|
+
export declare function buildDelegateLaunch(opts: {
|
|
28
|
+
targetAgent: 'claude' | 'codex';
|
|
29
|
+
targetDir: string;
|
|
30
|
+
targetEnv: Record<string, string>;
|
|
31
|
+
skipPermissions: boolean;
|
|
32
|
+
prompt: string;
|
|
33
|
+
cwd: string;
|
|
34
|
+
model?: string | null;
|
|
35
|
+
json?: boolean;
|
|
36
|
+
parentEnv?: Record<string, string | undefined>;
|
|
37
|
+
}): DelegateLaunch;
|
package/dist/delegate.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run another profile's agent headlessly as a subagent of the current session.
|
|
3
|
+
*
|
|
4
|
+
* `handoff` moves you to another profile interactively; this keeps you where you are and
|
|
5
|
+
* calls the other profile for one scoped task, returning its output. That is the only way
|
|
6
|
+
* to reach a different provider from inside a session: ANTHROPIC_BASE_URL / _AUTH_TOKEN are
|
|
7
|
+
* process-wide, and a native subagent's `model` is validated against the *current* provider,
|
|
8
|
+
* so a Claude session cannot dispatch a native subagent onto Kimi, GLM, or Codex.
|
|
9
|
+
*/
|
|
10
|
+
/** Env that pins a session to one account/provider. Must never survive into a delegate. */
|
|
11
|
+
export const PROVIDER_ENV_KEYS = [
|
|
12
|
+
'CLAUDE_CONFIG_DIR',
|
|
13
|
+
'CODEX_HOME',
|
|
14
|
+
'ANTHROPIC_API_KEY',
|
|
15
|
+
'ANTHROPIC_AUTH_TOKEN',
|
|
16
|
+
'ANTHROPIC_BASE_URL',
|
|
17
|
+
'ANTHROPIC_MODEL',
|
|
18
|
+
'ANTHROPIC_SMALL_FAST_MODEL',
|
|
19
|
+
'ANTHROPIC_DEFAULT_OPUS_MODEL',
|
|
20
|
+
'ANTHROPIC_DEFAULT_SONNET_MODEL',
|
|
21
|
+
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Parent env minus every provider pin, plus the target's own.
|
|
25
|
+
*
|
|
26
|
+
* Without the strip, delegating from a gateway profile (kimi/z/mimo, which export
|
|
27
|
+
* ANTHROPIC_BASE_URL) to a Claude profile would leave the gateway's base URL in place: the
|
|
28
|
+
* target would authenticate as the Claude profile but send its traffic to the gateway. The
|
|
29
|
+
* delegate would appear to work while silently running on the wrong model.
|
|
30
|
+
*/
|
|
31
|
+
export function delegateEnv(parentEnv, homeVar, targetDir, targetEnv) {
|
|
32
|
+
const out = {};
|
|
33
|
+
for (const [k, v] of Object.entries(parentEnv)) {
|
|
34
|
+
if (v === undefined)
|
|
35
|
+
continue;
|
|
36
|
+
if (PROVIDER_ENV_KEYS.includes(k))
|
|
37
|
+
continue;
|
|
38
|
+
out[k] = v;
|
|
39
|
+
}
|
|
40
|
+
out[homeVar] = targetDir;
|
|
41
|
+
return { ...out, ...targetEnv };
|
|
42
|
+
}
|
|
43
|
+
export function buildDelegateLaunch(opts) {
|
|
44
|
+
const codex = opts.targetAgent === 'codex';
|
|
45
|
+
const homeVar = codex ? 'CODEX_HOME' : 'CLAUDE_CONFIG_DIR';
|
|
46
|
+
const args = codex ? ['exec'] : ['-p'];
|
|
47
|
+
if (opts.model)
|
|
48
|
+
args.push(codex ? '-m' : '--model', opts.model);
|
|
49
|
+
// codex exec has no --output-format; the caller is told rather than silently given text
|
|
50
|
+
if (opts.json && !codex)
|
|
51
|
+
args.push('--output-format', 'json');
|
|
52
|
+
if (opts.skipPermissions) {
|
|
53
|
+
args.push(codex ? '--dangerously-bypass-approvals-and-sandbox' : '--dangerously-skip-permissions');
|
|
54
|
+
}
|
|
55
|
+
// codex resolves its workspace from --cd, not the spawn cwd
|
|
56
|
+
if (codex)
|
|
57
|
+
args.push('-C', opts.cwd);
|
|
58
|
+
args.push(opts.prompt);
|
|
59
|
+
return {
|
|
60
|
+
command: codex ? 'codex' : 'claude',
|
|
61
|
+
args,
|
|
62
|
+
env: delegateEnv(opts.parentEnv ?? {}, homeVar, opts.targetDir, opts.targetEnv),
|
|
63
|
+
cwd: opts.cwd,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/discovery.d.ts
CHANGED
|
@@ -9,6 +9,9 @@ export interface LiveProfile {
|
|
|
9
9
|
mcpServers: Record<string, McpServerDef>;
|
|
10
10
|
links: Record<string, string>;
|
|
11
11
|
settingsEnv: Record<string, string>;
|
|
12
|
+
/** settings.json `statusLine`, verbatim. Claude Code reads it from the profile's own
|
|
13
|
+
* settings.json, so configuring it once in ~/.claude leaves every other profile bare. */
|
|
14
|
+
statusLine?: unknown;
|
|
12
15
|
enabledPlugins: Record<string, boolean>;
|
|
13
16
|
/** Plugin ids actually installed (keys of plugins/installed_plugins.json) — the ground truth
|
|
14
17
|
* for "present". enabledPlugins alone can be stale: an enabled-but-never-installed entry
|
package/dist/discovery.js
CHANGED
|
@@ -98,6 +98,7 @@ export async function discoverProfiles(home) {
|
|
|
98
98
|
}
|
|
99
99
|
const settingsEnv = {};
|
|
100
100
|
const enabledPlugins = {};
|
|
101
|
+
let statusLine;
|
|
101
102
|
if (agent === 'claude')
|
|
102
103
|
try {
|
|
103
104
|
const s = JSON.parse(await readFile(join(dir, 'settings.json'), 'utf8'));
|
|
@@ -109,6 +110,8 @@ export async function discoverProfiles(home) {
|
|
|
109
110
|
for (const [k, v] of Object.entries(s.enabledPlugins))
|
|
110
111
|
if (typeof v === 'boolean')
|
|
111
112
|
enabledPlugins[k] = v;
|
|
113
|
+
if (s && typeof s.statusLine === 'object' && s.statusLine !== null)
|
|
114
|
+
statusLine = s.statusLine;
|
|
112
115
|
}
|
|
113
116
|
catch { /* no settings.json */ }
|
|
114
117
|
const marketplaces = {};
|
|
@@ -144,6 +147,7 @@ export async function discoverProfiles(home) {
|
|
|
144
147
|
mcpServers: agent === 'claude' ? cfg?.mcpServers ?? {} : await readCodexMcpServers(configPath),
|
|
145
148
|
links,
|
|
146
149
|
settingsEnv,
|
|
150
|
+
statusLine,
|
|
147
151
|
enabledPlugins,
|
|
148
152
|
installedPlugins,
|
|
149
153
|
installedPluginVersions,
|
package/dist/handoff.d.ts
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import type { ProjectSessions, SessionTranscript } from './sessions.js';
|
|
2
|
-
|
|
2
|
+
import type { OsKind } from './platform.js';
|
|
3
|
+
/**
|
|
4
|
+
* Compare two absolute paths the way the OS would. Windows is case-insensitive and mixes
|
|
5
|
+
* separators freely (`C:\p` / `c:/p` are one path), so a strict === there silently fails to
|
|
6
|
+
* match a session against its own project. POSIX is case-sensitive — normalizing it would
|
|
7
|
+
* merge genuinely distinct paths, so it stays exact.
|
|
8
|
+
*/
|
|
9
|
+
export declare function samePath(a: string, b: string, os: OsKind): boolean;
|
|
10
|
+
export declare function findLastSessionForCwd(scanned: ProjectSessions[], cwd: string, scope: string, agent: 'claude' | 'codex', os?: OsKind): {
|
|
3
11
|
scope: string;
|
|
4
12
|
id: string;
|
|
5
13
|
} | null;
|
package/dist/handoff.js
CHANGED
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Compare two absolute paths the way the OS would. Windows is case-insensitive and mixes
|
|
3
|
+
* separators freely (`C:\p` / `c:/p` are one path), so a strict === there silently fails to
|
|
4
|
+
* match a session against its own project. POSIX is case-sensitive — normalizing it would
|
|
5
|
+
* merge genuinely distinct paths, so it stays exact.
|
|
6
|
+
*/
|
|
7
|
+
export function samePath(a, b, os) {
|
|
8
|
+
if (os !== 'win32')
|
|
9
|
+
return a === b;
|
|
10
|
+
const norm = (p) => p.replace(/[\\/]+/g, '\\').replace(/(.)\\+$/, '$1').toLowerCase();
|
|
11
|
+
return norm(a) === norm(b);
|
|
12
|
+
}
|
|
13
|
+
export function findLastSessionForCwd(scanned, cwd, scope, agent, os = 'linux') {
|
|
2
14
|
let newest = null;
|
|
3
15
|
for (const p of scanned) {
|
|
4
|
-
if (p.project
|
|
16
|
+
if (!samePath(p.project, cwd, os) || p.scope !== scope || p.agent !== agent)
|
|
5
17
|
continue;
|
|
6
18
|
for (const s of p.sessions)
|
|
7
19
|
if (!newest || s.mtime > newest.mtime)
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/manifest.d.ts
CHANGED
|
@@ -160,6 +160,11 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
160
160
|
}, {
|
|
161
161
|
source: string;
|
|
162
162
|
}>>>;
|
|
163
|
+
/** One settings.json `statusLine` block mirrored into every claude profile. Claude Code
|
|
164
|
+
* reads it per-CLAUDE_CONFIG_DIR, so without this a statusline configured in one profile
|
|
165
|
+
* is simply absent in the others. Shared, not per-profile: the point is that every
|
|
166
|
+
* profile gets the same line (it renders per-profile data at runtime). */
|
|
167
|
+
statusLine: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
163
168
|
}, "strip", z.ZodTypeAny, {
|
|
164
169
|
version: 1;
|
|
165
170
|
hub: string | null;
|
|
@@ -187,6 +192,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
187
192
|
marketplaces: Record<string, {
|
|
188
193
|
source: string;
|
|
189
194
|
}>;
|
|
195
|
+
statusLine?: Record<string, unknown> | undefined;
|
|
190
196
|
}, {
|
|
191
197
|
version: 1;
|
|
192
198
|
hub: string | null;
|
|
@@ -214,6 +220,7 @@ declare const ManifestSchema: z.ZodObject<{
|
|
|
214
220
|
marketplaces?: Record<string, {
|
|
215
221
|
source: string;
|
|
216
222
|
}> | undefined;
|
|
223
|
+
statusLine?: Record<string, unknown> | undefined;
|
|
217
224
|
}>;
|
|
218
225
|
export type McpServerDef = z.infer<typeof McpServerSchema>;
|
|
219
226
|
export type ProfileDecl = z.infer<typeof ProfileSchema>;
|
package/dist/manifest.js
CHANGED
|
@@ -36,6 +36,11 @@ const ManifestSchema = z.object({
|
|
|
36
36
|
profiles: z.array(ProfileSchema),
|
|
37
37
|
mcpServers: z.record(McpServerSchema),
|
|
38
38
|
marketplaces: z.record(MarketplaceSchema).default({}),
|
|
39
|
+
/** One settings.json `statusLine` block mirrored into every claude profile. Claude Code
|
|
40
|
+
* reads it per-CLAUDE_CONFIG_DIR, so without this a statusline configured in one profile
|
|
41
|
+
* is simply absent in the others. Shared, not per-profile: the point is that every
|
|
42
|
+
* profile gets the same line (it renders per-profile data at runtime). */
|
|
43
|
+
statusLine: z.record(z.unknown()).optional(),
|
|
39
44
|
});
|
|
40
45
|
// identifiers that get interpolated into shell launcher code must be injection-safe
|
|
41
46
|
// leading '-' is forbidden (but internal '-' still allowed) so a value can never be parsed as a
|
package/dist/sessions.d.ts
CHANGED
|
@@ -36,8 +36,18 @@ export declare function sessionScanCacheStats(): {
|
|
|
36
36
|
hits: number;
|
|
37
37
|
misses: number;
|
|
38
38
|
};
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Last resort only — lossy, and unfixably so. Claude Code encodes a project path by replacing
|
|
41
|
+
* every '/' with '-', so a segment that legitimately contains a hyphen (`/Users/lp-stf00543`)
|
|
42
|
+
* cannot be told apart from a separator on the way back. Prefer the `cwd` recorded inside the
|
|
43
|
+
* session file; `headCwd` exists so that stays available even for files we decline to read.
|
|
44
|
+
*/
|
|
40
45
|
export declare function decodeProjectDir(name: string): string;
|
|
46
|
+
/**
|
|
47
|
+
* First cwd found in a head slice. A slice cut mid-record leaves a partial trailing line, which
|
|
48
|
+
* simply fails JSON.parse and is skipped — no need to guess which line is incomplete.
|
|
49
|
+
*/
|
|
50
|
+
export declare function headCwd(head: string, pick: (rec: any) => string | undefined): string | null;
|
|
41
51
|
export declare function scanSessions(opts: {
|
|
42
52
|
sharedRoot: string;
|
|
43
53
|
profiles: {
|
package/dist/sessions.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdir, readFile, stat, lstat } from 'node:fs/promises';
|
|
1
|
+
import { readdir, readFile, stat, lstat, open } from 'node:fs/promises';
|
|
2
2
|
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
const SAFE_SESSION_ID = /^[A-Za-z0-9_-]{1,160}$/;
|
|
@@ -7,6 +7,8 @@ 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
12
|
/** Per-request cap: once this many bytes have been read from disk in one scanSessions() call,
|
|
11
13
|
* remaining (uncached) files are summarized without reading their content either. */
|
|
12
14
|
const MAX_TOTAL_SCAN_BYTES = 50 * 1024 * 1024;
|
|
@@ -17,10 +19,52 @@ let scanCacheMisses = 0;
|
|
|
17
19
|
export function sessionScanCacheStats() {
|
|
18
20
|
return { hits: scanCacheHits, misses: scanCacheMisses };
|
|
19
21
|
}
|
|
20
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Last resort only — lossy, and unfixably so. Claude Code encodes a project path by replacing
|
|
24
|
+
* every '/' with '-', so a segment that legitimately contains a hyphen (`/Users/lp-stf00543`)
|
|
25
|
+
* cannot be told apart from a separator on the way back. Prefer the `cwd` recorded inside the
|
|
26
|
+
* session file; `headCwd` exists so that stays available even for files we decline to read.
|
|
27
|
+
*/
|
|
21
28
|
export function decodeProjectDir(name) {
|
|
22
29
|
return name.replace(/^-/, '/').replace(/-/g, '/');
|
|
23
30
|
}
|
|
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) {
|
|
33
|
+
let fh = null;
|
|
34
|
+
try {
|
|
35
|
+
fh = await open(file, 'r');
|
|
36
|
+
const buf = Buffer.alloc(bytes);
|
|
37
|
+
const { bytesRead } = await fh.read(buf, 0, bytes, 0);
|
|
38
|
+
return buf.subarray(0, bytesRead).toString('utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
await fh?.close().catch(() => { });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* First cwd found in a head slice. A slice cut mid-record leaves a partial trailing line, which
|
|
49
|
+
* simply fails JSON.parse and is skipped — no need to guess which line is incomplete.
|
|
50
|
+
*/
|
|
51
|
+
export function headCwd(head, pick) {
|
|
52
|
+
for (const line of head.split('\n')) {
|
|
53
|
+
if (!line.trim())
|
|
54
|
+
continue;
|
|
55
|
+
let rec;
|
|
56
|
+
try {
|
|
57
|
+
rec = JSON.parse(line);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const v = pick(rec);
|
|
63
|
+
if (typeof v === 'string' && v)
|
|
64
|
+
return v;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
24
68
|
/**
|
|
25
69
|
* Shared mtime+size cache lookup for the two session-file formats. `parseFull` runs only on a
|
|
26
70
|
* cache miss with a fresh read; `makeTruncated` builds a summary-only result when the file is
|
|
@@ -43,12 +87,12 @@ async function cachedParse(file, cache, budget, parseFull, makeTruncated) {
|
|
|
43
87
|
}
|
|
44
88
|
scanCacheMisses++;
|
|
45
89
|
if (st.size > MAX_FILE_SCAN_BYTES) {
|
|
46
|
-
const result = makeTruncated(st);
|
|
90
|
+
const result = makeTruncated(st, await readHead(file));
|
|
47
91
|
cache.set(file, { mtimeMs: st.mtimeMs, size: st.size, result });
|
|
48
92
|
return result;
|
|
49
93
|
}
|
|
50
94
|
if (st.size > budget.bytesLeft)
|
|
51
|
-
return makeTruncated(st); // not cached — budget is per-request
|
|
95
|
+
return makeTruncated(st, await readHead(file)); // not cached — budget is per-request
|
|
52
96
|
let raw;
|
|
53
97
|
try {
|
|
54
98
|
raw = await readFile(file, 'utf8');
|
|
@@ -90,7 +134,7 @@ async function parseClaudeSession(file, budget) {
|
|
|
90
134
|
break;
|
|
91
135
|
}
|
|
92
136
|
return { meta: { id, mtime: st.mtimeMs, messageCount: lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
|
|
93
|
-
}, st => ({ meta: { id, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd:
|
|
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) }));
|
|
94
138
|
}
|
|
95
139
|
async function scanProjectsDir(projectsDir, scope, budget) {
|
|
96
140
|
if (!existsSync(projectsDir))
|
|
@@ -314,7 +358,7 @@ async function parseCodexSession(file, budget) {
|
|
|
314
358
|
}
|
|
315
359
|
id ??= fallbackId;
|
|
316
360
|
return { meta: { id, mtime: st.mtimeMs, messageCount: messageCount || lines.length, firstPrompt, gitBranch, model, sizeBytes: st.size }, cwd };
|
|
317
|
-
}, st => ({ meta: { id: fallbackId, mtime: st.mtimeMs, messageCount: 0, firstPrompt: null, gitBranch: null, model: null, sizeBytes: st.size, truncated: true }, cwd:
|
|
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) }));
|
|
318
362
|
}
|
|
319
363
|
async function codexSessionFiles(dir) {
|
|
320
364
|
let entries;
|
package/package.json
CHANGED