mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 22:21:20 +00:00
feat(accounts): add advanced deeper continuity mode and claude pool discoverability
This commit is contained in:
@@ -269,6 +269,11 @@ Account profiles are isolated by default.
|
||||
| `isolated` | Yes | No `context_group` required |
|
||||
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
|
||||
|
||||
Shared mode continuity depth:
|
||||
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): additionally syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
Opt in to shared context when needed:
|
||||
|
||||
```bash
|
||||
@@ -277,13 +282,16 @@ ccs auth create backup --share-context
|
||||
|
||||
# Share context only within named group
|
||||
ccs auth create backup2 --context-group sprint-a
|
||||
|
||||
# Advanced deeper continuity mode (requires shared mode)
|
||||
ccs auth create backup3 --context-group sprint-a --deeper-continuity
|
||||
```
|
||||
|
||||
Update existing accounts without recreating login:
|
||||
|
||||
1. Run `ccs config`
|
||||
2. Open `Accounts`
|
||||
3. Click the pencil icon in Actions and set `isolated` or `shared` mode
|
||||
3. Click the pencil icon in Actions and set `isolated` or `shared` mode + continuity depth
|
||||
|
||||
Shared mode metadata in `~/.ccs/config.yaml`:
|
||||
|
||||
@@ -294,6 +302,7 @@ accounts:
|
||||
last_used: null
|
||||
context_mode: "shared"
|
||||
context_group: "team-alpha"
|
||||
continuity_mode: "standard"
|
||||
```
|
||||
|
||||
`context_group` rules:
|
||||
@@ -304,7 +313,11 @@ accounts:
|
||||
- non-empty after normalization
|
||||
- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`)
|
||||
|
||||
Shared context links project workspace data only. Credentials remain isolated per account.
|
||||
Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. Credentials remain isolated per account.
|
||||
|
||||
Alternative path for lower manual switching:
|
||||
|
||||
- Use CLIProxy Claude pool (`ccs cliproxy auth claude`) and manage pool behavior in `ccs config` -> `CLIProxy Plus`.
|
||||
|
||||
Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -24,6 +24,11 @@ Account context is isolation-first:
|
||||
| `isolated` | Yes | No `context_group` required |
|
||||
| `shared` | No (opt-in) | Valid non-empty `context_group` |
|
||||
|
||||
Shared continuity depth:
|
||||
|
||||
- `standard` (default): shares project workspace context only
|
||||
- `deeper` (advanced opt-in): also syncs `session-env`, `file-history`, `shell-snapshots`, `todos`
|
||||
|
||||
`context_group` normalization and validation:
|
||||
|
||||
- trim + lowercase + collapse internal whitespace to `-`
|
||||
@@ -31,17 +36,21 @@ Account context is isolation-first:
|
||||
- must start with a letter
|
||||
- max length: 64
|
||||
- shared mode requires non-empty value after normalization
|
||||
- `continuity_mode` is only valid when mode is `shared`
|
||||
|
||||
`PUT /api/config` behavior for account context:
|
||||
|
||||
- rejects invalid unified payloads
|
||||
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
|
||||
- rejects invalid `continuity_mode` values
|
||||
- normalizes valid shared `context_group` before save
|
||||
- defaults missing shared `continuity_mode` to `standard`
|
||||
- rejects `context_group` when mode is not `shared`
|
||||
- rejects `continuity_mode` when mode is not `shared`
|
||||
|
||||
Dashboard accounts context editing:
|
||||
|
||||
- `PUT /api/accounts/:name/context` updates context mode/group for existing auth accounts
|
||||
- `PUT /api/accounts/:name/context` updates context mode/group/continuity for existing auth accounts
|
||||
- rejects CLIProxy OAuth account keys for this route
|
||||
- applies normalization/validation rules above
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ CCS supports practical cross-account continuity by sharing workspace context fil
|
||||
This is implemented as a context policy per account:
|
||||
|
||||
- `isolated` (default): account keeps its own workspace context
|
||||
- `shared`: account workspace context is linked to a shared context group
|
||||
- `shared` + `standard` (default): account workspace context is linked to a shared context group
|
||||
- `shared` + `deeper` (advanced opt-in): account also shares continuity artifacts
|
||||
|
||||
## Why This Is Safe Enough
|
||||
|
||||
@@ -28,16 +29,27 @@ accounts:
|
||||
last_used: null
|
||||
context_mode: "shared"
|
||||
context_group: "team-alpha"
|
||||
continuity_mode: "deeper"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `context_mode` must be `isolated` or `shared`
|
||||
- `context_group` is required when `context_mode=shared`
|
||||
- `continuity_mode` is valid only when `context_mode=shared` (`standard` or `deeper`)
|
||||
- group normalization: trim, lowercase, internal spaces -> `-`
|
||||
- group must start with a letter and only include `[a-zA-Z0-9_-]`
|
||||
- max length: `64`
|
||||
|
||||
Deeper continuity links these directories per context group:
|
||||
|
||||
- `session-env`
|
||||
- `file-history`
|
||||
- `shell-snapshots`
|
||||
- `todos`
|
||||
|
||||
`.anthropic` and account credentials remain isolated.
|
||||
|
||||
## User Workflows
|
||||
|
||||
### New account with shared context
|
||||
@@ -45,14 +57,15 @@ Rules:
|
||||
```bash
|
||||
ccs auth create work2 --share-context
|
||||
ccs auth create backup --context-group sprint-a
|
||||
ccs auth create backup2 --context-group sprint-a --deeper-continuity
|
||||
```
|
||||
|
||||
### Existing account
|
||||
|
||||
- Open `ccs config`
|
||||
- Go to `Accounts`
|
||||
- Click the pencil icon (`Edit Context`)
|
||||
- Choose `isolated` or `shared` and set group
|
||||
- Click the pencil icon (`Edit History Sync`)
|
||||
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
||||
|
||||
No account recreation required for this workflow.
|
||||
|
||||
@@ -62,9 +75,15 @@ No account recreation required for this workflow.
|
||||
- Session continuity still depends on what the upstream tool/provider stores and allows.
|
||||
- Context sharing should only be enabled for accounts you intentionally trust to share workspace history.
|
||||
|
||||
## Alternative: CLIProxy Claude Pool
|
||||
|
||||
For users who prefer lower manual account switching, use CLIProxy Claude pool instead:
|
||||
|
||||
- Authenticate pool accounts via `ccs cliproxy auth claude`
|
||||
- Manage account pool behavior in `ccs config` -> `CLIProxy Plus`
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
|
||||
- Switch between accounts in the same group and verify workspace continuity
|
||||
- Run `ccs doctor` if symlink/context health looks inconsistent
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -86,6 +86,11 @@ class AuthCommands {
|
||||
console.log(` ${dim('# Share context only within a specific group')}`);
|
||||
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Advanced: deeper shared continuity for session history artifacts')}`);
|
||||
console.log(
|
||||
` ${color('ccs auth create backup --context-group sprint-a --deeper-continuity', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Set work as default')}`);
|
||||
console.log(` ${color('ccs auth default work', 'command')}`);
|
||||
console.log('');
|
||||
@@ -108,6 +113,9 @@ class AuthCommands {
|
||||
console.log(
|
||||
` ${color('--context-group <name>', 'command')} Share context only within a named group`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--deeper-continuity', 'command')} Advanced shared mode: sync additional continuity artifacts`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
||||
);
|
||||
@@ -128,6 +136,9 @@ class AuthCommands {
|
||||
console.log(
|
||||
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--deeper-continuity', 'command')} requires shared mode and syncs session-env/file-history/todos/shell-snapshots.`
|
||||
);
|
||||
console.log(
|
||||
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
||||
);
|
||||
|
||||
@@ -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.'));
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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],
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -150,11 +150,15 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['Run multiple Claude accounts concurrently'],
|
||||
[
|
||||
['ccs auth --help', 'Show account management commands'],
|
||||
['ccs auth create <name>', 'Create account profile (supports context sharing flags)'],
|
||||
['ccs config', 'Dashboard: Accounts table can edit context mode/group'],
|
||||
[
|
||||
'ccs auth create <name>',
|
||||
'Create account profile (supports shared groups + --deeper-continuity)',
|
||||
],
|
||||
['ccs config', 'Dashboard: Accounts table can edit context mode/group/continuity depth'],
|
||||
['ccs auth list', 'List all account profiles'],
|
||||
['ccs auth default <name>', 'Set default profile'],
|
||||
['ccs auth reset-default', 'Restore original CCS default'],
|
||||
['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'],
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -96,6 +96,8 @@ export interface ProfileMetadata {
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
/** Context-sharing group when context_mode='shared' */
|
||||
context_group?: string;
|
||||
/** Shared continuity depth when context_mode='shared' */
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}
|
||||
|
||||
export interface ProfilesRegistry {
|
||||
|
||||
@@ -7,7 +7,9 @@ export interface MergedAccountEntry {
|
||||
last_used: string | null;
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../../cliproxy/account-manager';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||
isValidContextGroupName,
|
||||
normalizeContextGroupName,
|
||||
resolveAccountContextPolicy,
|
||||
@@ -58,13 +59,18 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
const contextPolicy = resolveAccountContextPolicy(meta);
|
||||
const hasExplicitContextMode =
|
||||
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
meta.continuity_mode === 'standard' || meta.continuity_mode === 'deeper';
|
||||
merged[name] = {
|
||||
type: meta.type || 'account',
|
||||
created: meta.created,
|
||||
last_used: meta.last_used || null,
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
|
||||
context_inferred: !hasExplicitContextMode,
|
||||
continuity_inferred:
|
||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -73,13 +79,18 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
const contextPolicy = resolveAccountContextPolicy(account);
|
||||
const hasExplicitContextMode =
|
||||
account.context_mode === 'isolated' || account.context_mode === 'shared';
|
||||
const hasExplicitContinuityMode =
|
||||
account.continuity_mode === 'standard' || account.continuity_mode === 'deeper';
|
||||
merged[name] = {
|
||||
type: 'account',
|
||||
created: account.created,
|
||||
last_used: account.last_used,
|
||||
context_mode: contextPolicy.mode,
|
||||
context_group: contextPolicy.group,
|
||||
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
|
||||
context_inferred: !hasExplicitContextMode,
|
||||
continuity_inferred:
|
||||
contextPolicy.mode === 'shared' ? !hasExplicitContinuityMode : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -189,6 +200,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
|
||||
const mode = req.body?.context_mode;
|
||||
const rawGroup = req.body?.context_group;
|
||||
const rawContinuityMode = req.body?.continuity_mode;
|
||||
|
||||
if (mode !== 'isolated' && mode !== 'shared') {
|
||||
res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|shared' });
|
||||
@@ -202,7 +214,15 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode !== 'shared' && rawContinuityMode !== undefined) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'Invalid payload: continuity_mode requires context_mode=shared' });
|
||||
return;
|
||||
}
|
||||
|
||||
let normalizedGroup: string | undefined;
|
||||
let continuityMode: 'standard' | 'deeper' | undefined;
|
||||
if (mode === 'shared') {
|
||||
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
|
||||
res
|
||||
@@ -219,6 +239,19 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
rawContinuityMode !== undefined &&
|
||||
rawContinuityMode !== 'standard' &&
|
||||
rawContinuityMode !== 'deeper'
|
||||
) {
|
||||
res.status(400).json({
|
||||
error: 'Invalid continuity_mode: expected standard|deeper',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
continuityMode = rawContinuityMode === 'deeper' ? 'deeper' : DEFAULT_ACCOUNT_CONTINUITY_MODE;
|
||||
}
|
||||
|
||||
const metadata =
|
||||
@@ -226,6 +259,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
? {
|
||||
context_mode: 'shared' as const,
|
||||
context_group: normalizedGroup,
|
||||
continuity_mode: continuityMode,
|
||||
}
|
||||
: {
|
||||
context_mode: 'isolated' as const,
|
||||
@@ -258,7 +292,12 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
||||
name,
|
||||
context_mode: policy.mode,
|
||||
context_group: policy.group ?? null,
|
||||
continuity_mode:
|
||||
policy.mode === 'shared'
|
||||
? (policy.continuityMode ?? DEFAULT_ACCOUNT_CONTINUITY_MODE)
|
||||
: null,
|
||||
context_inferred: false,
|
||||
continuity_inferred: false,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,6 +64,20 @@ describe('SharedManager context policy', () => {
|
||||
return { instancePath, ccsDir };
|
||||
}
|
||||
|
||||
async function applyPolicyWithContinuity(
|
||||
policy: AccountContextPolicy
|
||||
): Promise<{ instancePath: string; ccsDir: string }> {
|
||||
const ccsDir = getTestCcsDir();
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
fs.mkdirSync(instancePath, { recursive: true });
|
||||
|
||||
const manager = new SharedManager();
|
||||
await manager.syncProjectContext(instancePath, policy);
|
||||
await manager.syncAdvancedContinuityArtifacts(instancePath, policy);
|
||||
|
||||
return { instancePath, ccsDir };
|
||||
}
|
||||
|
||||
it('keeps projects isolated by default', async () => {
|
||||
const { instancePath } = await applyPolicy({ mode: 'isolated' });
|
||||
const projectsPath = path.join(instancePath, 'projects');
|
||||
@@ -141,6 +155,62 @@ describe('SharedManager context policy', () => {
|
||||
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
|
||||
});
|
||||
|
||||
it('links advanced continuity artifacts for shared deeper mode', async () => {
|
||||
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
const artifactPath = path.join(instancePath, 'session-env');
|
||||
const targetFile = path.join(
|
||||
ccsDir,
|
||||
'shared',
|
||||
'context-groups',
|
||||
'sprint-a',
|
||||
'continuity',
|
||||
'session-env',
|
||||
'session.json'
|
||||
);
|
||||
|
||||
fs.mkdirSync(path.dirname(targetFile), { recursive: true });
|
||||
fs.writeFileSync(targetFile, '{"id":"shared"}', 'utf8');
|
||||
|
||||
expect(fs.lstatSync(artifactPath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(artifactPath, 'session.json'), 'utf8')).toContain('shared');
|
||||
});
|
||||
|
||||
it('detaches advanced continuity artifacts when moving from deeper to standard shared mode', async () => {
|
||||
const { instancePath, ccsDir } = await applyPolicyWithContinuity({
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'deeper',
|
||||
});
|
||||
|
||||
const sharedTodo = path.join(
|
||||
ccsDir,
|
||||
'shared',
|
||||
'context-groups',
|
||||
'sprint-a',
|
||||
'continuity',
|
||||
'todos',
|
||||
'todo.md'
|
||||
);
|
||||
fs.mkdirSync(path.dirname(sharedTodo), { recursive: true });
|
||||
fs.writeFileSync(sharedTodo, '- shared todo', 'utf8');
|
||||
|
||||
const manager = new SharedManager();
|
||||
await manager.syncAdvancedContinuityArtifacts(instancePath, {
|
||||
mode: 'shared',
|
||||
group: 'sprint-a',
|
||||
continuityMode: 'standard',
|
||||
});
|
||||
|
||||
const localTodoDir = path.join(instancePath, 'todos');
|
||||
expect(fs.lstatSync(localTodoDir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(localTodoDir, 'todo.md'), 'utf8')).toContain('shared todo');
|
||||
});
|
||||
|
||||
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
|
||||
const ccsDir = getTestCcsDir();
|
||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||
|
||||
@@ -108,6 +108,7 @@ describe('web-server account-routes context normalization', () => {
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string;
|
||||
continuity_mode?: string;
|
||||
context_inferred?: boolean;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
@@ -117,6 +118,7 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(work?.context_mode).toBe('isolated');
|
||||
expect(work?.context_inferred).toBe(true);
|
||||
expect(work && 'context_group' in work).toBe(false);
|
||||
expect(work && 'continuity_mode' in work).toBe(false);
|
||||
});
|
||||
|
||||
it('falls back shared accounts with invalid groups to default shared group', async () => {
|
||||
@@ -147,7 +149,9 @@ describe('web-server account-routes context normalization', () => {
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string;
|
||||
continuity_mode?: string;
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
|
||||
@@ -155,7 +159,9 @@ describe('web-server account-routes context normalization', () => {
|
||||
expect(work).toBeTruthy();
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('default');
|
||||
expect(work?.continuity_mode).toBe('standard');
|
||||
expect(work?.context_inferred).toBe(false);
|
||||
expect(work?.continuity_inferred).toBe(true);
|
||||
});
|
||||
|
||||
it('does not delete metadata when instance deletion fails', async () => {
|
||||
@@ -220,23 +226,34 @@ describe('web-server account-routes context normalization', () => {
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/context', {
|
||||
context_mode: 'shared',
|
||||
context_group: ' Team Alpha ',
|
||||
continuity_mode: 'deeper',
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as {
|
||||
context_mode: string;
|
||||
context_group: string | null;
|
||||
continuity_mode?: string | null;
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
};
|
||||
expect(payload.context_mode).toBe('shared');
|
||||
expect(payload.context_group).toBe('team-alpha');
|
||||
expect(payload.continuity_mode).toBe('deeper');
|
||||
expect(payload.context_inferred).toBe(false);
|
||||
expect(payload.continuity_inferred).toBe(false);
|
||||
|
||||
const accountsPayload = await getJson<{
|
||||
accounts: Array<{ name: string; context_mode?: string; context_group?: string }>;
|
||||
accounts: Array<{
|
||||
name: string;
|
||||
context_mode?: string;
|
||||
context_group?: string;
|
||||
continuity_mode?: string;
|
||||
}>;
|
||||
}>(baseUrl, '/api/accounts');
|
||||
const work = accountsPayload.accounts.find((account) => account.name === 'work');
|
||||
expect(work?.context_mode).toBe('shared');
|
||||
expect(work?.context_group).toBe('team-alpha');
|
||||
expect(work?.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('rejects shared mode updates without context_group', async () => {
|
||||
@@ -273,10 +290,43 @@ describe('web-server account-routes context normalization', () => {
|
||||
const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', {
|
||||
context_mode: 'shared',
|
||||
context_group: 'default',
|
||||
continuity_mode: 'deeper',
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('CLIProxy');
|
||||
});
|
||||
|
||||
it('rejects invalid continuity mode updates', async () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(ccsDir, 'config.yaml'),
|
||||
[
|
||||
'version: 8',
|
||||
'accounts:',
|
||||
' work:',
|
||||
' created: "2026-02-01T00:00:00.000Z"',
|
||||
' last_used: null',
|
||||
'profiles: {}',
|
||||
'cliproxy:',
|
||||
' oauth_accounts: {}',
|
||||
' providers: {}',
|
||||
' variants: {}',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const response = await putJson(baseUrl, '/api/accounts/work/context', {
|
||||
context_mode: 'shared',
|
||||
context_group: 'default',
|
||||
continuity_mode: 'extreme',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('continuity_mode');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,47 @@ describe('web-server config-routes account context validation', () => {
|
||||
expect(payload.error).toContain('context_group requires context_mode=shared');
|
||||
});
|
||||
|
||||
it('rejects continuity_mode when mode is not shared', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
accounts: {
|
||||
work: {
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'isolated',
|
||||
continuity_mode: 'deeper',
|
||||
},
|
||||
},
|
||||
profiles: {},
|
||||
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('continuity_mode requires context_mode=shared');
|
||||
});
|
||||
|
||||
it('rejects invalid shared continuity_mode values', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
accounts: {
|
||||
work: {
|
||||
created: '2026-01-01T00:00:00.000Z',
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'team-alpha',
|
||||
continuity_mode: 'extreme',
|
||||
},
|
||||
},
|
||||
profiles: {},
|
||||
cliproxy: { oauth_accounts: {}, providers: [], variants: {} },
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const payload = (await response.json()) as { error: string };
|
||||
expect(payload.error).toContain('continuity_mode');
|
||||
});
|
||||
|
||||
it('rejects invalid shared context_group names', async () => {
|
||||
const response = await putJson(baseUrl, '/api/config', {
|
||||
version: 8,
|
||||
@@ -162,6 +203,7 @@ describe('web-server config-routes account context validation', () => {
|
||||
last_used: null,
|
||||
context_mode: 'shared',
|
||||
context_group: 'Sprint-A',
|
||||
continuity_mode: 'deeper',
|
||||
};
|
||||
const response = await putJson(baseUrl, '/api/config', config);
|
||||
|
||||
@@ -171,6 +213,7 @@ describe('web-server config-routes account context validation', () => {
|
||||
|
||||
const savedConfig = loadUnifiedConfig();
|
||||
expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
|
||||
expect(savedConfig?.accounts.work.continuity_mode).toBe('deeper');
|
||||
});
|
||||
|
||||
it('returns alreadyMigrated when migration is not needed', async () => {
|
||||
|
||||
@@ -90,7 +90,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
},
|
||||
{
|
||||
id: 'context',
|
||||
header: 'Context',
|
||||
header: 'History Sync',
|
||||
size: 170,
|
||||
cell: ({ row }) => {
|
||||
if (row.original.type === 'cliproxy') {
|
||||
@@ -100,7 +100,19 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
||||
const mode = row.original.context_mode || 'isolated';
|
||||
if (mode === 'shared') {
|
||||
const group = row.original.context_group || 'default';
|
||||
return <span className="text-muted-foreground">shared ({group})</span>;
|
||||
if (row.original.continuity_mode === 'deeper') {
|
||||
return <span className="text-muted-foreground">shared ({group}, deeper)</span>;
|
||||
}
|
||||
|
||||
if (row.original.continuity_inferred) {
|
||||
return (
|
||||
<span className="text-amber-700 dark:text-amber-400">
|
||||
shared ({group}, standard legacy)
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return <span className="text-muted-foreground">shared ({group}, standard)</span>;
|
||||
}
|
||||
|
||||
if (row.original.context_inferred) {
|
||||
|
||||
@@ -28,6 +28,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
const [profileName, setProfileName] = useState('');
|
||||
const [shareContext, setShareContext] = useState(false);
|
||||
const [contextGroup, setContextGroup] = useState('');
|
||||
const [deeperContinuity, setDeeperContinuity] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Validate profile name: alphanumeric, dash, underscore only
|
||||
@@ -47,6 +48,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
? `--context-group ${normalizedGroup}`
|
||||
: '--share-context'
|
||||
: '',
|
||||
shareContext && deeperContinuity ? '--deeper-continuity' : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
@@ -63,6 +65,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
setProfileName('');
|
||||
setShareContext(false);
|
||||
setContextGroup('');
|
||||
setDeeperContinuity(false);
|
||||
setCopied(false);
|
||||
onClose();
|
||||
};
|
||||
@@ -74,7 +77,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
<DialogTitle>Create New Account</DialogTitle>
|
||||
<DialogDescription>
|
||||
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
|
||||
edit context mode/group later from the Accounts table.
|
||||
edit sync mode, group, and continuity depth later from the Accounts table.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -104,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
onCheckedChange={(checked) => setShareContext(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="share-context" className="cursor-pointer">
|
||||
Share project context with other accounts
|
||||
Enable shared history sync with other ccs auth accounts
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{shareContext && (
|
||||
<div className="space-y-2 pl-6">
|
||||
<Label htmlFor="context-group">Context Group (optional)</Label>
|
||||
<Label htmlFor="context-group">History Sync Group (optional)</Label>
|
||||
<Input
|
||||
id="context-group"
|
||||
value={contextGroup}
|
||||
@@ -121,6 +124,20 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave empty to use the default shared group. Spaces are normalized to dashes.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
<Checkbox
|
||||
id="deeper-continuity"
|
||||
checked={deeperContinuity}
|
||||
onCheckedChange={(checked) => setDeeperContinuity(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="deeper-continuity" className="cursor-pointer">
|
||||
Advanced: deeper continuity mode
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Adds sync for <code>session-env</code>, <code>file-history</code>,{' '}
|
||||
<code>shell-snapshots</code>, and <code>todos</code>. Credentials stay isolated.
|
||||
</p>
|
||||
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
||||
<p className="text-xs text-destructive">
|
||||
Group must start with a letter and use only letters, numbers, dashes, or
|
||||
@@ -158,6 +175,10 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
||||
<li>Complete the Claude login in your browser</li>
|
||||
<li>Return here and refresh to see the new account</li>
|
||||
</ol>
|
||||
<p className="pt-1">
|
||||
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
|
||||
page action button.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
|
||||
@@ -21,6 +21,7 @@ import type { Account } from '@/lib/api-client';
|
||||
import { useUpdateAccountContext } from '@/hooks/use-accounts';
|
||||
|
||||
type ContextMode = 'isolated' | 'shared';
|
||||
type ContinuityMode = 'standard' | 'deeper';
|
||||
|
||||
const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
||||
@@ -36,6 +37,9 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
||||
account.context_mode === 'shared' ? 'shared' : 'isolated'
|
||||
);
|
||||
const [group, setGroup] = useState(account.context_group || 'default');
|
||||
const [continuityMode, setContinuityMode] = useState<ContinuityMode>(
|
||||
account.continuity_mode === 'deeper' ? 'deeper' : 'standard'
|
||||
);
|
||||
|
||||
const normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
|
||||
const isSharedGroupValid =
|
||||
@@ -54,6 +58,7 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
||||
name: account.name,
|
||||
context_mode: mode,
|
||||
context_group: mode === 'shared' ? normalizedGroup : undefined,
|
||||
continuity_mode: mode === 'shared' ? continuityMode : undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
@@ -73,34 +78,36 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
||||
<Dialog open onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Context Mode</DialogTitle>
|
||||
<DialogTitle>Edit History Sync</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure how "{account.name}" shares project workspace context with other accounts.
|
||||
Configure how "{account.name}" shares history and continuity with other
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
accounts.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-mode">Context Mode</Label>
|
||||
<Label htmlFor="context-mode">Sync Mode</Label>
|
||||
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
|
||||
<SelectTrigger id="context-mode">
|
||||
<SelectValue placeholder="Select context mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="isolated">isolated</SelectItem>
|
||||
<SelectItem value="shared">shared</SelectItem>
|
||||
<SelectItem value="isolated">isolated (no sync)</SelectItem>
|
||||
<SelectItem value="shared">shared (sync enabled)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{mode === 'shared'
|
||||
? 'Shared mode reuses workspace context for accounts in the same group.'
|
||||
: 'Isolated mode keeps this account workspace context separate.'}
|
||||
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
|
||||
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mode === 'shared' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="context-group">Context Group</Label>
|
||||
<Label htmlFor="context-group">History Sync Group</Label>
|
||||
<Input
|
||||
id="context-group"
|
||||
value={group}
|
||||
@@ -120,8 +127,31 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'shared' && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="continuity-mode">Continuity Depth</Label>
|
||||
<Select
|
||||
value={continuityMode}
|
||||
onValueChange={(value) => setContinuityMode(value as ContinuityMode)}
|
||||
>
|
||||
<SelectTrigger id="continuity-mode">
|
||||
<SelectValue placeholder="Select continuity depth" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="standard">standard (projects only)</SelectItem>
|
||||
<SelectItem value="deeper">deeper continuity (advanced)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{continuityMode === 'deeper'
|
||||
? 'Advanced mode also syncs session-env, file-history, shell-snapshots, and todos.'
|
||||
: 'Standard mode syncs project workspace context only.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Credentials stay isolated per account. Only project workspace context is shared.
|
||||
Credentials and `.anthropic` remain isolated per account in all modes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,7 +13,10 @@ export interface AuthAccountsView {
|
||||
default: string | null;
|
||||
cliproxyCount: number;
|
||||
legacyContextCount: number;
|
||||
legacyContinuityCount: number;
|
||||
sharedCount: number;
|
||||
sharedStandardCount: number;
|
||||
deeperSharedCount: number;
|
||||
isolatedCount: number;
|
||||
}
|
||||
|
||||
@@ -27,8 +30,18 @@ export function useAccounts() {
|
||||
const sharedCount = authAccounts.filter(
|
||||
(account) => account.context_mode === 'shared'
|
||||
).length;
|
||||
const deeperSharedCount = authAccounts.filter(
|
||||
(account) => account.context_mode === 'shared' && account.continuity_mode === 'deeper'
|
||||
).length;
|
||||
const sharedStandardCount = Math.max(sharedCount - deeperSharedCount, 0);
|
||||
const isolatedCount = authAccounts.length - sharedCount;
|
||||
const legacyContextCount = authAccounts.filter((account) => account.context_inferred).length;
|
||||
const legacyContinuityCount = authAccounts.filter(
|
||||
(account) =>
|
||||
account.context_mode === 'shared' &&
|
||||
account.continuity_mode !== 'deeper' &&
|
||||
account.continuity_inferred
|
||||
).length;
|
||||
const defaultAccount = authAccounts.some((account) => account.name === data.default)
|
||||
? data.default
|
||||
: null;
|
||||
@@ -38,7 +51,10 @@ export function useAccounts() {
|
||||
default: defaultAccount,
|
||||
cliproxyCount,
|
||||
legacyContextCount,
|
||||
legacyContinuityCount,
|
||||
sharedCount,
|
||||
sharedStandardCount,
|
||||
deeperSharedCount,
|
||||
isolatedCount,
|
||||
};
|
||||
},
|
||||
@@ -98,16 +114,20 @@ export function useUpdateAccountContext() {
|
||||
name,
|
||||
context_mode,
|
||||
context_group,
|
||||
continuity_mode,
|
||||
}: {
|
||||
name: string;
|
||||
context_mode: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
}) => api.accounts.updateContext(name, { context_mode, context_group }),
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}) => api.accounts.updateContext(name, { context_mode, context_group, continuity_mode }),
|
||||
onSuccess: (_data, vars) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||
const contextSummary =
|
||||
vars.context_mode === 'shared'
|
||||
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')})`
|
||||
? vars.continuity_mode === 'deeper'
|
||||
? `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, deeper continuity)`
|
||||
: `shared (${(vars.context_group || 'default').trim().toLowerCase().replace(/\s+/g, '-')}, standard)`
|
||||
: 'isolated';
|
||||
toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
|
||||
},
|
||||
|
||||
@@ -455,7 +455,9 @@ export interface Account {
|
||||
last_used?: string | null;
|
||||
context_mode?: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
context_inferred?: boolean;
|
||||
continuity_inferred?: boolean;
|
||||
provider?: string;
|
||||
displayName?: string;
|
||||
}
|
||||
@@ -463,6 +465,7 @@ export interface Account {
|
||||
export interface UpdateAccountContext {
|
||||
context_mode: 'isolated' | 'shared';
|
||||
context_group?: string;
|
||||
continuity_mode?: 'standard' | 'deeper';
|
||||
}
|
||||
|
||||
// Unified config types
|
||||
|
||||
+83
-26
@@ -5,7 +5,7 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowRight, Link2, Plus, Unlink, Users, Zap } from 'lucide-react';
|
||||
import { ArrowRight, Link2, Plus, Unlink, Users, Waves, Zap } from 'lucide-react';
|
||||
import { AccountsTable } from '@/components/account/accounts-table';
|
||||
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,7 +21,10 @@ export function AccountsPage() {
|
||||
const authAccounts = data?.accounts || [];
|
||||
const cliproxyCount = data?.cliproxyCount || 0;
|
||||
const legacyContextCount = data?.legacyContextCount || 0;
|
||||
const legacyContinuityCount = data?.legacyContinuityCount || 0;
|
||||
const sharedCount = data?.sharedCount || 0;
|
||||
const sharedStandardCount = data?.sharedStandardCount || 0;
|
||||
const deeperSharedCount = data?.deeperSharedCount || 0;
|
||||
const isolatedCount = data?.isolatedCount || 0;
|
||||
|
||||
return (
|
||||
@@ -29,13 +32,13 @@ export function AccountsPage() {
|
||||
<div className="rounded-xl border bg-gradient-to-br from-background via-background to-muted/40 p-5 sm:p-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="space-y-2">
|
||||
<Badge variant="outline">CCS Auth Accounts</Badge>
|
||||
<Badge variant="outline">ccs auth Continuity</Badge>
|
||||
<h1 className="text-2xl font-bold tracking-tight">Accounts</h1>
|
||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||
This page is dedicated to{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5">ccs auth</code> accounts. Choose
|
||||
isolated mode to keep context separate, or shared mode to link context across selected
|
||||
accounts.
|
||||
This page manages
|
||||
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||
accounts only. Choose isolated, shared-standard, or shared-deeper continuity per
|
||||
account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -44,17 +47,48 @@ export function AccountsPage() {
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Account
|
||||
</Button>
|
||||
{cliproxyCount > 0 && (
|
||||
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
|
||||
Manage OAuth ({cliproxyCount})
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
|
||||
Open CLIProxy Claude Pool
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Card className="border-emerald-200/70 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
|
||||
Lane A: ccs auth continuity
|
||||
</CardDescription>
|
||||
<CardTitle className="text-lg text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
Profile isolation + opt-in sync
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Use this when you want per-account control over isolated vs shared history behavior.
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-blue-200/70 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-blue-800/90 dark:text-blue-300">
|
||||
Lane B: CLIProxy Claude pool
|
||||
</CardDescription>
|
||||
<CardTitle className="text-lg text-blue-700 dark:text-blue-300 flex items-center gap-2">
|
||||
<Zap className="w-5 h-5" />
|
||||
OAuth pool and lower manual switching
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
Use CLIProxy when you want pooled Claude OAuth accounts and easier account routing
|
||||
behavior.
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription>Total Auth Accounts</CardDescription>
|
||||
@@ -68,11 +102,23 @@ export function AccountsPage() {
|
||||
<Card className="border-emerald-200/70 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
|
||||
Shared (Linked)
|
||||
Shared Standard
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl font-mono text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
|
||||
<Link2 className="w-5 h-5" />
|
||||
{sharedCount}
|
||||
{sharedStandardCount}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
<Card className="border-indigo-200/70 bg-indigo-50/40 dark:border-indigo-900/40 dark:bg-indigo-900/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-indigo-800/90 dark:text-indigo-300">
|
||||
Shared Deeper
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl font-mono text-indigo-700 dark:text-indigo-300 flex items-center gap-2">
|
||||
<Waves className="w-5 h-5" />
|
||||
{deeperSharedCount}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
@@ -80,7 +126,7 @@ export function AccountsPage() {
|
||||
<Card className="border-blue-200/70 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardDescription className="text-blue-800/90 dark:text-blue-300">
|
||||
Isolated (Separate)
|
||||
Isolated
|
||||
</CardDescription>
|
||||
<CardTitle className="text-2xl font-mono text-blue-700 dark:text-blue-300 flex items-center gap-2">
|
||||
<Unlink className="w-5 h-5" />
|
||||
@@ -93,11 +139,10 @@ export function AccountsPage() {
|
||||
{cliproxyCount > 0 && (
|
||||
<Alert variant="info">
|
||||
<Zap className="h-4 w-4" />
|
||||
<AlertTitle>OAuth accounts are managed elsewhere</AlertTitle>
|
||||
<AlertTitle>CLIProxy accounts are intentionally excluded from this table</AlertTitle>
|
||||
<AlertDescription>
|
||||
This screen hides {cliproxyCount} CLIProxy OAuth account
|
||||
{cliproxyCount > 1 ? 's' : ''}. Use <strong>CLIProxy Plus</strong> to manage Gemini,
|
||||
Codex, Antigravity, and other OAuth providers.
|
||||
{cliproxyCount} CLIProxy OAuth account{cliproxyCount > 1 ? 's are' : ' is'} available.
|
||||
Manage them in <strong>CLIProxy Plus</strong> to enable account pool usage.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -105,21 +150,33 @@ export function AccountsPage() {
|
||||
{legacyContextCount > 0 && (
|
||||
<Alert variant="warning">
|
||||
<Users className="h-4 w-4" />
|
||||
<AlertTitle>Legacy accounts need context review</AlertTitle>
|
||||
<AlertTitle>Legacy accounts need first-time sync mode review</AlertTitle>
|
||||
<AlertDescription>
|
||||
{legacyContextCount} account{legacyContextCount > 1 ? 's were' : ' was'} onboarded
|
||||
before context controls and currently default to isolated mode. Use the pencil action in
|
||||
the table to explicitly choose isolated or shared.
|
||||
before explicit context controls. Use the pencil action to confirm isolated or shared
|
||||
behavior.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{legacyContinuityCount > 0 && (
|
||||
<Alert variant="warning">
|
||||
<Waves className="h-4 w-4" />
|
||||
<AlertTitle>Shared legacy accounts default to standard continuity</AlertTitle>
|
||||
<AlertDescription>
|
||||
{legacyContinuityCount} shared account
|
||||
{legacyContinuityCount > 1 ? 's are' : ' is'} currently on legacy standard depth. Edit
|
||||
and switch to deeper continuity only when you intentionally want broader history sync.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-lg">CCS Auth Accounts</CardTitle>
|
||||
<CardTitle className="text-lg">ccs auth Accounts</CardTitle>
|
||||
<CardDescription>
|
||||
New onboarding: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
|
||||
Existing accounts: use the pencil action to control linkage flexibility per account.
|
||||
Shared total: {sharedCount}. Create accounts here, then tune per-account sync mode and
|
||||
continuity depth from the table.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
|
||||
Reference in New Issue
Block a user