@link-assistant/hive-mind 2.11.2 → 2.11.4

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.
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Pricing and provider identity for the Formal AI model (issue #2119).
5
+ *
6
+ * `--model formal-ai` routes every request through a local Formal AI model
7
+ * server (`formal-ai with <tool> ...`). The requests never reach OpenCode Zen,
8
+ * OpenAI, Anthropic or Google, so:
9
+ *
10
+ * - the provider is Link.Assistant, not the provider of whichever agentic CLI
11
+ * happens to be driving the session;
12
+ * - the cost is $0.00, so an inherited `total_cost_usd` (claude reported
13
+ * $0.252315 for a Formal AI session) or a models.dev price lookup for an
14
+ * unrelated base model is a false positive.
15
+ *
16
+ * Every pricing producer funnels through here so the same numbers appear in
17
+ * logs, GitHub comments and budget statistics.
18
+ */
19
+
20
+ import { FORMAL_AI_MODEL_ALIAS, isFormalAiModel } from './models/index.mjs';
21
+
22
+ export const FORMAL_AI_PROVIDER_NAME = 'Link.Assistant';
23
+
24
+ const ZERO_PRICING = Object.freeze({
25
+ inputPerMillion: 0,
26
+ outputPerMillion: 0,
27
+ cacheReadPerMillion: 0,
28
+ cacheWritePerMillion: 0,
29
+ reasoningPerMillion: 0,
30
+ });
31
+
32
+ const ZERO_BREAKDOWN = Object.freeze({
33
+ input: 0,
34
+ output: 0,
35
+ cacheRead: 0,
36
+ cacheWrite: 0,
37
+ reasoning: 0,
38
+ });
39
+
40
+ /**
41
+ * Build the pricing record for a Formal AI session.
42
+ *
43
+ * @param {string|null} modelId model id as passed to the tool (alias or `formalai/formal-ai`)
44
+ * @param {Object|null} tokenUsage aggregated token usage, kept so token counts stay reportable
45
+ * @returns {Object} pricing info with a Link.Assistant provider and a $0.00 cost
46
+ */
47
+ export const buildFormalAiPricingInfo = (modelId = FORMAL_AI_MODEL_ALIAS, tokenUsage = null) => ({
48
+ modelId: modelId || FORMAL_AI_MODEL_ALIAS,
49
+ modelName: FORMAL_AI_MODEL_ALIAS,
50
+ provider: FORMAL_AI_PROVIDER_NAME,
51
+ // No third-party price applies, so there is no base model to reference.
52
+ originalProvider: null,
53
+ baseModelName: null,
54
+ tokenUsage: tokenUsage || null,
55
+ pricing: { ...ZERO_PRICING },
56
+ breakdown: { ...ZERO_BREAKDOWN },
57
+ totalCostUSD: 0,
58
+ isFreeModel: true,
59
+ isFormalAi: true,
60
+ });
61
+
62
+ /**
63
+ * Wrap a `(modelId, tokenUsage) => pricingInfo` calculator so Formal AI model
64
+ * ids short-circuit to the free Link.Assistant record instead of being priced
65
+ * against models.dev.
66
+ *
67
+ * @param {Function} calculatePricing the tool's own pricing calculator
68
+ * @returns {Function} wrapped calculator with the same signature
69
+ */
70
+ export const withFormalAiPricing =
71
+ calculatePricing =>
72
+ async (modelId, tokenUsage, ...rest) => {
73
+ if (isFormalAiModel(modelId)) return buildFormalAiPricingInfo(modelId, tokenUsage);
74
+ return calculatePricing(modelId, tokenUsage, ...rest);
75
+ };
76
+
77
+ /**
78
+ * Normalize a tool result's pricing fields for Formal AI sessions.
79
+ *
80
+ * Tools that report a provider cost of their own (claude's `total_cost_usd`)
81
+ * or that build a static provider record (gemini's "Google", qwen's "Alibaba")
82
+ * would otherwise attribute a Formal AI session to the wrong provider at a
83
+ * non-zero price.
84
+ *
85
+ * @param {Object} params
86
+ * @param {string|null} params.model model requested on the command line
87
+ * @param {Object|null} [params.pricingInfo]
88
+ * @param {number|null} [params.publicPricingEstimate]
89
+ * @param {number|null} [params.anthropicTotalCostUSD]
90
+ * @param {Object|null} [params.tokenUsage] fallback token usage when pricingInfo carries none
91
+ * @returns {{pricingInfo: Object|null, publicPricingEstimate: number|null, anthropicTotalCostUSD: number|null}}
92
+ */
93
+ export const applyFormalAiPricingOverride = ({ model, pricingInfo = null, publicPricingEstimate = null, anthropicTotalCostUSD = null, tokenUsage = null }) => {
94
+ if (!isFormalAiModel(model)) return { pricingInfo, publicPricingEstimate, anthropicTotalCostUSD };
95
+
96
+ const usage = pricingInfo?.tokenUsage || tokenUsage || null;
97
+ // Drop provider-specific cost fields carried by the tool's own record: a
98
+ // Formal AI session was never billed by OpenCode Zen, so a
99
+ // "Calculated by OpenCode Zen" line would be a false positive.
100
+ const { opencodeCost: _opencodeCost, isOpencodeFreeModel: _isOpencodeFreeModel, ...carried } = pricingInfo || {};
101
+ return {
102
+ pricingInfo: { ...carried, ...buildFormalAiPricingInfo(pricingInfo?.modelId || model, usage) },
103
+ publicPricingEstimate: 0,
104
+ // The session never billed Anthropic, so any captured Anthropic cost is a
105
+ // false positive and must not be rendered as a second cost line.
106
+ anthropicTotalCostUSD: null,
107
+ };
108
+ };
109
+
110
+ export { isFormalAiModel };
@@ -17,12 +17,15 @@ import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs
17
17
  import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
18
18
  const __geminiBuildSolveResumeCmd = (argv, sessionId, tempDir) => (sessionId && argv?.url ? buildSolveResumeCommand({ issueUrl: argv.url, sessionId, tool: 'gemini', model: argv.model, fallbackModel: argv.fallbackModel, tempDir }) : null);
19
19
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
20
- import { defaultModels, geminiModels } from './models/index.mjs';
20
+ import { defaultModels, geminiModels, isFormalAiModel } from './models/index.mjs';
21
21
  import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
22
+ import { buildFormalAiPricingInfo } from './formal-ai-pricing.lib.mjs'; // Issue #2119
22
23
  import { checkPlaywrightMcpPackageAvailability } from './playwright-mcp.lib.mjs';
23
24
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
24
25
  import { getCumulativeContextInputTokens, toTokenCount } from './context-fill.lib.mjs';
26
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
25
27
  import { getTerminalEventCompletionHealth } from './tool-run-health.lib.mjs'; // Issue #1990
28
+ import { takeJsonRecords } from './json-stream.lib.mjs'; // Issue #2119
26
29
 
27
30
  const shellQuote = value => `"${String(value).replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"`;
28
31
 
@@ -130,6 +133,15 @@ const pickTokenValue = (...values) => {
130
133
  return 0;
131
134
  };
132
135
 
136
+ /**
137
+ * Issue #2119: `--model formal-ai` is served by the local Link.Assistant model
138
+ * server, so the session must not be attributed to Google.
139
+ */
140
+ export const buildGeminiPricingInfo = mappedModel => {
141
+ if (isFormalAiModel(mappedModel)) return buildFormalAiPricingInfo(mappedModel);
142
+ return { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null };
143
+ };
144
+
133
145
  export const buildGeminiResultModelUsage = (modelId, stats = null) => {
134
146
  const modelStats = stats?.models && typeof stats.models === 'object' ? stats.models : null;
135
147
  if (modelStats) {
@@ -221,41 +233,17 @@ export const parseGeminiJsonOutput = (output, state = {}, modelId = null) => {
221
233
  partialLine: state.partialLine || '',
222
234
  };
223
235
 
224
- const trimmedOutput = output.trim();
225
- if (trimmedOutput && !nextState.partialLine) {
226
- try {
227
- const parsed = JSON.parse(trimmedOutput);
228
- for (const event of Array.isArray(parsed) ? parsed : [parsed]) {
229
- applyGeminiJsonEvent(event, nextState, modelId);
230
- }
231
- return nextState;
232
- } catch {
233
- // stream-json emits one JSON object per line; fall through to JSONL parsing.
234
- }
235
- }
236
-
237
- const bufferedOutput = `${nextState.partialLine}${output}`;
238
- nextState.partialLine = '';
239
- const lines = bufferedOutput.split(/\r?\n/);
240
- const hasTrailingLineBreak = /\r?\n$/.test(bufferedOutput);
241
- const completeLines = hasTrailingLineBreak ? lines : lines.slice(0, -1);
242
- const possiblePartialLine = hasTrailingLineBreak ? '' : lines.at(-1) || '';
243
-
244
- for (const line of completeLines) {
245
- if (!line.trim()) continue;
246
-
247
- try {
248
- applyGeminiJsonEvent(JSON.parse(line), nextState, modelId);
249
- } catch {
250
- continue;
251
- }
252
- }
253
-
254
- if (possiblePartialLine.trim()) {
255
- try {
256
- applyGeminiJsonEvent(JSON.parse(possiblePartialLine), nextState, modelId);
257
- } catch {
258
- nextState.partialLine = possiblePartialLine;
236
+ // Issue #2119: frame the stream by balanced JSON values instead of by lines.
237
+ // `formal-ai with gemini` emits pretty-printed, multi-line records, so every
238
+ // line failed to parse and every event - including the token usage - was
239
+ // dropped. Scanning for balanced values also covers records concatenated
240
+ // without a separator and records split across two process chunks.
241
+ const { records, rest } = takeJsonRecords(`${nextState.partialLine}${String(output ?? '')}`);
242
+ nextState.partialLine = rest;
243
+
244
+ for (const record of records) {
245
+ for (const event of Array.isArray(record) ? record : [record]) {
246
+ applyGeminiJsonEvent(event, nextState, modelId);
259
247
  }
260
248
  }
261
249
 
@@ -587,8 +575,8 @@ export const executeGeminiCommand = async params => {
587
575
  messageCount: geminiJsonState.messageCount || 0,
588
576
  toolUseCount: geminiJsonState.toolUseCount || 0,
589
577
  resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
590
- pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
591
- publicPricingEstimate: null,
578
+ pricingInfo: buildGeminiPricingInfo(mappedModel),
579
+ publicPricingEstimate: buildGeminiPricingInfo(mappedModel).totalCostUSD,
592
580
  resultSummary: geminiJsonState.resultSummary || null,
593
581
  // Issue #1845/#1941: surface the actual error, rejecting meaningless fragments (e.g. a lone "}")
594
582
  errorInfo: { message: buildToolErrorMessage({ lastMessage: errorText, exitCode, fallback: `Gemini command failed with exit code ${exitCode}`, toolLabel: 'Gemini' }), exitCode },
@@ -631,8 +619,8 @@ export const executeGeminiCommand = async params => {
631
619
  messageCount: geminiJsonState.messageCount || 0,
632
620
  toolUseCount: geminiJsonState.toolUseCount || 0,
633
621
  resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
634
- pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
635
- publicPricingEstimate: null,
622
+ pricingInfo: buildGeminiPricingInfo(mappedModel),
623
+ publicPricingEstimate: buildGeminiPricingInfo(mappedModel).totalCostUSD,
636
624
  resultSummary: geminiJsonState.resultSummary || null,
637
625
  completionHealth,
638
626
  incompleteSession: completionHealth.incompleteSession,
@@ -655,8 +643,8 @@ export const executeGeminiCommand = async params => {
655
643
  messageCount: geminiJsonState.messageCount || 0,
656
644
  toolUseCount: geminiJsonState.toolUseCount || 0,
657
645
  resultModelUsage: geminiJsonState.resultModelUsage || buildGeminiResultModelUsage(mappedModel),
658
- pricingInfo: { modelId: mappedModel, modelName: mappedModel, provider: 'Google', totalCostUSD: null },
659
- publicPricingEstimate: null,
646
+ pricingInfo: buildGeminiPricingInfo(mappedModel),
647
+ publicPricingEstimate: buildGeminiPricingInfo(mappedModel).totalCostUSD,
660
648
  resultSummary: geminiJsonState.resultSummary || null,
661
649
  };
662
650
  } catch (error) {
@@ -690,11 +678,14 @@ export const executeGeminiCommand = async params => {
690
678
 
691
679
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
692
680
  await log('\nšŸ” Checking for uncommitted changes...');
681
+ // Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
682
+ // the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
683
+ await ensureAiToolScratchIgnored(tempDir, log);
693
684
  try {
694
685
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
695
686
 
696
687
  if (gitStatusResult.code === 0) {
697
- const statusOutput = gitStatusResult.stdout.toString().trim();
688
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
698
689
 
699
690
  if (statusOutput) {
700
691
  await log('šŸ“ Found uncommitted changes');
@@ -51,6 +51,11 @@ export const buildCostInfoString = (totalCostUSD, anthropicTotalCostUSD, pricing
51
51
  }
52
52
  costInfo += `\n- Public pricing estimate: $${publicDec.toFixed(6)}${pricingRef}`;
53
53
  }
54
+ } else if (pricingInfo?.isFreeModel && !pricingInfo?.baseModelName) {
55
+ // Issue #2119: a free model has a known price - $0.00 - even when no
56
+ // usage-derived estimate was produced. Reporting "unknown" for it was a
57
+ // false negative (`--model formal-ai` is served free by Link.Assistant).
58
+ costInfo += '\n- Public pricing estimate: $0.00 (Free model)';
54
59
  } else if (hasPricing) {
55
60
  costInfo += '\n- Public pricing estimate: unknown';
56
61
  }
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Incremental JSON record scanner for agentic CLI output streams.
5
+ *
6
+ * Issue #2119: the agent stream readers split the raw output on newlines and
7
+ * called `JSON.parse` on each line. That works only when the tool emits strict
8
+ * NDJSON on line boundaries that happen to align with process chunk
9
+ * boundaries. Three real-world stream shapes break it:
10
+ *
11
+ * 1. Pretty-printed records — `formal-ai with agent --verbose` emits
12
+ * multi-line, indented JSON, so *every* line fails to parse. Every
13
+ * structured event (session id, token usage, errors, result text) is then
14
+ * dropped, which is how a run with 21677 input / 22834 output tokens was
15
+ * published as "Token usage: 0 input, 0 output".
16
+ * 2. Concatenated records — `{...}{...}` arriving without a separator
17
+ * (issue #1250).
18
+ * 3. Split records — one record spanning two process chunks.
19
+ *
20
+ * Scanning for balanced JSON values instead of relying on line framing handles
21
+ * all three with a single mechanism, and non-JSON output is still surfaced
22
+ * verbatim as text events so plain tool logs keep flowing.
23
+ */
24
+
25
+ // A pending fragment that never balances (for example prose that happens to
26
+ // start with `{`) must not grow without bound. Once the buffer exceeds this
27
+ // size it is released as text.
28
+ export const DEFAULT_MAX_PENDING_BYTES = 4 * 1024 * 1024;
29
+
30
+ const isStructuralOpener = character => character === '{' || character === '[';
31
+
32
+ /**
33
+ * Find the index just past the JSON value starting at `start`.
34
+ * @returns {number} end index (exclusive), or -1 when the value is incomplete.
35
+ */
36
+ const findValueEnd = (buffer, start) => {
37
+ let depth = 0;
38
+ let inString = false;
39
+ let escaped = false;
40
+
41
+ for (let index = start; index < buffer.length; index++) {
42
+ const character = buffer[index];
43
+
44
+ if (inString) {
45
+ if (escaped) escaped = false;
46
+ else if (character === '\\') escaped = true;
47
+ else if (character === '"') inString = false;
48
+ continue;
49
+ }
50
+
51
+ if (character === '"') {
52
+ inString = true;
53
+ continue;
54
+ }
55
+ if (character === '{' || character === '[') {
56
+ depth++;
57
+ continue;
58
+ }
59
+ if (character === '}' || character === ']') {
60
+ depth--;
61
+ if (depth === 0) return index + 1;
62
+ if (depth < 0) return -1;
63
+ }
64
+ }
65
+
66
+ return -1;
67
+ };
68
+
69
+ /**
70
+ * Create a stateful scanner that turns a byte stream into JSON and text events.
71
+ *
72
+ * @param {Object} [options]
73
+ * @param {number} [options.maxPendingBytes] release an unbalanced buffer once it grows past this size
74
+ * @returns {{write: (chunk: string) => Array<Object>, flush: () => Array<Object>}}
75
+ */
76
+ export const createJsonStreamScanner = (options = {}) => {
77
+ const maxPendingBytes = options.maxPendingBytes ?? DEFAULT_MAX_PENDING_BYTES;
78
+ let pending = '';
79
+
80
+ const emitTextLines = (text, events) => {
81
+ for (const line of text.split('\n')) {
82
+ if (line.trim()) events.push({ type: 'text', value: line });
83
+ }
84
+ };
85
+
86
+ const scan = (final, events) => {
87
+ let index = 0;
88
+
89
+ while (index < pending.length) {
90
+ const character = pending[index];
91
+
92
+ if (character === '\n' || character === '\r' || character === ' ' || character === '\t') {
93
+ index++;
94
+ continue;
95
+ }
96
+
97
+ if (isStructuralOpener(character)) {
98
+ const end = findValueEnd(pending, index);
99
+ if (end < 0) break; // incomplete record: wait for more input
100
+ const raw = pending.slice(index, end);
101
+ try {
102
+ events.push({ type: 'json', value: JSON.parse(raw), raw });
103
+ } catch {
104
+ // Balanced but not valid JSON: surface it verbatim rather than
105
+ // silently dropping tool output.
106
+ emitTextLines(raw, events);
107
+ }
108
+ index = end;
109
+ continue;
110
+ }
111
+
112
+ const newline = pending.indexOf('\n', index);
113
+ if (newline < 0) break; // incomplete text line: wait for more input
114
+ const line = pending.slice(index, newline).replace(/\r$/, '');
115
+ if (line.trim()) events.push({ type: 'text', value: line });
116
+ index = newline + 1;
117
+ }
118
+
119
+ pending = pending.slice(index);
120
+
121
+ if (final) {
122
+ if (pending.trim()) {
123
+ const trimmed = pending.trim();
124
+ let parsed = null;
125
+ if (isStructuralOpener(trimmed[0])) {
126
+ try {
127
+ parsed = { type: 'json', value: JSON.parse(trimmed), raw: trimmed };
128
+ } catch {
129
+ parsed = null;
130
+ }
131
+ }
132
+ if (parsed) events.push(parsed);
133
+ else emitTextLines(pending, events);
134
+ }
135
+ pending = '';
136
+ } else if (pending.length > maxPendingBytes) {
137
+ emitTextLines(pending, events);
138
+ pending = '';
139
+ }
140
+
141
+ return events;
142
+ };
143
+
144
+ return {
145
+ write(chunk) {
146
+ pending += String(chunk ?? '');
147
+ return scan(false, []);
148
+ },
149
+ flush() {
150
+ return scan(true, []);
151
+ },
152
+ /** The unconsumed tail: an incomplete record or text line. */
153
+ pending() {
154
+ return pending;
155
+ },
156
+ };
157
+ };
158
+
159
+ /**
160
+ * Split a buffered stream into complete JSON records plus the unconsumed tail.
161
+ *
162
+ * Stateless counterpart of `createJsonStreamScanner`, for the parsers that keep
163
+ * their buffer inside a plain state object they hand back to their caller
164
+ * (`parseGeminiJsonOutput`, `parseQwenStreamJsonOutput`) instead of holding a
165
+ * closure across chunks.
166
+ *
167
+ * @param {string} buffered carried-over tail followed by the new chunk
168
+ * @returns {{records: Array<Object>, rest: string}}
169
+ */
170
+ export const takeJsonRecords = buffered => {
171
+ const scanner = createJsonStreamScanner();
172
+ const events = scanner.write(String(buffered ?? ''));
173
+ return {
174
+ records: events.filter(event => event.type === 'json').map(event => event.value),
175
+ rest: scanner.pending(),
176
+ };
177
+ };
178
+
179
+ /**
180
+ * Buffer a byte stream into whole lines.
181
+ *
182
+ * Line-oriented parsers (Codex NDJSON plus its interleaved OTEL diagnostics)
183
+ * stay correct as long as they never see half a line. A process chunk boundary
184
+ * can fall anywhere, so the trailing partial line is carried over to the next
185
+ * chunk and released by `flush()`.
186
+ *
187
+ * @returns {{write: (chunk: string) => string, flush: () => string}}
188
+ */
189
+ export const createLineBuffer = () => {
190
+ let pending = '';
191
+
192
+ return {
193
+ write(chunk) {
194
+ pending += String(chunk ?? '');
195
+ const boundary = pending.lastIndexOf('\n');
196
+ if (boundary < 0) return '';
197
+ const complete = pending.slice(0, boundary + 1);
198
+ pending = pending.slice(boundary + 1);
199
+ return complete;
200
+ },
201
+ flush() {
202
+ const rest = pending;
203
+ pending = '';
204
+ return rest;
205
+ },
206
+ };
207
+ };
208
+
209
+ /**
210
+ * Extract every complete JSON record from a finished output buffer.
211
+ *
212
+ * @param {string} output raw tool output
213
+ * @returns {Array<Object>} parsed JSON values in stream order
214
+ */
215
+ export const parseJsonRecords = output => {
216
+ const scanner = createJsonStreamScanner();
217
+ const events = [...scanner.write(String(output ?? '')), ...scanner.flush()];
218
+ return events.filter(event => event.type === 'json').map(event => event.value);
219
+ };
@@ -23,8 +23,10 @@ import { opencodeModels, defaultModels } from './models/index.mjs';
23
23
  import { logPreparedToolCommand, resolveFormalAiToolInvocation } from './formal-ai.lib.mjs';
24
24
  import { checkPlaywrightMcpPackageAvailability, getOpenCodePlaywrightMcpDisableEnv } from './playwright-mcp.lib.mjs';
25
25
  import { createAgentTokenUsage, accumulateAgentStepFinishUsage, parseAgentTokenUsage as parseOpenCodeTokenUsage } from './agent-token-usage.lib.mjs';
26
+ import { createJsonStreamScanner } from './json-stream.lib.mjs';
26
27
  import { calculateAgentPricing } from './agent.lib.mjs';
27
28
  import { classifyRetryableError, prepareRetryAfterError, waitWithCountdown } from './tool-retry.lib.mjs';
29
+ import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
28
30
 
29
31
  export { parseOpenCodeTokenUsage };
30
32
 
@@ -336,6 +338,48 @@ export const executeOpenCodeCommand = async params => {
336
338
  let lastTextContent = ''; // Issue #1263: Track last text content for result summary
337
339
  let allOutput = ''; // Collect all output for error detection
338
340
 
341
+ // Issue #2119: frame records by balanced JSON instead of by newlines, so
342
+ // pretty-printed, concatenated and chunk-split records are all counted.
343
+ // The previous per-chunk try/catch also aborted parsing of the whole
344
+ // chunk as soon as one line was not JSON.
345
+ const stdoutScanner = createJsonStreamScanner();
346
+ const stderrScanner = createJsonStreamScanner();
347
+
348
+ const handleOpenCodeRecords = events => {
349
+ for (const event of events) {
350
+ if (event.type !== 'json') continue;
351
+ const data = sanitizeObjectStrings(event.value);
352
+ // Issue #1968: a bare `null`/primitive record must not abort the
353
+ // rest of the chunk (data.type access would throw on null).
354
+ if (data === null || typeof data !== 'object') continue;
355
+ accumulateAgentStepFinishUsage(streamingTokenUsage, data);
356
+ // Track text content for result summary
357
+ // OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
358
+ if (data.type === 'text' && data.text) {
359
+ lastTextContent = data.text;
360
+ } else if (data.type === 'assistant' && data.message?.content) {
361
+ const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
362
+ for (const item of content) {
363
+ if (item.type === 'text' && item.text) {
364
+ lastTextContent = item.text;
365
+ }
366
+ }
367
+ } else if (data.type === 'message' && data.content) {
368
+ if (typeof data.content === 'string') {
369
+ lastTextContent = data.content;
370
+ } else if (Array.isArray(data.content)) {
371
+ for (const item of data.content) {
372
+ if (item.type === 'text' && item.text) {
373
+ lastTextContent = item.text;
374
+ }
375
+ }
376
+ }
377
+ } else if (data.type === 'result' && data.result) {
378
+ lastTextContent = data.result;
379
+ }
380
+ }
381
+ };
382
+
339
383
  for await (const chunk of execCommand.stream()) {
340
384
  if (chunk.type === 'stdout') {
341
385
  const output = chunk.data.toString();
@@ -343,44 +387,8 @@ export const executeOpenCodeCommand = async params => {
343
387
  lastMessage = output;
344
388
  allOutput += output;
345
389
 
346
- // Issue #1263: Try to parse JSON output to extract text content for result summary
347
- try {
348
- const lines = output.split('\n');
349
- for (const line of lines) {
350
- if (!line.trim()) continue;
351
- const data = sanitizeObjectStrings(JSON.parse(line));
352
- // Issue #1968: a bare `null`/primitive NDJSON line must not abort the
353
- // rest of the chunk (data.type access would throw on null).
354
- if (data === null || typeof data !== 'object') continue;
355
- accumulateAgentStepFinishUsage(streamingTokenUsage, data);
356
- // Track text content for result summary
357
- // OpenCode outputs text via 'text', 'assistant', 'message', or 'result' type events
358
- if (data.type === 'text' && data.text) {
359
- lastTextContent = data.text;
360
- } else if (data.type === 'assistant' && data.message?.content) {
361
- const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
362
- for (const item of content) {
363
- if (item.type === 'text' && item.text) {
364
- lastTextContent = item.text;
365
- }
366
- }
367
- } else if (data.type === 'message' && data.content) {
368
- if (typeof data.content === 'string') {
369
- lastTextContent = data.content;
370
- } else if (Array.isArray(data.content)) {
371
- for (const item of data.content) {
372
- if (item.type === 'text' && item.text) {
373
- lastTextContent = item.text;
374
- }
375
- }
376
- }
377
- } else if (data.type === 'result' && data.result) {
378
- lastTextContent = data.result;
379
- }
380
- }
381
- } catch {
382
- // Not JSON, continue
383
- }
390
+ // Issue #1263: Parse JSON output to extract text content for result summary
391
+ handleOpenCodeRecords(stdoutScanner.write(output));
384
392
  }
385
393
 
386
394
  if (chunk.type === 'stderr') {
@@ -389,47 +397,18 @@ export const executeOpenCodeCommand = async params => {
389
397
  await log(errorOutput, { stream: 'stderr' });
390
398
  allOutput += errorOutput;
391
399
 
392
- // Issue #1263: Also try to parse stderr for text content
393
- try {
394
- const lines = errorOutput.split('\n');
395
- for (const line of lines) {
396
- if (!line.trim()) continue;
397
- const data = sanitizeObjectStrings(JSON.parse(line));
398
- // Issue #1968: skip bare `null`/primitive lines (see stdout handler above).
399
- if (data === null || typeof data !== 'object') continue;
400
- accumulateAgentStepFinishUsage(streamingTokenUsage, data);
401
- if (data.type === 'text' && data.text) {
402
- lastTextContent = data.text;
403
- } else if (data.type === 'assistant' && data.message?.content) {
404
- const content = Array.isArray(data.message.content) ? data.message.content : [data.message.content];
405
- for (const item of content) {
406
- if (item.type === 'text' && item.text) {
407
- lastTextContent = item.text;
408
- }
409
- }
410
- } else if (data.type === 'message' && data.content) {
411
- if (typeof data.content === 'string') {
412
- lastTextContent = data.content;
413
- } else if (Array.isArray(data.content)) {
414
- for (const item of data.content) {
415
- if (item.type === 'text' && item.text) {
416
- lastTextContent = item.text;
417
- }
418
- }
419
- }
420
- } else if (data.type === 'result' && data.result) {
421
- lastTextContent = data.result;
422
- }
423
- }
424
- } catch {
425
- // Not JSON, continue
426
- }
400
+ // Issue #1263: Also parse stderr for text content
401
+ handleOpenCodeRecords(stderrScanner.write(errorOutput));
427
402
  }
428
403
  } else if (chunk.type === 'exit') {
429
404
  exitCode = chunk.code;
430
405
  }
431
406
  }
432
407
 
408
+ // Release any record that was still being assembled when the stream ended.
409
+ handleOpenCodeRecords(stdoutScanner.flush());
410
+ handleOpenCodeRecords(stderrScanner.flush());
411
+
433
412
  // Clean up the opencode.json config file to avoid polluting the repository
434
413
  try {
435
414
  await fs.unlink(opencodeConfigPath);
@@ -631,11 +610,14 @@ export const executeOpenCodeCommand = async params => {
631
610
  export const checkForUncommittedChanges = async (tempDir, owner, repo, branchName, $, log, autoCommit = false, autoRestartEnabled = true) => {
632
611
  // Similar to Claude version, check for uncommitted changes
633
612
  await log('\nšŸ” Checking for uncommitted changes...');
613
+ // Issue #2119: AI tools leave scratch state (.formal-ai/, .playwright-mcp/) in
614
+ // the workspace. Ignoring it here keeps it out of both this check and 'git add -A'.
615
+ await ensureAiToolScratchIgnored(tempDir, log);
634
616
  try {
635
617
  const gitStatusResult = await $({ cwd: tempDir })`git status --porcelain 2>&1`;
636
618
 
637
619
  if (gitStatusResult.code === 0) {
638
- const statusOutput = gitStatusResult.stdout.toString().trim();
620
+ const statusOutput = filterAiToolScratchFromStatus(gitStatusResult.stdout.toString().trim());
639
621
 
640
622
  if (statusOutput) {
641
623
  await log('šŸ“ Found uncommitted changes');