Browse Source

add durable goal mode

Alvin Unreal 3 months ago
parent
commit
14f194ad35

+ 1 - 0
README.md

@@ -493,6 +493,7 @@ Use this section as a map: start with installation, then jump to features, confi
 | **[Multiplexer Integration](docs/multiplexer-integration.md)** | Watch agents work live in Tmux or Zellij panes |
 | **[Session Management](docs/session-management.md)** | Reuse recent child-agent sessions with short aliases instead of starting over |
 | **[Todo Continuation](docs/todo-continuation.md)** | Auto-continue orchestrator sessions with cooldowns and safety checks |
+| **[Goal](docs/goal.md)** | Durable-objective mode for long-running autonomous work |
 | **[Preset Switching](docs/preset-switching.md)** | Switch agent model presets at runtime with `/preset` |
 | **[Subtask](docs/subtask.md)** | Run a bounded child worker with `/subtask` and return a structured summary to the main session |
 | **[Codemap](docs/codemap.md)** | Generate hierarchical codemaps to understand large codebases faster |

+ 2 - 0
docs/configuration.md

@@ -140,6 +140,8 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `todoContinuation.cooldownMs` | integer | `3000` | Delay in ms before auto-continuing — gives user time to abort (0–30000) |
 | `todoContinuation.autoEnable` | boolean | `false` | Automatically enable auto-continue when session has enough todos |
 | `todoContinuation.autoEnableThreshold` | integer | `4` | Number of todos that triggers auto-enable (only used when `autoEnable` is true, 1–50) |
+| `goal.maxCycles` | integer | `10` | Max automatic goal continuations before blocking (1–50) |
+| `goal.cooldownMs` | integer | `3000` | Delay in ms before continuing a running goal (0–30000) |
 | `interview.maxQuestions` | integer | `2` | Max questions per interview round (1–10) |
 | `interview.outputFolder` | string | `"interview"` | Directory where interview markdown files are written (relative to project root) |
 | `interview.autoOpenBrowser` | boolean | `true` | Automatically open the interview UI in your default browser during interactive runs; suppressed in tests and CI |

+ 224 - 0
docs/goal.md

@@ -0,0 +1,224 @@
+# Goal
+
+`/goal` is a durable-objective mode for long-running work in
+oh-my-opencode-slim.
+
+It is meant for tasks where the orchestrator should keep working through a clear
+objective, maintain progress, validate the result, and stop when the goal is
+done without needing the user to steer every step.
+
+The first implementation focuses on one active goal per session, persisted goal
+state, prompt context, and guarded idle continuation.
+
+## When to use it
+
+Use `/goal` for work with a clear finish line:
+
+- implement a feature from a spec,
+- fix a bug and prove it with tests,
+- migrate code from one API or pattern to another,
+- refactor a subsystem while keeping checks green,
+- update docs to match changed behavior,
+- investigate an issue, patch it, and validate the fix.
+
+Avoid it for vague or high-risk work:
+
+- "improve the codebase",
+- open-ended product/design exploration,
+- destructive operations,
+- production deploys or credential handling,
+- tasks that need frequent human approval.
+
+## Command shape
+
+The command set is:
+
+```text
+/goal start <objective>
+/goal
+/goal status
+/goal pause
+/goal resume
+/goal complete [note]
+/goal block <reason>
+/goal clear
+/goal checkpoint <note>
+```
+
+Planned follow-up commands may include:
+
+```text
+/goal validate <command>
+/goal stop-condition <text>
+/goal list
+/goal export
+```
+
+Examples:
+
+```text
+/goal start Fix the tmux ghost pane issue. Stop when tests pass and no orphaned opencode attach processes remain.
+```
+
+```text
+/goal start Implement the preset-switching docs update. Stop when README.md and docs/configuration.md agree with the current command behavior.
+```
+
+## How it should work
+
+1. The user starts a goal with an objective and, ideally, a stopping condition.
+2. Slim persists the active goal for the current project/session.
+3. The orchestrator receives compact goal context in its system prompt.
+4. The orchestrator creates and maintains todos for the goal.
+5. Normal delegation still applies: Explorer scouts, Librarian researches,
+   Oracle reviews, Designer handles UI, and Fixer executes scoped changes.
+6. When the session goes idle and the goal is still running, Slim can safely
+   continue the orchestrator after a cooldown.
+7. The orchestrator validates through normal tools and marks the goal completed,
+   blocked, or paused.
+
+The feature should make long work feel supervised, not uncontrolled. Progress
+should be visible through status/checkpoints, and the user should always be able
+to pause or clear the goal.
+
+## Relationship to todo continuation
+
+`/auto-continue` is todo-based: it resumes the orchestrator when incomplete
+todos remain.
+
+`/goal` should be objective-based: it owns the durable user objective, lifecycle,
+checkpoints, stop condition, and validation expectations.
+
+The two features do not run competing continuation loops. When an active goal
+owns a session, todo continuation skips that session, including paused and
+blocked goals.
+
+## State model
+
+The implementation keeps one active goal per session and persists a compact
+record outside the repository by default.
+
+Suggested shape:
+
+```ts
+type GoalStatus =
+  | 'running'
+  | 'paused'
+  | 'blocked'
+  | 'completed'
+  | 'archived';
+
+interface GoalRecord {
+  version: 1;
+  id: string;
+  directory: string;
+  sessionID?: string;
+
+  objective: string;
+  stopCondition?: string;
+  validationCommands: string[];
+  artifacts: string[];
+
+  status: GoalStatus;
+
+  createdAt: string;
+  updatedAt: string;
+
+  maxCycles: number;
+  completedCycles: number;
+
+  checkpoints: GoalCheckpoint[];
+  lastError?: string;
+}
+```
+
+State should live in an XDG-style user data location rather than creating noisy
+files in every workspace. A later export command can write a Markdown summary
+when users want a shareable artifact.
+
+## Validation
+
+Slim should not secretly execute validation commands.
+
+Instead:
+
+- store validation commands on the goal,
+- inject them into the orchestrator's goal context,
+- let the orchestrator run them through normal OpenCode tool permissions,
+- optionally record observed results later.
+
+This preserves the normal permission model and keeps command execution visible.
+
+## Implementation
+
+The feature lives in:
+
+```text
+src/goal/
+  index.ts
+  manager.ts
+  store.ts
+  types.ts
+  prompts.ts
+  command.ts
+```
+
+It is wired through `src/index.ts`:
+
+- initialize the goal manager,
+- register `/goal`,
+- handle command execution,
+- inject compact goal context into orchestrator messages,
+- observe session lifecycle events,
+- coordinate with todo continuation to avoid double resumes.
+
+Current scope:
+
+- `/goal start/status/pause/resume/checkpoint/clear`,
+- `/goal complete` and `/goal block <reason>`,
+- durable JSON state,
+- one active goal per session,
+- compact prompt injection,
+- safe idle continuation with max-cycle limits,
+- manual status/checkpoint output,
+- docs and tests.
+
+Defer:
+
+- TUI/sidebar integration,
+- automatic validation execution,
+- multi-goal dependency graphs,
+- git checkpoints,
+- artifact registry,
+- automatic checkpoint summarization.
+
+## Safety gates
+
+Goal continuation should use strict guards similar to todo continuation:
+
+- current session is orchestrator-owned,
+- active goal status is `running`,
+- goal has not exceeded max cycles,
+- no pending continuation is already in flight,
+- session is not in a post-abort suppress window,
+- latest assistant message is not asking the user a question,
+- no conflicting `/auto-continue` loop owns the session.
+
+If the orchestrator is uncertain, blocked, or needs approval, it should mark the
+goal blocked or ask the user instead of continuing indefinitely.
+
+## Future version
+
+A fuller version can add a structured tool such as `goal_update` so the
+orchestrator can update status, checkpoints, validation results, and artifacts
+without relying on prose parsing.
+
+Possible later additions:
+
+- `/goal list` for cross-session resume,
+- automatic checkpoint cadence,
+- validation result capture from tool events,
+- TUI goal status,
+- Markdown export,
+- stale-session recovery,
+- optional review routing through Oracle before completion.

