ai-cli-mcp 2.18.0 → 2.20.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.
Files changed (101) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.ja.md +37 -11
  3. package/README.md +44 -11
  4. package/dist/app/cli.js +2 -1
  5. package/dist/app/mcp.js +65 -13
  6. package/dist/cli-builder.js +13 -6
  7. package/dist/cli-process-service.js +81 -95
  8. package/dist/cli-utils.js +6 -0
  9. package/dist/cli.js +1 -1
  10. package/dist/model-catalog.js +3 -2
  11. package/dist/parsers.js +111 -8
  12. package/dist/process-service.js +5 -4
  13. package/package.json +26 -2
  14. package/server.json +3 -3
  15. package/.gemini/settings.json +0 -11
  16. package/.github/dependabot.yml +0 -28
  17. package/.github/pull_request_template.md +0 -28
  18. package/.github/workflows/ci.yml +0 -34
  19. package/.github/workflows/dependency-review.yml +0 -22
  20. package/.github/workflows/publish.yml +0 -89
  21. package/.github/workflows/test.yml +0 -20
  22. package/.github/workflows/watch-session-prs.yml +0 -276
  23. package/.husky/pre-commit +0 -1
  24. package/.mcp.json +0 -11
  25. package/.releaserc.json +0 -18
  26. package/.vscode/settings.json +0 -3
  27. package/CONTRIBUTING.md +0 -81
  28. package/dist/__tests__/app-cli.test.js +0 -392
  29. package/dist/__tests__/cli-bin-smoke.test.js +0 -101
  30. package/dist/__tests__/cli-builder.test.js +0 -442
  31. package/dist/__tests__/cli-process-service.test.js +0 -655
  32. package/dist/__tests__/cli-utils.test.js +0 -171
  33. package/dist/__tests__/e2e.test.js +0 -256
  34. package/dist/__tests__/edge-cases.test.js +0 -130
  35. package/dist/__tests__/error-cases.test.js +0 -292
  36. package/dist/__tests__/mcp-contract.test.js +0 -636
  37. package/dist/__tests__/mocks.js +0 -32
  38. package/dist/__tests__/model-alias.test.js +0 -36
  39. package/dist/__tests__/parsers.test.js +0 -500
  40. package/dist/__tests__/peek.test.js +0 -36
  41. package/dist/__tests__/process-management.test.js +0 -871
  42. package/dist/__tests__/server.test.js +0 -809
  43. package/dist/__tests__/setup.js +0 -11
  44. package/dist/__tests__/utils/claude-mock.js +0 -80
  45. package/dist/__tests__/utils/mcp-client.js +0 -121
  46. package/dist/__tests__/utils/opencode-mock.js +0 -91
  47. package/dist/__tests__/utils/persistent-mock.js +0 -28
  48. package/dist/__tests__/utils/test-helpers.js +0 -11
  49. package/dist/__tests__/validation.test.js +0 -308
  50. package/dist/__tests__/version-print.test.js +0 -65
  51. package/dist/__tests__/wait.test.js +0 -260
  52. package/docs/RELEASE_CHECKLIST.md +0 -65
  53. package/docs/cli-architecture.md +0 -275
  54. package/docs/concept.md +0 -154
  55. package/docs/development.md +0 -156
  56. package/docs/e2e-testing.md +0 -148
  57. package/docs/prd.md +0 -146
  58. package/docs/session-stacking.md +0 -67
  59. package/src/__tests__/app-cli.test.ts +0 -495
  60. package/src/__tests__/cli-bin-smoke.test.ts +0 -136
  61. package/src/__tests__/cli-builder.test.ts +0 -549
  62. package/src/__tests__/cli-process-service.test.ts +0 -759
  63. package/src/__tests__/cli-utils.test.ts +0 -200
  64. package/src/__tests__/e2e.test.ts +0 -311
  65. package/src/__tests__/edge-cases.test.ts +0 -176
  66. package/src/__tests__/error-cases.test.ts +0 -370
  67. package/src/__tests__/mcp-contract.test.ts +0 -755
  68. package/src/__tests__/mocks.ts +0 -35
  69. package/src/__tests__/model-alias.test.ts +0 -44
  70. package/src/__tests__/parsers.test.ts +0 -564
  71. package/src/__tests__/peek.test.ts +0 -44
  72. package/src/__tests__/process-management.test.ts +0 -1043
  73. package/src/__tests__/server.test.ts +0 -1020
  74. package/src/__tests__/setup.ts +0 -13
  75. package/src/__tests__/utils/claude-mock.ts +0 -87
  76. package/src/__tests__/utils/mcp-client.ts +0 -159
  77. package/src/__tests__/utils/opencode-mock.ts +0 -108
  78. package/src/__tests__/utils/persistent-mock.ts +0 -33
  79. package/src/__tests__/utils/test-helpers.ts +0 -13
  80. package/src/__tests__/validation.test.ts +0 -369
  81. package/src/__tests__/version-print.test.ts +0 -81
  82. package/src/__tests__/wait.test.ts +0 -302
  83. package/src/app/cli.ts +0 -424
  84. package/src/app/mcp.ts +0 -466
  85. package/src/bin/ai-cli-mcp.ts +0 -7
  86. package/src/bin/ai-cli.ts +0 -11
  87. package/src/cli-builder.ts +0 -274
  88. package/src/cli-parse.ts +0 -105
  89. package/src/cli-process-service.ts +0 -708
  90. package/src/cli-utils.ts +0 -258
  91. package/src/cli.ts +0 -124
  92. package/src/model-catalog.ts +0 -87
  93. package/src/parsers.ts +0 -840
  94. package/src/peek.ts +0 -95
  95. package/src/process-result.ts +0 -88
  96. package/src/process-service.ts +0 -367
  97. package/src/server.ts +0 -10
  98. package/tsconfig.json +0 -16
  99. package/vitest.config.e2e.ts +0 -27
  100. package/vitest.config.ts +0 -22
  101. package/vitest.config.unit.ts +0 -28
