mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 18:16:29 +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 |
|
| `isolated` | Yes | No `context_group` required |
|
||||||
| `shared` | No (explicit opt-in) | Valid non-empty `context_group` |
|
| `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:
|
Opt in to shared context when needed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -277,13 +282,16 @@ ccs auth create backup --share-context
|
|||||||
|
|
||||||
# Share context only within named group
|
# Share context only within named group
|
||||||
ccs auth create backup2 --context-group sprint-a
|
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:
|
Update existing accounts without recreating login:
|
||||||
|
|
||||||
1. Run `ccs config`
|
1. Run `ccs config`
|
||||||
2. Open `Accounts`
|
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`:
|
Shared mode metadata in `~/.ccs/config.yaml`:
|
||||||
|
|
||||||
@@ -294,6 +302,7 @@ accounts:
|
|||||||
last_used: null
|
last_used: null
|
||||||
context_mode: "shared"
|
context_mode: "shared"
|
||||||
context_group: "team-alpha"
|
context_group: "team-alpha"
|
||||||
|
continuity_mode: "standard"
|
||||||
```
|
```
|
||||||
|
|
||||||
`context_group` rules:
|
`context_group` rules:
|
||||||
@@ -304,7 +313,11 @@ accounts:
|
|||||||
- non-empty after normalization
|
- non-empty after normalization
|
||||||
- normalized by trim + lowercase + whitespace collapse (`" Team Alpha "` -> `"team-alpha"`)
|
- 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)
|
Technical details: [`docs/session-sharing-technical-analysis.md`](docs/session-sharing-technical-analysis.md)
|
||||||
|
|
||||||
|
|||||||
@@ -203,15 +203,16 @@ src/
|
|||||||
|
|
||||||
### Account Context Metadata Flow
|
### 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`.
|
- Runtime policy resolver: `src/auth/account-context.ts`.
|
||||||
- Metadata storage normalization: `src/auth/profile-registry.ts`.
|
- Metadata storage normalization: `src/auth/profile-registry.ts`.
|
||||||
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
|
- API write validation: `PUT /api/config` in `src/web-server/routes/config-routes.ts`.
|
||||||
- Rules:
|
- Rules:
|
||||||
- mode is isolation-first (`isolated` default, `shared` opt-in)
|
- mode is isolation-first (`isolated` default, `shared` opt-in)
|
||||||
- shared mode requires non-empty valid `context_group`
|
- 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 `-`)
|
- `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
|
- registry normalization drops malformed persisted `context_group` values
|
||||||
|
|
||||||
### Target Adapter Module
|
### Target Adapter Module
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ Account context is isolation-first:
|
|||||||
| `isolated` | Yes | No `context_group` required |
|
| `isolated` | Yes | No `context_group` required |
|
||||||
| `shared` | No (opt-in) | Valid non-empty `context_group` |
|
| `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:
|
`context_group` normalization and validation:
|
||||||
|
|
||||||
- trim + lowercase + collapse internal whitespace to `-`
|
- trim + lowercase + collapse internal whitespace to `-`
|
||||||
@@ -31,17 +36,21 @@ Account context is isolation-first:
|
|||||||
- must start with a letter
|
- must start with a letter
|
||||||
- max length: 64
|
- max length: 64
|
||||||
- shared mode requires non-empty value after normalization
|
- shared mode requires non-empty value after normalization
|
||||||
|
- `continuity_mode` is only valid when mode is `shared`
|
||||||
|
|
||||||
`PUT /api/config` behavior for account context:
|
`PUT /api/config` behavior for account context:
|
||||||
|
|
||||||
- rejects invalid unified payloads
|
- rejects invalid unified payloads
|
||||||
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
|
- rejects explicit `context_mode: shared` with invalid/empty `context_group`
|
||||||
|
- rejects invalid `continuity_mode` values
|
||||||
- normalizes valid shared `context_group` before save
|
- normalizes valid shared `context_group` before save
|
||||||
|
- defaults missing shared `continuity_mode` to `standard`
|
||||||
- rejects `context_group` when mode is not `shared`
|
- rejects `context_group` when mode is not `shared`
|
||||||
|
- rejects `continuity_mode` when mode is not `shared`
|
||||||
|
|
||||||
Dashboard accounts context editing:
|
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
|
- rejects CLIProxy OAuth account keys for this route
|
||||||
- applies normalization/validation rules above
|
- 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:
|
This is implemented as a context policy per account:
|
||||||
|
|
||||||
- `isolated` (default): account keeps its own workspace context
|
- `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
|
## Why This Is Safe Enough
|
||||||
|
|
||||||
@@ -28,16 +29,27 @@ accounts:
|
|||||||
last_used: null
|
last_used: null
|
||||||
context_mode: "shared"
|
context_mode: "shared"
|
||||||
context_group: "team-alpha"
|
context_group: "team-alpha"
|
||||||
|
continuity_mode: "deeper"
|
||||||
```
|
```
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- `context_mode` must be `isolated` or `shared`
|
- `context_mode` must be `isolated` or `shared`
|
||||||
- `context_group` is required when `context_mode=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 normalization: trim, lowercase, internal spaces -> `-`
|
||||||
- group must start with a letter and only include `[a-zA-Z0-9_-]`
|
- group must start with a letter and only include `[a-zA-Z0-9_-]`
|
||||||
- max length: `64`
|
- 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
|
## User Workflows
|
||||||
|
|
||||||
### New account with shared context
|
### New account with shared context
|
||||||
@@ -45,14 +57,15 @@ Rules:
|
|||||||
```bash
|
```bash
|
||||||
ccs auth create work2 --share-context
|
ccs auth create work2 --share-context
|
||||||
ccs auth create backup --context-group sprint-a
|
ccs auth create backup --context-group sprint-a
|
||||||
|
ccs auth create backup2 --context-group sprint-a --deeper-continuity
|
||||||
```
|
```
|
||||||
|
|
||||||
### Existing account
|
### Existing account
|
||||||
|
|
||||||
- Open `ccs config`
|
- Open `ccs config`
|
||||||
- Go to `Accounts`
|
- Go to `Accounts`
|
||||||
- Click the pencil icon (`Edit Context`)
|
- Click the pencil icon (`Edit History Sync`)
|
||||||
- Choose `isolated` or `shared` and set group
|
- Choose `isolated` or `shared`, set group, and (optionally) choose deeper continuity
|
||||||
|
|
||||||
No account recreation required for this workflow.
|
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.
|
- 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.
|
- 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
|
## Validation Checklist
|
||||||
|
|
||||||
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
|
- Confirm account row shows `shared (<group>)` in Dashboard Accounts table
|
||||||
- Switch between accounts in the same group and verify workspace continuity
|
- Switch between accounts in the same group and verify workspace continuity
|
||||||
- Run `ccs doctor` if symlink/context health looks inconsistent
|
- Run `ccs doctor` if symlink/context health looks inconsistent
|
||||||
|
|
||||||
|
|||||||
@@ -6,20 +6,24 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export type AccountContextMode = 'isolated' | 'shared';
|
export type AccountContextMode = 'isolated' | 'shared';
|
||||||
|
export type AccountContinuityMode = 'standard' | 'deeper';
|
||||||
|
|
||||||
export interface AccountContextMetadata {
|
export interface AccountContextMetadata {
|
||||||
context_mode?: AccountContextMode;
|
context_mode?: AccountContextMode;
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: AccountContinuityMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccountContextPolicy {
|
export interface AccountContextPolicy {
|
||||||
mode: AccountContextMode;
|
mode: AccountContextMode;
|
||||||
group?: string;
|
group?: string;
|
||||||
|
continuityMode?: AccountContinuityMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateAccountContextInput {
|
export interface CreateAccountContextInput {
|
||||||
shareContext: boolean;
|
shareContext: boolean;
|
||||||
contextGroup?: string;
|
contextGroup?: string;
|
||||||
|
deeperContinuity?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedCreateAccountContext {
|
export interface ResolvedCreateAccountContext {
|
||||||
@@ -29,6 +33,7 @@ export interface ResolvedCreateAccountContext {
|
|||||||
|
|
||||||
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
|
export const DEFAULT_ACCOUNT_CONTEXT_MODE: AccountContextMode = 'isolated';
|
||||||
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
|
export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
|
||||||
|
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
|
||||||
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
export const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||||
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
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 candidate = value as Record<string, unknown>;
|
||||||
const mode = candidate['context_mode'];
|
const mode = candidate['context_mode'];
|
||||||
const group = candidate['context_group'];
|
const group = candidate['context_group'];
|
||||||
|
const continuity = candidate['continuity_mode'];
|
||||||
|
|
||||||
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
|
const modeValid = mode === undefined || mode === 'isolated' || mode === 'shared';
|
||||||
const groupValid = group === undefined || typeof group === 'string';
|
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
|
input: CreateAccountContextInput
|
||||||
): ResolvedCreateAccountContext {
|
): ResolvedCreateAccountContext {
|
||||||
const hasGroupFlag = input.contextGroup !== undefined;
|
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 (hasGroupFlag) {
|
||||||
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
|
if (!input.contextGroup || input.contextGroup.trim().length === 0) {
|
||||||
@@ -101,6 +126,7 @@ export function resolveCreateAccountContext(
|
|||||||
policy: {
|
policy: {
|
||||||
mode: 'shared',
|
mode: 'shared',
|
||||||
group: normalizedGroup,
|
group: normalizedGroup,
|
||||||
|
continuityMode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -110,6 +136,7 @@ export function resolveCreateAccountContext(
|
|||||||
policy: {
|
policy: {
|
||||||
mode: 'shared',
|
mode: 'shared',
|
||||||
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
|
group: DEFAULT_ACCOUNT_CONTEXT_GROUP,
|
||||||
|
continuityMode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -128,15 +155,21 @@ export function resolveAccountContextPolicy(
|
|||||||
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
|
const mode: AccountContextMode = metadata?.context_mode === 'shared' ? 'shared' : 'isolated';
|
||||||
|
|
||||||
if (mode === 'shared') {
|
if (mode === 'shared') {
|
||||||
|
const continuityMode: AccountContinuityMode =
|
||||||
|
metadata?.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||||
const rawGroup = metadata?.context_group;
|
const rawGroup = metadata?.context_group;
|
||||||
if (rawGroup && rawGroup.trim().length > 0) {
|
if (rawGroup && rawGroup.trim().length > 0) {
|
||||||
const normalized = normalizeContextGroupName(rawGroup);
|
const normalized = normalizeContextGroupName(rawGroup);
|
||||||
if (isValidContextGroupName(normalized)) {
|
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' };
|
return { mode: 'isolated' };
|
||||||
@@ -152,6 +185,8 @@ export function policyToAccountContextMetadata(
|
|||||||
return {
|
return {
|
||||||
context_mode: 'shared',
|
context_mode: 'shared',
|
||||||
context_group: policy.group || DEFAULT_ACCOUNT_CONTEXT_GROUP,
|
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 {
|
export function formatAccountContextPolicy(policy: AccountContextPolicy): string {
|
||||||
if (policy.mode === 'shared') {
|
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';
|
return 'isolated';
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ class AuthCommands {
|
|||||||
console.log(` ${dim('# Share context only within a specific group')}`);
|
console.log(` ${dim('# Share context only within a specific group')}`);
|
||||||
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
|
console.log(` ${color('ccs auth create backup --context-group sprint-a', 'command')}`);
|
||||||
console.log('');
|
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(` ${dim('# Set work as default')}`);
|
||||||
console.log(` ${color('ccs auth default work', 'command')}`);
|
console.log(` ${color('ccs auth default work', 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -108,6 +113,9 @@ class AuthCommands {
|
|||||||
console.log(
|
console.log(
|
||||||
` ${color('--context-group <name>', 'command')} Share context only within a named group`
|
` ${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(
|
console.log(
|
||||||
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
` ${color('--yes, -y', 'command')} Skip confirmation prompts (remove)`
|
||||||
);
|
);
|
||||||
@@ -128,6 +136,9 @@ class AuthCommands {
|
|||||||
console.log(
|
console.log(
|
||||||
` Account profiles stay isolated unless you opt in with ${color('--share-context', 'command')}.`
|
` 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(
|
console.log(
|
||||||
` Existing profiles: open ${color('ccs config', 'command')} -> Accounts -> Edit Context.`
|
` 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> {
|
export async function handleCreate(ctx: CommandContext, args: string[]): Promise<void> {
|
||||||
await initUI();
|
await initUI();
|
||||||
const { profileName, force, shareContext, contextGroup, unknownFlags } = parseArgs(args);
|
const { profileName, force, shareContext, contextGroup, deeperContinuity, unknownFlags } =
|
||||||
|
parseArgs(args);
|
||||||
|
|
||||||
if (unknownFlags && unknownFlags.length > 0) {
|
if (unknownFlags && unknownFlags.length > 0) {
|
||||||
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', ');
|
||||||
console.log(fail(`Unknown option(s): ${unknownList}`));
|
console.log(fail(`Unknown option(s): ${unknownList}`));
|
||||||
console.log('');
|
console.log('');
|
||||||
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(`Help: ${color('ccs auth --help', 'command')}`);
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -49,7 +50,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
console.log(fail('Profile name is required'));
|
console.log(fail('Profile name is required'));
|
||||||
console.log('');
|
console.log('');
|
||||||
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('');
|
||||||
console.log('Example:');
|
console.log('Example:');
|
||||||
@@ -89,6 +90,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
const resolvedContext = resolveCreateAccountContext({
|
const resolvedContext = resolveCreateAccountContext({
|
||||||
shareContext: !!shareContext,
|
shareContext: !!shareContext,
|
||||||
contextGroup,
|
contextGroup,
|
||||||
|
deeperContinuity: !!deeperContinuity,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (resolvedContext.error) {
|
if (resolvedContext.error) {
|
||||||
@@ -212,7 +214,9 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
|
|||||||
console.log('');
|
console.log('');
|
||||||
const launchDescription =
|
const launchDescription =
|
||||||
contextPolicy.mode === 'shared'
|
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...';
|
: 'Starting Claude in isolated instance...';
|
||||||
console.log(warn(launchDescription));
|
console.log(warn(launchDescription));
|
||||||
console.log(warn('You will be prompted to login with your account.'));
|
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,
|
last_used: account.last_used,
|
||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
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,
|
last_used: profile.last_used || null,
|
||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group || null,
|
context_group: contextPolicy.group || null,
|
||||||
|
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||||
instance_path: instancePath,
|
instance_path: instancePath,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
@@ -134,7 +136,7 @@ export async function handleList(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
console.log(
|
console.log(
|
||||||
table(rows, {
|
table(rows, {
|
||||||
head: headers,
|
head: headers,
|
||||||
colWidths: verbose ? [15, 12, 15, 12, 18] : [15, 12, 15],
|
colWidths: verbose ? [15, 12, 15, 12, 34] : [15, 12, 15],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function handleShow(ctx: CommandContext, args: string[]): Promise<v
|
|||||||
last_used: profile.last_used || null,
|
last_used: profile.last_used || null,
|
||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group || null,
|
context_group: contextPolicy.group || null,
|
||||||
|
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : null,
|
||||||
instance_path: instancePath,
|
instance_path: instancePath,
|
||||||
session_count: sessionCount,
|
session_count: sessionCount,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export interface AuthCommandArgs {
|
|||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
shareContext?: boolean;
|
shareContext?: boolean;
|
||||||
contextGroup?: string;
|
contextGroup?: string;
|
||||||
|
deeperContinuity?: boolean;
|
||||||
unknownFlags?: string[];
|
unknownFlags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ export interface ProfileOutput {
|
|||||||
last_used: string | null;
|
last_used: string | null;
|
||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string | null;
|
context_group?: string | null;
|
||||||
|
continuity_mode?: 'standard' | 'deeper' | null;
|
||||||
instance_path?: string;
|
instance_path?: string;
|
||||||
session_count?: number;
|
session_count?: number;
|
||||||
}
|
}
|
||||||
@@ -70,6 +72,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
|||||||
'--yes',
|
'--yes',
|
||||||
'-y',
|
'-y',
|
||||||
'--share-context',
|
'--share-context',
|
||||||
|
'--deeper-continuity',
|
||||||
]);
|
]);
|
||||||
const knownValueFlags = new Set(['--context-group']);
|
const knownValueFlags = new Set(['--context-group']);
|
||||||
|
|
||||||
@@ -121,6 +124,7 @@ export function parseArgs(args: string[]): AuthCommandArgs {
|
|||||||
json: args.includes('--json'),
|
json: args.includes('--json'),
|
||||||
yes: args.includes('--yes') || args.includes('-y'),
|
yes: args.includes('--yes') || args.includes('-y'),
|
||||||
shareContext: args.includes('--share-context'),
|
shareContext: args.includes('--share-context'),
|
||||||
|
deeperContinuity: args.includes('--deeper-continuity'),
|
||||||
contextGroup,
|
contextGroup,
|
||||||
unknownFlags: [...unknownFlags],
|
unknownFlags: [...unknownFlags],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ class ProfileDetector {
|
|||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
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
|
* last_used: <ISO timestamp or null> // Last usage time
|
||||||
* context_mode?: 'isolated' | 'shared' // Workspace context policy
|
* context_mode?: 'isolated' | 'shared' // Workspace context policy
|
||||||
* context_group?: <string> // Shared context group when mode=shared
|
* context_group?: <string> // Shared context group when mode=shared
|
||||||
|
* continuity_mode?: 'standard' | 'deeper' // Shared continuity depth
|
||||||
* }
|
* }
|
||||||
*
|
*
|
||||||
* Removed fields from v2.x:
|
* Removed fields from v2.x:
|
||||||
@@ -43,6 +44,7 @@ interface CreateMetadata {
|
|||||||
last_used?: string | null;
|
last_used?: string | null;
|
||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ProfileRegistry {
|
export class ProfileRegistry {
|
||||||
@@ -70,6 +72,7 @@ export class ProfileRegistry {
|
|||||||
|
|
||||||
if (normalized.context_mode !== 'shared') {
|
if (normalized.context_mode !== 'shared') {
|
||||||
delete normalized.context_group;
|
delete normalized.context_group;
|
||||||
|
delete normalized.continuity_mode;
|
||||||
} else {
|
} else {
|
||||||
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
||||||
if (normalizedGroup) {
|
if (normalizedGroup) {
|
||||||
@@ -77,6 +80,8 @@ export class ProfileRegistry {
|
|||||||
} else {
|
} else {
|
||||||
delete normalized.context_group;
|
delete normalized.context_group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -87,6 +92,7 @@ export class ProfileRegistry {
|
|||||||
|
|
||||||
if (normalized.context_mode !== 'shared') {
|
if (normalized.context_mode !== 'shared') {
|
||||||
delete normalized.context_group;
|
delete normalized.context_group;
|
||||||
|
delete normalized.continuity_mode;
|
||||||
} else {
|
} else {
|
||||||
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
const normalizedGroup = this.normalizeContextGroupValue(normalized.context_group);
|
||||||
if (normalizedGroup) {
|
if (normalizedGroup) {
|
||||||
@@ -94,6 +100,8 @@ export class ProfileRegistry {
|
|||||||
} else {
|
} else {
|
||||||
delete normalized.context_group;
|
delete normalized.context_group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalized.continuity_mode = normalized.continuity_mode === 'deeper' ? 'deeper' : 'standard';
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -164,6 +172,7 @@ export class ProfileRegistry {
|
|||||||
last_used: metadata.last_used || null,
|
last_used: metadata.last_used || null,
|
||||||
context_mode: metadata.context_mode,
|
context_mode: metadata.context_mode,
|
||||||
context_group: metadata.context_group,
|
context_group: metadata.context_group,
|
||||||
|
continuity_mode: metadata.continuity_mode,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Note: No longer auto-set as default
|
// Note: No longer auto-set as default
|
||||||
@@ -311,6 +320,7 @@ export class ProfileRegistry {
|
|||||||
last_used: null,
|
last_used: null,
|
||||||
context_mode: metadata.context_mode,
|
context_mode: metadata.context_mode,
|
||||||
context_group: metadata.context_group,
|
context_group: metadata.context_group,
|
||||||
|
continuity_mode: metadata.continuity_mode,
|
||||||
});
|
});
|
||||||
saveUnifiedConfig(config);
|
saveUnifiedConfig(config);
|
||||||
}
|
}
|
||||||
@@ -438,6 +448,7 @@ export class ProfileRegistry {
|
|||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
context_mode: account.context_mode,
|
context_mode: account.context_mode,
|
||||||
context_group: account.context_group,
|
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'],
|
['Run multiple Claude accounts concurrently'],
|
||||||
[
|
[
|
||||||
['ccs auth --help', 'Show account management commands'],
|
['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 list', 'List all account profiles'],
|
||||||
['ccs auth default <name>', 'Set default profile'],
|
['ccs auth default <name>', 'Set default profile'],
|
||||||
['ccs auth reset-default', 'Restore original CCS default'],
|
['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 metadata = meta as Record<string, unknown>;
|
||||||
const rawContextMode = metadata.context_mode;
|
const rawContextMode = metadata.context_mode;
|
||||||
const rawContextGroup = metadata.context_group;
|
const rawContextGroup = metadata.context_group;
|
||||||
|
const rawContinuityMode = metadata.continuity_mode;
|
||||||
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
|
const contextMode = rawContextMode === 'shared' ? 'shared' : 'isolated';
|
||||||
|
const continuityMode =
|
||||||
|
contextMode === 'shared' && rawContinuityMode === 'deeper' ? 'deeper' : 'standard';
|
||||||
let contextGroup: string | undefined;
|
let contextGroup: string | undefined;
|
||||||
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
|
if (typeof rawContextGroup === 'string' && rawContextGroup.trim().length > 0) {
|
||||||
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
|
const normalizedGroup = normalizeContextGroupName(rawContextGroup);
|
||||||
@@ -168,6 +171,7 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
|
|||||||
last_used: (metadata.last_used as string) || null,
|
last_used: (metadata.last_used as string) || null,
|
||||||
context_mode: contextMode,
|
context_mode: contextMode,
|
||||||
context_group: contextMode === 'shared' ? contextGroup : undefined,
|
context_group: contextMode === 'shared' ? contextGroup : undefined,
|
||||||
|
continuity_mode: contextMode === 'shared' ? continuityMode : undefined,
|
||||||
};
|
};
|
||||||
unifiedConfig.accounts[name] = account;
|
unifiedConfig.accounts[name] = account;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ export interface AccountConfig {
|
|||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
/** Context-sharing group when context_mode='shared' */
|
/** Context-sharing group when context_mode='shared' */
|
||||||
context_group?: string;
|
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).
|
// Apply context policy (isolated by default, optional shared group).
|
||||||
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
await this.sharedManager.syncProjectContext(instancePath, contextPolicy);
|
||||||
|
await this.sharedManager.syncAdvancedContinuityArtifacts(instancePath, contextPolicy);
|
||||||
});
|
});
|
||||||
|
|
||||||
return instancePath;
|
return instancePath;
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ class SharedManager {
|
|||||||
private readonly claudeDir: string;
|
private readonly claudeDir: string;
|
||||||
private readonly instancesDir: string;
|
private readonly instancesDir: string;
|
||||||
private readonly sharedItems: SharedItem[];
|
private readonly sharedItems: SharedItem[];
|
||||||
|
private readonly advancedContinuityItems: readonly string[] = [
|
||||||
|
'session-env',
|
||||||
|
'file-history',
|
||||||
|
'shell-snapshots',
|
||||||
|
'todos',
|
||||||
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.homeDir = os.homedir();
|
this.homeDir = os.homedir();
|
||||||
@@ -314,6 +320,133 @@ class SharedManager {
|
|||||||
await this.ensureDirectory(projectsPath);
|
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.
|
* 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.
|
* Link directory with Windows fallback to recursive copy.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -96,6 +96,8 @@ export interface ProfileMetadata {
|
|||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
/** Context-sharing group when context_mode='shared' */
|
/** Context-sharing group when context_mode='shared' */
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
/** Shared continuity depth when context_mode='shared' */
|
||||||
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProfilesRegistry {
|
export interface ProfilesRegistry {
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ export interface MergedAccountEntry {
|
|||||||
last_used: string | null;
|
last_used: string | null;
|
||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
|
continuity_inferred?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
} from '../../cliproxy/account-manager';
|
} from '../../cliproxy/account-manager';
|
||||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_ACCOUNT_CONTINUITY_MODE,
|
||||||
isValidContextGroupName,
|
isValidContextGroupName,
|
||||||
normalizeContextGroupName,
|
normalizeContextGroupName,
|
||||||
resolveAccountContextPolicy,
|
resolveAccountContextPolicy,
|
||||||
@@ -58,13 +59,18 @@ router.get('/', (_req: Request, res: Response): void => {
|
|||||||
const contextPolicy = resolveAccountContextPolicy(meta);
|
const contextPolicy = resolveAccountContextPolicy(meta);
|
||||||
const hasExplicitContextMode =
|
const hasExplicitContextMode =
|
||||||
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
meta.context_mode === 'isolated' || meta.context_mode === 'shared';
|
||||||
|
const hasExplicitContinuityMode =
|
||||||
|
meta.continuity_mode === 'standard' || meta.continuity_mode === 'deeper';
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
type: meta.type || 'account',
|
type: meta.type || 'account',
|
||||||
created: meta.created,
|
created: meta.created,
|
||||||
last_used: meta.last_used || null,
|
last_used: meta.last_used || null,
|
||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group,
|
context_group: contextPolicy.group,
|
||||||
|
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
|
||||||
context_inferred: !hasExplicitContextMode,
|
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 contextPolicy = resolveAccountContextPolicy(account);
|
||||||
const hasExplicitContextMode =
|
const hasExplicitContextMode =
|
||||||
account.context_mode === 'isolated' || account.context_mode === 'shared';
|
account.context_mode === 'isolated' || account.context_mode === 'shared';
|
||||||
|
const hasExplicitContinuityMode =
|
||||||
|
account.continuity_mode === 'standard' || account.continuity_mode === 'deeper';
|
||||||
merged[name] = {
|
merged[name] = {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
created: account.created,
|
created: account.created,
|
||||||
last_used: account.last_used,
|
last_used: account.last_used,
|
||||||
context_mode: contextPolicy.mode,
|
context_mode: contextPolicy.mode,
|
||||||
context_group: contextPolicy.group,
|
context_group: contextPolicy.group,
|
||||||
|
continuity_mode: contextPolicy.mode === 'shared' ? contextPolicy.continuityMode : undefined,
|
||||||
context_inferred: !hasExplicitContextMode,
|
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 mode = req.body?.context_mode;
|
||||||
const rawGroup = req.body?.context_group;
|
const rawGroup = req.body?.context_group;
|
||||||
|
const rawContinuityMode = req.body?.continuity_mode;
|
||||||
|
|
||||||
if (mode !== 'isolated' && mode !== 'shared') {
|
if (mode !== 'isolated' && mode !== 'shared') {
|
||||||
res.status(400).json({ error: 'Missing or invalid context_mode: expected isolated|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;
|
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 normalizedGroup: string | undefined;
|
||||||
|
let continuityMode: 'standard' | 'deeper' | undefined;
|
||||||
if (mode === 'shared') {
|
if (mode === 'shared') {
|
||||||
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
|
if (typeof rawGroup !== 'string' || rawGroup.trim().length === 0) {
|
||||||
res
|
res
|
||||||
@@ -219,6 +239,19 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
});
|
});
|
||||||
return;
|
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 =
|
const metadata =
|
||||||
@@ -226,6 +259,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
? {
|
? {
|
||||||
context_mode: 'shared' as const,
|
context_mode: 'shared' as const,
|
||||||
context_group: normalizedGroup,
|
context_group: normalizedGroup,
|
||||||
|
continuity_mode: continuityMode,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
context_mode: 'isolated' as const,
|
context_mode: 'isolated' as const,
|
||||||
@@ -258,7 +292,12 @@ router.put('/:name/context', async (req: Request, res: Response): Promise<void>
|
|||||||
name,
|
name,
|
||||||
context_mode: policy.mode,
|
context_mode: policy.mode,
|
||||||
context_group: policy.group ?? null,
|
context_group: policy.group ?? null,
|
||||||
|
continuity_mode:
|
||||||
|
policy.mode === 'shared'
|
||||||
|
? (policy.continuityMode ?? DEFAULT_ACCOUNT_CONTINUITY_MODE)
|
||||||
|
: null,
|
||||||
context_inferred: false,
|
context_inferred: false,
|
||||||
|
continuity_inferred: false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: (error as Error).message });
|
res.status(500).json({ error: (error as Error).message });
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ import {
|
|||||||
getBackupDirectories,
|
getBackupDirectories,
|
||||||
} from '../../config/migration-manager';
|
} from '../../config/migration-manager';
|
||||||
import { isUnifiedConfig } from '../../config/unified-config-types';
|
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();
|
const router = Router();
|
||||||
|
|
||||||
@@ -45,6 +49,7 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
|
|||||||
const account = accountValue as Record<string, unknown>;
|
const account = accountValue as Record<string, unknown>;
|
||||||
const mode = account.context_mode;
|
const mode = account.context_mode;
|
||||||
const group = account.context_group;
|
const group = account.context_group;
|
||||||
|
const continuity = account.continuity_mode;
|
||||||
|
|
||||||
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
|
if (mode !== undefined && mode !== 'isolated' && mode !== 'shared') {
|
||||||
return `Invalid config.accounts.${accountName}.context_mode: expected isolated|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`;
|
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) {
|
if (mode !== 'shared' && group !== undefined) {
|
||||||
return `Invalid config.accounts.${accountName}: context_group requires context_mode=shared`;
|
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) {
|
if (mode === 'shared' && typeof group === 'string' && group.trim().length > 0) {
|
||||||
const normalizedGroup = normalizeContextGroupName(group);
|
const normalizedGroup = normalizeContextGroupName(group);
|
||||||
if (!isValidContextGroupName(normalizedGroup)) {
|
if (!isValidContextGroupName(normalizedGroup)) {
|
||||||
@@ -66,6 +79,11 @@ function validateAndNormalizeAccountContextMetadata(config: unknown): string | n
|
|||||||
account.context_group = normalizedGroup;
|
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) {
|
if (mode === 'shared' && typeof group === 'string' && group.trim().length === 0) {
|
||||||
return `Invalid config.accounts.${accountName}.context_group: shared mode requires a non-empty value`;
|
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) {
|
if (mode === 'isolated' && group !== undefined) {
|
||||||
delete account.context_group;
|
delete account.context_group;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (mode === 'isolated' && continuity !== undefined) {
|
||||||
|
delete account.continuity_mode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -51,4 +51,35 @@ describe('account context helpers', () => {
|
|||||||
expect(result.policy.mode).toBe('shared');
|
expect(result.policy.mode).toBe('shared');
|
||||||
expect(result.policy.group).toBe('team-alpha');
|
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('');
|
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', () => {
|
it('tracks unknown flags and keeps positional profile intact', () => {
|
||||||
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
const parsed = parseArgs(['--foo', 'bar', 'work']);
|
||||||
|
|
||||||
|
|||||||
@@ -77,13 +77,19 @@ describe('auth list context metadata', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = JSON.parse(lines.join('\n')) as {
|
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');
|
const work = payload.profiles.find((profile) => profile.name === 'work');
|
||||||
|
|
||||||
expect(work).toBeTruthy();
|
expect(work).toBeTruthy();
|
||||||
expect(work?.context_mode).toBe('shared');
|
expect(work?.context_mode).toBe('shared');
|
||||||
expect(work?.context_group).toBe('sprint-a');
|
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 () => {
|
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 {
|
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');
|
const work = payload.profiles.find((profile) => profile.name === 'work');
|
||||||
|
|
||||||
expect(work).toBeTruthy();
|
expect(work).toBeTruthy();
|
||||||
expect(work?.context_mode).toBe('shared');
|
expect(work?.context_mode).toBe('shared');
|
||||||
expect(work?.context_group).toBe('sprint-a');
|
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_mode).toBe('shared');
|
||||||
expect(profile.context_group).toBeUndefined();
|
expect(profile.context_group).toBeUndefined();
|
||||||
|
expect(profile.continuity_mode).toBe('standard');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('drops non-string unified context_group values without throwing', () => {
|
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_mode).toBe('shared');
|
||||||
expect(accounts.work.context_group).toBeUndefined();
|
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).toBeTruthy();
|
||||||
expect(unified?.accounts.work.context_mode).toBe('shared');
|
expect(unified?.accounts.work.context_mode).toBe('shared');
|
||||||
expect(unified?.accounts.work.context_group).toBe('sprint-a');
|
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_mode).toBe('isolated');
|
||||||
expect(unified?.accounts.personal.context_group).toBeUndefined();
|
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 () => {
|
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.work.context_group).toBe('sprint-a');
|
||||||
expect(unified?.accounts.broken.context_mode).toBe('shared');
|
expect(unified?.accounts.broken.context_mode).toBe('shared');
|
||||||
expect(unified?.accounts.broken.context_group).toBeUndefined();
|
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 };
|
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 () => {
|
it('keeps projects isolated by default', async () => {
|
||||||
const { instancePath } = await applyPolicy({ mode: 'isolated' });
|
const { instancePath } = await applyPolicy({ mode: 'isolated' });
|
||||||
const projectsPath = path.join(instancePath, 'projects');
|
const projectsPath = path.join(instancePath, 'projects');
|
||||||
@@ -141,6 +155,62 @@ describe('SharedManager context policy', () => {
|
|||||||
expect(stats.isDirectory() || stats.isSymbolicLink()).toBe(true);
|
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 () => {
|
it('skips merge when projects symlink target is outside canonical CCS roots', async () => {
|
||||||
const ccsDir = getTestCcsDir();
|
const ccsDir = getTestCcsDir();
|
||||||
const instancePath = path.join(ccsDir, 'instances', 'work');
|
const instancePath = path.join(ccsDir, 'instances', 'work');
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
name: string;
|
name: string;
|
||||||
context_mode?: string;
|
context_mode?: string;
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: string;
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
}>;
|
}>;
|
||||||
}>(baseUrl, '/api/accounts');
|
}>(baseUrl, '/api/accounts');
|
||||||
@@ -117,6 +118,7 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
expect(work?.context_mode).toBe('isolated');
|
expect(work?.context_mode).toBe('isolated');
|
||||||
expect(work?.context_inferred).toBe(true);
|
expect(work?.context_inferred).toBe(true);
|
||||||
expect(work && 'context_group' in work).toBe(false);
|
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 () => {
|
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;
|
name: string;
|
||||||
context_mode?: string;
|
context_mode?: string;
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: string;
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
|
continuity_inferred?: boolean;
|
||||||
}>;
|
}>;
|
||||||
}>(baseUrl, '/api/accounts');
|
}>(baseUrl, '/api/accounts');
|
||||||
|
|
||||||
@@ -155,7 +159,9 @@ describe('web-server account-routes context normalization', () => {
|
|||||||
expect(work).toBeTruthy();
|
expect(work).toBeTruthy();
|
||||||
expect(work?.context_mode).toBe('shared');
|
expect(work?.context_mode).toBe('shared');
|
||||||
expect(work?.context_group).toBe('default');
|
expect(work?.context_group).toBe('default');
|
||||||
|
expect(work?.continuity_mode).toBe('standard');
|
||||||
expect(work?.context_inferred).toBe(false);
|
expect(work?.context_inferred).toBe(false);
|
||||||
|
expect(work?.continuity_inferred).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not delete metadata when instance deletion fails', async () => {
|
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', {
|
const response = await putJson(baseUrl, '/api/accounts/work/context', {
|
||||||
context_mode: 'shared',
|
context_mode: 'shared',
|
||||||
context_group: ' Team Alpha ',
|
context_group: ' Team Alpha ',
|
||||||
|
continuity_mode: 'deeper',
|
||||||
});
|
});
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
const payload = (await response.json()) as {
|
const payload = (await response.json()) as {
|
||||||
context_mode: string;
|
context_mode: string;
|
||||||
context_group: string | null;
|
context_group: string | null;
|
||||||
|
continuity_mode?: string | null;
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
|
continuity_inferred?: boolean;
|
||||||
};
|
};
|
||||||
expect(payload.context_mode).toBe('shared');
|
expect(payload.context_mode).toBe('shared');
|
||||||
expect(payload.context_group).toBe('team-alpha');
|
expect(payload.context_group).toBe('team-alpha');
|
||||||
|
expect(payload.continuity_mode).toBe('deeper');
|
||||||
expect(payload.context_inferred).toBe(false);
|
expect(payload.context_inferred).toBe(false);
|
||||||
|
expect(payload.continuity_inferred).toBe(false);
|
||||||
|
|
||||||
const accountsPayload = await getJson<{
|
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');
|
}>(baseUrl, '/api/accounts');
|
||||||
const work = accountsPayload.accounts.find((account) => account.name === 'work');
|
const work = accountsPayload.accounts.find((account) => account.name === 'work');
|
||||||
expect(work?.context_mode).toBe('shared');
|
expect(work?.context_mode).toBe('shared');
|
||||||
expect(work?.context_group).toBe('team-alpha');
|
expect(work?.context_group).toBe('team-alpha');
|
||||||
|
expect(work?.continuity_mode).toBe('deeper');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects shared mode updates without context_group', async () => {
|
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', {
|
const response = await putJson(baseUrl, '/api/accounts/gemini:test/context', {
|
||||||
context_mode: 'shared',
|
context_mode: 'shared',
|
||||||
context_group: 'default',
|
context_group: 'default',
|
||||||
|
continuity_mode: 'deeper',
|
||||||
});
|
});
|
||||||
expect(response.status).toBe(400);
|
expect(response.status).toBe(400);
|
||||||
|
|
||||||
const payload = (await response.json()) as { error: string };
|
const payload = (await response.json()) as { error: string };
|
||||||
expect(payload.error).toContain('CLIProxy');
|
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');
|
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 () => {
|
it('rejects invalid shared context_group names', async () => {
|
||||||
const response = await putJson(baseUrl, '/api/config', {
|
const response = await putJson(baseUrl, '/api/config', {
|
||||||
version: 8,
|
version: 8,
|
||||||
@@ -162,6 +203,7 @@ describe('web-server config-routes account context validation', () => {
|
|||||||
last_used: null,
|
last_used: null,
|
||||||
context_mode: 'shared',
|
context_mode: 'shared',
|
||||||
context_group: 'Sprint-A',
|
context_group: 'Sprint-A',
|
||||||
|
continuity_mode: 'deeper',
|
||||||
};
|
};
|
||||||
const response = await putJson(baseUrl, '/api/config', config);
|
const response = await putJson(baseUrl, '/api/config', config);
|
||||||
|
|
||||||
@@ -171,6 +213,7 @@ describe('web-server config-routes account context validation', () => {
|
|||||||
|
|
||||||
const savedConfig = loadUnifiedConfig();
|
const savedConfig = loadUnifiedConfig();
|
||||||
expect(savedConfig?.accounts.work.context_group).toBe('sprint-a');
|
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 () => {
|
it('returns alreadyMigrated when migration is not needed', async () => {
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'context',
|
id: 'context',
|
||||||
header: 'Context',
|
header: 'History Sync',
|
||||||
size: 170,
|
size: 170,
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
if (row.original.type === 'cliproxy') {
|
if (row.original.type === 'cliproxy') {
|
||||||
@@ -100,7 +100,19 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
|
|||||||
const mode = row.original.context_mode || 'isolated';
|
const mode = row.original.context_mode || 'isolated';
|
||||||
if (mode === 'shared') {
|
if (mode === 'shared') {
|
||||||
const group = row.original.context_group || 'default';
|
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) {
|
if (row.original.context_inferred) {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
const [profileName, setProfileName] = useState('');
|
const [profileName, setProfileName] = useState('');
|
||||||
const [shareContext, setShareContext] = useState(false);
|
const [shareContext, setShareContext] = useState(false);
|
||||||
const [contextGroup, setContextGroup] = useState('');
|
const [contextGroup, setContextGroup] = useState('');
|
||||||
|
const [deeperContinuity, setDeeperContinuity] = useState(false);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
// Validate profile name: alphanumeric, dash, underscore only
|
// Validate profile name: alphanumeric, dash, underscore only
|
||||||
@@ -47,6 +48,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
? `--context-group ${normalizedGroup}`
|
? `--context-group ${normalizedGroup}`
|
||||||
: '--share-context'
|
: '--share-context'
|
||||||
: '',
|
: '',
|
||||||
|
shareContext && deeperContinuity ? '--deeper-continuity' : '',
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(' ')
|
||||||
@@ -63,6 +65,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
setProfileName('');
|
setProfileName('');
|
||||||
setShareContext(false);
|
setShareContext(false);
|
||||||
setContextGroup('');
|
setContextGroup('');
|
||||||
|
setDeeperContinuity(false);
|
||||||
setCopied(false);
|
setCopied(false);
|
||||||
onClose();
|
onClose();
|
||||||
};
|
};
|
||||||
@@ -74,7 +77,7 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
<DialogTitle>Create New Account</DialogTitle>
|
<DialogTitle>Create New Account</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Auth profiles require Claude CLI login. Run the command below in your terminal. You can
|
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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -104,13 +107,13 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
onCheckedChange={(checked) => setShareContext(checked === true)}
|
onCheckedChange={(checked) => setShareContext(checked === true)}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="share-context" className="cursor-pointer">
|
<Label htmlFor="share-context" className="cursor-pointer">
|
||||||
Share project context with other accounts
|
Enable shared history sync with other ccs auth accounts
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareContext && (
|
{shareContext && (
|
||||||
<div className="space-y-2 pl-6">
|
<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
|
<Input
|
||||||
id="context-group"
|
id="context-group"
|
||||||
value={contextGroup}
|
value={contextGroup}
|
||||||
@@ -121,6 +124,20 @@ export function CreateAuthProfileDialog({ open, onClose }: CreateAuthProfileDial
|
|||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Leave empty to use the default shared group. Spaces are normalized to dashes.
|
Leave empty to use the default shared group. Spaces are normalized to dashes.
|
||||||
</p>
|
</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 && (
|
{contextGroup.trim().length > 0 && !isValidContextGroup && (
|
||||||
<p className="text-xs text-destructive">
|
<p className="text-xs text-destructive">
|
||||||
Group must start with a letter and use only letters, numbers, dashes, or
|
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>Complete the Claude login in your browser</li>
|
||||||
<li>Return here and refresh to see the new account</li>
|
<li>Return here and refresh to see the new account</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
<p className="pt-1">
|
||||||
|
Prefer pooled Claude OAuth routing instead? Use CLIProxy Claude pool from the Accounts
|
||||||
|
page action button.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-2">
|
<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';
|
import { useUpdateAccountContext } from '@/hooks/use-accounts';
|
||||||
|
|
||||||
type ContextMode = 'isolated' | 'shared';
|
type ContextMode = 'isolated' | 'shared';
|
||||||
|
type ContinuityMode = 'standard' | 'deeper';
|
||||||
|
|
||||||
const MAX_CONTEXT_GROUP_LENGTH = 64;
|
const MAX_CONTEXT_GROUP_LENGTH = 64;
|
||||||
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
|
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'
|
account.context_mode === 'shared' ? 'shared' : 'isolated'
|
||||||
);
|
);
|
||||||
const [group, setGroup] = useState(account.context_group || 'default');
|
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 normalizedGroup = useMemo(() => group.trim().toLowerCase().replace(/\s+/g, '-'), [group]);
|
||||||
const isSharedGroupValid =
|
const isSharedGroupValid =
|
||||||
@@ -54,6 +58,7 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
context_mode: mode,
|
context_mode: mode,
|
||||||
context_group: mode === 'shared' ? normalizedGroup : undefined,
|
context_group: mode === 'shared' ? normalizedGroup : undefined,
|
||||||
|
continuity_mode: mode === 'shared' ? continuityMode : undefined,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -73,34 +78,36 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
|||||||
<Dialog open onOpenChange={handleOpenChange}>
|
<Dialog open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit Context Mode</DialogTitle>
|
<DialogTitle>Edit History Sync</DialogTitle>
|
||||||
<DialogDescription>
|
<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>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-2">
|
<div className="space-y-4 py-2">
|
||||||
<div className="space-y-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)}>
|
<Select value={mode} onValueChange={(value) => setMode(value as ContextMode)}>
|
||||||
<SelectTrigger id="context-mode">
|
<SelectTrigger id="context-mode">
|
||||||
<SelectValue placeholder="Select context mode" />
|
<SelectValue placeholder="Select context mode" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="isolated">isolated</SelectItem>
|
<SelectItem value="isolated">isolated (no sync)</SelectItem>
|
||||||
<SelectItem value="shared">shared</SelectItem>
|
<SelectItem value="shared">shared (sync enabled)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{mode === 'shared'
|
{mode === 'shared'
|
||||||
? 'Shared mode reuses workspace context for accounts in the same group.'
|
? 'Shared mode reuses workspace context for accounts in the same history sync group.'
|
||||||
: 'Isolated mode keeps this account workspace context separate.'}
|
: 'Isolated mode keeps this account fully separate from other ccs auth accounts.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mode === 'shared' && (
|
{mode === 'shared' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="context-group">Context Group</Label>
|
<Label htmlFor="context-group">History Sync Group</Label>
|
||||||
<Input
|
<Input
|
||||||
id="context-group"
|
id="context-group"
|
||||||
value={group}
|
value={group}
|
||||||
@@ -120,8 +127,31 @@ export function EditAccountContextDialog({ account, onClose }: EditAccountContex
|
|||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ export interface AuthAccountsView {
|
|||||||
default: string | null;
|
default: string | null;
|
||||||
cliproxyCount: number;
|
cliproxyCount: number;
|
||||||
legacyContextCount: number;
|
legacyContextCount: number;
|
||||||
|
legacyContinuityCount: number;
|
||||||
sharedCount: number;
|
sharedCount: number;
|
||||||
|
sharedStandardCount: number;
|
||||||
|
deeperSharedCount: number;
|
||||||
isolatedCount: number;
|
isolatedCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,8 +30,18 @@ export function useAccounts() {
|
|||||||
const sharedCount = authAccounts.filter(
|
const sharedCount = authAccounts.filter(
|
||||||
(account) => account.context_mode === 'shared'
|
(account) => account.context_mode === 'shared'
|
||||||
).length;
|
).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 isolatedCount = authAccounts.length - sharedCount;
|
||||||
const legacyContextCount = authAccounts.filter((account) => account.context_inferred).length;
|
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)
|
const defaultAccount = authAccounts.some((account) => account.name === data.default)
|
||||||
? data.default
|
? data.default
|
||||||
: null;
|
: null;
|
||||||
@@ -38,7 +51,10 @@ export function useAccounts() {
|
|||||||
default: defaultAccount,
|
default: defaultAccount,
|
||||||
cliproxyCount,
|
cliproxyCount,
|
||||||
legacyContextCount,
|
legacyContextCount,
|
||||||
|
legacyContinuityCount,
|
||||||
sharedCount,
|
sharedCount,
|
||||||
|
sharedStandardCount,
|
||||||
|
deeperSharedCount,
|
||||||
isolatedCount,
|
isolatedCount,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -98,16 +114,20 @@ export function useUpdateAccountContext() {
|
|||||||
name,
|
name,
|
||||||
context_mode,
|
context_mode,
|
||||||
context_group,
|
context_group,
|
||||||
|
continuity_mode,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
context_mode: 'isolated' | 'shared';
|
context_mode: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
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) => {
|
onSuccess: (_data, vars) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
queryClient.invalidateQueries({ queryKey: ['accounts'] });
|
||||||
const contextSummary =
|
const contextSummary =
|
||||||
vars.context_mode === 'shared'
|
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';
|
: 'isolated';
|
||||||
toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
|
toast.success(`Updated "${vars.name}" context to ${contextSummary}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -455,7 +455,9 @@ export interface Account {
|
|||||||
last_used?: string | null;
|
last_used?: string | null;
|
||||||
context_mode?: 'isolated' | 'shared';
|
context_mode?: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
context_inferred?: boolean;
|
context_inferred?: boolean;
|
||||||
|
continuity_inferred?: boolean;
|
||||||
provider?: string;
|
provider?: string;
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
}
|
}
|
||||||
@@ -463,6 +465,7 @@ export interface Account {
|
|||||||
export interface UpdateAccountContext {
|
export interface UpdateAccountContext {
|
||||||
context_mode: 'isolated' | 'shared';
|
context_mode: 'isolated' | 'shared';
|
||||||
context_group?: string;
|
context_group?: string;
|
||||||
|
continuity_mode?: 'standard' | 'deeper';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unified config types
|
// Unified config types
|
||||||
|
|||||||
+83
-26
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 { AccountsTable } from '@/components/account/accounts-table';
|
||||||
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
import { CreateAuthProfileDialog } from '@/components/account/create-auth-profile-dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -21,7 +21,10 @@ export function AccountsPage() {
|
|||||||
const authAccounts = data?.accounts || [];
|
const authAccounts = data?.accounts || [];
|
||||||
const cliproxyCount = data?.cliproxyCount || 0;
|
const cliproxyCount = data?.cliproxyCount || 0;
|
||||||
const legacyContextCount = data?.legacyContextCount || 0;
|
const legacyContextCount = data?.legacyContextCount || 0;
|
||||||
|
const legacyContinuityCount = data?.legacyContinuityCount || 0;
|
||||||
const sharedCount = data?.sharedCount || 0;
|
const sharedCount = data?.sharedCount || 0;
|
||||||
|
const sharedStandardCount = data?.sharedStandardCount || 0;
|
||||||
|
const deeperSharedCount = data?.deeperSharedCount || 0;
|
||||||
const isolatedCount = data?.isolatedCount || 0;
|
const isolatedCount = data?.isolatedCount || 0;
|
||||||
|
|
||||||
return (
|
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="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="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
<div className="space-y-2">
|
<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>
|
<h1 className="text-2xl font-bold tracking-tight">Accounts</h1>
|
||||||
<p className="text-sm text-muted-foreground max-w-2xl">
|
<p className="text-sm text-muted-foreground max-w-2xl">
|
||||||
This page is dedicated to{' '}
|
This page manages
|
||||||
<code className="rounded bg-muted px-1 py-0.5">ccs auth</code> accounts. Choose
|
<code className="mx-1 rounded bg-muted px-1 py-0.5">ccs auth</code>
|
||||||
isolated mode to keep context separate, or shared mode to link context across selected
|
accounts only. Choose isolated, shared-standard, or shared-deeper continuity per
|
||||||
accounts.
|
account.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -44,17 +47,48 @@ export function AccountsPage() {
|
|||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
Create Account
|
Create Account
|
||||||
</Button>
|
</Button>
|
||||||
{cliproxyCount > 0 && (
|
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
|
||||||
<Button variant="outline" onClick={() => navigate('/cliproxy')}>
|
Open CLIProxy Claude Pool
|
||||||
Manage OAuth ({cliproxyCount})
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
</Button>
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription>Total Auth Accounts</CardDescription>
|
<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">
|
<Card className="border-emerald-200/70 bg-emerald-50/40 dark:border-emerald-900/40 dark:bg-emerald-900/10">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
|
<CardDescription className="text-emerald-800/90 dark:text-emerald-300">
|
||||||
Shared (Linked)
|
Shared Standard
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardTitle className="text-2xl font-mono text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
|
<CardTitle className="text-2xl font-mono text-emerald-700 dark:text-emerald-300 flex items-center gap-2">
|
||||||
<Link2 className="w-5 h-5" />
|
<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>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</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">
|
<Card className="border-blue-200/70 bg-blue-50/40 dark:border-blue-900/40 dark:bg-blue-900/10">
|
||||||
<CardHeader className="pb-2">
|
<CardHeader className="pb-2">
|
||||||
<CardDescription className="text-blue-800/90 dark:text-blue-300">
|
<CardDescription className="text-blue-800/90 dark:text-blue-300">
|
||||||
Isolated (Separate)
|
Isolated
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
<CardTitle className="text-2xl font-mono text-blue-700 dark:text-blue-300 flex items-center gap-2">
|
<CardTitle className="text-2xl font-mono text-blue-700 dark:text-blue-300 flex items-center gap-2">
|
||||||
<Unlink className="w-5 h-5" />
|
<Unlink className="w-5 h-5" />
|
||||||
@@ -93,11 +139,10 @@ export function AccountsPage() {
|
|||||||
{cliproxyCount > 0 && (
|
{cliproxyCount > 0 && (
|
||||||
<Alert variant="info">
|
<Alert variant="info">
|
||||||
<Zap className="h-4 w-4" />
|
<Zap className="h-4 w-4" />
|
||||||
<AlertTitle>OAuth accounts are managed elsewhere</AlertTitle>
|
<AlertTitle>CLIProxy accounts are intentionally excluded from this table</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
This screen hides {cliproxyCount} CLIProxy OAuth account
|
{cliproxyCount} CLIProxy OAuth account{cliproxyCount > 1 ? 's are' : ' is'} available.
|
||||||
{cliproxyCount > 1 ? 's' : ''}. Use <strong>CLIProxy Plus</strong> to manage Gemini,
|
Manage them in <strong>CLIProxy Plus</strong> to enable account pool usage.
|
||||||
Codex, Antigravity, and other OAuth providers.
|
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -105,21 +150,33 @@ export function AccountsPage() {
|
|||||||
{legacyContextCount > 0 && (
|
{legacyContextCount > 0 && (
|
||||||
<Alert variant="warning">
|
<Alert variant="warning">
|
||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
<AlertTitle>Legacy accounts need context review</AlertTitle>
|
<AlertTitle>Legacy accounts need first-time sync mode review</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{legacyContextCount} account{legacyContextCount > 1 ? 's were' : ' was'} onboarded
|
{legacyContextCount} account{legacyContextCount > 1 ? 's were' : ' was'} onboarded
|
||||||
before context controls and currently default to isolated mode. Use the pencil action in
|
before explicit context controls. Use the pencil action to confirm isolated or shared
|
||||||
the table to explicitly choose 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>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-4">
|
<CardHeader className="pb-4">
|
||||||
<CardTitle className="text-lg">CCS Auth Accounts</CardTitle>
|
<CardTitle className="text-lg">ccs auth Accounts</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
New onboarding: <code className="rounded bg-muted px-1 py-0.5">Create Account</code>.
|
Shared total: {sharedCount}. Create accounts here, then tune per-account sync mode and
|
||||||
Existing accounts: use the pencil action to control linkage flexibility per account.
|
continuity depth from the table.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user