diff --git a/docs/site/content/docs/cli/reference.mdx b/docs/site/content/docs/cli/reference.mdx index e4bf76f1f94..9efc948249c 100644 --- a/docs/site/content/docs/cli/reference.mdx +++ b/docs/site/content/docs/cli/reference.mdx @@ -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:_`), 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:_`), 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`. diff --git a/skill-guides/orca-cli.md b/skill-guides/orca-cli.md index 416f564d196..c67f80452e5 100644 --- a/skill-guides/orca-cli.md +++ b/skill-guides/orca-cli.md @@ -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:_`; 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 --json` shows where one agent is open (none if closed, two if resumed in two panes). - `ORCA agent read --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). diff --git a/src/cli/agents/agent-conversations.ts b/src/cli/agents/agent-conversations.ts index 149116861c6..761b2126b19 100644 --- a/src/cli/agents/agent-conversations.ts +++ b/src/cli/agents/agent-conversations.ts @@ -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 = { '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): 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) { diff --git a/src/cli/discovery/command-scoped-flag-help.ts b/src/cli/discovery/command-scoped-flag-help.ts index cf265c0c264..b9622cd7691 100644 --- a/src/cli/discovery/command-scoped-flag-help.ts +++ b/src/cli/discovery/command-scoped-flag-help.ts @@ -9,7 +9,8 @@ const AGENT_ID_FLAG_HELP = const COMMAND_SCOPED_FLAG_HELP: Record> = { 'agent list': { - state: '--state Only agents in this state: working, blocked, waiting or done' + state: + '--state Only agents in this state: working, blocked, waiting, done or asleep' }, 'agent show': { id: AGENT_ID_FLAG_HELP }, 'agent read': { diff --git a/src/cli/skills/bundled-skill-guides.ts b/src/cli/skills/bundled-skill-guides.ts index 3ba8bf6d02a..460ff7dbd49 100644 --- a/src/cli/skills/bundled-skill-guides.ts +++ b/src/cli/skills/bundled-skill-guides.ts @@ -300,7 +300,7 @@ const ORCA_CLI_MARKDOWN = [ "", "Every agent, chat or terminal, has an Orca session ID (`orca_session_id:_`; 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 --json` shows where one agent is open (none if closed, two if resumed in two panes).", "- `ORCA agent read --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).", "", diff --git a/src/cli/specs/agent-conversations.ts b/src/cli/specs/agent-conversations.ts index b7d36fd098a..a487643a5e1 100644 --- a/src/cli/specs/agent-conversations.ts +++ b/src/cli/specs/agent-conversations.ts @@ -8,9 +8,9 @@ export const AGENT_CONVERSATION_COMMAND_SPECS: CommandSpec[] = [ usage: 'orca agent list [--worktree ] [--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: [ ' 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'] }, diff --git a/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts b/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts index d6e8b36a84e..e21420571c5 100644 --- a/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts +++ b/src/main/agent-hooks/identity/agent-identity-client.test-fixtures.ts @@ -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)) { diff --git a/src/main/agent-hooks/identity/agent-identity-worker-client.ts b/src/main/agent-hooks/identity/agent-identity-worker-client.ts index 0f5d47fc85b..a93cacef40d 100644 --- a/src/main/agent-hooks/identity/agent-identity-worker-client.ts +++ b/src/main/agent-hooks/identity/agent-identity-worker-client.ts @@ -40,6 +40,8 @@ export type AgentIdentityClient = { path: string, providerSessionId: string ): Promise + /** 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 @@ -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 } diff --git a/src/main/agent-hooks/identity/agent-identity-worker-entry.ts b/src/main/agent-hooks/identity/agent-identity-worker-entry.ts index 8df68da9fde..54d41fdbc10 100644 --- a/src/main/agent-hooks/identity/agent-identity-worker-entry.ts +++ b/src/main/agent-hooks/identity/agent-identity-worker-entry.ts @@ -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. diff --git a/src/main/agent-hooks/identity/agent-identity-worker-protocol.ts b/src/main/agent-hooks/identity/agent-identity-worker-protocol.ts index e8531bfbc97..6d10a138fc3 100644 --- a/src/main/agent-hooks/identity/agent-identity-worker-protocol.ts +++ b/src/main/agent-hooks/identity/agent-identity-worker-protocol.ts @@ -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 diff --git a/src/main/agent-hooks/identity/pane-agent-identities.ts b/src/main/agent-hooks/identity/pane-agent-identities.ts index 1172bece177..69c7ef3418f 100644 --- a/src/main/agent-hooks/identity/pane-agent-identities.ts +++ b/src/main/agent-hooks/identity/pane-agent-identities.ts @@ -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>() private readonly canonicalBySubject = new Map() private readonly keyStrings = new WeakMap() + 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 { diff --git a/src/main/agent-hooks/identity/saved-agent-key-ids.ts b/src/main/agent-hooks/identity/saved-agent-key-ids.ts new file mode 100644 index 00000000000..21882e51d58 --- /dev/null +++ b/src/main/agent-hooks/identity/saved-agent-key-ids.ts @@ -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() + + 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 + } +} diff --git a/src/main/agent-hooks/identity/sleeping-agent-records.ts b/src/main/agent-hooks/identity/sleeping-agent-records.ts index bc44bc7e594..1b20c2d8dc4 100644 --- a/src/main/agent-hooks/identity/sleeping-agent-records.ts +++ b/src/main/agent-hooks/identity/sleeping-agent-records.ts @@ -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>, +export function sleepingRecordOfKey( + records: Readonly>, 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 diff --git a/src/main/agent-hooks/server/server-agent-identities.ts b/src/main/agent-hooks/server/server-agent-identities.ts index 8df857413b4..e13890f3d63 100644 --- a/src/main/agent-hooks/server/server-agent-identities.ts +++ b/src/main/agent-hooks/server/server-agent-identities.ts @@ -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( + id: string, + readSleeping: () => Readonly>, + 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( diff --git a/src/main/agent-hooks/server/testing/memory-agent-identity-store.test-fixture.ts b/src/main/agent-hooks/server/testing/memory-agent-identity-store.test-fixture.ts index 491c8cd7145..bf2658c3a8f 100644 --- a/src/main/agent-hooks/server/testing/memory-agent-identity-store.test-fixture.ts +++ b/src/main/agent-hooks/server/testing/memory-agent-identity-store.test-fixture.ts @@ -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 { diff --git a/src/main/runtime/agent-conversations/agent-conversation-list-sleeping.test.ts b/src/main/runtime/agent-conversations/agent-conversation-list-sleeping.test.ts new file mode 100644 index 00000000000..f3d1a03143d --- /dev/null +++ b/src/main/runtime/agent-conversations/agent-conversation-list-sleeping.test.ts @@ -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 unknown> } = { server: null } + return state +}) +vi.mock('../../agent-hooks/server/agent-hook-server', async (load) => ({ + ...(await load()), + 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 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, + agents: Partial[] = [] +) { + 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 = { + [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) + } +) diff --git a/src/main/runtime/agent-conversations/agent-conversation-list.test.ts b/src/main/runtime/agent-conversations/agent-conversation-list.test.ts index dee86769284..550aee1c74f 100644 --- a/src/main/runtime/agent-conversations/agent-conversation-list.test.ts +++ b/src/main/runtime/agent-conversations/agent-conversation-list.test.ts @@ -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, { diff --git a/src/main/runtime/agent-conversations/agent-conversation-list.ts b/src/main/runtime/agent-conversations/agent-conversation-list.ts index 3d42654042e..15fd908e4ea 100644 --- a/src/main/runtime/agent-conversations/agent-conversation-list.ts +++ b/src/main/runtime/agent-conversations/agent-conversation-list.ts @@ -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> +} + +/** 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 + }, + worktreeLimit: number, + filter: ListFilter = {} +): Promise { + 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 { + 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 { const { orcaSessionId } = resolved diff --git a/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts b/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts index 7b98010f7f6..8452fd9f054 100644 --- a/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts +++ b/src/main/runtime/rpc/methods/agent-conversations/agent-conversation-methods.ts @@ -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 => - projectAgentConversationList(await runtime.getWorktreePs(AGENT_LIST_WORKTREE_LIMIT), params) + listAgentConversations(runtime, AGENT_LIST_WORKTREE_LIMIT, params) }), defineMethod({ name: 'agent.show', diff --git a/src/shared/agent-conversations/agent-conversation-contracts.ts b/src/shared/agent-conversations/agent-conversation-contracts.ts index 0132b1a118d..e5baf9c2c12 100644 --- a/src/shared/agent-conversations/agent-conversation-contracts.ts +++ b/src/shared/agent-conversations/agent-conversation-contracts.ts @@ -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[] } diff --git a/src/shared/rpc-contract/agent-conversation-params.ts b/src/shared/rpc-contract/agent-conversation-params.ts index fa4f5d20f08..d5ecf2e4ae5 100644 --- a/src/shared/rpc-contract/agent-conversation-params.ts +++ b/src/shared/rpc-contract/agent-conversation-params.ts @@ -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({