hive-am 1.9.1 → 1.10.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.
Files changed (80) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +12 -10
  3. package/package.json +2 -2
  4. package/server/dist/connections/vision.js +2 -1
  5. package/server/dist/efforts.js +5 -1
  6. package/server/dist/history/cursor.js +91 -0
  7. package/server/dist/history/index.js +2 -0
  8. package/server/dist/http/routes/types.js +3 -1
  9. package/server/dist/instructions.js +2 -2
  10. package/server/dist/mcp-config.js +10 -0
  11. package/server/dist/models.js +10 -0
  12. package/server/dist/providers/cursor.js +197 -0
  13. package/server/dist/providers/index.js +3 -0
  14. package/server/dist/providers/spawn.js +5 -2
  15. package/server/dist/services/agents.js +5 -3
  16. package/server/dist/services/providers.js +3 -0
  17. package/web/.next/BUILD_ID +1 -1
  18. package/web/.next/app-build-manifest.json +47 -47
  19. package/web/.next/app-path-routes-manifest.json +3 -3
  20. package/web/.next/build-manifest.json +2 -2
  21. package/web/.next/prerender-manifest.json +38 -38
  22. package/web/.next/required-server-files.json +1 -1
  23. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  24. package/web/.next/server/app/_not-found.html +1 -1
  25. package/web/.next/server/app/_not-found.rsc +4 -4
  26. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  27. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  28. package/web/.next/server/app/agents.html +1 -1
  29. package/web/.next/server/app/agents.rsc +6 -6
  30. package/web/.next/server/app/chat/page_client-reference-manifest.js +1 -1
  31. package/web/.next/server/app/chat.html +1 -1
  32. package/web/.next/server/app/chat.rsc +5 -5
  33. package/web/.next/server/app/colony/page_client-reference-manifest.js +1 -1
  34. package/web/.next/server/app/colony.html +1 -1
  35. package/web/.next/server/app/colony.rsc +5 -5
  36. package/web/.next/server/app/connections/page.js +1 -1
  37. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  38. package/web/.next/server/app/connections.html +1 -1
  39. package/web/.next/server/app/connections.rsc +5 -5
  40. package/web/.next/server/app/index.html +1 -1
  41. package/web/.next/server/app/index.rsc +5 -5
  42. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  44. package/web/.next/server/app/relations.html +1 -1
  45. package/web/.next/server/app/relations.rsc +5 -5
  46. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -1
  47. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -1
  48. package/web/.next/server/app/resources.html +1 -1
  49. package/web/.next/server/app/resources.rsc +5 -5
  50. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -1
  51. package/web/.next/server/app/schedules.html +1 -1
  52. package/web/.next/server/app/schedules.rsc +5 -5
  53. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  54. package/web/.next/server/app/sessions.html +1 -1
  55. package/web/.next/server/app/sessions.rsc +5 -5
  56. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  57. package/web/.next/server/app/skills.html +1 -1
  58. package/web/.next/server/app/skills.rsc +5 -5
  59. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  60. package/web/.next/server/app/types.html +1 -1
  61. package/web/.next/server/app/types.rsc +5 -5
  62. package/web/.next/server/app-paths-manifest.json +3 -3
  63. package/web/.next/server/chunks/143.js +1 -1
  64. package/web/.next/server/chunks/953.js +2 -2
  65. package/web/.next/server/pages/404.html +1 -1
  66. package/web/.next/server/pages/500.html +1 -1
  67. package/web/.next/server/server-reference-manifest.json +1 -1
  68. package/web/.next/static/chunks/1517-5db81b0d2ac8f7f5.js +1 -0
  69. package/web/.next/static/chunks/969-07e498da015b149a.js +1 -0
  70. package/web/.next/static/chunks/9977-78b955d85f795a21.js +1 -0
  71. package/web/.next/static/chunks/app/connections/{page-2278b79b13b7fea8.js → page-0e4f9f272da95c83.js} +1 -1
  72. package/web/.next/static/chunks/app/{layout-68d8a864d355a789.js → layout-068c1695366993c1.js} +1 -1
  73. package/web/.next/static/css/1afa1d0e0fdc435f.css +1 -0
  74. package/web/.next/trace +31 -31
  75. package/web/.next/static/chunks/1517-01e286ff9d1d8ebe.js +0 -1
  76. package/web/.next/static/chunks/969-ad2583a1ff30fdf0.js +0 -1
  77. package/web/.next/static/chunks/9977-d80535fa4b24e377.js +0 -1
  78. package/web/.next/static/css/821e341e319fae9c.css +0 -1
  79. /package/web/.next/static/{2RnNXnGqTqztT7YR1JZRD → qv9RAZqD8Of6bH1cw6RzA}/_buildManifest.js +0 -0
  80. /package/web/.next/static/{2RnNXnGqTqztT7YR1JZRD → qv9RAZqD8Of6bH1cw6RzA}/_ssgManifest.js +0 -0
package/CHANGELOG.md CHANGED
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [1.10.0] - 2026-10-22
10
+
11
+ ### Added
12
+
13
+ - **Cursor as a fourth provider.** Pick **Cursor** (`cursor-agent`) for an agent, chat with it and see its text, thinking and tool calls live; the conversation continues between messages (`--resume`) and after a restart, and is read back from Cursor's own transcripts (text and tool calls; Cursor does not store tool output, thinking or usage, which are only shown live). Models come from `cursor-agent --list-models`, and a new Cursor agent starts on `auto`, the only model a free plan runs; Cursor has no effort selector because the level is part of the model id. Agents with hive tools get them over MCP under every permission: hive-am adds a `hive` entry to the folder's `.cursor/mcp.json`, keeps your other servers there and hides the file from `git status`. Permissions are Cursor's own rules in a config folder per agent: **Read-only** writes nothing and runs no shell, **Edit files** edits the project but never `.git` or keys and runs no shell, **Full access** runs everything. With Cursor, **Edit files** cannot stop the file tool from writing outside the agent's folder. It needs `cursor-agent` installed and signed in (`cursor-agent login`).
14
+
9
15
  ## [1.9.1] - 2026-10-21
