codehere 0.6.2 → 0.7.0

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 (70) hide show
  1. package/README.md +5 -3
  2. package/dist/application/use-cases/domain-trust.js +110 -0
  3. package/dist/application/use-cases/generate-final-report.js +28 -4
  4. package/dist/application/use-cases/generate-narration.js +11 -0
  5. package/dist/application/use-cases/generate-thread-title.js +4 -0
  6. package/dist/application/use-cases/thread-export.js +155 -0
  7. package/dist/application/use-cases/verify-claim.js +274 -0
  8. package/dist/application/use-cases/write-pass.js +277 -0
  9. package/dist/index.js +211 -16
  10. package/dist/infrastructure/completion/bash-completion.js +2 -2
  11. package/dist/infrastructure/config/config-manager.js +1 -1
  12. package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
  13. package/dist/infrastructure/context/adapters/figma-source.js +163 -0
  14. package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
  15. package/dist/infrastructure/context/adapters/linear-source.js +190 -0
  16. package/dist/infrastructure/context/context-resolver.js +144 -0
  17. package/dist/infrastructure/context/context-source.js +114 -0
  18. package/dist/infrastructure/context/oauth-flow.js +207 -0
  19. package/dist/infrastructure/context/oauth-store.js +119 -0
  20. package/dist/infrastructure/context/registry-bootstrap.js +99 -0
  21. package/dist/infrastructure/context/write-pass-resolver.js +106 -0
  22. package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
  23. package/dist/infrastructure/hooks/session-analyzer.js +65 -8
  24. package/dist/infrastructure/observability/execution-tracer.js +0 -5
  25. package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
  26. package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
  27. package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
  28. package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
  29. package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
  30. package/dist/infrastructure/orchestration/context-handoff.js +3 -2
  31. package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
  32. package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
  33. package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
  34. package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
  35. package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
  36. package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
  37. package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
  38. package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
  39. package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
  40. package/dist/infrastructure/orchestration/session-manager.js +94 -6
  41. package/dist/infrastructure/orchestration/session-persistence.js +139 -30
  42. package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
  43. package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
  44. package/dist/infrastructure/protocols/cloud-sync.js +1 -6
  45. package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
  46. package/dist/infrastructure/protocols/thread-phase.js +105 -0
  47. package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
  48. package/dist/infrastructure/protocols/ui-page.js +12 -3299
  49. package/dist/infrastructure/protocols/verify-page.js +941 -0
  50. package/dist/infrastructure/security/trust-scorer.js +76 -1
  51. package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
  52. package/dist/infrastructure/storage/project-thread-migration.js +2 -2
  53. package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
  54. package/dist/monitoring.js +1 -1
  55. package/dist/presentation/cli/commands/audit-command.js +126 -0
  56. package/dist/presentation/cli/commands/config-command.js +2 -2
  57. package/dist/presentation/cli/commands/context-source-command.js +198 -0
  58. package/dist/presentation/cli/commands/help-command.js +51 -1
  59. package/dist/presentation/cli/commands/report-command.js +6 -4
  60. package/dist/presentation/cli/commands/setup-command.js +71 -18
  61. package/dist/presentation/cli/commands/status-command.js +0 -4
  62. package/dist/presentation/cli/commands/thread-command.js +288 -0
  63. package/dist/presentation/cli/commands/verify-command.js +233 -0
  64. package/dist/presentation/cli/error-display.js +1 -1
  65. package/dist/presentation/cli/repl.js +32 -1
  66. package/dist/presentation/cli/task-deliverable.js +127 -0
  67. package/dist/testing/cassette.js +125 -0
  68. package/dist/testing/replay-adapter.js +166 -0
  69. package/dist/ui.js +18 -1
  70. package/package.json +4 -2
@@ -15,6 +15,8 @@
15
15
  */
16
16
  import { spawn } from 'child_process';
17
17
  import { execFileSync } from 'child_process';
18
+ import { startRecording } from './cassette-recorder.js';
19
+ import { resolveAgentSpawn } from './mocks/resolve-agent-spawn.js';
18
20
  import { EventEmitter } from 'events';
19
21
  import { randomUUID } from 'crypto';
20
22
  import { ProgressAggregator } from './progress-aggregator.js';
@@ -51,11 +53,16 @@ function scanForKnownError(text) {
51
53
  }
