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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions docs/decomposition/external-control-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -789,3 +789,15 @@ Full renderer suite: 114 files / 496 tests pass. The full local core run passes
developer-local recording is absent; its source and committed fixtures match main.
The PR records CI results separately rather than treating this local corpus as
portable evidence.


## Continuation coverage after the first release

The approved continuation is tracked in #795 and
`external-operator-toolkit.md`: exact lifecycle/native catalogs, explicit view and
placement, durable per-child batches, templates/ordinary settings, external
workflow ownership and reads, usage/worktrees and named surfaces. The live
`app.describe` guide and feature/tool catalogs describe current behavior; they are
not a promise that every provider-native operation has a typed wrapper. Full topic
search, provider draft/commit evidence and real multi-monitor/busy-input trials
remain tracked limitations. #802–806 stay separate subsystem work; A5 owns #806.
284 changes: 284 additions & 0 deletions docs/decomposition/external-operator-toolkit.md

Large diffs are not rendered by default.

103 changes: 101 additions & 2 deletions operator-skills/agent-code-computer-execution/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -53,6 +58,23 @@ 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: "<that stable ID>"}`. 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 a Dispatch row’s shared index places it into that row’s **focused lane**
(or its first lane when focus is in another row),
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,
Expand Down Expand Up @@ -93,11 +115,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. Optional `imagePaths` are prepared absolute local files and supported only for Claude. Refusals expose structured `error.details` with stage, retrySafe, disposition, promptWritten and enterWritten; do not turn an uncertain delivery into a second attempt with a new key.

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. `occupied` means provider-reported occupied input, without complete text; resolve through that UI. `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
Expand Down Expand Up @@ -142,6 +169,78 @@ 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.

## Batches and broader controls

`ac_agents_batch_read` and `ac_agents_batch_prompt` accept up to 20 independent
items. Inspect every child result, including failures. Keep each read cursor with
its own agent/depth. For partial prompt retries, keep `batchKey` and each `itemKey`
stable; if the subset changes, use a new parent `_control.requestKey`. Retrying an
unknown child under another key risks duplicate delivery. Batch acceptance says
nothing about task completion; monitor each agent and its conditions.

`ac_settings_values` / `ac_settings_set` handles advertised ordinary choices with revisions.
Use `ac_settings_reference` and the UI for other settings. `ac_templates_list` / `ac_templates_read`
finds reusable prompts; insertion needs both template and draft revisions and an
explicit project for dynamic context. Inspect the draft before sending. Save/delete
only changes custom templates. `ac_ui_surfaces` / `ac_ui_surface_set` selects named panels;
`ac_usage_read` and `ac_worktrees_read` provide read-only evidence.

For existing workflows, list definitions for the exact cwd, then start with JSON
arguments. Ordinary source approval may need computer use. Poll the returned task
callId through `ac_operations_read` for runId, then use `ac_workflows_status`, `ac_workflows_events` and `ac_workflows_result`.
Cancel/resume is limited to the external connection's runs. Clients sharing this
connection share that ownership; it is not a per-person identity. Internal agent
runs keep their owner, and workflow workers do not gain this operator toolkit.

## 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` for a bounded recent catalog outside the 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. This catalog is
not a full archive search. Use `ac_native_history_search` to search user-prompt text across the existing Claude/Codex index, including closed conversations. It searches at most 400 recent candidates per provider, is best-effort, and does not search assistant text or OpenCode. Inspect coverage and paginate results; a missing match does not prove absence. `ac_native_history_prompts` also supports a full-text substring query within a known native transcript.

