ai-cli-mcp 2.13.0 → 2.14.1

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/dist/app/mcp.js CHANGED
@@ -165,7 +165,7 @@ ${getSupportedModelsDescription()}
165
165
  },
166
166
  {
167
167
  name: 'get_result',
168
- description: 'Get the current output and status of an AI agent process by PID. Returns the output from the agent including session_id (if applicable), along with process metadata.',
168
+ description: 'Get the current output and status of an AI agent process by PID. Defaults to a compact result shape; set verbose to true for full metadata and detailed parsed output.',
169
169
  inputSchema: {
170
170
  type: 'object',
171
171
  properties: {
@@ -175,7 +175,7 @@ ${getSupportedModelsDescription()}
175
175
  },
176
176
  verbose: {
177
177
  type: 'boolean',
178
- description: 'Optional: If true, returns detailed execution information including tool usage history. Defaults to false.',
178
+ description: 'Optional: If true, returns the full result shape including metadata fields and detailed parsed output such as tool usage history. Defaults to false.',
179
179
  }
180
180
  },
181
181
  required: ['pid'],
@@ -183,7 +183,7 @@ ${getSupportedModelsDescription()}
183
183
  },
184
184
  {
185
185
  name: 'wait',
186
- description: 'Wait for multiple AI agent processes to complete and return their results. Blocks until all specified PIDs finish or timeout occurs.',
186
+ description: 'Wait for multiple AI agent processes to complete and return their results. Defaults to compact result items; set verbose to true for full metadata and detailed parsed output.',
187
187
  inputSchema: {
188
188
  type: 'object',
189
189
  properties: {
@@ -196,6 +196,10 @@ ${getSupportedModelsDescription()}
196
196
  type: 'number',
197
197
  description: 'Optional: Maximum time to wait in seconds. Defaults to 180 (3 minutes).',
198
198
  },
199
+ verbose: {
200
+ type: 'boolean',
201
+ description: 'Optional: If true, each result item uses the full result shape including metadata fields and detailed parsed output. Defaults to false.',
202
+ },
199
203
  },
200
204
  required: ['pids'],
201
205
  },
@@ -305,7 +309,7 @@ ${getSupportedModelsDescription()}
305
309
  throw new McpError(ErrorCode.InvalidParams, 'Missing or invalid required parameter: pids (must be a non-empty array of numbers)');
306
310
  }
