@znmin/claude-remote-cli 0.3.1 → 0.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.
package/src/client.js CHANGED
@@ -1,232 +1,232 @@
1
- import * as fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import os from 'node:os';
4
- import { createHash, randomUUID } from 'node:crypto';
5
- import { execute, exists, quote, readJSON, restoreTerminal, shellJoin, which, withLock, writeJSON } from './system.js';
6
- import { ensureMutagen } from './installer.js';
7
- import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
8
- import { sshArgs } from './ssh.js';
9
- import { ensureSSH } from './ssh-auth.js';
10
- export { sshArgs } from './ssh.js';
11
-
12
- export const DEFAULTS = {
13
- root: '.local/share/claude-remote/projects', claude: 'claude',
14
- ignores: ['.DS_Store', 'node_modules', '.venv', '__pycache__']
15
- };
16
-
17
- export function validate(config) {
18
- if (config.mode != null && !['local', 'remote'].includes(config.mode)) throw new Error('模式只能是 local 或 remote。');
19
- if (config.browserHeadless != null && typeof config.browserHeadless !== 'boolean') throw new Error('browserHeadless 必须是布尔值。');
20
- if (!/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(config.host || ''))
21
- throw new Error('服务器请填写 SSH 别名或 user@host,不要包含端口、空格或 SSH 参数。IPv6 请使用 SSH 别名。');
22
- if (config.port != null && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535))
23
- throw new Error('端口必须在 1–65535 之间。');
24
- if (typeof config.root !== 'string' || !/^\/?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(config.root) ||
25
- config.root.split('/').some(part => ['.', '..'].includes(part)))
26
- throw new Error('远程根目录仅支持字母、数字、点、短横线、下划线和斜杠,不支持 ..。');
27
- if (typeof config.claude !== 'string' || !config.claude || /[\r\n\0]/.test(config.claude) || config.claude.startsWith('-'))
28
- throw new Error('远程 Claude 程序路径无效。');
29
- if (!Array.isArray(config.ignores) || config.ignores.some(pattern => typeof pattern !== 'string' || !pattern))
30
- throw new Error('忽略规则需要是非空字符串列表。');
31
- if (config.client_id != null && (typeof config.client_id !== 'string' || !config.client_id))
32
- throw new Error('配置文件中的 client_id 无效。');
33
- return config;
34
- }
35
-
36
- export const remotePath = file => file.startsWith('/') ? quote(file) : '"$HOME"/' + quote(file);
37
-
38
- export function assertSynchronized(current) {
39
- if (!current) throw new Error('同步会话不存在。');
40
- if (current.conflicts?.length || current.excludedConflicts)
41
- throw new Error('发现双向修改冲突,未自动覆盖。运行 claude-remote status 查看路径,备份后将两边文件统一,再运行 claude-remote flush。');
42
- for (const side of ['alpha', 'beta']) {
43
- const state = current[side] || {};
44
- if (state.scanProblems?.length || state.transitionProblems?.length || state.excludedScanProblems || state.excludedTransitionProblems)
45
- throw new Error('同步端点有文件处理错误,请运行 claude-remote status 查看详情。');
46
- if (state.connected === false) throw new Error('同步端点已断开,请检查连接后重试。');
47
- }
48
- if (current.lastError) throw new Error('同步尚有错误:' + current.lastError);
49
- if (current.paused) throw new Error('同步会话已暂停,请重新运行 claude 恢复。');
50
- }
51
-
52
- export class Client {
53
- constructor(options = {}) {
54
- this.env = options.env || process.env;
55
- this.home = options.home || os.homedir();
56
- this.cwd = options.cwd || process.cwd();
57
- this.platform = options.platform || process.platform;
58
- this.directory = options.configDir || this.env.CLAUDE_REMOTE_CONFIG_DIR || path.join(this.home, '.config', 'claude-remote');
59
- this.configFile = path.join(this.directory, 'config.json');
60
- this.registryFile = path.join(this.directory, 'projects.json');
61
- this.lockFile = path.join(this.directory, 'npm-state');
62
- this.execute = options.execute || execute;
63
- this.ensureSSH = options.ensureSSH || ensureSSH;
64
- this.ensureMutagen = options.ensureMutagen || ensureMutagen;
65
- this.log = options.log || console.error;
66
- this.warn = options.warn || console.error;
67
- this.debug = (...messages) => {
68
- if (this.env.CLAUDE_REMOTE_DEBUG === '1') this.log(...messages);
69
- };
70
- this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
71
- this.restoreTerminal = options.restoreTerminal || (() => restoreTerminal());
72
- }
73
-
74
- async configure(input) {
75
- return withLock(this.lockFile, async () => {
76
- const old = await readJSON(this.configFile, {});
77
- const config = validate({ ...DEFAULTS, ...input,
78
- mode: input.mode ?? old.mode ?? 'local', browserHeadless: input.browserHeadless ?? old.browserHeadless ?? false,
79
- ignores: [...DEFAULTS.ignores, ...(input.ignores || [])],
80
- client_id: old.client_id || randomUUID().replaceAll('-', '').slice(0, 12) });
81
- await writeJSON(this.configFile, config);
82
- this.log(`已保存:${config.host}(${config.mode === 'local' ? '本地执行' : '远程同步'})。`);
83
- this.debug(`配置文件:${this.configFile}`);
84
- if (old.host && (old.host !== config.host || (old.mode || 'remote') !== config.mode))
85
- this.log('停止旧同步:claude-remote stop --all');
86
- return config;
87
- });
88
- }
89
-
90
- async settings() {
91
- return withLock(this.lockFile, async () => {
92
- const stored = await readJSON(this.configFile, {});
93
- const config = { ...DEFAULTS, ...stored };
94
- if (this.env.CLAUDE_REMOTE_HOST) config.host = this.env.CLAUDE_REMOTE_HOST;
95
- if (this.env.CLAUDE_REMOTE_PORT !== undefined) config.port = Number(this.env.CLAUDE_REMOTE_PORT);
96
- config.mode = this.env.CLAUDE_REMOTE_MODE || config.mode || 'remote';
97
- if (!config.host) throw new Error('尚未配置服务器。请运行:claude-remote configure 用户名@服务器(自定义端口加 --port 2222)。');
98
- validate(config);
99
- if (!config.client_id) {
100
- config.client_id = randomUUID().replaceAll('-', '').slice(0, 12);
101
- // Temporary environment overrides must not change the saved server.
102
- await writeJSON(this.configFile, { ...stored, client_id: config.client_id });
103
- }
104
- return config;
105
- });
106
- }
107
-
108
- async project(config) {
109
- const local = await fs.realpath(this.cwd);
110
- const home = await fs.realpath(this.home);
111
- if (local === home || local === path.parse(local).root)
112
- throw new Error('请进入具体项目目录;不允许同步整个用户目录或磁盘根目录。');
113
- const git = path.join(local, '.git');
114
- if (await exists(git) && (await fs.stat(git)).isFile())
115
- throw new Error('当前目录的 .git 指向外部仓库,暂不支持 Git worktree/子模块;请使用普通独立仓库。');
116
- // Keep the original Python implementation's stable project identifiers.
117
- const identity = '[' + [config.client_id, config.host, config.port ?? null, config.root, local]
118
- .map(value => JSON.stringify(value)).join(', ') + ']';
119
- const key = createHash('sha256').update(identity).digest('hex').slice(0, 24);
120
- return { name: 'claude-remote-' + key, local, remote: config.root + '/' + key,
121
- host: config.host, port: config.port ?? null };
122
- }
123
-
124
- async setMode(mode) {
125
- if (!['local', 'remote'].includes(mode)) throw new Error('模式只能是 local 或 remote。');
126
- await withLock(this.lockFile, async () => {
127
- const stored = await readJSON(this.configFile, {});
128
- await writeJSON(this.configFile, { ...stored, mode });
129
- });
130
- this.log(`默认模式已设为 ${mode}。旧后台同步不会自动停止;如需停止请运行 claude-remote stop --all。`);
131
- }
132
-
133
- async prepare() {
134
- if (!this.mutagen) this.mutagen = await this.ensureMutagen({ env: this.env });
135
- return this.mutagen;
136
- }
137
-
138
- async sync(args, options = {}) {
139
- const binary = await this.prepare();
140
- return this.execute(binary, ['sync', ...args], { env: this.env, capture: true, ...options });
141
- }
142
-
143
- async sessions() {
144
- const result = await this.sync(['list', '--label-selector', 'claude-remote=true', '--template', '{{json .}}'],
145
- { capture: true, timeout: 30000 });
146
- return JSON.parse(result.stdout) || [];
147
- }
148
-
149
- async flush(name) {
150
- await this.sync(['flush', name], { timeout: 120000 });
151
- assertSynchronized((await this.sessions()).find(session => session.name === name));
152
- }
153
-
154
- async remote(config, script, interactive = false, { timeout = 180000 } = {}) {
155
- return this.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && this.tty }),
156
- { env: this.env, timeout: interactive ? 0 : timeout, check: !interactive });
157
- }
158
-
159
- async launch(config, extra) {
160
- this.debug(`连接:${config.host}${config.port ? ':' + config.port : ''},模式:${config.mode || 'remote'}`);
161
- await this.ensureSSH(this, config);
162
- if (config.mode === 'local') {
163
- const { launchLocal } = await import('./local-mode.js');
164
- return launchLocal(this, config, extra);
165
- }
166
- for (const tool of ['ssh', 'scp']) if (!await which(tool, this.env.PATH))
167
- throw new Error(`找不到系统 ${tool},请安装 OpenSSH 客户端。`);
168
- const item = await this.project(config);
169
- await this.prepare();
170
- await withLock(this.lockFile, async () => {
171
- const existing = await this.sessions();
172
- const registry = await readJSON(this.registryFile, {});
173
- for (const session of existing) {
174
- if (session.name === item.name) continue;
175
- const storedPath = session.alpha?.path || registry[session.name]?.local;
176
- if (!storedPath) continue;
177
- let other;
178
- try { other = await fs.realpath(storedPath); }
179
- catch (error) { if (error.code !== 'ENOENT') throw error; other = path.resolve(storedPath); }
180
- const relative = path.relative(other, item.local);
181
- const reverse = path.relative(item.local, other);
182
- const inside = value => value === '' || (!value.startsWith('..' + path.sep) && value !== '..' && !path.isAbsolute(value));
183
- if (inside(relative) || inside(reverse))
184
- throw new Error(`目录与正在同步的项目重叠:${other}\n先运行:claude-remote stop --name ${session.name}`);
185
- }
186
- await this.remote(config, ensureRemoteClaude(config.claude) +
187
- 'mkdir -p -- ' + remotePath(item.remote), false, { timeout: 600000 });
188
- if (existing.some(session => session.name === item.name)) {
189
- await this.sync(['resume', item.name], { timeout: 60000, capture: this.env.CLAUDE_REMOTE_DEBUG !== '1' || !this.tty });
190
- } else {
191
- const endpoint = config.host + ':' + (config.port != null ? config.port + ':' : '') +
192
- (item.remote.startsWith('/') ? item.remote : '~/' + item.remote);
193
- const args = ['create', '--name', item.name, '--label', 'claude-remote=true', '--mode', 'two-way-safe',
194
- '--no-global-configuration', '--no-ignore-vcs', '--watch-polling-interval', '2'];
195
- for (const pattern of config.ignores) args.push('--ignore', pattern);
196
- await this.sync([...args, item.local, endpoint], { timeout: 180000, capture: this.env.CLAUDE_REMOTE_DEBUG !== '1' || !this.tty });
197
- }
198
- registry[item.name] = item;
199
- await writeJSON(this.registryFile, registry);
200
- });
201
- this.debug(`本地:${item.local}\n远程:${config.host}:${item.remote}`);
202
- this.log('正在同步…');
203
- await this.flush(item.name);
204
- this.debug('同步完成,启动远程 Claude Code。退出后继续后台同步;停止请运行 claude-remote stop。');
205
- let code = 1;
206
- try {
207
- code = (await this.remote(config, prefix + 'cd -- ' + remotePath(item.remote) +
208
- ' && exec ' + shellJoin([config.claude, ...extra]), true)).code;
209
- // A dropped connection leaves the remote UI's terminal modes switched on.
210
- if (code !== 0) this.restoreTerminal();
211
- } finally {
212
- try { await this.flush(item.name); }
213
- catch (error) {
214
- this.warn(`退出时同步未确认完成:${error.message}\n会话保留,可用 claude-remote status 检查。`);
215
- if (code === 0) code = 1;
216
- }
217
- }
218
- return code;
219
- }
220
-
221
- async stop({ all = false, name } = {}) {
222
- const selected = all || name ? name : (await this.project(await this.settings())).name;
223
- return withLock(this.lockFile, async () => {
224
- const available = (await this.sessions()).map(session => session.name);
225
- const targets = all ? available : [selected];
226
- if (!targets.length || targets.some(target => !available.includes(target)))
227
- throw new Error('没有找到对应同步会话;运行 claude-remote status 查看。');
228
- await this.sync(['terminate', ...targets], { timeout: 30000 });
229
- this.log('已停止同步,保留本地和远程文件。');
230
- });
231
- }
232
- }
1
+ import * as fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { execute, exists, quote, readJSON, restoreTerminal, shellJoin, which, withLock, writeJSON } from './system.js';
6
+ import { ensureMutagen } from './installer.js';
7
+ import { ensureRemoteClaude, remoteEnvironment as prefix } from './remote-claude.js';
8
+ import { sshArgs } from './ssh.js';
9
+ import { ensureSSH } from './ssh-auth.js';
10
+ export { sshArgs } from './ssh.js';
11
+
12
+ export const DEFAULTS = {
13
+ root: '.local/share/claude-remote/projects', claude: 'claude',
14
+ ignores: ['.DS_Store', 'node_modules', '.venv', '__pycache__']
15
+ };
16
+
17
+ export function validate(config) {
18
+ if (config.mode != null && !['local', 'remote'].includes(config.mode)) throw new Error('模式只能是 local 或 remote。');
19
+ if (config.browserHeadless != null && typeof config.browserHeadless !== 'boolean') throw new Error('browserHeadless 必须是布尔值。');
20
+ if (!/^(?:[A-Za-z0-9_][A-Za-z0-9_.-]*@)?[A-Za-z0-9_][A-Za-z0-9_.-]*$/.test(config.host || ''))
21
+ throw new Error('服务器请填写 SSH 别名或 user@host,不要包含端口、空格或 SSH 参数。IPv6 请使用 SSH 别名。');
22
+ if (config.port != null && (!Number.isInteger(config.port) || config.port < 1 || config.port > 65535))
23
+ throw new Error('端口必须在 1–65535 之间。');
24
+ if (typeof config.root !== 'string' || !/^\/?[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/.test(config.root) ||
25
+ config.root.split('/').some(part => ['.', '..'].includes(part)))
26
+ throw new Error('远程根目录仅支持字母、数字、点、短横线、下划线和斜杠,不支持 ..。');
27
+ if (typeof config.claude !== 'string' || !config.claude || /[\r\n\0]/.test(config.claude) || config.claude.startsWith('-'))
28
+ throw new Error('远程 Claude 程序路径无效。');
29
+ if (!Array.isArray(config.ignores) || config.ignores.some(pattern => typeof pattern !== 'string' || !pattern))
30
+ throw new Error('忽略规则需要是非空字符串列表。');
31
+ if (config.client_id != null && (typeof config.client_id !== 'string' || !config.client_id))
32
+ throw new Error('配置文件中的 client_id 无效。');
33
+ return config;
34
+ }
35
+
36
+ export const remotePath = file => file.startsWith('/') ? quote(file) : '"$HOME"/' + quote(file);
37
+
38
+ export function assertSynchronized(current) {
39
+ if (!current) throw new Error('同步会话不存在。');
40
+ if (current.conflicts?.length || current.excludedConflicts)
41
+ throw new Error('发现双向修改冲突,未自动覆盖。运行 claude-remote status 查看路径,备份后将两边文件统一,再运行 claude-remote flush。');
42
+ for (const side of ['alpha', 'beta']) {
43
+ const state = current[side] || {};
44
+ if (state.scanProblems?.length || state.transitionProblems?.length || state.excludedScanProblems || state.excludedTransitionProblems)
45
+ throw new Error('同步端点有文件处理错误,请运行 claude-remote status 查看详情。');
46
+ if (state.connected === false) throw new Error('同步端点已断开,请检查连接后重试。');
47
+ }
48
+ if (current.lastError) throw new Error('同步尚有错误:' + current.lastError);
49
+ if (current.paused) throw new Error('同步会话已暂停,请重新运行 claude 恢复。');
50
+ }
51
+
52
+ export class Client {
53
+ constructor(options = {}) {
54
+ this.env = options.env || process.env;
55
+ this.home = options.home || os.homedir();
56
+ this.cwd = options.cwd || process.cwd();
57
+ this.platform = options.platform || process.platform;
58
+ this.directory = options.configDir || this.env.CLAUDE_REMOTE_CONFIG_DIR || path.join(this.home, '.config', 'claude-remote');
59
+ this.configFile = path.join(this.directory, 'config.json');
60
+ this.registryFile = path.join(this.directory, 'projects.json');
61
+ this.lockFile = path.join(this.directory, 'npm-state');
62
+ this.execute = options.execute || execute;
63
+ this.ensureSSH = options.ensureSSH || ensureSSH;
64
+ this.ensureMutagen = options.ensureMutagen || ensureMutagen;
65
+ this.log = options.log || console.error;
66
+ this.warn = options.warn || console.error;
67
+ this.debug = (...messages) => {
68
+ if (this.env.CLAUDE_REMOTE_DEBUG === '1') this.log(...messages);
69
+ };
70
+ this.tty = options.tty ?? Boolean(process.stdin.isTTY && process.stdout.isTTY);
71
+ this.restoreTerminal = options.restoreTerminal || (() => restoreTerminal());
72
+ }
73
+
74
+ async configure(input) {
75
+ return withLock(this.lockFile, async () => {
76
+ const old = await readJSON(this.configFile, {});
77
+ const config = validate({ ...DEFAULTS, ...input,
78
+ mode: input.mode ?? old.mode ?? 'local', browserHeadless: input.browserHeadless ?? old.browserHeadless ?? false,
79
+ ignores: [...DEFAULTS.ignores, ...(input.ignores || [])],
80
+ client_id: old.client_id || randomUUID().replaceAll('-', '').slice(0, 12) });
81
+ await writeJSON(this.configFile, config);
82
+ this.log(`已保存:${config.host}(${config.mode === 'local' ? '本地执行' : '远程同步'})。`);
83
+ this.debug(`配置文件:${this.configFile}`);
84
+ if (old.host && (old.host !== config.host || (old.mode || 'remote') !== config.mode))
85
+ this.log('停止旧同步:claude-remote stop --all');
86
+ return config;
87
+ });
88
+ }
89
+
90
+ async settings() {
91
+ return withLock(this.lockFile, async () => {
92
+ const stored = await readJSON(this.configFile, {});
93
+ const config = { ...DEFAULTS, ...stored };
94
+ if (this.env.CLAUDE_REMOTE_HOST) config.host = this.env.CLAUDE_REMOTE_HOST;
95
+ if (this.env.CLAUDE_REMOTE_PORT !== undefined) config.port = Number(this.env.CLAUDE_REMOTE_PORT);
96
+ config.mode = this.env.CLAUDE_REMOTE_MODE || config.mode || 'remote';
97
+ if (!config.host) throw new Error('尚未配置服务器。请运行:claude-remote configure 用户名@服务器(自定义端口加 --port 2222)。');
98
+ validate(config);
99
+ if (!config.client_id) {
100
+ config.client_id = randomUUID().replaceAll('-', '').slice(0, 12);
101
+ // Temporary environment overrides must not change the saved server.
102
+ await writeJSON(this.configFile, { ...stored, client_id: config.client_id });
103
+ }
104
+ return config;
105
+ });
106
+ }
107
+
108
+ async project(config) {
109
+ const local = await fs.realpath(this.cwd);
110
+ const home = await fs.realpath(this.home);
111
+ if (local === home || local === path.parse(local).root)
112
+ throw new Error('请进入具体项目目录;不允许同步整个用户目录或磁盘根目录。');
113
+ const git = path.join(local, '.git');
114
+ if (await exists(git) && (await fs.stat(git)).isFile())
115
+ throw new Error('当前目录的 .git 指向外部仓库,暂不支持 Git worktree/子模块;请使用普通独立仓库。');
116
+ // Keep the original Python implementation's stable project identifiers.
117
+ const identity = '[' + [config.client_id, config.host, config.port ?? null, config.root, local]
118
+ .map(value => JSON.stringify(value)).join(', ') + ']';
119
+ const key = createHash('sha256').update(identity).digest('hex').slice(0, 24);
120
+ return { name: 'claude-remote-' + key, local, remote: config.root + '/' + key,
121
+ host: config.host, port: config.port ?? null };
122
+ }
123
+
124
+ async setMode(mode) {
125
+ if (!['local', 'remote'].includes(mode)) throw new Error('模式只能是 local 或 remote。');
126
+ await withLock(this.lockFile, async () => {
127
+ const stored = await readJSON(this.configFile, {});
128
+ await writeJSON(this.configFile, { ...stored, mode });
129
+ });
130
+ this.log(`默认模式已设为 ${mode}。旧后台同步不会自动停止;如需停止请运行 claude-remote stop --all。`);
131
+ }
132
+
133
+ async prepare() {
134
+ if (!this.mutagen) this.mutagen = await this.ensureMutagen({ env: this.env });
135
+ return this.mutagen;
136
+ }
137
+
138
+ async sync(args, options = {}) {
139
+ const binary = await this.prepare();
140
+ return this.execute(binary, ['sync', ...args], { env: this.env, capture: true, ...options });
141
+ }
142
+
143
+ async sessions() {
144
+ const result = await this.sync(['list', '--label-selector', 'claude-remote=true', '--template', '{{json .}}'],
145
+ { capture: true, timeout: 30000 });
146
+ return JSON.parse(result.stdout) || [];
147
+ }
148
+
149
+ async flush(name) {
150
+ await this.sync(['flush', name], { timeout: 120000 });
151
+ assertSynchronized((await this.sessions()).find(session => session.name === name));
152
+ }
153
+
154
+ async remote(config, script, interactive = false, { timeout = 180000 } = {}) {
155
+ return this.execute('ssh', sshArgs(config, script, { interactive, tty: interactive && this.tty }),
156
+ { env: this.env, timeout: interactive ? 0 : timeout, check: !interactive });
157
+ }
158
+
159
+ async launch(config, extra) {
160
+ this.debug(`连接:${config.host}${config.port ? ':' + config.port : ''},模式:${config.mode || 'remote'}`);
161
+ await this.ensureSSH(this, config);
162
+ if (config.mode === 'local') {
163
+ const { launchLocal } = await import('./local-mode.js');
164
+ return launchLocal(this, config, extra);
165
+ }
166
+ for (const tool of ['ssh', 'scp']) if (!await which(tool, this.env.PATH))
167
+ throw new Error(`找不到系统 ${tool},请安装 OpenSSH 客户端。`);
168
+ const item = await this.project(config);
169
+ await this.prepare();
170
+ await withLock(this.lockFile, async () => {
171
+ const existing = await this.sessions();
172
+ const registry = await readJSON(this.registryFile, {});
173
+ for (const session of existing) {
174
+ if (session.name === item.name) continue;
175
+ const storedPath = session.alpha?.path || registry[session.name]?.local;
176
+ if (!storedPath) continue;
177
+ let other;
178
+ try { other = await fs.realpath(storedPath); }
179
+ catch (error) { if (error.code !== 'ENOENT') throw error; other = path.resolve(storedPath); }
180
+ const relative = path.relative(other, item.local);
181
+ const reverse = path.relative(item.local, other);
182
+ const inside = value => value === '' || (!value.startsWith('..' + path.sep) && value !== '..' && !path.isAbsolute(value));
183
+ if (inside(relative) || inside(reverse))
184
+ throw new Error(`目录与正在同步的项目重叠:${other}\n先运行:claude-remote stop --name ${session.name}`);
185
+ }
186
+ await this.remote(config, ensureRemoteClaude(config.claude) +
187
+ 'mkdir -p -- ' + remotePath(item.remote), false, { timeout: 600000 });
188
+ if (existing.some(session => session.name === item.name)) {
189
+ await this.sync(['resume', item.name], { timeout: 60000, capture: this.env.CLAUDE_REMOTE_DEBUG !== '1' || !this.tty });
190
+ } else {
191
+ const endpoint = config.host + ':' + (config.port != null ? config.port + ':' : '') +
192
+ (item.remote.startsWith('/') ? item.remote : '~/' + item.remote);
193
+ const args = ['create', '--name', item.name, '--label', 'claude-remote=true', '--mode', 'two-way-safe',
194
+ '--no-global-configuration', '--no-ignore-vcs', '--watch-polling-interval', '2'];
195
+ for (const pattern of config.ignores) args.push('--ignore', pattern);
196
+ await this.sync([...args, item.local, endpoint], { timeout: 180000, capture: this.env.CLAUDE_REMOTE_DEBUG !== '1' || !this.tty });
197
+ }
198
+ registry[item.name] = item;
199
+ await writeJSON(this.registryFile, registry);
200
+ });
201
+ this.debug(`本地:${item.local}\n远程:${config.host}:${item.remote}`);
202
+ this.log('正在同步…');
203
+ await this.flush(item.name);
204
+ this.debug('同步完成,启动远程 Claude Code。退出后继续后台同步;停止请运行 claude-remote stop。');
205
+ let code = 1;
206
+ try {
207
+ code = (await this.remote(config, prefix + 'cd -- ' + remotePath(item.remote) +
208
+ ' && exec ' + shellJoin([config.claude, ...extra]), true)).code;
209
+ // A dropped connection leaves the remote UI's terminal modes switched on.
210
+ if (code !== 0) this.restoreTerminal();
211
+ } finally {
212
+ try { await this.flush(item.name); }
213
+ catch (error) {
214
+ this.warn(`退出时同步未确认完成:${error.message}\n会话保留,可用 claude-remote status 检查。`);
215
+ if (code === 0) code = 1;
216
+ }
217
+ }
218
+ return code;
219
+ }
220
+
221
+ async stop({ all = false, name } = {}) {
222
+ const selected = all || name ? name : (await this.project(await this.settings())).name;
223
+ return withLock(this.lockFile, async () => {
224
+ const available = (await this.sessions()).map(session => session.name);
225
+ const targets = all ? available : [selected];
226
+ if (!targets.length || targets.some(target => !available.includes(target)))
227
+ throw new Error('没有找到对应同步会话;运行 claude-remote status 查看。');
228
+ await this.sync(['terminate', ...targets], { timeout: 30000 });
229
+ this.log('已停止同步,保留本地和远程文件。');
230
+ });
231
+ }
232
+ }