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.
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)
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
gemini: 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.
+38 -1
View File
@@ -1,6 +1,6 @@
# Session Sharing Technical Analysis
Last Updated: 2026-02-26
Last Updated: 2026-04-04
## Summary
@@ -69,6 +69,30 @@ Behavior:
- Reuses `CLAUDE_CONFIG_DIR` from mapped account profile after normal account context policy resolution
- 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
### 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.
### 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
- 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 {
type CommandContext,
handleCreate,
handleBackup,
handleList,
handleShow,
handleRemove,
@@ -68,6 +69,9 @@ class AuthCommands {
console.log('');
console.log(subheader('Commands'));
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('show <profile>', 'command')} Show profile details`);
console.log(` ${color('remove <profile>', 'command')} Remove saved profile`);
@@ -97,6 +101,12 @@ class AuthCommands {
console.log(` ${dim('# Set work as default')}`);
console.log(` ${color('ccs auth default work', 'command')}`);
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(` ${color('ccs auth reset-default', 'command')}`);
console.log('');
@@ -162,6 +172,10 @@ class AuthCommands {
return handleCreate(this.getContext(), args);
}
async handleBackup(args: string[]): Promise<void> {
return handleBackup(this.getContext(), args);
}
/**
* List all profiles - delegates to list-command.ts
*/
@@ -227,6 +241,10 @@ class AuthCommands {
await this.handleList(commandArgs);
break;
case 'backup':
await this.handleBackup(commandArgs);
break;
case 'show':
await this.handleShow(commandArgs);
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
export { handleCreate } from './create-command';
export { handleBackup } from './backup-command';
export { handleList } from './list-command';
export { handleShow } from './show-command';
export { handleRemove } from './remove-command';
+17 -10
View File
@@ -100,6 +100,19 @@ function resolveMappedAccount(
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.
*
@@ -115,16 +128,10 @@ export async function resolveProfileContinuityInheritance(
return {};
}
const inheritFromAccount = getContinuityInheritanceMap();
const sourceAccount =
resolveMappedAccount(input.profileName, input.profileType, inheritFromAccount) ??
(!isUnifiedMode()
? resolveMappedAccount(
input.profileName,
input.profileType,
loadLegacyContinuityInheritanceMap()
)
: undefined);
const sourceAccount = resolveConfiguredContinuitySourceAccount(
input.profileName,
input.profileType
);
if (!sourceAccount) {
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)
*/
+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 * as fs from 'fs';
import * as path from 'path';
import { detectClaudeCli } from './utils/claude-detector';
import {
getSettingsPath,
@@ -64,6 +65,10 @@ import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions
import { execClaude } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import {
parseResumeFlagIntent,
resolveRuntimePlainCcsResumeLane,
} from './auth/resume-lane-diagnostics';
// Import target adapter system
import {
@@ -259,6 +264,43 @@ function shouldPassthroughNativeCodexFlagCommand(args: string[]): boolean {
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 {
const targetArgs = stripTargetFlag(args);
if (resolveTargetType(args) !== 'codex' || targetArgs.length === 0) {
@@ -1269,6 +1311,7 @@ async function main(): Promise<void> {
CCS_WEBSEARCH_SKIP: '1',
CCS_IMAGE_ANALYSIS_SKIP: '1',
};
await maybeWarnAboutResumeLaneMismatch(profileInfo.name, instancePath, remainingArgs);
const launchArgs = resolveNativeClaudeLaunchArgs(remainingArgs, 'account', instancePath);
execClaude(claudeCli, launchArgs, envVars);
} else {
+1
View File
@@ -215,6 +215,7 @@ export const ROOT_COMMAND_FLAGS = [
] as const;
export const AUTH_SUBCOMMANDS = [
'create',
'backup',
'list',
'show',
'remove',
+2
View File
@@ -94,6 +94,8 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
return completeSubcommands(ROOT_HELP_TOPICS.map((topic) => topic.name));
case 'auth':
if (!subcommand) return completeSubcommands(AUTH_SUBCOMMANDS);
if (subcommand === 'backup')
return completeSubcommands(['default', ...getProfileNames('accounts')], ['--json']);
if (subcommand === 'show')
return completeSubcommands(getProfileNames('accounts'), ['--json']);
if (subcommand === 'remove')
+14 -2
View File
@@ -32,6 +32,7 @@ import {
type MergedAccountEntry,
} from './account-route-helpers';
import type { AccountConfig } from '../../config/unified-config-types';
import { resolveConfiguredPlainCcsResumeLane } from '../../auth/resume-lane-diagnostics';
const router = Router();
@@ -59,7 +60,7 @@ function hasAuthAccount(name: string): boolean {
/**
* 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 {
const registry = createProfileRegistry();
@@ -147,8 +148,19 @@ router.get('/', (_req: Request, res: Response): void => {
// Get default from unified config first, fallback to legacy
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) {
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 = '',
shell: 'bash' | 'fish' | 'powershell' | 'zsh' = 'bash'
): string[] {
return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map((entry) => entry.value);
return getCompletionSuggestions({ tokensBeforeCurrent, current, shell }).map(
(entry) => entry.value
);
}
beforeEach(() => {
@@ -100,7 +102,9 @@ describe('completion backend', () => {
test('suggests lifecycle subcommands for 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', () => {
@@ -109,6 +113,13 @@ describe('completion backend', () => {
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', () => {
const values = suggestionValues(['cliproxy', 'edit']);
expect(values).toContain('my-codex');
@@ -116,7 +127,9 @@ describe('completion backend', () => {
test('suggests env format values after the format flag', () => {
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', () => {
@@ -164,6 +164,104 @@ describe('web-server account-routes context normalization', () => {
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 () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
+12 -4
View File
@@ -35,14 +35,21 @@ import {
useUpdateAccountContext,
} from '@/hooks/use-accounts';
import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity';
import type { PlainCcsLane } from '@/lib/api-client';
interface AccountsTableProps {
data: AuthAccountRow[];
defaultAccount: string | null;
groupSummaries: SharedGroupSummary[];
plainCcsLane: PlainCcsLane | null;
}
export function AccountsTable({ data, defaultAccount, groupSummaries }: AccountsTableProps) {
export function AccountsTable({
data,
defaultAccount,
groupSummaries,
plainCcsLane,
}: AccountsTableProps) {
const { t } = useTranslation();
const setDefaultMutation = useSetDefaultAccount();
const deleteMutation = useDeleteAccount();
@@ -114,7 +121,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
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'}`}
>
{isDeeper ? 'Deeper' : 'Shared'}
{isDeeper ? t('accountsTable.badges.deeper') : t('accountsTable.badges.shared')}
</Badge>
<span className="text-xs font-semibold text-foreground/80">{group}</span>
</div>
@@ -136,7 +143,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
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"
>
Legacy
{t('accountsTable.badges.legacy')}
</Badge>
<p className="text-[10px] text-amber-700/80 dark:text-amber-400/80 whitespace-nowrap">
{t('accountsTable.legacyReview')}
@@ -151,7 +158,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
variant="secondary"
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>
</div>
);
@@ -321,6 +328,7 @@ export function AccountsTable({ data, defaultAccount, groupSummaries }: Accounts
<EditAccountContextDialog
account={contextTarget}
groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
onClose={() => setContextTarget(null)}
/>
)}
@@ -12,10 +12,13 @@ import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { CopyButton } from '@/components/ui/copy-button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import type { PlainCcsLane } from '@/lib/api-client';
interface ContinuityOverviewProps {
totalAccounts: number;
primaryAccountName?: string | null;
isolatedCount: number;
sharedStandardCount: number;
deeperSharedCount: number;
@@ -27,6 +30,7 @@ interface ContinuityOverviewProps {
deeperReadyGroups: string[];
legacyTargetCount: number;
cliproxyCount: number;
plainCcsLane: PlainCcsLane | null;
}
type ReadinessState =
@@ -45,6 +49,7 @@ const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string
<Button
variant="ghost"
size="icon"
aria-label={t(titleKey)}
className="h-5 w-5 rounded-full hover:bg-muted text-muted-foreground/70 transition-colors"
>
<Info className="h-3 w-3" />
@@ -64,6 +69,7 @@ const InfoTooltip = ({ titleKey, descKey }: { titleKey: string; descKey: string
export function ContinuityOverview({
totalAccounts,
primaryAccountName,
isolatedCount,
sharedStandardCount,
deeperSharedCount,
@@ -74,8 +80,26 @@ export function ContinuityOverview({
deeperReadyGroups,
legacyTargetCount,
cliproxyCount,
plainCcsLane,
}: ContinuityOverviewProps) {
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 =
totalAccounts < 2
@@ -110,6 +134,11 @@ export function ContinuityOverview({
};
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 (
<div className="flex flex-col gap-4">
@@ -167,7 +196,7 @@ export function ContinuityOverview({
variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
>
Recommend: {highlightGroup}
{t('continuityOverview.recommendBadge', { group: highlightGroup })}
</Badge>
)}
{hasMixedState && (
@@ -175,13 +204,70 @@ export function ContinuityOverview({
variant="secondary"
className="font-mono text-[11px] px-2 bg-muted/50 text-muted-foreground border-transparent"
>
Partial sync ({highlightGroup})
{t('continuityOverview.partialBadge', { group: highlightGroup })}
</Badge>
)}
</div>
</CardContent>
</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 */}
<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">
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useMemo, useState, type KeyboardEvent } from 'react';
import {
Dialog,
DialogContent,
@@ -14,7 +14,9 @@ import { Label } from '@/components/ui/label';
import { Unlink, Link2, Waves, ShieldAlert, Info } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AuthAccountRow, SharedGroupSummary } from '@/lib/account-continuity';
import type { PlainCcsLane } from '@/lib/api-client';
import { useUpdateAccountContext } from '@/hooks/use-accounts';
import { CopyButton } from '@/components/ui/copy-button';
type ContextMode = 'isolated' | 'shared';
type ContinuityMode = 'standard' | 'deeper';
@@ -25,12 +27,14 @@ const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
interface EditAccountContextDialogProps {
account: AuthAccountRow;
groupSummaries: SharedGroupSummary[];
plainCcsLane: PlainCcsLane | null;
onClose: () => void;
}
export function EditAccountContextDialog({
account,
groupSummaries,
plainCcsLane,
onClose,
}: EditAccountContextDialogProps) {
const { t } = useTranslation();
@@ -73,6 +77,43 @@ export function EditAccountContextDialog({
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 = () => {
if (!canSubmit) {
@@ -124,7 +165,11 @@ export function EditAccountContextDialog({
type="button"
role="radio"
aria-checked={mode === 'isolated'}
tabIndex={mode === 'isolated' ? 0 : -1}
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 ${
mode === 'isolated'
? '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"
role="radio"
aria-checked={mode === 'shared'}
tabIndex={mode === 'shared' ? 0 : -1}
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 ${
mode === 'shared'
? '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"
role="radio"
aria-checked={continuityMode === 'standard'}
tabIndex={continuityMode === 'standard' ? 0 : -1}
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 ${
continuityMode === 'standard'
? '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"
role="radio"
aria-checked={continuityMode === 'deeper'}
tabIndex={continuityMode === 'deeper' ? 0 : -1}
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 ${
continuityMode === 'deeper'
? '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')}
</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
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 { api } from '@/lib/api-client';
import type { Account } from '@/lib/api-client';
import type { Account, PlainCcsLane } from '@/lib/api-client';
import {
summarizeAuthAccountContinuity,
type AuthAccountRow,
@@ -30,6 +30,7 @@ export interface AuthAccountsView {
deeperReadyGroups: string[];
sharedGroups: string[];
groupSummaries: SharedGroupSummary[];
plainCcsLane: PlainCcsLane | null;
}
export function useAccounts() {
@@ -61,6 +62,7 @@ export function useAccounts() {
deeperReadyGroups: continuity.deeperReadyGroups,
sharedGroups: continuity.sharedGroups,
groupSummaries: continuity.groupSummaries,
plainCcsLane: data.plain_ccs_lane ?? null,
};
},
});
+12 -1
View File
@@ -733,6 +733,14 @@ export interface Account {
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 {
context_mode: 'isolated' | 'shared';
context_group?: string;
@@ -1120,7 +1128,10 @@ export const api = {
},
},
accounts: {
list: () => request<{ accounts: Account[]; default: string | null }>('/accounts'),
list: () =>
request<{ accounts: Account[]; default: string | null; plain_ccs_lane?: PlainCcsLane }>(
'/accounts'
),
setDefault: (name: string) =>
request('/accounts/default', {
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.',
},
},
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: {
name: 'Name',
type: 'Type',
@@ -573,6 +588,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} same-group peers',
legacyReview: 'Review and confirm this inferred default.',
noHandoff: 'Cross-account resume stays in the original account.',
badges: {
shared: 'Shared',
deeper: 'Deeper',
legacy: 'Legacy',
isolated: 'Isolated',
},
},
addAccountDialog: {
title: 'Add {{displayName}} Account',
@@ -1820,6 +1841,20 @@ const resources = {
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: {
name: '名称',
type: '类型',
@@ -1853,6 +1888,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} 个同组账号',
legacyReview: '请检查并确认这个推断出的默认状态。',
noHandoff: '跨账号续接仍会回到原账号。',
badges: {
shared: '共享',
deeper: '更深',
legacy: '旧策略',
isolated: '隔离',
},
},
addAccountDialog: {
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.',
},
},
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: {
name: 'Tên',
type: 'Kiểu',
@@ -3152,6 +3208,12 @@ const resources = {
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.',
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: {
title: 'Thêm tài khoản {{displayName}}',
@@ -4454,6 +4516,21 @@ const resources = {
'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: {
name: '名前',
type: '種別',
@@ -4488,6 +4565,12 @@ const resources = {
sameGroupPeerCount_other: '{{count}} 件の同グループアカウント',
legacyReview: 'この推定された既定状態を確認して明示的に確定してください。',
noHandoff: 'アカウント間再開は元のアカウントに残ります。',
badges: {
shared: '共有',
deeper: '拡張',
legacy: 'レガシー',
isolated: '分離',
},
},
addAccountDialog: {
title: '{{displayName}} アカウントを追加',
+7
View File
@@ -39,6 +39,7 @@ export function AccountsPage() {
const deeperReadyGroups = data?.deeperReadyGroups || [];
const sharedGroups = data?.sharedGroups || [];
const groupSummaries = data?.groupSummaries || [];
const plainCcsLane = data?.plainCcsLane || null;
const legacyTargets = authAccounts.filter(
(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">
<ContinuityOverview
totalAccounts={authAccounts.length}
primaryAccountName={authAccounts.length === 1 ? authAccounts[0]?.name : null}
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
@@ -200,6 +202,7 @@ export function AccountsPage() {
deeperReadyGroups={deeperReadyGroups}
legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount}
plainCcsLane={plainCcsLane}
/>
<Card className="flex flex-col">
@@ -217,6 +220,7 @@ export function AccountsPage() {
data={authAccounts}
defaultAccount={data?.default ?? null}
groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
/>
)}
</CardContent>
@@ -264,6 +268,7 @@ export function AccountsPage() {
<ContinuityOverview
totalAccounts={authAccounts.length}
primaryAccountName={authAccounts.length === 1 ? authAccounts[0]?.name : null}
isolatedCount={isolatedCount}
sharedStandardCount={sharedStandardCount}
deeperSharedCount={deeperSharedCount}
@@ -275,6 +280,7 @@ export function AccountsPage() {
deeperReadyGroups={deeperReadyGroups}
legacyTargetCount={legacyTargetCount}
cliproxyCount={cliproxyCount}
plainCcsLane={plainCcsLane}
/>
<Card>
@@ -289,6 +295,7 @@ export function AccountsPage() {
data={authAccounts}
defaultAccount={data?.default ?? null}
groupSummaries={groupSummaries}
plainCcsLane={plainCcsLane}
/>
)}
</CardContent>