Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
6cc5405
feat(agents): add Cursor Agent CLI as a builtin agent
builtbyd3v Sep 29, 2026
2aa95db
feat(agents): draw the Cursor brand mark inline
builtbyd3v Sep 29, 2026
21b9386
feat(agents): cursor joins model switch and permission mode
builtbyd3v Sep 29, 2026
77259f7
feat(agents): cursor reports status through its hooks
builtbyd3v Sep 29, 2026
6b78f3e
chore(agents): drop em dashes from cursor comments
builtbyd3v Sep 29, 2026
8186634
Merge cursor status hooks into feat/cursor-agent
builtbyd3v Sep 29, 2026
faa6fdb
feat(agents): read Cursor chats for the chat view, context link, tran…
builtbyd3v Sep 29, 2026
f426844
Merge cursor transcript, chat view and context link into feat/cursor-…
builtbyd3v Sep 29, 2026
e99827e
feat(cursor): context meter from the chat store
builtbyd3v Oct 2, 2026
5beffa8
feat(cursor): per-node context meter from the store's token_details
builtbyd3v Oct 2, 2026
54fcead
Merge cursor context meter into feat/cursor-agent
builtbyd3v Oct 2, 2026
5d211e8
feat(cursor): resume, session-id minting, session end, in-place resta…
builtbyd3v Oct 2, 2026
e47ba76
Merge cursor session continuity and model catalogue into feat/cursor-…
builtbyd3v Oct 2, 2026
6b37643
feat(cursor): NEEDS YOU from one pane read on a pending tool call
builtbyd3v Oct 2, 2026
d338a49
feat(cursor): install status hooks on SSH hosts that have cursor-agent
builtbyd3v Oct 2, 2026
1b30479
test(cursor): capture interactive subagent hook payloads
builtbyd3v Oct 2, 2026
d9c0b90
docs(cursor): NEEDS YOU, SSH hooks and lost-stop section, wiring tests
builtbyd3v Oct 2, 2026
622abb5
feat(cursor): subagent cards, canvas control and rename write leg
builtbyd3v Oct 2, 2026
8d07351
Merge cursor NEEDS YOU pane read and SSH hook installer into feat/cur…
builtbyd3v Oct 2, 2026
fde30d2
test(cursor): pin subagent, rename and canvas-control parity
builtbyd3v Oct 2, 2026
2e143a2
docs(cursor): orchestration parity measurements
builtbyd3v Oct 2, 2026
5276d24
test(cursor): control routing pins follow cursor joining canvas control
builtbyd3v Oct 2, 2026
060242d
Merge cursor canvas control, subagent cards and rename into feat/curs…
builtbyd3v Oct 2, 2026
f2a8f57
fix(cursor): retry the approval pane read on a bounded schedule
builtbyd3v Oct 2, 2026
88f8abc
feat(cursor): launch the default Auto mode with --auto-review
builtbyd3v Oct 2, 2026
b3e33fa
fix(cursor): apply review findings
builtbyd3v Oct 2, 2026
2fcc677
fix(cursor): ignore cursor payloads on the claude hook, release subag…
builtbyd3v Oct 2, 2026
3da7344
fix(cursor): route by base harness and space out the exit writes
builtbyd3v Oct 2, 2026
9a4ebc0
fix(cursor): agent-facing copy, skill wording, model probe gate, sett…
builtbyd3v Oct 2, 2026
e229971
docs(cursor): fix stale text and record the sessionStart measurement
builtbyd3v Oct 2, 2026
a3c9698
fix(cursor): NEEDS YOU for subagent approvals and the plan-mode prompt
builtbyd3v Oct 2, 2026
ae1bdc3
docs(cursor): subagent and plan-mode NEEDS YOU, store creation, meter…
builtbyd3v Oct 2, 2026
cf59738
fix(cursor): approval watch clears a delegated build, releases nodes,…
builtbyd3v Oct 2, 2026
9b8cb1c
fix(cursor): no shared pane capture across turns, one capture per nod…
builtbyd3v Oct 2, 2026
775558a
fix(agents): clear the recorded exit once nodeterm relaunches a node …
builtbyd3v Oct 4, 2026
41b6b35
fix(cursor): serialize split sends, fence pending meter lookups, igno…
builtbyd3v Oct 4, 2026
eebbf77
fix(cursor): distinct node colour, linked-context skill list, custom-…
builtbyd3v Oct 4, 2026
cef2631
chore(cursor): fix stale comments and docs, drop a dead option, list …
builtbyd3v Oct 4, 2026
52b653d
Merge origin/main into feat/cursor-agent
builtbyd3v Oct 4, 2026
c3fae73
fix(cursor): write the remote hook script atomically and log a failed…
builtbyd3v Oct 4, 2026
f95cd77
feat(cursor): NEEDS YOU on the AskQuestion box
builtbyd3v Oct 4, 2026
2576858
feat(usage): show Cursor plan usage in the usage indicator
builtbyd3v Oct 4, 2026
91350f3
feat(usage): show all three Cursor buckets in the usage pill
builtbyd3v Oct 4, 2026
fefae65
test(cursor): read hook files through one descriptor (CodeQL file-sys…
builtbyd3v Oct 5, 2026
82dc7d8
Merge origin/main into feat/cursor-agent
builtbyd3v Oct 7, 2026
d8c8377
test(cursor): chat sends stay on the split paste beside the typed cha…
builtbyd3v Oct 7, 2026
cf42684
feat(cursor): follow agent-integration consent for skills and removal
builtbyd3v Oct 7, 2026
870ceaa
fix(cursor): split envelope and unmounted-node submits, never Enter i…
builtbyd3v Oct 7, 2026
fe2d1f7
fix(cursor): never parse a store blob larger than the read budget
builtbyd3v Oct 7, 2026
60864cf
fix(cursor): cap the usage reply body before parsing it
builtbyd3v Oct 7, 2026
f945bd4
fix(cursor): check for a dialog before the paste, report a withheld e…
builtbyd3v Oct 7, 2026
544627d
Merge origin/main into feat/cursor-agent
builtbyd3v Oct 7, 2026
5c487e3
Merge origin/main into feat/cursor-agent
builtbyd3v Oct 8, 2026
fc27e1b
Merge origin/main into feat/cursor-agent
Oct 15, 2026
be4712b
Merge remote-tracking branch 'origin/main' into pr-review-1128
Oct 16, 2026
27c4864
test(shortcuts): widen the parity key pool for the cursor create command
Oct 16, 2026
01e6d95
Merge remote-tracking branch 'origin/main' into pr-review-1128
Oct 16, 2026
9e1f4da
Merge remote-tracking branch 'origin/main'
Oct 16, 2026
652dc98
Merge remote-tracking branch 'origin/main'
Oct 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ never leaves the machine.
### Node kinds

🖥 **Terminal** (xterm + tmux, AI naming) · 🤖 **Agent** (Claude Code / Codex / Antigravity /
Gemini / GitHub Copilot / opencode / Grok / custom) · 📝 **Sticky note** (link to an agent as context) · 🗂 **Group**
Gemini / GitHub Copilot / opencode / Grok / Cursor / custom) · 📝 **Sticky note** (link to an agent as context) · 🗂 **Group**
(bind to a **git worktree** for agent-per-branch) · ✏️ **Editor** (Monaco, ⌘S) ·
🔀 **Diff** · 🌐 **Web / Video**

