codehere 0.8.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +22 -13
  2. package/dist/application/services/verdict-service.js +154 -0
  3. package/dist/application/use-cases/assess-cross-vendor.js +126 -0
  4. package/dist/application/use-cases/build-cross-vendor-verdict.js +118 -0
  5. package/dist/application/use-cases/export-aat.js +107 -0
  6. package/dist/application/use-cases/generate-acceptance-criteria.js +223 -0
  7. package/dist/application/use-cases/generate-final-report.js +140 -15
  8. package/dist/application/use-cases/generate-narration.js +5 -3
  9. package/dist/application/use-cases/generate-thread-title.js +5 -2
  10. package/dist/application/use-cases/plan-auto-review.js +57 -0
  11. package/dist/application/use-cases/reconcile-audit.js +100 -0
  12. package/dist/application/use-cases/rerun-tests.js +226 -0
  13. package/dist/application/use-cases/search-sessions.js +70 -0
  14. package/dist/application/use-cases/should-auto-review.js +71 -0
  15. package/dist/application/use-cases/thread-export.js +90 -0
  16. package/dist/application/use-cases/verify-claim.js +187 -5
  17. package/dist/domain/audit.js +11 -0
  18. package/dist/domain/services/codebase-detector.js +0 -6
  19. package/dist/domain/task.js +91 -0
  20. package/dist/error-handler.js +3 -115
  21. package/dist/index.js +269 -25
  22. package/dist/infrastructure/completion/bash-completion.js +7 -3
  23. package/dist/infrastructure/completion/fish-completion.js +7 -12
  24. package/dist/infrastructure/completion/zsh-completion.js +7 -12
  25. package/dist/infrastructure/config/config-manager.js +4 -3
  26. package/dist/infrastructure/config/product-context.js +1 -19
  27. package/dist/infrastructure/context/adapters/figma-source.js +37 -2
  28. package/dist/infrastructure/context/adapters/github-issue-source.js +3 -2
  29. package/dist/infrastructure/context/github-api.js +110 -0
  30. package/dist/infrastructure/context/github-device-flow.js +65 -0
  31. package/dist/infrastructure/context/oauth-store.js +5 -2
  32. package/dist/infrastructure/context/registry-bootstrap.js +0 -4
  33. package/dist/infrastructure/cost/budget-config.js +3 -2
  34. package/dist/infrastructure/cost/cost-router.js +16 -23
  35. package/dist/infrastructure/cost/spend-aggregator.js +90 -15
  36. package/dist/infrastructure/cost/team-ledger.js +355 -0
  37. package/dist/infrastructure/discovery/discovery-log.js +85 -0
  38. package/dist/infrastructure/hooks/audit-tip-signature.js +336 -0
  39. package/dist/infrastructure/hooks/claude-sdk-hook.js +133 -3
  40. package/dist/infrastructure/hooks/hook-mode.js +20 -4
  41. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +8 -2
  42. package/dist/infrastructure/hooks/pre-prompt-scan.js +3 -3
  43. package/dist/infrastructure/observability/execution-tracer.js +1 -1
  44. package/dist/infrastructure/orchestration/agents-config-seed.js +22 -10
  45. package/dist/infrastructure/orchestration/aider-adapter.js +188 -24
  46. package/dist/infrastructure/orchestration/artifacts-extractor.js +60 -2
  47. package/dist/infrastructure/orchestration/claude-code-adapter.js +117 -3
  48. package/dist/infrastructure/orchestration/codex-adapter.js +52 -16
  49. package/dist/infrastructure/orchestration/conductor-adapter.js +46 -7
  50. package/dist/infrastructure/orchestration/context-handoff.js +32 -14
  51. package/dist/infrastructure/orchestration/detect-ollama.js +75 -0
  52. package/dist/infrastructure/orchestration/failover-config.js +0 -10
  53. package/dist/infrastructure/orchestration/gemini-adapter.js +92 -12
  54. package/dist/infrastructure/orchestration/generic-cli-adapter.js +132 -1
  55. package/dist/infrastructure/orchestration/graphify-context.js +14 -59
  56. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +164 -9
  57. package/dist/infrastructure/orchestration/opencode-adapter.js +84 -14
  58. package/dist/infrastructure/orchestration/progress-aggregator.js +238 -21
  59. package/dist/infrastructure/orchestration/session-manager.js +593 -94
  60. package/dist/infrastructure/orchestration/session-persistence.js +177 -41
  61. package/dist/infrastructure/orchestration/thread-key-store.js +1 -1
  62. package/dist/infrastructure/orchestration/write-pass-store.js +0 -8
  63. package/dist/infrastructure/protocols/mcp-helpers.js +1 -1
  64. package/dist/infrastructure/protocols/mcp-server-impl.js +1462 -380
  65. package/dist/infrastructure/protocols/mcp-tools.js +401 -0
  66. package/dist/infrastructure/protocols/ui/app-script-artifacts.js +121 -0
  67. package/dist/infrastructure/protocols/ui/app-script-audit-footer.js +44 -0
  68. package/dist/infrastructure/protocols/ui/app-script-audit-verify.js +56 -0
  69. package/dist/infrastructure/protocols/ui/app-script-composer-cost.js +588 -0
  70. package/dist/infrastructure/protocols/ui/app-script-context-sources.js +168 -0
  71. package/dist/infrastructure/protocols/ui/app-script-conversation.js +67 -0
  72. package/dist/infrastructure/protocols/ui/app-script-drawer.js +115 -0
  73. package/dist/infrastructure/protocols/ui/app-script-failover.js +133 -0
  74. package/dist/infrastructure/protocols/ui/app-script-github.js +113 -0
  75. package/dist/infrastructure/protocols/ui/app-script-observer.js +104 -0
  76. package/dist/infrastructure/protocols/ui/app-script-onboarding.js +80 -0
  77. package/dist/infrastructure/protocols/ui/app-script-overlay.js +203 -0
  78. package/dist/infrastructure/protocols/ui/app-script-recovery.js +136 -0
  79. package/dist/infrastructure/protocols/ui/app-script-render.js +166 -0
  80. package/dist/infrastructure/protocols/ui/app-script-share.js +78 -0
  81. package/dist/infrastructure/protocols/ui/app-script-sidebar.js +194 -0
  82. package/dist/infrastructure/protocols/ui/app-script-tour.js +78 -0
  83. package/dist/infrastructure/protocols/ui/app-script-wpass.js +312 -0
  84. package/dist/infrastructure/protocols/ui/app-script.js +2998 -0
  85. package/dist/infrastructure/protocols/ui/font-faces.js +7 -0
  86. package/dist/infrastructure/protocols/ui/styles.js +2147 -0
  87. package/dist/infrastructure/protocols/ui/tokens.js +96 -0
  88. package/dist/infrastructure/protocols/ui-page-v2.js +116 -4214
  89. package/dist/infrastructure/protocols/verify-page.js +849 -155
  90. package/dist/infrastructure/security/fix-code.js +144 -0
  91. package/dist/infrastructure/security/fix-secret.js +89 -0
  92. package/dist/infrastructure/security/permissions-config.js +3 -2
  93. package/dist/infrastructure/security/scan-types.js +10 -0
  94. package/dist/infrastructure/security/ship-scan.js +195 -0
  95. package/dist/infrastructure/security/trust-scorer.js +70 -3
  96. package/dist/infrastructure/security/vibe-checks.js +99 -0
  97. package/dist/infrastructure/storage/atomic-write.js +86 -0
  98. package/dist/infrastructure/storage/project-thread-mirror.js +150 -3
  99. package/dist/infrastructure/storage/project-thread-reader.js +261 -24
  100. package/dist/monitoring.js +2 -2
  101. package/dist/presentation/cli/commands/audit-command.js +79 -4
  102. package/dist/presentation/cli/commands/budget-command.js +2 -2
  103. package/dist/presentation/cli/commands/check-command.js +96 -0
  104. package/dist/presentation/cli/commands/context-source-command.js +20 -1
  105. package/dist/presentation/cli/commands/help-command.js +5 -5
  106. package/dist/presentation/cli/commands/hook-command.js +5 -5
  107. package/dist/presentation/cli/commands/init-command.js +26 -14
  108. package/dist/presentation/cli/commands/memory-command.js +28 -28
  109. package/dist/presentation/cli/commands/permission-command.js +1 -12
  110. package/dist/presentation/cli/commands/protocols-builder.js +2 -2
  111. package/dist/presentation/cli/commands/protocols-command.js +4 -4
  112. package/dist/presentation/cli/commands/repo-map-builder.js +1 -1
  113. package/dist/presentation/cli/commands/report-command.js +48 -32
  114. package/dist/presentation/cli/commands/scan-command.js +52 -0
  115. package/dist/presentation/cli/commands/scan-tool-command.js +24 -8
  116. package/dist/presentation/cli/commands/search-command.js +51 -0
  117. package/dist/presentation/cli/commands/setup-command.js +31 -8
  118. package/dist/presentation/cli/commands/status-command.js +39 -86
  119. package/dist/presentation/cli/commands/team-command.js +256 -0
  120. package/dist/presentation/cli/commands/thread-command.js +87 -5
  121. package/dist/presentation/cli/commands/trust-command.js +53 -31
  122. package/dist/presentation/cli/commands/verify-command.js +74 -9
  123. package/dist/presentation/cli/error-display.js +0 -8
  124. package/dist/presentation/cli/repl.js +15 -7
  125. package/dist/presentation/cli/task-deliverable.js +36 -10
  126. package/dist/testing/replay-adapter.js +0 -19
  127. package/dist/ui.js +51 -239
  128. package/dist/utils/version.js +0 -6
  129. package/package.json +30 -10
  130. package/dist/domain/entities/task.js +0 -61
  131. package/dist/infrastructure/protocols/ui-page.js +0 -2377
  132. package/dist/infrastructure/security/input-validator.js +0 -152
  133. package/dist/infrastructure/storage/embeddings-path.js +0 -37
  134. package/dist/infrastructure/storage/task-repository.js +0 -206