+ 19 - 0
oh-my-opencode-slim.schema.json

@@ -599,6 +599,25 @@
         }
       }
     },
+    "goal": {
+      "type": "object",
+      "properties": {
+        "maxCycles": {
+          "default": 10,
+          "description": "Maximum automatic goal continuations before blocking",
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 50
+        },
+        "cooldownMs": {
+          "default": 3000,
+          "description": "Delay in ms before continuing a running goal",
+          "type": "integer",
+          "minimum": 0,
+          "maximum": 30000
+        }
+      }
+    },
     "fallback": {
       "type": "object",
       "properties": {

+ 1 - 0
src/config/loader.ts

@@ -197,6 +197,7 @@ export function mergePluginConfigs(
     interview: deepMerge(base.interview, override.interview),
     sessionManager: deepMerge(base.sessionManager, override.sessionManager),
     divoom: deepMerge(base.divoom, override.divoom),
+    goal: deepMerge(base.goal, override.goal),
     fallback: deepMerge(base.fallback, override.fallback),
     council: deepMerge(base.council, override.council),
   };

+ 20 - 0
src/config/schema.ts

@@ -252,6 +252,25 @@ export type TodoContinuationConfig = z.infer<
   typeof TodoContinuationConfigSchema
 >;
 
+export const GoalConfigSchema = z.object({
+  maxCycles: z
+    .number()
+    .int()
+    .min(1)
+    .max(50)
+    .default(10)
+    .describe('Maximum automatic goal continuations before blocking'),
+  cooldownMs: z
+    .number()
+    .int()
+    .min(0)
+    .max(30_000)
+    .default(3000)
+    .describe('Delay in ms before continuing a running goal'),
+});
+
+export type GoalConfig = z.infer<typeof GoalConfigSchema>;
+
 export const FailoverConfigSchema = z.object({
   enabled: z.boolean().default(true),
   timeoutMs: z.number().min(0).default(15000),
@@ -335,6 +354,7 @@ export const PluginConfigSchema = z
     sessionManager: SessionManagerConfigSchema.optional(),
     divoom: DivoomConfigSchema.optional(),
     todoContinuation: TodoContinuationConfigSchema.optional(),
+    goal: GoalConfigSchema.optional(),
     fallback: FailoverConfigSchema.optional(),
     council: CouncilConfigSchema.optional(),
   })

+ 8 - 0
src/goal/index.ts

@@ -0,0 +1,8 @@
+export { createGoalManager, type GoalManager } from './manager';
+export { GoalStore, getGoalStorePath } from './store';
+export type {
+  GoalCheckpoint,
+  GoalConfig,
+  GoalRecord,
+  GoalStatus,
+} from './types';

+ 241 - 0
src/goal/manager.test.ts

@@ -0,0 +1,241 @@
+import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import { SLIM_INTERNAL_INITIATOR_MARKER } from '../utils';
+import { createGoalManager } from './manager';
+
+function createMockContext() {
+  return {
+    directory: '/tmp/project',
+    client: {
+      session: {
+        messages: mock(async () => ({ data: [] })),
+        prompt: mock(async () => ({})),
+      },
+    },
+  } as any;
+}
+
+function createOutput() {
+  return { parts: [] as Array<{ type: string; text?: string }> };
+}
+
+function outputText(output: ReturnType<typeof createOutput>): string {
+  return output.parts.map((part) => part.text ?? '').join('\n');
+}
+
+let previousXdgDataHome: string | undefined;
+let tempDir: string;
+
+beforeEach(() => {
+  previousXdgDataHome = process.env.XDG_DATA_HOME;
+  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'omos-goal-'));
+  process.env.XDG_DATA_HOME = tempDir;
+});
+
+afterEach(() => {
+  if (previousXdgDataHome === undefined) {
+    delete process.env.XDG_DATA_HOME;
+  } else {
+    process.env.XDG_DATA_HOME = previousXdgDataHome;
+  }
+  fs.rmSync(tempDir, { recursive: true, force: true });
+});
+
+describe('createGoalManager', () => {
+  test('registers /goal command', () => {
+    const manager = createGoalManager(createMockContext());
+    const config: Record<string, unknown> = {};
+
+    manager.registerCommand(config);
+
+    expect((config.command as Record<string, unknown>).goal).toBeDefined();
+  });
+
+  test('starts and reports a goal', async () => {
+    const manager = createGoalManager(createMockContext());
+    const startOutput = createOutput();
+
+    await manager.handleCommandExecuteBefore(
+      {
+        command: 'goal',
+        sessionID: 's1',
+        arguments: 'start Fix failing tests',
+      },
+      startOutput,
+    );
+
+    expect(outputText(startOutput)).toContain('Goal started');
+    expect(outputText(startOutput)).toContain('Fix failing tests');
+    expect(manager.hasRunningGoal('s1')).toBe(true);
+
+    const statusOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: '' },
+      statusOutput,
+    );
+
+    expect(outputText(statusOutput)).toContain('Status: running');
+    expect(outputText(statusOutput)).toContain('Fix failing tests');
+  });
+
+  test('pauses, resumes, and clears a goal', async () => {
+    const manager = createGoalManager(createMockContext());
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'start Ship docs' },
+      createOutput(),
+    );
+
+    const pauseOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'pause waiting' },
+      pauseOutput,
+    );
+    expect(outputText(pauseOutput)).toContain('Goal paused');
+    expect(manager.hasRunningGoal('s1')).toBe(false);
+
+    const resumeOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'resume' },
+      resumeOutput,
+    );
+    expect(outputText(resumeOutput)).toContain('continue working');
+    expect(manager.hasRunningGoal('s1')).toBe(true);
+
+    const clearOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'clear' },
+      clearOutput,
+    );
+    expect(outputText(clearOutput)).toContain('Goal cleared');
+    expect(manager.hasRunningGoal('s1')).toBe(false);
+  });
+
+  test('completes and blocks goals', async () => {
+    const manager = createGoalManager(createMockContext());
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'start Ship docs' },
+      createOutput(),
+    );
+
+    const completeOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'complete tests pass' },
+      completeOutput,
+    );
+    expect(outputText(completeOutput)).toContain('Goal completed');
+    expect(manager.hasActiveGoal('s1')).toBe(false);
+
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'start Fix bug' },
+      createOutput(),
+    );
+    const blockOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'block needs review' },
+      blockOutput,
+    );
+    expect(outputText(blockOutput)).toContain('Goal blocked');
+    expect(manager.hasActiveGoal('s1')).toBe(true);
+    expect(manager.hasRunningGoal('s1')).toBe(false);
+  });
+
+  test('resumes latest directory goal into current session', async () => {
+    const manager = createGoalManager(createMockContext());
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'old', arguments: 'start Resume me' },
+      createOutput(),
+    );
+
+    const resumeOutput = createOutput();
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'new', arguments: 'resume' },
+      resumeOutput,
+    );
+
+    expect(outputText(resumeOutput)).toContain('Resume me');
+    expect(manager.hasRunningGoal('new')).toBe(true);
+  });
+
+  test('injects goal context into orchestrator messages', async () => {
+    const manager = createGoalManager(createMockContext());
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'start Fix bug' },
+      createOutput(),
+    );
+
+    const output = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 's1' },
+          parts: [{ type: 'text', text: 'continue' }],
+        },
+      ],
+    };
+
+    await manager.handleMessagesTransform(output);
+
+    expect(output.messages[0].parts[0].text).toContain('<goal_context>');
+    expect(output.messages[0].parts[0].text).toContain('Fix bug');
+  });
+
+  test('does not inject goal context into internal prompts', async () => {
+    const manager = createGoalManager(createMockContext());
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'start Fix bug' },
+      createOutput(),
+    );
+
+    const output = {
+      messages: [
+        {
+          info: { role: 'user', agent: 'orchestrator', sessionID: 's1' },
+          parts: [{ type: 'text', text: SLIM_INTERNAL_INITIATOR_MARKER }],
+        },
+      ],
+    };
+
+    await manager.handleMessagesTransform(output);
+
+    expect(output.messages[0].parts[0].text).not.toContain('<goal_context>');
+  });
+
+  test('does not manage non-orchestrator sessions when gated', async () => {
+    const manager = createGoalManager(createMockContext(), {
+      shouldManageSession: (sessionID) => sessionID === 'orchestrator',
+    });
+    const output = createOutput();
+
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 'child', arguments: 'start no' },
+      output,
+    );
+
+    expect(outputText(output)).toContain('orchestrator session');
+    expect(manager.hasRunningGoal('child')).toBe(false);
+  });
+
+  test('continues a running goal on idle', async () => {
+    const ctx = createMockContext();
+    const manager = createGoalManager(ctx, { cooldownMs: 1, maxCycles: 2 });
+    await manager.handleCommandExecuteBefore(
+      { command: 'goal', sessionID: 's1', arguments: 'start Finish work' },
+      createOutput(),
+    );
+
+    await manager.handleEvent({
+      event: {
+        type: 'session.status',
+        properties: { sessionID: 's1', status: { type: 'idle' } },
+      },
+    });
+
+    await new Promise((resolve) => setTimeout(resolve, 10));
+
+    expect(ctx.client.session.prompt).toHaveBeenCalledTimes(1);
+    expect(
+      ctx.client.session.prompt.mock.calls[0][0].body.parts[0].text,
+    ).toContain('continue working');
+  });
+});

