@link-assistant/agent 0.26.10 → 0.26.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/tool/bash.ts +81 -101
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/agent",
3
- "version": "0.26.10",
3
+ "version": "0.26.11",
4
4
  "description": "A minimal, public domain AI CLI agent compatible with OpenCode's JSON interface. Bun-only runtime.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
package/src/tool/bash.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import z from 'zod';
2
- import { spawn } from 'child_process';
2
+ import { ProcessRunner } from 'command-stream/process-runner';
3
3
  import { Tool } from './tool';
4
4
  import DESCRIPTION from './bash.txt';
5
5
  import { Branding } from '../branding';
@@ -7,8 +7,6 @@ import { Log } from '../util/log';
7
7
  import { Instance } from '../project/instance';
8
8
  import { lazy } from '../util/lazy';
9
9
  import { Language } from 'web-tree-sitter';
10
- import { $ } from 'bun';
11
- import { Filesystem } from '../util/filesystem';
12
10
  import { Permission } from '../permission';
13
11
  import { fileURLToPath } from 'url';
14
12
  import { processResult } from './process-result';
@@ -16,10 +14,22 @@ import { processResult } from './process-result';
16
14
  const MAX_OUTPUT_LENGTH = 30_000;
17
15
  const DEFAULT_TIMEOUT = 1 * 60 * 1000;
18
16
  const MAX_TIMEOUT = 10 * 60 * 1000;
19
- const SIGKILL_TIMEOUT_MS = 200;
20
-
17
+ const KILL_SIGNAL = 'SIGTERM';
21
18
  export const log = Log.create({ service: 'bash-tool' });
22
19
 
20
+ // command-stream 1.3.0 signals only the shell on Windows, which has no process
21
+ // groups, so the shell's descendants keep running
22
+ // (link-foundation/command-stream#205). taskkill /t walks the tree from the
23
+ // shell, so it must run before the shell itself is stopped.
24
+ const killWindowsTree = async (pid: number | undefined) => {
25
+ if (!pid) return;
26
+ // A failed taskkill resolves with its exit code; the runner is stopped next.
27
+ await new ProcessRunner(
28
+ { mode: 'exec', file: 'taskkill', args: ['/pid', String(pid), '/f', '/t'] },
29
+ { mirror: false, capture: false, stdin: 'ignore' }
30
+ ).catch(() => {});
31
+ };
32
+
23
33
  const resolveWasm = (asset: string) => {
24
34
  if (asset.startsWith('file://')) return fileURLToPath(asset);
25
35
  if (asset.startsWith('/') || /^[a-z]:/i.test(asset)) return asset;
@@ -119,15 +129,8 @@ export const BashTool = Tool.define('bash', {
119
129
  });
120
130
  }
121
131
 
122
- const proc = spawn(params.command, {
123
- shell: true,
124
- cwd: Instance.directory,
125
- env: {
126
- ...process.env,
127
- },
128
- stdio: ['ignore', 'pipe', 'pipe'],
129
- detached: process.platform !== 'win32',
130
- });
132
+ const timeoutSignal = AbortSignal.timeout(timeout);
133
+ const cancel = AbortSignal.any([ctx.abort, timeoutSignal]);
131
134
 
132
135
  let output = '';
133
136
 
@@ -139,97 +142,74 @@ export const BashTool = Tool.define('bash', {
139
142
  },
140
143
  });
141
144
 
142
- const append = (chunk: Buffer) => {
143
- output += chunk.toString();
144
- ctx.metadata({
145
- metadata: {
146
- output,
147
- description: params.description,
148
- },
149
- });
150
- };
145
+ let exit: number | null = null;
146
+ let signal: string | null = null;
151
147
 
