@brierb/brier-cli 0.0.9 → 0.0.11

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.
@@ -50,11 +50,19 @@ const startDaemon = (config) => {
50
50
  cwd: message.cwd,
51
51
  env: message.env,
52
52
  prompt: message.prompt,
53
+ execMode: message.execMode,
53
54
  });
54
55
  },
55
56
  onTaskCancel: (taskId) => {
56
57
  taskExecutor.cancel(taskId);
57
58
  },
59
+ onTaskInput: (message) => {
60
+ // 回答 AI 提问 / 交互输入:写入执行器;任务不存在/不可写时记录即可(无应答通道,靠输出侧感知)
61
+ const ok = taskExecutor.writeInput(message.taskId, message.data);
62
+ if (!ok) {
63
+ logger.warn(`Task ${message.taskId} input ignored: task not running or not writable`);
64
+ }
65
+ },
58
66
  };
59
67
  const client = createTunnelClient(config, messageHandlers);
60
68
  tunnel = client;
@@ -1,19 +1,24 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import pty from 'node-pty';
2
3
  import { logger, resolveRuntimeExecutable, RUNTIME_PROMPT_FLAGS } from '../core/index.js';
3
4
  const MAX_CONCURRENT = 3;
4
- /** 取消后等待 SIGTERM 生效的时间,超时升级 SIGKILL */
5
+ /** 取消后等待 SIGTERM 生效的时间,超时升级 SIGKILL(仅 pipe 模式需要;pty.kill 为同步终止) */
5
6
  const KILL_GRACE_MS = 2_000;
6
7
  /** dispose 整体等待上限 */
7
8
  const DISPOSE_TIMEOUT_MS = 2_000;
8
9
  const DISPOSE_POLL_MS = 100;