@@ -65,8 +65,17 @@ function readStdin(timeoutMs = 1500) {
65
65
  setTimeout(finish, timeoutMs);
66
66
  });
67
67
  }
68
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
68
+ // CODEHERE_MOCK_SLOW multiplies step delays so the "watch it work" running
69
+ // state can actually be observed — for dogfood screenshots and the record-replay
70
+ // webm. Default 1 (fast, for tests). e.g. CODEHERE_MOCK_SLOW=8 ≈ 1s/step.
71
+ const SLOW = Math.max(1, Number(process.env.CODEHERE_MOCK_SLOW) || 1);
72
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms * SLOW));
69
73
  const out = (o) => process.stdout.write(JSON.stringify(o) + '\n');
74
+ // Mock-currency rule: every agent emits token usage in ITS vendor's exact
75
+ // shape, so each adapter's cost-parse branch runs under CODEHERE_MOCK instead
76
+ // of being dead-until-a-real-run. Small fixed values → the session cost
77
+ // estimate is deterministic and non-zero under mock.
78
+ const MOCK_TOKENS = { input: 1200, output: 800 };
70
79
  // Derive a few plausible steps from the prompt. Heuristic, not modeled — enough
71
80
  // for the execution path to produce real steps/artifacts without an API call.
72
81
  function plannedSteps(p) {
@@ -82,11 +91,114 @@ function plannedSteps(p) {
82
91
  steps.push({ tool: 'Bash', input: { command: 'npm run build' }, result: 'build clean' });
83
92
  return steps;
84
93
  }
94
+ /** The wrap-up say every real agent emits before its result. The mock never
95
+ * did, so a mock report's only prose was the "On it" ack and every glance
96
+ * led with narration instead of the finding (journey-QA 2026-07-06).
97
+ * Finding first, in the same artifacts plannedSteps claims. */
98
+ function wrapUpText(steps) {
99
+ const edit = steps.find((s) => s.tool === 'Edit');
100
+ const check = steps.find((s) => s.tool === 'Bash');
101
+ const wrote = edit
102
+ ? 'Wrote the change to ' + String(edit.input.file_path) + ' (' + edit.result.split(' ·')[0] + ')'
103
+ : 'No file changes';
104
+ const checked = check ? String(check.input.command) + ' — ' + check.result : '';
105
+ return wrote + (checked ? '; ' + checked : '') + '. (mock · no API spend)';
106
+ }
85
107
  async function runClaudeCode() {
86
- const sid = 'mock-' + Math.random().toString(36).slice(2, 10);
87
- out({ type: 'system', subtype: 'init', session_id: sid, tools: ['Read', 'Edit', 'Bash'], model: 'mock' });
108
+ const resumeId = argVal('--resume');
109
+ const sid = resumeId || 'mock-' + Math.random().toString(36).slice(2, 10);
110
+ out({
111
+ type: 'system',
112
+ subtype: 'init',
113
+ session_id: sid,
114
+ tools: ['Read', 'Edit', 'Bash', 'AskUserQuestion'],
115
+ model: 'mock',
116
+ });
88
117
  await sleep(120);
118
+ // Continuation (answering a question / a follow-up): acknowledge + do the work.
119
+ if (resumeId) {
120
+ const ans = prompt.replace(/^[\s\S]*?My answer:\s*/i, '').trim() || prompt;
121
+ out({
122
+ type: 'assistant',
123
+ message: {
124
+ content: [{ type: 'thinking', thinking: 'The user chose: ' + ans + '. I will implement that path now.' }],
125
+ },
126
+ });
127
+ await sleep(160);
128
+ out({
129
+ type: 'assistant',
130
+ message: { content: [{ type: 'text', text: 'Got it — going with **' + ans + '**. Implementing now.' }] },
131
+ });
132
+ await sleep(160);
133
+ out({
134
+ type: 'assistant',
135
+ message: { content: [{ type: 'tool_use', id: 'r0', name: 'Edit', input: { file_path: MOCK_EDIT_FILE } }] },
136
+ });
137
+ await sleep(160);
138
+ out({
139
+ type: 'user',
140
+ message: {
141
+ content: [
142
+ { type: 'tool_result', tool_use_id: 'r0', content: '+18 lines · applied your choice', is_error: false },
143
+ ],
144
+ },
145
+ });
146
+ await sleep(140);
147
+ out({
148
+ type: 'result',
149
+ subtype: 'success',
150
+ session_id: sid,
151
+ result: 'Done — applied your answer (' + ans + ').',
152
+ usage: { input_tokens: MOCK_TOKENS.input, output_tokens: MOCK_TOKENS.output },
153
+ });
154
+ return;
155
+ }
89
156
  out({ type: 'assistant', message: { content: [{ type: 'text', text: 'On it — ' + prompt }] } });
157
+ // Ambiguous prompt → ask the user, then pause (the headless tool can't be
158
+ // answered, so the turn ends; codehere surfaces the card + resumes on answer).
159
+ if (/\bask\b|which|approach|prefer|option|client|server/.test(prompt.toLowerCase())) {
160
+ await sleep(160);
161
+ out({
162
+ type: 'assistant',
163
+ message: {
164
+ content: [
165
+ {
166
+ type: 'tool_use',
167
+ id: 'tu_ask',
168
+ name: 'AskUserQuestion',
169
+ input: {
170
+ questions: [
171
+ {
172
+ question: 'Which approach should I use?',
173
+ header: 'Approach',
174
+ options: [
175
+ { label: 'Client-side only', description: 'fast, no backend changes' },
176
+ { label: 'Client + server', description: 'safer — validates on the server too' },
177
+ ],
178
+ },
179
+ ],
180
+ },
181
+ },
182
+ ],
183
+ },
184
+ });
185
+ await sleep(120);
186
+ out({
187
+ type: 'user',
188
+ message: {
189
+ content: [{ type: 'tool_result', tool_use_id: 'tu_ask', is_error: true, content: 'awaiting your answer' }],
190
+ },
191
+ });
192
+ await sleep(120);
193
+ out({
194
+ type: 'result',
195
+ subtype: 'success',
196
+ session_id: sid,
197
+ result: 'Waiting for your answer to continue.',
198
+ usage: { input_tokens: MOCK_TOKENS.input, output_tokens: MOCK_TOKENS.output },
199
+ });
200
+ return;
201
+ }
90
202
  const steps = plannedSteps(prompt);
91
203
  for (let i = 0; i < steps.length; i++) {
92
204
  const s = steps[i];
@@ -100,11 +212,17 @@ async function runClaudeCode() {
100
212
  });
101
213
  }
102
214
  await sleep(140);
215
+ // The wrap-up say (real claude always emits one) — the report leads with it.
216
+ out({ type: 'assistant', message: { content: [{ type: 'text', text: wrapUpText(steps) }] } });
217
+ await sleep(120);
218
+ // Real claude stream-json carries the session's usage totals on the final
219
+ // `result` event (input_tokens / output_tokens / cache_*_input_tokens).
103
220
  out({
104
221
  type: 'result',
105
222
  subtype: 'success',
106
223
  session_id: sid,
107
224
  result: 'Completed (mock): ' + prompt + '. ' + steps.length + ' steps · no API spend.',
225
+ usage: { input_tokens: MOCK_TOKENS.input, output_tokens: MOCK_TOKENS.output },
108
226
  });
109
227
  }
