@hover-dev/core 0.2.1 → 0.2.3

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 CHANGED
@@ -43,7 +43,7 @@ pnpm smoke http://localhost:5173/ "log in then add a todo named 'verify hover'"
43
43
  Environment variables:
44
44
 
45
45
  - `HOVER_CDP` — CDP URL (default `http://localhost:9222`)
46
- - `HOVER_AGENT` — agent id (default `claude`)
46
+ - `HOVER_AGENT` — agent id (omit to auto-detect; tries the user's stated preference, then the first installed agent in registry order — `claude` → `codex` today)
47
47
  - `HOVER_MODEL` — model for the agent (default `sonnet`, much cheaper than opus)
48
48
 
49
49
  ## Sandboxing (what the smoke test enforces)
@@ -1 +1 @@
1
- {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/agents/claude.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,MAAM,YAAY,CAAC;AA2E9E,eAAO,MAAM,WAAW,EAAE,eA4GzB,CAAC"}
1
+ {"version":3,"file":"claude.d.ts","sourceRoot":"","sources":["../../src/agents/claude.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA2C,MAAM,YAAY,CAAC;AAsF3F,eAAO,MAAM,WAAW,EAAE,eAoHzB,CAAC"}
@@ -30,20 +30,27 @@ function estimateCostUsd(modelHint, usage) {
30
30
  (usage.cache_creation_input_tokens ?? 0) * tier.cacheCreate +
31
31
  (usage.cache_read_input_tokens ?? 0) * tier.cacheRead) / 1_000_000;
32
32
  }
33
- /**
34
- * Per-session running totals. Reset on every `system/init` event (one per
35
- * agent invocation). Safe as module-level state because service.ts enforces
36
- * one in-flight invocation per Hover service via its `busy` lock, and each
37
- * Vite dev server spawns its own Node process with its own module instance.
38
- */
39
- let runningCost = 0;
40
- let runningTurns = 0;
41
- let runningModel;
33
+ function claudeState(state) {
34
+ // First touch on this state object seed the keys we read below.
35
+ if (typeof state.runningCost !== 'number') {
36
+ state.runningCost = 0;
37
+ state.runningTurns = 0;
38
+ state.runningModel = undefined;
39
+ }
40
+ return state;
41
+ }
42
42
  export const claudeAgent = {
43
43
  id: 'claude',
44
44
  binName: 'claude',
45
45
  protocol: 'argv',
46
46
  streamFormat: 'stream-json',
47
+ sandboxStrength: 'hard',
48
+ display: {
49
+ label: 'Claude Code',
50
+ tagline: 'Anthropic — best-in-class browser driving, hard tool sandbox',
51
+ homepage: 'https://docs.claude.com/claude-code',
52
+ installHint: 'npm install -g @anthropic-ai/claude-code',
53
+ },
47
54
  buildArgs(opts) {
48
55
  const args = ['-p', opts.prompt];
49
56
  args.push('--output-format', 'stream-json', '--verbose');
@@ -71,7 +78,7 @@ export const claudeAgent = {
71
78
  }
72
79
  return args;
73
80
  },
74
- parseEvent(line) {
81
+ parseEvent(line, state = {}) {
75
82
  if (!line.trim())
76
83
  return [];
77
84
  let ev;
@@ -81,12 +88,13 @@ export const claudeAgent = {
81
88
  catch {
82
89
  return [{ kind: 'raw', line }];
83
90
  }
91
+ const s = claudeState(state);
84
92
  const out = [];
85
93
  if (ev.type === 'system' && ev.subtype === 'init') {
86
94
  // Fresh session — reset the cost/turn accumulator.
87
- runningCost = 0;
88
- runningTurns = 0;
89
- runningModel = ev.model;
95
+ s.runningCost = 0;
96
+ s.runningTurns = 0;
97
+ s.runningModel = ev.model;
90
98
  if (ev.session_id) {
91
99
  out.push({ kind: 'session_start', sessionId: ev.session_id, model: ev.model });
92
100
  }
@@ -96,18 +104,18 @@ export const claudeAgent = {
96
104
  return out;
97
105
  }
98
106
  if (ev.type === 'assistant') {
99
- runningTurns += 1;
107
+ s.runningTurns += 1;
100
108
  // Claude Code sometimes carries `total_cost_usd` on intermediate events;
101
109
  // when present it's authoritative (server-computed, includes anything
102
110
  // we'd miss). When absent, estimate from this turn's usage so the widget
103
111
  // still shows a growing $ counter on long runs.
104
112
  if (typeof ev.total_cost_usd === 'number') {
105
- runningCost = ev.total_cost_usd;
113
+ s.runningCost = ev.total_cost_usd;
106
114
  }
107
115
  else if (ev.message?.usage) {
108
- runningCost += estimateCostUsd(runningModel ?? ev.message.model, ev.message.usage);
116
+ s.runningCost += estimateCostUsd(s.runningModel ?? ev.message.model, ev.message.usage);
109
117
  }
110
- out.push({ kind: 'usage', costUsd: runningCost, turns: runningTurns });
118
+ out.push({ kind: 'usage', costUsd: s.runningCost, turns: s.runningTurns });
111
119
  for (const block of ev.message?.content ?? []) {
112
120
  if (block.type === 'tool_use') {
113
121
  const name = block.name ?? '';
@@ -0,0 +1,19 @@
1
+ import type { AgentDescriptor, ParserState } from './types.js';
2
+ export declare const codexAgent: AgentDescriptor;
3
+ /**
4
+ * Test-only escape hatches. Tests pass a state object in and get the
5
+ * accumulated counters back — same shape as the parser sees during a real
6
+ * invocation, just driven by the test instead of by invokeAgent.
7
+ */
8
+ export declare const __testing: {
9
+ freshState: () => ParserState;
10
+ resetCounters: (state: ParserState) => void;
11
+ getState: (state: ParserState) => {
12
+ runningCost: number;
13
+ runningTurns: number;
14
+ runningSessionId: string | undefined;
15
+ lastAgentMessage: string | undefined;
16
+ sawErrorEvent: boolean;
17
+ };
18
+ };
19
+ //# sourceMappingURL=codex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"codex.d.ts","sourceRoot":"","sources":["../../src/agents/codex.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAA8B,WAAW,EAAE,MAAM,YAAY,CAAC;AAmK3F,eAAO,MAAM,UAAU,EAAE,eAyJxB,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,SAAS;sBACJ,WAAW;2BACJ,WAAW;sBAChB,WAAW;;;;;;;CAU9B,CAAC"}
@@ -0,0 +1,210 @@
1
+ /**
2
+ * Pricing per million tokens. Keep in lockstep with claude.ts's table —
3
+ * approximate published OpenAI rates as of 2026. We are deliberately
4
+ * conservative (no cache-hit discount); cost shown to the user is therefore
5
+ * a high-water estimate, which is the right error direction for a "should
6
+ * I hit Stop now" UI signal.
7
+ */
8
+ const PRICE_PER_M_USD = {
9
+ // gpt-5.5 / gpt-5.4 / gpt-5 — public per-million pricing is similar to
10
+ // claude opus; tune empirically when OpenAI publishes a stable price table
11
+ // for the Codex tier specifically.
12
+ 'gpt-5.5': { in: 5, out: 25 },
13
+ 'gpt-5.4': { in: 5, out: 25 },
14
+ 'gpt-5': { in: 5, out: 25 },
15
+ // gpt-4.x kept for users on legacy --model
16
+ 'gpt-4o': { in: 2.5, out: 10 },
17
+ 'gpt-4': { in: 30, out: 60 },
18
+ };
19
+ function estimateCostUsd(modelHint, usage) {
20
+ const m = (modelHint ?? 'gpt-5.5').toLowerCase();
21
+ // Match by longest-prefix so 'gpt-5.5-mini' picks up the 'gpt-5.5' tier.
22
+ const tier = Object.entries(PRICE_PER_M_USD).find(([key]) => m.startsWith(key))?.[1] ??
23
+ PRICE_PER_M_USD['gpt-5.5'];
24
+ return ((usage.input_tokens ?? 0) * tier.in +
25
+ (usage.output_tokens ?? 0) * tier.out) / 1_000_000;
26
+ }
27
+ function codexState(state) {
28
+ if (typeof state.runningCost !== 'number') {
29
+ state.runningCost = 0;
30
+ state.runningTurns = 0;
31
+ state.runningModel = undefined;
32
+ state.runningSessionId = undefined;
33
+ state.lastAgentMessage = undefined;
34
+ state.sawErrorEvent = false;
35
+ state.itemTypeById = new Map();
36
+ }
37
+ return state;
38
+ }
39
+ function resetCodexCounters(s) {
40
+ s.runningCost = 0;
41
+ s.runningTurns = 0;
42
+ s.runningModel = undefined;
43
+ s.runningSessionId = undefined;
44
+ s.lastAgentMessage = undefined;
45
+ s.sawErrorEvent = false;
46
+ s.itemTypeById.clear();
47
+ }
48
+ /** Cap surfaced as a constraint in the system prompt — codex has no CLI flag. */
49
+ const CODEX_DEVELOPER_INSTRUCTIONS = [
50
+ 'You are operating in Hover, a browser-testing tool.',
51
+ 'Use ONLY the MCP playwright tools (prefixed `mcp__playwright__` / `mcp__hover-playwright__`) to drive the browser.',
52
+ 'Do NOT call shell, file-edit, web-search, or any other built-in tool.',
53
+ 'Do NOT navigate to a URL the user is already on; check the page state via `browser_snapshot` first.',
54
+ 'When the task is complete, emit a short agent_message summary and stop.',
55
+ ].join(' ');
56
+ export const codexAgent = {
57
+ id: 'codex',
58
+ binName: 'codex',
59
+ protocol: 'argv',
60
+ streamFormat: 'json-lines',
61
+ sandboxStrength: 'soft',
62
+ display: {
63
+ label: 'OpenAI Codex',
64
+ tagline: 'OpenAI — soft sandbox (no built-in tool deny-list)',
65
+ homepage: 'https://developers.openai.com/codex',
66
+ installHint: 'npm install -g @openai/codex',
67
+ },
68
+ buildArgs(opts) {
69
+ const args = ['exec'];
70
+ // Resume must come BEFORE the prompt positional. `codex exec resume <id>
71
+ // [prompt]` is the documented shape.
72
+ if (opts.sessionId) {
73
+ args.push('resume', opts.sessionId);
74
+ }
75
+ args.push(opts.prompt);
76
+ // JSONL streaming output.
77
+ args.push('--json');
78
+ // Never prompt for approval in headless mode.
79
+ args.push('--ask-for-approval', 'never');
80
+ // Soft sandbox: prevent shell side-effects on disk / network even when
81
+ // the agent tries to call its built-in shell. read-only is the strictest
82
+ // documented level.
83
+ args.push('--sandbox', 'read-only');
84
+ if (opts.model) {
85
+ args.push('--model', opts.model);
86
+ }
87
+ // System-prompt injection. Codex has no --append-system-prompt; we route
88
+ // through `-c developer_instructions='...'`. Concatenate the standing
89
+ // Hover-mode instructions with whatever the caller passes (e.g. "user is
90
+ // already on http://localhost:5173/").
91
+ const sysPrompt = opts.appendSystemPrompt && opts.appendSystemPrompt.trim().length > 0
92
+ ? `${CODEX_DEVELOPER_INSTRUCTIONS} ${opts.appendSystemPrompt}`
93
+ : CODEX_DEVELOPER_INSTRUCTIONS;
94
+ args.push('-c', `developer_instructions=${JSON.stringify(sysPrompt)}`);
95
+ // MCP servers are configured in ~/.codex/config.toml at install time,
96
+ // not per-invocation. If the user passed an mcpConfig path, we don't
97
+ // have a way to forward it to codex — log a warning to stderr from the
98
+ // invoker so the user knows. (See invoke.ts wiring.)
99
+ // No equivalent for --max-budget-usd or --allowedTools.
100
+ return args;
101
+ },
102
+ parseEvent(line, state = {}) {
103
+ if (!line.trim())
104
+ return [];
105
+ let ev;
106
+ try {
107
+ ev = JSON.parse(line);
108
+ }
109
+ catch {
110
+ return [{ kind: 'raw', line }];
111
+ }
112
+ const s = codexState(state);
113
+ const out = [];
114
+ if (ev.type === 'thread.started') {
115
+ resetCodexCounters(s);
116
+ if (ev.thread_id) {
117
+ s.runningSessionId = ev.thread_id;
118
+ out.push({ kind: 'session_start', sessionId: ev.thread_id, model: ev.model });
119
+ }
120
+ return out;
121
+ }
122
+ if (ev.type === 'item.started' && ev.item) {
123
+ const it = ev.item;
124
+ if (it.id && it.type)
125
+ s.itemTypeById.set(it.id, it.type);
126
+ if (it.type === 'mcp_tool_call') {
127
+ // The exact field names aren't published. Read defensively: prefer
128
+ // `name`, fall back to `tool`. Same for input.
129
+ const rawName = it.name ?? it.tool ?? '';
130
+ const tool = rawName.replace(/^mcp__playwright__/, '').replace(/^mcp__hover-playwright__/, '');
131
+ out.push({ kind: 'tool_use', tool, input: it.input ?? it.arguments });
132
+ }
133
+ else if (it.type === 'command_execution') {
134
+ // We DISCOURAGED this in developer_instructions but the agent can
135
+ // still try. Surface it so the user sees it happen.
136
+ out.push({ kind: 'tool_use', tool: 'shell', input: { command: it.command } });
137
+ }
138
+ return out;
139
+ }
140
+ if (ev.type === 'item.completed' && ev.item) {
141
+ const it = ev.item;
142
+ const recordedType = (it.id && s.itemTypeById.get(it.id)) || it.type;
143
+ if (recordedType === 'agent_message') {
144
+ const text = it.text?.trim();
145
+ if (text) {
146
+ s.lastAgentMessage = text;
147
+ out.push({ kind: 'text', text });
148
+ }
149
+ }
150
+ else if (recordedType === 'mcp_tool_call' || recordedType === 'command_execution') {
151
+ const isError = it.is_error === true ||
152
+ (typeof it.status === 'string' && /error|fail/i.test(it.status));
153
+ out.push({ kind: 'tool_result', isError });
154
+ }
155
+ return out;
156
+ }
157
+ if (ev.type === 'turn.completed') {
158
+ s.runningTurns += 1;
159
+ if (ev.usage) {
160
+ s.runningCost += estimateCostUsd(s.runningModel, ev.usage);
161
+ }
162
+ out.push({ kind: 'usage', costUsd: s.runningCost, turns: s.runningTurns });
163
+ return out;
164
+ }
165
+ // Codex emits various error envelopes; we conservatively match anything
166
+ // whose `type` contains 'error' or carries a top-level message string.
167
+ if (ev.type && /error/i.test(ev.type)) {
168
+ s.sawErrorEvent = true;
169
+ if (ev.message) {
170
+ out.push({ kind: 'text', text: `[codex] ${ev.message}` });
171
+ }
172
+ return out;
173
+ }
174
+ return [];
175
+ },
176
+ /**
177
+ * Codex doesn't emit a `session_end` line — the child process simply
178
+ * exits after the final `turn.completed`. We synthesize the terminator
179
+ * here so the widget sees the same shape it sees from claude.
180
+ */
181
+ onStreamEnd(exitCode, state = {}) {
182
+ const s = codexState(state);
183
+ return {
184
+ kind: 'session_end',
185
+ turns: s.runningTurns,
186
+ costUsd: s.runningCost,
187
+ isError: s.sawErrorEvent || (exitCode != null && exitCode !== 0),
188
+ summary: s.lastAgentMessage,
189
+ };
190
+ },
191
+ };
192
+ /**
193
+ * Test-only escape hatches. Tests pass a state object in and get the
194
+ * accumulated counters back — same shape as the parser sees during a real
195
+ * invocation, just driven by the test instead of by invokeAgent.
196
+ */
197
+ export const __testing = {
198
+ freshState: () => ({}),
199
+ resetCounters: (state) => resetCodexCounters(codexState(state)),
200
+ getState: (state) => {
201
+ const s = codexState(state);
202
+ return {
203
+ runningCost: s.runningCost,
204
+ runningTurns: s.runningTurns,
205
+ runningSessionId: s.runningSessionId,
206
+ lastAgentMessage: s.lastAgentMessage,
207
+ sawErrorEvent: s.sawErrorEvent,
208
+ };
209
+ },
210
+ };
@@ -10,7 +10,33 @@ export interface DetectedAgent {
10
10
  binPath: string;
11
11
  }
12
12
  /**
13
- * Scan PATH for every agent in the registry. Returns only the ones found.
13
+ * Scan PATH for every agent in the registry. Returns only the ones found,
14
+ * in registry insertion order.
14
15
  */
15
16
  export declare function detectAgents(): Promise<DetectedAgent[]>;
17
+ export interface AgentAvailability {
18
+ id: string;
19
+ label: string;
20
+ tagline?: string;
21
+ sandboxStrength: 'hard' | 'soft';
22
+ installed: boolean;
23
+ binPath?: string;
24
+ homepage?: string;
25
+ installHint?: string;
26
+ }
27
+ /**
28
+ * Like `detectAgents`, but also includes registered-but-not-installed agents
29
+ * so the widget can render them dimmed with an install hint. Order matches
30
+ * the registry.
31
+ */
32
+ export declare function listAgentAvailability(): Promise<AgentAvailability[]>;
33
+ /**
34
+ * Pick the agent we should default to when the user / Vite plugin didn't
35
+ * specify one. Prefer the explicit hint if it's installed; otherwise the
36
+ * first registered agent that's installed; finally null if nothing matches.
37
+ *
38
+ * `preferredId` is typically `process.env.HOVER_AGENT` or the value the user
39
+ * picked in the widget last session (persisted by the widget to localStorage).
40
+ */
41
+ export declare function pickPrimaryAgent(preferredId?: string): Promise<DetectedAgent | null>;
16
42
  //# sourceMappingURL=detect.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../../src/agents/detect.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAIlD;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAS3E;AAED,wBAAsB,kBAAkB,CAAC,UAAU,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAE5F;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;GAEG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAO7D"}
1
+ {"version":3,"file":"detect.d.ts","sourceRoot":"","sources":["../../src/agents/detect.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAIlD;;;GAGG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAS3E;AAED,wBAAsB,kBAAkB,CAAC,UAAU,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAE5F;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,eAAe,CAAC;IAC5B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED;;;GAGG;AACH,wBAAsB,YAAY,IAAI,OAAO,CAAC,aAAa,EAAE,CAAC,CAO7D;AAED,MAAM,WAAW,iBAAiB;IAChC,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,EAAE,MAAM,GAAG,MAAM,CAAC;IACjC,SAAS,EAAE,OAAO,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,IAAI,OAAO,CAAC,iBAAiB,EAAE,CAAC,CAgB1E;AAED;;;;;;;GAOG;AACH,wBAAsB,gBAAgB,CAAC,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAU1F"}
@@ -1,6 +1,6 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
- import { AGENTS } from './registry.js';
3
+ import { AGENTS, listAgents } from './registry.js';
4
4
  const execFileAsync = promisify(execFile);
5
5
  /**
6
6
  * Find a binary on PATH. Returns absolute path or null.
@@ -21,14 +21,57 @@ export async function resolveBinForAgent(descriptor) {
21
21
  return resolveOnPath(descriptor.binName);
22
22
  }
23
23
  /**
24
- * Scan PATH for every agent in the registry. Returns only the ones found.
24
+ * Scan PATH for every agent in the registry. Returns only the ones found,
25
+ * in registry insertion order.
25
26
  */
26
27
  export async function detectAgents() {
27
28
  const detected = [];
28
- for (const descriptor of Object.values(AGENTS)) {
29
+ for (const descriptor of listAgents()) {
29
30
  const binPath = await resolveBinForAgent(descriptor);
30
31
  if (binPath)
31
32
  detected.push({ descriptor, binPath });
32
33
  }
33
34
  return detected;
34
35
  }
36
+ /**
37
+ * Like `detectAgents`, but also includes registered-but-not-installed agents
38
+ * so the widget can render them dimmed with an install hint. Order matches
39
+ * the registry.
40
+ */
41
+ export async function listAgentAvailability() {
42
+ const result = [];
43
+ for (const descriptor of listAgents()) {
44
+ const binPath = await resolveBinForAgent(descriptor);
45
+ result.push({
46
+ id: descriptor.id,
47
+ label: descriptor.display.label,
48
+ tagline: descriptor.display.tagline,
49
+ sandboxStrength: descriptor.sandboxStrength,
50
+ installed: binPath != null,
51
+ binPath: binPath ?? undefined,
52
+ homepage: descriptor.display.homepage,
53
+ installHint: descriptor.display.installHint,
54
+ });
55
+ }
56
+ return result;
57
+ }
58
+ /**
59
+ * Pick the agent we should default to when the user / Vite plugin didn't
60
+ * specify one. Prefer the explicit hint if it's installed; otherwise the
61
+ * first registered agent that's installed; finally null if nothing matches.
62
+ *
63
+ * `preferredId` is typically `process.env.HOVER_AGENT` or the value the user
64
+ * picked in the widget last session (persisted by the widget to localStorage).
65
+ */
66
+ export async function pickPrimaryAgent(preferredId) {
67
+ if (preferredId) {
68
+ const descriptor = AGENTS[preferredId];
69
+ if (descriptor) {
70
+ const binPath = await resolveBinForAgent(descriptor);
71
+ if (binPath)
72
+ return { descriptor, binPath };
73
+ }
74
+ }
75
+ const detected = await detectAgents();
76
+ return detected[0] ?? null;
77
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"invoke.d.ts","sourceRoot":"","sources":["../../src/agents/invoke.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE7D;;;;;;GAMG;AACH,wBAAuB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,aAAa,CAAC,WAAW,CAAC,CAwDlF"}
1
+ {"version":3,"file":"invoke.d.ts","sourceRoot":"","sources":["../../src/agents/invoke.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAe,MAAM,YAAY,CAAC;AAE1E;;;;;;GAMG;AACH,wBAAuB,WAAW,CAAC,IAAI,EAAE,aAAa,GAAG,aAAa,CAAC,WAAW,CAAC,CAuElF"}
@@ -50,9 +50,13 @@ export async function* invokeAgent(opts) {
50
50
  }
51
51
  const rl = createInterface({ input: child.stdout });
52
52
  const exitPromise = new Promise(res => child.on('exit', c => res(c ?? -1)));
53
+ // Fresh parser state per invocation. Threaded into both parseEvent and
54
+ // onStreamEnd so descriptors don't have to reach for module globals
55
+ // (which would smear across concurrent invocations).
56
+ const state = {};
53
57
  let sawSessionEnd = false;
54
58
  for await (const line of rl) {
55
- for (const ev of descriptor.parseEvent(line)) {
59
+ for (const ev of descriptor.parseEvent(line, state)) {
56
60
  if (ev.kind === 'session_end')
57
61
  sawSessionEnd = true;
58
62
  yield ev;
@@ -60,11 +64,21 @@ export async function* invokeAgent(opts) {
60
64
  }
61
65
  const code = await exitPromise;
62
66
  opts.signal?.removeEventListener('abort', onAbort);
63
- if (!sawSessionEnd && code !== 0 && !opts.signal?.aborted) {
64
- yield {
65
- kind: 'session_end',
66
- isError: true,
67
- summary: `agent exited with code ${code}`,
68
- };
67
+ if (!sawSessionEnd && !opts.signal?.aborted) {
68
+ // Give the descriptor a chance to synthesize its own terminator from
69
+ // accumulated state (codex does this — its stream never emits a
70
+ // session_end). Falls back to a generic error session_end if the
71
+ // descriptor declines and the child exited non-zero.
72
+ const synthetic = descriptor.onStreamEnd?.(code, state);
73
+ if (synthetic) {
74
+ yield synthetic;
75
+ }
76
+ else if (code !== 0) {
77
+ yield {
78
+ kind: 'session_end',
79
+ isError: true,
80
+ summary: `agent exited with code ${code}`,
81
+ };
82
+ }
69
83
  }
70
84
  }
@@ -1,12 +1,17 @@
1
1
  import type { AgentDescriptor } from './types.js';
2
2
  /**
3
- * Registry of agents Hover can drive. Currently only `claude` is fully wired.
3
+ * Registry of agents Hover can drive.
4
4
  *
5
- * To add support for another agent (e.g. codex, cursor-agent, aider, gemini,
6
- * cline, continue, qwen, kilo), implement its AgentDescriptor in its own
7
- * file and register it here. The rest of the system — detect, argv, invoke,
8
- * smoke — works without further changes.
5
+ * To add support for another agent (e.g. cursor-agent, aider, gemini, cline,
6
+ * continue, qwen, kilo), implement its AgentDescriptor in its own file and
7
+ * register it here. The rest of the system — detect, argv, invoke, service,
8
+ * widget — works without further changes.
9
+ *
10
+ * Insertion order is the order shown in the widget's agent dropdown, so put
11
+ * the recommended primary first.
9
12
  */
10
13
  export declare const AGENTS: Record<string, AgentDescriptor>;
11
14
  export declare function getAgent(id: string): AgentDescriptor | undefined;
15
+ /** Stable, insertion-ordered list of all registered agents. */
16
+ export declare function listAgents(): AgentDescriptor[];
12
17
  //# sourceMappingURL=registry.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/agents/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAGlD;;;;;;;GAOG;AACH,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAElD,CAAC;AAEF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEhE"}
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/agents/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,YAAY,CAAC;AAIlD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAGlD,CAAC;AAEF,wBAAgB,QAAQ,CAAC,EAAE,EAAE,MAAM,GAAG,eAAe,GAAG,SAAS,CAEhE;AAED,+DAA+D;AAC/D,wBAAgB,UAAU,IAAI,eAAe,EAAE,CAE9C"}
@@ -1,15 +1,24 @@
1
1
  import { claudeAgent } from './claude.js';
2
+ import { codexAgent } from './codex.js';
2
3
  /**
3
- * Registry of agents Hover can drive. Currently only `claude` is fully wired.
4
+ * Registry of agents Hover can drive.
4
5
  *
5
- * To add support for another agent (e.g. codex, cursor-agent, aider, gemini,
6
- * cline, continue, qwen, kilo), implement its AgentDescriptor in its own
7
- * file and register it here. The rest of the system — detect, argv, invoke,
8
- * smoke — works without further changes.
6
+ * To add support for another agent (e.g. cursor-agent, aider, gemini, cline,
7
+ * continue, qwen, kilo), implement its AgentDescriptor in its own file and
8
+ * register it here. The rest of the system — detect, argv, invoke, service,
9
+ * widget — works without further changes.
10
+ *
11
+ * Insertion order is the order shown in the widget's agent dropdown, so put
12
+ * the recommended primary first.
9
13
  */
10
14
  export const AGENTS = {
11
15
  [claudeAgent.id]: claudeAgent,
16
+ [codexAgent.id]: codexAgent,
12
17
  };
13
18
  export function getAgent(id) {
14
19
  return AGENTS[id];
15
20
  }
21
+ /** Stable, insertion-ordered list of all registered agents. */
22
+ export function listAgents() {
23
+ return Object.values(AGENTS);
24
+ }
@@ -77,12 +77,82 @@ export type InvokeEvent = {
77
77
  kind: 'raw';
78
78
  line: string;
79
79
  };
80
+ /**
81
+ * How tightly the agent's tool surface can be locked down per invocation.
82
+ *
83
+ * 'hard' — the agent CLI accepts a deny-list / allow-list that effectively
84
+ * removes built-in tools (shell, file edit, etc.) so the only
85
+ * callable surface is whatever MCP servers we configure. Claude
86
+ * Code's `--strict-mcp-config` + `--allowedTools mcp__playwright`
87
+ * + `--disallowedTools <every built-in>` is the canonical example.
88
+ *
89
+ * 'soft' — the agent CLI does not expose a way to disable its built-in
90
+ * tools (shell, fs). We can constrain side-effects via OS-level
91
+ * sandbox flags (e.g. codex's `--sandbox read-only`) and we lean
92
+ * on a strict `developer_instructions` system-prompt to nudge the
93
+ * agent toward MCP-only behavior, but a determined / hallucinating
94
+ * agent COULD still try a built-in shell call. The widget should
95
+ * mark this agent with a warning indicator.
96
+ */
97
+ export type SandboxStrength = 'hard' | 'soft';
98
+ /**
99
+ * Human-facing metadata for the widget's agent picker. None of these affect
100
+ * agent invocation — they only shape how the agent is presented in the UI.
101
+ */
102
+ export interface AgentDisplay {
103
+ /** Pretty name for the dropdown ("Claude Code", "OpenAI Codex"). */
104
+ label: string;
105
+ /** One-line tagline shown under the label. */
106
+ tagline?: string;
107
+ /** Vendor / source URL — clicking the agent name in the widget can open
108
+ * this in a new tab when the agent isn't installed. */
109
+ homepage?: string;
110
+ /** Shell command the user can run to install (copy-paste from a tooltip
111
+ * in the widget when the agent is listed but not on PATH). */
112
+ installHint?: string;
113
+ }
114
+ /**
115
+ * Per-invocation parser state. A fresh object is created by `invokeAgent`
116
+ * for each spawn and passed to both `parseEvent` and `onStreamEnd`.
117
+ *
118
+ * Descriptors that need to accumulate state across lines (cost, turn count,
119
+ * last agent message for synthesized session_end, etc.) read and write
120
+ * their own keys on this object. There is no shared shape — each agent
121
+ * uses whatever fields it needs.
122
+ *
123
+ * Why: module-level state in claude.ts / codex.ts worked only because the
124
+ * service enforces one in-flight invocation per Node process. Two concurrent
125
+ * agent runs (future: tests in parallel, in-process workers) would silently
126
+ * smear their cost accumulators together. Threading the state object per
127
+ * invocation removes that hazard at zero runtime cost.
128
+ */
129
+ export type ParserState = Record<string, unknown>;
80
130
  export interface AgentDescriptor {
81
131
  id: string;
82
132
  binName: string;
83
133
  protocol: AgentProtocol;
84
134
  streamFormat: StreamFormat;
135
+ sandboxStrength: SandboxStrength;
136
+ display: AgentDisplay;
85
137
  buildArgs(opts: InvokeOptions): string[];
86
- parseEvent(line: string): InvokeEvent[];
138
+ /**
139
+ * Parse a single line of agent stdout into normalised InvokeEvents.
140
+ * `state` is a per-invocation scratch pad (see ParserState). Optional
141
+ * for callers that don't accumulate across lines (and for unit tests
142
+ * that don't care about cost / turn carry-over) — descriptors that
143
+ * DO accumulate must check / initialise the state object themselves.
144
+ */
145
+ parseEvent(line: string, state?: ParserState): InvokeEvent[];
146
+ /**
147
+ * Optional. Called once after the agent's stream closes, with the child's
148
+ * exit code (or null if it was aborted). Lets agents whose protocol does
149
+ * NOT emit an explicit session-terminating event synthesize one from
150
+ * accumulated parser state. Returns `null` if the agent's own `parseEvent`
151
+ * already emitted a `session_end` and nothing further is needed.
152
+ *
153
+ * Used by codex.ts (no native session_end). Claude does not implement
154
+ * this — `result` events terminate naturally.
155
+ */
156
+ onStreamEnd?(exitCode: number | null, state?: ParserState): InvokeEvent | null;
87
157
  }
88
158
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/agents/types.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,MAAM,aAAa,GACrB,MAAM,GACN,OAAO,GACP,KAAK,GACL,QAAQ,CAAC;AAEb,MAAM,MAAM,YAAY,GACpB,aAAa,GACb,KAAK,GACL,YAAY,GACZ,YAAY,CAAC;AAEjB,qBAAa,6BAA8B,SAAQ,KAAK;gBAC1C,OAAO,EAAE,MAAM;CAI5B;AAED,qBAAa,sBAAuB,SAAQ,KAAK;aACnB,OAAO,EAAE,MAAM;gBAAf,OAAO,EAAE,MAAM;CAI5C;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IACxB,eAAe,CAAC,EAAE,MAAM,EAAE,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;yCAGqC;IACrC,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B;6EACyE;IACzE,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED;;;GAGG;AACH,MAAM,MAAM,WAAW,GACnB;IAAE,IAAI,EAAE,eAAe,CAAC;IAAC,SAAS,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5D;IAAE,IAAI,EAAE,YAAY,CAAC;IAAC,MAAM,EAAE,MAAM,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,GACtD;IAAE,IAAI,EAAE,UAAU,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,OAAO,CAAA;CAAE,GAClD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAC5D;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAChC;;;qEAGqE;GACnE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACnD;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAAC,OAAO,CAAC,EAAE,OAAO,CAAC;IAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAAE,GAC9F;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC;AAElC,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,YAAY,EAAE,YAAY,CAAC;IAC3B,SAAS,CAAC,IAAI,EAAE,aAAa,GAAG,MAAM,EAAE,CAAC;IACzC,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,EAAE,CAAC;CACzC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../../src/agents/types.ts"],"names":[],"mappings":"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"}