From 9e7503c0ca69140fa8ae1d0cd01dc4acfef1e169 Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:34:34 +0000 Subject: [PATCH] feat: split goal control by transport A goal action either changes durable state or starts work, and ACP v1 treats those very differently. `LodyGoalCapability` now names which actions an agent accepts on the out-of-band `_lody/session/goal` request (`controlActions`) and which it accepts as prompt metadata (`promptActions`); `actions` alone could not say, and sending a work-starting action out of band produces turns the client never prompted for and cannot attribute to a conversation. `LodyGoalPromptControl` on `_meta.lody.goalControl` is the new prompt channel, so a client can resume or set a goal without putting command text in the transcript. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 --- README.md | 32 ++++++++++++++++++++++++++++++++ package.json | 2 +- src/capabilities.ts | 13 +++++++++++++ src/session.ts | 20 ++++++++++++++++++++ 4 files changed, 66 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7abc66d..0db9724 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,38 @@ time-bounded. specially. Adapters map provider-native names to these values; consumers never infer behavior from a human-facing tool title. +## Goal control + +A goal is durable session state, not a running prompt. Its two halves travel on +different transports because they need different things from ACP v1: + +| Transport | Actions | Property | +| ------------------------------- | -------------------------- | ------------------------------------------- | +| `_lody/session/goal` request | status-only actions | Never starts a turn; works mid-prompt | +| `prompt._meta.lody.goalControl` | any advertised action | Runs inside the prompt the client owns | + +The request exists for `pause` and `clear`: an active goal keeps a prompt open +across the agent's own continuations, so a client that could only speak through +prompts would have no way to reach a goal it wants to stop. Agents must accept +these mid-prompt and must not start a turn for them. + +`set` and `resume` start work, and ACP v1 gives a client exactly one way to own +running work — its own prompt. The client sends a prompt carrying +`_meta.lody.goalControl` instead of user-visible command text; the agent applies +the action, adopts any turn the action started natively, and keeps that prompt +open for the goal's remaining turns. Status-only actions may travel this way +too, which is what lets a client reach a goal whose session is not running. + +An agent may also accept work-starting actions on the request for clients that +cannot carry prompt metadata, but then the agent owns starting the work and the +client sees turns it never prompted. Clients that must attribute every turn to a +conversation entry use `promptActions` for exactly this reason. + +`LodyGoalCapability` advertises `actions` (everything implemented), +`controlActions` (accepted on the request while a prompt is in flight), and +`promptActions` (accepted through prompt metadata). Clients must not infer an +action's transport from `actions` alone. + ## Plan mode configuration `LODY_PLAN_MODE_CONFIG_ID` is `plan_mode`. `createPlanModeConfigOption(active)` diff --git a/package.json b/package.json index 233d141..cda067b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "acp-extension-core", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "main": "./dist/index.js", "types": "./src/index.ts", diff --git a/src/capabilities.ts b/src/capabilities.ts index 5211a2c..09332b5 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -11,7 +11,20 @@ export type LodySteeringCapability = LodyVersionOneCapability & { export type LodyGoalAction = 'set' | 'pause' | 'resume' | 'clear'; export type LodyGoalCapability = LodyVersionOneCapability & { + /** Every action the agent implements, on any transport. */ actions: readonly LodyGoalAction[]; + /** + * Actions the agent accepts on `_lody/session/goal` while a prompt is in + * flight. These only move durable goal state and never start a turn, so a + * client can send them without owning the session's prompt slot. + */ + controlActions?: readonly LodyGoalAction[]; + /** + * Actions the agent accepts through `prompt._meta.lody.goalControl`. Actions + * that start work appear only here: running them inside the client's own + * prompt is what keeps the resulting turns attributable to a conversation. + */ + promptActions?: readonly LodyGoalAction[]; }; export type LodySubagentCapability = LodyVersionOneCapability & { diff --git a/src/session.ts b/src/session.ts index 0fd2e5a..4cb7b5c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -69,6 +69,23 @@ export type LodyGoalControlResponse = { goal: LodyGoalSnapshot | null; }; +/** + * Client→agent prompt metadata that makes a prompt carry a goal action instead + * of user-visible command text. + * + * This is the general channel: any advertised action can travel here, and an + * action that starts work (`set`, `resume`) MUST, because ACP v1 gives a client + * exactly one way to own running work — its own prompt. A prompt carrying this + * metadata applies the action first and then stays open for the goal's turns, + * so the agent never has to start a turn nobody asked for. + * + * The prompt's content blocks are a fallback the agent may send when the action + * started no native turn; agents that need no fallback ignore them. + */ +export type LodyGoalPromptControl = + | { version: 1; action: 'set'; objective: string } + | { version: 1; action: 'pause' | 'resume' | 'clear' }; + export type LodySteerPromptMeta = { id: string; }; @@ -141,7 +158,10 @@ export type LodySessionMeta = { toolName?: string; activity?: LodyActivityMeta; task?: LodyTaskMeta; + /** Agent→client goal snapshot published on session updates. */ goal?: LodyGoalSnapshot | null; + /** Client→agent goal action carried by `session/prompt`. */ + goalControl?: LodyGoalPromptControl; notice?: LodyNotice; titleSource?: 'explicit' | 'generated' | 'fallback' | 'unset'; messagePhase?: 'commentary' | 'final_answer';