Skip to content
Open
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
2 changes: 1 addition & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -1837,7 +1837,7 @@ The checked TUI items in this phase were pulled forward as an explicit exception
- [ ] Reconcile uncertain submissions before retrying and implement provider cancellation where available.
- [ ] Keep steering and follow-ups queued until the pending batch request reaches a terminal state.
- [x] Implement daemon-owned steer and follow-up semantics at complete tool-call and turn boundaries.
- [ ] Complete interrupt-and-deliver semantics.
- [x] Complete interrupt-and-deliver semantics.
- [x] Queue multiple follow-ups in order.
- [x] Add `/fork` from a selected user message and `/clone` from the current tip.
- [ ] Add in-session branch and tree navigation.
Expand Down
2 changes: 1 addition & 1 deletion SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ axl daemon stop --interrupt --yes

Use the same `--unsafe`, `--sandbox`, `--image`, or `--socket` selection as the running daemon. Status and stop do not require provider credentials. Restart refuses to switch data directories. Stop and restart refuse active work unless `--interrupt` explicitly authorizes cancellation. `--yes` confirms disconnecting clients. A changed confirmation snapshot requires a fresh command. Exit codes are 0 for success, 1 for errors, 2 for refused or stale confirmation, and 3 for a missing daemon on status or stop. Restart starts a missing daemon.

An incompatible session wire fails loudly and points to these commands. No daemon is automatically replaced on a version mismatch. Host-control version 1 operates independently of session wire version 11 on a separate connection to the same owner-only Unix socket. It does not bypass the session handshake.
An incompatible session wire fails loudly and points to these commands. No daemon is automatically replaced on a version mismatch. Host-control version 1 operates independently of session wire version 12 on a separate connection to the same owner-only Unix socket. It does not bypass the session handshake.

If graceful cleanup fails or exceeds the host's ten-second wait, inspect `axl daemon status`. Shutdown can still be running. The TUI offers a separate force confirmation when available. From the CLI, explicitly request:

Expand Down
10 changes: 6 additions & 4 deletions docs/architecture/web-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ This document specifies typed RPC, negotiation, errors, package ownership, and t

## Current baseline

Wire version 11 uses newline-delimited JSON over a Unix socket. It includes typed request and result envelopes, initialization, capability negotiation, structured errors, idempotency keys, subscription identities, paged snapshots, acknowledged opaque cursors, presence, daemon security reporting, direct shell events, transient activity, session-bound blobs, workspace review, session profiles, web-tool selection, manual compaction, steering, follow-ups, and canonical model-retry attempts.
Wire version 12 uses newline-delimited JSON over a Unix socket. It includes typed request and result envelopes, initialization, capability negotiation, structured errors, idempotency keys, subscription identities, paged snapshots, acknowledged opaque cursors, presence, daemon security reporting, direct shell events, transient activity, session-bound blobs, workspace review, session profiles, web-tool selection, manual compaction, steering, follow-ups, atomic interrupt-and-deliver, and canonical model-retry attempts.

The TUI consumes these contracts through `packages/sdk`. Version 11 adds daemon-owned model request settings plus canonical effective-request events. Host-control version 1 remains separate from session wire negotiation and is available only to trusted process hosts.

Expand Down Expand Up @@ -61,8 +61,9 @@ session.resume
session.fork
session.clone
session.send.prompt
session.send.steer
session.send.follow_up
session.steer
session.follow_up
session.interrupt_deliver
session.queue.enqueue
session.queue.requeue
session.shell
Expand Down Expand Up @@ -227,6 +228,7 @@ The following table lists the additional errors each method may return. The expo
| `session.fork` | `unknown_session`, `event_migration_required`, `corrupt_session`, `operation_active`, `invalid_fork_point`, `invalid_idempotency_key`, `idempotency_conflict`, `content_too_large` |
| `session.clone` | `unknown_session`, `event_migration_required`, `corrupt_session`, `operation_active`, `empty_session`, `invalid_idempotency_key`, `idempotency_conflict`, `content_too_large` |
| `session.send` | `unknown_session`, `event_migration_required`, `operation_active`, `invalid_idempotency_key`, `idempotency_conflict`, `blob_not_owned`, `blob_missing`, `blob_corrupt`, `content_too_large` |
| `session.interruptAndDeliver` | `unknown_session`, `event_migration_required`, `operation_active`, `invalid_idempotency_key`, `idempotency_conflict`, `blob_not_owned`, `blob_missing`, `blob_corrupt`, `content_too_large` |
| `session.queue.enqueue` | `unknown_session`, `event_migration_required`, `invalid_idempotency_key`, `idempotency_conflict`, `blob_not_owned`, `blob_missing`, `blob_corrupt`, `content_too_large` |
| `session.queue.requeue` | `unknown_session`, `event_migration_required`, `unknown_queue_item`, `queue_not_paused`, `invalid_idempotency_key`, `idempotency_conflict`, `content_too_large` |
| `session.shell` | `unknown_session`, `event_migration_required`, `operation_active`, `idempotency_conflict`, `content_too_large` |
Expand Down Expand Up @@ -401,7 +403,7 @@ interface SessionDisposeResult {

`delivery: "prompt"` is ordinary prompt behavior. The version-7 `session.steer` and `session.followUp` methods remain available in version 8. The `session.send` delivery variants `steer` and `follow_up` remain unavailable until their separate capabilities are implemented, and clients must not simulate them.

`session.send` completes when the turn reaches a canonical terminal assistant event or error. Detaching does not cancel it. `session.interrupt` is the session-operation cancellation path.
`session.send` completes when the turn reaches a canonical terminal assistant event or error. Detaching does not cancel it. `session.interrupt` is the session-operation cancellation path. `session.interruptAndDeliver` atomically stops active work at a safe boundary and delivers its replacement content exactly once; when no operation is active, it behaves as an ordinary send.

Queued prompts use `session.queue.enqueue` and `session.queue.requeue`. Enqueue records prompt content and priority in canonical history before returning. The daemon appends lifecycle events as an item is queued, started, paused after restart, and explicitly re-queued. Pending items are never executed automatically after restart. Every attachment derives the same queue from those events.

Expand Down
79 changes: 64 additions & 15 deletions packages/daemon/src/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,6 +850,23 @@ export class AxlDaemon {
}
}