152
- proc.stdout?.on('data', append);
153
- proc.stderr?.on('data', append);
148
+ // command-stream 1.3.0 finishes a runner whose signal is already aborted
149
+ // before stream() subscribes to its end event, so the iterator never ends
150
+ // (link-foundation/command-stream#207).
151
+ // A cancelled command does not need to start at all.
152
+ if (!cancel.aborted) {
153
+ // On Windows the runner is stopped only after taskkill has stopped the
154
+ // shell's process tree.
155
+ const stop =
156
+ process.platform === 'win32' ? new AbortController() : undefined;
154
157
 
155
- let timedOut = false;
156
- let aborted = false;
157
- let exited = false;
158
- let closed = false;
158
+ // The file/args shell form spawns the command string through Node's
159
+ // platform shell, as `spawn(command, { shell: true })` does. A command
160
+ // string spec would route simple commands and pipelines through
161
+ // command-stream's JavaScript builtins (`ls`, `exit`, `cd`, ...) instead.
162
+ const command = new ProcessRunner(
163
+ { mode: 'shell', file: params.command, args: [] },
164
+ {
165
+ cwd: Instance.directory,
166
+ env: { ...process.env },
167
+ signal: stop?.signal ?? cancel,
168
+ killSignal: KILL_SIGNAL,
169
+ killGrace: 200,
170
+ mirror: false,
171
+ capture: true,
172
+ stdin: 'ignore',
173
+ }
174
+ );
159
175
 
160
- const killTree = async () => {
161
- const pid = proc.pid;
162
- if (!pid || closed) {
163
- return;
164
- }
176
+ const stopTree = () => {
177
+ void killWindowsTree(command.child?.pid).finally(() => stop?.abort());
178
+ };
179
+ if (stop) cancel.addEventListener('abort', stopTree, { once: true });
165
180
 
166
- if (process.platform === 'win32') {
167
- await new Promise<void>((resolve) => {
168
- const killer = spawn('taskkill', ['/pid', String(pid), '/f', '/t'], {
169
- stdio: 'ignore',
181
+ for await (const chunk of command.stream()) {
182
+ if (chunk.type === 'exit') {
183
+ exit = chunk.code;
184
+ } else {
185
+ output += chunk.data.toString();
186
+ ctx.metadata({
187
+ metadata: {
188
+ output,
189
+ description: params.description,
190
+ },
170
191
  });
171
- killer.once('exit', resolve);
172
- killer.once('error', resolve);
173
- });
174
- return;
175
- }
176
-
177
- try {
178
- process.kill(-pid, 'SIGTERM');
179
- await Bun.sleep(SIGKILL_TIMEOUT_MS);
180
- // The shell may exit before a child that ignores SIGTERM. The
181
- // process group still needs SIGKILL until its output pipes close.
182
- if (!closed) {
183
- process.kill(-pid, 'SIGKILL');
184
- }
185
- } catch (_e) {
186
- proc.kill('SIGTERM');
187
- await Bun.sleep(SIGKILL_TIMEOUT_MS);
188
- if (!exited) {
189
- proc.kill('SIGKILL');
190
192
  }
191
193
  }
192
- };
193
-
194
- const abortHandler = () => {
195
- aborted = true;
196
- void killTree();
197
- };
198
-
199
- ctx.abort.addEventListener('abort', abortHandler, { once: true });
200
194
 
201
- const timeoutTimer = setTimeout(() => {
202
- timedOut = true;
203
- void killTree();
204
- }, timeout);
195
+ cancel.removeEventListener('abort', stopTree);
205
196
 
206
- const completion = new Promise<void>((resolve, reject) => {
207
- const cleanup = () => {
208
- clearTimeout(timeoutTimer);
209
- ctx.abort.removeEventListener('abort', abortHandler);
210
- };
211
-
212
- proc.once('exit', () => {
213
- exited = true;
214
- });
215
-
216
- // close follows exit after stdout/stderr have drained. Install these
217
- // listeners before killing an already-aborted process.
218
- proc.once('close', () => {
219
- closed = true;
220
- cleanup();
221
- resolve();
222
- });
223
-
224
- proc.once('error', (error) => {
225
- exited = true;
226
- cleanup();
227
- reject(error);
228
- });
229
- });
197
+ // command-stream reports a shell that died from a signal as exit 0
198
+ // (link-foundation/command-stream#208).
199
+ // Its result keeps the native child, which has the signal name.
200
+ const finished = command.result as {
201
+ child?: { signalCode?: string | null };
202
+ } | null;
203
+ signal = finished?.child?.signalCode ?? null;
204
+ }
230
205
 
231
- if (ctx.abort.aborted) abortHandler();
232
- await completion;
206
+ const timedOut = timeoutSignal.aborted;
207
+ const aborted = ctx.abort.aborted;
208
+ // command-stream settles a cancelled command as soon as it sends the kill
209
+ // signal and reports a synthesized 128 + signal code. The real exit status
210
+ // is unavailable, as it is when the shell itself dies from a signal.
211
+ if (timedOut || aborted) signal = KILL_SIGNAL;
212
+ if (signal) exit = null;
233
213
 
234
214
  if (output.length > MAX_OUTPUT_LENGTH) {
235
215
  output = output.slice(0, MAX_OUTPUT_LENGTH);
@@ -246,16 +226,16 @@ export const BashTool = Tool.define('bash', {
246
226
 
247
227
  const result = processResult({
248
228
  output,
249
- exit: proc.exitCode,
250
- signal: proc.signalCode,
229
+ exit,
230
+ signal,
251
231
  timedOut,
252
232
  aborted,
253
233
  });
254
234
 
255
235
  log.debug(() => ({
256
236
  message: 'command finished',
257
- exit: proc.exitCode,
258
- signal: proc.signalCode,
237
+ exit,
238
+ signal,
259
239
  timedOut,
260
240
  aborted,
261
241
  isError: result.isError,
@@ -265,8 +245,8 @@ export const BashTool = Tool.define('bash', {
265
245
  title: params.command,
266
246
  metadata: {
267
247
  output,
268
- exit: proc.exitCode,
269
- signal: proc.signalCode,
248
+ exit,
249
+ signal,
270
250
  timedOut,
271
251
  aborted,
272
252
  description: params.description,