@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.
@@ -1,144 +1,144 @@
1
- import { spawn } from 'node:child_process';
2
- import { randomUUID } from 'node:crypto';
3
- import path from 'node:path';
4
- import { findShell, prepareCommand } from './shell.js';
5
-
6
- // Characters retained per stream; older output is dropped but offsets stay absolute.
7
- const LIMIT = 256 * 1024;
8
- const PREVIEW = 8000;
9
- const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
-
11
- export class Processes {
12
- constructor(workspace, env = process.env, { platform = process.platform, shell } = {}) {
13
- this.workspace = workspace; this.env = env; this.platform = platform; this.jobs = new Map();
14
- if (shell) this.shellChoice = Promise.resolve(shell);
15
- }
16
-
17
- shell() { return this.shellChoice ||= findShell(this.env, this.platform); }
18
-
19
- job(id) {
20
- const job = this.jobs.get(id);
21
- if (!job) throw new Error('找不到本会话的进程。');
22
- return job;
23
- }
24
-
25
- meta(id) {
26
- const job = this.job(id);
27
- return { job_id: id, pid: job.child.pid, running: !job.ended, exit_code: job.code ?? null,
28
- signal: job.signal ?? null, timed_out: Boolean(job.timedOut), cwd: job.cwd };
29
- }
30
-
31
- // Without a stream: the last PREVIEW characters of stdout and stderr.
32
- // With a stream: a page starting at an absolute character offset.
33
- status(id, { stream, offset, limit = PREVIEW } = {}) {
34
- const job = this.job(id);
35
- const totals = { stdout_total: job.stdout.dropped + job.stdout.text.length,
36
- stderr_total: job.stderr.dropped + job.stderr.text.length };
37
- if (!stream) {
38
- const truncated = job.stdout.dropped > 0 || job.stderr.dropped > 0 ||
39
- job.stdout.text.length > PREVIEW || job.stderr.text.length > PREVIEW;
40
- return { ...this.meta(id), stdout: job.stdout.text.slice(-PREVIEW), stderr: job.stderr.text.slice(-PREVIEW),
41
- output_truncated: truncated, ...totals,
42
- ...(truncated ? { hint: '仅返回末尾输出。用 process_status 的 stream、offset、limit 参数分页读取更早的内容。' } : {}) };
43
- }
44
- const buffer = job[stream];
45
- const total = buffer.dropped + buffer.text.length;
46
- const start = Math.max(offset ?? 0, buffer.dropped);
47
- const text = buffer.text.slice(start - buffer.dropped, start - buffer.dropped + limit);
48
- const next = start + text.length;
49
- return { ...this.meta(id), stream, offset: start, text, total, available_from: buffer.dropped,
50
- next_offset: next < total ? next : null,
51
- ...(offset !== undefined && offset < buffer.dropped ? { note: `前 ${buffer.dropped} 个字符已丢弃,仅保留最近 ${LIMIT} 个字符。` } : {}) };
52
- }
53
-
54
- async run(command, cwd = '.', timeoutMs = 30000, background = false, signal) {
55
- if ([...this.jobs.values()].filter(job => !job.ended).length >= 16) throw new Error('后台进程过多,请先停止部分进程。');
56
- for (const [id, job] of this.jobs) if (this.jobs.size >= 64 && job.ended) this.jobs.delete(id);
57
- const directory = await this.workspace.resolve(cwd);
58
- const prepared = await prepareCommand(await this.shell(), command, directory, this.env);
59
- const windows = this.platform === 'win32';
60
- // POSIX: a new process group, stopped as a whole. Windows: no new console
61
- // window; the process tree is stopped with taskkill instead.
62
- let child;
63
- try {
64
- child = spawn(prepared.file, prepared.args, {
65
- cwd: directory, env: prepared.env, detached: !windows, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
66
- });
67
- } catch (error) { await prepared.cleanup(); throw error; }
68
- const id = randomUUID();
69
- const job = { child, cwd: directory, ended: false,
70
- stdout: { text: '', dropped: 0 }, stderr: { text: '', dropped: 0 } };
71
- this.jobs.set(id, job);
72
- job.done = new Promise(resolve => {
73
- child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
74
- for (const stream of ['stdout', 'stderr']) child[stream].on('data', chunk => {
75
- const buffer = job[stream];
76
- buffer.text += chunk;
77
- if (buffer.text.length > LIMIT) {
78
- const excess = buffer.text.length - LIMIT;
79
- buffer.text = buffer.text.slice(excess);
80
- buffer.dropped += excess;
81
- }
82
- });
83
- child.once('error', error => { job.stderr.text += error.message; job.code = 127; job.ended = true; resolve(); });
84
- child.once('close', (code, sig) => { job.code = code; job.signal = sig; job.ended = true; resolve(); });
85
- }).finally(prepared.cleanup);
86
- if (background) {
87
- await Promise.race([job.done, delay(80)]);
88
- return this.status(id);
89
- }
90
- const abort = () => this.stop(id).catch(() => {});
91
- signal?.addEventListener('abort', abort, { once: true });
92
- const timer = setTimeout(() => { job.timedOut = true; abort(); }, timeoutMs);
93
- try {
94
- if (signal?.aborted) abort();
95
- await job.done;
96
- return this.status(id);
97
- } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
98
- }
99
-
100
- killGroup(job, signal) {
101
- if (!job.child.pid) return;
102
- try { process.kill(-job.child.pid, signal); }
103
- catch (error) { if (error.code !== 'ESRCH') throw error; }
104
- }
105
-
106
- // Console programs ignore taskkill's polite close request, so the tree is
107
- // stopped forcefully. It only reaches processes whose parent is still alive.
108
- killTree(job) {
109
- if (!job.child.pid) return Promise.resolve();
110
- const taskkill = path.win32.join(this.env.SystemRoot || this.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'taskkill.exe');
111
- return new Promise(resolve => {
112
- const killer = spawn(taskkill, ['/PID', String(job.child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
113
- killer.once('error', resolve); killer.once('close', resolve);
114
- });
115
- }
116
-
117
- async stop(id) {
118
- const job = this.job(id);
119
- if (job.ended) return this.status(id);
120
- if (this.platform === 'win32') {
121
- await this.killTree(job);
122
- await Promise.race([job.done, delay(1500)]);
123
- if (!job.ended) job.child.kill();
124
- await Promise.race([job.done, delay(1000)]);
125
- return this.status(id);
126
- }
127
- // Kill the owned group even if its parent shell has already exited.
128
- this.killGroup(job, 'SIGTERM');
129
- await Promise.race([job.done, delay(1500)]);
130
- this.killGroup(job, 'SIGKILL');
131
- await Promise.race([job.done, delay(1000)]);
132
- return this.status(id);
133
- }
134
-
135
- list() {
136
- return [...this.jobs.keys()].map(id => {
137
- const job = this.jobs.get(id);
138
- return { ...this.meta(id), stdout_characters: job.stdout.dropped + job.stdout.text.length,
139
- stderr_characters: job.stderr.dropped + job.stderr.text.length };
140
- });
141
- }
142
-
143
- async close() { await Promise.allSettled([...this.jobs.keys()].map(id => this.stop(id))); }
144
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { findShell, prepareCommand } from './shell.js';
5
+
6
+ // Characters retained per stream; older output is dropped but offsets stay absolute.
7
+ const LIMIT = 256 * 1024;
8
+ const PREVIEW = 8000;
9
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
10
+
11
+ export class Processes {
12
+ constructor(workspace, env = process.env, { platform = process.platform, shell } = {}) {
13
+ this.workspace = workspace; this.env = env; this.platform = platform; this.jobs = new Map();
14
+ if (shell) this.shellChoice = Promise.resolve(shell);
15
+ }
16
+
17
+ shell() { return this.shellChoice ||= findShell(this.env, this.platform); }
18
+
19
+ job(id) {
20
+ const job = this.jobs.get(id);
21
+ if (!job) throw new Error('找不到本会话的进程。');
22
+ return job;
23
+ }
24
+
25
+ meta(id) {
26
+ const job = this.job(id);
27
+ return { job_id: id, pid: job.child.pid, running: !job.ended, exit_code: job.code ?? null,
28
+ signal: job.signal ?? null, timed_out: Boolean(job.timedOut), cwd: job.cwd };
29
+ }
30
+
31
+ // Without a stream: the last PREVIEW characters of stdout and stderr.
32
+ // With a stream: a page starting at an absolute character offset.
33
+ status(id, { stream, offset, limit = PREVIEW } = {}) {
34
+ const job = this.job(id);
35
+ const totals = { stdout_total: job.stdout.dropped + job.stdout.text.length,
36
+ stderr_total: job.stderr.dropped + job.stderr.text.length };
37
+ if (!stream) {
38
+ const truncated = job.stdout.dropped > 0 || job.stderr.dropped > 0 ||
39
+ job.stdout.text.length > PREVIEW || job.stderr.text.length > PREVIEW;
40
+ return { ...this.meta(id), stdout: job.stdout.text.slice(-PREVIEW), stderr: job.stderr.text.slice(-PREVIEW),
41
+ output_truncated: truncated, ...totals,
42
+ ...(truncated ? { hint: '仅返回末尾输出。用 process_status 的 stream、offset、limit 参数分页读取更早的内容。' } : {}) };
43
+ }
44
+ const buffer = job[stream];
45
+ const total = buffer.dropped + buffer.text.length;
46
+ const start = Math.max(offset ?? 0, buffer.dropped);
47
+ const text = buffer.text.slice(start - buffer.dropped, start - buffer.dropped + limit);
48
+ const next = start + text.length;
49
+ return { ...this.meta(id), stream, offset: start, text, total, available_from: buffer.dropped,
50
+ next_offset: next < total ? next : null,
51
+ ...(offset !== undefined && offset < buffer.dropped ? { note: `前 ${buffer.dropped} 个字符已丢弃,仅保留最近 ${LIMIT} 个字符。` } : {}) };
52
+ }
53
+
54
+ async run(command, cwd = '.', timeoutMs = 30000, background = false, signal) {
55
+ if ([...this.jobs.values()].filter(job => !job.ended).length >= 16) throw new Error('后台进程过多,请先停止部分进程。');
56
+ for (const [id, job] of this.jobs) if (this.jobs.size >= 64 && job.ended) this.jobs.delete(id);
57
+ const directory = await this.workspace.resolve(cwd);
58
+ const prepared = await prepareCommand(await this.shell(), command, directory, this.env);
59
+ const windows = this.platform === 'win32';
60
+ // POSIX: a new process group, stopped as a whole. Windows: no new console
61
+ // window; the process tree is stopped with taskkill instead.
62
+ let child;
63
+ try {
64
+ child = spawn(prepared.file, prepared.args, {
65
+ cwd: directory, env: prepared.env, detached: !windows, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe']
66
+ });
67
+ } catch (error) { await prepared.cleanup(); throw error; }
68
+ const id = randomUUID();
69
+ const job = { child, cwd: directory, ended: false,
70
+ stdout: { text: '', dropped: 0 }, stderr: { text: '', dropped: 0 } };
71
+ this.jobs.set(id, job);
72
+ job.done = new Promise(resolve => {
73
+ child.stdout.setEncoding('utf8'); child.stderr.setEncoding('utf8');
74
+ for (const stream of ['stdout', 'stderr']) child[stream].on('data', chunk => {
75
+ const buffer = job[stream];
76
+ buffer.text += chunk;
77
+ if (buffer.text.length > LIMIT) {
78
+ const excess = buffer.text.length - LIMIT;
79
+ buffer.text = buffer.text.slice(excess);
80
+ buffer.dropped += excess;
81
+ }
82
+ });
83
+ child.once('error', error => { job.stderr.text += error.message; job.code = 127; job.ended = true; resolve(); });
84
+ child.once('close', (code, sig) => { job.code = code; job.signal = sig; job.ended = true; resolve(); });
85
+ }).finally(prepared.cleanup);
86
+ if (background) {
87
+ await Promise.race([job.done, delay(80)]);
88
+ return this.status(id);
89
+ }
90
+ const abort = () => this.stop(id).catch(() => {});
91
+ signal?.addEventListener('abort', abort, { once: true });
92
+ const timer = setTimeout(() => { job.timedOut = true; abort(); }, timeoutMs);
93
+ try {
94
+ if (signal?.aborted) abort();
95
+ await job.done;
96
+ return this.status(id);
97
+ } finally { clearTimeout(timer); signal?.removeEventListener('abort', abort); }
98
+ }
99
+
100
+ killGroup(job, signal) {
101
+ if (!job.child.pid) return;
102
+ try { process.kill(-job.child.pid, signal); }
103
+ catch (error) { if (error.code !== 'ESRCH') throw error; }
104
+ }
105
+
106
+ // Console programs ignore taskkill's polite close request, so the tree is
107
+ // stopped forcefully. It only reaches processes whose parent is still alive.
108
+ killTree(job) {
109
+ if (!job.child.pid) return Promise.resolve();
110
+ const taskkill = path.win32.join(this.env.SystemRoot || this.env.SYSTEMROOT || 'C:\\Windows', 'System32', 'taskkill.exe');
111
+ return new Promise(resolve => {
112
+ const killer = spawn(taskkill, ['/PID', String(job.child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
113
+ killer.once('error', resolve); killer.once('close', resolve);
114
+ });
115
+ }
116
+
117
+ async stop(id) {
118
+ const job = this.job(id);
119
+ if (job.ended) return this.status(id);
120
+ if (this.platform === 'win32') {
121
+ await this.killTree(job);
122
+ await Promise.race([job.done, delay(1500)]);
123
+ if (!job.ended) job.child.kill();
124
+ await Promise.race([job.done, delay(1000)]);
125
+ return this.status(id);
126
+ }
127
+ // Kill the owned group even if its parent shell has already exited.
128
+ this.killGroup(job, 'SIGTERM');
129
+ await Promise.race([job.done, delay(1500)]);
130
+ this.killGroup(job, 'SIGKILL');
131
+ await Promise.race([job.done, delay(1000)]);
132
+ return this.status(id);
133
+ }
134
+
135
+ list() {
136
+ return [...this.jobs.keys()].map(id => {
137
+ const job = this.jobs.get(id);
138
+ return { ...this.meta(id), stdout_characters: job.stdout.dropped + job.stdout.text.length,
139
+ stderr_characters: job.stderr.dropped + job.stderr.text.length };
140
+ });
141
+ }
142
+
143
+ async close() { await Promise.allSettled([...this.jobs.keys()].map(id => this.stop(id))); }
144
+ }