diff --git a/src/main/runtime/terminal/output/terminal-title-observation.ts b/src/main/runtime/terminal/output/terminal-title-observation.ts index 6d0b0c76879..a81fc7fbe7f 100644 --- a/src/main/runtime/terminal/output/terminal-title-observation.ts +++ b/src/main/runtime/terminal/output/terminal-title-observation.ts @@ -7,6 +7,7 @@ import { terminalTitleBlocksExplicitAgentStatus } from '../../workspaces/process import type { ClaudeTerminalEvidence } from '../../../../shared/claude-terminal-interrupt' import { agentStatusTakesTypedInput } from '../../orchestration/mailbox/routing/mailbox-owner' import { hermesNativeTitleStatus } from '../../../../shared/agent-status/title/providers/hermes-terminal-title' +import { jcodePaneTitleStatus } from '../../../../shared/agent-status/title/providers/jcode-terminal-title' export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWithGetUnpersistedTrackedTitleForPty { protected abstract collectPaneKeysForPty(ptyId: string): Set @@ -53,9 +54,11 @@ export abstract class OrcaRuntimeWithApplyTrackedPtyTitle extends OrcaRuntimeWit /** The PTY's agent status under this title: the program's own OSC 7501 activity while it * reports one, else what the title glyph says. */ protected classifyPtyTitleStatus(ptyId: string, rawTitle: string): AgentStatus | null { + const agent = this.getPaneTerminalAgent(ptyId) return ( this.ptyTitleTrackersByPtyId.get(ptyId)?.programActivity ?? - hermesNativeTitleStatus(rawTitle, this.getPaneTerminalAgent(ptyId)) ?? + hermesNativeTitleStatus(rawTitle, agent) ?? + jcodePaneTitleStatus(rawTitle, agent) ?? detectAgentStatusFromTitle(rawTitle) ) } diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts index c3b5296cf45..97db8a57b2a 100644 --- a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-agent-rows.ts @@ -8,6 +8,7 @@ import { import { detectAgentStatusFromTitle } from '../../../../../../shared/agent-status/title/agent-title-status' import { getAgentLabel } from '../../../../../../shared/agent-status/title/agent-title-identity' +import { jcodePaneTitleStatus } from '../../../../../../shared/agent-status/title/providers/jcode-terminal-title' import { tabHasLivePty } from '../../../../../../shared/tab-has-live-pty' import type { AgentStatusEntry, @@ -182,7 +183,8 @@ function buildTitleDerivedAgentRow(args: { return null } // Why: the title sets activity only; a plain title on a process-identified pane is idle. - const status = titleStatus ?? 'idle' + // Jcode's own tail outranks agent names its conversation title happens to mention. + const status = jcodePaneTitleStatus(title, agentType) ?? titleStatus ?? 'idle' const rowLabel = agentType === titleAgentType && label && diff --git a/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-jcode-rows.test.ts b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-jcode-rows.test.ts new file mode 100644 index 00000000000..854f0eac2c0 --- /dev/null +++ b/src/renderer/src/components/sidebar/workspace-card/activity/worktree-title-derived-jcode-rows.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab } from '../../../../../../shared/workspace-layout/terminal-tab-types' +import { makePaneKey } from '../../../../../../shared/workspace-layout/stable-pane-id' +import { buildTitleDerivedAgentRows } from './worktree-title-derived-agent-rows' + +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = makePaneKey('tab-1', LEAF_ID) + +function jcodeRow(title: string) { + const tab: TerminalTab = { + id: 'tab-1', + ptyId: 'pty-1', + worktreeId: 'wt-1', + title, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 0 + } + const rows = buildTitleDerivedAgentRows({ + tabs: [tab], + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: { + 'tab-1': { root: null, activeLeafId: LEAF_ID, expandedLeafId: null } + }, + // Linux reads jcode-linux-x86_64.bin; the foreground tracker publishes it as jcode. + paneForegroundAgentByPaneKey: { + [PANE_KEY]: { agent: 'jcode', agentEvidence: 'process-read', shellForeground: false } + }, + seenPaneKeys: new Set(), + now: 0 + }) + return rows.map((row) => ({ agentType: row.agentType, state: row.state })) +} + +describe('title-derived Jcode rows', () => { + it('keeps the row through Jcode titles and follows its turn tail', () => { + expect(jcodeRow('🐩 jcode/marsh Poodle')).toEqual([{ agentType: 'jcode', state: 'idle' }]) + expect(jcodeRow('🦊 Fix codex hooks · +3 -1 · work ~12s')).toEqual([ + { agentType: 'jcode', state: 'working' } + ]) + expect(jcodeRow('🦊 Fix codex hooks · last ~2m05s')).toEqual([ + { agentType: 'jcode', state: 'idle' } + ]) + }) +}) diff --git a/src/shared/agent-status/process/agent-process-recognition.ts b/src/shared/agent-status/process/agent-process-recognition.ts index 299b0dcbd3d..7789f0190b9 100644 --- a/src/shared/agent-status/process/agent-process-recognition.ts +++ b/src/shared/agent-status/process/agent-process-recognition.ts @@ -89,6 +89,10 @@ function agentForNormalizedProcess(normalized: string): TerminalAgent | undefine if (normalized.startsWith('grok-')) { return PROCESS_TO_AGENT.get('grok') } + // Jcode's Linux release wraps `jcode` around jcode-linux-.bin; raw release binaries keep the platform name. + if (/^jcode-(?:linux|macos|freebsd|windows)-(?:x86_64|aarch64)(?:\.bin)?$/.test(normalized)) { + return PROCESS_TO_AGENT.get('jcode') + } // Muse execs a versioned muse-bin binary; this prefix also covers truncated comm without matching unrelated muse-* tools. if (normalized.startsWith('muse-bin-')) { return PROCESS_TO_AGENT.get('muse') @@ -189,7 +193,8 @@ export function isExpectedAgentProcess( /^(?:qoderclicn|qodercn)(?:-\d.*)?$/.test(normalizedProcess)) || (['qoder', 'qodercli'].includes(normalizedExpected) && /^(?:qoder|qodercli(?:-\d.*)?)$/.test(normalizedProcess)) || - (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) + (normalizedExpected === 'muse' && normalizedProcess.startsWith('muse-bin-')) || + (normalizedExpected === 'jcode' && agentForNormalizedProcess(normalizedProcess) === 'jcode') ) } diff --git a/src/shared/agent-status/title/agent-title-evidence.ts b/src/shared/agent-status/title/agent-title-evidence.ts index 3ab626f3ef5..21e19b7b312 100644 --- a/src/shared/agent-status/title/agent-title-evidence.ts +++ b/src/shared/agent-status/title/agent-title-evidence.ts @@ -30,6 +30,7 @@ import { } from './providers/pi-compatible-synthetic-title' import { isDshTerminalTitle } from './providers/dsh-terminal-title' import { hasQoderTitleLabelPayload, isQoderTerminalTitle } from './providers/qoder-terminal-title' +import { isJcodeIdentityTerminalTitle } from './providers/jcode-terminal-title' import { SYNTHETIC_AGENT_TITLE_AGENTS, SYNTHETIC_AGENT_TITLE_PROFILES @@ -265,6 +266,7 @@ function collectObserverAnchors(title: string): TerminalAgent[] { ? 'dsb' : null) ?? agentForNativeFrame(segment) ?? + (isJcodeIdentityTerminalTitle(segment) ? 'jcode' : null) ?? agentForWholeTitle(segment) ?? agentForSyntheticTitle(segment) ?? (ANTIGRAVITY_MODEL_TITLE_RE.test(undecorated) ? 'antigravity' : null) ?? diff --git a/src/shared/agent-status/title/agent-title-identity.ts b/src/shared/agent-status/title/agent-title-identity.ts index 706e9045106..a367ab801ac 100644 --- a/src/shared/agent-status/title/agent-title-identity.ts +++ b/src/shared/agent-status/title/agent-title-identity.ts @@ -1,4 +1,5 @@ import { isQoderTerminalTitle } from './providers/qoder-terminal-title' +import { isJcodeIdentityTerminalTitle } from './providers/jcode-terminal-title' import { getPiStateTitleBrand } from './providers/pi-state-title-marker' import { AGY_AGENT_NAME_RE, @@ -110,7 +111,7 @@ function computeAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'devin')) { return 'Devin' } - if (titleHasAgentName(title, 'jcode')) { + if (titleHasAgentName(title, 'jcode') || isJcodeIdentityTerminalTitle(title)) { return 'Jcode' } if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { diff --git a/src/shared/agent-status/title/agent-title-status.ts b/src/shared/agent-status/title/agent-title-status.ts index 325a0114a33..16a93a7c99e 100644 --- a/src/shared/agent-status/title/agent-title-status.ts +++ b/src/shared/agent-status/title/agent-title-status.ts @@ -1,5 +1,6 @@ import { getDeepSeekBuildTitleStatus } from './providers/dsb-terminal-title' import { qoderTitleStatus } from './providers/qoder-terminal-title' +import { isJcodeIdentityTerminalTitle } from './providers/jcode-terminal-title' import { cursorTitleStatus } from './providers/cursor-terminal-title' import { AGY_AGENT_NAME_RE, @@ -279,6 +280,10 @@ function computeAgentStatusFromTitle(title: string): AgentStatus | null { if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { return 'idle' } + // Activity comes from jcodePaneTitleStatus; the identity alone says Jcode is at rest. + if (isJcodeIdentityTerminalTitle(title)) { + return 'idle' + } // Read π ! as blocked before generic idle or OMP user waits disappear. const piCompatibleSeparatorStatus = getPiCompatibleTitleSeparatorStatus(title) if (piCompatibleSeparatorStatus) { diff --git a/src/shared/agent-status/title/providers/jcode-terminal-title.ts b/src/shared/agent-status/title/providers/jcode-terminal-title.ts index a419622e7f3..ec3dec628ec 100644 --- a/src/shared/agent-status/title/providers/jcode-terminal-title.ts +++ b/src/shared/agent-status/title/providers/jcode-terminal-title.ts @@ -1,17 +1,43 @@ +import { CLAUDE_IDLE } from '../agent-title-glyphs' + // jcode updates emoji/session/diff/duration titles about once a second (terminal_title.rs); generated codenames are identity, not conversation names. // Captured provenance: docs/reference/jcode-hook-events.md. +// (? is a shell path. +const JCODE_IDENTITY_TITLE_RE = /^jcode(?:(?:\/[\w-]+)?\s+[^\s\u00B7]+)?$/iu /** True when a jcode title says only which jcode session this is, not what it is doing. */ export function isJcodeIdentityTerminalTitle(title: string | null | undefined): boolean { - return Boolean(title) && JCODE_IDENTITY_TITLE_RE.test(stripJcodeTitleStatus(title ?? '')) + // Claude's ✳ is also an emoji; `✳ Jcode hooks` is a Claude task title. + return ( + Boolean(title) && + /jcode/i.test(title ?? '') && + !title?.trimStart().startsWith(CLAUDE_IDLE) && + JCODE_IDENTITY_TITLE_RE.test(stripJcodeTitleStatus(title ?? '')) + ) +} + +// title_with_metrics: `· work ~2m05s` (or `· working`) mid-turn, `· last ~2m05s` after it. +const JCODE_ACTIVITY_TAIL_RE = + /\s\u00B7\s(?:(work|last)\s~(?:\d+h\d{2}m|\d+m\d{2}s|\d+s)|working)$/u + +/** The tail is too generic to identify Jcode, and title-only readers must not see a turn that a + * later conversation title drops, so it counts only in a pane already known to run Jcode. */ +export function jcodePaneTitleStatus( + title: string, + agent: string | null | undefined +): 'working' | 'idle' | null { + if (agent !== 'jcode') { + return null + } + const tail = JCODE_ACTIVITY_TAIL_RE.exec(title) + return tail ? (tail[1] === 'last' ? 'idle' : 'working') : null } diff --git a/src/shared/agent-status/title/terminal-title-agent-type.ts b/src/shared/agent-status/title/terminal-title-agent-type.ts index 59518d183c5..23a785b32a4 100644 --- a/src/shared/agent-status/title/terminal-title-agent-type.ts +++ b/src/shared/agent-status/title/terminal-title-agent-type.ts @@ -1,5 +1,6 @@ import { collectExplicitAgentTitleEvidence } from './agent-title-evidence' import { isQoderTerminalTitle } from './providers/qoder-terminal-title' +import { isJcodeIdentityTerminalTitle } from './providers/jcode-terminal-title' import { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, @@ -180,7 +181,7 @@ function computeAgentLabel(title: string): string | null { if (titleHasAgentName(title, 'devin')) { return 'Devin' } - if (titleHasAgentName(title, 'jcode')) { + if (titleHasAgentName(title, 'jcode') || isJcodeIdentityTerminalTitle(title)) { return 'Jcode' } if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { diff --git a/src/shared/jcode-agent-identity.test.ts b/src/shared/jcode-agent-identity.test.ts new file mode 100644 index 00000000000..6352d1c9348 --- /dev/null +++ b/src/shared/jcode-agent-identity.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' +import { + isExpectedAgentProcess, + recognizeAgentProcessFromCommandLine +} from './agent-status/process/agent-process-recognition' +import { detectAgentStatusFromTitle } from './agent-status/title/agent-title-status' +import { resolveExplicitTerminalTitleAgentType } from './agent-status/title/terminal-title-agent-type' +import { jcodePaneTitleStatus } from './agent-status/title/providers/jcode-terminal-title' + +describe('Jcode pane identity and title status', () => { + it('recognizes the platform binary the Linux release wrapper execs', () => { + for (const command of [ + '/home/dev/.local/bin/jcode-versions/0.99.1/jcode-linux-x86_64.bin', + '/home/dev/.local/bin/jcode-versions/0.99.1/jcode-linux-aarch64.bin --resume fox' + ]) { + expect(recognizeAgentProcessFromCommandLine(command)?.agent).toBe('jcode') + expect(isExpectedAgentProcess(command.split(' ')[0], 'jcode')).toBe(true) + } + expect(recognizeAgentProcessFromCommandLine('jcode-helper')).toBeNull() + }) + + it('reads the turn tail only in a Jcode pane', () => { + for (const title of [ + '🌐 jcode/fountain Duck · work ~2s', + '🦊 Fix codex hooks · +12 -3 · work ~2m05s', + '🐩 jcode Poodle · working' + ]) { + expect(jcodePaneTitleStatus(title, 'jcode')).toBe('working') + expect(jcodePaneTitleStatus(title, 'claude')).toBeNull() + } + expect(jcodePaneTitleStatus('jcode SSH devbox fox · last ~1h05m', 'jcode')).toBe('idle') + // Title-only readers see the identity at rest, never a turn a later title would drop. + expect(detectAgentStatusFromTitle('🌐 jcode/fountain Duck · work ~2s')).toBe('idle') + expect(detectAgentStatusFromTitle('🦊 Improve session tabs · work ~2m05s')).toBeNull() + }) + + it('identifies Jcode from its server-qualified session title', () => { + expect(resolveExplicitTerminalTitleAgentType('🌐 jcode/fountain Duck · work ~2s')).toBe('jcode') + expect(resolveExplicitTerminalTitleAgentType('✳ Jcode hooks')).not.toBe('jcode') + expect(resolveExplicitTerminalTitleAgentType('jcode/scripts')).not.toBe('jcode') + }) +})