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.
@@ -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, prompt, ...(options.extraArgs ?? [])], {
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 providers`.
270
- // Can't statically probe the config without reading its data dir — treat
271
- // install as ready, let runtime codehere:error catch auth failures.
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 your Claude Code subscription',
83
- detail: `ANTHROPIC_API_KEY is set in your environment. ` +
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
- status: c.status,
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: readinessForAllAgents() }));
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 ?? 'claude-code';
490
- if (typeof agentId !== 'string' || !validAgentIds.has(agentId)) {
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>'}`,