Merge pull request #641 from kaitranntt/kai/fix/issue-624-context-edit-discoverability

fix(accounts): improve shared-context editing and discoverability
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-26 09:33:31 -05:00
committed by GitHub
38 changed files with 1814 additions and 60 deletions
+22 -1
View File
@@ -269,6 +269,11 @@ Account profiles are isolated by default.
| `isolated` | Yes | No `context_group` required |
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
Shared mode continuity depth:
- `standard` (default): shares project workspace context only
- `deeper` (advanced opt-in): additionally syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
Opt in to shared context when needed:
```bash
@@ -277,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)
<br>
+3 -2
View File
@@ -203,15 +203,16 @@ src/
### Account Context Metadata Flow
- Source fields: `accounts.<name>.context_mode` and `accounts.<name>.context_group` in `~/.ccs/config.yaml`.
- Source fields: `accounts.<name>.context_mode`, `accounts.<name>.context_group`, `accounts.<name>.continuity_mode` in `~/.ccs/config.yaml`.
- Runtime policy resolver: `src/auth/account-context.ts`.
- Metadata storage normalization: `src/auth/profile-registry.ts`.
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
- Rules:
- mode is isolation-first (`isolated` default, `shared` opt-in)
- shared mode requires non-empty valid `context_group`
- shared mode continuity depth is `standard` by default, optional `deeper`
- `context_group` is normalized (trim + lowercase + whitespace collapse to `-`)
- API route rejects `context_group` when mode is not `shared`
- API route rejects `context_group`/`continuity_mode` when mode is not `shared`
- registry normalization drops malformed persisted `context_group` values
### Target Adapter Module
+16 -1
View File
@@ -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
@@ -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 (<group>)` in Dashboard Accounts table
- Switch between accounts in the same group and verify workspace continuity
- Run `ccs doctor` if symlink/context health looks inconsistent
+40 -4
View File
@@ -6,20 +6,24 @@
*/
export type AccountContextMode = 'isolated' | 'shared';
export type AccountContinuityMode = 'standard' | 'deeper';
export interface AccountContextMetadata {
context_mode?: AccountContextMode;
context_group?: string;
continuity_mode?: AccountContinuityMode;
}
export interface AccountContextPolicy {
mode: AccountContextMode;
group?: string;
continuityMode?: AccountContinuityMode;
}
export interface CreateAccountContextInput {
shareContext: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
}
export interface ResolvedCreateAccountContext {
@@ -29,6 +33,7 @@ export interface ResolvedCreateAccountContext {
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
export const MAX_CONTEXT_GROUP_LENGTH = 64;
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -66,11 +71,22 @@ export function isAccountContextMetadata(value: unknown): value is AccountContex
const candidate = value as Record<string, unknown>;
const mode = candidate['context_mode'];
const group = candidate['context_group'];
const continuity = candidate['continuity_mode'];
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
const groupValid = group === undefined || typeof group === 'string';
const continuityValid =
continuity === undefined || continuity === 'standard' || continuity === 'deeper';
return modeValid && groupValid;
if (!modeValid || !groupValid || !continuityValid) {
return false;
}
if (mode !== 'shared' && continuity !== undefined) {
return false;
}
return true;
}
/**
@@ -80,6 +96,15 @@ export function resolveCreateAccountContext(
input: CreateAccountContextInput
): ResolvedCreateAccountContext {
const hasGroupFlag = input.contextGroup !== undefined;
const continuityMode: AccountContinuityMode = input.deeperContinuity ? 'deeper' : 'standard';
if (input.deeperContinuity && !input.shareContext && !hasGroupFlag) {
return {
policy: { mode: 'isolated' },
error:
'Advanced deeper continuity requires shared context (--share-context or --context-group).',
};
}
if (hasGroupFlag) {
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
@@ -101,6 +126,7 @@ export function resolveCreateAccountContext(
policy: {
mode: 'shared',
group: normalizedGroup,
continuityMode,
},
};
}
@@ -110,6 +136,7 @@ export function resolveCreateAccountContext(
policy: {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
},
};
}
@@ -128,15 +155,21 @@ export function resolveAccountContextPolicy(
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
if (mode === 'shared') {
const continuityMode: AccountContinuityMode =
metadata?.continuity_mode === 'deeper' ? 'deeper' : 'standard';
const rawGroup = metadata?.context_group;
if (rawGroup && rawGroup.trim().length > 0) {
const normalized = normalizeContextGroupName(rawGroup);
if (isValidContextGroupName(normalized)) {
return { mode: 'shared', group: normalized };
return { mode: 'shared', group: normalized, continuityMode };
}
}
return { mode: 'shared', group: DEFAULT_ACCOUNT_CONTEXT_GROUP };
return {
mode: 'shared',
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuityMode,
};
}
return { mode: 'isolated' };
@@ -152,6 +185,8 @@ export function policyToAccountContextMetadata(
return {
context_mode: 'shared',
context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP,
continuity_mode:
policy.continuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE,
};
}
@@ -165,7 +200,8 @@ export function policyToAccountContextMetadata(
*/
export function formatAccountContextPolicy(policy: AccountContextPolicy): string {
if (policy.mode === 'shared') {
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP})`;
const continuity = policy.continuityMode === 'deeper' ? 'deeper continuity' : 'standard';
return `shared (${policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP}, ${continuity})`;
}
return 'isolated';
+14
View File
@@ -86,6 +86,11 @@ class AuthCommands {
console.log(` ${dim('# Share context only within a specific group')}`);
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
console.log('');
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
console.log(
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
);
console.log('');
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
console.log('');
@@ -108,6 +113,9 @@ class AuthCommands {
console.log(
` ${color('--context-group <name>', 'command')} Share context only within a named group`
);
console.log(
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
);
console.log(
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
);
@@ -128,6 +136,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.`
+8 -4
View File
@@ -31,14 +31,15 @@ function sanitizeProfileNameForInstance(name: string): string {
*/
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } =
parseArgs(args);
if (unknownFlags && unknownFlags.length > 0) {
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
console.log(fail(`Unknown option(s): ${unknownList}`));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log(`Help: ${color('ccs auth --help', 'command')}`);
console.log('');
@@ -49,7 +50,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log(fail('Profile name is required'));
console.log('');
console.log(
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>]', 'command')}`
`Usage: ${color('ccs auth create <profile> [--force] [--share-context] [--context-group <name>] [--deeper-continuity]', 'command')}`
);
console.log('');
console.log('Example:');
@@ -89,6 +90,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
const resolvedContext = resolveCreateAccountContext({
shareContext: !!shareContext,
contextGroup,
deeperContinuity: !!deeperContinuity,
});
if (resolvedContext.error) {
@@ -212,7 +214,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
console.log('');
const launchDescription =
contextPolicy.mode === 'shared'
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
? contextPolicy.continuityMode === 'deeper'
? `Starting Claude with shared context group "${contextPolicy.group || 'default'}" (deeper continuity)...`
: `Starting Claude with shared context group "${contextPolicy.group || 'default'}"...`
: 'Starting Claude in isolated instance...';
console.log(warn(launchDescription));
console.log(warn('You will be prompted to login with your account.'));
+3 -1
View File
@@ -32,6 +32,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
@@ -56,6 +57,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
};
}),
@@ -134,7 +136,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
console.log(
table(rows, {
head: headers,
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
})
);
console.log('');
+1
View File
@@ -60,6 +60,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
last_used: profile.last_used || null,
context_mode: contextPolicy.mode,
context_group: contextPolicy.group || null,
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
instance_path: instancePath,
session_count: sessionCount,
};
+4
View File
@@ -21,6 +21,7 @@ export interface AuthCommandArgs {
yes?: boolean;
shareContext?: boolean;
contextGroup?: string;
deeperContinuity?: boolean;
unknownFlags?: string[];
}
@@ -35,6 +36,7 @@ export interface ProfileOutput {
last_used: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string | null;
continuity_mode?: 'standard' | 'deeper' | null;
instance_path?: string;
session_count?: number;
}
@@ -70,6 +72,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
'--yes',
'-y',
'--share-context',
'--deeper-continuity',
]);
const knownValueFlags = new Set(['--context-group']);
@@ -121,6 +124,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
json: args.includes('--json'),
yes: args.includes('--yes') || args.includes('-y'),
shareContext: args.includes('--share-context'),
deeperContinuity: args.includes('--deeper-continuity'),
contextGroup,
unknownFlags: [...unknownFlags],
};
+1
View File
@@ -202,6 +202,7 @@ class ProfileDetector {
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
},
};
}
+11
View File
@@ -23,6 +23,7 @@ import { isValidContextGroupName, normalizeContextGroupName } from './account-co
* last_used: <ISO timestamp or null> // Last usage time
* context_mode?: 'isolated' | 'shared' // Workspace context policy
* context_group?: <string> // Shared context group when mode=shared
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
* }
*
* Removed fields from v2.x:
@@ -43,6 +44,7 @@ interface CreateMetadata {
last_used?: string | null;
context_mode?: 'isolated' | 'shared';
context_group?: string;
continuity_mode?: 'standard' | 'deeper';
}
export class ProfileRegistry {
@@ -70,6 +72,7 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
@@ -77,6 +80,8 @@ export class ProfileRegistry {
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
@@ -87,6 +92,7 @@ export class ProfileRegistry {
if (normalized.context_mode !== 'shared') {
delete normalized.context_group;
delete normalized.continuity_mode;
} else {
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
if (normalizedGroup) {
@@ -94,6 +100,8 @@ export class ProfileRegistry {
} else {
delete normalized.context_group;
}
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
}
return normalized;
@@ -164,6 +172,7 @@ export class ProfileRegistry {
last_used: metadata.last_used || null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
// Note: No longer auto-set as default
@@ -311,6 +320,7 @@ export class ProfileRegistry {
last_used: null,
context_mode: metadata.context_mode,
context_group: metadata.context_group,
continuity_mode: metadata.continuity_mode,
});
saveUnifiedConfig(config);
}
@@ -438,6 +448,7 @@ export class ProfileRegistry {
last_used: account.last_used,
context_mode: account.context_mode,
context_group: account.context_group,
continuity_mode: account.continuity_mode,
};
}
+6 -1
View File
@@ -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 <name>', 'Create account profile (supports context sharing flags)'],
[
'ccs auth create <name>',
'Create account profile (supports shared groups + --deeper-continuity)',
],
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
['ccs auth list', 'List all account profiles'],
['ccs auth default <name>', 'Set default profile'],
['ccs auth reset-default', 'Restore original CCS default'],
['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'],
]
);
+4
View File
@@ -151,7 +151,10 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
const metadata = meta as Record<string, unknown>;
const rawContextMode = metadata.context_mode;
const rawContextGroup = metadata.context_group;
const rawContinuityMode = metadata.continuity_mode;
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
const continuityMode =
contextMode === 'shared' && rawContinuityMode === 'deeper' ? 'deeper' : 'standard';
let contextGroup: string | undefined;
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
@@ -168,6 +171,7 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
last_used: (metadata.last_used as string) || null,
context_mode: contextMode,
context_group: contextMode === 'shared' ? contextGroup : undefined,
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
};
unifiedConfig.accounts[name] = account;
}
+2
View File
@@ -44,6 +44,8 @@ export interface AccountConfig {
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
}
/**
+1
View File
@@ -48,6 +48,7 @@ class InstanceManager {
// Apply context policy (isolated by default, optional shared group).
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
});
return instancePath;
+165
View File
@@ -27,6 +27,12 @@ class SharedManager {
private readonly claudeDir: string;
private readonly instancesDir: string;
private readonly sharedItems: SharedItem[];
private readonly advancedContinuityItems: readonly string[] = [
'session-env',
'file-history',
'shell-snapshots',
'todos',
];
constructor() {
this.homeDir = os.homedir();
@@ -314,6 +320,133 @@ class SharedManager {
await this.ensureDirectory(projectsPath);
}
/**
* Sync advanced continuity artifacts for shared deeper mode.
*
* - shared + deeper: artifacts are linked per context group.
* - shared + standard / isolated: artifacts stay local to instance.
*/
async syncAdvancedContinuityArtifacts(
instancePath: string,
policy: AccountContextPolicy
): Promise<void> {
const instanceName = path.basename(instancePath);
const useSharedContinuity = policy.mode === 'shared' && policy.continuityMode === 'deeper';
const contextGroup = policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP;
for (const artifactName of this.advancedContinuityItems) {
const instanceArtifactPath = path.join(instancePath, artifactName);
if (useSharedContinuity) {
const sharedArtifactPath = path.join(
this.sharedDir,
'context-groups',
contextGroup,
'continuity',
artifactName
);
await this.ensureDirectory(sharedArtifactPath);
await this.ensureDirectory(path.dirname(instanceArtifactPath));
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isSymbolicLink()) {
if (await this.isSymlinkTarget(instanceArtifactPath, sharedArtifactPath)) {
continue;
}
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(sharedArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
sharedArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(
`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`
)
);
}
await fs.promises.unlink(instanceArtifactPath);
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
await this.mergeDirectoryWithConflictCopies(
instanceArtifactPath,
sharedArtifactPath,
instanceName
);
await fs.promises.rm(instanceArtifactPath, { recursive: true, force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.linkDirectoryWithFallback(sharedArtifactPath, instanceArtifactPath);
continue;
}
const currentStats = await this.getLstat(instanceArtifactPath);
if (!currentStats) {
await this.ensureDirectory(instanceArtifactPath);
continue;
}
if (currentStats.isDirectory()) {
continue;
}
if (currentStats.isSymbolicLink()) {
const currentTarget = await this.resolveSymlinkTargetPath(instanceArtifactPath);
await fs.promises.unlink(instanceArtifactPath);
await this.ensureDirectory(instanceArtifactPath);
if (
currentTarget &&
path.resolve(currentTarget) !== path.resolve(instanceArtifactPath) &&
this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName) &&
(await this.pathExists(currentTarget))
) {
await this.mergeDirectoryWithConflictCopies(
currentTarget,
instanceArtifactPath,
instanceName
);
} else if (
currentTarget &&
!this.isSafeContinuityMergeSource(currentTarget, instanceName, artifactName)
) {
console.log(
warn(`Skipping unsafe ${artifactName} merge source outside CCS roots: ${currentTarget}`)
);
}
continue;
}
await fs.promises.rm(instanceArtifactPath, { force: true });
await this.ensureDirectory(instanceArtifactPath);
}
}
/**
* Ensure all project memory directories for an instance are shared.
*
@@ -712,6 +845,38 @@ class SharedManager {
);
}
/**
* Guard advanced continuity merge operations to known CCS-managed roots only.
*/
private isSafeContinuityMergeSource(
sourcePath: string,
instanceName: string,
artifactName: string
): boolean {
const resolvedSource = this.resolveCanonicalPath(sourcePath);
const sharedContextRoot = this.resolveCanonicalPath(
path.join(this.sharedDir, 'context-groups')
);
const instanceArtifactRoot = this.resolveCanonicalPath(
path.join(this.instancesDir, instanceName, artifactName)
);
const normalizedSource =
process.platform === 'win32' ? resolvedSource.toLowerCase() : resolvedSource;
const continuitySegment =
process.platform === 'win32'
? `${path.sep}continuity${path.sep}`.toLowerCase()
: `${path.sep}continuity${path.sep}`;
const withinSharedContinuity =
this.isPathWithinDirectory(resolvedSource, sharedContextRoot) &&
normalizedSource.includes(continuitySegment);
return (
withinSharedContinuity || this.isPathWithinDirectory(resolvedSource, instanceArtifactRoot)
);
}
/**
* Link directory with Windows fallback to recursive copy.
*/
+2
View File
@@ -96,6 +96,8 @@ export interface ProfileMetadata {
context_mode?: 'isolated' | 'shared';
/** Context-sharing group when context_mode='shared' */
context_group?: string;
/** Shared continuity depth when context_mode='shared' */
continuity_mode?: 'standard' | 'deeper';
}
export interface ProfilesRegistry {
@@ -7,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;
}
+156 -1
View File
@@ -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<void> => {
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
*/
+23 -1
View File
@@ -18,7 +18,11 @@ import {
getBackupDirectories,
} from '../../config/migration-manager';
import { isUnifiedConfig } from '../../config/unified-config-types';
import { isValidContextGroupName, normalizeContextGroupName } from '../../auth/account-context';
import {
DEFAULT_ACCOUNT_CONTINUITY_MODE,
isValidContextGroupName,
normalizeContextGroupName,
} from '../../auth/account-context';
const router = Router();
@@ -45,6 +49,7 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
const account = accountValue as Record<string, unknown>;
const mode = account.context_mode;
const group = account.context_group;
const continuity = account.continuity_mode;
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
return `Invalid config.accounts.${accountName}.context_mode: expected isolated|shared`;
@@ -54,10 +59,18 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
return `Invalid config.accounts.${accountName}.context_group: expected string`;
}
if (continuity !== undefined && continuity !== 'standard' && continuity !== 'deeper') {
return `Invalid config.accounts.${accountName}.continuity_mode: expected standard|deeper`;
}
if (mode !== 'shared' && group !== undefined) {
return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`;
}
if (mode !== 'shared' && continuity !== undefined) {
return `Invalid config.accounts.${accountName}: continuity_mode requires context_mode=shared`;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) {
const normalizedGroup = normalizeContextGroupName(group);
if (!isValidContextGroupName(normalizedGroup)) {
@@ -66,6 +79,11 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
account.context_group = normalizedGroup;
}
if (mode === 'shared') {
account.continuity_mode =
continuity === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
}
if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) {
return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`;
}
@@ -73,6 +91,10 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
if (mode === 'isolated' && group !== undefined) {
delete account.context_group;
}
if (mode === 'isolated' && continuity !== undefined) {
delete account.continuity_mode;
}
}
return null;
+31
View File
@@ -51,4 +51,35 @@ describe('account context helpers', () => {
expect(result.policy.mode).toBe('shared');
expect(result.policy.group).toBe('team-alpha');
});
it('supports deeper continuity for shared create flows', () => {
const result = resolveCreateAccountContext({
shareContext: true,
deeperContinuity: true,
});
expect(result.error).toBeUndefined();
expect(result.policy.mode).toBe('shared');
expect(result.policy.continuityMode).toBe('deeper');
});
it('rejects deeper continuity without shared context flags', () => {
const result = resolveCreateAccountContext({
shareContext: false,
deeperContinuity: true,
});
expect(result.error).toContain('requires shared context');
});
it('defaults shared continuity mode to standard for legacy metadata', () => {
const resolved = resolveAccountContextPolicy({
context_mode: 'shared',
context_group: 'team-alpha',
});
expect(resolved.mode).toBe('shared');
expect(resolved.group).toBe('team-alpha');
expect(resolved.continuityMode).toBe('standard');
});
});
+8
View File
@@ -39,6 +39,14 @@ describe('auth command args parsing', () => {
expect(parsed.contextGroup).toBe('');
});
it('parses deeper continuity flag for create command', () => {
const parsed = parseArgs(['work', '--share-context', '--deeper-continuity']);
expect(parsed.profileName).toBe('work');
expect(parsed.shareContext).toBe(true);
expect(parsed.deeperContinuity).toBe(true);
});
it('tracks unknown flags and keeps positional profile intact', () => {
const parsed = parseArgs(['--foo', 'bar', 'work']);
+14 -2
View File
@@ -77,13 +77,19 @@ describe('auth list context metadata', () => {
}
const payload = JSON.parse(lines.join('\n')) as {
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
profiles: Array<{
name: string;
context_mode?: string;
context_group?: string | null;
continuity_mode?: string | null;
}>;
};
const work = payload.profiles.find((profile) => profile.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('sprint-a');
expect(work?.continuity_mode).toBe('standard');
});
it('prefers unified context metadata over legacy when profile names overlap', async () => {
@@ -151,12 +157,18 @@ describe('auth list context metadata', () => {
}
const payload = JSON.parse(lines.join('\n')) as {
profiles: Array<{ name: string; context_mode?: string; context_group?: string | null }>;
profiles: Array<{
name: string;
context_mode?: string;
context_group?: string | null;
continuity_mode?: string | null;
}>;
};
const work = payload.profiles.find((profile) => profile.name === 'work');
expect(work).toBeTruthy();
expect(work?.context_mode).toBe('shared');
expect(work?.context_group).toBe('sprint-a');
expect(work?.continuity_mode).toBe('standard');
});
});
@@ -58,6 +58,7 @@ describe('profile-registry context normalization', () => {
expect(profile.context_mode).toBe('shared');
expect(profile.context_group).toBeUndefined();
expect(profile.continuity_mode).toBe('standard');
});
it('drops non-string unified context_group values without throwing', () => {
@@ -88,5 +89,6 @@ describe('profile-registry context normalization', () => {
expect(accounts.work.context_mode).toBe('shared');
expect(accounts.work.context_group).toBeUndefined();
expect(accounts.work.continuity_mode).toBe('standard');
});
});
@@ -166,8 +166,10 @@ describe('migration-manager legacy kimi compatibility', () => {
expect(unified).toBeTruthy();
expect(unified?.accounts.work.context_mode).toBe('shared');
expect(unified?.accounts.work.context_group).toBe('sprint-a');
expect(unified?.accounts.work.continuity_mode).toBe('standard');
expect(unified?.accounts.personal.context_mode).toBe('isolated');
expect(unified?.accounts.personal.context_group).toBeUndefined();
expect(unified?.accounts.personal.continuity_mode).toBeUndefined();
});
it('normalizes valid legacy shared groups and drops invalid ones during migration', async () => {
@@ -210,5 +212,7 @@ describe('migration-manager legacy kimi compatibility', () => {
expect(unified?.accounts.work.context_group).toBe('sprint-a');
expect(unified?.accounts.broken.context_mode).toBe('shared');
expect(unified?.accounts.broken.context_group).toBeUndefined();
expect(unified?.accounts.work.continuity_mode).toBe('standard');
expect(unified?.accounts.broken.continuity_mode).toBe('standard');
});
});
+70
View File
@@ -64,6 +64,20 @@ describe('SharedManager context policy', () => {
return { instancePath, ccsDir };
}
async function applyPolicyWithContinuity(
policy: AccountContextPolicy
): Promise<{ instancePath: string; ccsDir: string }> {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
const manager = new SharedManager();
await manager.syncProjectContext(instancePath, policy);
await manager.syncAdvancedContinuityArtifacts(instancePath, policy);
return { instancePath, ccsDir };
}
it('keeps projects isolated by default', async () => {
const { instancePath } = await applyPolicy({ mode: 'isolated' });
const projectsPath = path.join(instancePath, 'projects');
@@ -141,6 +155,62 @@ describe('SharedManager context policy', () => {
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
});
it('links advanced continuity artifacts for shared deeper mode', async () => {
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
mode: 'shared',
group: 'sprint-a',
continuityMode: 'deeper',
});
const artifactPath = path.join(instancePath, 'session-env');
const targetFile = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'continuity',
'session-env',
'session.json'
);
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
fs.writeFileSync(targetFile, '{"id":"shared"}', 'utf8');
expect(fs.lstatSync(artifactPath).isSymbolicLink()).toBe(true);
expect(fs.readFileSync(path.join(artifactPath, 'session.json'), 'utf8')).toContain('shared');
});
it('detaches advanced continuity artifacts when moving from deeper to standard shared mode', async () => {
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
mode: 'shared',
group: 'sprint-a',
continuityMode: 'deeper',
});
const sharedTodo = path.join(
ccsDir,
'shared',
'context-groups',
'sprint-a',
'continuity',
'todos',
'todo.md'
);
fs.mkdirSync(path.dirname(sharedTodo), { recursive: true });
fs.writeFileSync(sharedTodo, '- shared todo', 'utf8');
const manager = new SharedManager();
await manager.syncAdvancedContinuityArtifacts(instancePath, {
mode: 'shared',
group: 'sprint-a',
continuityMode: 'standard',
});
const localTodoDir = path.join(instancePath, 'todos');
expect(fs.lstatSync(localTodoDir).isDirectory()).toBe(true);
expect(fs.readFileSync(path.join(localTodoDir, 'todo.md'), 'utf8')).toContain('shared todo');
});
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
const ccsDir = getTestCcsDir();
const instancePath = path.join(ccsDir, 'instances', 'work');
@@ -18,6 +18,14 @@ async function deletePath(baseUrl: string, routePath: string): Promise<Response>
return fetch(`${baseUrl}${routePath}`, { method: 'DELETE' });
}
async function putJson(baseUrl: string, routePath: string, body: unknown): Promise<Response> {
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<void>((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');
});
});
@@ -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 () => {
+80 -8
View File
@@ -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<string | null>(null);
const [contextTarget, setContextTarget] = useState<Account | null>(null);
const columns: ColumnDef<Account>[] = [
{
@@ -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 <span className="text-muted-foreground">shared ({group})</span>;
if (row.original.continuity_mode === 'deeper') {
return <span className="text-muted-foreground">shared ({group}, deeper)</span>;
}
if (row.original.continuity_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">
shared ({group}, standard legacy)
</span>
);
}
return <span className="text-muted-foreground">shared ({group}, standard)</span>;
}
if (row.original.context_inferred) {
return (
<span className="text-amber-700 dark:text-amber-400">isolated (legacy default)</span>
);
}
return <span className="text-muted-foreground">isolated</span>;
@@ -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 (
<div className="flex items-center gap-1">
{!isCliproxy && (
<Button
variant="outline"
size="sm"
className="h-8 px-2"
disabled={isPending}
onClick={() => setContextTarget(row.original)}
title="Edit sync mode, group, and continuity depth"
>
<Pencil className="w-3.5 h-3.5 mr-1" />
Sync
</Button>
)}
{!isCliproxy && hasLegacyInference && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-amber-700 hover:text-amber-700 hover:bg-amber-500/10 dark:text-amber-400 dark:hover:text-amber-400"
disabled={isPending}
onClick={() =>
updateContextMutation.mutate({
name: row.original.name,
context_mode: row.original.context_mode === 'shared' ? 'shared' : 'isolated',
context_group:
row.original.context_mode === 'shared'
? row.original.context_group || 'default'
: undefined,
continuity_mode:
row.original.context_mode === 'shared'
? row.original.continuity_mode === 'deeper'
? 'deeper'
: 'standard'
: undefined,
})
}
title="Confirm this legacy account's current mode as explicit"
>
<CheckCheck className="w-3 h-3 mr-1" />
Confirm
</Button>
)}
<Button
variant={isDefault ? 'secondary' : 'default'}
size="sm"
@@ -151,7 +219,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
if (data.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No accounts found. Use{' '}
No CCS auth accounts found. Use{' '}
<code className="text-sm bg-muted px-1 rounded">ccs auth create</code> to add accounts.
</div>
);
@@ -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) {
)}
</div>
{contextTarget && (
<EditAccountContextDialog account={contextTarget} onClose={() => setContextTarget(null)} />
)}
{/* Delete confirmation dialog */}
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialogContent>
@@ -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
<DialogHeader>
<DialogTitle>Create New Account</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
@@ -103,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
onCheckedChange={(checked) => setShareContext(checked === true)}
/>
<Label htmlFor="share-context" className="cursor-pointer">
Share project context with other accounts
Enable shared history sync with other ccs auth accounts
</Label>
</div>
{shareContext && (
<div className="space-y-2 pl-6">
<Label htmlFor="context-group">Context Group (optional)</Label>
<Label htmlFor="context-group">History Sync Group (optional)</Label>
<Input
id="context-group"
value={contextGroup}
@@ -118,7 +122,21 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Leave empty to use the default shared group.
Leave empty to use the default shared group. Spaces are normalized to dashes.
</p>
<div className="flex items-center gap-2 pt-1">
<Checkbox
id="deeper-continuity"
checked={deeperContinuity}
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
/>
<Label htmlFor="deeper-continuity" className="cursor-pointer">
Advanced: deeper continuity mode
</Label>
</div>
<p className="text-xs text-muted-foreground">
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
</p>
{contextGroup.trim().length > 0 && !isValidContextGroup && (
<p className="text-xs text-destructive">
@@ -157,6 +175,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
<li>Complete the Claude login in your browser</li>
<li>Return here and refresh to see the new account</li>
</ol>
<p className="pt-1">
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
page action button.
</p>
</div>
<div className="flex justify-end gap-2 pt-2">
@@ -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<ContextMode>(
account.context_mode === 'shared' ? 'shared' : 'isolated'
);
const [group, setGroup] = useState(account.context_group || 'default');
const [continuityMode, setContinuityMode] = useState<ContinuityMode>(
account.continuity_mode === 'deeper' ? 'deeper' : 'standard'
);
const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
const isSharedGroupValid =
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 (
<Dialog open onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit History Sync</DialogTitle>
<DialogDescription>
Configure how "{account.name}" shares history and continuity with other
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="context-mode">Sync Mode</Label>
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
<SelectTrigger id="context-mode">
<SelectValue placeholder="Select context mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="isolated">isolated (no sync)</SelectItem>
<SelectItem value="shared">shared (sync enabled)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{mode === 'shared'
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
</p>
</div>
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="context-group">History Sync Group</Label>
<Input
id="context-group"
value={group}
onChange={(event) => setGroup(event.target.value)}
placeholder="default"
autoComplete="off"
/>
<p className="text-xs text-muted-foreground">
Normalized to lowercase (spaces become dashes). Allowed: letters, numbers, `_`, `-`
(max {MAX_CONTEXT_GROUP_LENGTH} chars).
</p>
{!isSharedGroupValid && (
<p className="text-xs text-destructive">
Enter a valid group name that starts with a letter.
</p>
)}
</div>
)}
{mode === 'shared' && (
<div className="space-y-2">
<Label htmlFor="continuity-mode">Continuity Depth</Label>
<Select
value={continuityMode}
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
>
<SelectTrigger id="continuity-mode">
<SelectValue placeholder="Select continuity depth" />
</SelectTrigger>
<SelectContent>
<SelectItem value="standard">standard (projects only)</SelectItem>
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{continuityMode === 'deeper'
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.'
: 'Standard mode syncs project workspace context only.'}
</p>
</div>
)}
<p className="text-xs text-muted-foreground">
Credentials and `.anthropic` remain isolated per account in all modes.
</p>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={updateContextMutation.isPending}>
Cancel
</Button>
<Button onClick={handleSave} disabled={!canSubmit || updateContextMutation.isPending}>
{updateContextMutation.isPending ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -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<StageTone, { border: string; icon: string; count: string }> = {
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 (
<div className={cn('rounded-md border p-2.5', toneClasses[tone].border)}>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-semibold">{title}</p>
<Icon className={cn('h-3.5 w-3.5', toneClasses[tone].icon)} />
</div>
<p className={cn('mt-1 text-lg font-mono font-semibold', toneClasses[tone].count)}>{count}</p>
</div>
);
}
export function HistorySyncLearningMap({
isolatedCount,
sharedStandardCount,
deeperSharedCount,
sharedGroups,
legacyTargetCount,
cliproxyCount,
}: HistorySyncLearningMapProps) {
const [open, setOpen] = useState(false);
const groupsToShow = sharedGroups.length > 0 ? sharedGroups : ['default'];
return (
<Card className="border-dashed">
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<div>
<CardTitle className="text-base">How History Sync Works</CardTitle>
<CardDescription className="mt-1">
Isolated -&gt; Shared -&gt; Deeper. Use <code>Sync</code> per row for all changes.
</CardDescription>
</div>
<Badge variant="outline">Learning Map</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
{cliproxyCount > 0 && (
<div className="rounded-md border border-blue-300/60 bg-blue-50/40 px-3 py-2 text-xs text-blue-800 dark:border-blue-900/40 dark:bg-blue-900/10 dark:text-blue-300">
{cliproxyCount} CLIProxy Claude pool account{cliproxyCount > 1 ? 's are' : ' is'}
managed in Action Center / CLIProxy page.
</div>
)}
<div className="grid gap-2 sm:grid-cols-[1fr_auto_1fr_auto_1fr] sm:items-center">
<StageTile title="Isolated" count={isolatedCount} icon={Unlink} tone="isolated" />
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile title="Shared" count={sharedStandardCount} icon={Link2} tone="shared" />
<div className="hidden sm:flex justify-center text-muted-foreground">
<ArrowRight className="h-4 w-4" />
</div>
<StageTile title="Deeper" count={deeperSharedCount} icon={Waves} tone="deeper" />
</div>
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger asChild>
<Button
variant="ghost"
className="h-8 w-full justify-between rounded-md px-2 text-xs text-muted-foreground hover:bg-muted/40 hover:text-foreground"
>
<span>Show details: groups, switching, and legacy policy</span>
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="grid gap-2 lg:grid-cols-2">
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<ArrowRightLeft className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">Mode Switch</p>
</div>
<p className="mt-1 text-muted-foreground">
Sync dialog lets users move between isolated/shared and choose deeper continuity.
</p>
</div>
<div className="rounded-md border bg-muted/20 p-2.5 text-xs">
<div className="flex items-center gap-2">
<Layers3 className="h-3.5 w-3.5 text-muted-foreground" />
<p className="font-semibold">History Sync Group</p>
</div>
<p className="mt-1 text-muted-foreground">
Same group means shared project context lane. Default fallback is{' '}
<code>default</code>.
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{groupsToShow.map((group) => (
<Badge key={group} variant="outline" className="font-mono text-[10px]">
{group}
</Badge>
))}
</div>
</div>
</div>
{legacyTargetCount > 0 && (
<div className="mt-2 rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-300">
{legacyTargetCount} legacy account
{legacyTargetCount > 1 ? 's still need' : ' still needs'} explicit confirmation.
</div>
)}
</CollapsibleContent>
</Collapsible>
</CardContent>
</Card>
);
}
+1
View File
@@ -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';
+120 -1
View File
@@ -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);
},
});
}
+16
View File
@@ -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: {
+302 -23
View File
@@ -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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Accounts</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage multi-account Claude sessions (profiles.json)
</p>
<>
<div className="h-[calc(100vh-100px)] hidden lg:flex">
{/* Left action column */}
<div className="w-80 border-r flex flex-col bg-muted/20 shrink-0">
<div className="p-4 border-b bg-background space-y-2">
<div className="flex items-center gap-2">
<Users className="h-5 w-5 text-primary" />
<h1 className="font-semibold">Accounts</h1>
</div>
<p className="text-xs text-muted-foreground">
Manage
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts and pool onboarding from one panel.
</p>
</div>
<ScrollArea className="flex-1">
<div className="p-4 space-y-3">
<div className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Primary Actions
</p>
<Button
size="sm"
className="w-full justify-start"
onClick={() => setCreateDialogOpen(true)}
>
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
<Button
size="sm"
className="w-full justify-start"
onClick={handleOpenClaudePoolAuth}
>
<Zap className="w-4 h-4 mr-2" />
Authenticate Claude in Pool
</Button>
<Button
variant="outline"
size="sm"
className="w-full justify-start"
onClick={handleOpenClaudePool}
>
Open Claude Pool Settings
<ArrowRight className="w-4 h-4 ml-auto" />
</Button>
</div>
{hasLegacyFollowUp ? (
<section className="space-y-2">
<p className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
Migration Follow-up
</p>
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 space-y-3">
<div className="flex items-start gap-2">
<AlertTriangle className="h-4 w-4 mt-0.5 text-amber-700 dark:text-amber-400 shrink-0" />
<div className="space-y-1 text-xs">
{legacyContextCount > 0 && (
<p className="text-amber-800 dark:text-amber-300">
{legacyContextCount} account
{legacyContextCount > 1 ? 's still need' : ' still needs'} first-time
mode confirmation.
</p>
)}
{legacyContinuityCount > 0 && (
<p className="text-amber-800 dark:text-amber-300">
{legacyContinuityCount} shared account
{legacyContinuityCount > 1 ? 's remain' : ' remains'} on standard legacy
continuity depth.
</p>
)}
</div>
</div>
<Button
variant="secondary"
size="sm"
className="w-full justify-start"
onClick={handleConfirmLegacy}
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
>
{confirmLegacyMutation.isPending
? 'Confirming Legacy Policies...'
: `Confirm Legacy Policies (${legacyTargetCount})`}
</Button>
</div>
</section>
) : (
<div className="rounded-md border bg-background px-3 py-2 text-xs text-muted-foreground">
No legacy follow-up pending.
</div>
)}
<Collapsible open={guideOpen} onOpenChange={setGuideOpen}>
<Card>
<CardHeader className="pb-2">
<CollapsibleTrigger asChild>
<Button variant="ghost" className="h-auto w-full justify-between px-0 py-0">
<div className="text-left">
<CardTitle className="text-sm">Continuity Guide</CardTitle>
<CardDescription className="mt-1">
Expand only when needed.
</CardDescription>
</div>
<ChevronDown
className={cn('h-4 w-4 transition-transform', guideOpen && 'rotate-180')}
/>
</Button>
</CollapsibleTrigger>
</CardHeader>
<CollapsibleContent>
<CardContent className="space-y-3 text-xs text-muted-foreground">
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Shared Standard</p>
<p className="mt-1">
Project workspace sync only. Best default for most teams.
</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Shared Deeper</p>
<p className="mt-1">
Adds <code>session-env</code>, <code>file-history</code>,{' '}
<code>shell-snapshots</code>, <code>todos</code>.
</p>
</div>
<div className="rounded-md border p-2.5">
<p className="font-semibold text-foreground">Isolated</p>
<p className="mt-1">No link. Best for strict separation.</p>
</div>
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Quick Commands</CardTitle>
<CardDescription>Copy and run in terminal.</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">
ccs auth create work --context-group sprint-a --deeper-continuity
</span>
<CopyButton
value="ccs auth create work --context-group sprint-a --deeper-continuity"
size="icon"
/>
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs cliproxy auth claude</span>
<CopyButton value="ccs cliproxy auth claude" size="icon" />
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</div>
{/* Main workspace */}
<div className="flex-1 min-w-0 flex flex-col bg-background">
<div className="px-5 py-4 border-b bg-background">
<div className="flex items-center gap-2">
<Badge variant="outline">ccs auth Workspace</Badge>
<Badge variant="secondary">History Sync Controls</Badge>
</div>
<h2 className="mt-2 text-xl font-semibold tracking-tight">Auth Accounts</h2>
<p className="mt-1 text-sm text-muted-foreground">
This table is intentionally scoped to
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
accounts. Use
<code className="mx-1 rounded bg-muted px-1 py-0.5">Sync</code>
for mode/group/depth changes.
</p>
</div>
<div className="flex-1 min-h-0 p-5 space-y-4 overflow-y-auto">
<HistorySyncLearningMap
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
sharedGroups={sharedGroups}
legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount}
/>
<Card className="flex flex-col">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Account Matrix</CardTitle>
<CardDescription>
Shared total: {sharedCount}. Actions include Sync settings and legacy
confirmation.
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
)}
</CardContent>
</Card>
</div>
</div>
<Button onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
</div>
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<AccountsTable
data={data?.accounts || []}
defaultAccount={data?.default ?? null}
onRefresh={refetch}
{/* Mobile fallback */}
<div className="p-4 space-y-4 lg:hidden">
<Card>
<CardHeader>
<CardTitle className="text-lg">Accounts</CardTitle>
<CardDescription>
Manage
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
continuity per account.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Button className="w-full" onClick={() => setCreateDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Account
</Button>
<Button variant="outline" className="w-full" onClick={handleOpenClaudePool}>
Open CLIProxy Claude Pool
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<Button variant="outline" className="w-full" onClick={handleOpenClaudePoolAuth}>
Authenticate Claude in Pool
<Zap className="w-4 h-4 ml-2" />
</Button>
<Button
variant="outline"
className="w-full"
onClick={handleConfirmLegacy}
disabled={confirmLegacyMutation.isPending || legacyTargetCount === 0}
>
{confirmLegacyMutation.isPending
? 'Confirming Legacy Policies...'
: `Confirm Legacy Policies (${legacyTargetCount})`}
</Button>
</CardContent>
</Card>
<HistorySyncLearningMap
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
sharedGroups={sharedGroups}
legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount}
/>
)}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">Account Matrix</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-muted-foreground">Loading accounts...</div>
) : (
<AccountsTable data={authAccounts} defaultAccount={data?.default ?? null} />
)}
</CardContent>
</Card>
</div>
<CreateAuthProfileDialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} />
</div>
</>
);
}
+27 -3
View File
@@ -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<string | null>(() => {
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';