private interruptTargetOperationId(
sessionId: SessionId,
): ReturnType<typeof parseOperationId> | undefined {
const active = this.sessions.activeOperationId(sessionId);
if (active !== undefined) return active;
for (const request of this.admitted.values()) {
if (request.method === "session.send" && request.params.sessionId === sessionId) {
const operationId = request.idempotencyKey;
if (operationId !== undefined) return parseOperationId(operationId, "idempotencyKey");
}
if (request.method === "session.shell" && request.params.sessionId === sessionId) {
return request.params.operationId;
}
}
return undefined;
}

private async executeRequest(
request: WireRequest,
send: (message: ServerMessage) => void,
Expand Down Expand Up @@ -908,25 +925,43 @@ export class AxlDaemon {
? parseSessionId(randomUUID(), "intendedSessionId")
: undefined;
const affectedOperationId =
normalized.method === "session.interrupt"
? this.sessions.activeOperationId(normalized.params.sessionId)
normalized.method === "session.interrupt" ||
normalized.method === "session.interruptAndDeliver"
? this.interruptTargetOperationId(normalized.params.sessionId)
: undefined;
const interactionId =
normalized.method === "session.interaction.respond"
? normalized.params.interactionId
: undefined;
return journal.execute(
{
idempotencyKey,
method: normalized.method as RetryableMutationMethod,
requestHash: hashCanonicalRequest(normalized.method, normalized.params as never),
...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }),
...(intendedSessionId === undefined ? {} : { intendedSessionId }),
...(affectedOperationId === undefined ? {} : { affectedOperationId }),
...(interactionId === undefined ? {} : { interactionId }),
},
(acceptance) => this.dispatch(normalized, send, state, acceptance) as never,
);
const interruptDeliveryOperationId =
normalized.method === "session.interruptAndDeliver"
? parseOperationId(idempotencyKey, "idempotencyKey")
: undefined;
if (interruptDeliveryOperationId !== undefined) {
this.sessions.reserveInterruptDelivery(
params.sessionId,
interruptDeliveryOperationId,
affectedOperationId,
);
}
try {
return await journal.execute(
{
idempotencyKey,
method: normalized.method as RetryableMutationMethod,
requestHash: hashCanonicalRequest(normalized.method, normalized.params as never),
...(params.sessionId === undefined ? {} : { targetSessionId: params.sessionId }),
...(intendedSessionId === undefined ? {} : { intendedSessionId }),
...(affectedOperationId === undefined ? {} : { affectedOperationId }),
...(interactionId === undefined ? {} : { interactionId }),
},
(acceptance) => this.dispatch(normalized, send, state, acceptance) as never,
);
} finally {
if (interruptDeliveryOperationId !== undefined) {
this.sessions.releaseInterruptDelivery(params.sessionId, interruptDeliveryOperationId);
}
}
}

private async dispatch(
Expand Down Expand Up @@ -1046,6 +1081,15 @@ export class AxlDaemon {
return this.sessions.steer(request.params.sessionId, request.params.content);
case "session.followUp":
return this.sessions.followUp(request.params.sessionId, request.params.content);
case "session.interruptAndDeliver":
return this.sessions.interruptAndDeliver(
request.params.sessionId,
request.params.content,
this.mutationOperationId(acceptance),
acceptance?.affectedOperationId === undefined
? undefined
: parseOperationId(acceptance.affectedOperationId, "affectedOperationId"),
);
case "session.compact":
return this.sessions.compact(request.params.sessionId, request.params.instructions);
case "session.queue.enqueue":
Expand All @@ -1070,7 +1114,12 @@ export class AxlDaemon {
request.params.excluded,
);
case "session.interrupt":
return this.sessions.interrupt(request.params.sessionId);
return this.sessions.interrupt(
request.params.sessionId,
acceptance?.affectedOperationId === undefined
? undefined
: parseOperationId(acceptance.affectedOperationId, "affectedOperationId"),
);
case "session.reload":
return this.sessions.reload(request.params.sessionId, this.mutationOperationId(acceptance));
case "session.configure": {
Expand Down
2 changes: 2 additions & 0 deletions packages/daemon/src/event-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ function eventBlobReferences(event: CanonicalEvent): readonly BlobReference[] {
if (
event.type !== "user.message" &&
event.type !== "assistant.message" &&
event.type !== "interrupt.requested" &&
event.type !== "user.shell" &&
event.type !== "tool.result"
) {
Expand All @@ -199,6 +200,7 @@ function externalizeTextContent(
if (
event.type !== "user.message" &&
event.type !== "assistant.message" &&
event.type !== "interrupt.requested" &&
event.type !== "user.shell" &&
event.type !== "tool.result"
) {
Expand Down
1 change: 1 addition & 0 deletions packages/daemon/src/session-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ function blobReferences(events: readonly CanonicalEvent[]): readonly BlobReferen
event.type !== "user.message" &&
event.type !== "assistant.message" &&
event.type !== "queue.enqueued" &&
event.type !== "interrupt.requested" &&
event.type !== "user.shell" &&
event.type !== "tool.result"
) {
Expand Down
Loading
Loading