fix(cli): agent list and show include sleeping and not-yet-reopened terminal agents

A terminal agent whose workspace is asleep, or whose tab has not reopened
since a wake, is listed and shown from its saved sleeping record with state
asleep, never woken. A woken row keeps its saved last reply until a new one
arrives. Record ids come from one read-only store question per new key and
store authority.
This commit is contained in:
Brennan Benson
2026-11-13 20:12:54 -08:00
parent c917a45022
commit d0bf8e4e4c
21 changed files with 547 additions and 61 deletions
+1 -1
View File
@@ -249,7 +249,7 @@ orca agent read orca_session_id:claude_1234 --limit 10
orca agent read orca_session_id:claude_1234 --json
```
Every agent, chat or terminal, has an Orca session ID (`orca_session_id:<agent>_<uuid>`), what Copy Orca Session ID copies. `agent list` lists the agents open in this host's workspaces with their IDs; a terminal agent shows none until it first reports to Orca. A chat keeps its ID across `/clear`; a terminal agent gets a new one. IDs belong to the host that runs the agent: agents on other hosts are not listed, and the output names those hosts with the `--environment` that lists them.
Every agent, chat or terminal, has an Orca session ID (`orca_session_id:<agent>_<uuid>`), what Copy Orca Session ID copies. `agent list` lists the agents in this host's workspaces with their IDs, including a terminal agent whose workspace is asleep or whose tab hasn't reopened since a wake (state `asleep`; listing never wakes it). A terminal agent shows no ID until it first reports to Orca. A chat keeps its ID across `/clear`; a terminal agent gets a new one. IDs belong to the host that runs the agent: agents on other hosts are not listed, and the output names those hosts with the `--environment` that lists them.
`agent read` reads the conversation, not a screen: a chat's history, or a terminal agent's own transcript. The agent need not be running and reading never starts it. A terminal agent can be read while Orca knows where its transcript is (it is open, or asleep in a workspace; on Windows, a WSL agent only once it wakes). To message an agent, use `orca terminal send` or `orca orchestration send`.
+1 -1
View File
@@ -72,7 +72,7 @@ If a launch reply is lost, use the returned `operationId` to re-issue the exact
Every agent, chat or terminal, has an Orca session ID (`orca_session_id:<agent>_<uuid>`; what Copy Orca Session ID copies). Use it to find and read agents:
- `ORCA agent list --json` lists the agents open in this host's workspaces, with `orcaSessionId`, `kind` (chat or terminal), state, workspace and last reply. A terminal agent has no ID until it first reports to Orca. Agents on other hosts are not listed; the output names those hosts and the `--environment` to use.
- `ORCA agent list --json` lists the agents in this host's workspaces, with `orcaSessionId`, `kind` (chat or terminal), state, workspace and last reply. A terminal agent whose workspace is asleep, or whose tab hasn't reopened since a wake, is listed with state `asleep`; listing never wakes it. A terminal agent has no ID until it first reports to Orca. Agents on other hosts are not listed; the output names those hosts and the `--environment` to use.
- `ORCA agent show <id> --json` shows where one agent is open (none if closed, two if resumed in two panes).
- `ORCA agent read <id> --json` reads what the agent said: a chat's history or a terminal agent's transcript, never its screen. It needs no running agent and never starts one. A terminal agent is readable while Orca knows its transcript (open, or asleep in a workspace; on Windows, a WSL agent only once it wakes).
+4 -4
View File
@@ -14,6 +14,7 @@ import {
type WithAnnotatedHostScope
} from '../hosts/omitted-host-scope-selectors'
import {
AGENT_LIST_STATES,
AGENT_READ_MAX_LIMIT,
type AgentConversationRow,
type AgentListResult,
@@ -22,7 +23,6 @@ import {
type AgentReadTool,
type AgentShowResult
} from '../../shared/agent-conversations/agent-conversation-contracts'
import { AGENT_STATUS_STATES } from '../../shared/agent-status/contracts/agent-status-types'
import type { RuntimeWorktreeRecord } from '../../shared/runtime-worktree-contracts'
import { ORCA_SESSION_ADDRESS_PREFIX } from '../../shared/orca-session-address-prefix'
import {
@@ -33,10 +33,10 @@ import {
export const AGENT_CONVERSATION_HANDLERS: Record<string, CommandHandler> = {
'agent list': async ({ flags, client, cwd, json }) => {
const state = getOptionalStringFlag(flags, 'state')
if (state !== undefined && !AGENT_STATUS_STATES.some((known) => known === state)) {
if (state !== undefined && !AGENT_LIST_STATES.some((known) => known === state)) {
throw new RuntimeClientError(
'invalid_argument',
`--state must be one of: ${AGENT_STATUS_STATES.join(', ')}.`
`--state must be one of: ${AGENT_LIST_STATES.join(', ')}.`
)
}
const selector = await getOptionalWorktreeSelector(flags, 'worktree', cwd, client)
@@ -126,7 +126,7 @@ function formatAgentRow(row: AgentConversationRow): string {
function formatAgentList(result: WithAnnotatedHostScope<AgentListResult>): string {
const scope = result.hostScope
const lines = [
result.agents.length === 0 ? 'No open agents.' : result.agents.map(formatAgentRow).join('\n\n')
result.agents.length === 0 ? 'No agents.' : result.agents.map(formatAgentRow).join('\n\n')
]
// Agents on hosts this one does not list are reached by running there or with --environment.
if (!scope || scope.omittedHostIds.length > 0) {
@@ -9,7 +9,8 @@ const AGENT_ID_FLAG_HELP =
const COMMAND_SCOPED_FLAG_HELP: Record<string, Record<string, string>> = {
'agent list': {
state: '--state <state> Only agents in this state: working, blocked, waiting or done'
state:
'--state <state> Only agents in this state: working, blocked, waiting, done or asleep'
},
'agent show': { id: AGENT_ID_FLAG_HELP },
'agent read': {
+1 -1
View File
@@ -300,7 +300,7 @@ const ORCA_CLI_MARKDOWN = [
"",
"Every agent, chat or terminal, has an Orca session ID (`orca_session_id:<agent>_<uuid>`; what Copy Orca Session ID copies). Use it to find and read agents:",
"",
"- `ORCA agent list --json` lists the agents open in this host's workspaces, with `orcaSessionId`, `kind` (chat or terminal), state, workspace and last reply. A terminal agent has no ID until it first reports to Orca. Agents on other hosts are not listed; the output names those hosts and the `--environment` to use.",
"- `ORCA agent list --json` lists the agents in this host's workspaces, with `orcaSessionId`, `kind` (chat or terminal), state, workspace and last reply. A terminal agent whose workspace is asleep, or whose tab hasn't reopened since a wake, is listed with state `asleep`; listing never wakes it. A terminal agent has no ID until it first reports to Orca. Agents on other hosts are not listed; the output names those hosts and the `--environment` to use.",
"- `ORCA agent show <id> --json` shows where one agent is open (none if closed, two if resumed in two panes).",
"- `ORCA agent read <id> --json` reads what the agent said: a chat's history or a terminal agent's transcript, never its screen. It needs no running agent and never starts one. A terminal agent is readable while Orca knows its transcript (open, or asleep in a workspace; on Windows, a WSL agent only once it wakes).",
"",
+3 -3
View File
@@ -8,9 +8,9 @@ export const AGENT_CONVERSATION_COMMAND_SPECS: CommandSpec[] = [
usage: 'orca agent list [--worktree <selector>] [--state <state>] [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'state'],
notes: [
"Lists the agents open in this Orca host's workspaces: chats and terminal agents alike, each with its `orca_session_id:` ID. A terminal agent shows no ID until it first reports to Orca.",
"Lists the agents in this Orca host's workspaces: chats and terminal agents alike, each with its `orca_session_id:` ID. A terminal agent whose workspace is asleep, or whose tab hasn't reopened since a wake, is listed as asleep; listing never wakes it. A terminal agent shows no ID until it first reports to Orca.",
'A chat keeps its ID across /clear; a terminal agent gets a new one when /clear starts a new conversation.',
'--state keeps agents in one state: working, blocked, waiting or done.',
'--state keeps agents in one state: working, blocked, waiting, done or asleep.',
'Agents on other hosts are not listed here; the output names those hosts and the `--environment` that lists them.'
],
examples: ['orca agent list --json', 'orca agent list --worktree active --state working']
@@ -23,7 +23,7 @@ export const AGENT_CONVERSATION_COMMAND_SPECS: CommandSpec[] = [
positionalArgs: ['id'],
notes: [
'<id> is the `orca_session_id:` ID from `orca agent list` or Copy Orca Session ID.',
'An agent resumed in two panes shows both; one that is not open shows none.'
'An agent resumed in two panes shows both; a terminal agent that is asleep shows its saved place as asleep; a chat that is not open shows none.'
],
examples: ['orca agent show orca_session_id:claude_1234 --json']
},
@@ -17,6 +17,7 @@ export class InProcessAgentIdentityClient implements AgentIdentityClient {
identifyRequests = 0
hasRequests = 0
providerKeyRequests = 0
idsOfKeysRequests = 0
/** When set, `identify` waits for `release()` before answering. */
hold = false
/** Answers the next N identify requests as unavailable after running their SQL. */
@@ -81,6 +82,17 @@ export class InProcessAgentIdentityClient implements AgentIdentityClient {
: undefined
}
async idsOfKeys(
path: string,
keys: AgentProviderKey[]
): Promise<(OrcaSessionId | null)[] | undefined> {
this.idsOfKeysRequests++
const response = handleAgentIdentityRequest({ id: 0, kind: 'idsOfKeys', path, keys })
return response.ok && Array.isArray(response.value)
? response.value.map((id) => (isOrcaSessionId(id) ? id : null))
: undefined
}
/** Lets every held request answer, oldest first. */
release(): void {
for (const resolve of this.held.splice(0)) {
@@ -40,6 +40,8 @@ export type AgentIdentityClient = {
path: string,
providerSessionId: string
): Promise<OrcaSessionId[] | ReverseLookupUnavailable | undefined>
/** Each key's linked id in order, null for none; links nothing. */
idsOfKeys(path: string, keys: AgentProviderKey[]): Promise<(OrcaSessionId | null)[] | undefined>
/** Terminated but not yet exited: a replacement must wait for `retired()`. */
readonly isRetiring: boolean
retired(): Promise<void>
@@ -119,6 +121,16 @@ export class AgentIdentityWorkerClient implements AgentIdentityClient {
return Array.isArray(value) ? value.filter(isOrcaSessionId) : undefined
}
async idsOfKeys(
path: string,
keys: AgentProviderKey[]
): Promise<(OrcaSessionId | null)[] | undefined> {
const value = await this.request((id) => ({ id, kind: 'idsOfKeys', path, keys }))
return Array.isArray(value) && value.length === keys.length
? value.map((id) => (isOrcaSessionId(id) ? id : null))
: undefined
}
get isRetiring(): boolean {
return this.queue.isRetiring
}
@@ -330,6 +330,12 @@ export function handleAgentIdentityRequest(
ok: true,
value: opened.idsOfProviderSession(request.providerSessionId)
}
case 'idsOfKeys':
return {
id: request.id,
ok: true,
value: request.keys.map((key) => opened.lookup(key) ?? '')
}
}
} catch (error) {
// A store that failed to open is reopened by the next request, never rebuilt.
@@ -7,6 +7,7 @@ export type AgentIdentityWorkerRequest =
| { id: number; kind: 'has'; path: string; orcaSessionId: string }
| { id: number; kind: 'providerKeyOf'; path: string; orcaSessionId: string }
| { id: number; kind: 'idsOfProviderSession'; path: string; providerSessionId: string }
| { id: number; kind: 'idsOfKeys'; path: string; keys: AgentProviderKey[] }
/** A reverse lookup this file can't serve by index: a newer build's read-only file without one.
* Not a store failure, so it neither closes the store nor starts the failure backoff. */
@@ -17,7 +18,8 @@ export function isReverseLookupUnavailable(value: unknown): value is ReverseLook
return isRecord(value) && value.unavailable === 'reverse-index'
}
/** `identify` answers the committed id, or null for an unlinked key in a read-only file. */
/** `identify` answers the committed id, or null for an unlinked key in a read-only file;
* `idsOfKeys` answers each key's linked id in order, '' for none, and links nothing. */
export type AgentIdentityWorkerResponse =
| {
id: number
@@ -7,6 +7,7 @@ import { providerSessionLocatorIdentifies } from '../../../shared/agent-session/
import type { OrcaSessionId } from '../../../shared/orca-session-address'
import { sameProviderKey, type AgentProviderKey } from './agent-provider-key'
import { AgentIdentityWorkerClient, type AgentIdentityClient } from './agent-identity-worker-client'
import { SavedAgentKeyIds } from './saved-agent-key-ids'
import { AgentIdentityDemand, type KeyDemand } from './agent-identity-demand'
import type { ReverseLookupUnavailable } from './agent-identity-worker-protocol'
@@ -98,6 +99,7 @@ export class PaneAgentIdentities {
private readonly placesById = new Map<string, Set<string>>()
private readonly canonicalBySubject = new Map<string, RowAgentIdentity>()
private readonly keyStrings = new WeakMap<AgentProviderKey, string>()
private readonly savedIds: SavedAgentKeyIds
private readonly demand: AgentIdentityDemand
constructor(
@@ -111,6 +113,7 @@ export class PaneAgentIdentities {
createClient,
() => this.invalidateSettled()
)
this.savedIds = new SavedAgentKeyIds(this.demand, (key) => this.keyString(key))
}
/** Changes whenever the store authority ends; a caller compares it across its own awaits. */
@@ -184,6 +187,12 @@ export class PaneAgentIdentities {
)
}
/** Each saved key's id, in order, null for none, linking nothing; at most one store question
* per new key and authority. Undefined when the store cannot answer now. */
idsOfKeys(keys: readonly AgentProviderKey[]): Promise<(OrcaSessionId | null)[] | undefined> {
return this.savedIds.idsOf(keys)
}
/** Keeps the indexes and demand subscriptions exact: `place` is a legacy row key or a
* canonical subject. Admission subscribes here, after the row is stored. */
setPlace(place: string, paneKey: string, identity: RowAgentIdentity | undefined): void {
@@ -0,0 +1,47 @@
import type { OrcaSessionId } from '../../../shared/orca-session-address'
import type { AgentIdentityDemand } from './agent-identity-demand'
import type { AgentProviderKey } from './agent-provider-key'
/** Saved keys' ids for one store authority at a time, linking nothing. An id is never relinked,
* so each key is asked once per authority, every new key in one question; only the keys last
* asked are kept. */
export class SavedAgentKeyIds {
private authority = -1
private ids = new Map<string, OrcaSessionId | null>()
constructor(
private readonly demand: AgentIdentityDemand,
private readonly keyString: (key: AgentProviderKey) => string
) {}
/** Each key's id, in order, null for none; undefined when the store cannot answer now. */
async idsOf(keys: readonly AgentProviderKey[]): Promise<(OrcaSessionId | null)[] | undefined> {
const authority = this.demand.authority()
if (this.authority !== authority) {
this.authority = authority
this.ids = new Map()
}
const known = this.ids
const strings = keys.map(this.keyString)
const missing = keys.filter((_, index) => !known.has(strings[index]!))
if (missing.length > 0) {
const missingStrings = missing.map(this.keyString)
const answer = await this.demand.lookup(
`idsOfKeys\0${missingStrings.join('\0')}`,
(client, path) => client.idsOfKeys(path, missing)
)
if (!answer || authority !== this.demand.generation) {
return undefined
}
missingStrings.forEach((keyString, index) => known.set(keyString, answer[index] ?? null))
}
// A key named since it was asked is the demand's.
const ids = strings.map(
(keyString) => known.get(keyString) ?? this.demand.of(keyString)?.identity.id ?? null
)
if (known.size > strings.length && this.ids === known) {
this.ids = new Map(strings.map((keyString, index) => [keyString, ids[index]!]))
}
return ids
}
}
@@ -2,8 +2,9 @@ import { isWslUncPath } from '../../../shared/wsl-paths'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-resume'
import { sameProviderKey, type AgentProviderKey } from './agent-provider-key'
import { agentProviderKeyOf } from './pane-agent-identities'
import type { OrcaSessionId } from '../../../shared/orca-session-address'
type SleepingAgentSighting = Pick<
export type SleepingAgentSighting = Pick<
SleepingAgentSessionRecord,
'agent' | 'providerSession' | 'connectionId'
>
@@ -39,29 +40,53 @@ function isGuestTranscriptPath(path: string | undefined): boolean {
)
}
/** The conversation a saved sleeping record names. On Windows a distro-less record with a guest
* path names no key: its WSL distro is unknown, and it is not a native agent's. */
export function sleepingRecordKey(
record: SleepingAgentSighting,
windows: boolean
): AgentProviderKey | null {
return windows && isGuestTranscriptPath(record.providerSession.transcriptPath)
? null
: agentProviderKeyOf({
agent: record.agent,
providerSession: record.providerSession,
connectionId: record.connectionId ?? null
})
}
/** Each record's id through `idsOfKeys`, in order; null for one naming no key. */
export async function sleepingRecordIds(
records: readonly SleepingAgentSighting[],
windows: boolean,
idsOfKeys: (keys: AgentProviderKey[]) => Promise<(OrcaSessionId | null)[] | undefined>
): Promise<(OrcaSessionId | null)[] | undefined> {
const keys = records.map((record) => sleepingRecordKey(record, windows))
const asked = keys.filter((key): key is AgentProviderKey => key !== null)
const ids = asked.length > 0 ? await idsOfKeys(asked) : []
if (!ids) {
return undefined
}
let next = 0
return keys.map((key) => (key ? (ids[next++] ?? null) : null))
}
/** The saved sleeping record naming `key`'s conversation, found through its provider session id
* and its full key. On Windows a distro-less WSL record of the same provider conversation also
* reads as native, so a native key never takes one with a guest path. */
export function sleepingRecordOfKey(
records: Readonly<Record<string, SleepingAgentSighting>>,
export function sleepingRecordOfKey<T extends SleepingAgentSighting>(
records: Readonly<Record<string, T>>,
key: AgentProviderKey,
windows: boolean
): SleepingAgentSighting | null {
): T | null {
for (const [paneKey, record] of sleepingByProviderSession(records).get(key.providerSessionId) ??
[]) {
if (
records[paneKey] !== record ||
(windows && isGuestTranscriptPath(record.providerSession.transcriptPath))
) {
if (records[paneKey] !== record) {
continue
}
const recordKey = agentProviderKeyOf({
agent: record.agent,
providerSession: record.providerSession,
connectionId: record.connectionId ?? null
})
const recordKey = sleepingRecordKey(record, windows)
if (recordKey && sameProviderKey(recordKey, key)) {
return record
return records[paneKey]!
}
}
return null
@@ -9,12 +9,13 @@ import {
type ProviderSessionSighting,
type SettledAgentIdentityPlace
} from '../identity/pane-agent-identities'
import type {
AgentProviderSessionMetadata,
SleepingAgentSessionRecord
} from '../../../shared/agent-session/lifecycle/agent-session-resume'
import type { AgentProviderSessionMetadata } from '../../../shared/agent-session/lifecycle/agent-session-resume'
import type { AgentProviderKey } from '../identity/agent-provider-key'
import { sleepingRecordOfKey } from '../identity/sleeping-agent-records'
import {
sleepingRecordIds,
sleepingRecordOfKey,
type SleepingAgentSighting
} from '../identity/sleeping-agent-records'
import {
isReverseLookupUnavailable,
type ReverseLookupUnavailable
@@ -30,11 +31,6 @@ import {
import { AgentHookServerState } from './server-state'
import { structuredStatusLegacyEvent } from './server-structured-status-row'
type SleepingAgentSighting = Pick<
SleepingAgentSessionRecord,
'agent' | 'providerSession' | 'connectionId'
>
/** One terminal agent conversation's own transcript, as the payload naming it reported it. */
export type TerminalAgentTranscriptSource = {
key: AgentProviderKey
@@ -219,6 +215,30 @@ export abstract class AgentHookServerAgentIdentities extends AgentHookServerStat
if (live) {
return live
}
const sleeping = await this.sleepingTerminalAgent(id, readSleeping, authority, known)
return sleeping && 'record' in sleeping
? { key: sleeping.key, providerSession: sleeping.record.providerSession, authority }
: sleeping
}
/** Each saved sleeping record's terminal agent id, in order, null for one never named; one
* store question for keys this authority has not answered. Undefined when it cannot answer. */
sleepingTerminalAgentIds(
records: readonly SleepingAgentSighting[]
): Promise<(OrcaSessionId | null)[] | undefined> {
return sleepingRecordIds(records, process.platform === 'win32', (keys) =>
this.agentIdentities.idsOfKeys(keys)
)
}
/** The saved sleeping record naming terminal agent `id`'s conversation, by its provider key;
* null when none does, undefined when the store cannot answer now. */
async sleepingTerminalAgent<T extends SleepingAgentSighting>(
id: string,
readSleeping: () => Readonly<Record<string, T>>,
authority = this.agentIdentities.authority(),
known?: TerminalAgentTranscriptSource
): Promise<{ key: AgentProviderKey; record: T } | ReverseLookupUnavailable | null | undefined> {
const linked =
known?.authority === authority
? { key: known.key }
@@ -237,10 +257,7 @@ export abstract class AgentHookServerAgentIdentities extends AgentHookServerStat
return null
}
const record = sleepingRecordOfKey(readSleeping(), key, windows)
if (record) {
return { key, providerSession: record.providerSession, authority }
}
return null
return record ? { key, record } : null
}
private liveTranscriptSource(
@@ -49,6 +49,10 @@ export class MemoryAgentIdentityClient implements AgentIdentityClient {
.map(([, id]) => id)
}
async idsOfKeys(path: string, keys: AgentProviderKey[]): Promise<(OrcaSessionId | null)[]> {
return keys.map((key) => this.ids.get(JSON.stringify([path, key])) ?? null)
}
readonly isRetiring = false
retired(): Promise<void> {
@@ -0,0 +1,240 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it, vi } from 'vitest'
import { normalizeHookPayload } from '../../../shared/agent-hook-listener/normalization/normalize-hook-payload'
import { createHookListenerState } from '../../../shared/agent-hook-listener/listener-state'
import { makePaneKey } from '../../../shared/workspace-layout/stable-pane-id'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-resume'
import type {
RuntimeWorktreeAgentRow,
RuntimeWorktreePsResult
} from '../../../shared/runtime-worktree-contracts'
import { isOrcaSessionId, type OrcaSessionId } from '../../../shared/orca-session-address'
import { InProcessAgentIdentityClient } from '../../agent-hooks/identity/agent-identity-client.test-fixtures'
import type { AgentIdentityClient } from '../../agent-hooks/identity/agent-identity-worker-client'
import { buildBody } from '../../agent-hooks/server/testing/server.test-fixtures'
import type * as HookServerModule from '../../agent-hooks/server/agent-hook-server'
/** The real hook server and its SQL identity store behind `agent list` and `agent show`. */
const hooks = vi.hoisted(() => {
const state: { server: null | Record<string, (...args: never[]) => unknown> } = { server: null }
return state
})
vi.mock('../../agent-hooks/server/agent-hook-server', async (load) => ({
...(await load<typeof HookServerModule>()),
agentHookServer: new Proxy(
{},
{
get:
(_, name: string) =>
(...args: never[]) =>
hooks.server![name]!(...args)
}
)
}))
const { AgentHookServer } = await import('../../agent-hooks/server/agent-hook-server')
const { agentConversationPlaces, listAgentConversations } =
await import('./agent-conversation-list')
const WORKTREE = 'repo::/work/delta'
const cleanups: (() => void)[] = []
afterEach(() => {
hooks.server = null
for (const cleanup of cleanups.splice(0)) {
cleanup()
}
})
class IdentityTestServer extends AgentHookServer {
readonly identityClient = new InProcessAgentIdentityClient()
protected override createAgentIdentityClient(): AgentIdentityClient {
return this.identityClient
}
moveStatusFile(path: string): void {
this.setStatusFilePath(path)
}
}
function directory(): string {
const path = mkdtempSync(join(tmpdir(), 'agent-list-sleeping-'))
cleanups.push(() => rmSync(path, { recursive: true, force: true }))
return path
}
const pane = (tab: number) => makePaneKey(`tab-${tab}`, '00000000-0000-4000-8000-000000000000')
function record(tab: number, session: string, reply: string): SleepingAgentSessionRecord {
return {
paneKey: pane(tab),
worktreeId: WORKTREE,
agent: 'claude',
providerSession: {
key: 'session_id',
id: session,
transcriptPath: `/home/u/.claude/projects/p/${session}.jsonl`
},
prompt: 'task',
state: 'done',
capturedAt: 10 + tab,
updatedAt: 10 + tab,
lastAssistantMessage: reply,
connectionId: null,
origin: 'worktree-sleep'
}
}
/** Local Claude agents that reported once, each named by the store, then put to sleep. */
async function sleptAgents(sessions: string[]) {
const server = new IdentityTestServer()
cleanups.unshift(() => server.stop())
await server.start({ env: 'production', userDataPath: directory() })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the proxy forwards only the hook server's own methods.
hooks.server = server as unknown as Record<string, (...args: never[]) => unknown>
const ids: OrcaSessionId[] = []
for (const [tab, session] of sessions.entries()) {
const event = normalizeHookPayload(
createHookListenerState(),
'claude',
buildBody(
{
hook_event_name: 'UserPromptSubmit',
session_id: session,
transcript_path: `/home/u/.claude/projects/p/${session}.jsonl`,
prompt: 'task'
},
{ paneKey: pane(tab), tabId: `tab-${tab}` }
),
'production'
)
server.ingestRemote(event!, null)
await new Promise((resolve) => setImmediate(resolve))
const id = server.getStatusSnapshot().find((row) => row.paneKey === pane(tab))?.orcaSessionId
if (!id || !isOrcaSessionId(id)) {
throw new Error('the agent was not named')
}
ids.push(id)
server.dropStatusEntry(pane(tab), { preserveResumeIdentity: false })
}
server.identityClient.idsOfKeysRequests = 0
server.identityClient.providerKeyRequests = 0
return { server, ids }
}
function runtime(
records: Record<string, SleepingAgentSessionRecord>,
agents: Partial<RuntimeWorktreeAgentRow>[] = []
) {
const ps = {
worktrees: [{ worktreeId: WORKTREE, displayName: 'delta', repo: 'repo', agents }],
totalCount: 1,
truncated: false
}
return {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: listing reads only each worktree's id, labels and agent rows.
getWorktreePs: async () => ps as unknown as RuntimeWorktreePsResult,
readLocalSleepingAgentSessions: () => records,
getWorkspaceLabel: (worktreeId: string) =>
worktreeId === WORKTREE ? { workspace: 'delta', repo: 'repo' } : null
}
}
it('lists and shows a terminal agent whose workspace is asleep, without waking it', async () => {
const { ids } = await sleptAgents(['session-a'])
const host = runtime({ [pane(0)]: record(0, 'session-a', 'DELTA done') })
const listed = await listAgentConversations(host, 10)
const shown = await agentConversationPlaces(host, { kind: 'terminal', orcaSessionId: ids[0]! })
const asleep = {
orcaSessionId: ids[0],
kind: 'terminal',
agent: 'claude',
state: 'asleep',
worktreeId: WORKTREE,
workspace: 'delta',
repo: 'repo',
paneKey: pane(0),
lastAssistantMessage: 'DELTA done'
}
expect(listed.agents).toEqual([expect.objectContaining(asleep)])
expect(shown).toEqual([expect.objectContaining(asleep)])
expect(await listAgentConversations(host, 10, { state: 'asleep' })).toMatchObject({
agents: [{ orcaSessionId: ids[0] }]
})
expect(await listAgentConversations(host, 10, { state: 'working' })).toMatchObject({
agents: []
})
})
it('keeps the tabs a wake has not reopened, and the woken row keeps its last reply', async () => {
const { ids } = await sleptAgents(['session-a', 'session-b'])
// Tab 0 woke and resumed with no new reply yet; tab 1 has not been opened since the wake.
const host = runtime(
{
[pane(0)]: { ...record(0, 'session-a', 'ECHO done'), origin: 'live' },
[pane(1)]: record(1, 'session-b', 'DELTA done')
},
[
{
paneKey: pane(0),
orcaSessionId: ids[0],
agentType: 'claude',
state: 'working',
lastAssistantMessage: null,
conversationName: 'echo',
updatedAt: 50
}
]
)
const listed = await listAgentConversations(host, 10)
expect(listed.agents).toEqual([
expect.objectContaining({
orcaSessionId: ids[0],
state: 'working',
lastAssistantMessage: 'ECHO done'
}),
expect.objectContaining({
orcaSessionId: ids[1],
state: 'asleep',
paneKey: pane(1),
lastAssistantMessage: 'DELTA done'
})
])
})
it.each([100, 1_000])(
'asks the store once for %i sleeping agents across repeated lists, and again for a new store',
async (count) => {
const { server, ids } = await sleptAgents(['session-a'])
const records: Record<string, SleepingAgentSessionRecord> = {
[pane(0)]: record(0, 'session-a', 'DELTA done')
}
for (let index = 1; index < count; index++) {
records[`tab-x${index}:1`] = {
...record(0, `never-${index}`, ''),
paneKey: `tab-x${index}:1`
}
}
const host = runtime(records)
for (let call = 0; call < 12; call++) {
const listed = await listAgentConversations(host, 10)
expect(listed.agents).toHaveLength(count)
expect(listed.agents.find((row) => row.paneKey === pane(0))?.orcaSessionId).toBe(ids[0])
}
expect(server.identityClient.idsOfKeysRequests).toBe(1)
expect(server.identityClient.providerKeyRequests).toBe(0)
// Another store never linked these keys: its answer, asked once, replaces the old one.
server.moveStatusFile(join(directory(), 'status.json'))
const moved = await listAgentConversations(host, 10)
expect(moved.agents.find((row) => row.paneKey === pane(0))?.orcaSessionId).toBeNull()
expect(server.identityClient.idsOfKeysRequests).toBe(2)
}
)
@@ -40,7 +40,8 @@ it.each([750, 7_500])(
)
const runtime = {
getWorkspaceLabel: vi.fn(() => ({ workspace: 'Workspace', repo: 'repo' })),
getWorktreePs: vi.fn()
getWorktreePs: vi.fn(),
readLocalSleepingAgentSessions: () => ({})
}
const places = await agentConversationPlaces(runtime, {
@@ -1,8 +1,9 @@
import type {
AgentConversationRow,
AgentListResult
AgentListResult,
AgentListState
} from '../../../shared/agent-conversations/agent-conversation-contracts'
import type { AgentStatusState } from '../../../shared/agent-status/contracts/agent-status-types'
import type { SleepingAgentSessionRecord } from '../../../shared/agent-session/lifecycle/agent-session-resume'
import type { RuntimeWorktreePsResult } from '../../../shared/runtime-worktree-contracts'
import { structuredAgentSessionIdFromTabId } from '../../../shared/agent-session/view/structured-agent-session-tab-id'
import {
@@ -15,10 +16,94 @@ import type { ResolvedAgentConversation } from './agent-conversation-address'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { connectionEvidenceHostId } from '../workspaces/process-listing/runtime-worktree-summary-paths'
type ListFilter = { worktreeId?: string; state?: AgentListState }
type AgentPlaceRuntime = {
getWorkspaceLabel(
worktreeId: string,
hostId: ExecutionHostId
): { workspace: string; repo?: string } | null
readLocalSleepingAgentSessions(): Readonly<Record<string, SleepingAgentSessionRecord>>
}
/** Every agent in this host's workspaces: `worktree.ps` rows, then each terminal agent with no
* open pane from its saved sleeping record (asleep, or its tab not reopened since a wake), so
* a list never wakes one. Work beyond `worktree.ps` is one pass over those records, keyed
* labels, and a store question only for keys this store authority has not answered. */
export async function listAgentConversations(
runtime: AgentPlaceRuntime & {
getWorktreePs(limit: number): Promise<RuntimeWorktreePsResult>
},
worktreeLimit: number,
filter: ListFilter = {}
): Promise<AgentListResult> {
const list = projectAgentConversationList(
await runtime.getWorktreePs(worktreeLimit),
filter.worktreeId ? { worktreeId: filter.worktreeId } : {}
)
const records = Object.values(runtime.readLocalSleepingAgentSessions()).filter(
(record) => !filter.worktreeId || record.worktreeId === filter.worktreeId
)
const ids = records.length > 0 ? await agentHookServer.sleepingTerminalAgentIds(records) : []
const open = new Map(list.agents.map((row) => [row.paneKey, row]))
const listed = new Set(list.agents.map((row) => row.orcaSessionId))
const asleep: AgentConversationRow[] = []
records.forEach((record, index) => {
const orcaSessionId = ids?.[index] ?? null
const row = open.get(record.paneKey)
if (row) {
// A woken agent's row before its first new reply: its saved record holds the last one.
if (orcaSessionId && row.orcaSessionId === orcaSessionId && !row.lastAssistantMessage) {
row.lastAssistantMessage = record.lastAssistantMessage ?? null
}
return
}
if (orcaSessionId && listed.has(orcaSessionId)) {
return
}
listed.add(orcaSessionId)
const place = sleepingPlace(runtime, record, orcaSessionId)
if (place) {
asleep.push(place)
}
})
asleep.sort((a, b) => a.updatedAt - b.updatedAt)
return {
...list,
agents: [...list.agents, ...asleep].filter((row) => !filter.state || row.state === filter.state)
}
}
/** A terminal agent's saved sleeping record as a listed place; none for a workspace this host
* no longer has. */
function sleepingPlace(
runtime: AgentPlaceRuntime,
record: SleepingAgentSessionRecord,
orcaSessionId: string | null
): AgentConversationRow | null {
const hostId = connectionEvidenceHostId(record.connectionId ?? null)
const label = runtime.getWorkspaceLabel(record.worktreeId, hostId)
return label
? {
orcaSessionId,
kind: 'terminal',
agent: record.agent,
state: 'asleep',
worktreeId: record.worktreeId,
...label,
hostId,
paneKey: record.paneKey,
title: record.terminalTitle ?? null,
lastAssistantMessage: record.lastAssistantMessage ?? null,
updatedAt: record.updatedAt
}
: null
}
/** `worktree.ps` agent rows as one flat list of chats and terminal agents, oldest state first. */
export function projectAgentConversationList(
ps: RuntimeWorktreePsResult,
filter: { worktreeId?: string; state?: AgentStatusState } = {}
filter: ListFilter = {}
): AgentListResult {
const agents: AgentConversationRow[] = []
for (const worktree of ps.worktrees) {
@@ -56,14 +141,28 @@ export function projectAgentConversationList(
}
/** Where one resolved conversation is open now, from its own keyed status rows: never a listing
* of every workspace. A closed chat or a terminal agent with no live pane has none. */
* of every workspace. A closed chat has none; a terminal agent with no live pane shows the
* saved sleeping record naming its conversation, as `agent list` does. */
export async function agentConversationPlaces(
runtime: {
getWorkspaceLabel(
worktreeId: string,
hostId: ExecutionHostId
): { workspace: string; repo?: string } | null
},
runtime: AgentPlaceRuntime,
resolved: ResolvedAgentConversation
): Promise<AgentConversationRow[]> {
const places = await openPlaces(runtime, resolved)
if (places.length > 0 || resolved.kind !== 'terminal') {
return places
}
const sleeping = await agentHookServer.sleepingTerminalAgent(resolved.orcaSessionId, () =>
runtime.readLocalSleepingAgentSessions()
)
const place =
sleeping && 'record' in sleeping
? sleepingPlace(runtime, sleeping.record, resolved.orcaSessionId)
: null
return place ? [place] : []
}
async function openPlaces(
runtime: AgentPlaceRuntime,
resolved: ResolvedAgentConversation
): Promise<AgentConversationRow[]> {
const { orcaSessionId } = resolved
@@ -11,7 +11,7 @@ import type {
} from '../../../../../shared/agent-conversations/agent-conversation-contracts'
import {
agentConversationPlaces,
projectAgentConversationList
listAgentConversations
} from '../../../agent-conversations/agent-conversation-list'
import { resolveAgentConversation } from '../../../agent-conversations/agent-conversation-address'
import { readAgentConversation } from '../../../agent-conversations/agent-conversation-read'
@@ -27,7 +27,7 @@ export const AGENT_CONVERSATION_METHODS = [
permission: 'workspace',
params: AgentListParams,
handler: async (params, { runtime }): Promise<AgentListResult> =>
projectAgentConversationList(await runtime.getWorktreePs(AGENT_LIST_WORKTREE_LIMIT), params)
listAgentConversations(runtime, AGENT_LIST_WORKTREE_LIMIT, params)
}),
defineMethod({
name: 'agent.show',
@@ -1,16 +1,24 @@
import type { AgentStatusState } from '../agent-status/contracts/agent-status-types'
import {
AGENT_STATUS_STATES,
type AgentStatusState
} from '../agent-status/contracts/agent-status-types'
import type { RuntimeListingHostScope } from '../runtime-listing-host-scope'
export type AgentConversationKind = 'chat' | 'terminal'
/** One open agent, chat or terminal, as `orca agent list` shows it. */
/** A listed agent's state: its live status, or `asleep` for a terminal agent with no open pane
* whose saved record Orca keeps (its workspace asleep, or its tab not reopened since a wake). */
export const AGENT_LIST_STATES = [...AGENT_STATUS_STATES, 'asleep'] as const
export type AgentListState = AgentStatusState | 'asleep'
/** One agent, chat or terminal, as `orca agent list` shows it. */
export type AgentConversationRow = {
/** The conversation's Orca session ID (a chat's /clear root); null for a terminal agent the
* host has not named yet, which no command can address by ID. */
orcaSessionId: string | null
kind: AgentConversationKind
agent: string | null
state: AgentStatusState
state: AgentListState
worktreeId: string
workspace: string
/** The workspace's project, so two workspaces on one branch name tell apart. */
@@ -32,8 +40,9 @@ export type AgentListResult = {
export type AgentShowResult = {
orcaSessionId: string
kind: AgentConversationKind
/** Where it is open now: none when it isn't open, two for one conversation resumed in two panes.
* Read from the conversation's own status rows, so they carry no title (`list` does). */
/** Where it is open now: none when it isn't open, two for one conversation resumed in two panes;
* a terminal agent with no open pane shows its saved `asleep` place. Read from the
* conversation's own status rows, so open places carry no title (`list` does). */
places: AgentConversationRow[]
}
@@ -1,12 +1,14 @@
import * as z from 'zod'
import { AGENT_STATUS_STATES } from '../agent-status/contracts/agent-status-types'
import { AGENT_READ_MAX_LIMIT } from '../agent-conversations/agent-conversation-contracts'
import {
AGENT_LIST_STATES,
AGENT_READ_MAX_LIMIT
} from '../agent-conversations/agent-conversation-contracts'
import { requiredString } from './rpc-param-primitives'
// Not strict: a newer CLI's filters must not break an older host's list.
export const AgentListParams = z.object({
worktreeId: z.string().min(1).optional(),
state: z.enum(AGENT_STATUS_STATES).optional()
state: z.enum(AGENT_LIST_STATES).optional()
})
export const AgentShowParams = z.object({