+ 529 - 0
src/goal/manager.ts

@@ -0,0 +1,529 @@
+import type { PluginInput } from '@opencode-ai/plugin';
+import {
+  createInternalAgentTextPart,
+  log,
+  SLIM_INTERNAL_INITIATOR_MARKER,
+} from '../utils';
+import {
+  buildGoalContext,
+  buildGoalContinuationPrompt,
+  buildGoalStartPrompt,
+} from './prompts';
+import { GoalStore } from './store';
+import type { GoalConfig, GoalRecord } from './types';
+
+const COMMAND_NAME = 'goal';
+const HOOK_NAME = 'goal';
+const SUPPRESS_AFTER_ABORT_MS = 5_000;
+const DEFAULT_MAX_CYCLES = 10;
+const DEFAULT_COOLDOWN_MS = 3_000;
+
+interface GoalRuntimeState {
+  pendingTimersBySession: Map<string, ReturnType<typeof setTimeout>>;
+  suppressUntilBySession: Map<string, number>;
+  isInjectingBySession: Set<string>;
+  orchestratorSessionIds: Set<string>;
+}
+
+interface MessagePart {
+  type?: string;
+  text?: string;
+  [key: string]: unknown;
+}
+
+interface ChatTransformMessage {
+  info: {
+    role?: string;
+    agent?: string;
+    sessionID?: string;
+  };
+  parts: MessagePart[];
+}
+
+interface Message {
+  info?: { role?: string };
+  parts?: MessagePart[];
+}
+
+function nowIso(): string {
+  return new Date().toISOString();
+}
+
+function createGoalId(): string {
+  return `goal-${Date.now().toString(36)}-${Math.random()
+    .toString(36)
+    .slice(2, 8)}`;
+}
+
+function isQuestion(text: string): boolean {
+  const lowerText = text.toLowerCase().trim();
+  return (
+    /\?\s*$/.test(lowerText) ||
+    lowerText.includes('should i') ||
+    lowerText.includes('do you want') ||
+    lowerText.includes('please review') ||
+    lowerText.includes('can you confirm') ||
+    lowerText.includes('let me know')
+  );
+}
+
+function commandName(input: string): string {
+  return input.replace(/^\//, '').trim().toLowerCase();
+}
+
+export function createGoalManager(ctx: PluginInput, config?: GoalConfig) {
+  const store = new GoalStore();
+  const maxCycles = config?.maxCycles ?? DEFAULT_MAX_CYCLES;
+  const cooldownMs = config?.cooldownMs ?? DEFAULT_COOLDOWN_MS;
+  const shouldManageSession = config?.shouldManageSession;
+  const state: GoalRuntimeState = {
+    pendingTimersBySession: new Map(),
+    suppressUntilBySession: new Map(),
+    isInjectingBySession: new Set(),
+    orchestratorSessionIds: new Set(),
+  };
+
+  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 a durable objective for long-running work',
+        description:
+          'Start, inspect, pause, resume, or clear a durable objective',
+      };
+    }
+  }
+
+  function activeGoal(sessionID: string): GoalRecord | undefined {
+    return store.findActiveBySession(sessionID);
+  }
+
+  function hasRunningGoal(sessionID: string): boolean {
+    return activeGoal(sessionID)?.status === 'running';
+  }
+
+  function hasActiveGoal(sessionID: string): boolean {
+    return activeGoal(sessionID) !== undefined;
+  }
+
+  function canManageSession(sessionID: string): boolean {
+    return shouldManageSession?.(sessionID) ?? true;
+  }
+
+  function cancelPendingTimer(sessionID?: string): void {
+    if (sessionID) {
+      const timer = state.pendingTimersBySession.get(sessionID);
+      if (!timer) return;
+      clearTimeout(timer);
+      state.pendingTimersBySession.delete(sessionID);
+      return;
+    }
+
+    for (const timer of state.pendingTimersBySession.values()) {
+      clearTimeout(timer);
+    }
+    state.pendingTimersBySession.clear();
+  }
+
+  async function startGoal(
+    input: { sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ): Promise<void> {
+    const objective = input.arguments.replace(/^start\s+/i, '').trim();
+    if (!canManageSession(input.sessionID)) {
+      output.parts.push(
+        createInternalAgentTextPart(
+          'Goal can only be started from an orchestrator session.',
+        ),
+      );
+      return;
+    }
+
+    if (!objective) {
+      output.parts.push(
+        createInternalAgentTextPart('Usage: /goal start <objective>'),
+      );
+      return;
+    }
+
+    const previous = activeGoal(input.sessionID);
+    if (previous) {
+      output.parts.push(
+        createInternalAgentTextPart(
+          `An active goal already exists: ${previous.objective}\nClear it before starting another goal.`,
+        ),
+      );
+      return;
+    }
+
+    const timestamp = nowIso();
+    const goal: GoalRecord = {
+      version: 1,
+      id: createGoalId(),
+      directory: ctx.directory,
+      sessionID: input.sessionID,
+      objective,
+      validationCommands: [],
+      artifacts: [],
+      status: 'running',
+      createdAt: timestamp,
+      updatedAt: timestamp,
+      maxCycles,
+      completedCycles: 0,
+      checkpoints: [
+        {
+          id: createGoalId(),
+          createdAt: timestamp,
+          note: 'Goal started',
+        },
+      ],
+    };
+    store.save(goal);
+    state.orchestratorSessionIds.add(input.sessionID);
+
+    output.parts.push(createInternalAgentTextPart(buildGoalStartPrompt(goal)));
+  }
+
+  function formatGoalStatus(goal: GoalRecord): string {
+    const checkpoints = goal.checkpoints.slice(-3);
+    return [
+      `Goal ${goal.id}`,
+      `Status: ${goal.status}`,
+      `Objective: ${goal.objective}`,
+      goal.stopCondition ? `Stop condition: ${goal.stopCondition}` : undefined,
+      `Cycles: ${goal.completedCycles}/${goal.maxCycles}`,
+      goal.validationCommands.length > 0
+        ? `Validation: ${goal.validationCommands.join(', ')}`
+        : undefined,
+      checkpoints.length > 0
+        ? `Recent checkpoints:\n${checkpoints
+            .map(
+              (checkpoint) => `- ${checkpoint.createdAt}: ${checkpoint.note}`,
+            )
+            .join('\n')}`
+        : undefined,
+    ]
+      .filter((part): part is string => typeof part === 'string')
+      .join('\n');
+  }
+
+  async function handleCommandExecuteBefore(
+    input: { command: string; sessionID: string; arguments: string },
+    output: { parts: Array<{ type: string; text?: string }> },
+  ): Promise<void> {
+    if (commandName(input.command) !== COMMAND_NAME) return;
+
+    state.orchestratorSessionIds.add(input.sessionID);
+    output.parts.length = 0;
+
+    const arg = input.arguments.trim();
+    if (!arg) {
+      const goal = activeGoal(input.sessionID);
+      output.parts.push(
+        createInternalAgentTextPart(
+          goal
+            ? formatGoalStatus(goal)
+            : 'No active goal for this session. Start one with /goal start <objective>.',
+        ),
+      );
+      return;
+    }
+
+    const [action = 'status'] = arg.split(/\s+/, 1);
+    const normalizedAction = action.toLowerCase();
+
+    if (normalizedAction === 'start') {
+      await startGoal(input, output);
+      return;
+    }
+
+    let goal = activeGoal(input.sessionID);
+    if (!goal) {
+      if (normalizedAction === 'resume') {
+        goal = store.findLatestByDirectory(ctx.directory);
+      }
+
+      if (!goal) {
+        output.parts.push(
+          createInternalAgentTextPart(
+            'No active goal for this session. Start one with /goal start <objective>.',
+          ),
+        );
+        return;
+      }
+    }
+
+    if (!canManageSession(input.sessionID)) {
+      output.parts.push(
+        createInternalAgentTextPart(
+          'Goal commands can only manage orchestrator sessions.',
+        ),
+      );
+      return;
+    }
+
+    if (normalizedAction === 'status' || normalizedAction === 'goal') {
+      output.parts.push(createInternalAgentTextPart(formatGoalStatus(goal)));
+      return;
+    }
+
+    if (normalizedAction === 'pause') {
+      goal.status = 'paused';
+      goal.updatedAt = nowIso();
+      goal.checkpoints.push({
+        id: createGoalId(),
+        createdAt: goal.updatedAt,
+        note: arg.replace(/^pause\s*/i, '').trim() || 'Paused by user',
+      });
+      store.save(goal);
+      cancelPendingTimer(input.sessionID);
+      output.parts.push(createInternalAgentTextPart('Goal paused.'));
+      return;
+    }
+
+    if (normalizedAction === 'resume') {
+      goal.status = 'running';
+      goal.sessionID = input.sessionID;
+      goal.updatedAt = nowIso();
+      goal.checkpoints.push({
+        id: createGoalId(),
+        createdAt: goal.updatedAt,
+        note: 'Resumed by user',
+      });
+      store.save(goal);
+      output.parts.push(
+        createInternalAgentTextPart(buildGoalContinuationPrompt(goal)),
+      );
+      return;
+    }
+
+    if (normalizedAction === 'complete') {
+      goal.status = 'completed';
+      goal.updatedAt = nowIso();
+      goal.checkpoints.push({
+        id: createGoalId(),
+        createdAt: goal.updatedAt,
+        note: arg.replace(/^complete\s*/i, '').trim() || 'Completed by user',
+      });
+      store.save(goal);
+      cancelPendingTimer(input.sessionID);
+      output.parts.push(createInternalAgentTextPart('Goal completed.'));
+      return;
+    }
+
+    if (normalizedAction === 'block' || normalizedAction === 'blocked') {
+      const reason = arg.replace(/^block(?:ed)?\s*/i, '').trim();
+      goal.status = 'blocked';
+      goal.lastError = reason || 'Blocked by user';
+      goal.updatedAt = nowIso();
+      goal.checkpoints.push({
+        id: createGoalId(),
+        createdAt: goal.updatedAt,
+        note: goal.lastError,
+      });
+      store.save(goal);
+      cancelPendingTimer(input.sessionID);
+      output.parts.push(createInternalAgentTextPart('Goal blocked.'));
+      return;
+    }
+
+    if (normalizedAction === 'clear') {
+      goal.status = 'archived';
+      goal.updatedAt = nowIso();
+      store.save(goal);
+      cancelPendingTimer(input.sessionID);
+      output.parts.push(createInternalAgentTextPart('Goal cleared.'));
+      return;
+    }
+
+    if (normalizedAction === 'checkpoint') {
+      const note = arg.replace(/^checkpoint\s*/i, '').trim();
+      goal.updatedAt = nowIso();
+      goal.checkpoints.push({
+        id: createGoalId(),
+        createdAt: goal.updatedAt,
+        note: note || 'Manual checkpoint',
+      });
+      store.save(goal);
+      output.parts.push(createInternalAgentTextPart('Goal checkpoint saved.'));
+      return;
+    }
+
+    output.parts.push(
+      createInternalAgentTextPart(
+        'Usage: /goal start <objective> | /goal status | /goal pause | /goal resume | /goal complete | /goal block <reason> | /goal checkpoint <note> | /goal clear',
+      ),
+    );
+  }
+
+  async function handleMessagesTransform(output: {
+    messages: ChatTransformMessage[];
+  }): Promise<void> {
+    const latestUser = [...output.messages]
+      .reverse()
+      .find((message) => message.info.role === 'user');
+    if (!latestUser) return;
+
+    const sessionID = latestUser.info.sessionID;
+    if (!sessionID) return;
+    if (!canManageSession(sessionID)) return;
+    if (latestUser.info.agent && latestUser.info.agent !== 'orchestrator')
+      return;
+
+    const goal = activeGoal(sessionID);
+    if (!goal) return;
+
+    const textPart = [...latestUser.parts]
+      .reverse()
+      .find((part) => part.type === 'text' && typeof part.text === 'string');
+    if (!textPart) return;
+    if (textPart.text?.includes(SLIM_INTERNAL_INITIATOR_MARKER)) return;
+
+    const goalContext = buildGoalContext(goal);
+    if (textPart.text?.includes('<goal_context>')) return;
+    textPart.text = textPart.text
+      ? `${textPart.text.trimEnd()}\n\n${goalContext}`
+      : goalContext;
+  }
+
+  async function handleEvent(input: {
+    event: { type: string; properties?: Record<string, unknown> };
+  }): Promise<void> {
+    const { event } = input;
+    const properties = event.properties ?? {};
+
+    if (event.type === 'session.deleted') {
+      const sessionID =
+        (properties.info as { id?: string } | undefined)?.id ??
+        (properties.sessionID as string | undefined);
+      if (!sessionID) return;
+      cancelPendingTimer(sessionID);
+      state.orchestratorSessionIds.delete(sessionID);
+      state.isInjectingBySession.delete(sessionID);
+      state.suppressUntilBySession.delete(sessionID);
+      return;
+    }
+
+    if (event.type === 'session.error') {
+      const sessionID = properties.sessionID as string | undefined;
+      const error = properties.error as { name?: string } | undefined;
+      if (!sessionID) return;
+      cancelPendingTimer(sessionID);
+      if (
+        error?.name === 'MessageAbortedError' ||
+        error?.name === 'AbortError'
+      ) {
+        state.suppressUntilBySession.set(
+          sessionID,
+          Date.now() + SUPPRESS_AFTER_ABORT_MS,
+        );
+      }
+      return;
+    }
+
+    if (event.type === 'session.status') {
+      const status = properties.status as { type?: string } | undefined;
+      const sessionID = properties.sessionID as string | undefined;
+      if (status?.type === 'busy' && sessionID) {
+        cancelPendingTimer(sessionID);
+      }
+    }
+
+    const isIdle =
+      event.type === 'session.idle' ||
+      (event.type === 'session.status' &&
+        (properties.status as { type?: string } | undefined)?.type === 'idle');
+    if (!isIdle) return;
+
+    const sessionID = properties.sessionID as string | undefined;
+    if (!sessionID) return;
+    if (!canManageSession(sessionID)) return;
+
+    const goal = activeGoal(sessionID);
+    if (!goal || goal.status !== 'running') return;
+
+    if (goal.completedCycles >= goal.maxCycles) {
+      goal.status = 'blocked';
+      goal.lastError = 'Goal reached max continuation cycles';
+      goal.updatedAt = nowIso();
+      store.save(goal);
+      return;
+    }
+
+    if ((state.suppressUntilBySession.get(sessionID) ?? 0) > Date.now()) return;
+    if (
+      state.pendingTimersBySession.has(sessionID) ||
+      state.isInjectingBySession.has(sessionID)
+    )
+      return;
+
+    try {
+      const messagesResult = await ctx.client.session.messages({
+        path: { id: sessionID },
+      });
+      const messages = messagesResult.data as Message[];
+      const lastAssistant = messages
+        .slice()
+        .reverse()
+        .find((message) => message.info?.role === 'assistant');
+      const text = lastAssistant?.parts
+        ?.map((part) => part.text ?? '')
+        .join(' ');
+      if (text && isQuestion(text)) return;
+    } catch (error) {
+      log(`[${HOOK_NAME}] failed to fetch messages`, {
+        sessionID,
+        error: error instanceof Error ? error.message : String(error),
+      });
+      return;
+    }
+
+    const timer = setTimeout(async () => {
+      state.pendingTimersBySession.delete(sessionID);
+
+      const latestGoal = activeGoal(sessionID);
+      if (!latestGoal || latestGoal.status !== 'running') return;
+
+      state.isInjectingBySession.add(sessionID);
+      try {
+        await ctx.client.session.prompt({
+          path: { id: sessionID },
+          body: {
+            parts: [
+              createInternalAgentTextPart(
+                buildGoalContinuationPrompt(latestGoal),
+              ),
+            ],
+          },
+        });
+        latestGoal.completedCycles++;
+        latestGoal.updatedAt = nowIso();
+        store.save(latestGoal);
+      } catch (error) {
+        log(`[${HOOK_NAME}] failed to inject continuation`, {
+          sessionID,
+          error: error instanceof Error ? error.message : String(error),
+        });
+      } finally {
+        state.isInjectingBySession.delete(sessionID);
+      }
+    }, cooldownMs);
+    state.pendingTimersBySession.set(sessionID, timer);
+  }
+
+  return {
+    registerCommand,
+    handleCommandExecuteBefore,
+    handleMessagesTransform,
+    handleEvent,
+    hasActiveGoal,
+    hasRunningGoal,
+  };
+}
+
+export type GoalManager = ReturnType<typeof createGoalManager>;