Expand Down
571 changes: 571 additions & 0 deletions docs/cursor-agent.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion docs/mobile-chat-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ as `hostBridge.chat` in `main/index.ts`, forwarded by both phone hosts).

| verb | params | reply | does |
|---|---|---|---|
| `chat.page` | `{nodeId, before?, maxBytes?}` | `{page: ChatPage}` | Only for agents with a chat view — `canChat(capabilityAgentId(agent))` (claude, grok, gemini, codex, copilot, opencode, and custom agents whose base harness is one of them; codex is read by its own reader, `src/core/codex-chat.ts`, locally and on an SSH host — fixtures and the record rules in `src/shared/chat-fixtures/codex/`); anything else is refused before any read. `readChatTranscript` with the ⌘M panel's own deps (paged ALWAYS — an absent page is the default 256 KB tail; grok unpaged) + `model`/`effort`, `version: 1`. Session id: the RENDERER's agent-status id first (what ⌘M reads — after a desktop restart a hook-fed id lives only there), then the mirror's, then the node's minted id; the fallbacks apply only when the renderer does not answer within 3 s or knows none. **Remote (SSH-project) nodes are read REMOTE-ONLY** (`remoteOnly`): over the live pty's ControlMaster, else the node's PROJECT's master (an idle tab / any node after a restart has no pty), and never from this machine's disk — a remote node with no reachable master, or a host that did not answer, is `Could not read the transcript.`, not an empty page. The host-side locate is TRI-STATE (`locateRemoteTranscriptRef`, `src/main/remote-transcript-page.ts`): a CLEAN MISS (the host looked and has no such file, or there is no session id to look for) is `found:false`, and only a failure to ask (no master, no resolved home, a failed ssh, a path outside the jail, a failed ranged read) is the error. For REMOTE nodes the desktop ⌘M panel follows the same remote-only rule and the same absent-vs-unreadable split (same deps): its IPC handler decides remoteness from the shell's own records (`isRemoteNode` — live remote pty or `workspaceStore.sshProjectIdForNode`, never a renderer flag) and applies `remoteOnly` to the paged read, the legacy unpaged read, the find-bar index and the presence probe (`unknown`); it shows `unreadable` as "Couldn't read the transcript." (a remote grok node is read on its host too — `core/remote-grok-chat.ts`, whole-file and unpaged, with the same absent-vs-unreadable split; a remote node of an agent with no remote reader — `CHAT_LOCAL_ONLY` — as "not supported yet"). LOCAL nodes on ⌘M are unchanged — the renderer still sends the cwd, so claude's cwd fallback still applies there; the cwd-only-when-remote rule below is the phone path's. The cwd rides ONLY for a remote node with a known id (the host-side locate needs it); locally a known-but-dead id must not fall back to the cwd's newest transcript (another node's), and with no id at all ⇒ `found:false`. `found:false` therefore always means "looked, and there is no transcript". The reply's `page.sessionId` is exactly the id that read used (absent when none was resolved); the phone should PREFER it over the mirror's `sessionId` as the page's thread identity — the mirror lags — every 4 s on SSH, up to ~30 s on relay (`projects.list` off-LAN) — so right after `/clear` or a resume it still names the old session while the page already comes from the new transcript, and merging by byte `key` across the two would splice threads. `sessionId` is present on a `found:false` page too (an id was resolved, its transcript is missing — e.g. a fresh session that has not written yet): a new id on such a page alone is NOT a confirmed thread switch, so the phone must not clear the thread on it. Additive field: `version` stays 1, and an older desktop simply omits it (fall back to the mirror). |
| `chat.page` | `{nodeId, before?, maxBytes?}` | `{page: ChatPage}` | Only for agents with a chat view — `canChat(capabilityAgentId(agent))` (claude, grok, gemini, codex, copilot, opencode, cursor (its SQLite chat store, `src/core/cursor-chat.ts`; local only), and custom agents whose base harness is one of them; codex is read by its own reader, `src/core/codex-chat.ts`, locally and on an SSH host — fixtures and the record rules in `src/shared/chat-fixtures/codex/`); anything else is refused before any read. `readChatTranscript` with the ⌘M panel's own deps (paged ALWAYS — an absent page is the default 256 KB tail; grok unpaged) + `model`/`effort`, `version: 1`. Session id: the RENDERER's agent-status id first (what ⌘M reads — after a desktop restart a hook-fed id lives only there), then the mirror's, then the node's minted id; the fallbacks apply only when the renderer does not answer within 3 s or knows none. **Remote (SSH-project) nodes are read REMOTE-ONLY** (`remoteOnly`): over the live pty's ControlMaster, else the node's PROJECT's master (an idle tab / any node after a restart has no pty), and never from this machine's disk — a remote node with no reachable master, or a host that did not answer, is `Could not read the transcript.`, not an empty page. The host-side locate is TRI-STATE (`locateRemoteTranscriptRef`, `src/main/remote-transcript-page.ts`): a CLEAN MISS (the host looked and has no such file, or there is no session id to look for) is `found:false`, and only a failure to ask (no master, no resolved home, a failed ssh, a path outside the jail, a failed ranged read) is the error. For REMOTE nodes the desktop ⌘M panel follows the same remote-only rule and the same absent-vs-unreadable split (same deps): its IPC handler decides remoteness from the shell's own records (`isRemoteNode` — live remote pty or `workspaceStore.sshProjectIdForNode`, never a renderer flag) and applies `remoteOnly` to the paged read, the legacy unpaged read, the find-bar index and the presence probe (`unknown`); it shows `unreadable` as "Couldn't read the transcript." (a remote grok node is read on its host too — `core/remote-grok-chat.ts`, whole-file and unpaged, with the same absent-vs-unreadable split; a remote node of an agent with no remote reader — `CHAT_LOCAL_ONLY` — as "not supported yet"). LOCAL nodes on ⌘M are unchanged — the renderer still sends the cwd, so claude's cwd fallback still applies there; the cwd-only-when-remote rule below is the phone path's. The cwd rides ONLY for a remote node with a known id (the host-side locate needs it); locally a known-but-dead id must not fall back to the cwd's newest transcript (another node's), and with no id at all ⇒ `found:false`. `found:false` therefore always means "looked, and there is no transcript". The reply's `page.sessionId` is exactly the id that read used (absent when none was resolved); the phone should PREFER it over the mirror's `sessionId` as the page's thread identity — the mirror lags — every 4 s on SSH, up to ~30 s on relay (`projects.list` off-LAN) — so right after `/clear` or a resume it still names the old session while the page already comes from the new transcript, and merging by byte `key` across the two would splice threads. `sessionId` is present on a `found:false` page too (an id was resolved, its transcript is missing — e.g. a fresh session that has not written yet): a new id on such a page alone is NOT a confirmed thread switch, so the phone must not clear the thread on it. Additive field: `version` stays 1, and an older desktop simply omits it (fall back to the mirror). |
| `chat.status` | `{nodeId}` | `{status: ChatStatus}` | the renderer's agent-status entry (`state` null = unknown, `held`, hibernated/paused/dropped/sessionEnded) + `structuredAnswers` = the held ticket is in main's structured-ticket ledger (script revision ≥ 5) + the host mirror's `hostRefuses`/`refusal` + `version: 1`. With `{nodeId, catalog: true}` (a literal `true`; opt-in, so an older phone sees the old shape) the status also carries `catalog: ChatCatalog` (`src/shared/chat-catalog.ts`) — the composer's `/` list for the node: measured built-ins, custom commands and skills, read on the node's host for an SSH node. Absent when the host could not build it (never an error on the status). Names pass `catalogName`, descriptions are one sanitized line; render them as text. `partial: true` = a root could not be read. The host waits at most 4 s for it (an SSH node's catalog is an ssh round trip) and then answers WITHOUT it — so a client asks with `catalog: true` ONCE per composer open (and again only when the user opens the menu after a while), never on every status poll. An entry with `interactive: true` is a built-in that opens a dialog in the agent's TUI: a client that cannot show the terminal after sending it must not offer it (the dialog would be invisible and the next send would answer it). Adopting it in nodeterm mobile is an iOS follow-up. |
| `chat.send` | `{nodeId, text}` | `ChatSendOutcome` = `{result, reason?}` | text capped at 64000 UTF-16 units RAW, then ESC + C0/C1 stripped (`\n`, `\t` kept; `sanitizeChatText`); refused FIRST (`reason` `working`/`dialog`) when the host's mirror says the agent is working / waiting / blocked or holds a question or approval ticket (renderer state is transient after a reload); refused `busy` while another send to the same node is still in flight (held until that send SETTLES, capped at 30 s); then the renderer runs the ⌘M composer's gate at send time (`chatSendRefusal` = `agentProcessInPane` + state ∈ {done, unknown} → its own kind as `reason`) plus "no held request" (`dialog`), then `pty.sendText`. `'sent'` only for `sendText === true`; a paste the pane refused is `refused`/`failed`. |
| `agent.answer` | `{nodeId, pendingId, answer}` | `{ok: boolean}` | `answerHeldPermission` with the node's own I/O (local fs, or the SSH project's ControlMaster — the same `heldPermissionIoFor` the desktop answer path uses): validated against the pending request file, structured-ticket gated, no `setMode auto`. The `pendingId` must belong to THIS node — one of the mirror's approval tickets for it, or the renderer's `held.pendingId` (plans / questions) — else `{ok:false}` with no I/O and no answered event. Success emits the same optimistic "answered" event. |
Expand Down
33 changes: 32 additions & 1 deletion src/core/agent-integrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// did not enable, a decline removes exactly what nodeterm wrote (a file they edited is kept and
// reported), and every agent that has canvas control / context link still discovers them.
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'fs'
import os from 'os'
import path from 'path'
import { initPlatform, resetPlatformForTests } from './platform'
Expand Down Expand Up @@ -242,6 +242,37 @@ describe('decline: remove exactly what we wrote', () => {
expect(existsSync(path.join(home, '.codex', 'skills', CANVAS_SKILL, 'SKILL.md'))).toBe(true)
})