`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.

Creating a detached agent normally selects it in the lane focused when creation
began, replacing that view without closing its agent. To preserve current tabs and
lanes, use `ac_agents_create` with `selectCreated:false`. Then read `ac_layout_read`
and use `ac_dispatch_configure` with `change.action:"lane-select"`, the target
`laneIndex`, the returned session ID and a fresh revision. Resume/duplicate also
accept `selectCreated:false` to preserve tabs/lanes; default true uses normal creation selection. Creation readiness is a cached observation, not admission to send.

## Wait and set display preferences

Use `ac_observations_wait` for a bounded status wait (at most 10 seconds), with a
caller-chosen `waitId` and an exact agent or lifecycle-operation target. For agent
waits, put the window owner inside `target.owner`; this is a main-owned tool.
`until: "change"` compares against `after` (the prior wait cursor), or the first
observation. `attention` means visible conditions/exit; `settled` means observed
input-ready idle without queued prompts/conditions, or a terminal operation result.
Neither means an agent completed your particular task successfully. Read output
and verify the requested outcome. Read `snapshot` even for a failed operation.

Handle `timeout`, `cancelled`, `unavailable` and `cursor_expired` explicitly.
Use the fresh status cursor to continue; these cursors are separate from transcript
cursors and expire after five minutes, eviction, restart or target identity change.
Cancel concurrently with `ac_observations_cancel_wait` and the same waitId; that
cancels the wait, not the agent. Use bounded waits instead of tight status polling.

Read `ac_views_preferences_read` before `ac_views_mode_set` or
`ac_views_follow_set` and supply its revision. Mode null inherits the global mode;
provider constraints still apply. Follow is the rendered-feed preference; Tail All
can keep it enabled even when the individual preference is false. Change the
window-wide override with `ac_views_tail_all_set`, supplying its expected current
value. Hidden panes may suspend scrolling. These controls preserve pane layout.

## Recover and verify

Use a fresh `_control.requestKey` for each new mutation intention. Reuse that key
Expand Down
8 changes: 8 additions & 0 deletions src/control-sdk/catalog/input.ts
Original file line number Diff line number Diff line change
@@ -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.enum(['unknown', 'occupied']), text: z.null(), reason: z.string() }),
inputReady: z.boolean().nullable(), readinessReason: z.string().nullable().default(null) })
3 changes: 2 additions & 1 deletion src/control-sdk/catalog/workspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})),
Expand Down
9 changes: 5 additions & 4 deletions src/control-sdk/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export type ControlFailureCode =
| 'history_unavailable' | 'idempotency_conflict' | 'interrupted'

export class ControlError extends Error {
constructor(readonly code: ControlFailureCode, message: string, readonly outcome: 'not_started' | 'unknown' = 'not_started') {
constructor(readonly code: ControlFailureCode, message: string, readonly outcome: 'not_started' | 'unknown' = 'not_started', readonly details?: unknown) {
super(message)
this.name = 'ControlError'
}
Expand All @@ -42,7 +42,7 @@ export const controlOperationSchema = z.object({
}).strict()
export type ControlResult<T = unknown> = (
| { ok: true; value: T }
| { ok: false; error: { code: ControlFailureCode; message: string; outcome: 'not_started' | 'unknown' } }
| { ok: false; error: { code: ControlFailureCode; message: string; outcome: 'not_started' | 'unknown'; details?: unknown } }
) & { operation?: z.infer<typeof controlOperationSchema> }

export type CapabilityDescriptor = Readonly<{
Expand Down Expand Up @@ -90,7 +90,7 @@ export const controlResultSchema = z.discriminatedUnion('ok', [
z.object({ ok: z.literal(true), value: z.json(), operation: controlOperationSchema.optional() }).strict(),
z.object({ ok: z.literal(false), error: z.object({
code: z.enum(['unavailable', 'ambiguous_owner', 'stale_owner', 'invalid_input', 'invalid_output', 'failed', 'stale_cursor', 'invalid_cursor', 'history_unavailable', 'idempotency_conflict', 'interrupted']),
message: z.string(), outcome: z.enum(['not_started', 'unknown']),
message: z.string(), outcome: z.enum(['not_started', 'unknown']), details: z.json().optional(),
}).strict(), operation: controlOperationSchema.optional() }).strict(),
])

Expand Down Expand Up @@ -122,6 +122,7 @@ export function controlFailure(
code: ControlFailureCode,
message: string,
outcome: 'not_started' | 'unknown' = 'not_started',
details?: unknown,
): ControlResult<never> {
return { ok: false, error: { code, message, outcome } }
return { ok: false, error: { code, message, outcome, ...(details === undefined ? {} : { details }) } }
}
2 changes: 1 addition & 1 deletion src/control-sdk/core/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ export function createControlExecutor(ports: {
}
} catch (error) {
result = error instanceof ControlError
? controlFailure(error.code, error.message, error.outcome)
? controlFailure(error.code, error.message, error.outcome, error.details)
: controlFailure(dispatched || previous ? 'failed' : 'history_unavailable',
error instanceof Error ? error.message : 'Control execution failed', dispatched || previous ? 'unknown' : 'not_started')
}
Expand Down
1 change: 1 addition & 0 deletions src/control-sdk/importBoundaries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function specifiers(source: string): Array<string | null> {

function violation(from: string, target: string | undefined, specifier: string | null): string | undefined {
const integration = /\/control(?:\.ts|\/)/.test(from)
if (target?.startsWith('src/main/workflows/externalOperator/') && from !== 'src/main/workflows/control.ts' && !from.startsWith('src/main/workflows/externalOperator/')) return 'external workflow ownership has one control consumer'
if (specifier === null) {
return from.startsWith(sdk) || from.startsWith(mcp) || integration ? 'computed import hides control dependency' : undefined
}
Expand Down
1 change: 1 addition & 0 deletions src/control-sdk/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,4 @@ export { operatorRoutingSchema, externalConnectionStatusSchema } from './operato
export type { ControlOperatorPort, ExternalConnectionStatus } from './operator'

export { startControlTask } from './task'
export { nativeInputOutput } from './catalog/input'
2 changes: 1 addition & 1 deletion src/control-sdk/registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function defineCapability<I extends z.ZodType, O extends z.ZodType>(defin
if (!json.success) return controlFailure('invalid_output', 'Capability returned a non-JSON value', 'unknown')
return { ok: true, value: JSON.parse(JSON.stringify(json.data)) as z.output<O> }
} catch (error) {
if (error instanceof ControlError) return controlFailure(error.code, error.message, error.outcome)
if (error instanceof ControlError) return controlFailure(error.code, error.message, error.outcome, error.details)
// A thrown handler may already have performed its effect. Only failure
// before dispatch is evidence that automatic resubmission is safe.
return controlFailure('failed', error instanceof Error ? error.message : 'Capability failed', 'unknown')
Expand Down
2 changes: 1 addition & 1 deletion src/control-sdk/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function startControlTask(context: ControlContext, invoke: (request
let result: ControlResult
try { result = { ok: true, value: z.json().parse(await run()) } }
catch (error) {
result = error instanceof ControlError ? controlFailure(error.code, error.message, error.outcome)
result = error instanceof ControlError ? controlFailure(error.code, error.message, error.outcome, error.details)
: controlFailure('failed', error instanceof Error ? error.message : 'Lifecycle operation failed', 'unknown')
}
// No automatic retry with a new intention: the domain may already have
Expand Down
Loading