Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ All changes must consider folder workspaces as well as git worktrees. Don't assu

## Agent Status

The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy.
The execution host owns agent status in one store, the hook server's, and every reader (sidebar, `worktree ps`, mobile, dashboard) subscribes to it. New producers write into that store, and readers keep only presentation policy. Each pane's row has one owner (`agentPresence`), decided by `transitionHookPresence` on the execution host (main for local panes, the relay for SSH/WSL; main adopts relayed rows as given): only the owner's hook events write the row, a different agent's events are guests that leave it unchanged, and ownership ends only on the owner's exit, a host check that it `exited`, or the freshness fallback for an owner the host cannot check.

## Agent Terminal Screens

Expand Down
81 changes: 38 additions & 43 deletions src/main/agent-hooks/server-agent-presence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,6 @@ describe('host-owned hook presence', () => {
expect(visible(server)).toBe(false)
})

it('keeps the pane owned by its agent while a nested agent in it starts and ends', async () => {
const server = await createServer()
await hook(server, 'SessionStart', 'outer')
await hook(server, 'UserPromptSubmit', 'outer')
await hook(server, 'SessionStart', 'nested', undefined, 4002)
await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002)
await hook(server, 'SessionEnd', 'nested', 'other', 4002)
expect(visible(server)).toBe(true)
await hook(server, 'PostToolUse', 'outer')
expect(state(server)).toBe('working')
await hook(server, 'SessionEnd', 'outer', 'other')
expect(visible(server)).toBe(false)
})

it('never ends a pane from a SessionEnd without a process identity', async () => {
const server = await createServer()
await hook(server, 'SessionStart', 'outer', undefined, null)
Expand All @@ -140,63 +126,72 @@ describe('host-owned hook presence', () => {
expect(await server.checkAgentPresence(PANE)).toBeNull()
})

it('does not let a Claude started inside a working Codex turn end the pane', async () => {
it('adopts a relayed owner verdict as given, including its exit', async () => {
const server = await createServer()
const base = { paneKey: PANE, tabId: 'tab-1', worktreeId: 'wt-1' }
const owner = {
agent: 'claude',
process: { pid: 4002, platform: 'linux', startTime: 'boot:1' }
}
server.ingestRemote(
{
...base,
source: 'codex',
source: 'claude',
hookEventName: 'UserPromptSubmit',
payload: { state: 'working', prompt: 'codex task', agentType: 'codex' }
agentPresence: owner,
payload: { state: 'working', prompt: 'remote task', agentType: 'claude' }
},
'ssh-1'
)
// The relay has no identity resolution, so it can hand the nested Claude ownership.
// Why: the relay decided this exit; main never re-checks it against its own copy of the owner.
server.ingestRemote(
{
...base,
source: 'claude',
hookEventName: 'SessionEnd',
providerSessionOnly: true,
agentPresence: {
agent: 'claude',
process: { pid: 4002, platform: 'linux', startTime: 'boot:1' },
ended: true
},
agentPresence: { ...owner, ended: true },
payload: { state: 'done', prompt: '', agentType: 'claude' }
},
'ssh-1'
)
expect(state(server)).toBe('working')
expect(visible(server)).toBe(false)
})

it.each([
['working', []],
['idle', ['Stop']]
])('keeps a %s Codex pane when a Claude run inside it ends', async (_label, codexTail) => {
const codex = async (server: AgentHookServer, event: string): Promise<void> => {
const response = await postHookEvent(
server,
buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }),
'/hook/codex'
)
expect(response.status).toBe(204)
}

it('keeps a working Codex pane when a Claude run inside it ends', async () => {
const server = await createServer()
const codex = async (event: string) => {
const response = await postHookEvent(
server,
buildBody({ hook_event_name: event, session_id: 'codex-a', prompt: 'task' }),
'/hook/codex'
)
expect(response.status).toBe(204)
}
await codex('UserPromptSubmit')
for (const event of codexTail) {
await codex(event)
}
const before = state(server)
await codex(server, 'UserPromptSubmit')
await hook(server, 'SessionStart', 'nested', undefined, 4002)
await hook(server, 'UserPromptSubmit', 'nested', undefined, 4002)
await hook(server, 'SessionEnd', 'nested', 'other', 4002)
expect(visible(server)).toBe(true)
expect(before).not.toBeNull()
const row = server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)
expect(row).toMatchObject({ state: 'working', agentType: 'codex', prompt: 'task' })
expect(await server.checkAgentPresence(PANE)).toBeNull()
})

// Why: Codex hooks report no process yet, so an idle Codex owner falls back to the freshness rule.
it('lets another agent take an idle owner the host cannot check', async () => {
const server = await createServer()
await codex(server, 'UserPromptSubmit')
await codex(server, 'Stop')
await hook(server, 'UserPromptSubmit', 'next', undefined, 4002)
expect(server.getStatusSnapshot().find((entry) => entry.paneKey === PANE)).toMatchObject({
state: 'working',
agentType: 'claude'
})
await hook(server, 'SessionEnd', 'next', 'prompt_input_exit', 4002)
expect(visible(server)).toBe(false)
})

it.each(['devin', 'qoder', 'codebuddy', 'copilot'])(
'settles %s to done on its own SessionEnd hook',
async (agent) => {
Expand Down
156 changes: 33 additions & 123 deletions src/main/agent-hooks/server-ingest-remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,131 +194,41 @@ describe('AgentHookServer ingestRemote', () => {
)
})

it('preserves active pane identity when a nested remote hook reports another agent', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' }
},
'conn-1'
)

vi.setSystemTime(1_100)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: {
state: 'working',
prompt: 'nested claude',
agentType: 'claude',
toolName: 'Read',
toolInput: '00-review-context.md'
}
},
'conn-1'
)

expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({
paneKey: PANE,
state: 'working',
prompt: 'nested claude',
agentType: 'codex',
toolName: 'Read',
toolInput: '00-review-context.md',
receivedAt: 1_100
})
])
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
prompt: 'nested claude',
agentType: 'codex'
})
})
)
expect(trackMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})

