ai-cli-mcp 2.19.0 → 2.20.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.
Files changed (100) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.ja.md +34 -8
  3. package/README.md +41 -8
  4. package/dist/app/cli.js +1 -0
  5. package/dist/app/mcp.js +64 -12
  6. package/dist/cli-builder.js +13 -6
  7. package/dist/cli-process-service.js +76 -91
  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 +8 -2
  12. package/package.json +27 -3
  13. package/server.json +3 -3
  14. package/.gemini/settings.json +0 -11
  15. package/.github/dependabot.yml +0 -28
  16. package/.github/pull_request_template.md +0 -28
  17. package/.github/workflows/ci.yml +0 -34
  18. package/.github/workflows/dependency-review.yml +0 -22
  19. package/.github/workflows/publish.yml +0 -89
  20. package/.github/workflows/test.yml +0 -20
  21. package/.github/workflows/watch-session-prs.yml +0 -276
  22. package/.husky/pre-commit +0 -1
  23. package/.mcp.json +0 -11
  24. package/.releaserc.json +0 -18
  25. package/.vscode/settings.json +0 -3
  26. package/CONTRIBUTING.md +0 -81
  27. package/dist/__tests__/app-cli.test.js +0 -392
  28. package/dist/__tests__/cli-bin-smoke.test.js +0 -101
  29. package/dist/__tests__/cli-builder.test.js +0 -442
  30. package/dist/__tests__/cli-process-service.test.js +0 -655
  31. package/dist/__tests__/cli-utils.test.js +0 -171
  32. package/dist/__tests__/e2e.test.js +0 -256
  33. package/dist/__tests__/edge-cases.test.js +0 -130
  34. package/dist/__tests__/error-cases.test.js +0 -292
  35. package/dist/__tests__/mcp-contract.test.js +0 -636
  36. package/dist/__tests__/mocks.js +0 -32
  37. package/dist/__tests__/model-alias.test.js +0 -36
  38. package/dist/__tests__/parsers.test.js +0 -646
  39. package/dist/__tests__/peek.test.js +0 -36
  40. package/dist/__tests__/process-management.test.js +0 -949
  41. package/dist/__tests__/server.test.js +0 -809
  42. package/dist/__tests__/setup.js +0 -11
  43. package/dist/__tests__/utils/claude-mock.js +0 -80
  44. package/dist/__tests__/utils/mcp-client.js +0 -121
  45. package/dist/__tests__/utils/opencode-mock.js +0 -91
  46. package/dist/__tests__/utils/persistent-mock.js +0 -28
  47. package/dist/__tests__/utils/test-helpers.js +0 -11
  48. package/dist/__tests__/validation.test.js +0 -308
  49. package/dist/__tests__/version-print.test.js +0 -65
  50. package/dist/__tests__/wait.test.js +0 -260
  51. package/docs/RELEASE_CHECKLIST.md +0 -65
  52. package/docs/cli-architecture.md +0 -275
  53. package/docs/concept.md +0 -154
  54. package/docs/development.md +0 -156
  55. package/docs/e2e-testing.md +0 -148
  56. package/docs/prd.md +0 -146
  57. package/docs/session-stacking.md +0 -67
  58. package/src/__tests__/app-cli.test.ts +0 -495
  59. package/src/__tests__/cli-bin-smoke.test.ts +0 -136
  60. package/src/__tests__/cli-builder.test.ts +0 -549
  61. package/src/__tests__/cli-process-service.test.ts +0 -759
  62. package/src/__tests__/cli-utils.test.ts +0 -200
  63. package/src/__tests__/e2e.test.ts +0 -311
  64. package/src/__tests__/edge-cases.test.ts +0 -176
  65. package/src/__tests__/error-cases.test.ts +0 -370
  66. package/src/__tests__/mcp-contract.test.ts +0 -755
  67. package/src/__tests__/mocks.ts +0 -35
  68. package/src/__tests__/model-alias.test.ts +0 -44
  69. package/src/__tests__/parsers.test.ts +0 -730
  70. package/src/__tests__/peek.test.ts +0 -44
  71. package/src/__tests__/process-management.test.ts +0 -1129
  72. package/src/__tests__/server.test.ts +0 -1020
  73. package/src/__tests__/setup.ts +0 -13
  74. package/src/__tests__/utils/claude-mock.ts +0 -87
  75. package/src/__tests__/utils/mcp-client.ts +0 -159
  76. package/src/__tests__/utils/opencode-mock.ts +0 -108
  77. package/src/__tests__/utils/persistent-mock.ts +0 -33
  78. package/src/__tests__/utils/test-helpers.ts +0 -13
  79. package/src/__tests__/validation.test.ts +0 -369
  80. package/src/__tests__/version-print.test.ts +0 -81
  81. package/src/__tests__/wait.test.ts +0 -302
  82. package/src/app/cli.ts +0 -424
  83. package/src/app/mcp.ts +0 -466
  84. package/src/bin/ai-cli-mcp.ts +0 -7
  85. package/src/bin/ai-cli.ts +0 -11
  86. package/src/cli-builder.ts +0 -274
  87. package/src/cli-parse.ts +0 -105
  88. package/src/cli-process-service.ts +0 -709
  89. package/src/cli-utils.ts +0 -258
  90. package/src/cli.ts +0 -124
  91. package/src/model-catalog.ts +0 -87
  92. package/src/parsers.ts +0 -965
  93. package/src/peek.ts +0 -95
  94. package/src/process-result.ts +0 -88
  95. package/src/process-service.ts +0 -368
  96. package/src/server.ts +0 -10
  97. package/tsconfig.json +0 -16
  98. package/vitest.config.e2e.ts +0 -27
  99. package/vitest.config.ts +0 -22
  100. package/vitest.config.unit.ts +0 -28
