@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.
- package/CHANGELOG.md +6 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/agent.prompts.lib.mjs +25 -37
- package/src/architecture-care.prompts.lib.mjs +11 -11
- package/src/claude.prompts.lib.mjs +31 -46
- package/src/codex.lib.mjs +481 -100
- package/src/codex.options.lib.mjs +52 -0
- package/src/codex.prompts.lib.mjs +84 -39
- package/src/experiments-examples.prompts.lib.mjs +7 -7
- package/src/hive.bootstrap.lib.mjs +32 -0
- package/src/hive.config.lib.mjs +3 -3
- package/src/hive.mjs +13 -20
- package/src/interactive-mode.lib.mjs +200 -265
- package/src/interactive-mode.shared.lib.mjs +133 -0
- package/src/limits.lib.mjs +339 -2
- package/src/models/index.mjs +21 -12
- package/src/opencode.prompts.lib.mjs +26 -38
- package/src/queue-config.lib.mjs +6 -0
- package/src/solve.auto-continue.lib.mjs +1 -0
- package/src/solve.bootstrap.lib.mjs +39 -0
- package/src/solve.config.lib.mjs +11 -11
- package/src/solve.mjs +35 -40
- package/src/solve.progress-monitoring.lib.mjs +10 -2
- package/src/solve.restart-shared.lib.mjs +13 -1
- package/src/solve.results.lib.mjs +43 -5
- package/src/solve.validation.lib.mjs +1 -1
- package/src/telegram-bot.mjs +4 -2
- package/src/telegram-solve-queue.helpers.lib.mjs +151 -0
- package/src/telegram-solve-queue.lib.mjs +82 -181
- package/src/version-info.lib.mjs +8 -5
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Interactive Mode Library
|
|
4
4
|
*
|
|
5
|
-
* [EXPERIMENTAL] This module provides real-time PR comment updates during
|
|
6
|
-
* It parses Claude
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
+
};
|