// cursor-agent loads `~/.cursor/skills` ahead of `~/.claude/skills`, so its skills follow ITS consent,
// not claude's. Its hooks.json is shared with the Cursor IDE: a decline strips only our entries.
it.skipIf(process.platform === 'win32')('cursor: hook and skills in ~/.cursor under its own consent; a decline removes only ours', () => {
const bin = path.join(home, '.local', 'bin', 'cursor-agent')
put(bin, '#!/bin/sh\n')
chmodSync(bin, 0o755)
let settings = withAgents({ cursor: 'enabled' })
const { lc } = lifecycle(() => settings, { realHooks: true })
lc.reconcile()
const hooksJson = path.join(home, '.cursor', 'hooks.json')
const skills = path.join(home, '.cursor', 'skills')
expect(read(hooksJson)).toContain('agent-hooks/cursor.sh')
expect(read(path.join(skills, CANVAS_SKILL, 'SKILL.md'))).toBe(buildCanvasSkillBody(SHIMS().canvas))
expect(read(path.join(skills, CONTEXT_SKILL, 'SKILL.md'))).toBe(buildContextLinkSkillBody(SHIMS().context))
expect(existsSync(path.join(home, '.nodeterm', 'agent-hooks', 'cursor.sh'))).toBe(true)
expect(existsSync(path.join(home, '.claude'))).toBe(false)
// The IDE (or another tool) adds its own hook beside ours.
const cfg = JSON.parse(read(hooksJson)) as { hooks: Record<string, { command: string }[]> }
cfg.hooks.stop.push({ command: 'ide-hook' })
writeFileSync(hooksJson, JSON.stringify(cfg))

settings = withAgents({ cursor: 'declined' })
lc.onSettingsChanged(settings)
const after = read(hooksJson)
expect(after).not.toContain('agent-hooks/cursor.sh')
expect(after).toContain('ide-hook')
expect(existsSync(path.join(skills, CANVAS_SKILL))).toBe(false)
expect(existsSync(path.join(skills, CONTEXT_SKILL))).toBe(false)
expect(existsSync(path.join(home, '.nodeterm', 'agent-hooks', 'cursor.sh'))).toBe(false)
})

