@worldzb/agent-sync 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +176 -0
  2. package/dist/agents/abstract-file-adapter.js +53 -0
  3. package/dist/agents/claude-adapter.js +154 -0
  4. package/dist/agents/codex-adapter.js +139 -0
  5. package/dist/agents/jsonl.js +26 -0
  6. package/dist/agents/opencode-adapter.js +193 -0
  7. package/dist/agents/process-runner.js +33 -0
  8. package/dist/agents/registry.js +23 -0
  9. package/dist/agents/types.js +1 -0
  10. package/dist/claude/settings.js +52 -0
  11. package/dist/cli/create-program.js +32 -0
  12. package/dist/commands/add.js +70 -0
  13. package/dist/commands/agents.js +16 -0
  14. package/dist/commands/context.js +1 -0
  15. package/dist/commands/current.js +32 -0
  16. package/dist/commands/delete.js +47 -0
  17. package/dist/commands/history.js +155 -0
  18. package/dist/commands/integrations.js +77 -0
  19. package/dist/commands/list.js +31 -0
  20. package/dist/commands/migrate.js +42 -0
  21. package/dist/commands/sessions.js +52 -0
  22. package/dist/commands/switch.js +93 -0
  23. package/dist/config/config-data.js +85 -0
  24. package/dist/config/config-repository.js +29 -0
  25. package/dist/config/paths.js +16 -0
  26. package/dist/config/types.js +1 -0
  27. package/dist/history/session-loader-worker.js +23 -0
  28. package/dist/history/session-scope.js +10 -0
  29. package/dist/history/session-service.js +29 -0
  30. package/dist/history/ui/formatters.js +37 -0
  31. package/dist/history/ui/history-app.js +262 -0
  32. package/dist/history/ui/key-hints.js +6 -0
  33. package/dist/history/ui/navigation.js +11 -0
  34. package/dist/history/ui/session-details.js +8 -0
  35. package/dist/history/ui/session-list.js +16 -0
  36. package/dist/history/ui/theme.js +14 -0
  37. package/dist/history/ui/viewport.js +13 -0
  38. package/dist/index.js +27 -0
  39. package/dist/migration/migration-service.js +55 -0
  40. package/dist/migration/prompt-renderer.js +27 -0
  41. package/dist/migration/transcript-normalizer.js +53 -0
  42. package/dist/sessions/managed-session-repository.js +56 -0
  43. package/dist/sessions/session-launcher.js +63 -0
  44. package/dist/sessions/session-monitor.js +16 -0
  45. package/dist/ui/output.js +13 -0
  46. package/package.json +52 -0
