codehere 0.5.0-alpha.6 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -8
- package/dist/application/use-cases/generate-narration.js +111 -0
- package/dist/application/use-cases/generate-thread-title.js +58 -4
- package/dist/application/use-cases/narrator-prompt-template.js +158 -0
- package/dist/index.js +89 -6
- package/dist/infrastructure/cost/budget-config.js +113 -0
- package/dist/infrastructure/cost/spend-aggregator.js +147 -0
- package/dist/infrastructure/hooks/claude-sdk-hook.js +280 -4
- package/dist/infrastructure/hooks/hook-mode.js +71 -0
- package/dist/infrastructure/hooks/mirror-tool-call-to-audit.js +43 -0
- package/dist/infrastructure/hooks/pre-prompt-scan.js +122 -0
- package/dist/infrastructure/orchestration/aider-adapter.js +28 -0
- package/dist/infrastructure/orchestration/artifacts-extractor.js +128 -8
- package/dist/infrastructure/orchestration/claude-code-adapter.js +1 -0
- package/dist/infrastructure/orchestration/codex-adapter.js +14 -0
- package/dist/infrastructure/orchestration/context-handoff.js +143 -0
- package/dist/infrastructure/orchestration/failover-classifier.js +151 -0
- package/dist/infrastructure/orchestration/failover-config.js +77 -0
- package/dist/infrastructure/orchestration/failover-policy.js +106 -0
- package/dist/infrastructure/orchestration/gemini-adapter.js +116 -14
- package/dist/infrastructure/orchestration/graphify-context.js +256 -0
- package/dist/infrastructure/orchestration/opencode-adapter.js +100 -18
- package/dist/infrastructure/orchestration/progress-aggregator.js +141 -6
- package/dist/infrastructure/orchestration/session-manager.js +515 -15
- package/dist/infrastructure/protocols/cloud-sync.js +208 -0
- package/dist/infrastructure/protocols/mcp-server-impl.js +655 -24
- package/dist/infrastructure/protocols/ui-page-v2.js +505 -0
- package/dist/infrastructure/protocols/ui-page.js +2872 -220
- package/dist/infrastructure/security/permissions-config.js +234 -0
- package/dist/infrastructure/storage/project-thread-mirror.js +371 -39
- package/dist/infrastructure/storage/project-thread-reader.js +23 -1
- package/dist/infrastructure/storage/project-thread-title-backfill.js +28 -0
- package/dist/presentation/cli/commands/budget-command.js +79 -0
- package/dist/presentation/cli/commands/narrator-command.js +101 -0
- package/dist/presentation/cli/commands/permission-command.js +116 -0
- package/dist/presentation/cli/commands/scan-tool-command.js +36 -7
- package/dist/presentation/cli/repl.js +29 -0
- package/dist/utils/typo-guard.js +77 -17
- package/package.json +2 -1
|
@@ -0,0 +1,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,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Failover config loader — v0.6.x rate-limit fail-over wedge (Slice C).
|
|
3
|
+
*
|
|
4
|
+
* Reads `failoverChain` from `~/.codehere/config.json` and validates it
|
|
5
|
+
* against the known agent ids. Returns the validated chain or undefined
|
|
6
|
+
* if the user has not configured one (caller falls back to the default).
|
|
7
|
+
*
|
|
8
|
+
* Config shape (~/.codehere/config.json):
|
|
9
|
+
* {
|
|
10
|
+
* "version": "0.6.0",
|
|
11
|
+
* "failover": {
|
|
12
|
+
* "chain": ["codex", "claude-code", "aider"]
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
*
|
|
16
|
+
* If `failover.chain` is missing, empty, or invalid, returns undefined
|
|
17
|
+
* so the caller uses DEFAULT_FAILOVER_CHAIN from failover-policy.ts.
|
|
18
|
+
*
|
|
19
|
+
* Slice C scope: config read + validation. Slice D wires it into
|
|
20
|
+
* SessionManager so the resolved chain flows into the resolver.
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, existsSync } from 'fs';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { validateFailoverChain } from './failover-policy.js';
|
|
26
|
+
const GLOBAL_CONFIG_PATH = join(homedir(), '.codehere', 'config.json');
|
|
27
|
+
/**
|
|
28
|
+
* Load failover configuration from ~/.codehere/config.json.
|
|
29
|
+
* Returns undefined if the config file does not exist or has no failover section.
|
|
30
|
+
*
|
|
31
|
+
* Safe to call at any time — no side effects, no caching, no I/O errors
|
|
32
|
+
* propagated (returns undefined on any read failure).
|
|
33
|
+
*/
|
|
34
|
+
export function loadFailoverConfig() {
|
|
35
|
+
if (!existsSync(GLOBAL_CONFIG_PATH))
|
|
36
|
+
return undefined;
|
|
37
|
+
try {
|
|
38
|
+
const raw = JSON.parse(readFileSync(GLOBAL_CONFIG_PATH, 'utf-8'));
|
|
39
|
+
if (!raw || typeof raw !== 'object')
|
|
40
|
+
return undefined;
|
|
41
|
+
const failover = raw.failover;
|
|
42
|
+
if (!failover || typeof failover !== 'object')
|
|
43
|
+
return undefined;
|
|
44
|
+
const config = {};
|
|
45
|
+
// Validate chain if present
|
|
46
|
+
if (Array.isArray(failover.chain)) {
|
|
47
|
+
try {
|
|
48
|
+
config.chain = validateFailoverChain(failover.chain);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Invalid chain — skip it, caller uses default
|
|
52
|
+
config.chain = undefined;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
// autoFailover — boolean, defaults to false
|
|
56
|
+
if (typeof failover.autoFailover === 'boolean') {
|
|
57
|
+
config.autoFailover = failover.autoFailover;
|
|
58
|
+
}
|
|
59
|
+
return config;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Config file exists but is malformed — return undefined so caller
|
|
63
|
+
// falls back to defaults without crashing.
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Get the resolved failover chain: user-configured if present, else default.
|
|
69
|
+
* Pure function — no I/O. Caller loads config and passes it in.
|
|
70
|
+
*/
|
|
71
|
+
export function resolveChain(userChain) {
|
|
72
|
+
if (userChain && userChain.length > 0)
|
|
73
|
+
return userChain;
|
|
74
|
+
// Re-export the default from failover-policy
|
|
75
|
+
return ['claude-code', 'codex', 'aider', 'opencode', 'gemini'];
|
|
76
|
+
}
|
|
77
|
+
//# sourceMappingURL=failover-config.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
|
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
* fingerprints. Finalizes on `close` (not `exit`) so trailing stdout flushes.
|
|
29
29
|
* Kills the process group (negative pid) so subagents under gemini exit too.
|
|
30
30
|
*/
|
|
31
|
-
import { spawn, execFileSync } from 'child_process';
|
|
31
|
+
import { spawn, execFile, execFileSync } from 'child_process';
|
|
32
|
+
import { promisify } from 'util';
|
|
33
|
+
const execFileAsync = promisify(execFile);
|
|
32
34
|
import { EventEmitter } from 'events';
|
|
33
35
|
import { randomUUID } from 'crypto';
|
|
34
36
|
import { existsSync } from 'fs';
|
|
@@ -36,6 +38,7 @@ import { homedir } from 'os';
|
|
|
36
38
|
import { join } from 'path';
|
|
37
39
|
import { ProgressAggregator } from './progress-aggregator.js';
|
|
38
40
|
import { applyMemoryPrefix } from './memory-injector.js';
|
|
41
|
+
import { mirrorToolCallToAudit } from '../hooks/mirror-tool-call-to-audit.js';
|
|
39
42
|
import { snapshotCwd, extractArtifacts } from './artifacts-extractor.js';
|
|
40
43
|
// `--approval-mode=yolo` auto-accepts every tool action without prompting (the
|
|
41
44
|
// older `--yolo` flag is deprecated). codehere's safety hook is the external
|
|
@@ -87,6 +90,41 @@ function scanForKnownError(text) {
|
|
|
87
90
|
}
|
|
88
91
|
return null;
|
|
89
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse the plain-text output of `gemini --list-sessions` and return the
|
|
95
|
+
* 1-based index for a given session UUID, or null if not found.
|
|
96
|
+
*
|
|
97
|
+
* Format (verified against gemini-cli source — packages/cli/src/utils/sessions.ts):
|
|
98
|
+
* Available sessions for this project (3):
|
|
99
|
+
*
|
|
100
|
+
* 1. Some Title (5 minutes ago) [a1b2c3d4-...]
|
|
101
|
+
* 2. Other Title (1 hour ago, current) [e5f6g7h8-...]
|
|
102
|
+
* 3. Third One (yesterday) [9i0j1k2l-...]
|
|
103
|
+
*
|
|
104
|
+
* Indices are 1-based and stable within one run (sort-by-startTime); the UUID
|
|
105
|
+
* is the durable handle. We exported as a pure function so tests don't need
|
|
106
|
+
* the real gemini binary or a real session store.
|
|
107
|
+
*/
|
|
108
|
+
export function parseGeminiSessionIndexForUuid(listOutput, uuid) {
|
|
109
|
+
if (!uuid)
|
|
110
|
+
return null;
|
|
111
|
+
// Match ` N. <title> ... [<uuid>]` — UUID is always the last bracketed
|
|
112
|
+
// segment on the line. Title can contain anything (including bracketed
|
|
113
|
+
// text), so we match `[uuid]` at end-of-line, not the first `[...]`.
|
|
114
|
+
const lineRegex = /^\s*(\d+)\.\s.*\[([0-9a-fA-F-]{8,})\]\s*$/;
|
|
115
|
+
for (const raw of listOutput.split('\n')) {
|
|
116
|
+
const line = raw.replace(/\r$/, '');
|
|
117
|
+
const m = line.match(lineRegex);
|
|
118
|
+
if (!m)
|
|
119
|
+
continue;
|
|
120
|
+
const idx = Number.parseInt(m[1], 10);
|
|
121
|
+
if (!Number.isFinite(idx) || idx <= 0)
|
|
122
|
+
continue;
|
|
123
|
+
if (m[2] === uuid)
|
|
124
|
+
return idx;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
90
128
|
export function spawnGemini(prompt, options = {}) {
|
|
91
129
|
const sessionId = randomUUID();
|
|
92
130
|
const startedAt = Date.now();
|
|
@@ -137,6 +175,27 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
137
175
|
if (typeof t === 'string')
|
|
138
176
|
textAccum += t + '\n';
|
|
139
177
|
}
|
|
178
|
+
// #110 slice 4 — mirror gemini tool calls to the audit log. Same
|
|
179
|
+
// shape Codex uses (post-hoc, no block). gemini emits tool_use
|
|
180
|
+
// BEFORE the call lands; mirror at that point so the audit row
|
|
181
|
+
// appears around when the action happens.
|
|
182
|
+
if (event.type === 'tool_use') {
|
|
183
|
+
const data = event.data;
|
|
184
|
+
const toolName = String(data?.tool_name ?? 'tool');
|
|
185
|
+
const params = data?.parameters ?? {};
|
|
186
|
+
const input = (typeof params.command === 'string' && params.command) ||
|
|
187
|
+
(typeof params.file_path === 'string' && params.file_path) ||
|
|
188
|
+
(typeof params.path === 'string' && params.path) ||
|
|
189
|
+
(typeof params.query === 'string' && params.query) ||
|
|
190
|
+
JSON.stringify(params).slice(0, 500);
|
|
191
|
+
mirrorToolCallToAudit({
|
|
192
|
+
sessionId,
|
|
193
|
+
agent: 'gemini',
|
|
194
|
+
toolName,
|
|
195
|
+
toolInput: String(input),
|
|
196
|
+
cwd,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
140
199
|
// Token usage comes ONLY in the final `result` event with status:'success',
|
|
141
200
|
// shape { stats: { input_tokens, output_tokens, total_tokens, cached } }.
|
|
142
201
|
if (event.type === 'result') {
|
|
@@ -234,6 +293,11 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
234
293
|
};
|
|
235
294
|
}
|
|
236
295
|
let stdoutBuf = '';
|
|
296
|
+
// Captured from the gemini-cli `init` event so appendMessage can resolve
|
|
297
|
+
// it to a session-list index (gemini's --resume only takes index or
|
|
298
|
+
// 'latest', NOT the UUID itself). See parseGeminiSessionIndexForUuid +
|
|
299
|
+
// the appendMessage block below.
|
|
300
|
+
let geminiSessionUuid = null;
|
|
237
301
|
child.stdout?.on('data', (chunk) => {
|
|
238
302
|
stdoutBuf += chunk.toString('utf-8');
|
|
239
303
|
let idx;
|
|
@@ -244,6 +308,9 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
244
308
|
continue;
|
|
245
309
|
try {
|
|
246
310
|
const data = JSON.parse(line);
|
|
311
|
+
if (data.type === 'init' && typeof data.session_id === 'string') {
|
|
312
|
+
geminiSessionUuid = data.session_id;
|
|
313
|
+
}
|
|
247
314
|
emit({ type: data.type ?? 'gemini_event', timestamp: Date.now(), data });
|
|
248
315
|
}
|
|
249
316
|
catch {
|
|
@@ -356,24 +423,58 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
356
423
|
},
|
|
357
424
|
canAppendMessage: true,
|
|
358
425
|
/**
|
|
359
|
-
* v0.6.
|
|
360
|
-
*
|
|
361
|
-
*
|
|
362
|
-
*
|
|
363
|
-
* race condition (indices shift as sessions are added/deleted).
|
|
426
|
+
* v0.6.x — UUID→index resolution (replaces the prior --resume latest
|
|
427
|
+
* shortcut). Gemini's --resume flag accepts a numeric INDEX (per-cwd
|
|
428
|
+
* ordinal of past sessions) or the literal 'latest' — NOT the session_id
|
|
429
|
+
* UUID that the init JSONL event emits.
|
|
364
430
|
*
|
|
365
|
-
*
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
431
|
+
* Strategy: capture session_id on spawn (see geminiSessionUuid above) →
|
|
432
|
+
* here, run `gemini --list-sessions` (plain text, no --json flag), parse
|
|
433
|
+
* each line for `[uuid]` at end of line, find the index that matches our
|
|
434
|
+
* captured UUID, and use it in --resume <N>. If anything fails (binary
|
|
435
|
+
* missing, list times out, UUID not found), fall back to --resume latest
|
|
436
|
+
* — same posture as before for safety, but with a chance to nail the
|
|
437
|
+
* exact session in the common case.
|
|
438
|
+
*
|
|
439
|
+
* Concurrency win: if a sibling session is spawned in the same cwd between
|
|
440
|
+
* spawn and reply, we no longer route the reply to the wrong one.
|
|
370
441
|
*/
|
|
371
|
-
appendMessage(text) {
|
|
442
|
+
async appendMessage(text) {
|
|
372
443
|
const startedAt = Date.now();
|
|
444
|
+
// Resolve UUID→index ASYNCHRONOUSLY before spawning the followup. Must
|
|
445
|
+
// be async (not execFileSync) because this runs in the daemon's HTTP
|
|
446
|
+
// request path; a sync call would block the event loop and freeze every
|
|
447
|
+
// other in-flight session for up to the timeout.
|
|
448
|
+
let resumeArg = 'latest';
|
|
449
|
+
if (geminiSessionUuid) {
|
|
450
|
+
try {
|
|
451
|
+
// gemini-cli's --list-sessions runs an internal generateSummary()
|
|
452
|
+
// first, which itself has a 5000ms LLM-call abort timeout (see
|
|
453
|
+
// sessionSummaryService.ts DEFAULT_TIMEOUT_MS in upstream). Our
|
|
454
|
+
// wrapper timeout MUST be comfortably greater than that or we kill
|
|
455
|
+
// the child mid-summary, get empty stdout, and silently fall back
|
|
456
|
+
// to --resume latest — which would defeat the whole UUID→index fix.
|
|
457
|
+
// 15s = 5s upstream LLM cap + parse/flush/spawn overhead + slack.
|
|
458
|
+
// Override via CODEHERE_GEMINI_LIST_TIMEOUT_MS for slow boxes.
|
|
459
|
+
const timeoutMs = Number(process.env.CODEHERE_GEMINI_LIST_TIMEOUT_MS) || 15000;
|
|
460
|
+
const { stdout: listOut } = await execFileAsync('gemini', ['--list-sessions'], {
|
|
461
|
+
cwd,
|
|
462
|
+
env: spawnEnv,
|
|
463
|
+
encoding: 'utf-8',
|
|
464
|
+
timeout: timeoutMs,
|
|
465
|
+
});
|
|
466
|
+
const idx = parseGeminiSessionIndexForUuid(listOut, geminiSessionUuid);
|
|
467
|
+
if (idx !== null)
|
|
468
|
+
resumeArg = String(idx);
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
// Binary missing, slow auth, no sessions yet — fall through to latest.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
373
474
|
return new Promise((resolve, reject) => {
|
|
374
475
|
let stdout = '';
|
|
375
476
|
let stderr = '';
|
|
376
|
-
const followup = spawn('gemini', ['-p', text, '--resume',
|
|
477
|
+
const followup = spawn('gemini', ['-p', text, '--resume', resumeArg, ...HEADLESS_FLAGS], {
|
|
377
478
|
cwd,
|
|
378
479
|
env: spawnEnv,
|
|
379
480
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -387,7 +488,7 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
387
488
|
});
|
|
388
489
|
followup.on('exit', (code) => {
|
|
389
490
|
if (code !== 0) {
|
|
390
|
-
reject(new Error(`gemini --resume
|
|
491
|
+
reject(new Error(`gemini --resume ${resumeArg} exited ${code}: ${stderr.slice(0, 300)}`));
|
|
391
492
|
return;
|
|
392
493
|
}
|
|
393
494
|
// Parse stream-json — extract assistant message text from 'message'
|
|
@@ -417,6 +518,7 @@ export function spawnGemini(prompt, options = {}) {
|
|
|
417
518
|
export const GeminiAdapter = {
|
|
418
519
|
id: 'gemini',
|
|
419
520
|
label: 'Gemini',
|
|
521
|
+
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.',
|
|
420
522
|
// Match `gemini`, `node /path/.../gemini`, and `./gemini` invocations.
|
|
421
523
|
processPattern: /(?:^|\/)gemini(?:\s|$)|node\s+\S*gemini/,
|
|
422
524
|
isInstalled() {
|