codehere 0.5.0-alpha.6 → 0.6.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 +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +89 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +515 -15
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +2872 -220
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/presentation/cli/repl.js +29 -0
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +2 -1
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graphify-context — shared structural memory layer (P1).
|
|
3
|
+
*
|
|
4
|
+
* Wraps `graphify-ts` CLI to compile compact context packs for agent prompts.
|
|
5
|
+
* Every spawn can optionally get a graphify context pack that replaces
|
|
6
|
+
* raw file reads + unstructured JSONL scans.
|
|
7
|
+
*
|
|
8
|
+
* All functions are synchronous (execFileSync) to slot into the sync
|
|
9
|
+
* spawn() call chain without breaking the API.
|
|
10
|
+
*
|
|
11
|
+
* P1 integration points:
|
|
12
|
+
* - Session spawn: inject context pack into prompt prefix
|
|
13
|
+
* - Failover handoff (P2): replacement agent inherits the same pack
|
|
14
|
+
* - S7 compaction (future): use graph to prune stale session logs
|
|
15
|
+
* - PM-brain routing (future): community_overview for agent selection
|
|
16
|
+
*/
|
|
17
|
+
import { execFileSync } from 'child_process';
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
19
|
+
import { join } from 'path';
|
|
20
|
+
import { randomUUID } from 'crypto';
|
|
21
|
+
// ── Constants ──────────────────────────────────────────────────────────────────
|
|
22
|
+
const GRAPHIFY_OUT = 'graphify-out';
|
|
23
|
+
const GRAPH_JSON = 'graph.json';
|
|
24
|
+
const CONTEXT_PACKS_DIR = '.codehere';
|
|
25
|
+
const CONTEXT_PACKS_FILE = 'context-packs.jsonl';
|
|
26
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
27
|
+
function getGraphPath(cwd) {
|
|
28
|
+
return join(cwd, GRAPHIFY_OUT, GRAPH_JSON);
|
|
29
|
+
}
|
|
30
|
+
function getGraphStats(cwd) {
|
|
31
|
+
const graphPath = getGraphPath(cwd);
|
|
32
|
+
try {
|
|
33
|
+
if (!existsSync(graphPath)) {
|
|
34
|
+
return { nodeCount: 0, edgeCount: 0 };
|
|
35
|
+
}
|
|
36
|
+
const graphJson = JSON.parse(readFileSync(graphPath, 'utf-8'));
|
|
37
|
+
return {
|
|
38
|
+
nodeCount: graphJson.nodes?.length ?? 0,
|
|
39
|
+
edgeCount: graphJson.edges?.length ?? 0,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { nodeCount: 0, edgeCount: 0 };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// ── Graph management (sync) ────────────────────────────────────────────────────
|
|
47
|
+
/**
|
|
48
|
+
* Build or update the knowledge graph for a workspace directory.
|
|
49
|
+
* Writes to <cwd>/graphify-out/graph.json (graphify-ts default).
|
|
50
|
+
* Synchronous — runs synchronously so it can be called from spawn().
|
|
51
|
+
*/
|
|
52
|
+
export function buildGraphSync(cwd, { incremental = true } = {}) {
|
|
53
|
+
const startTime = Date.now();
|
|
54
|
+
const graphPath = getGraphPath(cwd);
|
|
55
|
+
const args = ['generate', cwd];
|
|
56
|
+
if (incremental && existsSync(graphPath)) {
|
|
57
|
+
args.push('--update');
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
execFileSync('npx', ['graphify-ts', ...args], {
|
|
61
|
+
cwd,
|
|
62
|
+
stdio: 'ignore',
|
|
63
|
+
timeout: 120_000,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Non-zero exit is fine — graphify-ts returns 1 when no files changed.
|
|
68
|
+
// Also swallow ENOENT (graphify-ts not installed) — degrade gracefully.
|
|
69
|
+
}
|
|
70
|
+
return { elapsedMs: Date.now() - startTime };
|
|
71
|
+
}
|
|
72
|
+
// ── Context pack compilation (sync) ────────────────────────────────────────────
|
|
73
|
+
/**
|
|
74
|
+
* Compile a compact context pack for a prompt + workspace.
|
|
75
|
+
* Synchronous — calls graphify-ts pack and returns the payload.
|
|
76
|
+
*/
|
|
77
|
+
export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000, } = {}) {
|
|
78
|
+
const startTime = Date.now();
|
|
79
|
+
const graphPath = getGraphPath(cwd);
|
|
80
|
+
// Build graph if it doesn't exist yet
|
|
81
|
+
if (!existsSync(graphPath)) {
|
|
82
|
+
buildGraphSync(cwd, { incremental: false });
|
|
83
|
+
}
|
|
84
|
+
// Degrade gracefully if graph still doesn't exist
|
|
85
|
+
if (!existsSync(graphPath)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
const output = execFileSync('npx', [
|
|
90
|
+
'graphify-ts', 'pack', prompt,
|
|
91
|
+
'--graph', graphPath,
|
|
92
|
+
'--task', task,
|
|
93
|
+
'--budget', String(budget),
|
|
94
|
+
], {
|
|
95
|
+
cwd,
|
|
96
|
+
encoding: 'utf-8',
|
|
97
|
+
maxBuffer: 512 * 1024,
|
|
98
|
+
timeout: 30_000,
|
|
99
|
+
});
|
|
100
|
+
const { nodeCount, edgeCount } = getGraphStats(cwd);
|
|
101
|
+
return {
|
|
102
|
+
graphPath,
|
|
103
|
+
payload: output.trim(),
|
|
104
|
+
budget,
|
|
105
|
+
task,
|
|
106
|
+
nodeCount,
|
|
107
|
+
edgeCount,
|
|
108
|
+
elapsedMs: Date.now() - startTime,
|
|
109
|
+
packId: randomUUID(),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// graphify-ts may return non-zero or hit timeout — degrade gracefully.
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Compile a context pack from a session's final state (prompt + artifacts + steps).
|
|
119
|
+
* Used for P1 session-exit persistence and P2 handoff enrichment.
|
|
120
|
+
*/
|
|
121
|
+
export function compileSessionPackSync(cwd, { prompt, artifacts, steps, agent }) {
|
|
122
|
+
// Combine prompt + artifacts + steps into a rich prompt for the pack
|
|
123
|
+
const sessionContext = [
|
|
124
|
+
prompt,
|
|
125
|
+
agent ? `Agent: ${agent}` : undefined,
|
|
126
|
+
artifacts ? `Artifacts:\n${artifacts}` : undefined,
|
|
127
|
+
steps ? `Steps taken:\n${steps}` : undefined,
|
|
128
|
+
].filter(Boolean).join('\n\n');
|
|
129
|
+
return compileContextPackSync(sessionContext, cwd, { task: 'impact', budget: 2500 });
|
|
130
|
+
}
|
|
131
|
+
// ── Context pack persistence ───────────────────────────────────────────────────
|
|
132
|
+
/**
|
|
133
|
+
* Persist a context pack to disk for cross-session handoff (P2).
|
|
134
|
+
* Returns the path to the packs file.
|
|
135
|
+
*/
|
|
136
|
+
export function persistContextPack(pack, cwd) {
|
|
137
|
+
const packsDir = join(cwd, CONTEXT_PACKS_DIR);
|
|
138
|
+
if (!existsSync(packsDir)) {
|
|
139
|
+
mkdirSync(packsDir, { recursive: true });
|
|
140
|
+
}
|
|
141
|
+
const packsFile = join(packsDir, CONTEXT_PACKS_FILE);
|
|
142
|
+
const entry = {
|
|
143
|
+
packId: pack.packId,
|
|
144
|
+
timestamp: Date.now(),
|
|
145
|
+
task: pack.task,
|
|
146
|
+
nodeCount: pack.nodeCount,
|
|
147
|
+
edgeCount: pack.edgeCount,
|
|
148
|
+
graphPath: pack.graphPath,
|
|
149
|
+
payloadPreview: pack.payload.slice(0, 500), // truncate for log
|
|
150
|
+
payloadLength: pack.payload.length,
|
|
151
|
+
};
|
|
152
|
+
writeFileSync(packsFile, JSON.stringify(entry) + '\n', { flag: 'a' });
|
|
153
|
+
return packsFile;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Load the most recent context pack from disk (for P2 handoff).
|
|
157
|
+
*/
|
|
158
|
+
export function loadRecentContextPack(cwd) {
|
|
159
|
+
const packsFile = join(cwd, CONTEXT_PACKS_DIR, CONTEXT_PACKS_FILE);
|
|
160
|
+
if (!existsSync(packsFile)) {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const lines = readFileSync(packsFile, 'utf-8').split('\n').filter(Boolean);
|
|
165
|
+
if (lines.length === 0)
|
|
166
|
+
return null;
|
|
167
|
+
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
|
168
|
+
return {
|
|
169
|
+
packId: lastEntry.packId,
|
|
170
|
+
graphPath: lastEntry.graphPath,
|
|
171
|
+
payload: '', // payload is truncated in disk storage
|
|
172
|
+
budget: 3000,
|
|
173
|
+
task: lastEntry.task,
|
|
174
|
+
nodeCount: lastEntry.nodeCount,
|
|
175
|
+
edgeCount: lastEntry.edgeCount,
|
|
176
|
+
elapsedMs: 0,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// ── Prompt enrichment (sync) ───────────────────────────────────────────────────
|
|
184
|
+
/**
|
|
185
|
+
* Prepend a context pack to a prompt if graphify-ts is available.
|
|
186
|
+
*
|
|
187
|
+
* Synchronous — can be called directly from spawn().
|
|
188
|
+
* Returns the original prompt if graphify-ts fails or is unavailable.
|
|
189
|
+
*
|
|
190
|
+
* Respects CODEHERE_GRAPHIFY env var:
|
|
191
|
+
* - unset or "auto" (default): compile on spawn if graph doesn't exist
|
|
192
|
+
* - "force": always compile, even if pack exists
|
|
193
|
+
* - "off": skip entirely
|
|
194
|
+
*/
|
|
195
|
+
export function enrichPromptWithPackSync(prompt, cwd, { force = false } = {}) {
|
|
196
|
+
const mode = process.env.CODEHERE_GRAPHIFY ?? 'auto';
|
|
197
|
+
if (mode === 'off')
|
|
198
|
+
return prompt;
|
|
199
|
+
// Check for existing pack to avoid recompiling on every spawn
|
|
200
|
+
const existingPack = loadRecentContextPack(cwd);
|
|
201
|
+
const graphPath = getGraphPath(cwd);
|
|
202
|
+
const graphExists = existsSync(graphPath);
|
|
203
|
+
if (!force && existingPack && graphExists) {
|
|
204
|
+
// Reuse existing pack — just enrich the prompt with a compact reference
|
|
205
|
+
const reference = `[graphify-ts context pack: ${existingPack.nodeCount} nodes, ${existingPack.edgeCount} edges, pack=${existingPack.packId}]`;
|
|
206
|
+
return `[graphify context: ${reference} — agent should call retrieve() for full pack]\n\n${prompt}`;
|
|
207
|
+
}
|
|
208
|
+
// Compile a fresh pack
|
|
209
|
+
const pack = compileContextPackSync(prompt, cwd, { task: 'explain', budget: 2500 });
|
|
210
|
+
if (!pack) {
|
|
211
|
+
return prompt;
|
|
212
|
+
}
|
|
213
|
+
// Persist for P2 handoff
|
|
214
|
+
persistContextPack(pack, cwd);
|
|
215
|
+
// Prepend context pack as a system hint so the agent sees it first
|
|
216
|
+
return [
|
|
217
|
+
`[graphify-ts context pack — ${pack.nodeCount} nodes, ${pack.edgeCount} edges, ${pack.elapsedMs}ms]`,
|
|
218
|
+
pack.payload,
|
|
219
|
+
'---',
|
|
220
|
+
prompt,
|
|
221
|
+
].join('\n\n');
|
|
222
|
+
}
|
|
223
|
+
// ── Graph querying (sync) ──────────────────────────────────────────────────────
|
|
224
|
+
/**
|
|
225
|
+
* Query the knowledge graph for a question.
|
|
226
|
+
* Useful for PM-brain routing and structured context handoff (P2).
|
|
227
|
+
*/
|
|
228
|
+
export function queryGraphSync(question, cwd, { maxTokens = 2000 } = {}) {
|
|
229
|
+
const startTime = Date.now();
|
|
230
|
+
const graphPath = getGraphPath(cwd);
|
|
231
|
+
if (!existsSync(graphPath)) {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
try {
|
|
235
|
+
const output = execFileSync('npx', [
|
|
236
|
+
'graphify-ts', 'query', question,
|
|
237
|
+
'--graph', graphPath,
|
|
238
|
+
'--budget', String(maxTokens),
|
|
239
|
+
], {
|
|
240
|
+
cwd,
|
|
241
|
+
encoding: 'utf-8',
|
|
242
|
+
maxBuffer: 256 * 1024,
|
|
243
|
+
timeout: 15_000,
|
|
244
|
+
});
|
|
245
|
+
return {
|
|
246
|
+
question,
|
|
247
|
+
answer: output.trim(),
|
|
248
|
+
elapsedMs: Date.now() - startTime,
|
|
249
|
+
graphPath,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=graphify-context.js.map
|
|
@@ -10,16 +10,64 @@
|
|
|
10
10
|
*
|
|
11
11
|
* Same spawn-pattern pattern as Codex.
|
|
12
12
|
*/
|
|
13
|
-
import { spawn, execFileSync } from 'child_process';
|
|
13
|
+
import { spawn, execFile, execFileSync } from 'child_process';
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
16
|
import { existsSync } from 'fs';
|
|
17
17
|
import { homedir } from 'os';
|
|
18
18
|
import { join } from 'path';
|
|
19
|
+
import { promisify } from 'util';
|
|
19
20
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
20
21
|
import { applyMemoryPrefix } from './memory-injector.js';
|
|
21
22
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
23
|
+
const execFileAsync = promisify(execFile);
|
|
22
24
|
const HEADLESS_FLAGS = ['run', '--format', 'json'];
|
|
25
|
+
/**
|
|
26
|
+
* Tag prefix written into opencode's `--title` so we can find a session by
|
|
27
|
+
* codehere's own UUID later (opencode's `ses_*` ID isn't observable until
|
|
28
|
+
* the LLM call completes — see appendMessage docblock for the full story).
|
|
29
|
+
*/
|
|
30
|
+
const CODEHERE_TITLE_PREFIX = 'codehere-';
|
|
31
|
+
/**
|
|
32
|
+
* Parse `opencode session list --format json` output and return the opencode
|
|
33
|
+
* session ID (`ses_*`) for the row whose title starts with our codehere
|
|
34
|
+
* tag. Pure function so tests don't need a real opencode binary.
|
|
35
|
+
*
|
|
36
|
+
* Schema verified live (2026-05-04):
|
|
37
|
+
* [{ "id": "ses_212f...", "title": "...", "updated": <ms>, "created": <ms>,
|
|
38
|
+
* "projectId": "...", "directory": "..." }, ...]
|
|
39
|
+
*
|
|
40
|
+
* Returns the session whose title is exactly `<prefix><codehereSessionId>`.
|
|
41
|
+
* Falls back to null if not found, the input is malformed, or the array is
|
|
42
|
+
* empty. Caller falls back to --continue in that case.
|
|
43
|
+
*/
|
|
44
|
+
export function parseOpenCodeSessionIdByTitle(listJson, codehereSessionId) {
|
|
45
|
+
if (!codehereSessionId)
|
|
46
|
+
return null;
|
|
47
|
+
const wantedTitle = `${CODEHERE_TITLE_PREFIX}${codehereSessionId}`;
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(listJson);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (!Array.isArray(parsed))
|
|
56
|
+
return null;
|
|
57
|
+
for (const row of parsed) {
|
|
58
|
+
if (!row || typeof row !== 'object')
|
|
59
|
+
continue;
|
|
60
|
+
const r = row;
|
|
61
|
+
if (typeof r.id === 'string' && typeof r.title === 'string' && r.title === wantedTitle) {
|
|
62
|
+
// Defense in depth: opencode's IDs are always `ses_<base32-ish>`. If
|
|
63
|
+
// an attacker (or upstream bug) injects a row with a poisoned id like
|
|
64
|
+
// ` --rm-rf` we don't want to feed it back as a CLI arg.
|
|
65
|
+
if (/^ses_[A-Za-z0-9]+$/.test(r.id))
|
|
66
|
+
return r.id;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
23
71
|
const ERROR_PATTERNS = [
|
|
24
72
|
{
|
|
25
73
|
code: 'auth_invalid',
|
|
@@ -122,7 +170,12 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
122
170
|
let child;
|
|
123
171
|
try {
|
|
124
172
|
// OpenCode's `run` expects the message as positional args, not stdin.
|
|
125
|
-
|
|
173
|
+
// --title 'codehere-<sessionId>' tags the session with codehere's own
|
|
174
|
+
// UUID; appendMessage later looks it up by this title (see
|
|
175
|
+
// parseOpenCodeSessionIdByTitle for the why — opencode buffers the
|
|
176
|
+
// ses_* id until LLM completion, so we can't capture it mid-stream).
|
|
177
|
+
const titleTag = `${CODEHERE_TITLE_PREFIX}${sessionId}`;
|
|
178
|
+
child = spawn('opencode', [...HEADLESS_FLAGS, '--title', titleTag, finalPrompt, ...(options.extraArgs ?? [])], {
|
|
126
179
|
cwd,
|
|
127
180
|
env: spawnEnv,
|
|
128
181
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -247,28 +300,56 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
247
300
|
},
|
|
248
301
|
canAppendMessage: true,
|
|
249
302
|
/**
|
|
250
|
-
* v0.6.
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
* codex's thread_id.
|
|
254
|
-
*
|
|
303
|
+
* v0.6.x — UUID→ses_* resolution (replaces the prior --continue shortcut).
|
|
304
|
+
* OpenCode's `run --format json` buffers stdout until the LLM call
|
|
305
|
+
* completes, so the ses_* session ID isn't observable mid-stream like
|
|
306
|
+
* claude-code's session_id or codex's thread_id.
|
|
307
|
+
*
|
|
308
|
+
* Strategy: spawn() above tags the session with `--title codehere-<our
|
|
309
|
+
* UUID>`. Here, run `opencode session list --format json` (clean array,
|
|
310
|
+
* no parsing of column-aligned text), find the row whose title matches
|
|
311
|
+
* our tag, capture its ses_* id, and use `-s <ses_*>`. Falls back to
|
|
312
|
+
* --continue if anything fails (binary missing, no match, parse error)
|
|
313
|
+
* — same posture as before for safety, never worse.
|
|
255
314
|
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* follow-up, the follow-up routes to the wrong session. Acceptable for
|
|
259
|
-
* the typical reply-within-minutes flow; the rigorous version (capture
|
|
260
|
-
* session ID via post-spawn `opencode session list` filtered by --title
|
|
261
|
-
* tag) is queued for v0.6.x.
|
|
315
|
+
* Concurrency win: spawning a sibling opencode session in the same cwd
|
|
316
|
+
* between original spawn and reply no longer hijacks the reply target.
|
|
262
317
|
*
|
|
263
|
-
* Output parsing:
|
|
264
|
-
* the spawn path; we extract assistant message text from `text` events.
|
|
318
|
+
* Output parsing: same shape as spawn — text events carry assistant text.
|
|
265
319
|
*/
|
|
266
|
-
appendMessage(text) {
|
|
320
|
+
async appendMessage(text) {
|
|
267
321
|
const startedAt = Date.now();
|
|
322
|
+
// Resolve our codehere session UUID → opencode ses_* id asynchronously.
|
|
323
|
+
// Async (not execFileSync) because this runs in the daemon's HTTP
|
|
324
|
+
// request path; sync exec would block the event loop for up to the
|
|
325
|
+
// timeout, freezing every other in-flight session.
|
|
326
|
+
let resumeArgs = ['--continue'];
|
|
327
|
+
try {
|
|
328
|
+
// Override timeout via env for slow boxes; default 15s mirrors the
|
|
329
|
+
// gemini adapter's choice (and is well above any LLM-tied internal
|
|
330
|
+
// timer opencode might have).
|
|
331
|
+
const timeoutMs = Number(process.env.CODEHERE_OPENCODE_LIST_TIMEOUT_MS) || 15000;
|
|
332
|
+
const { stdout: listOut } = await execFileAsync('opencode', ['session', 'list', '--format', 'json'], {
|
|
333
|
+
cwd,
|
|
334
|
+
env: spawnEnv,
|
|
335
|
+
encoding: 'utf-8',
|
|
336
|
+
timeout: timeoutMs,
|
|
337
|
+
// opencode's session-list output can grow as the user accumulates
|
|
338
|
+
// sessions. Default exec maxBuffer is 1MB; bump to 8MB so a long
|
|
339
|
+
// history doesn't ENOBUFS the lookup.
|
|
340
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
341
|
+
});
|
|
342
|
+
const sesId = parseOpenCodeSessionIdByTitle(listOut, sessionId);
|
|
343
|
+
if (sesId)
|
|
344
|
+
resumeArgs = ['-s', sesId];
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
// Binary missing, slow auth, or no list — fall through to --continue.
|
|
348
|
+
}
|
|
268
349
|
return new Promise((resolve, reject) => {
|
|
269
350
|
let stdout = '';
|
|
270
351
|
let stderr = '';
|
|
271
|
-
const followup = spawn('opencode', [...HEADLESS_FLAGS,
|
|
352
|
+
const followup = spawn('opencode', [...HEADLESS_FLAGS, ...resumeArgs, text], {
|
|
272
353
|
cwd,
|
|
273
354
|
env: spawnEnv,
|
|
274
355
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -282,7 +363,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
282
363
|
});
|
|
283
364
|
followup.on('exit', (code) => {
|
|
284
365
|
if (code !== 0) {
|
|
285
|
-
reject(new Error(`opencode
|
|
366
|
+
reject(new Error(`opencode ${resumeArgs.join(' ')} exited ${code}: ${stderr.slice(0, 300)}`));
|
|
286
367
|
return;
|
|
287
368
|
}
|
|
288
369
|
// Parse JSONL — extract assistant text from `text` events
|
|
@@ -312,6 +393,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
312
393
|
export const OpenCodeAdapter = {
|
|
313
394
|
id: 'opencode',
|
|
314
395
|
label: 'OpenCode',
|
|
396
|
+
description: 'Runs several agents at once for genuinely independent jobs. You won\'t see live progress while it works — the trade-off for the speed of doing things in parallel.',
|
|
315
397
|
processPattern: /(?:^|\/)opencode(?:\s|$)/,
|
|
316
398
|
isInstalled() {
|
|
317
399
|
try {
|
|
@@ -40,21 +40,65 @@ function labelForClaudeToolUse(name, input) {
|
|
|
40
40
|
return truncate('Run ' + cmd);
|
|
41
41
|
}
|
|
42
42
|
case 'Grep':
|
|
43
|
-
|
|
43
|
+
// M2 plain-English pass: "Grep" is a unix command name, jargon
|
|
44
|
+
// for non-technical users. "Search code for" tells the user what
|
|
45
|
+
// it actually does.
|
|
46
|
+
return 'Search code for ' + truncate(String(i.pattern ?? ''), 40);
|
|
44
47
|
case 'Glob':
|
|
45
|
-
return 'List ' + truncate(String(i.pattern ?? ''), 40);
|
|
48
|
+
return 'List files matching ' + truncate(String(i.pattern ?? ''), 40);
|
|
46
49
|
case 'WebFetch':
|
|
47
50
|
return 'Fetch ' + truncate(String(i.url ?? ''), 50);
|
|
48
51
|
case 'WebSearch':
|
|
49
|
-
return 'Search ' + truncate(String(i.query ?? ''), 50);
|
|
52
|
+
return 'Search the web for ' + truncate(String(i.query ?? ''), 50);
|
|
50
53
|
case 'TodoWrite':
|
|
51
54
|
return 'Plan ' + (Array.isArray(i.todos) ? `${i.todos.length} items` : 'tasks');
|
|
52
55
|
case 'Task':
|
|
53
|
-
|
|
56
|
+
// M2 plain-English pass: "subagent" is internal vocab. "Specialist"
|
|
57
|
+
// matches the team-roster framing the user already sees in the
|
|
58
|
+
// dashboard ("codehere · Conductor", "Claude Code specialist", etc.)
|
|
59
|
+
return 'Hand off to a specialist';
|
|
54
60
|
default:
|
|
55
61
|
return truncate(String(name || 'tool'));
|
|
56
62
|
}
|
|
57
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Extract human-readable text from a Claude tool_result content block.
|
|
66
|
+
* Handles the two shapes the SDK emits:
|
|
67
|
+
* { content: 'plain text' } (most)
|
|
68
|
+
* { content: [{type:'text', text:'...'}, {type:'image', ...}, ...] } (multi-block)
|
|
69
|
+
*/
|
|
70
|
+
function extractClaudeToolResultText(b) {
|
|
71
|
+
const c = b.content;
|
|
72
|
+
if (typeof c === 'string')
|
|
73
|
+
return c;
|
|
74
|
+
if (Array.isArray(c)) {
|
|
75
|
+
const parts = [];
|
|
76
|
+
for (const part of c) {
|
|
77
|
+
if (part && typeof part === 'object') {
|
|
78
|
+
const p = part;
|
|
79
|
+
if (p.type === 'text' && typeof p.text === 'string')
|
|
80
|
+
parts.push(p.text);
|
|
81
|
+
}
|
|
82
|
+
else if (typeof part === 'string') {
|
|
83
|
+
parts.push(part);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return parts.join('\n');
|
|
87
|
+
}
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Truncate output for step preview. Keeps the LAST ~400 chars (tail is
|
|
92
|
+
* usually more useful than head for command output — errors land
|
|
93
|
+
* there). Adds a leading ellipsis when truncation occurs.
|
|
94
|
+
*/
|
|
95
|
+
function truncateOutput(s) {
|
|
96
|
+
const max = 400;
|
|
97
|
+
const stripped = s.replace(/\[[0-9;]*m/g, ''); // strip ANSI
|
|
98
|
+
if (stripped.length <= max)
|
|
99
|
+
return stripped;
|
|
100
|
+
return '…' + stripped.slice(-(max - 1));
|
|
101
|
+
}
|
|
58
102
|
function unwrapShellWrapper(command) {
|
|
59
103
|
// Codex wraps its shell calls in `/bin/zsh -lc "…"` or `sh -c "…"`. Strip that
|
|
60
104
|
// so the step label reads as the actual command instead of the wrapper.
|
|
@@ -73,8 +117,20 @@ function labelForCodexCommand(command) {
|
|
|
73
117
|
}
|
|
74
118
|
if (/^(?:ls|find)\b/.test(raw))
|
|
75
119
|
return truncate('List ' + raw.replace(/^(?:ls|find)\s*/, ''));
|
|
76
|
-
|
|
77
|
-
|
|
120
|
+
// M2 plain-English pass: "File op:" is awkward; describe what's
|
|
121
|
+
// happening per command kind.
|
|
122
|
+
if (/^mkdir\b/.test(raw))
|
|
123
|
+
return truncate('Create folder: ' + raw.replace(/^mkdir\s+(?:-\S+\s+)*/, ''));
|
|
124
|
+
if (/^cp\b/.test(raw))
|
|
125
|
+
return truncate('Copy: ' + raw.replace(/^cp\s+(?:-\S+\s+)*/, ''));
|
|
126
|
+
if (/^mv\b/.test(raw))
|
|
127
|
+
return truncate('Move/rename: ' + raw.replace(/^mv\s+(?:-\S+\s+)*/, ''));
|
|
128
|
+
if (/^rm\b/.test(raw))
|
|
129
|
+
return truncate('Remove: ' + raw.replace(/^rm\s+(?:-\S+\s+)*/, ''));
|
|
130
|
+
if (/^touch\b/.test(raw))
|
|
131
|
+
return truncate('Create file: ' + raw.replace(/^touch\s+/, ''));
|
|
132
|
+
if (/\becho\s+.+>/.test(raw))
|
|
133
|
+
return truncate('Write to file: ' + raw);
|
|
78
134
|
if (/^git\s+/.test(raw))
|
|
79
135
|
return truncate(raw);
|
|
80
136
|
if (/^npm\s+test\b/.test(raw) || /^pytest\b/.test(raw) || /^vitest\b/.test(raw))
|
|
@@ -153,6 +209,15 @@ export class ProgressAggregator {
|
|
|
153
209
|
const step = this._steps[idx];
|
|
154
210
|
step.state = b.is_error ? 'failed' : 'done';
|
|
155
211
|
step.completedAt = Date.now();
|
|
212
|
+
// P22 / Track B #8 — capture tool-result text for the
|
|
213
|
+
// dashboard's bash-output preview. Claude SDK shape:
|
|
214
|
+
// content: string (most cases)
|
|
215
|
+
// content: Array<{type:'text', text:string}> (multi-block)
|
|
216
|
+
// Truncate to last ~400 chars so the SSE payload + render
|
|
217
|
+
// cost stay bounded.
|
|
218
|
+
const out = extractClaudeToolResultText(b);
|
|
219
|
+
if (out)
|
|
220
|
+
step.output = truncateOutput(out);
|
|
156
221
|
changed = true;
|
|
157
222
|
}
|
|
158
223
|
}
|
|
@@ -192,6 +257,17 @@ export class ProgressAggregator {
|
|
|
192
257
|
const isError = data.error !== undefined && data.error !== null;
|
|
193
258
|
step.state = isError ? 'failed' : 'done';
|
|
194
259
|
step.completedAt = Date.now();
|
|
260
|
+
// P22 slice 3 — Gemini tool_result output. Schema is sparse;
|
|
261
|
+
// accept the common output/result/content fields, with error
|
|
262
|
+
// text as fallback when isError is true.
|
|
263
|
+
const out = (typeof data.output === 'string' && data.output) ||
|
|
264
|
+
(typeof data.result === 'string' && data.result) ||
|
|
265
|
+
(typeof data.content === 'string' && data.content) ||
|
|
266
|
+
(isError && data.error && typeof data.error === 'object'
|
|
267
|
+
? `${data.error.type ?? ''} ${data.error.message ?? ''}`
|
|
268
|
+
: '');
|
|
269
|
+
if (out)
|
|
270
|
+
step.output = truncateOutput(String(out));
|
|
195
271
|
return true;
|
|
196
272
|
}
|
|
197
273
|
}
|
|
@@ -252,6 +328,34 @@ export class ProgressAggregator {
|
|
|
252
328
|
* once, not `Write` followed by `Edit`.
|
|
253
329
|
*/
|
|
254
330
|
aiderJustCreated = new Set();
|
|
331
|
+
/**
|
|
332
|
+
* P22 slice 4 (Aider) — output buffering. Aider's command output
|
|
333
|
+
* streams as raw stdout lines AFTER a "Running X" announcement and
|
|
334
|
+
* BEFORE the next aider-pattern (or end of session). This index is
|
|
335
|
+
* the most-recent "Run" step that's still collecting its tail. When
|
|
336
|
+
* a new aider-pattern fires we flush the buffer onto step.output and
|
|
337
|
+
* reset.
|
|
338
|
+
*
|
|
339
|
+
* Only "Running" steps collect output — file-edit/write actions
|
|
340
|
+
* don't have a captured stdout; their target IS the visible state.
|
|
341
|
+
*/
|
|
342
|
+
aiderRunningStepIdx = null;
|
|
343
|
+
aiderOutputBuffer = [];
|
|
344
|
+
aiderFlushOutput() {
|
|
345
|
+
if (this.aiderRunningStepIdx === null)
|
|
346
|
+
return;
|
|
347
|
+
const step = this._steps[this.aiderRunningStepIdx];
|
|
348
|
+
if (!step) {
|
|
349
|
+
this.aiderRunningStepIdx = null;
|
|
350
|
+
this.aiderOutputBuffer = [];
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
if (this.aiderOutputBuffer.length > 0) {
|
|
354
|
+
step.output = truncateOutput(this.aiderOutputBuffer.join('\n'));
|
|
355
|
+
}
|
|
356
|
+
this.aiderRunningStepIdx = null;
|
|
357
|
+
this.aiderOutputBuffer = [];
|
|
358
|
+
}
|
|
255
359
|
ingestAider(type, data) {
|
|
256
360
|
if (type !== 'stdout')
|
|
257
361
|
return false;
|
|
@@ -270,18 +374,26 @@ export class ProgressAggregator {
|
|
|
270
374
|
// "Running <command>" — base_coder.py:2472
|
|
271
375
|
let m = line.match(/^Running\s+(.+)$/);
|
|
272
376
|
if (m) {
|
|
377
|
+
// P22 slice 4 — flush the previous Running step's buffer before
|
|
378
|
+
// starting the new one. New step is the active output target.
|
|
379
|
+
this.aiderFlushOutput();
|
|
380
|
+
const idx = this._steps.length;
|
|
273
381
|
this._steps.push({
|
|
274
382
|
label: truncate('Run ' + m[1]),
|
|
275
383
|
state: 'done',
|
|
276
384
|
startedAt: Date.now(),
|
|
277
385
|
completedAt: Date.now(),
|
|
278
386
|
});
|
|
387
|
+
this.aiderRunningStepIdx = idx;
|
|
279
388
|
return true;
|
|
280
389
|
}
|
|
281
390
|
// "Applied edit to <path>" — base_coder.py:2334
|
|
282
391
|
m = line.match(/^Applied edit to\s+(.+)$/);
|
|
283
392
|
if (m) {
|
|
284
393
|
const path = m[1];
|
|
394
|
+
// P22 slice 4 — flush any in-flight running-step output before
|
|
395
|
+
// recording the edit (which is a new aider-pattern signal).
|
|
396
|
+
this.aiderFlushOutput();
|
|
285
397
|
// If aider JUST created this file, the Write step already represents the
|
|
286
398
|
// action; skip the Edit step that aider emits for the initial content.
|
|
287
399
|
// Clear from the set after consuming so subsequent edits to the same
|
|
@@ -302,6 +414,7 @@ export class ProgressAggregator {
|
|
|
302
414
|
m = line.match(/^Creating empty file\s+(.+)$/);
|
|
303
415
|
if (m) {
|
|
304
416
|
const path = m[1];
|
|
417
|
+
this.aiderFlushOutput();
|
|
305
418
|
this.aiderJustCreated.add(path);
|
|
306
419
|
// Use "Write" to align with Claude/Gemini's net-new-file label vocabulary.
|
|
307
420
|
this._steps.push({
|
|
@@ -312,6 +425,20 @@ export class ProgressAggregator {
|
|
|
312
425
|
});
|
|
313
426
|
return true;
|
|
314
427
|
}
|
|
428
|
+
// P22 slice 4 — non-pattern stdout line WITHIN a Running window
|
|
429
|
+
// gets buffered as that step's output. Cap at 50 lines so a long-
|
|
430
|
+
// running command (e.g., `npm test` printing thousands) doesn't
|
|
431
|
+
// bloat memory; truncateOutput at flush time keeps the on-step
|
|
432
|
+
// value bounded too.
|
|
433
|
+
if (this.aiderRunningStepIdx !== null && this.aiderOutputBuffer.length < 50) {
|
|
434
|
+
this.aiderOutputBuffer.push(line);
|
|
435
|
+
// Update the active step's output incrementally so the dashboard
|
|
436
|
+
// sees the tail before the next aider-pattern flushes.
|
|
437
|
+
const step = this._steps[this.aiderRunningStepIdx];
|
|
438
|
+
if (step)
|
|
439
|
+
step.output = truncateOutput(this.aiderOutputBuffer.join('\n'));
|
|
440
|
+
return true; // mark as "step changed" so the SSE re-emits codehere:steps
|
|
441
|
+
}
|
|
315
442
|
return false;
|
|
316
443
|
}
|
|
317
444
|
ingestCodex(type, data) {
|
|
@@ -322,21 +449,29 @@ export class ProgressAggregator {
|
|
|
322
449
|
if (itemType === 'command_execution') {
|
|
323
450
|
const label = labelForCodexCommand(String(item.command ?? ''));
|
|
324
451
|
const ok = item.exit_code === 0 || item.status === 'completed';
|
|
452
|
+
// P22 slice 2 — capture aggregated_output for the bash preview.
|
|
453
|
+
// Codex emits the full stdout+stderr in this field on completion.
|
|
454
|
+
const aggregated = typeof item.aggregated_output === 'string' ? item.aggregated_output : '';
|
|
325
455
|
this._steps.push({
|
|
326
456
|
label,
|
|
327
457
|
state: ok ? 'done' : 'failed',
|
|
328
458
|
startedAt: Date.now(),
|
|
329
459
|
completedAt: Date.now(),
|
|
460
|
+
...(aggregated ? { output: truncateOutput(aggregated) } : {}),
|
|
330
461
|
});
|
|
331
462
|
return true;
|
|
332
463
|
}
|
|
333
464
|
if (itemType === 'tool_call' || itemType === 'local_shell_call') {
|
|
334
465
|
const name = String(item.tool_name ?? item.name ?? 'tool');
|
|
466
|
+
// Tool-call items may carry a `result` or `output` field too.
|
|
467
|
+
const result = (typeof item.result === 'string' ? item.result : '') ||
|
|
468
|
+
(typeof item.output === 'string' ? item.output : '');
|
|
335
469
|
this._steps.push({
|
|
336
470
|
label: truncate(name),
|
|
337
471
|
state: 'done',
|
|
338
472
|
startedAt: Date.now(),
|
|
339
473
|
completedAt: Date.now(),
|
|
474
|
+
...(result ? { output: truncateOutput(result) } : {}),
|
|
340
475
|
});
|
|
341
476
|
return true;
|
|
342
477
|
}
|