Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
34 changes: 34 additions & 0 deletions cmux-tui/crates/acpmux/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1540,6 +1540,40 @@ list with no daemon running, which the cmux app bundle records for its web pages
Any other method that names a `sessionId` is forwarded to the agent unchanged, so vendor
extensions keep working.

### Fork before an edited prompt

Gate Edit and Resend on `initialize._meta.acpmux.operations` containing
`acp.session.fork.beforePromptId`. Send the source's recorded prompt identity and a stable
client-generated retry identity:

```json
{"jsonrpc":"2.0","id":1,"method":"acp.session.fork","params":{"sessionId":"source","beforePromptId":"prompt-id","requestId":"edit-request-id"}}
```

Both ids must contain 1–512 UTF-8 bytes. Omit `throughSeq` and `live` entirely, including
`live: false`. The daemon validates the complete source history. For Claude and Codex it
forks through the preceding completed turn, or creates an empty configured child for the
true first prompt. The reply's `sessionId` is the child; send the edited prompt there.
Configuration and parent/boundary lineage follow fork semantics; source history stays unchanged.
A retry with the same source, request id and boundary returns that child even after a lost reply.
Reusing the request id for another boundary is refused. An incomplete persisted fork is refused
without creating another child. Direct `session/fork` cannot supply internal boundary metadata.

Refusals use JSON-RPC `-32602` and `data.reason`:

| Reason | Meaning |
| --- | --- |
| `fork.invalid_boundary` | Invalid ids or combined boundary selectors. |
| `fork.unknown_prompt` | No recorded prompt has that identity. |
| `fork.ambiguous_prompt` | More than one recorded prompt has that identity. |
| `fork.unsupported` | Unsupported harness or steering boundary. |
| `fork.unreconstructable` | The full boundary or prompt content cannot be reconstructed. |
| `fork.request_conflict` | The persisted request identity names a different boundary. |
| `fork.incomplete` | The persisted fork has not completed safely. |

Existing fork authorization and admission checks still apply. Retain the editor draft on refusal;
never fall back to a whole-conversation fork when the capability is absent.

### Typed errors

