ccprofiles-core 0.10.0 → 0.10.2

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.
@@ -8,7 +8,7 @@
8
8
  * so a Claude session cannot dispatch a native subagent onto Kimi, GLM, or Codex.
9
9
  */
10
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"];
11
+ export declare const PROVIDER_ENV_KEYS: readonly ["CLAUDE_CONFIG_DIR", "CODEX_HOME", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "ANTHROPIC_MODEL", "ANTHROPIC_SMALL_FAST_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_USE_BEDROCK", "CLAUDE_CODE_USE_VERTEX", "CLAUDE_CODE_SKIP_BEDROCK_AUTH", "CLAUDE_CODE_SKIP_VERTEX_AUTH", "AWS_BEARER_TOKEN_BEDROCK", "ANTHROPIC_BEDROCK_BASE_URL", "ANTHROPIC_VERTEX_BASE_URL", "ANTHROPIC_VERTEX_PROJECT_ID", "CLOUD_ML_REGION", "OPENAI_API_KEY", "OPENAI_BASE_URL"];
12
12
  export interface DelegateLaunch {
13
13
  command: string;
14
14
  args: string[];
package/dist/delegate.js CHANGED
@@ -13,12 +13,29 @@ export const PROVIDER_ENV_KEYS = [
13
13
  'CODEX_HOME',
14
14
  'ANTHROPIC_API_KEY',
15
15
  'ANTHROPIC_AUTH_TOKEN',
16
+ // a direct auth override — left in place, the delegate authenticates as the *parent* account
17
+ 'CLAUDE_CODE_OAUTH_TOKEN',
16
18
  'ANTHROPIC_BASE_URL',
19
+ // gateways route and bill on these, so they re-point a delegate as surely as a base URL
20
+ 'ANTHROPIC_CUSTOM_HEADERS',
17
21
  'ANTHROPIC_MODEL',
18
22
  'ANTHROPIC_SMALL_FAST_MODEL',
19
23
  'ANTHROPIC_DEFAULT_OPUS_MODEL',
20
24
  'ANTHROPIC_DEFAULT_SONNET_MODEL',
21
25
  'ANTHROPIC_DEFAULT_HAIKU_MODEL',
26
+ // alternate backends: these switch the provider wholesale, ignoring ANTHROPIC_BASE_URL
27
+ 'CLAUDE_CODE_USE_BEDROCK',
28
+ 'CLAUDE_CODE_USE_VERTEX',
29
+ 'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
30
+ 'CLAUDE_CODE_SKIP_VERTEX_AUTH',
31
+ 'AWS_BEARER_TOKEN_BEDROCK',
32
+ 'ANTHROPIC_BEDROCK_BASE_URL',
33
+ 'ANTHROPIC_VERTEX_BASE_URL',
34
+ 'ANTHROPIC_VERTEX_PROJECT_ID',
35
+ 'CLOUD_ML_REGION',
36
+ // codex targets pin on the OpenAI pair, the exact mirror of the Anthropic leak
37
+ 'OPENAI_API_KEY',
38
+ 'OPENAI_BASE_URL',
22
39
  ];
23
40
  /**
24
41
  * Parent env minus every provider pin, plus the target's own.
@@ -55,7 +72,9 @@ export function buildDelegateLaunch(opts) {
55
72
  // codex resolves its workspace from --cd, not the spawn cwd
56
73
  if (codex)
57
74
  args.push('-C', opts.cwd);
58
- args.push(opts.prompt);
75
+ // `--` or a prompt like "-fix the tests" is parsed as an option by the child and the whole
76
+ // delegation fails. Both `claude` (commander) and `codex exec` (clap) honour the separator.
77
+ args.push('--', opts.prompt);
59
78
  return {
60
79
  command: codex ? 'codex' : 'claude',
61
80
  args,
package/dist/handoff.d.ts CHANGED
@@ -1,5 +1,13 @@
1
1
  import type { ProjectSessions, SessionTranscript } from './sessions.js';
2
- export declare function findLastSessionForCwd(scanned: ProjectSessions[], cwd: string, scope: string, agent: 'claude' | 'codex'): {
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
- export function findLastSessionForCwd(scanned, cwd, scope, agent) {
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 !== cwd || p.scope !== scope || p.agent !== agent)
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
@@ -17,5 +17,6 @@ export * from './bundle.js';
17
17
  export * from './sessions.js';
18
18
  export * from './handoff.js';
19
19
  export * from './delegate.js';
20
+ export * from './scope.js';
20
21
  export * from './plugins.js';
21
22
  export * from './provider.js';
package/dist/index.js CHANGED
@@ -17,5 +17,6 @@ export * from './bundle.js';
17
17
  export * from './sessions.js';
18
18
  export * from './handoff.js';
19
19
  export * from './delegate.js';
20
+ export * from './scope.js';
20
21
  export * from './plugins.js';
21
22
  export * from './provider.js';
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Path-lane enforcement for parallel delegation.
3
+ *
4
+ * Running two agents at once is only safe while they touch disjoint files. A scope stated in
5
+ * the prompt is a request, not a constraint — models drift the moment a task turns out to need
6
+ * a shared type or config. These helpers turn "they probably stayed in their lane" into a
7
+ * checkable fact after the run.
8
+ */
9
+ /**
10
+ * Glob → RegExp for the subset that matters here: `**` (any depth, may be empty), `*` (one
11
+ * segment), `?` (one char). Everything else is escaped literally. Deliberately not a full
12
+ * globbing implementation — a scope is a coarse directory lane, not a filter language.
13
+ */
14
+ export declare function globToRegExp(glob: string): RegExp;
15
+ /**
16
+ * A lane written as a bare directory (`src/web` or `src/web/`) means everything under it.
17
+ * Taken literally it would match exactly zero files and flag every in-lane edit as a
18
+ * violation — a footgun that makes the check useless in precisely the way that looks broken.
19
+ */
20
+ export declare function normalizeScope(scope: string): string;
21
+ /** True when `file` falls inside any lane. An empty scope list means "no lane declared". */
22
+ export declare function inScope(file: string, scopes: string[]): boolean;
23
+ export interface ScopeReport {
24
+ touched: string[];
25
+ violations: string[];
26
+ }
27
+ /**
28
+ * Which files the delegate touched, and which of those left the lane.
29
+ *
30
+ * `before`/`after` map a repo-relative path to a change fingerprint (see snapshotChanges).
31
+ * A path counts as touched if its fingerprint appeared, vanished, or moved — the vanish case
32
+ * matters because a delegate that reverts your edit or deletes your untracked file would
33
+ * otherwise be invisible.
34
+ *
35
+ * `committed` carries paths from commits the delegate made, which leave the working tree clean
36
+ * and so never show up in either snapshot at all.
37
+ */
38
+ export declare function scopeReport(before: Map<string, string>, after: Map<string, string>, scopes: string[], committed?: string[]): ScopeReport;
package/dist/scope.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Path-lane enforcement for parallel delegation.
3
+ *
4
+ * Running two agents at once is only safe while they touch disjoint files. A scope stated in
5
+ * the prompt is a request, not a constraint — models drift the moment a task turns out to need
6
+ * a shared type or config. These helpers turn "they probably stayed in their lane" into a
7
+ * checkable fact after the run.
8
+ */
9
+ /**
10
+ * Glob → RegExp for the subset that matters here: `**` (any depth, may be empty), `*` (one
11
+ * segment), `?` (one char). Everything else is escaped literally. Deliberately not a full
12
+ * globbing implementation — a scope is a coarse directory lane, not a filter language.
13
+ */
14
+ export function globToRegExp(glob) {
15
+ let re = '';
16
+ for (let i = 0; i < glob.length; i++) {
17
+ const c = glob[i];
18
+ if (c === '*') {
19
+ if (glob[i + 1] === '*') {
20
+ i++;
21
+ if (glob[i + 1] === '/') {
22
+ // mid-pattern `a/**/b` must also match `a/b`, so the whole group is optional
23
+ i++;
24
+ re += '(?:.*/)?';
25
+ }
26
+ else {
27
+ // trailing `a/**` is "everything under a/", files included
28
+ re += '.*';
29
+ }
30
+ }
31
+ else {
32
+ re += '[^/]*';
33
+ }
34
+ }
35
+ else if (c === '?') {
36
+ re += '[^/]';
37
+ }
38
+ else {
39
+ re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
40
+ }
41
+ }
42
+ return new RegExp(`^${re}$`);
43
+ }
44
+ /**
45
+ * A lane written as a bare directory (`src/web` or `src/web/`) means everything under it.
46
+ * Taken literally it would match exactly zero files and flag every in-lane edit as a
47
+ * violation — a footgun that makes the check useless in precisely the way that looks broken.
48
+ */
49
+ export function normalizeScope(scope) {
50
+ const s = scope.replace(/\\/g, '/').replace(/\/+$/, '');
51
+ return /[*?]/.test(s) ? s : `${s}/**`;
52
+ }
53
+ /** True when `file` falls inside any lane. An empty scope list means "no lane declared". */
54
+ export function inScope(file, scopes) {
55
+ if (!scopes.length)
56
+ return true;
57
+ const normalized = file.replace(/\\/g, '/');
58
+ return scopes.some(s => globToRegExp(normalizeScope(s)).test(normalized));
59
+ }
60
+ /**
61
+ * Which files the delegate touched, and which of those left the lane.
62
+ *
63
+ * `before`/`after` map a repo-relative path to a change fingerprint (see snapshotChanges).
64
+ * A path counts as touched if its fingerprint appeared, vanished, or moved — the vanish case
65
+ * matters because a delegate that reverts your edit or deletes your untracked file would
66
+ * otherwise be invisible.
67
+ *
68
+ * `committed` carries paths from commits the delegate made, which leave the working tree clean
69
+ * and so never show up in either snapshot at all.
70
+ */
71
+ export function scopeReport(before, after, scopes, committed = []) {
72
+ const touched = new Set(committed.map(f => f.replace(/\\/g, '/')));
73
+ for (const [file, fp] of after) {
74
+ if (before.get(file) !== fp)
75
+ touched.add(file);
76
+ }
77
+ for (const file of before.keys()) {
78
+ if (!after.has(file))
79
+ touched.add(file); // reverted or deleted
80
+ }
81
+ const list = [...touched].sort();
82
+ return { touched: list, violations: list.filter(f => !inScope(f, scopes)) };
83
+ }
@@ -36,8 +36,18 @@ export declare function sessionScanCacheStats(): {
36
36
  hits: number;
37
37
  misses: number;
38
38
  };
39
- /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
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
- /** Best-effort decode of Claude Code's project dir name (cwd from a record is preferred). */
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: null }));
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: null }));
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccprofiles-core",
3
- "version": "0.10.0",
3
+ "version": "0.10.2",
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",