codehere 0.5.0-alpha.5 → 0.6.0-alpha.1

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.
Files changed (34) hide show
  1. package/README.md +18 -8
  2. package/dist/application/use-cases/generate-narration.js +111 -0
  3. package/dist/application/use-cases/generate-thread-title.js +58 -4
  4. package/dist/application/use-cases/narrator-prompt-template.js +158 -0
  5. package/dist/index.js +75 -6
  6. package/dist/infrastructure/cost/budget-config.js +113 -0
  7. package/dist/infrastructure/cost/spend-aggregator.js +147 -0
  8. package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
  9. package/dist/infrastructure/hooks/hook-mode.js +71 -0
  10. package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
  11. package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
  12. package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
  13. package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
  14. package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
  15. package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
  16. package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
  17. package/dist/infrastructure/orchestration/failover-policy.js +106 -0
  18. package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
  19. package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
  20. package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
  21. package/dist/infrastructure/orchestration/session-manager.js +117 -10
  22. package/dist/infrastructure/orchestration/session-persistence.js +28 -0
  23. package/dist/infrastructure/protocols/mcp-server-impl.js +564 -13
  24. package/dist/infrastructure/protocols/ui-page.js +2728 -245
  25. package/dist/infrastructure/security/permissions-config.js +234 -0
  26. package/dist/infrastructure/storage/project-thread-mirror.js +379 -24
  27. package/dist/infrastructure/storage/project-thread-reader.js +23 -1
  28. package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
  29. package/dist/presentation/cli/commands/budget-command.js +79 -0
  30. package/dist/presentation/cli/commands/narrator-command.js +101 -0
  31. package/dist/presentation/cli/commands/permission-command.js +116 -0
  32. package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
  33. package/dist/utils/typo-guard.js +77 -17
  34. package/package.json +1 -1
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Pre-prompt safety scan — cross-vendor parity, task #110.
3
+ *
4
+ * The substrate the FAQ landing claims for v0.5.x → v0.6: "per-tool
5
+ * blocking parity for Codex / Aider / OpenCode / Gemini." Today, only
6
+ * Claude Code has a per-tool PreToolUse hook. The other 4 adapters
7
+ * spawn raw — every Bash/Edit they make goes unscanned.
8
+ *
9
+ * Closing that gap fully needs per-adapter pre-tool intercepts (huge
10
+ * scope per-adapter — different IPC + lifecycle for each CLI). This
11
+ * module ships the FIRST cross-vendor slice: scan the user's PROMPT
12
+ * before spawn. Catches the prompt-side leaks (API key in prompt,
13
+ * injection-pattern poisoning, credential paste) for ALL 5 adapters
14
+ * with one piece of code.
15
+ *
16
+ * Posture follows getHookMode():
17
+ * block → high+ severity findings refuse spawn (throw)
18
+ * advisory → findings logged + warning event, spawn proceeds
19
+ * log-only → findings logged, spawn proceeds, no warning
20
+ * off → no scan, spawn proceeds
21
+ *
22
+ * Every scan writes one audit-log entry with synthetic toolName
23
+ * 'pre_prompt' so the existing /history feed + hash chain pick it up
24
+ * without surface changes.
25
+ */
26
+ import { scanPayload, calculateRiskScore, appendAuditLog, } from './claude-sdk-hook.js';
27
+ import { getHookMode } from './hook-mode.js';
28
+ import { filterAllowed } from '../security/permissions-config.js';
29
+ /**
30
+ * Scan a user prompt before spawn. Always writes one audit-log entry.
31
+ * Returns allowed=false ONLY when mode === 'block' AND there's a
32
+ * high-or-critical finding. Other modes always allow but still log.
33
+ */
34
+ export function scanPromptBeforeSpawn(input) {
35
+ const rawFindings = scanPayload(input.prompt);
36
+ // P18 slice 1 — filter against the cwd's permissions.yaml allow-list.
37
+ // Allow-listed findings are dropped from the BLOCK calculation but
38
+ // still recorded in the audit-log allowed-list field so a forensic
39
+ // reviewer can see what was suppressed and why.
40
+ const { surviving, allowed } = input.cwd
41
+ ? filterAllowed(rawFindings, input.cwd)
42
+ : { surviving: rawFindings, allowed: [] };
43
+ const findings = surviving;
44
+ const riskScore = calculateRiskScore(findings);
45
+ const mode = (() => {
46
+ try {
47
+ return getHookMode();
48
+ }
49
+ catch {
50
+ return 'off';
51
+ }
52
+ })();
53
+ // Severity gate: only critical/high findings can refuse a spawn.
54
+ // Medium/low findings get logged but never block — they're flagged
55
+ // for visibility, not enforcement. Matches the per-tool hook's
56
+ // existing severity-to-action policy.
57
+ const hardFindings = findings.filter((f) => f.severity === 'critical' || f.severity === 'high');
58
+ const wouldBlock = hardFindings.length > 0;
59
+ const blocked = mode === 'block' && wouldBlock;
60
+ // Audit entry — written for every scan, regardless of mode. Synthetic
61
+ // toolName 'pre_prompt' lets /history filter chips group these
62
+ // separately from per-tool entries (existing chip filters on tool).
63
+ const entry = {
64
+ timestamp: new Date().toISOString(),
65
+ sessionId: input.sessionId,
66
+ toolName: 'pre_prompt',
67
+ // Truncate the prompt for the audit record — full prompts can be
68
+ // 10K+ chars and we don't want the audit log to balloon.
69
+ toolInput: JSON.stringify({
70
+ agent: input.agent,
71
+ prompt: input.prompt.length > 500 ? input.prompt.slice(0, 500) + '…' : input.prompt,
72
+ cwd: input.cwd ?? null,
73
+ promptLength: input.prompt.length,
74
+ // P18 slice 1 — record what got allow-listed for forensic audit.
75
+ // Empty array when no permissions.yaml exists in the cwd.
76
+ ...(allowed.length > 0
77
+ ? {
78
+ allowedByPermissions: allowed.map((a) => ({
79
+ category: a.finding.category,
80
+ matched: a.finding.matched,
81
+ reason: a.entry.reason ?? '(no reason given)',
82
+ })),
83
+ }
84
+ : {}),
85
+ }),
86
+ riskScore,
87
+ blocked,
88
+ findings: findings.length,
89
+ criticalFindings: findings.filter((f) => f.severity === 'critical').length,
90
+ // Mode-driven override marker so the audit reader knows WHY a
91
+ // would-block finding wasn't enforced. Reuses the existing
92
+ // OverrideReason 'log_only_mode' for log-only AND advisory + off
93
+ // (all three skip the block; 'log_only_mode' is the closest
94
+ // shared semantic — the entry's mode field can be added as a new
95
+ // OverrideReason later without breaking the audit chain).
96
+ ...(wouldBlock && !blocked ? { overrideReason: 'log_only_mode' } : {}),
97
+ };
98
+ try {
99
+ appendAuditLog(entry);
100
+ }
101
+ catch {
102
+ // Audit-write failure should NOT itself block a spawn — that would
103
+ // be a denial-of-service vector (fill the log dir → no spawns).
104
+ // The audit infra has its own failure-mode logging.
105
+ }
106
+ // Compose a block reason from the worst findings. Used by the
107
+ // session-manager to throw an actionable error.
108
+ let blockReason = '';
109
+ if (blocked) {
110
+ const top = hardFindings.slice(0, 3);
111
+ const summary = top.map((f) => `[${f.severity}] ${f.description}`).join('; ');
112
+ blockReason = `pre-prompt scan blocked: ${summary}`;
113
+ }
114
+ return {
115
+ allowed: !blocked,
116
+ blocked,
117
+ riskScore,
118
+ findings,
119
+ blockReason,
120
+ };
121
+ }
122
+ //# sourceMappingURL=pre-prompt-scan.js.map
@@ -17,6 +17,7 @@ import { EventEmitter } from 'events';
17
17
  import { randomUUID } from 'crypto';