An error a client sees can say what kind of failure it is and what to do, in three keys added
Expand Down
1 change: 1 addition & 0 deletions cmux-tui/crates/acpmux/docs/acpmux-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"session/update": "Live agent updates arrive with sessionId set to the acpmux id and _meta.acpmux {seq, at, kind} merged into the agent's own _meta (the agent's keys are kept). Subagent updates add _meta.acpmux.subagents[] entries; see SubagentEvent and WorkflowAgent. session/load replays add replay: true."
},
"methods": {
"acp.session.fork": {"params": {"sessionId": "string", "throughSeq": "u64? (completed turn_result)", "live": "boolean? (whole conversation)", "beforePromptId": "string? (recorded prompt id, 1..512 UTF-8 bytes)", "requestId": "string? (required with beforePromptId, 1..512 UTF-8 bytes)"}, "result": {"sessionId": "string", "modes": "any", "configOptions": "any", "_meta": "{acpmux: session summary}"}, "errors": "-32602 with data.reason fork.invalid_boundary, fork.unknown_prompt, fork.ambiguous_prompt, fork.unsupported, fork.unreconstructable, fork.request_conflict or fork.incomplete; existing session/fork guards still apply", "note": "Gate beforePromptId on initialize._meta.acpmux.operations containing acp.session.fork.beforePromptId. beforePromptId is mutually exclusive by presence with throughSeq and live. Claude/Codex only: the server resolves the recorded prompt against the complete source log, rejects steering and unreconstructable boundaries, and retains only prior completed history. The true first prompt creates an empty configured child with persistent parent and boundary lineage, without an invented native transcript or sequence. Source history stays unchanged. requestId is scoped to the source: retrying the same boundary returns the same child, while changing the boundary conflicts; an incomplete persisted fork is refused rather than duplicated. Direct session/fork cannot supply internal boundary metadata."},
"_acpmux/mesh_control": {"params": "{op: start|resume|stop, to: {sessionId}|{name,host?}|{host}|{daemonId}, params?: session/new object, idempotencyKey: string} or {receipt: messageId}", "result": "Mesh receipt {messageId,state,detail?,...}; enqueue adds idempotencyKey", "note": "Local socket only; remote-controlled agent callers are refused. Uses this daemon's configured mesh client, never request-supplied sender identity. Queued is not executed: read receipt once again to learn delivery. Receiving control is off unless its owner lists the hub-stamped sender daemonId in meshRelay.controlFrom. The recipient still enforces its existing peer and app-only policy rules."},
"_acpmux/status": {"params": {}, "result": {"version": "string", "capabilities": "string[] (the protocol surfaces this build serves, by stable id: acpmux-v1, queue-edit-v1, ...; also in initialize's _meta.acpmux.capabilities and `acpmux capabilities --json`)", "pid": "number", "exe": "string|null (this daemon's own executable; Linux may add \" (deleted)\" after an update replaced the file)", "exeStable": "boolean (exe is a stable install: not under a CI runner's _work, a temporary folder or a build tree; daemon/exe_path.rs)", "exeUnstable": "runner-work|temp|build-tree|null (why exe is not stable)", "runsAsCmuxTuiAcp": "boolean (it runs as `cmux-tui acp …`; its command line itself is never reported)", "personKey": "boolean (this run holds a person key; never the key)", "personInstance": "string (this daemon run's id, which decision proofs bind)", "sessions": "number", "liveAgents": "number", "openWaits": "number (_acpmux/wait requests subscribed to this daemon's records and not yet answered)", "largeMessages": "{nearCap: number (messages sent at or above nearCapBytes, three quarters of limit, since this daemon started), tooLarge: number (messages refused as larger than limit: a reply goes as its message.too_large error, a notification is left out, a request fails), nearCapBytes: number, limit: number (the per-message cap, 15 MiB), lastMethod: string|null (the last one counted: a method, \"reply to <method>\" or \"reply\"), lastBytes: number|null, lastAt: number|null (unix ms), lastTooLarge: boolean|null} (wire_size.rs; each method and kind is warned of in the log at most once a minute)", "harnesses": "string[]", "peers": "object[]", "webUrl": "string|null", "listen": "string|null (the bound dashboard/WebSocket address; the real port when started with --listen 127.0.0.1:0)", "ready": "boolean (false while the daemon imports the login environment and checks launchers; session/new and agent spawns wait for it)", "loginEnv": "pending|imported|not_requested", "host_pressure": "HostPressure? (host_pressure.rs: this host's CPU/RAM budget, derived from its cores and RAM and sampled when asked, at most every formula.sample_seconds, on a blocking thread (status waits at most 250 ms, then answers with the last verdict; ok is null before the first): {host, cores, cores_kind: performance|physical|logical (macOS performance: the cores of every perf level not named Efficiency, 15 on an M5 Pro), load1: number|null, load_limit (cores * formula.load1_per_core), pressure: normal|warn|critical|unknown, available_ram_fraction: number|null, swap_mb_per_min: number|null (null until two samples), over: string[] (one phrase per reason; empty when within budget), ok: boolean, warning: string|null (\"host-budget: HOST over its CPU/RAM budget: ...\"), enforce: boolean (the override file asks for holds; this build only warns), formula: {load1_per_core, cores, pressure, psi_some_avg10, min_available_ram_fraction, swap_growth_mb_per_min, sample_seconds} (defaults built in; $ACPMUX_HOST_BUDGET or ~/.config/cmux/host-budget.json overrides them, its hosts.<short host name> entry over its formula), local_turns: number, background_processes: number (of chats whose processes this daemon has read), top: [{session_id, name, turns, processes}] (at most 5, most background processes first)}. Only this daemon's sessions count; a peer's are its own. While ok is false, a turn's _acpmux/prompt_accepted (and the send's accepted) carries the same verdict as host_pressure. Nothing is refused, queued or held.)"}},
"_acpmux/sessions": {"params": {"withTools": "boolean? default false; include tool inventories (also on watch notices)", "usage": "boolean? default false; read each chat's usage (SessionSummary.usageStats) from its harness's records first: at most one read every 5 s (sooner after a turn ends), waited for at most 750 ms; without it the rows carry the last read's (acpmux ls --wide and --json send it)", "limit": "number? (a positive integer: page the list; without it the whole list, in pages by size when it is too large for one message)", "cursor": "string? (a nextCursor: the page after the session it names)", "peer": "string? (sessions owned by this peer)", "status": "string? (sessions with this status)", "localOnly": "boolean? (only sessions owned by this daemon)", "excludeStatus": "string[]? (omit sessions with these statuses)", "orderBy": "sessionId? (requires localOnly; stable ID order for complete scans)"}, "result": {"sessions": "SessionSummary[] (newest updatedAt first, ties by sessionId; the pages of a list too large for one message go in an order updates do not change, a hash of sessionId and peer, and Client::whole_list sorts them back newest first)", "nextCursor": "string|null? (with limit: null when the list is done; without limit: present only when the list is too large for one message (over 14 MiB), and the rest is read with _acpmux/sessions {cursor} and the same filters)"}, "errors": "invalid_params: limit not a positive integer; a cursor that is not a nextCursor; malformed filters", "note": "Keyset pages (hub/session_pages.rs): a session that moves or leaves between pages neither repeats nor skips another."},
Expand Down
45 changes: 44 additions & 1 deletion cmux-tui/crates/acpmux/spec/acpmux-protocol.json
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,9 @@
{
"kind": "request",
"name": "acp.session.fork",
"params": true,
"params": {
"$ref": "#/types/ForkParams"
},
"result": true
},
{
Expand Down Expand Up @@ -2367,6 +2369,47 @@
],
"type": "object"
},
"ForkParams": {
"description": "`acp.session.fork`: a completed-turn, live, or before-prompt fork.\nBefore-prompt requests require requestId and omit both throughSeq and live.",
"properties": {
"_meta": true,
"beforePromptId": {
"description": "Recorded prompt identity, nonempty and at most 512 UTF-8 bytes. Claude/Codex only.",
"type": [
"string",
"null"
]
},
"live": {
"type": [
"boolean",
"null"
]
},
"requestId": {
"description": "Persisted retry identity, nonempty and at most 512 UTF-8 bytes; scoped to the source.",
"type": [
"string",
"null"
]
},
"sessionId": {
"type": "string"
},
"throughSeq": {
"format": "uint64",
"minimum": 0,
"type": [
"integer",
"null"
]
}
},
"required": [
"sessionId"
],
"type": "object"
},
"HarnessUpdatePlanParams": {
"additionalProperties": false,
"properties": {
Expand Down
Loading
Loading