feat(auth): add resume lane diagnostics and recovery

- add runtime-aware resume lane diagnostics and auth backup flows

- warn when account resume uses a different plain ccs continuity lane

- surface lane mismatch guidance in the accounts dashboard, docs, and tests
This commit is contained in:
Tam Nhu Tran
2026-04-04 12:17:32 -04:00
parent d9f78ad490
commit 2830c2ae9e
23 changed files with 1131 additions and 25 deletions
+24
View File
@@ -522,6 +522,29 @@ accounts:
Shared context with `standard` depth links project workspace data. `deeper` depth links additional continuity artifacts. 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.
Resume is lane-scoped:
- plain `ccs -r` resumes the lane that plain `ccs` currently uses
- `ccs <account> -r` resumes that account's lane only
- those lanes can differ, even when an account is `shared + deeper`
If you do most of your work with plain `ccs` and want future resumes to line up with an auth account:
```bash
ccs auth default work
```
If you need to protect local continuity files before changing account sync settings:
```bash
ccs auth backup work
ccs auth backup default
```
- `ccs auth backup work` backs up the local continuity lane for that auth account
- `ccs auth backup default` backs up the lane plain `ccs` would use right now
- this backs up local continuity artifacts only; Claude-hosted resume behavior still depends on upstream state
#### Cross-Profile Continuity Inheritance (Claude Target) #### Cross-Profile Continuity Inheritance (Claude Target)
You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile: You can map non-account profiles (API, CLIProxy, Copilot, or `default`) to reuse continuity artifacts from an account profile:
@@ -532,6 +555,7 @@ continuity:
glm: pro glm: pro
gemini: pro gemini: pro
copilot: pro copilot: pro
default: pro
``` ```
With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings. With this config, `ccs glm`, `ccs gemini`, and `ccs copilot` run with `pro`'s `CLAUDE_CONFIG_DIR` continuity context while keeping each profile's own provider credentials/settings.
+38 -1
View File
@@ -1,6 +1,6 @@
# Session Sharing Technical Analysis # Session Sharing Technical Analysis
Last Updated: 2026-02-26 Last Updated: 2026-04-04
## Summary ## Summary
@@ -69,6 +69,30 @@ Behavior:
- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution - Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution
- Invalid/missing mapped accounts are skipped safely - Invalid/missing mapped accounts are skipped safely
### Resume Lane Note
Resume follows the active `CLAUDE_CONFIG_DIR`, not just the continuity group:
- plain `ccs -r` resumes the lane plain `ccs` is using right now
- `ccs <account> -r` resumes only that account lane
- those two commands can point at different continuity inventories
That means `shared + deeper` on an account does **not** automatically make old plain-`ccs` resume history appear inside `ccs <account> -r`.
If you want future plain `ccs` sessions to use an account lane, either:
```bash
ccs auth default work
```
or map the default profile explicitly:
```yaml
continuity:
inherit_from_account:
default: work
```
## User Workflows ## User Workflows
### New account with shared context ### New account with shared context
@@ -88,6 +112,19 @@ ccs auth create backup2 --context-group sprint-a --deeper-continuity
No account recreation required for this workflow. No account recreation required for this workflow.
### Backup Before Changing Sync
CCS can back up local continuity artifacts before you change settings:
```bash
ccs auth backup work
ccs auth backup default
```
- `ccs auth backup work` backs up the selected account lane
- `ccs auth backup default` backs up the lane plain `ccs` would use right now
- this is a local continuity backup, not a guaranteed export of all upstream Claude-hosted resume state
## Current Limitations ## Current Limitations
- Shared context is local filesystem sharing. It does not bypass remote provider permission models. - Shared context is local filesystem sharing. It does not bypass remote provider permission models.
+18
View File
@@ -21,6 +21,7 @@ import packageJson from '../../package.json';
import { import {
type CommandContext, type CommandContext,
handleCreate, handleCreate,
handleBackup,
handleList, handleList,
handleShow, handleShow,
handleRemove, handleRemove,
@@ -68,6 +69,9 @@ class AuthCommands {
console.log(''); console.log('');
console.log(subheader('Commands')); console.log(subheader('Commands'));
console.log(` ${color('create <profile>', 'command')} Create new profile and login`); console.log(` ${color('create <profile>', 'command')} Create new profile and login`);
console.log(
` ${color('backup <profile>', 'command')} Backup local continuity for an account`
);
console.log(` ${color('list', 'command')} List all saved profiles`); console.log(` ${color('list', 'command')} List all saved profiles`);
console.log(` ${color('show <profile>', 'command')} Show profile details`); console.log(` ${color('show <profile>', 'command')} Show profile details`);
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`); console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
@@ -97,6 +101,12 @@ class AuthCommands {
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('');
console.log(` ${dim('# Backup the local continuity lane for an account or plain ccs')}`);
console.log(` ${color('ccs auth backup work', 'command')}`);
console.log(
` ${color('ccs auth backup default', 'command')} ${dim('# backup plain ccs lane')}`
);
console.log('');
console.log(` ${dim('# Restore original CCS behavior')}`); console.log(` ${dim('# Restore original CCS behavior')}`);
console.log(` ${color('ccs auth reset-default', 'command')}`); console.log(` ${color('ccs auth reset-default', 'command')}`);
console.log(''); console.log('');
@@ -162,6 +172,10 @@ class AuthCommands {
return handleCreate(this.getContext(), args); return handleCreate(this.getContext(), args);
} }
async handleBackup(args: string[]): Promise<void> {
return handleBackup(this.getContext(), args);
}
/** /**
* List all profiles - delegates to list-command.ts * List all profiles - delegates to list-command.ts
*/ */
@@ -227,6 +241,10 @@ class AuthCommands {
await this.handleList(commandArgs); await this.handleList(commandArgs);
break; break;
case 'backup':
await this.handleBackup(commandArgs);
break;
case 'show': case 'show':
await this.handleShow(commandArgs); await this.handleShow(commandArgs);
break; break;
+139
View File
@@ -0,0 +1,139 @@
import * as fs from 'fs';
import * as path from 'path';
import { initUI, color, dim, fail, info, ok } from '../../utils/ui';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
import {
createTimestampStamp,
getAuthBackupRoot,
getContinuityArtifactNames,
resolveRuntimePlainCcsResumeLane,
} from '../resume-lane-diagnostics';
import { isAccountContextMetadata, resolveAccountContextPolicy } from '../account-context';
import { CommandContext, parseArgs } from './types';
interface BackupManifest {
target: string;
source_config_dir: string;
created_at: string;
copied: string[];
skipped: string[];
}
function copyDirectoryIfPresent(sourcePath: string, targetPath: string): boolean {
if (!fs.existsSync(sourcePath)) {
return false;
}
fs.cpSync(sourcePath, targetPath, {
recursive: true,
dereference: true,
force: false,
errorOnExist: false,
});
return true;
}
export async function handleBackup(ctx: CommandContext, args: string[]): Promise<void> {
await initUI();
const { profileName, json } = parseArgs(args);
if (!profileName) {
console.log(fail('Profile name is required'));
console.log('');
console.log(`Usage: ${color('ccs auth backup <profile|default> [--json]', 'command')}`);
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
}
try {
let sourceConfigDir = '';
let backupLabel = profileName;
let artifactNames: string[] = [];
if (profileName === 'default') {
const lane = await resolveRuntimePlainCcsResumeLane();
sourceConfigDir = lane.configDir;
backupLabel = lane.accountName ? `default-${lane.accountName}` : 'default';
artifactNames = getContinuityArtifactNames('default');
} else {
const profiles = ctx.registry.getAllProfilesMerged();
const profile = profiles[profileName];
if (!profile || profile.type !== 'account') {
exitWithError(
`Backup supports auth accounts or "default". Not found: ${profileName}`,
ExitCode.PROFILE_ERROR
);
}
const contextPolicy = resolveAccountContextPolicy(
isAccountContextMetadata(profile) ? profile : undefined
);
sourceConfigDir = await ctx.instanceMgr.ensureInstance(profileName, contextPolicy, {
bare: profile.bare === true,
});
artifactNames = getContinuityArtifactNames('account');
}
const backupRoot = path.join(getAuthBackupRoot(), backupLabel, createTimestampStamp());
fs.mkdirSync(backupRoot, { recursive: true, mode: 0o700 });
const copied: string[] = [];
const skipped: string[] = [];
for (const artifactName of artifactNames) {
const sourcePath = path.join(sourceConfigDir, artifactName);
const targetPath = path.join(backupRoot, artifactName);
if (copyDirectoryIfPresent(sourcePath, targetPath)) {
copied.push(artifactName);
} else {
skipped.push(artifactName);
}
}
const manifest: BackupManifest = {
target: profileName,
source_config_dir: sourceConfigDir,
created_at: new Date().toISOString(),
copied,
skipped,
};
fs.writeFileSync(
path.join(backupRoot, 'manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`,
{
encoding: 'utf8',
mode: 0o600,
}
);
if (json) {
console.log(
JSON.stringify(
{
target: profileName,
backup_path: backupRoot,
copied,
skipped,
},
null,
2
)
);
return;
}
console.log(ok(`Continuity backup created: ${backupRoot}`));
console.log('');
console.log(info(`Source lane: ${sourceConfigDir}`));
console.log(` ${dim(`Copied: ${copied.length > 0 ? copied.join(', ') : 'none'}`)}`);
if (skipped.length > 0) {
console.log(` ${dim(`Skipped: ${skipped.join(', ')}`)}`);
}
console.log('');
} catch (error) {
exitWithError(
`Failed to create continuity backup: ${(error as Error).message}`,
ExitCode.GENERAL_ERROR
);
}
}
+1
View File
@@ -16,6 +16,7 @@ export {
// Command handlers // Command handlers
export { handleCreate } from './create-command'; export { handleCreate } from './create-command';
export { handleBackup } from './backup-command';
export { handleList } from './list-command'; export { handleList } from './list-command';
export { handleShow } from './show-command'; export { handleShow } from './show-command';
export { handleRemove } from './remove-command'; export { handleRemove } from './remove-command';
+17 -10
View File
@@ -100,6 +100,19 @@ function resolveMappedAccount(
return undefined; return undefined;
} }
export function resolveConfiguredContinuitySourceAccount(
profileName: string,
profileType: ProfileType
): string | undefined {
const inheritFromAccount = getContinuityInheritanceMap();
return (
resolveMappedAccount(profileName, profileType, inheritFromAccount) ??
(!isUnifiedMode()
? resolveMappedAccount(profileName, profileType, loadLegacyContinuityInheritanceMap())
: undefined)
);
}
/** /**
* Resolve optional cross-profile continuity inheritance. * Resolve optional cross-profile continuity inheritance.
* *
@@ -115,16 +128,10 @@ export async function resolveProfileContinuityInheritance(
return {}; return {};
} }
const inheritFromAccount = getContinuityInheritanceMap(); const sourceAccount = resolveConfiguredContinuitySourceAccount(
const sourceAccount = input.profileName,
resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ?? input.profileType
(!isUnifiedMode() );
? resolveMappedAccount(
input.profileName,
input.profileType,
loadLegacyContinuityInheritanceMap()
)
: undefined);
if (!sourceAccount) { if (!sourceAccount) {
return {}; return {};
} }
+8
View File
@@ -462,6 +462,14 @@ class ProfileDetector {
}; };
} }
/**
* Public wrapper for diagnostics and tooling that need to inspect how
* plain `ccs` resolves without duplicating the default-profile logic.
*/
resolveDefaultProfileResult(): ProfileDetectionResult {
return this.resolveDefaultProfile();
}
/** /**
* List available profiles (for error messages) * List available profiles (for error messages)
*/ */
+163
View File
@@ -0,0 +1,163 @@
import * as fs from 'fs';
import * as path from 'path';
import { getDefaultClaudeConfigDir } from '../utils/claude-config-path';
import { getCcsDir } from '../utils/config-manager';
import InstanceManager from '../management/instance-manager';
import ProfileDetector from './profile-detector';
import { resolveConfiguredContinuitySourceAccount } from './profile-continuity-inheritance';
import type { ProfileType } from '../types/profile';
export type ResumeLaneKind =
| 'native'
| 'account-default'
| 'account-inherited'
| 'profile-default'
| 'ambient';
export interface ResumeLaneSummary {
kind: ResumeLaneKind;
label: string;
configDir: string;
accountName?: string;
profileName?: string;
projectCount: number;
}
export interface ResumeFlagIntent {
implicit: boolean;
explicitSessionId?: string;
}
function countTopLevelProjectDirs(projectsDir: string): number {
try {
return fs
.readdirSync(projectsDir, { withFileTypes: true })
.filter((entry) => entry.isDirectory()).length;
} catch {
return 0;
}
}
function resolveNativeLaneSummary(
kind: ResumeLaneKind = 'native',
profileName?: string
): ResumeLaneSummary {
const configDir = getDefaultClaudeConfigDir();
const label =
kind === 'profile-default' && profileName
? `profile "${profileName}" via native Claude lane`
: 'native Claude lane';
return {
kind,
label,
configDir,
profileName,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
function resolveAccountLaneSummary(
kind: Extract<ResumeLaneKind, 'account-default' | 'account-inherited'>,
accountName: string
): ResumeLaneSummary {
const instanceMgr = new InstanceManager();
const configDir = instanceMgr.getInstancePath(accountName);
return {
kind,
label:
kind === 'account-inherited'
? `plain ccs inherits from account "${accountName}"`
: `plain ccs defaults to account "${accountName}"`,
configDir,
accountName,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
export async function resolveConfiguredPlainCcsResumeLane(): Promise<ResumeLaneSummary> {
const detector = new ProfileDetector();
const defaultProfile = detector.resolveDefaultProfileResult();
if (defaultProfile.type === 'account') {
return resolveAccountLaneSummary('account-default', defaultProfile.name);
}
const inheritedAccount = resolveConfiguredContinuitySourceAccount(
defaultProfile.name,
defaultProfile.type
);
if (inheritedAccount) {
return resolveAccountLaneSummary('account-inherited', inheritedAccount);
}
if (defaultProfile.type !== 'default') {
return resolveNativeLaneSummary('profile-default', defaultProfile.name);
}
return resolveNativeLaneSummary();
}
export async function resolveRuntimePlainCcsResumeLane(): Promise<ResumeLaneSummary> {
if (process.env.CLAUDE_CONFIG_DIR) {
const configDir = path.resolve(process.env.CLAUDE_CONFIG_DIR);
return {
kind: 'ambient',
label: 'current shell CLAUDE_CONFIG_DIR',
configDir,
projectCount: countTopLevelProjectDirs(path.join(configDir, 'projects')),
};
}
return resolveConfiguredPlainCcsResumeLane();
}
export function parseResumeFlagIntent(args: string[]): ResumeFlagIntent | null {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '-r') {
return { implicit: true };
}
if (arg === '--resume') {
const next = args[index + 1];
if (next && !next.startsWith('-')) {
return { implicit: false, explicitSessionId: next };
}
return { implicit: true };
}
if (arg.startsWith('--resume=')) {
const sessionId = arg.slice('--resume='.length).trim();
return sessionId ? { implicit: false, explicitSessionId: sessionId } : { implicit: true };
}
}
return null;
}
export function getAuthBackupRoot(): string {
return path.join(getCcsDir(), 'backups', 'auth-continuity');
}
export function createTimestampStamp(date = new Date()): string {
const yyyy = date.getFullYear();
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
const hh = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const ss = String(date.getSeconds()).padStart(2, '0');
return `${yyyy}${mm}${dd}_${hh}${min}${ss}`;
}
export function getContinuityArtifactNames(profileType: ProfileType): string[] {
const sharedItems = ['projects'];
const deeperItems = ['session-env', 'file-history', 'shell-snapshots', 'todos'];
if (profileType === 'default' || profileType === 'account') {
return [...sharedItems, ...deeperItems];
}
return sharedItems;
}
+43
View File
@@ -1,6 +1,7 @@
import './utils/fetch-proxy-setup'; import './utils/fetch-proxy-setup';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path';
import { detectClaudeCli } from './utils/claude-detector'; import { detectClaudeCli } from './utils/claude-detector';
import { import {
getSettingsPath, getSettingsPath,
@@ -64,6 +65,10 @@ import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions // Import extracted utility functions
import { execClaude } from './utils/shell-executor'; import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation'; import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import {
parseResumeFlagIntent,
resolveRuntimePlainCcsResumeLane,
} from './auth/resume-lane-diagnostics';
// Import target adapter system // Import target adapter system
import { import {
@@ -259,6 +264,43 @@ function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
return getNativeCodexPassthroughArgs(args) !== null; return getNativeCodexPassthroughArgs(args) !== null;
} }
async function maybeWarnAboutResumeLaneMismatch(
profileName: string,
accountConfigDir: string,
args: string[]
): Promise<void> {
const resumeIntent = parseResumeFlagIntent(args);
if (!resumeIntent) {
return;
}
const plainLane = await resolveRuntimePlainCcsResumeLane();
if (path.resolve(plainLane.configDir) === path.resolve(accountConfigDir)) {
return;
}
console.error(
warn(
`Resume for account "${profileName}" will search that account lane, not the current plain ccs lane.`
)
);
console.error(info(` Account lane: ${accountConfigDir}`));
console.error(info(` Plain ccs lane: ${plainLane.label} (${plainLane.configDir})`));
if (resumeIntent.explicitSessionId) {
console.error(
info(
` This explicit session ID may have been created in a different lane, so Claude may not find it here.`
)
);
}
console.error(info(` Recover the original lane first: ccs -r`));
console.error(info(` Back it up before changing setup: ccs auth backup default`));
console.error(
info(` For future work, align plain ccs with this account: ccs auth default ${profileName}`)
);
console.error('');
}
function getNativeCodexPassthroughArgs(args: string[]): string[] | null { function getNativeCodexPassthroughArgs(args: string[]): string[] | null {
const targetArgs = stripTargetFlag(args); const targetArgs = stripTargetFlag(args);
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) { if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
@@ -1269,6 +1311,7 @@ async function main(): Promise<void> {
CCS_WEBSEARCH_SKIP: '1', CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1', CCS_IMAGE_ANALYSIS_SKIP: '1',
}; };
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs);
const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath); const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath);
execClaude(claudeCli, launchArgs, envVars); execClaude(claudeCli, launchArgs, envVars);
} else { } else {
+1
View File
@@ -215,6 +215,7 @@ export const ROOT_COMMAND_FLAGS = [
] as const; ] as const;
export const AUTH_SUBCOMMANDS = [ export const AUTH_SUBCOMMANDS = [
'create', 'create',
'backup',
'list', 'list',
'show', 'show',
'remove', 'remove',
+2
View File
@@ -94,6 +94,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name)); return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name));
case 'auth': case 'auth':
if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS); if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS);
if (subcommand === 'backup')
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
if (subcommand === 'show') if (subcommand === 'show')
return completeSubcommands(getProfileNames('accounts'), ['--json']); return completeSubcommands(getProfileNames('accounts'), ['--json']);
if (subcommand === 'remove') if (subcommand === 'remove')
+14 -2
View File
@@ -32,6 +32,7 @@ import {
type MergedAccountEntry, type MergedAccountEntry,
} from './account-route-helpers'; } from './account-route-helpers';
import type { AccountConfig } from '../../config/unified-config-types'; import type { AccountConfig } from '../../config/unified-config-types';
import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics';
const router = Router(); const router = Router();
@@ -59,7 +60,7 @@ function hasAuthAccount(name: string): boolean {
/** /**
* GET /api/accounts - List accounts from both profiles.json and config.yaml * GET /api/accounts - List accounts from both profiles.json and config.yaml
*/ */
router.get('/', (_req: Request, res: Response): void => { router.get('/', async (_req: Request, res: Response): Promise<void> => {
try { try {
const registry = createProfileRegistry(); const registry = createProfileRegistry();
@@ -147,8 +148,19 @@ router.get('/', (_req: Request, res: Response): void => {
// Get default from unified config first, fallback to legacy // Get default from unified config first, fallback to legacy
const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null; const defaultProfile = registry.getDefaultUnified() ?? registry.getDefaultProfile() ?? null;
const plainCcsLane = await resolveConfiguredPlainCcsResumeLane();
res.json({ accounts, default: defaultProfile }); res.json({
accounts,
default: defaultProfile,
plain_ccs_lane: {
kind: plainCcsLane.kind,
label: plainCcsLane.label,
account_name: plainCcsLane.accountName ?? null,
profile_name: plainCcsLane.profileName ?? null,
project_count: plainCcsLane.projectCount,
},
});
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
+99
View File
@@ -0,0 +1,99 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import ProfileRegistry from '../../../src/auth/profile-registry';
import { InstanceManager } from '../../../src/management/instance-manager';
import { handleBackup } from '../../../src/auth/commands/backup-command';
describe('auth backup command', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnified: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-auth-backup-'));
originalCcsHome = process.env.CCS_HOME;
originalUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
function writeConfig(): void {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
}
it('creates a JSON backup for an auth account continuity lane', async () => {
writeConfig();
const instanceMgr = new InstanceManager();
const registry = new ProfileRegistry();
const instancePath = await instanceMgr.ensureInstance('work', {
mode: 'shared',
group: 'default',
continuityMode: 'deeper',
});
fs.mkdirSync(path.join(instancePath, 'projects', 'demo-project'), { recursive: true });
fs.writeFileSync(
path.join(instancePath, 'projects', 'demo-project', 'history.jsonl'),
'{}\n',
'utf8'
);
fs.writeFileSync(path.join(instancePath, 'todos', 'todo.md'), '- keep this\n', 'utf8');
const consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await handleBackup(
{
registry,
instanceMgr,
version: 'test',
},
['work', '--json']
);
} finally {
consoleSpy.mockRestore();
}
const backupBase = path.join(tempHome, '.ccs', 'backups', 'auth-continuity', 'work');
const [latestBackupDir] = fs.readdirSync(backupBase).sort().reverse();
expect(latestBackupDir).toBeTruthy();
const backupPath = path.join(backupBase, latestBackupDir as string);
expect(fs.existsSync(path.join(backupPath, 'manifest.json'))).toBe(true);
expect(
fs.existsSync(path.join(backupPath, 'projects', 'demo-project', 'history.jsonl'))
).toBe(true);
expect(fs.existsSync(path.join(backupPath, 'todos', 'todo.md'))).toBe(true);
});
});
@@ -0,0 +1,143 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
parseResumeFlagIntent,
resolveConfiguredPlainCcsResumeLane,
} from '../../../src/auth/resume-lane-diagnostics';
describe('resume lane diagnostics', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnified: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-resume-lane-'));
originalCcsHome = process.env.CCS_HOME;
originalUnified = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
process.env.CCS_UNIFIED_CONFIG = '1';
});
afterEach(() => {
if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome;
else delete process.env.CCS_HOME;
if (originalUnified !== undefined) process.env.CCS_UNIFIED_CONFIG = originalUnified;
else delete process.env.CCS_UNIFIED_CONFIG;
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
function writeConfig(lines: string[]): void {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), `${lines.join('\n')}\n`, 'utf8');
}
it('parses implicit and explicit resume flags', () => {
expect(parseResumeFlagIntent(['-r'])).toEqual({ implicit: true });
expect(parseResumeFlagIntent(['--resume'])).toEqual({ implicit: true });
expect(parseResumeFlagIntent(['--resume', 'abc123'])).toEqual({
implicit: false,
explicitSessionId: 'abc123',
});
expect(parseResumeFlagIntent(['--resume=xyz'])).toEqual({
implicit: false,
explicitSessionId: 'xyz',
});
expect(parseResumeFlagIntent(['hello'])).toBeNull();
});
it('defaults to the native lane when no account/default mapping exists', async () => {
writeConfig([
'version: 12',
'accounts: {}',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('native');
expect(lane.configDir).toBe(path.join(tempHome, '.claude'));
});
it('resolves the plain ccs lane to a default account when configured', async () => {
writeConfig([
'version: 12',
'default: work',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('account-default');
expect(lane.accountName).toBe('work');
expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work'));
});
it('resolves the plain ccs lane to inherited account continuity when configured', async () => {
writeConfig([
'version: 12',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'continuity:',
' inherit_from_account:',
' default: work',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('account-inherited');
expect(lane.accountName).toBe('work');
expect(lane.configDir).toBe(path.join(tempHome, '.ccs', 'instances', 'work'));
});
it('resolves a non-account default profile through continuity inheritance', async () => {
writeConfig([
'version: 12',
'default: glm',
'accounts:',
' work:',
' created: "2026-04-04T00:00:00.000Z"',
'profiles:',
' glm:',
' type: api',
' settings: ~/.ccs/glm.settings.json',
'continuity:',
' inherit_from_account:',
' glm: work',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
]);
const lane = await resolveConfiguredPlainCcsResumeLane();
expect(lane.kind).toBe('account-inherited');
expect(lane.accountName).toBe('work');
});
});
+16 -3
View File
@@ -15,7 +15,9 @@ function suggestionValues(
current = '', current = '',
shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash' shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash'
): string[] { ): string[] {
return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map((entry) => entry.value); return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map(
(entry) => entry.value
);
} }
beforeEach(() => { beforeEach(() => {
@@ -100,7 +102,9 @@ describe('completion backend', () => {
test('suggests lifecycle subcommands for api', () => { test('suggests lifecycle subcommands for api', () => {
const values = suggestionValues(['api']); const values = suggestionValues(['api']);
expect(values).toEqual(expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])); expect(values).toEqual(
expect.arrayContaining(['create', 'discover', 'copy', 'export', 'import', 'remove'])
);
}); });
test('suggests account profiles for auth show', () => { test('suggests account profiles for auth show', () => {
@@ -109,6 +113,13 @@ describe('completion backend', () => {
expect(values).toContain('--json'); expect(values).toContain('--json');
}); });
test('suggests default lane and account profiles for auth backup', () => {
const values = suggestionValues(['auth', 'backup']);
expect(values).toContain('default');
expect(values).toContain('work');
expect(values).toContain('--json');
});
test('suggests cliproxy variants for variant-scoped commands', () => { test('suggests cliproxy variants for variant-scoped commands', () => {
const values = suggestionValues(['cliproxy', 'edit']); const values = suggestionValues(['cliproxy', 'edit']);
expect(values).toContain('my-codex'); expect(values).toContain('my-codex');
@@ -116,7 +127,9 @@ describe('completion backend', () => {
test('suggests env format values after the format flag', () => { test('suggests env format values after the format flag', () => {
const values = suggestionValues(['env', '--format']); const values = suggestionValues(['env', '--format']);
expect(values).toEqual(expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])); expect(values).toEqual(
expect.arrayContaining(['openai', 'anthropic', 'raw', 'claude-extension'])
);
}); });
test('includes live doctor and cliproxy flags from the shared catalog', () => { test('includes live doctor and cliproxy flags from the shared catalog', () => {
@@ -164,6 +164,104 @@ describe('web-server account-routes context normalization', () => {
expect(work?.continuity_inferred).toBe(true); expect(work?.continuity_inferred).toBe(true);
}); });
it('reports the plain ccs lane as native when no account default or inheritance exists', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const payload = await getJson<{
plain_ccs_lane?: {
kind: string;
account_name?: string | null;
};
}>(baseUrl, '/api/accounts');
expect(payload.plain_ccs_lane?.kind).toBe('native');
expect(payload.plain_ccs_lane?.account_name).toBeNull();
});
it('reports the plain ccs lane as an account when default profile is an auth account', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'default: work',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const payload = await getJson<{
plain_ccs_lane?: {
kind: string;
account_name?: string | null;
};
}>(baseUrl, '/api/accounts');
expect(payload.plain_ccs_lane?.kind).toBe('account-default');
expect(payload.plain_ccs_lane?.account_name).toBe('work');
});
it('does not initialize an account instance as a side effect of listing accounts', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'default: work',
'accounts:',
' work:',
' created: "2026-02-01T00:00:00.000Z"',
' context_mode: shared',
' context_group: default',
' continuity_mode: deeper',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const instancePath = path.join(ccsDir, 'instances', 'work');
expect(fs.existsSync(instancePath)).toBe(false);
await getJson(baseUrl, '/api/accounts');
expect(fs.existsSync(instancePath)).toBe(false);
});
it('does not delete metadata when instance deletion fails', async () => { it('does not delete metadata when instance deletion fails', async () => {
const ccsDir = path.join(tempHome, '.ccs'); const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true }); fs.mkdirSync(ccsDir, { recursive: true });
+12 -4
View File
@@ -35,14 +35,21 @@ import {
useUpdateAccountContext, useUpdateAccountContext,
} from '@/hooks/use-accounts'; } from '@/hooks/use-accounts';
import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity';
import type { PlainCcsLane } from '@/lib/api-client';
interface AccountsTableProps { interface AccountsTableProps {
data: AuthAccountRow[]; data: AuthAccountRow[];
defaultAccount: string | null; defaultAccount: string | null;
groupSummaries: SharedGroupSummary[]; groupSummaries: SharedGroupSummary[];
plainCcsLane: PlainCcsLane | null;
} }
export function AccountsTable({ data, defaultAccount, groupSummaries }: AccountsTableProps) { export function AccountsTable({
data,
defaultAccount,
groupSummaries,
plainCcsLane,
}: AccountsTableProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const setDefaultMutation = useSetDefaultAccount(); const setDefaultMutation = useSetDefaultAccount();
const deleteMutation = useDeleteAccount(); const deleteMutation = useDeleteAccount();
@@ -114,7 +121,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
variant="outline" variant="outline"
className={`font-mono text-[10px] uppercase px-1.5 py-0 border ${isDeeper ? 'text-indigo-700 border-indigo-300/60 bg-indigo-50/50 dark:text-indigo-300 dark:border-indigo-900/40 dark:bg-indigo-900/20' : 'text-emerald-700 border-emerald-300/60 bg-emerald-50/50 dark:text-emerald-300 dark:border-emerald-900/40 dark:bg-emerald-900/20'}`} className={`font-mono text-[10px] uppercase px-1.5 py-0 border ${isDeeper ? 'text-indigo-700 border-indigo-300/60 bg-indigo-50/50 dark:text-indigo-300 dark:border-indigo-900/40 dark:bg-indigo-900/20' : 'text-emerald-700 border-emerald-300/60 bg-emerald-50/50 dark:text-emerald-300 dark:border-emerald-900/40 dark:bg-emerald-900/20'}`}
> >
{isDeeper ? 'Deeper' : 'Shared'} {isDeeper ? t('accountsTable.badges.deeper') : t('accountsTable.badges.shared')}
</Badge> </Badge>
<span className="text-xs font-semibold text-foreground/80">{group}</span> <span className="text-xs font-semibold text-foreground/80">{group}</span>
</div> </div>
@@ -136,7 +143,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
variant="outline" variant="outline"
className="text-amber-700 border-amber-300/60 bg-amber-50/50 dark:text-amber-400 dark:border-amber-900/40 dark:bg-amber-900/20 font-mono text-[10px] uppercase px-1.5 py-0" className="text-amber-700 border-amber-300/60 bg-amber-50/50 dark:text-amber-400 dark:border-amber-900/40 dark:bg-amber-900/20 font-mono text-[10px] uppercase px-1.5 py-0"
> >
Legacy {t('accountsTable.badges.legacy')}
</Badge> </Badge>
<p className="text-[10px] text-amber-700/80 dark:text-amber-400/80 whitespace-nowrap"> <p className="text-[10px] text-amber-700/80 dark:text-amber-400/80 whitespace-nowrap">
{t('accountsTable.legacyReview')} {t('accountsTable.legacyReview')}
@@ -151,7 +158,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
variant="secondary" variant="secondary"
className="font-mono text-[10px] uppercase px-1.5 py-0 text-muted-foreground bg-muted/60 border-transparent shadow-none" className="font-mono text-[10px] uppercase px-1.5 py-0 text-muted-foreground bg-muted/60 border-transparent shadow-none"
> >
Isolated {t('accountsTable.badges.isolated')}
</Badge> </Badge>
</div> </div>
); );
@@ -321,6 +328,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
<EditAccountContextDialog <EditAccountContextDialog
account={contextTarget} account={contextTarget}
groupSummaries={groupSummaries} groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
onClose={() => setContextTarget(null)} onClose={() => setContextTarget(null)}
/> />
)} )}
@@ -12,10 +12,13 @@ import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { CopyButton } from '@/components/ui/copy-button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { PlainCcsLane } from '@/lib/api-client';
interface ContinuityOverviewProps { interface ContinuityOverviewProps {
totalAccounts: number; totalAccounts: number;
primaryAccountName?: string | null;
isolatedCount: number; isolatedCount: number;
sharedStandardCount: number; sharedStandardCount: number;
deeperSharedCount: number; deeperSharedCount: number;
@@ -27,6 +30,7 @@ interface ContinuityOverviewProps {
deeperReadyGroups: string[]; deeperReadyGroups: string[];
legacyTargetCount: number; legacyTargetCount: number;
cliproxyCount: number; cliproxyCount: number;
plainCcsLane: PlainCcsLane | null;
} }
type ReadinessState = type ReadinessState =
@@ -45,6 +49,7 @@ const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label={t(titleKey)}
className="h-5 w-5 rounded-full hover:bg-muted text-muted-foreground/70 transition-colors" className="h-5 w-5 rounded-full hover:bg-muted text-muted-foreground/70 transition-colors"
> >
<Info className="h-3 w-3" /> <Info className="h-3 w-3" />
@@ -64,6 +69,7 @@ const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string
export function ContinuityOverview({ export function ContinuityOverview({
totalAccounts, totalAccounts,
primaryAccountName,
isolatedCount, isolatedCount,
sharedStandardCount, sharedStandardCount,
deeperSharedCount, deeperSharedCount,
@@ -74,8 +80,26 @@ export function ContinuityOverview({
deeperReadyGroups, deeperReadyGroups,
legacyTargetCount, legacyTargetCount,
cliproxyCount, cliproxyCount,
plainCcsLane,
}: ContinuityOverviewProps) { }: ContinuityOverviewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const laneLabel = plainCcsLane
? plainCcsLane.kind === 'native'
? t('continuityOverview.lane.native')
: plainCcsLane.kind === 'account-default'
? t('continuityOverview.lane.accountDefault', {
name: plainCcsLane.account_name || '',
})
: plainCcsLane.kind === 'account-inherited'
? t('continuityOverview.lane.accountInherited', {
name: plainCcsLane.account_name || '',
})
: plainCcsLane.kind === 'profile-default'
? t('continuityOverview.lane.profileDefault', {
name: plainCcsLane.profile_name || 'default',
})
: plainCcsLane.label
: '';
const readiness: ReadinessState = const readiness: ReadinessState =
totalAccounts < 2 totalAccounts < 2
@@ -110,6 +134,11 @@ export function ContinuityOverview({
}; };
const currentIcon = iconMap[readiness]; const currentIcon = iconMap[readiness];
const plainLaneUsesPrimaryAccount =
!!primaryAccountName && plainCcsLane?.account_name === primaryAccountName;
const showPlainLaneRecovery =
totalAccounts > 0 && !!plainCcsLane && (!primaryAccountName || !plainLaneUsesPrimaryAccount);
const showSharedRecoverySteps = totalAccounts > 1 && readiness !== 'ready';
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -167,7 +196,7 @@ export function ContinuityOverview({
variant="secondary" variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent" className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
> >
Recommend: {highlightGroup} {t('continuityOverview.recommendBadge', { group: highlightGroup })}
</Badge> </Badge>
)} )}
{hasMixedState && ( {hasMixedState && (
@@ -175,13 +204,70 @@ export function ContinuityOverview({
variant="secondary" variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent" className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
> >
Partial sync ({highlightGroup}) {t('continuityOverview.partialBadge', { group: highlightGroup })}
</Badge> </Badge>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{(showPlainLaneRecovery || showSharedRecoverySteps) && (
<Card className="border-dashed">
<CardContent className="p-5 space-y-4">
{showPlainLaneRecovery && (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold text-foreground">
{t('continuityOverview.plainLaneTitle')}
</p>
<p className="text-sm text-muted-foreground">
{t('continuityOverview.plainLaneDescription', {
lane: laneLabel || 'plain ccs',
})}
</p>
</div>
<div className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs -r</span>
<CopyButton value="ccs -r" size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs auth backup default</span>
<CopyButton value="ccs auth backup default" size="icon" />
</div>
{primaryAccountName ? (
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">
{`ccs auth default ${primaryAccountName}`}
</span>
<CopyButton value={`ccs auth default ${primaryAccountName}`} size="icon" />
</div>
) : (
<p className="text-xs text-muted-foreground">
{t('continuityOverview.setDefaultHint')}
</p>
)}
</div>
</div>
)}
{showSharedRecoverySteps && (
<div className="space-y-2">
<p className="text-sm font-semibold text-foreground">
{t('continuityReadiness.stepsTitle')}
</p>
<ol className="space-y-1 pl-5 text-sm text-muted-foreground">
<li>{t('continuityReadiness.steps.syncBoth')}</li>
<li>{t('continuityReadiness.steps.sameGroup', { group: highlightGroup })}</li>
<li>{t('continuityReadiness.steps.enableDeeper')}</li>
<li>{t('continuityReadiness.steps.resumeOriginal')}</li>
</ol>
</div>
)}
</CardContent>
</Card>
)}
{/* Horizontal Progression Chain */} {/* Horizontal Progression Chain */}
<div className="flex flex-col md:flex-row items-center gap-3"> <div className="flex flex-col md:flex-row items-center gap-3">
<div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-blue-300/40 bg-blue-50/50 dark:border-blue-900/30 dark:bg-blue-900/10 shadow-sm transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20"> <div className="flex-1 w-full flex items-center justify-between p-3.5 rounded-xl border border-blue-300/40 bg-blue-50/50 dark:border-blue-900/30 dark:bg-blue-900/10 shadow-sm transition-colors hover:bg-blue-100/40 dark:hover:bg-blue-900/20">
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useMemo, useState, type KeyboardEvent } from 'react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -14,7 +14,9 @@ import { Label } from '@/components/ui/label';
import { Unlink, Link2, Waves, ShieldAlert, Info } from 'lucide-react'; import { Unlink, Link2, Waves, ShieldAlert, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity'; import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity';
import type { PlainCcsLane } from '@/lib/api-client';
import { useUpdateAccountContext } from '@/hooks/use-accounts'; import { useUpdateAccountContext } from '@/hooks/use-accounts';
import { CopyButton } from '@/components/ui/copy-button';
type ContextMode = 'isolated' | 'shared'; type ContextMode = 'isolated' | 'shared';
type ContinuityMode = 'standard' | 'deeper'; type ContinuityMode = 'standard' | 'deeper';
@@ -25,12 +27,14 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
interface EditAccountContextDialogProps { interface EditAccountContextDialogProps {
account: AuthAccountRow; account: AuthAccountRow;
groupSummaries: SharedGroupSummary[]; groupSummaries: SharedGroupSummary[];
plainCcsLane: PlainCcsLane | null;
onClose: () => void; onClose: () => void;
} }
export function EditAccountContextDialog({ export function EditAccountContextDialog({
account, account,
groupSummaries, groupSummaries,
plainCcsLane,
onClose, onClose,
}: EditAccountContextDialogProps) { }: EditAccountContextDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -73,6 +77,43 @@ export function EditAccountContextDialog({
0 0
) )
: 0; : 0;
const plainLaneUsesThisAccount = plainCcsLane?.account_name === account.name;
const showPlainLaneMismatch = !!plainCcsLane && !plainLaneUsesThisAccount;
const defaultCommand = `ccs auth default ${account.name}`;
const accountBackupCommand = `ccs auth backup ${account.name}`;
const handleRadioKeyDown = <T extends string>(
event: KeyboardEvent<HTMLButtonElement>,
current: T,
values: readonly T[],
setValue: (value: T) => void
) => {
if (!['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) {
return;
}
event.preventDefault();
const currentIndex = values.indexOf(current);
const direction = event.key === 'ArrowLeft' || event.key === 'ArrowUp' ? -1 : 1;
const nextIndex = (currentIndex + direction + values.length) % values.length;
setValue(values[nextIndex] as T);
};
const laneLabel = plainCcsLane
? plainCcsLane.kind === 'native'
? t('continuityOverview.lane.native')
: plainCcsLane.kind === 'account-default'
? t('continuityOverview.lane.accountDefault', {
name: plainCcsLane.account_name || '',
})
: plainCcsLane.kind === 'account-inherited'
? t('continuityOverview.lane.accountInherited', {
name: plainCcsLane.account_name || '',
})
: plainCcsLane.kind === 'profile-default'
? t('continuityOverview.lane.profileDefault', {
name: plainCcsLane.profile_name || 'default',
})
: plainCcsLane.label
: '';
const handleSave = () => { const handleSave = () => {
if (!canSubmit) { if (!canSubmit) {
@@ -124,7 +165,11 @@ export function EditAccountContextDialog({
type="button" type="button"
role="radio" role="radio"
aria-checked={mode === 'isolated'} aria-checked={mode === 'isolated'}
tabIndex={mode === 'isolated' ? 0 : -1}
onClick={() => setMode('isolated')} onClick={() => setMode('isolated')}
onKeyDown={(event) =>
handleRadioKeyDown(event, mode, ['isolated', 'shared'] as const, setMode)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${ className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
mode === 'isolated' mode === 'isolated'
? 'bg-background text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-border/50' ? 'bg-background text-blue-600 dark:text-blue-400 shadow-sm ring-1 ring-border/50'
@@ -138,7 +183,11 @@ export function EditAccountContextDialog({
type="button" type="button"
role="radio" role="radio"
aria-checked={mode === 'shared'} aria-checked={mode === 'shared'}
tabIndex={mode === 'shared' ? 0 : -1}
onClick={() => setMode('shared')} onClick={() => setMode('shared')}
onKeyDown={(event) =>
handleRadioKeyDown(event, mode, ['isolated', 'shared'] as const, setMode)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${ className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
mode === 'shared' mode === 'shared'
? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50' ? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50'
@@ -191,7 +240,16 @@ export function EditAccountContextDialog({
type="button" type="button"
role="radio" role="radio"
aria-checked={continuityMode === 'standard'} aria-checked={continuityMode === 'standard'}
tabIndex={continuityMode === 'standard' ? 0 : -1}
onClick={() => setContinuityMode('standard')} onClick={() => setContinuityMode('standard')}
onKeyDown={(event) =>
handleRadioKeyDown(
event,
continuityMode,
['standard', 'deeper'] as const,
setContinuityMode
)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${ className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
continuityMode === 'standard' continuityMode === 'standard'
? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50' ? 'bg-background text-emerald-600 dark:text-emerald-400 shadow-sm ring-1 ring-border/50'
@@ -205,7 +263,16 @@ export function EditAccountContextDialog({
type="button" type="button"
role="radio" role="radio"
aria-checked={continuityMode === 'deeper'} aria-checked={continuityMode === 'deeper'}
tabIndex={continuityMode === 'deeper' ? 0 : -1}
onClick={() => setContinuityMode('deeper')} onClick={() => setContinuityMode('deeper')}
onKeyDown={(event) =>
handleRadioKeyDown(
event,
continuityMode,
['standard', 'deeper'] as const,
setContinuityMode
)
}
className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${ className={`flex-1 flex justify-center items-center gap-2 px-3 py-1.5 rounded-[10px] text-sm font-medium transition-all duration-200 overflow-hidden ${
continuityMode === 'deeper' continuityMode === 'deeper'
? 'bg-background text-indigo-600 dark:text-indigo-400 shadow-sm ring-1 ring-border/50' ? 'bg-background text-indigo-600 dark:text-indigo-400 shadow-sm ring-1 ring-border/50'
@@ -228,6 +295,40 @@ export function EditAccountContextDialog({
{t('editAccountContext.credentialsIsolated')} {t('editAccountContext.credentialsIsolated')}
</p> </p>
{showPlainLaneMismatch && (
<div className="rounded-[14px] border border-amber-200 bg-amber-50/50 p-4 text-xs shadow-sm dark:border-amber-900/40 dark:bg-amber-900/10">
<div className="space-y-2">
<p className="font-medium text-foreground">
{t('continuityOverview.plainLaneTitle')}
</p>
<p className="text-muted-foreground leading-relaxed">
{t('continuityOverview.plainLaneDescription', {
lane: laneLabel || 'plain ccs',
name: account.name,
})}
</p>
<div className="space-y-2">
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs -r</span>
<CopyButton value="ccs -r" size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">ccs auth backup default</span>
<CopyButton value="ccs auth backup default" size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">{accountBackupCommand}</span>
<CopyButton value={accountBackupCommand} size="icon" />
</div>
<div className="rounded-md border bg-background px-2 py-2 font-mono text-[11px] flex items-start gap-2">
<span className="flex-1 break-all">{defaultCommand}</span>
<CopyButton value={defaultCommand} size="icon" />
</div>
</div>
</div>
</div>
)}
<div <div
className={`rounded-[14px] border p-4 text-xs shadow-sm transition-colors ${mode === 'isolated' ? 'bg-blue-50/50 border-blue-200 dark:bg-blue-900/10 dark:border-blue-800/40' : 'bg-muted/40 border-border/60'}`} className={`rounded-[14px] border p-4 text-xs shadow-sm transition-colors ${mode === 'isolated' ? 'bg-blue-50/50 border-blue-200 dark:bg-blue-900/10 dark:border-blue-800/40' : 'bg-muted/40 border-border/60'}`}
> >
+3 -1
View File
@@ -5,7 +5,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api-client'; import { api } from '@/lib/api-client';
import type { Account } from '@/lib/api-client'; import type { Account, PlainCcsLane } from '@/lib/api-client';
import { import {
summarizeAuthAccountContinuity, summarizeAuthAccountContinuity,
type AuthAccountRow, type AuthAccountRow,
@@ -30,6 +30,7 @@ export interface AuthAccountsView {
deeperReadyGroups: string[]; deeperReadyGroups: string[];
sharedGroups: string[]; sharedGroups: string[];
groupSummaries: SharedGroupSummary[]; groupSummaries: SharedGroupSummary[];
plainCcsLane: PlainCcsLane | null;
} }
export function useAccounts() { export function useAccounts() {
@@ -61,6 +62,7 @@ export function useAccounts() {
deeperReadyGroups: continuity.deeperReadyGroups, deeperReadyGroups: continuity.deeperReadyGroups,
sharedGroups: continuity.sharedGroups, sharedGroups: continuity.sharedGroups,
groupSummaries: continuity.groupSummaries, groupSummaries: continuity.groupSummaries,
plainCcsLane: data.plain_ccs_lane ?? null,
}; };
}, },
}); });
+12 -1
View File
@@ -733,6 +733,14 @@ export interface Account {
displayName?: string; displayName?: string;
} }
export interface PlainCcsLane {
kind: 'native' | 'account-default' | 'account-inherited' | 'profile-default' | 'ambient';
label: string;
account_name?: string | null;
profile_name?: string | null;
project_count: number;
}
export interface UpdateAccountContext { export interface UpdateAccountContext {
context_mode: 'isolated' | 'shared'; context_mode: 'isolated' | 'shared';
context_group?: string; context_group?: string;
@@ -1120,7 +1128,10 @@ export const api = {
}, },
}, },
accounts: { accounts: {
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'), list: () =>
request<{ accounts: Account[]; default: string | null; plain_ccs_lane?: PlainCcsLane }>(
'/accounts'
),
setDefault: (name: string) => setDefault: (name: string) =>
request('/accounts/default', { request('/accounts/default', {
method: 'POST', method: 'POST',
+83
View File
@@ -540,6 +540,21 @@ const resources = {
'If the original conversation is important, keep resuming it from the original account until the second account is ready.', 'If the original conversation is important, keep resuming it from the original account until the second account is ready.',
}, },
}, },
continuityOverview: {
plainLaneTitle: 'Plain ccs is using a different resume lane',
plainLaneDescription:
'Plain ccs currently resumes from {{lane}}, not this account lane. Recover the original lane first before changing sync settings.',
setDefaultHint:
'Use the Set Default action on the account you want plain ccs to follow in future sessions.',
recommendBadge: 'Recommend {{group}}',
partialBadge: 'Partial sync {{group}}',
lane: {
native: 'native Claude (~/.claude)',
accountDefault: 'account {{name}}',
accountInherited: 'account {{name}} via continuity inheritance',
profileDefault: 'default profile {{name}} via native Claude lane',
},
},
accountsTable: { accountsTable: {
name: 'Name', name: 'Name',
type: 'Type', type: 'Type',
@@ -573,6 +588,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} same-group peers', sameGroupPeerCount_other: '{{count}} same-group peers',
legacyReview: 'Review and confirm this inferred default.', legacyReview: 'Review and confirm this inferred default.',
noHandoff: 'Cross-account resume stays in the original account.', noHandoff: 'Cross-account resume stays in the original account.',
badges: {
shared: 'Shared',
deeper: 'Deeper',
legacy: 'Legacy',
isolated: 'Isolated',
},
}, },
addAccountDialog: { addAccountDialog: {
title: 'Add {{displayName}} Account', title: 'Add {{displayName}} Account',
@@ -1820,6 +1841,20 @@ const resources = {
resumeOriginal: '在第二个账号配置完成之前,重要会话仍应从原账号继续恢复。', resumeOriginal: '在第二个账号配置完成之前,重要会话仍应从原账号继续恢复。',
}, },
}, },
continuityOverview: {
plainLaneTitle: '普通 ccs 正在使用不同的续接通道',
plainLaneDescription:
'普通 ccs 当前会从 {{lane}} 续接,而不是这个账号通道。请先恢复原通道中的会话,再修改同步设置。',
setDefaultHint: '如果希望以后普通 ccs 跟随某个账号,请使用该账号行上的“设为默认”。',
recommendBadge: '建议 {{group}}',
partialBadge: '部分同步 {{group}}',
lane: {
native: '原生 Claude~/.claude',
accountDefault: '账号 {{name}}',
accountInherited: '通过连续性继承使用账号 {{name}}',
profileDefault: '默认配置 {{name}}(仍走原生 Claude 通道)',
},
},
accountsTable: { accountsTable: {
name: '名称', name: '名称',
type: '类型', type: '类型',
@@ -1853,6 +1888,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} 个同组账号', sameGroupPeerCount_other: '{{count}} 个同组账号',
legacyReview: '请检查并确认这个推断出的默认状态。', legacyReview: '请检查并确认这个推断出的默认状态。',
noHandoff: '跨账号续接仍会回到原账号。', noHandoff: '跨账号续接仍会回到原账号。',
badges: {
shared: '共享',
deeper: '更深',
legacy: '旧策略',
isolated: '隔离',
},
}, },
addAccountDialog: { addAccountDialog: {
title: '添加 {{displayName}} 账号', title: '添加 {{displayName}} 账号',
@@ -3118,6 +3159,21 @@ const resources = {
'Cho đến khi tài khoản thứ hai sẵn sàng, các cuộc trò chuyện quan trọng vẫn nên được resume từ tài khoản gốc.', 'Cho đến khi tài khoản thứ hai sẵn sàng, các cuộc trò chuyện quan trọng vẫn nên được resume từ tài khoản gốc.',
}, },
}, },
continuityOverview: {
plainLaneTitle: 'Plain ccs đang dùng một lane resume khác',
plainLaneDescription:
'Plain ccs hiện resume từ {{lane}}, không phải lane của tài khoản này. Hãy khôi phục lane gốc trước khi đổi cài đặt sync.',
setDefaultHint:
'Nếu muốn plain ccs theo một tài khoản trong các phiên sau, hãy dùng hành động Set Default trên dòng tài khoản đó.',
recommendBadge: 'Đề xuất {{group}}',
partialBadge: 'Đồng bộ một phần {{group}}',
lane: {
native: 'Claude gốc (~/.claude)',
accountDefault: 'tài khoản {{name}}',
accountInherited: 'tài khoản {{name}} qua continuity inheritance',
profileDefault: 'profile mặc định {{name}} qua lane Claude gốc',
},
},
accountsTable: { accountsTable: {
name: 'Tên', name: 'Tên',
type: 'Kiểu', type: 'Kiểu',
@@ -3152,6 +3208,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} tài khoản cùng nhóm', sameGroupPeerCount_other: '{{count}} tài khoản cùng nhóm',
legacyReview: 'Hãy xem lại và xác nhận trạng thái suy ra này.', legacyReview: 'Hãy xem lại và xác nhận trạng thái suy ra này.',
noHandoff: 'Resume xuyên tài khoản vẫn sẽ quay về tài khoản gốc.', noHandoff: 'Resume xuyên tài khoản vẫn sẽ quay về tài khoản gốc.',
badges: {
shared: 'Chia sẻ',
deeper: 'Sâu hơn',
legacy: 'Kế thừa',
isolated: 'Tách biệt',
},
}, },
addAccountDialog: { addAccountDialog: {
title: 'Thêm tài khoản {{displayName}}', title: 'Thêm tài khoản {{displayName}}',
@@ -4454,6 +4516,21 @@ const resources = {
'2 つ目のアカウントが整うまでは、重要な会話は元のアカウントから再開し続けてください。', '2 つ目のアカウントが整うまでは、重要な会話は元のアカウントから再開し続けてください。',
}, },
}, },
continuityOverview: {
plainLaneTitle: '通常の ccs は別の再開レーンを使っています',
plainLaneDescription:
'通常の ccs は現在 {{lane}} から再開しており、このアカウントのレーンではありません。同期設定を変える前に元のレーンを先に復旧してください。',
setDefaultHint:
'今後の通常の ccs を特定のアカウントに合わせたい場合は、その行の「既定に設定」を使ってください。',
recommendBadge: '推奨 {{group}}',
partialBadge: '部分同期 {{group}}',
lane: {
native: 'ネイティブ Claude~/.claude',
accountDefault: 'アカウント {{name}}',
accountInherited: '継続性継承によるアカウント {{name}}',
profileDefault: '既定プロファイル {{name}}(ネイティブ Claude レーン)',
},
},
accountsTable: { accountsTable: {
name: '名前', name: '名前',
type: '種別', type: '種別',
@@ -4488,6 +4565,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} 件の同グループアカウント', sameGroupPeerCount_other: '{{count}} 件の同グループアカウント',
legacyReview: 'この推定された既定状態を確認して明示的に確定してください。', legacyReview: 'この推定された既定状態を確認して明示的に確定してください。',
noHandoff: 'アカウント間再開は元のアカウントに残ります。', noHandoff: 'アカウント間再開は元のアカウントに残ります。',
badges: {
shared: '共有',
deeper: '拡張',
legacy: 'レガシー',
isolated: '分離',
},
}, },
addAccountDialog: { addAccountDialog: {
title: '{{displayName}} アカウントを追加', title: '{{displayName}} アカウントを追加',
+7
View File
@@ -39,6 +39,7 @@ export function AccountsPage() {
const deeperReadyGroups = data?.deeperReadyGroups || []; const deeperReadyGroups = data?.deeperReadyGroups || [];
const sharedGroups = data?.sharedGroups || []; const sharedGroups = data?.sharedGroups || [];
const groupSummaries = data?.groupSummaries || []; const groupSummaries = data?.groupSummaries || [];
const plainCcsLane = data?.plainCcsLane || null;
const legacyTargets = authAccounts.filter( const legacyTargets = authAccounts.filter(
(account) => account.context_inferred || account.continuity_inferred (account) => account.context_inferred || account.continuity_inferred
@@ -189,6 +190,7 @@ export function AccountsPage() {
<div className="flex-1 min-h-0 p-5 space-y-4 overflow-y-auto"> <div className="flex-1 min-h-0 p-5 space-y-4 overflow-y-auto">
<ContinuityOverview <ContinuityOverview
totalAccounts={authAccounts.length} totalAccounts={authAccounts.length}
primaryAccountName={authAccounts.length === 1 ? authAccounts[0]?.name : null}
isolatedCount={isolatedCount} isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount} sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount} deeperSharedCount={deeperSharedCount}
@@ -200,6 +202,7 @@ export function AccountsPage() {
deeperReadyGroups={deeperReadyGroups} deeperReadyGroups={deeperReadyGroups}
legacyTargetCount={legacyTargetCount} legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount} cliproxyCount={cliproxyCount}
plainCcsLane={plainCcsLane}
/> />
<Card className="flex flex-col"> <Card className="flex flex-col">
@@ -217,6 +220,7 @@ export function AccountsPage() {
data={authAccounts} data={authAccounts}
defaultAccount={data?.default ?? null} defaultAccount={data?.default ?? null}
groupSummaries={groupSummaries} groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
/> />
)} )}
</CardContent> </CardContent>
@@ -264,6 +268,7 @@ export function AccountsPage() {
<ContinuityOverview <ContinuityOverview
totalAccounts={authAccounts.length} totalAccounts={authAccounts.length}
primaryAccountName={authAccounts.length === 1 ? authAccounts[0]?.name : null}
isolatedCount={isolatedCount} isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount} sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount} deeperSharedCount={deeperSharedCount}
@@ -275,6 +280,7 @@ export function AccountsPage() {
deeperReadyGroups={deeperReadyGroups} deeperReadyGroups={deeperReadyGroups}
legacyTargetCount={legacyTargetCount} legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount} cliproxyCount={cliproxyCount}
plainCcsLane={plainCcsLane}
/> />
<Card> <Card>
@@ -289,6 +295,7 @@ export function AccountsPage() {
data={authAccounts} data={authAccounts}
defaultAccount={data?.default ?? null} defaultAccount={data?.default ?? null}
groupSummaries={groupSummaries} groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
/> />
)} )}
</CardContent> </CardContent>