@@ -1,709 +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, source: 'stdout' }),
293
- stderrExtractor: new PeekEventExtractor(process.toolType, { includeToolCalls, source: 'stderr' }),
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
- const terminal = observer.process.status !== 'running';
339
- appendPeekEvents(observer.result, observer.stdoutExtractor.flush(flushTs, { terminal }));
340
- appendPeekEvents(observer.result, observer.stderrExtractor.flush(flushTs, { terminal }));
341
- }
342
-
343
- return {
344
- peek_started_at: startedAt.toISOString(),
345
- observed_duration_sec: observedDurationSec(startedAtMs),
346
- processes,
347
- };
348
- }
349
-
350
- async killProcess(pid: number): Promise<{ pid: number; status: string; message: string }> {
351
- const process = this.readProcess(pid);
352
- const refreshed = this.refreshStatus(process);
353
-
354
- if (refreshed.status !== 'running') {
355
- return {
356
- pid,
357
- status: refreshed.status,
358
- message: 'Process already terminated',
359
- };
360
- }
361
-
362
- this.killPidOrGroup(pid, 'SIGTERM');
363
- await this.waitForProcessExit(pid, 250);
364
-
365
- if (isProcessRunning(pid)) {
366
- return {
367
- pid,
368
- status: 'running',
369
- message: 'Signal sent but process is still running',
370
- };
371
- }
372
-
373
- refreshed.status = 'failed';
374
- this.writeProcess(refreshed);
375
-
376
- return {
377
- pid,
378
- status: 'terminated',
379
- message: 'Process terminated successfully',
380
- };
381
- }
382
-
383
- async cleanupProcesses(): Promise<{ removed: number; message: string }> {
384
- let removed = 0;
385
-
386
- for (const process of this.readAllProcesses()) {
387
- const refreshed = this.refreshStatus(process);
388
- if (refreshed.status === 'running') {
389
- continue;
390
- }
391
-
392
- const processDir = this.resolveStoredProcessDir(refreshed);
393
- if (existsSync(processDir)) {
394
- rmSync(processDir, { recursive: true, force: true });
395
- removed++;
396
- }
397
- }
398
-
399
- this.removeEmptyCwdDirs();
400
-
401
- return {
402
- removed,
403
- message: `Removed ${removed} processes`,
404
- };
405
- }
406
-
407
- private async startDetachedOpenCodeProcess(
408
- cmd: Awaited<ReturnType<typeof buildCliCommand>>,
409
- model: string | undefined,
410
- ): Promise<{ pid: number; status: 'started'; agent: AgentType; message: string }> {
411
- const cwdKey = this.resolveCwdKey(cmd.cwd);
412
- const wrapperPath = this.ensureOpenCodeWrapperScript();
413
-
414
- const childProcess = spawn(wrapperPath, [this.stateDir, cwdKey, cmd.cliPath, ...cmd.args], {
415
- cwd: cmd.cwd,
416
- detached: true,
417
- stdio: 'ignore',
418
- });
419
-
420
- const pid = childProcess.pid;
421
- childProcess.unref();
422
-
423
- if (!pid) {
424
- throw new Error(`Failed to start ${cmd.agent} CLI process`);
425
- }
426
-
427
- const processDir = this.resolveProcessDir(cmd.cwd, pid);
428
- mkdirSync(processDir, { recursive: true });
429
- const stdoutPath = this.resolveStdoutPath(processDir);
430
- const stderrPath = this.resolveStderrPath(processDir);
431
- if (!existsSync(stdoutPath)) {
432
- writeFileSync(stdoutPath, '');
433
- }
434
- if (!existsSync(stderrPath)) {
435
- writeFileSync(stderrPath, '');
436
- }
437
-
438
- const storedProcess: StoredProcess = {
439
- pid,
440
- prompt: cmd.prompt,
441
- workFolder: cmd.cwd,
442
- cwdKey,
443
- model,
444
- toolType: cmd.agent,
445
- startTime: new Date().toISOString(),
446
- stdoutPath,
447
- stderrPath,
448
- status: 'running',
449
- };
450
- this.writeProcess(storedProcess);
451
-
452
- return {
453
- pid,
454
- status: 'started',
455
- agent: cmd.agent,
456
- message: `${cmd.agent} process started successfully`,
457
- };
458
- }
459
-
460
- private readAllProcesses(): StoredProcess[] {
461
- const cwdsDir = this.resolveCwdsDir();
462
- if (!existsSync(cwdsDir)) {
463
- return [];
464
- }
465
-
466
- const processes: StoredProcess[] = [];
467
- for (const cwdEntry of readdirSync(cwdsDir)) {
468
- const cwdDir = join(cwdsDir, cwdEntry);
469
- for (const pidEntry of readdirSync(cwdDir)) {
470
- const metaPath = join(cwdDir, pidEntry, 'meta.json');
471
- if (existsSync(metaPath)) {
472
- processes.push(this.parseProcessFile(metaPath));
473
- }
474
- }
475
- }
476
-
477
- return processes;
478
- }
479
-
480
- private readProcess(pid: number): StoredProcess {
481
- const process = this.readAllProcesses().find((entry) => entry.pid === pid);
482
- if (!process) {
483
- throw new Error(`Process with PID ${pid} not found`);
484
- }
485
- return process;
486
- }
487
-
488
- private parseProcessFile(metaPath: string): StoredProcess {
489
- const process = JSON.parse(readFileSync(metaPath, 'utf-8')) as StoredProcess;
490
- if (!process.cwdKey) {
491
- process.cwdKey = basename(dirname(dirname(metaPath)));
492
- }
493
- return process;
494
- }
495
-
496
- private writeProcess(process: StoredProcess): void {
497
- const processDir = this.resolveStoredProcessDir(process);
498
- mkdirSync(processDir, { recursive: true });
499
- writeFileSync(this.resolveMetaPath(processDir), JSON.stringify(process, null, 2));
500
- }
501
-
502
- private refreshStatus(process: StoredProcess): StoredProcess {
503
- if (process.status !== 'running') {
504
- return process;
505
- }
506
-
507
- const persistedExitStatus = this.readExitStatus(process);
508
- if (persistedExitStatus) {
509
- process.status = persistedExitStatus.status;
510
- process.exitCode = persistedExitStatus.exitCode;
511
- this.writeProcess(process);
512
- return process;
513
- }
514
-
515
- if (!isProcessRunning(process.pid)) {
516
- process.status = 'completed';
517
- this.writeProcess(process);
518
- }
519
- return process;
520
- }
521
-
522
- private readExitStatus(process: StoredProcess): StoredExitStatus | null {
523
- if (process.toolType !== 'opencode') {
524
- return null;
525
- }
526
-
527
- const exitMetaPath = this.resolveExitStatusPath(this.resolveStoredProcessDir(process));
528
- if (!existsSync(exitMetaPath)) {
529
- return null;
530
- }
531
-
532
- try {
533
- const parsed = JSON.parse(readFileSync(exitMetaPath, 'utf-8')) as StoredExitStatus;
534
- if (parsed.status === 'completed' || parsed.status === 'failed') {
535
- return parsed;
536
- }
537
- } catch {
538
- return null;
539
- }
540
-
541
- return null;
542
- }
543
-
544
- private readTextFileSafe(filePath: string): string {
545
- if (!existsSync(filePath)) {
546
- return '';
547
- }
548
- return readFileSync(filePath, 'utf-8');
549
- }
550
-
551
- private fileSizeSafe(filePath: string): number {
552
- if (!existsSync(filePath)) {
553
- return 0;
554
- }
555
- return statSync(filePath).size;
556
- }
557
-
558
- private readTextFromOffset(filePath: string, offset: number): { text: string; offset: number } {
559
- if (!existsSync(filePath)) {
560
- return { text: '', offset };
561
- }
562
-
563
- const size = statSync(filePath).size;
564
- if (size <= offset) {
565
- return { text: '', offset: size };
566
- }
567
-
568
- const fd = openSync(filePath, 'r');
569
- try {
570
- const length = size - offset;
571
- const buffer = Buffer.alloc(length);
572
- const bytesRead = readSync(fd, buffer, 0, length, offset);
573
- return {
574
- text: buffer.subarray(0, bytesRead).toString('utf-8'),
575
- offset: size,
576
- };
577
- } finally {
578
- closeSync(fd);
579
- }
580
- }
581
-
582
- private resolveCwdsDir(): string {
583
- return join(this.stateDir, 'cwds');
584
- }
585
-
586
- private resolveProcessDir(cwd: string, pid: number): string {
587
- return join(this.resolveCwdsDir(), this.resolveCwdKey(cwd), String(pid));
588
- }
589
-
590
- private resolveStoredProcessDir(process: StoredProcess): string {
591
- if (!process.cwdKey) {
592
- process.cwdKey = this.resolveCwdKey(process.workFolder);
593
- }
594
- return join(this.resolveCwdsDir(), process.cwdKey, String(process.pid));
595
- }
596
-
597
- private resolveCwdKey(cwd: string): string {
598
- return normalizeCwdForStorage(realpathSync(cwd));
599
- }
600
-
601
- private resolveMetaPath(processDir: string): string {
602
- return join(processDir, 'meta.json');
603
- }
604
-
605
- private resolveStdoutPath(processDir: string): string {
606
- return join(processDir, 'stdout.log');
607
- }
608
-
609
- private resolveStderrPath(processDir: string): string {
610
- return join(processDir, 'stderr.log');
611
- }
612
-
613
- private resolveExitStatusPath(processDir: string): string {
614
- return join(processDir, 'exit-status.json');
615
- }
616
-
617
- private resolveOpenCodeWrapperPath(): string {
618
- return join(this.stateDir, 'opencode-detached-wrapper.sh');
619
- }
620
-
621
- private resolveStdoutPathForPidPlaceholder(): string {
622
- return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stdout.log`);
623
- }
624
-
625
- private resolveStderrPathForPidPlaceholder(): string {
626
- return join(this.stateDir, `pending-${Date.now()}-${Math.random().toString(36).slice(2)}.stderr.log`);
627
- }
628
-
629
- private ensureOpenCodeWrapperScript(): string {
630
- const wrapperPath = this.resolveOpenCodeWrapperPath();
631
- if (existsSync(wrapperPath)) {
632
- return wrapperPath;
633
- }
634
-
635
- writeFileSync(
636
- wrapperPath,
637
- `#!/bin/sh
638
- set +e
639
- state_dir="$1"
640
- cwd_key="$2"
641
- shift 2
642
- pid="$$"
643
- process_dir="$state_dir/cwds/$cwd_key/$pid"
644
- stdout_path="$process_dir/stdout.log"
645
- stderr_path="$process_dir/stderr.log"
646
- exit_meta_path="$process_dir/exit-status.json"
647
- mkdir -p "$process_dir"
648
- : > "$stdout_path"
649
- : > "$stderr_path"
650
- "$@" >> "$stdout_path" 2>> "$stderr_path"
651
- exit_code="$?"
652
- status="completed"
653
- if [ "$exit_code" -ne 0 ]; then
654
- status="failed"
655
- fi
656
- printf '{\n "status": "%s",\n "exitCode": %s\n}\n' "$status" "$exit_code" > "$exit_meta_path"
657
- exit "$exit_code"
658
- `,
659
- );
660
- chmodSync(wrapperPath, 0o755);
661
- return wrapperPath;
662
- }
663
-
664
- private renamePlaceholderFile(fromPath: string, toPath: string): void {
665
- renameSync(fromPath, toPath);
666
- }
667
-
668
- private removeFileIfExists(filePath: string): void {
669
- if (existsSync(filePath)) {
670
- unlinkSync(filePath);
671
- }
672
- }
673
-
674
- private killPidOrGroup(pid: number, signal: NodeJS.Signals): void {
675
- try {
676
- globalThis.process.kill(-pid, signal);
677
- } catch (error: any) {
678
- if (error.code === 'ESRCH' || error.code === 'EINVAL') {
679
- globalThis.process.kill(pid, signal);
680
- return;
681
- }
682
- if (error.code === 'EPERM') {
683
- throw error;
684
- }
685
- globalThis.process.kill(pid, signal);
686
- }
687
- }
688
-
689
- private async waitForProcessExit(pid: number, timeoutMs: number): Promise<void> {
690
- const startedAt = Date.now();
691
- while (isProcessRunning(pid) && Date.now() - startedAt < timeoutMs) {
692
- await new Promise((resolve) => setTimeout(resolve, 25));
693
- }
694
- }
695
-
696
- private removeEmptyCwdDirs(): void {
697
- const cwdsDir = this.resolveCwdsDir();
698
- if (!existsSync(cwdsDir)) {
699
- return;
700
- }
701
-
702
- for (const cwdEntry of readdirSync(cwdsDir)) {
703
- const cwdDir = join(cwdsDir, cwdEntry);
704
- if (readdirSync(cwdDir).length === 0) {
705
- rmSync(cwdDir, { recursive: true, force: true });
706
- }
707
- }
708
- }
709
- }