Add Cursor orchestration group routing (#8436)
Co-authored-by: nikg24 <228026988+nikg24@users.noreply.github.com>
This commit is contained in:
parent
d452385bf7
commit
67cd462b24
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
export type { AgentStatus } from './agent-title-core'
|
||||
export {
|
||||
isClaudeManagementTitle,
|
||||
isCursorAgentTitle,
|
||||
isCursorNativeAgentTitle,
|
||||
isGeminiTerminalTitle,
|
||||
isPiTerminalTitle,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue