@link-assistant/hive-mind 1.50.8 → 1.50.9

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.
@@ -2,10 +2,10 @@
2
2
  /**
3
3
  * Interactive Mode Library
4
4
  *
5
- * [EXPERIMENTAL] This module provides real-time PR comment updates during Claude execution.
6
- * It parses Claude CLI's NDJSON output and posts relevant events as GitHub PR comments.
5
+ * [EXPERIMENTAL] This module provides real-time PR comment updates during tool execution.
6
+ * It parses Claude or Codex JSON output and posts relevant events as GitHub PR comments.
7
7
  *
8
- * Supported JSON event types:
8
+ * Supported Claude JSON event types:
9
9
  * - system.init: Session initialization
10
10
  * - system.task_started: Agent subtask started (Issue #1450)
11
11
  * - system.task_progress: Agent subtask progress update (Issue #1450)
@@ -32,265 +32,10 @@
32
32
  * @experimental
33
33
  */
34
34
 
35
- // Configuration constants
36
- const CONFIG = {
37
- // Minimum time between comments to avoid rate limiting (in ms)
38
- MIN_COMMENT_INTERVAL: 5000,
39
- // Maximum lines to show before truncation kicks in
40
- MAX_LINES_BEFORE_TRUNCATION: 50,
41
- // Lines to keep at start when truncating
42
- LINES_TO_KEEP_START: 20,
43
- // Lines to keep at end when truncating
44
- LINES_TO_KEEP_END: 20,
45
- // Maximum JSON depth for raw JSON display
46
- MAX_JSON_DEPTH: 10,
47
- };
48
-
49
- // Import sanitizeUnicode from the shared module so that the same logic is used
50
- // everywhere: in the interactive-mode PR-comment path and in the regular
51
- // Claude output parsing path (claude.lib.mjs).
52
- // See: https://github.com/link-assistant/hive-mind/issues/1324
53
- import { sanitizeUnicode } from './unicode-sanitization.lib.mjs';
54
-
55
- // Use child_process.spawn for stdin-based API calls to avoid shell quoting
56
- // issues with large/complex comment bodies containing backticks, quotes, etc.
57
- // IMPORTANT: We use spawn (not execFile) because promisify(execFile) silently
58
- // ignores the `input` option — only the sync variants (execFileSync, execSync,
59
- // spawnSync) support `input`. Using execFile with `input` causes `gh api --input -`
60
- // to hang forever waiting for stdin, which blocks the stream processing loop and
61
- // prevents interactive mode from working at all.
62
- // See: https://github.com/link-assistant/hive-mind/issues/1458
63
- // See: https://github.com/link-assistant/hive-mind/issues/1532
64
- import { spawn } from 'node:child_process';
65
-
66
- /**
67
- * Spawn a child process with stdin piping support.
68
- * Unlike promisify(execFile), this correctly writes `input` to the child's
69
- * stdin before closing it, so commands like `gh api --input -` work.
70
- *
71
- * @param {string} command - The command to run
72
- * @param {string[]} args - Command arguments
73
- * @param {Object} [options] - Options
74
- * @param {string} [options.input] - Data to write to stdin
75
- * @param {number} [options.maxBuffer=1048576] - Max stdout/stderr buffer size
76
- * @returns {Promise<{stdout: string, stderr: string}>}
77
- */
78
- const execFileAsync = (command, args, options = {}) => {
79
- return new Promise((resolve, reject) => {
80
- const { input, maxBuffer = 1024 * 1024, ...spawnOpts } = options;
81
- const child = spawn(command, args, { ...spawnOpts, stdio: ['pipe', 'pipe', 'pipe'] });
82
- let stdout = '';
83
- let stderr = '';
84
- let stdoutLen = 0;
85
- let stderrLen = 0;
86
- child.stdout.on('data', chunk => {
87
- const str = chunk.toString();
88
- stdoutLen += str.length;
89
- if (stdoutLen <= maxBuffer) stdout += str;
90
- });
91
- child.stderr.on('data', chunk => {
92
- const str = chunk.toString();
93
- stderrLen += str.length;
94
- if (stderrLen <= maxBuffer) stderr += str;
95
- });
96
- child.on('error', reject);
97
- child.on('close', code => {
98
- if (code !== 0) {
99
- const err = new Error(`Command failed: ${command} ${args.join(' ')}\n${stderr}`);
100
- err.code = code;
101
- err.stdout = stdout;
102
- err.stderr = stderr;
103
- reject(err);
104
- } else {
105
- resolve({ stdout, stderr });
106
- }
107
- });
108
- if (input != null) {
109
- child.stdin.write(input);
110
- child.stdin.end();
111
- } else {
112
- child.stdin.end();
113
- }
114
- });
115
- };
116
-
117
- /**
118
- * Truncate content in the middle, keeping start and end
119
- * This helps show context while reducing size for large outputs
120
- *
121
- * The result is always passed through sanitizeUnicode() so that a truncation
122
- * point that falls inside a UTF-16 surrogate pair never produces invalid JSON.
123
- * See: https://github.com/link-assistant/hive-mind/issues/1324
124
- *
125
- * @param {string} content - Content to potentially truncate
126
- * @param {Object} options - Truncation options
127
- * @param {number} [options.maxLines=50] - Maximum lines before truncation
128
- * @param {number} [options.keepStart=20] - Lines to keep at start
129
- * @param {number} [options.keepEnd=20] - Lines to keep at end
130
- * @returns {string} Truncated, Unicode-sanitized content with ellipsis indicator
131
- */
132
- const truncateMiddle = (content, options = {}) => {
133
- const { maxLines = CONFIG.MAX_LINES_BEFORE_TRUNCATION, keepStart = CONFIG.LINES_TO_KEEP_START, keepEnd = CONFIG.LINES_TO_KEEP_END } = options;
134
-
135
- if (!content || typeof content !== 'string') {
136
- return content || '';
137
- }
138
-
139
- const lines = content.split('\n');
140
- if (lines.length <= maxLines) {
141
- return sanitizeUnicode(content);
142
- }
143
-
144
- const startLines = lines.slice(0, keepStart);
145
- const endLines = lines.slice(-keepEnd);
146
- // Show the actual line number range that was omitted (1-based)
147
- const omitStart = keepStart + 1;
148
- const omitEnd = lines.length - keepEnd;
149
-
150
- return sanitizeUnicode([...startLines, '', `... [${omitStart}-${omitEnd} lines are omitted] ...`, '', ...endLines].join('\n'));
151
- };
152
-
153
- /**
154
- * Safely stringify JSON with depth limit and circular reference handling.
155
- * String values are passed through sanitizeUnicode() so that orphaned UTF-16
156
- * surrogates (which can appear after persisted-output truncation) never reach
157
- * JSON.stringify() and cause a 400 API error.
158
- *
159
- * @see https://github.com/link-assistant/hive-mind/issues/1324
160
- *
161
- * @param {any} obj - Object to stringify
162
- * @param {number} [indent=2] - Indentation spaces
163
- * @returns {string} Formatted JSON string with sanitized Unicode
164
- */
165
- const safeJsonStringify = (obj, indent = 2) => {
166
- const seen = new WeakSet();
167
- return JSON.stringify(
168
- obj,
169
- (key, value) => {
170
- if (typeof value === 'object' && value !== null) {
171
- if (seen.has(value)) {
172
- return '[Circular]';
173
- }
174
- seen.add(value);
175
- }
176
- if (typeof value === 'string') {
177
- return sanitizeUnicode(value);
178
- }
179
- return value;
180
- },
181
- indent
182
- );
183
- };
184
-
185
- /**
186
- * Create a collapsible section in GitHub markdown
187
- *
188
- * @param {string} summary - Summary text shown when collapsed
189
- * @param {string} content - Content shown when expanded
190
- * @param {boolean} [startOpen=false] - Whether to start expanded
191
- * @returns {string} GitHub markdown details block
192
- */
193
- const createCollapsible = (summary, content, startOpen = false) => {
194
- const openAttr = startOpen ? ' open' : '';
195
- return `<details${openAttr}>
196
- <summary>${summary}</summary>
197
-
198
- ${content}
199
-
200
- </details>`;
201
- };
202
-
203
- /**
204
- * Create a collapsible raw JSON section
205
- * Always wraps data in an array for consistent merging
206
- *
207
- * @param {Object|Array} data - JSON data to display (will be wrapped in array if not already)
208
- * @returns {string} Collapsible JSON block
209
- */
210
- const createRawJsonSection = data => {
211
- // Ensure data is always an array at root level for easier merging
212
- const dataArray = Array.isArray(data) ? data : [data];
213
- const jsonContent = truncateMiddle(safeJsonStringify(dataArray, 2), {
214
- maxLines: 100,
215
- keepStart: 40,
216
- keepEnd: 40,
217
- });
218
- return createCollapsible('📄 Raw JSON', '```json\n' + jsonContent + '\n```');
219
- };
220
-
221
- /**
222
- * Format duration from milliseconds to human-readable string
223
- *
224
- * @param {number} ms - Duration in milliseconds
225
- * @returns {string} Formatted duration (e.g., "12m 7s")
226
- */
227
- const formatDuration = ms => {
228
- if (!ms || ms < 0) return 'unknown';
229
-
230
- const seconds = Math.floor(ms / 1000);
231
- const minutes = Math.floor(seconds / 60);
232
- const hours = Math.floor(minutes / 60);
233
-
234
- if (hours > 0) {
235
- return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
236
- } else if (minutes > 0) {
237
- return `${minutes}m ${seconds % 60}s`;
238
- } else {
239
- return `${seconds}s`;
240
- }
241
- };
242
-
243
- /**
244
- * Format cost to USD string
245
- *
246
- * @param {number} cost - Cost in USD
247
- * @returns {string} Formatted cost (e.g., "$1.60")
248
- */
249
- const formatCost = cost => {
250
- if (typeof cost !== 'number' || isNaN(cost)) return 'unknown';
251
- return `$${cost.toFixed(2)}`;
252
- };
35
+ import { CONFIG, createCollapsible, createRawJsonSection, escapeMarkdown, execFileAsync, formatCost, formatDuration, getToolIcon, safeJsonStringify, sanitizeUnicode, truncateMiddle } from './interactive-mode.shared.lib.mjs';
253
36
 
254
37
  /**
255
- * Escape special markdown characters in text
256
- *
257
- * @param {string} text - Text to escape
258
- * @returns {string} Escaped text
259
- */
260
- const escapeMarkdown = text => {
261
- if (!text || typeof text !== 'string') return '';
262
- // Escape backticks that would break code blocks
263
- return text.replace(/```/g, '\\`\\`\\`');
264
- };
265
-
266
- /**
267
- * Get tool icon based on tool name
268
- *
269
- * @param {string} toolName - Name of the tool
270
- * @returns {string} Emoji icon
271
- */
272
- const getToolIcon = toolName => {
273
- const icons = {
274
- Bash: '💻',
275
- Read: '📖',
276
- Write: '✏️',
277
- Edit: '📝',
278
- Glob: '🔍',
279
- Grep: '🔎',
280
- WebFetch: '🌐',
281
- WebSearch: '🔍',
282
- TodoWrite: '📋',
283
- ToolSearch: '🔍',
284
- Task: '🎯',
285
- Agent: '🤖',
286
- NotebookEdit: '📓',
287
- default: '🔧',
288
- };
289
- return icons[toolName] || icons.default;
290
- };
291
-
292
- /**
293
- * Creates an interactive mode handler for processing Claude CLI events
38
+ * Creates an interactive mode handler for processing Claude/Codex CLI events
294
39
  *
295
40
  * @param {Object} options - Handler configuration
296
41
  * @param {string} options.owner - Repository owner
@@ -1238,6 +983,151 @@ ${createRawJsonSection(data)}`;
1238
983
  }
1239
984
  };
1240
985
 
986
+ const handleCodexThreadStarted = async data => {
987
+ if (state.sessionId) return;
988
+
989
+ state.sessionId = data.thread_id || data.session_id || null;
990
+ state.startTime = Date.now();
991
+
992
+ const comment = `## 🚀 Interactive session started
993
+
994
+ | Property | Value |
995
+ |----------|-------|
996
+ | **Session ID** | \`${state.sessionId || 'unknown'}\` |
997
+ | **Model** | \`${data.model || 'unknown'}\` |
998
+ | **Tool** | \`codex\` |
999
+
1000
+ ---
1001
+
1002
+ ${createRawJsonSection(data)}`;
1003
+
1004
+ await postComment(comment);
1005
+ };
1006
+
1007
+ const handleCodexAgentMessage = async data => {
1008
+ const text = data.item?.text;
1009
+ if (typeof text !== 'string' || !text.trim()) return;
1010
+ await handleAssistantText(data, text);
1011
+ };
1012
+
1013
+ const handleCodexTodoList = async data => {
1014
+ const items = Array.isArray(data.item?.items) ? data.item.items : [];
1015
+ const todosPreview = items.length > 0 ? items.map(todo => `- [${todo?.completed ? 'x' : ' '}] ${todo?.text || ''}`).join('\n') : '_No tasks_';
1016
+ const completedCount = items.filter(todo => todo?.completed).length;
1017
+
1018
+ const comment = `## 📋 Codex todo list
1019
+
1020
+ ${createCollapsible(`📋 Todos (${completedCount}/${items.length} items)`, todosPreview, true)}
1021
+
1022
+ ---
1023
+
1024
+ ${createRawJsonSection(data)}`;
1025
+
1026
+ await postComment(comment);
1027
+ };
1028
+
1029
+ const handleCodexCommandExecution = async data => {
1030
+ const item = data.item || {};
1031
+ const command = item.command || '';
1032
+ const output = item.aggregated_output || '';
1033
+ const status = item.status || (data.type === 'item.completed' ? 'completed' : data.type === 'item.updated' ? 'updated' : 'started');
1034
+ const body = `## 💻 Codex command execution
1035
+
1036
+ **Status:** \`${status}\`
1037
+ ${command ? '\n' + createCollapsible('📋 Executed command', '```bash\n' + escapeMarkdown(command) + '\n```', true) : ''}
1038
+ ${output ? '\n\n' + createCollapsible('📤 Output', '```\n' + escapeMarkdown(truncateMiddle(output, { maxLines: 60, keepStart: 25, keepEnd: 25 })) + '\n```', true) : ''}
1039
+
1040
+ ---
1041
+
1042
+ ${createRawJsonSection(data)}`;
1043
+ await postComment(body);
1044
+ };
1045
+
1046
+ const handleCodexMcpToolCall = async data => {
1047
+ const item = data.item || {};
1048
+ const summary = [`**Server:** \`${item.server || 'unknown'}\``, `**Tool:** \`${item.tool || 'unknown'}\``, `**Status:** \`${item.status || 'unknown'}\``].join('\n');
1049
+ const details = item.arguments != null ? createCollapsible('📥 Arguments', '```json\n' + safeJsonStringify(item.arguments, 2) + '\n```', true) : '';
1050
+ const resultSection = item.result != null ? '\n\n' + createCollapsible('📤 Result', '```json\n' + safeJsonStringify(item.result, 2) + '\n```', false) : '';
1051
+ const errorSection = item.error != null ? '\n\n' + createCollapsible('❌ Error', '```json\n' + safeJsonStringify(item.error, 2) + '\n```', true) : '';
1052
+
1053
+ await postComment(`## 🔌 Codex MCP tool call
1054
+
1055
+ ${summary}
1056
+ ${details}${resultSection}${errorSection}
1057
+
1058
+ ---
1059
+
1060
+ ${createRawJsonSection(data)}`);
1061
+ };
1062
+
1063
+ const handleCodexWebSearch = async data => {
1064
+ const item = data.item || {};
1065
+ await postComment(`## 🌐 Codex web search
1066
+
1067
+ **Query:** ${escapeMarkdown(item.query || 'unknown')}
1068
+ ${item.action ? `\n**Action:** \`${item.action}\`` : ''}
1069
+
1070
+ ---
1071
+
1072
+ ${createRawJsonSection(data)}`);
1073
+ };
1074
+
1075
+ const handleCodexFileChange = async data => {
1076
+ const item = data.item || {};
1077
+ const changes = Array.isArray(item.changes) ? item.changes.map(change => `- \`${change?.kind || 'change'}\` ${change?.path || ''}`).join('\n') : '_No changes listed_';
1078
+ await postComment(`## 📝 Codex file changes
1079
+
1080
+ **Status:** \`${item.status || 'unknown'}\`
1081
+ ${createCollapsible('📄 Files', changes, true)}
1082
+
1083
+ ---
1084
+
1085
+ ${createRawJsonSection(data)}`);
1086
+ };
1087
+
1088
+ const handleCodexCollabToolCall = async data => {
1089
+ const item = data.item || {};
1090
+ const prompt = item.prompt || item.description || `${item.tool || 'collab_tool_call'} via codex`;
1091
+ await postComment(`## 🤝 Codex collab/sub-agent call
1092
+
1093
+ **Tool:** \`${item.tool || 'unknown'}\`
1094
+ **Status:** \`${item.status || 'unknown'}\`
1095
+ ${createCollapsible('📝 Prompt', escapeMarkdown(truncateMiddle(prompt, { maxLines: 30, keepStart: 12, keepEnd: 12 })), true)}
1096
+
1097
+ ---
1098
+
1099
+ ${createRawJsonSection(data)}`);
1100
+ };
1101
+
1102
+ const handleCodexTurnCompleted = async data => {
1103
+ const usage = data.usage || {};
1104
+ let usageSection = '| Type | Count |\n|------|-------|\n';
1105
+ usageSection += `| Input | ${(usage.input_tokens || 0).toLocaleString()} |\n`;
1106
+ usageSection += `| Cache Read | ${(usage.cached_input_tokens || 0).toLocaleString()} |\n`;
1107
+ usageSection += `| Output | ${(usage.output_tokens || 0).toLocaleString()} |\n`;
1108
+
1109
+ await postComment(`## ✅ Codex turn completed
1110
+
1111
+ ### 📊 Token Usage
1112
+
1113
+ ${usageSection}
1114
+
1115
+ ---
1116
+
1117
+ ${createRawJsonSection(data)}`);
1118
+ };
1119
+
1120
+ const handleCodexError = async data => {
1121
+ const message = data.message || data.error?.message || 'Unknown Codex error';
1122
+ await postComment(`## ❌ Codex error
1123
+
1124
+ ${createCollapsible('View error', escapeMarkdown(message), true)}
1125
+
1126
+ ---
1127
+
1128
+ ${createRawJsonSection(data)}`);
1129
+ };
1130
+
1241
1131
  /**
1242
1132
  * Handle unrecognized event types
1243
1133
  * @param {Object} data - Event data
@@ -1260,7 +1150,7 @@ ${createRawJsonSection(data)}`;
1260
1150
  };
1261
1151
 
1262
1152
  /**
1263
- * Process a single JSON event from Claude CLI
1153
+ * Process a single JSON event from Claude or Codex CLI
1264
1154
  *
1265
1155
  * @param {Object} data - Parsed JSON object from Claude CLI output
1266
1156
  * @returns {Promise<void>}
@@ -1327,6 +1217,42 @@ ${createRawJsonSection(data)}`;
1327
1217
  await handleResult(data);
1328
1218
  break;
1329
1219
 
1220
+ case 'thread.started':
1221
+ await handleCodexThreadStarted(data);
1222
+ break;
1223
+
1224
+ case 'turn.completed':
1225
+ await handleCodexTurnCompleted(data);
1226
+ break;
1227
+
1228
+ case 'error':
1229
+ await handleCodexError(data);
1230
+ break;
1231
+
1232
+ case 'item.started':
1233
+ case 'item.updated':
1234
+ case 'item.completed': {
1235
+ const itemType = data.item?.type;
1236
+ if (itemType === 'agent_message') {
1237
+ await handleCodexAgentMessage(data);
1238
+ } else if (itemType === 'todo_list') {
1239
+ await handleCodexTodoList(data);
1240
+ } else if (itemType === 'command_execution') {
1241
+ await handleCodexCommandExecution(data);
1242
+ } else if (itemType === 'mcp_tool_call') {
1243
+ await handleCodexMcpToolCall(data);
1244
+ } else if (itemType === 'web_search') {
1245
+ await handleCodexWebSearch(data);
1246
+ } else if (itemType === 'file_change') {
1247
+ await handleCodexFileChange(data);
1248
+ } else if (itemType === 'collab_tool_call') {
1249
+ await handleCodexCollabToolCall(data);
1250
+ } else if (itemType === 'error') {
1251
+ await handleCodexError(data.item);
1252
+ }
1253
+ break;
1254
+ }
1255
+
1330
1256
  default:
1331
1257
  await handleUnrecognized(data);
1332
1258
  }
@@ -1368,6 +1294,16 @@ ${createRawJsonSection(data)}`;
1368
1294
  handleTaskNotification,
1369
1295
  handleRateLimitEvent,
1370
1296
  handleUnrecognized,
1297
+ handleCodexThreadStarted,
1298
+ handleCodexAgentMessage,
1299
+ handleCodexTodoList,
1300
+ handleCodexCommandExecution,
1301
+ handleCodexMcpToolCall,
1302
+ handleCodexWebSearch,
1303
+ handleCodexFileChange,
1304
+ handleCodexCollabToolCall,
1305
+ handleCodexTurnCompleted,
1306
+ handleCodexError,
1371
1307
  },
1372
1308
  };
1373
1309
  };
@@ -1379,8 +1315,7 @@ ${createRawJsonSection(data)}`;
1379
1315
  * @returns {boolean} Whether interactive mode is supported
1380
1316
  */
1381
1317
  export const isInteractiveModeSupported = tool => {
1382
- // Currently only supported for Claude
1383
- return tool === 'claude';
1318
+ return tool === 'claude' || tool === 'codex';
1384
1319
  };
1385
1320
 
1386
1321
  /**
@@ -1397,7 +1332,7 @@ export const validateInteractiveModeConfig = async (argv, log) => {
1397
1332
 
1398
1333
  // Check tool support
1399
1334
  if (!isInteractiveModeSupported(argv.tool)) {
1400
- await log(`⚠️ --interactive-mode is only supported for --tool claude (current: ${argv.tool})`, {
1335
+ await log(`⚠️ --interactive-mode is only supported for --tool claude and --tool codex (current: ${argv.tool})`, {
1401
1336
  level: 'warning',
1402
1337
  });
1403
1338
  await log(' Interactive mode will be disabled for this session.', { level: 'warning' });
@@ -1409,7 +1344,7 @@ export const validateInteractiveModeConfig = async (argv, log) => {
1409
1344
  // The actual PR number check happens during execution
1410
1345
 
1411
1346
  await log('🔌 Interactive mode: ENABLED (experimental)', { level: 'info' });
1412
- await log(' Claude output will be posted as PR comments in real-time.', { level: 'info' });
1347
+ await log(` ${argv.tool || 'claude'} output will be posted as PR comments in real-time.`, { level: 'info' });
1413
1348
 
1414
1349
  return true;
1415
1350
  };
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process';
4
+ import { sanitizeUnicode } from './unicode-sanitization.lib.mjs';
5
+
6
+ export { sanitizeUnicode };
7
+
8
+ export const CONFIG = {
9
+ MIN_COMMENT_INTERVAL: 5000,
10
+ MAX_LINES_BEFORE_TRUNCATION: 50,
11
+ LINES_TO_KEEP_START: 20,
12
+ LINES_TO_KEEP_END: 20,
13
+ MAX_JSON_DEPTH: 10,
14
+ };
15
+
16
+ export const execFileAsync = (command, args, options = {}) =>
17
+ new Promise((resolve, reject) => {
18
+ const { input, maxBuffer = 1024 * 1024, ...spawnOpts } = options;
19
+ const child = spawn(command, args, { ...spawnOpts, stdio: ['pipe', 'pipe', 'pipe'] });
20
+ let stdout = '';
21
+ let stderr = '';
22
+ let stdoutLen = 0;
23
+ let stderrLen = 0;
24
+
25
+ child.stdout.on('data', chunk => {
26
+ const str = chunk.toString();
27
+ stdoutLen += str.length;
28
+ if (stdoutLen <= maxBuffer) stdout += str;
29
+ });
30
+ child.stderr.on('data', chunk => {
31
+ const str = chunk.toString();
32
+ stderrLen += str.length;
33
+ if (stderrLen <= maxBuffer) stderr += str;
34
+ });
35
+ child.on('error', reject);
36
+ child.on('close', code => {
37
+ if (code !== 0) {
38
+ const err = new Error(`Command failed: ${command} ${args.join(' ')}\n${stderr}`);
39
+ err.code = code;
40
+ err.stdout = stdout;
41
+ err.stderr = stderr;
42
+ reject(err);
43
+ } else {
44
+ resolve({ stdout, stderr });
45
+ }
46
+ });
47
+
48
+ if (input != null) {
49
+ child.stdin.write(input);
50
+ }
51
+ child.stdin.end();
52
+ });
53
+
54
+ export const truncateMiddle = (content, options = {}) => {
55
+ const { maxLines = CONFIG.MAX_LINES_BEFORE_TRUNCATION, keepStart = CONFIG.LINES_TO_KEEP_START, keepEnd = CONFIG.LINES_TO_KEEP_END } = options;
56
+
57
+ if (!content || typeof content !== 'string') return content || '';
58
+
59
+ const lines = content.split('\n');
60
+ if (lines.length <= maxLines) return sanitizeUnicode(content);
61
+
62
+ const omitStart = keepStart + 1;
63
+ const omitEnd = lines.length - keepEnd;
64
+ return sanitizeUnicode([...lines.slice(0, keepStart), '', `... [${omitStart}-${omitEnd} lines are omitted] ...`, '', ...lines.slice(-keepEnd)].join('\n'));
65
+ };
66
+
67
+ export const safeJsonStringify = (obj, indent = 2) => {
68
+ const seen = new WeakSet();
69
+ return JSON.stringify(
70
+ obj,
71
+ (key, value) => {
72
+ if (typeof value === 'object' && value !== null) {
73
+ if (seen.has(value)) return '[Circular]';
74
+ seen.add(value);
75
+ }
76
+ return typeof value === 'string' ? sanitizeUnicode(value) : value;
77
+ },
78
+ indent
79
+ );
80
+ };
81
+
82
+ export const createCollapsible = (summary, content, startOpen = false) => `<details${startOpen ? ' open' : ''}>
83
+ <summary>${summary}</summary>
84
+
85
+ ${content}
86
+
87
+ </details>`;
88
+
89
+ export const createRawJsonSection = data => {
90
+ const dataArray = Array.isArray(data) ? data : [data];
91
+ const jsonContent = truncateMiddle(safeJsonStringify(dataArray, 2), {
92
+ maxLines: 100,
93
+ keepStart: 40,
94
+ keepEnd: 40,
95
+ });
96
+ return createCollapsible('📄 Raw JSON', '```json\n' + jsonContent + '\n```');
97
+ };
98
+
99
+ export const formatDuration = ms => {
100
+ if (!ms || ms < 0) return 'unknown';
101
+
102
+ const seconds = Math.floor(ms / 1000);
103
+ const minutes = Math.floor(seconds / 60);
104
+ const hours = Math.floor(minutes / 60);
105
+
106
+ if (hours > 0) return `${hours}h ${minutes % 60}m ${seconds % 60}s`;
107
+ if (minutes > 0) return `${minutes}m ${seconds % 60}s`;
108
+ return `${seconds}s`;
109
+ };
110
+
111
+ export const formatCost = cost => (typeof cost !== 'number' || isNaN(cost) ? 'unknown' : `$${cost.toFixed(2)}`);
112
+
113
+ export const escapeMarkdown = text => (!text || typeof text !== 'string' ? '' : text.replace(/```/g, '\\`\\`\\`'));
114
+
115
+ export const getToolIcon = toolName => {
116
+ const icons = {
117
+ Bash: '💻',
118
+ Read: '📖',
119
+ Write: '✏️',
120
+ Edit: '📝',
121
+ Glob: '🔍',
122
+ Grep: '🔎',
123
+ WebFetch: '🌐',
124
+ WebSearch: '🔍',
125
+ TodoWrite: '📋',
126
+ ToolSearch: '🔍',
127
+ Task: '🎯',
128
+ Agent: '🤖',
129
+ NotebookEdit: '📓',
130
+ default: '🔧',
131
+ };
132
+ return icons[toolName] || icons.default;
133
+ };