307
311
  try {
308
- const results = await this.processService.waitForProcesses(toolArguments.pids, typeof toolArguments.timeout === 'number' ? toolArguments.timeout : 180);
312
+ const results = await this.processService.waitForProcesses(toolArguments.pids, typeof toolArguments.timeout === 'number' ? toolArguments.timeout : 180, !!toolArguments.verbose);
309
313
  return {
310
314
  content: [{
311
315
  type: 'text',
@@ -1,10 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
3
- import { join } from 'node:path';
3
+ import { join, basename, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { buildCliCommand } from './cli-builder.js';
6
6
  import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
7
7
  import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
8
+ import { buildProcessResult } from './process-result.js';
8
9
  function resolveDefaultStateDir() {
9
10
  return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
10
11
  }
@@ -76,6 +77,7 @@ export class CliProcessService {
76
77
  pid,
77
78
  prompt: cmd.prompt,
78
79
  workFolder: cmd.cwd,
80
+ cwdKey: this.resolveCwdKey(cmd.cwd),
79
81
  model: options.model,
80
82
  toolType: cmd.agent,
81
83
  startTime: new Date().toISOString(),
@@ -132,7 +134,7 @@ export class CliProcessService {
132
134
  agentOutput = parseForgeOutput(stdout);
133
135
  }
134
136
  }
135
- const response = {
137
+ return buildProcessResult({
136
138
  pid,
137
139
  agent: refreshed.toolType,
138
140
  status: refreshed.status,
@@ -141,26 +143,11 @@ export class CliProcessService {
141
143
  workFolder: refreshed.workFolder,
142
144
  prompt: refreshed.prompt,
143
145
  model: refreshed.model,
144
- };
145
- if (agentOutput) {
146
- if (!verbose && agentOutput.tools) {
147
- const { tools, ...rest } = agentOutput;
148
- response.agentOutput = rest;
149
- }
150
- else {
151
- response.agentOutput = agentOutput;
152
- }
153
- if (agentOutput.session_id) {
154
- response.session_id = agentOutput.session_id;
155
- }
156
- }
157
- else {
158
- response.stdout = stdout;
159
- response.stderr = stderr;
160
- }
161
- return response;
146
+ stdout,
147
+ stderr,
148
+ }, agentOutput, verbose);
162
149
  }
163
- async waitForProcesses(pids, timeoutSeconds = 180) {
150
+ async waitForProcesses(pids, timeoutSeconds = 180, verbose = false) {
164
151
  const start = Date.now();
165
152
  for (const pid of pids) {
166
153
  this.readProcess(pid);
@@ -168,7 +155,7 @@ export class CliProcessService {
168
155
  while (true) {
169
156
  const statuses = pids.map((pid) => this.refreshStatus(this.readProcess(pid)).status);
170
157
  if (statuses.every((status) => status !== 'running')) {
171
- return Promise.all(pids.map((pid) => this.getProcessResult(pid, false)));
158
+ return Promise.all(pids.map((pid) => this.getProcessResult(pid, verbose)));
172
159
  }
173
160
  if (Date.now() - start >= timeoutSeconds * 1000) {
174
161
  throw new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`);
@@ -210,7 +197,7 @@ export class CliProcessService {
210
197
  if (refreshed.status === 'running') {
211
198
  continue;
212
199
  }
213
- const processDir = this.resolveProcessDir(refreshed.workFolder, refreshed.pid);
200
+ const processDir = this.resolveStoredProcessDir(refreshed);
214
201
  if (existsSync(processDir)) {
215
202
  rmSync(processDir, { recursive: true, force: true });
216
203
  removed++;
@@ -247,10 +234,14 @@ export class CliProcessService {
247
234
  return process;
248
235
  }
249
236
  parseProcessFile(metaPath) {
250
- return JSON.parse(readFileSync(metaPath, 'utf-8'));
237
+ const process = JSON.parse(readFileSync(metaPath, 'utf-8'));
238
+ if (!process.cwdKey) {
239
+ process.cwdKey = basename(dirname(dirname(metaPath)));
240
+ }
241
+ return process;
251
242
  }
252
243
  writeProcess(process) {
253
- const processDir = this.resolveProcessDir(process.workFolder, process.pid);
244
+ const processDir = this.resolveStoredProcessDir(process);
254
245
  mkdirSync(processDir, { recursive: true });
255
246
  writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
256
247
  }
@@ -271,7 +262,16 @@ export class CliProcessService {
271
262
  return join(this.stateDir, 'cwds');
272
263
  }
273
264
  resolveProcessDir(cwd, pid) {
274
- return join(this.resolveCwdsDir(), normalizeCwdForStorage(realpathSync(cwd)), String(pid));
265
+ return join(this.resolveCwdsDir(), this.resolveCwdKey(cwd), String(pid));
266
+ }
267
+ resolveStoredProcessDir(process) {
268
+ if (!process.cwdKey) {
269
+ process.cwdKey = this.resolveCwdKey(process.workFolder);
270
+ }
271
+ return join(this.resolveCwdsDir(), process.cwdKey, String(process.pid));
272
+ }
273
+ resolveCwdKey(cwd) {
274
+ return normalizeCwdForStorage(realpathSync(cwd));
275
275
  }
276
276
  resolveMetaPath(processDir) {
277
277
  return join(processDir, 'meta.json');
@@ -0,0 +1,51 @@
1
+ function compactAgentOutput(agentOutput) {
2
+ if (!agentOutput || typeof agentOutput !== 'object') {
3
+ return null;
4
+ }
5
+ const { tools: _tools, ...rest } = agentOutput;
6
+ const compact = Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined && value !== null));
7
+ return Object.keys(compact).length > 0 ? compact : null;
8
+ }
9
+ function hasMeaningfulParsedOutput(agentOutput) {
10
+ if (!agentOutput || typeof agentOutput !== 'object') {
11
+ return false;
12
+ }
13
+ return Object.entries(agentOutput).some(([key, value]) => {
14
+ if (value === undefined || value === null) {
15
+ return false;
16
+ }
17
+ if (key === 'session_id') {
18
+ return false;
19
+ }
20
+ if (key === 'tools') {
21
+ return Array.isArray(value) ? value.length > 0 : true;
22
+ }
23
+ return true;
24
+ });
25
+ }
26
+ export function buildProcessResult(context, agentOutput, verbose = false) {
27
+ const response = {
28
+ pid: context.pid,
29
+ agent: context.agent,
30
+ status: context.status,
31
+ exitCode: context.exitCode ?? null,
32
+ model: context.model ?? null,
33
+ };
34
+ if (verbose) {
35
+ response.startTime = context.startTime;
36
+ response.workFolder = context.workFolder;
37
+ response.prompt = context.prompt;
38
+ }
39
+ if (agentOutput?.session_id) {
40
+ response.session_id = agentOutput.session_id;
41
+ }
42
+ const shapedAgentOutput = verbose ? agentOutput : compactAgentOutput(agentOutput);
43
+ if (hasMeaningfulParsedOutput(shapedAgentOutput)) {
44
+ response.agentOutput = shapedAgentOutput;
45
+ }
46
+ if (!response.agentOutput) {
47
+ response.stdout = context.stdout;
48
+ response.stderr = context.stderr;
49
+ }
50
+ return response;
51
+ }
@@ -1,6 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildCliCommand } from './cli-builder.js';
3
3
  import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
4
+ import { buildProcessResult } from './process-result.js';
4
5
  export class ProcessService {
5
6
  processManager = new Map();
6
7
  cliPaths;
@@ -100,7 +101,7 @@ export class ProcessService {
100
101
  agentOutput = parseForgeOutput(process.stdout);
101
102
  }
102
103
  }
103
- const response = {
104
+ return buildProcessResult({
104
105
  pid,
105
106
  agent: process.toolType,
106
107
  status: process.status,
@@ -109,26 +110,11 @@ export class ProcessService {
109
110
  workFolder: process.workFolder,
110
111
  prompt: process.prompt,
111
112
  model: process.model,
112
- };
113
- if (agentOutput) {
114
- if (!verbose && agentOutput.tools) {
115
- const { tools, ...rest } = agentOutput;
116
- response.agentOutput = rest;
117
- }
118
- else {
119
- response.agentOutput = agentOutput;
120
- }
121
- if (agentOutput.session_id) {
122
- response.session_id = agentOutput.session_id;
123
- }
124
- }
125
- else {
126
- response.stdout = process.stdout;
127
- response.stderr = process.stderr;
128
- }
129
- return response;
113
+ stdout: process.stdout,
114
+ stderr: process.stderr,
115
+ }, agentOutput, verbose);
130
116
  }
131
- async waitForProcesses(pids, timeoutSeconds = 180) {
117
+ async waitForProcesses(pids, timeoutSeconds = 180, verbose = false) {
132
118
  for (const pid of pids) {
133
119
  if (!this.processManager.has(pid)) {
134
120
  throw new Error(`Process with PID ${pid} not found`);
@@ -155,7 +141,7 @@ export class ProcessService {
155
141
  });
156
142
  try {
157
143
  await Promise.race([Promise.all(waitPromises), timeoutPromise]);
158
- return pids.map((pid) => this.getProcessResult(pid, false));
144
+ return pids.map((pid) => this.getProcessResult(pid, verbose));
159
145
  }
160
146
  finally {
161
147
  if (timeoutHandle) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-cli-mcp",
3
- "version": "2.13.0",
3
+ "version": "2.14.1",
4
4
  "mcpName": "io.github.mkXultra/ai-cli-mcp",
5
5
  "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, and Forge) with background process management",
6
6
  "author": "mkXultra",
@@ -3,6 +3,7 @@ import {
3
3
  CLI_HELP_TEXT,
4
4
  DOCTOR_HELP_TEXT,
5
5
  MODELS_HELP_TEXT,
6
+ RESULT_HELP_TEXT,
6
7
  RUN_HELP_TEXT,
7
8
  WAIT_HELP_TEXT,
8
9
  runCli,
@@ -142,10 +143,28 @@ describe('ai-cli app', () => {
142
143
  );
143
144
 
144
145
  expect(exitCode).toBe(0);
145
- expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5);
146
+ expect(waitForProcesses).toHaveBeenCalledWith([123, 456], 5, false);
146
147
  expect(stdout).toHaveBeenCalledWith(expect.stringContaining('"status": "completed"'));
147
148
  });
148
149
 
150
+ it('passes verbose through to wait', async () => {
151
+ const stdout = vi.fn();
152
+ const stderr = vi.fn();
153
+ const waitForProcesses = vi.fn().mockResolvedValue([{ pid: 123, status: 'completed' }]);
154
+
155
+ const exitCode = await runCli(
156
+ ['wait', '123', '--verbose'],
157
+ {
158
+ stdout,
159
+ stderr,
160
+ waitForProcesses,
161
+ }
162
+ );
163
+
164
+ expect(exitCode).toBe(0);
165
+ expect(waitForProcesses).toHaveBeenCalledWith([123], undefined, true);
166
+ });
167
+
149
168
  it('rejects invalid wait timeout values', async () => {
150
169
  const stdout = vi.fn();
151
170
  const stderr = vi.fn();
@@ -291,6 +310,19 @@ describe('ai-cli app', () => {
291
310
  expect(stderr).not.toHaveBeenCalled();
292
311
  });
293
312
 
313
+ it('prints detailed help for result --help', async () => {
314
+ const stdout = vi.fn();
315
+ const stderr = vi.fn();
316
+
317
+ const exitCode = await runCli(['result', '--help'], { stdout, stderr });
318
+
319
+ expect(exitCode).toBe(0);
320
+ expect(stdout).toHaveBeenCalledWith(RESULT_HELP_TEXT);
321
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('compact result shape'));
322
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
323
+ expect(stderr).not.toHaveBeenCalled();
324
+ });
325
+
294
326
  it('prints detailed help for wait --help', async () => {
295
327
  const stdout = vi.fn();
296
328
  const stderr = vi.fn();
@@ -299,6 +331,8 @@ describe('ai-cli app', () => {
299
331
 
300
332
  expect(exitCode).toBe(0);
301
333
  expect(stdout).toHaveBeenCalledWith(WAIT_HELP_TEXT);
334
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('compact shape'));
335
+ expect(stdout).toHaveBeenCalledWith(expect.stringContaining('--verbose'));
302
336
  expect(stderr).not.toHaveBeenCalled();
303
337
  });
304
338
 
@@ -84,8 +84,18 @@ describe('CliProcessService', () => {
84
84
 
85
85
  const waitResult = await service.waitForProcesses([runResult.pid], 5);
86
86
  expect(waitResult).toHaveLength(1);
87
- expect(waitResult[0].pid).toBe(runResult.pid);
88
- expect(waitResult[0].status).toBe('completed');
87
+ expect(waitResult[0]).toMatchObject({
88
+ pid: runResult.pid,
89
+ agent: 'claude',
90
+ status: 'completed',
91
+ exitCode: null,
92
+ model: 'sonnet',
93
+ stdout: expect.any(String),
94
+ stderr: expect.any(String),
95
+ });
96
+ expect(waitResult[0]).not.toHaveProperty('startTime');
97
+ expect(waitResult[0]).not.toHaveProperty('workFolder');
98
+ expect(waitResult[0]).not.toHaveProperty('prompt');
89
99
 
90
100
  const listed = await service.listProcesses();
91
101
  expect(listed).toContainEqual({
@@ -95,12 +105,141 @@ describe('CliProcessService', () => {
95
105
  });
96
106
 
97
107
  const result = await service.getProcessResult(runResult.pid, false);
98
- expect(result.pid).toBe(runResult.pid);
99
- expect(result.status).toBe('completed');
100
- expect(result.stdout).toContain('Command executed successfully');
108
+ expect(result).toMatchObject({
109
+ pid: runResult.pid,
110
+ agent: 'claude',
111
+ status: 'completed',
112
+ exitCode: null,
113
+ model: 'sonnet',
114
+ stdout: expect.stringContaining('Command executed successfully'),
115
+ stderr: expect.any(String),
116
+ });
117
+ expect(result).not.toHaveProperty('startTime');
118
+ expect(result).not.toHaveProperty('workFolder');
119
+ expect(result).not.toHaveProperty('prompt');
101
120
  expect(readFileSync(join(processDir, 'meta.json'), 'utf-8')).toContain('"status": "completed"');
102
121
  });
103
122
 
123
+ it('returns compact results by default and full results when verbose is true', async () => {
124
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
125
+ tempDirs.push(root);
126
+ const scriptPath = join(root, 'mock-claude-json');
127
+ writeFileSync(
128
+ scriptPath,
129
+ `#!/bin/bash
130
+ printf '%s\n' '{"type":"assistant","message":{"content":[{"type":"tool_use","id":"tool-1","name":"Read","input":{"file_path":"/tmp/demo.txt"}}]}}'
131
+ printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"tool-1","content":[{"type":"text","text":"demo output"}]}]}}'
132
+ printf '%s\n' '{"type":"result","result":"Completed cli-process-service test"}'
133
+ printf '%s\n' '{"type":"system","session_id":"session-cli-1"}'
134
+ `
135
+ );
136
+ chmodSync(scriptPath, 0o755);
137
+ const stateDir = join(root, 'state');
138
+ const workFolder = join(root, 'work');
139
+ mkdirSync(workFolder, { recursive: true });
140
+
141
+ const service = new CliProcessService({
142
+ stateDir,
143
+ cliPaths: {
144
+ claude: scriptPath,
145
+ codex: scriptPath,
146
+ gemini: scriptPath,
147
+ forge: scriptPath,
148
+ },
149
+ });
150
+
151
+ const runResult = await service.startProcess({
152
+ prompt: 'hello structured output',
153
+ cwd: workFolder,
154
+ });
155
+
156
+ const compactWait = await service.waitForProcesses([runResult.pid], 5);
157
+ expect(compactWait).toHaveLength(1);
158
+ expect(compactWait[0]).toMatchObject({
159
+ pid: runResult.pid,
160
+ agent: 'claude',
161
+ status: 'completed',
162
+ exitCode: null,
163
+ model: null,
164
+ session_id: 'session-cli-1',
165
+ agentOutput: {
166
+ message: 'Completed cli-process-service test',
167
+ session_id: 'session-cli-1',
168
+ },
169
+ });
170
+ expect(compactWait[0]).not.toHaveProperty('startTime');
171
+ expect(compactWait[0]).not.toHaveProperty('workFolder');
172
+ expect(compactWait[0]).not.toHaveProperty('prompt');
173
+ expect(compactWait[0].agentOutput).not.toHaveProperty('tools');
174
+
175
+ const compactResult = await service.getProcessResult(runResult.pid, false);
176
+ expect(compactResult).toMatchObject({
177
+ pid: runResult.pid,
178
+ agent: 'claude',
179
+ status: 'completed',
180
+ exitCode: null,
181
+ model: null,
182
+ session_id: 'session-cli-1',
183
+ agentOutput: {
184
+ message: 'Completed cli-process-service test',
185
+ session_id: 'session-cli-1',
186
+ },
187
+ });
188
+ expect(compactResult).not.toHaveProperty('startTime');
189
+ expect(compactResult).not.toHaveProperty('workFolder');
190
+ expect(compactResult).not.toHaveProperty('prompt');
191
+ expect(compactResult.agentOutput).not.toHaveProperty('tools');
192
+
193
+ const verboseWait = await service.waitForProcesses([runResult.pid], 5, true);
194
+ expect(verboseWait).toHaveLength(1);
195
+ expect(verboseWait[0]).toMatchObject({
196
+ pid: runResult.pid,
197
+ agent: 'claude',
198
+ status: 'completed',
199
+ exitCode: null,
200
+ model: null,
201
+ startTime: expect.any(String),
202
+ workFolder,
203
+ prompt: 'hello structured output',
204
+ session_id: 'session-cli-1',
205
+ agentOutput: {
206
+ message: 'Completed cli-process-service test',
207
+ session_id: 'session-cli-1',
208
+ tools: [
209
+ {
210
+ tool: 'Read',
211
+ input: { file_path: '/tmp/demo.txt' },
212
+ output: 'demo output',
213
+ },
214
+ ],
215
+ },
216
+ });
217
+
218
+ const verboseResult = await service.getProcessResult(runResult.pid, true);
219
+ expect(verboseResult).toMatchObject({
220
+ pid: runResult.pid,
221
+ agent: 'claude',
222
+ status: 'completed',
223
+ exitCode: null,
224
+ model: null,
225
+ startTime: expect.any(String),
226
+ workFolder,
227
+ prompt: 'hello structured output',
228
+ session_id: 'session-cli-1',
229
+ agentOutput: {
230
+ message: 'Completed cli-process-service test',
231
+ session_id: 'session-cli-1',
232
+ tools: [
233
+ {
234
+ tool: 'Read',
235
+ input: { file_path: '/tmp/demo.txt' },
236
+ output: 'demo output',
237
+ },
238
+ ],
239
+ },
240
+ });
241
+ });
242
+
104
243
  it('can terminate a tracked process', async () => {
105
244
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
106
245
  tempDirs.push(root);
@@ -195,6 +334,111 @@ describe('CliProcessService', () => {
195
334
  killSpy.mockRestore();
196
335
  });
197
336
 
337
+ it('lists processes without crashing when a tracked work folder has been deleted', async () => {
338
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
339
+ tempDirs.push(root);
340
+ const stateDir = join(root, 'state');
341
+ const workFolder = join(root, 'deleted-project');
342
+ mkdirSync(workFolder, { recursive: true });
343
+
344
+ const pid = 45678;
345
+ const processDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)), String(pid));
346
+ mkdirSync(processDir, { recursive: true });
347
+
348
+ writeFileSync(
349
+ join(processDir, 'meta.json'),
350
+ JSON.stringify({
351
+ pid,
352
+ prompt: 'deleted cwd',
353
+ workFolder,
354
+ toolType: 'claude',
355
+ startTime: new Date().toISOString(),
356
+ stdoutPath: join(processDir, 'stdout.log'),
357
+ stderrPath: join(processDir, 'stderr.log'),
358
+ status: 'running',
359
+ })
360
+ );
361
+
362
+ rmSync(workFolder, { recursive: true, force: true });
363
+
364
+ const service = new CliProcessService({
365
+ stateDir,
366
+ cliPaths: {
367
+ claude: '/bin/sh',
368
+ codex: '/bin/sh',
369
+ gemini: '/bin/sh',
370
+ forge: '/bin/sh',
371
+ },
372
+ });
373
+
374
+ const killSpy = vi.spyOn(globalThis.process, 'kill').mockImplementation((target: number, signal?: string | number) => {
375
+ if (signal === 0 && target === pid) {
376
+ throw Object.assign(new Error('not running'), { code: 'ESRCH' });
377
+ }
378
+ return true;
379
+ });
380
+
381
+ const listed = await service.listProcesses();
382
+
383
+ expect(listed).toEqual([
384
+ {
385
+ pid,
386
+ agent: 'claude',
387
+ status: 'completed',
388
+ },
389
+ ]);
390
+ expect(JSON.parse(readFileSync(join(processDir, 'meta.json'), 'utf-8')).status).toBe('completed');
391
+ killSpy.mockRestore();
392
+ });
393
+
394
+ it('cleans up finished process directories even when their work folder has been deleted', async () => {
395
+ const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
396
+ tempDirs.push(root);
397
+ const stateDir = join(root, 'state');
398
+ const workFolder = join(root, 'deleted-finished-project');
399
+ mkdirSync(workFolder, { recursive: true });
400
+
401
+ const pid = 56789;
402
+ const cwdDir = join(stateDir, 'cwds', encodeCwd(realpathSync(workFolder)));
403
+ const processDir = join(cwdDir, String(pid));
404
+ mkdirSync(processDir, { recursive: true });
405
+
406
+ writeFileSync(
407
+ join(processDir, 'meta.json'),
408
+ JSON.stringify({
409
+ pid,
410
+ prompt: 'done',
411
+ workFolder,
412
+ toolType: 'claude',
413
+ startTime: new Date().toISOString(),
414
+ stdoutPath: join(processDir, 'stdout.log'),
415
+ stderrPath: join(processDir, 'stderr.log'),
416
+ status: 'completed',
417
+ })
418
+ );
419
+
420
+ rmSync(workFolder, { recursive: true, force: true });
421
+
422
+ const service = new CliProcessService({
423
+ stateDir,
424
+ cliPaths: {
425
+ claude: '/bin/sh',
426
+ codex: '/bin/sh',
427
+ gemini: '/bin/sh',
428
+ forge: '/bin/sh',
429
+ },
430
+ });
431
+
432
+ const result = await service.cleanupProcesses();
433
+
434
+ expect(result).toEqual({
435
+ removed: 1,
436
+ message: 'Removed 1 processes',
437
+ });
438
+ expect(existsSync(processDir)).toBe(false);
439
+ expect(existsSync(cwdDir)).toBe(false);
440
+ });
441
+
198
442
  it('cleans up completed and failed process directories but preserves running ones', async () => {
199
443
  const root = mkdtempSync(join(tmpdir(), 'ai-cli-cli-service-'));
200
444
  tempDirs.push(root);