9
10
  export const createTaskExecutor = (callbacks) => {
10
- const processes = new Map();
11
- /** 已请求取消的任务:其 close 不再上报 complete */
11
+ /** pipe 模式子进程(ChildProcess,原有实现) */
12
+ const pipeProcs = new Map();
13
+ /** pty 模式伪终端会话 */
14
+ const ptyProcs = new Map();
15
+ /** 已请求取消的任务:其 close/onExit 不再上报 complete */
12
16
  const cancelRequested = new Set();
13
- /** 已上报终态(error/complete 二选一,防双重上报)的任务 */
17
+ /** 已上报终态(error/complete 二选一,防双重上报) */
14
18
  const finished = new Set();
15
- /** 取消后的 SIGKILL 升级计时器 */
19
+ /** 取消后的 SIGKILL 升级计时器(仅 pipe 模式使用) */
16
20
  const killTimers = new Map();
21
+ const runningCount = () => pipeProcs.size + ptyProcs.size;
17
22
  const clearKillTimer = (taskId) => {
18
23
  const timer = killTimers.get(taskId);
19
24
  if (timer) {
@@ -25,12 +30,22 @@ export const createTaskExecutor = (callbacks) => {
25
30
  clearKillTimer(taskId);
26
31
  killTimers.set(taskId, setTimeout(() => {
27
32
  killTimers.delete(taskId);
28
- if (processes.get(taskId) === child) {
33
+ if (pipeProcs.get(taskId) === child) {
29
34
  logger.warn(`Task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
30
35
  child.kill('SIGKILL');
31
36
  }
32
37
  }, delayMs));
33
38
  };
39
+ /** 终态去重 + 统一清理(出表/清定时器)。注意:不清 cancelRequested,由调用分支决定取消语义 */
40
+ const markFinished = (taskId) => {
41
+ if (finished.has(taskId))
42
+ return false;
43
+ finished.add(taskId);
44
+ clearKillTimer(taskId);
45
+ pipeProcs.delete(taskId);
46
+ ptyProcs.delete(taskId);
47
+ return true;
48
+ };
34
49
  /**
35
50
  * 解析要执行的命令:
36
51
  * - 显式 command:直接用(调用方负责其可执行性)
@@ -54,36 +69,24 @@ export const createTaskExecutor = (callbacks) => {
54
69
  }
55
70
  return task.args ?? [];
56
71
  };
57
- const execute = (task) => {
58
- if (processes.has(task.taskId)) {
59
- logger.warn(`Task ${task.taskId} is already running, cancelling previous instance`);
60
- cancel(task.taskId);
61
- }
62
- if (processes.size >= MAX_CONCURRENT) {
63
- callbacks.onError(task.taskId, `Max concurrent tasks (${MAX_CONCURRENT}) reached`);
64
- return;
65
- }
66
- const cmd = resolveCommand(task);
67
- if (!cmd) {
68
- callbacks.onError(task.taskId, `Cannot resolve command for runtime: ${task.runtime} (no command provided)`);
69
- return;
70
- }
71
- const args = buildArgs(task);
72
- const childEnv = task.env ? { ...process.env, ...task.env } : process.env;
73
- logger.info(`Task ${task.taskId} starting: ${cmd} ${args.join(' ')}`, task.runtime);
72
+ const buildEnv = (task) => task.env ? { ...process.env, ...task.env } : process.env;
73
+ /** pipe 模式:非交互子进程,stdout/stderr 管道直传(原有行为,保持逐字节 toString 语义) */
74
+ const executePipe = (task, cmd, args, childEnv) => {
74
75
  let child;
75
76
  try {
76
77
  child = spawn(cmd, args, {
77
78
  cwd: task.cwd,
78
79
  env: childEnv,
79
- stdio: ['pipe', 'pipe', 'pipe'],
80
+ // stdin ignore:非交互任务输入全部来自 prompt/command 参数,runtime 无交互输入。
81
+ // 若用 pipe 且不关闭,TUI 类 runtime(opencode 等)会因 stdin 永不 EOF 而挂起不执行。
82
+ stdio: ['ignore', 'pipe', 'pipe'],
80
83
  });
81
84
  }
82
85
  catch (err) {
83
86
  callbacks.onError(task.taskId, err instanceof Error ? err.message : String(err));
84
87
  return;
85
88
  }
86
- processes.set(task.taskId, child);
89
+ pipeProcs.set(task.taskId, child);
87
90
  child.stdout?.on('data', (data) => {
88
91
  callbacks.onOutput(task.taskId, 'stdout', data.toString());
89
92
  });
@@ -92,20 +95,15 @@ export const createTaskExecutor = (callbacks) => {
92
95
  });
93
96
  child.on('error', (err) => {
94
97
  // error 后可能仍触发 close:终态只上报一次
95
- if (finished.has(task.taskId))
98
+ if (!markFinished(task.taskId))
96
99
  return;
97
- finished.add(task.taskId);
98
- clearKillTimer(task.taskId);
99
- processes.delete(task.taskId);
100
+ cancelRequested.delete(task.taskId);
100
101
  logger.error(`Task ${task.taskId} process error:`, err.message);
101
102
  callbacks.onError(task.taskId, err.message);
102
103
  });
103
104
  child.on('close', (code) => {
104
- if (finished.has(task.taskId))
105
+ if (!markFinished(task.taskId))
105
106
  return;
106
- finished.add(task.taskId);
107
- clearKillTimer(task.taskId);
108
- processes.delete(task.taskId);
109
107
  if (cancelRequested.has(task.taskId)) {
110
108
  cancelRequested.delete(task.taskId);
111
109
  logger.info(`Task ${task.taskId} cancelled`);
@@ -116,24 +114,134 @@ export const createTaskExecutor = (callbacks) => {
116
114
  callbacks.onComplete(task.taskId, exitCode);
117
115
  });
118
116
  };
119
- const cancel = (taskId) => {
120
- const child = processes.get(taskId);
121
- if (!child) {
122
- logger.warn(`Task ${taskId} not found, cannot cancel`);
117
+ /**
118
+ * pty 模式:伪终端交互子进程。
119
+ * CLI 检测到 tty 后进入交互模式(会提问、渲染进度、等待输入);
120
+ * 屏幕字节经 onData 上行(含 ANSI),用户输入经 writeInput 写入(模拟击键)。
121
+ */
122
+ const executePty = (task, cmd, args, childEnv) => {
123
+ let handle;
124
+ try {
125
+ handle = pty.spawn(cmd, args, {
126
+ name: 'xterm-256color',
127
+ cols: 120,
128
+ rows: 32,
129
+ cwd: task.cwd,
130
+ env: childEnv,
131
+ });
132
+ }
133
+ catch (err) {
134
+ callbacks.onError(task.taskId, err instanceof Error ? err.message : String(err));
123
135
  return;
124
136
  }
125
- if (cancelRequested.has(taskId)) {
137
+ ptyProcs.set(task.taskId, handle);
138
+ logger.info(`Task ${task.taskId} started in pty mode: ${cmd} ${args.join(' ')}`, task.runtime);
139
+ handle.onData((data) => {
140
+ // pty 只有一路输出(合并 stdout/stderr 的终端字节流),统一按 stdout 上行;
141
+ // 前端如需区分文本/控制序列,属于展示层解析,不在执行层拆流。
142
+ callbacks.onOutput(task.taskId, 'stdout', data);
143
+ });
144
+ handle.onExit(({ exitCode }) => {
145
+ if (!markFinished(task.taskId))
146
+ return;
147
+ if (cancelRequested.has(task.taskId)) {
148
+ cancelRequested.delete(task.taskId);
149
+ logger.info(`Task ${task.taskId} cancelled`);
150
+ return;
151
+ }
152
+ const code = exitCode ?? 0;
153
+ logger.info(`Task ${task.taskId} completed with exit code ${code}`);
154
+ callbacks.onComplete(task.taskId, code);
155
+ });
156
+ };
157
+ const execute = (task) => {
158
+ if (pipeProcs.has(task.taskId) || ptyProcs.has(task.taskId)) {
159
+ logger.warn(`Task ${task.taskId} is already running, cancelling previous instance`);
160
+ cancel(task.taskId);
161
+ }
162
+ if (runningCount() >= MAX_CONCURRENT) {
163
+ callbacks.onError(task.taskId, `Max concurrent tasks (${MAX_CONCURRENT}) reached`);
126
164
  return;
127
165
  }
166
+ const cmd = resolveCommand(task);
167
+ if (!cmd) {
168
+ callbacks.onError(task.taskId, `Cannot resolve command for runtime: ${task.runtime} (no command provided)`);
169
+ return;
170
+ }
171
+ const args = buildArgs(task);
172
+ const childEnv = buildEnv(task);
173
+ if (task.execMode === 'pty') {
174
+ executePty(task, cmd, args, childEnv);
175
+ return;
176
+ }
177
+ executePipe(task, cmd, args, childEnv);
178
+ };
179
+ const writeInput = (taskId, data) => {
180
+ if (!data)
181
+ return false;
182
+ const handle = ptyProcs.get(taskId);
183
+ if (handle) {
184
+ try {
185
+ handle.write(data);
186
+ return true;
187
+ }
188
+ catch (err) {
189
+ logger.error(`Task ${taskId} pty write failed:`, err);
190
+ return false;
191
+ }
192
+ }
193
+ const child = pipeProcs.get(taskId);
194
+ if (child?.stdin?.writable) {
195
+ child.stdin.write(data);
196
+ return true;
197
+ }
198
+ return false;
199
+ };
200
+ const cancel = (taskId) => {
201
+ if (cancelRequested.has(taskId))
202
+ return;
128
203
  cancelRequested.add(taskId);
204
+ const handle = ptyProcs.get(taskId);
205
+ if (handle) {
206
+ logger.info(`Task ${taskId} cancelling (pty kill)`);
207
+ try {
208
+ handle.kill();
209
+ }
210
+ catch (err) {
211
+ logger.warn(`Task ${taskId} pty kill failed, escalating SIGKILL:`, err);
212
+ try {
213
+ handle.kill('SIGKILL');
214
+ }
215
+ catch {
216
+ // 兜底:升级失败也收口,避免悬挂
217
+ if (markFinished(taskId)) {
218
+ callbacks.onError(taskId, 'cancel failed');
219
+ }
220
+ }
221
+ }
222
+ return;
223
+ }
224
+ const child = pipeProcs.get(taskId);
225
+ if (!child)
226
+ return;
129
227
  logger.info(`Task ${taskId} cancelling (SIGTERM)`);
130
228
  child.kill('SIGTERM');
131
229
  armKillTimer(taskId, child, KILL_GRACE_MS);
132
230
  };
133
231
  const dispose = () => {
134
- if (processes.size === 0)
232
+ if (runningCount() === 0)
135
233
  return Promise.resolve();
136
- for (const [taskId, child] of processes) {
234
+ for (const taskId of [...ptyProcs.keys()]) {
235
+ cancelRequested.add(taskId);
236
+ logger.info(`Task ${taskId} cancelling (shutdown, pty kill)`);
237
+ try {
238
+ ptyProcs.get(taskId)?.kill();
239
+ }
240
+ catch {
241
+ markFinished(taskId);
242
+ }
243
+ }
244
+ for (const [taskId, child] of pipeProcs) {
137
245
  cancelRequested.add(taskId);
138
246
  logger.info(`Task ${taskId} cancelling (shutdown, SIGTERM)`);
139
247
  child.kill('SIGTERM');
@@ -142,9 +250,9 @@ export const createTaskExecutor = (callbacks) => {
142
250
  return new Promise((resolve) => {
143
251
  const start = Date.now();
144
252
  const check = () => {
145
- if (processes.size === 0 || Date.now() - start >= DISPOSE_TIMEOUT_MS) {
146
- // 仍未退出的升级为 SIGKILL;close 事件随后清理各集合
147
- for (const [taskId, child] of processes) {
253
+ if (runningCount() === 0 || Date.now() - start >= DISPOSE_TIMEOUT_MS) {
254
+ // 仍未退出的 pipe 子进程升级为 SIGKILL;pty 已同步 kill
255
+ for (const [taskId, child] of pipeProcs) {
148
256
  logger.warn(`Task ${taskId} force killed during shutdown`);
149
257
  child.kill('SIGKILL');
150
258
  }
@@ -156,5 +264,5 @@ export const createTaskExecutor = (callbacks) => {
156
264
  check();
157
265
  });
158
266
  };
159
- return { execute, cancel, dispose };
267
+ return { execute, writeInput, cancel, dispose };
160
268
  };
@@ -25,6 +25,9 @@ export const dispatchServerMessage = (raw, handlers) => {
25
25
  case 'task-cancel':
26
26
  handlers.onTaskCancel(message.taskId);
27
27
  break;
28
+ case 'task-input':
29
+ handlers.onTaskInput(message);
30
+ break;
28
31
  case 'query-runtimes':
29
32
  handlers.onQueryRuntimes();
30
33
  break;
@@ -142,6 +142,10 @@ export const createTunnelClient = (config, messageHandlers) => {
142
142
  logger.info('Task cancel:', taskId);
143
143
  messageHandlers.onTaskCancel(taskId);
144
144
  },
145
+ onTaskInput: (message) => {
146
+ logger.info('Task input:', message.taskId);
147
+ messageHandlers.onTaskInput(message);
148
+ },
145
149
  onQueryRuntimes: () => {
146
150
  send({ type: 'runtime-info', runtimes: config.runtimes });
147
151
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brierb/brier-cli",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Brier 平台接入命令行工具:通过加密隧道将本机接入 Brier 远程编码池,接收并执行远程任务",
5
5
  "keywords": [
6
6
  "brier",
@@ -43,6 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "commander": "^12.1.0",
46
+ "node-pty": "^1.1.0",
46
47
  "ws": "^8.18.0"
47
48
  },
48
49
  "devDependencies": {
@@ -6,9 +6,11 @@ export interface TaskExecutorCallbacks {
6
6
  }
7
7
  export interface TaskExecutor {
8
8
  execute: (task: TaskInfo) => void;
9
- /** 取消单个任务:SIGTERM,宽限期后 SIGKILL */
9
+ /** 向运行中任务写入输入(pty = 模拟击键;pipe = 写 stdin)。任务不存在/不可写返回 false */
10
+ writeInput: (taskId: string, data: string) => boolean;
11
+ /** 取消单个任务:pipe 走 SIGTERM(宽限期后 SIGKILL);pty 直接终止伪终端会话 */
10
12
  cancel: (taskId: string) => void;
11
- /** 停止并收尾:对全部运行中任务 SIGTERM,等待退出,未退出的 SIGKILL */
13
+ /** 停止并收尾:对全部运行中任务终止,等待退出;pipe 超时升级 SIGKILL */
12
14
  dispose: () => Promise<void>;
13
15
  }
14
16
  export declare const createTaskExecutor: (callbacks: TaskExecutorCallbacks) => TaskExecutor;
@@ -1,3 +1,4 @@
1
+ import type { ExecMode } from './tunnel.js';
1
2
  /**
2
3
  * 本地任务执行上下文。
3
4
  *
@@ -22,4 +23,6 @@ export interface TaskInfo {
22
23
  env?: Record<string, string>;
23
24
  /** 自然语言指令(可选):存在时按 RUNTIME_PROMPT_FLAGS 拼到命令参数中执行 */
24
25
  prompt?: string;
26
+ /** 执行形态(可选):缺省 pipe(非交互,向后兼容);pty 为伪终端交互(支持 task-input) */
27
+ execMode?: ExecMode;
25
28
  }
@@ -16,6 +16,12 @@
16
16
  * 新增流类型时两端必须同步修改。
17
17
  */
18
18
  export type StreamType = 'stdout' | 'stderr';
19
+ /**
20
+ * 子进程执行形态(随 task-start 下发,两端同步):
21
+ * - pipe:非交互,stdout/stderr 管道直传(默认,向后兼容旧行为)
22
+ * - pty:伪终端交互,可接收 task-input 写入(AI 提问等场景)
23
+ */
24
+ export type ExecMode = 'pipe' | 'pty';
19
25
  /**
20
26
  * 隧道线协议消息(daemon ⇄ Brier 服务端,WebSocket JSON 文本帧)。
21
27
  *
@@ -70,6 +76,7 @@ export type ClientMessage = {
70
76
  * - heartbeat-ack:心跳回执(echo 客户端发送的 timestamp)
71
77
  * - task-start:下发任务执行;prompt 模式由 CLI 按 runtime 拼参数,command 模式直接执行
72
78
  * - task-cancel:请求终止正在执行的子进程(SIGTERM)
79
+ * - task-input:向运行中任务写入输入(pty 模式 = 模拟键盘击键;pipe 模式写入 stdin)
73
80
  * - query-runtimes:查询本机 runtime 清单(当前服务端不会主动发送,属预留;CLI 保留处理以兼容旧服务端)
74
81
  */
75
82
  export type ServerMessage = {
@@ -95,9 +102,15 @@ export type ServerMessage = {
95
102
  env?: Record<string, string>;
96
103
  /** 自然语言指令(可选):存在时按 RUNTIME_PROMPT_FLAGS 拼入执行参数 */
97
104
  prompt?: string;
105
+ /** 执行形态(可选):缺省 pipe(向后兼容旧服务端) */
106
+ execMode?: ExecMode;
98
107
  } | {
99
108
  type: 'task-cancel';
100
109
  taskId: string;
110
+ } | {
111
+ type: 'task-input';
112
+ taskId: string;
113
+ data: string;
101
114
  } | {
102
115
  type: 'query-runtimes';
103
116
  };
@@ -19,6 +19,10 @@ export interface ServerMessageHandlers {
19
19
  }>) => void;
20
20
  /** 任务取消 */
21
21
  onTaskCancel: (taskId: string) => void;
22
+ /** 向运行中任务写入输入(回答 AI 提问 / pty 击键) */
23
+ onTaskInput: (message: Extract<ServerMessage, {
24
+ type: 'task-input';
25
+ }>) => void;
22
26
  /** 服务端查询本机 runtime 清单 */
23
27
  onQueryRuntimes: () => void;
24
28
  }
@@ -19,6 +19,9 @@ export interface TunnelMessageHandlers {
19
19
  type: 'task-start';
20
20
  }>) => void;
21
21
  onTaskCancel: (taskId: string) => void;
22
+ onTaskInput: (message: Extract<ServerMessage, {
23
+ type: 'task-input';
24
+ }>) => void;
22
25
  }
23
26
  /**
24
27
  * 隧道骨架:连接生命周期编排与状态仲裁的唯一入口。