52
54
  return null;
53
55
  }
56
+ // v0.7 MED-2 fix — dropped `--include-partial-messages` and
57
+ // `--include-hook-events` from the headless flags. Per docs/contracts/claude-code.md
58
+ // §7 the adapter doesn't consume partial-message deltas (only full assistant
59
+ // blocks) and doesn't surface hook events in any UI. Both flags inflated
60
+ // stream volume + token cost without benefit. If we later need either
61
+ // (e.g., to wire a hook visualization in v0.8), re-add with explicit
62
+ // parsing intent.
54
63
  const HEADLESS_FLAGS = [
55
64
  '--output-format',
56
65
  'stream-json',
57
- '--include-partial-messages',
58
- '--include-hook-events',
59
66
  '--verbose',
60
67
  // Pre-grant common tools so -p mode doesn't dead-end at interactive permission
61
68
  // prompts we cannot surface to the user. Safety is still enforced by codehere's
@@ -183,13 +190,21 @@ export function spawnClaudeCode(prompt, options = {}) {
183
190
  }
184
191
  let child;
185
192
  try {
186
- child = spawn('claude', ['-p', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
193
+ const sp = resolveAgentSpawn('claude-code', 'claude', [
194
+ '-p',
195
+ finalPrompt,
196
+ ...HEADLESS_FLAGS,
197
+ ...(options.extraArgs ?? []),
198
+ ]);
199
+ child = spawn(sp.command, sp.args, {
187
200
  cwd,
188
201
  env: spawnEnv,
189
202
  stdio: ['ignore', 'pipe', 'pipe'],
190
203
  });
191
204
  }
192
205
  catch (err) {
206
+ // Recorder is short-circuit no-op when CODEHERE_RECORD_CASSETTES != '1'
207
+ // so the spawn-failure path doesn't even pay the closure cost.
193
208
  statusValue = 'error';
194
209
  const msg = err instanceof Error ? err.message : String(err);
195
210
  // Emit synchronously so subscribers added immediately after see it
@@ -219,6 +234,16 @@ export function spawnClaudeCode(prompt, options = {}) {
219
234
  },
220
235
  };
221
236
  }
237
+ // ── Organic cassette recording (S2b · capture-first plan) ─────────────────
238
+ // No-op unless CODEHERE_RECORD_CASSETTES=1 · zero overhead by default.
239
+ // Captures the raw byte stream + exit so a future replay can reproduce
240
+ // this exact session through the mock adapter without a live agent call.
241
+ const recorder = startRecording({
242
+ adapter: 'claude-code',
243
+ sessionId,
244
+ prompt: finalPrompt,
245
+ cwd,
246
+ });
222
247
  // ── stdout: newline-delimited JSON from `--output-format stream-json` ────────
223
248
  let stdoutBuf = '';
224
249
  // Iter 13 — capture the claude-internal session_id from the init event so
@@ -228,7 +253,9 @@ export function spawnClaudeCode(prompt, options = {}) {
228
253
  // headless API can't replay).
229
254
  let claudeSessionId;
230
255
  child.stdout?.on('data', (chunk) => {
231
- stdoutBuf += chunk.toString('utf-8');
256
+ const text = chunk.toString('utf-8');
257
+ recorder.onStdout(text);
258
+ stdoutBuf += text;
232
259
  let idx;
233
260
  while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
234
261
  const line = stdoutBuf.slice(0, idx).trim();
@@ -254,12 +281,14 @@ export function spawnClaudeCode(prompt, options = {}) {
254
281
  // ── stderr: Claude prints debug/errors here; forward raw ─────────────────────
255
282
  child.stderr?.on('data', (chunk) => {
256
283
  const text = chunk.toString('utf-8');
284
+ recorder.onStderr(text);
257
285
  for (const line of text.split('\n')) {
258
286
  if (line.trim())
259
287
  emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
260
288
  }
261
289
  });
262
290
  child.on('exit', (code, signal) => {
291
+ recorder.onExit(code ?? -1, signal);
263
292
  statusValue =
264
293
  killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
265
294
  // Emit synthetic PM events BEFORE 'exit' so the client has time to render
@@ -16,10 +16,12 @@
16
16
  import { spawn, execFileSync } from 'child_process';
17
17
  import { EventEmitter } from 'events';
18
18
  import { randomUUID } from 'crypto';
19
+ import { startRecording } from './cassette-recorder.js';
19
20
  import { ProgressAggregator } from './progress-aggregator.js';
20
21
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
21
22
  import { applyMemoryPrefix } from './memory-injector.js';
22
23
  import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
24
+ import { resolveAgentSpawn } from './mocks/resolve-agent-spawn.js';
23
25
  const HEADLESS_FLAGS = [
24
26
  'exec',
25
27
  '--json',
@@ -98,37 +100,158 @@ export function spawnCodex(prompt, options = {}) {
98
100
  }
99
101
  // Collect text for test-summary parsing (Layer 3). Codex surfaces command
100
102
  // output via item.completed.aggregated_output and agent messages via item.text.
103
+ //
104
+ // v0.7 CRIT-1 fix — extended item subtypes parsed:
105
+ // - command_execution (Bash) — existing, mirrored to audit
106
+ // - agent_message (assistant text) — existing
107
+ // - file_change (Edit/Write batch) — NEW, mirrored as Edit per file
108
+ // - mcp_tool_call (MCP servers) — NEW, mirrored as Tool with server prefix
109
+ // - collab_tool_call (SUBAGENT FAN-OUT, multi-agent v2 GA 2026-05-18) — NEW
110
+ // · spawn_agent / send_input / wait / close_agent / resume_agent
111
+ // · mirrored to audit as "Subagent" toolName so /history shows the
112
+ // child-spawn activity. Without this, multi-agent Codex tasks fall
113
+ // silently through (audit + cost claim breach per CRIT-1).
114
+ // · Token usage of children remains UNDER-REPORTED in this fix —
115
+ // child usage lives on a separate app-server stream we don't yet
116
+ // subscribe to. Disclosed honestly in docs/contracts/00-FINDINGS.md.
117
+ // - todo_list — NEW, fed through as a synthetic 'todo' tool call (lets
118
+ // ProgressAggregator surface plan changes for PM Layer 2 UX parity)
119
+ // - web_search — NEW, mirrored as WebSearch tool
120
+ // - reasoning — NOT mirrored (private CoT trace; not an action)
121
+ // - error (item) — NEW, surfaced as codehere:error for non-fatal errors
101
122
  if (event.type === 'item.completed') {
102
123
  const item = event.data?.item;
103
- if (item?.type === 'command_execution' && item.aggregated_output)
104
- textAccum += item.aggregated_output + '\n';
105
- if (item?.type === 'agent_message' && item.text)
106
- textAccum += item.text + '\n';
107
- // #110 slice 2 — mirror tool calls to the audit log so codex
108
- // gets the same audit-trail surface as Claude's per-tool hook.
109
- // Post-hoc only (no block); pre-tool intercept is a future slice.
110
- if (item?.type === 'command_execution' && typeof item.command === 'string' && item.command) {
124
+ if (!item?.type) {
125
+ // unknown shape — let it pass for textAccum below
126
+ }
127
+ else if (item.type === 'command_execution') {
128
+ if (item.aggregated_output)
129
+ textAccum += item.aggregated_output + '\n';
130
+ if (typeof item.command === 'string' && item.command) {
131
+ mirrorToolCallToAudit({
132
+ sessionId,
133
+ agent: 'codex',
134
+ toolName: 'Bash',
135
+ toolInput: item.command,
136
+ cwd,
137
+ });
138
+ }
139
+ }
140
+ else if (item.type === 'agent_message') {
141
+ if (item.text)
142
+ textAccum += item.text + '\n';
143
+ }
144
+ else if (item.type === 'file_change') {
145
+ // One audit entry per changed file. Codehere-cased toolName per
146
+ // /history filter convention (matches Claude Edit/Write rows).
147
+ for (const change of item.changes ?? []) {
148
+ const path = String(change.path ?? '');
149
+ const kind = String(change.kind ?? 'update');
150
+ if (!path)
151
+ continue;
152
+ const toolName = kind === 'add' ? 'Write' : kind === 'delete' ? 'Delete' : 'Edit';
153
+ mirrorToolCallToAudit({
154
+ sessionId,
155
+ agent: 'codex',
156
+ toolName,
157
+ toolInput: path,
158
+ cwd,
159
+ });
160
+ }
161
+ }
162
+ else if (item.type === 'mcp_tool_call') {
163
+ const server = String(item.server ?? '');
164
+ const tool = String(item.tool ?? 'mcp');
111
165
  mirrorToolCallToAudit({
112
166
  sessionId,
113
167
  agent: 'codex',
114
- toolName: 'Bash',
115
- toolInput: item.command,
168
+ toolName: `MCP.${server}.${tool}`,
169
+ toolInput: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {}).slice(0, 500),
116
170
  cwd,
117
171
  });
118
172
  }
173
+ else if (item.type === 'collab_tool_call') {
174
+ // SUBAGENT FAN-OUT — the v0.7 fix that closes CRIT-1's audit gap.
175
+ // Mirror every collab call to the audit log so /history shows the
176
+ // subagent activity. Tool naming convention: "Subagent.<tool>".
177
+ const collabTool = String(item.tool ?? 'collab');
178
+ const receivers = (item.receiver_thread_ids ?? []).join(',');
179
+ const promptExcerpt = item.prompt ? String(item.prompt).slice(0, 200) : '';
180
+ const auditPayload = JSON.stringify({
181
+ tool: collabTool,
182
+ sender: item.sender_thread_id,
183
+ receivers,
184
+ prompt: promptExcerpt,
185
+ agents_states: item.agents_states ?? {},
186
+ }).slice(0, 1000);
187
+ mirrorToolCallToAudit({
188
+ sessionId,
189
+ agent: 'codex',
190
+ toolName: `Subagent.${collabTool}`,
191
+ toolInput: auditPayload,
192
+ cwd,
193
+ });
194
+ }
195
+ else if (item.type === 'todo_list') {
196
+ // Feed into textAccum for Final Report visibility. Future v0.8: wire
197
+ // into ProgressAggregator for PM Layer 2 parity with Claude TodoWrite.
198
+ const items = (item.items ?? []).map((t) => `- [${t.completed ? 'x' : ' '}] ${t.text ?? ''}`).join('\n');
199
+ if (items)
200
+ textAccum += `Todo list:\n${items}\n`;
201
+ }
202
+ else if (item.type === 'web_search') {
203
+ mirrorToolCallToAudit({
204
+ sessionId,
205
+ agent: 'codex',
206
+ toolName: 'WebSearch',
207
+ toolInput: String(item.query ?? ''),
208
+ cwd,
209
+ });
210
+ }
211
+ else if (item.type === 'error') {
212
+ // Non-fatal item-level error (distinct from top-level `error` + `turn.failed`).
213
+ // Don't flip errorEmitted — caller may still complete successfully.
214
+ if (item.message)
215
+ textAccum += `[error] ${item.message}\n`;
216
+ }
217
+ // reasoning: intentionally not mirrored (private trace, not an action)
119
218
  }
120
219
  // Accumulate token usage across all turns (one turn.completed per turn).
220
+ // v0.7 MED-3 parity — also sum reasoning_output_tokens into outputTokens.
121
221
  if (event.type === 'turn.completed') {
122
222
  const u = event.data?.usage;
123
223
  if (u) {
124
224
  const acc = usageSnapshot ?? { inputTokens: 0, outputTokens: 0 };
125
225
  acc.inputTokens += Number(u.input_tokens ?? 0);
126
226
  acc.outputTokens += Number(u.output_tokens ?? 0);
227
+ // Reasoning tokens billed at output rate per OpenAI pricing — fold in.
228
+ if (u.reasoning_output_tokens)
229
+ acc.outputTokens += Number(u.reasoning_output_tokens);
127
230
  if (u.cached_input_tokens)
128
231
  acc.cacheReadTokens = (acc.cacheReadTokens ?? 0) + Number(u.cached_input_tokens);
129
232
  usageSnapshot = acc;
130
233
  }
131
234
  }
235
+ // v0.7 MED-1 — explicit handler for turn.failed. Previously caught only by
236
+ // the catch-all stringify scan below (load-bearing accident). Now the
237
+ // canonical wire surface for API errors is parsed directly.
238
+ if (event.type === 'turn.failed' && !errorEmitted) {
239
+ const errObj = event.data?.error;
240
+ const message = String(errObj?.message ?? '');
241
+ if (message) {
242
+ const hit = scanForKnownError(message);
243
+ if (hit) {
244
+ errorEmitted = true;
245
+ const errEvent = {
246
+ type: 'codehere:error',
247
+ timestamp: Date.now(),
248
+ data: { code: hit.code, message: hit.message, recovery: hit.recovery },
249
+ };
250
+ buffer.push(errEvent);
251
+ emitter.emit('event', errEvent);
252
+ }
253
+ }
254
+ }
132
255
  if (!errorEmitted) {
133
256
  const data = event.data;
134
257
  let text = '';
@@ -139,7 +262,9 @@ export function spawnCodex(prompt, options = {}) {
139
262
  text = data.line;
140
263
  }
141
264
  else if (data && typeof data === 'object') {
142
- // Catch-all: scan any string fields for known error patterns
265
+ // Catch-all (kept for stream-level `error` events + any future shape we
266
+ // don't have an explicit handler for). MED-1's explicit turn.failed
267
+ // handler above runs first; this is the safety net.
143
268
  text = JSON.stringify(data);
144
269
  }
145
270
  if (text) {
@@ -163,7 +288,8 @@ export function spawnCodex(prompt, options = {}) {
163
288
  // Codex expects the prompt via stdin when stdin is piped; passing as argv
164
289
  // while stdin is 'ignore' causes codex to hang waiting for EOF → exit 0
165
290
  // with no task run. Pipe stdin, write prompt, close.
166
- child = spawn('codex', [...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
291
+ const sp = resolveAgentSpawn('codex', 'codex', [...HEADLESS_FLAGS, ...(options.extraArgs ?? [])]);
292
+ child = spawn(sp.command, sp.args, {
167
293
  cwd,
168
294
  env: spawnEnv,
169
295
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -205,9 +331,14 @@ export function spawnCodex(prompt, options = {}) {
205
331
  // multi-turn continuity. Same pattern as claude-code-adapter capturing
206
332
  // session_id from the init event for `claude --resume`.
207
333
  let codexThreadId;
334
+ // Organic cassette recording (S2b · capture-first plan) · no-op unless
335
+ // CODEHERE_RECORD_CASSETTES=1, so zero overhead by default.
336
+ const recorder = startRecording({ adapter: 'codex', sessionId, prompt: finalPrompt, cwd });
208
337
  let stdoutBuf = '';
209
338
  child.stdout?.on('data', (chunk) => {
210
- stdoutBuf += chunk.toString('utf-8');
339
+ const text = chunk.toString('utf-8');
340
+ recorder.onStdout(text);
341
+ stdoutBuf += text;
211
342
  let idx;
212
343
  while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
213
344
  const line = stdoutBuf.slice(0, idx).trim();
@@ -228,12 +359,14 @@ export function spawnCodex(prompt, options = {}) {
228
359
  });
229
360
  child.stderr?.on('data', (chunk) => {
230
361
  const text = chunk.toString('utf-8');
362
+ recorder.onStderr(text);
231
363
  for (const line of text.split('\n')) {
232
364
  if (line.trim())
233
365
  emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
234
366
  }
235
367
  });
236
368
  child.on('exit', (code, signal) => {
369
+ recorder.onExit(code ?? -1, signal);
237
370
  statusValue =
238
371
  killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
239
372
  // PM events BEFORE 'exit' — see claude-code-adapter for rationale
@@ -52,7 +52,7 @@ function loadAllPacks(cwd) {
52
52
  * 2. Same workspace, any pack (most recent)
53
53
  * 3. Fallback: no handoff
54
54
  */
55
- export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_AGE_MS, minNodes = DEFAULT_MIN_NODES } = {}) {
55
+ export function loadHandoffContext(cwd, { maxAgeMs = DEFAULT_MAX_AGE_MS, minNodes = DEFAULT_MIN_NODES } = {}) {
56
56
  const now = Date.now();
57
57
  const packs = loadAllPacks(cwd);
58
58
  // Filter by age
@@ -92,10 +92,11 @@ export function enrichPromptWithHandoff(prompt, handoff) {
92
92
  if (!handoff)
93
93
  return prompt;
94
94
  const handoffLine = `[handoff: agent=${handoff.agent}, pack=${handoff.packId}, ${handoff.nodeCount} nodes, ${handoff.edgeCount} edges]`;
95
- const preview = handoff.payloadPreview ? ` ${handoff.payloadPreview.slice(0, 800)}` : '';
95
+ const preview = handoff.payloadPreview ? handoff.payloadPreview.slice(0, 800) : '';
96
96
  return [
97
97
  `[Previous session context — ${handoffLine}]`,
98
98
  `This workspace has recent structural context from a prior session.`,
99
+ ...(preview ? [`Recent context: ${preview}`] : []),
99
100
  `For details, refer to the knowledge graph at the workspace root.`,
100
101
  `---`,
101
102
  prompt,
@@ -0,0 +1,99 @@
1
+ /**
2
+ * CodexControlSurface · drives a running codex session via the app-server
3
+ * JSON-RPC protocol (newline-delimited over stdio). inject() = `turn/steer`,
4
+ * which pushes a message into the running turn.
5
+ *
6
+ * Under CODEHERE_MOCK=1 it spawns the mock app-server (zero API, no rate-limit);
7
+ * otherwise the real `codex app-server`. The protocol is identical — the real
8
+ * handshake was verified 2026-05-25; the mock lets us build + test the inject
9
+ * path without spending tokens or waiting on the rate-limit.
10
+ */
11
+ import { spawn } from 'child_process';
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, join } from 'path';
14
+ import { isMockMode } from '../mocks/resolve-agent-spawn.js';
15
+ function serverSpawn() {
16
+ if (isMockMode()) {
17
+ // Env override lets tests point at the compiled dist mock (import.meta.url
18
+ // is the src .ts under vitest, where the .js sibling doesn't exist).
19
+ const mockPath = process.env.CODEHERE_MOCK_CODEX_SERVER ||
20
+ join(dirname(fileURLToPath(import.meta.url)), '..', 'mocks', 'mock-codex-app-server.js');
21
+ return { command: process.execPath, args: [mockPath] };
22
+ }
23
+ return { command: 'codex', args: ['app-server'] };
24
+ }
25
+ export class CodexControlSurface {
26
+ agentId = 'codex';
27
+ proc = null;
28
+ buf = '';
29
+ nextId = 1;
30
+ pending = new Map();
31
+ listeners = [];
32
+ threadId = '';
33
+ start() {
34
+ if (this.proc)
35
+ return;
36
+ const { command, args } = serverSpawn();
37
+ this.proc = spawn(command, args, { stdio: ['pipe', 'pipe', 'inherit'] });
38
+ this.proc.stdout?.on('data', (d) => this.onData(d.toString()));
39
+ }
40
+ onData(chunk) {
41
+ this.buf += chunk;
42
+ let nl;
43
+ while ((nl = this.buf.indexOf('\n')) >= 0) {
44
+ const line = this.buf.slice(0, nl).trim();
45
+ this.buf = this.buf.slice(nl + 1);
46
+ if (!line)
47
+ continue;
48
+ let m;
49
+ try {
50
+ m = JSON.parse(line);
51
+ }
52
+ catch {
53
+ continue;
54
+ }
55
+ if (typeof m.id === 'number' && this.pending.has(m.id)) {
56
+ this.pending.get(m.id)(m.result ?? m.error);
57
+ this.pending.delete(m.id);
58
+ }
59
+ else if (m.method) {
60
+ const evt = { method: m.method, params: m.params };
61
+ for (const cb of this.listeners)
62
+ cb(evt);
63
+ }
64
+ }
65
+ }
66
+ rpc(method, params) {
67
+ this.start();
68
+ return new Promise((resolve) => {
69
+ const id = this.nextId++;
70
+ this.pending.set(id, resolve);
71
+ this.proc.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n');
72
+ });
73
+ }
74
+ async open(prompt) {
75
+ await this.rpc('initialize', { clientInfo: { name: 'codehere', version: '0.6.2' } });
76
+ const th = (await this.rpc('thread/start', { cwd: process.cwd() }));
77
+ this.threadId = String(th?.thread_id ?? '');
78
+ // Fire the initial turn (don't await — it streams; inject() steers it).
79
+ void this.rpc('turn/start', { thread_id: this.threadId, input: prompt });
80
+ return { threadId: this.threadId };
81
+ }
82
+ async inject(message) {
83
+ const r = (await this.rpc('turn/steer', { thread_id: this.threadId, input: message }));
84
+ return { accepted: r?.accepted === true };
85
+ }
86
+ onEvent(cb) {
87
+ this.listeners.push(cb);
88
+ }
89
+ close() {
90
+ try {
91
+ this.proc?.kill();
92
+ }
93
+ catch {
94
+ /* best-effort */
95
+ }
96
+ this.proc = null;
97
+ }
98
+ }
99
+ //# sourceMappingURL=codex-control-surface.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * ControlSurface · the vendor-neutral "drive another agent" abstraction.
3
+ *
4
+ * The POC (agent/test/evidence/poc/control-channel/) proved the cross-agent
5
+ * finding: each agent exposes a DIFFERENT control surface (codex app-server
6
+ * turn/steer · claude --channels · etc.), so codehere's control layer is one
7
+ * interface implemented per-adapter — exactly the adapter pattern codehere
8
+ * already uses for spawning.
9
+ *
10
+ * `inject(message)` is the core primitive: push a message INTO a running agent
11
+ * session (the remote-control headline). Today: codex app-server. Next: claude
12
+ * channel.
13
+ */
14
+ export {};
15
+ //# sourceMappingURL=control-surface.js.map
@@ -29,6 +29,7 @@
29
29
  * Kills the process group (negative pid) so subagents under gemini exit too.
30
30
  */
31
31
  import { spawn, execFile, execFileSync } from 'child_process';
32
+ import { startRecording } from './cassette-recorder.js';
32
33
  import { promisify } from 'util';
33
34
  const execFileAsync = promisify(execFile);
34
35
  import { EventEmitter } from 'events';
@@ -37,6 +38,7 @@ import { existsSync } from 'fs';
37
38
  import { homedir } from 'os';
38
39
  import { join } from 'path';
39
40
  import { ProgressAggregator } from './progress-aggregator.js';
41
+ import { resolveAgentSpawn } from './mocks/resolve-agent-spawn.js';
40
42
  import { applyMemoryPrefix } from './memory-injector.js';
41
43
  import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
42
44
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
@@ -247,7 +249,8 @@ export function spawnGemini(prompt, options = {}) {
247
249
  };
248
250
  let child;
249
251
  try {
250
- child = spawn('gemini', ['-p', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], {
252
+ const sp = resolveAgentSpawn('gemini', 'gemini', ['-p', finalPrompt, ...HEADLESS_FLAGS, ...(options.extraArgs ?? [])], finalPrompt);
253
+ child = spawn(sp.command, sp.args, {
251
254
  cwd,
252
255
  env: spawnEnv,
253
256
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -292,8 +295,13 @@ export function spawnGemini(prompt, options = {}) {
292
295
  // 'latest', NOT the UUID itself). See parseGeminiSessionIndexForUuid +
293
296
  // the appendMessage block below.
294
297
  let geminiSessionUuid = null;
298
+ // Organic cassette recording (S2b · capture-first plan) · no-op unless
299
+ // CODEHERE_RECORD_CASSETTES=1, so zero overhead by default.
300
+ const recorder = startRecording({ adapter: 'gemini', sessionId, prompt: finalPrompt, cwd });
295
301
  child.stdout?.on('data', (chunk) => {
296
- stdoutBuf += chunk.toString('utf-8');
302
+ const text = chunk.toString('utf-8');
303
+ recorder.onStdout(text);
304
+ stdoutBuf += text;
297
305
  let idx;
298
306
  while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
299
307
  const line = stdoutBuf.slice(0, idx).trim();
@@ -314,6 +322,7 @@ export function spawnGemini(prompt, options = {}) {
314
322
  });
315
323
  child.stderr?.on('data', (chunk) => {
316
324
  const text = chunk.toString('utf-8');
325
+ recorder.onStderr(text);
317
326
  for (const line of text.split('\n')) {
318
327
  if (line.trim())
319
328
  emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
@@ -322,6 +331,7 @@ export function spawnGemini(prompt, options = {}) {
322
331
  // Use `close` (after all stdio has drained), not `exit` (which can fire while
323
332
  // unread stdout is still in-flight). Flush any final un-newlined buffered line.
324
333
  child.on('close', (code, signal) => {
334
+ recorder.onExit(code ?? -1, signal);
325
335
  if (stdoutBuf.trim()) {
326
336
  const line = stdoutBuf.trim();
327
337
  stdoutBuf = '';