@@ -0,0 +1,77 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ export function registerIntegrationsCommand(program, context) {
4
+ program
5
+ .command('integrations')
6
+ .alias('manage')
7
+ .description('🧩 查看和管理 Agent 的插件、Skills 与 MCP')
8
+ .option('-a, --agent <agent>', '筛选 Agent:claude、codex、opencode')
9
+ .option('--project <path>', '包含项目级配置')
10
+ .option('--install-plugin <plugin>', '安装插件')
11
+ .option('--remove <agent:kind:name>', '移除资源')
12
+ .option('--scope <scope>', '安装范围:user、project', 'user')
13
+ .action(async (options) => {
14
+ validateAgent(options.agent);
15
+ validateScope(options.scope);
16
+ if (options.installPlugin) {
17
+ const agent = requireAgent(options.agent);
18
+ const accepted = await confirm({
19
+ message: `为 ${agent} ${options.scope === 'project' ? '项目范围' : '用户范围'}安装插件 "${options.installPlugin}"?`,
20
+ initialValue: false,
21
+ });
22
+ if (isCancel(accepted) || !accepted)
23
+ return;
24
+ context.agents.get(agent).installPlugin(options.installPlugin, options.scope || 'user');
25
+ console.log(chalk.green('插件安装命令已完成。'));
26
+ return;
27
+ }
28
+ if (options.remove) {
29
+ const item = findIntegration(context, options.remove, options.project);
30
+ const accepted = await confirm({ message: `移除 ${item.agent} ${item.kind} "${item.name}"?`, initialValue: false });
31
+ if (isCancel(accepted) || !accepted)
32
+ return;
33
+ context.agents.get(item.agent).removeIntegration(item);
34
+ console.log(chalk.green('资源已移除。'));
35
+ return;
36
+ }
37
+ listIntegrations(context, options.agent, options.project);
38
+ });
39
+ }
40
+ function listIntegrations(context, filter, project) {
41
+ const adapters = filter ? [context.agents.get(filter)] : context.agents.all();
42
+ adapters.forEach((adapter) => {
43
+ const items = adapter.listIntegrations(project);
44
+ console.log(chalk.bold(`\n${adapter.name}`));
45
+ if (!items.length) {
46
+ console.log(chalk.gray(' 未发现 plugins、skills 或 MCP。'));
47
+ return;
48
+ }
49
+ items.forEach((item) => console.log(` [${item.kind}] ${item.name} · ${item.scope} · ${item.location}`));
50
+ });
51
+ }
52
+ function findIntegration(context, value, project) {
53
+ const [agent, kind, ...nameParts] = value.split(':');
54
+ const name = nameParts.join(':');
55
+ if (!agent || !kind || !name)
56
+ throw new Error('--remove 应为 <agent>:<kind>:<name>。');
57
+ validateAgent(agent);
58
+ const item = context.agents.get(agent).listIntegrations(project).find((candidate) => candidate.kind === kind && candidate.name === name);
59
+ if (!item)
60
+ throw new Error(`未找到资源:${value}`);
61
+ if (!item.removable)
62
+ throw new Error(`当前版本不支持移除:${value}`);
63
+ return item;
64
+ }
65
+ function requireAgent(agent) {
66
+ if (!agent)
67
+ throw new Error('安装插件时必须通过 --agent 指定目标 Agent。');
68
+ return agent;
69
+ }
70
+ function validateAgent(agent) {
71
+ if (agent && !['claude', 'codex', 'opencode'].includes(agent))
72
+ throw new Error('Agent 必须为 claude、codex 或 opencode。');
73
+ }
74
+ function validateScope(scope) {
75
+ if (scope && scope !== 'user' && scope !== 'project')
76
+ throw new Error('scope 必须为 user 或 project。');
77
+ }
@@ -0,0 +1,31 @@
1
+ import chalk from 'chalk';
2
+ import { findActiveConfig, readClaudeSettings } from '../claude/settings.js';
3
+ import { maskApiKey } from '../ui/output.js';
4
+ export function registerListCommand(program, context) {
5
+ program
6
+ .command('list')
7
+ .alias('ls')
8
+ .description('📋 列出所有 Claude 配置')
9
+ .action(() => listConfigs(context));
10
+ }
11
+ function listConfigs(context) {
12
+ const data = context.repository.read();
13
+ if (data.configs.length === 0) {
14
+ console.log(chalk.yellow('暂无配置,请先添加配置。'));
15
+ return;
16
+ }
17
+ const activeConfig = findActiveConfig(data.configs, readClaudeSettings(context.claudeSettingsFile));
18
+ console.log(chalk.bold('\n📋 可用配置:\n'));
19
+ data.configs.forEach((config) => {
20
+ const isActive = activeConfig?.name === config.name;
21
+ const isDefault = data.current === config.name;
22
+ const icon = isActive ? '🟢' : isDefault ? '🔵' : '⚪';
23
+ const status = isActive
24
+ ? (isDefault ? chalk.green(' (当前默认)') : chalk.yellow(' (使用中)'))
25
+ : (isDefault ? chalk.gray(' (已设为默认)') : '');
26
+ console.log(`${icon} ${chalk.hex('#7C3AED')('[Claude]')} ${chalk.bold(config.name)}${status}`);
27
+ console.log(` 🔑 API Key: ${chalk.gray(maskApiKey(config.apiKey))}`);
28
+ console.log(` 🌐 Base URL: ${chalk.blue(config.baseUrl)}`);
29
+ console.log('');
30
+ });
31
+ }
@@ -0,0 +1,42 @@
1
+ import { cancel, confirm, isCancel, select } from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { parseSessionId } from '../history/session-service.js';
4
+ import { migrateAndLaunch } from './history.js';
5
+ export function registerMigrateCommand(program, context) {
6
+ program
7
+ .command('migrate <session>')
8
+ .description('🔀 将历史会话转换为新 Agent 会话')
9
+ .option('--to <agent>', '目标 Agent:claude、codex、opencode')
10
+ .action(async (sessionId, options) => {
11
+ const parsed = parseSessionId(sessionId);
12
+ const source = context.agents.get(parsed.agent).listSessions().find((session) => session.id === parsed.id);
13
+ if (!source)
14
+ throw new Error(`未找到会话:${sessionId}`);
15
+ const target = options.to || await select({
16
+ message: '选择目标 Agent',
17
+ initialValue: parsed.agent,
18
+ options: context.agents.all().filter((adapter) => adapter.discover().installed).map((adapter) => ({ value: adapter.id, label: adapter.name })),
19
+ });
20
+ if (isCancel(target))
21
+ return void cancel('已取消迁移');
22
+ validateAgent(target);
23
+ const result = await migrateAndLaunch(context, source, target);
24
+ if (!result.launch) {
25
+ console.log(chalk.gray(result.message));
26
+ return;
27
+ }
28
+ const [command, ...args] = result.launch.command;
29
+ if (!command)
30
+ throw new Error('启动命令为空。');
31
+ console.log(chalk.gray('原会话未被修改。'));
32
+ const { spawnSync } = await import('node:child_process');
33
+ const launched = spawnSync(command, args, { cwd: result.launch.cwd, stdio: 'inherit' });
34
+ if (launched.error)
35
+ throw new Error(`无法启动 ${command}:${launched.error.message}`);
36
+ });
37
+ }
38
+ function validateAgent(agent) {
39
+ if (!['claude', 'codex', 'opencode'].includes(agent)) {
40
+ throw new Error('目标 Agent 必须为 claude、codex 或 opencode。');
41
+ }
42
+ }
@@ -0,0 +1,52 @@
1
+ import { confirm, isCancel } from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { describeManagedSession } from '../sessions/session-monitor.js';
4
+ export function registerSessionsCommand(program, context) {
5
+ program
6
+ .command('sessions')
7
+ .description('📡 查看 zmai 启动的托管会话')
8
+ .option('--watch <id>', '显示托管会话的最新状态和输出')
9
+ .option('--stop <id>', '停止托管会话')
10
+ .action(async (options) => {
11
+ if (options.watch) {
12
+ showSession(context, options.watch);
13
+ return;
14
+ }
15
+ if (options.stop) {
16
+ const accepted = await confirm({ message: `停止托管会话 "${options.stop}"?`, initialValue: false });
17
+ if (isCancel(accepted) || !accepted)
18
+ return;
19
+ context.sessionLauncher.stop(context.managedSessions.get(options.stop));
20
+ console.log(chalk.green('托管会话已停止。'));
21
+ return;
22
+ }
23
+ const sessions = context.managedSessions.read();
24
+ if (!sessions.length) {
25
+ console.log(chalk.yellow('暂无由 zmai 启动的托管会话。'));
26
+ return;
27
+ }
28
+ sessions.forEach((session) => {
29
+ const status = context.sessionLauncher.status(session);
30
+ console.log(`${status === 'running' ? '🟢' : '⚪'} ${session.id} [${session.agent}] ${status}`);
31
+ console.log(` ${session.cwd} · ${session.createdAt}`);
32
+ });
33
+ });
34
+ program
35
+ .command('watch <id>')
36
+ .description('👀 查看托管会话进度和最新输出')
37
+ .action((id) => showSession(context, id));
38
+ program
39
+ .command('stop <id>')
40
+ .description('⏹ 停止托管会话')
41
+ .action(async (id) => {
42
+ const accepted = await confirm({ message: `停止托管会话 "${id}"?`, initialValue: false });
43
+ if (isCancel(accepted) || !accepted)
44
+ return;
45
+ context.sessionLauncher.stop(context.managedSessions.get(id));
46
+ console.log(chalk.green('托管会话已停止。'));
47
+ });
48
+ }
49
+ function showSession(context, id) {
50
+ const session = context.managedSessions.get(id);
51
+ console.log(describeManagedSession(session, context.sessionLauncher));
52
+ }
@@ -0,0 +1,93 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { cancel, intro, isCancel, note, outro, select } from '@clack/prompts';
4
+ import chalk from 'chalk';
5
+ import { applyDefaultConfig, createTemporaryExports, readClaudeSettings, writeClaudeSettings, } from '../claude/settings.js';
6
+ import { getConfig, setCurrentConfig } from '../config/config-data.js';
7
+ import { maskApiKey, printSuccess } from '../ui/output.js';
8
+ export function registerSwitchCommand(program, context) {
9
+ program
10
+ .command('switch')
11
+ .alias('use')
12
+ .description('🔄 切换 Claude API 配置')
13
+ .option('-i, --interactive', '交互式选择')
14
+ .option('-n, --name <name>', '配置名称')
15
+ .option('-t, --temp', '临时使用(仅当前终端)')
16
+ .option('-d, --default', '设为默认(修改 .claude/settings.json)')
17
+ .option('-e, --eval', '输出 eval 可执行的命令(配合 -t 使用)')
18
+ .action(async (options) => {
19
+ if (options.eval && !options.temp) {
20
+ throw new Error('--eval 只能与 --temp 一起使用。');
21
+ }
22
+ if (options.interactive || !options.name) {
23
+ await interactiveSwitch(context, options);
24
+ return;
25
+ }
26
+ switchNamedConfig(context, options.name, options.temp === true, options.eval === true);
27
+ });
28
+ }
29
+ async function interactiveSwitch(context, options) {
30
+ if (options.eval) {
31
+ throw new Error('--eval 不能与交互式模式一起使用。');
32
+ }
33
+ console.clear();
34
+ intro(chalk.cyan.bold('🔄 Claude API Switcher - 切换配置'));
35
+ const data = context.repository.read();
36
+ if (data.configs.length === 0) {
37
+ return void outro(chalk.yellow('暂无配置,请先添加配置。'));
38
+ }
39
+ const selected = await select({
40
+ message: '选择要切换的配置',
41
+ options: data.configs.map((config) => ({
42
+ value: config.name,
43
+ label: `${config.name}${data.current === config.name ? ' (当前默认)' : ''}`,
44
+ hint: maskApiKey(config.apiKey),
45
+ })),
46
+ });
47
+ if (isCancel(selected))
48
+ return void cancel('操作已取消');
49
+ const selectedName = String(selected);
50
+ const mode = options.temp || options.default
51
+ ? (options.temp ? 'temp' : 'default')
52
+ : await select({
53
+ message: '选择切换模式',
54
+ options: [
55
+ { value: 'temp', label: '🔹 临时使用(仅当前终端)', hint: '不影响默认配置' },
56
+ { value: 'default', label: '🟢 设为默认(全局配置)', hint: '修改 Claude 设置,需要重启' },
57
+ ],
58
+ });
59
+ if (isCancel(mode))
60
+ return void cancel('操作已取消');
61
+ switchNamedConfig(context, selectedName, mode === 'temp', false);
62
+ outro('');
63
+ }
64
+ function switchNamedConfig(context, name, temporary, evalMode) {
65
+ const data = context.repository.read();
66
+ const config = getConfig(data, name);
67
+ if (temporary) {
68
+ switchTemporary(config, evalMode);
69
+ return;
70
+ }
71
+ const updatedSettings = applyDefaultConfig(readClaudeSettings(context.claudeSettingsFile), config);
72
+ writeClaudeSettings(context.claudeSettingsFile, updatedSettings);
73
+ writeEnvironmentFile(context.environmentFile, config);
74
+ context.repository.write(setCurrentConfig(data, name));
75
+ printSuccess(`已将 "${name}" 设置为默认配置`);
76
+ note(`配置: ${name}\nAPI Key: ${maskApiKey(config.apiKey)}\nBase URL: ${config.baseUrl}\n\n已更新:\n~/.claude/settings.json\n~/.claude-switch-config/.claude-env\n\n需要重启 Claude Code 才能生效。`, '默认配置已更新');
77
+ }
78
+ function switchTemporary(config, evalMode) {
79
+ const exports = createTemporaryExports(config);
80
+ if (evalMode) {
81
+ console.log(exports);
82
+ return;
83
+ }
84
+ printSuccess(`临时使用配置 "${config.name}"`);
85
+ console.log(chalk.cyan('\n在当前终端运行以下命令:\n'));
86
+ console.log(exports);
87
+ console.log(chalk.yellow(`\n或直接运行:\n eval $(zmai switch -n ${JSON.stringify(config.name)} -t --eval)`));
88
+ }
89
+ function writeEnvironmentFile(filePath, config) {
90
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
91
+ fs.writeFileSync(filePath, `${createTemporaryExports(config)}\n`, { encoding: 'utf8', mode: 0o600 });
92
+ fs.chmodSync(filePath, 0o600);
93
+ }
@@ -0,0 +1,85 @@
1
+ const DEFAULT_BASE_URL = 'https://api.anthropic.com';
2
+ export function normalizeConfigData(value) {
3
+ if (!isRecord(value) || !Array.isArray(value.configs)) {
4
+ return { configs: [], current: null };
5
+ }
6
+ const configs = value.configs
7
+ .filter(isRecord)
8
+ .filter((config) => config.target === undefined || config.target === 'claude')
9
+ .map(toApiConfig)
10
+ .filter((config) => config !== null);
11
+ const current = typeof value.current === 'string' && configs.some((config) => config.name === value.current)
12
+ ? value.current
13
+ : null;
14
+ return { configs, current };
15
+ }
16
+ export function addConfig(data, config) {
17
+ if (data.configs.some((item) => item.name === config.name)) {
18
+ throw new Error(`配置 "${config.name}" 已存在。`);
19
+ }
20
+ return { ...data, configs: [...data.configs, config] };
21
+ }
22
+ export function deleteConfig(data, name) {
23
+ if (!data.configs.some((config) => config.name === name)) {
24
+ throw new Error(`配置 "${name}" 不存在。`);
25
+ }
26
+ return {
27
+ configs: data.configs.filter((config) => config.name !== name),
28
+ current: data.current === name ? null : data.current,
29
+ };
30
+ }
31
+ export function setCurrentConfig(data, name) {
32
+ if (!data.configs.some((config) => config.name === name)) {
33
+ throw new Error(`配置 "${name}" 不存在。`);
34
+ }
35
+ return { ...data, current: name };
36
+ }
37
+ export function getConfig(data, name) {
38
+ const config = data.configs.find((item) => item.name === name);
39
+ if (!config) {
40
+ throw new Error(`配置 "${name}" 不存在。`);
41
+ }
42
+ return config;
43
+ }
44
+ export function createApiConfig(input) {
45
+ const name = input.name.trim();
46
+ const apiKey = input.apiKey.trim();
47
+ const baseUrl = (input.baseUrl || DEFAULT_BASE_URL).trim();
48
+ if (!name) {
49
+ throw new Error('请输入配置名称。');
50
+ }
51
+ if (!apiKey) {
52
+ throw new Error('请输入 API Key。');
53
+ }
54
+ try {
55
+ new URL(baseUrl);
56
+ }
57
+ catch {
58
+ throw new Error('Base URL 格式不正确。');
59
+ }
60
+ return {
61
+ name,
62
+ apiKey,
63
+ baseUrl,
64
+ ...(input.createdAt ? { createdAt: input.createdAt } : {}),
65
+ };
66
+ }
67
+ function toApiConfig(value) {
68
+ if (typeof value.name !== 'string' || typeof value.apiKey !== 'string') {
69
+ return null;
70
+ }
71
+ try {
72
+ return createApiConfig({
73
+ name: value.name,
74
+ apiKey: value.apiKey,
75
+ baseUrl: typeof value.baseUrl === 'string' ? value.baseUrl : DEFAULT_BASE_URL,
76
+ createdAt: typeof value.createdAt === 'string' ? value.createdAt : undefined,
77
+ });
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ function isRecord(value) {
84
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
85
+ }
@@ -0,0 +1,29 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { normalizeConfigData } from './config-data.js';
4
+ export class ConfigRepository {
5
+ filePath;
6
+ constructor(filePath) {
7
+ this.filePath = filePath;
8
+ }
9
+ read() {
10
+ if (!fs.existsSync(this.filePath)) {
11
+ return { configs: [], current: null };
12
+ }
13
+ try {
14
+ return normalizeConfigData(JSON.parse(fs.readFileSync(this.filePath, 'utf8')));
15
+ }
16
+ catch {
17
+ throw new Error(`无法读取配置文件:${this.filePath}`);
18
+ }
19
+ }
20
+ write(data) {
21
+ const directory = path.dirname(this.filePath);
22
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
23
+ fs.writeFileSync(this.filePath, `${JSON.stringify(normalizeConfigData(data), null, 2)}\n`, {
24
+ encoding: 'utf8',
25
+ mode: 0o600,
26
+ });
27
+ fs.chmodSync(this.filePath, 0o600);
28
+ }
29
+ }
@@ -0,0 +1,16 @@
1
+ import path from 'node:path';
2
+ export function getAppPaths(homeDirectory = process.env.HOME || process.env.USERPROFILE) {
3
+ if (!homeDirectory) {
4
+ throw new Error('无法确定用户主目录。');
5
+ }
6
+ const configDirectory = path.join(homeDirectory, '.claude-switch-config');
7
+ const zmaiDirectory = path.join(homeDirectory, '.zmai');
8
+ return {
9
+ configFile: path.join(configDirectory, 'claude-configs.json'),
10
+ environmentFile: path.join(configDirectory, '.claude-env'),
11
+ claudeSettingsFile: path.join(homeDirectory, '.claude', 'settings.json'),
12
+ zmaiDirectory,
13
+ managedSessionsFile: path.join(zmaiDirectory, 'sessions.json'),
14
+ migrationDirectory: path.join(zmaiDirectory, 'migrations'),
15
+ };
16
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { parentPort } from 'node:worker_threads';
2
+ import { AgentRegistry } from '../agents/registry.js';
3
+ const port = parentPort;
4
+ if (!port) {
5
+ throw new Error('历史扫描 Worker 必须在 worker_threads 中运行。');
6
+ }
7
+ const homeDirectory = process.env.HOME || process.env.USERPROFILE;
8
+ if (!homeDirectory) {
9
+ throw new Error('无法确定用户主目录。');
10
+ }
11
+ try {
12
+ const registry = new AgentRegistry(homeDirectory);
13
+ const sessions = registry.all().flatMap((adapter) => {
14
+ port.postMessage({ type: 'progress', message: `正在扫描 ${adapter.name} 的本地记录…` });
15
+ const result = adapter.listSessions();
16
+ port.postMessage({ type: 'progress', message: `${adapter.name} 扫描完成,找到 ${result.length} 条会话。` });
17
+ return result;
18
+ });
19
+ port.postMessage({ type: 'complete', sessions });
20
+ }
21
+ catch (error) {
22
+ port.postMessage({ type: 'error', message: error instanceof Error ? error.message : '历史扫描失败。' });
23
+ }
@@ -0,0 +1,10 @@
1
+ import path from 'node:path';
2
+ export function filterSessionsByScope(sessions, scope, currentDirectory) {
3
+ if (scope === 'all')
4
+ return sessions;
5
+ const normalizedCurrentDirectory = normalizePath(currentDirectory);
6
+ return sessions.filter((session) => normalizePath(session.cwd) === normalizedCurrentDirectory);
7
+ }
8
+ function normalizePath(value) {
9
+ return path.resolve(value);
10
+ }
@@ -0,0 +1,29 @@
1
+ export function pageSessions(sessions, options) {
2
+ const pageSize = Math.max(1, Math.min(options.pageSize, 100));
3
+ const filtered = options.agent ? sessions.filter((session) => session.agent === options.agent) : sessions;
4
+ const ordered = [...filtered].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
5
+ const totalPages = Math.max(1, Math.ceil(ordered.length / pageSize));
6
+ const page = Math.max(1, Math.min(options.page, totalPages));
7
+ const start = (page - 1) * pageSize;
8
+ return {
9
+ items: ordered.slice(start, start + pageSize),
10
+ page,
11
+ pageSize,
12
+ total: ordered.length,
13
+ totalPages,
14
+ };
15
+ }
16
+ export function createSessionId(session) {
17
+ return `${session.agent}:${encodeURIComponent(session.id)}`;
18
+ }
19
+ export function parseSessionId(value) {
20
+ const separator = value.indexOf(':');
21
+ if (separator < 1) {
22
+ throw new Error('会话 ID 应为 <agent>:<session-id>。');
23
+ }
24
+ const agent = value.slice(0, separator);
25
+ if (!['claude', 'codex', 'opencode'].includes(agent)) {
26
+ throw new Error(`不支持的 Agent:${agent}`);
27
+ }
28
+ return { agent, id: decodeURIComponent(value.slice(separator + 1)) };
29
+ }
@@ -0,0 +1,37 @@
1
+ export function formatSessionRow(session, width) {
2
+ const titleWidth = Math.max(12, Math.floor(width * 0.56));
3
+ const cwdWidth = Math.max(12, width - titleWidth);
4
+ return {
5
+ agent: session.agent.toUpperCase(),
6
+ id: session.id.slice(0, 8),
7
+ title: truncate(session.title.replaceAll(/\s+/g, ' '), titleWidth),
8
+ cwd: truncatePath(session.cwd, cwdWidth),
9
+ };
10
+ }
11
+ export function relativeTime(value, now = new Date()) {
12
+ const elapsedSeconds = Math.max(0, Math.floor((now.valueOf() - new Date(value).valueOf()) / 1_000));
13
+ if (elapsedSeconds < 60)
14
+ return '刚刚';
15
+ if (elapsedSeconds < 3_600)
16
+ return `${Math.floor(elapsedSeconds / 60)} 分钟前`;
17
+ if (elapsedSeconds < 86_400)
18
+ return `${Math.floor(elapsedSeconds / 3_600)} 小时前`;
19
+ if (elapsedSeconds < 2_592_000)
20
+ return `${Math.floor(elapsedSeconds / 86_400)} 天前`;
21
+ return new Date(value).toLocaleDateString('zh-CN');
22
+ }
23
+ export function truncate(value, width) {
24
+ if (value.length <= width)
25
+ return value;
26
+ return `${value.slice(0, Math.max(0, width - 1))}…`;
27
+ }
28
+ export function truncatePath(value, width) {
29
+ if (value.length <= width)
30
+ return value;
31
+ const segments = value.split('/').filter(Boolean);
32
+ let result = '';
33
+ while (segments.length && result.length < width - 1) {
34
+ result = `/${segments.pop()}${result}`;
35
+ }
36
+ return truncate(`…${result}`, width);
37
+ }