diff --git a/README.md b/README.md index 6a91a87a..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,8 +282,17 @@ 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 + continuity depth + Shared mode metadata in `~/.ccs/config.yaml`: ```yaml @@ -288,6 +302,7 @@ accounts: last_used: null context_mode: "shared" context_group: "team-alpha" + continuity_mode: "standard" ``` `context_group` rules: @@ -298,7 +313,13 @@ 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 85d1a7ed..1449e0ec 100644 --- a/docs/dashboard-auth-cli.md +++ b/docs/dashboard-auth-cli.md @@ -1,6 +1,6 @@ # Dashboard Authentication CLI -Last Updated: 2026-02-24 +Last Updated: 2026-02-26 CLI commands for managing CCS dashboard authentication. @@ -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,13 +36,23 @@ 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/continuity for existing auth accounts +- rejects CLIProxy OAuth account keys for this route +- applies normalization/validation rules above ## Commands diff --git a/docs/session-sharing-technical-analysis.md b/docs/session-sharing-technical-analysis.md new file mode 100644 index 00000000..9e4b9a68 --- /dev/null +++ b/docs/session-sharing-technical-analysis.md @@ -0,0 +1,89 @@ +# Session Sharing Technical Analysis + +Last Updated: 2026-02-26 + +## Summary + +CCS supports practical cross-account continuity by sharing workspace context files between selected accounts, while keeping credentials isolated per account. + +This is implemented as a context policy per account: + +- `isolated` (default): account keeps its own workspace context +- `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 + +CCS only shares workspace context paths (project/session context files). It does **not** merge or copy authentication credentials between accounts. + +Credential storage remains per account instance. + +## Implementation Model + +Account metadata is stored in `~/.ccs/config.yaml`: + +```yaml +accounts: + work: + created: "2026-02-24T00:00:00.000Z" + 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 + +```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 History Sync`) +- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity + +No account recreation required for this workflow. + +## Current Limitations + +- Shared context is local filesystem sharing. It does not bypass remote provider permission models. +- 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 2ed849ee..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,12 @@ 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.` + ); console.log(` Shared context groups are normalized (trim + lowercase) and spaces become "-".`); console.log( ` ${color('context_group', 'path')} must be non-empty and <= ${MAX_CONTEXT_GROUP_LENGTH} chars in shared mode.` 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 1db7717c..9c8561b5 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -150,10 +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 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 645262be..bbf05469 100644 --- a/src/web-server/routes/account-route-helpers.ts +++ b/src/web-server/routes/account-route-helpers.ts @@ -7,6 +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 b6ebc359..d96009d4 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -19,7 +19,12 @@ import { soloAccount, } from '../../cliproxy/account-manager'; import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; -import { resolveAccountContextPolicy } from '../../auth/account-context'; +import { + DEFAULT_ACCOUNT_CONTINUITY_MODE, + isValidContextGroupName, + normalizeContextGroupName, + resolveAccountContextPolicy, +} from '../../auth/account-context'; import { buildCliproxyAccountKey, parseCliproxyKey, @@ -52,24 +57,40 @@ router.get('/', (_req: Request, res: Response): void => { // Add legacy profiles first for (const [name, meta] of Object.entries(legacyProfiles)) { 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, }; } // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { 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, }; } @@ -149,6 +170,140 @@ router.post('/default', (req: Request, res: Response): void => { } }); +/** + * PUT /api/accounts/:name/context - Update account context mode/group + */ +router.put('/:name/context', async (req: Request, res: Response): Promise => { + try { + const { name } = req.params; + + if (!name) { + res.status(400).json({ error: 'Missing account name' }); + return; + } + + // CLIProxy OAuth accounts do not support local account context metadata. + const cliproxyKey = !hasAuthAccount(name) ? parseCliproxyKey(name) : null; + if (cliproxyKey) { + res + .status(400) + .json({ error: `Context mode is not supported for CLIProxy account: ${name}` }); + return; + } + + const existsUnified = isUnifiedMode() && registry.hasAccountUnified(name); + const existsLegacy = registry.hasProfile(name); + if (!existsUnified && !existsLegacy) { + res.status(404).json({ error: `Account not found: ${name}` }); + return; + } + + 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' }); + return; + } + + if (mode !== 'shared' && rawGroup !== undefined) { + res + .status(400) + .json({ error: 'Invalid payload: context_group requires context_mode=shared' }); + 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 + .status(400) + .json({ error: 'Invalid payload: shared context_mode requires non-empty context_group' }); + return; + } + + normalizedGroup = normalizeContextGroupName(rawGroup); + if (!isValidContextGroupName(normalizedGroup)) { + res.status(400).json({ + error: + 'Invalid context_group. Use letters/numbers/dash/underscore, start with a letter, max 64 chars.', + }); + 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 = + mode === 'shared' + ? { + context_mode: 'shared' as const, + context_group: normalizedGroup, + continuity_mode: continuityMode, + } + : { + context_mode: 'isolated' as const, + }; + const policy = resolveAccountContextPolicy(metadata); + + const previousUnified = existsUnified ? registry.getAllAccountsUnified()[name] : undefined; + const previousLegacy = existsLegacy ? registry.getProfile(name) : undefined; + + try { + if (existsUnified) { + registry.updateAccountUnified(name, metadata); + } + if (existsLegacy) { + registry.updateProfile(name, metadata); + } + + await instanceMgr.ensureInstance(name, policy); + } catch (error) { + if (existsUnified && previousUnified) { + registry.updateAccountUnified(name, previousUnified); + } + if (existsLegacy && previousLegacy) { + registry.updateProfile(name, previousLegacy); + } + throw error; + } + + res.json({ + 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 }); + } +}); + /** * DELETE /api/accounts/reset-default - Reset to CCS default */ 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 c3b27999..8e35607c 100644 --- a/tests/unit/web-server/account-routes-context.test.ts +++ b/tests/unit/web-server/account-routes-context.test.ts @@ -18,6 +18,14 @@ async function deletePath(baseUrl: string, routePath: string): Promise return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' }); } +async function putJson(baseUrl: string, routePath: string, body: unknown): Promise { + return fetch(`${baseUrl}${routePath}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); +} + describe('web-server account-routes context normalization', () => { let server: Server; let baseUrl = ''; @@ -27,6 +35,7 @@ describe('web-server account-routes context normalization', () => { beforeAll(async () => { const app = express(); + app.use(express.json()); app.use('/api/accounts', accountRoutes); await new Promise((resolve, reject) => { @@ -95,13 +104,21 @@ describe('web-server account-routes context normalization', () => { ); const payload = 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; + context_inferred?: boolean; + }>; }>(baseUrl, '/api/accounts'); const work = payload.accounts.find((account) => account.name === 'work'); expect(work).toBeTruthy(); 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 () => { @@ -128,13 +145,23 @@ describe('web-server account-routes context normalization', () => { ); const payload = 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; + context_inferred?: boolean; + continuity_inferred?: boolean; + }>; }>(baseUrl, '/api/accounts'); const work = payload.accounts.find((account) => account.name === 'work'); 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 () => { @@ -173,4 +200,133 @@ describe('web-server account-routes context normalization', () => { InstanceManager.prototype.deleteInstance = originalDeleteInstance; } }); + + it('updates existing account context metadata and normalizes shared group', 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', + ' context_mode: isolated', + 'profiles: {}', + 'cliproxy:', + ' oauth_accounts: {}', + ' providers: {}', + ' variants: {}', + ].join('\n'), + 'utf8' + ); + + 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; + 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 () => { + 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', + }); + expect(response.status).toBe(400); + + const payload = (await response.json()) as { error: string }; + expect(payload.error).toContain('context_group'); + }); + + it('rejects context updates for CLIProxy account identifiers', async () => { + 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 baaddc25..37abf24e 100644 --- a/ui/src/components/account/accounts-table.tsx +++ b/ui/src/components/account/accounts-table.tsx @@ -24,25 +24,28 @@ import { AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; -import { Check, Trash2, RotateCcw } from 'lucide-react'; +import { Check, CheckCheck, Pencil, RotateCcw, Trash2 } from 'lucide-react'; +import { EditAccountContextDialog } from '@/components/account/edit-account-context-dialog'; import { useSetDefaultAccount, useDeleteAccount, useResetDefaultAccount, + useUpdateAccountContext, } from '@/hooks/use-accounts'; import type { Account } from '@/lib/api-client'; interface AccountsTableProps { data: Account[]; defaultAccount: string | null; - onRefresh?: () => void; } export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { const setDefaultMutation = useSetDefaultAccount(); const deleteMutation = useDeleteAccount(); const resetDefaultMutation = useResetDefaultAccount(); + const updateContextMutation = useUpdateAccountContext(); const [deleteTarget, setDeleteTarget] = useState(null); + const [contextTarget, setContextTarget] = useState(null); const columns: ColumnDef[] = [ { @@ -89,7 +92,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { }, { id: 'context', - header: 'Context', + header: 'History Sync', size: 170, cell: ({ row }) => { if (row.original.type === 'cliproxy') { @@ -99,7 +102,25 @@ 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) { + return ( + isolated (legacy default) + ); } return isolated; @@ -108,13 +129,60 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { { id: 'actions', header: 'Actions', - size: 180, + size: 220, cell: ({ row }) => { const isDefault = row.original.name === defaultAccount; - const isPending = setDefaultMutation.isPending || deleteMutation.isPending; + const isPending = + setDefaultMutation.isPending || + deleteMutation.isPending || + updateContextMutation.isPending; + const isCliproxy = row.original.type === 'cliproxy'; + const hasLegacyInference = + row.original.context_inferred || row.original.continuity_inferred; return (
+ {!isCliproxy && ( + + )} + {!isCliproxy && hasLegacyInference && ( + + )}
); @@ -173,7 +241,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { created: 'w-[150px]', last_used: 'w-[150px]', context: 'w-[170px]', - actions: 'w-[180px]', + actions: 'w-[290px]', }[header.id] || 'w-auto'; return ( @@ -217,6 +285,10 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) { )} + {contextTarget && ( + setContextTarget(null)} /> + )} + {/* Delete confirmation dialog */} !open && setDeleteTarget(null)}> diff --git a/ui/src/components/account/create-auth-profile-dialog.tsx b/ui/src/components/account/create-auth-profile-dialog.tsx index 51b7b997..1c6cd3ec 100644 --- a/ui/src/components/account/create-auth-profile-dialog.tsx +++ b/ui/src/components/account/create-auth-profile-dialog.tsx @@ -28,11 +28,12 @@ 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 const isValidName = /^[a-zA-Z][a-zA-Z0-9_-]*$/.test(profileName); - const normalizedGroup = contextGroup.trim().toLowerCase(); + const normalizedGroup = contextGroup.trim().toLowerCase().replace(/\s+/g, '-'); const isValidContextGroup = normalizedGroup.length === 0 || (normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH && @@ -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(); }; @@ -73,7 +76,8 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial Create New Account - Auth profiles require Claude CLI login. Run the command below in your terminal. + Auth profiles require Claude CLI login. Run the command below in your terminal. You can + edit sync mode, group, and continuity depth later from the Accounts table. @@ -103,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial onCheckedChange={(checked) => setShareContext(checked === true)} /> {shareContext && (
- +

- Leave empty to use the default shared group. + 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 && (

@@ -157,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 new file mode 100644 index 00000000..4edf98ad --- /dev/null +++ b/ui/src/components/account/edit-account-context-dialog.tsx @@ -0,0 +1,169 @@ +import { useMemo, useState } from 'react'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +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_-]*$/; + +interface EditAccountContextDialogProps { + account: Account; + onClose: () => void; +} + +export function EditAccountContextDialog({ account, onClose }: EditAccountContextDialogProps) { + const updateContextMutation = useUpdateAccountContext(); + const [mode, setMode] = useState( + 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 = + normalizedGroup.length > 0 && + normalizedGroup.length <= MAX_CONTEXT_GROUP_LENGTH && + CONTEXT_GROUP_PATTERN.test(normalizedGroup); + const canSubmit = mode === 'isolated' || isSharedGroupValid; + + const handleSave = () => { + if (!canSubmit) { + return; + } + + updateContextMutation.mutate( + { + name: account.name, + context_mode: mode, + context_group: mode === 'shared' ? normalizedGroup : undefined, + continuity_mode: mode === 'shared' ? continuityMode : undefined, + }, + { + onSuccess: () => { + onClose(); + }, + } + ); + }; + + const handleOpenChange = (nextOpen: boolean) => { + if (!nextOpen) { + onClose(); + } + }; + + return ( + + + + Edit History Sync + + 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 history sync group.' + : 'Isolated mode keeps this account fully separate from other ccs auth accounts.'} +

    +
    + + {mode === 'shared' && ( +
    + + setGroup(event.target.value)} + placeholder="default" + autoComplete="off" + /> +

    + Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-` + (max {MAX_CONTEXT_GROUP_LENGTH} chars). +

    + {!isSharedGroupValid && ( +

    + Enter a valid group name that starts with a letter. +

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

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

    +
    + )} + +

    + Credentials and `.anthropic` remain isolated per account in all modes. +

    +
    + + + + + +
    +
    + ); +} diff --git a/ui/src/components/account/history-sync-learning-map.tsx b/ui/src/components/account/history-sync-learning-map.tsx new file mode 100644 index 00000000..7c7b2f94 --- /dev/null +++ b/ui/src/components/account/history-sync-learning-map.tsx @@ -0,0 +1,168 @@ +import { useState } from 'react'; +import { + ArrowRight, + ArrowRightLeft, + ChevronDown, + Layers3, + Link2, + Unlink, + Waves, + type LucideIcon, +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { cn } from '@/lib/utils'; + +interface HistorySyncLearningMapProps { + isolatedCount: number; + sharedStandardCount: number; + deeperSharedCount: number; + sharedGroups: string[]; + legacyTargetCount: number; + cliproxyCount: number; +} + +type StageTone = 'isolated' | 'shared' | 'deeper'; + +function StageTile({ + title, + count, + icon: Icon, + tone, +}: { + title: string; + count: number; + icon: LucideIcon; + tone: StageTone; +}) { + const toneClasses: Record = { + isolated: { + border: 'border-blue-300/60 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10', + icon: 'text-blue-700 dark:text-blue-400', + count: 'text-blue-700 dark:text-blue-400', + }, + shared: { + border: + 'border-emerald-300/60 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10', + icon: 'text-emerald-700 dark:text-emerald-400', + count: 'text-emerald-700 dark:text-emerald-400', + }, + deeper: { + border: + 'border-indigo-300/60 bg-indigo-50/40 dark:border-indigo-900/40 dark:bg-indigo-900/10', + icon: 'text-indigo-700 dark:text-indigo-400', + count: 'text-indigo-700 dark:text-indigo-400', + }, + }; + + return ( +
    +
    +

    {title}

    + +
    +

    {count}

    +
    + ); +} + +export function HistorySyncLearningMap({ + isolatedCount, + sharedStandardCount, + deeperSharedCount, + sharedGroups, + legacyTargetCount, + cliproxyCount, +}: HistorySyncLearningMapProps) { + const [open, setOpen] = useState(false); + const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default']; + + return ( + + +
    +
    + How History Sync Works + + Isolated -> Shared -> Deeper. Use Sync per row for all changes. + +
    + Learning Map +
    +
    + + + {cliproxyCount > 0 && ( +
    + {cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'} + managed in Action Center / CLIProxy page. +
    + )} + +
    + +
    + +
    + +
    + +
    + +
    + + + + + + +
    +
    +
    + +

    Mode Switch

    +
    +

    + Sync dialog lets users move between isolated/shared and choose deeper continuity. +

    +
    + +
    +
    + +

    History Sync Group

    +
    +

    + Same group means shared project context lane. Default fallback is{' '} + default. +

    +
    + {groupsToShow.map((group) => ( + + {group} + + ))} +
    +
    +
    + + {legacyTargetCount > 0 && ( +
    + {legacyTargetCount} legacy account + {legacyTargetCount > 1 ? 's still need' : ' still needs'} explicit confirmation. +
    + )} +
    +
    +
    +
    + ); +} diff --git a/ui/src/components/account/index.ts b/ui/src/components/account/index.ts index 0b75ccb8..fe6da6ea 100644 --- a/ui/src/components/account/index.ts +++ b/ui/src/components/account/index.ts @@ -6,6 +6,7 @@ export { AccountsTable } from './accounts-table'; export { AddAccountDialog } from './add-account-dialog'; export { CreateAuthProfileDialog } from './create-auth-profile-dialog'; +export { EditAccountContextDialog } from './edit-account-context-dialog'; // Flow visualization (from subdirectory) export { AccountFlowViz } from './flow-viz'; diff --git a/ui/src/hooks/use-accounts.ts b/ui/src/hooks/use-accounts.ts index b46691af..282d1aa3 100644 --- a/ui/src/hooks/use-accounts.ts +++ b/ui/src/hooks/use-accounts.ts @@ -1,16 +1,63 @@ /** - * React Query hooks for accounts (profiles.json) + * React Query hooks for account management * Dashboard parity: Full CRUD operations for auth profiles */ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { api } from '@/lib/api-client'; +import type { Account } from '@/lib/api-client'; import { toast } from 'sonner'; +export interface AuthAccountsView { + accounts: Account[]; + default: string | null; + cliproxyCount: number; + legacyContextCount: number; + legacyContinuityCount: number; + sharedCount: number; + sharedStandardCount: number; + deeperSharedCount: number; + isolatedCount: number; +} + export function useAccounts() { return useQuery({ queryKey: ['accounts'], queryFn: () => api.accounts.list(), + select: (data): AuthAccountsView => { + const authAccounts = data.accounts.filter((account) => account.type !== 'cliproxy'); + const cliproxyCount = data.accounts.length - authAccounts.length; + 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; + + return { + accounts: authAccounts, + default: defaultAccount, + cliproxyCount, + legacyContextCount, + legacyContinuityCount, + sharedCount, + sharedStandardCount, + deeperSharedCount, + isolatedCount, + }; + }, }); } @@ -58,3 +105,75 @@ export function useDeleteAccount() { }, }); } + +export function useUpdateAccountContext() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + name, + context_mode, + context_group, + continuity_mode, + }: { + name: string; + context_mode: 'isolated' | 'shared'; + context_group?: string; + 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' + ? 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}`); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +export function useConfirmLegacyAccountPolicies() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (accounts: Account[]) => { + const legacyTargets = accounts.filter( + (account) => account.context_inferred || account.continuity_inferred + ); + + for (const account of legacyTargets) { + const isShared = account.context_mode === 'shared'; + await api.accounts.updateContext(account.name, { + context_mode: isShared ? 'shared' : 'isolated', + context_group: isShared ? account.context_group || 'default' : undefined, + continuity_mode: isShared + ? account.continuity_mode === 'deeper' + ? 'deeper' + : 'standard' + : undefined, + }); + } + + return { updatedCount: legacyTargets.length }; + }, + onSuccess: ({ updatedCount }) => { + queryClient.invalidateQueries({ queryKey: ['accounts'] }); + if (updatedCount > 0) { + toast.success( + `Confirmed explicit sync mode for ${updatedCount} legacy account${updatedCount > 1 ? 's' : ''}` + ); + return; + } + + toast.info('No legacy accounts need confirmation'); + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 12ec590e..5f581f2f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -455,6 +455,17 @@ 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; +} + +export interface UpdateAccountContext { + context_mode: 'isolated' | 'shared'; + context_group?: string; + continuity_mode?: 'standard' | 'deeper'; } // Unified config types @@ -785,6 +796,11 @@ export const api = { }), resetDefault: () => request('/accounts/reset-default', { method: 'DELETE' }), delete: (name: string) => request(`/accounts/${name}`, { method: 'DELETE' }), + updateContext: (name: string, data: UpdateAccountContext) => + request(`/accounts/${encodeURIComponent(name)}/context`, { + method: 'PUT', + body: JSON.stringify(data), + }), }, // Unified config API config: { diff --git a/ui/src/pages/accounts.tsx b/ui/src/pages/accounts.tsx index bbed9c89..b0c5efe1 100644 --- a/ui/src/pages/accounts.tsx +++ b/ui/src/pages/accounts.tsx @@ -4,42 +4,321 @@ */ import { useState } from 'react'; -import { Plus } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { AlertTriangle, ArrowRight, ChevronDown, Plus, Users, Zap } from 'lucide-react'; import { AccountsTable } from '@/components/account/accounts-table'; import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog'; +import { HistorySyncLearningMap } from '@/components/account/history-sync-learning-map'; +import { CopyButton } from '@/components/ui/copy-button'; import { Button } from '@/components/ui/button'; -import { useAccounts } from '@/hooks/use-accounts'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { useAccounts, useConfirmLegacyAccountPolicies } from '@/hooks/use-accounts'; +import { cn } from '@/lib/utils'; export function AccountsPage() { - const { data, isLoading, refetch } = useAccounts(); + const navigate = useNavigate(); + const { data, isLoading } = useAccounts(); + const confirmLegacyMutation = useConfirmLegacyAccountPolicies(); const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [guideOpen, setGuideOpen] = useState(false); + + 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; + const sharedGroups = Array.from( + new Set( + authAccounts + .filter((account) => account.context_mode === 'shared') + .map((account) => account.context_group || 'default') + ) + ).sort((a, b) => a.localeCompare(b)); + + const legacyTargets = authAccounts.filter( + (account) => account.context_inferred || account.continuity_inferred + ); + const legacyTargetCount = legacyTargets.length; + const hasLegacyFollowUp = legacyTargetCount > 0; + + const handleOpenClaudePool = () => navigate('/cliproxy?provider=claude'); + const handleOpenClaudePoolAuth = () => navigate('/cliproxy?provider=claude&action=auth'); + const handleConfirmLegacy = () => confirmLegacyMutation.mutate(legacyTargets); return ( -
    -
    -
    -

    Accounts

    -

    - Manage multi-account Claude sessions (profiles.json) -

    + <> +
    + {/* Left action column */} +
    +
    +
    + +

    Accounts

    +
    +

    + Manage + ccs auth + accounts and pool onboarding from one panel. +

    +
    + + +
    +
    +

    + Primary Actions +

    + + + +
    + + {hasLegacyFollowUp ? ( +
    +

    + Migration Follow-up +

    +
    +
    + +
    + {legacyContextCount > 0 && ( +

    + {legacyContextCount} account + {legacyContextCount > 1 ? 's still need' : ' still needs'} first-time + mode confirmation. +

    + )} + {legacyContinuityCount > 0 && ( +

    + {legacyContinuityCount} shared account + {legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy + continuity depth. +

    + )} +
    +
    + +
    +
    + ) : ( +
    + No legacy follow-up pending. +
    + )} + + + + + + + + + + +
    +

    Shared Standard

    +

    + Project workspace sync only. Best default for most teams. +

    +
    +
    +

    Shared Deeper

    +

    + Adds session-env, file-history,{' '} + shell-snapshots, todos. +

    +
    +
    +

    Isolated

    +

    No link. Best for strict separation.

    +
    +
    +
    +
    +
    + + + + Quick Commands + Copy and run in terminal. + + +
    + + ccs auth create work --context-group sprint-a --deeper-continuity + + +
    +
    + ccs cliproxy auth claude + +
    +
    +
    +
    +
    +
    + + {/* Main workspace */} +
    +
    +
    + ccs auth Workspace + History Sync Controls +
    +

    Auth Accounts

    +

    + This table is intentionally scoped to + ccs auth + accounts. Use + Sync + for mode/group/depth changes. +

    +
    + +
    + + + + + Account Matrix + + Shared total: {sharedCount}. Actions include Sync settings and legacy + confirmation. + + + + {isLoading ? ( +
    Loading accounts...
    + ) : ( + + )} +
    +
    +
    -
    - {isLoading ? ( -
    Loading accounts...
    - ) : ( - + + + Accounts + + Manage + ccs auth + continuity per account. + + + + + + + + + + + - )} + + + + Account Matrix + + + {isLoading ? ( +
    Loading accounts...
    + ) : ( + + )} +
    +
    +
    setCreateDialogOpen(false)} /> -
    + ); } diff --git a/ui/src/pages/cliproxy.tsx b/ui/src/pages/cliproxy.tsx index 5f6229b7..345f0fd0 100644 --- a/ui/src/pages/cliproxy.tsx +++ b/ui/src/pages/cliproxy.tsx @@ -4,7 +4,7 @@ * Right panel: Provider Editor with split-view (settings + code editor) */ -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -32,6 +32,7 @@ import { } from '@/hooks/use-cliproxy'; import type { AuthStatus, Variant } from '@/lib/api-client'; import { MODEL_CATALOGS } from '@/lib/model-catalogs'; +import { getProviderDisplayName, isValidProvider } from '@/lib/provider-config'; import { cn } from '@/lib/utils'; // Sidebar provider item @@ -198,9 +199,14 @@ export function CliproxyPage() { const deleteMutation = useDeleteVariant(); // Selection state: either a provider or a variant - // Initialize from localStorage if available + // Initialize from URL provider deep-link, fallback to localStorage. const [selectedProvider, setSelectedProviderState] = useState(() => { if (typeof window !== 'undefined') { + const query = new URLSearchParams(window.location.search); + const queryProvider = query.get('provider')?.trim().toLowerCase(); + if (queryProvider && isValidProvider(queryProvider)) { + return queryProvider; + } return localStorage.getItem('cliproxy-selected-provider'); } return null; @@ -211,7 +217,25 @@ export function CliproxyPage() { provider: string; displayName: string; isFirstAccount: boolean; - } | null>(null); + } | null>(() => { + if (typeof window === 'undefined') { + return null; + } + + const query = new URLSearchParams(window.location.search); + const queryProvider = query.get('provider')?.trim().toLowerCase(); + const action = query.get('action'); + + if (action !== 'auth' || !queryProvider || !isValidProvider(queryProvider)) { + return null; + } + + return { + provider: queryProvider, + displayName: getProviderDisplayName(queryProvider), + isFirstAccount: false, + }; + }); const providers = useMemo(() => authData?.authStatus || [], [authData?.authStatus]); const isRemoteMode = authData?.source === 'remote';