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
1 change: 1 addition & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"mcp.oauth.disconnect": { type: "mcp.oauth.disconnect", id: "r92", server: "notion" },
"skill.grant": { type: "skill.grant", id: "r93", workdir: "/tmp/w", command: "./probe.sh", allowed: true },
"session.turns": { type: "session.turns", id: "r94", sessionId: "s1" },
"session.rewind": { type: "session.rewind", id: "r95", sessionId: "s1", turnId: "t1", restoreFiles: true, dryRun: true },
"settings.schema": { type: "settings.schema", id: "r29" },
"settings.get": { type: "settings.get", id: "r30" },
"settings.set": {
Expand Down
12 changes: 12 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,17 @@ export const sessionTurnsSchema = z.object({
sessionId: sessionIdField,
});

export const sessionRewindSchema = z.object({
...base,
type: z.literal("session.rewind"),
sessionId: sessionIdField,
turnId: z.string().min(1).max(128),
restoreFiles: z.boolean().optional(),
dryRun: z.boolean().optional(),
force: z.boolean().optional(),
planId: z.string().min(1).max(64).optional(),
});

export const skillGrantSchema = z.object({
...base,
type: z.literal("skill.grant"),
Expand Down Expand Up @@ -772,6 +783,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
backendLoginCancelSchema,
skillGrantSchema,
sessionTurnsSchema,
sessionRewindSchema,
mcpOAuthBeginSchema,
mcpOAuthCompleteSchema,
mcpOAuthDisconnectSchema,
Expand Down
93 changes: 93 additions & 0 deletions packages/protocol/src/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,96 @@ export interface SessionTurnsResultMsg {
*/
checkpointsSupported: boolean;
}

// ── Going back a turn (#355) ──────────────────────────────────────────────────

/**
* Take back turn `turnId` and every turn after it. The agent no longer
* remembers them — on every backend, after a restart too. With
* `restoreFiles`, the working directory also goes back to how it was when
* that turn started (its snapshot). `dryRun` reports what would happen and
* changes nothing; a real restore that would overwrite files changed by
* hand since the agent's last turn is refused unless `force`.
*
* A running turn is stopped first. Scopes: `session:send`; a preview also
* needs `session:attach` or `session:watch`; restoring files needs
* `session:approve`; stopping a busy session needs `session:interrupt`.
*/
export interface SessionRewindMsg {
type: "session.rewind";
id: string;
sessionId: string;
turnId: string;
restoreFiles?: boolean;
dryRun?: boolean;
force?: boolean;
/**
* The `planId` of the dry run being confirmed: the real run refuses (and
* changes nothing) if anything changed since that preview.
*/
planId?: string;
}

/** Something a taken-back turn did that going back can't undo. */
export interface RewindIrreversible {
/** The tool, as the backend named it (e.g. "Bash", "mcp__github__create_pr"). */
tool: string;
/** What it did (a shell command, an MCP call), shortened. */
detail: string;
}

export interface RewindFiles {
/** Files that get their snapshot content back (changed or deleted since). */
restore: string[];
/** Files created since the snapshot, which get deleted. */
remove: string[];
/**
* Of those, files changed by hand since the agent's last turn ended —
* overwriting them loses work the agent didn't do.
*/
conflicts: string[];
/** The snapshot was still being taken when that turn's agent started. */
late?: boolean;
/**
* Paths left as they are because something else occupies them now (an
* ignored file, a file where a deleted directory would go, a symlinked
* directory): not restored.
*/
skipped?: string[];
/**
* The agent's edits couldn't be told apart from hand edits (a snapshot is
* missing), so every file that would change is counted as a conflict.
*/
unverified?: boolean;
/** True once the files were actually put back (never on a dry run). */
applied: boolean;
}

export interface SessionRewindResultMsg {
type: "session.rewind.result";
requestId: string;
sessionId: string;
turnId: string;
dryRun: boolean;
/** Identifies exactly this plan; send it back with the real run. */
planId: string;
/** How many turns were (or would be) taken back. */
removedTurns: number;
/** The prompt of the turn taken back — to edit and send again. */
restoredPrompt: string;
/** Present when `restoreFiles` was asked for. */
files?: RewindFiles;
/**
* When files were asked for but can't be restored (no snapshot for that
* turn, checkpoints disabled): why. The conversation still goes back.
*/
filesUnavailable?: string;
/** Effects of the taken-back turns that going back can't undo. */
irreversible: RewindIrreversible[];
/**
* Set when nothing changed because going back would overwrite files
* changed by hand since the agent's last turn (see `files.conflicts`).
* Send again with `force` to go ahead.
*/
refused?: string;
}
4 changes: 3 additions & 1 deletion packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import type {
BackendLoginCancelResultMsg,
} from "./backend-login.js";
import type { SkillGrantMsg, SkillGrantResultMsg } from "./skill-grant.js";
import type { SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js";
import type { SessionRewindMsg, SessionRewindResultMsg, SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js";
import type {
McpOAuthBeginMsg,
McpOAuthCompleteMsg,
Expand Down Expand Up @@ -1025,6 +1025,7 @@ export type ClientMessage =
| BackendLoginCancelMsg
| SkillGrantMsg
| SessionTurnsMsg
| SessionRewindMsg
| McpOAuthBeginMsg
| McpOAuthCompleteMsg
| McpOAuthDisconnectMsg
Expand Down Expand Up @@ -2583,6 +2584,7 @@ export type DaemonMessage =
| BackendLoginCancelResultMsg
| SkillGrantResultMsg
| SessionTurnsResultMsg
| SessionRewindResultMsg
| McpOAuthBeginResultMsg
| McpOAuthCompleteResultMsg
| McpOAuthDisconnectResultMsg
Expand Down
12 changes: 12 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* codeoid send <name|id> <message> Send a one-shot message
* codeoid interrupt <name|id> Interrupt a running agent
* codeoid approve <name|id> [yes|no] Approve/deny pending permission
* codeoid undo <name|id> [files [yes|force]] Take back the last message
* codeoid skill allow <command> Pre-approve a skill's command (unattended runs)
* codeoid destroy <name|id> Destroy a session
*/
Expand Down Expand Up @@ -591,6 +592,17 @@ program
client.disconnect();
});

program
.command("undo <session> [args...]")
.description("Take back the last message (the agent forgets it); 'files' previews restoring the files, 'files yes' does it")
.action(async (session: string, args: string[]) => {
const config = loadConfig();
const client = new TerminalClient(config);
await client.connect();
await client.undoSession(session, args ?? []);
client.disconnect();
});

const skill = program
.command("skill")
.description("Pre-approve commands that installed skills declare (for unattended runs like `pipeline run`)");
Expand Down
Loading
Loading