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
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Mock agent REPL · the synthetic subprocess codehere spawns under CODEHERE_MOCK=1
3
+ * INSTEAD of a real agent CLI. Zero API spend, zero rate limits — lets us build +
4
+ * dogfood the REAL execution path (spawn → tool calls → audit → artifacts → Final
5
+ * Report) and remote-control against a stand-in agent.
6
+ *
7
+ * Run as: node mock-agent-repl.js --agent <id> -p "<prompt>" [...ignored flags]
8
+ *
9
+ * Each agent emits its OWN CLI output format so the matching adapter parses it
10
+ * unchanged. claude-code → newline-delimited stream-json. (codex/aider/gemini/
11
+ * opencode formats added as their adapters get wired.)
12
+ *
13
+ * Honest scope: synthetic, deterministic-ish output derived from the prompt. It
14
+ * is NOT a real model — it produces plausible steps + a summary so the pipeline
15
+ * runs end-to-end. For replaying REAL captured runs, that's the cassette system.
16
+ */
17
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
18
+ import { join, isAbsolute, basename } from 'path';
19
+ function argVal(flag) {
20
+ const i = process.argv.indexOf(flag);
21
+ return i >= 0 && i + 1 < process.argv.length ? process.argv[i + 1] : undefined;
22
+ }
23
+ /** The file the mock claims to edit — derived from the prompt, used for both
24
+ * the emitted step AND the real on-disk edit so they stay consistent. */
25
+ function targetFile(p) {
26
+ return /(\b[\w./-]+\.(ts|tsx|js|py|go|rs|md))\b/.exec(p)?.[1] || 'src/feature.ts';
27
+ }
28
+ /** CODEHERE_MOCK fidelity: actually apply a small, safe edit so the REAL
29
+ * artifacts pipeline (git diff → trust score → Final Report → verify) has a
30
+ * genuine change to report. The demo's "1 file changed · trust" stays honest —
31
+ * it is a real diff on disk, just authored by the mock, not a model. */
32
+ function materializeEdit(file) {
33
+ try {
34
+ const cwd = process.cwd();
35
+ let target = isAbsolute(file) ? file : join(cwd, file);
36
+ if (!target.startsWith(cwd))
37
+ target = join(cwd, basename(file)); // no traversal
38
+ const marker = `\n// codehere(mock): change applied for "${prompt.slice(0, 60).replace(/"/g, "'")}"\n`;
39
+ if (existsSync(target)) {
40
+ writeFileSync(target, readFileSync(target, 'utf-8') + marker);
41
+ }
42
+ else if (!file.includes('/')) {
43
+ writeFileSync(target, marker.trimStart()); // top-level only — never mkdir
44
+ }
45
+ }
46
+ catch {
47
+ /* best-effort — mock fidelity must never crash the run */
48
+ }
49
+ }
50
+ const agent = argVal('--agent') || 'claude-code';
51
+ let prompt = argVal('--mock-prompt') || argVal('-p') || argVal('--prompt') || argVal('--message') || '';
52
+ // Some agents (codex) pass the prompt via stdin, not an arg. Read it if no arg.
53
+ function readStdin(timeoutMs = 1500) {
54
+ return new Promise((resolve) => {
55
+ let buf = '';
56
+ let done = false;
57
+ const finish = () => {
58
+ if (done)
59
+ return;
60
+ done = true;
61
+ resolve(buf.trim());
62
+ };
63
+ if (process.stdin.isTTY)
64
+ return finish();
65
+ process.stdin.setEncoding('utf-8');
66
+ process.stdin.on('data', (c) => (buf += c));
67
+ process.stdin.on('end', finish);
68
+ setTimeout(finish, timeoutMs);
69
+ });
70
+ }
71
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
72
+ const out = (o) => process.stdout.write(JSON.stringify(o) + '\n');
73
+ // Derive a few plausible steps from the prompt. Heuristic, not modeled — enough
74
+ // for the execution path to produce real steps/artifacts without an API call.
75
+ function plannedSteps(p) {
76
+ const lc = p.toLowerCase();
77
+ const file = targetFile(p);
78
+ const steps = [
79
+ { tool: 'Read', input: { file_path: 'REPO_MAP.md' }, result: 'context loaded · 84 files indexed' },
80
+ { tool: 'Edit', input: { file_path: file }, result: '+' + (20 + (p.length % 90)) + ' lines · change applied' },
81
+ ];
82
+ if (/test|spec/.test(lc))
83
+ steps.push({ tool: 'Bash', input: { command: 'npm test' }, result: 'tests passed' });
84
+ else
85
+ steps.push({ tool: 'Bash', input: { command: 'npm run build' }, result: 'build clean' });
86
+ return steps;
87
+ }
88
+ async function runClaudeCode() {
89
+ const sid = 'mock-' + Math.random().toString(36).slice(2, 10);
90
+ out({ type: 'system', subtype: 'init', session_id: sid, tools: ['Read', 'Edit', 'Bash'], model: 'mock' });
91
+ await sleep(120);
92
+ out({ type: 'assistant', message: { content: [{ type: 'text', text: 'On it — ' + prompt }] } });
93
+ const steps = plannedSteps(prompt);
94
+ for (let i = 0; i < steps.length; i++) {
95
+ const s = steps[i];
96
+ const id = 'tu_' + i;
97
+ await sleep(180);
98
+ out({ type: 'assistant', message: { content: [{ type: 'tool_use', id, name: s.tool, input: s.input }] } });
99
+ await sleep(160);
100
+ out({
101
+ type: 'user',
102
+ message: { content: [{ type: 'tool_result', tool_use_id: id, content: s.result, is_error: false }] },
103
+ });
104
+ }
105
+ await sleep(140);
106
+ out({
107
+ type: 'result',
108
+ subtype: 'success',
109
+ session_id: sid,
110
+ result: 'Completed (mock): ' + prompt + '. ' + steps.length + ' steps · no API spend.',
111
+ });
112
+ }
113
+ // codex exec --json format · thread.started → item.completed events → turn.completed
114
+ async function runCodex() {
115
+ const tid = 'mock-thread-' + Math.random().toString(36).slice(2, 8);
116
+ out({ type: 'thread.started', thread_id: tid });
117
+ await sleep(120);
118
+ // codex puts `item` at the TOP LEVEL of the event (sibling of type), not nested.
119
+ out({ type: 'item.completed', item: { type: 'agent_message', text: 'On it — ' + prompt } });
120
+ // Representative extra item types codex emits (so the timeline parser is exercised):
121
+ // a web_search when the task implies lookup, an mcp_tool_call when it implies an integration.
122
+ const lc = prompt.toLowerCase();
123
+ if (/search|find|research|look ?up|latest|docs?\b/.test(lc)) {
124
+ await sleep(120);
125
+ out({ type: 'item.completed', item: { type: 'web_search', query: prompt.slice(0, 60) } });
126
+ }
127
+ if (/linear|github|jira|figma|mcp|issue|ticket/.test(lc)) {
128
+ await sleep(120);
129
+ out({ type: 'item.completed', item: { type: 'mcp_tool_call', server: 'linear', tool: 'get_issue', arguments: { id: 'INFRA-247' } } });
130
+ }
131
+ const steps = plannedSteps(prompt);
132
+ for (const s of steps) {
133
+ await sleep(170);
134
+ if (s.tool === 'Bash') {
135
+ out({
136
+ type: 'item.completed',
137
+ item: {
138
+ type: 'command_execution',
139
+ command: String(s.input.command),
140
+ aggregated_output: s.result,
141
+ exit_code: 0,
142
+ status: 'completed',
143
+ },
144
+ });
145
+ }
146
+ else {
147
+ const kind = s.tool === 'Read' ? 'read' : 'update';
148
+ out({
149
+ type: 'item.completed',
150
+ item: { type: 'file_change', changes: [{ path: String(s.input.file_path), kind }] },
151
+ });
152
+ }
153
+ }
154
+ await sleep(120);
155
+ out({ type: 'turn.completed', usage: { input_tokens: 1200, output_tokens: 340 } });
156
+ out({ type: 'item.completed', item: { type: 'agent_message', text: 'Completed (mock): ' + prompt } });
157
+ }
158
+ // aider · plain-text stdout · "Applied edit to <p>" + "Running <cmd>"
159
+ async function runAider() {
160
+ process.stdout.write('Aider v0.x (mock)\n');
161
+ const steps = plannedSteps(prompt);
162
+ for (const s of steps) {
163
+ await sleep(170);
164
+ if (s.tool === 'Bash')
165
+ process.stdout.write('Running ' + String(s.input.command) + '\n' + s.result + '\n');
166
+ else if (s.tool === 'Edit')
167
+ process.stdout.write('Applied edit to ' + String(s.input.file_path) + '\n');
168
+ else
169
+ process.stdout.write(String(s.input.file_path) + '\n'); // read → plain mention
170
+ }
171
+ await sleep(120);
172
+ process.stdout.write('Done (mock): ' + prompt + '\n');
173
+ }
174
+ // gemini · JSON tool_use / tool_result (tool_id, tool_name, parameters)
175
+ async function runGemini() {
176
+ out({ type: 'session', session_id: 'mock-gem-' + Math.random().toString(36).slice(2, 8) });
177
+ await sleep(120);
178
+ const steps = plannedSteps(prompt);
179
+ for (let i = 0; i < steps.length; i++) {
180
+ const s = steps[i];
181
+ const id = 'g' + i;
182
+ const name = s.tool === 'Read' ? 'read_file' : s.tool === 'Edit' ? 'edit_file' : 'run_shell_command';
183
+ await sleep(170);
184
+ out({ type: 'tool_use', tool_id: id, tool_name: name, parameters: s.input });
185
+ await sleep(150);
186
+ out({ type: 'tool_result', tool_id: id, output: s.result });
187
+ }
188
+ await sleep(120);
189
+ out({ type: 'content', text: 'Completed (mock): ' + prompt });
190
+ }
191
+ // opencode · JSON tool_use with part:{tool,state:{input,output,status}}
192
+ async function runOpencode() {
193
+ const steps = plannedSteps(prompt);
194
+ for (const s of steps) {
195
+ const tool = s.tool === 'Read' ? 'read' : s.tool === 'Edit' ? 'edit' : 'bash';
196
+ await sleep(180);
197
+ out({ type: 'tool_use', part: { tool, state: { input: s.input, output: s.result, status: 'completed' } } });
198
+ }
199
+ await sleep(120);
200
+ out({ type: 'text', text: 'Completed (mock): ' + prompt });
201
+ }
202
+ async function main() {
203
+ if (!prompt)
204
+ prompt = (await readStdin()) || 'task';
205
+ // Apply the real on-disk edit before emitting the run so the artifacts
206
+ // extractor (which diffs at exit) sees a genuine change.
207
+ materializeEdit(targetFile(prompt));
208
+ switch (agent) {
209
+ case 'claude-code':
210
+ case 'claude':
211
+ await runClaudeCode();
212
+ break;
213
+ case 'codex':
214
+ await runCodex();
215
+ break;
216
+ case 'aider':
217
+ await runAider();
218
+ break;
219
+ case 'gemini':
220
+ await runGemini();
221
+ break;
222
+ case 'opencode':
223
+ await runOpencode();
224
+ break;
225
+ default:
226
+ // Other agents' formats land here as their adapters get wired.
227
+ process.stderr.write('mock-agent-repl: agent "' + agent + '" not yet mocked\n');
228
+ out({ type: 'result', subtype: 'success', result: 'mock stub for ' + agent });
229
+ }
230
+ process.exit(0);
231
+ }
232
+ void main();
233
+ //# sourceMappingURL=mock-agent-repl.js.map
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Mock codex app-server · speaks the real app-server JSON-RPC protocol
3
+ * (newline-delimited over stdio) so the codehere ControlSurface can be built +
4
+ * tested WITHOUT real codex (rate-limited until 2026-05-26) and zero API spend.
5
+ *
6
+ * Subset for remote-control inject: initialize · thread/start · turn/start
7
+ * (running turn emitting items) · turn/steer (inject mid-turn) · turn/interrupt.
8
+ * Schema source: `codex app-server generate-json-schema` (dumped 2026-05-25).
9
+ *
10
+ * Runs as a subprocess (node dist/.../mock-codex-app-server.js). Production code
11
+ * never imports it; only CODEHERE_MOCK spawns it.
12
+ */
13
+ import { createInterface } from 'readline';
14
+ const send = (o) => void process.stdout.write(JSON.stringify(o) + '\n');
15
+ const reply = (id, result) => send({ jsonrpc: '2.0', id, result });
16
+ const notify = (method, params) => send({ jsonrpc: '2.0', method, params });
17
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
18
+ let threadSeq = 0;
19
+ let steeredText = null;
20
+ const rl = createInterface({ input: process.stdin });
21
+ rl.on('line', async (line) => {
22
+ const t = line.trim();
23
+ if (!t)
24
+ return;
25
+ let msg;
26
+ try {
27
+ msg = JSON.parse(t);
28
+ }
29
+ catch {
30
+ return;
31
+ }
32
+ const { id, method, params } = msg;
33
+ switch (method) {
34
+ case 'initialize':
35
+ reply(id, { userAgent: 'mock-codex-app-server/1.0', platformOs: 'mock', protocolVersion: '1' });
36
+ break;
37
+ case 'thread/start':
38
+ reply(id, { thread_id: 'mock-thread-' + ++threadSeq });
39
+ break;
40
+ case 'turn/start': {
41
+ reply(id, { accepted: true });
42
+ const prompt = String((params?.input ?? params?.prompt ?? 'task'));
43
+ await sleep(120);
44
+ notify('item.completed', { item: { type: 'agent_message', text: 'starting: ' + prompt.slice(0, 80) } });
45
+ // Real codex turns run for seconds; keep a window open so a mid-turn
46
+ // turn/steer (incl. from the web UI, which takes ~1-2s to fire) lands and
47
+ // gets reflected. Poll for a steer ~6s, then finish the turn.
48
+ for (let i = 0; i < 12; i++) {
49
+ await sleep(500);
50
+ if (steeredText) {
51
+ notify('item.completed', { item: { type: 'agent_message', text: 'STEERED mid-turn: ' + steeredText } });
52
+ steeredText = null;
53
+ }
54
+ }
55
+ notify('item.completed', {
56
+ item: { type: 'command_execution', command: 'npm test', exit_code: 0, status: 'completed' },
57
+ });
58
+ notify('turn.completed', { usage: { input_tokens: 900, output_tokens: 210 } });
59
+ break;
60
+ }
61
+ case 'turn/steer':
62
+ steeredText = String((params?.input ?? params?.text ?? params?.message ?? '(empty)'));
63
+ reply(id, { accepted: true });
64
+ break;
65
+ case 'turn/interrupt':
66
+ reply(id, { interrupted: true });
67
+ break;
68
+ default:
69
+ if (id !== undefined)
70
+ reply(id, { ok: true });
71
+ }
72
+ });
73
+ //# sourceMappingURL=mock-codex-app-server.js.map
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Mock synthesis · the CODEHERE_MOCK stand-in for the three LLM "narrator"
3
+ * calls (Final Report, mid-task narration, thread title).
4
+ *
5
+ * The agent-spawn seam (resolve-agent-spawn.ts) already mocks the coding agents
6
+ * themselves. But the Final Report / narration / title are SEPARATE `claude`
7
+ * subprocess calls, so under CODEHERE_MOCK they would still hit a real model (or
8
+ * fail) and the headline deliverable would be missing in a demo. These builders
9
+ * produce deterministic, grounded text from the run's own data — zero API spend
10
+ * — so the whole flow (incl. the Final Report + verify cross-check) runs under
11
+ * mock end-to-end.
12
+ *
13
+ * Honest scope: this is demo/dev output, only active when CODEHERE_MOCK=1. It is
14
+ * grounded in the real (mock) run's steps + artifacts so the verifier still
15
+ * cross-checks it the same way it checks a real report.
16
+ */
17
+ function stepVerbs(steps) {
18
+ const labels = steps
19
+ .filter((s) => s.kind !== 'say')
20
+ .map((s) => s.label)
21
+ .filter(Boolean);
22
+ if (labels.length === 0)
23
+ return 'started the task but recorded no structured steps';
24
+ if (labels.length <= 3)
25
+ return labels.join(', then ');
26
+ return `${labels.slice(0, 3).join(', ')}, and ${labels.length - 3} more step${labels.length - 3 === 1 ? '' : 's'}`;
27
+ }
28
+ /** Build the "files" paragraph so it stays consistent with what the verifier
29
+ * checks against artifacts — same truth rules as the real prompt template. */
30
+ function filesParagraph(artifacts) {
31
+ const files = artifacts?.files ?? [];
32
+ if (artifacts?.noGitTracking) {
33
+ return 'codehere could not track file changes here because this folder is not a git repository, so there is no per-file diff to show.';
34
+ }
35
+ if (files.length === 0) {
36
+ return 'No files were changed.';
37
+ }
38
+ const list = files
39
+ .map((f) => `${f.path} (+${f.added}/-${f.removed}${typeof f.trust === 'number' ? `, trust ${f.trust}` : ''})`)
40
+ .join('; ');
41
+ return `The agent touched ${files.length} file${files.length === 1 ? '' : 's'}: ${list}.`;
42
+ }
43
+ export function mockFinalReport(input) {
44
+ const p1 = `Working on "${input.prompt.slice(0, 120)}", the ${input.agent} agent ${stepVerbs(input.steps)}.`;
45
+ const p2 = filesParagraph(input.artifacts);
46
+ const files = input.artifacts?.files ?? [];
47
+ const tests = input.artifacts?.tests;
48
+ let p3;
49
+ if (tests) {
50
+ p3 = `Tests ran with ${tests.passed} passing${tests.failed ? ` and ${tests.failed} failing` : ''} — ${tests.failed ? 'look at the failures before relying on this' : 'a quick glance at the changed lines is still worth it'}.`;
51
+ }
52
+ else if (files.length > 0) {
53
+ p3 = `Open ${files[0].path} and confirm the change does what you asked before relying on it.`;
54
+ }
55
+ else {
56
+ p3 = 'Nothing to verify on disk; this looks like a read-only session.';
57
+ }
58
+ return { summary: `${p1}\n\n${p2}\n\n${p3}`, generatedAt: Date.now(), durationMs: 8 };
59
+ }
60
+ export function mockNarration(input) {
61
+ const recent = input.steps
62
+ .filter((s) => s.kind !== 'say')
63
+ .slice(-2)
64
+ .map((s) => s.label)
65
+ .filter(Boolean);
66
+ const summary = recent.length > 0
67
+ ? `The agent is working through it — most recently: ${recent.join(', then ')}.`
68
+ : 'The agent is getting started on your task.';
69
+ return { summary, durationMs: 5 };
70
+ }
71
+ export function mockTitle(firstPrompt, cwd) {
72
+ const words = firstPrompt
73
+ .replace(/[^a-zA-Z0-9 ]/g, ' ')
74
+ .split(/\s+/)
75
+ .filter(Boolean)
76
+ .slice(0, 6);
77
+ if (words.length >= 2) {
78
+ return words.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
79
+ }
80
+ const seg = cwd.split('/').filter(Boolean).pop() || 'Project';
81
+ return seg.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) + ' Workspace';
82
+ }
83
+ //# sourceMappingURL=mock-synthesis.js.map
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Mock spawn seam · the ONE place adapters route through to get mocked.
3
+ *
4
+ * When CODEHERE_MOCK=1, resolveAgentSpawn() swaps the real agent binary for the
5
+ * mock REPL subprocess (node mock-agent-repl.js --agent <id> ...same args). The
6
+ * adapter's spawn/parse path is otherwise untouched — it parses the mock's
7
+ * output exactly as it would the real CLI's. Zero API spend, zero rate limits.
8
+ *
9
+ * Off by default: without the env var it returns the real binary + args verbatim,
10
+ * so production behavior is unchanged.
11
+ */
12
+ import { fileURLToPath } from 'url';
13
+ import { dirname, join } from 'path';
14
+ export function isMockMode() {
15
+ return process.env.CODEHERE_MOCK === '1' || process.env.CODEHERE_MOCK === 'true';
16
+ }
17
+ const mockReplPath = () => join(dirname(fileURLToPath(import.meta.url)), 'mock-agent-repl.js');
18
+ /**
19
+ * @param agentId stable adapter id (e.g. 'claude-code', 'codex') — tells the
20
+ * mock which CLI output format to emit.
21
+ * @param realBin the real binary the adapter would spawn (e.g. 'claude').
22
+ * @param args the args the adapter would pass.
23
+ * @returns { command, args } to hand to child_process.spawn().
24
+ */
25
+ export function resolveAgentSpawn(agentId, realBin, args, prompt) {
26
+ if (!isMockMode())
27
+ return { command: realBin, args, mocked: false };
28
+ // Pass the prompt explicitly (agents take it differently: -p / --message /
29
+ // stdin / positional) so the mock doesn't have to guess where it is.
30
+ const promptArgs = prompt ? ['--mock-prompt', prompt] : [];
31
+ return {
32
+ command: process.execPath, // node
33
+ args: [mockReplPath(), '--agent', agentId, ...promptArgs, ...args],
34
+ mocked: true,
35
+ };
36
+ }
37
+ //# sourceMappingURL=resolve-agent-spawn.js.map
@@ -13,11 +13,13 @@
13
13
  import { spawn, execFile, execFileSync } from 'child_process';