+ 53 - 0
src/goal/prompts.ts

@@ -0,0 +1,53 @@
+import type { GoalRecord } from './types';
+
+export function buildGoalContext(goal: GoalRecord): string {
+  const checkpoints = goal.checkpoints.slice(-3).map((checkpoint) => {
+    return `- ${checkpoint.createdAt}: ${checkpoint.note}`;
+  });
+
+  return [
+    '<goal_context>',
+    `Status: ${goal.status}`,
+    `Objective: ${goal.objective}`,
+    goal.stopCondition ? `Stop condition: ${goal.stopCondition}` : undefined,
+    goal.validationCommands.length > 0
+      ? `Validation commands:\n${goal.validationCommands.map((cmd) => `- ${cmd}`).join('\n')}`
+      : undefined,
+    `Cycles: ${goal.completedCycles}/${goal.maxCycles}`,
+    checkpoints.length > 0
+      ? `Recent checkpoints:\n${checkpoints.join('\n')}`
+      : undefined,
+    '',
+    'Goal instructions:',
+    '- Keep the todo list aligned with this goal.',
+    '- Continue normal specialist delegation when useful.',
+    '- Validate through normal OpenCode tools and permissions.',
+    '- If the goal is complete, say so clearly and stop working.',
+    '- If blocked or user approval is needed, ask instead of continuing.',
+    '</goal_context>',
+  ]
+    .filter((part): part is string => typeof part === 'string')
+    .join('\n');
+}
+
+export function buildGoalContinuationPrompt(goal: GoalRecord): string {
+  return [
+    `[Goal: continue working on active goal ${goal.id}.]`,
+    `Objective: ${goal.objective}`,
+    goal.stopCondition ? `Stop condition: ${goal.stopCondition}` : undefined,
+    'Continue from the current todo state. If the goal is complete, report completion and stop. If blocked or you need user input, ask instead of continuing.',
+  ]
+    .filter((part): part is string => typeof part === 'string')
+    .join('\n');
+}
+
+export function buildGoalStartPrompt(goal: GoalRecord): string {
+  return [
+    `[Goal started: ${goal.id}]`,
+    `Objective: ${goal.objective}`,
+    goal.stopCondition ? `Stop condition: ${goal.stopCondition}` : undefined,
+    'Create or update todos for this goal, then begin work. Validate through normal tools before declaring the goal complete.',
+  ]
+    .filter((part): part is string => typeof part === 'string')
+    .join('\n');
+}

