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 +1 -0
- package/dist/apply.js +7 -2
- package/dist/manifest.d.ts +16 -0
- package/dist/manifest.js +19 -2
- package/dist/plugins.d.ts +23 -0
- package/dist/plugins.js +88 -3
- package/dist/rcblock.js +8 -2
- package/dist/secrets.js +3 -0
- 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/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
|
-
|
|
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
|
-
|
|
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
|
}
|
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<{
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/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
|
}
|
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.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.
|
|
41
|
+
"smol-toml": "^1.9.0",
|
|
42
42
|
"yaml": "^2.5.0",
|
|
43
43
|
"zod": "^3.23.0"
|
|
44
44
|
},
|