瀏覽代碼

feat: add visible session checkpoints

Alvin Unreal 3 月之前
父節點
當前提交
cb86235ffb

+ 1 - 0
README.md

@@ -471,6 +471,7 @@ Use this section as a map: start with installation, then jump to features, confi
 |-----|----------------|
 | **[Council](docs/council.md)** | Run multiple models in parallel and synthesize a single answer with `@council` |
 | **[Interview](docs/interview.md)** | Turn rough ideas into a structured markdown spec through a browser-based Q&A flow |
+| **[Checkpoints](docs/checkpoints.md)** | Save visible named rollback milestones and restore them through OpenCode revert |
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Todo Continuation](docs/todo-continuation.md)** | Auto-continue orchestrator sessions with cooldowns and safety checks |
 | **[Codemap](docs/codemap.md)** | Generate hierarchical codemaps to understand large codebases faster |

+ 49 - 0
docs/checkpoints.md

@@ -0,0 +1,49 @@
+# Checkpoints
+
+Checkpoints add named rollback milestones on top of OpenCode's built-in
+session revert and snapshot system.
+
+## What they do
+
+- create a visible checkpoint anchor in the current root orchestrator session
+- save a human-readable label like `before multiplexer refactor`
+- let you list, inspect, restore, or delete saved checkpoints later
+- let the orchestrator use the same feature through the `checkpoint` tool
+
+Restores use upstream OpenCode session revert, so file changes and session state
+rewind together.
+
+## Commands
+
+```text
+/checkpoint create "before multiplexer refactor"
+/checkpoint list
+/checkpoint show cp-8f92a
+/checkpoint restore cp-8f92a
+/checkpoint drop cp-8f92a
+```
+
+If you run `/checkpoint` with a bare label, it is treated like `create`.
+
+## Workflow
+
+Good times to create checkpoints:
+
+- before risky multi-file edits
+- before parallel delegation
+- after a major milestone worth preserving
+
+Avoid creating them for tiny one-file tweaks or read-only work.
+
+## Visibility
+
+Checkpoint creation posts a visible status message into the session so you can
+see when checkpoints were created.
+
+Use `/checkpoint list` to see the current timeline for the active root session.
+
+## Limits
+
+- checkpoints are root-session only in the current MVP
+- restore is blocked while child sessions are still active
+- checkpoint metadata is stored in `.opencode/oh-my-opencode-slim/checkpoints.json`

+ 6 - 0
src/agents/orchestrator.ts