+ 111 - 0
src/goal/store.ts

@@ -0,0 +1,111 @@
+import * as fs from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import type { GoalRecord } from './types';
+
+const STATE_DIR = 'oh-my-opencode-slim';
+const STATE_FILE = 'goals.json';
+
+interface GoalStoreSnapshot {
+  version: 1;
+  goals: GoalRecord[];
+}
+
+function dataDir(): string {
+  return (
+    process.env.XDG_DATA_HOME ?? path.join(os.homedir(), '.local', 'share')
+  );
+}
+
+export function getGoalStorePath(): string {
+  return path.join(dataDir(), 'opencode', 'storage', STATE_DIR, STATE_FILE);
+}
+
+function emptySnapshot(): GoalStoreSnapshot {
+  return { version: 1, goals: [] };
+}
+
+function isGoalRecord(value: unknown): value is GoalRecord {
+  if (!value || typeof value !== 'object') return false;
+  const record = value as Partial<GoalRecord>;
+  return (
+    record.version === 1 &&
+    typeof record.id === 'string' &&
+    typeof record.directory === 'string' &&
+    typeof record.sessionID === 'string' &&
+    typeof record.objective === 'string' &&
+    typeof record.status === 'string' &&
+    Array.isArray(record.validationCommands) &&
+    Array.isArray(record.artifacts) &&
+    Array.isArray(record.checkpoints)
+  );
+}
+
+function parseSnapshot(value: string): GoalStoreSnapshot {
+  const parsed = JSON.parse(value) as Partial<GoalStoreSnapshot> | undefined;
+  if (parsed?.version !== 1 || !Array.isArray(parsed.goals)) {
+    return emptySnapshot();
+  }
+
+  return {
+    version: 1,
+    goals: parsed.goals.filter(isGoalRecord),
+  };
+}
+
+export class GoalStore {
+  read(): GoalStoreSnapshot {
+    try {
+      return parseSnapshot(fs.readFileSync(getGoalStorePath(), 'utf8'));
+    } catch {
+      return emptySnapshot();
+    }
+  }
+
+  write(snapshot: GoalStoreSnapshot): void {
+    const filePath = getGoalStorePath();
+    fs.mkdirSync(path.dirname(filePath), { recursive: true });
+    fs.writeFileSync(filePath, `${JSON.stringify(snapshot, null, 2)}\n`);
+  }
+
+  list(): GoalRecord[] {
+    return this.read().goals;
+  }
+
+  save(goal: GoalRecord): void {
+    const snapshot = this.read();
+    const existingIndex = snapshot.goals.findIndex(
+      (item) => item.id === goal.id,
+    );
+    if (existingIndex === -1) {
+      snapshot.goals.push(goal);
+    } else {
+      snapshot.goals[existingIndex] = goal;
+    }
+    this.write(snapshot);
+  }
+
+  findActiveBySession(sessionID: string): GoalRecord | undefined {
+    return this.list()
+      .filter(
+        (goal) =>
+          goal.sessionID === sessionID &&
+          (goal.status === 'running' ||
+            goal.status === 'paused' ||
+            goal.status === 'blocked'),
+      )
+      .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
+  }
+
+  findLatestByDirectory(directory: string): GoalRecord | undefined {
+    return this.list()
+      .filter(
+        (goal) =>
+          goal.directory === directory &&
+          (goal.status === 'running' ||
+            goal.status === 'paused' ||
+            goal.status === 'blocked'),
+      )
+      .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
+  }
+}