110
228
  // codex exec --json format · thread.started → item.completed events → turn.completed
@@ -123,7 +241,10 @@ async function runCodex() {
123
241
  }
124
242
  if (/linear|github|jira|figma|mcp|issue|ticket/.test(lc)) {
125
243
  await sleep(120);
126
- out({ type: 'item.completed', item: { type: 'mcp_tool_call', server: 'linear', tool: 'get_issue', arguments: { id: 'INFRA-247' } } });
244
+ out({
245
+ type: 'item.completed',
246
+ item: { type: 'mcp_tool_call', server: 'linear', tool: 'get_issue', arguments: { id: 'INFRA-247' } },
247
+ });
127
248
  }
128
249
  const steps = plannedSteps(prompt);
129
250
  for (const s of steps) {
@@ -150,12 +271,18 @@ async function runCodex() {
150
271
  }
151
272
  await sleep(120);
152
273
  out({ type: 'turn.completed', usage: { input_tokens: 1200, output_tokens: 340 } });
153
- out({ type: 'item.completed', item: { type: 'agent_message', text: 'Completed (mock): ' + prompt } });
274
+ out({ type: 'item.completed', item: { type: 'agent_message', text: wrapUpText(steps) } });
154
275
  }
155
276
  // aider · plain-text stdout · "Applied edit to <p>" + "Running <cmd>"
