codehere 0.4.1 → 0.5.0-alpha.2
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 +64 -7
- package/dist/index.js +43 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +18 -3
- package/dist/infrastructure/orchestration/aider-adapter.js +12 -2
- package/dist/infrastructure/orchestration/claude-code-adapter.js +9 -1
- package/dist/infrastructure/orchestration/codex-adapter.js +6 -1
- package/dist/infrastructure/orchestration/conductor-adapter.js +12 -2
- package/dist/infrastructure/orchestration/gemini-adapter.js +41 -5
- package/dist/infrastructure/orchestration/generic-cli-adapter.js +10 -2
- package/dist/infrastructure/orchestration/memory-injector.js +337 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +34 -4
- package/dist/infrastructure/orchestration/progress-aggregator.js +119 -0
- package/dist/infrastructure/orchestration/readiness.js +2 -5
- package/dist/infrastructure/orchestration/session-persistence.js +13 -1
- package/dist/infrastructure/protocols/mcp-server-impl.js +30 -3
- package/dist/infrastructure/protocols/ui-page.js +468 -59
- package/dist/presentation/cli/commands/init-command.js +342 -24
- package/dist/presentation/cli/commands/memory-command.js +1017 -0
- package/dist/presentation/cli/commands/protocols-builder.js +254 -0
- package/dist/presentation/cli/commands/protocols-command.js +84 -0
- package/dist/presentation/cli/commands/repo-map-builder.js +247 -0
- package/package.json +4 -3
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* F3 Task 8 — memory injection into spawned-agent prompts.
|
|
3
|
+
*
|
|
4
|
+
* Builds a prompt prefix from the project's `.codehere/` memory layers
|
|
5
|
+
* (AGENTS.md briefing + working/session.md + semantic/lessons.jsonl + a
|
|
6
|
+
* slice of episodic/repo_map.json topSymbols) so spawned agents start
|
|
7
|
+
* warm — with the project's conventions, in-flight context, accumulated
|
|
8
|
+
* lessons, and the most-referenced symbols already in their context window.
|
|
9
|
+
*
|
|
10
|
+
* Pure, side-effect-free, fully unit-tested. Each adapter calls
|
|
11
|
+
* `buildMemoryPrefix(projectRoot)` near the top of `spawn()`, prepends
|
|
12
|
+
* the resulting prefix to the user's prompt, and exposes the metadata via
|
|
13
|
+
* `TaskSession.memoryInjection`.
|
|
14
|
+
*
|
|
15
|
+
* Token budget is char-count-approximated (4 chars ≈ 1 token, the
|
|
16
|
+
* Anthropic / OpenAI rule-of-thumb). Default cap is 3000 tokens —
|
|
17
|
+
* generous but well under any realistic context window. When budget is
|
|
18
|
+
* exceeded, sections are dropped in priority order:
|
|
19
|
+
*
|
|
20
|
+
* keep AGENTS.md — never dropped if user-edited (the briefing
|
|
21
|
+
* is the primary value)
|
|
22
|
+
* drop 3 repo_map slice — symbols are nice-to-have; the agent can
|
|
23
|
+
* discover them via grep if missing
|
|
24
|
+
* drop 2 lessons — useful but not load-bearing
|
|
25
|
+
* drop 1 session.md — useful for hand-off but redundant if the
|
|
26
|
+
* user re-states their task
|
|
27
|
+
*
|
|
28
|
+
* Default-seed detection skips placeholder content (the init seeds for
|
|
29
|
+
* AGENTS.md / session.md). Same byte-equality-based detection pattern as
|
|
30
|
+
* memory-command.ts (per feedback_implementation_patterns_v05.md Pattern #1).
|
|
31
|
+
*/
|
|
32
|
+
import { existsSync, readFileSync, realpathSync } from 'fs';
|
|
33
|
+
import { join, dirname, resolve } from 'path';
|
|
34
|
+
const DEFAULT_MAX_TOKENS = 3000;
|
|
35
|
+
const DEFAULT_MAX_LESSONS = 10;
|
|
36
|
+
const DEFAULT_MAX_SYMBOLS = 30;
|
|
37
|
+
// 3 chars per token — conservative ratio per K2 Round 1 feedback. Code
|
|
38
|
+
// tokenizes denser than prose under typical BPE, so the safer-defaults 4/1
|
|
39
|
+
// rule-of-thumb underestimates by 1.5-2x for code-heavy prefixes (which
|
|
40
|
+
// repo_map + lessons.jsonl frequently are). 3/1 stays well clear of model
|
|
41
|
+
// per-message caps without dropping useful content from typical repos.
|
|
42
|
+
const CHARS_PER_TOKEN = 3;
|
|
43
|
+
/** Char-count → token estimate. Conservative — actual tokenization may differ. */
|
|
44
|
+
function estimateTokens(s) {
|
|
45
|
+
return Math.ceil(s.length / CHARS_PER_TOKEN);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Detect whether the given file content is still the init-time placeholder.
|
|
49
|
+
* If so, the user hasn't customized it — injecting the placeholder would
|
|
50
|
+
* just confuse the agent with template instructions.
|
|
51
|
+
*
|
|
52
|
+
* Heuristic: presence of bracketed-instruction lines like `[One-paragraph
|
|
53
|
+
* description...]` or the `> Per-machine, gitignored` boilerplate, AND no
|
|
54
|
+
* substantive non-template content after the markers. Conservative — when
|
|
55
|
+
* in doubt, INCLUDE rather than exclude (better to over-share than miss
|
|
56
|
+
* the briefing).
|
|
57
|
+
*/
|
|
58
|
+
function looksLikeInitSeed(content, kind) {
|
|
59
|
+
if (kind === 'agents') {
|
|
60
|
+
// The init AGENTS/CONTEXT seed has these distinctive bracketed placeholders.
|
|
61
|
+
// If at least 2 of them are present, treat as untouched.
|
|
62
|
+
const markers = [
|
|
63
|
+
/\[One-paragraph description of what this repo does/,
|
|
64
|
+
/\[How this codebase is organized/,
|
|
65
|
+
/\[Tone for generated code\./,
|
|
66
|
+
/\[Don't do X\. Always do Y\./,
|
|
67
|
+
];
|
|
68
|
+
const hits = markers.filter(re => re.test(content)).length;
|
|
69
|
+
return hits >= 2;
|
|
70
|
+
}
|
|
71
|
+
if (kind === 'session') {
|
|
72
|
+
// The init session.md seed has this header + structure
|
|
73
|
+
const markers = [
|
|
74
|
+
/^# Working session/m,
|
|
75
|
+
/^> Per-machine, gitignored/m,
|
|
76
|
+
/^## In flight\s*\n+- \[ \]\s*$/m,
|
|
77
|
+
];
|
|
78
|
+
const hits = markers.filter(re => re.test(content)).length;
|
|
79
|
+
return hits >= 2;
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Read the project briefing. Search order (highest-priority first):
|
|
85
|
+
* 1. <projectRoot>/AGENTS.md — agentic-stack convention; if the user
|
|
86
|
+
* has this file at the repo root with real content, it IS their
|
|
87
|
+
* canonical briefing and codehere must respect it (Bug 2 from the
|
|
88
|
+
* Apr 28 EXP-2 dogfood on `~/code/clipa`).
|
|
89
|
+
* 2. <codehereDir>/AGENTS.md — F3-canonical (codehere's own)
|
|
90
|
+
* 3. <codehereDir>/CONTEXT.md — v0.4.1 back-compat
|
|
91
|
+
*
|
|
92
|
+
* Returns the body string + the path that was read (for the prefix
|
|
93
|
+
* header), or null if no usable briefing exists at any tier (every
|
|
94
|
+
* candidate is missing, empty, or looks like the init placeholder).
|
|
95
|
+
*/
|
|
96
|
+
function readBriefing(projectRoot, codehereDir) {
|
|
97
|
+
const candidates = [
|
|
98
|
+
{ path: join(projectRoot, 'AGENTS.md'), label: 'AGENTS.md (repo root)' },
|
|
99
|
+
{ path: join(codehereDir, 'AGENTS.md'), label: '.codehere/AGENTS.md' },
|
|
100
|
+
{ path: join(codehereDir, 'CONTEXT.md'), label: '.codehere/CONTEXT.md' },
|
|
101
|
+
];
|
|
102
|
+
for (const c of candidates) {
|
|
103
|
+
if (existsSync(c.path)) {
|
|
104
|
+
try {
|
|
105
|
+
const body = readFileSync(c.path, 'utf8').trim();
|
|
106
|
+
if (body.length === 0)
|
|
107
|
+
continue;
|
|
108
|
+
if (looksLikeInitSeed(body, 'agents'))
|
|
109
|
+
continue; // placeholder — try next
|
|
110
|
+
return { body, sourcePath: c.label };
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
/** Read working/session.md. Skip if seed-shaped or empty. */
|
|
120
|
+
function readSession(codehereDir) {
|
|
121
|
+
const p = join(codehereDir, 'memory/working/session.md');
|
|
122
|
+
if (!existsSync(p))
|
|
123
|
+
return null;
|
|
124
|
+
try {
|
|
125
|
+
const body = readFileSync(p, 'utf8').trim();
|
|
126
|
+
if (body.length === 0)
|
|
127
|
+
return null;
|
|
128
|
+
if (looksLikeInitSeed(body, 'session'))
|
|
129
|
+
return null;
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Read up to N lessons from semantic/lessons.jsonl. */
|
|
137
|
+
function readLessons(codehereDir, n) {
|
|
138
|
+
const p = join(codehereDir, 'memory/semantic/lessons.jsonl');
|
|
139
|
+
if (!existsSync(p))
|
|
140
|
+
return [];
|
|
141
|
+
try {
|
|
142
|
+
const body = readFileSync(p, 'utf8');
|
|
143
|
+
return body
|
|
144
|
+
.split('\n')
|
|
145
|
+
.filter(line => line.trim().length > 0)
|
|
146
|
+
.slice(0, n)
|
|
147
|
+
.map(line => {
|
|
148
|
+
try {
|
|
149
|
+
const obj = JSON.parse(line);
|
|
150
|
+
return typeof obj.text === 'string' ? obj.text : line;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return line;
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function readTopSymbols(codehereDir, n) {
|
|
162
|
+
const p = join(codehereDir, 'memory/episodic/repo_map.json');
|
|
163
|
+
if (!existsSync(p))
|
|
164
|
+
return [];
|
|
165
|
+
try {
|
|
166
|
+
const body = JSON.parse(readFileSync(p, 'utf8'));
|
|
167
|
+
if (!Array.isArray(body.topSymbols))
|
|
168
|
+
return [];
|
|
169
|
+
return body.topSymbols.slice(0, n);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
return [];
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Format individual sections. Each returns `null` if empty/skipped.
|
|
177
|
+
*/
|
|
178
|
+
function formatBriefing(briefing, sourcePath) {
|
|
179
|
+
return `# Project briefing (${sourcePath})\n\n${briefing}\n`;
|
|
180
|
+
}
|
|
181
|
+
function formatSession(session) {
|
|
182
|
+
return `# Current session scratch (.codehere/memory/working/session.md)\n\n${session}\n`;
|
|
183
|
+
}
|
|
184
|
+
function formatLessons(lessons) {
|
|
185
|
+
const items = lessons.map(l => `- ${l}`).join('\n');
|
|
186
|
+
return `# Project lessons (.codehere/memory/semantic/lessons.jsonl)\n\n${items}\n`;
|
|
187
|
+
}
|
|
188
|
+
function formatSymbols(symbols) {
|
|
189
|
+
const lines = symbols.map(s => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
|
|
190
|
+
return `# Top symbols in this repo (.codehere/memory/episodic/repo_map.json)\n\n${lines}\n`;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Build the memory-injection prefix for the project at `projectRoot`.
|
|
194
|
+
*
|
|
195
|
+
* If `.codehere/` doesn't exist, returns empty (nothing to inject).
|
|
196
|
+
* Otherwise reads the four sections, formats each, fits to the token
|
|
197
|
+
* budget by dropping low-priority sections first.
|
|
198
|
+
*/
|
|
199
|
+
export function buildMemoryPrefix(projectRoot, options = {}) {
|
|
200
|
+
const codehereDir = join(projectRoot, '.codehere');
|
|
201
|
+
const hasCodehereDir = existsSync(codehereDir);
|
|
202
|
+
const maxTokens = options.maxTokens ?? DEFAULT_MAX_TOKENS;
|
|
203
|
+
const maxLessons = options.maxLessons ?? DEFAULT_MAX_LESSONS;
|
|
204
|
+
const maxSymbols = options.maxSymbols ?? DEFAULT_MAX_SYMBOLS;
|
|
205
|
+
// Briefing discovery is INDEPENDENT of .codehere/ presence — a pure
|
|
206
|
+
// agentic-stack repo (root AGENTS.md, no .codehere/) is a valid
|
|
207
|
+
// injection source on its own (codex Round 1 fix on Bug 2). Only
|
|
208
|
+
// session/lessons/repo_map require the .codehere/ tree.
|
|
209
|
+
const briefing = readBriefing(projectRoot, codehereDir);
|
|
210
|
+
const session = hasCodehereDir ? readSession(codehereDir) : null;
|
|
211
|
+
const lessons = hasCodehereDir ? readLessons(codehereDir, maxLessons) : [];
|
|
212
|
+
const symbols = hasCodehereDir ? readTopSymbols(codehereDir, maxSymbols) : [];
|
|
213
|
+
// Build candidate sections in PRIORITY order (most-essential first).
|
|
214
|
+
// Drop direction is REVERSE — least-essential drops first.
|
|
215
|
+
const candidates = [];
|
|
216
|
+
if (briefing)
|
|
217
|
+
candidates.push({ key: 'agents', body: formatBriefing(briefing.body, briefing.sourcePath) });
|
|
218
|
+
if (session)
|
|
219
|
+
candidates.push({ key: 'session', body: formatSession(session) });
|
|
220
|
+
if (lessons.length > 0)
|
|
221
|
+
candidates.push({ key: 'lessons', body: formatLessons(lessons) });
|
|
222
|
+
if (symbols.length > 0)
|
|
223
|
+
candidates.push({ key: 'repo_map', body: formatSymbols(symbols) });
|
|
224
|
+
if (candidates.length === 0) {
|
|
225
|
+
return { prefix: '', included: [], tokens: 0, truncated: false };
|
|
226
|
+
}
|
|
227
|
+
// Closing separator, always added at the end.
|
|
228
|
+
const SEPARATOR = '\n---\n\n';
|
|
229
|
+
const separatorTokens = estimateTokens(SEPARATOR);
|
|
230
|
+
// Drop from least-essential (last) until everything fits in budget.
|
|
231
|
+
let truncated = false;
|
|
232
|
+
let kept = [...candidates];
|
|
233
|
+
while (kept.length > 0) {
|
|
234
|
+
const totalChars = kept.reduce((acc, c) => acc + c.body.length, 0);
|
|
235
|
+
const totalTokens = estimateTokens(kept.map(c => c.body).join('\n')) + separatorTokens;
|
|
236
|
+
if (totalTokens <= maxTokens)
|
|
237
|
+
break;
|
|
238
|
+
// Drop the last (least-essential) section.
|
|
239
|
+
kept.pop();
|
|
240
|
+
truncated = true;
|
|
241
|
+
void totalChars;
|
|
242
|
+
}
|
|
243
|
+
// Edge: even AGENTS alone is over budget — truncate AGENTS body itself.
|
|
244
|
+
if (kept.length === 1 && estimateTokens(kept[0].body) + separatorTokens > maxTokens) {
|
|
245
|
+
const room = (maxTokens - separatorTokens) * CHARS_PER_TOKEN;
|
|
246
|
+
const truncatedBody = kept[0].body.slice(0, Math.max(0, room - 32)) + '\n... [truncated to fit]\n';
|
|
247
|
+
kept = [{ key: kept[0].key, body: truncatedBody }];
|
|
248
|
+
truncated = true;
|
|
249
|
+
}
|
|
250
|
+
if (kept.length === 0) {
|
|
251
|
+
return { prefix: '', included: [], tokens: 0, truncated };
|
|
252
|
+
}
|
|
253
|
+
const prefix = kept.map(c => c.body).join('\n') + SEPARATOR;
|
|
254
|
+
return {
|
|
255
|
+
prefix,
|
|
256
|
+
included: kept.map(c => c.key),
|
|
257
|
+
tokens: estimateTokens(prefix),
|
|
258
|
+
truncated,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Walk up from `startPath` looking for the nearest ancestor (inclusive)
|
|
263
|
+
* that codehere can inject memory FROM — meaning EITHER:
|
|
264
|
+
* - has a `.codehere/` directory (codehere-initialized project), OR
|
|
265
|
+
* - has a root-level `AGENTS.md` (agentic-stack convention)
|
|
266
|
+
*
|
|
267
|
+
* Returns the REAL filesystem ancestor path (symlinks resolved), or
|
|
268
|
+
* `null` if no such ancestor exists.
|
|
269
|
+
*
|
|
270
|
+
* codex Round 1 BLOCKER fix: spawning a task from a subdirectory (e.g.
|
|
271
|
+
* `cd src/auth && codehere task ...`) would miss the project root if we
|
|
272
|
+
* only used the immediate cwd. Hence the upward walk.
|
|
273
|
+
*
|
|
274
|
+
* codex Round 2 BLOCKER fix: the upward walk must walk the REAL
|
|
275
|
+
* filesystem ancestry, not the LEXICAL one. Without `realpathSync`,
|
|
276
|
+
* `cwd` inside a symlinked subtree ascends through the lexical path and
|
|
277
|
+
* misses the real project root. Real scenario for iCloud-symlinked /
|
|
278
|
+
* shared-volume / mounted-drive projects.
|
|
279
|
+
*
|
|
280
|
+
* Bug 2 Round 2 BLOCKER fix: also accept a project root that has a
|
|
281
|
+
* root-level `AGENTS.md` even WITHOUT `.codehere/`. A pure agentic-stack
|
|
282
|
+
* repo (clipa-style) is a valid injection source on its own.
|
|
283
|
+
*
|
|
284
|
+
* Stops at the filesystem root. Symlink-following on read is the right
|
|
285
|
+
* asymmetry per ADR-012 (the symlink defense applies to writes, not reads).
|
|
286
|
+
*/
|
|
287
|
+
function findProjectRoot(startPath) {
|
|
288
|
+
let current;
|
|
289
|
+
try {
|
|
290
|
+
current = realpathSync(resolve(startPath));
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Broken symlink at startPath itself — fall back to lexical resolve.
|
|
294
|
+
// (Better to attempt some lookup than fail outright.)
|
|
295
|
+
current = resolve(startPath);
|
|
296
|
+
}
|
|
297
|
+
// Bound the loop at the filesystem root by checking dirname stability.
|
|
298
|
+
for (;;) {
|
|
299
|
+
if (existsSync(join(current, '.codehere')) ||
|
|
300
|
+
existsSync(join(current, 'AGENTS.md'))) {
|
|
301
|
+
return current;
|
|
302
|
+
}
|
|
303
|
+
const parent = dirname(current);
|
|
304
|
+
if (parent === current)
|
|
305
|
+
return null;
|
|
306
|
+
current = parent;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Adapter-side convenience: turn a raw user prompt into the spawn-ready
|
|
311
|
+
* prompt + the diagnostic record. Each of the 5 built-in adapters calls
|
|
312
|
+
* this near the top of `spawn()` and uses the returned `prompt` going
|
|
313
|
+
* forward, plus exposes `record` via `TaskSession.memoryInjection`.
|
|
314
|
+
*
|
|
315
|
+
* Walks UPWARD from `cwd` to find the nearest ancestor with `.codehere/`,
|
|
316
|
+
* so spawning from a subdirectory still gets injection (codex Round 1 fix).
|
|
317
|
+
*
|
|
318
|
+
* Returns `{ prompt: <unchanged>, record: undefined }` when:
|
|
319
|
+
* - `options.noMemoryPrefix` is true (caller opted out)
|
|
320
|
+
* - no ancestor of `cwd` has `.codehere/` (nothing to inject)
|
|
321
|
+
* - all sections are empty / placeholder seeds
|
|
322
|
+
*/
|
|
323
|
+
export function applyMemoryPrefix(prompt, cwd, options) {
|
|
324
|
+
if (options.noMemoryPrefix)
|
|
325
|
+
return { prompt, record: undefined };
|
|
326
|
+
const projectRoot = findProjectRoot(cwd);
|
|
327
|
+
if (projectRoot === null)
|
|
328
|
+
return { prompt, record: undefined };
|
|
329
|
+
const r = buildMemoryPrefix(projectRoot);
|
|
330
|
+
if (r.prefix === '')
|
|
331
|
+
return { prompt, record: undefined };
|
|
332
|
+
return {
|
|
333
|
+
prompt: r.prefix + prompt,
|
|
334
|
+
record: { included: r.included, tokens: r.tokens, truncated: r.truncated },
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
//# sourceMappingURL=memory-injector.js.map
|
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
import { spawn, execFileSync } from 'child_process';
|
|
14
14
|
import { EventEmitter } from 'events';
|
|
15
15
|
import { randomUUID } from 'crypto';
|
|
16
|
+
import { existsSync } from 'fs';
|
|
17
|
+
import { homedir } from 'os';
|
|
18
|
+
import { join } from 'path';
|
|
16
19
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
20
|
+
import { applyMemoryPrefix } from './memory-injector.js';
|
|
17
21
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
18
22
|
const HEADLESS_FLAGS = ['run', '--format', 'json'];
|
|
19
23
|
const ERROR_PATTERNS = [
|
|
@@ -45,6 +49,9 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
45
49
|
let statusValue = 'running';
|
|
46
50
|
let killRequested = false;
|
|
47
51
|
const cwd = options.cwd ?? process.cwd();
|
|
52
|
+
// F3 Task 8 — memory injection (same shape as every other adapter).
|
|
53
|
+
const injected = applyMemoryPrefix(prompt, cwd, options);
|
|
54
|
+
const finalPrompt = injected.prompt;
|
|
48
55
|
const snapshot = snapshotCwd(cwd);
|
|
49
56
|
const aggregator = new ProgressAggregator('opencode');
|
|
50
57
|
let stepsSnapshot = [];
|
|
@@ -115,7 +122,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
115
122
|
let child;
|
|
116
123
|
try {
|
|
117
124
|
// OpenCode's `run` expects the message as positional args, not stdin.
|
|
118
|
-
child = spawn('opencode', [...HEADLESS_FLAGS,
|
|
125
|
+
child = spawn('opencode', [...HEADLESS_FLAGS, finalPrompt, ...(options.extraArgs ?? [])], {
|
|
119
126
|
cwd,
|
|
120
127
|
env: spawnEnv,
|
|
121
128
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -206,6 +213,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
206
213
|
startedAt,
|
|
207
214
|
prompt,
|
|
208
215
|
agent: 'opencode',
|
|
216
|
+
memoryInjection: injected.record,
|
|
209
217
|
get status() {
|
|
210
218
|
return statusValue;
|
|
211
219
|
},
|
|
@@ -266,9 +274,31 @@ export const OpenCodeAdapter = {
|
|
|
266
274
|
installHint: { command: 'curl -fsSL https://opencode.ai/install | bash', url: 'https://opencode.ai' },
|
|
267
275
|
};
|
|
268
276
|
}
|
|
269
|
-
// OpenCode manages its own provider credentials via `opencode
|
|
270
|
-
//
|
|
271
|
-
//
|
|
277
|
+
// OpenCode manages its own provider credentials via `opencode auth`
|
|
278
|
+
// (alias `opencode providers`). The auth file lives at
|
|
279
|
+
// $XDG_DATA_HOME/opencode/auth.json (default ~/.local/share/opencode/auth.json
|
|
280
|
+
// on Linux/macOS) — created with mode 600 the first time a provider is added.
|
|
281
|
+
//
|
|
282
|
+
// Pre-May-1 we returned 'ready' on install alone, deferring to runtime to
|
|
283
|
+
// catch missing-auth via ERROR_PATTERNS.auth_missing. That was dishonest:
|
|
284
|
+
// /api/readiness/agents would say 'ready' → user would spawn → ~50s later
|
|
285
|
+
// the task errored with no clear next step. Surfaced May 1 2026 by the
|
|
286
|
+
// multi-agent dogfood test alongside the same-shape gemini fix.
|
|
287
|
+
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share');
|
|
288
|
+
const opencodeAuthPath = join(xdgData, 'opencode', 'auth.json');
|
|
289
|
+
if (!existsSync(opencodeAuthPath)) {
|
|
290
|
+
return {
|
|
291
|
+
id: this.id,
|
|
292
|
+
label: this.label,
|
|
293
|
+
state: 'needs_auth',
|
|
294
|
+
installed: true,
|
|
295
|
+
authHint: {
|
|
296
|
+
command: 'opencode auth # interactive provider/key wizard',
|
|
297
|
+
url: 'https://opencode.ai/docs/configuration',
|
|
298
|
+
detail: `OpenCode found on PATH but no provider configured (${opencodeAuthPath} missing). Run \`opencode auth\` once to add an AI provider — supports 75+ providers BYOK including Anthropic, OpenAI, Mistral, etc.`,
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
272
302
|
return { id: this.id, label: this.label, state: 'ready', installed: true };
|
|
273
303
|
},
|
|
274
304
|
};
|
|
@@ -101,6 +101,8 @@ export class ProgressAggregator {
|
|
|
101
101
|
return this.ingestCodex(type, data);
|
|
102
102
|
if (this.agentId === 'gemini')
|
|
103
103
|
return this.ingestGemini(type, data);
|
|
104
|
+
if (this.agentId === 'aider')
|
|
105
|
+
return this.ingestAider(type, data);
|
|
104
106
|
return false;
|
|
105
107
|
}
|
|
106
108
|
/** Flush: mark every still-active step as failed (called on abnormal exit). */
|
|
@@ -195,6 +197,123 @@ export class ProgressAggregator {
|
|
|
195
197
|
}
|
|
196
198
|
return false;
|
|
197
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* Aider observability parity (v0.5 Path B per docs/design/f3_memory_spec.md
|
|
202
|
+
* + ~/.claude/.../memory/learning_aider.md).
|
|
203
|
+
*
|
|
204
|
+
* Aider doesn't expose a structured event stream — we parse stdout lines
|
|
205
|
+
* matching the EXACT patterns aider's `base_coder.py` writes when it
|
|
206
|
+
* applies an edit, creates a new file, or runs a shell command (verified
|
|
207
|
+
* against installed aider 0.86.2 source — line refs below).
|
|
208
|
+
*
|
|
209
|
+
* Patterns we match:
|
|
210
|
+
* "Running <command>" — base_coder.py:2472 — fires AFTER
|
|
211
|
+
* aider's confirm_ask returns yes (which `--yes-always` answers
|
|
212
|
+
* automatically). Aider has already started executing by the time we see
|
|
213
|
+
* this — OBSERVABILITY, not blocking.
|
|
214
|
+
* "Applied edit to <path>" — base_coder.py:2334 — fires after
|
|
215
|
+
* a SEARCH/REPLACE block lands. Used for both edits AND net-new files
|
|
216
|
+
* created via the same flow (aider doesn't distinguish in this message).
|
|
217
|
+
* "Creating empty file <path>" — base_coder.py:461 — fires when
|
|
218
|
+
* aider creates a brand-new empty file as scaffolding before edits.
|
|
219
|
+
*
|
|
220
|
+
* Patterns we DELIBERATELY do NOT match:
|
|
221
|
+
* "Did not apply edit to ..." — explicit non-event; we drop these
|
|
222
|
+
* (would be a false-positive Edit step otherwise).
|
|
223
|
+
* "Create new file?" — that's a CONFIRM_ASK prompt, not a
|
|
224
|
+
* tool_output. With `--yes-always` it never fires interactively, and
|
|
225
|
+
* it's not the action-taken signal anyway.
|
|
226
|
+
*
|
|
227
|
+
* Each matched pattern → one ProgressStep marked done immediately (aider
|
|
228
|
+
* doesn't emit separate completion events; the line itself IS the completion).
|
|
229
|
+
*
|
|
230
|
+
* Label vocabulary is aligned with Claude / Gemini / Codex labels for
|
|
231
|
+
* cross-agent UI consistency: `Run X` for shell, `Edit <path>` for file
|
|
232
|
+
* edits, `Write <path>` for net-new file creation.
|
|
233
|
+
*
|
|
234
|
+
* ANSI handling: HEADLESS_FLAGS in aider-adapter.ts include `--no-pretty`
|
|
235
|
+
* which strips colors. As defense-in-depth (in case extraArgs override that
|
|
236
|
+
* or aider regresses), we strip ANSI escape sequences before matching.
|
|
237
|
+
*
|
|
238
|
+
* Limitation: codehere CANNOT block aider's actions via this path. The
|
|
239
|
+
* upstream-PR Path A (adding `--pre-action-hook` to aider) is what enables
|
|
240
|
+
* blocking parity. v0.5 ships observability; v0.5.x or v0.6 ships blocking
|
|
241
|
+
* once Path A merges.
|
|
242
|
+
*/
|
|
243
|
+
// eslint-disable-next-line no-control-regex
|
|
244
|
+
static ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
245
|
+
/**
|
|
246
|
+
* Per-instance set of file paths aider just created via "Creating empty file ...".
|
|
247
|
+
* Aider emits BOTH `Creating empty file <p>` AND `Applied edit to <p>` for one
|
|
248
|
+
* logical net-new-file creation (file gets touched, then initial content
|
|
249
|
+
* gets applied). Without this set we'd record two steps (`Write <p>` then
|
|
250
|
+
* `Edit <p>`) for one action — codex Round 2 caught this. We suppress the
|
|
251
|
+
* subsequent `Applied edit to` for the same path so the user sees `Write`
|
|
252
|
+
* once, not `Write` followed by `Edit`.
|
|
253
|
+
*/
|
|
254
|
+
aiderJustCreated = new Set();
|
|
255
|
+
ingestAider(type, data) {
|
|
256
|
+
if (type !== 'stdout')
|
|
257
|
+
return false;
|
|
258
|
+
const raw = typeof data.line === 'string' ? data.line : '';
|
|
259
|
+
// Strip ANSI escape codes BEFORE matching so the parser survives a
|
|
260
|
+
// `--no-pretty` regression or override.
|
|
261
|
+
const line = raw.replace(ProgressAggregator.ANSI_RE, '').trim();
|
|
262
|
+
if (!line)
|
|
263
|
+
return false;
|
|
264
|
+
// EXPLICIT NON-EVENT: defense-in-depth guard. The `^Applied edit to` regex
|
|
265
|
+
// below wouldn't match "Did not apply edit to ..." because of the anchor,
|
|
266
|
+
// but if either pattern ever loosens, this short-circuit prevents a false
|
|
267
|
+
// Edit step from creeping in.
|
|
268
|
+
if (/^Did not apply edit to\s+/.test(line))
|
|
269
|
+
return false;
|
|
270
|
+
// "Running <command>" — base_coder.py:2472
|
|
271
|
+
let m = line.match(/^Running\s+(.+)$/);
|
|
272
|
+
if (m) {
|
|
273
|
+
this._steps.push({
|
|
274
|
+
label: truncate('Run ' + m[1]),
|
|
275
|
+
state: 'done',
|
|
276
|
+
startedAt: Date.now(),
|
|
277
|
+
completedAt: Date.now(),
|
|
278
|
+
});
|
|
279
|
+
return true;
|
|
280
|
+
}
|
|
281
|
+
// "Applied edit to <path>" — base_coder.py:2334
|
|
282
|
+
m = line.match(/^Applied edit to\s+(.+)$/);
|
|
283
|
+
if (m) {
|
|
284
|
+
const path = m[1];
|
|
285
|
+
// If aider JUST created this file, the Write step already represents the
|
|
286
|
+
// action; skip the Edit step that aider emits for the initial content.
|
|
287
|
+
// Clear from the set after consuming so subsequent edits to the same
|
|
288
|
+
// path (in this same session) DO show as Edit.
|
|
289
|
+
if (this.aiderJustCreated.has(path)) {
|
|
290
|
+
this.aiderJustCreated.delete(path);
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
this._steps.push({
|
|
294
|
+
label: 'Edit ' + shortPath(path),
|
|
295
|
+
state: 'done',
|
|
296
|
+
startedAt: Date.now(),
|
|
297
|
+
completedAt: Date.now(),
|
|
298
|
+
});
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
// "Creating empty file <path>" — base_coder.py:461 — net-new file scaffold
|
|
302
|
+
m = line.match(/^Creating empty file\s+(.+)$/);
|
|
303
|
+
if (m) {
|
|
304
|
+
const path = m[1];
|
|
305
|
+
this.aiderJustCreated.add(path);
|
|
306
|
+
// Use "Write" to align with Claude/Gemini's net-new-file label vocabulary.
|
|
307
|
+
this._steps.push({
|
|
308
|
+
label: 'Write ' + shortPath(path),
|
|
309
|
+
state: 'done',
|
|
310
|
+
startedAt: Date.now(),
|
|
311
|
+
completedAt: Date.now(),
|
|
312
|
+
});
|
|
313
|
+
return true;
|
|
314
|
+
}
|
|
315
|
+
return false;
|
|
316
|
+
}
|
|
198
317
|
ingestCodex(type, data) {
|
|
199
318
|
if (type !== 'item.completed')
|
|
200
319
|
return false;
|
|
@@ -79,11 +79,8 @@ export function detectReadiness(env = process.env) {
|
|
|
79
79
|
return {
|
|
80
80
|
state: 'api_key_shadow',
|
|
81
81
|
severity: 'warn',
|
|
82
|
-
title: 'Using
|
|
83
|
-
detail: `ANTHROPIC_API_KEY is set
|
|
84
|
-
`codehere unsets it for spawned tasks so they use your Claude subscription — ` +
|
|
85
|
-
`the same auth your interactive \`claude\` uses. Remove the key from your shell ` +
|
|
86
|
-
`config to dismiss this warning.`,
|
|
82
|
+
title: 'Using Claude subscription auth',
|
|
83
|
+
detail: '`ANTHROPIC_API_KEY` is set. Spawned tasks ignore it and use your `claude` login instead. Unset it in your shell to hide this.',
|
|
87
84
|
action: {
|
|
88
85
|
label: 'Where is it set?',
|
|
89
86
|
command: 'grep -l ANTHROPIC_API_KEY ~/.zshrc ~/.bashrc ~/.zprofile 2>/dev/null',
|
|
@@ -96,7 +96,19 @@ export function loadRecent(limit = 20) {
|
|
|
96
96
|
prompt: s.prompt,
|
|
97
97
|
cwd: s.cwd,
|
|
98
98
|
startedAt: s.startedAt,
|
|
99
|
-
|
|
99
|
+
// BUG 2 (May 1 2026) — defensive normalization for zombie records.
|
|
100
|
+
// Pre-fix sessions persisted before commit <THIS> were written with
|
|
101
|
+
// status='running' even when their completed-record carried real
|
|
102
|
+
// durationMs/completedAt — the conductor adapter mirrored its status
|
|
103
|
+
// AFTER emitting the exit event. The fix at the source (conductor-
|
|
104
|
+
// adapter.ts) prevents new zombies; this normalization repairs old
|
|
105
|
+
// records on read so the UI doesn't show a "running" badge on a
|
|
106
|
+
// session that obviously finished. We have no ground truth on what
|
|
107
|
+
// the actual exit code WAS for these old records, so 'completed' is
|
|
108
|
+
// the safest default — they'd been displayed as completed in the UI
|
|
109
|
+
// had this been a session-manager.ts bug. Mark explicitly via a
|
|
110
|
+
// synthetic note so debug viewers can tell normalization happened.
|
|
111
|
+
status: c.status === 'running' && c.completedAt > 0 ? 'completed' : c.status,
|
|
100
112
|
durationMs: c.durationMs,
|
|
101
113
|
completedAt: c.completedAt,
|
|
102
114
|
steps: c.steps,
|
|
@@ -437,10 +437,24 @@ export function startMCPServer(options = {}) {
|
|
|
437
437
|
// ── E2 · Per-agent readiness (onboarding surface) ─────────────────────────
|
|
438
438
|
// Full registry snapshot: install state + auth state + hints. Powers the
|
|
439
439
|
// setup-aware agent dropdown and the one-click swap flow on auth errors.
|
|
440
|
+
//
|
|
441
|
+
// Apr 29: Conductor moved to a separate `auto` field. The web UI IS the
|
|
442
|
+
// Conductor surface — exposing "conductor" as a peer in the agents array
|
|
443
|
+
// is the "Gmail asks you to pick 'email' before composing" anti-pattern.
|
|
444
|
+
// But Conductor's readiness state IS the Auto option's readiness, so the
|
|
445
|
+
// UI needs it preserved (codex Round 1 caught: fresh install with no
|
|
446
|
+
// specialists installed would otherwise lose the setup prompt — Auto
|
|
447
|
+
// gets selected by default, submission fails at /api/task because
|
|
448
|
+
// conductor.isInstalled() returns false). The separate `auto` field
|
|
449
|
+
// keeps the setup hint plumbing intact while preventing the dropdown
|
|
450
|
+
// from showing Conductor as a peer choice.
|
|
440
451
|
if (req.method === 'GET' && req.url === '/api/readiness/agents') {
|
|
441
452
|
try {
|
|
453
|
+
const all = readinessForAllAgents();
|
|
454
|
+
const auto = all.find((a) => a.id === 'conductor');
|
|
455
|
+
const agents = all.filter((a) => a.id !== 'conductor');
|
|
442
456
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
443
|
-
res.end(JSON.stringify({ agents
|
|
457
|
+
res.end(JSON.stringify({ agents, auto }));
|
|
444
458
|
}
|
|
445
459
|
catch (e) {
|
|
446
460
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
@@ -485,9 +499,22 @@ export function startMCPServer(options = {}) {
|
|
|
485
499
|
// log/spawn. Stops log injection (`agent: "claude-code\n[FAKE]"`),
|
|
486
500
|
// future-proofs against partial-match exploits, and fails fast
|
|
487
501
|
// with a clear 400 instead of letting SessionManager.spawn throw.
|
|
502
|
+
//
|
|
503
|
+
// Apr 29: default flipped from 'claude-code' to 'conductor'. The web
|
|
504
|
+
// UI is the Conductor surface; tasks submitted without an explicit
|
|
505
|
+
// agent route through the supervisor (rule-based routing today,
|
|
506
|
+
// LLM classifier in v0.6). Empty string is treated like undefined
|
|
507
|
+
// so the form submitting `agent: ""` (Auto option) hits the same
|
|
508
|
+
// path. Specialists remain explicit overrides via the dropdown.
|
|
509
|
+
// Type-check is preserved: non-string non-undefined values still 400.
|
|
510
|
+
if (agent !== undefined && agent !== null && typeof agent !== 'string') {
|
|
511
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
512
|
+
res.end(JSON.stringify({ error: 'agent must be a string or omitted' }));
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
488
515
|
const validAgentIds = new Set(allAdapters().map((a) => a.id));
|
|
489
|
-
const agentId = agent
|
|
490
|
-
if (
|
|
516
|
+
const agentId = (typeof agent === 'string' && agent.length > 0) ? agent : 'conductor';
|
|
517
|
+
if (!validAgentIds.has(agentId)) {
|
|
491
518
|
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
492
519
|
res.end(JSON.stringify({
|
|
493
520
|
error: `Unknown agent: ${typeof agentId === 'string' ? agentId.slice(0, 64) : '<non-string>'}`,
|