From 67cd462b24fe0d549acde5a931cda88dad1e9377 Mon Sep 17 00:00:00 2001 From: nikg24 Date: Mon, 13 Jul 2026 01:39:18 +0100 Subject: [PATCH] Add Cursor orchestration group routing (#8436) Co-authored-by: nikg24 <228026988+nikg24@users.noreply.github.com> --- .../orchestration-skill-guidance.test.mjs | 7 ++ skills/orchestration/SKILL.md | 2 +- src/main/runtime/orca-runtime.ts | 19 +---- src/main/runtime/orchestration/groups.test.ts | 83 +++++++++++++++++++ src/main/runtime/orchestration/groups.ts | 24 ++++-- .../runtime/rpc/methods/orchestration.test.ts | 17 ++++ src/main/runtime/rpc/methods/orchestration.ts | 2 +- src/shared/agent-detection.test.ts | 49 +++++++++++ src/shared/agent-detection.ts | 1 + src/shared/agent-title-core.ts | 22 +++++ 10 files changed, 200 insertions(+), 26 deletions(-) diff --git a/config/scripts/orchestration-skill-guidance.test.mjs b/config/scripts/orchestration-skill-guidance.test.mjs index 82b408a33..16747b1ad 100644 --- a/config/scripts/orchestration-skill-guidance.test.mjs +++ b/config/scripts/orchestration-skill-guidance.test.mjs @@ -198,6 +198,13 @@ describe('orchestration skill guidance', () => { expect(messaging).toContain('`@grok`') }) + it('documents @cursor in the Messaging group address list', () => { + const skill = readSkill() + const messaging = getSection(skill, 'Messaging') + + expect(messaging).toContain('`@cursor`') + }) + it('keeps agent-first launch, handle recovery, and inbox injection distinct', () => { const skill = readSkill() const messaging = getSection(skill, 'Messaging') diff --git a/skills/orchestration/SKILL.md b/skills/orchestration/SKILL.md index b7596f76c..583fc2ad6 100644 --- a/skills/orchestration/SKILL.md +++ b/skills/orchestration/SKILL.md @@ -96,7 +96,7 @@ Rules: - Heartbeats and visible terminal activity mean the worker is alive, not done. Do not stop, close, kill, or restart a worker just because it has not produced a completion message yet. - Use `ask` when a worker needs a blocking answer from the coordinator; it waits for the reply and returns the answer directly. - `check --wait` returns one message at a time. If N workers may finish together, loop N times and dispatch newly ready tasks after each completion. -- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, and `@worktree:`. +- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:`. - Message types include `status`, `dispatch`, `worker_done`, `merge_ready`, `escalation`, `handoff`, `decision_gate`, and `heartbeat`. - Use group addresses only for messages that are genuinely useful to many terminals, such as `status` broadcasts or intentional fan-out questions. Do not send dispatch lifecycle messages to groups. - `worker_done` must target the concrete coordinator handle from the live preamble. It is completion authority for one dispatch; group fanout would create false lifecycle mail in unrelated terminals. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index f21d38723..45f582a55 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -4,6 +4,7 @@ import { detectAgentStatusFromTitle, isClaudeManagementTitle, + isCursorAgentTitle, isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle @@ -967,24 +968,6 @@ function isCursorAgentOrchestrationTarget( return [leaf.lastOscTitle, leaf.paneTitle, tabTitle].some(isCursorAgentTitle) } -function isCursorAgentTitle(title: string | null | undefined): boolean { - if (typeof title !== 'string') { - return false - } - const trimmed = title.trim() - const lower = trimmed.toLowerCase() - if ( - lower === 'cursor agent' || - lower === 'cursor ready' || - lower === 'cursor - action required' - ) { - return true - } - // Why: display labels can mention Cursor in another agent's task text. Only - // treat the controlled synthetic Cursor spinner title as Cursor identity. - return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) -} - type RuntimePtyWorktreeRecord = { ptyId: string worktreeId: string diff --git a/src/main/runtime/orchestration/groups.test.ts b/src/main/runtime/orchestration/groups.test.ts index 5f7f07ca0..81c2345c8 100644 --- a/src/main/runtime/orchestration/groups.test.ts +++ b/src/main/runtime/orchestration/groups.test.ts @@ -31,6 +31,7 @@ describe('isGroupAddress', () => { expect(isGroupAddress('@claude')).toBe(true) expect(isGroupAddress('@droid')).toBe(true) expect(isGroupAddress('@grok')).toBe(true) + expect(isGroupAddress('@cursor')).toBe(true) expect(isGroupAddress('@worktree:wt_1')).toBe(true) }) @@ -218,6 +219,88 @@ describe('resolveGroupAddress', () => { expect(result).toEqual(['term_exe', 'term_cmd']) }) + + // Why: these are the titles cursor-agent natively emits and the ones Orca synthesizes + // from Cursor hooks, so each must resolve. + it('matches native and Orca-synthesized Cursor titles', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_native', { title: 'Cursor Agent' }), + makeSummary('term_working', { title: '⠋ Cursor Agent' }), + makeSummary('term_idle', { title: 'Cursor ready' }), + makeSummary('term_permission', { title: 'Cursor - action required' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_native', 'term_working', 'term_idle', 'term_permission']) + }) + + it('is case-insensitive for @cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_b', { title: 'CURSOR READY' }) + ] + + const result = resolveGroupAddress('@CuRsOr', 'coordinator', terminals, noStatus) + + expect(result).toEqual(['term_b']) + }) + + // Why: this is the false positive that a whole-token matcher cannot catch. Claude + // and Codex put their task summary in the OSC title, and none of Claude's status + // prefixes carries its own name, so "cursor" as ordinary editor vocabulary is the + // only agent token such a title has. Routing @cursor there would write into a live + // non-Cursor agent's prompt. + it('does not match another agent whose task title mentions a text cursor', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_claude_working', { + title: '⠋ preserve cursor visibility across replays' + }), + makeSummary('term_claude_idle', { title: '✳ Fix the text cursor blink' }), + makeSummary('term_claude_dot', { title: '. fix cursor position' }), + makeSummary('term_claude_star', { title: '* cursor rendering done' }), + makeSummary('term_codex', { title: '⠋ Codex: fix cursor offsets' }), + makeSummary('term_grok', { title: '⠋ - restoring cursor state - grok' }), + makeSummary('term_shell', { title: 'Terminal Cursor and Orca slows down' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + // Why: pin the deliberate tradeoff. A renamed Cursor pane outranks its OSC title, so it + // is group-unaddressable and resolves to nothing instead of resolving loosely. A silent + // miss (address it by handle) is preferred over delivering into another agent's prompt. + it('does not match a renamed Cursor terminal or the bare process name', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_renamed', { title: 'Cursor - reviewer' }), + makeSummary('term_worker', { title: 'cursor worker 2' }), + makeSummary('term_process', { title: 'cursor-agent' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) + + it('does not match cursor paths, hyphenated compounds, or dotted tokens', () => { + const terminals = [ + makeSummary('coordinator', { title: 'Coordinator' }), + makeSummary('term_rules', { title: '~/cursor-rules' }), + makeSummary('term_path', { title: '/tmp/cursor' }), + makeSummary('term_worker', { title: 'my-cursor-worker' }), + makeSummary('term_file', { title: 'render-cursor-after-bracketed-paste' }), + makeSummary('term_dotted', { title: 'cursor.ts' }) + ] + + const result = resolveGroupAddress('@cursor', 'coordinator', terminals, noStatus) + + expect(result).toEqual([]) + }) }) describe('unknown groups', () => { diff --git a/src/main/runtime/orchestration/groups.ts b/src/main/runtime/orchestration/groups.ts index 39ba290bd..5d24f68a1 100644 --- a/src/main/runtime/orchestration/groups.ts +++ b/src/main/runtime/orchestration/groups.ts @@ -1,3 +1,4 @@ +import { isCursorAgentTitle } from '../../../shared/agent-title-core' import { buildAgentNameRe } from '../../../shared/agent-name-token-match' import type { RuntimeTerminalSummary } from '../../../shared/runtime-types' @@ -13,20 +14,31 @@ const AGENT_NAME_GROUPS = [ 'mimo', 'gemini', 'droid', - 'grok' + 'grok', + 'cursor' ] as const -export type GroupAddress = - | '@all' - | '@idle' - | `@${(typeof AGENT_NAME_GROUPS)[number]}` - | `@worktree:${string}` +type AgentNameGroup = (typeof AGENT_NAME_GROUPS)[number] + +export type GroupAddress = '@all' | '@idle' | `@${AgentNameGroup}` | `@worktree:${string}` export function isGroupAddress(to: string): boolean { return to.startsWith('@') } +// Why: a name token identifies an agent only when the name is a coined word. `cursor` is +// also ordinary vocabulary in another agent's task-summary title ("fix the text cursor +// blink"), so token-matching it would route @cursor into a live Claude/Codex prompt. Names +// with that ambiguity register the identity predicate delivery already applies to them. +const GROUP_TITLE_MATCHERS: Partial boolean>> = { + cursor: isCursorAgentTitle +} + function titleMatchesAgentNameGroup(title: string, agentName: string): boolean { + const identityMatcher = GROUP_TITLE_MATCHERS[agentName as AgentNameGroup] + if (identityMatcher) { + return identityMatcher(title) + } // Why: reuse the shared whole-token matcher so orchestration groups honor the // same Windows launcher-suffix rule (e.g. `grok.exe`) as the rest of Orca's // agent-title detection, instead of maintaining a divergent regex here. diff --git a/src/main/runtime/rpc/methods/orchestration.test.ts b/src/main/runtime/rpc/methods/orchestration.test.ts index 6308b4257..cf0c722c6 100644 --- a/src/main/runtime/rpc/methods/orchestration.test.ts +++ b/src/main/runtime/rpc/methods/orchestration.test.ts @@ -372,6 +372,23 @@ describe('orchestration RPC methods', () => { expect(result.messages[0].to_handle).toBe('term_b') }) + it('fans out @cursor by title match without claiming a cursor-mentioning title', async () => { + setupWithTerminals([ + makeSummary('term_a', { title: 'Codex' }), + makeSummary('term_b', { title: 'Cursor ready' }), + makeSummary('term_c', { title: '✳ Fix the text cursor blink' }) + ]) + + const result = (await call('orchestration.send', { + from: 'term_a', + to: '@cursor', + subject: 'cursor only' + })) as { messages: { to_handle: string }[]; recipients: number } + + expect(result.recipients).toBe(1) + expect(result.messages[0].to_handle).toBe('term_b') + }) + it('fans out @worktree: to matching worktree', async () => { setupWithTerminals([ makeSummary('term_a', { worktreeId: 'wt_1' }), diff --git a/src/main/runtime/rpc/methods/orchestration.ts b/src/main/runtime/rpc/methods/orchestration.ts index 4232633f1..597ae99b7 100644 --- a/src/main/runtime/rpc/methods/orchestration.ts +++ b/src/main/runtime/rpc/methods/orchestration.ts @@ -504,7 +504,7 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [ if (!hasAgent) { throw new Error( `Cannot dispatch --inject to terminal ${to}: no recognized agent detected. ` + - 'Start an agent CLI (e.g. claude, codex, gemini, droid) in the terminal first, ' + + 'Start an agent CLI (e.g. claude, codex, gemini, droid, cursor) in the terminal first, ' + 'or dispatch without --inject and send the prompt manually.' ) } diff --git a/src/shared/agent-detection.test.ts b/src/shared/agent-detection.test.ts index 5ab927d30..7f2f1308d 100644 --- a/src/shared/agent-detection.test.ts +++ b/src/shared/agent-detection.test.ts @@ -5,6 +5,7 @@ import { extractAllOscTitles, extractLastOscTitle, getAgentLabel, + isCursorAgentTitle, MAX_OSC_TITLE_CHARS, normalizeTerminalTitle } from './agent-detection' @@ -14,6 +15,7 @@ import { normalizeCompatibleAgentTitleForOwner, resolveCompatibleAgentTypeForOwner } from './agent-title-owner' +import { SYNTHETIC_AGENT_TITLE_PROFILES } from './synthetic-agent-title' afterEach(() => { vi.restoreAllMocks() @@ -177,3 +179,50 @@ describe('Pi-compatible title detection', () => { } ) }) + +describe('Cursor agent title identity', () => { + // Why: the accepted vocabulary is the set of labels Orca actually synthesizes for Cursor. + // Pin it to that profile so renaming a label there cannot silently drop @cursor to zero + // recipients (and desync the auto-Enter suppression that shares this predicate). + it('accepts every label Orca synthesizes for Cursor', () => { + const profile = SYNTHETIC_AGENT_TITLE_PROFILES.cursor + + for (const label of [ + profile.workingLabel, + `⠋ ${profile.workingLabel}`, + profile.idleLabel, + profile.permissionLabel + ]) { + expect(isCursorAgentTitle(label)).toBe(true) + } + }) + + it.each([ + 'Cursor Agent', + ' cursor agent ', + '⠋ Cursor Agent', + '⣿ Cursor Agent', + 'Cursor ready', + 'Cursor - action required' + ])('accepts the native or Orca-synthesized Cursor title %j', (title) => { + expect(isCursorAgentTitle(title)).toBe(true) + }) + + // Why: "cursor" is ordinary editor vocabulary in another agent's task-summary title, + // so a whole-token name match is not Cursor identity. + it.each([ + '⠋ fix the text cursor blink', + '✳ Fix the text cursor blink', + '. fix cursor position', + '* cursor rendering done', + 'Terminal Cursor and Orca slows down', + 'cursor-agent', + 'cursor.exe', + '~/cursor-rules', + '', + null, + undefined + ])('rejects the non-Cursor title %j', (title) => { + expect(isCursorAgentTitle(title)).toBe(false) + }) +}) diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index dbb13895a..891f37093 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -11,6 +11,7 @@ export type { AgentStatus } from './agent-title-core' export { isClaudeManagementTitle, + isCursorAgentTitle, isCursorNativeAgentTitle, isGeminiTerminalTitle, isPiTerminalTitle, diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 1bafae3cb..15dc12d65 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -108,3 +108,25 @@ export function isClaudeManagementTitle(title: string): boolean { export function isCursorNativeAgentTitle(title: string): boolean { return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER } + +// Why: `cursor` is also an ordinary editor noun that other agents type into their own +// task-summary titles, so a name token is not identity. Cursor's identifying titles are +// a closed set (the native literal plus the labels Orca synthesizes from Cursor hooks), +// so match that vocabulary instead. +export function isCursorAgentTitle(title: string | null | undefined): boolean { + if (typeof title !== 'string') { + return false + } + const trimmed = title.trim() + const lower = trimmed.toLowerCase() + if ( + lower === CURSOR_NATIVE_TITLE_LOWER || + lower === 'cursor ready' || + lower === 'cursor - action required' + ) { + return true + } + // Why: display labels can mention Cursor in another agent's task text. Only + // treat the controlled synthetic Cursor spinner title as Cursor identity. + return /^[\u2800-\u28ff] Cursor Agent$/u.test(trimmed) +}