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.
- package/README.md +5 -3
- package/dist/application/use-cases/domain-trust.js +110 -0
- package/dist/application/use-cases/generate-final-report.js +28 -4
- package/dist/application/use-cases/generate-narration.js +11 -0
- package/dist/application/use-cases/generate-thread-title.js +4 -0
- package/dist/application/use-cases/thread-export.js +155 -0
- package/dist/application/use-cases/verify-claim.js +274 -0
- package/dist/application/use-cases/write-pass.js +277 -0
- package/dist/index.js +211 -16
- package/dist/infrastructure/completion/bash-completion.js +2 -2
- package/dist/infrastructure/config/config-manager.js +1 -1
- package/dist/infrastructure/context/adapters/drawio-source.js +157 -0
- package/dist/infrastructure/context/adapters/figma-source.js +163 -0
- package/dist/infrastructure/context/adapters/github-issue-source.js +159 -0
- package/dist/infrastructure/context/adapters/linear-source.js +190 -0
- package/dist/infrastructure/context/context-resolver.js +144 -0
- package/dist/infrastructure/context/context-source.js +114 -0
- package/dist/infrastructure/context/oauth-flow.js +207 -0
- package/dist/infrastructure/context/oauth-store.js +119 -0
- package/dist/infrastructure/context/registry-bootstrap.js +99 -0
- package/dist/infrastructure/context/write-pass-resolver.js +106 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +73 -57
- package/dist/infrastructure/hooks/session-analyzer.js +65 -8
- package/dist/infrastructure/observability/execution-tracer.js +0 -5
- package/dist/infrastructure/orchestration/aider-adapter.js +45 -17
- package/dist/infrastructure/orchestration/artifacts-extractor.js +39 -5
- package/dist/infrastructure/orchestration/cassette-recorder.js +120 -0
- package/dist/infrastructure/orchestration/claude-code-adapter.js +33 -4
- package/dist/infrastructure/orchestration/codex-adapter.js +146 -13
- package/dist/infrastructure/orchestration/context-handoff.js +3 -2
- package/dist/infrastructure/orchestration/control/codex-control-surface.js +99 -0
- package/dist/infrastructure/orchestration/control/control-surface.js +15 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +12 -2
- package/dist/infrastructure/orchestration/mocks/mock-agent-repl.js +233 -0
- package/dist/infrastructure/orchestration/mocks/mock-codex-app-server.js +73 -0
- package/dist/infrastructure/orchestration/mocks/mock-synthesis.js +83 -0
- package/dist/infrastructure/orchestration/mocks/resolve-agent-spawn.js +37 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +22 -2
- package/dist/infrastructure/orchestration/progress-aggregator.js +177 -3
- package/dist/infrastructure/orchestration/session-manager.js +94 -6
- package/dist/infrastructure/orchestration/session-persistence.js +139 -30
- package/dist/infrastructure/orchestration/thread-key-store.js +127 -0
- package/dist/infrastructure/orchestration/write-pass-store.js +46 -0
- package/dist/infrastructure/protocols/cloud-sync.js +1 -6
- package/dist/infrastructure/protocols/mcp-server-impl.js +1280 -12
- package/dist/infrastructure/protocols/thread-phase.js +105 -0
- package/dist/infrastructure/protocols/ui-page-v2.js +3768 -75
- package/dist/infrastructure/protocols/ui-page.js +12 -3299
- package/dist/infrastructure/protocols/verify-page.js +941 -0
- package/dist/infrastructure/security/trust-scorer.js +76 -1
- package/dist/infrastructure/storage/cross-tool-agents.js +1 -1
- package/dist/infrastructure/storage/project-thread-migration.js +2 -2
- package/dist/infrastructure/storage/project-thread-title-backfill.js +1 -1
- package/dist/monitoring.js +1 -1
- package/dist/presentation/cli/commands/audit-command.js +126 -0
- package/dist/presentation/cli/commands/config-command.js +2 -2
- package/dist/presentation/cli/commands/context-source-command.js +198 -0
- package/dist/presentation/cli/commands/help-command.js +51 -1
- package/dist/presentation/cli/commands/report-command.js +6 -4
- package/dist/presentation/cli/commands/setup-command.js +71 -18
- package/dist/presentation/cli/commands/status-command.js +0 -4
- package/dist/presentation/cli/commands/thread-command.js +288 -0
- package/dist/presentation/cli/commands/verify-command.js +233 -0
- package/dist/presentation/cli/error-display.js +1 -1
- package/dist/presentation/cli/repl.js +32 -1
- package/dist/presentation/cli/task-deliverable.js +127 -0
- package/dist/testing/cassette.js +125 -0
- package/dist/testing/replay-adapter.js +166 -0
- package/dist/ui.js +18 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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:
|
|
115
|
-
toolInput: item.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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 ?
|
|
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
|
-
|
|
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
|
-
|
|
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 = '';
|