From 17050dec8fa297b5d4353c70e42ccccbdf19a985 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 01:52:38 -0700 Subject: [PATCH 01/10] docs(control): plan remaining external operator toolkit stages --- .../external-operator-toolkit.md | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 docs/decomposition/external-operator-toolkit.md diff --git a/docs/decomposition/external-operator-toolkit.md b/docs/decomposition/external-operator-toolkit.md new file mode 100644 index 00000000..7dd882ba --- /dev/null +++ b/docs/decomposition/external-operator-toolkit.md @@ -0,0 +1,109 @@ +# External operator toolkit continuation + +Approved scope: the user requested completing the existing plan beyond #794, +prioritizing lifecycle/navigation, then batches and broader controls. No merge +without fresh explicit confirmation. Tracking: #795; parent design: #793. +Base: `5d6418459d8eb6aa99819eaec70e1f0610c898a7`. + +## A and D + +A: `src/control-sdk` owns typed registration, routing and durable execution; +`main/control/history/tasks.ts` and the SDK task launcher record long operations; +feature-owned adapters expose the first release. `providerSwitchCore.ts` already +returns outcomes, but `provider.ts` exposes focused UI actions that discard them. +The original product plan and `external-control-sdk.md` remain authoritative. + +D: an external operator can complete the remaining planned lifecycle/navigation, +batch and common feature workflows using explicit IDs and observable results, +with computer use for documented UI-only decisions. Internal agents remain excluded. + +## Stages + +### 1. Lifecycle and navigation domain contracts + +- **Produces:** exact-target domain entry points and result types for switch, + reload, rewind/undo, resume/duplicate and placement/view actions; thin feature + control adapters plus exact backend interrupt. No MCP imports in domain code. +- **Verified by:** existing replacement/provider/rewind tests plus focused + behavioral checks of captured targets, changed ownership, preserved drafts, + normal confirmations and final identity/result reporting. +- **Why separate:** wrapping toast-only/focused callbacks would bless success + without a completed effect and silently follow a changed selection. +- **Reality check:** actual `providerSwitchCore`, `provider`, `pane`, `session`, + Reader/Spotlight owners, native history/rewind IPC and existing test fixtures. + +### 2. Native history/address discovery + +- **Produces:** bounded native session and prompt catalogs using existing main + provider readers, returning the exact provider/cwd/rewind addresses consumed by + lifecycle actions. Do not confuse operation history with conversation history. +- **Verified by:** existing recorded provider transcripts and stale/mismatched + addresses, pagination and cold reads without waking sessions. +- **Why separate:** invented native IDs or prompt offsets make lifecycle tools + impossible to use reliably despite apparently valid schemas. +- **Reality check:** actual session indexes, rewind address types and provider + capability declarations; unsupported provider discovery stays explicit. + +### 3. Batch operations + +- **Produces:** bounded batch read/prompt contracts with individual results, + independent continuation cursors and stable per-target child request keys. +- **Verified by:** mixed successes/failures across actual SDK registrations; + retrying a partially completed batch does not repeat a delivered child prompt. +- **Why separate:** whole-batch success/idempotency cannot express partial delivery. +- **Reality check:** first-release single-agent contracts and durable executor; + concrete external-operation feedback determines additional cases. + +### 4. Broader feature controls + +- **Produces:** feature-owned template, ordinary settings, usage/worktree, named + surface and workflow adapters, with live documentation updates. +- **Verified by:** existing domain owners and their UI semantics; external workflow + ownership must be established before exposing run, and each slice has its own + independently verified result contract. +- **Why separate:** workflow identity and settings side effects are not generic + store edits; each owner must settle its actual semantics first. +- **Reality check:** feature registries/services and existing workflows/templates; + no fabricated cross-provider reconciliation fixtures. + +### 5. Integration and review + +- **Produces:** verified packaged/external lifecycle evidence, updated capability + coverage and a complete unmerged PR linked to #795 and relevant feedback issues. +- **Verified by:** standalone SDK/full app types, meaningful feature checks, + system/renderer tests and production build; external trial where an isolated + app/state is available. Report unavailable real-world evidence honestly. +- **Why separate:** unit/schema checks cannot prove installed client or window + behavior. A useful release is not a tool-count target. +- **Reality check:** compiled app, actual clients and feedback linked to #793. + +## Isolation + +Domain operations remain owned by workspace/features/main services. Their public +results feed control adapters; adapters may not import MCP. The SDK remains +platform-neutral. Native history reconciliation has one main-owned port; batches +consume existing SDK calls under the original caller identity, never upgrade an +external caller to application privileges. Workflow ownership is isolated inside +the workflow service boundary, not inferred in the transport. + +## Unknowns and evidence plan + +- Per-provider resume/duplicate/export coverage, including open #773 for OpenCode + picker support; do not imply that an MCP wrapper repairs unsupported discovery. +- Lifecycle outcomes after compaction, lost placement, or a user editing during + an asynchronous operation; preserve real domain guards and report uncertainty. +- View/placement semantics for detached, buried, mirrored and related sessions. +- Batch size/output bounds and partial retries after a renderer/client restart. +- External workflow ownership; no invented parent Agent Code session. +- Which ordinary settings are safe to change through existing apply handlers. +- Real external trial access and runtime installation; preserve the user's live + workspace and unrelated local lockfile edits. + +Use existing recorded transcripts/layout fixtures where shape reconciliation is +involved. Ordinary contract/fault-injection tests need no general recording harness, +as explicitly agreed. Check #793 cross-references at stage boundaries and update +this plan/issue for concrete findings. All work stays unmerged pending confirmation. + +## Checkpoints + +- Planning: source owners inspected; no new feedback linked to #793 at start. From 2ade58b2c936f7ade4547931873f3aceaf625eae Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 02:06:32 -0700 Subject: [PATCH 02/10] feat(control): extend exact agent lifecycle and operator navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse provider replacement, native transcript and placement owners with explicit targets and observable results. Incorporate the external trial’s visible identity, effective focus, attachment and draft-knowledge findings. OS activation and native draft end-to-end confirmation remain external evidence. Refs #795 Refs #796 Refs #797 Refs #798 Refs #799 Refs #800 Refs #801 --- .../external-operator-toolkit.md | 17 ++ .../agent-code-computer-execution/SKILL.md | 47 +++++- src/control-sdk/catalog/input.ts | 8 + src/control-sdk/catalog/workspace.ts | 3 +- src/control-sdk/index.ts | 1 + src/main/control/globalCapabilities.ts | 8 +- src/main/index.ts | 4 +- src/main/sessions/conditionControl.test.ts | 23 +++ src/main/sessions/conditionControl.ts | 17 ++ .../sessions/nativeHistoryControl.test.ts | 37 +++++ src/main/sessions/nativeHistoryControl.ts | 49 ++++++ src/main/sessions/terminalControl.ts | 12 +- src/main/window/focusWindow.test.ts | 31 ++++ src/main/window/focusWindow.ts | 36 +++-- src/main/window/identityControl.ts | 18 +++ src/providers/registry.main.ts | 1 + .../src/control/registerRendererHost.ts | 4 + .../features/workspace/controlReference.ts | 8 +- src/renderer/src/workspace/control.ts | 17 +- src/renderer/src/workspace/control/agents.ts | 4 +- .../src/workspace/control/conditions.ts | 18 ++- src/renderer/src/workspace/control/drafts.ts | 2 +- .../control/identity.renderer.test.ts | 32 ++++ .../control/layout.renderer.test.tsx | 31 ++++ src/renderer/src/workspace/control/layout.ts | 12 +- .../control/lifecycle.renderer.test.tsx | 66 ++++++++ .../src/workspace/control/lifecycle.ts | 147 ++++++++++++++++++ .../src/workspace/control/navigation.ts | 81 ++++++++++ .../workspace/dispatch/DispatchAgentList.tsx | 45 +----- .../src/workspace/dispatch/rowTitle.ts | 48 ++++++ .../controlPlacement.renderer.test.tsx | 14 ++ .../hook/actions/focusSurfaceTarget.ts | 4 +- .../src/workspace/hook/actions/pane.ts | 24 +-- .../src/workspace/hook/actions/provider.ts | 93 +++++++---- .../src/workspace/hook/actions/reader.ts | 18 ++- .../src/workspace/hook/actions/spotlight.ts | 16 +- src/renderer/src/workspace/hook/index.ts | 12 +- src/shared/types/providerConfig.ts | 2 + 38 files changed, 886 insertions(+), 124 deletions(-) create mode 100644 src/control-sdk/catalog/input.ts create mode 100644 src/main/sessions/nativeHistoryControl.test.ts create mode 100644 src/main/sessions/nativeHistoryControl.ts create mode 100644 src/main/window/focusWindow.test.ts create mode 100644 src/main/window/identityControl.ts create mode 100644 src/renderer/src/workspace/control/identity.renderer.test.ts create mode 100644 src/renderer/src/workspace/control/lifecycle.renderer.test.tsx create mode 100644 src/renderer/src/workspace/control/lifecycle.ts create mode 100644 src/renderer/src/workspace/control/navigation.ts create mode 100644 src/renderer/src/workspace/dispatch/rowTitle.ts diff --git a/docs/decomposition/external-operator-toolkit.md b/docs/decomposition/external-operator-toolkit.md index 7dd882ba..eb4e9295 100644 --- a/docs/decomposition/external-operator-toolkit.md +++ b/docs/decomposition/external-operator-toolkit.md @@ -107,3 +107,20 @@ this plan/issue for concrete findings. All work stays unmerged pending confirmat ## Checkpoints - Planning: source owners inspected; no new feedback linked to #793 at start. + +- Feedback checkpoint: #796–#801 arrived during implementation. This stage now + includes canonical visible labels/displayed titles, explicit effective tiled + focus, app/process attachment identity, application activation before window + focus, native-draft uncertainty and the multi-lane navigation recipe. The + activation root cause is a supported hypothesis until the two-monitor trial + verifies it; no focus acknowledgment is bypassed. #800's real committed-prompt + trial remains external evidence, not a fabricated passing fixture. +- Lifecycle adapters and native prompt catalogs are implemented. Initial existing + checks: 11 files/33 tests passed; new lifecycle/placement/focus checks: 3 files/8 + tests passed. Recorded label/focus transitions and native catalog checks are + being verified before the batch stage. +- Stage 1/2 code checkpoint: full TypeScript including the standalone neutral SDK + passed. The combined lifecycle/navigation/catalog/feedback/import-boundary run + passed 11 files/25 tests, including recorded native prompts and Dispatch + coordinates. OS two-monitor activation and occupied native-draft trials remain + external verification; do not label those reproduced/fixed on unit evidence. diff --git a/operator-skills/agent-code-computer-execution/SKILL.md b/operator-skills/agent-code-computer-execution/SKILL.md index e81d87e9..2cd89b08 100644 --- a/operator-skills/agent-code-computer-execution/SKILL.md +++ b/operator-skills/agent-code-computer-execution/SKILL.md @@ -28,6 +28,11 @@ external server to the agents being operated or edit their MCP configuration. ## Select the correct window and agent +For computer attachment, first use `ac_app_identity`: match the running PID and +exact executable/bundle path. Development checkouts can share the Electron bundle +ID and title. Attach to the existing process; opening a guessed executable can +launch an unrelated blank Electron window. + Use `ac_app_windows` to get stable window IDs and current renderer generations, then `ac_app_observe` for the project tabs, agents, layout and input-owning surfaces in each window. A window, project tab, grid tile and agent session are different @@ -53,6 +58,22 @@ explicit restoration; do not create a replacement just because a session is off screen. After a tool changes focus or opens a surface, inspect the actual selected window before clicking or typing. +For example, to focus “A5 in window two” while preserving `[A5, B7, B8, B9]`: + +1. Match window two using `ac_app_windows` and its projects in `ac_app_observe`. +2. Search `{label: "A5", windowId: ""}`. Check `displayedTitle`, + provider and conversation; the same label in another window is another agent. +3. Locate the returned stable session ID, then call `ac_agents_show` with + `intent: "reuse-existing-view"` and that window/generation. +4. Verify the foreground window and lane selections before computer input. + +Clicking A5 in the shared Dispatch index places it into the **focused lane**, +even if another lane already shows A5. That can change `[A5, B7, B8, B9]` to +`[A5, A5, B8, B9]`: two views of one process, not a new agent. Use explicit +`open-in-focused-tiled-dispatch-lane` only when that replacement is intended. +`layout.read.effectiveFocusedSessionId` is the current command target; +`dispatch.classicFocusedSessionId` only remembers classic Dispatch selection. + ## Choose MCP or computer use Prefer a typed tool for stable IDs, difficult navigation, prompt delivery, @@ -93,11 +114,16 @@ shows unsaved/conflicting buffers; `ac_editor_open` preserves those edits. Use `ac_agents_prompt` with the exact session and the user's intended prompt. A successful response confirms provider acceptance, which may be a queue or transport acknowledgment. It does not mean the task finished. The tool preserves -the composer's draft; do not also click Send for the same prompt. +Agent Code’s app-owned draft; do not also click Send for the same prompt. To edit unsent text, read `ac_agents_draft_get` and supply its revision to `ac_agents_draft_set`. Replace preserves attachments; clear removes them; undo -restores text only. Draft editing never submits a prompt. +restores text only. Draft editing never submits a prompt. `ac_agents_input_inspect` separately reports +native provider draft knowledge. `unknown` is not empty; an xterm accessibility +“Terminal input” value is transport state and may omit existing TUI text. Prefer +typed prompt delivery with its provider checks. If computer paste/Return is +needed, establish the full native composer first and verify the committed prompt; +do not clear uncertain existing text just to make room. Use `ac_agents_conditions_read` to inspect a blocking provider condition. Reply only with an advertised action ID and its current revision using @@ -142,6 +168,23 @@ Finish its dialog with computer use and call `ac_operations_read` with the retur callId to learn whether it closed or was cancelled. Do not interpret acceptance as completion or issue a second close while confirmation is pending. +## Lifecycle and views + +Read `ac_agents_lifecycle_read` before reload, switch, duplicate, rewind or undo. +Use its supported choices and current revision. These operations return a task +callId; `ac_operations_read` returns the final replacement ID. Re-find that ID +before further actions. Reload/rewind/duplicate require idle native conversations; +`ac_agents_interrupt` requests the ordinary Stop signal, not process termination. + +Use `ac_native_history_list` to find conversations outside the current workspace, +and `ac_native_history_prompts` for exact rewind addresses. Native IDs are not +Agent Code session IDs. Resume continues the native conversation; duplicate +branches a copy. OpenCode discovery can be unavailable while known IDs still work. + +`ac_placement_inspect` explains detach/bury consequences before their revision-bound +operations. Last-pane bury also archives detached children. `ac_views_agent_set` +selects Reader, Spotlight or normal workspace by desired state, without toggles. + ## Recover and verify Use a fresh `_control.requestKey` for each new mutation intention. Reuse that key diff --git a/src/control-sdk/catalog/input.ts b/src/control-sdk/catalog/input.ts new file mode 100644 index 00000000..647549a8 --- /dev/null +++ b/src/control-sdk/catalog/input.ts @@ -0,0 +1,8 @@ +import { z } from 'zod' + +// The provider terminal composer and Agent Code's draft are separate owners. +// No current provider port proves the full native draft, so absence of a probe +// must remain unknown, never an empty string inferred from an xterm textarea. +export const nativeInputOutput = z.object({ sessionId: z.string(), sessionRunId: z.string().nullable(), + backendPresent: z.boolean(), nativeDraft: z.object({ state: z.literal('unknown'), text: z.null(), reason: z.string() }), + inputReady: z.boolean().nullable() }) diff --git a/src/control-sdk/catalog/workspace.ts b/src/control-sdk/catalog/workspace.ts index 6d8fd20e..31258ad4 100644 --- a/src/control-sdk/catalog/workspace.ts +++ b/src/control-sdk/catalog/workspace.ts @@ -13,7 +13,8 @@ export const workspaceObservationSchema = z.object({ mode: z.enum(['grid', 'tiled-tabs', 'dispatch', 'tiled-dispatch']), tabs: z.array(z.object({ id: z.string(), title: z.string(), focusedSessionId: z.string(), sessionIds: z.array(z.string()) })), sessions: z.array(z.object({ - sessionId: z.string(), title: z.string(), cwd: z.string(), provider: z.string(), + sessionId: z.string(), title: z.string(), displayLabel: z.string().nullable().default(null).describe('Current window-local visible coordinate; can change with layout. Never use as a stable ID.'), + displayedTitle: z.string().default('').describe('The current UI title, including prompt fallback where shown.'), cwd: z.string(), provider: z.string(), providerRuntime: z.string().nullable(), providerSessionId: z.string().nullable(), pinned: z.boolean(), placements: z.array(placementSchema), })), diff --git a/src/control-sdk/index.ts b/src/control-sdk/index.ts index 1917c9fb..4ac6f27b 100644 --- a/src/control-sdk/index.ts +++ b/src/control-sdk/index.ts @@ -29,3 +29,4 @@ export { operatorRoutingSchema, externalConnectionStatusSchema } from './operato export type { ControlOperatorPort, ExternalConnectionStatus } from './operator' export { startControlTask } from './task' +export { nativeInputOutput } from './catalog/input' diff --git a/src/main/control/globalCapabilities.ts b/src/main/control/globalCapabilities.ts index fc9ecadd..a16d1e77 100644 --- a/src/main/control/globalCapabilities.ts +++ b/src/main/control/globalCapabilities.ts @@ -18,18 +18,18 @@ export function globalControlCapabilities(observe: ObserveWindows) { }), defineCapability({ id: 'agents.search', title: 'Search agents across windows', execution: 'main', effect: 'read', - description: 'Find existing agents across every window/project, including related, detached and buried agents. Results carry stable ownership for direct navigation. Incomplete windows are reported, never silently dropped.', - input: z.object({ query: z.string().default('').describe('Case-insensitive substring of agent ID, title, directory or provider. Empty searches all agents.'), windowId: z.string().optional().describe('Optional stable window ID from app.windows to restrict the cross-window search.'), tabId: z.string().optional().describe('Optional project tab ID from app.observe.'), + description: 'Find existing agents across every window/project, including related, detached and buried agents. Labels are window-local and may be ambiguous globally; all matching candidates are returned. Results carry stable ownership for direct navigation. Incomplete windows are reported, never silently dropped.', + input: z.object({ label: z.string().regex(/^[A-Za-z]+[1-9]\d*$/).optional().describe('Exact visible label, e.g. C18. Scope with windowId; global matches may identify different agents in different windows. Resolve to a stable ID before acting.'), query: z.string().default('').describe('Case-insensitive substring of agent ID, visible label, displayed/stored title, directory or provider. Empty searches all agents.'), windowId: z.string().optional().describe('Optional stable window ID from app.windows to restrict the cross-window search.'), tabId: z.string().optional().describe('Optional project tab ID from app.observe.'), provider: z.enum(['claude', 'codex', 'opencode']).optional().describe('Restrict to one provider.'), placement: z.enum(['grid', 'related', 'dispatch', 'detached', 'buried', 'reader', 'spotlight']).optional().describe('Restrict to agents with this placement; mirrored placements still identify the same agent.'), ...pageInput }).strict(), output: pageSchema(match).extend({ unavailableWindows: z.array(z.object({ windowId: z.string(), error: z.string() })) }), handler: async (input, context) => { const windows = (await observe(context)).filter(window => !input.windowId || window.windowId === input.windowId) const query = input.query.trim().toLocaleLowerCase() const rows = windows.flatMap(window => window.workspace && window.owner ? window.workspace.sessions.filter(session => - session.provider !== 'terminal' && (!input.provider || session.provider === input.provider) + session.provider !== 'terminal' && (!input.label || session.displayLabel === input.label.toUpperCase()) && (!input.provider || session.provider === input.provider) && (!input.tabId || session.placements.some(placement => placement.tabId === input.tabId)) && (!input.placement || session.placements.some(placement => placement.kind === input.placement)) - && [session.sessionId, session.title, session.cwd, session.provider].some(value => value.toLocaleLowerCase().includes(query))) + && [session.sessionId, session.title, session.displayedTitle, session.displayLabel ?? '', session.cwd, session.provider].some(value => value.toLocaleLowerCase().includes(query))) .map(session => ({ ...session, owner: window.owner! })) : []) const { cursor: _cursor, limit: _limit, ...filters } = input return { ...paginate(rows, input, `agents.search:${JSON.stringify(filters)}`), diff --git a/src/main/index.ts b/src/main/index.ts index 77bdb36d..bac94eee 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -17,6 +17,8 @@ import { performance } from 'perf_hooks' import { SessionManager } from '@main/sessionManager.js' import { createControlHost } from '@main/control/createControlHost.js' import { sessionHistoryControlCapabilities } from '@main/sessions/control.js' +import { nativeHistoryControlCapabilities } from '@main/sessions/nativeHistoryControl.js' +import { applicationIdentityCapabilities } from '@main/window/identityControl.js' import { conditionBackendCapabilities } from '@main/sessions/conditionControl.js' import { terminalBackendCapabilities } from '@main/sessions/terminalControl.js' import { windowLifecycleControlCapabilities } from '@main/window/lifecycleControl.js' @@ -953,7 +955,7 @@ async function startApp(): Promise { stop: () => externalHost.stop(), copy: text => clipboard.writeText(text), }) const controlHost = createControlHost({ getBrowserWindow, windowIdFor, listWindowIds }, join(STATE_DIR, 'control-history'), [ - ...sessionHistoryControlCapabilities(), ...conditionBackendCapabilities(manager), ...terminalBackendCapabilities(manager), ...windowLifecycleControlCapabilities(), ...externalSettings.capabilities, + ...applicationIdentityCapabilities(), ...sessionHistoryControlCapabilities(), ...nativeHistoryControlCapabilities(), ...conditionBackendCapabilities(manager), ...terminalBackendCapabilities(manager), ...windowLifecycleControlCapabilities(), ...externalSettings.capabilities, ]) externalHost = new ExternalControlMcpHost(controlHost.forCaller({ kind: 'external', id: 'agent-code-control' })) await externalSettings.initialize() diff --git a/src/main/sessions/conditionControl.test.ts b/src/main/sessions/conditionControl.test.ts index 848f3553..129639f2 100644 --- a/src/main/sessions/conditionControl.test.ts +++ b/src/main/sessions/conditionControl.test.ts @@ -41,3 +41,26 @@ it('routes the recorded trust choice intact and rejects stale process/dialog ide expect(resolveCondition).toHaveBeenCalledTimes(1) expect(write).not.toHaveBeenCalled() }) + +it('sends the ordinary Stop byte only to the observed process with no current condition', async () => { + let run = 'original' + let conditions: Record = {} + const write = vi.fn().mockReturnValue(true) + const manager = { getBackendSnapshot: () => ({ sessionId: 'agent', cwd: '/trial', kind: 'codex', sessionRunId: run }), + getConditionsSnapshot: () => ({ provider: 'codex', conditions }), write } as unknown as Parameters[0] + const caps = conditionBackendCapabilities(manager) + const invoke = (id: string, input: unknown) => caps.find(cap => cap.descriptor.id === id)!.execute(input, context) + const identity = { sessionId: 'agent', cwd: '/trial', provider: 'codex' } + const result = await invoke('sessions.conditionsRead', identity) + if (!result.ok) throw new Error(JSON.stringify(result)) + const input = { ...identity, revision: (result.value as { revision: string }).revision } + run = 'replacement' + expect(await invoke('sessions.interrupt', input)).toMatchObject({ ok: false, error: { code: 'stale_cursor' } }) + expect(write).not.toHaveBeenCalled() + run = 'original' + expect(await invoke('sessions.interrupt', input)).toMatchObject({ ok: true, value: { accepted: true } }) + expect(write).toHaveBeenCalledExactlyOnceWith('agent', '\x1b') + conditions = { trust: buildClaudeTrustDialogCondition(detectTrustDialog(screen))! } + expect(await invoke('sessions.interrupt', input)).toMatchObject({ ok: false, error: { code: 'stale_cursor' } }) + expect(write).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/sessions/conditionControl.ts b/src/main/sessions/conditionControl.ts index 77e909b8..5338234a 100644 --- a/src/main/sessions/conditionControl.ts +++ b/src/main/sessions/conditionControl.ts @@ -1,4 +1,5 @@ import { createHash } from 'node:crypto' +import { z } from 'zod' import { ControlError, defineCapability, conditionTargetInput, conditionReadOutput, conditionReplyInput, conditionBackendIdentity, conditionReplyOutput } from '@control-sdk' import { makeDispatch } from '@shared/conditions-core/dispatch' import type { SessionManager } from '@main/sessionManager' @@ -19,6 +20,22 @@ export function conditionBackendCapabilities(manager: Pick { + const { backend, conditions, revision } = observe(input) + if (!backend.sessionRunId || input.revision !== revision) throw new ControlError('stale_cursor', 'Backend or conditions changed; inspect again') + if (conditions.length) throw new ControlError('unavailable', 'Resolve the advertised condition before requesting Stop') + // TileLeaf's Stop sends Escape, not SIGINT or process termination. Use + // that same write path so delivery reservations and provider handling + // remain authoritative. Observation and admission share one JS turn. + if (!manager.write(input.sessionId, '\u001b')) throw new ControlError('unavailable', 'Backend refused Stop input') + return { sessionId: input.sessionId, sessionRunId: backend.sessionRunId, accepted: true as const } + }, + }), defineCapability({ id: 'sessions.conditionsRead', visibility: 'application', title: 'Read authoritative conditions', execution: 'main', effect: 'read', description: 'Backing operation for the owning window; reads current backend conditions without spawning or attaching.', diff --git a/src/main/sessions/nativeHistoryControl.test.ts b/src/main/sessions/nativeHistoryControl.test.ts new file mode 100644 index 00000000..b0e02e5f --- /dev/null +++ b/src/main/sessions/nativeHistoryControl.test.ts @@ -0,0 +1,37 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +const source = vi.hoisted(() => ({ path: '', list: vi.fn() })) +vi.mock('@providers/registry.main', () => ({ getMainProvider: (id: string) => id === 'opencode' + ? { sessionDiscoveryUnavailableReason: 'OpenCode discovery unavailable (#773)' } : { listSessions: source.list, listAllSessions: source.list } })) +vi.mock('@main/providerSwitch/shared.js', () => ({ getClaudeSessionFilePath: async () => source.path, writeProjectedClaudeSessionFile: vi.fn(), projectedClaudeSessionId: vi.fn() })) +import { nativeHistoryControlCapabilities } from './nativeHistoryControl' +import { getHostTranscriptAdapter } from '@main/providerSwitch/transcriptEngine' +const directories: string[] = [] +afterEach(async () => { await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) +const context = { requestId: 'catalog', caller: { kind: 'external' as const, id: 'operator' }, owner: { kind: 'main' as const, generation: 'main' } } +it('pages exact rewind references from the recorded Claude transcript through the real native engine', async () => { + const dir = await mkdtemp(join(tmpdir(), 'ac-native-catalog-')); directories.push(dir) + source.path = join(dir, 'source.jsonl') + // These are captured native Claude records from the existing rendering + // bundle, not invented prompt shapes. Only the storage path is redirected. + const bundle = JSON.parse(await readFile('testing/fixtures/rendering-bundles/2026-07-07T13-17-48-452-5b19529f.json', 'utf8')) + await writeFile(source.path, bundle.input.entries.map((row: unknown) => JSON.stringify(row)).join('\n')) + const native = await getHostTranscriptAdapter('claude').listPrompts('/trial', 'recorded') + expect(native.length).toBeGreaterThan(1) + const cap = nativeHistoryControlCapabilities().find(cap => cap.descriptor.id === 'nativeHistory.prompts')! + const input = { provider: 'claude', cwd: '/trial', nativeSessionId: 'recorded', limit: 1, previewChars: 20 } + const first = await cap.execute(input, context) + if (!first.ok) throw new Error(JSON.stringify(first)) + const page = first.value as { items: Array<{ address: unknown; totalChars: number; text: string }>; nextCursor: string } + expect(page.items[0]).toMatchObject({ address: native.at(-1)!.address, text: native.at(-1)!.text.slice(0, 20), totalChars: native.at(-1)!.text.length }) + expect(await cap.execute({ ...input, cursor: page.nextCursor }, context)).toMatchObject({ ok: true, value: { items: [{ address: native.at(-2)!.address }] } }) + expect(await cap.execute({ ...input, previewChars: 0, cursor: page.nextCursor }, context)).toMatchObject({ ok: false, error: { code: 'stale_cursor' } }) +}) +it('reports unsupported discovery and IO failures rather than a complete empty account', async () => { + const cap = nativeHistoryControlCapabilities().find(cap => cap.descriptor.id === 'nativeHistory.list')! + expect(await cap.execute({ provider: 'opencode' }, context)).toMatchObject({ ok: false, error: { code: 'unavailable' } }) + source.list.mockRejectedValue(new Error('Provider directory is unreadable')) + expect(await cap.execute({ provider: 'claude' }, context)).toMatchObject({ ok: false, error: { message: 'Provider directory is unreadable' } }) +}) diff --git a/src/main/sessions/nativeHistoryControl.ts b/src/main/sessions/nativeHistoryControl.ts new file mode 100644 index 00000000..1dbcfb8d --- /dev/null +++ b/src/main/sessions/nativeHistoryControl.ts @@ -0,0 +1,49 @@ +import { z } from 'zod' +import { ControlError, defineCapability, pageInput, pageSchema, paginate } from '@control-sdk' +import { getMainProvider } from '@providers/registry.main' +import { getHostTranscriptAdapter } from '@main/providerSwitch/transcriptEngine' + +const provider = z.enum(['claude', 'codex', 'opencode']) +const identity = z.object({ provider, cwd: z.string().min(1).describe('Native session working directory, not a project title.'), + nativeSessionId: z.string().min(1).describe('Provider-native ID from nativeHistory.list or agents.lifecycleRead; not an Agent Code session ID.') }) +const prompt = z.object({ address: z.object({ provider, line: z.number(), sessionId: z.string().nullable(), uuid: z.string().nullable().optional() }), + text: z.string(), totalChars: z.number(), timestamp: z.string().nullable() }) +const session = z.object({ nativeSessionId: z.string(), summary: z.string(), lastModified: z.number(), fileSize: z.number(), + cwd: z.string().nullable(), customTitle: z.string().nullable(), firstPrompt: z.string().nullable(), gitBranch: z.string().nullable() }) + +// Catalogs adapt the same provider registry and transcript engine used by the +// native pickers. In particular, discovery failure is not an empty inventory: +// the current OpenCode registry intentionally cannot enumerate sessions (#773), +// although known native IDs can still be read, resumed and transformed. +export function nativeHistoryControlCapabilities() { + return [ + defineCapability({ id: 'nativeHistory.list', title: 'Find native sessions to resume', execution: 'main', effect: 'read', + description: 'List recent provider-native sessions, including conversations not open in Agent Code. Select one provider and optionally an exact cwd. Discovery does not wake agents. The catalog is bounded by scanLimit; possiblyTruncated means older sessions may exist beyond it. OpenCode discovery is currently unsupported (#773), not an empty account. Use agents.resume to open a chosen native identity in an explicit project.', + input: z.object({ provider, cwd: z.string().min(1).optional(), scanLimit: z.number().int().min(1).max(2000).default(500).describe('Number of recent native records to load before paging; keep fixed for continuation.'), ...pageInput }).strict(), + output: pageSchema(session).extend({ provider, possiblyTruncated: z.boolean() }), + handler: async input => { + const owner = getMainProvider(input.provider) + if (owner.sessionDiscoveryUnavailableReason) throw new ControlError('unavailable', owner.sessionDiscoveryUnavailableReason) + if (!input.cwd && !owner.listAllSessions) throw new ControlError('unavailable', 'This provider requires a working directory for discovery') + const rows = input.cwd ? await owner.listSessions(input.cwd, input.scanLimit) : await owner.listAllSessions!(input.scanLimit) + const normalized = rows.map(row => ({ nativeSessionId: row.sessionId, summary: row.summary.slice(0, 4000), lastModified: row.lastModified, + fileSize: row.fileSize, cwd: row.cwd ?? input.cwd ?? null, customTitle: row.customTitle ?? null, + firstPrompt: row.firstPrompt?.slice(0, 4000) ?? null, gitBranch: row.gitBranch ?? null })) + return { ...paginate(normalized, input, `native:${input.provider}:${input.cwd ?? ''}:${input.scanLimit}`), provider: input.provider, + possiblyTruncated: rows.length >= input.scanLimit } + }, + }), + defineCapability({ id: 'nativeHistory.prompts', title: 'Find exact native rewind addresses', execution: 'main', effect: 'read', + description: 'Read user prompt addresses from an exact native transcript, newest first, without waking its agent. Uses the native engine, including OpenCode export for a known ID. Text previews are bounded; totalChars reports omitted text. Addresses are opaque source references, not rendered message indexes. Pass an address unchanged to agents.rewind. Source changes invalidate paging; rewind itself revalidates membership and refuses an empty resulting conversation.', + input: identity.extend({ ...pageInput, previewChars: z.number().int().min(0).max(4000).default(1000).describe('Maximum text characters per prompt; zero returns addresses only.') }).strict(), + output: pageSchema(prompt), + handler: async input => { + const prompts = await getHostTranscriptAdapter(input.provider).listPrompts(input.cwd, input.nativeSessionId) + // Revision includes full text, not only previews: an edited prompt + // after the preview boundary must invalidate the address catalog too. + const page = paginate(prompts.slice().reverse(), input, `prompts:${input.provider}:${input.cwd}:${input.nativeSessionId}:${input.previewChars}`) + return { ...page, items: page.items.map(row => ({ ...row, text: row.text.slice(0, input.previewChars), totalChars: row.text.length })) } + }, + }), + ] +} diff --git a/src/main/sessions/terminalControl.ts b/src/main/sessions/terminalControl.ts index 2702b444..59251d49 100644 --- a/src/main/sessions/terminalControl.ts +++ b/src/main/sessions/terminalControl.ts @@ -1,6 +1,6 @@ import { createHash, randomUUID } from 'node:crypto' import { z } from 'zod' -import { ControlError, defineCapability, terminalReadInput, terminalReadOutput, terminalInput, terminalInputOutput } from '@control-sdk' +import { ControlError, defineCapability, nativeInputOutput, terminalReadInput, terminalReadOutput, terminalInput, terminalInputOutput } from '@control-sdk' import type { SessionManager } from '@main/sessionManager' const ownership = { cwd: z.string(), provider: z.string() } @@ -20,6 +20,16 @@ export function terminalBackendCapabilities(manager: Pick { + const backend = manager.getBackendSnapshot(input.sessionId) + if (backend && (backend.cwd !== input.cwd || backend.kind !== input.provider)) throw new ControlError('unavailable', 'Backend identity changed') + return { sessionId: input.sessionId, sessionRunId: backend?.sessionRunId ?? null, backendPresent: Boolean(backend), inputReady: backend?.input.ready ?? null, + nativeDraft: { state: 'unknown' as const, text: null, reason: 'The provider port does not expose a complete native composer snapshot. Readiness and the terminal accessibility input value do not prove an empty draft. agents.draftGet reads only the separate Agent Code draft.' } } + }, + }), defineCapability({ id: 'sessions.terminalRead', visibility: 'application', title: 'Read retained raw output', execution: 'main', effect: 'read', description: 'Backing read without attach, resize, wake or subscription changes. Frozen pages retain the current backend identity.', diff --git a/src/main/window/focusWindow.test.ts b/src/main/window/focusWindow.test.ts new file mode 100644 index 00000000..c03e32d3 --- /dev/null +++ b/src/main/window/focusWindow.test.ts @@ -0,0 +1,31 @@ +import { EventEmitter } from 'node:events' +import type { BrowserWindow } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +const app = vi.hoisted(() => ({ focus: vi.fn() })) +vi.mock('electron', () => ({ app })) +import { focusWindow } from './focusWindow' +afterEach(() => { vi.useRealTimers(); vi.clearAllMocks() }) +function target() { + let focused = false + const window = Object.assign(new EventEmitter(), { isDestroyed: () => false, isMinimized: () => true, + isFocused: () => focused, restore: vi.fn(), show: vi.fn(), focus: vi.fn(() => { focused = true; window.emit('focus') }) }) + return window +} +it('requests application activation before the window and catches synchronous acknowledgment', async () => { + const window = target() + window.focus.mockImplementation(() => { expect(app.focus).toHaveBeenCalledWith({ steal: true }); window.isFocused = () => true; window.emit('focus') }) + await focusWindow(window as unknown as BrowserWindow) + expect(window.restore).toHaveBeenCalledOnce() + expect(window.listenerCount('focus')).toBe(0) + expect(window.listenerCount('closed')).toBe(0) +}) +it('does not claim focus on denied activation and cleans listeners on timeout/closure', async () => { + vi.useFakeTimers() + const window = target(); window.focus.mockImplementation(() => {}) + const denied = expect(focusWindow(window as unknown as BrowserWindow)).rejects.toThrow('did not acknowledge focus') + await vi.advanceTimersByTimeAsync(2500); await denied + expect(window.listenerCount('focus')).toBe(0) + const closed = expect(focusWindow(window as unknown as BrowserWindow)).rejects.toThrow('disappeared') + window.emit('closed'); await closed + expect(vi.getTimerCount()).toBe(0) +}) diff --git a/src/main/window/focusWindow.ts b/src/main/window/focusWindow.ts index c0e2e8a5..dc23a224 100644 --- a/src/main/window/focusWindow.ts +++ b/src/main/window/focusWindow.ts @@ -1,18 +1,30 @@ -import type { BrowserWindow } from 'electron' +import { app, type BrowserWindow } from 'electron' -// Both automatic capability activation and an explicit operator handoff need -// the same acknowledgment. Calling focus() is only a request to the OS, so a -// successful tool must not authorize typing before Electron observes focus. +// App activation and window focus are different macOS operations. show/focus +// alone can leave our window behind the external operator (#797). Electron's +// documented app.focus({steal:true}) requests an explicit app handoff; it does +// not waive the subsequent window acknowledgment or guarantee OS permission. +// https://www.electronjs.org/docs/latest/api/app#appfocusoptions export async function focusWindow(window: BrowserWindow): Promise { if (window.isDestroyed()) throw new Error('Target window disappeared') - if (window.isMinimized()) window.restore() - window.show(); window.focus() - if (window.isFocused()) return await new Promise((resolve, reject) => { - const cleanup = () => { clearTimeout(timer); window.removeListener('focus', focused) } - const focused = () => { cleanup(); resolve() } - const timer = setTimeout(() => { cleanup(); reject(new Error('Window focus was not acknowledged')) }, 2500) - window.once('focus', focused) - if (window.isFocused()) focused() + const cleanup = () => { clearTimeout(timer); window.removeListener('focus', focused); window.removeListener('closed', closed) } + const focused = () => { if (!window.isDestroyed() && window.isFocused()) { cleanup(); resolve() } } + const closed = () => { cleanup(); reject(new Error('Target window disappeared during activation')) } + const timer = setTimeout(() => { + cleanup() + reject(new Error('Application activation was requested, but the target window did not acknowledge focus. Select this existing window through the OS Window menu, then inspect app.windows before continuing.')) + }, 2500) + // Subscribe before requests: show/restore/focus may emit synchronously. + // A destroyed window must fail immediately rather than wait out the timer. + window.on('focus', focused) + window.once('closed', closed) + try { + if (window.isMinimized()) window.restore() + app.focus({ steal: true }) + window.show() + window.focus() + focused() + } catch (error) { cleanup(); reject(error) } }) } diff --git a/src/main/window/identityControl.ts b/src/main/window/identityControl.ts new file mode 100644 index 00000000..02a75023 --- /dev/null +++ b/src/main/window/identityControl.ts @@ -0,0 +1,18 @@ +import { app } from 'electron' +import { z } from 'zod' +import { defineCapability } from '@control-sdk' + +export function applicationIdentityCapabilities() { + return [defineCapability({ id: 'app.identity', title: 'Identify the running application for computer use', execution: 'main', effect: 'read', + description: 'Identify THIS already-running Agent Code process: PID, exact executable, app bundle (macOS), application source/resources path and packaged/development identity. Attach computer use to this existing process; never guess another checkout or launch an Electron executable to find it. Pair app.windows stable IDs, bounds and app.observe project descriptions with the native window inventory. A bundle ID or generic Electron title alone cannot distinguish development checkouts.', + input: z.object({}).strict(), output: z.object({ pid: z.number(), name: z.string(), version: z.string(), packaged: z.boolean(), + executablePath: z.string(), applicationPath: z.string(), bundlePath: z.string().nullable(), platform: z.string() }), + handler: () => { + const executablePath = app.getPath('exe') + const boundary = executablePath.lastIndexOf('.app/') + return { pid: process.pid, name: app.getName(), version: app.getVersion(), packaged: app.isPackaged, + executablePath, applicationPath: app.getAppPath(), bundlePath: process.platform === 'darwin' && boundary >= 0 ? executablePath.slice(0, boundary + 4) : null, + platform: process.platform } + }, + })] +} diff --git a/src/providers/registry.main.ts b/src/providers/registry.main.ts index 8d14dbfc..b6f8f246 100644 --- a/src/providers/registry.main.ts +++ b/src/providers/registry.main.ts @@ -108,6 +108,7 @@ const opencodeMain: MainProviderConfig = { // Known `ses_` identities are fully resumable/transformable through the CLI; // returning an empty list keeps only discovery unavailable. listSessions: async () => [], + sessionDiscoveryUnavailableReason: 'OpenCode native session discovery is not implemented (#773). Known ses_ identities remain resumable.', // Opencode has no per-cwd project dir concept; the storage root is // server-owned. Returning cwd keeps consumers (which only display // it) harmless. diff --git a/src/renderer/src/control/registerRendererHost.ts b/src/renderer/src/control/registerRendererHost.ts index f3304ecd..e8685e1c 100644 --- a/src/renderer/src/control/registerRendererHost.ts +++ b/src/renderer/src/control/registerRendererHost.ts @@ -9,6 +9,8 @@ import { agentControlCapabilities } from '@renderer/workspace/control/agents' import { draftControlCapabilities } from '@renderer/workspace/control/drafts' import { conditionControlCapabilities } from '@renderer/workspace/control/conditions' import { layoutControlCapabilities } from '@renderer/workspace/control/layout' +import { lifecycleControlCapabilities } from '@renderer/workspace/control/lifecycle' +import { navigationControlCapabilities } from '@renderer/workspace/control/navigation' import { terminalControlCapabilities } from '@renderer/workspace/control/terminals' import { editorControlCapabilities } from '@renderer/features/global-editor/control' import { commandControlCapabilities } from '@renderer/features/command-palette/control' @@ -73,6 +75,8 @@ export function useControlRegistration(workspace: Workspace): void { ...conditionControlCapabilities(), ...layoutControlCapabilities(() => current.current), ...terminalControlCapabilities(() => current.current), + ...lifecycleControlCapabilities(() => current.current), + ...navigationControlCapabilities(() => current.current), ...editorControlCapabilities(), ...commandControlCapabilities(), ...keybindingControlCapabilities(), diff --git a/src/renderer/src/features/workspace/controlReference.ts b/src/renderer/src/features/workspace/controlReference.ts index 3e01538b..4faa2bc3 100644 --- a/src/renderer/src/features/workspace/controlReference.ts +++ b/src/renderer/src/features/workspace/controlReference.ts @@ -41,7 +41,7 @@ export const controlReference = [ "pin frequently used sessions." ], "outcome": "Each lane shows its selected session; mirrored lanes share the same session.", - "cautions": "Removing a lane and closing its agent are separate actions. Empty lanes stay empty until selected. layout.read returns the revision required by dispatch.configure, layout.adjust and tabs.reorder. Grid edits carry explicit sourceRow identities to preserve each retained row's agents and project filters.", + "cautions": "To focus an agent already shown in another lane, use agents.show with reuse-existing-view. Clicking the shared index replaces the focused lane selection, and intentional mirrors remain supported. Visible labels are window-local; agents.search accepts exact label plus windowId. Removing a lane and closing its agent are separate actions. Empty lanes stay empty until selected. layout.read returns the revision required by dispatch.configure, layout.adjust and tabs.reorder. Grid edits carry explicit sourceRow identities to preserve each retained row's agents and project filters.", "commandIds": [ "dispatch-mode", "global-dispatch", @@ -60,10 +60,10 @@ export const controlReference = [ "Inspect the current provider and readiness", "choose the supported operation", "wait for its actual outcome", - "observe the same session again." + "read operations.read for the new session ID and observe that replacement." ], "outcome": "The chosen provider or history state is visible and ready for the next step.", - "cautions": "A live process does not establish input readiness. Provider switch can change the provider conversation identity. Rewind is not a harmless view change.", + "cautions": "Use agents.lifecycleRead for supported choices/revisions and nativeHistory.list/prompts for native identities/rewind addresses. Resume, duplicate, switch, reload, rewind and undoRewind report final IDs through operations.read. A live process does not establish input readiness. Provider switch can change the provider conversation identity. Rewind is not a harmless view change.", "commandIds": [] }, { @@ -79,7 +79,7 @@ export const controlReference = [ "read output and exit state." ], "outcome": "Commands run in the selected terminal; agent terminal view exposes its existing provider process.", - "cautions": "Use terminals.create/read/input for detached terminal creation and retained raw PTY output with exact run-bound input. Retained output is bounded and is not unlimited history. Terminal keystrokes belong to the running program. Closing a view, interrupting a job and killing a session differ.", + "cautions": "Use terminals.create/read/input for detached terminal creation and retained raw PTY output with exact run-bound input. Retained output is bounded and is not unlimited history. agents.inputInspect reports native draft knowledge separately from agents.draftGet: unknown never means empty, and xterm accessibility input is not the full TUI draft. Terminal keystrokes belong to the running program. Closing a view, interrupting a job and killing a session differ.", "commandIds": [] } ] satisfies FeatureReference[] diff --git a/src/renderer/src/workspace/control.ts b/src/renderer/src/workspace/control.ts index 42ee58df..556c5dd7 100644 --- a/src/renderer/src/workspace/control.ts +++ b/src/renderer/src/workspace/control.ts @@ -6,6 +6,9 @@ import { resolveTabSessions } from '@renderer/workspace/queries' import { buildGridRelatedAgentTabs, selectedGridRelatedSessionId } from '@renderer/workspace/gridRelatedAgents' import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership' import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId' +import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors' +import { dispatchRowTitle } from '@renderer/workspace/dispatch/rowTitle' +import { paneLabelForSession, resolveAgentPaneLabel } from '@renderer/workspace/tile-tree/paneLabels' import { DEFAULT_PROVIDER } from '@shared/types/providerKind' import type { Workspace } from '@renderer/workspace/hook' @@ -61,12 +64,24 @@ export function observeWorkspace(getWorkspace: () => Pick [record.sessionId, record.sessionMeta])), ...state.sessions } + const dispatchRows = state.dispatchMode && !tileTabs ? buildVisibleDispatchRows(state) : [] + const identity = (sessionId: string, meta: (typeof sessions)[string]) => { + const row = dispatchRows.find(row => row.sessionId === sessionId) + const tab = state.tabs.find(tab => resolveTabSessions(state, tab.id).includes(sessionId)) + const localLabel = tab ? paneLabelForSession(state, tab.id, sessionId) : null + // Dispatch labels can shadow project-local labels. Only advertise a + // fallback that the app's label resolver maps back to this same session. + const displayLabel = row?.label ?? (localLabel && resolveAgentPaneLabel(state, localLabel, tileTabs)?.sessionId === sessionId ? localLabel : null) + const displayedTitle = row ? dispatchRowTitle(row, store.workspaceRuntimes[sessionId]?.entries) + : meta.title?.trim() || meta.cwd.split('/').filter(Boolean).pop() || meta.cwd + return { displayLabel, displayedTitle } + } return { observedAt: Date.now(), focusedSessionId, ui: { commandPickerOpen: store.commandPaletteOpen, settingsOpen: store.settingsPageOpen, inputOwnedBySurface: hasAppInteractionOwner() }, restoreStatus: getWorkspace().restoreStatus, activeTabId: state.activeTabId, mode: tileTabs ? 'tiled-tabs' as const : state.dispatchMode?.tiled ? 'tiled-dispatch' as const : state.dispatchMode ? 'dispatch' as const : 'grid' as const, tabs: state.tabs.map(tab => ({ id: tab.id, title: tab.title, focusedSessionId: tab.focusedSessionId, sessionIds: resolveTabSessions(state, tab.id) })), sessions: Object.entries(sessions).map(([sessionId, meta]) => ({ - sessionId, title: meta.title ?? '', cwd: meta.cwd, provider: meta.kind ?? DEFAULT_PROVIDER, + sessionId, ...identity(sessionId, meta), title: meta.title ?? '', cwd: meta.cwd, provider: meta.kind ?? DEFAULT_PROVIDER, providerRuntime: meta.providerRuntime ?? null, providerSessionId: meta.providerSessionId ?? null, pinned: state.pinnedSessionIds?.includes(sessionId) ?? false, placements: placements.get(sessionId) ?? [], })), diff --git a/src/renderer/src/workspace/control/agents.ts b/src/renderer/src/workspace/control/agents.ts index a9434d43..e13f27b6 100644 --- a/src/renderer/src/workspace/control/agents.ts +++ b/src/renderer/src/workspace/control/agents.ts @@ -96,7 +96,7 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) { const query = input.query.trim().toLocaleLowerCase() const rows = observe().sessions.filter(session => session.provider !== 'terminal' && (!input.tabId || session.placements.some(placement => placement.tabId === input.tabId)) - && [session.sessionId, session.title, session.cwd, session.provider].some(value => value.toLocaleLowerCase().includes(query))) + && [session.sessionId, session.title, session.displayedTitle, session.displayLabel ?? '', session.cwd, session.provider].some(value => value.toLocaleLowerCase().includes(query))) return paginate(rows, input, `agents:${query}:${input.tabId ?? ''}`) }, }), @@ -203,7 +203,7 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) { }), defineCapability({ id: 'agents.prompt', target: { kind: 'session', field: 'sessionId' }, title: 'Send an agent prompt', execution: 'window', effect: 'mutation', completion: 'accepted', - description: 'Deliver text to the exact agent through the provider delivery protocol. Reports user, queue or transport acceptance, not task completion. Preserves the composer draft and never retries an uncertain write.', + description: 'Deliver text to the exact agent through the provider delivery protocol. Reports user, queue or transport acceptance, not task completion. Preserves the Agent Code composer draft and never retries an uncertain write. Native TUI drafts are separate: agents.inputInspect reports available knowledge; provider delivery checks remain authoritative and transport acceptance is not proof of the exact committed text.', input: sessionInput.extend({ prompt: z.string().min(1).max(1_000_000).describe('Exact text to deliver once. A successful acceptance can be queued; inspect agents.read for actual progress.') }), output: z.object({ sessionId: z.string(), acceptance: z.object({ kind: z.enum(['user', 'queue', 'transport']), acceptedAt: z.number(), entryId: z.string().optional() }) }), handler: async ({ sessionId, prompt }) => { diff --git a/src/renderer/src/workspace/control/conditions.ts b/src/renderer/src/workspace/control/conditions.ts index d996adee..73ccc070 100644 --- a/src/renderer/src/workspace/control/conditions.ts +++ b/src/renderer/src/workspace/control/conditions.ts @@ -1,5 +1,6 @@ -import { ControlError, defineCapability, conditionTargetInput, conditionReadOutput, conditionReplyInput, conditionReplyOutput } from '@control-sdk' +import { ControlError, defineCapability, nativeInputOutput, conditionTargetInput, conditionReadOutput, conditionReplyInput, conditionReplyOutput } from '@control-sdk' import { useAppStore } from '@renderer/app-state/store' +import { z } from 'zod' export function conditionControlCapabilities() { const invoke = async (capabilityId: string, input: { sessionId: string }) => { @@ -11,6 +12,21 @@ export function conditionControlCapabilities() { return result.value } return [ + defineCapability({ id: 'agents.inputInspect', title: 'Inspect provider draft uncertainty', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, + description: 'Inspect backend readiness and whether the full native terminal draft is known. Currently nativeDraft.state is unknown: neither xterm accessibility input nor an empty Agent Code draft proves the provider composer is empty. Prefer agents.prompt with its provider-owned delivery checks. Before computer paste/Return, establish the full native composer through the actual UI; do not clear or submit uncertain existing text. Reads never wake or type.', + input: conditionTargetInput, output: nativeInputOutput, handler: async input => nativeInputOutput.parse(await invoke('sessions.inputInspect', input)), + }), + defineCapability({ + id: 'agents.interrupt', title: 'Request Stop for an exact agent', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, + description: 'Send the same Escape signal as the composer Stop button to an active agent, preserving its process and draft. First call agents.conditionsRead and supply its revision; changed backend identity or any current condition refuses the write. Acceptance means the signal was delivered, not that the turn stopped. Read agents.read afterward. Does not wake or force-kill anything.', + input: conditionTargetInput.extend({ revision: z.string().describe('Fresh revision from agents.conditionsRead.') }), + output: z.object({ sessionId: z.string(), sessionRunId: z.string(), accepted: z.literal(true) }), + handler: async input => { + const runtime = useAppStore.getState().workspaceRuntimes[input.sessionId] + if (!runtime?.processActive && !runtime?.semantic.currentTurn) throw new ControlError('unavailable', 'No active turn was observed') + return z.object({ sessionId: z.string(), sessionRunId: z.string(), accepted: z.literal(true) }).parse(await invoke('sessions.interrupt', input)) + }, + }), defineCapability({ id: 'agents.conditionsRead', title: 'Read current agent conditions', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, description: 'Read current provider dialogs, questions and permissions with their advertised action IDs and an exact revision. Uses the live backend, never wakes an agent. An empty action list means use the condition UI; arbitrary typed answers are not synthesized by this tool.', diff --git a/src/renderer/src/workspace/control/drafts.ts b/src/renderer/src/workspace/control/drafts.ts index a4bff755..e20bad86 100644 --- a/src/renderer/src/workspace/control/drafts.ts +++ b/src/renderer/src/workspace/control/drafts.ts @@ -26,7 +26,7 @@ export function draftControlCapabilities(getWorkspace: () => Workspace) { return [ defineCapability({ id: 'agents.draftGet', title: 'Read an agent composer draft', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, - description: 'Read unsent composer text and attachment references without waking the agent. Includes a content revision for safe edits. Large drafts page by UTF-16 offset; keep the returned revision while continuing. No attachment binary data is returned.', + description: 'Read Agent Code-owned unsent composer text and attachment references without waking the agent. Includes a content revision for safe edits. Large drafts page by UTF-16 offset; keep the returned revision while continuing. No attachment binary data is returned. This is not the native provider terminal draft; agents.inputInspect reports that separate knowledge boundary.', input: z.object({ ...identity, offset: z.number().int().min(0).default(0).describe('UTF-16 nextOffset from the previous page; starts at zero.'), revision: z.string().optional().describe('Revision from the first page; required when offset is nonzero.'), maxChars: z.number().int().min(256).max(262144).default(24000).describe('Maximum UTF-16 code units per page.') }).strict(), diff --git a/src/renderer/src/workspace/control/identity.renderer.test.ts b/src/renderer/src/workspace/control/identity.renderer.test.ts new file mode 100644 index 00000000..0184b3c3 --- /dev/null +++ b/src/renderer/src/workspace/control/identity.renderer.test.ts @@ -0,0 +1,32 @@ +import { readFileSync } from 'node:fs' +import { afterEach, expect, it } from 'vitest' +import { useAppStore } from '@renderer/app-state/store' +import { observeWorkspace } from '@renderer/workspace/control' +import { globalControlCapabilities } from '@main/control/globalCapabilities' +import { buildVisibleDispatchRows } from '@renderer/workspace/dispatch/dispatchSelectors' +import { dispatchRowTitle } from '@renderer/workspace/dispatch/rowTitle' +import { emptyRuntime } from '@renderer/session-runtime/state' +import type { WorkspaceState } from '@renderer/workspace/types' + +const initial = useAppStore.getState() +afterEach(() => useAppStore.setState(initial, true)) +it('resolves the recorded visible Dispatch label instead of its different project-local coordinate, retaining cross-window ambiguity', async () => { + const fixture = JSON.parse(readFileSync('testing/fixtures/worktree-context/dispatch-global-d23.json', 'utf8')) + const bundle = JSON.parse(readFileSync('testing/fixtures/rendering-bundles/2026-05-20T19-11-51-193-d4a44a16.json', 'utf8')) + const id = fixture.$fixture.observed.targetSessionId + useAppStore.setState({ workspaceState: fixture.state as WorkspaceState, workspaceTileTabs: null, workspaceReaderMode: null, workspaceSpotlight: null, + workspaceRuntimes: { [id]: { ...emptyRuntime(), entries: bundle.input.entries } } }) + const observed = observeWorkspace(() => ({ restoreStatus: 'fresh' })) + const target = observed.sessions.find(session => session.sessionId === id)! + const visible = buildVisibleDispatchRows(fixture.state).find(row => row.sessionId === id)! + expect(target.displayLabel).toBe(fixture.$fixture.observed.targetVisibleLabel) + expect(target.displayLabel).not.toBe(fixture.$fixture.observed.targetLocalLabel) + expect(target.displayedTitle).toBe(dispatchRowTitle(visible, bundle.input.entries)) + const owners = ['left', 'right'].map(windowId => ({ kind: 'window' as const, windowId, generation: 'current' })) + const caps = globalControlCapabilities(async () => owners.map(owner => ({ windowId: owner.windowId, owner, workspace: observed }))) + const search = (input: unknown) => caps.find(cap => cap.descriptor.id === 'agents.search')!.execute(input, { requestId: 'search', caller: { kind: 'external', id: 'test' }, owner: { kind: 'main', generation: 'main' } }) + expect(await search({ label: target.displayLabel })).toMatchObject({ ok: true, value: { total: 2, items: [ + { sessionId: id, owner: owners[0] }, { sessionId: id, owner: owners[1] }, + ] } }) + expect(await search({ label: target.displayLabel, windowId: 'right' })).toMatchObject({ ok: true, value: { total: 1, items: [{ sessionId: id, owner: owners[1] }] } }) +}) diff --git a/src/renderer/src/workspace/control/layout.renderer.test.tsx b/src/renderer/src/workspace/control/layout.renderer.test.tsx index 655fe490..6d1d118e 100644 --- a/src/renderer/src/workspace/control/layout.renderer.test.tsx +++ b/src/renderer/src/workspace/control/layout.renderer.test.tsx @@ -57,3 +57,34 @@ it('preserves recorded workspace identities through row edits and refuses a stal expect(useAppStore.getState().workspaceState.activeTabId).toBe('tab-1') expect(useAppStore.getState().workspaceState.tabs.find(tab => tab.id === 'tab-4')!.root).toMatchObject({ direction: 'horizontal' }) }) + +it('reports effective tiled focus separately from remembered classic selection after lane replacement and removal (#798)', async () => { + useAppStore.setState({ workspaceState: structuredClone(fixture.state) as unknown as WorkspaceState, workspaceTileTabs: null, workspaceReaderMode: null, workspaceSpotlight: null }) + const mounted = renderHook(() => { + const state = useAppStore(store => store.workspaceState) + const refs = useRef(makeRefs(state)).current + refs.stateRef.current = state; refs.latestStateRef.current = state + const store = useAppStore.getState() + return { ...useDispatchActions(state, store.setWorkspaceState, store.setWorkspaceTileTabs, () => {}, refs, vi.fn(), () => {}), restoreStatus: 'fresh' } + }) + const caps = layoutControlCapabilities(() => mounted.result.current as unknown as Workspace) + const invoke = (id: string, input: unknown) => caps.find(cap => cap.descriptor.id === id)!.execute(input, context) + const read = async () => { + const result = await invoke('layout.read', {}) + if (!result.ok) throw new Error(JSON.stringify(result)) + return result.value as unknown as { revision: string; effectiveFocusedSessionId: string | null; dispatch: { focusedSessionId: string | null; classicFocusedSessionId: string | null } } + } + const configure = async (change: unknown) => { const revision = (await read()).revision; await act(async () => { expect(await invoke('dispatch.configure', { revision, change })).toMatchObject({ ok: true }) }) } + await configure({ action: 'grid', rows: [{ sourceRow: 0, length: 4 }] }) + await configure({ action: 'lane-select', laneIndex: 1, sessionId: 'session-23' }) + await configure({ action: 'lane-focus', laneIndex: 1 }) + expect((await read()).effectiveFocusedSessionId).toBe('session-23') + await configure({ action: 'lane-select', laneIndex: 1, sessionId: 'session-1' }) + const replaced = await read() + expect(replaced.dispatch.focusedSessionId).toBe(replaced.effectiveFocusedSessionId) + expect(replaced.effectiveFocusedSessionId).toBe('session-1') + await configure({ action: 'grid', rows: [{ sourceRow: 0, length: 1 }] }) + const removed = await read() + expect(removed.dispatch.focusedSessionId).toBe(removed.effectiveFocusedSessionId) + expect(removed.effectiveFocusedSessionId).not.toBe('session-23') +}) diff --git a/src/renderer/src/workspace/control/layout.ts b/src/renderer/src/workspace/control/layout.ts index dfd931bc..860ed4c6 100644 --- a/src/renderer/src/workspace/control/layout.ts +++ b/src/renderer/src/workspace/control/layout.ts @@ -4,6 +4,7 @@ import { useAppStore } from '@renderer/app-state/store' import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership' import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' import { normalizeGridShape, MAX_DISPATCH_ROWS, MAX_DISPATCH_TILES, MAX_DISPATCH_LANES, INDEX_FRACTION_MIN, INDEX_FRACTION_MAX } from '@renderer/workspace/dispatch/gridShape' +import { observeWorkspace } from '@renderer/workspace/control' import type { Workspace } from '@renderer/workspace/hook' const tabId = z.string().describe('Stable project tab ID from app.observe in this window.') @@ -16,14 +17,19 @@ const rowIndex = z.number().int().min(0).describe('Zero-based row index from lay const laneIndex = z.number().int().min(0).describe('Zero-based flat lane index from layout.read; rows are laid out in row-major order.') const scope = z.enum(['project', 'global']).describe('Project uses the active project; global includes every project in this window.') const layoutOutput = z.object({ revision: z.string(), activeTabId: z.string(), tabs: z.array(z.object({ id: z.string(), root: z.json() })), - dispatch: z.json().nullable() }) + dispatch: z.json().nullable(), effectiveFocusedSessionId: z.string().nullable() }) export function layoutControlCapabilities(getWorkspace: () => Workspace) { const read = () => { const { workspaceState: state } = useAppStore.getState() const dispatch = state.dispatchMode ? { ...state.dispatchMode, + // Stored focusedSessionId remembers classic Dispatch selection. Tiled + // command targeting follows its focused lane instead (#798). Preserve + // that memory under an honest name and expose the effective target. + classicFocusedSessionId: state.dispatchMode.focusedSessionId, + focusedSessionId: observeWorkspace(getWorkspace).focusedSessionId, ...(state.dispatchMode.tiled ? { tiled: normalizeGridShape(state.dispatchMode.tiled) } : {}) } : null - const value = { activeTabId: state.activeTabId, tabs: state.tabs.map(({ id, root }) => ({ id, root })), dispatch } + const value = { effectiveFocusedSessionId: observeWorkspace(getWorkspace).focusedSessionId, activeTabId: state.activeTabId, tabs: state.tabs.map(({ id, root }) => ({ id, root })), dispatch } return { ...JSON.parse(JSON.stringify(value)), revision: paginate([value], { limit: 1 }, 'workspace-layout').revision } } const admit = (expected: string) => { @@ -39,7 +45,7 @@ export function layoutControlCapabilities(getWorkspace: () => Workspace) { return [ defineCapability({ id: 'layout.read', title: 'Read project trees and Dispatch layout', execution: 'window', effect: 'read', input: z.object({}).strict(), output: layoutOutput, - description: 'Read exact project tile trees, active tab and normalized Dispatch rows/lanes with a revision for edits. Tree split direction vertical means left/right; horizontal means top/bottom; ratio is the a-child share. Dispatch lanes are flat row-major indices, rows specify their lengths. Reading does not focus or wake agents.', + description: 'Read exact project tile trees, active tab and normalized Dispatch rows/lanes with a revision for edits. Tree split direction vertical means left/right; horizontal means top/bottom; ratio is the a-child share. Dispatch lanes are flat row-major indices, rows specify their lengths. effectiveFocusedSessionId is the current command target; dispatch.classicFocusedSessionId is only remembered classic selection. Reading does not focus or wake agents.', handler: read, }), defineCapability({ diff --git a/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx b/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx new file mode 100644 index 00000000..225820bf --- /dev/null +++ b/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx @@ -0,0 +1,66 @@ +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@renderer/app-state/store' +import { emptyRuntime } from '@renderer/session-runtime/state' +import { useProviderActions } from '@renderer/workspace/hook/actions/provider' +import { makeRefs, sessionActionsWithSpawn } from '@renderer/workspace/hook/actions/testing/paneActionsHarness' +import type { Workspace } from '@renderer/workspace/hook' +import { lifecycleControlCapabilities } from './lifecycle' + +const original = useAppStore.getState() +const originalApi = window.api +afterEach(() => { cleanup(); useAppStore.setState(original, true); window.api = originalApi }) +const context = { requestId: 'original-call', operationId: 'original-call', caller: { kind: 'external' as const, id: 'operator' }, owner: { kind: 'window' as const, windowId: 'one', generation: 'current' } } +function setup() { + useAppStore.setState({ workspaceState: { ...original.workspaceState, activeTabId: 'project', + tabs: [{ id: 'project', title: 'Project', root: { type: 'leaf', sessionId: 'other' }, focusedSessionId: 'other' }], + sessions: { source: { kind: 'codex', cwd: '/source', providerSessionId: 'native-source' }, other: { kind: 'claude', cwd: '/other' } }, + detachedSessions: { source: { sessionId: 'source', projectTabId: 'project', projectTabTitle: 'Project', projectTabIndex: 0, detachedAt: 1, surface: 'dispatch' } }, buried: [], + }, workspaceRuntimes: { source: { ...emptyRuntime(), draftInput: 'Human draft' } } }) + const refs = makeRefs(useAppStore.getState().workspaceState) + refs.latestRuntimesRef.current = useAppStore.getState().workspaceRuntimes + const replaceSession = vi.fn().mockResolvedValue('replacement') + const mounted = renderHook(() => useProviderActions(refs, useAppStore.getState().setWorkspaceRuntimes, vi.fn(), sessionActionsWithSpawn(vi.fn(), { replaceSession }))) + const report = vi.fn().mockResolvedValue({ ok: true, value: { recorded: true } }) + window.api = { ...originalApi, controlInvoke: report } + const caps = lifecycleControlCapabilities(() => ({ ...mounted.result.current, restoreStatus: 'fresh' }) as unknown as Workspace) + const invoke = (id: string, input: unknown) => caps.find(cap => cap.descriptor.id === id)!.execute(input, context) + const revision = async () => { + const result = await invoke('agents.lifecycleRead', { sessionId: 'source' }) + if (!result.ok) throw new Error(JSON.stringify(result)) + return (result.value as { revision: string }).revision + } + return { invoke, revision, replaceSession, report, refs, mounted } +} +it('reloads the named detached agent despite another focused pane and records its replacement identity', async () => { + const { invoke, revision, replaceSession, report } = setup() + await act(async () => { + expect(await invoke('agents.reload', { sessionId: 'source', revision: await revision() })).toMatchObject({ ok: true, value: { accepted: true } }) + }) + expect(replaceSession).toHaveBeenCalledExactlyOnceWith('/source', expect.objectContaining({ targetSessionId: 'source', resumeSessionId: 'native-source', kind: 'codex' })) + await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.objectContaining({ capabilityId: 'operations.finish', input: { + callId: 'original-call', result: { ok: true, value: { sourceSessionId: 'source', newSessionId: 'replacement', status: 'completed' } }, + } }))) +}) +it('rejects a changed unsent draft both before admission and after its IPC wait', async () => { + const { invoke, revision, replaceSession, report } = setup() + const observed = await revision() + const edit = () => useAppStore.setState(state => ({ workspaceRuntimes: { ...state.workspaceRuntimes, source: { ...state.workspaceRuntimes.source, draftInput: 'Edited during admission' } } })) + edit() + expect(await invoke('agents.reload', { sessionId: 'source', revision: observed })).toMatchObject({ ok: false, error: { code: 'stale_cursor' } }) + expect(report).not.toHaveBeenCalled() + const fresh = await revision() + report.mockImplementation(async request => { + if (request.capabilityId === 'operations.start') useAppStore.setState(state => ({ workspaceRuntimes: { ...state.workspaceRuntimes, source: { ...state.workspaceRuntimes.source, draftInput: 'Another edit' } } })) + return { ok: true, value: {} } + }) + await invoke('agents.reload', { sessionId: 'source', revision: fresh }) + await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.objectContaining({ capabilityId: 'operations.finish', input: expect.objectContaining({ result: expect.objectContaining({ ok: false, error: expect.objectContaining({ code: 'stale_cursor' }) }) }) }))) + expect(replaceSession).not.toHaveBeenCalled() +}) +it('reports a domain refusal instead of treating a resolved void transaction as completion', async () => { + const { invoke, revision, replaceSession, report } = setup() + replaceSession.mockResolvedValue(undefined) + await invoke('agents.reload', { sessionId: 'source', revision: await revision() }) + await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.objectContaining({ capabilityId: 'operations.finish', input: expect.objectContaining({ result: expect.objectContaining({ ok: false }) }) }))) +}) diff --git a/src/renderer/src/workspace/control/lifecycle.ts b/src/renderer/src/workspace/control/lifecycle.ts new file mode 100644 index 00000000..d8b73d85 --- /dev/null +++ b/src/renderer/src/workspace/control/lifecycle.ts @@ -0,0 +1,147 @@ +import { z } from 'zod' +import { ControlError, defineCapability, paginate } from '@control-sdk' +import { useAppStore } from '@renderer/app-state/store' +import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership' +import type { Workspace } from '@renderer/workspace/hook' +import { resumableProviderSessionId } from '@renderer/workspace/providerSessionIdentity' +import { providerSwitchChoices } from '@renderer/workspace/providerChoices' +import { isAgentProviderKind } from '@shared/types/providerKind' +import { getProviderFeatures } from '@providers/shared/featureCapabilities' +import { resolveTabSessions } from '@renderer/workspace/queries' +import { startControlTask } from './startTask' + +const target = z.object({ sessionId: z.string().min(1).describe('Exact Agent Code sessionId from agents.search; not the native transcript ID.') }).strict() +const revision = z.string().describe('Revision from agents.lifecycleRead. Refresh it after any lifecycle or draft change.') +const accepted = z.object({ callId: z.string(), accepted: z.literal(true) }) +const address = z.object({ provider: z.enum(['claude', 'codex', 'opencode']), line: z.number().int().min(0), + sessionId: z.string().nullable(), uuid: z.string().nullable().optional() }).strict() + +// Lifecycle adapters consume observable domain results, not toasts or before/ +// after session-set differences. The native transaction remains the only place +// allowed to replace a session; the journal task only carries its final result. +export function lifecycleControlCapabilities(getWorkspace: () => Workspace) { + const inspect = (sessionId: string) => { + const state = useAppStore.getState() + const meta = state.workspaceState.sessions[sessionId] + if (!meta || !isAgentProviderKind(meta.kind ?? 'claude') || state.workspaceState.buried.some(row => row.sessionId === sessionId)) { + throw new ControlError('unavailable', 'Agent is absent, buried or not an agent; inspect or restore it first') + } + const provider = meta.kind ?? 'claude' + if (!isAgentProviderKind(provider)) throw new ControlError('unavailable', 'Not an agent') + const runtime = state.workspaceRuntimes[sessionId] + const nativeSessionId = resumableProviderSessionId(meta) ?? null + const processActive = runtime?.processActive === true || Boolean(runtime?.semantic.currentTurn) + // Do not hash streaming text: it would invalidate every inspection. The + // guard covers identity, activity boundaries and unsent work, which are the + // facts a destructive replacement decision was made against. + const evidence = { meta, processActive, draft: runtime?.draftInput ?? '', images: runtime?.draftImages.map(image => image.id) ?? [], + rewindUndo: runtime?.pendingRewindUndo?.createdAt ?? null, providerSwitch: runtime?.providerSwitch ?? null } + return { sessionId, provider, providerRuntime: meta.providerRuntime ?? null, nativeSessionId, cwd: meta.cwd, processActive, + hasRewindUndo: Boolean(runtime?.pendingRewindUndo), revision: paginate([evidence], { limit: 1 }, `lifecycle:${sessionId}`).revision, + switchChoices: providerSwitchChoices(provider).map(choice => ({ provider: choice.kind, runtime: choice.providerRuntime ?? null, label: choice.label })) } + } + const guard = (input: { sessionId: string; revision: string }) => { + if (getWorkspace().restoreStatus === 'pending' || hasAppInteractionOwner()) throw new ControlError('unavailable', 'Wait for restoration or finish the input-owning surface') + const current = inspect(input.sessionId) + if (current.revision !== input.revision) throw new ControlError('stale_cursor', 'Agent lifecycle or draft changed; read agents.lifecycleRead again') + return current + } + const result = (value: { status: string; reason?: string; message?: string; newSessionId?: string }, sourceSessionId: string) => { + if (value.status === 'skipped') throw new ControlError('unavailable', value.reason ?? 'Operation declined') + if (value.status === 'failed' || !value.newSessionId) throw new ControlError('failed', value.message ?? 'Replacement was not observed', 'unknown') + return { sourceSessionId, newSessionId: value.newSessionId, status: value.status } + } + return [ + defineCapability({ id: 'agents.resume', title: 'Resume a native session in a project', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'project', field: 'tabId' }, + description: 'Open a known native conversation as a new detached agent in an explicit project. Supply provider/nativeSessionId/cwd from nativeHistory.list; known OpenCode IDs are supported. This resumes the same native conversation, not a copy; the ordinary backend ownership policy applies if already open. Returns a task callId; operations.read reports the exact newSessionId. Use agents.show or placement.attach afterward.', + input: z.object({ tabId: z.string(), anchorSessionId: z.string(), provider: z.enum(['claude', 'codex', 'opencode']), nativeSessionId: z.string().min(1), cwd: z.string().min(1), runtime: z.enum(['terminal']).optional() }).strict(), output: accepted, + handler: (input, context) => { + const check = () => { + if (getWorkspace().restoreStatus === 'pending' || hasAppInteractionOwner()) throw new ControlError('unavailable', 'Wait for restoration or finish the input-owning surface') + if (!resolveTabSessions(useAppStore.getState().workspaceState, input.tabId).includes(input.anchorSessionId)) throw new ControlError('unavailable', 'Anchor is not in the target project') + if (input.runtime && input.provider !== 'opencode') throw new ControlError('invalid_input', 'Only OpenCode supports the terminal runtime') + } + check() + return startControlTask(context, async () => { + check() + const newSessionId = await getWorkspace().createDetachedSession({ kind: input.provider, providerRuntime: input.runtime }, + { tabId: input.tabId, anchorSessionId: input.anchorSessionId }, { cwd: input.cwd, resumeSessionId: input.nativeSessionId }) + if (!newSessionId) throw new ControlError('failed', 'Resume did not commit a placed session; inspect before retrying', 'unknown') + return { newSessionId, nativeSessionId: input.nativeSessionId } + }) + }, + }), + defineCapability({ id: 'agents.duplicate', title: 'Branch an exact agent conversation', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, + description: 'Copy an idle native conversation to a new native identity and create a detached agent in the chosen project. Preserves provider/runtime and enabled built-in domain names; leaves the source and its draft intact. Requires a fresh lifecycle revision and an explicit target project/anchor in the same window. Use operations.read for both new IDs, then agents.show or placement.attach. A failed placement can leave a native transcript copy; do not blindly retry unknown outcomes.', + input: target.extend({ revision, tabId: z.string(), anchorSessionId: z.string() }), output: accepted, + handler: (input, context) => { + const check = () => { + const value = guard(input) + if (!value.nativeSessionId || value.processActive || !getProviderFeatures(value.provider).transcriptDuplicate) throw new ControlError('unavailable', 'Choose an idle native conversation with duplicate support') + if (!resolveTabSessions(useAppStore.getState().workspaceState, input.tabId).includes(input.anchorSessionId)) throw new ControlError('unavailable', 'Anchor is not in the target project') + return value + } + check() + return startControlTask(context, async () => { + const value = check() + const meta = useAppStore.getState().workspaceState.sessions[input.sessionId] + const clone = await window.api.duplicateSession({ provider: value.provider, sourceProviderSessionId: value.nativeSessionId!, cwd: value.cwd }) + // The source can change during export. Never place a clone under a + // newly selected project or pretend to have branched the new state. + check() + const newSessionId = await getWorkspace().createDetachedSession({ kind: value.provider, providerRuntime: meta.providerRuntime }, + { tabId: input.tabId, anchorSessionId: input.anchorSessionId }, { cwd: value.cwd, resumeSessionId: clone.newProviderSessionId, builtInMcpDomains: meta.builtInMcpDomains }) + if (!newSessionId) throw new ControlError('failed', `Native copy ${clone.newProviderSessionId} exists but no placement was committed`, 'unknown') + return { sourceSessionId: input.sessionId, newSessionId, nativeSessionId: clone.newProviderSessionId } + }) + }, + }), + defineCapability({ id: 'agents.lifecycleRead', title: 'Inspect agent lifecycle choices', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, + description: 'Inspect native session identity, activity, rewind-undo availability and actual supported provider/runtime switch choices without waking the agent. Its revision binds subsequent lifecycle mutations to the observed identity and draft. Native transcript IDs differ from Agent Code session IDs.', + input: target, output: z.object({ sessionId: z.string(), provider: z.string(), providerRuntime: z.string().nullable(), nativeSessionId: z.string().nullable(), cwd: z.string(), + processActive: z.boolean(), hasRewindUndo: z.boolean(), revision, switchChoices: z.array(z.object({ provider: z.string(), runtime: z.string().nullable(), label: z.string() })) }), + handler: ({ sessionId }) => inspect(sessionId), + }), + defineCapability({ id: 'agents.switchProvider', title: 'Switch an exact agent provider', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, + description: 'Move an observed agent to one of agents.lifecycleRead switchChoices through the normal translation, capacity/compaction and replacement transaction. May open a confirmation or take minutes. Returns a task callId; use operations.read for the new Agent Code session ID or failure. Draft and supported internal MCP-domain continuity follow the ordinary UI operation. Never assume the source ID remains valid.', + input: target.extend({ revision, provider: z.enum(['claude', 'codex', 'opencode']), runtime: z.enum(['terminal']).optional().describe('Supply only when the chosen switchChoices entry declares this runtime; omit for structured rendering.') }), output: accepted, + handler: (input, context) => { + const current = guard(input) + if (!current.switchChoices.some(choice => choice.provider === input.provider && choice.runtime === (input.runtime ?? null))) throw new ControlError('invalid_input', 'Choose a supported provider/runtime from agents.lifecycleRead') + return startControlTask(context, async () => { guard(input); return result(await getWorkspace().switchSessionProvider(input.sessionId, input.provider, input.runtime), input.sessionId) }) + }, + }), + defineCapability({ id: 'agents.reload', title: 'Reload an exact agent backend', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, + description: 'Restart one agent through its native resume identity, preserving its placement, runtime and draft through the existing replacement transaction. Requires an idle, resumable agent and a fresh lifecycle revision. This replaces the Agent Code session ID. Read operations.read for completion/newSessionId; it is not a visual-only refresh.', + input: target.extend({ revision }), output: accepted, + handler: (input, context) => { + const check = () => { const value = guard(input); if (!value.nativeSessionId || value.processActive) throw new ControlError('unavailable', 'Reload requires an idle agent with a native session ID'); return value } + check() + return startControlTask(context, async () => { check(); return result(await getWorkspace().reloadSessionAgent(input.sessionId), input.sessionId) }) + }, + }), + defineCapability({ id: 'agents.rewind', title: 'Rewind an exact agent to a native prompt', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, + description: 'Create a new native transcript ending before an exact prompt address from nativeHistory.prompts, and replace this idle agent in place. The original transcript remains intact. The selected historical prompt becomes the new unsent draft, replacing the current draft; undoRewind can restore the prior conversation/draft until the next submission. First read agents.lifecycleRead. Use operations.read for the final newSessionId; acceptance alone is not completion.', + input: target.extend({ revision, address: address.describe('Exact address from nativeHistory.prompts for this native session; never infer line numbers from rendered feed rows.') }), output: accepted, + handler: (input, context) => { + const check = () => { const value = guard(input); if (!value.nativeSessionId || value.processActive) throw new ControlError('unavailable', 'Rewind requires an idle resumable agent'); + // Imported transcripts can retain original source session IDs in + // their addresses. The native transcript engine owns exact address + // membership; comparing source identity to the container ID here + // would reject valid rewinds of translated/cloned conversations. + if (input.address.provider !== value.provider) throw new ControlError('invalid_input', 'Prompt provider differs from this agent') } + check() + return startControlTask(context, async () => { check(); return result(await getWorkspace().rewindSessionToPrompt(input.sessionId, input.address), input.sessionId) }) + }, + }), + defineCapability({ id: 'agents.undoRewind', title: 'Undo an agent rewind', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, + description: 'Restore the prior native conversation and pre-rewind draft using the existing one-use undo record. Requires an idle agent with hasRewindUndo from agents.lifecycleRead; submission expires undo. Replaces the local session ID again. Read operations.read for its final newSessionId.', + input: target.extend({ revision }), output: accepted, + handler: (input, context) => { + const check = () => { const value = guard(input); if (!value.hasRewindUndo || value.processActive) throw new ControlError('unavailable', 'No idle rewind undo is available') } + check() + return startControlTask(context, async () => { check(); return result(await getWorkspace().undoSessionRewind(input.sessionId), input.sessionId) }) + }, + }), + ] +} diff --git a/src/renderer/src/workspace/control/navigation.ts b/src/renderer/src/workspace/control/navigation.ts new file mode 100644 index 00000000..e9815f39 --- /dev/null +++ b/src/renderer/src/workspace/control/navigation.ts @@ -0,0 +1,81 @@ +import { z } from 'zod' +import { ControlError, defineCapability, paginate } from '@control-sdk' +import { useAppStore } from '@renderer/app-state/store' +import { hasAppInteractionOwner } from '@renderer/lib/interaction-ownership' +import { collectLeaves } from '@renderer/workspace/tile-tree/treeOps' +import { resolveTabSessions } from '@renderer/workspace/queries' +import type { Workspace } from '@renderer/workspace/hook' + +const session = z.object({ sessionId: z.string().min(1) }).strict() +export function navigationControlCapabilities(getWorkspace: () => Workspace) { + const ready = () => { + if (getWorkspace().restoreStatus === 'pending' || hasAppInteractionOwner()) throw new ControlError('unavailable', 'Wait for restoration or finish the input-owning surface') + } + const placement = (sessionId: string) => { + const state = useAppStore.getState().workspaceState + if (!state.sessions[sessionId]) throw new ControlError('unavailable', 'Session no longer exists') + const tabs = state.tabs.filter(tab => resolveTabSessions(state, tab.id).includes(sessionId)) + const buried = state.buried.some(row => row.sessionId === sessionId) + const grid = tabs.find(tab => collectLeaves(tab.root).includes(sessionId)) + const affectedSessionIds = grid && collectLeaves(grid.root).length === 1 ? resolveTabSessions(state, grid.id) : [sessionId] + // Last-pane bury also archives detached project children. Expose that + // actual domain cascade before the caller chooses to commit it. + const evidence = { tabs, detached: state.detachedSessions, buried: state.buried, affectedSessionIds } + return { sessionId, gridTabId: grid?.id ?? null, buried, detached: Boolean(state.detachedSessions[sessionId]), + affectedSessionIds, revision: paginate([evidence], { limit: 1 }, `placement:${sessionId}`).revision } + } + return [ + defineCapability({ id: 'placement.inspect', title: 'Inspect detach and bury consequences', execution: 'window', effect: 'read', target: { kind: 'session', field: 'sessionId' }, + description: 'Inspect exact grid/detached/buried placement and the sessions affected by burying a last grid pane. Returns the revision required for detach or bury. Does not wake or focus anything.', + input: session, output: z.object({ sessionId: z.string(), gridTabId: z.string().nullable(), buried: z.boolean(), detached: z.boolean(), affectedSessionIds: z.array(z.string()), revision: z.string() }), handler: input => placement(input.sessionId), + }), + defineCapability({ id: 'placement.detach', title: 'Move a grid agent to Dispatch', execution: 'window', effect: 'mutation', target: { kind: 'session', field: 'sessionId' }, + description: 'Detach an exact grid pane through the ordinary placement operation. Requires placement.inspect revision. Preserves its live backend and project affinity; refuses the last grid pane in a project. Does not toggle Dispatch on.', + input: session.extend({ revision: z.string() }), output: z.object({ sessionId: z.string(), detached: z.literal(true) }), + handler: input => { + ready(); const before = placement(input.sessionId) + if (before.revision !== input.revision) throw new ControlError('stale_cursor', 'Placement changed; inspect again') + if (!before.gridTabId || before.buried) throw new ControlError('unavailable', 'Choose a current grid pane') + getWorkspace().detachSessionToDispatch(input.sessionId) + if (!placement(input.sessionId).detached) throw new ControlError('unavailable', 'Detach refused; the last grid pane must remain') + return { sessionId: input.sessionId, detached: true as const } + }, + }), + defineCapability({ id: 'agents.bury', title: 'Archive a grid pane without killing it', execution: 'window', effect: 'mutation', target: { kind: 'session', field: 'sessionId' }, + description: 'Bury the exact grid pane with an optional archive note through the existing non-destructive archive operation. Requires placement.inspect revision acknowledging affectedSessionIds: burying the last pane also archives detached children and removes the project tab. Backends remain alive. Use agents.restore for recovery; detached agents must be attached before burying.', + input: session.extend({ revision: z.string(), note: z.string().max(4000).optional() }), output: z.object({ sessionId: z.string(), buriedSessionIds: z.array(z.string()) }), + handler: input => { + ready(); const before = placement(input.sessionId) + if (before.revision !== input.revision) throw new ControlError('stale_cursor', 'Placement changed; inspect the affected sessions again') + if (!before.gridTabId || before.buried) throw new ControlError('unavailable', 'Choose a current grid pane') + getWorkspace().buryFocused(input.note, input.sessionId) + const ids = useAppStore.getState().workspaceState.buried.map(row => row.sessionId).filter(id => before.affectedSessionIds.includes(id)) + if (!ids.includes(input.sessionId)) throw new ControlError('failed', 'Archive was not observed', 'unknown') + return { sessionId: input.sessionId, buriedSessionIds: ids } + }, + }), + defineCapability({ id: 'views.agentSet', title: 'Show an agent in Reader or Spotlight', execution: 'window', effect: 'ui', target: { kind: 'session', field: 'sessionId' }, + description: 'Set an exact visible agent view to Reader, Spotlight or normal workspace. Uses desired state, not a toggle. Reader shows the conversation; Spotlight zooms its pane. Requires a current non-buried session. For normal workspace this exits focus views and navigates to the agent; use agents.show when staying in the current view mode.', + input: session.extend({ mode: z.enum(['reader', 'spotlight', 'workspace']) }), output: z.object({ sessionId: z.string(), mode: z.string() }), + handler: async input => { + ready(); const before = placement(input.sessionId) + if (before.buried) throw new ControlError('unavailable', 'Restore the buried agent first') + const workspace = getWorkspace() + let changed: boolean + if (input.mode === 'reader') changed = workspace.setReaderModeTarget(input.sessionId) + else { + workspace.setReaderModeTarget(null) + if (input.mode === 'spotlight') changed = workspace.setSpotlightTarget(input.sessionId) + else { workspace.setSpotlightTarget(null); changed = await workspace.focusAgentBySessionId(input.sessionId) } + } + if (!changed) throw new ControlError('unavailable', 'The target does not support this view') + await new Promise(resolve => requestAnimationFrame(() => resolve())) + const store = useAppStore.getState() + const visible = input.mode === 'reader' ? store.workspaceReaderMode?.focusedSessionId === input.sessionId + : input.mode === 'spotlight' ? store.workspaceSpotlight?.focusedSessionId === input.sessionId : !store.workspaceReaderMode && !store.workspaceSpotlight + if (!visible || hasAppInteractionOwner()) throw new ControlError('failed', 'View changed during navigation; inspect app.observe', 'unknown') + return { sessionId: input.sessionId, mode: input.mode } + }, + }), + ] +} diff --git a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx index a5daa150..60e8ff05 100644 --- a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx +++ b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx @@ -9,7 +9,8 @@ import { useShallow } from 'zustand/react/shallow' import type { Workspace } from '@renderer/workspace/workspaceStore' import { useAppStore } from '@renderer/app-state/hooks' import { WorktreeBadge } from '@renderer/workspace/tile-tree/TileLeaf/SessionBadges' -import { extractLatestUserPrompt } from '@renderer/features/workspace/lib/latestUserPrompts' +import { dispatchRowTitle } from './rowTitle' +export { cachedLatestPromptTitle, dispatchRowTitle } from './rowTitle' import { buildDispatchGroups } from '@renderer/workspace/dispatch/dispatchSelectors' import type { DispatchAgentRow } from '@renderer/workspace/dispatch/dispatchSelectors' import { DispatchColorFlagStrip } from '@renderer/workspace/dispatch/DispatchColorFlagStrip' @@ -31,11 +32,6 @@ import { isSessionExited } from '@renderer/workspace/providerSessionIdentity' export type DispatchAgentActivity = 'working' | 'running' | 'idle' | 'exited' | 'starting' -const latestPromptTitleCache = new WeakMap< - Entry[], - { kind: DispatchAgentRow['kind']; title: string | null } ->() - export const DispatchAgentList = memo(function DispatchAgentList({ groups, pinnedRows, @@ -477,43 +473,6 @@ const DispatchAgentListRow = memo(function DispatchAgentListRow({ ) }) -// Exported for reuse by the Tiled Dispatch mini-list, which renders the -// same prompt-derived title in a more compact row. -export function cachedLatestPromptTitle( - entries: Entry[], - kind: DispatchAgentRow['kind'], -): string | null { - const cached = latestPromptTitleCache.get(entries) - if (cached && cached.kind === kind) return cached.title - - const title = extractLatestUserPrompt(entries, kind)?.text ?? null - latestPromptTitleCache.set(entries, { kind, title }) - return title -} - -/** - * Resolve the one-line Dispatch label without erasing the distinction between - * an explicit title and the existing latest-prompt fallback. - * - * WHY `row.title` alone is insufficient: selectors historically fold the cwd - * basename into that field, and the component then replaces it with the latest - * prompt. Once users can author a title, applying the same replacement makes - * Save appear to work in the pane while the primary Dispatch index—the surface - * built for scanning many agents—continues showing something else. Carrying - * `agentTitle` separately lets explicit user intent win while preserving the - * useful automatic prompt label for every untitled agent. - */ -export function dispatchRowTitle( - row: Pick, - entries?: Entry[], -): string { - if (row.agentTitle) return row.agentTitle - if (row.kind !== 'terminal' && entries) { - return cachedLatestPromptTitle(entries, row.kind) ?? row.title - } - return row.title -} - function dispatchSubtitle(runtime: { sessionStatus?: string streamPhase?: string diff --git a/src/renderer/src/workspace/dispatch/rowTitle.ts b/src/renderer/src/workspace/dispatch/rowTitle.ts new file mode 100644 index 00000000..223a5eac --- /dev/null +++ b/src/renderer/src/workspace/dispatch/rowTitle.ts @@ -0,0 +1,48 @@ +import { extractLatestUserPrompt } from '@renderer/features/workspace/lib/latestUserPrompts' +import type { Entry } from '@shared/types/transcript' +import type { DispatchAgentRow } from './dispatchSelectors' + +// Shared presentation policy for the visible index and external observation. +// Control must not reimplement the title fallback or import the index UI. +const latestPromptTitleCache = new WeakMap< + Entry[], + { kind: DispatchAgentRow['kind']; title: string | null } +>() + +// Exported for reuse by the Tiled Dispatch mini-list, which renders the +// same prompt-derived title in a more compact row. +export function cachedLatestPromptTitle( + entries: Entry[], + kind: DispatchAgentRow['kind'], +): string | null { + const cached = latestPromptTitleCache.get(entries) + if (cached && cached.kind === kind) return cached.title + + const title = extractLatestUserPrompt(entries, kind)?.text ?? null + latestPromptTitleCache.set(entries, { kind, title }) + return title +} + +/** + * Resolve the one-line Dispatch label without erasing the distinction between + * an explicit title and the existing latest-prompt fallback. + * + * WHY `row.title` alone is insufficient: selectors historically fold the cwd + * basename into that field, and the component then replaces it with the latest + * prompt. Once users can author a title, applying the same replacement makes + * Save appear to work in the pane while the primary Dispatch index—the surface + * built for scanning many agents—continues showing something else. Carrying + * `agentTitle` separately lets explicit user intent win while preserving the + * useful automatic prompt label for every untitled agent. + */ +export function dispatchRowTitle( + row: Pick, + entries?: Entry[], +): string { + if (row.agentTitle) return row.agentTitle + if (row.kind !== 'terminal' && entries) { + return cachedLatestPromptTitle(entries, row.kind) ?? row.title + } + return row.title +} + diff --git a/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx index 61bb7b40..25852cef 100644 --- a/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/actions/controlPlacement.renderer.test.tsx @@ -36,3 +36,17 @@ it('restores a buried record under its existing ID without spawning another agen expect(harness.spawn).not.toHaveBeenCalled() harness.mounted.unmount() }) + +it('keeps native continuation cwd and target project separate from focus', async () => { + const initial = state() + const harness = mountPaneActions(initial, { spawnSessionId: 'resumed-agent' }) + await act(async () => { + expect(await harness.actions.createDetachedSession({ kind: 'opencode', providerRuntime: 'terminal' }, + { tabId: 'project', anchorSessionId: 'anchor' }, { cwd: '/native-worktree', resumeSessionId: 'ses_native', builtInMcpDomains: ['orchestration'] })) + .toBe('resumed-agent') + }) + expect(harness.spawn).toHaveBeenCalledExactlyOnceWith('/native-worktree', expect.objectContaining({ kind: 'opencode', providerRuntime: 'terminal', resumeSessionId: 'ses_native', builtInMcpDomains: ['orchestration'] })) + expect(harness.getState().detachedSessions['resumed-agent'].projectTabId).toBe('project') + expect(harness.getState().tabs[0].root).toEqual(initial.tabs[0].root) + harness.mounted.unmount() +}) diff --git a/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts b/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts index 3bad22a8..a6ebd3e6 100644 --- a/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts +++ b/src/renderer/src/workspace/hook/actions/focusSurfaceTarget.ts @@ -10,8 +10,8 @@ export type FocusSurfaceTarget = { sessionId: SessionId } -export function resolveFocusSurfaceTarget(state: WorkspaceState): FocusSurfaceTarget | null { - const sessionId = commandTargetSessionIdForState(state) +export function resolveFocusSurfaceTarget(state: WorkspaceState, explicitSessionId?: SessionId): FocusSurfaceTarget | null { + const sessionId = explicitSessionId ?? commandTargetSessionIdForState(state) if (!sessionId || !state.sessions[sessionId]) return null if (state.dispatchMode) { diff --git a/src/renderer/src/workspace/hook/actions/pane.ts b/src/renderer/src/workspace/hook/actions/pane.ts index 4fa27711..2e27ead0 100644 --- a/src/renderer/src/workspace/hook/actions/pane.ts +++ b/src/renderer/src/workspace/hook/actions/pane.ts @@ -379,7 +379,7 @@ export function usePaneActions( ) => Promise startNewAgentPlacement: () => void commitNewAgentPlacement: (selection: SessionSpawnSelection, target: PlacementTarget) => Promise - createDetachedSession: (selection: SessionSpawnSelection, projectOverride?: { tabId: TabId; anchorSessionId: SessionId }) => Promise + createDetachedSession: (selection: SessionSpawnSelection, projectOverride?: { tabId: TabId; anchorSessionId: SessionId }, continuation?: SplitFocusedContinuation) => Promise createDetachedDispatchAgent: ( selection: SessionSpawnSelection & { kind: Exclude }, projectOverride?: { tabId: TabId; anchorSessionId: SessionId }, @@ -399,6 +399,7 @@ export function usePaneActions( }) => Promise attachDetachedToGrid: (sessionId: SessionId, targetTabId: string, target: PlacementTarget) => Promise attachAllDetachedForTab: (tabId: string) => Promise + detachSessionToDispatch: (sessionId: SessionId) => void detachFocusedToDispatch: () => void closeFocused: () => Promise /** Resolves true when the session was actually closed, false when it did @@ -666,6 +667,7 @@ export function usePaneActions( // whose grid leaves are all closed has no leaf cwd to fall back on and // Dispatch agents are never inserted into tab.root. projectOverride?: { tabId: TabId; anchorSessionId: SessionId }, + continuation?: SplitFocusedContinuation, ) => { const { kind, providerRuntime } = selection const snapshot = refs.stateRef.current @@ -686,7 +688,9 @@ export function usePaneActions( if (!tab) return null const leafIds = collectLeaves(tab.root) - const cwd = + // A native continuation owns its cwd; the project only owns placement. + // Reusing the anchor cwd here can resume a transcript in another repo. + const cwd = continuation?.cwd ?? (target.cwdSessionId ? snapshot.sessions[target.cwdSessionId]?.cwd : null) ?? // Do NOT fall back to tab.focusedSessionId: in Tiled Dispatch that's // stale grid focus (the focused lane's session is already @@ -700,7 +704,7 @@ export function usePaneActions( let sessionId: SessionId try { - sessionId = await sessionActions.spawn(cwd, { kind, providerRuntime }) + sessionId = await sessionActions.spawn(cwd, { kind, providerRuntime, resumeSessionId: continuation?.resumeSessionId, builtInMcpDomains: continuation?.builtInMcpDomains }) } catch (err) { showToast( err instanceof Error && err.message.length > 0 @@ -1198,13 +1202,8 @@ export function usePaneActions( // return null and the tab.root type cannot represent an empty // tree. We don't want to silently close the tab either, so we // refuse and ask the user to add another pane first. - const detachFocusedToDispatch = useCallback(() => { + const detachSessionToDispatch = useCallback((sessionId: SessionId) => { const snapshot = refs.stateRef.current - const sessionId = commandTargetSessionIdForState(snapshot) - if (!sessionId) { - showToast('No focused session to detach') - return - } const meta = snapshot.sessions[sessionId] if (!meta) return const tab = snapshot.tabs.find(t => collectLeaves(t.root).includes(sessionId)) @@ -1266,6 +1265,12 @@ export function usePaneActions( }, [refs.stateRef, setState, showToast]) + const detachFocusedToDispatch = useCallback(() => { + const id = commandTargetSessionIdForState(refs.stateRef.current) + if (id) detachSessionToDispatch(id) + else showToast('No focused session to detach') + }, [refs.stateRef, detachSessionToDispatch, showToast]) + const commitNewAgentPlacement = useCallback( async (selection: SessionSpawnSelection, target: PlacementTarget) => { const { kind, providerRuntime } = selection @@ -2269,6 +2274,7 @@ export function usePaneActions( createOrchestrationAgent, attachDetachedToGrid, attachAllDetachedForTab, + detachSessionToDispatch, detachFocusedToDispatch, closeFocused, closeSession, diff --git a/src/renderer/src/workspace/hook/actions/provider.ts b/src/renderer/src/workspace/hook/actions/provider.ts index cbbe8cb5..ec1087b5 100644 --- a/src/renderer/src/workspace/hook/actions/provider.ts +++ b/src/renderer/src/workspace/hook/actions/provider.ts @@ -12,7 +12,7 @@ import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import type { SessionActions } from '@renderer/workspace/hook/actions/session' import { resumableProviderSessionId } from '@renderer/workspace/providerSessionIdentity' -import { switchAgentProvider } from '@renderer/workspace/hook/actions/providerSwitchCore' +import { switchAgentProvider, type SwitchAgentProviderResult } from '@renderer/workspace/hook/actions/providerSwitchCore' import { providerChoiceLabel } from '@renderer/workspace/providerChoices' // Provider-level actions on the focused pane. @@ -25,6 +25,14 @@ import { providerChoiceLabel } from '@renderer/workspace/providerChoices' // re-homes onto a truncated transcript with // the prompt prefilled as an unsent draft. +// Domain outcomes are shared by UI commands and external control. A returned +// Promise cannot distinguish a declined operation from a replacement; +// the transport must not infer success from a toast or a changed session census. +export type AgentLifecycleResult = + | { status: 'completed'; sourceSessionId: SessionId; newSessionId: SessionId } + | { status: 'skipped'; reason: string } + | { status: 'failed'; message: string } + export function useProviderActions( refs: WorkspaceRefs, setRuntimes: WorkspaceSetRuntimes, @@ -35,7 +43,10 @@ export function useProviderActions( sourceSessionId: SessionId, targetKind: AgentProviderKind, targetProviderRuntime?: AgentProviderRuntime, - ) => Promise + ) => Promise + reloadSessionAgent: (sourceSessionId: SessionId) => Promise + rewindSessionToPrompt: (sourceSessionId: SessionId, anchor: RewindPromptAddress) => Promise + undoSessionRewind: (sourceSessionId: SessionId) => Promise reloadFocusedAgent: () => Promise rewindFocusedToPrompt: ( anchor: RewindPromptAddress, @@ -46,16 +57,16 @@ export function useProviderActions( sourceSessionId: SessionId, targetKind: AgentProviderKind, targetProviderRuntime?: AgentProviderRuntime, - ) => { + ): Promise => { const current = refs.stateRef.current const meta = current.sessions[sourceSessionId] - if (!meta) return + if (!meta) return { status: 'skipped', reason: 'Session no longer exists' } const sourceKind = meta.kind ?? DEFAULT_PROVIDER if (!isAgentProviderKind(sourceKind)) { // Non-agent (terminal) pane — nothing to switch. showPaneToast(sourceSessionId, 'Only agent panes can switch provider') - return + return { status: 'skipped', reason: 'Only agent panes can switch provider' } } const result = await switchAgentProvider({ sessionId: sourceSessionId, @@ -77,14 +88,13 @@ export function useProviderActions( } else { showPaneToast(sourceSessionId, result.reason) } + return result }, [refs, sessionActions, setRuntimes, showPaneToast]) - const reloadFocusedAgent = useCallback(async () => { + const reloadSessionAgent = useCallback(async (sourceSessionId: SessionId): Promise => { const current = refs.stateRef.current - const sourceSessionId = commandTargetSessionIdForState(current) - if (!sourceSessionId) return const meta = current.sessions[sourceSessionId] - if (!meta) return + if (!meta) return { status: 'skipped', reason: 'Session no longer exists' } const kind = meta.kind ?? DEFAULT_PROVIDER if (!isAgentProviderKind(kind)) { @@ -92,31 +102,34 @@ export function useProviderActions( // right for any current OR future provider set. The previous // "Claude and Codex" wording rotted the moment OpenCode was registered. showPaneToast(sourceSessionId, 'Only agent panes can reload') - return + return { status: 'skipped', reason: 'Only agent panes can reload' } } const resumeSessionId = resumableProviderSessionId(meta) if (!resumeSessionId) { showPaneToast(sourceSessionId, 'Provider session id is not ready yet') - return + return { status: 'skipped', reason: 'Provider session id is not ready yet' } } try { const newSessionId = await sessionActions.replaceSession(meta.cwd, { kind, + targetSessionId: sourceSessionId, resumeSessionId, builtInMcpDomains: meta.builtInMcpDomains, }) - if (!newSessionId) return + if (!newSessionId) return { status: 'failed', message: 'Replacement was not committed' } showPaneToast( newSessionId, `${getRendererProviderCapabilities(kind).shortLabel} reloaded`, ) + return { status: 'completed', sourceSessionId, newSessionId } } catch (err) { const message = err instanceof Error && err.message.length > 0 ? err.message : 'Reload failed' showPaneToast(sourceSessionId, message) + return { status: 'failed', message } } }, [refs.stateRef, sessionActions, showPaneToast]) @@ -141,13 +154,11 @@ export function useProviderActions( // toast and return. Rewinding while a response is streaming // exercises every race we have around the live-to-committed // handoff at once; requiring idle is the safe path. - const rewindFocusedToPrompt = useCallback( - async (anchor: RewindPromptAddress) => { + const rewindSessionToPrompt = useCallback( + async (sourceSessionId: SessionId, anchor: RewindPromptAddress): Promise => { const current = refs.stateRef.current - const sourceSessionId = commandTargetSessionIdForState(current) - if (!sourceSessionId) return const meta = current.sessions[sourceSessionId] - if (!meta) return + if (!meta) return { status: 'skipped', reason: 'Session no longer exists' } const kind = meta.kind ?? DEFAULT_PROVIDER if (!isAgentProviderKind(kind)) { @@ -155,25 +166,25 @@ export function useProviderActions( // Terminal panes are the only non-agent kind today; the wording will // continue to read right when future providers register. showPaneToast(sourceSessionId, 'Only agent panes support rewind') - return + return { status: 'skipped', reason: 'Only agent panes support rewind' } } const previousProviderSessionId = resumableProviderSessionId(meta) if (!previousProviderSessionId) { showPaneToast(sourceSessionId, 'Provider session id is not ready yet') - return + return { status: 'skipped', reason: 'Provider session id is not ready yet' } } if (kind !== anchor.provider) { showPaneToast( sourceSessionId, `Prompt address is for ${anchor.provider} but focused pane is ${kind}`, ) - return + return { status: 'skipped', reason: 'Prompt provider differs from this session' } } const currentRuntime = refs.latestRuntimesRef.current[sourceSessionId] if (currentRuntime?.processActive || currentRuntime?.semantic.currentTurn) { showPaneToast(sourceSessionId, 'Wait for the current turn to finish before rewinding') - return + return { status: 'skipped', reason: 'Wait for the current turn to finish before rewinding' } } try { @@ -193,7 +204,7 @@ export function useProviderActions( builtInMcpDomains: meta.builtInMcpDomains, targetSessionId: sourceSessionId, }) - if (!newSessionId) return + if (!newSessionId) return { status: 'failed', message: 'Replacement was not committed' } // `replaceSession` copied the PRIOR pane's draft forward. // For rewind we deliberately clobber that draft with the @@ -260,43 +271,43 @@ export function useProviderActions( }) showPaneToast(newSessionId, 'Rewound to prompt - Undo Rewind available until next submit') + return { status: 'completed', sourceSessionId, newSessionId } } catch (err) { const message = err instanceof Error && err.message.length > 0 ? err.message : 'Rewind failed' showPaneToast(sourceSessionId, message) + return { status: 'failed', message } } }, [refs.latestRuntimesRef, refs.stateRef, sessionActions, setRuntimes, showPaneToast], ) - const undoLastRewind = useCallback(async () => { + const undoSessionRewind = useCallback(async (sourceSessionId: SessionId): Promise => { const current = refs.stateRef.current - const sourceSessionId = commandTargetSessionIdForState(current) - if (!sourceSessionId) return const meta = current.sessions[sourceSessionId] - if (!meta) return + if (!meta) return { status: 'skipped', reason: 'Session no longer exists' } const runtime = refs.latestRuntimesRef.current[sourceSessionId] const pending = runtime?.pendingRewindUndo ?? null if (!pending) { showPaneToast(sourceSessionId, 'No rewind to undo') - return + return { status: 'skipped', reason: 'No rewind to undo' } } const kind = meta.kind ?? DEFAULT_PROVIDER if (kind !== pending.provider) { showPaneToast(sourceSessionId, 'Rewind undo no longer matches this pane') - return + return { status: 'skipped', reason: 'Rewind undo no longer matches this pane' } } if (meta.providerSessionId !== pending.rewoundProviderSessionId) { showPaneToast(sourceSessionId, 'Rewind undo is no longer available') - return + return { status: 'skipped', reason: 'Rewind undo is no longer available' } } if (runtime.processActive || runtime.semantic.currentTurn) { showPaneToast(sourceSessionId, 'Wait for the current turn to finish before undoing rewind') - return + return { status: 'skipped', reason: 'Wait for the current turn to finish before undoing rewind' } } try { @@ -306,7 +317,7 @@ export function useProviderActions( builtInMcpDomains: pending.builtInMcpDomains, targetSessionId: sourceSessionId, }) - if (!newSessionId) return + if (!newSessionId) return { status: 'failed', message: 'Replacement was not committed' } setRuntimes(prev => { const restored = prev[newSessionId] @@ -328,14 +339,32 @@ export function useProviderActions( }) showPaneToast(newSessionId, 'Undid rewind') + return { status: 'completed', sourceSessionId, newSessionId } } catch (err) { const message = err instanceof Error && err.message.length > 0 ? err.message : 'Undo rewind failed' showPaneToast(sourceSessionId, message) + return { status: 'failed', message } } }, [refs.latestRuntimesRef, refs.stateRef, sessionActions, setRuntimes, showPaneToast]) - return { switchSessionProvider, reloadFocusedAgent, rewindFocusedToPrompt, undoLastRewind } + // UI commands capture focus once, before any asynchronous work. External + // callers use the explicit methods directly and never move selection as a + // substitute for specifying an operation target. + const reloadFocusedAgent = useCallback(async () => { + const id = commandTargetSessionIdForState(refs.stateRef.current) + if (id) await reloadSessionAgent(id) + }, [refs.stateRef, reloadSessionAgent]) + const rewindFocusedToPrompt = useCallback(async (anchor: RewindPromptAddress) => { + const id = commandTargetSessionIdForState(refs.stateRef.current) + if (id) await rewindSessionToPrompt(id, anchor) + }, [refs.stateRef, rewindSessionToPrompt]) + const undoLastRewind = useCallback(async () => { + const id = commandTargetSessionIdForState(refs.stateRef.current) + if (id) await undoSessionRewind(id) + }, [refs.stateRef, undoSessionRewind]) + return { switchSessionProvider, reloadSessionAgent, rewindSessionToPrompt, undoSessionRewind, + reloadFocusedAgent, rewindFocusedToPrompt, undoLastRewind } } diff --git a/src/renderer/src/workspace/hook/actions/reader.ts b/src/renderer/src/workspace/hook/actions/reader.ts index 7d523ba5..fa748987 100644 --- a/src/renderer/src/workspace/hook/actions/reader.ts +++ b/src/renderer/src/workspace/hook/actions/reader.ts @@ -26,9 +26,25 @@ export function useReaderActions( setState: WorkspaceSetState, refs: WorkspaceRefs, ): { + setReaderModeTarget: (sessionId: SessionId | null) => boolean toggleReaderMode: () => void setReaderModeSession: (sessionId: SessionId) => void } { + // Explicit desired state lets command clients select a non-focused agent + // without a focus-then-toggle race. Ownership uses the same placement query + // as UI toggles, including detached and related sessions. + const setReaderModeTarget = useCallback((sessionId: SessionId | null) => { + if (sessionId === null) { setReaderMode(null); return true } + const current = refs.stateRef.current + const target = resolveFocusSurfaceTarget(current, sessionId) + if (!target) return false + if (!isAgentProviderKind(current.sessions[sessionId]?.kind ?? DEFAULT_PROVIDER)) return false + setSpotlight(null) + setState(prev => ({ ...prev, activeTabId: target.tabId })) + setReaderMode({ tabId: target.tabId, focusedSessionId: sessionId }) + return true + }, [refs.stateRef, setReaderMode, setState, setSpotlight]) + const toggleReaderMode = useCallback(() => { const current = refs.stateRef.current const target = resolveFocusSurfaceTarget(current) @@ -108,5 +124,5 @@ export function useReaderActions( [refs.stateRef, setReaderMode, setState], ) - return { toggleReaderMode, setReaderModeSession } + return { setReaderModeTarget, toggleReaderMode, setReaderModeSession } } diff --git a/src/renderer/src/workspace/hook/actions/spotlight.ts b/src/renderer/src/workspace/hook/actions/spotlight.ts index 86805db6..0a54d1d9 100644 --- a/src/renderer/src/workspace/hook/actions/spotlight.ts +++ b/src/renderer/src/workspace/hook/actions/spotlight.ts @@ -25,9 +25,23 @@ export function useSpotlightActions( setState: WorkspaceSetState, refs: WorkspaceRefs, ): { + setSpotlightTarget: (sessionId: SessionId | null) => boolean toggleSpotlight: () => void setSpotlightSession: (sessionId: SessionId) => void } { + // Explicit desired state lets command clients select a non-focused agent + // without a focus-then-toggle race. Ownership uses the same placement query + // as UI toggles, including detached and related sessions. + const setSpotlightTarget = useCallback((sessionId: SessionId | null) => { + if (sessionId === null) { setSpotlight(null); return true } + const current = refs.stateRef.current + const target = resolveFocusSurfaceTarget(current, sessionId) + if (!target) return false + setState(prev => ({ ...prev, activeTabId: target.tabId })) + setSpotlight({ tabId: target.tabId, focusedSessionId: sessionId }) + return true + }, [refs.stateRef, setSpotlight, setState]) + const toggleSpotlight = useCallback(() => { const current = refs.stateRef.current const target = resolveFocusSurfaceTarget(current) @@ -91,5 +105,5 @@ export function useSpotlightActions( [refs.stateRef, setSpotlight, setState], ) - return { toggleSpotlight, setSpotlightSession } + return { setSpotlightTarget, toggleSpotlight, setSpotlightSession } } diff --git a/src/renderer/src/workspace/hook/index.ts b/src/renderer/src/workspace/hook/index.ts index bd229790..c1c1ce42 100644 --- a/src/renderer/src/workspace/hook/index.ts +++ b/src/renderer/src/workspace/hook/index.ts @@ -272,12 +272,12 @@ export function useWorkspace( useStreamingActions(setRuntimes, isCodexSession) const { pickerEnter, pickerMove, pickerCancel, pickerConfirm, setCodeBlockPicker } = usePickerActions(setRuntimes, refs, showPaneToast) - const { toggleSpotlight, setSpotlightSession } = useSpotlightActions( + const { setSpotlightTarget, toggleSpotlight, setSpotlightSession } = useSpotlightActions( setSpotlight, setState, refs, ) - const { toggleReaderMode, setReaderModeSession } = useReaderActions( + const { setReaderModeTarget, toggleReaderMode, setReaderModeSession } = useReaderActions( setReaderMode, setSpotlight, setState, @@ -825,7 +825,7 @@ export function useWorkspace( return off }, [refs, setRuntimes]) - const { switchSessionProvider, reloadFocusedAgent, rewindFocusedToPrompt, undoLastRewind } = + const { switchSessionProvider, reloadSessionAgent, rewindSessionToPrompt, undoSessionRewind, reloadFocusedAgent, rewindFocusedToPrompt, undoLastRewind } = useProviderActions(refs, setRuntimes, showPaneToast, sessionActions) // Bulk provider switch (Switch Agents modal) + remembered-batch return. Uses @@ -903,6 +903,7 @@ export function useWorkspace( readerMode, dispatchMode: state.dispatchMode, restoreStatus, + setReaderModeTarget, toggleReaderMode, setReaderModeSession, latestScreenRef: refs.latestScreenRef, @@ -929,6 +930,7 @@ export function useWorkspace( createOrchestrationAgent: paneActions.createOrchestrationAgent, attachDetachedToGrid: paneActions.attachDetachedToGrid, attachAllDetachedForTab: paneActions.attachAllDetachedForTab, + detachSessionToDispatch: paneActions.detachSessionToDispatch, detachFocusedToDispatch: paneActions.detachFocusedToDispatch, closeFocused: paneActions.closeFocused, closeSession: paneActions.closeSession, @@ -975,11 +977,15 @@ export function useWorkspace( reloadFocusedAgent, softReloadAgentView, switchSessionProvider, + reloadSessionAgent, + rewindSessionToPrompt, + undoSessionRewind, switchAgentsToProvider, returnLastProviderSwitchBatch, rewindFocusedToPrompt, undoLastRewind, reloadAgentSessions, + setSpotlightTarget, toggleSpotlight, setSpotlightSession, openTileTabs, diff --git a/src/shared/types/providerConfig.ts b/src/shared/types/providerConfig.ts index dcd29c6d..d8c22bd5 100644 --- a/src/shared/types/providerConfig.ts +++ b/src/shared/types/providerConfig.ts @@ -369,6 +369,8 @@ export type MainProviderConfig = { createTerminalSession?: (opts: SessionOptions) => AgentSession /** List resumable sessions for a cwd. */ listSessions: (cwd: string, limit: number) => Promise + /** A placeholder list must not be advertised as a complete empty catalog. */ + sessionDiscoveryUnavailableReason?: string /** * List resumable sessions without cwd scoping when a caller genuinely needs a * global debug/resume inventory. From 8910efb0e35826cfcc50e676f60301295f620821 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 02:11:13 -0700 Subject: [PATCH 03/10] feat(control): add durable per-agent batch operations --- .../external-operator-toolkit.md | 5 ++ src/main/control/batches.test.ts | 46 +++++++++++++++++++ src/main/control/batches.ts | 44 ++++++++++++++++++ src/main/control/createControlHost.ts | 3 +- 4 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/main/control/batches.test.ts create mode 100644 src/main/control/batches.ts diff --git a/docs/decomposition/external-operator-toolkit.md b/docs/decomposition/external-operator-toolkit.md index eb4e9295..18885c40 100644 --- a/docs/decomposition/external-operator-toolkit.md +++ b/docs/decomposition/external-operator-toolkit.md @@ -124,3 +124,8 @@ this plan/issue for concrete findings. All work stays unmerged pending confirmat passed 11 files/25 tests, including recorded native prompts and Dispatch coordinates. OS two-monitor activation and occupied native-draft trials remain external verification; do not label those reproduced/fixed on unit evidence. +- Stage 3 verified: bounded multi-window batch read/prompt adapters reuse the + executor with the original caller. Real file-journal checks prove per-child + accepted/unknown receipts, no redelivery after an executor restart/subset retry, + and argument conflicts. Full typecheck passed. The batch is deliberately not + atomic; independent read cursors and child call IDs remain visible. diff --git a/src/main/control/batches.test.ts b/src/main/control/batches.test.ts new file mode 100644 index 00000000..3c35cc7e --- /dev/null +++ b/src/main/control/batches.test.ts @@ -0,0 +1,46 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID } from 'node:crypto' +import { afterEach, expect, it } from 'vitest' +import { z } from 'zod' +import { ControlError, defineCapability } from '@control-sdk' +import { createControlExecutor, createControlRegistry } from '../../control-sdk/host' +import { FileControlHistory } from './history/FileControlHistory' +import { batchControlCapabilities } from './batches' +const directories: string[] = [] +afterEach(async () => { await Promise.all(directories.splice(0).map(path => rm(path, { recursive: true, force: true }))) }) +it('keeps independent cross-window receipts and never redelivers an uncertain child on partial retry or restart', async () => { + const directory = await mkdtemp(join(tmpdir(), 'ac-batch-')); directories.push(directory) + const history = new FileControlHistory(directory) + const registry = createControlRegistry() + const caller = { kind: 'external' as const, id: 'operator' } + const owners = ['left', 'right'].map(windowId => ({ kind: 'window' as const, windowId, generation: 'current' })) + const deliveries: string[] = [] + owners.forEach(owner => registry.register(owner, [defineCapability({ id: 'agents.prompt', title: 'Delivery boundary trial', execution: 'window', effect: 'mutation', completion: 'accepted', description: 'Fault injection at the provider delivery boundary, not a simulated agent conversation.', + input: z.object({ sessionId: z.string(), prompt: z.string() }), output: z.object({ acceptance: z.string() }), + handler: (input, context) => { + expect(context.caller).toEqual(caller) + deliveries.push(input.sessionId) + if (input.sessionId === 'uncertain') throw new ControlError('failed', 'Lost acknowledgment after write', 'unknown') + return { acceptance: 'transport' } + }, + })])) + const executor = () => createControlExecutor({ history, instanceId: randomUUID(), id: randomUUID, now: () => new Date().toISOString(), catalog: () => registry.list(), + dispatch: (request, context) => registry.invoke(request, context) }) + let current = executor() + registry.register({ kind: 'main', generation: 'main' }, batchControlCapabilities((request, identity) => current.invoke(request, identity))) + const items = [{ itemKey: 'first', sessionId: 'accepted', prompt: 'first request', owner: owners[0] }, + { itemKey: 'second', sessionId: 'uncertain', prompt: 'second request', owner: owners[1] }] + const run = (selected = items) => current.invoke({ capabilityId: 'agents.batchPrompt', input: { batchKey: 'trial', items: selected } }, caller) + expect(await run()).toMatchObject({ ok: true, value: { succeeded: 1, failed: 1, items: [{ result: { ok: true } }, { result: { ok: false, error: { outcome: 'unknown' } } }] } }) + expect(deliveries).toEqual(['accepted', 'uncertain']) + current = executor() + // Reordered/subset retry has a new parent call but the same child intention. + expect(await run([items[1]])).toMatchObject({ ok: true, value: { failed: 1, items: [{ result: { error: { outcome: 'unknown' }, operation: { reusedCallId: expect.any(String) } } }] } }) + expect(deliveries).toEqual(['accepted', 'uncertain']) + expect(await run([{ ...items[0], prompt: 'different intention under old key' }])).toMatchObject({ ok: true, value: { failed: 1, items: [{ result: { error: { code: 'idempotency_conflict' } } }] } }) + expect(deliveries).toEqual(['accepted', 'uncertain']) + const events = await history.events() + expect(events.filter(event => event.capabilityId === 'agents.prompt' && event.kind === 'received').map(event => event.caller)).toEqual(['external:operator', 'external:operator']) +}) diff --git a/src/main/control/batches.ts b/src/main/control/batches.ts new file mode 100644 index 00000000..52c5d227 --- /dev/null +++ b/src/main/control/batches.ts @@ -0,0 +1,44 @@ +import { z } from 'zod' +import { agentReadInput, controlOwnerSchema, controlResultSchema, defineCapability, + type ControlCaller, type ControlRequest, type ControlResult } from '@control-sdk' + +const owner = controlOwnerSchema.optional().describe('Exact owner from agents.search. Omit only when the session has one unambiguous owner.') +const key = z.string().min(1).max(80).describe('Stable item intention key, unique within this batch. Preserve it across partial retries; never derive it from item position.') +const result = z.object({ itemKey: z.string(), sessionId: z.string(), result: controlResultSchema }) +const output = z.object({ items: z.array(result), succeeded: z.number(), failed: z.number() }) +type Invoke = (request: ControlRequest, caller: ControlCaller) => Promise + +// Batches compose the existing executor, including its caller, durable intent, +// ownership and uncertainty rules. Calling a renderer's application bridge from +// here would silently upgrade an external caller and bypass child idempotency. +// Sequential children bound provider admission pressure and make partial work +// observable; a failed child never cancels or rewinds already delivered prompts. +export function batchControlCapabilities(invoke: Invoke) { + const unique = (items: T[]) => new Set(items.map(item => item.itemKey)).size === items.length + return [ + defineCapability({ id: 'agents.batchRead', title: 'Read several agents with independent cursors', execution: 'main', effect: 'read', + description: 'Read up to 20 exact agents across windows using the ordinary agents.read contract. Returns a separate success/error and continuation cursor set per item; one unavailable agent does not hide the others. Defaults to user prompts and assistant prose. Each page is capped at 8192 characters per agent; use the individual nextCursor/olderCursor/deltaCursor with that same agent. Does not wake agents.', + input: z.object({ items: z.array(z.object({ itemKey: key, owner, read: agentReadInput.extend({ maxChars: z.number().int().min(256).max(8192).default(4000), maxMessages: z.number().int().min(1).max(50).default(20) }) }).strict()).min(1).max(20).refine(unique, 'Item keys must be unique') }).strict(), output, + handler: async (input, context) => { + const items: z.infer[] = [] + for (const item of input.items) items.push({ itemKey: item.itemKey, sessionId: item.read.sessionId, + result: controlResultSchema.parse(await invoke({ capabilityId: 'agents.read', input: item.read, owner: item.owner }, context.caller)) }) + return { items, succeeded: items.filter(item => item.result.ok).length, failed: items.filter(item => !item.result.ok).length } + }, + }), + defineCapability({ id: 'agents.batchPrompt', title: 'Deliver a batch with per-agent receipts', execution: 'main', effect: 'mutation', + description: 'Deliver up to 20 independent prompts through agents.prompt, including its provider checks and app-draft preservation. Returns each child receipt/error; success counts acceptance, not finished work. Every child has a durable request key derived from batchKey + itemKey under your original caller identity. To inspect/retry a partial batch, retain those keys and the exact item arguments; never generate new keys for uncertain deliveries. Changing arguments under an existing key conflicts. The batch is not atomic and continues after a child fails.', + input: z.object({ batchKey: z.string().min(1).max(80).describe('Stable identity of this batch intention. Retain it with each itemKey across partial retry requests.'), + items: z.array(z.object({ itemKey: key, owner, sessionId: z.string().min(1), prompt: z.string().min(1).max(32000) }).strict()).min(1).max(20).refine(unique, 'Item keys must be unique') }).strict(), output, + handler: async (input, context) => { + const items: z.infer[] = [] + for (const item of input.items) items.push({ itemKey: item.itemKey, sessionId: item.sessionId, + result: controlResultSchema.parse(await invoke({ capabilityId: 'agents.prompt', input: { sessionId: item.sessionId, prompt: item.prompt }, owner: item.owner, + // Length-delimited keys prevent ("a:b", "c") colliding with + // ("a", "b:c"). These are intention IDs, never secret credentials. + requestKey: `batch:${input.batchKey.length}:${input.batchKey}:${item.itemKey}` }, context.caller)) }) + return { items, succeeded: items.filter(item => item.result.ok).length, failed: items.filter(item => !item.result.ok).length } + }, + }), + ] +} diff --git a/src/main/control/createControlHost.ts b/src/main/control/createControlHost.ts index ebcbaa92..ef0ed86d 100644 --- a/src/main/control/createControlHost.ts +++ b/src/main/control/createControlHost.ts @@ -13,6 +13,7 @@ import { FileControlHistory } from './history/FileControlHistory' import { historyCapabilities } from './history/control' import { taskHistoryCapabilities } from './history/tasks' import { globalControlCapabilities, type ObserveWindows } from './globalCapabilities' +import { batchControlCapabilities } from './batches' export function createControlHost(windowAccess: { getBrowserWindow(id: string): BrowserWindow | null @@ -73,7 +74,7 @@ export function createControlHost(windowAccess: { generation: windows.get(windowId)?.generation ?? null, })), ), ...historyCapabilities(history), ...taskHistoryCapabilities(history, owner => registry.list().some(row => JSON.stringify(row.owner) === JSON.stringify(owner))), - ...globalControlCapabilities(observeWindows), ...additionalCapabilities]) + ...globalControlCapabilities(observeWindows), ...batchControlCapabilities((request, caller) => executor.invoke(request, caller)), ...additionalCapabilities]) ipcMain.handle('control:register', (event, raw: unknown) => { const windowId = senderWindow(event) From 9265cee3e2e98565a0967d43dc11912f18d6419f Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 02:26:35 -0700 Subject: [PATCH 04/10] fix(sessions): preserve current drafts during agent replacement Transfer the latest unsent text and destination-supported attachments atomically when retiring the predecessor. Provider switching must not restore an earlier image snapshot; rewind undo records the draft actually carried through replacement. Refs #807, #795 --- .../control/lifecycle.renderer.test.tsx | 16 ++++++++++ .../src/workspace/hook/actions/provider.ts | 10 +++---- .../hook/actions/providerSwitchCore.ts | 26 ++--------------- .../src/workspace/hook/actions/session.ts | 29 ++++++++++++------- ...essionReplacementHandoff.renderer.test.tsx | 22 +++++++------- 5 files changed, 54 insertions(+), 49 deletions(-) diff --git a/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx b/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx index 225820bf..8dfe7be5 100644 --- a/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx +++ b/src/renderer/src/workspace/control/lifecycle.renderer.test.tsx @@ -64,3 +64,19 @@ it('reports a domain refusal instead of treating a resolved void transaction as await invoke('agents.reload', { sessionId: 'source', revision: await revision() }) await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.objectContaining({ capabilityId: 'operations.finish', input: expect.objectContaining({ result: expect.objectContaining({ ok: false }) }) }))) }) + +it('keeps draft edits made during native rewind recoverable by undo', async () => { + const { invoke, revision, replaceSession, report, refs } = setup() + window.api.rewindToPrompt = vi.fn().mockResolvedValue({ provider: 'codex', newProviderSessionId: 'rewound-native', newFilePath: '/recorded/rewound.jsonl', promptText: 'Historical prompt', promptImages: [], promptMode: 'prompt', promptTimestamp: null }) + // The actual replacement contract is independently exercised in + // sessionReplacementHandoff: this boundary returns its latest carried draft, + // including edits made after the original lifecycle inspection. + replaceSession.mockImplementation(async () => { + useAppStore.getState().setWorkspaceRuntimes(previous => ({ ...previous, replacement: { ...emptyRuntime(), draftInput: 'Edited during replacement' } })) + refs.latestRuntimesRef.current = useAppStore.getState().workspaceRuntimes + return 'replacement' + }) + await invoke('agents.rewind', { sessionId: 'source', revision: await revision(), address: { provider: 'codex', sessionId: 'native-source', line: 1 } }) + await vi.waitFor(() => expect(report).toHaveBeenCalledWith(expect.objectContaining({ capabilityId: 'operations.finish' }))) + expect(useAppStore.getState().workspaceRuntimes.replacement).toMatchObject({ draftInput: 'Historical prompt', pendingRewindUndo: { previousDraftInput: 'Edited during replacement' } }) +}) diff --git a/src/renderer/src/workspace/hook/actions/provider.ts b/src/renderer/src/workspace/hook/actions/provider.ts index ec1087b5..9cb26259 100644 --- a/src/renderer/src/workspace/hook/actions/provider.ts +++ b/src/renderer/src/workspace/hook/actions/provider.ts @@ -188,9 +188,6 @@ export function useProviderActions( } try { - const previousDraftInput = currentRuntime?.draftInput ?? '' - const previousDraftImages = currentRuntime?.draftImages ?? [] - const result = await window.api.rewindToPrompt({ provider: kind, sourceProviderSessionId: previousProviderSessionId, @@ -262,8 +259,11 @@ export function useProviderActions( rewoundProviderSessionId: result.newProviderSessionId, rewoundPromptText: result.promptText, rewoundPromptTimestamp: result.promptTimestamp, - previousDraftInput, - previousDraftImages: previousDraftImages.slice(), + // The replacement owner just transferred the latest draft. + // Save that here, not the pre-export snapshot: edits made while + // native rewind/spawn awaited must remain recoverable by Undo. + previousDraftInput: runtime.draftInput, + previousDraftImages: runtime.draftImages.slice(), builtInMcpDomains: meta.builtInMcpDomains, }, }, diff --git a/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts b/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts index f665e423..7059a9d4 100644 --- a/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts +++ b/src/renderer/src/workspace/hook/actions/providerSwitchCore.ts @@ -1,6 +1,5 @@ // See docs/design/provider-switching.md for the renderer/main transaction, // progress, and non-cancellable compaction lock invariants. -import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import type { SessionId } from '@renderer/workspace/types' import { DEFAULT_PROVIDER, isAgentProviderKind } from '@shared/types/providerKind' import type { AgentProviderKind, AgentProviderRuntime } from '@shared/types/providerKind' @@ -111,7 +110,6 @@ export async function switchAgentProvider(params: { // Both states need the same pane replacement. Keeping that operation in // one closure prevents the two identity models from drifting on draft and // MCP-domain preservation. - const draftImages = refs.latestRuntimesRef.current[sessionId]?.draftImages ?? [] const effectiveSourceDomains = meta.builtInMcpDomains === undefined ? undefined @@ -134,22 +132,8 @@ export async function switchAgentProvider(params: { }) if (!newSessionId) return { status: 'failed', message: 'Replacement failed' } - setRuntimes(prev => { - const runtime = prev[newSessionId] - if (!runtime) return prev - return { - ...prev, - [newSessionId]: { - ...runtime, - // A target without image attachment support must not inherit hidden - // image state: the invisible array participates in the empty-submit - // guard and could make an apparently blank composer send a prompt. - draftImages: getRendererProviderCapabilities(targetKind).supportsImageAttachments - ? draftImages - : [], - }, - } - }) + // The replacement owner transfers the latest supported draft atomically. + // A second snapshot here would overwrite edits made while spawn awaited. return { status: 'switched', newSessionId, targetKind } } @@ -189,12 +173,6 @@ export async function switchAgentProvider(params: { // state is "I opened the wrong provider before starting", so a no-resume // replacement is the faithful operation. // - // `replaceSession` already preserves draftInput because several - // replacement flows want typed-but-unsent text to survive. It does not - // preserve draftImages, and broadening that helper would change - // reload/rewind/resume semantics. Image drafts are still part of the - // user's unsent empty-pane state, so this branch snapshots and restores - // them explicitly — but only when the target provider can render them. return await replaceTranscriptlessPane() } diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index 1ba80313..5d9eb420 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -1,3 +1,4 @@ +import { getRendererProviderCapabilities } from '@providers/registry.renderer.capabilities' import { DEFAULT_PROVIDER, isAgentProviderKind, @@ -1044,7 +1045,7 @@ export function useSessionActions( defaultDomains: refs.defaultBuiltInMcpDomainsRef.current, }) : undefined - const oldDraft = refs.latestRuntimesRef.current[oldId]?.draftInput ?? '' + const draftFallback = refs.latestRuntimesRef.current[oldId] const newId = await spawn(cwd, { ...spawnOpts, providerRuntime, @@ -1060,19 +1061,27 @@ export function useSessionActions( }) const mainHandledPredecessor = pendingReplacementSuccessorsRef.current.delete(newId) - setRuntimes(prev => ({ - ...prev, - [newId]: { - ...(prev[newId] ?? emptyRuntime()), - draftInput: oldDraft, - }, - })) - if (!mainHandledPredecessor) { await killSessionBackendIfOwned(refs, oldId) } setRuntimes(prev => { - const next = { ...prev } + // Replacement can await spawn and backend retirement while the user + // keeps editing. Transfer the latest draft in the same state update + // that retires its owner; a pre-await snapshot silently loses edits. + // All replacement paths share this contract. Rewind deliberately + // substitutes its historical prompt afterwards and keeps an undo copy. + const draft = prev[oldId] ?? draftFallback + const next = { + ...prev, + [newId]: { + ...(prev[newId] ?? emptyRuntime()), + draftInput: draft?.draftInput ?? '', + // Unsupported invisible attachments participate in submit guards. + // Preserve images only when the destination can expose them. + draftImages: isAgentProviderKind(nextKind) && getRendererProviderCapabilities(nextKind).supportsImageAttachments + ? (draft?.draftImages ?? []) : [], + }, + } delete next[oldId] return next }) diff --git a/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx b/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx index 6eb002b8..64e8ff17 100644 --- a/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/actions/sessionReplacementHandoff.renderer.test.tsx @@ -30,8 +30,9 @@ function ref(current: T): MutableRefObject { } describe('renderer session replacement handoff', () => { - it('names the local predecessor only on the central replaceSession spawn', async () => { + it.each(['claude', 'codex', 'terminal'] as const)('preserves current text and destination-supported images when replacing with %s', async (destination) => { vi.useFakeTimers() + const image = { id: 'draft-image', mediaType: 'image/png', base64Data: 'eA==', previewUrl: 'blob:draft', filename: 'draft.png' } const predecessorId = 'local-predecessor' let state = { tabs: [{ @@ -95,9 +96,9 @@ describe('renderer session replacement handoff', () => { refs.latestRuntimesRef.current = runtimes } const spawnSession = vi.fn() - .mockResolvedValueOnce({ - sessionId: 'local-successor', - replacementTransactionId: 'replacement-transaction', + .mockImplementationOnce(async () => { + setRuntimes(previous => ({ ...previous, [predecessorId]: { ...previous[predecessorId], draftInput: 'edited while spawning', draftImages: [image] } })) + return { sessionId: 'local-successor', replacementTransactionId: 'replacement-transaction' } }) .mockResolvedValueOnce({ sessionId: 'fresh-local-session' }) const killOwnedSession = vi.fn(async () => false) @@ -123,7 +124,7 @@ describe('renderer session replacement handoff', () => { await act(async () => { await result.current.replaceSession('/recorded/worktree', { - kind: 'codex', + kind: destination, resumeSessionId: 'recorded-provider-session', builtInMcpDomains: ['workflows'], }) @@ -131,14 +132,14 @@ describe('renderer session replacement handoff', () => { }) expect(spawnSession).toHaveBeenCalledWith({ - kind: 'codex', + kind: destination, cwd: '/recorded/worktree', resumeSessionId: 'recorded-provider-session', predecessorSessionId: predecessorId, - dangerousMode: false, - useProxy: true, + dangerousMode: destination === 'terminal' ? undefined : false, + useProxy: destination === 'terminal' ? undefined : true, recoverTmuxName: undefined, - builtInMcpDomains: ['workflows'], + builtInMcpDomains: destination === 'terminal' ? undefined : destination === 'codex' ? ['workflows'] : [], }) // A transaction-bearing result means main already retired the predecessor // and is holding the successor pending durable workspace ownership. Sending @@ -149,7 +150,8 @@ describe('renderer session replacement handoff', () => { root: { type: 'leaf', sessionId: 'local-successor' }, focusedSessionId: 'local-successor', }) - expect(runtimes['local-successor']?.draftInput).toBe('keep this draft') + expect(runtimes['local-successor']?.draftInput).toBe('edited while spawning') + expect(runtimes['local-successor']?.draftImages).toEqual(destination === 'claude' ? [image] : []) await act(async () => { await result.current.spawn('/recorded/worktree', { kind: 'codex' }) From 666e9c7980b483f911abb1057b85e5cef05c599c Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Sat, 5 Sep 2026 02:28:29 -0700 Subject: [PATCH 05/10] feat(control): make operator creation placement explicit Keep ordinary UI selection as the default while allowing external creation to preserve every lane and the active tab. Explain the row-specific index destination, and retain truthful uncertainty when native duplication succeeds before a placement guard fails. Refs #795, #799 --- src/renderer/src/workspace/control/agents.ts | 8 +++--- .../src/workspace/control/lifecycle.ts | 8 +++--- .../workspace/dispatch/DispatchAgentList.tsx | 11 +++++++- .../dispatch/TiledDispatchLayout.tsx | 1 + .../controlPlacement.renderer.test.tsx | 25 +++++++++++++++++++ .../src/workspace/hook/actions/pane.ts | 15 ++++++++--- 6 files changed, 56 insertions(+), 12 deletions(-) diff --git a/src/renderer/src/workspace/control/agents.ts b/src/renderer/src/workspace/control/agents.ts index e13f27b6..1d97919d 100644 --- a/src/renderer/src/workspace/control/agents.ts +++ b/src/renderer/src/workspace/control/agents.ts @@ -186,16 +186,16 @@ export function agentControlCapabilities(getWorkspace: () => Workspace) { }), defineCapability({ id: 'agents.create', target: { kind: 'project', field: 'tabId' }, title: 'Create a project agent', execution: 'window', effect: 'mutation', - description: 'Create an ordinary detached agent in the explicit project, anchored to an existing agent directory. Returns its exact ID; agents.show can then reveal it without creating another process.', + description: 'Create an ordinary detached agent in the explicit project, anchored to an existing agent directory. Detached means outside the project grid, not hidden: selectCreated defaults true, activates the project and selects the new agent in the Dispatch lane focused when creation began, replacing that view without closing its agent. Set selectCreated:false to preserve tabs and lane assignments, then use layout.read and dispatch.configure (lane-select) to place the returned ID in an explicit lane.', input: z.object({ tabId: z.string().describe('Project tab ID from app.observe in the target window.'), anchorSessionId: z.string().describe('Existing agent in this project that supplies the working directory or grid placement anchor.'), provider, - providerRuntime: z.enum(AGENT_PROVIDER_RUNTIMES).optional().describe('Omit for the normal structured agent view. terminal requests the provider-native terminal runtime.'), title: z.string().describe('Agent display title; empty clears a custom title. Normal UI normalization applies.').optional() }).strict(), + selectCreated: z.boolean().default(true).describe('False preserves the current tab and every Dispatch lane; true selects the created agent using normal UI creation behavior.'), providerRuntime: z.enum(AGENT_PROVIDER_RUNTIMES).optional().describe('Omit for the normal structured agent view. terminal requests the provider-native terminal runtime.'), title: z.string().describe('Agent display title; empty clears a custom title. Normal UI normalization applies.').optional() }).strict(), output: sessionReference, - handler: async ({ tabId, anchorSessionId, provider: kind, providerRuntime, title }) => { + handler: async ({ tabId, anchorSessionId, provider: kind, providerRuntime, title, selectCreated }) => { requireUi(); requireSession(anchorSessionId) if (!resolveTabSessions(useAppStore.getState().workspaceState, tabId).includes(anchorSessionId)) { throw new ControlError('unavailable', 'Anchor does not belong to that project') } - const sessionId = await getWorkspace().createDetachedDispatchAgent({ kind, providerRuntime }, { tabId, anchorSessionId }) + const sessionId = await getWorkspace().createDetachedDispatchAgent({ kind, providerRuntime }, { tabId, anchorSessionId }, undefined, { selectCreated }) if (!sessionId) throw new ControlError('failed', 'Agent creation did not produce a placed session; inspect the project', 'unknown') if (title !== undefined) setTitle(sessionId, title) return requireSession(sessionId) diff --git a/src/renderer/src/workspace/control/lifecycle.ts b/src/renderer/src/workspace/control/lifecycle.ts index d8b73d85..df575565 100644 --- a/src/renderer/src/workspace/control/lifecycle.ts +++ b/src/renderer/src/workspace/control/lifecycle.ts @@ -53,7 +53,7 @@ export function lifecycleControlCapabilities(getWorkspace: () => Workspace) { } return [ defineCapability({ id: 'agents.resume', title: 'Resume a native session in a project', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'project', field: 'tabId' }, - description: 'Open a known native conversation as a new detached agent in an explicit project. Supply provider/nativeSessionId/cwd from nativeHistory.list; known OpenCode IDs are supported. This resumes the same native conversation, not a copy; the ordinary backend ownership policy applies if already open. Returns a task callId; operations.read reports the exact newSessionId. Use agents.show or placement.attach afterward.', + description: 'Open a known native conversation as a new detached agent in an explicit project. Supply provider/nativeSessionId/cwd from nativeHistory.list; known OpenCode IDs are supported. This resumes the same native conversation, not a copy; the ordinary backend ownership policy applies if already open. Returns a task callId; operations.read reports the exact newSessionId. Creation selects the captured focused Dispatch lane without closing its previous agent. Use agents.show or placement.attach afterward.', input: z.object({ tabId: z.string(), anchorSessionId: z.string(), provider: z.enum(['claude', 'codex', 'opencode']), nativeSessionId: z.string().min(1), cwd: z.string().min(1), runtime: z.enum(['terminal']).optional() }).strict(), output: accepted, handler: (input, context) => { const check = () => { @@ -72,7 +72,7 @@ export function lifecycleControlCapabilities(getWorkspace: () => Workspace) { }, }), defineCapability({ id: 'agents.duplicate', title: 'Branch an exact agent conversation', execution: 'window', effect: 'mutation', completion: 'accepted', target: { kind: 'session', field: 'sessionId' }, - description: 'Copy an idle native conversation to a new native identity and create a detached agent in the chosen project. Preserves provider/runtime and enabled built-in domain names; leaves the source and its draft intact. Requires a fresh lifecycle revision and an explicit target project/anchor in the same window. Use operations.read for both new IDs, then agents.show or placement.attach. A failed placement can leave a native transcript copy; do not blindly retry unknown outcomes.', + description: 'Copy an idle native conversation to a new native identity and create a detached agent in the chosen project. Preserves provider/runtime and enabled built-in domain names; leaves the source and its draft intact. Requires a fresh lifecycle revision and an explicit target project/anchor in the same window. Use operations.read for both new IDs, then agents.show or placement.attach. Creation selects the lane focused when it begins without closing its previous agent. A failed placement can leave a native transcript copy; do not blindly retry unknown outcomes.', input: target.extend({ revision, tabId: z.string(), anchorSessionId: z.string() }), output: accepted, handler: (input, context) => { const check = () => { @@ -88,7 +88,9 @@ export function lifecycleControlCapabilities(getWorkspace: () => Workspace) { const clone = await window.api.duplicateSession({ provider: value.provider, sourceProviderSessionId: value.nativeSessionId!, cwd: value.cwd }) // The source can change during export. Never place a clone under a // newly selected project or pretend to have branched the new state. - check() + try { check() } catch (error) { + throw new ControlError('failed', `Native copy ${clone.newProviderSessionId} exists but the source/placement changed: ${String(error)}`, 'unknown') + } const newSessionId = await getWorkspace().createDetachedSession({ kind: value.provider, providerRuntime: meta.providerRuntime }, { tabId: input.tabId, anchorSessionId: input.anchorSessionId }, { cwd: value.cwd, resumeSessionId: clone.newProviderSessionId, builtInMcpDomains: meta.builtInMcpDomains }) if (!newSessionId) throw new ControlError('failed', `Native copy ${clone.newProviderSessionId} exists but no placement was committed`, 'unknown') diff --git a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx index 60e8ff05..04790e49 100644 --- a/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx +++ b/src/renderer/src/workspace/dispatch/DispatchAgentList.tsx @@ -45,6 +45,7 @@ export const DispatchAgentList = memo(function DispatchAgentList({ onToggleExpandedParent, onToggleCapChildren, onPickRowProject, + targetLaneIndex, }: { groups: ReturnType pinnedRows: DispatchAgentRow[] @@ -65,6 +66,10 @@ export const DispatchAgentList = memo(function DispatchAgentList({ onToggleExpandedParent?: (parentSessionId: SessionId) => void onToggleCapChildren?: () => void onPickRowProject?: () => void + // The row owner supplies the exact destination, including its first-lane + // fallback when focus is in another row. Re-deriving global focus here would + // give misleading help for an unfocused row's index. + targetLaneIndex?: number // Sessions that must render as unselectable in this index. Used by Tiled // Dispatch's lane-0 index to grey out agents already shown in another lane // (the one-session-per-lane invariant — without this, clicking a claimed @@ -229,6 +234,7 @@ export const DispatchAgentList = memo(function DispatchAgentList({ disabled={disabledSessionIds?.has(row.sessionId) ?? false} showWorktreeBadges={showWorktreeBadges} focusSessionInTab={focusSessionInTab} + targetLaneIndex={targetLaneIndex} projectChip={`${tabIndexLabel(row.tabIndex)} · ${row.tabTitle}`} /> ))} @@ -253,6 +259,7 @@ export const DispatchAgentList = memo(function DispatchAgentList({ disabled={disabledSessionIds?.has(item.row.sessionId) ?? false} showWorktreeBadges={showWorktreeBadges} focusSessionInTab={focusSessionInTab} + targetLaneIndex={targetLaneIndex} /> ) : (