@@ -186,6 +186,12 @@ When working through multi-step tasks, consider enabling auto-continue to avoid
 - Use the \`auto_continue\` tool with \`enabled: true\` to activate. The system will automatically resume you when incomplete todos remain after you stop.
 - The user can toggle this anytime via the \`/auto-continue\` command.
 
+### Checkpoints
+- Use the \`checkpoint\` tool before risky multi-file work, before parallel delegation, and at major milestone boundaries worth preserving
+- Prefer concise semantic labels like "before multiplexer refactor" or "post config wiring"
+- Don't create checkpoints for read-only work or tiny one-file edits
+- Checkpoint creation should stay visible to the user; restoring should clearly report what was restored before you continue with any large follow-up edits
+
 ### Validation routing
 - Validation is a workflow stage owned by the Orchestrator, not a separate specialist
 ${enabledValidationRouting}

+ 2 - 0
src/checkpoint/index.ts

@@ -0,0 +1,2 @@
+export { createCheckpointManager } from './manager';
+export * from './types';

+ 284 - 0
src/checkpoint/manager.test.ts

@@ -0,0 +1,284 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createCheckpointManager } from './manager';
+
+function createMockContext(overrides?: {
+  directory?: string;
+  promptImpl?: (args: unknown) => Promise<unknown>;
+  revertImpl?: (args: unknown) => Promise<unknown>;
+}) {
+  return {
+    client: {
+      session: {
+        prompt: mock(async (args: unknown) => {
+          if (overrides?.promptImpl) {
+            return overrides.promptImpl(args);
+          }
+          return { data: { info: { id: 'msg-checkpoint' } } };
+        }),
+        revert: mock(async (args: unknown) => {
+          if (overrides?.revertImpl) {
+            return overrides.revertImpl(args);
+          }
+          return {};
+        }),
+      },
+    },
+    directory: overrides?.directory ?? '/tmp/checkpoint-test',
+  } as any;
+}
+
+function createDepth(getDepth = 0) {
+  return {
+    getDepth: mock((_sessionID: string) => getDepth),
+  } as any;
+}
+
+function toolCtx(sessionID: string) {
+  return {
+    sessionID,
+    messageID: 'msg-ctx',
+    directory: '/tmp',
+    worktree: '/tmp',
+    abort: new AbortController().signal,
+    ask: mock(async () => ({})),
+    read: mock(async () => ''),
+    agent: 'orchestrator',
+  } as any;
+}
+
+describe('checkpoint manager', () => {
+  test('registers checkpoint command', () => {
+    const ctx = createMockContext();
+    const manager = createCheckpointManager(ctx, createDepth());
+    const cfg: Record<string, unknown> = {};
+
+    manager.registerCommand(cfg);
+
+    expect(cfg.command).toBeTruthy();
+    expect((cfg.command as Record<string, unknown>).checkpoint).toBeTruthy();
+  });
+
+  test('create command stores checkpoint and posts anchor notification', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth());
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'create "before refactor"',
+      },
+      output,
+    );
+
+    expect(output.parts).toHaveLength(0);
+    expect(ctx.client.session.prompt).toHaveBeenCalled();
+    const call = ctx.client.session.prompt.mock.calls[0]?.[0] as {
+      body?: { noReply?: boolean; parts?: Array<{ text?: string }> };
+    };
+    expect(call.body?.noReply).toBe(true);
+    expect(call.body?.parts?.[0]?.text).toContain(
+      'Checkpoint saved: before refactor',
+    );
+
+    const raw = await fs.readFile(
+      `${dir}/.opencode/oh-my-opencode-slim/checkpoints.json`,
+      'utf8',
+    );
+    expect(raw).toContain('before refactor');
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+
+  test('list command shows saved checkpoints for current root session', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth());
+    await manager.tool.checkpoint.execute(
+      { action: 'create', label: 'before alpha' },
+      toolCtx('root-1'),
+    );
+
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'list',
+      },
+      output,
+    );
+
+    expect(output.parts[0]?.text).toContain('Checkpoint timeline');
+    expect(output.parts[0]?.text).toContain('before alpha');
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+
+  test('restore command calls upstream session.revert', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth());
+    await manager.tool.checkpoint.execute(
+      { action: 'create', label: 'before beta' },
+      toolCtx('root-1'),
+    );
+
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'restore before beta',
+      },
+      output,
+    );
+
+    expect(ctx.client.session.revert).toHaveBeenCalled();
+    const call = ctx.client.session.revert.mock.calls[0]?.[0] as {
+      path?: { id?: string };
+      body?: { messageID?: string };
+    };
+    expect(call.path?.id).toBe('root-1');
+    expect(call.body?.messageID).toBe('msg-checkpoint');
+    expect(output.parts[0]?.text).toContain(
+      'Reverted via OpenCode session revert',
+    );
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+
+  test('restore is blocked while child session is active', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth());
+    await manager.tool.checkpoint.execute(
+      { action: 'create', label: 'before gamma' },
+      toolCtx('root-1'),
+    );
+
+    await manager.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-1', parentID: 'root-1' },
+        },
+      },
+    });
+
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'restore before gamma',
+      },
+      output,
+    );
+
+    expect(ctx.client.session.revert).not.toHaveBeenCalled();
+    expect(output.parts[0]?.text).toContain(
+      'Cannot restore while child sessions are still active',
+    );
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+
+  test('idle child sessions still block restore until deletion', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth());
+    await manager.tool.checkpoint.execute(
+      { action: 'create', label: 'before delta' },
+      toolCtx('root-1'),
+    );
+
+    await manager.handleEvent({
+      event: {
+        type: 'session.created',
+        properties: {
+          info: { id: 'child-1', parentID: 'root-1' },
+        },
+      },
+    });
+    await manager.handleEvent({
+      event: {
+        type: 'session.status',
+        properties: {
+          sessionID: 'child-1',
+          status: { type: 'idle' },
+        },
+      },
+    });
+
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'restore before delta',
+      },
+      output,
+    );
+
+    expect(ctx.client.session.revert).not.toHaveBeenCalled();
+    expect(output.parts[0]?.text).toContain(
+      'Cannot restore while child sessions are still active',
+    );
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+
+  test('list marks the most recently restored checkpoint as current', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth());
+    await manager.tool.checkpoint.execute(
+      { action: 'create', label: 'before one' },
+      toolCtx('root-1'),
+    );
+    await manager.tool.checkpoint.execute(
+      { action: 'create', label: 'before two' },
+      toolCtx('root-1'),
+    );
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'restore before one',
+      },
+      { parts: [] },
+    );
+
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'root-1',
+        arguments: 'list',
+      },
+      output,
+    );
+
+    expect(output.parts[0]?.text).toContain('before one      [current]');
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+
+  test('subagent sessions are rejected', async () => {
+    const dir = await fs.mkdtemp('/tmp/checkpoint-test-');
+    const ctx = createMockContext({ directory: dir });
+    const manager = createCheckpointManager(ctx, createDepth(1));
+    const output = { parts: [] as Array<{ type: string; text?: string }> };
+
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'checkpoint',
+        sessionID: 'child-1',
+        arguments: 'create nope',
+      },
+      output,
+    );
+
+    expect(output.parts[0]?.text).toContain('root orchestrator session');
+    expect(ctx.client.session.prompt).not.toHaveBeenCalled();
+    await fs.rm(dir, { recursive: true, force: true });
+  });
+});

+ 545 - 0
src/checkpoint/manager.ts

@@ -0,0 +1,545 @@
+import type { PluginInput, ToolDefinition } from '@opencode-ai/plugin';
+import { tool } from '@opencode-ai/plugin/tool';
+import { log } from '../utils';
+import type { SubagentDepthTracker } from '../utils/subagent-depth';
+import {
+  appendCheckpoint,
+  deleteCheckpoint,
+  loadCheckpointState,
+  replaceCheckpoint,
+} from './store';
+import type { CheckpointRecord, CheckpointSource } from './types';
+
+const COMMAND_NAME = 'checkpoint';
+const NO_ACTION_NEEDED_TEXT = '[system status: no action needed]';
+const z = tool.schema;
+
+function stripQuotes(text: string): string {
+  if (
+    (text.startsWith('"') && text.endsWith('"')) ||
+    (text.startsWith("'") && text.endsWith("'"))
+  ) {
+    return text.slice(1, -1).trim();
+  }
+  return text.trim();
+}
+
+function makeId(): string {
+  return `cp-${Math.random().toString(36).slice(2, 7)}`;
+}
+
+function fmtTime(iso: string): string {
+  return new Date(iso).toLocaleTimeString([], {
+    hour: 'numeric',
+    minute: '2-digit',
+  });
+}
+
+function fmtCheckpoint(item: CheckpointRecord, current = false): string {
+  const mark = current ? '      [current]' : '';
+  return [
+    `• ${item.id}  ${item.label}${mark}`,
+    `  ${item.source} • ${fmtTime(item.createdAt)}`,
+  ].join('\n');
+}
+
+function currentId(items: CheckpointRecord[]): string | null {
+  const restored = items
+    .filter((item) => item.restoredAt)
+    .sort(
+      (a, b) =>
+        new Date(b.restoredAt ?? '').getTime() -
+        new Date(a.restoredAt ?? '').getTime(),
+    )[0];
+
+  if (restored) {
+    return restored.id;
+  }
+
+  return items[0]?.id ?? null;
+}
+
+function usage(): string {
+  return [
+    'Checkpoint commands:',
+    '/checkpoint create <label>',
+    '/checkpoint list',
+    '/checkpoint show <id|label>',
+    '/checkpoint restore <id|label>',
+    '/checkpoint drop <id|label>',
+  ].join('\n');
+}
+
+function parseArgs(
+  raw: string,
+):
+  | { action: 'create' | 'list' | 'show' | 'restore' | 'drop'; value?: string }
+  | { action: 'help' } {
+  const text = raw.trim();
+  if (!text) {
+    return { action: 'help' };
+  }
+
+  const [head, ...rest] = text.split(/\s+/);
+  if (head === 'list') {
+    return { action: 'list' };
+  }
+  if (
+    head === 'create' ||
+    head === 'show' ||
+    head === 'restore' ||
+    head === 'drop'
+  ) {
+    return {
+      action: head,
+      value: stripQuotes(rest.join(' ')),
+    };
+  }
+  return {
+    action: 'create',
+    value: stripQuotes(text),
+  };
+}
+
+function isRoot(depth: SubagentDepthTracker, sessionID: string): boolean {
+  return depth.getDepth(sessionID) === 0;
+}
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+  return typeof value === 'object' && value !== null;
+}
+
+function promptMessageId(value: unknown): string | undefined {
+  if (!isRecord(value)) {
+    return undefined;
+  }
+  const direct = value.info;
+  if (isRecord(direct) && typeof direct.id === 'string') {
+    return direct.id;
+  }
+  const data = value.data;
+  if (!isRecord(data)) {
+    return undefined;
+  }
+  const info = data.info;
+  if (isRecord(info) && typeof info.id === 'string') {
+    return info.id;
+  }
+  return undefined;
+}
+
+export function createCheckpointManager(
+  ctx: PluginInput,
+  depth: SubagentDepthTracker,
+): {
+  tool: { checkpoint: ToolDefinition };
+  registerCommand: (config: Record<string, unknown>) => void;
+  handleCommandExecuteBefore: (
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ) => Promise<void>;
+  handleEvent: (input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }) => Promise<void>;
+} {
+  const childByParent = new Map<string, Set<string>>();
+  const parentByChild = new Map<string, string>();
+
+  function activeChildren(root: string): string[] {
+    return [...(childByParent.get(root) ?? new Set())];
+  }
+
+  function ensureRootSession(sessionID: string): void {
+    if (!isRoot(depth, sessionID)) {
+      throw new Error(
+        'Checkpoints are only available from the root orchestrator session.',
+      );
+    }
+  }
+
+  async function list(rootSessionID: string): Promise<CheckpointRecord[]> {
+    const state = await loadCheckpointState(ctx.directory);
+    return state.checkpoints
+      .filter((item) => item.rootSessionID === rootSessionID)
+      .sort(
+        (a, b) =>
+          new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+      );
+  }
+
+  async function resolve(
+    rootSessionID: string,
+    ref: string,
+  ): Promise<CheckpointRecord | null> {
+    const items = await list(rootSessionID);
+    return items.find((item) => item.id === ref || item.label === ref) ?? null;
+  }
+
+  async function anchor(
+    sessionID: string,
+    id: string,
+    label: string,
+    source: CheckpointSource,
+  ): Promise<string> {
+    const icon = source === 'auto' ? '📸' : '📍';
+    const status =
+      source === 'auto' ? 'Auto-checkpoint saved' : 'Checkpoint saved';
+    const result = await ctx.client.session.prompt({
+      path: { id: sessionID },
+      body: {
+        noReply: true,
+        parts: [
+          {
+            type: 'text',
+            text: [
+              `${icon} ${status}: ${label}`,
+              `ID: ${id} • ${source} • ${fmtTime(new Date().toISOString())}`,
+              '',
+              NO_ACTION_NEEDED_TEXT,
+            ].join('\n'),
+          },
+        ],
+      },
+    });
+    const idValue = promptMessageId(result);
+    if (!idValue) {
+      throw new Error('Checkpoint anchor message was created without an id.');
+    }
+    return idValue;
+  }
+
+  async function create(input: {
+    sessionID: string;
+    label: string;
+    source: CheckpointSource;
+    reason?: string;
+  }): Promise<CheckpointRecord> {
+    ensureRootSession(input.sessionID);
+    const label = stripQuotes(input.label);
+    if (!label) {
+      throw new Error('Checkpoint label is required.');
+    }
+
+    const id = makeId();
+    const createdAt = new Date().toISOString();
+    const messageID = await anchor(input.sessionID, id, label, input.source);
+    const record: CheckpointRecord = {
+      id,
+      label,
+      createdAt,
+      source: input.source,
+      reason: input.reason,
+      sessionID: input.sessionID,
+      rootSessionID: input.sessionID,
+      messageID,
+    };
+    await appendCheckpoint(ctx.directory, record);
+    return record;
+  }
+
+  async function restore(input: {
+    sessionID: string;
+    ref: string;
+  }): Promise<CheckpointRecord> {
+    ensureRootSession(input.sessionID);
+    const children = activeChildren(input.sessionID);
+    if (children.length > 0) {
+      throw new Error(
+        `Cannot restore while child sessions are still active: ${children.join(', ')}`,
+      );
+    }
+
+    const item = await resolve(input.sessionID, input.ref);
+    if (!item) {
+      throw new Error(`Checkpoint not found: ${input.ref}`);
+    }
+    await ctx.client.session.revert({
+      path: { id: item.sessionID },
+      body: {
+        messageID: item.messageID,
+        ...(item.partID ? { partID: item.partID } : {}),
+      },
+    });
+    const next = {
+      ...item,
+      restoredAt: new Date().toISOString(),
+    };
+    await replaceCheckpoint(ctx.directory, next);
+    return next;
+  }
+
+  async function drop(input: {
+    sessionID: string;
+    ref: string;
+  }): Promise<CheckpointRecord> {
+    ensureRootSession(input.sessionID);
+    const item = await resolve(input.sessionID, input.ref);
+    if (!item) {
+      throw new Error(`Checkpoint not found: ${input.ref}`);
+    }
+    await deleteCheckpoint(ctx.directory, item.id);
+    return item;
+  }
+
+  function registerCommand(opencodeConfig: Record<string, unknown>): void {
+    const configCommand = opencodeConfig.command as
+      | Record<string, unknown>
+      | undefined;
+    if (!configCommand?.[COMMAND_NAME]) {
+      if (!opencodeConfig.command) {
+        opencodeConfig.command = {};
+      }
+      (opencodeConfig.command as Record<string, unknown>)[COMMAND_NAME] = {
+        template: 'Manage named checkpoints for the current root session',
+        description:
+          'Create, list, show, restore, and delete named rollback checkpoints',
+      };
+    }
+  }
+
+  const checkpoint: ToolDefinition = tool({
+    description:
+      'Manage named checkpoints for the current root orchestrator session. Creates visible checkpoint anchors and restores via OpenCode session revert.',
+    args: {
+      action: z.enum(['create', 'list', 'show', 'restore', 'drop', 'delete']),
+      id: z.string().optional(),
+      label: z.string().optional(),
+      reason: z.string().optional(),
+    },
+    async execute(args, toolContext) {
+      if (
+        !toolContext ||
+        typeof toolContext !== 'object' ||
+        !('sessionID' in toolContext)
+      ) {
+        throw new Error('Invalid toolContext: missing sessionID');
+      }
+
+      const sessionID = (toolContext as { sessionID: string }).sessionID;
+      const agent = (toolContext as { agent?: string }).agent;
+      if (agent && agent !== 'orchestrator') {
+        throw new Error(
+          `Checkpoint tool is only available to the orchestrator. Current agent: ${agent}`,
+        );
+      }
+
+      if (args.action === 'create') {
+        const record = await create({
+          sessionID,
+          label: args.label ?? args.reason ?? 'checkpoint',
+          source: 'auto',
+          reason: args.reason,
+        });
+        return `Checkpoint saved: ${record.label} (${record.id})`;
+      }
+
+      if (args.action === 'list') {
+        const items = await list(sessionID);
+        if (items.length === 0) {
+          return 'No checkpoints saved for this session.';
+        }
+        const current = currentId(items);
+        return [
+          'Checkpoint timeline',
+          '',
+          ...items.map((item, index) =>
+            fmtCheckpoint(
+              item,
+              item.id === current || (!current && index === 0),
+            ),
+          ),
+        ].join('\n');
+      }
+
+      const ref = args.id ?? args.label;
+      if (!ref) {
+        throw new Error('Checkpoint id or label is required for this action.');
+      }
+
+      if (args.action === 'show') {
+        const item = await resolve(sessionID, ref);
+        if (!item) {
+          throw new Error(`Checkpoint not found: ${ref}`);
+        }
+        return [
+          `Checkpoint: ${item.label}`,
+          `ID: ${item.id}`,
+          `Created: ${fmtTime(item.createdAt)}`,
+          `Source: ${item.source}`,
+          `Session: ${item.rootSessionID}`,
+          `Target: message ${item.messageID}`,
+          ...(item.reason ? [`Reason: ${item.reason}`] : []),
+        ].join('\n');
+      }
+
+      if (args.action === 'restore') {
+        const item = await restore({ sessionID, ref });
+        return `Restored checkpoint: ${item.label} (${item.id})`;
+      }
+
+      const item = await drop({ sessionID, ref });
+      return `Dropped checkpoint: ${item.label} (${item.id})`;
+    },
+  });
+
+  async function handleCommandExecuteBefore(
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ): Promise<void> {
+    if (input.command !== COMMAND_NAME) {
+      return;
+    }
+
+    output.parts.length = 0;
+
+    try {
+      const parsed = parseArgs(input.arguments);
+      if (parsed.action === 'help') {
+        output.parts.push({ type: 'text', text: usage() });
+        return;
+      }
+
+      if (parsed.action === 'create') {
+        await create({
+          sessionID: input.sessionID,
+          label: parsed.value ?? '',
+          source: 'manual',
+        });
+        return;
+      }
+
+      if (parsed.action === 'list') {
+        const items = await list(input.sessionID);
+        const current = currentId(items);
+        output.parts.push({
+          type: 'text',
+          text:
+            items.length === 0
+              ? 'No checkpoints saved for this session.'
+              : [
+                  'Checkpoint timeline',
+                  '',
+                  ...items.map((item, index) =>
+                    fmtCheckpoint(
+                      item,
+                      item.id === current || (!current && index === 0),
+                    ),
+                  ),
+                ].join('\n'),
+        });
+        return;
+      }
+
+      const ref = parsed.value?.trim();
+      if (!ref) {
+        output.parts.push({ type: 'text', text: usage() });
+        return;
+      }
+
+      if (parsed.action === 'show') {
+        const item = await resolve(input.sessionID, ref);
+        if (!item) {
+          throw new Error(`Checkpoint not found: ${ref}`);
+        }
+        output.parts.push({
+          type: 'text',
+          text: [
+            `Checkpoint: ${item.label}`,
+            `ID: ${item.id}`,
+            `Created: ${fmtTime(item.createdAt)}`,
+            `Source: ${item.source}`,
+            `Session: ${item.rootSessionID}`,
+            `Target: message ${item.messageID}`,
+            ...(item.reason ? [`Reason: ${item.reason}`] : []),
+          ].join('\n'),
+        });
+        return;
+      }
+
+      if (parsed.action === 'restore') {
+        const item = await restore({ sessionID: input.sessionID, ref });
+        output.parts.push({
+          type: 'text',
+          text: [
+            `🔄 Restoring checkpoint: ${item.label}`,
+            '✓ Reverted via OpenCode session revert',
+          ].join('\n'),
+        });
+        return;
+      }
+
+      const item = await drop({ sessionID: input.sessionID, ref });
+      output.parts.push({
+        type: 'text',
+        text: `Dropped checkpoint: ${item.label} (${item.id})`,
+      });
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      log('[checkpoint] command failed', {
+        sessionID: input.sessionID,
+        command: input.arguments,
+        error: msg,
+      });
+      output.parts.push({
+        type: 'text',
+        text: `Checkpoint error: ${msg}`,
+      });
+    }
+  }
+
+  async function handleEvent(input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }): Promise<void> {
+    const event = input.event;
+    if (event.type === 'session.created') {
+      const props = event.properties;
+      const info = isRecord(props?.info) ? props?.info : undefined;
+      const child = typeof info?.id === 'string' ? info.id : undefined;
+      const parent =
+        typeof info?.parentID === 'string' ? info.parentID : undefined;
+      if (!child || !parent) {
+        return;
+      }
+      parentByChild.set(child, parent);
+      const set = childByParent.get(parent) ?? new Set<string>();
+      set.add(child);
+      childByParent.set(parent, set);
+      return;
+    }
+
+    if (event.type !== 'session.deleted') {
+      return;
+    }
+
+    const props = event.properties;
+    const sessionID =
+      (isRecord(props?.info) && typeof props.info.id === 'string'
+        ? props.info.id
+        : undefined) ??
+      (typeof props?.sessionID === 'string' ? props.sessionID : undefined);
+    if (!sessionID) {
+      return;
+    }
+
+    const parent = parentByChild.get(sessionID);
+    if (!parent) {
+      return;
+    }
+    parentByChild.delete(sessionID);
+    const set = childByParent.get(parent);
+    if (!set) {
+      return;
+    }
+    set.delete(sessionID);
+    if (set.size === 0) {
+      childByParent.delete(parent);
+    }
+  }
+
+  return {
+    tool: { checkpoint },
+    registerCommand,
+    handleCommandExecuteBefore,
+    handleEvent,
+  };
+}

+ 86 - 0
src/checkpoint/store.ts

@@ -0,0 +1,86 @@
+import * as fs from 'node:fs/promises';
+import path from 'node:path';
+import type { CheckpointRecord, CheckpointState } from './types';
+
+const VERSION = 1 as const;
+
+function empty(): CheckpointState {
+  return {
+    version: VERSION,
+    checkpoints: [],
+  };
+}
+
+export function resolveCheckpointFile(dir: string): string {
+  return path.join(dir, '.opencode', 'oh-my-opencode-slim', 'checkpoints.json');
+}
+
+async function ensureParent(file: string): Promise<void> {
+  await fs.mkdir(path.dirname(file), { recursive: true });
+}
+
+export async function loadCheckpointState(
+  dir: string,
+): Promise<CheckpointState> {
+  const file = resolveCheckpointFile(dir);
+
+  try {
+    const raw = await fs.readFile(file, 'utf8');
+    const parsed = JSON.parse(raw) as Partial<CheckpointState>;
+    if (parsed.version !== VERSION || !Array.isArray(parsed.checkpoints)) {
+      return empty();
+    }
+    return {
+      version: VERSION,
+      checkpoints: parsed.checkpoints,
+    };
+  } catch (err) {
+    if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
+      return empty();
+    }
+    throw err;
+  }
+}
+
+export async function saveCheckpointState(
+  dir: string,
+  state: CheckpointState,
+): Promise<void> {
+  const file = resolveCheckpointFile(dir);
+  await ensureParent(file);
+  await fs.writeFile(file, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
+}
+
+export async function appendCheckpoint(
+  dir: string,
+  record: CheckpointRecord,
+): Promise<void> {
+  const state = await loadCheckpointState(dir);
+  state.checkpoints.push(record);
+  await saveCheckpointState(dir, state);
+}
+
+export async function replaceCheckpoint(
+  dir: string,
+  record: CheckpointRecord,
+): Promise<void> {
+  const state = await loadCheckpointState(dir);
+  state.checkpoints = state.checkpoints.map((item) =>
+    item.id === record.id ? record : item,
+  );
+  await saveCheckpointState(dir, state);
+}
+
+export async function deleteCheckpoint(
+  dir: string,
+  id: string,
+): Promise<boolean> {
+  const state = await loadCheckpointState(dir);
+  const next = state.checkpoints.filter((item) => item.id !== id);
+  if (next.length === state.checkpoints.length) {
+    return false;
+  }
+  state.checkpoints = next;
+  await saveCheckpointState(dir, state);
+  return true;
+}

+ 33 - 0
src/checkpoint/types.ts

@@ -0,0 +1,33 @@
+export type CheckpointSource = 'manual' | 'auto';
+
+export interface CheckpointRecord {
+  id: string;
+  label: string;
+  createdAt: string;
+  source: CheckpointSource;
+  reason?: string;
+  sessionID: string;
+  rootSessionID: string;
+  messageID: string;
+  partID?: string;
+  restoredAt?: string;
+}
+
+export interface CheckpointState {
+  version: 1;
+  checkpoints: CheckpointRecord[];
+}
+
+export interface SessionMessagePart {
+  id?: string;
+  type?: string;
+  text?: string;
+}
+
+export interface SessionMessage {
+  info?: {
+    id?: string;
+    role?: string;
+  };
+  parts?: SessionMessagePart[];
+}

+ 20 - 0
src/index.ts

@@ -1,6 +1,7 @@
 import type { Plugin } from '@opencode-ai/plugin';
 import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
+import { createCheckpointManager } from './checkpoint';
 import { loadPluginConfig, type MultiplexerConfig } from './config';
 import { parseList } from './config/agent-mcps';
 import { CouncilManager } from './council';
@@ -109,6 +110,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let foregroundFallback: ForegroundFallbackManager;
   let todoContinuationHook: ReturnType<typeof createTodoContinuationHook>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
+  let checkpointManager: ReturnType<typeof createCheckpointManager>;
   let councilTools: Record<string, unknown>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
 
@@ -249,9 +251,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
     });
     interviewManager = createInterviewManager(ctx, config);
+    checkpointManager = createCheckpointManager(ctx, depthTracker);
 
     toolCount =
       Object.keys(councilTools).length +
+      Object.keys(checkpointManager.tool).length +
       Object.keys(todoContinuationHook.tool).length +
       1 + // webfetch
       2; // ast_grep_search, ast_grep_replace
@@ -319,6 +323,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     tool: {
       ...councilTools,
       webfetch,
+      ...checkpointManager.tool,
       ...todoContinuationHook.tool,
       ast_grep_search,
       ast_grep_replace,
@@ -520,6 +525,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       }
 
       interviewManager.registerCommand(opencodeConfig);
+      checkpointManager.registerCommand(opencodeConfig);
     },
 
     event: async (input) => {
@@ -563,6 +569,11 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           event: { type: string; properties?: Record<string, unknown> };
         },
       );
+      await checkpointManager.handleEvent(
+        input as {
+          event: { type: string; properties?: Record<string, unknown> };
+        },
+      );
 
       await postFileToolNudgeHook.event(
         input as {
@@ -625,6 +636,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { parts: Array<{ type: string; text?: string }> },
       );
+
+      await checkpointManager.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
     },
 
     'chat.headers': chatHeadersHook['chat.headers'],

+ 3 - 1
src/interview/interview.test.ts

@@ -1618,7 +1618,9 @@ describe('renderInterviewPage', () => {
     expect(html).toContain(
       "window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });",
     );
-    expect(html).toContain('overlayText.textContent = "Submitting Answers...";');
+    expect(html).toContain(
+      'overlayText.textContent = "Submitting Answers...";',
+    );
     expect(html).toContain('scrollToTop();');
   });
 });