14
14
  import { EventEmitter } from 'events';
15
15
  import { randomUUID } from 'crypto';
16
+ import { startRecording } from './cassette-recorder.js';
16
17
  import { existsSync } from 'fs';
17
18
  import { homedir } from 'os';
18
19
  import { join } from 'path';
19
20
  import { promisify } from 'util';
20
21
  import { ProgressAggregator } from './progress-aggregator.js';
22
+ import { resolveAgentSpawn } from './mocks/resolve-agent-spawn.js';
21
23
  import { applyMemoryPrefix } from './memory-injector.js';
22
24
  import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
23
25
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
@@ -150,8 +152,18 @@ export function spawnOpenCode(prompt, options = {}) {
150
152
  // { input: {...}, output, status, ... } } } (verified via deepwiki +
151
153
  // takopi cheatsheet). Same post-hoc visibility surface as Codex/Aider/
152
154
  // Gemini — no synchronous block until upstream pre-tool hooks land.
155
+ //
156
+ // v0.7 CRIT-2 fix — only mirror on state.status === 'completed' to
157
+ // avoid double-firing (state transitions running → completed both arrive
158
+ // as tool_use events; mirroring twice doubles audit entries + trust
159
+ // scans). Per docs/contracts/opencode.md, status is canonical.
153
160
  if (event.type === 'tool_use') {
154
161
  const part = event.data?.part;
162
+ // CRIT-2 guard: skip in-flight running state. Only audit on terminal.
163
+ const status = part?.state?.status;
164
+ if (status && status !== 'completed') {
165
+ return;
166
+ }
155
167
  const rawTool = String(part?.tool ?? 'tool').toLowerCase();
156
168
  // Normalize to Claude-cased tool names so /history filter chips group
157
169
  // cross-vendor (a bash call from claude-code and opencode show under
@@ -216,7 +228,8 @@ export function spawnOpenCode(prompt, options = {}) {
216
228
  // parseOpenCodeSessionIdByTitle for the why — opencode buffers the
217
229
  // ses_* id until LLM completion, so we can't capture it mid-stream).
218
230
  const titleTag = `${CODEHERE_TITLE_PREFIX}${sessionId}`;
219
- child = spawn('opencode', [...HEADLESS_FLAGS, '--title', titleTag, finalPrompt, ...(options.extraArgs ?? [])], {
231
+ const sp = resolveAgentSpawn('opencode', 'opencode', [...HEADLESS_FLAGS, '--title', titleTag, finalPrompt, ...(options.extraArgs ?? [])], finalPrompt);
232
+ child = spawn(sp.command, sp.args, {
220
233
  cwd,
221
234
  env: spawnEnv,
222
235
  stdio: ['ignore', 'pipe', 'pipe'],
@@ -251,9 +264,14 @@ export function spawnOpenCode(prompt, options = {}) {
251
264
  },
252
265
  };
253
266
  }
267
+ // Organic cassette recording (S2b · capture-first plan) · no-op unless
268
+ // CODEHERE_RECORD_CASSETTES=1, so zero overhead by default.
269
+ const recorder = startRecording({ adapter: 'opencode', sessionId, prompt: finalPrompt, cwd });
254
270
  let stdoutBuf = '';
255
271
  child.stdout?.on('data', (chunk) => {
256
- stdoutBuf += chunk.toString('utf-8');
272
+ const text = chunk.toString('utf-8');
273
+ recorder.onStdout(text);
274
+ stdoutBuf += text;
257
275
  let idx;
258
276
  while ((idx = stdoutBuf.indexOf('\n')) >= 0) {
259
277
  const line = stdoutBuf.slice(0, idx).trim();
@@ -271,12 +289,14 @@ export function spawnOpenCode(prompt, options = {}) {
271
289
  });
272
290
  child.stderr?.on('data', (chunk) => {
273
291
  const text = chunk.toString('utf-8');
292
+ recorder.onStderr(text);
274
293
  for (const line of text.split('\n')) {
275
294
  if (line.trim())
276
295
  emit({ type: 'stderr', timestamp: Date.now(), data: { line } });
277
296
  }
278
297
  });
279
298
  child.on('exit', (code, signal) => {
299
+ recorder.onExit(code ?? -1, signal);
280
300
  statusValue =
281
301
  killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
282
302
  if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {