Add Cursor orchestration group routing (#8436)

Co-authored-by: nikg24 <228026988+nikg24@users.noreply.github.com>
This commit is contained in:
nikg24 2026-07-13 01:39:18 +01:00 committed by GitHub
parent d452385bf7
commit 67cd462b24
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 200 additions and 26 deletions

View File

@ -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')

View File

@ -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:<id>`.
- Group addresses include `@all`, `@idle`, `@claude`, `@codex`, `@opencode`, `@gemini`, `@droid`, `@grok`, `@cursor`, and `@worktree:<id>`.
- 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.

View File

@ -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

View File

@ -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', () => {

View File

@ -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<Record<AgentNameGroup, (title: string) => 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.

View File

@ -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:<id> to matching worktree', async () => {
setupWithTerminals([
makeSummary('term_a', { worktreeId: 'wt_1' }),

View File

@ -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.'
)
}

View File

@ -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)
})
})

View File

@ -11,6 +11,7 @@
export type { AgentStatus } from './agent-title-core'
export {
isClaudeManagementTitle,
isCursorAgentTitle,
isCursorNativeAgentTitle,
isGeminiTerminalTitle,
isPiTerminalTitle,

View File

@ -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)
}