diff --git a/README.md b/README.md index 8b4588e8..80c78858 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index d138f34b..ae78f67a 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -203,15 +203,16 @@ src/ ### Account Context Metadata Flow -- Source fields: `accounts..context_mode` and `accounts..context_group` in `~/.ccs/config.yaml`. +- Source fields: `accounts..context_mode`, `accounts..context_group`, `accounts..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 diff --git a/docs/dashboard-auth-cli.md b/docs/dashboard-auth-cli.md index b810656f..1449e0ec 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -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 diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md index 93c8b466..9e4b9a68 100644 --- a/docs/session-sharing-technical-analysis.md +++ b/docs/session-sharing-technical-analysis.md @@ -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 ()` in Dashboard Accounts table - Switch between accounts in the same group and verify workspace continuity - Run `ccs doctor` if symlink/context health looks inconsistent - diff --git a/src/auth/account-context.ts b/src/auth/account-context.ts index c30a0f7c..a8c28b50 100644 --- a/src/auth/account-context.ts +++ b/src/auth/account-context.ts @@ -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; 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'; diff --git a/src/auth/auth-commands.ts b/src/auth/auth-commands.ts index c94a91c5..86a3c710 100644 --- a/src/auth/auth-commands.ts +++ b/src/auth/auth-commands.ts @@ -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 ', '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.` ); diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index 8c169c1d..93550a2e 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -31,14 +31,15 @@ function sanitizeProfileNameForInstance(name: string): string { */ export async function handleCreate(ctx: CommandContext, args: string[]): Promise { 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 [--force] [--share-context] [--context-group ]', 'command')}` + `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ] [--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 [--force] [--share-context] [--context-group ]', 'command')}` + `Usage: ${color('ccs auth create [--force] [--share-context] [--context-group ] [--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.')); diff --git a/src/auth/commands/list-command.ts b/src/auth/commands/list-command.ts index a248c2ee..f25ab770 100644 --- a/src/auth/commands/list-command.ts +++ b/src/auth/commands/list-command.ts @@ -32,6 +32,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise // Last usage time * context_mode?: 'isolated' | 'shared' // Workspace context policy * context_group?: // 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, }; } diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 461ea979..9c8561b5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 ', 'Create account profile (supports context sharing flags)'], - ['ccs config', 'Dashboard: Accounts table can edit context mode/group'], + [ + 'ccs auth create ', + '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 ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], + ['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'], ] ); diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index cf9628d2..38659b74 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -151,7 +151,10 @@ export async function migrate(dryRun = false): Promise { const metadata = meta as Record; 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 { 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; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index f668cf17..287cba72 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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'; } /** diff --git a/src/management/instance-manager.ts b/src/management/instance-manager.ts index c0142f06..8f49cf74 100644 --- a/src/management/instance-manager.ts +++ b/src/management/instance-manager.ts @@ -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; diff --git a/src/management/shared-manager.ts b/src/management/shared-manager.ts index 4fc0e394..9a010edd 100644 --- a/src/management/shared-manager.ts +++ b/src/management/shared-manager.ts @@ -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 { + 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. */ diff --git a/src/types/config.ts b/src/types/config.ts index c385eef9..bd3787ae 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -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 { diff --git a/src/web-server/routes/account-route-helpers.ts b/src/web-server/routes/account-route-helpers.ts index d8c2e9cc..bbf05469 100644 --- a/src/web-server/routes/account-route-helpers.ts +++ b/src/web-server/routes/account-route-helpers.ts @@ -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; } diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index fdf1610a..d96009d4 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -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 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 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 }); 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 ? { 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 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 }); diff --git a/src/web-server/routes/config-routes.ts b/src/web-server/routes/config-routes.ts index 31a25cc7..7b3ed8fd 100644 --- a/src/web-server/routes/config-routes.ts +++ b/src/web-server/routes/config-routes.ts @@ -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; 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; diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts index 2c8f658f..ecb21c74 100644 --- a/tests/unit/account-context.test.ts +++ b/tests/unit/account-context.test.ts @@ -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'); + }); }); diff --git a/tests/unit/auth-command-args.test.ts b/tests/unit/auth-command-args.test.ts index e6ba81e7..8811f4d6 100644 --- a/tests/unit/auth-command-args.test.ts +++ b/tests/unit/auth-command-args.test.ts @@ -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']); diff --git a/tests/unit/auth-list-context.test.ts b/tests/unit/auth-list-context.test.ts index b62cb798..adcb7ba8 100644 --- a/tests/unit/auth-list-context.test.ts +++ b/tests/unit/auth-list-context.test.ts @@ -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'); }); }); diff --git a/tests/unit/auth/profile-registry-context-normalization.test.ts b/tests/unit/auth/profile-registry-context-normalization.test.ts index 95882b35..8fb895aa 100644 --- a/tests/unit/auth/profile-registry-context-normalization.test.ts +++ b/tests/unit/auth/profile-registry-context-normalization.test.ts @@ -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'); }); }); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts index 1e09fcaf..c05cdb51 100644 --- a/tests/unit/config/migration-manager.test.ts +++ b/tests/unit/config/migration-manager.test.ts @@ -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'); }); }); diff --git a/tests/unit/shared-context-policy.test.ts b/tests/unit/shared-context-policy.test.ts index b882b5d5..7b724f14 100644 --- a/tests/unit/shared-context-policy.test.ts +++ b/tests/unit/shared-context-policy.test.ts @@ -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'); diff --git a/tests/unit/web-server/account-routes-context.test.ts b/tests/unit/web-server/account-routes-context.test.ts index 4f9efb68..8e35607c 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -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'); + }); }); diff --git a/tests/unit/web-server/config-routes-account-context.test.ts b/tests/unit/web-server/config-routes-account-context.test.ts index a1bcb728..f24328ff 100644 --- a/tests/unit/web-server/config-routes-account-context.test.ts +++ b/tests/unit/web-server/config-routes-account-context.test.ts @@ -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 () => { diff --git a/ui/src/components/account/accounts-table.tsx b/ui/src/components/account/accounts-table.tsx index 7c49ba5d..d1b7232c 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -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 shared ({group}); + if (row.original.continuity_mode === 'deeper') { + return shared ({group}, deeper); + } + + if (row.original.continuity_inferred) { + return ( + + shared ({group}, standard legacy) + + ); + } + + return shared ({group}, standard); } if (row.original.context_inferred) { diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx index 9a04b4cd..1c6cd3ec 100644 --- a/ui/src/components/account/create-auth-profile-dialog.tsx +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -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 Create New Account 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. @@ -104,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial onCheckedChange={(checked) => setShareContext(checked === true)} /> {shareContext && (
- + Leave empty to use the default shared group. Spaces are normalized to dashes.

