codehere 0.6.1 → 0.6.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/dist/application/use-cases/generate-final-report.js +57 -2
- package/dist/application/use-cases/generate-narration.js +9 -3
- package/dist/index.js +7 -3
- package/dist/infrastructure/cost/budget-config.js +1 -1
- package/dist/infrastructure/cost/spend-aggregator.js +5 -5
- package/dist/infrastructure/hooks/claude-sdk-hook.js +6 -2
- package/dist/infrastructure/orchestration/codex-adapter.js +11 -6
- package/dist/infrastructure/orchestration/context-handoff.js +1 -1
- package/dist/infrastructure/orchestration/gemini-adapter.js +3 -15
- package/dist/infrastructure/orchestration/graphify-context.js +6 -13
- package/dist/infrastructure/orchestration/memory-injector.js +11 -16
- package/dist/infrastructure/orchestration/opencode-adapter.js +1 -1
- package/dist/infrastructure/orchestration/progress-aggregator.js +1 -2
- package/dist/infrastructure/orchestration/session-manager.js +48 -23
- package/dist/infrastructure/protocols/cloud-sync.js +9 -5
- package/dist/infrastructure/protocols/mcp-server-impl.js +15 -5
- package/dist/infrastructure/storage/cross-tool-agents.js +19 -2
- package/dist/infrastructure/storage/project-thread-migration.js +12 -7
- package/dist/infrastructure/storage/project-thread-reader.js +11 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +6 -2
- package/dist/presentation/cli/commands/budget-command.js +2 -2
- package/dist/presentation/cli/commands/init-command.js +5 -4
- package/dist/presentation/cli/commands/memory-command.js +29 -15
- package/dist/presentation/cli/commands/protocols-builder.js +2 -2
- package/dist/presentation/cli/commands/repo-map-builder.js +4 -4
- package/dist/presentation/cli/repl.js +20 -1
- package/dist/ui.js +36 -28
- package/dist/utils/typo-guard.js +1 -1
- package/package.json +1 -1
|
@@ -61,7 +61,9 @@ ${i.steps.length ? i.steps.map((s) => `- ${s.label} (${s.state})`).join('\n') :
|
|
|
61
61
|
|
|
62
62
|
Files changed:
|
|
63
63
|
${i.artifacts?.files?.length
|
|
64
|
-
? i.artifacts.files
|
|
64
|
+
? i.artifacts.files
|
|
65
|
+
.map((f) => `- ${f.path} (+${f.added}/-${f.removed}${f.trust !== undefined ? `, trust: ${f.trust}` : ''})`)
|
|
66
|
+
.join('\n')
|
|
65
67
|
: '(no file artifacts captured)'}
|
|
66
68
|
|
|
67
69
|
Token usage: ${i.artifacts?.usage?.inputTokens ?? 0} in / ${i.artifacts?.usage?.outputTokens ?? 0} out
|
|
@@ -75,16 +77,69 @@ function selectProviderBinary() {
|
|
|
75
77
|
return [override];
|
|
76
78
|
return ['claude-free', 'claude'];
|
|
77
79
|
}
|
|
80
|
+
/**
|
|
81
|
+
* UAT-003 — patterns that indicate the wrapped agent's session terminated
|
|
82
|
+
* because of a network / auth / quota failure rather than a real task.
|
|
83
|
+
* When we see these in the would-be summary text, we prepend a plain-English
|
|
84
|
+
* recovery hint instead of letting the raw provider error echo through.
|
|
85
|
+
*
|
|
86
|
+
* Pre-fix: bare `exit` in REPL → Conductor spawned claude-code → real API
|
|
87
|
+
* call failed with "All connection attempts failed Request ID: req_...";
|
|
88
|
+
* Final Report quoted that text verbatim. Per Core Values #4 + #6, this is
|
|
89
|
+
* unacceptable — the user sees a Request ID that means nothing.
|
|
90
|
+
*/
|
|
91
|
+
const NETWORK_FAILURE_PATTERNS = /(?:all connection attempts failed|ECONNREFUSED|ENOTFOUND|fetch failed|network error|request id:\s*req_|connection timed out|getaddrinfo)/i;
|
|
92
|
+
function isLikelyNetworkFailureSummary(text) {
|
|
93
|
+
return NETWORK_FAILURE_PATTERNS.test(text);
|
|
94
|
+
}
|
|
95
|
+
function buildErrorRecoveryReport(input, durationMs) {
|
|
96
|
+
// Tailor the hint to the specific failure shape we can detect from input.
|
|
97
|
+
const agentName = input.agent === 'conductor' ? 'the agent codehere chose' : input.agent;
|
|
98
|
+
return {
|
|
99
|
+
summary: `The ${agentName} session ended without completing the task. ` +
|
|
100
|
+
`This usually means a network hiccup, an expired login, or a rate limit on the provider.\n\n` +
|
|
101
|
+
`What to try: check your internet, run \`codehere health\` to confirm reachability + auth, ` +
|
|
102
|
+
`or wait a minute and rerun the same prompt. If it keeps happening, try a different agent ` +
|
|
103
|
+
`from the dropdown — codehere is vendor-neutral on purpose.\n\n` +
|
|
104
|
+
`No files were changed in this attempt.`,
|
|
105
|
+
generatedAt: Date.now(),
|
|
106
|
+
durationMs,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
78
109
|
/**
|
|
79
110
|
* Generate a Final Report. Returns null if no provider is reachable.
|
|
80
111
|
* Never throws — failure modes return null + log a debug line.
|
|
112
|
+
*
|
|
113
|
+
* UAT-003 — when the input.status is 'error', we skip the LLM call entirely
|
|
114
|
+
* and return a deterministic recovery hint. Saves tokens on a known-failed
|
|
115
|
+
* task AND gives the user a plain-English next step instead of waiting for
|
|
116
|
+
* a possibly-unhelpful LLM summary.
|
|
81
117
|
*/
|
|
82
118
|
export async function generateFinalReport(input) {
|
|
119
|
+
// Short-circuit on errored sessions — don't waste tokens summarizing nothing.
|
|
120
|
+
if (input.status === 'error') {
|
|
121
|
+
return buildErrorRecoveryReport(input, 0);
|
|
122
|
+
}
|
|
83
123
|
const prompt = PROMPT_TEMPLATE(input);
|
|
84
124
|
for (const binary of selectProviderBinary()) {
|
|
85
125
|
const result = await tryProvider(binary, prompt, input.sessionId);
|
|
86
|
-
if (result)
|
|
126
|
+
if (result) {
|
|
127
|
+
// Defense-in-depth: even on 'completed' status, if the generated
|
|
128
|
+
// summary leaks a provider network-failure string (e.g., the wrapped
|
|
129
|
+
// agent exited 0 but its assistant text was an error message), wrap
|
|
130
|
+
// the summary with a recovery preamble.
|
|
131
|
+
if (isLikelyNetworkFailureSummary(result.summary)) {
|
|
132
|
+
return {
|
|
133
|
+
summary: `Couldn't reach the provider cleanly — the agent's session text looks like a network or auth failure.\n\n` +
|
|
134
|
+
`What to try: check your internet, run \`codehere health\`, or rerun in a minute. ` +
|
|
135
|
+
`If it persists, switch agents from the dropdown.\n\n` +
|
|
136
|
+
`— Raw agent message —\n${result.summary}`,
|
|
137
|
+
generatedAt: result.generatedAt,
|
|
138
|
+
durationMs: result.durationMs,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
87
141
|
return result;
|
|
142
|
+
}
|
|
88
143
|
}
|
|
89
144
|
return null;
|
|
90
145
|
}
|
|
@@ -75,11 +75,17 @@ function tryProvider(binary, prompt, sessionId, stepCount) {
|
|
|
75
75
|
try {
|
|
76
76
|
child.kill();
|
|
77
77
|
}
|
|
78
|
-
catch {
|
|
78
|
+
catch {
|
|
79
|
+
/* ignore */
|
|
80
|
+
}
|
|
79
81
|
finish(null);
|
|
80
82
|
}, 20_000);
|
|
81
|
-
child.stdout?.on('data', (chunk) => {
|
|
82
|
-
|
|
83
|
+
child.stdout?.on('data', (chunk) => {
|
|
84
|
+
stdout += chunk.toString('utf-8');
|
|
85
|
+
});
|
|
86
|
+
child.stderr?.on('data', (chunk) => {
|
|
87
|
+
stderr += chunk.toString('utf-8');
|
|
88
|
+
});
|
|
83
89
|
child.on('close', (code) => {
|
|
84
90
|
clearTimeout(timeout);
|
|
85
91
|
if (code !== 0) {
|
package/dist/index.js
CHANGED
|
@@ -77,7 +77,7 @@ program
|
|
|
77
77
|
// typo guard miss the unknown-command shape. See typo-guard.ts.
|
|
78
78
|
.argument('[query...]', 'Optional initial task to delegate (otherwise enter interactive REPL)')
|
|
79
79
|
.action(async (queryParts) => {
|
|
80
|
-
const joined =
|
|
80
|
+
const joined = queryParts && queryParts.length > 0 ? queryParts.join(' ') : '';
|
|
81
81
|
// One-shot delegate: `codehere "review src/auth.ts"` runs that task and exits.
|
|
82
82
|
// Useful for scripting or quick one-offs without entering the REPL.
|
|
83
83
|
if (joined && joined.trim()) {
|
|
@@ -581,13 +581,17 @@ program
|
|
|
581
581
|
const { SessionManager } = require('./infrastructure/orchestration/session-manager.js');
|
|
582
582
|
SessionManager.getInstance().shutdown();
|
|
583
583
|
}
|
|
584
|
-
catch {
|
|
584
|
+
catch {
|
|
585
|
+
/* graceful degradation — daemon may not have started fully */
|
|
586
|
+
}
|
|
585
587
|
if (cloud) {
|
|
586
588
|
try {
|
|
587
589
|
const { stopCloudSync } = require('./infrastructure/protocols/cloud-sync.js');
|
|
588
590
|
stopCloudSync();
|
|
589
591
|
}
|
|
590
|
-
catch {
|
|
592
|
+
catch {
|
|
593
|
+
/* cloud sync may not be loaded */
|
|
594
|
+
}
|
|
591
595
|
}
|
|
592
596
|
server.close();
|
|
593
597
|
};
|
|
@@ -84,7 +84,7 @@ export function saveBudget(cfg) {
|
|
|
84
84
|
mkdirSync(dir, { recursive: true });
|
|
85
85
|
const lines = [
|
|
86
86
|
'# codehere daily-spend budget. Edit by hand or via `codehere budget set <amount>`.',
|
|
87
|
-
|
|
87
|
+
"# daily_usd: hard ceiling for today's estimated spend. Set to a number to enable.",
|
|
88
88
|
'# Leave commented out / omit to disable budget enforcement entirely.',
|
|
89
89
|
];
|
|
90
90
|
if (cfg.dailyUsd !== null) {
|
|
@@ -36,11 +36,11 @@ function sessionsFile() {
|
|
|
36
36
|
* under-promise and miss a real bill. */
|
|
37
37
|
const COST_PER_M_OUTPUT_TOKENS_USD = {
|
|
38
38
|
'claude-code': 15.0, // Sonnet 4.6 baseline
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
39
|
+
codex: 10.0, // GPT-5 / codex baseline
|
|
40
|
+
aider: 15.0, // typically Sonnet
|
|
41
|
+
gemini: 0, // free tier (Google AI Studio)
|
|
42
|
+
opencode: 0, // typically free local / NIM-routed
|
|
43
|
+
conductor: 15.0, // routes to Claude Code by default
|
|
44
44
|
};
|
|
45
45
|
const INPUT_RATE_RATIO = 0.2; // input ≈ 1/5 of output
|
|
46
46
|
const CACHE_READ_RATIO = 0.1; // cache reads ≈ 1/10 of input (so 1/50 output)
|
|
@@ -517,7 +517,9 @@ export function appendAuditLog(entry) {
|
|
|
517
517
|
catch {
|
|
518
518
|
// Lock held by another process — spin-wait ~10ms
|
|
519
519
|
const deadline = Date.now() + 10;
|
|
520
|
-
while (Date.now() < deadline) {
|
|
520
|
+
while (Date.now() < deadline) {
|
|
521
|
+
/* busy-wait — sync context */
|
|
522
|
+
}
|
|
521
523
|
}
|
|
522
524
|
}
|
|
523
525
|
return false;
|
|
@@ -526,7 +528,9 @@ export function appendAuditLog(entry) {
|
|
|
526
528
|
try {
|
|
527
529
|
unlinkSync(LOCK_FILE);
|
|
528
530
|
}
|
|
529
|
-
catch {
|
|
531
|
+
catch {
|
|
532
|
+
/* best effort */
|
|
533
|
+
}
|
|
530
534
|
};
|
|
531
535
|
if (!lockAcquired()) {
|
|
532
536
|
// Fall through unlocked rather than drop the entry. The entry still
|
|
@@ -216,9 +216,7 @@ export function spawnCodex(prompt, options = {}) {
|
|
|
216
216
|
continue;
|
|
217
217
|
try {
|
|
218
218
|
const data = JSON.parse(line);
|
|
219
|
-
if (codexThreadId === undefined &&
|
|
220
|
-
data.type === 'thread.started' &&
|
|
221
|
-
typeof data.thread_id === 'string') {
|
|
219
|
+
if (codexThreadId === undefined && data.type === 'thread.started' && typeof data.thread_id === 'string') {
|
|
222
220
|
codexThreadId = data.thread_id;
|
|
223
221
|
}
|
|
224
222
|
emit({ type: data.type ?? 'codex_event', timestamp: Date.now(), data });
|
|
@@ -325,8 +323,15 @@ export function spawnCodex(prompt, options = {}) {
|
|
|
325
323
|
let stdout = '';
|
|
326
324
|
let stderr = '';
|
|
327
325
|
// codex exec resume <SESSION_ID> - → reads prompt from stdin
|
|
328
|
-
const followup = spawn('codex', [
|
|
329
|
-
'
|
|
326
|
+
const followup = spawn('codex', [
|
|
327
|
+
'exec',
|
|
328
|
+
'--json',
|
|
329
|
+
'--skip-git-repo-check',
|
|
330
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
331
|
+
'resume',
|
|
332
|
+
codexThreadId,
|
|
333
|
+
'-',
|
|
334
|
+
], {
|
|
330
335
|
cwd,
|
|
331
336
|
env: spawnEnv,
|
|
332
337
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -371,7 +376,7 @@ export function spawnCodex(prompt, options = {}) {
|
|
|
371
376
|
export const CodexAdapter = {
|
|
372
377
|
id: 'codex',
|
|
373
378
|
label: 'Codex',
|
|
374
|
-
description:
|
|
379
|
+
description: "OpenAI's coding agent. Good when the task needs reading a lot of code first, then changing several files together.",
|
|
375
380
|
processPattern: /(?:^|\/)codex(?:\s|$)/,
|
|
376
381
|
isInstalled() {
|
|
377
382
|
try {
|
|
@@ -58,7 +58,7 @@ export function loadHandoffContext(cwd, { fromSessionId, maxAgeMs = DEFAULT_MAX_
|
|
|
58
58
|
// Filter by age
|
|
59
59
|
const recent = packs.filter((p) => {
|
|
60
60
|
const ts = p.timestamp;
|
|
61
|
-
return ts &&
|
|
61
|
+
return ts && now - ts < maxAgeMs;
|
|
62
62
|
});
|
|
63
63
|
// Prefer packs with enough graph structure to be useful
|
|
64
64
|
const structured = recent.filter((p) => {
|
|
@@ -48,13 +48,7 @@ import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
|
48
48
|
//
|
|
49
49
|
// `--skip-trust` — skip the per-workspace trust prompt that gemini shows on
|
|
50
50
|
// first run in a new directory.
|
|
51
|
-
const HEADLESS_FLAGS = [
|
|
52
|
-
'--approval-mode',
|
|
53
|
-
'yolo',
|
|
54
|
-
'--skip-trust',
|
|
55
|
-
'-o',
|
|
56
|
-
'stream-json',
|
|
57
|
-
];
|
|
51
|
+
const HEADLESS_FLAGS = ['--approval-mode', 'yolo', '--skip-trust', '-o', 'stream-json'];
|
|
58
52
|
// Error patterns scoped to STDERR / result.error / error events only — NOT
|
|
59
53
|
// every JSON-serialized event (which would false-positive on tool output).
|
|
60
54
|
const ERROR_PATTERNS = [
|
|
@@ -340,13 +334,7 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
340
334
|
}
|
|
341
335
|
}
|
|
342
336
|
statusValue =
|
|
343
|
-
killRequested || signal !== null
|
|
344
|
-
? 'killed'
|
|
345
|
-
: errorEmitted
|
|
346
|
-
? 'error'
|
|
347
|
-
: code === 0
|
|
348
|
-
? 'completed'
|
|
349
|
-
: 'error';
|
|
337
|
+
killRequested || signal !== null ? 'killed' : errorEmitted ? 'error' : code === 0 ? 'completed' : 'error';
|
|
350
338
|
if (aggregator.finalize(statusValue === 'completed' ? 'completed' : statusValue === 'killed' ? 'killed' : 'error')) {
|
|
351
339
|
stepsSnapshot = aggregator.steps.map((s) => ({ ...s }));
|
|
352
340
|
emit({ type: 'codehere:steps', timestamp: Date.now(), data: { steps: stepsSnapshot } });
|
|
@@ -518,7 +506,7 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
518
506
|
export const GeminiAdapter = {
|
|
519
507
|
id: 'gemini',
|
|
520
508
|
label: 'Gemini',
|
|
521
|
-
description:
|
|
509
|
+
description: "Google's coding agent. Has a free tier and can read very large amounts of code or docs at once. Useful when the paid agents are out of credits.",
|
|
522
510
|
// Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
|
|
523
511
|
processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
|
|
524
512
|
isInstalled() {
|
|
@@ -74,7 +74,7 @@ export function buildGraphSync(cwd, { incremental = true } = {}) {
|
|
|
74
74
|
* Compile a compact context pack for a prompt + workspace.
|
|
75
75
|
* Synchronous — calls graphify-ts pack and returns the payload.
|
|
76
76
|
*/
|
|
77
|
-
export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000
|
|
77
|
+
export function compileContextPackSync(prompt, cwd, { task = 'explain', budget = 3000 } = {}) {
|
|
78
78
|
const startTime = Date.now();
|
|
79
79
|
const graphPath = getGraphPath(cwd);
|
|
80
80
|
// Build graph if it doesn't exist yet
|
|
@@ -86,12 +86,7 @@ export function compileContextPackSync(prompt, cwd, { task = 'explain', budget =
|
|
|
86
86
|
return null;
|
|
87
87
|
}
|
|
88
88
|
try {
|
|
89
|
-
const output = execFileSync('npx', [
|
|
90
|
-
'graphify-ts', 'pack', prompt,
|
|
91
|
-
'--graph', graphPath,
|
|
92
|
-
'--task', task,
|
|
93
|
-
'--budget', String(budget),
|
|
94
|
-
], {
|
|
89
|
+
const output = execFileSync('npx', ['graphify-ts', 'pack', prompt, '--graph', graphPath, '--task', task, '--budget', String(budget)], {
|
|
95
90
|
cwd,
|
|
96
91
|
encoding: 'utf-8',
|
|
97
92
|
maxBuffer: 512 * 1024,
|
|
@@ -125,7 +120,9 @@ export function compileSessionPackSync(cwd, { prompt, artifacts, steps, agent })
|
|
|
125
120
|
agent ? `Agent: ${agent}` : undefined,
|
|
126
121
|
artifacts ? `Artifacts:\n${artifacts}` : undefined,
|
|
127
122
|
steps ? `Steps taken:\n${steps}` : undefined,
|
|
128
|
-
]
|
|
123
|
+
]
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join('\n\n');
|
|
129
126
|
return compileContextPackSync(sessionContext, cwd, { task: 'impact', budget: 2500 });
|
|
130
127
|
}
|
|
131
128
|
// ── Context pack persistence ───────────────────────────────────────────────────
|
|
@@ -232,11 +229,7 @@ export function queryGraphSync(question, cwd, { maxTokens = 2000 } = {}) {
|
|
|
232
229
|
return null;
|
|
233
230
|
}
|
|
234
231
|
try {
|
|
235
|
-
const output = execFileSync('npx', [
|
|
236
|
-
'graphify-ts', 'query', question,
|
|
237
|
-
'--graph', graphPath,
|
|
238
|
-
'--budget', String(maxTokens),
|
|
239
|
-
], {
|
|
232
|
+
const output = execFileSync('npx', ['graphify-ts', 'query', question, '--graph', graphPath, '--budget', String(maxTokens)], {
|
|
240
233
|
cwd,
|
|
241
234
|
encoding: 'utf-8',
|
|
242
235
|
maxBuffer: 256 * 1024,
|
|
@@ -65,17 +65,13 @@ function looksLikeInitSeed(content, kind) {
|
|
|
65
65
|
/\[Tone for generated code\./,
|
|
66
66
|
/\[Don't do X\. Always do Y\./,
|
|
67
67
|
];
|
|
68
|
-
const hits = markers.filter(re => re.test(content)).length;
|
|
68
|
+
const hits = markers.filter((re) => re.test(content)).length;
|
|
69
69
|
return hits >= 2;
|
|
70
70
|
}
|
|
71
71
|
if (kind === 'session') {
|
|
72
72
|
// The init session.md seed has this header + structure
|
|
73
|
-
const markers = [
|
|
74
|
-
|
|
75
|
-
/^> Per-machine, gitignored/m,
|
|
76
|
-
/^## In flight\s*\n+- \[ \]\s*$/m,
|
|
77
|
-
];
|
|
78
|
-
const hits = markers.filter(re => re.test(content)).length;
|
|
73
|
+
const markers = [/^# Working session/m, /^> Per-machine, gitignored/m, /^## In flight\s*\n+- \[ \]\s*$/m];
|
|
74
|
+
const hits = markers.filter((re) => re.test(content)).length;
|
|
79
75
|
return hits >= 2;
|
|
80
76
|
}
|
|
81
77
|
return false;
|
|
@@ -142,9 +138,9 @@ function readLessons(codehereDir, n) {
|
|
|
142
138
|
const body = readFileSync(p, 'utf8');
|
|
143
139
|
return body
|
|
144
140
|
.split('\n')
|
|
145
|
-
.filter(line => line.trim().length > 0)
|
|
141
|
+
.filter((line) => line.trim().length > 0)
|
|
146
142
|
.slice(0, n)
|
|
147
|
-
.map(line => {
|
|
143
|
+
.map((line) => {
|
|
148
144
|
try {
|
|
149
145
|
const obj = JSON.parse(line);
|
|
150
146
|
return typeof obj.text === 'string' ? obj.text : line;
|
|
@@ -182,11 +178,11 @@ function formatSession(session) {
|
|
|
182
178
|
return `# Current session scratch (.codehere/memory/working/session.md)\n\n${session}\n`;
|
|
183
179
|
}
|
|
184
180
|
function formatLessons(lessons) {
|
|
185
|
-
const items = lessons.map(l => `- ${l}`).join('\n');
|
|
181
|
+
const items = lessons.map((l) => `- ${l}`).join('\n');
|
|
186
182
|
return `# Project lessons (.codehere/memory/semantic/lessons.jsonl)\n\n${items}\n`;
|
|
187
183
|
}
|
|
188
184
|
function formatSymbols(symbols) {
|
|
189
|
-
const lines = symbols.map(s => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
|
|
185
|
+
const lines = symbols.map((s) => `- \`${s.name}\` — ${s.file}:${s.line} (${s.references} refs)`).join('\n');
|
|
190
186
|
return `# Top symbols in this repo (.codehere/memory/episodic/repo_map.json)\n\n${lines}\n`;
|
|
191
187
|
}
|
|
192
188
|
/**
|
|
@@ -232,7 +228,7 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
|
|
|
232
228
|
let kept = [...candidates];
|
|
233
229
|
while (kept.length > 0) {
|
|
234
230
|
const totalChars = kept.reduce((acc, c) => acc + c.body.length, 0);
|
|
235
|
-
const totalTokens = estimateTokens(kept.map(c => c.body).join('\n')) + separatorTokens;
|
|
231
|
+
const totalTokens = estimateTokens(kept.map((c) => c.body).join('\n')) + separatorTokens;
|
|
236
232
|
if (totalTokens <= maxTokens)
|
|
237
233
|
break;
|
|
238
234
|
// Drop the last (least-essential) section.
|
|
@@ -250,10 +246,10 @@ export function buildMemoryPrefix(projectRoot, options = {}) {
|
|
|
250
246
|
if (kept.length === 0) {
|
|
251
247
|
return { prefix: '', included: [], tokens: 0, truncated };
|
|
252
248
|
}
|
|
253
|
-
const prefix = kept.map(c => c.body).join('\n') + SEPARATOR;
|
|
249
|
+
const prefix = kept.map((c) => c.body).join('\n') + SEPARATOR;
|
|
254
250
|
return {
|
|
255
251
|
prefix,
|
|
256
|
-
included: kept.map(c => c.key),
|
|
252
|
+
included: kept.map((c) => c.key),
|
|
257
253
|
tokens: estimateTokens(prefix),
|
|
258
254
|
truncated,
|
|
259
255
|
};
|
|
@@ -296,8 +292,7 @@ function findProjectRoot(startPath) {
|
|
|
296
292
|
}
|
|
297
293
|
// Bound the loop at the filesystem root by checking dirname stability.
|
|
298
294
|
for (;;) {
|
|
299
|
-
if (existsSync(join(current, '.codehere')) ||
|
|
300
|
-
existsSync(join(current, 'AGENTS.md'))) {
|
|
295
|
+
if (existsSync(join(current, '.codehere')) || existsSync(join(current, 'AGENTS.md'))) {
|
|
301
296
|
return current;
|
|
302
297
|
}
|
|
303
298
|
const parent = dirname(current);
|
|
@@ -434,7 +434,7 @@ export function spawnOpenCode(prompt, options = {}) {
|
|
|
434
434
|
export const OpenCodeAdapter = {
|
|
435
435
|
id: 'opencode',
|
|
436
436
|
label: 'OpenCode',
|
|
437
|
-
description:
|
|
437
|
+
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.",
|
|
438
438
|
processPattern: /(?:^|\/)opencode(?:\s|$)/,
|
|
439
439
|
isInstalled() {
|
|
440
440
|
try {
|
|
@@ -464,8 +464,7 @@ export class ProgressAggregator {
|
|
|
464
464
|
if (itemType === 'tool_call' || itemType === 'local_shell_call') {
|
|
465
465
|
const name = String(item.tool_name ?? item.name ?? 'tool');
|
|
466
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 : '');
|
|
467
|
+
const result = (typeof item.result === 'string' ? item.result : '') || (typeof item.output === 'string' ? item.output : '');
|
|
469
468
|
this._steps.push({
|
|
470
469
|
label: truncate(name),
|
|
471
470
|
state: 'done',
|
|
@@ -22,14 +22,14 @@ import { buildGenericAdapter, loadAgentsConfig } from './generic-cli-adapter.js'
|
|
|
22
22
|
import { buildConductorAdapter } from './conductor-adapter.js';
|
|
23
23
|
import { seedAgentsConfig } from './agents-config-seed.js';
|
|
24
24
|
import { AUDIT_FILE } from '../hooks/claude-sdk-hook.js';
|
|
25
|
-
import { appendSpawned, appendCompleted, appendFinalReport, loadRecent } from './session-persistence.js';
|
|
25
|
+
import { appendSpawned, appendCompleted, appendFinalReport, loadRecent, } from './session-persistence.js';
|
|
26
26
|
import { generateFinalReport, printFinalReportToTerminal } from '../../application/use-cases/generate-final-report.js';
|
|
27
27
|
import { mirrorSpawnedToThread, mirrorCompletedToThread } from '../storage/project-thread-mirror.js';
|
|
28
28
|
import { scanPromptBeforeSpawn } from '../hooks/pre-prompt-scan.js';
|
|
29
29
|
import { generateNarration } from '../../application/use-cases/generate-narration.js';
|
|
30
30
|
import { randomUUID } from 'crypto';
|
|
31
31
|
import { classifyFailoverEligibility } from './failover-classifier.js';
|
|
32
|
-
import { resolveFailoverTarget, toAvailability
|
|
32
|
+
import { resolveFailoverTarget, toAvailability } from './failover-policy.js';
|
|
33
33
|
import { loadFailoverConfig, resolveChain } from './failover-config.js';
|
|
34
34
|
import { enrichPromptWithPackSync, compileSessionPackSync, persistContextPack } from './graphify-context.js';
|
|
35
35
|
import { buildFailoverHandoff, enrichPromptWithHandoff } from './context-handoff.js';
|
|
@@ -39,7 +39,13 @@ import { mirrorFailoverToThread } from '../storage/project-thread-mirror.js';
|
|
|
39
39
|
// order. Built-in adapters first; user-config-driven ones appended from
|
|
40
40
|
// ~/.codehere/agents.yaml (generic-cli-adapter). isInstalled() gates visibility
|
|
41
41
|
// so an adapter registering for an un-installed CLI is harmless.
|
|
42
|
-
const BUILTIN_REGISTRY = [
|
|
42
|
+
const BUILTIN_REGISTRY = [
|
|
43
|
+
ClaudeCodeAdapter,
|
|
44
|
+
CodexAdapter,
|
|
45
|
+
AiderAdapter,
|
|
46
|
+
OpenCodeAdapter,
|
|
47
|
+
GeminiAdapter,
|
|
48
|
+
];
|
|
43
49
|
// Seed the default agents.yaml if this user has never configured one, so Gemini
|
|
44
50
|
// / Cursor / Pi / Antigravity / Goose appear in the registry pre-wired. They
|
|
45
51
|
// stay hidden in the UI until isInstalled() flips to true. Idempotent.
|
|
@@ -149,16 +155,16 @@ export class SessionManager {
|
|
|
149
155
|
* reads these via getFailoverOffers() for the /api/failover-offers endpoint. */
|
|
150
156
|
failoverOffers;
|
|
151
157
|
/** Tracks the failover error code per session so acceptFailover can emit
|
|
152
|
-
|
|
158
|
+
* the correct reason in the thread event. Populated by handleFailover. */
|
|
153
159
|
failoverReasonBySession = new Map();
|
|
154
160
|
/** Session threading — tracks which session continued as which after failover.
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
* continuedAsBySession: original → new session ID
|
|
162
|
+
* continuedFromBySession: new → original session ID */
|
|
157
163
|
continuedAsBySession = new Map();
|
|
158
164
|
continuedFromBySession = new Map();
|
|
159
165
|
/** Final Report failure tracking — when generateFinalReport rejects, we
|
|
160
|
-
|
|
161
|
-
|
|
166
|
+
* record it so the API can return a definitive "failed" status instead
|
|
167
|
+
* of leaving the UI showing "Generating report…" forever. */
|
|
162
168
|
failedReportBySession = new Set();
|
|
163
169
|
spawn(prompt, agentId = 'claude-code', options) {
|
|
164
170
|
const adapter = getAdapter(agentId);
|
|
@@ -305,7 +311,10 @@ export class SessionManager {
|
|
|
305
311
|
// or pack compilation may fail. All degrade silently.
|
|
306
312
|
void Promise.resolve().then(() => {
|
|
307
313
|
try {
|
|
308
|
-
const stepsStr = session.steps
|
|
314
|
+
const stepsStr = session.steps
|
|
315
|
+
?.map((s) => `${s.label}: ${s.output || s.state}`)
|
|
316
|
+
.filter(Boolean)
|
|
317
|
+
.join('\n') ?? '';
|
|
309
318
|
const pack = compileSessionPackSync(cwd, {
|
|
310
319
|
prompt: session.prompt,
|
|
311
320
|
artifacts: JSON.stringify(session.artifacts, null, 2),
|
|
@@ -446,14 +455,21 @@ export class SessionManager {
|
|
|
446
455
|
try {
|
|
447
456
|
printFinalReportToTerminal(reportInput, report);
|
|
448
457
|
}
|
|
449
|
-
catch {
|
|
458
|
+
catch {
|
|
459
|
+
/* defensive */
|
|
460
|
+
}
|
|
450
461
|
}
|
|
451
462
|
})
|
|
452
|
-
.catch(() => {
|
|
463
|
+
.catch(() => {
|
|
464
|
+
/* defensive */
|
|
465
|
+
});
|
|
453
466
|
// P1 — persist context pack at failover-replacement session exit.
|
|
454
467
|
void Promise.resolve().then(() => {
|
|
455
468
|
try {
|
|
456
|
-
const stepsStr = newSession.steps
|
|
469
|
+
const stepsStr = newSession.steps
|
|
470
|
+
?.map((s) => `${s.label}: ${s.output || s.state}`)
|
|
471
|
+
.filter(Boolean)
|
|
472
|
+
.join('\n') ?? '';
|
|
457
473
|
const pack = compileSessionPackSync(cwd, {
|
|
458
474
|
prompt: newSession.prompt,
|
|
459
475
|
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
@@ -546,9 +562,9 @@ export class SessionManager {
|
|
|
546
562
|
return offers;
|
|
547
563
|
}
|
|
548
564
|
/** v0.6.x P0 — user-initiated failover accept. Spawns a replacement
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
565
|
+
* session on the target agent, preserving the original prompt + cwd.
|
|
566
|
+
* P2 — injects handoff context for cross-agent continuity.
|
|
567
|
+
* Returns the new session or undefined if the target is unavailable. */
|
|
552
568
|
acceptFailover(sessionId, targetAgent) {
|
|
553
569
|
const session = this.sessions.get(sessionId);
|
|
554
570
|
if (!session)
|
|
@@ -631,14 +647,21 @@ export class SessionManager {
|
|
|
631
647
|
try {
|
|
632
648
|
printFinalReportToTerminal(reportInput, report);
|
|
633
649
|
}
|
|
634
|
-
catch {
|
|
650
|
+
catch {
|
|
651
|
+
/* defensive */
|
|
652
|
+
}
|
|
635
653
|
}
|
|
636
654
|
})
|
|
637
|
-
.catch(() => {
|
|
655
|
+
.catch(() => {
|
|
656
|
+
/* defensive */
|
|
657
|
+
});
|
|
638
658
|
// P1 — persist context pack at accept-failover replacement session exit.
|
|
639
659
|
void Promise.resolve().then(() => {
|
|
640
660
|
try {
|
|
641
|
-
const stepsStr = newSession.steps
|
|
661
|
+
const stepsStr = newSession.steps
|
|
662
|
+
?.map((s) => `${s.label}: ${s.output || s.state}`)
|
|
663
|
+
.filter(Boolean)
|
|
664
|
+
.join('\n') ?? '';
|
|
642
665
|
const pack = compileSessionPackSync(cwd, {
|
|
643
666
|
prompt: newSession.prompt,
|
|
644
667
|
artifacts: JSON.stringify(newSession.artifacts, null, 2),
|
|
@@ -718,7 +741,9 @@ export class SessionManager {
|
|
|
718
741
|
if (out)
|
|
719
742
|
this.narrationBySession.set(session.sessionId, out);
|
|
720
743
|
})
|
|
721
|
-
.catch(() => {
|
|
744
|
+
.catch(() => {
|
|
745
|
+
/* defensive — generateNarration never throws */
|
|
746
|
+
});
|
|
722
747
|
}, intervalMs);
|
|
723
748
|
this.narrationTimers.set(session.sessionId, timer);
|
|
724
749
|
}
|
|
@@ -750,14 +775,14 @@ export class SessionManager {
|
|
|
750
775
|
return this.continuedAsBySession.get(sessionId);
|
|
751
776
|
}
|
|
752
777
|
/** PM-UX Layer 3 · prose Final Report for an in-memory exited session.
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
778
|
+
* Returns undefined while generation is in flight OR if the provider chain
|
|
779
|
+
* failed (no claude-free, no claude, no credit, etc). The web UI reads this
|
|
780
|
+
* through the /api/tasks serializer to render the prose summary inline. */
|
|
756
781
|
getFinalReport(sessionId) {
|
|
757
782
|
return this.finalReportBySession.get(sessionId);
|
|
758
783
|
}
|
|
759
784
|
/** Returns true if Final Report generation explicitly failed for this session.
|
|
760
|
-
|
|
785
|
+
* Lets the API distinguish "not yet" from "never coming". */
|
|
761
786
|
hasFailedReport(sessionId) {
|
|
762
787
|
return this.failedReportBySession.has(sessionId);
|
|
763
788
|
}
|
|
@@ -62,7 +62,7 @@ export function collectSessionMetadata() {
|
|
|
62
62
|
}
|
|
63
63
|
try {
|
|
64
64
|
const content = readFileSync(SESSIONS_FILE, 'utf-8');
|
|
65
|
-
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
|
65
|
+
const lines = content.split('\n').filter((l) => l.trim().length > 0);
|
|
66
66
|
// Parse records and pair spawned + completed
|
|
67
67
|
const spawnedMap = new Map();
|
|
68
68
|
const completedMap = new Map();
|
|
@@ -89,9 +89,13 @@ export function collectSessionMetadata() {
|
|
|
89
89
|
const completed = completedMap.get(id);
|
|
90
90
|
const finalReport = finalReportMap.get(id);
|
|
91
91
|
const status = completed
|
|
92
|
-
?
|
|
92
|
+
? completed.status === 'completed'
|
|
93
|
+
? 'done'
|
|
94
|
+
: completed.status === 'failed'
|
|
95
|
+
? 'failed'
|
|
96
|
+
: 'done'
|
|
93
97
|
: 'running';
|
|
94
|
-
const durationMs = completed?.durationMs ??
|
|
98
|
+
const durationMs = completed?.durationMs ?? Date.now() - spawned.startedAt;
|
|
95
99
|
const tools = completed?.steps?.length ?? 0;
|
|
96
100
|
const files = completed?.artifacts?.filesChanged?.length ?? 0;
|
|
97
101
|
const trustScore = extractTrustScore(completed?.artifacts);
|
|
@@ -151,7 +155,7 @@ export function startCloudSync(machineName) {
|
|
|
151
155
|
console.error(` Cloud sync failed: ${res.status} ${body}`);
|
|
152
156
|
}
|
|
153
157
|
else {
|
|
154
|
-
const data = await res.json();
|
|
158
|
+
const data = (await res.json());
|
|
155
159
|
if (data.sessionsPushed && data.sessionsPushed > 0) {
|
|
156
160
|
console.log(` Cloud sync: ${data.sessionsPushed} session(s) pushed`);
|
|
157
161
|
}
|
|
@@ -166,7 +170,7 @@ export function startCloudSync(machineName) {
|
|
|
166
170
|
const res = await fetch(`${CLOUD_URL}?action=pull-tasks&machineId=${machineId}`);
|
|
167
171
|
if (!res.ok)
|
|
168
172
|
return;
|
|
169
|
-
const data = await res.json();
|
|
173
|
+
const data = (await res.json());
|
|
170
174
|
const tasks = data.tasks || [];
|
|
171
175
|
for (const task of tasks) {
|
|
172
176
|
if (task.prompt) {
|
|
@@ -439,7 +439,7 @@ export function startMCPServer(options = {}) {
|
|
|
439
439
|
// Send current session state on connect
|
|
440
440
|
try {
|
|
441
441
|
const manager = SessionManager.getInstance();
|
|
442
|
-
const sessions = manager.list().map(s => ({
|
|
442
|
+
const sessions = manager.list().map((s) => ({
|
|
443
443
|
sessionId: s.sessionId,
|
|
444
444
|
status: s.status,
|
|
445
445
|
agent: s.agent,
|
|
@@ -455,7 +455,9 @@ export function startMCPServer(options = {}) {
|
|
|
455
455
|
try {
|
|
456
456
|
lastSize = statSync(SESSIONS_FILE).size;
|
|
457
457
|
}
|
|
458
|
-
catch {
|
|
458
|
+
catch {
|
|
459
|
+
/* first time */
|
|
460
|
+
}
|
|
459
461
|
}
|
|
460
462
|
const onChange = () => {
|
|
461
463
|
try {
|
|
@@ -467,7 +469,9 @@ export function startMCPServer(options = {}) {
|
|
|
467
469
|
lastSize = currentSize;
|
|
468
470
|
res.write(`data: ${JSON.stringify({ type: 'change' })}\n\n`);
|
|
469
471
|
}
|
|
470
|
-
catch {
|
|
472
|
+
catch {
|
|
473
|
+
/* transient */
|
|
474
|
+
}
|
|
471
475
|
};
|
|
472
476
|
watchFile(SESSIONS_FILE, { interval: 500 }, onChange);
|
|
473
477
|
const ping = setInterval(() => res.write(': ping\n\n'), 15000);
|
|
@@ -1257,7 +1261,9 @@ export function startMCPServer(options = {}) {
|
|
|
1257
1261
|
});
|
|
1258
1262
|
}
|
|
1259
1263
|
}
|
|
1260
|
-
catch {
|
|
1264
|
+
catch {
|
|
1265
|
+
/* mirror is best-effort */
|
|
1266
|
+
}
|
|
1261
1267
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1262
1268
|
res.end(JSON.stringify({ sessionId, ...out }));
|
|
1263
1269
|
}
|
|
@@ -1325,7 +1331,11 @@ export function startMCPServer(options = {}) {
|
|
|
1325
1331
|
}
|
|
1326
1332
|
else {
|
|
1327
1333
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
1328
|
-
res.end(JSON.stringify({
|
|
1334
|
+
res.end(JSON.stringify({
|
|
1335
|
+
ok: false,
|
|
1336
|
+
error: 'target_unavailable',
|
|
1337
|
+
detail: `Could not spawn ${parsed.targetAgent}. Check if it's installed and authenticated.`,
|
|
1338
|
+
}));
|
|
1329
1339
|
}
|
|
1330
1340
|
}
|
|
1331
1341
|
catch (e) {
|
|
@@ -143,7 +143,10 @@ export function parseAgentFile(filePath, defaultFormat = 'unknown') {
|
|
|
143
143
|
// Tools normalization
|
|
144
144
|
let tools = null;
|
|
145
145
|
if (typeof fm.tools === 'string') {
|
|
146
|
-
tools = fm.tools
|
|
146
|
+
tools = fm.tools
|
|
147
|
+
.split(',')
|
|
148
|
+
.map((t) => t.trim().toLowerCase())
|
|
149
|
+
.filter(Boolean);
|
|
147
150
|
}
|
|
148
151
|
else if (hasObjectTools) {
|
|
149
152
|
tools = [];
|
|
@@ -162,7 +165,21 @@ export function parseAgentFile(filePath, defaultFormat = 'unknown') {
|
|
|
162
165
|
? 'subagent'
|
|
163
166
|
: 'all';
|
|
164
167
|
// Track unknown frontmatter keys (everything outside our normalized set)
|
|
165
|
-
const knownKeys = new Set([
|
|
168
|
+
const knownKeys = new Set([
|
|
169
|
+
'name',
|
|
170
|
+
'description',
|
|
171
|
+
'mode',
|
|
172
|
+
'model',
|
|
173
|
+
'temperature',
|
|
174
|
+
'tools',
|
|
175
|
+
'permission',
|
|
176
|
+
'color',
|
|
177
|
+
'hidden',
|
|
178
|
+
'top_p',
|
|
179
|
+
'steps',
|
|
180
|
+
'prompt',
|
|
181
|
+
'disable',
|
|
182
|
+
]);
|
|
166
183
|
const unknownKeys = Object.keys(fm).filter((k) => !knownKeys.has(k));
|
|
167
184
|
return {
|
|
168
185
|
name: typeof fm.name === 'string' ? fm.name : filenameName,
|
|
@@ -32,12 +32,7 @@ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync, readdirSync
|
|
|
32
32
|
import { join } from 'path';
|
|
33
33
|
import { createHash } from 'crypto';
|
|
34
34
|
/** Path patterns considered ephemeral and skipped from project list. */
|
|
35
|
-
const DEFAULT_TEMP_DIR_PATTERNS = [
|
|
36
|
-
/^\/tmp\//,
|
|
37
|
-
/^\/var\/folders\//,
|
|
38
|
-
/^\/private\/var\/folders\//,
|
|
39
|
-
/^\/private\/tmp\//,
|
|
40
|
-
];
|
|
35
|
+
const DEFAULT_TEMP_DIR_PATTERNS = [/^\/tmp\//, /^\/var\/folders\//, /^\/private\/var\/folders\//, /^\/private\/tmp\//];
|
|
41
36
|
/** sha1(cwd)[:12] — stable, short, filesystem-safe. */
|
|
42
37
|
function slugForCwd(cwd, unknownLabel) {
|
|
43
38
|
if (!cwd)
|
|
@@ -79,7 +74,17 @@ function rollupSessions(events) {
|
|
|
79
74
|
completedIds.add(id);
|
|
80
75
|
const existing = byId.get(id) ?? { id, abandoned: true };
|
|
81
76
|
const existingAny = existing;
|
|
82
|
-
for (const k of [
|
|
77
|
+
for (const k of [
|
|
78
|
+
'agent',
|
|
79
|
+
'prompt',
|
|
80
|
+
'cwd',
|
|
81
|
+
'startedAt',
|
|
82
|
+
'status',
|
|
83
|
+
'durationMs',
|
|
84
|
+
'completedAt',
|
|
85
|
+
'steps',
|
|
86
|
+
'artifacts',
|
|
87
|
+
]) {
|
|
83
88
|
if (e[k] !== undefined && existingAny[k] === undefined) {
|
|
84
89
|
existingAny[k] = e[k];
|
|
85
90
|
}
|
|
@@ -133,7 +133,17 @@ export function readProjectThread(slug, options) {
|
|
|
133
133
|
};
|
|
134
134
|
// Merge fields — later entries override earlier (covers
|
|
135
135
|
// spawn → completion update pattern from forward-mirror)
|
|
136
|
-
for (const k of [
|
|
136
|
+
for (const k of [
|
|
137
|
+
'agent',
|
|
138
|
+
'prompt',
|
|
139
|
+
'cwd',
|
|
140
|
+
'startedAt',
|
|
141
|
+
'status',
|
|
142
|
+
'durationMs',
|
|
143
|
+
'completedAt',
|
|
144
|
+
'steps',
|
|
145
|
+
'artifacts',
|
|
146
|
+
]) {
|
|
137
147
|
if (rec[k] !== undefined) {
|
|
138
148
|
existing[k] = rec[k];
|
|
139
149
|
}
|
|
@@ -178,10 +178,14 @@ export async function backfillThreadTitles(options) {
|
|
|
178
178
|
break;
|
|
179
179
|
}
|
|
180
180
|
}
|
|
181
|
-
catch {
|
|
181
|
+
catch {
|
|
182
|
+
/* skip malformed */
|
|
183
|
+
}
|
|
182
184
|
}
|
|
183
185
|
}
|
|
184
|
-
catch {
|
|
186
|
+
catch {
|
|
187
|
+
/* if we can't read, fall through and let the append fail */
|
|
188
|
+
}
|
|
185
189
|
if (userBeatUs) {
|
|
186
190
|
result.alreadyTitled++;
|
|
187
191
|
continue;
|
|
@@ -39,7 +39,7 @@ function showBudget() {
|
|
|
39
39
|
const cfg = loadBudget();
|
|
40
40
|
const spend = computeSpendToday();
|
|
41
41
|
newline();
|
|
42
|
-
console.log(info(
|
|
42
|
+
console.log(info("Today's estimated spend:"), fmtUsd(spend.totalUsd), `(${spend.sessionCount} session${spend.sessionCount === 1 ? '' : 's'})`);
|
|
43
43
|
if (cfg.dailyUsd === null) {
|
|
44
44
|
console.log(info('Daily budget:'), 'none — no enforcement');
|
|
45
45
|
console.log(info('Set one with:'), 'codehere budget set 5');
|
|
@@ -67,7 +67,7 @@ function setBudget(value) {
|
|
|
67
67
|
cfg.dailyUsd = n;
|
|
68
68
|
saveBudget(cfg);
|
|
69
69
|
console.log(success('Daily budget set to'), fmtUsd(n));
|
|
70
|
-
console.log(info(
|
|
70
|
+
console.log(info("Spawns will be blocked once today's estimated spend reaches this amount."));
|
|
71
71
|
console.log(info('Disable with:'), 'codehere budget off');
|
|
72
72
|
}
|
|
73
73
|
function disableBudget() {
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* Refuses to follow symlinks (lstat check) so a hostile/misplaced symlink at
|
|
40
40
|
* `.codehere/CONTEXT.md` or `.gitignore` cannot redirect writes outside the repo.
|
|
41
41
|
*/
|
|
42
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, lstatSync, statSync, symlinkSync, readlinkSync, rmSync } from 'fs';
|
|
42
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, appendFileSync, lstatSync, statSync, symlinkSync, readlinkSync, rmSync, } from 'fs';
|
|
43
43
|
import { homedir } from 'os';
|
|
44
44
|
import { join, dirname } from 'path';
|
|
45
45
|
import { colors, success, info, warning, error, newline } from '../../../ui.js';
|
|
@@ -316,7 +316,7 @@ function findSymlinkAncestor(codehereDir, relativePath) {
|
|
|
316
316
|
// Defensive: reject any relative path containing .. — even though templates
|
|
317
317
|
// are hard-coded, this protects against future schema additions slipping a
|
|
318
318
|
// traversal through.
|
|
319
|
-
if (segments.some(seg => seg === '..' || seg === '')) {
|
|
319
|
+
if (segments.some((seg) => seg === '..' || seg === '')) {
|
|
320
320
|
return relativePath;
|
|
321
321
|
}
|
|
322
322
|
// Walk codehereDir, codehereDir/seg1, codehereDir/seg1/seg2, ... (excluding
|
|
@@ -338,12 +338,13 @@ function ensureGitignore(repoRoot) {
|
|
|
338
338
|
return { result: 'refused-symlink', addedLines: 0 };
|
|
339
339
|
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf8') : '';
|
|
340
340
|
// De-duplicate: only append lines that aren't already present.
|
|
341
|
-
const missing = GITIGNORE_LINES.filter(line => !existing.includes(line));
|
|
341
|
+
const missing = GITIGNORE_LINES.filter((line) => !existing.includes(line));
|
|
342
342
|
if (missing.length === 0)
|
|
343
343
|
return { result: 'already-present', addedLines: 0 };
|
|
344
344
|
const block = (existing.length > 0 && !existing.endsWith('\n') ? '\n' : '') +
|
|
345
345
|
`\n${GITIGNORE_MARKER}\n` +
|
|
346
|
-
missing.join('\n') +
|
|
346
|
+
missing.join('\n') +
|
|
347
|
+
'\n';
|
|
347
348
|
appendFileSync(gitignorePath, block);
|
|
348
349
|
return { result: 'updated', addedLines: missing.length };
|
|
349
350
|
}
|
|
@@ -48,7 +48,7 @@ import { buildMemoryPrefix } from '../../../infrastructure/orchestration/memory-
|
|
|
48
48
|
* Built lazily from init-command.ts's F3_TEMPLATES so the seed bodies stay
|
|
49
49
|
* in one place — no duplication, no drift.
|
|
50
50
|
*/
|
|
51
|
-
const DEFAULT_SEED_BODIES = new Map(F3_TEMPLATES.map(t => [t.filename, t.body]));
|
|
51
|
+
const DEFAULT_SEED_BODIES = new Map(F3_TEMPLATES.map((t) => [t.filename, t.body]));
|
|
52
52
|
function lessonIdForText(text) {
|
|
53
53
|
return createHash('sha256').update(text).digest('hex').slice(0, 16);
|
|
54
54
|
}
|
|
@@ -58,8 +58,8 @@ function readLessonsFile(path) {
|
|
|
58
58
|
return readFileSync(path, 'utf8')
|
|
59
59
|
.trim()
|
|
60
60
|
.split('\n')
|
|
61
|
-
.filter(l => l.length > 0)
|
|
62
|
-
.map(l => {
|
|
61
|
+
.filter((l) => l.length > 0)
|
|
62
|
+
.map((l) => {
|
|
63
63
|
try {
|
|
64
64
|
return JSON.parse(l);
|
|
65
65
|
}
|
|
@@ -83,7 +83,7 @@ function readLessonsFile(path) {
|
|
|
83
83
|
function appendLessonIfNew(path, text, source) {
|
|
84
84
|
const id = lessonIdForText(text);
|
|
85
85
|
const existing = readLessonsFile(path);
|
|
86
|
-
const dup = existing.find(l => l.id === id);
|
|
86
|
+
const dup = existing.find((l) => l.id === id);
|
|
87
87
|
if (dup)
|
|
88
88
|
return { result: 'duplicate', lesson: dup };
|
|
89
89
|
const lesson = {
|
|
@@ -326,7 +326,9 @@ function gatherLayerSummary(codehereDir, projectRoot) {
|
|
|
326
326
|
const episodicLearnings = join(codehereDir, 'memory/episodic/AGENT_LEARNINGS.jsonl');
|
|
327
327
|
const repoMapPath = join(codehereDir, 'memory/episodic/repo_map.json');
|
|
328
328
|
const learningsLine = existsSync(episodicLearnings) || isSymlink(episodicLearnings)
|
|
329
|
-
?
|
|
329
|
+
? isSymlink(episodicLearnings)
|
|
330
|
+
? `AGENT_LEARNINGS.jsonl → ${readlinkSync(episodicLearnings)}`
|
|
331
|
+
: 'AGENT_LEARNINGS.jsonl (real file)'
|
|
330
332
|
: '(no AGENT_LEARNINGS.jsonl)';
|
|
331
333
|
const repoMapLine = existsSync(repoMapPath)
|
|
332
334
|
? `repo_map.json (${fmtSize(statSync(repoMapPath).size)})`
|
|
@@ -354,9 +356,12 @@ function gatherLayerSummary(codehereDir, projectRoot) {
|
|
|
354
356
|
const skillsDir = join(codehereDir, 'skills');
|
|
355
357
|
if (existsSync(skillsDir)) {
|
|
356
358
|
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
357
|
-
.filter(e => e.isDirectory())
|
|
358
|
-
.filter(e => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
359
|
-
out.push({
|
|
359
|
+
.filter((e) => e.isDirectory())
|
|
360
|
+
.filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
361
|
+
out.push({
|
|
362
|
+
layer: 'skills',
|
|
363
|
+
line: `skills · ${subdirs.length} skill folder${subdirs.length === 1 ? '' : 's'}`,
|
|
364
|
+
});
|
|
360
365
|
}
|
|
361
366
|
else {
|
|
362
367
|
out.push({ layer: 'skills', line: `skills · (no skills/ dir)` });
|
|
@@ -368,7 +373,10 @@ function gatherLayerSummary(codehereDir, projectRoot) {
|
|
|
368
373
|
const parsed = JSON.parse(readFileSync(patternsPath, 'utf8'));
|
|
369
374
|
const builtin = Array.isArray(parsed.patterns_builtin) ? parsed.patterns_builtin.length : 0;
|
|
370
375
|
const project = Array.isArray(parsed.patterns_project) ? parsed.patterns_project.length : 0;
|
|
371
|
-
out.push({
|
|
376
|
+
out.push({
|
|
377
|
+
layer: 'protocols',
|
|
378
|
+
line: `protocols · hook_patterns.json (${builtin} built-in + ${project} project)`,
|
|
379
|
+
});
|
|
372
380
|
}
|
|
373
381
|
catch {
|
|
374
382
|
out.push({ layer: 'protocols', line: `protocols · hook_patterns.json (parse error)` });
|
|
@@ -389,7 +397,10 @@ function gatherLayerSummary(codehereDir, projectRoot) {
|
|
|
389
397
|
}
|
|
390
398
|
else {
|
|
391
399
|
const truncNote = inj.truncated ? ' TRUNCATED' : '';
|
|
392
|
-
out.push({
|
|
400
|
+
out.push({
|
|
401
|
+
layer: 'injection',
|
|
402
|
+
line: `injection · ${inj.tokens} tokens · ${inj.included.join(', ')}${truncNote}`,
|
|
403
|
+
});
|
|
393
404
|
}
|
|
394
405
|
return out;
|
|
395
406
|
}
|
|
@@ -526,7 +537,10 @@ function executeMemoryShow(layerArg) {
|
|
|
526
537
|
console.log(` ${info('empty ')} no lessons.jsonl yet`);
|
|
527
538
|
break;
|
|
528
539
|
}
|
|
529
|
-
const lines = readFileSync(path, 'utf8')
|
|
540
|
+
const lines = readFileSync(path, 'utf8')
|
|
541
|
+
.trim()
|
|
542
|
+
.split('\n')
|
|
543
|
+
.filter((l) => l.length > 0);
|
|
530
544
|
if (lines.length === 0) {
|
|
531
545
|
console.log(` ${info('empty ')} lessons.jsonl is empty — run ${colors.cyan('codehere memory graduate')} (coming in v0.5)`);
|
|
532
546
|
break;
|
|
@@ -561,8 +575,8 @@ function executeMemoryShow(layerArg) {
|
|
|
561
575
|
break;
|
|
562
576
|
}
|
|
563
577
|
const subdirs = readdirSync(skillsDir, { withFileTypes: true })
|
|
564
|
-
.filter(e => e.isDirectory())
|
|
565
|
-
.filter(e => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
578
|
+
.filter((e) => e.isDirectory())
|
|
579
|
+
.filter((e) => existsSync(join(skillsDir, e.name, 'SKILL.md')));
|
|
566
580
|
if (subdirs.length === 0) {
|
|
567
581
|
console.log(` ${info('empty ')} no skill folders yet (skills/<name>/SKILL.md)`);
|
|
568
582
|
break;
|
|
@@ -907,8 +921,8 @@ function executeMemoryInit(options) {
|
|
|
907
921
|
}
|
|
908
922
|
// Idempotency hint when nothing was actually migrated.
|
|
909
923
|
const allOutcomes = [contextOutcome, handoverOutcome, promptsResult.outcome];
|
|
910
|
-
const anyMigrated = allOutcomes.some(o => o === 'migrated');
|
|
911
|
-
const allAlreadyOrMissing = allOutcomes.every(o => o === 'already-migrated' || o === 'missing');
|
|
924
|
+
const anyMigrated = allOutcomes.some((o) => o === 'migrated');
|
|
925
|
+
const allAlreadyOrMissing = allOutcomes.every((o) => o === 'already-migrated' || o === 'missing');
|
|
912
926
|
if (!anyMigrated && allAlreadyOrMissing) {
|
|
913
927
|
newline();
|
|
914
928
|
console.log(` ${info('no-op')} everything already migrated or no source files to migrate.`);
|
|
@@ -122,8 +122,8 @@ export function buildHookPatternsExport() {
|
|
|
122
122
|
source: 'agent/src/infrastructure/security/trust-scorer.ts',
|
|
123
123
|
_note: "Exported from codehere's trust-scorer pattern bank via 'codehere protocols export'. " +
|
|
124
124
|
"DO NOT hand-edit `patterns_builtin[]` — it's regenerated on every export. " +
|
|
125
|
-
|
|
126
|
-
|
|
125
|
+
'Add project-specific patterns to `patterns_project[]` — those are preserved across re-exports. ' +
|
|
126
|
+
'Pattern ids are stable slugs of category + description; reference them deterministically.',
|
|
127
127
|
patterns_builtin: builtin,
|
|
128
128
|
patterns_project: [],
|
|
129
129
|
};
|
|
@@ -107,8 +107,8 @@ function gitListFiles(repoRoot) {
|
|
|
107
107
|
return null;
|
|
108
108
|
return r.stdout
|
|
109
109
|
.split('\0')
|
|
110
|
-
.filter(p => p.length > 0)
|
|
111
|
-
.filter(p => SUPPORTED_EXTENSIONS.has(extname(p)));
|
|
110
|
+
.filter((p) => p.length > 0)
|
|
111
|
+
.filter((p) => SUPPORTED_EXTENSIONS.has(extname(p)));
|
|
112
112
|
}
|
|
113
113
|
/**
|
|
114
114
|
* Count references to a symbol name across the repo via git grep.
|
|
@@ -191,13 +191,13 @@ export function buildRepoMap(repoRoot) {
|
|
|
191
191
|
// acceptable for v0.5 — codehere's own 249-symbol repo runs in ~5s.
|
|
192
192
|
// v0.6's tree-sitter port will swap raw grep for AST-aware reference
|
|
193
193
|
// resolution + PageRank, which is faster AND more accurate.
|
|
194
|
-
const uniqueNames = Array.from(new Set(allSymbols.map(s => s.name)));
|
|
194
|
+
const uniqueNames = Array.from(new Set(allSymbols.map((s) => s.name)));
|
|
195
195
|
const refCounts = new Map();
|
|
196
196
|
for (const name of uniqueNames) {
|
|
197
197
|
refCounts.set(name, countReferences(repoRoot, name));
|
|
198
198
|
}
|
|
199
199
|
const topSymbols = allSymbols
|
|
200
|
-
.map(s => ({ ...s, references: refCounts.get(s.name) ?? 0 }))
|
|
200
|
+
.map((s) => ({ ...s, references: refCounts.get(s.name) ?? 0 }))
|
|
201
201
|
// Stable sort: references DESC, then name ASC for tie-breaking.
|
|
202
202
|
.sort((a, b) => b.references - a.references || a.name.localeCompare(b.name))
|
|
203
203
|
.slice(0, 50);
|
|
@@ -648,7 +648,24 @@ async function runSubcommand(name, extraArgs) {
|
|
|
648
648
|
});
|
|
649
649
|
});
|
|
650
650
|
}
|
|
651
|
+
/**
|
|
652
|
+
* UAT-002 — exit-intent words typed at the REPL prompt without a leading
|
|
653
|
+
* slash should leave the session, not get routed to a Conductor spawn.
|
|
654
|
+
*
|
|
655
|
+
* Pre-fix: typing bare `exit` (no `/`) fell through to spawnViaConductor,
|
|
656
|
+
* which booted Conductor, picked claude-code under default routing, made
|
|
657
|
+
* a real API call (network or quota), and emitted a confusing Final Report
|
|
658
|
+
* echoing the provider error verbatim. First-impression UX bug found by
|
|
659
|
+
* UAT 2026-05-19.
|
|
660
|
+
*/
|
|
661
|
+
const EXIT_INTENT_WORDS = new Set(['exit', 'quit', 'q', 'bye', 'goodbye', 'logout', ':q']);
|
|
651
662
|
async function spawnViaConductor(prompt, state) {
|
|
663
|
+
// UAT-002 guard — exit-intent before any spawn / API call.
|
|
664
|
+
const trimmed = prompt.trim().toLowerCase();
|
|
665
|
+
if (EXIT_INTENT_WORDS.has(trimmed)) {
|
|
666
|
+
console.log(colors.dim('Goodbye! 👋'));
|
|
667
|
+
process.exit(0);
|
|
668
|
+
}
|
|
652
669
|
const installed = availableAgents().filter((a) => a.installed);
|
|
653
670
|
if (installed.length === 0) {
|
|
654
671
|
console.log(warning('No specialist installed — install one and try again.'));
|
|
@@ -711,7 +728,9 @@ function renderEvent(event) {
|
|
|
711
728
|
console.log(colors.dim(' │ ') + icon + ' ' + label);
|
|
712
729
|
// P22 / Track B #8 — render last 3 lines of step output for done/failed
|
|
713
730
|
if (lastStep.output && String(lastStep.output).trim()) {
|
|
714
|
-
const outLines = String(lastStep.output)
|
|
731
|
+
const outLines = String(lastStep.output)
|
|
732
|
+
.split(String.fromCharCode(10))
|
|
733
|
+
.filter((l) => l.trim());
|
|
715
734
|
const preview = outLines.slice(-3);
|
|
716
735
|
for (const ln of preview) {
|
|
717
736
|
console.log(colors.dim(' │ ') + colors.gray(ln));
|
package/dist/ui.js
CHANGED
|
@@ -397,37 +397,45 @@ export function randomFarewell() {
|
|
|
397
397
|
export function randomCleanScan() {
|
|
398
398
|
return CLEAN_SCAN_CELEBRATIONS[Math.floor(Math.random() * CLEAN_SCAN_CELEBRATIONS.length)];
|
|
399
399
|
}
|
|
400
|
+
/**
|
|
401
|
+
* v0.6.2 sync — every non-replOnly entry MUST correspond to a real
|
|
402
|
+
* `program.command('<name>')` registration in src/index.ts. Drift is
|
|
403
|
+
* blocked by test/cli/slash-commands-sync.test.ts (UAT-001 CI guard).
|
|
404
|
+
*
|
|
405
|
+
* Pre-fix (v0.6.1 and earlier): this list carried 16 ghost commands
|
|
406
|
+
* (ask/explain/fix/review/plan/suggest/test/docs/refactor/orchestrate/run/
|
|
407
|
+
* git-review/index/cost/models/undo) removed in v0.3 — and was missing
|
|
408
|
+
* 8 real commands (init/memory/permission/narrator/budget/start/task/kill).
|
|
409
|
+
* Catastrophic first-impression bug found by UAT 2026-05-19.
|
|
410
|
+
*/
|
|
400
411
|
export const SLASH_COMMANDS = [
|
|
401
|
-
//
|
|
402
|
-
{ name: '
|
|
403
|
-
{ name: '
|
|
404
|
-
{ name: '
|
|
405
|
-
{ name: 'review', args: '<file>', description: 'Code review with findings' },
|
|
406
|
-
{ name: 'plan', args: '<goal>', description: 'Break a goal into steps' },
|
|
407
|
-
{ name: 'suggest', args: '<query>', description: 'Read-only assistant — no changes' },
|
|
408
|
-
{ name: 'test', args: '<file>', description: 'Generate tests for a file' },
|
|
409
|
-
{ name: 'docs', args: '<type>', description: 'Generate documentation' },
|
|
410
|
-
{ name: 'refactor', args: '<pattern> <instr>', description: 'Refactor across files' },
|
|
411
|
-
{ name: 'orchestrate', args: '<goal>', description: 'Multi-agent workflow' },
|
|
412
|
-
{ name: 'run', args: '<goal>', description: 'Execute a task or goal' },
|
|
413
|
-
{ name: 'git-review', args: '', description: 'Review git changes' },
|
|
414
|
-
// Safety & config (no key)
|
|
415
|
-
{ name: 'trust', args: '[path]', description: 'Scan for vulnerabilities', noKey: true },
|
|
416
|
-
{ name: 'report', args: '[path]', description: 'Security audit report', noKey: true },
|
|
417
|
-
{ name: 'hook', args: '[install|status]', description: 'Safety hook management', noKey: true },
|
|
418
|
-
{ name: 'status', args: '', description: 'Dashboard — hook activity', noKey: true },
|
|
419
|
-
{ name: 'health', args: '', description: 'System health check', noKey: true },
|
|
420
|
-
{ name: 'index', args: '', description: 'Build the search index' },
|
|
421
|
-
{ name: 'serve', args: '', description: 'Start MCP server', noKey: true },
|
|
422
|
-
{ name: 'setup', args: '', description: 'Configure API provider', noKey: true },
|
|
412
|
+
// Setup + config (no key)
|
|
413
|
+
{ name: 'setup', args: '', description: 'Interactive first-run wizard', noKey: true },
|
|
414
|
+
{ name: 'init', args: '', description: 'Scaffold .codehere/ in this repo', noKey: true },
|
|
415
|
+
{ name: 'memory', args: '<sub>', description: 'Project memory · init / repo-map / show', noKey: true },
|
|
423
416
|
{ name: 'config', args: '[key] [value]', description: 'Manage preferences', noKey: true },
|
|
424
|
-
{ name: '
|
|
425
|
-
{ name: '
|
|
426
|
-
{ name: '
|
|
427
|
-
//
|
|
417
|
+
{ name: 'permission', args: '<sub>', description: 'Manage .codehere/permissions.yaml allow/deny', noKey: true },
|
|
418
|
+
{ name: 'narrator', args: '', description: 'Customize the PM narrator prompt', noKey: true },
|
|
419
|
+
{ name: 'budget', args: '[set <amount>]', description: 'Daily spend budget', noKey: true },
|
|
420
|
+
// Safety + observability (no key)
|
|
421
|
+
{ name: 'hook', args: '[install|status]', description: 'Safety hook management', noKey: true },
|
|
422
|
+
{ name: 'trust', args: '[path]', description: 'Scan for vulnerabilities', noKey: true },
|
|
423
|
+
{ name: 'report', args: '[path]', description: 'Shareable security audit', noKey: true },
|
|
424
|
+
{ name: 'status', args: '', description: 'Security dashboard — hook activity', noKey: true },
|
|
425
|
+
{ name: 'health', args: '', description: 'System health + metrics', noKey: true },
|
|
426
|
+
// Orchestration
|
|
427
|
+
{ name: 'start', args: '', description: 'Open the dashboard at localhost:3773' },
|
|
428
|
+
{ name: 'serve', args: '', description: 'Run headless MCP server (no UI)', noKey: true },
|
|
429
|
+
{ name: 'task', args: '<prompt>', description: 'Delegate a task to a coding agent' },
|
|
430
|
+
{ name: 'kill', args: '<sessionId>', description: 'Kill a running task' },
|
|
431
|
+
// Utility (no key)
|
|
428
432
|
{ name: 'help', args: '', description: 'Command reference', noKey: true },
|
|
429
|
-
|
|
430
|
-
|
|
433
|
+
// REPL-only meta-commands (handled in repl.ts, not in Commander).
|
|
434
|
+
// Marked replOnly so the CI sync guard skips them.
|
|
435
|
+
{ name: 'agents', args: '', description: 'List installed specialists', noKey: true, replOnly: true },
|
|
436
|
+
{ name: 'history', args: '', description: 'Recent REPL session history', noKey: true, replOnly: true },
|
|
437
|
+
{ name: 'clear', args: '', description: 'Clear the screen', noKey: true, replOnly: true },
|
|
438
|
+
{ name: 'exit', args: '', description: 'Leave the session', noKey: true, replOnly: true },
|
|
431
439
|
];
|
|
432
440
|
export function showSlashCommandMenu() {
|
|
433
441
|
newline();
|
package/dist/utils/typo-guard.js
CHANGED
|
@@ -64,7 +64,7 @@ function checkTypoSingle(token, knownCommands) {
|
|
|
64
64
|
.map((n) => ({ n, d: levenshtein(lower, n) }))
|
|
65
65
|
.filter((s) => s.d > 0 && s.d <= 2 && s.d < lower.length)
|
|
66
66
|
// Deterministic tie-breaker (alphabetical) so ranking is stable across runs.
|
|
67
|
-
.sort((a, b) =>
|
|
67
|
+
.sort((a, b) => a.d - b.d || a.n.localeCompare(b.n))
|
|
68
68
|
.slice(0, 3);
|
|
69
69
|
return scored.length > 0 ? scored.map((s) => s.n) : null;
|
|
70
70
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codehere",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.2",
|
|
4
4
|
"description": "A PM for your AI coding agents. Delegate, orchestrate, and audit Claude Code, Codex, Aider, OpenCode, Gemini, and any user-configured CLI agent from one local web UI — every AI action traced, every file scored, every spawn warmed with project memory.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"type": "module",
|