@@ -1,708 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import {
3
- chmodSync,
4
- closeSync,
5
- existsSync,
6
- mkdirSync,
7
- openSync,
8
- readSync,
9
- readFileSync,
10
- readdirSync,
11
- realpathSync,
12
- renameSync,
13
- rmSync,
14
- statSync,
15
- unlinkSync,
16
- writeFileSync,
17
- } from 'node:fs';
18
- import { join, basename, dirname } from 'node:path';
19
- import { homedir } from 'node:os';
20
- import { buildCliCommand, type BuildCliCommandOptions } from './cli-builder.js';
21
- import { findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
22
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekEventExtractor } from './parsers.js';
23
- import { buildProcessResult } from './process-result.js';
24
- import {
25
- appendPeekEvents,
26
- buildNotFoundPeekProcess,
27
- observedDurationSec,
28
- validatePeekPids,
29
- validatePeekTimeSec,
30
- type PeekProcessResult,
31
- type PeekResponse,
32
- } from './peek.js';
33
- import type { AgentType, ProcessListItem } from './process-service.js';
34
-
35
- interface StoredProcess {
36
- pid: number;
37
- prompt: string;
38
- workFolder: string;
39
- cwdKey?: string;
40
- model?: string;
41
- toolType: AgentType;
42
- startTime: string;
43
- stdoutPath: string;
44
- stderrPath: string;
45
- status: 'running' | 'completed' | 'failed';
46
- exitCode?: number;
47
- }
48
-
49
- interface StoredExitStatus {
50
- status: 'completed' | 'failed';
51
- exitCode?: number;
52
- }
53
-
54
- interface CliProcessServiceOptions {
55
- stateDir?: string;
56
- cliPaths?: BuildCliCommandOptions['cliPaths'];
57
- }
58
-
59
- export interface CliRunOptions {
60
- cwd: string;
61
- prompt?: string;
62
- prompt_file?: string;
63
- model?: string;
64
- session_id?: string;
65
- reasoning_effort?: string;
66
- }
67
-
68
- function resolveDefaultStateDir(): string {
69
- return process.env.AI_CLI_STATE_DIR || join(homedir(), '.local', 'state', 'ai-cli');
70
- }
71
-
72
- function isProcessRunning(pid: number): boolean {
73
- try {
74
- process.kill(pid, 0);
75
- return true;
76
- } catch (error: any) {
77
- if (error.code === 'EPERM') {
78
- return true;
79
- }
80
- return false;
81
- }
82
- }
83
-
84
- function normalizeCwdForStorage(cwd: string): string {
85
- return cwd
86
- .split('')
87
- .map((char) => (/^[A-Za-z0-9.-]$/.test(char) ? char : `_${char.charCodeAt(0).toString(16).padStart(2, '0')}`))
88
- .join('');
89
- }
90
-
91
- function parseAgentOutput(agent: AgentType, stdout: string, stderr: string): any {
92
- if (agent === 'codex') {
93
- return parseCodexOutput(`${stdout}\n${stderr}`);
94
- }
95
-
96
- if (!stdout) {
97
- return null;
98
- }
99
-
100
- if (agent === 'claude') {
101
- return parseClaudeOutput(stdout);
102
- }
103
- if (agent === 'gemini') {
104
- return parseGeminiOutput(stdout);
105
- }
106
- if (agent === 'forge') {
107
- return parseForgeOutput(stdout);
108
- }
109
- if (agent === 'opencode') {
110
- return parseOpenCodeOutput(stdout);
111
- }
112
- return null;
113
- }
114
-
115
- export class CliProcessService {
116
- private readonly stateDir: string;
117
- private readonly cliPaths: BuildCliCommandOptions['cliPaths'];
118
-
119
- constructor(options: CliProcessServiceOptions = {}) {
120
- this.stateDir = options.stateDir || resolveDefaultStateDir();
121
- this.cliPaths = options.cliPaths || {
122
- claude: findClaudeCli(),
123
- codex: findCodexCli(),
124
- gemini: findGeminiCli(),
125
- forge: findForgeCli(),
126
- opencode: findOpencodeCli(),
127
- };
128
- mkdirSync(this.stateDir, { recursive: true });
129
- }
130
-
131
- async startProcess(options: CliRunOptions): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
132
- const cmd = buildCliCommand({
133
- prompt: options.prompt,
134
- prompt_file: options.prompt_file,
135
- workFolder: options.cwd,
136
- model: options.model,
137
- session_id: options.session_id,
138
- reasoning_effort: options.reasoning_effort,
139
- cliPaths: this.cliPaths,
140
- });
141
-
142
- if (cmd.agent === 'opencode') {
143
- return this.startDetachedOpenCodeProcess(cmd, options.model);
144
- }
145
-
146
- const stdoutPath = this.resolveStdoutPathForPidPlaceholder();
147
- const stderrPath = this.resolveStderrPathForPidPlaceholder();
148
- let stdoutFd: number | undefined;
149
- let stderrFd: number | undefined;
150
-
151
- try {
152
- stdoutFd = openSync(stdoutPath, 'w');
153
- stderrFd = openSync(stderrPath, 'w');
154
-
155
- const childProcess = spawn(cmd.cliPath, cmd.args, {
156
- cwd: cmd.cwd,
157
- detached: true,
158
- stdio: ['ignore', stdoutFd, stderrFd],
159
- });
160
-
161
- const pid = childProcess.pid;
162
- childProcess.unref();
163
-
164
- if (!pid) {
165
- throw new Error(`Failed to start ${cmd.agent} CLI process`);
166
- }
167
-
168
- const processDir = this.resolveProcessDir(cmd.cwd, pid);
169
- mkdirSync(processDir, { recursive: true });
170
- const finalStdoutPath = this.resolveStdoutPath(processDir);
171
- const finalStderrPath = this.resolveStderrPath(processDir);
172
- this.renamePlaceholderFile(stdoutPath, finalStdoutPath);
173
- this.renamePlaceholderFile(stderrPath, finalStderrPath);
174
-
175
- const storedProcess: StoredProcess = {
176
- pid,
177
- prompt: cmd.prompt,
178
- workFolder: cmd.cwd,
179
- cwdKey: this.resolveCwdKey(cmd.cwd),
180
- model: options.model,
181
- toolType: cmd.agent,
182
- startTime: new Date().toISOString(),
183
- stdoutPath: finalStdoutPath,
184
- stderrPath: finalStderrPath,
185
- status: 'running',
186
- };
187
- this.writeProcess(storedProcess);
188
-
189
- return {
190
- pid,
191
- status: 'started',
192
- agent: cmd.agent,
193
- message: `${cmd.agent} process started successfully`,
194
- };
195
- } catch (error) {
196
- this.removeFileIfExists(stdoutPath);
197
- this.removeFileIfExists(stderrPath);
198
- throw error;
199
- } finally {
200
- if (stdoutFd !== undefined) {
201
- closeSync(stdoutFd);
202
- }
203
- if (stderrFd !== undefined) {
204
- closeSync(stderrFd);
205
- }
206
- }
207
- }
208
-
209
- async listProcesses(): Promise<ProcessListItem[]> {
210
- return this.readAllProcesses().map((process) => ({
211
- pid: process.pid,
212
- agent: process.toolType,
213
- status: this.refreshStatus(process).status,
214
- }));
215
- }
216
-
217
- async getProcessResult(pid: number, verbose = false): Promise<any> {
218
- const storedProcess = this.readProcess(pid);
219
- const refreshed = this.refreshStatus(storedProcess);
220
- const stdout = this.readTextFileSafe(refreshed.stdoutPath);
221
- const stderr = this.readTextFileSafe(refreshed.stderrPath);
222
- const agentOutput = parseAgentOutput(refreshed.toolType, stdout, stderr);
223
-
224
- return buildProcessResult({
225
- pid,
226
- agent: refreshed.toolType,
227
- status: refreshed.status,
228
- exitCode: refreshed.exitCode,
229
- startTime: refreshed.startTime,
230
- workFolder: refreshed.workFolder,
231
- prompt: refreshed.prompt,
232
- model: refreshed.model,
233
- stdout,
234
- stderr,
235
- }, agentOutput, verbose);
236
- }
237
-
238
- async waitForProcesses(pids: number[], timeoutSeconds = 180, verbose = false): Promise<any[]> {
239
- const start = Date.now();
240
- for (const pid of pids) {
241
- this.readProcess(pid);
242
- }
243
-
244
- while (true) {
245
- const statuses = pids.map((pid) => this.refreshStatus(this.readProcess(pid)).status);
246
- if (statuses.every((status) => status !== 'running')) {
247
- return Promise.all(pids.map((pid) => this.getProcessResult(pid, verbose)));
248
- }
249
-
250
- if (Date.now() - start >= timeoutSeconds * 1000) {
251
- throw new Error(`Timed out after ${timeoutSeconds} seconds waiting for processes`);
252
- }
253
-
254
- await new Promise((resolve) => setTimeout(resolve, 50));
255
- }
256
- }
257
-
258
- async peekProcesses(pids: number[], peekTimeSec = 10, includeToolCalls = false): Promise<PeekResponse> {
259
- const targetPids = validatePeekPids(pids);
260
- const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
261
- const processes: PeekProcessResult[] = [];
262
- const observers: Array<{
263
- process: StoredProcess;
264
- result: PeekProcessResult;
265
- stdoutExtractor: PeekEventExtractor;
266
- stderrExtractor: PeekEventExtractor;
267
- stdoutOffset: number;
268
- stderrOffset: number;
269
- }> = [];
270
-
271
- for (const pid of targetPids) {
272
- let process: StoredProcess;
273
- try {
274
- process = this.refreshStatus(this.readProcess(pid));
275
- } catch {
276
- processes.push(buildNotFoundPeekProcess(pid));
277
- continue;
278
- }
279
-
280
- const result: PeekProcessResult = {
281
- pid,
282
- agent: process.toolType,
283
- status: process.status,
284
- events: [],
285
- truncated: false,
286
- error: null,
287
- };
288
- processes.push(result);
289
- observers.push({
290
- process,
291
- result,
292
- stdoutExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls }),
293
- stderrExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls }),
294
- stdoutOffset: this.fileSizeSafe(process.stdoutPath),
295
- stderrOffset: this.fileSizeSafe(process.stderrPath),
296
- });
297
- }
298
-
299
- const startedAt = new Date();
300
- const startedAtMs = Date.now();
301
- const deadlineMs = startedAtMs + targetPeekTimeSec * 1000;
302
-
303
- while (Date.now() <= deadlineMs) {
304
- const observedAt = new Date().toISOString();
305
- let allTerminal = true;
306
-
307
- for (const observer of observers) {
308
- const stdoutRead = this.readTextFromOffset(observer.process.stdoutPath, observer.stdoutOffset);
309
- observer.stdoutOffset = stdoutRead.offset;
310
- appendPeekEvents(observer.result, observer.stdoutExtractor.push(stdoutRead.text, observedAt));
311
-
312
- const stderrRead = this.readTextFromOffset(observer.process.stderrPath, observer.stderrOffset);
313
- observer.stderrOffset = stderrRead.offset;
314
- appendPeekEvents(observer.result, observer.stderrExtractor.push(stderrRead.text, observedAt));
315
-
316
- observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
317
- observer.result.status = observer.process.status;
318
- if (observer.process.status === 'running') {
319
- allTerminal = false;
320
- }
321
- }
322
-
323
- if (allTerminal) {
324
- break;
325
- }
326
-
327
- const remainingMs = deadlineMs - Date.now();
328
- if (remainingMs <= 0) {
329
- break;
330
- }
331
- await new Promise((resolve) => setTimeout(resolve, Math.min(50, remainingMs)));
332
- }
333
-
334
- const flushTs = new Date().toISOString();
335
- for (const observer of observers) {
336
- observer.process = this.refreshStatus(this.readProcess(observer.process.pid));
337
- observer.result.status = observer.process.status;
338
- appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs));
339
- appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs));
340
- }
341
-
342
- return {
343
- peek_started_at: startedAt.toISOString(),
344
- observed_duration_sec: observedDurationSec(startedAtMs),
345
- processes,
346
- };
347
- }
348
-
349
- async killProcess(pid: number): Promise<{ pid: number; status: string; message: string }> {
350
- const process = this.readProcess(pid);
351
- const refreshed = this.refreshStatus(process);
352
-
353
- if (refreshed.status !== 'running') {
354
- return {
355
- pid,
356
- status: refreshed.status,
357
- message: 'Process already terminated',
358
- };
359
- }
360
-
361
- this.killPidOrGroup(pid, 'SIGTERM');
362
- await this.waitForProcessExit(pid, 250);
363
-
364
- if (isProcessRunning(pid)) {
365
- return {
366
- pid,
367
- status: 'running',
368
- message: 'Signal sent but process is still running',
369
- };
370
- }
371
-
372
- refreshed.status = 'failed';
373
- this.writeProcess(refreshed);
374
-
375
- return {
376
- pid,
377
- status: 'terminated',
378
- message: 'Process terminated successfully',
379
- };
380
- }
381
-
382
- async cleanupProcesses(): Promise<{ removed: number; message: string }> {
383
- let removed = 0;
384
-
385
- for (const process of this.readAllProcesses()) {
386
- const refreshed = this.refreshStatus(process);
387
- if (refreshed.status === 'running') {
388
- continue;
389
- }
390
-
391
- const processDir = this.resolveStoredProcessDir(refreshed);
392
- if (existsSync(processDir)) {
393
- rmSync(processDir, { recursive: true, force: true });
394
- removed++;
395
- }
396
- }
397
-
398
- this.removeEmptyCwdDirs();
399
-
400
- return {
401
- removed,
402
- message: `Removed ${removed} processes`,
403
- };
404
- }
405
-
406
- private async startDetachedOpenCodeProcess(
407
- cmd: Awaited<ReturnType<typeof buildCliCommand>>,
408
- model: string | undefined,
409
- ): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
410
- const cwdKey = this.resolveCwdKey(cmd.cwd);
411
- const wrapperPath = this.ensureOpenCodeWrapperScript();
412
-
413
- const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
414
- cwd: cmd.cwd,
415
- detached: true,
416
- stdio: 'ignore',
417
- });
418
-
419
- const pid = childProcess.pid;
420
- childProcess.unref();
421
-
422
- if (!pid) {
423
- throw new Error(`Failed to start ${cmd.agent} CLI process`);
424
- }
425
-
426
- const processDir = this.resolveProcessDir(cmd.cwd, pid);
427
- mkdirSync(processDir, { recursive: true });
428
- const stdoutPath = this.resolveStdoutPath(processDir);
429
- const stderrPath = this.resolveStderrPath(processDir);
430
- if (!existsSync(stdoutPath)) {
431
- writeFileSync(stdoutPath, '');
432
- }
433
- if (!existsSync(stderrPath)) {
434
- writeFileSync(stderrPath, '');
435
- }
436
-
437
- const storedProcess: StoredProcess = {
438
- pid,
439
- prompt: cmd.prompt,
440
- workFolder: cmd.cwd,
441
- cwdKey,
442
- model,
443
- toolType: cmd.agent,
444
- startTime: new Date().toISOString(),
445
- stdoutPath,
446
- stderrPath,
447
- status: 'running',
448
- };
449
- this.writeProcess(storedProcess);
450
-
451
- return {
452
- pid,
453
- status: 'started',
454
- agent: cmd.agent,
455
- message: `${cmd.agent} process started successfully`,
456
- };
457
- }
458
-
459
- private readAllProcesses(): StoredProcess[] {
460
- const cwdsDir = this.resolveCwdsDir();
461
- if (!existsSync(cwdsDir)) {
462
- return [];
463
- }
464
-
465
- const processes: StoredProcess[] = [];
466
- for (const cwdEntry of readdirSync(cwdsDir)) {
467
- const cwdDir = join(cwdsDir, cwdEntry);
468
- for (const pidEntry of readdirSync(cwdDir)) {
469
- const metaPath = join(cwdDir, pidEntry, 'meta.json');
470
- if (existsSync(metaPath)) {
471
- processes.push(this.parseProcessFile(metaPath));
472
- }
473
- }
474
- }
475
-
476
- return processes;
477
- }
478
-
479
- private readProcess(pid: number): StoredProcess {
480
- const process = this.readAllProcesses().find((entry) => entry.pid === pid);
481
- if (!process) {
482
- throw new Error(`Process with PID ${pid} not found`);
483
- }
484
- return process;
485
- }
486
-
487
- private parseProcessFile(metaPath: string): StoredProcess {
488
- const process = JSON.parse(readFileSync(metaPath, 'utf-8')) as StoredProcess;
489
- if (!process.cwdKey) {
490
- process.cwdKey = basename(dirname(dirname(metaPath)));
491
- }
492
- return process;
493
- }
494
-
495
- private writeProcess(process: StoredProcess): void {
496
- const processDir = this.resolveStoredProcessDir(process);
497
- mkdirSync(processDir, { recursive: true });
498
- writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
499
- }
500
-
501
- private refreshStatus(process: StoredProcess): StoredProcess {
502
- if (process.status !== 'running') {
503
- return process;
504
- }
505
-
506
- const persistedExitStatus = this.readExitStatus(process);
507
- if (persistedExitStatus) {
508
- process.status = persistedExitStatus.status;
509
- process.exitCode = persistedExitStatus.exitCode;
510
- this.writeProcess(process);
511
- return process;
512
- }
513
-
514
- if (!isProcessRunning(process.pid)) {
515
- process.status = 'completed';
516
- this.writeProcess(process);
517
- }
518
- return process;
519
- }
520
-
521
- private readExitStatus(process: StoredProcess): StoredExitStatus | null {
522
- if (process.toolType !== 'opencode') {
523
- return null;
524
- }
525
-
526
- const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
527
- if (!existsSync(exitMetaPath)) {
528
- return null;
529
- }
530
-
531
- try {
532
- const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8')) as StoredExitStatus;
533
- if (parsed.status === 'completed' || parsed.status === 'failed') {
534
- return parsed;
535
- }
536
- } catch {
537
- return null;
538
- }
539
-
540
- return null;
541
- }
542
-
543
- private readTextFileSafe(filePath: string): string {
544
- if (!existsSync(filePath)) {
545
- return '';
546
- }
547
- return readFileSync(filePath, 'utf-8');
548
- }
549
-
550
- private fileSizeSafe(filePath: string): number {
551
- if (!existsSync(filePath)) {
552
- return 0;
553
- }
554
- return statSync(filePath).size;
555
- }
556
-
557
- private readTextFromOffset(filePath: string, offset: number): { text: string; offset: number } {
558
- if (!existsSync(filePath)) {
559
- return { text: '', offset };
560
- }
561
-
562
- const size = statSync(filePath).size;
563
- if (size <= offset) {
564
- return { text: '', offset: size };
565
- }
566
-
567
- const fd = openSync(filePath, 'r');
568
- try {
569
- const length = size - offset;
570
- const buffer = Buffer.alloc(length);
571
- const bytesRead = readSync(fd, buffer, 0, length, offset);
572
- return {
573
- text: buffer.subarray(0, bytesRead).toString('utf-8'),
574
- offset: size,
575
- };
576
- } finally {
577
- closeSync(fd);
578
- }
579
- }
580
-
581
- private resolveCwdsDir(): string {
582
- return join(this.stateDir, 'cwds');
583
- }
584
-
585
- private resolveProcessDir(cwd: string, pid: number): string {
586
- return join(this.resolveCwdsDir(), this.resolveCwdKey(cwd), String(pid));
587
- }
588
-
589
- private resolveStoredProcessDir(process: StoredProcess): string {
590
- if (!process.cwdKey) {
591
- process.cwdKey = this.resolveCwdKey(process.workFolder);
592
- }
593
- return join(this.resolveCwdsDir(), process.cwdKey, String(process.pid));
594
- }
595
-
596
- private resolveCwdKey(cwd: string): string {
597
- return normalizeCwdForStorage(realpathSync(cwd));
598
- }
599
-
600
- private resolveMetaPath(processDir: string): string {
601
- return join(processDir, 'meta.json');
602
- }
603
-
604
- private resolveStdoutPath(processDir: string): string {
605
- return join(processDir, 'stdout.log');
606
- }
607
-
608
- private resolveStderrPath(processDir: string): string {
609
- return join(processDir, 'stderr.log');
610
- }
611
-
612
- private resolveExitStatusPath(processDir: string): string {
613
- return join(processDir, 'exit-status.json');
614
- }
615
-
616
- private resolveOpenCodeWrapperPath(): string {
617
- return join(this.stateDir, 'opencode-detached-wrapper.sh');
618
- }
619
-
620
- private resolveStdoutPathForPidPlaceholder(): string {
621
- return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
622
- }
623
-
624
- private resolveStderrPathForPidPlaceholder(): string {
625
- return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
626
- }
627
-
628
- private ensureOpenCodeWrapperScript(): string {
629
- const wrapperPath = this.resolveOpenCodeWrapperPath();
630
- if (existsSync(wrapperPath)) {
631
- return wrapperPath;
632
- }
633
-
634
- writeFileSync(
635
- wrapperPath,
636
- `#!/bin/sh
637
- set +e
638
- state_dir="$1"
639
- cwd_key="$2"
640
- shift 2
641
- pid="$$"
642
- process_dir="$state_dir/cwds/$cwd_key/$pid"
643
- stdout_path="$process_dir/stdout.log"
644
- stderr_path="$process_dir/stderr.log"
645
- exit_meta_path="$process_dir/exit-status.json"
646
- mkdir -p "$process_dir"
647
- : > "$stdout_path"
648
- : > "$stderr_path"
649
- "$@" >> "$stdout_path" 2>> "$stderr_path"
650
- exit_code="$?"
651
- status="completed"
652
- if [ "$exit_code" -ne 0 ]; then
653
- status="failed"
654
- fi
655
- printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
656
- exit "$exit_code"
657
- `,
658
- );
659
- chmodSync(wrapperPath, 0o755);
660
- return wrapperPath;
661
- }
662
-
663
- private renamePlaceholderFile(fromPath: string, toPath: string): void {
664
- renameSync(fromPath, toPath);
665
- }
666
-
667
- private removeFileIfExists(filePath: string): void {
668
- if (existsSync(filePath)) {
669
- unlinkSync(filePath);
670
- }
671
- }
672
-
673
- private killPidOrGroup(pid: number, signal: NodeJS.Signals): void {
674
- try {
675
- globalThis.process.kill(-pid, signal);
676
- } catch (error: any) {
677
- if (error.code === 'ESRCH' || error.code === 'EINVAL') {
678
- globalThis.process.kill(pid, signal);
679
- return;
680
- }
681
- if (error.code === 'EPERM') {
682
- throw error;
683
- }
684
- globalThis.process.kill(pid, signal);
685
- }
686
- }
687
-
688
- private async waitForProcessExit(pid: number, timeoutMs: number): Promise<void> {
689
- const startedAt = Date.now();
690
- while (isProcessRunning(pid) && Date.now() - startedAt < timeoutMs) {
691
- await new Promise((resolve) => setTimeout(resolve, 25));
692
- }
693
- }
694
-
695
- private removeEmptyCwdDirs(): void {
696
- const cwdsDir = this.resolveCwdsDir();
697
- if (!existsSync(cwdsDir)) {
698
- return;
699
- }
700
-
701
- for (const cwdEntry of readdirSync(cwdsDir)) {
702
- const cwdDir = join(cwdsDir, cwdEntry);
703
- if (readdirSync(cwdDir).length === 0) {
704
- rmSync(cwdDir, { recursive: true, force: true });
705
- }
706
- }
707
- }
708
- }