+
+ setDeeperContinuity(checked === true)} + /> + +
+

+ Adds sync for session-env, file-history,{' '} + shell-snapshots, and todos. Credentials stay isolated. +

{contextGroup.trim().length > 0 && !isValidContextGroup && (

Group must start with a letter and use only letters, numbers, dashes, or @@ -158,6 +175,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial

  • Complete the Claude login in your browser
  • Return here and refresh to see the new account
  • +

    + Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts + page action button. +

    diff --git a/ui/src/components/account/edit-account-context-dialog.tsx b/ui/src/components/account/edit-account-context-dialog.tsx index cd6230c2..4edf98ad 100644 --- a/ui/src/components/account/edit-account-context-dialog.tsx +++ b/ui/src/components/account/edit-account-context-dialog.tsx @@ -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( + 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 - Edit Context Mode + Edit History Sync - Configure how "{account.name}" shares project workspace context with other accounts. + Configure how "{account.name}" shares history and continuity with other + ccs auth + accounts.
    - +

    {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.'}

    {mode === 'shared' && (
    - + )} + {mode === 'shared' && ( +
    + + +

    + {continuityMode === 'deeper' + ? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.' + : 'Standard mode syncs project workspace context only.'} +

    +
    + )} +

    - Credentials stay isolated per account. Only project workspace context is shared. + Credentials and `.anthropic` remain isolated per account in all modes.

    diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index 82684e3e..8caff09c 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -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}`); }, diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index f45600d6..5f581f2f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -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 diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index 9bde14fe..74d322a0 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -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() {
    - CCS Auth Accounts + ccs auth Continuity

    Accounts

    - This page is dedicated to{' '} - ccs auth accounts. Choose - isolated mode to keep context separate, or shared mode to link context across selected - accounts. + This page manages + ccs auth + accounts only. Choose isolated, shared-standard, or shared-deeper continuity per + account.

    @@ -44,17 +47,48 @@ export function AccountsPage() { Create Account - {cliproxyCount > 0 && ( - - )} +
    -
    +
    + + + + Lane A: ccs auth continuity + + + + Profile isolation + opt-in sync + + + + Use this when you want per-account control over isolated vs shared history behavior. + + + + + + + Lane B: CLIProxy Claude pool + + + + OAuth pool and lower manual switching + + + + Use CLIProxy when you want pooled Claude OAuth accounts and easier account routing + behavior. + + +
    + +
    Total Auth Accounts @@ -68,11 +102,23 @@ export function AccountsPage() { - Shared (Linked) + Shared Standard - {sharedCount} + {sharedStandardCount} + + + + + + + + Shared Deeper + + + + {deeperSharedCount} @@ -80,7 +126,7 @@ export function AccountsPage() { - Isolated (Separate) + Isolated @@ -93,11 +139,10 @@ export function AccountsPage() { {cliproxyCount > 0 && ( - OAuth accounts are managed elsewhere + CLIProxy accounts are intentionally excluded from this table - This screen hides {cliproxyCount} CLIProxy OAuth account - {cliproxyCount > 1 ? 's' : ''}. Use CLIProxy Plus to manage Gemini, - Codex, Antigravity, and other OAuth providers. + {cliproxyCount} CLIProxy OAuth account{cliproxyCount > 1 ? 's are' : ' is'} available. + Manage them in CLIProxy Plus to enable account pool usage. )} @@ -105,21 +150,33 @@ export function AccountsPage() { {legacyContextCount > 0 && ( - Legacy accounts need context review + Legacy accounts need first-time sync mode review {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. + + + )} + + {legacyContinuityCount > 0 && ( + + + Shared legacy accounts default to standard continuity + + {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. )} - CCS Auth Accounts + ccs auth Accounts - New onboarding: Create Account. - 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.