it('ignores nested remote done while the parent pane agent is still active', () => {
vi.useFakeTimers()
vi.setSystemTime(1_000)
try {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' }
},
'conn-1'
)

vi.setSystemTime(1_100)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
hasExplicitPrompt: true,
payload: {
state: 'done',
prompt: 'nested claude',
agentType: 'claude',
toolName: 'Read',
toolInput: '00-review-context.md',
lastAssistantMessage: 'child finished'
}
},
'conn-1'
)

const snapshot = server.getStatusSnapshot()
expect(snapshot).toHaveLength(1)
expect(snapshot[0]).toMatchObject({
// Why: the relay classifies its panes' producers (a nested agent is a guest there and never
// forwarded), so main adopts every relayed row as given and never re-guesses its identity.
it('adopts a relayed row as its relay built it', () => {
const server = new AgentHookServer()
const listener = vi.fn()
server.setListener(listener)
server.ingestRemote(
{
paneKey: PANE,
state: 'working',
prompt: 'parent codex',
agentType: 'codex',
receivedAt: 1_000,
stateStartedAt: 1_000
tabId: 'tab-1',
worktreeId: 'wt-1',
agentPresence: { agent: 'codex', session: 'codex-a' },
payload: { state: 'working', prompt: 'parent codex', agentType: 'codex' }
},
'conn-1'
)
server.ingestRemote(
{
paneKey: PANE,
tabId: 'tab-1',
worktreeId: 'wt-1',
agentPresence: { agent: 'claude', session: 'claude-a' },
payload: { state: 'working', prompt: 'claude now', agentType: 'claude' }
},
'conn-1'
)
expect(server.getStatusSnapshot()).toEqual([
expect.objectContaining({ state: 'working', prompt: 'claude now', agentType: 'claude' })
])
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
agentPresence: { agent: 'claude', session: 'claude-a' },
payload: expect.objectContaining({ agentType: 'claude' })
})
expect(snapshot[0].toolName).toBeUndefined()
expect(snapshot[0].toolInput).toBeUndefined()
expect(snapshot[0].lastAssistantMessage).toBeUndefined()
expect(listener).toHaveBeenCalledTimes(1)
expect(listener).toHaveBeenLastCalledWith(
expect.objectContaining({
payload: expect.objectContaining({
state: 'working',
prompt: 'parent codex',
agentType: 'codex'
})
})
)
expect(trackMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
)
})

it('allows remote pane identity to change after the prior turn is done', () => {
Expand Down
Loading
Loading