@link-assistant/hive-mind 2.11.10 → 2.11.11
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 +34 -0
- package/package.json +1 -1
- package/src/claude.lib.mjs +2 -2
- package/src/codex-health.lib.mjs +25 -0
- package/src/codex.lib.mjs +13 -43
- package/src/codex.run-diagnostics.lib.mjs +7 -0
- package/src/codex.usage-fields.lib.mjs +59 -0
- package/src/gemini.lib.mjs +1 -1
- package/src/lib.mjs +18 -3
- package/src/opencode.lib.mjs +1 -1
- package/src/qwen.lib.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.11.11
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 36e7976: Keep stream provenance on mirrored agent output and make the Codex completion
|
|
8
|
+
gate explain itself (issue #2140).
|
|
9
|
+
|
|
10
|
+
A `solve --tool codex` run that had finished its work — PR updated, all 46
|
|
11
|
+
check-runs green, `turn.completed` received — was still failed by the completion
|
|
12
|
+
gate with `turn.started=3, turn.completed=1`. Replaying the real 96k-line log
|
|
13
|
+
through the parser shows the two extra `turn.started` records arrived on Codex's
|
|
14
|
+
**stderr**, inside an OTEL `codex.tool_result` dump of a command that merely read
|
|
15
|
+
a stored NDJSON log file from disk. This is the issue #2136 defect on a binary
|
|
16
|
+
released minutes before that fix shipped; current builds already gate correctly.
|
|
17
|
+
Three residual gaps remain, and this change closes them:
|
|
18
|
+
|
|
19
|
+
- `log()` accepted an `options.stream` hint and silently discarded it, so every
|
|
20
|
+
agent CLI's mirrored output — both streams, all five tools — was written as
|
|
21
|
+
`[INFO]` on our stdout. It is now tagged `[STDOUT]` / `[STDERR]`, matching the
|
|
22
|
+
tags the stdio interceptor already uses, and mirrored stderr goes to our
|
|
23
|
+
stderr so piping stdout yields only what the child wrote there. An explicit
|
|
24
|
+
`level` still wins, and callers that pass no stream are unchanged.
|
|
25
|
+
- `codex exec` starts exactly one thread, so a `thread.started` on the protocol
|
|
26
|
+
stream announcing a different `thread_id` is proof of echoed output. Those ids
|
|
27
|
+
are now collected and reported (`🧬 Foreign thread IDs seen on the codex
|
|
28
|
+
protocol stream`).
|
|
29
|
+
- The completion-failure reason carried counts and nothing else, which made a
|
|
30
|
+
false positive impossible to refute from the posted comment. It now also
|
|
31
|
+
states the ordered turn lifecycle, how many `turn.started` records were
|
|
32
|
+
discarded as echoed telemetry, and any foreign thread id seen.
|
|
33
|
+
|
|
34
|
+
No gate outcome changes: a genuinely truncated turn still fails, and a completed
|
|
35
|
+
run with echoed events still passes.
|
|
36
|
+
|
|
3
37
|
## 2.11.10
|
|
4
38
|
|
|
5
39
|
### Patch Changes
|
package/package.json
CHANGED
package/src/claude.lib.mjs
CHANGED
|
@@ -1009,7 +1009,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1009
1009
|
}
|
|
1010
1010
|
// Not JSON or parsing failed, output as-is if it's not empty
|
|
1011
1011
|
if (line.trim() && !line.includes('node:internal')) {
|
|
1012
|
-
await log(line, { stream: '
|
|
1012
|
+
await log(line, { stream: 'stdout' });
|
|
1013
1013
|
lastMessage = line;
|
|
1014
1014
|
// Issue #1015: Detect terms acceptance prompt (non-JSON "[ACTION REQUIRED]..." message)
|
|
1015
1015
|
const termsAcceptancePattern = /\[ACTION REQUIRED\].*terms|must run.*claude.*review.*terms/i;
|
|
@@ -1080,7 +1080,7 @@ export const executeClaudeCommand = async params => {
|
|
|
1080
1080
|
}
|
|
1081
1081
|
if (progressMonitor) await progressMonitor.processStreamEvent(data, true).catch(e => log(`⚠️ Progress: ${e.message}`, { verbose: true }));
|
|
1082
1082
|
} catch {
|
|
1083
|
-
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: '
|
|
1083
|
+
if (!stdoutLineBuffer.includes('node:internal')) await log(stdoutLineBuffer, { stream: 'stdout' });
|
|
1084
1084
|
}
|
|
1085
1085
|
}
|
|
1086
1086
|
if (startupTimeoutId) {
|
package/src/codex-health.lib.mjs
CHANGED
|
@@ -269,9 +269,28 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
269
269
|
addDiskEvidence('result-summary', codexJsonState?.resultSummary);
|
|
270
270
|
const diskPressureDetected = diskEvidence.length > 0;
|
|
271
271
|
|
|
272
|
+
// Issue #2140: the counts alone made the #2136 false positive unfalsifiable —
|
|
273
|
+
// "turn.started=3, turn.completed=1" was posted to the PR with nothing to say
|
|
274
|
+
// whether those starts were codex's own. Carry the evidence that decides it:
|
|
275
|
+
// the ordered lifecycle, what was discarded as echoed telemetry, and any
|
|
276
|
+
// foreign thread id that reached the protocol stream.
|
|
277
|
+
const turnLifecycle = Array.isArray(codexJsonState?.turnLifecycle) ? codexJsonState.turnLifecycle : [];
|
|
278
|
+
const telemetryEventCounts = codexJsonState?.telemetryEventCounts || {};
|
|
279
|
+
const foreignThreadIds = codexJsonState?.foreignThreadIds || [];
|
|
280
|
+
const echoedTurnStarts = telemetryEventCounts['turn.started'] || 0;
|
|
281
|
+
|
|
272
282
|
const reasons = [];
|
|
273
283
|
if (incompleteSession) {
|
|
274
284
|
reasons.push(`Codex session ended without completing its turn (turn.started=${turnStarted}, turn.completed=${turnCompleted}, turn.failed=${turnFailed}); the process exited 0 but was cut off mid-turn.`);
|
|
285
|
+
if (turnLifecycle.length > 0) {
|
|
286
|
+
reasons.push(`Turn lifecycle in order: ${turnLifecycle.join(' → ')} — the stream ends on a start, so the last turn never finished.`);
|
|
287
|
+
}
|
|
288
|
+
if (echoedTurnStarts > 0 || foreignThreadIds.length > 0) {
|
|
289
|
+
const echoParts = [];
|
|
290
|
+
if (echoedTurnStarts > 0) echoParts.push(`${echoedTurnStarts} echoed turn.started on codex stderr (excluded from the counts above)`);
|
|
291
|
+
if (foreignThreadIds.length > 0) echoParts.push(`foreign thread id(s) on the protocol stream: ${foreignThreadIds.join(', ')}`);
|
|
292
|
+
reasons.push(`Echo diagnostics (issues #2136/#2140): ${echoParts.join('; ')}.`);
|
|
293
|
+
}
|
|
275
294
|
if (diskPressureDetected) {
|
|
276
295
|
reasons.push(`Disk-exhaustion signals were present in ${diskEvidence.length} location(s) (e.g. "No space left on device") — the likely cause of the interrupted session.`);
|
|
277
296
|
}
|
|
@@ -285,6 +304,9 @@ export const getCodexCompletionHealth = (codexJsonState, { lastMessage = '' } =
|
|
|
285
304
|
turnStarted,
|
|
286
305
|
turnCompleted,
|
|
287
306
|
turnFailed,
|
|
307
|
+
turnLifecycle,
|
|
308
|
+
echoedTurnStarts,
|
|
309
|
+
foreignThreadIds,
|
|
288
310
|
reasons,
|
|
289
311
|
};
|
|
290
312
|
};
|
|
@@ -305,6 +327,9 @@ export const reportCodexCompletionFailure = async ({ completionHealth, log, getR
|
|
|
305
327
|
await log(` • ${reason}`, { level: 'error' });
|
|
306
328
|
}
|
|
307
329
|
await log(` 📊 turn.started=${completionHealth.turnStarted}, turn.completed=${completionHealth.turnCompleted}, turn.failed=${completionHealth.turnFailed}`, { verbose: true });
|
|
330
|
+
if (completionHealth.turnLifecycle?.length) {
|
|
331
|
+
await log(` 🔁 turn lifecycle: ${completionHealth.turnLifecycle.join(' → ')}`, { verbose: true });
|
|
332
|
+
}
|
|
308
333
|
if (completionHealth.diskPressureDetected) {
|
|
309
334
|
await log(' 💽 Disk-exhaustion evidence (diagnostic):', { level: 'error' });
|
|
310
335
|
for (const evidence of completionHealth.diskEvidence.slice(0, 5)) {
|
package/src/codex.lib.mjs
CHANGED
|
@@ -43,52 +43,11 @@ import { applyCodexCapabilityEnv, runCodexCapabilityPreflight } from './codex-ca
|
|
|
43
43
|
import { createPullRequestBaseBranchCommandIntervention } from './solve.pr-base-command-intervention.lib.mjs';
|
|
44
44
|
import Decimal from 'decimal.js-light';
|
|
45
45
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
46
|
+
import { CODEX_CACHE_READ_USAGE_PATHS, CODEX_CACHE_WRITE_USAGE_PATHS, CODEX_MODEL_DIAGNOSTIC_PATHS, CODEX_REASONING_USAGE_PATHS, CODEX_USAGE_FIELD_NAMES, createCodexTokenFieldAvailability, getFirstObservedNumber, hasAnyObservedPath, hasOwnPath } from './codex.usage-fields.lib.mjs';
|
|
46
47
|
|
|
47
|
-
const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
48
48
|
const CODEX_LONG_CONTEXT_PRICE_THRESHOLD = 272000;
|
|
49
49
|
const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
|
|
50
50
|
const getCodexExecEnv = (verbose = false) => (verbose ? { ...process.env, RUST_LOG: 'debug' } : { ...process.env });
|
|
51
|
-
const CODEX_MODEL_DIAGNOSTIC_PATHS = [
|
|
52
|
-
['model', data => data?.model],
|
|
53
|
-
['model_name', data => data?.model_name],
|
|
54
|
-
['from_model', data => data?.from_model],
|
|
55
|
-
['to_model', data => data?.to_model],
|
|
56
|
-
['message.model', data => data?.message?.model],
|
|
57
|
-
];
|
|
58
|
-
|
|
59
|
-
const createCodexTokenFieldAvailability = () => ({
|
|
60
|
-
inputTokens: false,
|
|
61
|
-
outputTokens: false,
|
|
62
|
-
reasoningTokens: false,
|
|
63
|
-
cacheReadTokens: false,
|
|
64
|
-
cacheWriteTokens: false,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const hasOwnPath = (object, pathName) => {
|
|
68
|
-
let cursor = object;
|
|
69
|
-
for (const part of pathName.split('.')) {
|
|
70
|
-
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
|
|
71
|
-
cursor = cursor[part];
|
|
72
|
-
}
|
|
73
|
-
return true;
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
|
|
77
|
-
|
|
78
|
-
const getFirstObservedNumber = (object, pathNames) => {
|
|
79
|
-
for (const pathName of pathNames) {
|
|
80
|
-
if (!hasOwnPath(object, pathName)) continue;
|
|
81
|
-
const value = getPathValue(object, pathName);
|
|
82
|
-
return Number.isFinite(value) ? value : 0;
|
|
83
|
-
}
|
|
84
|
-
return 0;
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
|
|
88
|
-
|
|
89
|
-
const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
|
|
90
|
-
const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
|
|
91
|
-
const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
92
51
|
|
|
93
52
|
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
94
53
|
|
|
@@ -380,6 +339,12 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
380
339
|
// completion gate can ask "did the last turn finish?" instead of comparing
|
|
381
340
|
// counts that an echoed `turn.started` can skew.
|
|
382
341
|
turnLifecycle: state.turnLifecycle || [],
|
|
342
|
+
// Issue #2140: `thread.started` records seen on the protocol stream that
|
|
343
|
+
// announce a thread id other than this session's. Codex only starts one
|
|
344
|
+
// thread per `codex exec`, so a second id is proof that something echoed
|
|
345
|
+
// another agent's protocol into ours — the one turn event that carries an
|
|
346
|
+
// identity we can check. Diagnostics only; the gate stays order-based.
|
|
347
|
+
foreignThreadIds: state.foreignThreadIds || [],
|
|
383
348
|
};
|
|
384
349
|
|
|
385
350
|
nextState.tokenUsage.tokenFieldAvailability ||= createCodexTokenFieldAvailability();
|
|
@@ -432,6 +397,11 @@ export const parseCodexExecJsonOutput = (output, state = {}, requestedModelId =
|
|
|
432
397
|
|
|
433
398
|
if (eventType === 'thread.started' && typeof data.thread_id === 'string' && !nextState.sessionId) {
|
|
434
399
|
nextState.sessionId = data.thread_id;
|
|
400
|
+
} else if (eventType === 'thread.started' && typeof data.thread_id === 'string' && data.thread_id !== nextState.sessionId) {
|
|
401
|
+
// Issue #2140: a foreign thread id on the protocol stream is echoed
|
|
402
|
+
// output, not a second codex session. Record it once so a run that ends
|
|
403
|
+
// up disputed can be settled from the log alone.
|
|
404
|
+
if (!nextState.foreignThreadIds.includes(data.thread_id)) nextState.foreignThreadIds.push(data.thread_id);
|
|
435
405
|
} else if (!nextState.sessionId && typeof data.session_id === 'string') {
|
|
436
406
|
nextState.sessionId = data.session_id;
|
|
437
407
|
}
|
|
@@ -1030,7 +1000,7 @@ export const executeCodexCommand = async params => {
|
|
|
1030
1000
|
if (chunk.type === 'stdout') {
|
|
1031
1001
|
const raw = chunk.data.toString();
|
|
1032
1002
|
if (argv.verbose) {
|
|
1033
|
-
await log(raw);
|
|
1003
|
+
await log(raw, { stream: 'stdout' });
|
|
1034
1004
|
}
|
|
1035
1005
|
lastMessage = raw;
|
|
1036
1006
|
const output = codexStdoutLines.write(raw);
|
|
@@ -97,6 +97,13 @@ export const buildCodexRunDiagnostics = ({ state = {}, exitCode = null, mappedMo
|
|
|
97
97
|
}
|
|
98
98
|
if (state.turnLifecycle?.length > 0) push(`🔁 Codex turn lifecycle: ${state.turnLifecycle.join(' → ')}`);
|
|
99
99
|
|
|
100
|
+
// Issue #2140: codex starts exactly one thread per `codex exec`, so any other
|
|
101
|
+
// thread id on the protocol stream is echoed output that leaked past the
|
|
102
|
+
// stream separation above. Always worth saying out loud.
|
|
103
|
+
if (state.foreignThreadIds?.length > 0) {
|
|
104
|
+
push(`🧬 Foreign thread IDs seen on the codex protocol stream (echoed, not codex sessions): ${state.foreignThreadIds.join(', ')}`, { level: 'warning', verbose: true });
|
|
105
|
+
}
|
|
106
|
+
|
|
100
107
|
const usage = state.tokenUsage || {};
|
|
101
108
|
if (usage.stepCount > 0) {
|
|
102
109
|
push(`📈 Codex usage from turn.completed: ${usage.inputTokens.toLocaleString()} input, ${usage.cacheReadTokens.toLocaleString()} cache read, ${usage.outputTokens.toLocaleString()} output across ${usage.stepCount} turn(s)`);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage-field vocabulary and JSON-path helpers for the `codex exec --json`
|
|
3
|
+
* parser.
|
|
4
|
+
*
|
|
5
|
+
* Split out of codex.lib.mjs to keep that file inside the max-lines budget
|
|
6
|
+
* (issues #1730 / #1990 / #2140). Everything here is pure data plus pure
|
|
7
|
+
* lookups: codex has renamed and re-nested its usage fields several times
|
|
8
|
+
* across releases, so the parser reads whichever spelling is *present* rather
|
|
9
|
+
* than assuming one shape, and reports what it actually observed.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Every usage field name we know codex has used, for observability reporting. */
|
|
13
|
+
export const CODEX_USAGE_FIELD_NAMES = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'cache_write_tokens', 'cache_creation_input_tokens', 'reasoning_tokens', 'reasoning_output_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
14
|
+
|
|
15
|
+
/** Places a codex event has been seen to name a model, in preference order. */
|
|
16
|
+
export const CODEX_MODEL_DIAGNOSTIC_PATHS = [
|
|
17
|
+
['model', data => data?.model],
|
|
18
|
+
['model_name', data => data?.model_name],
|
|
19
|
+
['from_model', data => data?.from_model],
|
|
20
|
+
['to_model', data => data?.to_model],
|
|
21
|
+
['message.model', data => data?.message?.model],
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
export const CODEX_CACHE_READ_USAGE_PATHS = ['cached_input_tokens', 'input_tokens_details.cached_tokens', 'input_tokens_details.cache_read_tokens'];
|
|
25
|
+
export const CODEX_CACHE_WRITE_USAGE_PATHS = ['cache_write_tokens', 'cache_creation_input_tokens', 'input_tokens_details.cache_write_tokens', 'input_tokens_details.cache_creation_tokens', 'input_tokens_details.cache_creation_input_tokens'];
|
|
26
|
+
export const CODEX_REASONING_USAGE_PATHS = ['reasoning_tokens', 'reasoning_output_tokens', 'output_tokens_details.reasoning_tokens'];
|
|
27
|
+
|
|
28
|
+
/** Which token kinds this run has actually seen codex report. */
|
|
29
|
+
export const createCodexTokenFieldAvailability = () => ({
|
|
30
|
+
inputTokens: false,
|
|
31
|
+
outputTokens: false,
|
|
32
|
+
reasoningTokens: false,
|
|
33
|
+
cacheReadTokens: false,
|
|
34
|
+
cacheWriteTokens: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
/** Own-property check along a dotted path — absent ≠ present-and-zero. */
|
|
38
|
+
export const hasOwnPath = (object, pathName) => {
|
|
39
|
+
let cursor = object;
|
|
40
|
+
for (const part of pathName.split('.')) {
|
|
41
|
+
if (!cursor || typeof cursor !== 'object' || !Object.hasOwn(cursor, part)) return false;
|
|
42
|
+
cursor = cursor[part];
|
|
43
|
+
}
|
|
44
|
+
return true;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const getPathValue = (object, pathName) => pathName.split('.').reduce((cursor, part) => cursor?.[part], object);
|
|
48
|
+
|
|
49
|
+
/** First path that is actually present wins; a non-finite value counts as 0. */
|
|
50
|
+
export const getFirstObservedNumber = (object, pathNames) => {
|
|
51
|
+
for (const pathName of pathNames) {
|
|
52
|
+
if (!hasOwnPath(object, pathName)) continue;
|
|
53
|
+
const value = getPathValue(object, pathName);
|
|
54
|
+
return Number.isFinite(value) ? value : 0;
|
|
55
|
+
}
|
|
56
|
+
return 0;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const hasAnyObservedPath = (object, pathNames) => pathNames.some(pathName => hasOwnPath(object, pathName));
|
package/src/gemini.lib.mjs
CHANGED
|
@@ -462,7 +462,7 @@ export const executeGeminiCommand = async params => {
|
|
|
462
462
|
for await (const chunk of execCommand.stream()) {
|
|
463
463
|
if (chunk.type === 'stdout') {
|
|
464
464
|
const output = chunk.data.toString();
|
|
465
|
-
await log(output);
|
|
465
|
+
await log(output, { stream: 'stdout' });
|
|
466
466
|
allOutput += output;
|
|
467
467
|
geminiJsonState = parseGeminiJsonOutput(output, geminiJsonState, mappedModel);
|
|
468
468
|
if (geminiJsonState.sessionId) {
|
package/src/lib.mjs
CHANGED
|
@@ -95,10 +95,14 @@ export const getAbsoluteLogPath = async () => {
|
|
|
95
95
|
* @param {Object} options - Logging options
|
|
96
96
|
* @param {string} [options.level='info'] - Log level (info, warn, error)
|
|
97
97
|
* @param {boolean} [options.verbose=false] - Whether this is a verbose log
|
|
98
|
+
* @param {string} [options.stream] - Provenance of the message when it is raw
|
|
99
|
+
* output mirrored from a child process: 'stdout' or 'stderr'. Tags the log
|
|
100
|
+
* file lines [STDOUT]/[STDERR] (matching the process.stdout/stderr
|
|
101
|
+
* interceptor below) and routes the console write to the same stream.
|
|
98
102
|
* @returns {Promise<void>}
|
|
99
103
|
*/
|
|
100
104
|
export const log = async (message, options = {}) => {
|
|
101
|
-
const { level = 'info', verbose = false } = options;
|
|
105
|
+
const { level = 'info', verbose = false, stream = null } = options;
|
|
102
106
|
|
|
103
107
|
// Skip verbose logs unless --verbose is enabled
|
|
104
108
|
if (verbose && !global.verboseMode) {
|
|
@@ -107,12 +111,20 @@ export const log = async (message, options = {}) => {
|
|
|
107
111
|
|
|
108
112
|
const sanitizedMessage = sanitizeCredentialText(message);
|
|
109
113
|
|
|
114
|
+
// Issue #2140: mirrored child output must stay attributable to the stream it
|
|
115
|
+
// came from. Both Codex streams used to be written as plain [INFO], so a run
|
|
116
|
+
// log could not answer "did Codex emit this protocol line, or did its stderr
|
|
117
|
+
// merely echo one?" — the exact question a false completion failure hinges on.
|
|
118
|
+
// An explicit level still wins, so warnings/errors keep their own tag.
|
|
119
|
+
const mirroredStream = stream === 'stdout' || stream === 'stderr' ? stream : null;
|
|
120
|
+
const tag = mirroredStream && level === 'info' ? mirroredStream.toUpperCase() : level.toUpperCase();
|
|
121
|
+
|
|
110
122
|
// Write to file if log file is set
|
|
111
123
|
// Issue #1572: Handle multi-line messages by timestamping each line,
|
|
112
124
|
// so continuation lines don't appear without timestamps in the log file
|
|
113
125
|
if (logFile) {
|
|
114
126
|
const timestamp = new Date().toISOString();
|
|
115
|
-
const prefix = `[${timestamp}] [${
|
|
127
|
+
const prefix = `[${timestamp}] [${tag}]`;
|
|
116
128
|
const lines = sanitizedMessage.split('\n');
|
|
117
129
|
const logMessage = lines.map(line => `${prefix} ${line}`).join('\n');
|
|
118
130
|
try {
|
|
@@ -146,7 +158,10 @@ export const log = async (message, options = {}) => {
|
|
|
146
158
|
break;
|
|
147
159
|
case 'info':
|
|
148
160
|
default:
|
|
149
|
-
|
|
161
|
+
// Mirrored child stderr goes to our stderr, so piping stdout to a
|
|
162
|
+
// consumer keeps yielding only what the child wrote to stdout.
|
|
163
|
+
if (mirroredStream === 'stderr') console.error(sanitizedMessage);
|
|
164
|
+
else console.log(sanitizedMessage);
|
|
150
165
|
break;
|
|
151
166
|
}
|
|
152
167
|
} finally {
|
package/src/opencode.lib.mjs
CHANGED
|
@@ -394,7 +394,7 @@ export const executeOpenCodeCommand = async params => {
|
|
|
394
394
|
for await (const chunk of execCommand.stream()) {
|
|
395
395
|
if (chunk.type === 'stdout') {
|
|
396
396
|
const output = chunk.data.toString();
|
|
397
|
-
await log(output);
|
|
397
|
+
await log(output, { stream: 'stdout' });
|
|
398
398
|
lastMessage = output;
|
|
399
399
|
allOutput += output;
|
|
400
400
|
|
package/src/qwen.lib.mjs
CHANGED
|
@@ -566,7 +566,7 @@ export const executeQwenCommand = async params => {
|
|
|
566
566
|
for await (const chunk of execCommand.stream()) {
|
|
567
567
|
if (chunk.type === 'stdout') {
|
|
568
568
|
const output = chunk.data.toString();
|
|
569
|
-
await log(output);
|
|
569
|
+
await log(output, { stream: 'stdout' });
|
|
570
570
|
allOutput += output;
|
|
571
571
|
qwenState = parseQwenStreamJsonOutput(output, qwenState);
|
|
572
572
|
}
|