it('never overwrites a skill the user edited while still enabled', () => {
const { lc } = lifecycle(() => withAgents({ gemini: 'enabled' }))
lc.reconcile()
Expand Down
8 changes: 7 additions & 1 deletion src/core/agent-integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ export function skillRootsFor(
return [path.join(copilotHomeDir(), 'skills')]
case 'opencode':
return [path.join(opencodeConfigDir(), 'skills')]
// cursor-agent's bundle loads `~/.cursor/skills` before `~/.claude/skills` (docs/cursor-agent.md),
// so its own dir keeps canvas control working when claude is declined, and ahead of the tail
// its long-skill-list truncation drops.
case 'cursor':
return [path.join(home, '.cursor', 'skills')]
default:
return []
}
Expand Down Expand Up @@ -245,7 +250,8 @@ const HOOK_SCRIPTS: Partial<Record<IntegrationAgentId, string[]>> = {
gemini: ['gemini.sh', 'gemini-hook.cmd'],
grok: ['grok.sh', 'grok-hook.cmd'],
copilot: ['copilot.sh', 'copilot-hook.cmd'],
antigravity: ['antigravity.sh', 'antigravity-hook.cmd']
antigravity: ['antigravity.sh', 'antigravity-hook.cmd'],
cursor: ['cursor.sh']
}

export interface IntegrationLifecycle {
Expand Down
47 changes: 47 additions & 0 deletions src/core/agent-launch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,53 @@ describe("prepareAgentLaunch logical argv", () => {
});
});

it("puts Cursor's flags before its `agent` subcommand, not after the prompt", async () => {
await expect(
prepareAgentLaunch(
start("cursor", { prompt: "hi", permissionMode: "bypassPermissions" }),
"posix",
builtinContext("cursor"),
),
).resolves.toEqual({ command: "'cursor-agent' '--force' 'agent' 'hi'" });
await expect(
prepareAgentLaunch(
start("cursor", { prompt: "hi", permissionMode: "plan" }),
"posix",
builtinContext("cursor"),
),
).resolves.toEqual({ command: "'cursor-agent' '--mode' 'plan' 'agent' 'hi'" });
// The default mode emits nothing, so an untouched node's line is byte-identical to before.
await expect(
prepareAgentLaunch(
start("cursor", { prompt: "hi", permissionMode: "auto" }),
"posix",
builtinContext("cursor"),
),
).resolves.toEqual({ command: "'cursor-agent' '--auto-review' 'agent' 'hi'" });
});

it("resumes and mints Cursor with `--resume <id>` before the `agent` subcommand", async () => {
const id = "550e8400-e29b-41d4-a716-446655440000";
await expect(
prepareAgentLaunch(
resume("cursor", id, { permissionMode: "plan" }),
"posix",
builtinContext("cursor"),
),
).resolves.toEqual({
command: `'cursor-agent' '--resume' '${id}' '--mode' 'plan'`,
});
await expect(
prepareAgentLaunch(
start("cursor", { prompt: "hi", newSessionId: id }),
"posix",
builtinContext("cursor"),
),
).resolves.toEqual({
command: `'cursor-agent' '--resume' '${id}' 'agent' 'hi'`,
});
});

it("uses flag-prompt for OpenCode and preserves a trailing slash", async () => {
await expect(
prepareAgentLaunch(
Expand Down
5 changes: 4 additions & 1 deletion src/core/agent-launch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,9 +399,12 @@ function logicalLaunch(
const sessionArgs: string[] = [];
if (intent.newSessionId !== undefined) {
if (!mintsSessionId(config.id)) fail("invalid-intent");
// `withSessionId`'s grammar: copilot's verified contract is `--session-id=<uuid>` (1.0.80).
// `withSessionId`'s grammar: copilot's verified contract is `--session-id=<uuid>` (1.0.80);
// cursor mints by resuming an id that does not exist yet (see withSessionId in shared/agents/launch).
if (config.id === "copilot")
sessionArgs.push(`--session-id=${intent.newSessionId}`);
else if (config.id === "cursor")
sessionArgs.push("--resume", intent.newSessionId);
else sessionArgs.push("--session-id", intent.newSessionId);
}

Expand Down
28 changes: 28 additions & 0 deletions src/core/agent-session-name.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { tmpdir } from 'os'
import path from 'path'
import { readAgentSessionName } from './agent-session-name'
import { rememberGrokSessionDir, forgetGrokSession } from './grok-session'
import { setCustomAgentBaseResolver } from '../shared/agents/config'

const root = mkdtempSync(path.join(tmpdir(), 'agent-session-name-'))
afterAll(() => rmSync(root, { recursive: true, force: true }))
Expand Down Expand Up @@ -86,6 +87,33 @@ describe('readAgentSessionName', () => {
delete process.env.CODEX_HOME
})

it("routes cursor to its chat store's reader: null for an unknown id, never claude's scan", async () => {
process.env.CURSOR_CONFIG_DIR = path.join(root, 'no-such-cursor-dir')
expect(await readAgentSessionName('66666666-e4c7-4f28-9d99-027f84c10837', undefined, 'cursor')).toBeNull()
delete process.env.CURSOR_CONFIG_DIR
})

it('routes a custom agent built on cursor to the cursor store, not claude\'s reader', async () => {
const { DatabaseSync } = await import('node:sqlite')
const cfg = path.join(root, 'cursor-cfg')
const id = '77777777-e4c7-4f28-9d99-027f84c10837'
const d = path.join(cfg, 'chats', 'b', id)
mkdirSync(d, { recursive: true })
const db = new DatabaseSync(path.join(d, 'store.db'))
db.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB); CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);')
db.prepare("INSERT INTO meta (key, value) VALUES ('0', ?)").run(Buffer.from(JSON.stringify({ name: 'Cursor named it' })).toString('hex'))
db.close()
process.env.CURSOR_CONFIG_DIR = cfg
setCustomAgentBaseResolver((a) => (a === 'my-cursor' ? 'cursor' : undefined))
try {
expect(await readAgentSessionName(id, undefined, 'cursor')).toBe('Cursor named it')
expect(await readAgentSessionName(id, undefined, 'my-cursor')).toBe('Cursor named it')
} finally {
setCustomAgentBaseResolver(null)
delete process.env.CURSOR_CONFIG_DIR
}
})

it('answers null for an empty session id without asking either reader', async () => {
expect(await readAgentSessionName('', undefined, 'grok')).toBeNull()
expect(await readAgentSessionName('')).toBeNull()
Expand Down
Loading
Loading