18
18
  import { ProgressAggregator } from './progress-aggregator.js';
19
19
  import { applyMemoryPrefix } from './memory-injector.js';
20
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
20
21
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
21
22
  const HEADLESS_FLAGS = [
22
23
  '--yes-always', // auto-accept all file-edit confirmations
@@ -99,6 +100,32 @@ export function spawnAider(prompt, options = {}) {
99
100
  if (event.type === 'stdout' && event.data?.line) {
100
101
  const line = String(event.data.line);
101
102
  textAccum += line + '\n';
103
+ // #110 slice 3 — mirror aider tool calls to the audit log. Match
104
+ // the same stdout patterns the aggregator parses (per
105
+ // learning_aider.md): "Running <cmd>" → Bash, "Applied edit to
106
+ // <path>" → Edit, "Creating empty file <path>" → Write.
107
+ // Strip ANSI as defense-in-depth (HEADLESS_FLAGS already includes
108
+ // --no-pretty but extraArgs could override).
109
+ const stripAnsi = (s) => s.replace(/\[[0-9;]*m/g, '');
110
+ const clean = stripAnsi(line).trim();
111
+ let toolName = '';
112
+ let input = '';
113
+ let mirrorMatch;
114
+ if ((mirrorMatch = clean.match(/^Running\s+(.+)$/))) {
115
+ toolName = 'Bash';
116
+ input = mirrorMatch[1];
117
+ }
118
+ else if ((mirrorMatch = clean.match(/^Applied edit to\s+(.+)$/))) {
119
+ toolName = 'Edit';
120
+ input = mirrorMatch[1];
121
+ }
122
+ else if ((mirrorMatch = clean.match(/^Creating empty file\s+(.+)$/))) {
123
+ toolName = 'Write';
124
+ input = mirrorMatch[1];
125
+ }
126
+ if (toolName && input) {
127
+ mirrorToolCallToAudit({ sessionId, agent: 'aider', toolName, toolInput: input, cwd });
128
+ }
102
129
  // Aider emits a cost summary like:
103
130
  // "Tokens: 1.2k sent, 412 received. Cost: $0.01 request, $0.01 session."
104
131
  const m = line.match(/Tokens:\s*([\d.]+)([kM]?)\s*sent,\s*([\d.]+)([kM]?)\s*received/i);
@@ -330,6 +357,7 @@ export function spawnAider(prompt, options = {}) {
330
357
  export const AiderAdapter = {
331
358
  id: 'aider',
332
359
  label: 'Aider',
360
+ description: 'Best when you already know which file to change and what to change in it. Tight, focused edits — no plan-then-explore phase.',
333
361
  processPattern: /(?:^|\/)aider(?:\s|$)/,
334
362
  isInstalled() {
335
363
  try {
@@ -9,11 +9,81 @@
9
9
  *
10
10
  * Agent-agnostic on purpose. Both Claude and Codex mutate the filesystem;
11
11
  * git tells the truth regardless of which tool did the write.
12
+ *
13
+ * v0.6.x: dirty-baseline subtraction. Without it, files the user had
14
+ * uncommitted at spawn time would falsely appear in artifacts even though
15
+ * the agent never touched them — git diff vs spawn-HEAD includes EVERYTHING
16
+ * dirty in the working tree, not just what changed during the session.
17
+ * Now snapshotCwd captures `git status --porcelain` + per-file mtime/size at
18
+ * spawn time. extractArtifacts filters out files whose stat is unchanged
19
+ * since spawn — they were dirty before, not by this session. Files that were
20
+ * dirty at spawn AND further modified during the session still appear.
21
+ *
22
+ * Limitation: cannot distinguish concurrent human writes from agent writes
23
+ * during the session window. True PID-level attribution would require
24
+ * fanotify/audit subsystem. The dirty-baseline filter eliminates the most
25
+ * common false-positive (pre-existing uncommitted work) without that.
12
26
  */
13
27
  import { execFileSync } from 'child_process';
14
28
  import { existsSync, readFileSync, statSync } from 'fs';
15
29
  import { join, isAbsolute } from 'path';
16
30
  import { scoreFile } from '../security/trust-scorer.js';
31
+ /** Capture mtime + size for a single path. Returns null if stat fails. */
32
+ function statBaseline(fullPath) {
33
+ try {
34
+ const st = statSync(fullPath);
35
+ return { mtimeMs: st.mtimeMs, size: st.size };
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** Read `git status --porcelain` and stat every dirty path. Best-effort.
42
+ * Paths are git-relative (i.e. relative to repoRoot, not cwd). */
43
+ function captureDirtyBaseline(cwd, repoRoot) {
44
+ const baseline = new Map();
45
+ try {
46
+ const out = execFileSync('git', ['status', '--porcelain', '-z'], {
47
+ cwd,
48
+ encoding: 'utf-8',
49
+ timeout: 5000,
50
+ stdio: ['ignore', 'pipe', 'ignore'],
51
+ });
52
+ // Porcelain -z separates entries by NUL. First two chars are status, then space, then path.
53
+ // Renames have an extra NUL-separated old path which we don't need here.
54
+ for (const entry of out.split('\0')) {
55
+ if (!entry)
56
+ continue;
57
+ // Strip the 2-char status prefix + 1 separator (3 chars total)
58
+ const path = entry.slice(3);
59
+ if (!path)
60
+ continue;
61
+ const full = isAbsolute(path) ? path : join(repoRoot, path);
62
+ const b = statBaseline(full);
63
+ if (b)
64
+ baseline.set(path, b);
65
+ }
66
+ }
67
+ catch {
68
+ /* best-effort — empty baseline means no filtering, status-quo behavior */
69
+ }
70
+ return baseline;
71
+ }
72
+ /** Get repo root via `git rev-parse --show-toplevel`. Returns null if not in a repo. */
73
+ function getRepoRoot(cwd) {
74
+ try {
75
+ const out = execFileSync('git', ['rev-parse', '--show-toplevel'], {
76
+ cwd,
77
+ encoding: 'utf-8',
78
+ timeout: 2000,
79
+ stdio: ['ignore', 'pipe', 'ignore'],
80
+ }).trim();
81
+ return out.length > 0 ? out : null;
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
17
87
  /** Take a baseline so Layer 3 can diff against it on exit. Cheap; ~5ms. */
18
88
  export function snapshotCwd(cwd) {
19
89
  const spawnedAtMs = Date.now();
@@ -24,10 +94,26 @@ export function snapshotCwd(cwd) {
24
94
  timeout: 2000,
25
95
  stdio: ['ignore', 'pipe', 'ignore'],
26
96
  }).trim();
27
- return { cwd, head: out, isGitRepo: out.length > 0, spawnedAtMs };
97
+ const isGitRepo = out.length > 0;
98
+ const repoRoot = isGitRepo ? (getRepoRoot(cwd) ?? cwd) : cwd;
99
+ return {
100
+ cwd,
101
+ head: out,
102
+ isGitRepo,
103
+ spawnedAtMs,
104
+ repoRoot,
105
+ dirtyBaseline: isGitRepo ? captureDirtyBaseline(cwd, repoRoot) : new Map(),
106
+ };
28
107
  }
29
108
  catch {
30
- return { cwd, head: null, isGitRepo: false, spawnedAtMs };
109
+ return {
110
+ cwd,
111
+ head: null,
112
+ isGitRepo: false,
113
+ spawnedAtMs,
114
+ repoRoot: cwd,
115
+ dirtyBaseline: new Map(),
116
+ };
31
117
  }
32
118
  }
33
119
  /** Collect {path, +, -} from git diff (numstat) + untracked files (wc -l). */
@@ -69,9 +155,10 @@ function changedFiles(snap) {
69
155
  for (const rel of untracked.split('\n')) {
70
156
  if (!rel.trim())
71
157
  continue;
72
- // Count lines in the new file
158
+ // Count lines in the new file. Untracked paths come from git relative
159
+ // to repoRoot — must join with repoRoot, not cwd, when cwd is a subdir.
73
160
  try {
74
- const full = isAbsolute(rel) ? rel : join(snap.cwd, rel);
161
+ const full = isAbsolute(rel) ? rel : join(snap.repoRoot, rel);
75
162
  if (!existsSync(full))
76
163
  continue;
77
164
  const size = statSync(full).size;
@@ -125,7 +212,9 @@ function parseTestSummary(stdout) {
125
212
  function annotateTrust(snap, files) {
126
213
  return files.map((f) => {
127
214
  try {
128
- const full = isAbsolute(f.path) ? f.path : join(snap.cwd, f.path);
215
+ // Git-relative path: join with repoRoot, not cwd (matters when cwd is
216
+ // a subdirectory of the repo).
217
+ const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
129
218
  if (!existsSync(full))
130
219
  return f;
131
220
  const size = statSync(full).size;
@@ -154,7 +243,8 @@ function filterByMtime(snap, files) {
154
243
  const grace = 1000; // ms; allow clock skew between git's touch and our Date.now()
155
244
  return files.filter((f) => {
156
245
  try {
157
- const full = isAbsolute(f.path) ? f.path : join(snap.cwd, f.path);
246
+ // Git-relative path: join with repoRoot, not cwd.
247
+ const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
158
248
  if (!existsSync(full))
159
249
  return true; // file was deleted — keep it
160
250
  const { mtimeMs } = statSync(full);
@@ -165,10 +255,40 @@ function filterByMtime(snap, files) {
165
255
  }
166
256
  });
167
257
  }
258
+ /**
259
+ * Drop files that were dirty at spawn time and have NOT been further modified
260
+ * since. The git-diff-vs-spawn-HEAD path picks up the user's pre-existing
261
+ * uncommitted changes; without this filter, those files get mis-attributed to
262
+ * the agent's task. A file is considered "further modified" if its mtime OR
263
+ * size changed since the spawn snapshot — content-level tampering with same
264
+ * mtime/size is rare enough to ignore.
265
+ */
266
+ function filterBySpawnBaseline(snap, files) {
267
+ if (snap.dirtyBaseline.size === 0)
268
+ return files;
269
+ return files.filter((f) => {
270
+ const baseline = snap.dirtyBaseline.get(f.path);
271
+ if (!baseline)
272
+ return true; // wasn't dirty at spawn — definitely a session change
273
+ try {
274
+ // Git-relative path: join with repoRoot, not cwd.
275
+ const full = isAbsolute(f.path) ? f.path : join(snap.repoRoot, f.path);
276
+ if (!existsSync(full))
277
+ return true; // deleted during session — that's a change
278
+ const { mtimeMs, size } = statSync(full);
279
+ // Further-modified if either mtime advanced or size differs from baseline.
280
+ return mtimeMs > baseline.mtimeMs || size !== baseline.size;
281
+ }
282
+ catch {
283
+ return true; // stat failed — be permissive (don't drop files we can't verify)
284
+ }
285
+ });
286
+ }
168
287
  export function extractArtifacts(snap, collectedStdout) {
169
288
  const raw = changedFiles(snap);
170
- const mine = filterByMtime(snap, raw);
171
- const files = annotateTrust(snap, mine);
289
+ const sessionScoped = filterByMtime(snap, raw);
290
+ const agentLikely = filterBySpawnBaseline(snap, sessionScoped);
291
+ const files = annotateTrust(snap, agentLikely);
172
292
  const tests = parseTestSummary(collectedStdout);
173
293
  return { files, tests };
174
294
  }
@@ -407,6 +407,7 @@ export function spawnClaudeCode(prompt, options = {}) {
407
407
  export const ClaudeCodeAdapter = {
408
408
  id: 'claude-code',
409
409
  label: 'Claude Code',
410
+ description: 'Good default for editing code, running tests, and shell commands. Strongest safety net: codehere can stop risky actions before they run, not just record them after.',
410
411
  processPattern: /(?:^|\/)claude(?:\s|$)/,
411
412
  isInstalled() {
412
413
  try {
@@ -19,6 +19,7 @@ import { randomUUID } from 'crypto';
19
19
  import { ProgressAggregator } from './progress-aggregator.js';
20
20
  import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
21
21
  import { applyMemoryPrefix } from './memory-injector.js';
22
+ import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
22
23
  const HEADLESS_FLAGS = [
23
24
  'exec',
24
25
  '--json',
@@ -103,6 +104,18 @@ export function spawnCodex(prompt, options = {}) {
103
104
  textAccum += item.aggregated_output + '\n';
104
105
  if (item?.type === 'agent_message' && item.text)
105
106
  textAccum += item.text + '\n';
107
+ // #110 slice 2 — mirror tool calls to the audit log so codex
108
+ // gets the same audit-trail surface as Claude's per-tool hook.
109
+ // Post-hoc only (no block); pre-tool intercept is a future slice.
110
+ if (item?.type === 'command_execution' && typeof item.command === 'string' && item.command) {
111
+ mirrorToolCallToAudit({
112
+ sessionId,
113
+ agent: 'codex',
114
+ toolName: 'Bash',
115
+ toolInput: item.command,
116
+ cwd,
117
+ });
118
+ }
106
119
  }
107
120
  // Accumulate token usage across all turns (one turn.completed per turn).
108
121
  if (event.type === 'turn.completed') {
@@ -358,6 +371,7 @@ export function spawnCodex(prompt, options = {}) {
358
371
  export const CodexAdapter = {
359
372
  id: 'codex',
360
373
  label: 'Codex',
374
+ description: 'OpenAI\'s coding agent. Good when the task needs reading a lot of code first, then changing several files together.',
361
375
  processPattern: /(?:^|\/)codex(?:\s|$)/,
362
376
  isInstalled() {
363
377
  try {
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Failover classifier — v0.6.x rate-limit fail-over wedge (Slice A).
3
+ *
4
+ * Interprets adapter error codes to decide whether fail-over to a different
5
+ * adapter should be offered to the user. Pure function — no I/O, no adapter
6
+ * spawning. Adapters already detect rate-limit / quota errors via their
7
+ * ERROR_PATTERNS arrays and emit `codehere:error` events with codes:
8
+ * - 'rate_limit' — Anthropic / OpenAI / Google 429s
9
+ * - 'quota_exceeded' — credit balance / quota exhausted
10
+ * - 'auth_invalid' — NOT failover-eligible (different fix)
11
+ * - other codes — NOT failover-eligible (specific to provider)
12
+ *
13
+ * This module's job: take an emitted error code, return either a FailoverSignal
14
+ * (caller should ask user "switch to Codex?") or null (don't offer failover).
15
+ *
16
+ * Per `feedback_research_sharpens_not_pivots.md` — this sharpens what already
17
+ * ships (per-adapter error patterns) rather than introducing a new detection
18
+ * mechanism. Per claim_market_position v4 — the fail-over capability is the
19
+ * v0.6 outward proof point that demonstrates codehere's vendor-neutral value
20
+ * (only a vendor-neutral layer can switch you off Claude when Claude refuses).
21
+ *
22
+ * Slice A scope (this commit): the pure classifier + retry-after parser +
23
+ * tests. Slices B-F wire policy resolution, user consent, mid-task hand-off,
24
+ * UI surfacing, and end-to-end integration.
25
+ */
26
+ /**
27
+ * Error codes that should trigger a fail-over offer. Keep tight — false
28
+ * positives waste user attention with "switch?" prompts they don't want.
29
+ *
30
+ * Verified against current adapter ERROR_PATTERNS (May 2026):
31
+ * - 'rate_limit' — emitted by all 5 adapters on 429s
32
+ * - 'quota_exceeded' — emitted by codex-adapter, gemini-adapter
33
+ * - 'credit_low' — emitted by claude-code-adapter, aider-adapter
34
+ * when API credit balance is exhausted
35
+ *
36
+ * NOT included (and why):
37
+ * - 'auth_invalid' — fixing the wrong adapter doesn't help; switching to
38
+ * a different agent that ALSO needs auth doesn't fix anything
39
+ * - generic errors — no signal that another adapter would do better
40
+ * - timeouts — could be transient on either side
41
+ */
42
+ const FAILOVER_TRIGGER_CODES = new Set(['rate_limit', 'quota_exceeded', 'credit_low']);
43
+ /**
44
+ * Returns a FailoverSignal if the error code suggests we should offer
45
+ * fail-over to a different adapter, null otherwise. Pure function — no
46
+ * side effects. Caller decides how to surface the offer (CLI prompt,
47
+ * web UI banner, programmatic auto-failover).
48
+ *
49
+ * @param fromAgent Adapter id of the agent that hit the error
50
+ * @param errorCode Code from the adapter's ERROR_PATTERNS classification
51
+ * @param errorMessage Original error text (used to extract retry-after)
52
+ */
53
+ export function classifyFailoverEligibility(fromAgent, errorCode, errorMessage) {
54
+ if (!FAILOVER_TRIGGER_CODES.has(errorCode))
55
+ return null;
56
+ if (!fromAgent)
57
+ return null;
58
+ const retryAfterMs = parseRetryAfterMs(errorMessage);
59
+ const signal = {
60
+ fromAgent,
61
+ errorCode,
62
+ reason: errorMessage || `${fromAgent} hit ${errorCode}`,
63
+ };
64
+ if (retryAfterMs !== undefined)
65
+ signal.retryAfterMs = retryAfterMs;
66
+ return signal;
67
+ }
68
+ /**
69
+ * Best-effort extraction of retry-after from common error message shapes.
70
+ * Returns milliseconds or undefined. Conservative — only matches patterns
71
+ * we've seen in real provider responses; unknown shapes return undefined
72
+ * (caller treats as "unknown — show indefinite wait").
73
+ *
74
+ * Patterns recognized:
75
+ * - "Retry-After: 60"
76
+ * - "retry after 60s"
77
+ * - "retry after 60 seconds"
78
+ * - "try again in 2 minutes"
79
+ * - "wait 5 min"
80
+ * - "wait 90 seconds"
81
+ * - "in 2m 30s" (rare, treat as 2 minutes)
82
+ *
83
+ * NOT matched (intentionally — too ambiguous):
84
+ * - bare numbers ("60") — could be anything
85
+ * - "wait a moment" — no quantity
86
+ */
87
+ export function parseRetryAfterMs(message) {
88
+ if (!message)
89
+ return undefined;
90
+ // "Retry-After: <N> <unit>" — HTTP header style with EXPLICIT unit.
91
+ // Per RFC 7231, default unit is seconds; we also accept explicit unit
92
+ // suffix (some providers add one for clarity, e.g. "Retry-After: 5 minutes").
93
+ // Must NOT match HTTP-date forms ("Retry-After: Wed, 21 Oct ...").
94
+ const headerWithUnitMatch = message.match(/retry-?after[:\s]+(\d+)\s*(seconds?|secs?|s\b|minutes?|mins?|m\b|hours?|hrs?|h\b)/i);
95
+ if (headerWithUnitMatch) {
96
+ const n = Number(headerWithUnitMatch[1]);
97
+ const unit = headerWithUnitMatch[2].toLowerCase();
98
+ if (Number.isFinite(n) && n > 0)
99
+ return convertToMs(n, unit);
100
+ }
101
+ // "Retry-After: 60" — bare header with no unit, defaults to seconds per RFC.
102
+ // Negative lookahead rejects HTTP-date forms (alphabetic chars after the digits).
103
+ const headerBareMatch = message.match(/retry-?after[:\s]+(\d+)(?!\s*[A-Za-z])/i);
104
+ if (headerBareMatch) {
105
+ const sec = Number(headerBareMatch[1]);
106
+ if (Number.isFinite(sec) && sec > 0)
107
+ return sec * 1000;
108
+ }
109
+ // Order matters: try LARGER units first so compound durations like
110
+ // "2m 30s" or "1h 45m" don't get misread as the smaller unit. Without
111
+ // this, "try again in 2m 30s" would match the seconds pattern on "30s"
112
+ // and return 30 seconds instead of ~2 minutes.
113
+ //
114
+ // Each pattern accepts a fractional value (e.g. OpenAI's "try again in 1.23s")
115
+ // — bare-int regex would have matched "23s" and missed the "1." prefix,
116
+ // returning 23 seconds instead of ~1.23 seconds. Capture group is "\d+(?:\.\d+)?".
117
+ // "in 2 hours" / "try again in 1 hour" — common for daily / weekly limits
118
+ const naturalHoursMatch = message.match(/(?:retry|try again|in|wait|reset).*?(\d+(?:\.\d+)?)\s*hours?\b/i);
119
+ if (naturalHoursMatch) {
120
+ const hr = Number(naturalHoursMatch[1]);
121
+ if (Number.isFinite(hr) && hr > 0)
122
+ return Math.round(hr * 60 * 60 * 1000);
123
+ }
124
+ // "retry after 2 minutes" / "try again in 5 min" / "wait 5 min" / "in 2m 30s"
125
+ const naturalMinutesMatch = message.match(/(?:retry|try again|wait).*?(\d+(?:\.\d+)?)\s*(?:minutes?|mins?|m\b)/i);
126
+ if (naturalMinutesMatch) {
127
+ const min = Number(naturalMinutesMatch[1]);
128
+ if (Number.isFinite(min) && min > 0)
129
+ return Math.round(min * 60 * 1000);
130
+ }
131
+ // "retry after 60s" / "try again in 60s" / "wait 60s" / "try again in 1.23s"
132
+ // (OpenAI returns fractional seconds for sub-2-second rate-limit windows).
133
+ // Last because compound durations like "2m 30s" should be read as minutes.
134
+ const naturalSecondsMatch = message.match(/(?:retry|try again|wait).*?(\d+(?:\.\d+)?)\s*(?:seconds?|secs?|s\b)/i);
135
+ if (naturalSecondsMatch) {
136
+ const sec = Number(naturalSecondsMatch[1]);
137
+ if (Number.isFinite(sec) && sec > 0)
138
+ return Math.round(sec * 1000);
139
+ }
140
+ return undefined;
141
+ }
142
+ /** Convert a number + unit string to milliseconds. Unit is one of the regex
143
+ * alternatives in the header-with-unit pattern above. */
144
+ function convertToMs(n, unit) {
145
+ if (/^h/i.test(unit))
146
+ return n * 60 * 60 * 1000;
147
+ if (/^m/i.test(unit))
148
+ return n * 60 * 1000;
149
+ return n * 1000; // seconds (default)
150
+ }
151
+ //# sourceMappingURL=failover-classifier.js.map
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Failover policy — v0.6.x rate-limit fail-over wedge (Slice B).
3
+ *
4
+ * Given: an adapter that hit a fail-over-eligible error (per Slice A's
5
+ * `classifyFailoverEligibility`), a user-configured preference chain, and
6
+ * the set of currently-available adapters on this machine.
7
+ * Returns: the next adapter to try, or null if no fail-over target is
8
+ * available.
9
+ *
10
+ * Pure function — no I/O, no spawning. Caller (session-manager, Slice D)
11
+ * uses the resolver output to drive the user-consent flow (Slice C) and
12
+ * the mid-task hand-off.
13
+ *
14
+ * Default chain rationale:
15
+ * claude-code → codex → aider → opencode → gemini
16
+ *
17
+ * The default is ordered by capability+reliability for general coding work
18
+ * as of May 2026:
19
+ * - claude-code: strongest at multi-file reasoning + tool use
20
+ * - codex: strongest at independent review + adversarial mode
21
+ * - aider: most reliable for surgical single-file edits
22
+ * - opencode: cross-vendor flexibility within itself
23
+ * - gemini: longest context window, but free-tier rate limits hit fast
24
+ *
25
+ * Users can override via `~/.codehere/config.json` `failoverChain` array.
26
+ * Per-task override also supported via SpawnOptions.failoverChain (Slice D
27
+ * wires this).
28
+ *
29
+ * Slice B scope (this commit): pure resolver + config schema + tests.
30
+ * Slices C-F wire user consent, mid-task hand-off, UI surfacing, e2e.
31
+ */
32
+ /** Adapter ids in the order codehere supports. Keep in sync with adapter
33
+ * registry. Used as the strict ordering for chain validation. */
34
+ export const KNOWN_AGENT_IDS = ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
35
+ /** Default fail-over chain. See module docblock for ordering rationale.
36
+ * Each entry is the agent codehere will TRY in order when the previous
37
+ * one is unavailable or has already been tried this hand-off. */
38
+ export const DEFAULT_FAILOVER_CHAIN = ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
39
+ /**
40
+ * Resolve the next fail-over target. Returns the adapter id to try, or
41
+ * null if no eligible target exists.
42
+ *
43
+ * Selection rules (in order):
44
+ * 1. Use preferredChain if provided, else DEFAULT_FAILOVER_CHAIN
45
+ * 2. Skip the failed agent itself
46
+ * 3. Skip any adapter in alreadyTried
47
+ * 4. Skip adapters not present in available[] OR with ready=false
48
+ * 5. Return the FIRST remaining adapter in chain order
49
+ * 6. If nothing remains, return null
50
+ *
51
+ * Pure — no spawn, no config read. Caller assembles the inputs.
52
+ */
53
+ export function resolveFailoverTarget(opts) {
54
+ const chain = opts.preferredChain && opts.preferredChain.length > 0 ? opts.preferredChain : DEFAULT_FAILOVER_CHAIN;
55
+ const tried = new Set([opts.failedAgent, ...(opts.alreadyTried ?? [])]);
56
+ const readyById = new Map();
57
+ for (const a of opts.available)
58
+ readyById.set(a.id, a.ready);
59
+ for (const candidate of chain) {
60
+ if (tried.has(candidate))
61
+ continue;
62
+ if (readyById.get(candidate) !== true)
63
+ continue;
64
+ return candidate;
65
+ }
66
+ return null;
67
+ }
68
+ /**
69
+ * Validate a user-supplied chain (e.g. read from config.json). Returns the
70
+ * chain if every entry is a known agent id; throws otherwise. Caller
71
+ * (config-loader, Slice C) uses this to give a helpful error if the user
72
+ * mistypes an adapter name.
73
+ */
74
+ export function validateFailoverChain(chain) {
75
+ if (!Array.isArray(chain)) {
76
+ throw new Error('failoverChain must be an array of agent ids');
77
+ }
78
+ const validated = [];
79
+ for (const item of chain) {
80
+ if (typeof item !== 'string') {
81
+ throw new Error(`failoverChain entries must be strings, got ${typeof item}`);
82
+ }
83
+ if (!isKnownAgentId(item)) {
84
+ throw new Error(`Unknown agent id in failoverChain: "${item}". Valid ids: ${KNOWN_AGENT_IDS.join(', ')}`);
85
+ }
86
+ if (validated.includes(item)) {
87
+ throw new Error(`Duplicate agent id in failoverChain: "${item}"`);
88
+ }
89
+ validated.push(item);
90
+ }
91
+ return validated;
92
+ }
93
+ function isKnownAgentId(id) {
94
+ return KNOWN_AGENT_IDS.includes(id);
95
+ }
96
+ /**
97
+ * Helper: convert an array of AgentAdapter (typically from an adapter
98
+ * registry) to the AdapterAvailability shape this module expects, given
99
+ * a readiness probe function. Used by callers who already have the
100
+ * adapter instances handy. Caller passes a probe like
101
+ * `adapter.checkReadiness().state === 'ready'`.
102
+ */
103
+ export function toAvailability(adapters, isReady) {
104
+ return adapters.map((a) => ({ id: a.id, ready: isReady(a) }));
105
+ }
106
+ //# sourceMappingURL=failover-policy.js.map