Browse Source

feat: add session-scoped autopilot todo continuation

Introduce keyword-driven autopilot mode that continues remaining todos on idle and auto-disables when work is done, with config knobs and docs.
Alvin Unreal 5 months ago
parent
commit
7a7650e3e5
8 changed files with 478 additions and 9 deletions
  1. 1 7
      README.md
  2. 47 0
      docs/autopilot.md
  3. 1 0
      src/config/loader.ts
  4. 11 0
      src/config/schema.ts
  5. 156 0
      src/hooks/autopilot/index.test.ts
  6. 233 0
      src/hooks/autopilot/index.ts
  7. 1 0
      src/hooks/index.ts
  8. 28 2
      src/index.ts

+ 1 - 7
README.md

@@ -45,13 +45,6 @@ Paste this into any coding agent:
 Install and configure by following the instructions here:
 https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/refs/heads/master/README.md
 ```
-
-**Detailed installation guide:** [docs/installation.md](docs/installation.md)
-
-**Additional guides:**
-- **[Antigravity Setup](docs/antigravity.md)** - Complete guide for Antigravity provider configuration  
-- **[Tmux Integration](docs/tmux-integration.md)** - Real-time agent monitoring with tmux
-
 ---
 
 ## 🏛️ Meet the Pantheon
@@ -249,6 +242,7 @@ https://raw.githubusercontent.com/alvinunreal/oh-my-opencode-slim/refs/heads/mas
 - **[Cartography Skill](docs/cartography.md)** - Custom skill for repository mapping + codemap generation
 - **[Antigravity Setup](docs/antigravity.md)** - Complete guide for Antigravity provider configuration
 - **[Tmux Integration](docs/tmux-integration.md)** - Real-time agent monitoring with tmux
+- **[Autopilot Todo Continuation](docs/autopilot.md)** - Session-scoped todo continuation mode via `autopilot`
 
 ---
 

+ 47 - 0
docs/autopilot.md

@@ -0,0 +1,47 @@
+# Autopilot Todo Continuation
+
+Autopilot is a session-scoped mode that keeps working through todos in the
+same session after the assistant goes idle.
+
+## How It Works
+
+1. Type `autopilot` in a user message to enable the mode for the current
+   session.
+2. When the session becomes idle, the plugin checks session todos.
+3. If any todo is still active (`pending` or `in_progress`), the plugin sends a
+   continuation prompt in the same session.
+4. If all todos are terminal (`completed`, `cancelled`, `done`), autopilot
+   turns off automatically for that session.
+
+Use `manual` to disable autopilot immediately for the current session.
+
+## Defaults
+
+- Keyword on: `autopilot`
+- Keyword off: `manual`
+- Cooldown between auto-continue kicks: `8000ms`
+- Maximum auto-continues per activation: `10`
+
+## Configuration
+
+Add this to `~/.config/opencode/oh-my-opencode-slim.jsonc` or
+`.opencode/oh-my-opencode-slim.jsonc`:
+
+```jsonc
+{
+  "autopilot": {
+    "enabled": true,
+    "keyword": "autopilot",
+    "disableKeyword": "manual",
+    "cooldownMs": 8000,
+    "maxAutoContinues": 10
+  }
+}
+```
+
+## Notes
+
+- Scope is per `sessionID` (not global across all sessions).
+- Autopilot only reacts on idle transitions, not every token/tool step.
+- If todo APIs are unavailable in the running OpenCode build, autopilot fails
+  safely and does nothing.

+ 1 - 0
src/config/loader.ts

@@ -160,6 +160,7 @@ export function loadPluginConfig(directory: string): PluginConfig {
       agents: deepMerge(config.agents, projectConfig.agents),
       tmux: deepMerge(config.tmux, projectConfig.tmux),
       fallback: deepMerge(config.fallback, projectConfig.fallback),
+      autopilot: deepMerge(config.autopilot, projectConfig.autopilot),
     };
   }
 

+ 11 - 0
src/config/schema.ts

@@ -131,6 +131,16 @@ export const FailoverConfigSchema = z.object({
 
 export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 
+export const AutopilotConfigSchema = z.object({
+  enabled: z.boolean().default(true),
+  keyword: z.string().min(1).default('autopilot'),
+  disableKeyword: z.string().min(1).default('manual'),
+  cooldownMs: z.number().min(0).default(8000),
+  maxAutoContinues: z.number().min(1).default(10),
+});
+
+export type AutopilotConfig = z.infer<typeof AutopilotConfigSchema>;
+
 // Main plugin config
 export const PluginConfigSchema = z.object({
   preset: z.string().optional(),
@@ -143,6 +153,7 @@ export const PluginConfigSchema = z.object({
   tmux: TmuxConfigSchema.optional(),
   background: BackgroundTaskConfigSchema.optional(),
   fallback: FailoverConfigSchema.optional(),
+  autopilot: AutopilotConfigSchema.optional(),
 });
 
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;

+ 156 - 0
src/hooks/autopilot/index.test.ts

@@ -0,0 +1,156 @@
+import { describe, expect, test } from 'bun:test';
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../../config';
+import { createAutopilotHook } from './index';
+
+function createHarness(todoSequence: unknown[], config?: PluginConfig) {
+  let todoCallCount = 0;
+  let promptCallCount = 0;
+
+  const todo = async () => {
+    const index = Math.min(todoCallCount, Math.max(todoSequence.length - 1, 0));
+    const data = todoSequence[index];
+    todoCallCount += 1;
+    return { data };
+  };
+
+  const prompt = async () => {
+    promptCallCount += 1;
+    return { data: {} };
+  };
+
+  const ctx = {
+    client: {
+      session: {
+        todo,
+        prompt,
+      },
+    },
+    directory: '/tmp/test',
+  } as unknown as PluginInput;
+
+  const hook = createAutopilotHook(ctx, (config ?? {}) as PluginConfig);
+
+  return {
+    hook,
+    getTodoCallCount: () => todoCallCount,
+    getPromptCallCount: () => promptCallCount,
+  };
+}
+
+function createMessages(text: string, sessionID = 's1') {
+  return {
+    messages: [
+      {
+        info: { role: 'user', agent: 'orchestrator', sessionID },
+        parts: [{ type: 'text', text }],
+      },
+    ],
+  };
+}
+
+describe('autopilot hook', () => {
+  test('enables on keyword and continues when todos are active', async () => {
+    const harness = createHarness([[{ status: 'pending' }]]);
+
+    await harness.hook['experimental.chat.messages.transform'](
+      {},
+      createMessages('autopilot'),
+    );
+
+    await harness.hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(harness.getPromptCallCount()).toBe(1);
+    expect(harness.getTodoCallCount()).toBe(1);
+  });
+
+  test('auto-disables when all todos are completed', async () => {
+    const harness = createHarness([
+      [{ status: 'completed' }],
+      [{ status: 'pending' }],
+    ]);
+
+    await harness.hook['experimental.chat.messages.transform'](
+      {},
+      createMessages('autopilot'),
+    );
+
+    await harness.hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+
+    await harness.hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(harness.getPromptCallCount()).toBe(0);
+    expect(harness.getTodoCallCount()).toBe(1);
+  });
+
+  test('disable keyword turns autopilot off for the session', async () => {
+    const harness = createHarness([[{ status: 'pending' }]]);
+
+    await harness.hook['experimental.chat.messages.transform'](
+      {},
+      createMessages('autopilot'),
+    );
+    await harness.hook['experimental.chat.messages.transform'](
+      {},
+      createMessages('manual'),
+    );
+
+    await harness.hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(harness.getPromptCallCount()).toBe(0);
+    expect(harness.getTodoCallCount()).toBe(0);
+  });
+
+  test('stops auto-continuing after maxAutoContinues', async () => {
+    const harness = createHarness([[{ status: 'pending' }]], {
+      autopilot: {
+        enabled: true,
+        keyword: 'autopilot',
+        disableKeyword: 'manual',
+        cooldownMs: 0,
+        maxAutoContinues: 1,
+      },
+    } as PluginConfig);
+
+    await harness.hook['experimental.chat.messages.transform'](
+      {},
+      createMessages('autopilot'),
+    );
+
+    await harness.hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+    await harness.hook.event({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+
+    expect(harness.getPromptCallCount()).toBe(1);
+    expect(harness.getTodoCallCount()).toBe(1);
+  });
+});

+ 233 - 0
src/hooks/autopilot/index.ts

@@ -0,0 +1,233 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import type { PluginConfig } from '../../config';
+
+const AUTOPILOT_CONTINUE_PROMPT =
+  '[AUTOPILOT] Continue working through remaining todos in this same session. Pick the next pending or in-progress todo and execute it now. Do not wait for a new user message. Stop only when all todos are completed or cancelled.';
+
+const TERMINAL_TODO_STATUSES = new Set(['completed', 'cancelled', 'done']);
+
+interface MessageInfo {
+  role: string;
+  agent?: string;
+  sessionID?: string;
+}
+
+interface MessagePart {
+  type: string;
+  text?: string;
+}
+
+interface MessageWithParts {
+  info: MessageInfo;
+  parts: MessagePart[];
+}
+
+interface SessionAutopilotState {
+  enabled: boolean;
+  autoContinueCount: number;
+  lastKickAt: number;
+}
+
+interface TodoEntry {
+  status?: string;
+}
+
+function escapeRegExp(value: string): string {
+  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+function hasKeyword(text: string, keyword: string): boolean {
+  if (!keyword.trim()) return false;
+  const escaped = escapeRegExp(keyword.trim());
+  const pattern = new RegExp(
+    `(^|[^a-zA-Z0-9_])${escaped}([^a-zA-Z0-9_]|$)`,
+    'i',
+  );
+  return pattern.test(text);
+}
+
+function extractTodoList(data: unknown): TodoEntry[] {
+  if (Array.isArray(data)) {
+    return data.filter((item): item is TodoEntry => typeof item === 'object');
+  }
+
+  if (!data || typeof data !== 'object') {
+    return [];
+  }
+
+  const record = data as { items?: unknown; todos?: unknown };
+  if (Array.isArray(record.items)) {
+    return record.items.filter(
+      (item): item is TodoEntry => typeof item === 'object',
+    );
+  }
+
+  if (Array.isArray(record.todos)) {
+    return record.todos.filter(
+      (item): item is TodoEntry => typeof item === 'object',
+    );
+  }
+
+  return [];
+}
+
+function isActiveTodo(todo: TodoEntry): boolean {
+  const status = todo.status?.toLowerCase();
+  if (!status) return true;
+  return !TERMINAL_TODO_STATUSES.has(status);
+}
+
+export function createAutopilotHook(ctx: PluginInput, config: PluginConfig) {
+  const autopilotConfig = config.autopilot;
+  const enabled = autopilotConfig?.enabled ?? true;
+  const keyword = autopilotConfig?.keyword ?? 'autopilot';
+  const disableKeyword = autopilotConfig?.disableKeyword ?? 'manual';
+  const cooldownMs = autopilotConfig?.cooldownMs ?? 8000;
+  const maxAutoContinues = autopilotConfig?.maxAutoContinues ?? 10;
+
+  const stateBySession = new Map<string, SessionAutopilotState>();
+
+  const getOrCreateState = (sessionID: string): SessionAutopilotState => {
+    const existing = stateBySession.get(sessionID);
+    if (existing) return existing;
+    const created: SessionAutopilotState = {
+      enabled: false,
+      autoContinueCount: 0,
+      lastKickAt: 0,
+    };
+    stateBySession.set(sessionID, created);
+    return created;
+  };
+
+  const maybeContinueSession = async (sessionID: string): Promise<void> => {
+    const current = stateBySession.get(sessionID);
+    if (!current?.enabled) return;
+
+    const now = Date.now();
+    if (now - current.lastKickAt < cooldownMs) return;
+
+    if (current.autoContinueCount >= maxAutoContinues) {
+      current.enabled = false;
+      return;
+    }
+
+    const todoFn = (
+      ctx.client.session as unknown as {
+        todo?: (args: { path: { id: string } }) => Promise<{ data?: unknown }>;
+      }
+    ).todo;
+
+    if (!todoFn) {
+      return;
+    }
+
+    const todoResult = await todoFn({ path: { id: sessionID } });
+    const todos = extractTodoList(todoResult.data);
+    const hasActiveTodos = todos.some(isActiveTodo);
+
+    if (!hasActiveTodos) {
+      current.enabled = false;
+      current.autoContinueCount = 0;
+      return;
+    }
+
+    await ctx.client.session.prompt({
+      path: { id: sessionID },
+      body: {
+        parts: [{ type: 'text', text: AUTOPILOT_CONTINUE_PROMPT }],
+      },
+    });
+
+    current.autoContinueCount += 1;
+    current.lastKickAt = now;
+  };
+
+  return {
+    'experimental.chat.messages.transform': async (
+      _input: Record<string, never>,
+      output: { messages: MessageWithParts[] },
+    ): Promise<void> => {
+      if (!enabled) return;
+
+      const { messages } = output;
+      if (messages.length === 0) return;
+
+      let lastUserMessage: MessageWithParts | undefined;
+      for (let i = messages.length - 1; i >= 0; i--) {
+        if (messages[i].info.role === 'user') {
+          lastUserMessage = messages[i];
+          break;
+        }
+      }
+
+      if (!lastUserMessage) return;
+
+      const agent = lastUserMessage.info.agent;
+      if (agent && agent !== 'orchestrator') return;
+
+      const sessionID = lastUserMessage.info.sessionID;
+      if (!sessionID) return;
+
+      const textPart = lastUserMessage.parts.find(
+        (part) => part.type === 'text' && typeof part.text === 'string',
+      );
+      if (!textPart?.text) return;
+
+      const text = textPart.text;
+      const shouldDisable = hasKeyword(text, disableKeyword);
+      const shouldEnable = hasKeyword(text, keyword);
+
+      if (!shouldDisable && !shouldEnable) return;
+
+      const state = getOrCreateState(sessionID);
+      if (shouldDisable) {
+        state.enabled = false;
+        state.autoContinueCount = 0;
+        return;
+      }
+
+      state.enabled = true;
+      state.autoContinueCount = 0;
+      state.lastKickAt = 0;
+    },
+
+    event: async (input: {
+      event: {
+        type: string;
+        properties?: {
+          sessionID?: string;
+          status?: { type?: string };
+          info?: { id?: string };
+        };
+      };
+    }): Promise<void> => {
+      if (!enabled) return;
+
+      const event = input.event;
+
+      if (event.type === 'session.deleted') {
+        const sessionID =
+          event.properties?.sessionID ?? event.properties?.info?.id;
+        if (sessionID) {
+          stateBySession.delete(sessionID);
+        }
+        return;
+      }
+
+      if (event.type !== 'session.status') return;
+
+      const sessionID = event.properties?.sessionID;
+      if (!sessionID) return;
+
+      if (event.properties?.status?.type !== 'idle') return;
+
+      try {
+        await maybeContinueSession(sessionID);
+      } catch {
+        // Fail safe: do not break plugin event loop if todo API/prompt fails.
+      }
+    },
+  };
+}
+
+export { AUTOPILOT_CONTINUE_PROMPT, extractTodoList, hasKeyword, isActiveTodo };

+ 1 - 0
src/hooks/index.ts

@@ -1,5 +1,6 @@
 export type { AutoUpdateCheckerOptions } from './auto-update-checker';
 export { createAutoUpdateCheckerHook } from './auto-update-checker';
+export { createAutopilotHook } from './autopilot';
 export { createDelegateTaskRetryHook } from './delegate-task-retry';
 export { createJsonErrorRecoveryHook } from './json-error-recovery';
 export { createPhaseReminderHook } from './phase-reminder';

+ 28 - 2
src/index.ts

@@ -4,6 +4,7 @@ import { BackgroundTaskManager, TmuxSessionManager } from './background';
 import { loadPluginConfig, type TmuxConfig } from './config';
 import { parseList } from './config/agent-mcps';
 import {
+  createAutopilotHook,
   createAutoUpdateCheckerHook,
   createDelegateTaskRetryHook,
   createJsonErrorRecoveryHook,
@@ -67,6 +68,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   // Initialize phase reminder hook for workflow compliance
   const phaseReminderHook = createPhaseReminderHook();
 
+  // Initialize session-scoped autopilot hook (keyword + todo continuation)
+  const autopilotHook = createAutopilotHook(ctx, config);
+
   // Initialize post-read nudge hook
   const postReadNudgeHook = createPostReadNudgeHook();
 
@@ -203,11 +207,33 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
           properties?: { sessionID?: string };
         },
       );
+
+      // Handle autopilot session state + continuation on idle
+      await autopilotHook.event(
+        input as {
+          event: {
+            type: string;
+            properties?: {
+              sessionID?: string;
+              status?: { type?: string };
+              info?: { id?: string };
+            };
+          };
+        },
+      );
     },
 
     // Inject phase reminder before sending to API (doesn't show in UI)
-    'experimental.chat.messages.transform':
-      phaseReminderHook['experimental.chat.messages.transform'],
+    'experimental.chat.messages.transform': async (input, output) => {
+      await phaseReminderHook['experimental.chat.messages.transform'](
+        input,
+        output,
+      );
+      await autopilotHook['experimental.chat.messages.transform'](
+        input,
+        output,
+      );
+    },
 
     // Post-tool hooks: retry guidance for delegation errors + post-read nudge
     'tool.execute.after': async (input, output) => {