Fix remote OMP terminal title thrash (#6689)
Fixes #6619: OMP-owned remote/mobile terminals no longer flicker their tab label between "OMP" and "Pi". OMP wraps Pi, so it emits Pi-identity OSC titles and status frames during active work. On the host, in mirrored remote tabs, and in the title-derived sidebar rows, those frames were stored verbatim — so an OMP-launched pane alternated between "OMP" (launch identity) and "Pi" (live frame). The fix introduces a shared owner-normalization helper (agent-title-owner.ts) that rewrites Pi-compatible titles/status entries to the authoritative launch owner, but only when the incoming and owner profiles share the same titleIdentityGroup — so true Pi sessions, unrelated agents, and custom titles are left untouched. Maintainer hardening on top of the original change: - Skip the new getForegroundProcess probe entirely when launchAgent is already known (it is only ever the owner fallback when launchAgent is unknown), and gate the onPtyData trigger on a real status transition rather than per-frame braille-spinner title churn — avoiding a relay round-trip per output frame on SSH/daemon-backed terminals. - Make the foreground refresh fire-and-forget on the mobile listing hot path (listTerminals/getWorktreePs) so latency does not grow per session and a throwing snapshot listener cannot abort the liveness sweep. - Added regression tests + a Why comment on the renderer owner precedence. Verified: 829 tests pass; node/web/cli typechecks clean; oxlint clean; reproduced the flicker against main as a negative control and confirmed the live renderer build collapses interleaved OMP/Pi frames to a stable OMP label with zero Pi leaks while leaving true-Pi/unrelated/custom titles unchanged. Co-authored-by: Dvitash <dvitash3414@gmail.com>
This commit is contained in:
parent
9b275fa16f
commit
29df9a3ab5
|
|
@ -11128,6 +11128,200 @@ describe('OrcaRuntimeService', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('preserves authoritative OMP identity for Pi-compatible remote terminal snapshots', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
const hostPaneKey = `tab-1:${leafId}`
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [],
|
||||
leaves: [],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: 'group-1',
|
||||
activeTabId: `tab-1::${leafId}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `tab-1::${leafId}`,
|
||||
parentTabId: 'tab-1',
|
||||
leafId,
|
||||
title: '\u280b Pi',
|
||||
launchAgent: 'omp',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
prompt: 'fix parity',
|
||||
updatedAt: 1_700_000_000_000,
|
||||
stateStartedAt: 1_699_999_999_000,
|
||||
agentType: 'pi',
|
||||
paneKey: hostPaneKey,
|
||||
terminalTitle: '\u280b Pi',
|
||||
stateHistory: []
|
||||
},
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(result.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: '\u280b OMP',
|
||||
launchAgent: 'omp',
|
||||
agentStatus: expect.objectContaining({
|
||||
state: 'working',
|
||||
agentType: 'omp',
|
||||
paneKey: hostPaneKey,
|
||||
terminalTitle: '\u280b OMP'
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('derives remote OMP owner from live PTY metadata when the tab snapshot omits it', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-omp' })
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'omp',
|
||||
launchAgent: 'omp',
|
||||
title: 'OMP',
|
||||
activate: true
|
||||
})
|
||||
const spawnCall = spawn.mock.calls[0]?.[0]
|
||||
expect(spawnCall).toEqual(
|
||||
expect.objectContaining({
|
||||
tabId: expect.any(String),
|
||||
leafId: expect.any(String)
|
||||
})
|
||||
)
|
||||
const { tabId, leafId } = spawnCall as { tabId: string; leafId: string }
|
||||
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: '\u280b π - tmp',
|
||||
activeLeafId: leafId,
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId,
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-omp',
|
||||
paneTitle: '\u280b π - tmp'
|
||||
}
|
||||
],
|
||||
mobileSessionTabs: [
|
||||
{
|
||||
worktree: TEST_WORKTREE_ID,
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 1,
|
||||
activeGroupId: null,
|
||||
activeTabId: `${tabId}::${leafId}`,
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: `${tabId}::${leafId}`,
|
||||
parentTabId: tabId,
|
||||
leafId,
|
||||
ptyId: 'pty-omp',
|
||||
title: '\u280b π - tmp',
|
||||
isActive: true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const result = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
|
||||
expect(result.tabs[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'terminal',
|
||||
title: '\u280b OMP',
|
||||
launchAgent: 'omp'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('skips the foreground-process probe when the PTY launch agent is already known', async () => {
|
||||
// Why: foregroundAgent is only the owner fallback when launchAgent is unknown,
|
||||
// so probing a launched agent (e.g. omp) would burn a relay round-trip on every
|
||||
// status transition without ever changing the resolved owner.
|
||||
const getForegroundProcess = vi.fn(async () => 'omp')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-omp' }),
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'omp',
|
||||
launchAgent: 'omp',
|
||||
title: 'OMP',
|
||||
activate: true
|
||||
})
|
||||
|
||||
runtime.onPtyData('pty-omp', '\x1b]0;⠋ OMP\x07working\n', 100)
|
||||
runtime.onPtyData('pty-omp', '\x1b]0;OMP ready\x07idle\n', 200)
|
||||
|
||||
expect(getForegroundProcess).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('probes the foreground process only on a status transition for unknown launch agents', async () => {
|
||||
const getForegroundProcess = vi.fn(async () => 'omp')
|
||||
const runtime = createRuntime()
|
||||
runtime.setPtyController({
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess
|
||||
})
|
||||
syncSinglePty(runtime, 'pty-bg')
|
||||
// Why: each probe dedups while in-flight, so settle it before the next frame
|
||||
// to prove the gate (not the dedup) is what suppresses extra probes.
|
||||
const settleProbe = (): Promise<void> => new Promise((resolve) => setImmediate(resolve))
|
||||
|
||||
// Two working frames (spinner churn) collapse to a single status transition.
|
||||
runtime.onPtyData('pty-bg', '\x1b]0;⠋ OMP\x07alpha\n', 100)
|
||||
runtime.onPtyData('pty-bg', '\x1b]0;⠊ OMP\x07bravo\n', 200)
|
||||
await settleProbe()
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Transition to idle is a second distinct status, so it probes again.
|
||||
runtime.onPtyData('pty-bg', '\x1b]0;OMP ready\x07charlie\n', 300)
|
||||
await settleProbe()
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
|
||||
// A repeated idle frame is not a transition, so it does not probe again.
|
||||
runtime.onPtyData('pty-bg', '\x1b]0;OMP ready\x07delta\n', 400)
|
||||
await settleProbe()
|
||||
expect(getForegroundProcess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps renderer-vetted mobile agent status for custom-titled terminals', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const leafId = '11111111-1111-4111-8111-111111111111'
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
type AgentStatusOrchestrationContext,
|
||||
type AgentStatusEntry
|
||||
} from '../../shared/agent-status-types'
|
||||
import {
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner
|
||||
} from '../../shared/agent-title-owner'
|
||||
import {
|
||||
createAgentStatusOscProcessor,
|
||||
type ProcessedAgentStatusChunk
|
||||
|
|
@ -905,6 +909,7 @@ type RuntimePtyWorktreeRecord = {
|
|||
launchConfig: SleepingAgentLaunchConfig | null
|
||||
launchToken: string | null
|
||||
launchAgent: TuiAgent | null
|
||||
foregroundAgent: TuiAgent | null
|
||||
connected: boolean
|
||||
disconnectedAt: number | null
|
||||
lastExitCode: number | null
|
||||
|
|
@ -1866,6 +1871,7 @@ export class OrcaRuntimeService {
|
|||
private resolvedWorktreeGeneration = 0
|
||||
private cloneInFlightByPath = new Map<string, Promise<void>>()
|
||||
private agentDetector: AgentDetector | null = null
|
||||
private ptyForegroundAgentRefreshes = new Map<string, Promise<void>>()
|
||||
private _orchestrationDb: OrchestrationDb | null = null
|
||||
private messageWaitersByHandle = new Map<string, Set<MessageWaiter>>()
|
||||
// Why: mobile clients subscribe to terminal output via terminal.subscribe.
|
||||
|
|
@ -3176,6 +3182,10 @@ export class OrcaRuntimeService {
|
|||
.filter((group) => group.tabOrder.length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a PTY-backed terminal tab snapshot to the synced mobile session,
|
||||
* normalizing Pi-compatible titles based on launch ownership.
|
||||
*/
|
||||
private publishPtyBackedMobileSessionTerminal(
|
||||
worktreeId: string,
|
||||
pty: RuntimePtyWorktreeRecord,
|
||||
|
|
@ -3189,7 +3199,10 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
): void {
|
||||
const existing = this.mobileSessionTabsByWorktree.get(worktreeId)
|
||||
const title = args.title ?? getLatestPtyTitle(pty) ?? 'Terminal'
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
args.title ?? getLatestPtyTitle(pty) ?? 'Terminal',
|
||||
pty.launchAgent
|
||||
)
|
||||
const existingTab = existing?.tabs.find(
|
||||
(candidate): candidate is RuntimeMobileSessionTerminalTab =>
|
||||
candidate.type === 'terminal' &&
|
||||
|
|
@ -3219,6 +3232,7 @@ export class OrcaRuntimeService {
|
|||
leafId: args.leafId,
|
||||
ptyId: pty.ptyId,
|
||||
title,
|
||||
...(pty.launchAgent ? { launchAgent: pty.launchAgent } : {}),
|
||||
parentLayout,
|
||||
isActive:
|
||||
args.activate || (args.selectIfNoActiveTab !== false && existing?.activeTabId == null)
|
||||
|
|
@ -4942,6 +4956,10 @@ export class OrcaRuntimeService {
|
|||
this.recordPtyWorktree(ptyId, worktreeId, { connected: true, connectionId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles incoming data from a PTY process, running agent detection,
|
||||
* updating terminal tail buffers, and triggering foreground agent refreshes.
|
||||
*/
|
||||
onPtyData(ptyId: string, data: string, at: number): number {
|
||||
const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length
|
||||
this.ptyOutputSequenceById.set(ptyId, outputSequence)
|
||||
|
|
@ -5034,6 +5052,12 @@ export class OrcaRuntimeService {
|
|||
if (agentStatus === 'idle' && prevStatus !== 'idle') {
|
||||
this.resolvePtyTuiIdleWaiters(pty, ptyId)
|
||||
}
|
||||
// Why: gate on an actual status transition — braille spinner frames
|
||||
// mutate the title every tick, so probing per-title-change would stream
|
||||
// a foreground query per frame during active work.
|
||||
if (prevStatus !== pty.lastAgentStatus) {
|
||||
this.refreshPtyForegroundAgent(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -8401,6 +8425,64 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules an asynchronous query to check which agent process is currently
|
||||
* running in the foreground of a PTY.
|
||||
*/
|
||||
private refreshPtyForegroundAgent(ptyId: string): void {
|
||||
void this.refreshPtyForegroundAgentFromController(ptyId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicates and manages in-flight foreground agent refresh queries
|
||||
* for a specific PTY.
|
||||
*/
|
||||
private refreshPtyForegroundAgentFromController(ptyId: string): Promise<void> {
|
||||
const pendingRefresh = this.ptyForegroundAgentRefreshes.get(ptyId)
|
||||
if (pendingRefresh) {
|
||||
return pendingRefresh
|
||||
}
|
||||
const refresh = this.loadPtyForegroundAgentFromController(ptyId).finally(() => {
|
||||
this.ptyForegroundAgentRefreshes.delete(ptyId)
|
||||
})
|
||||
this.ptyForegroundAgentRefreshes.set(ptyId, refresh)
|
||||
return refresh
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries the PTY controller for the active foreground process, identifies if it
|
||||
* is a recognized agent, and updates the PTY's foreground agent state if changed.
|
||||
*/
|
||||
private async loadPtyForegroundAgentFromController(ptyId: string): Promise<void> {
|
||||
if (!this.ptyController) {
|
||||
return
|
||||
}
|
||||
const pty = this.ptysById.get(ptyId)
|
||||
if (!pty?.connected) {
|
||||
return
|
||||
}
|
||||
// Why: foregroundAgent is only consulted as the owner fallback when
|
||||
// launchAgent is unknown, so a known launchAgent makes the relay
|
||||
// getForegroundProcess round-trip pure waste (covers all launched agents).
|
||||
if (pty.launchAgent) {
|
||||
return
|
||||
}
|
||||
let foregroundProcess: string | null
|
||||
try {
|
||||
foregroundProcess = await this.ptyController.getForegroundProcess(ptyId)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const foregroundAgent = foregroundProcess
|
||||
? (recognizeAgentProcess(foregroundProcess)?.agent ?? null)
|
||||
: null
|
||||
if (pty.foregroundAgent === foregroundAgent) {
|
||||
return
|
||||
}
|
||||
pty.foregroundAgent = foregroundAgent
|
||||
this.touchMobileSessionSnapshotsForPty(ptyId)
|
||||
}
|
||||
|
||||
private getFreshExplicitAgentStatusForHandle(handle: string): {
|
||||
status: NonNullable<RuntimeTerminalAgentStatus['status']>
|
||||
updatedAt: number
|
||||
|
|
@ -17270,6 +17352,7 @@ export class OrcaRuntimeService {
|
|||
launchConfig: null,
|
||||
launchToken: null,
|
||||
launchAgent: null,
|
||||
foregroundAgent: null,
|
||||
connected: state.connected ?? true,
|
||||
disconnectedAt: state.connected === false ? Date.now() : null,
|
||||
lastExitCode: null,
|
||||
|
|
@ -17356,6 +17439,10 @@ export class OrcaRuntimeService {
|
|||
return this.recordPtyWorktree(ptyId, inferredWorktreeId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronizes PTY tracking records with the running daemon sessions,
|
||||
* querying their foreground agent states.
|
||||
*/
|
||||
private async refreshPtyWorktreeRecordsFromController(
|
||||
resolvedWorktrees: ResolvedWorktree[],
|
||||
targetWorktreeId: string | null = null
|
||||
|
|
@ -17385,6 +17472,10 @@ export class OrcaRuntimeService {
|
|||
connected: true
|
||||
})
|
||||
}
|
||||
// Why: fire-and-forget so this listing hot path (listTerminals/getWorktreePs)
|
||||
// does not serialize a relay round-trip per session — and a throwing snapshot
|
||||
// listener cannot abort the liveness sweep below.
|
||||
this.refreshPtyForegroundAgent(session.id)
|
||||
}
|
||||
for (const pty of this.ptysById.values()) {
|
||||
if (!livePtyIds.has(pty.ptyId) && !this.leafExistsForPty(pty.ptyId)) {
|
||||
|
|
@ -17850,6 +17941,10 @@ export class OrcaRuntimeService {
|
|||
return first ?? second
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms an internal mobile session tab snapshot into a sanitized client payload,
|
||||
* resolving launch agent ownership and normalizing titles.
|
||||
*/
|
||||
private toMobileSessionTabsResult(
|
||||
snapshot: RuntimeMobileSessionTabsSnapshot
|
||||
): RuntimeMobileSessionTabsResult {
|
||||
|
|
@ -17884,6 +17979,7 @@ export class OrcaRuntimeService {
|
|||
const leaf = this.leaves.get(this.getLeafKey(tab.parentTabId, tab.leafId)) ?? null
|
||||
const liveLeaf = leaf?.ptyId && leaf.connected ? leaf : null
|
||||
const liveLeafPtyId = liveLeaf?.ptyId ?? null
|
||||
const liveLeafPty = liveLeafPtyId ? (this.ptysById.get(liveLeafPtyId) ?? null) : null
|
||||
const pty = liveLeaf
|
||||
? null
|
||||
: this.findPtyForMobileTerminalTab(snapshot.worktree, tab, {
|
||||
|
|
@ -17906,9 +18002,17 @@ export class OrcaRuntimeService {
|
|||
{ title: pty.lastOscTitle, updatedAt: pty.lastOscTitleAt }
|
||||
)
|
||||
: null
|
||||
const title = leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title
|
||||
const launchAgent = tab.launchAgent ?? liveLeafPty?.launchAgent ?? pty?.launchAgent ?? null
|
||||
const ownerAgent = launchAgent ?? liveLeafPty?.foregroundAgent ?? pty?.foregroundAgent ?? null
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
leafTitle ?? ptyTitle ?? syncedTab?.title ?? tab.title,
|
||||
ownerAgent
|
||||
)
|
||||
const liveTitleEvidence = leafTitle ?? ptyTitle
|
||||
const liveTitleEvidenceClassification = classifyAgentTitle(liveTitleEvidence)
|
||||
const normalizedTabAgentStatus = tab.agentStatus
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(tab.agentStatus, ownerAgent)
|
||||
: null
|
||||
// Why: keep the rich hook-driven status when the agent has a live
|
||||
// interactive prompt or an active tool — those are authoritative agent
|
||||
// activity even if the terminal's title isn't agent-classified (e.g. it
|
||||
|
|
@ -17916,31 +18020,32 @@ export class OrcaRuntimeService {
|
|||
// the OSC-title-only status and never sees interactivePrompt (the question
|
||||
// card never renders).
|
||||
const hasLiveAgentSignal =
|
||||
tab.agentStatus?.interactivePrompt != null || tab.agentStatus?.toolName != null
|
||||
normalizedTabAgentStatus?.interactivePrompt != null ||
|
||||
normalizedTabAgentStatus?.toolName != null
|
||||
const keepFullAgentStatus =
|
||||
tab.agentStatus &&
|
||||
normalizedTabAgentStatus &&
|
||||
(liveTitleEvidence === null ||
|
||||
liveTitleEvidenceClassification === 'agent' ||
|
||||
hasLiveAgentSignal)
|
||||
const agentStatus = keepFullAgentStatus
|
||||
? { agentStatus: tab.agentStatus }
|
||||
? { agentStatus: normalizedTabAgentStatus }
|
||||
: // Why: when live title evidence says the pane is idle (e.g. the Claude
|
||||
// agents picker or a neutral shell title), suppress the stale "working"
|
||||
// state so the client shows no spinner — but retain agent identity
|
||||
// (agentType + providerSession) so native chat can still address an
|
||||
// idle agent's transcript. Reset the transient state to 'done'.
|
||||
tab.agentStatus?.agentType != null
|
||||
normalizedTabAgentStatus?.agentType != null
|
||||
? {
|
||||
agentStatus: {
|
||||
state: 'done' as const,
|
||||
prompt: '',
|
||||
updatedAt: tab.agentStatus.updatedAt,
|
||||
stateStartedAt: tab.agentStatus.stateStartedAt,
|
||||
paneKey: tab.agentStatus.paneKey,
|
||||
updatedAt: normalizedTabAgentStatus.updatedAt,
|
||||
stateStartedAt: normalizedTabAgentStatus.stateStartedAt,
|
||||
paneKey: normalizedTabAgentStatus.paneKey,
|
||||
stateHistory: [],
|
||||
agentType: tab.agentStatus.agentType,
|
||||
...(tab.agentStatus.providerSession
|
||||
? { providerSession: tab.agentStatus.providerSession }
|
||||
agentType: normalizedTabAgentStatus.agentType,
|
||||
...(normalizedTabAgentStatus.providerSession
|
||||
? { providerSession: normalizedTabAgentStatus.providerSession }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
|
@ -17966,7 +18071,7 @@ export class OrcaRuntimeService {
|
|||
title,
|
||||
...(tab.ptyId ? { ptyId: tab.ptyId } : {}),
|
||||
...(tab.terminalTheme ? { terminalTheme: tab.terminalTheme } : {}),
|
||||
...(tab.launchAgent ? { launchAgent: tab.launchAgent } : {}),
|
||||
...(launchAgent ? { launchAgent } : {}),
|
||||
...(agentStatus ?? this.buildPtyMobileAgentStatus(livePty ?? pty, tab, terminalHandle)),
|
||||
...(tab.parentLayout ? { parentLayout: tab.parentLayout } : {}),
|
||||
...(tab.color != null ? { color: tab.color } : {}),
|
||||
|
|
@ -18017,6 +18122,10 @@ export class OrcaRuntimeService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a mobile-friendly status entry for a PTY, aligning agentType
|
||||
* and titles with the active owner.
|
||||
*/
|
||||
private buildPtyMobileAgentStatus(
|
||||
pty: RuntimePtyWorktreeRecord | null,
|
||||
tab: RuntimeMobileSessionTerminalTab,
|
||||
|
|
@ -18034,6 +18143,8 @@ export class OrcaRuntimeService {
|
|||
return {}
|
||||
}
|
||||
const now = pty.lastOutputAt ?? Date.now()
|
||||
const ownerAgent = tab.launchAgent ?? pty.launchAgent ?? pty.foregroundAgent ?? null
|
||||
const agentType = ownerAgent ?? undefined
|
||||
return {
|
||||
agentStatus: {
|
||||
state:
|
||||
|
|
@ -18047,10 +18158,13 @@ export class OrcaRuntimeService {
|
|||
stateStartedAt: now,
|
||||
paneKey: this.getMobileTerminalPaneKey(tab),
|
||||
...(terminalHandle ? { terminalHandle } : {}),
|
||||
...(tab.launchAgent ? { agentType: tab.launchAgent } : {}),
|
||||
...(agentType ? { agentType } : {}),
|
||||
worktreeId: pty.worktreeId,
|
||||
tabId: tab.parentTabId,
|
||||
terminalTitle: getLatestPtyTitle(pty) ?? tab.title,
|
||||
terminalTitle: normalizeCompatibleAgentTitleForOwner(
|
||||
getLatestPtyTitle(pty) ?? tab.title,
|
||||
ownerAgent
|
||||
),
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -141,6 +141,38 @@ describe('buildWorktreeAgentRows', () => {
|
|||
expect(rows[0].agentType).toBe('codex')
|
||||
})
|
||||
|
||||
it('prefers an unrelated live title over the launched tab agent for unknown rows', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'omp', title: '\u280b Codex' })],
|
||||
entries: [
|
||||
makeEntry(PANE_KEY_1, 1000, {
|
||||
agentType: undefined,
|
||||
terminalTitle: '\u280b Codex'
|
||||
})
|
||||
],
|
||||
retained: [],
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows[0].agentType).toBe('codex')
|
||||
})
|
||||
|
||||
it('normalizes live Pi-compatible rows from the launched OMP tab agent', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'omp', title: '\u280b Pi' })],
|
||||
entries: [
|
||||
makeEntry(PANE_KEY_1, 1000, {
|
||||
agentType: 'pi',
|
||||
terminalTitle: '\u280b Pi'
|
||||
})
|
||||
],
|
||||
retained: [],
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows[0].agentType).toBe('omp')
|
||||
})
|
||||
|
||||
it('resolves retained unknown rows from the launched tab agent', () => {
|
||||
const retained = makeRetained(ORPHAN_PANE_KEY, 'wt-1', 1000, {
|
||||
entry: makeEntry(ORPHAN_PANE_KEY, 1000, {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
buildTitleDerivedAgentRows,
|
||||
resolveAgentTypeFromTerminalTitle
|
||||
} from './worktree-title-derived-agent-rows'
|
||||
import { resolveCompatibleAgentTypeForOwner } from '../../../../shared/agent-title-owner'
|
||||
|
||||
function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | null {
|
||||
const parsed = parsePaneKey(entry.paneKey)
|
||||
|
|
@ -40,14 +41,19 @@ function tabFromAttributedStatusEntry(entry: AgentStatusEntry): TerminalTab | nu
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the sidebar row agent type, prioritizing launch agent configuration
|
||||
* and normalizing compatible agent kinds.
|
||||
*/
|
||||
function resolveRowAgentType(entry: AgentStatusEntry, tab?: TerminalTab | null): AgentType {
|
||||
if (entry.agentType && entry.agentType !== 'unknown') {
|
||||
return entry.agentType
|
||||
const entryAgentType = resolveCompatibleAgentTypeForOwner(entry.agentType, tab?.launchAgent)
|
||||
if (entryAgentType && entryAgentType !== 'unknown') {
|
||||
return entryAgentType
|
||||
}
|
||||
return (
|
||||
resolveAgentTypeFromTerminalTitle(entry.terminalTitle ?? tab?.title, tab?.launchAgent) ??
|
||||
tab?.launchAgent ??
|
||||
resolveAgentTypeFromTerminalTitle(entry.terminalTitle ?? tab?.title) ??
|
||||
entry.agentType ??
|
||||
entryAgentType ??
|
||||
'unknown'
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,46 @@ describe('buildTitleDerivedAgentRows', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('normalizes Pi-compatible title-derived rows to the launched OMP owner', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'omp' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: {
|
||||
'tab-1': {
|
||||
1: '\u280b π: tmp'
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-omp'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
|
||||
['omp', 'working', '\u280b OMP']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps Pi-compatible title-derived rows as Pi for launched Pi sessions', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1', { launchAgent: 'pi' })],
|
||||
entries: [],
|
||||
retained: [],
|
||||
runtimePaneTitlesByTabId: {
|
||||
'tab-1': {
|
||||
1: '\u280b Pi'
|
||||
}
|
||||
},
|
||||
ptyIdsByTabId: { 'tab-1': ['pty-pi'] },
|
||||
terminalLayoutsByTabId: { 'tab-1': makeSingleLayout(LEAF_ID_1) },
|
||||
now: 2000
|
||||
})
|
||||
|
||||
expect(rows.map((row) => [row.agentType, row.state, row.entry.terminalTitle])).toEqual([
|
||||
['pi', 'working', '\u280b Pi']
|
||||
])
|
||||
})
|
||||
|
||||
it('does not add title-derived rows for panes without a live PTY', () => {
|
||||
const rows = buildWorktreeAgentRows({
|
||||
tabs: [makeTab('tab-1')],
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import type {
|
|||
TerminalPaneLayoutNode,
|
||||
TerminalTab
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
|
||||
const EMPTY_RUNTIME_TITLES: Record<string, Record<number, string>> = {}
|
||||
const EMPTY_LIVE_PTY_IDS: Record<string, string[]> = {}
|
||||
|
|
@ -36,7 +40,8 @@ const TITLE_AGENT_LABEL_TO_TYPE: Record<string, AgentType> = {
|
|||
Cursor: 'cursor',
|
||||
Droid: 'droid',
|
||||
Hermes: 'hermes',
|
||||
Pi: 'pi'
|
||||
Pi: 'pi',
|
||||
OMP: 'omp'
|
||||
}
|
||||
|
||||
const CLAUDE_AGENT_TOKEN_RE = /(?<![\w./\\-])claude(?![\w./\\-])/i
|
||||
|
|
@ -114,6 +119,10 @@ export function buildTitleDerivedAgentRows(args: {
|
|||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs a dashboard agent row from a terminal tab's title fallback,
|
||||
* normalising Pi-compatible agent names to their owner.
|
||||
*/
|
||||
function buildTitleDerivedAgentRow(args: {
|
||||
tab: TerminalTab
|
||||
leafId: string
|
||||
|
|
@ -121,12 +130,13 @@ function buildTitleDerivedAgentRow(args: {
|
|||
now: number
|
||||
runtimeAgentOrchestrationByPaneKey?: Record<string, AgentStatusOrchestrationContext>
|
||||
}): DashboardAgentRow | null {
|
||||
const isClaudeAgentsTitle = isClaudeManagementTitle(args.title)
|
||||
const title = normalizeCompatibleAgentTitleForOwner(args.title, args.tab.launchAgent)
|
||||
const isClaudeAgentsTitle = isClaudeManagementTitle(title)
|
||||
// Why: `claude agents` is a live Claude Code Agent Teams surface, but the
|
||||
// shared detector keeps it neutral so runtime liveness probes do not treat
|
||||
// the management/list screen as active work.
|
||||
const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(args.title)
|
||||
const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(args.title)
|
||||
const status = isClaudeAgentsTitle ? 'idle' : detectAgentStatusFromTitle(title)
|
||||
const label = isClaudeAgentsTitle ? 'Claude Code' : getAgentLabel(title)
|
||||
if (!status || !label) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -135,7 +145,7 @@ function buildTitleDerivedAgentRow(args: {
|
|||
}
|
||||
const paneKey = makePaneKey(args.tab.id, args.leafId)
|
||||
const orchestration = args.runtimeAgentOrchestrationByPaneKey?.[paneKey]
|
||||
const agentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(args.title, label)
|
||||
const agentType = isClaudeAgentsTitle ? 'claude' : resolveTitleDerivedAgentType(title, label)
|
||||
if (!agentType) {
|
||||
return null
|
||||
}
|
||||
|
|
@ -151,7 +161,7 @@ function buildTitleDerivedAgentRow(args: {
|
|||
stateStartedAt: args.now,
|
||||
stateHistory: [],
|
||||
agentType,
|
||||
terminalTitle: args.title,
|
||||
terminalTitle: title,
|
||||
lastAssistantMessage: secondary,
|
||||
...(orchestration ? { orchestration } : {})
|
||||
}
|
||||
|
|
@ -177,14 +187,25 @@ export function resolveTitleDerivedAgentType(title: string, label: string): Agen
|
|||
return CLAUDE_AGENT_TOKEN_RE.test(title) ? agentType : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the agent type from a terminal title, normalising Pi-compatible
|
||||
* agents to their authoritative owner if specified.
|
||||
*/
|
||||
export function resolveAgentTypeFromTerminalTitle(
|
||||
title: string | null | undefined
|
||||
title: string | null | undefined,
|
||||
ownerAgentType?: AgentType | null
|
||||
): AgentType | null {
|
||||
if (!title) {
|
||||
return null
|
||||
}
|
||||
const label = getAgentLabel(title)
|
||||
return label ? resolveTitleDerivedAgentType(title, label) : null
|
||||
const normalizedTitle = normalizeCompatibleAgentTitleForOwner(title, ownerAgentType)
|
||||
const label = getAgentLabel(normalizedTitle)
|
||||
return label
|
||||
? (resolveCompatibleAgentTypeForOwner(
|
||||
resolveTitleDerivedAgentType(normalizedTitle, label),
|
||||
ownerAgentType
|
||||
) ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
function titleStatusToRowState(
|
||||
|
|
|
|||
|
|
@ -52,7 +52,10 @@ function leafIdForPane(paneId: number): string {
|
|||
|
||||
type StoreState = {
|
||||
activeWorktreeId: string | null
|
||||
tabsByWorktree: Record<string, { id: string; ptyId: string | null; title?: string }[]>
|
||||
tabsByWorktree: Record<
|
||||
string,
|
||||
{ id: string; ptyId: string | null; title?: string; launchAgent?: string }[]
|
||||
>
|
||||
ptyIdsByTabId?: Record<string, string[]>
|
||||
terminalLayoutsByTabId?: Record<string, TerminalLayoutSnapshot>
|
||||
unreadTerminalTabs?: Record<string, true>
|
||||
|
|
@ -9287,40 +9290,76 @@ describe('connectPanePty', () => {
|
|||
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', 'Codex - action required')
|
||||
})
|
||||
|
||||
it('resolves synthetic terminal titles for remote hook status updates', async () => {
|
||||
it('normalizes Pi-compatible remote titles to authoritative OMP launch identity', async () => {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport('pty-devin')
|
||||
const transport = createMockTransport('pty-omp')
|
||||
transportFactoryQueue.push(transport)
|
||||
enableActiveRuntimeEnvironment()
|
||||
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Devin' } }
|
||||
mockStoreState.tabsByWorktree = {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty', launchAgent: 'omp' }]
|
||||
}
|
||||
mockStoreState.runtimePaneTitlesByTabId = { 'tab-1': { 1: '\u280b Pi' } }
|
||||
|
||||
const pane = createPane(1)
|
||||
const manager = createManager(1)
|
||||
manager.getActivePane.mockReturnValue({ id: 1 })
|
||||
const deps = createDeps()
|
||||
|
||||
connectPanePty(pane as never, manager as never, deps as never)
|
||||
|
||||
const titleHandler = createdTransportOptions[0]?.onTitleChange as
|
||||
| ((title: string, rawTitle: string) => void)
|
||||
| undefined
|
||||
if (!titleHandler) {
|
||||
throw new Error('Expected onTitleChange to be registered')
|
||||
}
|
||||
titleHandler('\u280b Pi', '\u280b Pi')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, '\u280b OMP')
|
||||
expect(deps.updateTabTitle).toHaveBeenCalledWith('tab-1', '\u280b OMP')
|
||||
titleHandler('π: tmp', 'π: tmp')
|
||||
expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'OMP ready')
|
||||
expect(deps.updateTabTitle).toHaveBeenLastCalledWith('tab-1', 'OMP ready')
|
||||
|
||||
const statusHandler = createdTransportOptions[0]?.onAgentStatus as
|
||||
| ((payload: { state: 'done'; prompt: string; agentType: 'devin' }) => void)
|
||||
| ((payload: { state: 'working'; prompt: string; agentType: 'pi' }) => void)
|
||||
| undefined
|
||||
if (!statusHandler) {
|
||||
throw new Error('Expected onAgentStatus to be registered')
|
||||
}
|
||||
|
||||
statusHandler({
|
||||
state: 'done',
|
||||
prompt: 'finish the implementation',
|
||||
agentType: 'devin'
|
||||
state: 'working',
|
||||
prompt: 'fix the remote title',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(mockStoreState.setAgentStatus).toHaveBeenCalledWith(
|
||||
makePaneKey('tab-1', LEAF_1),
|
||||
{
|
||||
state: 'done',
|
||||
prompt: 'finish the implementation',
|
||||
agentType: 'devin'
|
||||
state: 'working',
|
||||
prompt: 'fix the remote title',
|
||||
agentType: 'omp'
|
||||
},
|
||||
'Devin ready'
|
||||
'\u280b OMP'
|
||||
)
|
||||
|
||||
mockStoreState.tabsByWorktree = {
|
||||
'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }]
|
||||
}
|
||||
statusHandler({
|
||||
state: 'working',
|
||||
prompt: 'keep the remote title',
|
||||
agentType: 'pi'
|
||||
})
|
||||
|
||||
expect(mockStoreState.setAgentStatus).toHaveBeenLastCalledWith(
|
||||
makePaneKey('tab-1', LEAF_1),
|
||||
{
|
||||
state: 'working',
|
||||
prompt: 'keep the remote title',
|
||||
agentType: 'omp'
|
||||
},
|
||||
'\u280b OMP'
|
||||
)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -76,7 +76,7 @@ import { createBrowserUuid } from '@/lib/browser-uuid'
|
|||
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
|
||||
import { e2eConfig } from '@/lib/e2e-config'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
|
||||
import { isWebTerminalSurfaceTabId } from '@/runtime/web-terminal-surface-id'
|
||||
import {
|
||||
createAgentInterruptInference,
|
||||
|
|
@ -132,6 +132,10 @@ import {
|
|||
type ResumableTuiAgent,
|
||||
type SleepingAgentSessionRecord
|
||||
} from '../../../../shared/agent-session-resume'
|
||||
import {
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from '../../../../shared/agent-title-owner'
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import { isWslUncPath } from '../../../../shared/wsl-paths'
|
||||
|
||||
|
|
@ -808,6 +812,10 @@ function containsCursorRestore(data: string): boolean {
|
|||
return hideIndex !== -1 && showIndex > hideIndex && containsCursorPositionSequence(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Establishes a binding between a terminal pane and its corresponding PTY stream,
|
||||
* managing input, output, title synchronization, and agent status tracking.
|
||||
*/
|
||||
export function connectPanePty(
|
||||
pane: ManagedPane,
|
||||
manager: PaneManager,
|
||||
|
|
@ -975,6 +983,26 @@ export function connectPanePty(
|
|||
)
|
||||
return tab?.defaultTitle?.trim() || 'Terminal'
|
||||
}
|
||||
/**
|
||||
* Resolves the authoritative owner agent type for this pane, checking tab launch,
|
||||
* pane startup, and store state configuration.
|
||||
*
|
||||
* Why: launch ownership wins so Pi-compatible live titles/hooks can't repaint an
|
||||
* OMP-owned pane back to Pi; the stored status agentType is only the last-resort
|
||||
* fallback because it can itself be a Pi-compatible frame.
|
||||
*/
|
||||
const getAuthoritativePaneAgent = (): AgentType | undefined => {
|
||||
const state = useAppStore.getState()
|
||||
const tab = (state.tabsByWorktree[deps.worktreeId] ?? []).find(
|
||||
(entry) => entry.id === deps.tabId
|
||||
)
|
||||
return (
|
||||
tab?.launchAgent ??
|
||||
paneStartup?.launchAgent ??
|
||||
paneStartup?.initialAgentStatus?.agent ??
|
||||
state.agentStatusByPaneKey[cacheKey]?.agentType
|
||||
)
|
||||
}
|
||||
const clearInferredInterruptWorkingTitle = (): void => {
|
||||
const state = useAppStore.getState()
|
||||
const currentTitle = state.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
|
|
@ -1429,8 +1457,9 @@ export function connectPanePty(
|
|||
let allowInitialIdleCacheSeed = false
|
||||
|
||||
const onTitleChange = (title: string, rawTitle: string): void => {
|
||||
const paneTitle = normalizeCompatibleAgentTitleForOwner(title, getAuthoritativePaneAgent())
|
||||
if (
|
||||
shouldSuppressCodexAutoApprovalSyntheticTitle(title, {
|
||||
shouldSuppressCodexAutoApprovalSyntheticTitle(paneTitle, {
|
||||
paneKey: cacheKey,
|
||||
tabId: deps.tabId,
|
||||
...(launchToken ? { launchToken } : {})
|
||||
|
|
@ -1439,7 +1468,7 @@ export function connectPanePty(
|
|||
return
|
||||
}
|
||||
manager.setPaneGpuRendering(pane.id, !isGeminiTerminalTitle(rawTitle))
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, title)
|
||||
deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle)
|
||||
if (syncAgentTaskCompleteTrackingEnabled()) {
|
||||
agentCompletionCoordinator.observeTitle(rawTitle)
|
||||
}
|
||||
|
|
@ -1449,7 +1478,7 @@ export function connectPanePty(
|
|||
// focus changes, onActivePaneChange syncs the newly active pane's stored
|
||||
// title to the tab.
|
||||
if (manager.getActivePane()?.id === pane.id) {
|
||||
deps.updateTabTitle(deps.tabId, title)
|
||||
deps.updateTabTitle(deps.tabId, paneTitle)
|
||||
}
|
||||
|
||||
if (!hasConsideredInitialCacheTimerSeed) {
|
||||
|
|
@ -1476,7 +1505,10 @@ export function connectPanePty(
|
|||
const statusPayload = {
|
||||
state: 'working' as const,
|
||||
prompt: initialStatus.prompt,
|
||||
agentType: initialStatus.agent
|
||||
agentType: resolveCompatibleAgentTypeForOwner(
|
||||
initialStatus.agent,
|
||||
getAuthoritativePaneAgent()
|
||||
)
|
||||
}
|
||||
if (paneStartup.launchConfig) {
|
||||
useAppStore
|
||||
|
|
@ -1959,20 +1991,40 @@ export function connectPanePty(
|
|||
// be stored against a title that was never paired with it.
|
||||
const currentState = useAppStore.getState()
|
||||
const title = currentState.runtimePaneTitlesByTabId?.[deps.tabId]?.[pane.id]
|
||||
const statusTitle = resolveAgentStatusTerminalTitle(payload, title)
|
||||
const authoritativePaneAgent = getAuthoritativePaneAgent()
|
||||
const agentType = resolveCompatibleAgentTypeForOwner(
|
||||
payload.agentType,
|
||||
authoritativePaneAgent
|
||||
)
|
||||
const statusPayload =
|
||||
agentType === payload.agentType ? payload : { ...payload, agentType }
|
||||
const resolvedStatusTitle = resolveAgentStatusTerminalTitle(statusPayload, title)
|
||||
const statusTitle = resolvedStatusTitle
|
||||
? normalizeCompatibleAgentTitleForOwner(
|
||||
resolvedStatusTitle,
|
||||
agentType ?? authoritativePaneAgent
|
||||
)
|
||||
: resolvedStatusTitle
|
||||
if (launchToken) {
|
||||
currentState.setAgentStatus(cacheKey, payload, statusTitle, undefined, undefined, {
|
||||
launchToken
|
||||
})
|
||||
currentState.setAgentStatus(
|
||||
cacheKey,
|
||||
statusPayload,
|
||||
statusTitle,
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
launchToken
|
||||
}
|
||||
)
|
||||
} else {
|
||||
currentState.setAgentStatus(cacheKey, payload, statusTitle)
|
||||
currentState.setAgentStatus(cacheKey, statusPayload, statusTitle)
|
||||
}
|
||||
if (syncAgentTaskCompleteTrackingEnabled()) {
|
||||
const storedStatus = useAppStore.getState().agentStatusByPaneKey[cacheKey]
|
||||
const notificationPayload =
|
||||
typeof storedStatus?.stateStartedAt === 'number'
|
||||
? { ...payload, stateStartedAt: storedStatus.stateStartedAt }
|
||||
: payload
|
||||
? { ...statusPayload, stateStartedAt: storedStatus.stateStartedAt }
|
||||
: statusPayload
|
||||
agentCompletionCoordinator.observeHookStatus(notificationPayload)
|
||||
}
|
||||
if (payload.state === 'working' && pendingTerminalBellNotification) {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,8 @@ const TITLE_LABEL_TO_AGENT: Partial<Record<string, TuiAgent>> = {
|
|||
Cursor: 'cursor',
|
||||
Droid: 'droid',
|
||||
Hermes: 'hermes',
|
||||
Pi: 'pi'
|
||||
Pi: 'pi',
|
||||
OMP: 'omp'
|
||||
}
|
||||
|
||||
const HELPER_FOREGROUND_RETRY_DELAYS_MS = [250, 1250, 3500, 750] as const
|
||||
|
|
|
|||
|
|
@ -1509,6 +1509,49 @@ describe('applyWebSessionTabsSnapshot', () => {
|
|||
expect(patch.sortEpoch).toBe(1)
|
||||
})
|
||||
|
||||
it('keeps mirrored OMP tabs from repainting to Pi-compatible titles', () => {
|
||||
const hostPaneKey = makePaneKey('host-tab-1', LEAF_ID)
|
||||
const patch = applyWebSessionTabsSnapshot(
|
||||
makeState(),
|
||||
makeSnapshot([
|
||||
{
|
||||
type: 'terminal',
|
||||
id: HOST_SURFACE_ID,
|
||||
title: 'Pi ready',
|
||||
parentTabId: 'host-tab-1',
|
||||
leafId: LEAF_ID,
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-1',
|
||||
launchAgent: 'omp',
|
||||
agentStatus: {
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
updatedAt: NOW - 100,
|
||||
stateStartedAt: NOW - 1_000,
|
||||
agentType: 'pi',
|
||||
paneKey: hostPaneKey,
|
||||
terminalTitle: 'Pi ready',
|
||||
stateHistory: []
|
||||
}
|
||||
}
|
||||
]),
|
||||
ENV,
|
||||
NOW
|
||||
) as Partial<WebSessionTabsSyncState>
|
||||
|
||||
const mirroredId = patch.tabsByWorktree?.[WT]?.[0]?.id
|
||||
const mirroredPaneKey = makePaneKey(mirroredId!, LEAF_ID)
|
||||
expect(patch.tabsByWorktree?.[WT]?.[0]).toMatchObject({
|
||||
title: 'OMP ready',
|
||||
launchAgent: 'omp'
|
||||
})
|
||||
expect(patch.agentStatusByPaneKey?.[mirroredPaneKey]).toMatchObject({
|
||||
agentType: 'omp',
|
||||
terminalTitle: 'OMP ready'
|
||||
})
|
||||
})
|
||||
|
||||
it('hydrates multiple initial host snapshots in one merged patch', () => {
|
||||
const secondWorktree = 'repo::/other-worktree'
|
||||
const patch = applyWebSessionTabsSnapshots(
|
||||
|
|
|
|||
|
|
@ -43,6 +43,10 @@ import {
|
|||
toWebTerminalSurfaceTabId,
|
||||
WEB_TERMINAL_SURFACE_TAB_PREFIX
|
||||
} from './web-runtime-session'
|
||||
import {
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner
|
||||
} from '../../../shared/agent-title-owner'
|
||||
import { resolveTerminalLayoutRoot } from './remote-terminal-layout-resolution'
|
||||
import { toRuntimeWorktreeSelector } from './runtime-worktree-selector'
|
||||
import { clearWebSessionFocusIntent, peekWebSessionFocusIntent } from './web-session-focus-intent'
|
||||
|
|
@ -479,6 +483,10 @@ function shouldReplaceTerminalTab(
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs mirrored terminal tabs from the mobile session status payload,
|
||||
* normalising Pi-compatible agent titles under launch ownership.
|
||||
*/
|
||||
function buildMirroredTerminalTabs(
|
||||
snapshot: RuntimeMobileSessionTabsResult,
|
||||
environmentId: string,
|
||||
|
|
@ -511,7 +519,16 @@ function buildMirroredTerminalTabs(
|
|||
const ptyIds = surfaces
|
||||
.map((surface) => ptyIdsByLeafId[surface.leafId]!)
|
||||
.filter((ptyId): ptyId is string => typeof ptyId === 'string' && ptyId.length > 0)
|
||||
const title = activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal'
|
||||
const launchAgent =
|
||||
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
|
||||
const ownerAgent =
|
||||
launchAgent ??
|
||||
activeSurface.agentStatus?.agentType ??
|
||||
surfaces.find((surface) => surface.agentStatus?.agentType)?.agentStatus?.agentType
|
||||
const title = normalizeCompatibleAgentTitleForOwner(
|
||||
activeSurface.title.trim() || surfaces[0]?.title.trim() || 'Terminal',
|
||||
ownerAgent
|
||||
)
|
||||
const existing =
|
||||
existingById.get(localTabId) ??
|
||||
existingById.get(parentTabId) ??
|
||||
|
|
@ -522,8 +539,6 @@ function buildMirroredTerminalTabs(
|
|||
activeSurface.quickCommandLabel?.trim() ||
|
||||
surfaces.find((surface) => surface.quickCommandLabel?.trim())?.quickCommandLabel?.trim() ||
|
||||
existing?.quickCommandLabel?.trim()
|
||||
const launchAgent =
|
||||
activeSurface.launchAgent ?? surfaces.find((surface) => surface.launchAgent)?.launchAgent
|
||||
// Why: tab color/pin echo back through host snapshots, so prefer the client's
|
||||
// own record (kept authoritative in tabsByWorktree by the pin/color setters)
|
||||
// and fall back to the host value only when this client has no prior tab —
|
||||
|
|
@ -571,6 +586,10 @@ function toMirroredPaneKey(surface: TerminalSurface): string | null {
|
|||
return makePaneKey(toWebTerminalSurfaceTabId(surface.parentTabId), surface.leafId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises and mirrors agent status updates from the host payload,
|
||||
* preserving authoritative ownership metadata.
|
||||
*/
|
||||
function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null {
|
||||
if (!surface.agentStatus) {
|
||||
return null
|
||||
|
|
@ -579,8 +598,9 @@ function remapHostAgentStatus(surface: TerminalSurface): AgentStatusEntry | null
|
|||
if (!paneKey) {
|
||||
return null
|
||||
}
|
||||
const ownerAgent = surface.launchAgent ?? surface.agentStatus.agentType
|
||||
return {
|
||||
...surface.agentStatus,
|
||||
...normalizeCompatibleAgentStatusEntryForOwner(surface.agentStatus, ownerAgent),
|
||||
paneKey
|
||||
}
|
||||
}
|
||||
|
|
@ -590,6 +610,10 @@ function isMirroredAgentPaneKeyForTabs(paneKey: string, tabIds: ReadonlySet<stri
|
|||
return parsed !== null && tabIds.has(parsed.tabId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a state patch for mirrored agent statuses, merging host
|
||||
* status entries with client overrides defensively.
|
||||
*/
|
||||
function buildMirroredAgentStatusPatch(
|
||||
state: WebSessionTabsSyncState,
|
||||
currentTerminalTabs: readonly TerminalTab[],
|
||||
|
|
@ -620,10 +644,11 @@ function buildMirroredAgentStatusPatch(
|
|||
// Why: active web streams can report a fresher OSC 9999 status for the same
|
||||
// mirrored pane before the next host snapshot arrives. Do not rewind that
|
||||
// row with an older host publication.
|
||||
nextByPaneKey.set(
|
||||
entry.paneKey,
|
||||
existing && existing.updatedAt > entry.updatedAt ? existing : entry
|
||||
)
|
||||
const nextEntry =
|
||||
existing && existing.updatedAt > entry.updatedAt
|
||||
? normalizeCompatibleAgentStatusEntryForOwner(existing, entry.agentType)
|
||||
: entry
|
||||
nextByPaneKey.set(entry.paneKey, nextEntry)
|
||||
}
|
||||
|
||||
let nextAgentStatusByPaneKey = state.agentStatusByPaneKey
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@ import {
|
|||
getAgentLabel,
|
||||
MAX_OSC_TITLE_CHARS
|
||||
} from './agent-detection'
|
||||
import {
|
||||
normalizeCompatibleAgentStatusEntryForOwner,
|
||||
normalizeCompatibleAgentTitleForOwner,
|
||||
resolveCompatibleAgentTypeForOwner
|
||||
} from './agent-title-owner'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
|
|
@ -91,6 +96,44 @@ describe('Pi-compatible title detection', () => {
|
|||
expect(detectAgentStatusFromTitle(title)).toBe(expectedStatus)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['\u280b Pi', 'omp', '\u280b OMP'],
|
||||
['Pi ready', 'omp', 'OMP ready'],
|
||||
['Pi - action required', 'omp', 'OMP - action required'],
|
||||
['π - tmp', 'omp', 'OMP ready'],
|
||||
['π: tmp', 'omp', 'OMP ready'],
|
||||
['\u280b π: tmp', 'omp', '\u280b OMP'],
|
||||
['\u280b π - tmp', 'omp', '\u280b OMP'],
|
||||
['\u280b OMP', 'pi', '\u280b Pi']
|
||||
] as const)('normalizes %s to the authoritative %s owner', (title, owner, expectedTitle) => {
|
||||
expect(normalizeCompatibleAgentTitleForOwner(title, owner)).toBe(expectedTitle)
|
||||
})
|
||||
|
||||
it('preserves Pi-compatible custom titles and unrelated owners', () => {
|
||||
expect(normalizeCompatibleAgentTitleForOwner('Fix pi bugs', 'omp')).toBe('Fix pi bugs')
|
||||
expect(normalizeCompatibleAgentTitleForOwner('\u280b Pi', 'codex')).toBe('\u280b Pi')
|
||||
})
|
||||
|
||||
it('normalizes Pi-compatible status identity and terminal title to the owner', () => {
|
||||
const status = normalizeCompatibleAgentStatusEntryForOwner(
|
||||
{
|
||||
state: 'working',
|
||||
prompt: '',
|
||||
updatedAt: 1,
|
||||
stateStartedAt: 1,
|
||||
agentType: 'pi',
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
terminalTitle: '\u280b Pi',
|
||||
stateHistory: []
|
||||
},
|
||||
'omp'
|
||||
)
|
||||
|
||||
expect(status.agentType).toBe('omp')
|
||||
expect(status.terminalTitle).toBe('\u280b OMP')
|
||||
expect(resolveCompatibleAgentTypeForOwner('codex', 'omp')).toBe('codex')
|
||||
})
|
||||
|
||||
it.each(['~/omp/working', 'omp-harness ready', '~/pi/working', 'pi-scratch ready'])(
|
||||
'does not classify path or hyphen false positive %s',
|
||||
(title) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,166 @@
|
|||
import { detectAgentStatusFromTitle, getAgentLabel } from './agent-detection'
|
||||
import type { AgentStatusEntry, AgentType } from './agent-status-types'
|
||||
import {
|
||||
getSyntheticAgentTitleProfile,
|
||||
SYNTHETIC_AGENT_TITLE_PROFILES,
|
||||
type SyntheticAgentTitleProfile
|
||||
} from './synthetic-agent-title'
|
||||
|
||||
type TitleProfileMatch = {
|
||||
profile: SyntheticAgentTitleProfile
|
||||
}
|
||||
|
||||
const COMPATIBLE_IDLE_TITLE_RE = /(?<![\w./\\-])(?:ready|idle|done)(?![\w-])/i
|
||||
const LEGACY_PI_COMPATIBLE_TITLE_RE = /^\s*(?:[\u2800-\u28ff]\s+)?π\s*(?:[-:]|\s)\s*.+/u
|
||||
|
||||
/**
|
||||
* Resolves the synthetic title profile matching a given agent label.
|
||||
*/
|
||||
function getProfileForTitleLabel(label: string | null): TitleProfileMatch | null {
|
||||
if (!label) {
|
||||
return null
|
||||
}
|
||||
const normalizedLabel = label.trim().toLowerCase()
|
||||
for (const profile of Object.values(SYNTHETIC_AGENT_TITLE_PROFILES)) {
|
||||
if (profile.workingLabel.toLowerCase() === normalizedLabel) {
|
||||
return { profile }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the synthetic title profile matching a given terminal title.
|
||||
*/
|
||||
function getProfileForTitle(title: string): TitleProfileMatch | null {
|
||||
const labelProfile = getProfileForTitleLabel(getAgentLabel(title))
|
||||
if (labelProfile) {
|
||||
return labelProfile
|
||||
}
|
||||
if (LEGACY_PI_COMPATIBLE_TITLE_RE.test(title)) {
|
||||
return getProfileForTitleLabel('Pi')
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the agent status (working, permission, idle) from a terminal title,
|
||||
* accounting for legacy Pi titles.
|
||||
*/
|
||||
function getSourceTitleStatus(title: string): 'working' | 'permission' | 'idle' | null {
|
||||
const detectedStatus = detectAgentStatusFromTitle(title)
|
||||
if (detectedStatus) {
|
||||
return detectedStatus
|
||||
}
|
||||
if (LEGACY_PI_COMPATIBLE_TITLE_RE.test(title)) {
|
||||
return 'idle'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a title indicates an agent is waiting for permissions or input.
|
||||
*/
|
||||
function hasPermissionSuffix(title: string, sourceProfile: SyntheticAgentTitleProfile): boolean {
|
||||
const normalizedTitle = title.trim().toLowerCase()
|
||||
return (
|
||||
normalizedTitle === sourceProfile.permissionLabel.toLowerCase() ||
|
||||
normalizedTitle.includes('action required') ||
|
||||
normalizedTitle.includes('permission') ||
|
||||
normalizedTitle.includes('waiting')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a title indicates an agent is idle or ready.
|
||||
*/
|
||||
function hasIdleSuffix(title: string, sourceProfile: SyntheticAgentTitleProfile): boolean {
|
||||
const normalizedTitle = title.trim().toLowerCase()
|
||||
return (
|
||||
normalizedTitle === sourceProfile.idleLabel.toLowerCase() ||
|
||||
COMPATIBLE_IDLE_TITLE_RE.test(title)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: remote OMP surfaces may report Pi as the live status identity, while
|
||||
* launch ownership still identifies the user-selected agent.
|
||||
*/
|
||||
export function resolveCompatibleAgentTypeForOwner(
|
||||
incomingAgentType: AgentType | null | undefined,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
): AgentType | undefined {
|
||||
if (!incomingAgentType) {
|
||||
return undefined
|
||||
}
|
||||
const incomingProfile = getSyntheticAgentTitleProfile(incomingAgentType)
|
||||
const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType)
|
||||
if (
|
||||
!incomingProfile?.titleIdentityGroup ||
|
||||
!ownerProfile?.titleIdentityGroup ||
|
||||
incomingProfile.titleIdentityGroup !== ownerProfile.titleIdentityGroup
|
||||
) {
|
||||
return incomingAgentType
|
||||
}
|
||||
return ownerAgentType as AgentType
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: Pi-compatible title frames can come from the wrapped harness during
|
||||
* active work, so render them through the stable owner profile.
|
||||
*/
|
||||
export function normalizeCompatibleAgentTitleForOwner(
|
||||
title: string,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
): string {
|
||||
const ownerProfile = getSyntheticAgentTitleProfile(ownerAgentType)
|
||||
if (!ownerProfile?.titleIdentityGroup) {
|
||||
return title
|
||||
}
|
||||
const source = getProfileForTitle(title)
|
||||
if (
|
||||
!source?.profile.titleIdentityGroup ||
|
||||
source.profile.titleIdentityGroup !== ownerProfile.titleIdentityGroup
|
||||
) {
|
||||
return title
|
||||
}
|
||||
const sourceStatus = getSourceTitleStatus(title)
|
||||
if (sourceStatus === 'working') {
|
||||
return `\u280b ${ownerProfile.workingLabel}`
|
||||
}
|
||||
if (sourceStatus === 'permission') {
|
||||
return ownerProfile.permissionLabel
|
||||
}
|
||||
if (sourceStatus === 'idle') {
|
||||
return ownerProfile.idleLabel
|
||||
}
|
||||
if (hasPermissionSuffix(title, source.profile)) {
|
||||
return ownerProfile.permissionLabel
|
||||
}
|
||||
if (hasIdleSuffix(title, source.profile)) {
|
||||
return ownerProfile.idleLabel
|
||||
}
|
||||
return ownerProfile.workingLabel
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: mirrored remote status entries must keep the owner and title in sync
|
||||
* or later snapshots repaint the same tab under the wrapper agent.
|
||||
*/
|
||||
export function normalizeCompatibleAgentStatusEntryForOwner(
|
||||
entry: AgentStatusEntry,
|
||||
ownerAgentType: AgentType | null | undefined
|
||||
): AgentStatusEntry {
|
||||
const agentType = resolveCompatibleAgentTypeForOwner(entry.agentType, ownerAgentType)
|
||||
const terminalTitle = entry.terminalTitle
|
||||
? normalizeCompatibleAgentTitleForOwner(entry.terminalTitle, agentType ?? ownerAgentType)
|
||||
: entry.terminalTitle
|
||||
if (agentType === entry.agentType && terminalTitle === entry.terminalTitle) {
|
||||
return entry
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
...(agentType ? { agentType } : {}),
|
||||
...(terminalTitle ? { terminalTitle } : {})
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ export type SyntheticAgentTitleProfile = {
|
|||
workingLabel: string
|
||||
permissionLabel: string
|
||||
idleLabel: string
|
||||
titleIdentityGroup?: string
|
||||
synthesizeWorkingTitle?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -29,12 +30,14 @@ export const SYNTHETIC_AGENT_TITLE_PROFILES: Record<string, SyntheticAgentTitleP
|
|||
pi: {
|
||||
workingLabel: 'Pi',
|
||||
permissionLabel: 'Pi - action required',
|
||||
idleLabel: 'Pi ready'
|
||||
idleLabel: 'Pi ready',
|
||||
titleIdentityGroup: 'pi-compatible'
|
||||
},
|
||||
omp: {
|
||||
workingLabel: 'OMP',
|
||||
permissionLabel: 'OMP - action required',
|
||||
idleLabel: 'OMP ready'
|
||||
idleLabel: 'OMP ready',
|
||||
titleIdentityGroup: 'pi-compatible'
|
||||
},
|
||||
droid: {
|
||||
workingLabel: 'Droid',
|
||||
|
|
|
|||
Loading…
Reference in New Issue