ai-cli-mcp 2.14.1 → 2.16.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 (60) hide show
  1. package/.github/dependabot.yml +28 -0
  2. package/.github/workflows/ci.yml +4 -1
  3. package/.github/workflows/dependency-review.yml +22 -0
  4. package/CHANGELOG.md +14 -0
  5. package/README.ja.md +83 -6
  6. package/README.md +83 -7
  7. package/dist/__tests__/app-cli.test.js +80 -5
  8. package/dist/__tests__/cli-bin-smoke.test.js +43 -0
  9. package/dist/__tests__/cli-builder.test.js +93 -15
  10. package/dist/__tests__/cli-process-service.test.js +162 -0
  11. package/dist/__tests__/cli-utils.test.js +31 -0
  12. package/dist/__tests__/e2e.test.js +79 -52
  13. package/dist/__tests__/mcp-contract.test.js +162 -0
  14. package/dist/__tests__/parsers.test.js +224 -1
  15. package/dist/__tests__/peek.test.js +35 -0
  16. package/dist/__tests__/process-management.test.js +160 -1
  17. package/dist/__tests__/server.test.js +39 -9
  18. package/dist/__tests__/utils/opencode-mock.js +91 -0
  19. package/dist/__tests__/validation.test.js +40 -2
  20. package/dist/app/cli.js +47 -5
  21. package/dist/app/mcp.js +53 -4
  22. package/dist/cli-builder.js +67 -28
  23. package/dist/cli-parse.js +11 -5
  24. package/dist/cli-process-service.js +241 -20
  25. package/dist/cli-utils.js +14 -23
  26. package/dist/cli.js +6 -4
  27. package/dist/model-catalog.js +13 -1
  28. package/dist/parsers.js +242 -28
  29. package/dist/peek.js +56 -0
  30. package/dist/process-result.js +9 -2
  31. package/dist/process-service.js +103 -17
  32. package/dist/server.js +1 -2
  33. package/package.json +9 -6
  34. package/src/__tests__/app-cli.test.ts +95 -4
  35. package/src/__tests__/cli-bin-smoke.test.ts +62 -1
  36. package/src/__tests__/cli-builder.test.ts +111 -15
  37. package/src/__tests__/cli-process-service.test.ts +180 -0
  38. package/src/__tests__/cli-utils.test.ts +34 -0
  39. package/src/__tests__/e2e.test.ts +87 -55
  40. package/src/__tests__/mcp-contract.test.ts +188 -0
  41. package/src/__tests__/parsers.test.ts +260 -1
  42. package/src/__tests__/peek.test.ts +43 -0
  43. package/src/__tests__/process-management.test.ts +185 -1
  44. package/src/__tests__/server.test.ts +49 -13
  45. package/src/__tests__/utils/opencode-mock.ts +108 -0
  46. package/src/__tests__/validation.test.ts +48 -2
  47. package/src/app/cli.ts +52 -4
  48. package/src/app/mcp.ts +54 -4
  49. package/src/cli-builder.ts +91 -32
  50. package/src/cli-parse.ts +11 -5
  51. package/src/cli-process-service.ts +304 -17
  52. package/src/cli-utils.ts +37 -33
  53. package/src/cli.ts +6 -4
  54. package/src/model-catalog.ts +24 -1
  55. package/src/parsers.ts +299 -33
  56. package/src/peek.ts +88 -0
  57. package/src/process-result.ts +11 -2
  58. package/src/process-service.ts +134 -15
  59. package/src/server.ts +2 -2
  60. package/vitest.config.unit.ts +2 -3
@@ -20,6 +20,7 @@ export const GEMINI_MODELS = [
20
20
  'gemini-3-flash-preview',
21
21
  ];
22
22
  export const FORGE_MODELS = ['forge'];