+ 36 - 0
src/goal/types.ts

@@ -0,0 +1,36 @@
+export type GoalStatus =
+  | 'running'
+  | 'paused'
+  | 'blocked'
+  | 'completed'
+  | 'archived';
+
+export interface GoalCheckpoint {
+  id: string;
+  createdAt: string;
+  note: string;
+}
+
+export interface GoalRecord {
+  version: 1;
+  id: string;
+  directory: string;
+  sessionID: string;
+  objective: string;
+  stopCondition?: string;
+  validationCommands: string[];
+  artifacts: string[];
+  status: GoalStatus;
+  createdAt: string;
+  updatedAt: string;
+  maxCycles: number;
+  completedCycles: number;
+  checkpoints: GoalCheckpoint[];
+  lastError?: string;
+}
+
+export interface GoalConfig {
+  maxCycles?: number;
+  cooldownMs?: number;
+  shouldManageSession?: (sessionID: string) => boolean;
+}

+ 19 - 0
src/hooks/todo-continuation/index.ts

@@ -169,6 +169,7 @@ export function createTodoContinuationHook(
     cooldownMs?: number;
     autoEnable?: boolean;
     autoEnableThreshold?: number;
+    shouldSkipSession?: (sessionID: string) => boolean;
   },
 ): {
   tool: Record<string, unknown>;
@@ -199,6 +200,7 @@ export function createTodoContinuationHook(
   const cooldownMs = config?.cooldownMs ?? 3000;
   const autoEnable = config?.autoEnable ?? false;
   const autoEnableThreshold = config?.autoEnableThreshold ?? 4;
+  const shouldSkipSession = config?.shouldSkipSession;
   const requestSignatureBySession = new Map<string, string>();
 
   const state: ContinuationState = {
@@ -506,6 +508,13 @@ export function createTodoContinuationHook(
         return;
       }
 
+      if (shouldSkipSession?.(sessionID)) {
+        log(`[${HOOK_NAME}] Skipped: session owned by another continuation`, {
+          sessionID,
+        });
+        return;
+      }
+
       // Auto-enable check: if configured, not yet enabled, and enough
       // todos exist, automatically enable auto-continue.
       if (autoEnable && !state.enabled) {
@@ -799,6 +808,16 @@ export function createTodoContinuationHook(
       return;
     }
 
+    if (shouldSkipSession?.(input.sessionID)) {
+      output.parts.length = 0;
+      output.parts.push(
+        createInternalAgentTextPart(
+          '[Auto-continue: skipped because an active /goal owns continuation for this session.]',
+        ),
+      );
+      return;
+    }
+
     // Seed orchestrator session from slash command (more reliable than
     // first-idle heuristic — slash commands only fire in main chat)
     registerOrchestratorSession(input.sessionID);

+ 31 - 8
src/index.ts

@@ -16,6 +16,7 @@ import {
 } from './config/runtime-preset';
 import { CouncilManager } from './council';
 import { createDivoomManager } from './divoom/manager';
+import { createGoalManager } from './goal';
 import {
   createApplyPatchHook,
   createAutoUpdateCheckerHook,
@@ -140,6 +141,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
   let taskSessionManagerHook: ReturnType<typeof createTaskSessionManagerHook>;
   let interviewManager: ReturnType<typeof createInterviewManager>;
   let presetManager: ReturnType<typeof createPresetManager>;
+  let goalManager: ReturnType<typeof createGoalManager>;
   let divoomManager: ReturnType<typeof createDivoomManager>;
   let councilTools: Record<string, unknown>;
   let webfetch: ReturnType<typeof createWebfetchTool>;
@@ -299,14 +301,6 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         Object.keys(runtimeChains).length > 0,
     );
 
-    // Initialize todo-continuation hook (opt-in auto-continue for
-    // incomplete todos)
-    todoContinuationHook = createTodoContinuationHook(ctx, {
-      maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
-      cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
-      autoEnable: config.todoContinuation?.autoEnable ?? false,
-      autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
-    });
     taskSessionManagerHook = createTaskSessionManagerHook(ctx, {
       maxSessionsPerAgent: config.sessionManager?.maxSessionsPerAgent ?? 2,
       readContextMinLines: config.sessionManager?.readContextMinLines ?? 10,
@@ -316,6 +310,21 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     });
     interviewManager = createInterviewManager(ctx, config);
     presetManager = createPresetManager(ctx, config);
+    goalManager = createGoalManager(ctx, {
+      maxCycles: config.goal?.maxCycles ?? 10,
+      cooldownMs: config.goal?.cooldownMs ?? 3000,
+      shouldManageSession: (sessionID) =>
+        sessionAgentMap.get(sessionID) === 'orchestrator',
+    });
+    // Initialize todo-continuation hook (opt-in auto-continue for
+    // incomplete todos). Goal mode owns its own continuation loop.
+    todoContinuationHook = createTodoContinuationHook(ctx, {
+      maxContinuations: config.todoContinuation?.maxContinuations ?? 5,
+      cooldownMs: config.todoContinuation?.cooldownMs ?? 3000,
+      autoEnable: config.todoContinuation?.autoEnable ?? false,
+      autoEnableThreshold: config.todoContinuation?.autoEnableThreshold ?? 4,
+      shouldSkipSession: (sessionID) => goalManager.hasActiveGoal(sessionID),
+    });
     divoomManager = createDivoomManager(config.divoom);
 
     subtaskState = createSubtaskState();
@@ -733,6 +742,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       interviewManager.registerCommand(opencodeConfig);
       presetManager.registerCommand(opencodeConfig);
+      goalManager.registerCommand(opencodeConfig);
       subtaskCommandManager.registerCommand(opencodeConfig);
     },
 
@@ -783,6 +793,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
 
       // Todo-continuation: auto-continue orchestrator on incomplete todos
       await todoContinuationHook.handleEvent(input);
+      await goalManager.handleEvent(input);
 
       // Handle auto-update checking
       await autoUpdateChecker.event(input);
@@ -952,6 +963,15 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
         },
         output as { parts: Array<{ type: string; text?: string }> },
       );
+
+      await goalManager.handleCommandExecuteBefore(
+        input as {
+          command: string;
+          sessionID: string;
+          arguments: string;
+        },
+        output as { parts: Array<{ type: string; text?: string }> },
+      );
     },
 
     'chat.headers': chatHeadersHook['chat.headers'],
@@ -1078,6 +1098,9 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
       await todoContinuationHook.handleMessagesTransform({
         messages: typedOutput.messages,
       });
+      await goalManager.handleMessagesTransform({
+        messages: typedOutput.messages,
+      });
       await taskSessionManagerHook['experimental.chat.messages.transform'](
         input,
         typedOutput,