156
277
  async function runAider() {
157
278
  process.stdout.write('Aider v0.x (mock)\n');
158
279
  const steps = plannedSteps(prompt);
280
+ // Real aider narrates BEFORE its edits/commands (its prose precedes the
281
+ // "Applied edit"/"Running" lines; nothing prose-shaped follows the last
282
+ // command — the adapter attributes post-Running lines to that command's
283
+ // output). Mock-currency: same order, so the say capture sees the finding.
284
+ await sleep(140);
285
+ process.stdout.write(wrapUpText(steps).replace(/^Wrote the change to/, 'I will write the change to') + '\n');
159
286
  for (const s of steps) {
160
287
  await sleep(170);
161
288
  if (s.tool === 'Bash')
@@ -166,12 +293,22 @@ async function runAider() {
166
293
  process.stdout.write(String(s.input.file_path) + '\n'); // read → plain mention
167
294
  }
168
295
  await sleep(120);
169
- process.stdout.write('Done (mock): ' + prompt + '\n');
296
+ // Real aider prints a cost summary line at turn end; the adapter's token
297
+ // extraction parses exactly this "Tokens: Nk sent, N received" shape.
298
+ process.stdout.write('Tokens: 1.2k sent, 800 received. Cost: $0.0046 message, $0.0046 session.\n');
170
299
  }
171
- // gemini · JSON tool_use / tool_result (tool_id, tool_name, parameters)
300
+ // gemini · JSON tool_use / tool_result (tool_id, tool_name, parameters).
301
+ // Narration matches the REAL gemini-cli stream: `message` events with
302
+ // role:'assistant' + delta:true chunks (verified against gemini-cli 0.39.1 —
303
+ // assistant messages are ALWAYS delta'd; a `content` event type doesn't
304
+ // exist). Mock-currency rule: the mock emits what the real CLI emits, or
305
+ // mock runs silently exercise dead branches (this gap hid the no-say bug
306
+ // that made every real gemini report degrade to the deterministic fallback).
172
307
  async function runGemini() {
173
308
  out({ type: 'session', session_id: 'mock-gem-' + Math.random().toString(36).slice(2, 8) });
174
309
  await sleep(120);
310
+ out({ type: 'message', role: 'assistant', content: 'Working on it (mock): ', delta: true });
311
+ out({ type: 'message', role: 'assistant', content: prompt.slice(0, 60), delta: true });
175
312
  const steps = plannedSteps(prompt);
176
313
  for (let i = 0; i < steps.length; i++) {
177
314
  const s = steps[i];
@@ -183,7 +320,19 @@ async function runGemini() {
183
320
  out({ type: 'tool_result', tool_id: id, output: s.result });
184
321
  }
185
322
  await sleep(120);
186
- out({ type: 'content', text: 'Completed (mock): ' + prompt });
323
+ out({ type: 'message', role: 'assistant', content: wrapUpText(steps), delta: true });
324
+ // Real gemini carries token usage ONLY on the final success `result` event,
325
+ // under `stats` (the adapter reads input_tokens/output_tokens/cached).
326
+ out({
327
+ type: 'result',
328
+ status: 'success',
329
+ stats: {
330
+ total_tokens: MOCK_TOKENS.input + MOCK_TOKENS.output,
331
+ input_tokens: MOCK_TOKENS.input,
332
+ output_tokens: MOCK_TOKENS.output,
333
+ cached: 0,
334
+ },
335
+ });
187
336
  }
188
337
  // opencode · JSON tool_use with part:{tool,state:{input,output,status}}
189
338
  async function runOpencode() {
@@ -194,10 +343,16 @@ async function runOpencode() {
194
343
  out({ type: 'tool_use', part: { tool, state: { input: s.input, output: s.result, status: 'completed' } } });
195
344
  }
196
345
  await sleep(120);
346
+ // Real opencode carries token usage on step_finish events under
347
+ // part.tokens.{input,output,cache.{read,write}}; the adapter sums them.
348
+ out({
349
+ type: 'step_finish',
350
+ part: { tokens: { input: MOCK_TOKENS.input, output: MOCK_TOKENS.output, cache: { read: 0, write: 0 } } },
351
+ });
197
352
  // Real opencode carries narrative text in part.text (the adapter's summary
198
353
  // extraction and the aggregator's say capture both read that shape) — the
199
354
  // prior top-level `text` field was mock drift no real parser ever saw.
200
- out({ type: 'text', part: { text: 'Completed (mock): ' + prompt } });
355
+ out({ type: 'text', part: { text: wrapUpText(steps) } });
201
356
  }
202
357
  async function main() {
203
358
  if (!prompt)
@@ -19,8 +19,9 @@ import { homedir } from 'os';
19
19
  import { join } from 'path';
20
20
  import { promisify } from 'util';
21
21
  import { ProgressAggregator } from './progress-aggregator.js';
22
- import { resolveAgentSpawn } from './mocks/resolve-agent-spawn.js';
22
+ import { resolveAgentSpawn, isMockMode } from './mocks/resolve-agent-spawn.js';
23
23
  import { applyMemoryPrefix } from './memory-injector.js';
24
+ import { detectOllama } from './detect-ollama.js';
24
25
  import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
25
26
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
26
27
  const execFileAsync = promisify(execFile);
@@ -220,6 +221,22 @@ export function spawnOpenCode(prompt, options = {}) {
220
221
  }
221
222
  };
222
223
  const spawnEnv = { ...process.env, ...(options.env ?? {}) };
224
+ // Cost-bounded by default: when the user hasn't pinned a model and Ollama is
225
+ // serving one locally, route opencode to it (opencode lists it as
226
+ // `ollama/<model>`). This makes opencode a FREE, LOCAL vendor on a machine
227
+ // with Ollama — the same pattern proven on aider (aider-adapter.ts ~314) —
228
+ // so the "vendor-neutral + cost-bounded" promise isn't aider-only. Without
229
+ // it, opencode falls to its config default (e.g. a paid byteplus model),
230
+ // silently spending. Always overridable: --model in extraArgs, or
231
+ // CODEHERE_NO_LOCAL_OLLAMA=1 to opt out.
232
+ const _extra = options.extraArgs ?? [];
233
+ const _modelPinned = _extra.some((a) => a === '--model' || a.startsWith('--model='));
234
+ const _localArgs = [];
235
+ if (!_modelPinned && spawnEnv.CODEHERE_NO_LOCAL_OLLAMA !== '1') {
236
+ const ollama = detectOllama();
237
+ if (ollama.available && ollama.model)
238
+ _localArgs.push('--model', `ollama/${ollama.model}`);
239
+ }
223
240
  let child;
224
241
  try {
225
242
  // OpenCode's `run` expects the message as positional args, not stdin.
@@ -228,7 +245,7 @@ export function spawnOpenCode(prompt, options = {}) {
228
245
  // parseOpenCodeSessionIdByTitle for the why — opencode buffers the
229
246
  // ses_* id until LLM completion, so we can't capture it mid-stream).
230
247
  const titleTag = `${CODEHERE_TITLE_PREFIX}${sessionId}`;
231
- const sp = resolveAgentSpawn('opencode', 'opencode', [...HEADLESS_FLAGS, '--title', titleTag, finalPrompt, ...(options.extraArgs ?? [])], finalPrompt);
248
+ const sp = resolveAgentSpawn('opencode', 'opencode', [...HEADLESS_FLAGS, '--title', titleTag, ..._localArgs, finalPrompt, ...(options.extraArgs ?? [])], finalPrompt);
232
249
  child = spawn(sp.command, sp.args, {
233
250
  cwd,
234
251
  env: spawnEnv,
@@ -268,6 +285,7 @@ export function spawnOpenCode(prompt, options = {}) {
268
285
  // CODEHERE_RECORD_CASSETTES=1, so zero overhead by default.
269
286
  const recorder = startRecording({ adapter: 'opencode', sessionId, prompt: finalPrompt, cwd });
270
287
  let stdoutBuf = '';
288
+ let eventsParsed = 0;
271
289
  child.stdout?.on('data', (chunk) => {
272
290
  const text = chunk.toString('utf-8');
273
291
  recorder.onStdout(text);
@@ -280,9 +298,13 @@ export function spawnOpenCode(prompt, options = {}) {
280
298
  continue;
281
299
  try {
282
300
  const data = JSON.parse(line);
301
+ eventsParsed++;
283
302
  emit({ type: data.type ?? 'opencode_event', timestamp: Date.now(), data });
284
303
  }
285
304
  catch {
305
+ // Non-JSON output still counts as observed work — the no-op guard below
306
+ // must fire only when opencode produced NOTHING at all (refuter 2026-07-18).
307
+ eventsParsed++;
286
308
  emit({ type: 'stdout', timestamp: Date.now(), data: { line } });
287
309
  }
288
310
  }
@@ -295,23 +317,63 @@ export function spawnOpenCode(prompt, options = {}) {
295
317
  emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
296
318
  }
297
319
  });
298
- child.on('exit', (code, signal) => {
320
+ // 'close', not 'exit' — opencode buffers stdout until the LLM call completes,
321
+ // so the final burst can be delivered AFTER 'exit' fires. 'close' waits for
322
+ // all stdio to drain, which the zero-events guard below depends on (refuter
323
+ // 2026-07-18: checking in 'exit' could flip a true success to error).
324
+ child.on('close', (code, signal) => {
299
325
  recorder.onExit(code ?? -1, signal);
300
- statusValue =
301
- killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
302
- if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {
303
- stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
304
- emit({ type: 'codehere:steps', timestamp: Date.now(), data: { steps: stepsSnapshot } });
305
- }
306
- if (statusValue === 'completed') {
326
+ // Flush a final line that arrived without a trailing newline — it would
327
+ // otherwise sit unparsed in stdoutBuf and read as "no output".
328
+ const remnant = stdoutBuf.trim();
329
+ if (remnant) {
330
+ stdoutBuf = '';
307
331
  try {
308
- artifactsSnapshot = { ...extractArtifacts(snapshot, textAccum), usage: usageSnapshot };
309
- emit({ type: 'codehere:artifacts', timestamp: Date.now(), data: artifactsSnapshot });
332
+ const data = JSON.parse(remnant);
333
+ eventsParsed++;
334
+ emit({ type: data.type ?? 'opencode_event', timestamp: Date.now(), data });
310
335
  }
311
336
  catch {
312
- /* best-effort */
337
+ eventsParsed++;
338
+ emit({ type: 'stdout', timestamp: Date.now(), data: { line: remnant } });
313
339
  }
314
340
  }
341
+ // Trust the PROCESS's own exit code for success. The catch-all error-pattern
342
+ // scan above flips `errorEmitted` on any transient "rate limit"/"error" token
343
+ // anywhere in the stream — which falsely marked a SUCCESSFUL local-model run
344
+ // as a failure (opencode read+edited the file, said "I've added…", exited 0).
345
+ // For a trust product, lying that a success failed is the worst bug. So a
346
+ // clean exit (code 0, not killed) = completed; errorEmitted only carries the
347
+ // reason for a genuine non-zero failure.
348
+ statusValue = killRequested || signal !== null ? 'killed' : code === 0 ? 'completed' : 'error';
349
+ // BUG-8 (E2E 3.4) · the inverse lie: exit 0 with ZERO observed output lines
350
+ // means opencode did nothing observable. "Done" over a no-op misleads worse
351
+ // than an honest failure.
352
+ if (statusValue === 'completed' && eventsParsed === 0) {
353
+ statusValue = 'error';
354
+ emit({
355
+ type: 'error',
356
+ timestamp: Date.now(),
357
+ data: {
358
+ message: 'opencode exited cleanly but reported no work at all (no events, no output). ' +
359
+ 'Usually a provider/model config issue — try `opencode run "say hi"` in this folder to see the real behavior.',
360
+ },
361
+ });
362
+ }
363
+ if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {
364
+ stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
365
+ emit({ type: 'codehere:steps', timestamp: Date.now(), data: { steps: stepsSnapshot } });
366
+ }
367
+ // Capture artifacts ALWAYS, not only on completion — a failed run that still
368
+ // edited files must report those files, never "no files changed" (the second
369
+ // half of the same lie). The honesty layer downstream needs the real diff.
370
+ try {
371
+ artifactsSnapshot = { ...extractArtifacts(snapshot, textAccum), usage: usageSnapshot };
372
+ emit({ type: 'codehere:artifacts', timestamp: Date.now(), data: artifactsSnapshot });
373
+ }
374
+ catch {
375
+ /* best-effort */
376
+ }
315
377
  emit({
316
378
  type: 'exit',
317
379
  timestamp: Date.now(),
@@ -390,6 +452,10 @@ export function spawnOpenCode(prompt, options = {}) {
390
452
  // gemini adapter's choice (and is well above any LLM-tied internal
391
453
  // timer opencode might have).
392
454
  const timeoutMs = Number(process.env.CODEHERE_OPENCODE_LIST_TIMEOUT_MS) || 15000;
455
+ // S9 · never execute the real binary under mock — the session-id
456
+ // refinement is meaningless against the mock REPL anyway.
457
+ if (isMockMode())
458
+ throw new Error('mock mode: skip real session list');
393
459
  const { stdout: listOut } = await execFileAsync('opencode', ['session', 'list', '--format', 'json'], {
394
460
  cwd,
395
461
  env: spawnEnv,
@@ -410,7 +476,11 @@ export function spawnOpenCode(prompt, options = {}) {
410
476
  return new Promise((resolve, reject) => {
411
477
  let stdout = '';
412
478
  let stderr = '';
413
- const followup = spawn('opencode', [...HEADLESS_FLAGS, ...resumeArgs, text], {
479
+ // S9 · route follow-ups through the mock seam too — under
480
+ // CODEHERE_MOCK=1 this hits the mock REPL, not real opencode.
481
+ // (Was a real-spend leak on reply; mirrors the codex fix.)
482
+ const sp2 = resolveAgentSpawn('opencode', 'opencode', [...HEADLESS_FLAGS, ...resumeArgs, text], text);
483
+ const followup = spawn(sp2.command, sp2.args, {
414
484
  cwd,
415
485
  env: spawnEnv,
416
486
  stdio: ['ignore', 'pipe', 'pipe'],