mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 10:27:33 +00:00
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:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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).",
|
||||
"",
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user