feat(accounts): add advanced deeper continuity mode and claude pool discoverability

This commit is contained in:
Tam Nhu Tran
2026-02-26 19:01:13 +07:00
parent 10c08b9be8
commit b6475baab3
35 changed files with 782 additions and 66 deletions
+15 -2
View File
@@ -269,6 +269,11 @@ Account profiles are isolated by default.
| `isolated` | Yes | No `context_group` required |
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
Shared mode continuity depth:
- `standard` (default): shares project workspace context only
- `deeper` (advanced opt-in): additionally syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
Opt in to shared context when needed:
```bash
@@ -277,13 +282,16 @@ ccs auth create backup --share-context
# Share context only within named group
ccs auth create backup2 --context-group sprint-a
# Advanced deeper continuity mode (requires shared mode)
ccs auth create backup3 --context-group sprint-a --deeper-continuity
```
Update existing accounts without recreating login:
1. Run `ccs config`
2. Open `Accounts`
3. Click the pencil icon in Actions and set `isolated` or `shared` mode
3. Click the pencil icon in Actions and set `isolated` or `shared` mode + continuity depth
Shared mode metadata in `~/.ccs/config.yaml`:
@@ -294,6 +302,7 @@ accounts:
last_used: null
context_mode: "shared"
context_group: "team-alpha"
continuity_mode: "standard"
```
`context_group` rules:
@@ -304,7 +313,11 @@ accounts:
- non-empty after normalization
- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`)
Shared context links project workspace data only. Credentials remain isolated per account.
Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account.
Alternative path for lower manual switching:
- Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`.
Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
+3 -2
View File
@@ -203,15 +203,16 @@ src/
### Account Context Metadata Flow
- Source fields: `accounts.<name>.context_mode` and `accounts.<name>.context_group` in `~/.ccs/config.yaml`.
- Source fields: `accounts.<name>.context_mode`, `accounts.<name>.context_group`, `accounts.<name>.continuity_mode` in `~/.ccs/config.yaml`.
- Runtime policy resolver: `src/auth/account-context.ts`.
- Metadata storage normalization: `src/auth/profile-registry.ts`.
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
- Rules:
- mode is isolation-first (`isolated` default, `shared` opt-in)
- shared mode requires non-empty valid `context_group`
- shared mode continuity depth is `standard` by default, optional `deeper`
- `context_group` is normalized (trim + lowercase + whitespace collapse to `-`)
- API route rejects `context_group` when mode is not `shared`
- API route rejects `context_group`/`continuity_mode` when mode is not `shared`
- registry normalization drops malformed persisted `context_group` values
### Target Adapter Module
+10 -1
View File
@@ -24,6 +24,11 @@ Account context is isolation-first:
| `isolated` | Yes | No `context_group` required |
| `shared` | No (opt-in) | Valid non-empty `context_group` |
Shared continuity depth:
- `standard` (default): shares project workspace context only
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
`context_group` normalization and validation:
- trim + lowercase + collapse internal whitespace to `-`
@@ -31,17 +36,21 @@ Account context is isolation-first:
- must start with a letter
- max length: 64
- shared mode requires non-empty value after normalization
- `continuity_mode` is only valid when mode is `shared`
`PUT /api/config` behavior for account context:
- rejects invalid unified payloads
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
- rejects invalid `continuity_mode` values
- normalizes valid shared `context_group` before save
- defaults missing shared `continuity_mode` to `standard`
- rejects `context_group` when mode is not `shared`
- rejects `continuity_mode` when mode is not `shared`
Dashboard accounts context editing:
- `PUT /api/accounts/:name/context` updates context mode/group for existing auth accounts
- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts
- rejects CLIProxy OAuth account keys for this route
- applies normalization/validation rules above
+23 -4
View File
@@ -9,7 +9,8 @@ CCS supports practical cross-account continuity by sharing workspace context fil
This is implemented as a context policy per account:
- `isolated` (default): account keeps its own workspace context
- `shared`: account workspace context is linked to a shared context group
- `shared` + `standard` (default): account workspace context is linked to a shared context group
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
## Why This Is Safe Enough
@@ -28,16 +29,27 @@ accounts:
last_used: null
context_mode: "shared"
context_group: "team-alpha"
continuity_mode: "deeper"
```
Rules:
- `context_mode` must be `isolated` or `shared`
- `context_group` is required when `context_mode=shared`
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
- group normalization: trim, lowercase, internal spaces -> `-`
- group must start with a letter and only include `[a-zA-Z0-9_-]`
- max length: `64`
Deeper continuity links these directories per context group:
- `session-env`
- `file-history`
- `shell-snapshots`
- `todos`
`.anthropic` and account credentials remain isolated.
## User Workflows
### New account with shared context
@@ -45,14 +57,15 @@ Rules:
```bash
ccs auth create work2 --share-context
ccs auth create backup --context-group sprint-a
ccs auth create backup2 --context-group sprint-a --deeper-continuity
```
### Existing account
- Open `ccs config`
- Go to `Accounts`
- Click the pencil icon (`Edit Context`)
- Choose `isolated` or `shared` and set group
- Click the pencil icon (`Edit History Sync`)
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
No account recreation required for this workflow.
@@ -62,9 +75,15 @@ No account recreation required for this workflow.
- Session continuity still depends on what the upstream tool/provider stores and allows.
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
## Alternative: CLIProxy Claude Pool
For users who prefer lower manual account switching, use CLIProxy Claude pool instead:
- Authenticate pool accounts via `ccs cliproxy auth claude`
- Manage account pool behavior in `ccs config` -> `CLIProxy Plus`
## Validation Checklist
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
- Switch between accounts in the same group and verify workspace continuity
- Run `ccs doctor` if symlink/context health looks inconsistent
+40 -4
View File
@@ -6,20 +6,24 @@
*/
export type AccountContextMode = 'isolated' | 'shared';
export type AccountContinuityMode = 'standard' | 'deeper';
export interface AccountContextMetadata {
context_mode?: AccountContextMode;
context_group?: string;
continuity_mode?: AccountContinuityMode;
}
export interface AccountContextPolicy {
mode: AccountContextMode;
group?: string;
continuityMode?: AccountContinuityMode;
}
export interface CreateAccountContextInput {
shareContext: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
}
export interface ResolvedCreateAccountContext {
@@ -29,6 +33,7 @@ export interface ResolvedCreateAccountContext {
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
export const MAX_CONTEXT_GROUP_LENGTH = 64;
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -66,11 +71,22 @@ export function isAccountContextMetadata(value: unknown): value is AccountContex
const candidate = value as Record<string, unknown>;
const mode = candidate['context_mode'];
const group = candidate['context_group'];
const continuity = candidate['continuity_mode'];
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
const groupValid = group === undefined || typeof group === 'string';
const continuityValid =
continuity === undefined || continuity === 'standard' || continuity === 'deeper';
return modeValid && groupValid;
if (!modeValid || !groupValid || !continuityValid) {
return false;
}
if (mode !== 'shared' && continuity !== undefined) {
return false;
}
return true;
}
/**
@@ -80,6 +96,15 @@ export function resolveCreateAccountContext(
input: CreateAccountContextInput
): ResolvedCreateAccountContext {
const hasGroupFlag = input.contextGroup !== undefined;
const continuityMode: AccountContinuityMode = input.deeperContinuity ? 'deeper' : 'standard';
if (input.deeperContinuity && !input.shareContext && !hasGroupFlag) {
return {
policy: { mode: 'isolated' },
error:
'Advanced deeper continuity requires shared context (--share-context or --context-group).',
};
}
if (hasGroupFlag) {
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
@@ -101,6 +126,7 @@ export function resolveCreateAccountContext(
policy: {
mode: 'shared',
group: normalizedGroup,
continuityMode,
},
};
}
@@ -110,6 +136,7 @@ export function resolveCreateAccountContext(
policy: {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
},
};
}
@@ -128,15 +155,21 @@ export function resolveAccountContextPolicy(
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const continuityMode: AccountContinuityMode =
metadata?.continuity_mode === 'deeper' ? 'deeper' : 'standard';
const rawGroup = metadata?.context_group;
if (rawGroup && rawGroup.trim().length > 0) {
const normalized = normalizeContextGroupName(rawGroup);
if (isValidContextGroupName(normalized)) {
return { mode: 'shared', group: normalized };
return { mode: 'shared', group: normalized, continuityMode };
}
}
return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP };
return {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
};
}
return { mode: 'isolated' };
@@ -152,6 +185,8 @@ export function policyToAccountContextMetadata(
return {
context_mode: 'shared',
context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuity_mode:
policy.continuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE,
};
}
@@ -165,7 +200,8 @@ export function policyToAccountContextMetadata(
*/
export function formatAccountContextPolicy(policy: AccountContextPolicy): string {
if (policy.mode === 'shared') {
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`;
const continuity = policy.continuityMode === 'deeper' ? 'deeper continuity' : 'standard';
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity})`;
}
return 'isolated';
+11
View File
@@ -86,6 +86,11 @@ class AuthCommands {
console.log(` ${dim('# Share context only within a specific group')}`);
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
console.log('');
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
console.log(
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
);
console.log('');
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
@@ -108,6 +113,9 @@ class AuthCommands {
console.log(
` ${color('--context-group <name>', 'command')} Share context only within a named group`
);
console.log(
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
);
console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
);
@@ -128,6 +136,9 @@ class AuthCommands {
console.log(
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
);
console.log(
` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.`
);
console.log(
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
);
+8 -4
View File
@@ -31,14 +31,15 @@ function sanitizeProfileNameForInstance(name: string): string {
*/
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } =
parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log(`Help: ${color('ccs auth --help', 'command')}`);
console.log('');
@@ -49,7 +50,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(fail('Profile name is required'));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log('');
console.log('Example:');
@@ -89,6 +90,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const resolvedContext = resolveCreateAccountContext({
shareContext: !!shareContext,
contextGroup,
deeperContinuity: !!deeperContinuity,
});
if (resolvedContext.error) {
@@ -212,7 +214,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log('');
const launchDescription =
contextPolicy.mode === 'shared'
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
? contextPolicy.continuityMode === 'deeper'
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}" (deeper continuity)...`
: `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
: 'Starting Claude in isolated instance...';
console.log(warn(launchDescription));
console.log(warn('You will be prompted to login with your account.'));
+3 -1
View File
@@ -32,6 +32,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
@@ -56,6 +57,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
};
}),
@@ -134,7 +136,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
console.log(
table(rows, {
head: headers,
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
})
);
console.log('');
+1
View File
@@ -60,6 +60,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
session_count: sessionCount,
};
+4
View File
@@ -21,6 +21,7 @@ export interface AuthCommandArgs {
yes?: boolean;
shareContext?: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
unknownFlags?: string[];
}
@@ -35,6 +36,7 @@ export interface ProfileOutput {
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
continuity_mode?: 'standard' | 'deeper' | null;
instance_path?: string;
session_count?: number;
}
@@ -70,6 +72,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
'--yes',
'-y',
'--share-context',
'--deeper-continuity',
]);
const knownValueFlags = new Set(['--context-group']);
@@ -121,6 +124,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
json: args.includes('--json'),
yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'),
deeperContinuity: args.includes('--deeper-continuity'),
contextGroup,
unknownFlags: [...unknownFlags],
};
+1
View File
@@ -202,6 +202,7 @@ class ProfileDetector {
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
},
};
}
+11
View File
@@ -23,6 +23,7 @@ import { isValidContextGroupName, normalizeContextGroupName } from './account-co
* last_used: <ISO timestamp or null> // Last usage time
* context_mode?: 'isolated' | 'shared' // Workspace context policy
* context_group?: <string> // Shared context group when mode=shared
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
* }
*
* Removed fields from v2.x:
@@ -43,6 +44,7 @@ interface CreateMetadata {
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
}
export class ProfileRegistry {
@@ -70,6 +72,7 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
@@ -77,6 +80,8 @@ export class ProfileRegistry {
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
@@ -87,6 +92,7 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
@@ -94,6 +100,8 @@ export class ProfileRegistry {
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
@@ -164,6 +172,7 @@ export class ProfileRegistry {
last_used: metadata.last_used || null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
// Note: No longer auto-set as default
@@ -311,6 +320,7 @@ export class ProfileRegistry {
last_used: null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
saveUnifiedConfig(config);
}
@@ -438,6 +448,7 @@ export class ProfileRegistry {
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
+6 -2
View File
@@ -150,11 +150,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['Run multiple Claude accounts concurrently'],
[
['ccs auth --help', 'Show account management commands'],
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
['ccs config', 'Dashboard: Accounts table can edit context mode/group'],
[
'ccs auth create <name>',
'Create account profile (supports shared groups + --deeper-continuity)',
],
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
['ccs auth list', 'List all account profiles'],
['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'],
]
);
+4
View File
@@ -151,7 +151,10 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
const metadata = meta as Record<string, unknown>;
const rawContextMode = metadata.context_mode;
const rawContextGroup = metadata.context_group;
const rawContinuityMode = metadata.continuity_mode;
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
const continuityMode =
contextMode === 'shared' && rawContinuityMode === 'deeper' ? 'deeper' : 'standard';
let contextGroup: string | undefined;
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
@@ -168,6 +171,7 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
last_used: (metadata.last_used as string) || null,
context_mode: contextMode,
context_group: contextMode === 'shared' ? contextGroup : undefined,
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
};
unifiedConfig.accounts[name] = account;
}
+2
View File
@@ -44,6 +44,8 @@ export interface AccountConfig {
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
}
/**
+1
View File
@@ -48,6 +48,7 @@ class InstanceManager {
// Apply context policy (isolated by default, optional shared group).
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
});
return instancePath;
+165
View File
@@ -27,6 +27,12 @@ class SharedManager {
private readonly claudeDir: string;
private readonly instancesDir: string;
private readonly sharedItems: SharedItem[];
private readonly advancedContinuityItems: readonly string[] = [
'session-env',
'file-history',
'shell-snapshots',
'todos',
];
constructor() {
this.homeDir = os.homedir();
@@ -314,6 +320,133 @@ class SharedManager {
await this.ensureDirectory(projectsPath);
}
/**
* Sync advanced continuity artifacts for shared deeper mode.
*
* - shared + deeper: artifacts are linked per context group.
* - shared + standard / isolated: artifacts stay local to instance.
*/
async syncAdvancedContinuityArtifacts(
instancePath: string,
policy: AccountContextPolicy
): Promise<void> {
const instanceName = path.basename(instancePath);
const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper';
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
for (const artifactName of this.advancedContinuityItems) {
const instanceArtifactPath = path.join(instancePath, artifactName);
if (useSharedContinuity) {
const sharedArtifactPath = path.join(
this.sharedDir,
'context-groups',
contextGroup,
'continuity',
artifactName
);
await this.ensureDirectory(sharedArtifactPath);
await this.ensureDirectory(path.dirname(instanceArtifactPath));
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) {
continue;
}
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
sharedArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(
`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`
)
);
}
await fs.promises.unlink(instanceArtifactPath);
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
await this.mergeDirectoryWithConflictCopies(
instanceArtifactPath,
sharedArtifactPath,
instanceName
);
await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.ensureDirectory(instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
continue;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
await fs.promises.unlink(instanceArtifactPath);
await this.ensureDirectory(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
instanceArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
);
}
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.ensureDirectory(instanceArtifactPath);
}
}
/**
* Ensure all project memory directories for an instance are shared.
*
@@ -712,6 +845,38 @@ class SharedManager {
);
}
/**
* Guard advanced continuity merge operations to known CCS-managed roots only.
*/
private isSafeContinuityMergeSource(
sourcePath: string,
instanceName: string,
artifactName: string
): boolean {
const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = this.resolveCanonicalPath(
path.join(this.sharedDir, 'context-groups')
);
const instanceArtifactRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, artifactName)
);
const normalizedSource =
process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource;
const continuitySegment =
process.platform === 'win32'
? `${path.sep}continuity${path.sep}`.toLowerCase()
: `${path.sep}continuity${path.sep}`;
const withinSharedContinuity =
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) &&
normalizedSource.includes(continuitySegment);
return (
withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot)
);
}
/**
* Link directory with Windows fallback to recursive copy.
*/
+2
View File
@@ -96,6 +96,8 @@ export interface ProfileMetadata {
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
}
export interface ProfilesRegistry {
@@ -7,7 +7,9 @@ export interface MergedAccountEntry {
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
context_inferred?: boolean;
continuity_inferred?: boolean;
provider?: string;
displayName?: string;
}
+39
View File
@@ -20,6 +20,7 @@ import {
} from '../../cliproxy/account-manager';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
isValidContextGroupName,
normalizeContextGroupName,
resolveAccountContextPolicy,
@@ -58,13 +59,18 @@ router.get('/', (_req: Request, res: Response): void => {
const contextPolicy = resolveAccountContextPolicy(meta);
const hasExplicitContextMode =
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
const hasExplicitContinuityMode =
meta.continuity_mode === 'standard' || meta.continuity_mode === 'deeper';
merged[name] = {
type: meta.type || 'account',
created: meta.created,
last_used: meta.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
context_inferred: !hasExplicitContextMode,
continuity_inferred:
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
};
}
@@ -73,13 +79,18 @@ router.get('/', (_req: Request, res: Response): void => {
const contextPolicy = resolveAccountContextPolicy(account);
const hasExplicitContextMode =
account.context_mode === 'isolated' || account.context_mode === 'shared';
const hasExplicitContinuityMode =
account.continuity_mode === 'standard' || account.continuity_mode === 'deeper';
merged[name] = {
type: 'account',
created: account.created,
last_used: account.last_used,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
context_inferred: !hasExplicitContextMode,
continuity_inferred:
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
};
}
@@ -189,6 +200,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
const mode = req.body?.context_mode;
const rawGroup = req.body?.context_group;
const rawContinuityMode = req.body?.continuity_mode;
if (mode !== 'isolated' && mode !== 'shared') {
res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|shared' });
@@ -202,7 +214,15 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
return;
}
if (mode !== 'shared' && rawContinuityMode !== undefined) {
res
.status(400)
.json({ error: 'Invalid payload: continuity_mode requires context_mode=shared' });
return;
}
let normalizedGroup: string | undefined;
let continuityMode: 'standard' | 'deeper' | undefined;
if (mode === 'shared') {
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
res
@@ -219,6 +239,19 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
});
return;
}
if (
rawContinuityMode !== undefined &&
rawContinuityMode !== 'standard' &&
rawContinuityMode !== 'deeper'
) {
res.status(400).json({
error: 'Invalid continuity_mode: expected standard|deeper',
});
return;
}
continuityMode = rawContinuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
}
const metadata =
@@ -226,6 +259,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
? {
context_mode: 'shared' as const,
context_group: normalizedGroup,
continuity_mode: continuityMode,
}
: {
context_mode: 'isolated' as const,
@@ -258,7 +292,12 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
name,
context_mode: policy.mode,
context_group: policy.group ?? null,
continuity_mode:
policy.mode === 'shared'
? (policy.continuityMode ?? DEFAULT_ACCOUNT_CONTINUITY_MODE)
: null,
context_inferred: false,
continuity_inferred: false,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
+23 -1
View File
@@ -18,7 +18,11 @@ import {
getBackupDirectories,
} from '../../config/migration-manager';
import { isUnifiedConfig } from '../../config/unified-config-types';
import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/account-context';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
isValidContextGroupName,
normalizeContextGroupName,
} from '../../auth/account-context';
const router = Router();
@@ -45,6 +49,7 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
const account = accountValue as Record<string, unknown>;
const mode = account.context_mode;
const group = account.context_group;
const continuity = account.continuity_mode;
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`;
@@ -54,10 +59,18 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
return `Invalid config.accounts.${accountName}.context_group: expected string`;
}
if (continuity !== undefined && continuity !== 'standard' && continuity !== 'deeper') {
return `Invalid config.accounts.${accountName}.continuity_mode: expected standard|deeper`;
}
if (mode !== 'shared' && group !== undefined) {
return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`;
}
if (mode !== 'shared' && continuity !== undefined) {
return `Invalid config.accounts.${accountName}: continuity_mode requires context_mode=shared`;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(group);
if (!isValidContextGroupName(normalizedGroup)) {
@@ -66,6 +79,11 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
account.context_group = normalizedGroup;
}
if (mode === 'shared') {
account.continuity_mode =
continuity === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) {
return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`;
}
@@ -73,6 +91,10 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
if (mode === 'isolated' && group !== undefined) {
delete account.context_group;
}
if (mode === 'isolated' && continuity !== undefined) {
delete account.continuity_mode;
}
}
return null;
+31
View File
@@ -51,4 +51,35 @@ describe('account context helpers', () => {
expect(result.policy.mode).toBe('shared');
expect(result.policy.group).toBe('team-alpha');
});
it('supports deeper continuity for shared create flows', () => {
const result = resolveCreateAccountContext({
shareContext: true,
deeperContinuity: true,
});
expect(result.error).toBeUndefined();
expect(result.policy.mode).toBe('shared');
expect(result.policy.continuityMode).toBe('deeper');
});
it('rejects deeper continuity without shared context flags', () => {
const result = resolveCreateAccountContext({
shareContext: false,
deeperContinuity: true,
});
expect(result.error).toContain('requires shared context');
});
it('defaults shared continuity mode to standard for legacy metadata', () => {
const resolved = resolveAccountContextPolicy({
context_mode: 'shared',
context_group: 'team-alpha',
});
expect(resolved.mode).toBe('shared');
expect(resolved.group).toBe('team-alpha');
expect(resolved.continuityMode).toBe('standard');
});
});
+8
View File
@@ -39,6 +39,14 @@ describe('auth command args parsing', () => {
expect(parsed.contextGroup).toBe('');
});
it('parses deeper continuity flag for create command', () => {
const parsed = parseArgs(['work', '--share-context', '--deeper-continuity']);
expect(parsed.profileName).toBe('work');
expect(parsed.shareContext).toBe(true);
expect(parsed.deeperContinuity).toBe(true);
});
it('tracks unknown flags and keeps positional profile intact', () => {
const parsed = parseArgs(['--foo', 'bar', 'work']);
+14 -2
View File
@@ -77,13 +77,19 @@ describe('auth list context metadata', () => {
}
const payload = JSON.parse(lines.join('\n')) as {
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
profiles: Array<{
name: string;
context_mode?: string;
context_group?: string | null;
continuity_mode?: string | null;
}>;
};
const work = payload.profiles.find((profile) => profile.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('sprint-a');
expect(work?.continuity_mode).toBe('standard');
});
it('prefers unified context metadata over legacy when profile names overlap', async () => {
@@ -151,12 +157,18 @@ describe('auth list context metadata', () => {
}
const payload = JSON.parse(lines.join('\n')) as {
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
profiles: Array<{
name: string;
context_mode?: string;
context_group?: string | null;
continuity_mode?: string | null;
}>;
};
const work = payload.profiles.find((profile) => profile.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('sprint-a');
expect(work?.continuity_mode).toBe('standard');
});
});
@@ -58,6 +58,7 @@ describe('profile-registry context normalization', () => {
expect(profile.context_mode).toBe('shared');
expect(profile.context_group).toBeUndefined();
expect(profile.continuity_mode).toBe('standard');
});
it('drops non-string unified context_group values without throwing', () => {
@@ -88,5 +89,6 @@ describe('profile-registry context normalization', () => {
expect(accounts.work.context_mode).toBe('shared');
expect(accounts.work.context_group).toBeUndefined();
expect(accounts.work.continuity_mode).toBe('standard');
});
});
@@ -166,8 +166,10 @@ describe('migration-manager legacy kimi compatibility', () => {
expect(unified).toBeTruthy();
expect(unified?.accounts.work.context_mode).toBe('shared');
expect(unified?.accounts.work.context_group).toBe('sprint-a');
expect(unified?.accounts.work.continuity_mode).toBe('standard');
expect(unified?.accounts.personal.context_mode).toBe('isolated');
expect(unified?.accounts.personal.context_group).toBeUndefined();
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
});
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
@@ -210,5 +212,7 @@ describe('migration-manager legacy kimi compatibility', () => {
expect(unified?.accounts.work.context_group).toBe('sprint-a');
expect(unified?.accounts.broken.context_mode).toBe('shared');
expect(unified?.accounts.broken.context_group).toBeUndefined();
expect(unified?.accounts.work.continuity_mode).toBe('standard');
expect(unified?.accounts.broken.continuity_mode).toBe('standard');
});
});
+70
View File
@@ -64,6 +64,20 @@ describe('SharedManager context policy', () => {
return { instancePath, ccsDir };
}
async function applyPolicyWithContinuity(
policy: AccountContextPolicy
): Promise<{ instancePath: string; ccsDir: string }> {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, policy);
await manager.syncAdvancedContinuityArtifacts(instancePath, policy);
return { instancePath, ccsDir };
}
it('keeps projects isolated by default', async () => {
const { instancePath } = await applyPolicy({ mode: 'isolated' });
const projectsPath = path.join(instancePath, 'projects');
@@ -141,6 +155,62 @@ describe('SharedManager context policy', () => {
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
});
it('links advanced continuity artifacts for shared deeper mode', async () => {
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
mode: 'shared',
group: 'sprint-a',
continuityMode: 'deeper',
});
const artifactPath = path.join(instancePath, 'session-env');
const targetFile = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'continuity',
'session-env',
'session.json'
);
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
fs.writeFileSync(targetFile, '{"id":"shared"}', 'utf8');
expect(fs.lstatSync(artifactPath).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(artifactPath, 'session.json'), 'utf8')).toContain('shared');
});
it('detaches advanced continuity artifacts when moving from deeper to standard shared mode', async () => {
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
mode: 'shared',
group: 'sprint-a',
continuityMode: 'deeper',
});
const sharedTodo = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'continuity',
'todos',
'todo.md'
);
fs.mkdirSync(path.dirname(sharedTodo), { recursive: true });
fs.writeFileSync(sharedTodo, '- shared todo', 'utf8');
const manager = new SharedManager();
await manager.syncAdvancedContinuityArtifacts(instancePath, {
mode: 'shared',
group: 'sprint-a',
continuityMode: 'standard',
});
const localTodoDir = path.join(instancePath, 'todos');
expect(fs.lstatSync(localTodoDir).isDirectory()).toBe(true);
expect(fs.readFileSync(path.join(localTodoDir, 'todo.md'), 'utf8')).toContain('shared todo');
});
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
@@ -108,6 +108,7 @@ describe('web-server account-routes context normalization', () => {
name: string;
context_mode?: string;
context_group?: string;
continuity_mode?: string;
context_inferred?: boolean;
}>;
}>(baseUrl, '/api/accounts');
@@ -117,6 +118,7 @@ describe('web-server account-routes context normalization', () => {
expect(work?.context_mode).toBe('isolated');
expect(work?.context_inferred).toBe(true);
expect(work && 'context_group' in work).toBe(false);
expect(work && 'continuity_mode' in work).toBe(false);
});
it('falls back shared accounts with invalid groups to default shared group', async () => {
@@ -147,7 +149,9 @@ describe('web-server account-routes context normalization', () => {
name: string;
context_mode?: string;
context_group?: string;
continuity_mode?: string;
context_inferred?: boolean;
continuity_inferred?: boolean;
}>;
}>(baseUrl, '/api/accounts');
@@ -155,7 +159,9 @@ describe('web-server account-routes context normalization', () => {
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('default');
expect(work?.continuity_mode).toBe('standard');
expect(work?.context_inferred).toBe(false);
expect(work?.continuity_inferred).toBe(true);
});
it('does not delete metadata when instance deletion fails', async () => {
@@ -220,23 +226,34 @@ describe('web-server account-routes context normalization', () => {
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
context_group: ' Team Alpha ',
continuity_mode: 'deeper',
});
expect(response.status).toBe(200);
const payload = (await response.json()) as {
context_mode: string;
context_group: string | null;
continuity_mode?: string | null;
context_inferred?: boolean;
continuity_inferred?: boolean;
};
expect(payload.context_mode).toBe('shared');
expect(payload.context_group).toBe('team-alpha');
expect(payload.continuity_mode).toBe('deeper');
expect(payload.context_inferred).toBe(false);
expect(payload.continuity_inferred).toBe(false);
const accountsPayload = await getJson<{
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
accounts: Array<{
name: string;
context_mode?: string;
context_group?: string;
continuity_mode?: string;
}>;
}>(baseUrl, '/api/accounts');
const work = accountsPayload.accounts.find((account) => account.name === 'work');
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('team-alpha');
expect(work?.continuity_mode).toBe('deeper');
});
it('rejects shared mode updates without context_group', async () => {
@@ -273,10 +290,43 @@ describe('web-server account-routes context normalization', () => {
const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', {
context_mode: 'shared',
context_group: 'default',
continuity_mode: 'deeper',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('CLIProxy');
});
it('rejects invalid continuity mode updates', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 8',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const response = await putJson(baseUrl, '/api/accounts/work/context', {
context_mode: 'shared',
context_group: 'default',
continuity_mode: 'extreme',
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('continuity_mode');
});
});
@@ -115,6 +115,47 @@ describe('web-server config-routes account context validation', () => {
expect(payload.error).toContain('context_group requires context_mode=shared');
});
it('rejects continuity_mode when mode is not shared', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'isolated',
continuity_mode: 'deeper',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('continuity_mode requires context_mode=shared');
});
it('rejects invalid shared continuity_mode values', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
accounts: {
work: {
created: '2026-01-01T00:00:00.000Z',
last_used: null,
context_mode: 'shared',
context_group: 'team-alpha',
continuity_mode: 'extreme',
},
},
profiles: {},
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
});
expect(response.status).toBe(400);
const payload = (await response.json()) as { error: string };
expect(payload.error).toContain('continuity_mode');
});
it('rejects invalid shared context_group names', async () => {
const response = await putJson(baseUrl, '/api/config', {
version: 8,
@@ -162,6 +203,7 @@ describe('web-server config-routes account context validation', () => {
last_used: null,
context_mode: 'shared',
context_group: 'Sprint-A',
continuity_mode: 'deeper',
};
const response = await putJson(baseUrl, '/api/config', config);
@@ -171,6 +213,7 @@ describe('web-server config-routes account context validation', () => {
const savedConfig = loadUnifiedConfig();
expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper');
});
it('returns alreadyMigrated when migration is not needed', async () => {
+14 -2
View File
@@ -90,7 +90,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
},
{
id: 'context',
header: 'Context',
header: 'History Sync',
size: 170,
cell: ({ row }) => {
if (row.original.type === 'cliproxy') {
@@ -100,7 +100,19 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
const mode = row.original.context_mode || 'isolated';
if (mode === 'shared') {
const group = row.original.context_group || 'default';
return <span className="text-muted-foreground">shared ({group})</span>;
if (row.original.continuity_mode === 'deeper') {
return <span className="text-muted-foreground">shared ({group}, deeper)</span>;
}
if (row.original.continuity_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">
shared ({group}, standard legacy)
</span>
);
}
return <span className="text-muted-foreground">shared ({group}, standard)</span>;
}
if (row.original.context_inferred) {
@@ -28,6 +28,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
const [profileName, setProfileName] = useState('');
const [shareContext, setShareContext] = useState(false);
const [contextGroup, setContextGroup] = useState('');
const [deeperContinuity, setDeeperContinuity] = useState(false);
const [copied, setCopied] = useState(false);
// Validate profile name: alphanumeric, dash, underscore only
@@ -47,6 +48,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
? `--context-group ${normalizedGroup}`
: '--share-context'
: '',
shareContext && deeperContinuity ? '--deeper-continuity' : '',
]
.filter(Boolean)
.join(' ')
@@ -63,6 +65,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
setProfileName('');
setShareContext(false);
setContextGroup('');
setDeeperContinuity(false);
setCopied(false);
onClose();
};
@@ -74,7 +77,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<DialogTitle>Create New Account</DialogTitle>
<DialogDescription>
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
edit context mode/group later from the Accounts table.
edit sync mode, group, and continuity depth later from the Accounts table.
</DialogDescription>
</DialogHeader>
@@ -104,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setShareContext(checked === true)}
/>
<Label htmlFor="share-context" className="cursor-pointer">
Share project context with other accounts
Enable shared history sync with other ccs auth accounts
</Label>
</div>
{shareContext && (
<div className="space-y-2 pl-6">
<Label htmlFor="context-group">Context Group (optional)</Label>
<Label htmlFor="context-group">History Sync Group (optional)</Label>
<Input
id="context-group"
value={contextGroup}
@@ -121,6 +124,20 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group. Spaces are normalized to dashes.
</p>
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="deeper-continuity"
checked={deeperContinuity}
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
/>
<Label htmlFor="deeper-continuity" className="cursor-pointer">
Advanced: deeper continuity mode
</Label>
</div>
<p className="text-xs text-muted-foreground">
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
Group must start with a letter and use only letters, numbers, dashes, or
@@ -158,6 +175,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<li>Complete the Claude login in your browser</li>
<li>Return here and refresh to see the new account</li>
</ol>
<p className="pt-1">
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
page action button.
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
@@ -21,6 +21,7 @@ import type { Account } from '@/lib/api-client';
import { useUpdateAccountContext } from '@/hooks/use-accounts';
type ContextMode = 'isolated' | 'shared';
type ContinuityMode = 'standard' | 'deeper';
const MAX_CONTEXT_GROUP_LENGTH = 64;
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -36,6 +37,9 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
account.context_mode === 'shared' ? 'shared' : 'isolated'
);
const [group, setGroup] = useState(account.context_group || 'default');
const [continuityMode, setContinuityMode] = useState<ContinuityMode>(
account.continuity_mode === 'deeper' ? 'deeper' : 'standard'
);
const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
const isSharedGroupValid =
@@ -54,6 +58,7 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
name: account.name,
context_mode: mode,
context_group: mode === 'shared' ? normalizedGroup : undefined,
continuity_mode: mode === 'shared' ? continuityMode : undefined,
},
{
onSuccess: () => {
@@ -73,34 +78,36 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Context Mode</DialogTitle>
<DialogTitle>Edit History Sync</DialogTitle>
<DialogDescription>
Configure how "{account.name}" shares project workspace context with other accounts.
Configure how "{account.name}" shares history and continuity with other
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="context-mode">Context Mode</Label>
<Label htmlFor="context-mode">Sync Mode</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder="Select context mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">isolated</SelectItem>
<SelectItem value="shared">shared</SelectItem>
<SelectItem value="isolated">isolated (no sync)</SelectItem>
<SelectItem value="shared">shared (sync enabled)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{mode === 'shared'
? 'Shared mode reuses workspace context for accounts in the same group.'
: 'Isolated mode keeps this account workspace context separate.'}
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
</p>
</div>
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="context-group">Context Group</Label>
<Label htmlFor="context-group">History Sync Group</Label>
<Input
id="context-group"
value={group}
@@ -120,8 +127,31 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
</div>
)}
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="continuity-mode">Continuity Depth</Label>
<Select
value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
>
<SelectTrigger id="continuity-mode">
<SelectValue placeholder="Select continuity depth" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">standard (projects only)</SelectItem>
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{continuityMode === 'deeper'
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.'
: 'Standard mode syncs project workspace context only.'}
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
Credentials stay isolated per account. Only project workspace context is shared.
Credentials and `.anthropic` remain isolated per account in all modes.
</p>
</div>
+22 -2
View File
@@ -13,7 +13,10 @@ export interface AuthAccountsView {
default: string | null;
cliproxyCount: number;
legacyContextCount: number;
legacyContinuityCount: number;
sharedCount: number;
sharedStandardCount: number;
deeperSharedCount: number;
isolatedCount: number;
}
@@ -27,8 +30,18 @@ export function useAccounts() {
const sharedCount = authAccounts.filter(
(account) => account.context_mode === 'shared'
).length;
const deeperSharedCount = authAccounts.filter(
(account) => account.context_mode === 'shared' && account.continuity_mode === 'deeper'
).length;
const sharedStandardCount = Math.max(sharedCount - deeperSharedCount, 0);
const isolatedCount = authAccounts.length - sharedCount;
const legacyContextCount = authAccounts.filter((account) => account.context_inferred).length;
const legacyContinuityCount = authAccounts.filter(
(account) =>
account.context_mode === 'shared' &&
account.continuity_mode !== 'deeper' &&
account.continuity_inferred
).length;
const defaultAccount = authAccounts.some((account) => account.name === data.default)
? data.default
: null;
@@ -38,7 +51,10 @@ export function useAccounts() {
default: defaultAccount,
cliproxyCount,
legacyContextCount,
legacyContinuityCount,
sharedCount,
sharedStandardCount,
deeperSharedCount,
isolatedCount,
};
},
@@ -98,16 +114,20 @@ export function useUpdateAccountContext() {
name,
context_mode,
context_group,
continuity_mode,
}: {
name: string;
context_mode: 'isolated' | 'shared';
context_group?: string;
}) => api.accounts.updateContext(name, { context_mode, context_group }),
continuity_mode?: 'standard' | 'deeper';
}) => api.accounts.updateContext(name, { context_mode, context_group, continuity_mode }),
onSuccess: (_data, vars) => {
queryClient.invalidateQueries({ queryKey: ['accounts'] });
const contextSummary =
vars.context_mode === 'shared'
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')})`
? vars.continuity_mode === 'deeper'
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)`
: `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)`
: 'isolated';
toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
},
+3
View File
@@ -455,7 +455,9 @@ export interface Account {
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
context_inferred?: boolean;
continuity_inferred?: boolean;
provider?: string;
displayName?: string;
}
@@ -463,6 +465,7 @@ export interface Account {
export interface UpdateAccountContext {
context_mode: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
}
// Unified config types
+83 -26
View File
@@ -5,7 +5,7 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowRight, Link2, Plus, Unlink, Users, Zap } from 'lucide-react';
import { ArrowRight, Link2, Plus, Unlink, Users, Waves, Zap } from 'lucide-react';
import { AccountsTable } from '@/components/account/accounts-table';
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
import { Button } from '@/components/ui/button';
@@ -21,7 +21,10 @@ export function AccountsPage() {
const authAccounts = data?.accounts || [];
const cliproxyCount = data?.cliproxyCount || 0;
const legacyContextCount = data?.legacyContextCount || 0;
const legacyContinuityCount = data?.legacyContinuityCount || 0;
const sharedCount = data?.sharedCount || 0;
const sharedStandardCount = data?.sharedStandardCount || 0;
const deeperSharedCount = data?.deeperSharedCount || 0;
const isolatedCount = data?.isolatedCount || 0;
return (
@@ -29,13 +32,13 @@ export function AccountsPage() {
<div className="rounded-xl border bg-gradient-to-br from-background via-background to-muted/40 p-5 sm:p-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<Badge variant="outline">CCS Auth Accounts</Badge>
<Badge variant="outline">ccs auth Continuity</Badge>
<h1 className="text-2xl font-bold tracking-tight">Accounts</h1>
<p className="text-sm text-muted-foreground max-w-2xl">
This page is dedicated to{' '}
<code className="rounded bg-muted px-1 py-0.5">ccs auth</code> accounts. Choose
isolated mode to keep context separate, or shared mode to link context across selected
accounts.
This page manages
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts only. Choose isolated, shared-standard, or shared-deeper continuity per
account.
</p>
</div>
@@ -44,17 +47,48 @@ export function AccountsPage() {
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
{cliproxyCount > 0 && (
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
Manage OAuth ({cliproxyCount})
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
)}
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
Open CLIProxy Claude Pool
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="grid gap-4 md:grid-cols-2">
<Card className="border-emerald-200/70 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
Lane A: ccs auth continuity
</CardDescription>
<CardTitle className="text-lg text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
<Users className="w-5 h-5" />
Profile isolation + opt-in sync
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Use this when you want per-account control over isolated vs shared history behavior.
</CardContent>
</Card>
<Card className="border-blue-200/70 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-blue-800/90 dark:text-blue-300">
Lane B: CLIProxy Claude pool
</CardDescription>
<CardTitle className="text-lg text-blue-700 dark:text-blue-300 flex items-center gap-2">
<Zap className="w-5 h-5" />
OAuth pool and lower manual switching
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
Use CLIProxy when you want pooled Claude OAuth accounts and easier account routing
behavior.
</CardContent>
</Card>
</div>
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Total Auth Accounts</CardDescription>
@@ -68,11 +102,23 @@ export function AccountsPage() {
<Card className="border-emerald-200/70 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
Shared (Linked)
Shared Standard
</CardDescription>
<CardTitle className="text-2xl font-mono text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
<Link2 className="w-5 h-5" />
{sharedCount}
{sharedStandardCount}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-indigo-200/70 bg-indigo-50/40 dark:border-indigo-900/40 dark:bg-indigo-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-indigo-800/90 dark:text-indigo-300">
Shared Deeper
</CardDescription>
<CardTitle className="text-2xl font-mono text-indigo-700 dark:text-indigo-300 flex items-center gap-2">
<Waves className="w-5 h-5" />
{deeperSharedCount}
</CardTitle>
</CardHeader>
</Card>
@@ -80,7 +126,7 @@ export function AccountsPage() {
<Card className="border-blue-200/70 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10">
<CardHeader className="pb-2">
<CardDescription className="text-blue-800/90 dark:text-blue-300">
Isolated (Separate)
Isolated
</CardDescription>
<CardTitle className="text-2xl font-mono text-blue-700 dark:text-blue-300 flex items-center gap-2">
<Unlink className="w-5 h-5" />
@@ -93,11 +139,10 @@ export function AccountsPage() {
{cliproxyCount > 0 && (
<Alert variant="info">
<Zap className="h-4 w-4" />
<AlertTitle>OAuth accounts are managed elsewhere</AlertTitle>
<AlertTitle>CLIProxy accounts are intentionally excluded from this table</AlertTitle>
<AlertDescription>
This screen hides {cliproxyCount} CLIProxy OAuth account
{cliproxyCount > 1 ? 's' : ''}. Use <strong>CLIProxy Plus</strong> to manage Gemini,
Codex, Antigravity, and other OAuth providers.
{cliproxyCount} CLIProxy OAuth account{cliproxyCount > 1 ? 's are' : ' is'} available.
Manage them in <strong>CLIProxy Plus</strong> to enable account pool usage.
</AlertDescription>
</Alert>
)}
@@ -105,21 +150,33 @@ export function AccountsPage() {
{legacyContextCount > 0 && (
<Alert variant="warning">
<Users className="h-4 w-4" />
<AlertTitle>Legacy accounts need context review</AlertTitle>
<AlertTitle>Legacy accounts need first-time sync mode review</AlertTitle>
<AlertDescription>
{legacyContextCount} account{legacyContextCount > 1 ? 's were' : ' was'} onboarded
before context controls and currently default to isolated mode. Use the pencil action in
the table to explicitly choose isolated or shared.
before explicit context controls. Use the pencil action to confirm isolated or shared
behavior.
</AlertDescription>
</Alert>
)}
{legacyContinuityCount > 0 && (
<Alert variant="warning">
<Waves className="h-4 w-4" />
<AlertTitle>Shared legacy accounts default to standard continuity</AlertTitle>
<AlertDescription>
{legacyContinuityCount} shared account
{legacyContinuityCount > 1 ? 's are' : ' is'} currently on legacy standard depth. Edit
and switch to deeper continuity only when you intentionally want broader history sync.
</AlertDescription>
</Alert>
)}
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-lg">CCS Auth Accounts</CardTitle>
<CardTitle className="text-lg">ccs auth Accounts</CardTitle>
<CardDescription>
New onboarding: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
Existing accounts: use the pencil action to control linkage flexibility per account.
Shared total: {sharedCount}. Create accounts here, then tune per-account sync mode and
continuity depth from the table.
</CardDescription>
</CardHeader>
<CardContent>