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
  ] as const;
22
22
  export const FORGE_MODELS = ['forge'] as const;
23
+ export const OPENCODE_MODELS = ['opencode'] as const;
23
24
 
24
25
  export const MODEL_ALIASES: Record<string, string> = {
25
26
  'claude-ultra': 'opus',
@@ -33,6 +34,13 @@ export const MODEL_ALIAS_DETAILS = [
33
34
  { name: 'gemini-ultra', resolvesTo: 'gemini-3.1-pro-preview', agent: 'gemini' },
34
35
  ] as const;
35
36
 
37
+ export interface DynamicModelBackendDescription {
38
+ explicitPrefix: string;
39
+ explicitPattern: string;
40
+ discoveryCommand: string;
41
+ modelsAreDynamic: boolean;
42
+ }
43
+
36
44
  export function getSupportedModelsDescription(): string {
37
45
  return [
38
46
  '"claude-ultra", "codex-ultra", "gemini-ultra"',
@@ -40,11 +48,13 @@ export function getSupportedModelsDescription(): string {
40
48
  ...CODEX_MODELS.map((model) => `"${model}"`),
41
49
  ...GEMINI_MODELS.map((model) => `"${model}"`),
42
50
  ...FORGE_MODELS.map((model) => `"${model}"`),
51
+ ...OPENCODE_MODELS.map((model) => `"${model}"`),
52
+ '"oc-<provider/model>"',
43
53
  ].join(', ');
44
54
  }
45
55
 
46
56
  export function getModelParameterDescription(): string {
47
- 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.`;
57
+ 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.`;
48
58
  }
49
59
 
50
60
  export function getModelsPayload(): {
@@ -53,6 +63,10 @@ export function getModelsPayload(): {
53
63
  codex: ReadonlyArray<string>;
54
64
  gemini: ReadonlyArray<string>;
55
65
  forge: ReadonlyArray<string>;
66
+ opencode: ReadonlyArray<string>;
67
+ dynamicModelBackends: {
68
+ opencode: DynamicModelBackendDescription;
69
+ };
56
70
  } {
57
71
  return {
58
72
  aliases: MODEL_ALIAS_DETAILS,
@@ -60,5 +74,14 @@ export function getModelsPayload(): {
60
74
  codex: CODEX_MODELS,
61
75
  gemini: GEMINI_MODELS,
62
76
  forge: FORGE_MODELS,
77
+ opencode: OPENCODE_MODELS,
78
+ dynamicModelBackends: {
79
+ opencode: {
80
+ explicitPrefix: 'oc-',
81
+ explicitPattern: 'oc-<provider/model>',
82
+ discoveryCommand: 'opencode models',
83
+ modelsAreDynamic: true,
84
+ },
85
+ },
63
86
  };
64
87
  }
package/src/parsers.ts CHANGED
@@ -1,18 +1,141 @@
1
1
  import { debugLog } from './cli-utils.js';
2
2
 
3
- /**
4
- * Parse Codex NDJSON output to extract the last agent message and token count
5
- */
3
+ export interface PeekMessage {
4
+ ts: string;
5
+ text: string;
6
+ }
7
+
8
+ type PeekAgent = 'claude' | 'codex' | string | null;
9
+
10
+ function isGeminiAssistantMessageEvent(parsed: any): boolean {
11
+ return parsed.type === 'message' && parsed.role === 'assistant' && typeof parsed.content === 'string';
12
+ }
13
+
14
+ const GEMINI_STREAM_EVENT_TYPES = new Set([
15
+ 'init',
16
+ 'message',
17
+ 'tool_use',
18
+ 'tool_result',
19
+ 'result',
20
+ 'error',
21
+ 'stats',
22
+ ]);
23
+
24
+ function isGeminiStreamJsonEvent(parsed: any): boolean {
25
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) && GEMINI_STREAM_EVENT_TYPES.has(parsed.type);
26
+ }
27
+
28
+ function extractPeekMessagesFromParsedEvent(agent: PeekAgent, parsed: any, observedAt: string): PeekMessage[] {
29
+ if (agent === 'codex') {
30
+ if (parsed.item?.type === 'agent_message' && typeof parsed.item.text === 'string' && parsed.item.text.trim()) {
31
+ return [{ ts: observedAt, text: parsed.item.text }];
32
+ }
33
+ if (parsed.msg?.type === 'agent_message' && typeof parsed.msg.message === 'string' && parsed.msg.message.trim()) {
34
+ return [{ ts: observedAt, text: parsed.msg.message }];
35
+ }
36
+ return [];
37
+ }
38
+
39
+ if (agent === 'claude' && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
40
+ return parsed.message.content
41
+ .filter((content: any) => content?.type === 'text' && typeof content.text === 'string' && content.text.trim())
42
+ .map((content: any) => ({ ts: observedAt, text: content.text }));
43
+ }
44
+
45
+ if (agent === 'opencode' && parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string' && parsed.part.text.trim()) {
46
+ return [{ ts: observedAt, text: parsed.part.text }];
47
+ }
48
+
49
+ return [];
50
+ }
51
+
52
+ export class PeekMessageExtractor {
53
+ private pending = '';
54
+ private geminiAssistantBuffer = '';
55
+
56
+ constructor(private readonly agent: PeekAgent) {}
57
+
58
+ push(chunk: string, observedAt = new Date().toISOString()): PeekMessage[] {
59
+ if (!chunk) {
60
+ return [];
61
+ }
62
+
63
+ const lines = `${this.pending}${chunk}`.split(/\r?\n/);
64
+ this.pending = lines.pop() || '';
65
+ return this.extractLines(lines, observedAt);
66
+ }
67
+
68
+ flush(observedAt = new Date().toISOString()): PeekMessage[] {
69
+ const messages: PeekMessage[] = [];
70
+
71
+ if (this.pending) {
72
+ const line = this.pending;
73
+ this.pending = '';
74
+ messages.push(...this.extractLines([line], observedAt));
75
+ }
76
+
77
+ messages.push(...this.flushGeminiAssistantBuffer(observedAt));
78
+ return messages;
79
+ }
80
+
81
+ private extractLines(lines: string[], observedAt: string): PeekMessage[] {
82
+ const messages: PeekMessage[] = [];
83
+
84
+ for (const line of lines) {
85
+ if (!line.trim()) {
86
+ continue;
87
+ }
88
+
89
+ try {
90
+ messages.push(...this.extractParsedEvent(JSON.parse(line), observedAt));
91
+ } catch {
92
+ debugLog(`[Debug] Skipping invalid peek JSON line: ${line}`);
93
+ messages.push(...this.flushGeminiAssistantBuffer(observedAt));
94
+ }
95
+ }
96
+
97
+ return messages;
98
+ }
99
+
100
+ private extractParsedEvent(parsed: any, observedAt: string): PeekMessage[] {
101
+ if (this.agent !== 'gemini') {
102
+ return extractPeekMessagesFromParsedEvent(this.agent, parsed, observedAt);
103
+ }
104
+
105
+ if (isGeminiAssistantMessageEvent(parsed)) {
106
+ this.geminiAssistantBuffer += parsed.content;
107
+ return [];
108
+ }
109
+
110
+ return this.flushGeminiAssistantBuffer(observedAt);
111
+ }
112
+
113
+ private flushGeminiAssistantBuffer(observedAt: string): PeekMessage[] {
114
+ if (this.agent !== 'gemini' || !this.geminiAssistantBuffer) {
115
+ return [];
116
+ }
117
+
118
+ const text = this.geminiAssistantBuffer;
119
+ this.geminiAssistantBuffer = '';
120
+
121
+ if (!text.trim()) {
122
+ return [];
123
+ }
124
+
125
+ return [{ ts: observedAt, text }];
126
+ }
127
+ }
128
+
6
129
  export function parseCodexOutput(stdout: string): any {
7
130
  if (!stdout) return null;
8
-
131
+
9
132
  try {
10
133
  const lines = stdout.trim().split('\n');
11
134
  let lastMessage = null;
12
135
  let tokenCount = null;
13
136
  let threadId = null;
14
137
  const tools: any[] = [];
15
-
138
+
16
139
  for (const line of lines) {
17
140
  if (line.trim()) {
18
141
  try {
@@ -24,14 +147,13 @@ export function parseCodexOutput(stdout: string): any {
24
147
  } else if (parsed.msg?.type === 'agent_message') {
25
148
  lastMessage = parsed.msg.message;
26
149
  } else if (parsed.item?.type === 'reasoning') {
27
- // Ignore reasoning-only items for message selection.
28
150
  } else if (parsed.msg?.type === 'token_count') {
29
151
  tokenCount = parsed.msg;
30
152
  } else if (parsed.type === 'item.completed' && parsed.item?.type === 'mcp_tool_call') {
31
153
  tools.push({
32
154
  server: parsed.item.server,
33
155
  tool: parsed.item.tool,
34
- input: parsed.item.arguments, // Map arguments to input to match common patterns
156
+ input: parsed.item.arguments,
35
157
  output: parsed.item.result
36
158
  });
37
159
  } else if (parsed.type === 'item.completed' && parsed.item?.type === 'command_execution') {
@@ -43,12 +165,11 @@ export function parseCodexOutput(stdout: string): any {
43
165
  });
44
166
  }
45
167
  } catch (e) {
46
- // Skip invalid JSON lines
47
168
  debugLog(`[Debug] Skipping invalid JSON line: ${line}`);
48
169
  }
49
170
  }
50
171
  }
51
-
172
+
52
173
  if (lastMessage || tokenCount || threadId || tools.length > 0) {
53
174
  return {
54
175
  message: lastMessage,
@@ -60,28 +181,23 @@ export function parseCodexOutput(stdout: string): any {
60
181
  } catch (e) {
61
182
  debugLog(`[Debug] Failed to parse Codex NDJSON output: ${e}`);
62
183
  }
63
-
184
+
64
185
  return null;
65
186
  }
66
187
 
67
- /**
68
- * Parse Claude Output (supports both JSON and stream-json/NDJSON)
69
- */
70
188
  export function parseClaudeOutput(stdout: string): any {
71
189
  if (!stdout) return null;
72
190
 
73
- // First try parsing as a single JSON object (backward compatibility)
74
191
  try {
75
192
  return JSON.parse(stdout);
76
193
  } catch (e) {
77
- // If not valid single JSON, proceed to parse as NDJSON
78
194
  }
79
195
 
80
196
  try {
81
197
  const lines = stdout.trim().split('\n');
82
198
  let lastMessage = null;
83
199
  let sessionId = null;
84
- const toolsMap = new Map<string, any>(); // Map by tool_use id for matching results
200
+ const toolsMap = new Map<string, any>();
85
201
 
86
202
  for (const line of lines) {
87
203
  if (!line.trim()) continue;
@@ -89,36 +205,31 @@ export function parseClaudeOutput(stdout: string): any {
89
205
  try {
90
206
  const parsed = JSON.parse(line);
91
207
 
92
- // Extract session ID from any message that has it
93
208
  if (parsed.session_id) {
94
209
  sessionId = parsed.session_id;
95
210
  }
96
211
 
97
- // Extract final result message
98
212
  if (parsed.type === 'result' && parsed.result) {
99
213
  lastMessage = parsed.result;
100
214
  }
101
215
 
102
- // Extract tool usage from assistant messages
103
216
  if (parsed.type === 'assistant' && parsed.message?.content) {
104
217
  for (const content of parsed.message.content) {
105
218
  if (content.type === 'tool_use') {
106
219
  toolsMap.set(content.id, {
107
220
  tool: content.name,
108
221
  input: content.input,
109
- output: null // Will be filled when tool_result is found
222
+ output: null
110
223
  });
111
224
  }
112
225
  }
113
226
  }
114
227
 
115
- // Match tool results from user messages
116
228
  if (parsed.type === 'user' && parsed.message?.content) {
117
229
  for (const content of parsed.message.content) {
118
230
  if (content.type === 'tool_result' && content.tool_use_id) {
119
231
  const tool = toolsMap.get(content.tool_use_id);
120
232
  if (tool) {
121
- // Extract text from content array
122
233
  if (Array.isArray(content.content)) {
123
234
  const textContent = content.content.find((c: any) => c.type === 'text');
124
235
  tool.output = textContent?.text || null;
@@ -135,12 +246,11 @@ export function parseClaudeOutput(stdout: string): any {
135
246
  }
136
247
  }
137
248
 
138
- // Convert Map to array
139
249
  const tools = Array.from(toolsMap.values());
140
250
 
141
251
  if (lastMessage || sessionId || tools.length > 0) {
142
252
  return {
143
- message: lastMessage, // This is the final result text
253
+ message: lastMessage,
144
254
  session_id: sessionId,
145
255
  tools: tools.length > 0 ? tools : undefined
146
256
  };
@@ -150,27 +260,115 @@ export function parseClaudeOutput(stdout: string): any {
150
260
  debugLog(`[Debug] Failed to parse Claude NDJSON output: ${e}`);
151
261
  return null;
152
262
  }
153
-
263
+
154
264
  return null;
155
265
  }
156
266
 
157
- /**
158
- * Parse Gemini JSON output
159
- */
160
267
  export function parseGeminiOutput(stdout: string): any {
161
268
  if (!stdout) return null;
162
269
 
163
270
  try {
164
- return JSON.parse(stdout);
271
+ const parsed = JSON.parse(stdout.trim());
272
+ if (!isGeminiStreamJsonEvent(parsed)) {
273
+ return parsed;
274
+ }
165
275
  } catch (e) {
166
276
  debugLog(`[Debug] Failed to parse Gemini JSON output: ${e}`);
167
- return null;
168
277
  }
278
+
279
+ let sessionId: string | null = null;
280
+ let assistantBuffer = '';
281
+ let lastMessage: string | null = null;
282
+ let stats: any = null;
283
+ const toolsById = new Map<string, any>();
284
+ const toolsWithoutId: any[] = [];
285
+ const flushAssistantMessage = () => {
286
+ if (assistantBuffer.trim()) {
287
+ lastMessage = assistantBuffer;
288
+ }
289
+ assistantBuffer = '';
290
+ };
291
+
292
+ for (const line of stdout.split('\n')) {
293
+ if (!line.trim()) {
294
+ continue;
295
+ }
296
+
297
+ let parsed: any;
298
+ try {
299
+ parsed = JSON.parse(line);
300
+ } catch (e) {
301
+ debugLog(`[Debug] Skipping invalid Gemini stream-json line: ${line}`);
302
+ flushAssistantMessage();
303
+ continue;
304
+ }
305
+
306
+ if (parsed.type === 'init' && typeof parsed.session_id === 'string' && parsed.session_id) {
307
+ sessionId = parsed.session_id;
308
+ continue;
309
+ }
310
+
311
+ if (isGeminiAssistantMessageEvent(parsed)) {
312
+ assistantBuffer += parsed.content;
313
+ continue;
314
+ }
315
+
316
+ flushAssistantMessage();
317
+
318
+ if (parsed.type === 'result') {
319
+ if (parsed.stats) {
320
+ stats = parsed.stats;
321
+ }
322
+ continue;
323
+ }
324
+
325
+ if (parsed.type === 'tool_use') {
326
+ const tool = {
327
+ tool: parsed.tool_name || parsed.name || 'tool_use',
328
+ input: parsed.parameters ?? parsed.input ?? null,
329
+ output: null,
330
+ status: null,
331
+ };
332
+ if (typeof parsed.tool_id === 'string' && parsed.tool_id) {
333
+ toolsById.set(parsed.tool_id, tool);
334
+ } else {
335
+ toolsWithoutId.push(tool);
336
+ }
337
+ continue;
338
+ }
339
+
340
+ if (parsed.type === 'tool_result') {
341
+ const toolId = typeof parsed.tool_id === 'string' ? parsed.tool_id : '';
342
+ const tool = toolId ? toolsById.get(toolId) : null;
343
+ if (tool) {
344
+ tool.output = parsed.output ?? parsed.result ?? null;
345
+ tool.status = parsed.status ?? null;
346
+ } else {
347
+ toolsWithoutId.push({
348
+ tool: 'tool_result',
349
+ input: null,
350
+ output: parsed.output ?? parsed.result ?? null,
351
+ status: parsed.status ?? null,
352
+ });
353
+ }
354
+ }
355
+ }
356
+
357
+ flushAssistantMessage();
358
+ const tools = [...toolsById.values(), ...toolsWithoutId];
359
+
360
+ if (lastMessage || sessionId || stats || tools.length > 0) {
361
+ return {
362
+ message: lastMessage,
363
+ session_id: sessionId,
364
+ stats: stats || undefined,
365
+ tools: tools.length > 0 ? tools : undefined,
366
+ };
367
+ }
368
+
369
+ return null;
169
370
  }
170
371
 
171
- /**
172
- * Parse Forge output framed by Initialize/Continue/Finished markers.
173
- */
174
372
  export function parseForgeOutput(stdout: string): any {
175
373
  if (!stdout) return null;
176
374
 
@@ -228,3 +426,71 @@ export function parseForgeOutput(stdout: string): any {
228
426
  session_id: lastConversationId,
229
427
  };
230
428
  }
429
+
430
+ export function parseOpenCodeOutput(stdout: string): any {
431
+ if (!stdout) {
432
+ return null;
433
+ }
434
+
435
+ let sessionId: string | null = null;
436
+ let currentStepBuffer = '';
437
+ let latestCompletedStep: {
438
+ message: string;
439
+ session_id?: string;
440
+ tokens?: any;
441
+ cost?: number;
442
+ } | null = null;
443
+ let hasStepFinish = false;
444
+ let hasParseableAssistantText = false;
445
+
446
+ for (const line of stdout.split('\n')) {
447
+ if (!line.trim()) {
448
+ continue;
449
+ }
450
+
451
+ let parsed: any;
452
+ try {
453
+ parsed = JSON.parse(line);
454
+ } catch {
455
+ continue;
456
+ }
457
+
458
+ if (typeof parsed.sessionID === 'string' && parsed.sessionID) {
459
+ sessionId = parsed.sessionID;
460
+ }
461
+
462
+ if (parsed.type === 'step_start') {
463
+ currentStepBuffer = '';
464
+ continue;
465
+ }
466
+
467
+ if (parsed.type === 'text' && parsed.part?.type === 'text' && typeof parsed.part.text === 'string') {
468
+ currentStepBuffer += parsed.part.text;
469
+ hasParseableAssistantText = true;
470
+ continue;
471
+ }
472
+
473
+ if (parsed.type === 'step_finish') {
474
+ hasStepFinish = true;
475
+ latestCompletedStep = {
476
+ message: currentStepBuffer,
477
+ session_id: sessionId || undefined,
478
+ tokens: parsed.part?.tokens,
479
+ cost: parsed.part?.cost,
480
+ };
481
+ }
482
+ }
483
+
484
+ if (hasStepFinish && latestCompletedStep) {
485
+ return latestCompletedStep;
486
+ }
487
+
488
+ if (hasParseableAssistantText) {
489
+ return {
490
+ message: currentStepBuffer,
491
+ session_id: sessionId || undefined,
492
+ };
493
+ }
494
+
495
+ return null;
496
+ }
package/src/peek.ts ADDED
@@ -0,0 +1,88 @@
1
+ import type { PeekMessage } from './parsers.js';
2
+ import type { AgentType, ProcessStatus } from './process-service.js';
3
+
4
+ export const DEFAULT_PEEK_TIME_SEC = 10;
5
+ export const MAX_PEEK_TIME_SEC = 60;
6
+ export const MAX_PEEK_PIDS = 32;
7
+ export const PEEK_MESSAGE_CAP = 50;
8
+
9
+ export type PeekStatus = ProcessStatus | 'not_found';
10
+ export type PeekAgent = AgentType | string | null;
11
+
12
+ export interface PeekProcessResult {
13
+ pid: number;
14
+ agent: PeekAgent;
15
+ status: PeekStatus;
16
+ messages: PeekMessage[];
17
+ truncated: boolean;
18
+ error: string | null;
19
+ }
20
+
21
+ export interface PeekResponse {
22
+ peek_started_at: string;
23
+ observed_duration_sec: number;
24
+ processes: PeekProcessResult[];
25
+ }
26
+
27
+ export function validatePeekPids(value: unknown): number[] {
28
+ if (!Array.isArray(value)) {
29
+ throw new Error('Missing or invalid required parameter: pids (must be an array of positive safe integers)');
30
+ }
31
+
32
+ const deduped: number[] = [];
33
+ const seen = new Set<number>();
34
+
35
+ for (const pid of value) {
36
+ if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) {
37
+ throw new Error('All pids must be positive safe integers');
38
+ }
39
+
40
+ if (!seen.has(pid)) {
41
+ seen.add(pid);
42
+ deduped.push(pid);
43
+ }
44
+ }
45
+
46
+ if (deduped.length === 0 || deduped.length > MAX_PEEK_PIDS) {
47
+ throw new Error(`pids must contain 1..${MAX_PEEK_PIDS} entries after dedupe`);
48
+ }
49
+
50
+ return deduped;
51
+ }
52
+
53
+ export function validatePeekTimeSec(value: unknown): number {
54
+ if (value === undefined || value === null) {
55
+ return DEFAULT_PEEK_TIME_SEC;
56
+ }
57
+
58
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0 || value > MAX_PEEK_TIME_SEC) {
59
+ throw new Error(`peek_time_sec must be a positive integer no greater than ${MAX_PEEK_TIME_SEC}`);
60
+ }
61
+
62
+ return value;
63
+ }
64
+
65
+ export function buildNotFoundPeekProcess(pid: number): PeekProcessResult {
66
+ return {
67
+ pid,
68
+ agent: null,
69
+ status: 'not_found',
70
+ messages: [],
71
+ truncated: false,
72
+ error: 'process not found',
73
+ };
74
+ }
75
+
76
+ export function appendPeekMessages(target: PeekProcessResult, messages: PeekMessage[]): void {
77
+ for (const message of messages) {
78
+ if (target.messages.length < PEEK_MESSAGE_CAP) {
79
+ target.messages.push(message);
80
+ } else {
81
+ target.truncated = true;
82
+ }
83
+ }
84
+ }
85
+
86
+ export function observedDurationSec(startedAtMs: number, endedAtMs = Date.now()): number {
87
+ return Number(((endedAtMs - startedAtMs) / 1000).toFixed(2));
88
+ }
@@ -45,6 +45,10 @@ function hasMeaningfulParsedOutput(agentOutput: any): boolean {
45
45
  });
46
46
  }
47
47
 
48
+ function shouldPreserveRawFailureOutput(context: ProcessResultContext): boolean {
49
+ return context.agent === 'opencode' && context.status === 'failed';
50
+ }
51
+
48
52
  export function buildProcessResult(context: ProcessResultContext, agentOutput: any, verbose = false): any {
49
53
  const response: any = {
50
54
  pid: context.pid,
@@ -65,15 +69,20 @@ export function buildProcessResult(context: ProcessResultContext, agentOutput: a
65
69
  }
66
70
 
67
71
  const shapedAgentOutput = verbose ? agentOutput : compactAgentOutput(agentOutput);
72
+ const preserveRawFailureOutput = shouldPreserveRawFailureOutput(context);
68
73
 
69
- if (hasMeaningfulParsedOutput(shapedAgentOutput)) {
74
+ if (hasMeaningfulParsedOutput(shapedAgentOutput) && (verbose || !preserveRawFailureOutput)) {
70
75
  response.agentOutput = shapedAgentOutput;
71
76
  }
72
77
 
73
- if (!response.agentOutput) {
78
+ if (!response.agentOutput || preserveRawFailureOutput) {
74
79
  response.stdout = context.stdout;
75
80
  response.stderr = context.stderr;
76
81
  }
77
82
 
83
+ if (verbose && preserveRawFailureOutput && hasMeaningfulParsedOutput(shapedAgentOutput)) {
84
+ response.agentOutput = shapedAgentOutput;
85
+ }
86
+
78
87
  return response;
79
88
  }