23
+ export const OPENCODE_MODELS = ['opencode'];
23
24
  export const MODEL_ALIASES = {
24
25
  'claude-ultra': 'opus',
25
26
  'codex-ultra': 'gpt-5.4',
@@ -37,10 +38,12 @@ export function getSupportedModelsDescription() {
37
38
  ...CODEX_MODELS.map((model) => `"${model}"`),
38
39
  ...GEMINI_MODELS.map((model) => `"${model}"`),
39
40
  ...FORGE_MODELS.map((model) => `"${model}"`),
41
+ ...OPENCODE_MODELS.map((model) => `"${model}"`),
42
+ '"oc-<provider/model>"',
40
43
  ].join(', ');
41
44
  }
42
45
  export function getModelParameterDescription() {
43
- return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS].map((model) => `"${model}"`).join(', ')}. "forge" is a provider key, not a Forge model family selector.`;
46
+ return `The model to use. Aliases: "claude-ultra" (auto high effort), "codex-ultra" (auto xhigh reasoning), "gemini-ultra". Standard: ${[...CLAUDE_MODELS, ...CODEX_MODELS, ...GEMINI_MODELS, ...FORGE_MODELS, ...OPENCODE_MODELS].map((model) => `"${model}"`).join(', ')}. OpenCode also accepts explicit dynamic models using "oc-<provider/model>". "forge" is a provider key, not a Forge model family selector.`;
44
47
  }
45
48
  export function getModelsPayload() {
46
49
  return {
@@ -49,5 +52,14 @@ export function getModelsPayload() {
49
52
  codex: CODEX_MODELS,
50
53
  gemini: GEMINI_MODELS,
51
54
  forge: FORGE_MODELS,
55
+ opencode: OPENCODE_MODELS,
56
+ dynamicModelBackends: {
57
+ opencode: {
58
+ explicitPrefix: 'oc-',
59
+ explicitPattern: 'oc-<provider/model>',
60
+ discoveryCommand: 'opencode models',
61
+ modelsAreDynamic: true,
62
+ },
63
+ },
52
64
  };
53
65
  }
package/dist/parsers.js CHANGED
@@ -1,7 +1,102 @@
1
1
  import { debugLog } from './cli-utils.js';
2
- /**
3
- * Parse Codex NDJSON output to extract the last agent message and token count
4
- */
2
+ function isGeminiAssistantMessageEvent(parsed) {
3
+ return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
4
+ }
5
+ const GEMINI_STREAM_EVENT_TYPES = new Set([
6
+ 'init',
7
+ 'message',
8
+ 'tool_use',
9
+ 'tool_result',
10
+ 'result',
11
+ 'error',
12
+ 'stats',
13
+ ]);
14
+ function isGeminiStreamJsonEvent(parsed) {
15
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && GEMINI_STREAM_EVENT_TYPES.has(parsed.type);
16
+ }
17
+ function extractPeekMessagesFromParsedEvent(agent, parsed, observedAt) {
18
+ if (agent === 'codex') {
19
+ if (parsed.item?.type === 'agent_message' && typeof parsed.item.text === 'string' && parsed.item.text.trim()) {
20
+ return [{ ts: observedAt, text: parsed.item.text }];
21
+ }
22
+ if (parsed.msg?.type === 'agent_message' && typeof parsed.msg.message === 'string' && parsed.msg.message.trim()) {
23
+ return [{ ts: observedAt, text: parsed.msg.message }];
24
+ }
25
+ return [];
26
+ }
27
+ if (agent === 'claude' && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
28
+ return parsed.message.content
29
+ .filter((content) => content?.type === 'text' && typeof content.text === 'string' && content.text.trim())
30
+ .map((content) => ({ ts: observedAt, text: content.text }));
31
+ }
32
+ if (agent === 'opencode' && parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string' && parsed.part.text.trim()) {
33
+ return [{ ts: observedAt, text: parsed.part.text }];
34
+ }
35
+ return [];
36
+ }
37
+ export class PeekMessageExtractor {
38
+ agent;
39
+ pending = '';
40
+ geminiAssistantBuffer = '';
41
+ constructor(agent) {
42
+ this.agent = agent;
43
+ }
44
+ push(chunk, observedAt = new Date().toISOString()) {
45
+ if (!chunk) {
46
+ return [];
47
+ }
48
+ const lines = `${this.pending}${chunk}`.split(/\r?\n/);
49
+ this.pending = lines.pop() || '';
50
+ return this.extractLines(lines, observedAt);
51
+ }
52
+ flush(observedAt = new Date().toISOString()) {
53
+ const messages = [];
54
+ if (this.pending) {
55
+ const line = this.pending;
56
+ this.pending = '';
57
+ messages.push(...this.extractLines([line], observedAt));
58
+ }
59
+ messages.push(...this.flushGeminiAssistantBuffer(observedAt));
60
+ return messages;
61
+ }
62
+ extractLines(lines, observedAt) {
63
+ const messages = [];
64
+ for (const line of lines) {
65
+ if (!line.trim()) {
66
+ continue;
67
+ }
68
+ try {
69
+ messages.push(...this.extractParsedEvent(JSON.parse(line), observedAt));
70
+ }
71
+ catch {
72
+ debugLog(`[Debug] Skipping invalid peek JSON line: ${line}`);
73
+ messages.push(...this.flushGeminiAssistantBuffer(observedAt));
74
+ }
75
+ }
76
+ return messages;
77
+ }
78
+ extractParsedEvent(parsed, observedAt) {
79
+ if (this.agent !== 'gemini') {
80
+ return extractPeekMessagesFromParsedEvent(this.agent, parsed, observedAt);
81
+ }
82
+ if (isGeminiAssistantMessageEvent(parsed)) {
83
+ this.geminiAssistantBuffer += parsed.content;
84
+ return [];
85
+ }
86
+ return this.flushGeminiAssistantBuffer(observedAt);
87
+ }
88
+ flushGeminiAssistantBuffer(observedAt) {
89
+ if (this.agent !== 'gemini' || !this.geminiAssistantBuffer) {
90
+ return [];
91
+ }
92
+ const text = this.geminiAssistantBuffer;
93
+ this.geminiAssistantBuffer = '';
94
+ if (!text.trim()) {
95
+ return [];
96
+ }
97
+ return [{ ts: observedAt, text }];
98
+ }
99
+ }
5
100
  export function parseCodexOutput(stdout) {
6
101
  if (!stdout)
7
102
  return null;
@@ -25,7 +120,6 @@ export function parseCodexOutput(stdout) {
25
120
  lastMessage = parsed.msg.message;
26
121
  }
27
122
  else if (parsed.item?.type === 'reasoning') {
28
- // Ignore reasoning-only items for message selection.
29
123
  }
30
124
  else if (parsed.msg?.type === 'token_count') {
31
125
  tokenCount = parsed.msg;
@@ -34,7 +128,7 @@ export function parseCodexOutput(stdout) {
34
128
  tools.push({
35
129
  server: parsed.item.server,
36
130
  tool: parsed.item.tool,
37
- input: parsed.item.arguments, // Map arguments to input to match common patterns
131
+ input: parsed.item.arguments,
38
132
  output: parsed.item.result
39
133
  });
40
134
  }
@@ -48,7 +142,6 @@ export function parseCodexOutput(stdout) {
48
142
  }
49
143
  }
50
144
  catch (e) {
51
- // Skip invalid JSON lines
52
145
  debugLog(`[Debug] Skipping invalid JSON line: ${line}`);
53
146
  }
54
147
  }
@@ -67,56 +160,46 @@ export function parseCodexOutput(stdout) {
67
160
  }
68
161
  return null;
69
162
  }
70
- /**
71
- * Parse Claude Output (supports both JSON and stream-json/NDJSON)
72
- */
73
163
  export function parseClaudeOutput(stdout) {
74
164
  if (!stdout)
75
165
  return null;
76
- // First try parsing as a single JSON object (backward compatibility)
77
166
  try {
78
167
  return JSON.parse(stdout);
79
168
  }
80
169
  catch (e) {
81
- // If not valid single JSON, proceed to parse as NDJSON
82
170
  }
83
171
  try {
84
172
  const lines = stdout.trim().split('\n');
85
173
  let lastMessage = null;
86
174
  let sessionId = null;
87
- const toolsMap = new Map(); // Map by tool_use id for matching results
175
+ const toolsMap = new Map();
88
176
  for (const line of lines) {
89
177
  if (!line.trim())
90
178
  continue;
91
179
  try {
92
180
  const parsed = JSON.parse(line);
93
- // Extract session ID from any message that has it
94
181
  if (parsed.session_id) {
95
182
  sessionId = parsed.session_id;
96
183
  }
97
- // Extract final result message
98
184
  if (parsed.type === 'result' && parsed.result) {
99
185
  lastMessage = parsed.result;
100
186
  }
101
- // Extract tool usage from assistant messages
102
187
  if (parsed.type === 'assistant' && parsed.message?.content) {
103
188
  for (const content of parsed.message.content) {
104
189
  if (content.type === 'tool_use') {
105
190
  toolsMap.set(content.id, {
106
191
  tool: content.name,
107
192
  input: content.input,
108
- output: null // Will be filled when tool_result is found
193
+ output: null
109
194
  });
110
195
  }
111
196
  }
112
197
  }
113
- // Match tool results from user messages
114
198
  if (parsed.type === 'user' && parsed.message?.content) {
115
199
  for (const content of parsed.message.content) {
116
200
  if (content.type === 'tool_result' && content.tool_use_id) {
117
201
  const tool = toolsMap.get(content.tool_use_id);
118
202
  if (tool) {
119
- // Extract text from content array
120
203
  if (Array.isArray(content.content)) {
121
204
  const textContent = content.content.find((c) => c.type === 'text');
122
205
  tool.output = textContent?.text || null;
@@ -133,11 +216,10 @@ export function parseClaudeOutput(stdout) {
133
216
  debugLog(`[Debug] Skipping invalid JSON line in Claude output: ${line}`);
134
217
  }
135
218
  }
136
- // Convert Map to array
137
219
  const tools = Array.from(toolsMap.values());
138
220
  if (lastMessage || sessionId || tools.length > 0) {
139
221
  return {
140
- message: lastMessage, // This is the final result text
222
+ message: lastMessage,
141
223
  session_id: sessionId,
142
224
  tools: tools.length > 0 ? tools : undefined
143
225
  };
@@ -149,23 +231,102 @@ export function parseClaudeOutput(stdout) {
149
231
  }
150
232
  return null;
151
233
  }
152
- /**
153
- * Parse Gemini JSON output
154
- */
155
234
  export function parseGeminiOutput(stdout) {
156
235
  if (!stdout)
157
236
  return null;
158
237
  try {
159
- return JSON.parse(stdout);
238
+ const parsed = JSON.parse(stdout.trim());
239
+ if (!isGeminiStreamJsonEvent(parsed)) {
240
+ return parsed;
241
+ }
160
242
  }
161
243
  catch (e) {
162
244
  debugLog(`[Debug] Failed to parse Gemini JSON output: ${e}`);
163
- return null;
164
245
  }
246
+ let sessionId = null;
247
+ let assistantBuffer = '';
248
+ let lastMessage = null;
249
+ let stats = null;
250
+ const toolsById = new Map();
251
+ const toolsWithoutId = [];
252
+ const flushAssistantMessage = () => {
253
+ if (assistantBuffer.trim()) {
254
+ lastMessage = assistantBuffer;
255
+ }
256
+ assistantBuffer = '';
257
+ };
258
+ for (const line of stdout.split('\n')) {
259
+ if (!line.trim()) {
260
+ continue;
261
+ }
262
+ let parsed;
263
+ try {
264
+ parsed = JSON.parse(line);
265
+ }
266
+ catch (e) {
267
+ debugLog(`[Debug] Skipping invalid Gemini stream-json line: ${line}`);
268
+ flushAssistantMessage();
269
+ continue;
270
+ }
271
+ if (parsed.type === 'init' && typeof parsed.session_id === 'string' && parsed.session_id) {
272
+ sessionId = parsed.session_id;
273
+ continue;
274
+ }
275
+ if (isGeminiAssistantMessageEvent(parsed)) {
276
+ assistantBuffer += parsed.content;
277
+ continue;
278
+ }
279
+ flushAssistantMessage();
280
+ if (parsed.type === 'result') {
281
+ if (parsed.stats) {
282
+ stats = parsed.stats;
283
+ }
284
+ continue;
285
+ }
286
+ if (parsed.type === 'tool_use') {
287
+ const tool = {
288
+ tool: parsed.tool_name || parsed.name || 'tool_use',
289
+ input: parsed.parameters ?? parsed.input ?? null,
290
+ output: null,
291
+ status: null,
292
+ };
293
+ if (typeof parsed.tool_id === 'string' && parsed.tool_id) {
294
+ toolsById.set(parsed.tool_id, tool);
295
+ }
296
+ else {
297
+ toolsWithoutId.push(tool);
298
+ }
299
+ continue;
300
+ }
301
+ if (parsed.type === 'tool_result') {
302
+ const toolId = typeof parsed.tool_id === 'string' ? parsed.tool_id : '';
303
+ const tool = toolId ? toolsById.get(toolId) : null;
304
+ if (tool) {
305
+ tool.output = parsed.output ?? parsed.result ?? null;
306
+ tool.status = parsed.status ?? null;
307
+ }
308
+ else {
309
+ toolsWithoutId.push({
310
+ tool: 'tool_result',
311
+ input: null,
312
+ output: parsed.output ?? parsed.result ?? null,
313
+ status: parsed.status ?? null,
314
+ });
315
+ }
316
+ }
317
+ }
318
+ flushAssistantMessage();
319
+ const tools = [...toolsById.values(), ...toolsWithoutId];
320
+ if (lastMessage || sessionId || stats || tools.length > 0) {
321
+ return {
322
+ message: lastMessage,
323
+ session_id: sessionId,
324
+ stats: stats || undefined,
325
+ tools: tools.length > 0 ? tools : undefined,
326
+ };
327
+ }
328
+ return null;
165
329
  }
166
- /**
167
- * Parse Forge output framed by Initialize/Continue/Finished markers.
168
- */
169
330
  export function parseForgeOutput(stdout) {
170
331
  if (!stdout)
171
332
  return null;
@@ -218,3 +379,56 @@ export function parseForgeOutput(stdout) {
218
379
  session_id: lastConversationId,
219
380
  };
220
381
  }
382
+ export function parseOpenCodeOutput(stdout) {
383
+ if (!stdout) {
384
+ return null;
385
+ }
386
+ let sessionId = null;
387
+ let currentStepBuffer = '';
388
+ let latestCompletedStep = null;
389
+ let hasStepFinish = false;
390
+ let hasParseableAssistantText = false;
391
+ for (const line of stdout.split('\n')) {
392
+ if (!line.trim()) {
393
+ continue;
394
+ }
395
+ let parsed;
396
+ try {
397
+ parsed = JSON.parse(line);
398
+ }
399
+ catch {
400
+ continue;
401
+ }
402
+ if (typeof parsed.sessionID === 'string' && parsed.sessionID) {
403
+ sessionId = parsed.sessionID;
404
+ }
405
+ if (parsed.type === 'step_start') {
406
+ currentStepBuffer = '';
407
+ continue;
408
+ }
409
+ if (parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string') {
410
+ currentStepBuffer += parsed.part.text;
411
+ hasParseableAssistantText = true;
412
+ continue;
413
+ }
414
+ if (parsed.type === 'step_finish') {
415
+ hasStepFinish = true;
416
+ latestCompletedStep = {
417
+ message: currentStepBuffer,
418
+ session_id: sessionId || undefined,
419
+ tokens: parsed.part?.tokens,
420
+ cost: parsed.part?.cost,
421
+ };
422
+ }
423
+ }
424
+ if (hasStepFinish && latestCompletedStep) {
425
+ return latestCompletedStep;
426
+ }
427
+ if (hasParseableAssistantText) {
428
+ return {
429
+ message: currentStepBuffer,
430
+ session_id: sessionId || undefined,
431
+ };
432
+ }
433
+ return null;
434
+ }
package/dist/peek.js ADDED
@@ -0,0 +1,56 @@
1
+ export const DEFAULT_PEEK_TIME_SEC = 10;
2
+ export const MAX_PEEK_TIME_SEC = 60;
3
+ export const MAX_PEEK_PIDS = 32;
4
+ export const PEEK_MESSAGE_CAP = 50;
5
+ export function validatePeekPids(value) {
6
+ if (!Array.isArray(value)) {
7
+ throw new Error('Missing or invalid required parameter: pids (must be an array of positive safe integers)');
8
+ }
9
+ const deduped = [];
10
+ const seen = new Set();
11
+ for (const pid of value) {
12
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
13
+ throw new Error('All pids must be positive safe integers');
14
+ }
15
+ if (!seen.has(pid)) {
16
+ seen.add(pid);
17
+ deduped.push(pid);
18
+ }
19
+ }
20
+ if (deduped.length === 0 || deduped.length > MAX_PEEK_PIDS) {
21
+ throw new Error(`pids must contain 1..${MAX_PEEK_PIDS} entries after dedupe`);
22
+ }
23
+ return deduped;
24
+ }
25
+ export function validatePeekTimeSec(value) {
26
+ if (value === undefined || value === null) {
27
+ return DEFAULT_PEEK_TIME_SEC;
28
+ }
29
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > MAX_PEEK_TIME_SEC) {
30
+ throw new Error(`peek_time_sec must be a positive integer no greater than ${MAX_PEEK_TIME_SEC}`);
31
+ }
32
+ return value;
33
+ }
34
+ export function buildNotFoundPeekProcess(pid) {
35
+ return {
36
+ pid,
37
+ agent: null,
38
+ status: 'not_found',
39
+ messages: [],
40
+ truncated: false,
41
+ error: 'process not found',
42
+ };
43
+ }
44
+ export function appendPeekMessages(target, messages) {
45
+ for (const message of messages) {
46
+ if (target.messages.length < PEEK_MESSAGE_CAP) {
47
+ target.messages.push(message);
48
+ }
49
+ else {
50
+ target.truncated = true;
51
+ }
52
+ }
53
+ }
54
+ export function observedDurationSec(startedAtMs, endedAtMs = Date.now()) {
55
+ return Number(((endedAtMs - startedAtMs) / 1000).toFixed(2));
56
+ }
@@ -23,6 +23,9 @@ function hasMeaningfulParsedOutput(agentOutput) {
23
23
  return true;
24
24
  });
25
25
  }
26
+ function shouldPreserveRawFailureOutput(context) {
27
+ return context.agent === 'opencode' && context.status === 'failed';
28
+ }
26
29
  export function buildProcessResult(context, agentOutput, verbose = false) {
27
30
  const response = {
28
31
  pid: context.pid,
@@ -40,12 +43,16 @@ export function buildProcessResult(context, agentOutput, verbose = false) {
40
43
  response.session_id = agentOutput.session_id;
41
44
  }
42
45
  const shapedAgentOutput = verbose ? agentOutput : compactAgentOutput(agentOutput);
43
- if (hasMeaningfulParsedOutput(shapedAgentOutput)) {
46
+ const preserveRawFailureOutput = shouldPreserveRawFailureOutput(context);
47
+ if (hasMeaningfulParsedOutput(shapedAgentOutput) && (verbose || !preserveRawFailureOutput)) {
44
48
  response.agentOutput = shapedAgentOutput;
45
49
  }
46
- if (!response.agentOutput) {
50
+ if (!response.agentOutput || preserveRawFailureOutput) {
47
51
  response.stdout = context.stdout;
48
52
  response.stderr = context.stderr;
49
53
  }
54
+ if (verbose && preserveRawFailureOutput && hasMeaningfulParsedOutput(shapedAgentOutput)) {
55
+ response.agentOutput = shapedAgentOutput;
56
+ }
50
57
  return response;
51
58
  }
@@ -1,7 +1,29 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { buildCliCommand } from './cli-builder.js';
3
- import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput } from './parsers.js';
3
+ import { parseClaudeOutput, parseCodexOutput, parseForgeOutput, parseGeminiOutput, parseOpenCodeOutput, PeekMessageExtractor } from './parsers.js';
4
+ import { appendPeekMessages, buildNotFoundPeekProcess, observedDurationSec, validatePeekPids, validatePeekTimeSec, } from './peek.js';
4
5
  import { buildProcessResult } from './process-result.js';
6
+ function parseAgentOutput(agent, stdout, stderr) {
7
+ if (agent === 'codex') {
8
+ return parseCodexOutput(`${stdout || ''}\n${stderr || ''}`);
9
+ }
10
+ if (!stdout) {
11
+ return null;
12
+ }
13
+ if (agent === 'claude') {
14
+ return parseClaudeOutput(stdout);
15
+ }
16
+ if (agent === 'gemini') {
17
+ return parseGeminiOutput(stdout);
18
+ }
19
+ if (agent === 'forge') {
20
+ return parseForgeOutput(stdout);
21
+ }
22
+ if (agent === 'opencode') {
23
+ return parseOpenCodeOutput(stdout);
24
+ }
25
+ return null;
26
+ }
5
27
  export class ProcessService {
6
28
  processManager = new Map();
7
29
  cliPaths;
@@ -85,22 +107,7 @@ export class ProcessService {
85
107
  if (!process) {
86
108
  throw new Error(`Process with PID ${pid} not found`);
87
109
  }
88
- let agentOutput = null;
89
- if (process.toolType === 'codex') {
90
- const combinedOutput = (process.stdout || '') + '\n' + (process.stderr || '');
91
- agentOutput = parseCodexOutput(combinedOutput);
92
- }
93
- else if (process.stdout) {
94
- if (process.toolType === 'claude') {
95
- agentOutput = parseClaudeOutput(process.stdout);
96
- }
97
- else if (process.toolType === 'gemini') {
98
- agentOutput = parseGeminiOutput(process.stdout);
99
- }
100
- else if (process.toolType === 'forge') {
101
- agentOutput = parseForgeOutput(process.stdout);
102
- }
103
- }
110
+ const agentOutput = parseAgentOutput(process.toolType, process.stdout, process.stderr);
104
111
  return buildProcessResult({
105
112
  pid,
106
113
  agent: process.toolType,
@@ -149,6 +156,85 @@ export class ProcessService {
149
156
  }
150
157
  }
151
158
  }
159
+ async peekProcesses(pids, peekTimeSec = 10) {
160
+ const targetPids = validatePeekPids(pids);
161
+ const targetPeekTimeSec = validatePeekTimeSec(peekTimeSec);
162
+ const processes = [];
163
+ const observers = [];
164
+ for (const pid of targetPids) {
165
+ const entry = this.processManager.get(pid);
166
+ if (!entry) {
167
+ processes.push(buildNotFoundPeekProcess(pid));
168
+ continue;
169
+ }
170
+ const result = {
171
+ pid,
172
+ agent: entry.toolType,
173
+ status: entry.status,
174
+ messages: [],
175
+ truncated: false,
176
+ error: null,
177
+ };
178
+ processes.push(result);
179
+ const stdoutExtractor = new PeekMessageExtractor(entry.toolType);
180
+ const stderrExtractor = new PeekMessageExtractor(entry.toolType);
181
+ const onStdout = (data) => {
182
+ appendPeekMessages(result, stdoutExtractor.push(data.toString(), new Date().toISOString()));
183
+ };
184
+ const onStderr = (data) => {
185
+ appendPeekMessages(result, stderrExtractor.push(data.toString(), new Date().toISOString()));
186
+ };
187
+ if (entry.status === 'running') {
188
+ entry.process.stdout?.on('data', onStdout);
189
+ entry.process.stderr?.on('data', onStderr);
190
+ }
191
+ observers.push({ entry, result, stdoutExtractor, stderrExtractor, onStdout, onStderr });
192
+ }
193
+ const startedAt = new Date();
194
+ const startedAtMs = Date.now();
195
+ const runningObservers = observers.filter((observer) => observer.entry.status === 'running');
196
+ const terminalPromise = Promise.all(runningObservers.map((observer) => this.waitForProcessTerminal(observer.entry)));
197
+ let timeoutHandle;
198
+ const timeoutPromise = new Promise((resolve) => {
199
+ timeoutHandle = setTimeout(resolve, targetPeekTimeSec * 1000);
200
+ timeoutHandle.unref?.();
201
+ });
202
+ try {
203
+ await Promise.race([terminalPromise, timeoutPromise]);
204
+ }
205
+ finally {
206
+ if (timeoutHandle) {
207
+ clearTimeout(timeoutHandle);
208
+ }
209
+ const flushTs = new Date().toISOString();
210
+ for (const observer of observers) {
211
+ observer.entry.process.stdout?.off('data', observer.onStdout);
212
+ observer.entry.process.stderr?.off('data', observer.onStderr);
213
+ appendPeekMessages(observer.result, observer.stdoutExtractor.flush(flushTs));
214
+ appendPeekMessages(observer.result, observer.stderrExtractor.flush(flushTs));
215
+ observer.result.status = observer.entry.status;
216
+ }
217
+ }
218
+ return {
219
+ peek_started_at: startedAt.toISOString(),
220
+ observed_duration_sec: observedDurationSec(startedAtMs),
221
+ processes,
222
+ };
223
+ }
224
+ waitForProcessTerminal(processEntry) {
225
+ if (processEntry.status !== 'running') {
226
+ return Promise.resolve();
227
+ }
228
+ return new Promise((resolve) => {
229
+ const done = () => {
230
+ processEntry.process.off('close', done);
231
+ processEntry.process.off('error', done);
232
+ resolve();
233
+ };
234
+ processEntry.process.once('close', done);
235
+ processEntry.process.once('error', done);
236
+ });
237
+ }
152
238
  killProcess(pid) {
153
239
  const processEntry = this.processManager.get(pid);
154
240
  if (!processEntry) {
package/dist/server.js CHANGED
@@ -1,5 +1,4 @@
1
- #!/usr/bin/env node
2
- export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli } from './cli-utils.js';
1
+ export { debugLog, findClaudeCli, findCodexCli, findForgeCli, findGeminiCli, findOpencodeCli } from './cli-utils.js';
3
2
  export { resolveModelAlias } from './cli-builder.js';
4
3
  export { ClaudeCodeServer, runMcpServer, spawnAsync } from './app/mcp.js';
5
4
  import { runMcpServer } from './app/mcp.js';
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "ai-cli-mcp",
3
- "version": "2.14.1",
3
+ "version": "2.16.0",
4
4
  "mcpName": "io.github.mkXultra/ai-cli-mcp",
5
- "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, and Forge) with background process management",
5
+ "description": "MCP server for AI CLI tools (Claude, Codex, Gemini, Forge, and OpenCode) with background process management",
6
6
  "author": "mkXultra",
7
7
  "license": "MIT",
8
8
  "main": "dist/server.js",
@@ -25,21 +25,24 @@
25
25
  "prepare": "husky"
26
26
  },
27
27
  "dependencies": {
28
- "@modelcontextprotocol/sdk": "^1.11.2",
28
+ "@modelcontextprotocol/sdk": "^1.29.0",
29
29
  "zod": "^3.24.4"
30
30
  },
31
+ "engines": {
32
+ "node": "^20.19.0 || >=22.12.0"
33
+ },
31
34
  "type": "module",
32
35
  "devDependencies": {
33
36
  "@eslint/js": "^9.26.0",
34
37
  "@semantic-release/changelog": "^6.0.3",
35
38
  "@semantic-release/git": "^10.0.1",
36
39
  "@types/node": "^22.15.17",
37
- "@vitest/coverage-v8": "^2.1.8",
40
+ "@vitest/coverage-v8": "^4.1.3",
38
41
  "husky": "^9.1.7",
39
- "semantic-release": "^25.0.2",
42
+ "semantic-release": "^25.0.3",
40
43
  "tsx": "^4.19.4",
41
44
  "typescript": "^5.8.3",
42
- "vitest": "^2.1.8"
45
+ "vitest": "^4.1.3"
43
46
  },
44
47
  "repository": {
45
48
  "type": "git",