10
16
 
11
17
  ### Changed
@@ -277,7 +283,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
277
283
  - Complete documentation in English and Spanish.
278
284
  - npm package with the `hive-am` command.
279
285
 
280
- [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.9.1...HEAD
286
+ [Unreleased]: https://github.com/hughcbscrt/hive-am/compare/v1.10.0...HEAD
287
+ [1.10.0]: https://github.com/hughcbscrt/hive-am/compare/v1.9.1...v1.10.0
281
288
  [1.9.1]: https://github.com/hughcbscrt/hive-am/compare/v1.9.0...v1.9.1
282
289
  [1.9.0]: https://github.com/hughcbscrt/hive-am/compare/v1.8.0...v1.9.0
283
290
  [1.8.0]: https://github.com/hughcbscrt/hive-am/compare/v1.7.0...v1.8.0
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  <p align="center">
8
8
  <strong>A colony of coding agents on your machine.</strong><br />
9
- Orchestrators and subagents over <b>Claude Code</b>, <b>OpenCode</b> and <b>Kiro</b>, with the real sessions of each CLI, a browser UI and Telegram.
9
+ Orchestrators and subagents over <b>Claude Code</b>, <b>OpenCode</b>, <b>Kiro</b> and <b>Cursor</b>, with the real sessions of each CLI, a browser UI and Telegram.
10
10
  </p>
11
11
 
12
12
  <p align="center">
@@ -26,7 +26,7 @@ have installed; it does not replace them and it never copies your conversations
26
26
 
27
27
  ## Highlights
28
28
 
29
- - **Three providers, one place.** Claude Code, OpenCode and Kiro agents side by side, each with its own model.
29
+ - **Four providers, one place.** Claude Code, OpenCode, Kiro and Cursor agents side by side, each with its own model.
30
30
  - **Real sessions.** An agent stores only a pointer (`provider`, `session_id`, `cwd`). The transcript lives in the CLI's own store and is read back from there. After a restart every agent resumes its real session.
31
31
  - **Orchestration.** An orchestrator delegates to the workers connected to it on the Relations canvas. Every delegation runs in a fresh native session of the worker, and the rule is enforced on the server, not just requested.
32
32
  - **Colonies.** Group agents on a honeycomb map and lend them defaults: working folder, permissions, skills and a shared prompt. Each agent can opt out per field.
@@ -35,7 +35,7 @@ have installed; it does not replace them and it never copies your conversations
35
35
  - **Telegram.** Link a bot to an agent: private chats, groups and topics, one shared session across all of them. In groups the agent can listen to everything and speak only when it adds something; people can mute it per thread and bring it back.
36
36
  - **Slack.** The same over Socket Mode (no public URL): DMs and channels, the agent answers in a thread opened on the message, scheduled messages land in the channel, `/hive` slash commands.
37
37
  - **Files and images.** People send documents and photos to the agent; the agent sends files back. An optional image model can describe pictures for models that cannot see them.
38
- - **Permissions that hold.** *Read-only*, *Edit files* and *Full access* are enforced by the CLI itself in all three providers.
38
+ - **Permissions that hold.** *Read-only*, *Edit files* and *Full access* are enforced by the CLI itself in all four providers. With Cursor they are `deny` rules, and *Edit files* cannot keep its file tool inside the agent's folder.
39
39
  - **Changes explorer.** A Git tab per agent: file tree with highlighting, diff viewer, history, commit, pull, push and branches. Comment on lines of the diff and send them to the agent in one message.
40
40
  - **Resources.** Servers, Docker containers, HTTP requests (`.http` files) and clusters that group them live in a colony as hexagons: start, stop, read logs, run requests; agents can look after them, and an agent with the Database management skill can look inside a database and run SQL, within its permission.
41
41
  - **Terminals.** A bottom panel (Ctrl+`) with real shells and object logs, reachable only from this machine.
@@ -68,12 +68,13 @@ More in the [user manual](docs/manual/en/README.md).
68
68
  | Claude Code | `claude -p --output-format stream-json --resume <id>` | `~/.claude/projects/*/<id>.jsonl` |
69
69
  | OpenCode | `opencode run --format json -s <id>` | `~/.local/share/opencode/opencode.db` (`session_message`) |
70
70
  | Kiro | `kiro-cli chat --no-interactive --output-format stream-json --resume-id <id>` | `~/.kiro/sessions/cli/<id>.jsonl` |
71
+ | Cursor | `cursor-agent -p --output-format stream-json --resume <id>` | `~/.cursor/projects/<folder>/agent-transcripts/<id>/<id>.jsonl` |
71
72
 
72
73
  Plain CLI processes, one per turn, not ACP. Turns are serialized per agent.
73
74
 
74
75
  ## Quick start
75
76
 
76
- You need **Node 20+** (macOS or Linux) and at least one of the three CLIs installed and logged in (`claude`, `opencode` or `kiro-cli`).
77
+ You need **Node 20+** (macOS or Linux) and at least one of the four CLIs installed and logged in (`claude`, `opencode`, `kiro-cli` or `cursor-agent`).
77
78
 
78
79
  ### From npm
79
80
 
@@ -108,13 +109,14 @@ Data lives in `~/.hive-am/hive-am.db` (override with `HIVE_AM_HOME`). The server
108
109
 
109
110
  ## Permission levels
110
111
 
111
- | Level | Claude Code | OpenCode | Kiro |
112
- |---|---|---|---|
113
- | **Read-only** | `--permission-mode plan` | edit, shell and subagents denied | no tool trusted |
114
- | **Edit files** | `acceptEdits`, `.git` and keys protected | edits (`.env` included), no shell, only inside the agent's folder | same |
115
- | **Full access** | `bypassPermissions` | no restrictions | `--trust-all-tools` |
112
+ | Level | Claude Code | OpenCode | Kiro | Cursor |
113
+ |---|---|---|---|---|
114
+ | **Read-only** | `--permission-mode plan` | edit, shell and subagents denied | no tool trusted | `--mode plan`, writes and shell denied |
115
+ | **Edit files** | `acceptEdits`, `.git` and keys protected | edits (`.env` included), no shell, only inside the agent's folder | same | edits (`.env` included), no shell, `.git` and keys denied |
116
+ | **Full access** | `bypassPermissions` | no restrictions | `--trust-all-tools` | `--force` |
116
117
 
117
- In *Edit files* the agent cannot touch `.git` or private keys (`*.pem`, `*.key`) and cannot write outside its folder. Details and the
118
+ In *Edit files* the agent cannot touch `.git` or private keys (`*.pem`, `*.key`) and cannot write outside its folder, except with
119
+ Cursor: its file tool can still write outside the folder, because Cursor has no rule for that. Details and the
118
120
  few differences between providers are in [docs/06](docs/reference/en/06-providers.md) and [docs/12](docs/reference/en/12-operations-and-troubleshooting.md).
119
121
 
120
122
  ## Connections (Telegram and Slack)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hive-am",
3
- "version": "1.9.1",
4
- "description": "A colony of coding agents: orchestrators and subagents over Claude Code, OpenCode and Kiro, with real session resume, a browser UI and Telegram.",
3
+ "version": "1.10.0",
4
+ "description": "A colony of coding agents: orchestrators and subagents over Claude Code, OpenCode, Kiro and Cursor, with real session resume, a browser UI and Telegram.",
5
5
  "keywords": [
6
6
  "ai-agents",
7
7
  "coding-agents",
@@ -1,9 +1,10 @@
1
1
  import { runners } from '../providers/index.js';
2
+ import { isProvider } from '../services/providers.js';
2
3
  import { inboxDir } from './files.js';
3
4
  const MAX_IMAGES = 3; // per message
4
5
  const MAX_IMAGE_BYTES = 8_000_000;
5
6
  const TIMEOUT_MS = 120_000;
6
- export const validVision = (v) => !!v && typeof v === 'object' && ['claude', 'opencode', 'kiro'].includes(v.provider) && typeof v.model === 'string';
7
+ export const validVision = (v) => !!v && typeof v === 'object' && isProvider(v.provider) && typeof v.model === 'string';
7
8
  async function describe(agent, cfg, path) {
8
9
  // A throwaway agent in the agent's own id (so its inbox is readable) and read-only: it can only look at the file.
9
10
  const helper = { ...agent, provider: cfg.provider, model: cfg.model, session_id: null, session_cwd: null, instr_hash: null, permission: 'plan', cwd: inboxDir(agent.id), system_prompt: '', skill_ids: [], skill_loads: {}, worker_ids: [] };
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { DATA_DIR } from './db.js';
4
4
  /**
5
- * Reasoning effort: how hard the model thinks in a turn. Claude Code and Kiro take `--effort <level>`; OpenCode takes it as a variant of the
5
+ * Reasoning effort: how hard the model thinks in a turn. Claude Code and Kiro take `--effort <level>`; Cursor takes none (the level is part of its model id); OpenCode takes it as a variant of the
6
6
  * model (`provider/model#variant`) and the variants are whatever the chosen model has, so what OpenCode and Kiro report is learned and stored
7
7
  * on disk (`reasoning-efforts.json`), next to what is learned about context windows. An empty effort means the CLI's own default: no flag and no `#variant`.
8
8
  */
@@ -50,6 +50,8 @@ export const learnedEfforts = (provider, model) => load()[key(provider, model)]
50
50
  /** The levels the form offers for a provider and (for OpenCode and Kiro) a model. */
51
51
  export function effortOptions(provider, model) {
52
52
  const shared = EFFORTS.map((id) => ({ id, level: id }));
53
+ if (provider === 'cursor')
54
+ return { options: [], known: true }; // the level is part of Cursor's model id
53
55
  if (provider === 'claude')
54
56
  return { options: shared, known: true };
55
57
  if (provider === 'kiro') {
@@ -67,6 +69,8 @@ export function checkEffort(provider, model, effort) {
67
69
  return null;
68
70
  if (typeof effort !== 'string')
69
71
  return 'effort must be text';
72
+ if (provider === 'cursor')
73
+ return 'Cursor takes no effort: pick a model whose id names the level (gpt-5.3-codex-high).';
70
74
  if (provider === 'opencode') {
71
75
  if (model.includes('#'))
72
76
  return 'This model id already names its variant: leave the effort empty.';
@@ -0,0 +1,91 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { stripInstructions } from '../providers/preamble.js';
5
+ import { jsonLines } from './lines.js';
6
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
+ const projectsDir = () => join(process.env.CURSOR_DATA_DIR?.trim() || join(homedir(), '.cursor'), 'projects');
8
+ /** How Cursor names a folder under `~/.cursor/projects`: every run of characters that are not letters or digits becomes one dash. */
9
+ export const cursorSlug = (cwd) => cwd.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
10
+ /** The transcript of a conversation: under the folder's own project, else under any project (the folder may have been renamed). */
11
+ export function cursorFile(cwd, sessionId) {
12
+ if (!UUID.test(sessionId))
13
+ return null;
14
+ const rel = join('agent-transcripts', sessionId, `${sessionId}.jsonl`);
15
+ const own = join(projectsDir(), cursorSlug(cwd), rel);
16
+ if (existsSync(own))
17
+ return own;
18
+ try {
19
+ for (const p of readdirSync(projectsDir())) {
20
+ const f = join(projectsDir(), p, rel);
21
+ if (existsSync(f))
22
+ return f;
23
+ }
24
+ }
25
+ catch { /* no projects yet */ }
26
+ return null;
27
+ }
28
+ /** Changes when the conversation changes (used to cache what is computed from it). */
29
+ export function cursorSig(cwd, sessionId) {
30
+ const f = cursorFile(cwd, sessionId);
31
+ if (!f)
32
+ return null;
33
+ try {
34
+ const st = statSync(f);
35
+ return `${st.mtimeMs}:${st.size}`;
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ /** What the person wrote: Cursor wraps it as `<timestamp>…</timestamp>\n<user_query>\n…\n</user_query>`, and our instructions block comes first inside. */
42
+ export function cursorUserText(text) {
43
+ const q = /<user_query>\n?([\s\S]*?)\n?<\/user_query>/.exec(text);
44
+ return stripInstructions((q ? q[1] : text.replace(/^<timestamp>[\s\S]*?<\/timestamp>\s*/, '')).trim()).trim();
45
+ }
46
+ /** A tool as hive-am shows it: a call to an MCP server (`CallDynamicTool`) is named like Claude's (`mcp__<server>__<tool>`). */
47
+ export function cursorTool(name, input) {
48
+ if (name === 'CallDynamicTool' && input?.namespace && input?.toolName)
49
+ return { name: `mcp__${input.namespace}__${input.toolName}`, input: input.arguments ?? {} };
50
+ return { name, input };
51
+ }
52
+ /**
53
+ * Cursor's transcript (`~/.cursor/projects/<folder>/agent-transcripts/<id>/<id>.jsonl`): user and assistant messages with their text and tool calls.
54
+ * It keeps no thinking, tool results, ids, usage or times: those are only seen while the turn runs. `SendToCall` repeats the reply's text, so it is left out.
55
+ */
56
+ export function readCursor(cwd, sessionId) {
57
+ const file = cursorFile(cwd, sessionId);
58
+ if (!file)
59
+ return [];
60
+ const out = [];
61
+ let n = 0;
62
+ for (const e of jsonLines(file)) {
63
+ const content = e.message?.content;
64
+ if (!Array.isArray(content))
65
+ continue;
66
+ const id = `${sessionId}:${n++}`;
67
+ if (e.role === 'user') {
68
+ const text = cursorUserText(content.filter((b) => b?.type === 'text').map((b) => b.text ?? '').join(''));
69
+ if (text)
70
+ out.push({ id, role: 'user', ts: null, blocks: [{ type: 'text', text }] });
71
+ }
72
+ else if (e.role === 'assistant') {
73
+ const blocks = [];
74
+ for (const b of content) {
75
+ if (b?.type === 'text' && b.text)
76
+ blocks.push({ type: 'text', text: b.text });
77
+ else if (b?.type === 'tool_use' && b.name !== 'SendToCall')
78
+ blocks.push({ type: 'tool', id: `${id}:${blocks.length}`, ...cursorTool(b.name ?? 'tool', b.input) });
79
+ }
80
+ if (!blocks.length)
81
+ continue;
82
+ // One reply arrives as several lines (one per step): they are one message, as in the live chat.
83
+ const prev = out[out.length - 1];
84
+ if (prev?.role === 'assistant')
85
+ prev.blocks.push(...blocks);
86
+ else
87
+ out.push({ id, role: 'assistant', ts: null, blocks });
88
+ }
89
+ }
90
+ return out;
91
+ }
@@ -1,10 +1,12 @@
1
1
  import { claudeSig, readClaude } from './claude.js';
2
2
  import { opencodeSig, readOpencode } from './opencode.js';
3
3
  import { kiroSig, readKiro } from './kiro.js';
4
+ import { cursorSig, readCursor } from './cursor.js';
4
5
  export const historyReaders = {
5
6
  claude: { read: readClaude, sig: claudeSig },
6
7
  opencode: { read: (_cwd, id) => readOpencode(id), sig: (_cwd, id) => opencodeSig(id) },
7
8
  kiro: { read: (_cwd, id) => readKiro(id), sig: (_cwd, id) => kiroSig(id) },
9
+ cursor: { read: readCursor, sig: cursorSig },
8
10
  };
9
11
  export function readHistory(a, sessionId = a.session_id) {
10
12
  if (!sessionId)
@@ -3,7 +3,7 @@ import { spawnFromType } from '../../services/agents.js';
3
3
  import { body } from '../body.js';
4
4
  import { bad, notFound } from '../errors.js';
5
5
  import { route } from '../router.js';
6
- import { isProvider } from '../../services/providers.js';
6
+ import { defaultModel, isProvider } from '../../services/providers.js';
7
7
  import { checkEffort } from '../../efforts.js';
8
8
  import { requireName, text, validateAgentInput } from '../validate.js';
9
9
  route('GET', '/api/types', () => types.list());
@@ -12,6 +12,8 @@ route('POST', '/api/types', async ({ req }) => {
12
12
  const name = requireName(p.name);
13
13
  if (!isProvider(p.provider))
14
14
  throw bad('Pick a provider');
15
+ if (p.model === undefined)
16
+ p.model = defaultModel(p.provider);
15
17
  const why = checkEffort(p.provider, text(p.model), p.effort);
16
18
  if (why)
17
19
  throw bad(why);
@@ -35,8 +35,8 @@ export function mcpCaps(a) {
35
35
  caps.push('skills');
36
36
  return caps;
37
37
  }
38
- /** How each CLI names the tools of the `hive` MCP server. OpenCode exposes them as `tools.<server>.<tool>`, Kiro as `@<server>/<tool>`. */
39
- const toolName = (provider, tool) => (provider === 'opencode' ? `tools.hive.${tool}` : provider === 'kiro' ? `@hive/${tool}` : `mcp__hive__${tool}`);
38
+ /** How each CLI names the tools of the `hive` MCP server. OpenCode exposes them as `tools.<server>.<tool>`, Kiro as `@<server>/<tool>`, Cursor as `<server>-<tool>`. */
39
+ const toolName = (provider, tool) => (provider === 'opencode' ? `tools.hive.${tool}` : provider === 'kiro' ? `@hive/${tool}` : provider === 'cursor' ? `hive-${tool}` : `mcp__hive__${tool}`);
40
40
  export function composeInstructions(a, delegated = false) {
41
41
  const parts = [];
42
42
  // Identity first: the underlying CLI has its own persona, but inside hive-am this agent has a name and a place.
@@ -21,3 +21,13 @@ export const kiroProfile = (agentId, name, caps, access = { trusted: [] }) => ({
21
21
  tools: ['*'], allowedTools: [...access.trusted, ...(caps.length ? ['@hive'] : [])], includeMcpJson: false,
22
22
  ...(access.toolsSettings ? { toolsSettings: access.toolsSettings } : {}),
23
23
  });
24
+ /**
25
+ * The `hive` server in a Cursor `mcp.json` (Cursor reads it from the project folder). Every agent in the folder shares the same entry: the values
26
+ * come from the process env (`${env:...}`), which each turn sets for its own agent.
27
+ */
28
+ export const cursorMcpServer = () => ({
29
+ command: process.execPath, args: [script],
30
+ env: { HIVE_AGENT_ID: '${env:HIVE_AGENT_ID}', HIVE_AM_API: '${env:HIVE_AM_API}', HIVE_CAPS: '${env:HIVE_CAPS}' },
31
+ });
32
+ /** The env a Cursor turn passes so the shared `hive` entry starts as this agent (no caps: the server offers no tools). */
33
+ export const cursorMcpEnv = (agentId, caps) => env(agentId, caps);
@@ -11,6 +11,11 @@ const CLAUDE = [
11
11
  { id: 'claude-sonnet-5-5', label: 'Sonnet 5.5' },
12
12
  { id: 'claude-haiku-4-5-20251001', label: 'Haiku 4.5' },
13
13
  ];
14
+ /** The models `cursor-agent --list-models` prints. */
15
+ export function parseCursorModels(stdout) {
16
+ return stdout.replace(/\x1b\[[0-9;]*m/g, '').split('\n').map((l) => /^([\w.\-\[\]=,]+) - (.+)$/.exec(l.trim())).filter((m) => !!m)
17
+ .map((m) => ({ id: m[1], label: m[2].replace(/\s*\((?:current|default)[^)]*\)\s*$/, '').replace(/[\u200b\u200c\u200d\ufeff]/g, '').trim() }));
18
+ }
14
19
  async function discover(p) {
15
20
  try {
16
21
  if (p === 'claude')
@@ -19,6 +24,11 @@ async function discover(p) {
19
24
  const { stdout } = await run('opencode', ['models'], { timeout: 20000, maxBuffer: 4e6 });
20
25
  return stdout.split('\n').map((s) => s.trim()).filter((s) => /^[\w.-]+\/\S+/.test(s)).map((id) => ({ id, label: id }));
21
26
  }
27
+ if (p === 'cursor') {
28
+ // `id - Label`, one per line, with a "(current, default)" note on the one in use.
29
+ const { stdout } = await run('cursor-agent', ['--list-models'], { timeout: 20000, maxBuffer: 4e6 });
30
+ return parseCursorModels(stdout);
31
+ }
22
32
  const { stdout } = await run('kiro-cli', ['chat', '--list-models', '--format', 'json'], { timeout: 20000, maxBuffer: 4e6 });
23
33
  const j = JSON.parse(stdout);
24
34
  const list = Array.isArray(j) ? j : j.models ?? [];
@@ -0,0 +1,197 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { isAbsolute, join } from 'node:path';
5
+ import { DATA_DIR } from '../db.js';
6
+ import { spawnJson } from './spawn.js';
7
+ import { asPositional, withInstructions } from './preamble.js';
8
+ import { cursorMcpEnv, cursorMcpServer } from '../mcp-config.js';
9
+ import { cursorTool } from '../history/cursor.js';
10
+ /**
11
+ * What Cursor may do for each permission level, as the `permissions` of its config. A turn under `-p` cannot ask, and without `--force` every MCP call is
12
+ * refused, so every turn runs with `--force` and these rules (which `--force` does not override) set the limits: read-only writes nothing and runs no
13
+ * shell, even if the model switches out of plan mode; editing writes project files (including .env, never .git or keys) and runs no shell, because
14
+ * Cursor's sandbox lets a command write anywhere. Its file tool can still write outside the agent's folder: Cursor has no rule for that.
15
+ */
16
+ export function cursorPermissions(level) {
17
+ if (level === 'bypassPermissions')
18
+ return null;
19
+ if (level === 'plan')
20
+ return { allow: [], deny: ['Write(**)', 'Shell(*)'] };
21
+ return { allow: [], deny: ['Shell(*)', 'Write(**/.git/**)', 'Write(**/*.pem)', 'Write(**/*.key)'] };
22
+ }
23
+ /** Where Cursor keeps its own config (`cli-config.json`): the person's settings are copied from there. */
24
+ const userConfigDir = () => process.env.CURSOR_CONFIG_DIR?.trim() || (process.env.XDG_CONFIG_HOME?.trim() ? join(process.env.XDG_CONFIG_HOME, 'cursor') : join(homedir(), '.cursor'));
25
+ /**
26
+ * A config folder for this agent (`CURSOR_CONFIG_DIR`): the person's own `cli-config.json` with the permission rules of the level. Rewritten every turn,
27
+ * so a changed permission applies to the next message. Sign-in and conversations live elsewhere and are not affected.
28
+ */
29
+ function cursorConfigDir(agentId, rules) {
30
+ let cfg = { version: 1 };
31
+ try {
32
+ cfg = JSON.parse(readFileSync(join(userConfigDir(), 'cli-config.json'), 'utf8'));
33
+ }
34
+ catch { /* no config yet: Cursor fills in the rest */ }
35
+ const dir = join(DATA_DIR, 'cursor', agentId);
36
+ mkdirSync(dir, { recursive: true });
37
+ writeFileSync(join(dir, 'cli-config.json'), JSON.stringify({ ...cfg, permissions: rules }, null, 2));
38
+ return dir;
39
+ }
40
+ const excluded = new Set();
41
+ /** Keeps the generated `.cursor/mcp.json` out of `git status`: added once to the repository's `.git/info/exclude` (local, never committed). */
42
+ function excludeFromGit(cwd) {
43
+ if (excluded.has(cwd))
44
+ return;
45
+ excluded.add(cwd);
46
+ try {
47
+ const git = (...a) => execFileSync('git', a, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).trim();
48
+ const pathOut = git('rev-parse', '--git-path', 'info/exclude');
49
+ const file = isAbsolute(pathOut) ? pathOut : join(cwd, pathOut);
50
+ const line = `/${git('rev-parse', '--show-prefix')}.cursor/mcp.json`;
51
+ const now = existsSync(file) ? readFileSync(file, 'utf8') : '';
52
+ if (now.split('\n').includes(line))
53
+ return;
54
+ mkdirSync(join(file, '..'), { recursive: true });
55
+ appendFileSync(file, `${now && !now.endsWith('\n') ? '\n' : ''}${line}\n`);
56
+ }
57
+ catch { /* not a repository */ }
58
+ }
59
+ /**
60
+ * Cursor reads MCP servers only from `<folder>/.cursor/mcp.json` (or the person's global one). The `hive` entry is merged into whatever servers the
61
+ * person has there, and the file is written only when it changes.
62
+ */
63
+ export function writeCursorMcp(cwd) {
64
+ const dir = join(cwd, '.cursor');
65
+ const file = join(dir, 'mcp.json');
66
+ let cfg = {};
67
+ const before = existsSync(file) ? readFileSync(file, 'utf8') : '';
68
+ if (before) {
69
+ try {
70
+ cfg = JSON.parse(before);
71
+ }
72
+ catch {
73
+ return;
74
+ }
75
+ } // a file the person is editing is not overwritten
76
+ const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
77
+ if (!isObject(cfg) || (cfg.mcpServers !== undefined && !isObject(cfg.mcpServers)))
78
+ return; // nor one that is not a config (`null`, a list, a number)
79
+ const next = JSON.stringify({ ...cfg, mcpServers: { ...(cfg.mcpServers ?? {}), hive: cursorMcpServer() } }, null, 2);
80
+ if (next !== before) {
81
+ mkdirSync(dir, { recursive: true });
82
+ writeFileSync(file, next);
83
+ }
84
+ excludeFromGit(cwd);
85
+ }
86
+ /** A tool as hive-am shows it: the key of a `tool_call` (`shellToolCall`) is the tool, named as Cursor's transcript names it (`Shell`). */
87
+ export function cursorStreamTool(call) {
88
+ const key = Object.keys(call ?? {}).find((k) => k.endsWith('ToolCall'));
89
+ if (!key)
90
+ return null;
91
+ const a = call[key]?.args ?? {};
92
+ const base = key.slice(0, -'ToolCall'.length);
93
+ if (base === 'mcp')
94
+ return cursorTool('CallDynamicTool', { namespace: a.providerIdentifier ?? a.serverIdentifier, toolName: a.toolName, arguments: a.args });
95
+ if (base === 'shell')
96
+ return { name: 'Shell', input: { command: a.command, ...(a.workingDirectory ? { cwd: a.workingDirectory } : {}), ...(a.timeout ? { timeout: a.timeout } : {}) } };
97
+ if (base === 'edit' && typeof a.streamContent === 'string')
98
+ return { name: 'Write', input: { path: a.path, contents: a.streamContent } };
99
+ const { toolCallId: _id, ...input } = a;
100
+ return { name: base.charAt(0).toUpperCase() + base.slice(1), input };
101
+ }
102
+ /** What a finished tool returned, and whether it failed: anything but `success` (`rejected`, `permissionDenied`, `error`…) is a failure. */
103
+ export function cursorToolResult(call) {
104
+ const key = Object.keys(call ?? {}).find((k) => k.endsWith('ToolCall'));
105
+ const r = (key && call[key]?.result) ?? {};
106
+ const kind = Object.keys(r)[0] ?? '';
107
+ const v = r[kind] ?? {};
108
+ if (kind !== 'success')
109
+ return { output: String(v.error ?? v.reason ?? v.message ?? (kind || 'no result')), error: true };
110
+ if (key === 'mcpToolCall') {
111
+ const text = (Array.isArray(v.content) ? v.content : []).map((c) => c?.text?.text ?? c?.text ?? '').filter((t) => typeof t === 'string' && t).join('\n');
112
+ return { output: text, error: !!v.isError };
113
+ }
114
+ if (key === 'shellToolCall') {
115
+ const out = v.interleavedOutput || [v.stdout, v.stderr].filter(Boolean).join('\n');
116
+ return { output: `${out}${v.exitCode ? `${out ? '\n' : ''}Exit code: ${v.exitCode}` : ''}`, error: false };
117
+ }
118
+ if (typeof v.message === 'string')
119
+ return { output: v.message, error: false };
120
+ if (typeof v.content === 'string')
121
+ return { output: v.content, error: false };
122
+ return { output: JSON.stringify(v), error: false };
123
+ }
124
+ /** Cursor CLI: `cursor-agent -p --output-format stream-json --stream-partial-output`; the session comes in `system/init` and is resumed by id. */
125
+ export async function* runCursor(o) {
126
+ const { agent } = o;
127
+ const args = ['-p', '--output-format', 'stream-json', '--stream-partial-output', '--trust', '--workspace', agent.cwd, '--force'];
128
+ if (agent.permission === 'plan')
129
+ args.push('--mode', 'plan');
130
+ const env = { ...cursorMcpEnv(agent.id, o.mcpCaps) };
131
+ const rules = cursorPermissions(agent.permission);
132
+ if (rules)
133
+ env.CURSOR_CONFIG_DIR = cursorConfigDir(agent.id, rules);
134
+ if (o.mcpCaps.length) {
135
+ writeCursorMcp(agent.cwd);
136
+ args.push('--approve-mcps');
137
+ }
138
+ if (agent.session_id)
139
+ args.push('--resume', agent.session_id);
140
+ if (agent.model)
141
+ args.push('--model', agent.model);
142
+ // No effort: the reasoning level is part of Cursor's model id (`gpt-5.3-codex-high`).
143
+ const prompt = withInstructions(o.prompt, o.instructions, !!agent.session_id, o.refreshInstructions);
144
+ // The prompt goes last; asPositional keeps a leading dash from being read as an option (see preamble.ts).
145
+ args.push(asPositional(prompt));
146
+ let sawSession = false;
147
+ let model;
148
+ // With --stream-partial-output the text comes in pieces (`timestamp_ms`, no `model_call_id`) and each stretch then once more whole: before a tool call
149
+ // with a `model_call_id`, at the end without `timestamp_ms`. A whole copy after pieces is skipped; without pieces before it (no partial output) it is the text.
150
+ let streamed = false;
151
+ // An unknown model is reported first and followed by the whole list of models: the start of stderr is what matters.
152
+ for await (const ev of spawnJson({ cmd: 'cursor-agent', args, cwd: agent.cwd, env, signal: o.signal, stderrHead: true })) {
153
+ if (ev.session_id && !sawSession) {
154
+ sawSession = true;
155
+ yield { t: 'session', sessionId: ev.session_id };
156
+ }
157
+ if (ev.type === 'system' && ev.subtype === 'init')
158
+ model = ev.model;
159
+ else if (ev.type === 'thinking') {
160
+ if (ev.subtype === 'delta' && ev.text)
161
+ yield { t: 'thinking', delta: ev.text };
162
+ }
163
+ else if (ev.type === 'assistant') {
164
+ const text = (ev.message?.content ?? []).filter((b) => b?.type === 'text').map((b) => b.text ?? '').join('');
165
+ const whole = ev.timestamp_ms === undefined || ev.model_call_id !== undefined;
166
+ if (whole && streamed)
167
+ streamed = false;
168
+ else if (text) {
169
+ streamed = !whole;
170
+ yield { t: 'text', delta: text };
171
+ }
172
+ }
173
+ else if (ev.type === 'tool_call') {
174
+ const id = String(ev.call_id ?? ev.tool_call?.toolCallId ?? '');
175
+ if (ev.subtype === 'started') {
176
+ const tool = cursorStreamTool(ev.tool_call);
177
+ if (tool)
178
+ yield { t: 'tool', id, ...tool };
179
+ }
180
+ else if (ev.subtype === 'completed')
181
+ yield { t: 'tool_result', id, ...cursorToolResult(ev.tool_call) };
182
+ }
183
+ else if (ev.type === 'result') {
184
+ const u = ev.usage;
185
+ if (u)
186
+ yield {
187
+ t: 'usage', durationMs: ev.duration_ms, model: agent.model || model,
188
+ usage: { input: u.inputTokens ?? 0, output: u.outputTokens ?? 0, cacheRead: u.cacheReadTokens ?? 0, cacheWrite: u.cacheWriteTokens ?? 0 },
189
+ };
190
+ yield ev.is_error
191
+ ? { t: 'error', message: String(ev.result || 'Cursor returned an error') }
192
+ : { t: 'done', ok: true, summary: typeof ev.result === 'string' ? ev.result : undefined };
193
+ return;
194
+ }
195
+ }
196
+ yield { t: 'done', ok: true };
197
+ }
@@ -1,12 +1,15 @@
1
1
  import { runClaude } from './claude.js';
2
2
  import { runOpencode } from './opencode.js';
3
3
  import { runKiro } from './kiro.js';
4
+ import { runCursor } from './cursor.js';
4
5
  export const providers = {
5
6
  claude: { id: 'claude', run: runClaude },
6
7
  opencode: { id: 'opencode', run: runOpencode },
7
8
  kiro: { id: 'kiro', run: runKiro },
9
+ cursor: { id: 'cursor', run: runCursor },
8
10
  };
9
11
  /** The adapters as plain functions (what `runtime.ts` and the image reader call; tests may replace one). */
10
12
  export const runners = {
11
13
  claude: (o) => providers.claude.run(o), opencode: (o) => providers.opencode.run(o), kiro: (o) => providers.kiro.run(o),
14
+ cursor: (o) => providers.cursor.run(o),
12
15
  };
@@ -37,7 +37,9 @@ export async function* spawnLines(o) {
37
37
  };
38
38
  armStall();
39
39
  let stderr = '';
40
- child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); });
40
+ let head = '';
41
+ child.stderr.on('data', (d) => { stderr = (stderr + d).slice(-4000); if (head.length < 600)
42
+ head = (head + d).slice(0, 600); });
41
43
  const exit = new Promise((res) => {
42
44
  child.on('error', (e) => { stderr += String(e); res(-1); });
43
45
  // 'exit' (not 'close'): a daemon the CLI forks (e.g. opencode's background service) can keep
@@ -68,8 +70,9 @@ export async function* spawnLines(o) {
68
70
  const code = await exit;
69
71
  if (stalled && !o.signal.aborted)
70
72
  throw new StalledError(`${o.cmd} stopped answering (nothing on stdout for ${quietFor(STALL_MS())})`);
73
+ const said = o.stderrHead ? head.trim() : stderr.trim().slice(-600);
71
74
  if (code !== 0 && !o.signal.aborted)
72
- throw new Error(`${o.cmd} exited with code ${code}${stderr ? `: ${stderr.trim().slice(-600)}` : ''}`);
75
+ throw new Error(`${o.cmd} exited with code ${code}${said ? `: ${said}` : ''}`);
73
76
  }
74
77
  export function safeJson(line) {
75
78
  try {
@@ -1,6 +1,7 @@
1
1
  import { agents, colonies, db, resolved } from '../db.js';
2
2
  import { notebooks } from '../skills/notebook.js';
3
3
  import { checkEffort } from '../efforts.js';
4
+ import { defaultModel } from './providers.js';
4
5
  import { channelPermissionError } from '../connections/rules.js';
5
6
  import { liveTurn, notifyAgentsChanged, stopAgent } from '../runtime.js';
6
7
  import { findRepo } from '../git/repo.js';
@@ -28,16 +29,17 @@ const checkAgentEffort = (provider, model, effort) => { const why = checkEffort(
28
29
  export function createAgent(p) {
29
30
  const name = trimmed(p.name);
30
31
  uniqueName(name);
32
+ const model = p.model ?? defaultModel(p.provider);
31
33
  if (p.effort !== undefined && p.provider)
32
- checkAgentEffort(p.provider, p.model ?? '', p.effort);
33
- return create({ ...p, name, cwd: p.cwd ?? '' });
34
+ checkAgentEffort(p.provider, model, p.effort);
35
+ return create({ ...p, name, model, cwd: p.cwd ?? '' });
34
36
  }
35
37
  /** A new agent that starts from a type: the type lends everything but name, description, folder and colony. */
36
38
  export function spawnFromType(t, p) {
37
39
  const name = trimmed(p.name);
38
40
  uniqueName(name);
39
41
  return create({
40
- name, description: p.description ?? t.description, role: t.role, type_id: t.id, provider: t.provider, model: t.model,
42
+ name, description: p.description ?? t.description, role: t.role, type_id: t.id, provider: t.provider, model: t.model || defaultModel(t.provider),
41
43
  effort: checkEffort(t.provider, t.model, t.effort) === null ? t.effort : '', // a type whose level the model no longer takes starts at the default
42
44
  system_prompt: t.system_prompt, permission: t.permission, cwd: p.cwd ?? '', skill_ids: t.skill_ids, skill_loads: t.skill_loads, colony_id: p.colony_id ?? null,
43
45
  });
@@ -5,7 +5,10 @@ export const PROVIDERS = {
5
5
  claude: { bin: 'claude', label: 'Claude Code' },
6
6
  opencode: { bin: 'opencode', label: 'OpenCode' },
7
7
  kiro: { bin: 'kiro-cli', label: 'Kiro' },
8
+ cursor: { bin: 'cursor-agent', label: 'Cursor' },
8
9
  };
10
+ /** The model a new agent, type or image model starts with: Cursor's own default (Composer) is refused on a free plan, `auto` runs on every plan. */
11
+ export const defaultModel = (p) => (p === 'cursor' ? 'auto' : '');
9
12
  /** Own keys only: `"constructor" in PROVIDERS` is true. */
10
13
  export const isProvider = (v) => typeof v === 'string' && Object.hasOwn(PROVIDERS, v);
11
14
  let providerCache = null;
@@ -1 +1 @@
1
- 2RnNXnGqTqztT7YR1JZRD
1
+ qv9RAZqD8Of6bH1cw6RzA