Explorar o código

Merge pull request #1100 from GoldJohnKing/feat/opencode-v2-compatibility

fix(v2): adapt command registration and session bridge to beta-18269+ plugin API
Alvin hai 3 semanas
pai
achega
4e718fe6a7

+ 19 - 6
docs/opencode-v2-compatibility.md

@@ -36,6 +36,9 @@ v2's plugin resolver tries the `server` subpath first
 (`subpaths: ["server", ""]`), so a v2 package install loads the self-contained
 `dist/server.js`. v1 uses the main entry.
 
+**Supported v2 builds:** verified on `beta-18269` and `beta-18286` (add-only
+command drafts; flat session `prompt`/`synthetic`/`rename`/`switchAgent`).
+
 ## The v2 adapter (`src/v2/setup.ts`)
 
 `setup(ctx)` wraps the existing v1 factory rather than reimplementing it:
@@ -51,10 +54,15 @@ v2's plugin resolver tries the `server` subpath first
    - `agent` → `ctx.agent.transform` (model/prompt/permission adaptation +
      `subagent`/`execute` permission mapping + prompt rewrite `task`→`subagent`)
    - `tool` → `ctx.tool.transform` (zod shape → JSON schema; execute shimmed)
-   - `command` → `ctx.command.transform` (deepwork/reflect/loop)
-   - `experimental.chat.system.transform` +
-     `experimental.chat.messages.transform` → `ctx.session.hook("context")`
-     (SystemPart[]/Message.content shape conversion)
+   - `command` → `ctx.command.transform` — v2 command drafts are add-only:
+     `draft.add({name, description, execute})`. `execute` submits a
+     `<omos-cmd-command data-name="...">` marker as a user prompt; the session
+     context hook recovers it and dispatches to the v1
+     `command.execute.before` hook (deepwork/reflect/loop)
+   - a single `ctx.session.hook("context")` handles the system/messages
+     transforms (SystemPart[]/Message.content shape conversion),
+     `chat.message` agent tracking, and interview + generic command marker
+     dispatch
    - `tool.execute.before/after` → `ctx.tool.hook`
    - `event` → `ctx.event.subscribe()` loop
    - `dispose` → returned cleanup
@@ -167,8 +175,9 @@ plugin without v2 adding the corresponding capability:
   the plugin cannot switch models on a rate-limited foreground session.
   v1-only.
 - **Interactive `/preset` switcher impossible.** The switcher is a three-level
-  v1-TUI UI (`@opentui/solid`). v2 slash commands are template-only (no
-  interactive UI, no execute handler). **Workaround:** set `"preset"` in
+  v1-TUI UI (`@opentui/solid`). v2 slash commands have had `execute` handlers
+  since beta-18269, but the plugin API offers no interactive multi-level TUI
+  UI, so the switcher stays v1-only. **Workaround:** set `"preset"` in
   `oh-my-opencode-slim.json` — it applies at plugin load and resolves all agent
   models correctly.
 - **No programmatic MCP registration.** v2's plugin context has no MCP domain.
@@ -190,6 +199,10 @@ plugin without v2 adding the corresponding capability:
 
 These are adapter/environment caveats that can be worked around:
 
+- **Reduced/TUI-side hosts.** Some host processes load the plugin's `setup`
+  with a reduced, TUI-side context that lacks `agent.transform` (and other
+  domains). The adapter capability-guards `setup` and skips registration
+  gracefully for those hosts instead of crashing or retry-storming.
 - **Path-based dev loading.** When v2 loads the plugin by absolute file path it
   appends a `?mtime=` cache-busting query, which can break resolution of the
   externalized `jsdom` import from the plugin's `node_modules`. The plugin still

+ 25 - 5
src/v2/codemap.md

@@ -15,11 +15,13 @@ v2 registrations. v1 behavior is unchanged.
 | Path | Role |
 |---|---|
 | `index.ts` | Barrel: re-exports `createV2Setup` and the v2 context types. Imported by `src/index.ts` for the dual `default` export. |
-| `setup.ts` | `createV2Setup()` → the `setup(ctx)` orchestrator v2 calls. |
+| `setup.ts` | `createV2Setup()` → the `setup(ctx)` orchestrator v2 calls. Capability-guards reduced/TUI-side hosts (no `agent.transform`). Exports the pure command-marker helpers (`wrapCommandMarker`/`parseCommandMarker`/`stripCommandMarker`), `createCommandRegistration`, `applyCommandMarkerToContext`, and the merged context-hook builder `createSessionContextHandler`. |
 | `types.ts` | v2 plugin context surface (`V2Context` + draft/event types), mirrored locally (v2 plugin package is not a build-time dependency). |
+| `session-submit.ts` | Shared `createSessionSubmit` (prompt-only user-prompt submit via `ctx.session.prompt`) + `textFromContent`; used by both the generic command bridge and the interview bridge to avoid a setup↔bridge import cycle. |
 | `client-shim.ts` | `buildPluginInput`: constructs a v1-shaped `PluginInput` (shimmed `client`, `process.cwd()` directory) for the v1 factory. |
 | `adapters.ts` | Shape adapters: `parseModelRef`, `adaptPermissions` (v1 map → v2 Rule[] + v2 permissive base + `task`→`subagent`/`bash`→`execute` mapping), `rewritePromptForV2` (`task(`→`subagent(`), `adaptTool`, `applyAgentToDraft`. |
 | `interview-bridge.ts` | v2-only `/interview` marker command, trailing-message context bridge, v2 interview runtime, and per-session transcript projections. |
+| `setup-command.test.ts` | Unit tests for the command marker helpers, add-only draft registration, the shared submit helper, and the merged context-hook seam. |
 
 ## Data Flow
 
@@ -31,12 +33,20 @@ v2 registrations. v1 behavior is unchanged.
 4. Registers into v2 domains:
    - `agent` → `ctx.agent.transform` (via `applyAgentToDraft`)
    - `tool` → `ctx.tool.transform` (via `adaptTool`, zod shape → JSON schema)
-   - `command` → `ctx.command.transform`
-   - system/message transforms → `ctx.session.hook("context")` (SystemPart[]/
-     Message.content ↔ v1 `{info,parts}` conversion + `rewritePromptForV2`)
+   - `command` → `ctx.command.transform` (add-only draft; `execute` submits a
+     `<omos-cmd-command>` marker as a user prompt via the shared session
+     submit)
+   - a single `ctx.session.hook("context")` handles the system/messages
+     transforms (SystemPart[]/Message.content ↔ v1 `{info,parts}` conversion +
+     `rewritePromptForV2`), `chat.message` agent tracking, and interview +
+     generic command marker dispatch (whole-text-anchored markers recovered
+     from the trailing user message and routed to the v1
+     `command.execute.before` hook)
    - `tool.execute.before/after` → `ctx.tool.hook`
    - `event` → `ctx.event.subscribe()` loop
-    - interview marker/context/events → `interview-bridge.ts`
+   - interview marker/context/events → `interview-bridge.ts` (supplies
+     `registerCommand`/`handleContext`/`handleEvent` to the merged hook — no
+     separate registration)
 5. Returns a cleanup that disposes every v2 registration + the v1 `dispose`.
 
 Each bridge in step 4 is independently try/catch-guarded so one failure cannot
@@ -63,6 +73,16 @@ expanding the global v2 client surface.
 - **Interview cache boundary.** The interview context hook only rewrites the
   current trailing command message; prior messages remain unchanged for
   provider prompt-cache prefix reuse.
+- **Commands via marker round-trip.** v2 command drafts are add-only, so
+  `execute` submits a whole-text-anchored `<omos-cmd-command>` marker as a
+  user prompt and the session context hook dispatches it to the v1
+  `command.execute.before` hook, mutating only the trailing message (same
+  cache-preserving rule as the interview bridge).
+- **Capability guard.** Hosts invoking `setup()` with a reduced/TUI-side ctx
+  (no `agent.transform`) are skipped gracefully instead of crashing.
+- **Shared session submit.** A single `session-submit.ts` helper submits
+  marker text via `ctx.session.prompt` for both the generic commands and the
+  interview bridge.
 
 ## Integration Points
 

+ 1 - 0
src/v2/index.ts

@@ -22,6 +22,7 @@ export type {
   ModelRef,
   V2AgentDraft,
   V2Cleanup,
+  V2CommandDefinition,
   V2CommandDraft,
   V2Context,
   V2Registration,

+ 173 - 18
src/v2/interview-bridge.test.ts

@@ -2,19 +2,22 @@ import { describe, expect, mock, test } from 'bun:test';
 import * as fs from 'node:fs/promises';
 import { createServer } from 'node:http';
 import {
+  applyInterviewCommandParts,
   createV2InterviewBridge,
-  INTERVIEW_COMMAND_MARKER,
+  markerText,
 } from './interview-bridge';
 
 function createContext(overrides?: {
   synthetic?: (input: Record<string, unknown>) => Promise<unknown>;
-  update?: (input: Record<string, unknown>) => Promise<unknown>;
+  rename?: (input: Record<string, unknown>) => Promise<unknown>;
+  prompt?: (input: Record<string, unknown>) => Promise<unknown>;
 }): any {
   return {
     session: {
       hook: mock(async () => ({ dispose() {} })),
       synthetic: overrides?.synthetic,
-      update: overrides?.update,
+      rename: overrides?.rename,
+      prompt: overrides?.prompt,
     },
   };
 }
@@ -34,29 +37,54 @@ async function findFreePort(): Promise<number> {
   });
 }
 
+describe('markerText', () => {
+  test('renders args byte-exact', () => {
+    expect(markerText('build a notes app')).toBe(
+      '<omos-interview-command>build a notes app</omos-interview-command>',
+    );
+    expect(markerText('')).toBe(
+      '<omos-interview-command></omos-interview-command>',
+    );
+  });
+
+  test('does not mangle $-sequences in args (regression)', () => {
+    // A string replacer would turn $$ into $, $& into the whole match, and
+    // $` into the preceding text. Function replacer keeps them byte-exact.
+    expect(markerText('pay $$ now')).toBe(
+      '<omos-interview-command>pay $$ now</omos-interview-command>',
+    );
+    expect(markerText('a $& b')).toBe(
+      '<omos-interview-command>a $& b</omos-interview-command>',
+    );
+    expect(markerText('a $` b')).toBe(
+      '<omos-interview-command>a $` b</omos-interview-command>',
+    );
+  });
+});
+
 describe('v2 interview bridge', () => {
-  test('registers an orchestrator-owned marker command and rewrites only the tail', async () => {
+  test('registers an add-only marker command and rewrites only the tail', async () => {
     const directory = `.tmp-v2-interview-${Date.now()}`;
     const synthetic = mock(async () => ({}));
-    const update = mock(async () => ({}));
+    const rename = mock(async () => ({}));
     const bridge = createV2InterviewBridge(
-      createContext({ synthetic, update }),
+      createContext({ synthetic, rename }),
       {
         outputFolder: directory,
       } as never,
     );
-    const commands: Record<string, Record<string, unknown>> = {};
+    const added: Array<{ name: string; description?: string }> = [];
     bridge.registerCommand({
-      update(name, apply) {
-        commands[name] = {};
-        apply(commands[name]);
-      },
+      add: (def) =>
+        added.push({ name: def.name, description: def.description }),
     });
 
-    expect(commands.interview).toMatchObject({
-      agent: 'orchestrator',
-      template: INTERVIEW_COMMAND_MARKER,
-    });
+    expect(added).toEqual([
+      {
+        name: 'interview',
+        description: 'Open a localhost interview UI for a feature idea',
+      },
+    ]);
 
     const earlier = {
       id: 'old',
@@ -77,7 +105,7 @@ describe('v2 interview bridge', () => {
           content: [
             {
               type: 'text',
-              text: '<omos-interview-command>build a notes app</omos-interview-command>',
+              text: markerText('build a notes app'),
             },
           ],
         },
@@ -90,7 +118,7 @@ describe('v2 interview bridge', () => {
     expect(event.messages[1].content[0].text).toContain('build a notes app');
     expect(event.messages[1].content[0].text).toContain('<interview_state>');
     expect(synthetic).toHaveBeenCalled();
-    expect(update).toHaveBeenCalledWith({
+    expect(rename).toHaveBeenCalledWith({
       sessionID: 'ses_v2',
       title: 'Interview: build a notes app',
     });
@@ -102,6 +130,133 @@ describe('v2 interview bridge', () => {
     });
   });
 
+  test('registerCommand is a no-op when the draft has no add', () => {
+    const bridge = createV2InterviewBridge(createContext());
+    expect(() => bridge.registerCommand({} as never)).not.toThrow();
+    bridge.dispose();
+  });
+
+  test('embedded interview markers are not dispatched (whole-text anchor)', async () => {
+    const synthetic = mock(async () => ({}));
+    const bridge = createV2InterviewBridge(createContext({ synthetic }), {
+      outputFolder: `.tmp-v2-embedded-${Date.now()}`,
+    } as never);
+    const trailing = {
+      id: 't',
+      role: 'user',
+      content: [
+        {
+          type: 'text',
+          text: `before ${markerText('hijack')} after`,
+        },
+      ],
+    };
+    const before = structuredClone(trailing.content);
+
+    await bridge.handleContext({
+      sessionID: 'ses_embed',
+      agent: 'orchestrator',
+      model: {},
+      system: [],
+      tools: {},
+      messages: [trailing],
+    });
+
+    expect(trailing.content).toEqual(before);
+    expect(synthetic).not.toHaveBeenCalled();
+    bridge.dispose();
+  });
+
+  test('runtime methods probe the v2 session domain with flat inputs', async () => {
+    const calls: Array<{ method: string; input: Record<string, unknown> }> = [];
+    const track =
+      (method: string) =>
+      async (input: Record<string, unknown>): Promise<unknown> => {
+        calls.push({ method, input });
+        return {};
+      };
+    const bridge = createV2InterviewBridge({
+      session: {
+        prompt: track('prompt'),
+        synthetic: track('synthetic'),
+        switchAgent: track('switchAgent'),
+        rename: track('rename'),
+      },
+    } as never);
+
+    await bridge.runtime.notify('ses_n', 'ready');
+    expect(calls).toContainEqual({
+      method: 'synthetic',
+      input: { sessionID: 'ses_n', text: 'ready' },
+    });
+
+    await bridge.runtime.continue('ses_c', 'go on');
+    expect(calls).toContainEqual({
+      method: 'switchAgent',
+      input: { sessionID: 'ses_c', agent: 'orchestrator' },
+    });
+    expect(calls).toContainEqual({
+      method: 'prompt',
+      input: { sessionID: 'ses_c', text: 'go on' },
+    });
+
+    await bridge.runtime.rename('ses_r', 'Interview: x');
+    expect(calls).toContainEqual({
+      method: 'rename',
+      input: { sessionID: 'ses_r', title: 'Interview: x' },
+    });
+
+    bridge.dispose();
+  });
+
+  test('notify is a no-op (no prompt fallback) when synthetic is unavailable', async () => {
+    const prompt = mock(async () => ({}));
+    const bridge = createV2InterviewBridge({
+      session: { prompt },
+    } as never);
+    await bridge.runtime.notify('ses_f', 'hey');
+    expect(prompt).not.toHaveBeenCalled();
+    bridge.dispose();
+  });
+
+  test('rename logs and skips when unavailable', async () => {
+    const prompt = mock(async () => ({}));
+    const bridge = createV2InterviewBridge({
+      session: { prompt },
+    } as never);
+    await expect(
+      bridge.runtime.rename('ses_ru', 'Interview: x'),
+    ).resolves.toBeUndefined();
+    expect(prompt).not.toHaveBeenCalled();
+    bridge.dispose();
+  });
+
+  test('applyInterviewCommandParts: empty parts strip the marker, keep args', () => {
+    const trailing = {
+      role: 'user',
+      content: [{ type: 'text', text: markerText('standup notes') }],
+    };
+    applyInterviewCommandParts(
+      trailing,
+      trailing.content[0].text as string,
+      [],
+    );
+    expect(trailing.content).toEqual([{ type: 'text', text: 'standup notes' }]);
+  });
+
+  test('applyInterviewCommandParts: non-empty parts replace the content', () => {
+    const trailing = {
+      role: 'user',
+      content: [{ type: 'text', text: markerText('idea') }],
+    };
+    applyInterviewCommandParts(trailing, trailing.content[0].text as string, [
+      { type: 'text', text: 'EXPANDED', synthetic: true },
+    ]);
+    expect(trailing.content).toEqual([
+      { type: 'text', text: 'EXPANDED', synthetic: true },
+    ]);
+  });
+
   test('projects text events and removes a deleted session', async () => {
     const bridge = createV2InterviewBridge(createContext());
     await bridge.handleContext({
@@ -176,7 +331,7 @@ describe('v2 interview bridge', () => {
             content: [
               {
                 type: 'text',
-                text: `<omos-interview-command>${idea}</omos-interview-command>`,
+                text: markerText(idea),
               },
             ],
           },

+ 94 - 51
src/v2/interview-bridge.ts

@@ -6,26 +6,28 @@ import { createInterviewServer } from '../interview/server';
 import { createInterviewService } from '../interview/service';
 import type { InterviewMessage } from '../interview/types';
 import { log } from '../utils/logger';
-import type { V2Context, V2SessionContextEvent } from './types';
+import { createSessionSubmit, textFromContent } from './session-submit';
+import type {
+  V2CommandDraft,
+  V2Context,
+  V2Session,
+  V2SessionContextEvent,
+} from './types';
 
 export const INTERVIEW_COMMAND_MARKER =
   '<omos-interview-command>$ARGUMENTS</omos-interview-command>';
 
+// Whole-text anchored: v2 writes the marker as the entire submitted prompt,
+// so whole-text anchoring is the contract. A user-typed embedded marker must
+// not hijack dispatch in the merged session context hook.
 const MARKER_PATTERN =
-  /<omos-interview-command>\s*([\s\S]*?)\s*<\/omos-interview-command>/i;
+  /^\s*<omos-interview-command>\s*([\s\S]*?)\s*<\/omos-interview-command>\s*$/;
 
-type V2SessionMethods = {
-  prompt?: (input: Record<string, unknown>) => Promise<unknown>;
-  promptAsync?: (input: Record<string, unknown>) => Promise<unknown>;
-  synthetic?: (input: Record<string, unknown>) => Promise<unknown>;
-  update?: (input: Record<string, unknown>) => Promise<unknown>;
-};
-
-function textFromContent(content: Array<Record<string, unknown>>): string {
-  return content
-    .filter((part) => part.type === 'text')
-    .map((part) => (typeof part.text === 'string' ? part.text : ''))
-    .join('');
+/** Render the `/interview` command marker with the given arguments. */
+export function markerText(args: string): string {
+  // Function replacer: a string replacer would interpret `$`-sequences in
+  // args (`$&`, `` $` ``, `$$`, ...) instead of emitting them byte-exact.
+  return INTERVIEW_COMMAND_MARKER.replace('$ARGUMENTS', () => args);
 }
 
 function toInterviewMessages(event: V2SessionContextEvent): InterviewMessage[] {
@@ -41,55 +43,88 @@ function toInterviewMessages(event: V2SessionContextEvent): InterviewMessage[] {
 export interface V2InterviewBridge {
   readonly service: ReturnType<typeof createInterviewService>;
   readonly runtime: InterviewSessionRuntime;
-  registerCommand(draft: {
-    update(
-      name: string,
-      update: (command: Record<string, unknown>) => void,
-    ): void;
-  }): void;
+  registerCommand(draft: V2CommandDraft): void;
   handleContext(event: V2SessionContextEvent): Promise<void>;
   handleEvent(event: Record<string, unknown>): Promise<void>;
   getTranscript(sessionID: string): InterviewMessage[];
   dispose(): void;
 }
 
+/** Mutate the trailing command message from hook-produced parts. When the
+ * hook produced nothing, strip the marker and leave the raw args text.
+ * Only the trailing message is mutated; earlier messages are left
+ * byte-for-byte untouched so provider prompt prefixes remain cacheable. */
+export function applyInterviewCommandParts(
+  trailing: { role: string; content: Array<Record<string, unknown>> },
+  text: string,
+  parts: Array<Record<string, unknown>>,
+): void {
+  if (parts.length > 0) {
+    trailing.content = parts.map((part) => ({ ...part }));
+    return;
+  }
+  trailing.content = [
+    {
+      type: 'text',
+      // Function replacer: a string replacer would interpret `$`-sequences.
+      text: text.replace(MARKER_PATTERN, (_match, args: string) => args),
+    },
+  ];
+}
+
 export function createV2InterviewBridge(
   ctx: V2Context,
   config?: InterviewConfig,
 ): V2InterviewBridge {
   const transcripts = new Map<string, InterviewMessage[]>();
   const activeText = new Map<string, string>();
-  const methods = ctx.session as V2SessionMethods;
+  // Reduced hosts may omit the session domain entirely.
+  const methods = (ctx.session ?? {}) as V2Session;
+  const submitUserText = createSessionSubmit(ctx);
 
   const runtime: InterviewSessionRuntime = {
     messages: async (sessionID) => transcripts.get(sessionID) ?? [],
     notify: async (sessionID, text) => {
-      if (methods.synthetic) {
-        await methods.synthetic({ sessionID, text });
+      // synthetic only — no prompt fallback: synthetic avoids triggering an
+      // agent turn; a prompt fallback would double-send and wake the loop.
+      if (typeof methods.synthetic !== 'function') {
+        log('[v2][interview] synthetic unavailable for notify', { sessionID });
         return;
       }
-      if (methods.prompt) {
-        await methods.prompt({
+      try {
+        await methods.synthetic({ sessionID, text });
+      } catch (err) {
+        log('[v2][interview] synthetic notify failed', {
           sessionID,
-          noReply: true,
-          parts: [{ type: 'text', text }],
+          err: String(err),
         });
       }
     },
     continue: async (sessionID, text) => {
-      const input = {
-        sessionID,
-        agent: 'orchestrator',
-        parts: [{ type: 'text', text, synthetic: true }],
-      };
-      if (methods.promptAsync) {
-        await methods.promptAsync(input);
-        return;
+      // Best-effort switch to the orchestrator agent, then a flat prompt.
+      try {
+        await methods.switchAgent?.({ sessionID, agent: 'orchestrator' });
+      } catch (err) {
+        log('[v2][interview] switchAgent failed (best-effort)', {
+          sessionID,
+          err: String(err),
+        });
       }
-      if (methods.prompt) await methods.prompt(input);
+      await submitUserText(sessionID, text);
     },
     rename: async (sessionID, title) => {
-      if (methods.update) await methods.update({ sessionID, title });
+      if (typeof methods.rename !== 'function') {
+        log('[v2][interview] session rename unavailable', { sessionID });
+        return;
+      }
+      try {
+        await methods.rename({ sessionID, title });
+      } catch (err) {
+        log('[v2][interview] session rename failed', {
+          sessionID,
+          err: String(err),
+        });
+      }
     },
   };
 
@@ -135,17 +170,27 @@ export function createV2InterviewBridge(
       });
   if (server) service.setBaseUrlResolver(() => server.ensureStarted());
 
-  function registerCommand(draft: {
-    update(
-      name: string,
-      update: (command: Record<string, unknown>) => void,
-    ): void;
-  }): void {
-    draft.update('interview', (command) => {
-      command.name = 'interview';
-      command.agent = 'orchestrator';
-      command.description = 'Open a localhost interview UI for a feature idea';
-      command.template = INTERVIEW_COMMAND_MARKER;
+  function registerCommand(draft: V2CommandDraft): void {
+    // v2 command drafts are add-only. `/interview` renders its marker as a
+    // user prompt; the context hook below consumes it.
+    if (typeof draft.add !== 'function') {
+      log('[v2][interview] command draft has no add');
+      return;
+    }
+    draft.add({
+      name: 'interview',
+      description: 'Open a localhost interview UI for a feature idea',
+      execute: async (invocation) => {
+        // Never throw: v2 surfaces command execution errors to the user.
+        try {
+          await submitUserText(
+            invocation?.sessionID ?? '',
+            markerText(invocation?.prompt?.text ?? ''),
+          );
+        } catch (err) {
+          log('[v2][interview] command execute failed', String(err));
+        }
+      },
     });
   }
 
@@ -176,9 +221,7 @@ export function createV2InterviewBridge(
       output,
     );
 
-    // Only replace the current command message. Earlier messages are left
-    // byte-for-byte untouched so provider prompt prefixes remain cacheable.
-    trailing.content = output.parts.map((part) => ({ ...part }));
+    applyInterviewCommandParts(trailing, text, output.parts);
     transcripts.set(event.sessionID, toInterviewMessages(event));
   }
 

+ 47 - 0
src/v2/session-submit.ts

@@ -0,0 +1,47 @@
+/**
+ * Shared v2 session submit + text helpers.
+ *
+ * One implementation of "submit text as a user prompt on a v2 session" so the
+ * generic command bridge (setup.ts) and the interview bridge stay identical;
+ * avoids a setup↔interview-bridge import cycle.
+ */
+
+import { log } from '../utils/logger';
+import type { V2Context } from './types';
+
+/** Function that submits `text` as a user prompt on a v2 session. */
+export type V2CommandSubmit = (
+  sessionID: string,
+  text: string,
+) => Promise<void>;
+
+/** Submit marker text as a user prompt via `ctx.session.prompt`. Never
+ * throws — the session methods reject on transport errors and command
+ * `execute` must not leak that out to the host. */
+export function createSessionSubmit(ctx: V2Context): V2CommandSubmit {
+  // Reduced hosts may omit the session domain entirely (mirrors
+  // interview-bridge.ts); without the ?? {} the probe below would throw a
+  // TypeError and be mislogged as a submit failure.
+  const session = (ctx.session ?? {}) as V2Context['session'];
+  return async (sessionID, text) => {
+    try {
+      if (typeof session.prompt === 'function') {
+        await session.prompt({ sessionID, text });
+        return;
+      }
+      log('[v2] command submit unavailable', { sessionID });
+    } catch (err) {
+      log('[v2] command submit failed', { sessionID, err: String(err) });
+    }
+  };
+}
+
+/** Join the text parts of a v2 message content array. */
+export function textFromContent(
+  content: Array<Record<string, unknown>>,
+): string {
+  return content
+    .filter((part) => part.type === 'text')
+    .map((part) => (typeof part.text === 'string' ? part.text : ''))
+    .join('');
+}

+ 534 - 0
src/v2/setup-command.test.ts

@@ -0,0 +1,534 @@
+import { describe, expect, mock, test } from 'bun:test';
+import * as fs from 'node:fs/promises';
+import { createV2InterviewBridge, markerText } from './interview-bridge';
+import { createSessionSubmit } from './session-submit';
+import {
+  applyCommandMarkerToContext,
+  createCommandRegistration,
+  createSessionContextHandler,
+  parseCommandMarker,
+  registerSynthCommands,
+  stripCommandMarker,
+  type V1CommandBeforeHook,
+  wrapCommandMarker,
+} from './setup';
+import type {
+  V2CommandDefinition,
+  V2CommandDraft,
+  V2SessionContextEvent,
+} from './types';
+
+function makeEvent(
+  messages: Array<{ id?: string; role: string; content: unknown[] }>,
+  overrides?: Partial<V2SessionContextEvent>,
+): V2SessionContextEvent {
+  return {
+    sessionID: 'ses_cmd',
+    agent: 'orchestrator',
+    model: {},
+    system: [],
+    tools: {},
+    messages: messages as V2SessionContextEvent['messages'],
+    ...overrides,
+  };
+}
+
+describe('command marker wrap/parse', () => {
+  test('round-trips empty, simple, and multiline args', () => {
+    for (const args of ['', 'focus 25m', 'line one\nline two\nline three']) {
+      expect(parseCommandMarker(wrapCommandMarker('deepwork', args))).toEqual({
+        name: 'deepwork',
+        args,
+      });
+    }
+  });
+
+  test('round-trips widened name charsets (\\w . -)', () => {
+    for (const name of ['git_commit', 'Task.v2', 'deepwork', 'a-b-c']) {
+      expect(parseCommandMarker(wrapCommandMarker(name, 'args'))).toEqual({
+        name,
+        args: 'args',
+      });
+    }
+  });
+
+  test('renders the exact marker shape', () => {
+    expect(wrapCommandMarker('deepwork', 'focus')).toBe(
+      '<omos-cmd-command data-name="deepwork">focus</omos-cmd-command>',
+    );
+    expect(wrapCommandMarker('loop', '')).toBe(
+      '<omos-cmd-command data-name="loop"></omos-cmd-command>',
+    );
+  });
+
+  test('whole-text anchored: embedded markers never match', () => {
+    expect(
+      parseCommandMarker(
+        'before <omos-cmd-command data-name="reflect">a b</omos-cmd-command> after',
+      ),
+    ).toBeUndefined();
+  });
+
+  test('whole-text anchored: surrounding whitespace is tolerated', () => {
+    expect(
+      parseCommandMarker(`  \n${wrapCommandMarker('reflect', 'a b')}\n  `),
+    ).toEqual({ name: 'reflect', args: 'a b' });
+  });
+
+  test('returns undefined without a marker', () => {
+    expect(parseCommandMarker('plain user text')).toBeUndefined();
+    expect(parseCommandMarker(markerText('x'))).toBeUndefined();
+  });
+
+  test('stripCommandMarker leaves the raw args on marker-only text', () => {
+    expect(stripCommandMarker(wrapCommandMarker('deepwork', 'focus 25m'))).toBe(
+      'focus 25m',
+    );
+    // Only runs on marker-only text (anchored pattern): other text is a no-op.
+    expect(
+      stripCommandMarker(`pre ${wrapCommandMarker('deepwork', 'x')} post`),
+    ).toBe(`pre ${wrapCommandMarker('deepwork', 'x')} post`);
+  });
+});
+
+describe('createCommandRegistration', () => {
+  test('add-only draft registers via add and execute submits the marker', async () => {
+    const added: V2CommandDefinition[] = [];
+    const submit = mock(async () => {});
+    createCommandRegistration(
+      { add: (def) => added.push(def) },
+      'deepwork',
+      { description: 'Start a deep work block' },
+      submit,
+    );
+
+    expect(added).toHaveLength(1);
+    expect(added[0]?.name).toBe('deepwork');
+    expect(added[0]?.description).toBe('Start a deep work block');
+    expect(added[0]?.execute).toBeTypeOf('function');
+
+    await added[0]?.execute({
+      sessionID: 'ses_1',
+      prompt: { text: 'focus on tests' },
+    });
+    expect(submit).toHaveBeenCalledTimes(1);
+    expect(submit).toHaveBeenCalledWith(
+      'ses_1',
+      wrapCommandMarker('deepwork', 'focus on tests'),
+    );
+  });
+
+  test('execute swallows submit errors and empty prompts', async () => {
+    const added: V2CommandDefinition[] = [];
+    const submit = mock(async () => {
+      throw new Error('transport down');
+    });
+    createCommandRegistration(
+      { add: (def) => added.push(def) },
+      'loop',
+      {},
+      submit,
+    );
+
+    await expect(
+      added[0]?.execute({ sessionID: 'ses_2', prompt: { text: '' } }),
+    ).resolves.toBeUndefined();
+    expect(submit).toHaveBeenCalledWith('ses_2', wrapCommandMarker('loop', ''));
+  });
+
+  test('a throwing draft.add propagates to the caller (no internal catch)', () => {
+    const draft: V2CommandDraft = {
+      add: () => {
+        throw new Error('draft rejected');
+      },
+    };
+    expect(() =>
+      createCommandRegistration(draft, 'loop', {}, async () => {}),
+    ).toThrow('draft rejected');
+  });
+
+  test('draft without add is a logged no-op', () => {
+    expect(() =>
+      createCommandRegistration(
+        {} as V2CommandDraft,
+        'loop',
+        {},
+        async () => {},
+      ),
+    ).not.toThrow();
+  });
+});
+
+describe('registerSynthCommands (generic loop skips bridge-owned interview)', () => {
+  test('interview is NOT add()ed from the generic path; the bridge registers it', () => {
+    const added: V2CommandDefinition[] = [];
+    const draft: V2CommandDraft = { add: (def) => added.push(def) };
+
+    registerSynthCommands(
+      draft,
+      [
+        ['interview', { description: 'Open a localhost interview UI' }],
+        ['deepwork', { description: 'Start a deep work block' }],
+      ],
+      async () => {},
+    );
+    expect(added.map((def) => def.name)).toEqual(['deepwork']);
+
+    const bridge = createV2InterviewBridge({ session: {} } as never, undefined);
+    bridge.registerCommand(draft);
+    expect(added.map((def) => def.name)).toEqual(['deepwork', 'interview']);
+    bridge.dispose();
+  });
+
+  test('a failing command is skipped without blocking the rest', () => {
+    const added: V2CommandDefinition[] = [];
+    const draft: V2CommandDraft = {
+      add: (def) => {
+        if (def.name === 'deepwork') throw new Error('draft rejected');
+        added.push(def);
+      },
+    };
+
+    registerSynthCommands(
+      draft,
+      [
+        ['deepwork', {}],
+        ['loop', {}],
+      ],
+      async () => {},
+    );
+    expect(added.map((def) => def.name)).toEqual(['loop']);
+  });
+});
+
+describe('createSessionSubmit', () => {
+  test('submits via ctx.session.prompt only', async () => {
+    const prompt = mock(async () => ({}));
+    await createSessionSubmit({
+      session: { prompt },
+    } as never)('ses_a', 'hello');
+    expect(prompt).toHaveBeenCalledWith({ sessionID: 'ses_a', text: 'hello' });
+  });
+
+  test('logs and gives up when prompt is unavailable', async () => {
+    await expect(
+      createSessionSubmit({ session: {} } as never)('ses_c', 'hello'),
+    ).resolves.toBeUndefined();
+  });
+
+  test('undefined session domain resolves without throwing', async () => {
+    // Reduced hosts may omit ctx.session entirely; the probe inside must
+    // take the unavailable path, not die on `session.prompt` of undefined.
+    await expect(
+      createSessionSubmit({} as never)('ses_e', 'hello'),
+    ).resolves.toBeUndefined();
+  });
+
+  test('never throws on transport errors', async () => {
+    const prompt = mock(async () => {
+      throw new Error('boom');
+    });
+    await expect(
+      createSessionSubmit({ session: { prompt } } as never)('ses_d', 'x'),
+    ).resolves.toBeUndefined();
+  });
+});
+
+describe('interview registerCommand (add-only draft)', () => {
+  test('registers via add and execute submits the interview marker', async () => {
+    const prompt = mock(async () => ({}));
+    const bridge = createV2InterviewBridge(
+      { session: { prompt } } as never,
+      undefined,
+    );
+    const added: V2CommandDefinition[] = [];
+    bridge.registerCommand({ add: (def) => added.push(def) });
+
+    expect(added).toHaveLength(1);
+    expect(added[0]?.name).toBe('interview');
+    expect(added[0]?.description).toBe(
+      'Open a localhost interview UI for a feature idea',
+    );
+
+    await added[0]?.execute({
+      sessionID: 'ses_iv',
+      prompt: { text: 'build a notes app' },
+    });
+    expect(prompt).toHaveBeenCalledWith({
+      sessionID: 'ses_iv',
+      text: markerText('build a notes app'),
+    });
+    bridge.dispose();
+  });
+});
+
+describe('applyCommandMarkerToContext', () => {
+  test('replaces the trailing marker with hook parts; other messages untouched', async () => {
+    const earlier = {
+      id: 'm1',
+      role: 'user',
+      content: [{ type: 'text', text: 'earlier context' }],
+    };
+    const trailing = {
+      id: 'm2',
+      role: 'user',
+      content: [
+        { type: 'text', text: wrapCommandMarker('deepwork', 'focus 25m') },
+      ],
+    };
+    const event = makeEvent([earlier, trailing]);
+    const earlierBefore = structuredClone(earlier);
+
+    const calls: Array<{
+      command: string;
+      sessionID: string;
+      arguments: string;
+    }> = [];
+    const commandBefore: V1CommandBeforeHook = async (input, output) => {
+      calls.push(input);
+      output.parts.push({
+        type: 'text',
+        text: 'DEEPWORK EXPANDED',
+        synthetic: true,
+      });
+    };
+
+    await applyCommandMarkerToContext(event, commandBefore);
+
+    expect(earlier).toEqual(earlierBefore);
+    expect(calls).toEqual([
+      { command: 'deepwork', sessionID: 'ses_cmd', arguments: 'focus 25m' },
+    ]);
+    expect(trailing.content).toEqual([
+      { type: 'text', text: 'DEEPWORK EXPANDED', synthetic: true },
+    ]);
+  });
+
+  test('empty hook parts strip the marker and leave the raw args', async () => {
+    const trailing = {
+      id: 'm1',
+      role: 'user',
+      content: [
+        { type: 'text', text: wrapCommandMarker('reflect', 'standup notes') },
+      ],
+    };
+    const event = makeEvent([trailing]);
+    const calls: unknown[] = [];
+    const commandBefore: V1CommandBeforeHook = async (input) => {
+      calls.push(input);
+    };
+
+    await applyCommandMarkerToContext(event, commandBefore);
+
+    expect(calls).toHaveLength(1);
+    expect(trailing.content).toEqual([{ type: 'text', text: 'standup notes' }]);
+  });
+
+  test('no-ops for assistant trailing messages and marker-less text', async () => {
+    const calls: unknown[] = [];
+    const commandBefore: V1CommandBeforeHook = async (input) => {
+      calls.push(input);
+    };
+
+    const assistant = makeEvent([
+      { id: 'a', role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
+    ]);
+    await applyCommandMarkerToContext(assistant, commandBefore);
+
+    const plain = makeEvent([
+      { id: 'u', role: 'user', content: [{ type: 'text', text: 'plain' }] },
+    ]);
+    await applyCommandMarkerToContext(plain, commandBefore);
+
+    expect(calls).toEqual([]);
+  });
+});
+
+describe('createSessionContextHandler (merged context hook seam)', () => {
+  function recordCommandCalls(): {
+    calls: Array<{
+      command: string;
+      sessionID: string;
+      arguments: string;
+    }>;
+    hook: V1CommandBeforeHook;
+  } {
+    const calls: Array<{
+      command: string;
+      sessionID: string;
+      arguments: string;
+    }> = [];
+    return {
+      calls,
+      hook: async (input) => {
+        calls.push(input);
+      },
+    };
+  }
+
+  test('(a) interview-marker-only tail: interview handler fires, generic dispatch no-op', async () => {
+    const directory = `.tmp-v2-seam-a-${Date.now()}`;
+    const synthetic = mock(async () => ({}));
+    const rename = mock(async () => ({}));
+    const bridge = createV2InterviewBridge(
+      { session: { synthetic, rename } } as never,
+      { outputFolder: directory } as never,
+    );
+    const { calls, hook } = recordCommandCalls();
+    const handler = createSessionContextHandler({
+      interviewHandleContext: (event) => bridge.handleContext(event),
+      commandBefore: hook,
+    });
+
+    const earlier = {
+      id: 'm1',
+      role: 'user',
+      content: [{ type: 'text', text: 'earlier context' }],
+    };
+    const trailing = {
+      id: 'm2',
+      role: 'user',
+      content: [{ type: 'text', text: markerText('build a notes app') }],
+    };
+    const event = makeEvent([earlier, trailing]);
+    const earlierBefore = structuredClone(earlier);
+
+    await handler(event);
+
+    expect(calls).toEqual([]); // generic dispatch no-op
+    expect(earlier).toEqual(earlierBefore);
+    // The interview bridge consumed the marker (tail rewritten).
+    expect(JSON.stringify(trailing.content)).toContain('<interview_state>');
+
+    bridge.dispose();
+    await fs.rm(`${process.cwd()}/${directory}`, {
+      recursive: true,
+      force: true,
+    });
+  });
+
+  test('(b) generic-marker-only tail: generic dispatch fires, interview no-op', async () => {
+    const directory = `.tmp-v2-seam-b-${Date.now()}`;
+    const synthetic = mock(async () => ({}));
+    const bridge = createV2InterviewBridge(
+      { session: { synthetic } } as never,
+      { outputFolder: directory } as never,
+    );
+    const calls: Array<{
+      command: string;
+      sessionID: string;
+      arguments: string;
+    }> = [];
+    const handler = createSessionContextHandler({
+      interviewHandleContext: (event) => bridge.handleContext(event),
+      commandBefore: async (input, output) => {
+        calls.push(input);
+        output.parts.push({ type: 'text', text: 'GENERIC EXPANDED' });
+      },
+    });
+
+    const trailing = {
+      id: 'm1',
+      role: 'user',
+      content: [
+        { type: 'text', text: wrapCommandMarker('deepwork', 'focus 25m') },
+      ],
+    };
+    const event = makeEvent([trailing]);
+
+    await handler(event);
+
+    expect(calls).toEqual([
+      { command: 'deepwork', sessionID: 'ses_cmd', arguments: 'focus 25m' },
+    ]);
+    expect(trailing.content).toEqual([
+      { type: 'text', text: 'GENERIC EXPANDED' },
+    ]);
+    // Interview bridge no-op on generic markers: no synthetic notification.
+    expect(synthetic).not.toHaveBeenCalled();
+
+    bridge.dispose();
+    await fs.rm(`${process.cwd()}/${directory}`, {
+      recursive: true,
+      force: true,
+    });
+  });
+
+  test('(c) system/messages transforms + chat.message run on the same event', async () => {
+    const chatCalls: Array<{ sessionID: string; agent?: string }> = [];
+    const handler = createSessionContextHandler({
+      interviewHandleContext: async () => {},
+      chatMessage: async (input) => {
+        chatCalls.push(input);
+      },
+      systemTransform: async (_input, output) => {
+        output.system.push('INJECTED');
+      },
+      messagesTransform: async (_input, output) => {
+        output.messages[0]?.parts.push({ type: 'text', text: 'APPENDED' });
+      },
+    });
+
+    const message = {
+      id: 'u',
+      role: 'user',
+      content: [{ type: 'text', text: 'hi' }],
+    };
+    const event = makeEvent([message], {
+      system: [{ type: 'text', text: 'base' }],
+    });
+
+    await handler(event);
+
+    expect(chatCalls).toEqual([
+      { sessionID: 'ses_cmd', agent: 'orchestrator' },
+    ]);
+    expect(event.system).toEqual([
+      { type: 'text', text: 'base' },
+      { type: 'text', text: 'INJECTED' },
+    ]);
+    expect(message.content).toEqual([
+      { type: 'text', text: 'hi' },
+      { type: 'text', text: 'APPENDED' },
+    ]);
+  });
+
+  test('(d) embedded markers inside other text never fire either dispatcher', async () => {
+    const directory = `.tmp-v2-seam-d-${Date.now()}`;
+    const synthetic = mock(async () => ({}));
+    const bridge = createV2InterviewBridge(
+      { session: { synthetic } } as never,
+      { outputFolder: directory } as never,
+    );
+    const { calls, hook } = recordCommandCalls();
+    const handler = createSessionContextHandler({
+      interviewHandleContext: (event) => bridge.handleContext(event),
+      commandBefore: hook,
+    });
+
+    const trailing = {
+      id: 'm1',
+      role: 'user',
+      content: [
+        {
+          type: 'text',
+          text: `look at ${wrapCommandMarker('deepwork', 'x')} and ${markerText('idea')} please`,
+        },
+      ],
+    };
+    const event = makeEvent([trailing]);
+    const contentBefore = structuredClone(trailing.content);
+
+    await handler(event);
+
+    expect(calls).toEqual([]);
+    expect(synthetic).not.toHaveBeenCalled();
+    expect(trailing.content).toEqual(contentBefore);
+
+    bridge.dispose();
+    await fs.rm(`${process.cwd()}/${directory}`, {
+      recursive: true,
+      force: true,
+    });
+  });
+});

+ 286 - 82
src/v2/setup.ts

@@ -4,8 +4,9 @@
  * Returns the `setup(ctx)` function v2 calls via `default.setup`. The setup
  * wraps the existing v1 factory (reusing ALL build logic) and translates the
  * returned v1 `Hooks` into v2 registrations: agent/tool/command transforms,
- * the session context hook (system + message transforms), tool execute hooks,
- * and the event stream. Each bridge is independently try/catch-guarded.
+ * a single session context hook (system/messages transforms, chat.message
+ * tracking, and interview + generic command marker dispatch), tool execute
+ * hooks, and the event stream. Each bridge is independently try/catch-guarded.
  */
 
 import { loadPluginConfig } from '../config/loader';
@@ -15,14 +16,265 @@ import { initLogger, log } from '../utils/logger';
 import { adaptTool, applyAgentToDraft } from './adapters';
 import { buildPluginInput } from './client-shim';
 import { createV2InterviewBridge } from './interview-bridge';
+import {
+  createSessionSubmit,
+  textFromContent,
+  type V2CommandSubmit,
+} from './session-submit';
 import type {
   V2Cleanup,
+  V2CommandDefinition,
+  V2CommandDraft,
   V2Context,
   V2SessionContextEvent,
   V2ToolAfterEvent,
   V2ToolBeforeEvent,
 } from './types';
 
+/** v1 `command.execute.before` hook shape (see src/index.ts wiring). */
+export type V1CommandBeforeHook = (
+  input: { command: string; sessionID: string; arguments: string },
+  output: {
+    parts: Array<{
+      type: string;
+      text?: string;
+      synthetic?: boolean;
+      metadata?: Record<string, unknown>;
+    }>;
+  },
+) => Promise<void>;
+
+/** v1 command hook part shape. */
+type V1CommandPart = {
+  type: string;
+  text?: string;
+  synthetic?: boolean;
+  metadata?: Record<string, unknown>;
+};
+
+/** Wrap slash-command arguments in the generic v2 command marker. v2 command
+ * drafts are add-only (no `template`), so `execute` submits this marker as a
+ * plain user prompt and the session context hook recovers it below. */
+export function wrapCommandMarker(name: string, args: string): string {
+  return `<omos-cmd-command data-name="${name}">${args}</omos-cmd-command>`;
+}
+
+// Whole-text anchored: v2 writes the marker as the entire submitted prompt,
+// so whole-text anchoring is the contract. A user-typed embedded marker must
+// not hijack dispatch in the merged session context hook.
+const COMMAND_MARKER_PATTERN =
+  /^\s*<omos-cmd-command\s+data-name="([\w.-]+)">([\s\S]*?)<\/omos-cmd-command>\s*$/;
+
+export interface ParsedCommandMarker {
+  name: string;
+  args: string;
+}
+
+/** Parse the generic command marker from a message text, if present. */
+export function parseCommandMarker(
+  text: string,
+): ParsedCommandMarker | undefined {
+  const match = text.match(COMMAND_MARKER_PATTERN);
+  if (!match) return undefined;
+  return { name: match[1], args: match[2] };
+}
+
+/** Strip the marker tags from marker-only `text`, leaving the raw args. */
+export function stripCommandMarker(text: string): string {
+  // Function replacer: a string replacer would interpret `$`-sequences in
+  // the captured args. Group 1 is the command name; group 2 the args.
+  return text.replace(
+    COMMAND_MARKER_PATTERN,
+    (_match, _name: string, args: string) => args,
+  );
+}
+
+/** Register one v1 synth command on a v2 command draft. Uses `add` when
+ * present; callers wrap per-command in try/catch so a throwing `draft.add`
+ * only skips that command. */
+export function createCommandRegistration(
+  draft: V2CommandDraft,
+  name: string,
+  cmd: { description?: string },
+  submit: V2CommandSubmit,
+): void {
+  if (typeof draft.add !== 'function') {
+    log('[v2] command draft has no add', { name });
+    return;
+  }
+  const definition: V2CommandDefinition = {
+    name,
+    ...(typeof cmd.description === 'string'
+      ? { description: cmd.description }
+      : {}),
+    execute: async (invocation) => {
+      // Never throw: v2 surfaces command execution errors to the user.
+      try {
+        await submit(
+          invocation?.sessionID ?? '',
+          wrapCommandMarker(name, invocation?.prompt?.text ?? ''),
+        );
+      } catch (err) {
+        log('[v2] command submit failed', { name, err: String(err) });
+      }
+    },
+  };
+  draft.add(definition);
+}
+
+/** Register the v1 synth commands on a v2 command draft. `interview` is
+ * owned by the interview bridge's own registration (whose context hook owns
+ * the interview marker), so it is skipped here — a duplicate `draft.add`
+ * would break `/interview` on host builds that are first-wins or throw on
+ * duplicates. */
+export function registerSynthCommands(
+  draft: V2CommandDraft,
+  entries: Array<[string, { description?: string }]>,
+  submit: V2CommandSubmit,
+): void {
+  for (const [name, cmd] of entries) {
+    if (name === 'interview') continue; // owned by the interview bridge registration below
+    try {
+      createCommandRegistration(draft, name, cmd, submit);
+    } catch (err) {
+      log('[v2] command adapt failed', { name, err: String(err) });
+    }
+  }
+}
+
+/** Dispatch a generic command marker found in the trailing user message to
+ * the v1 `command.execute.before` hook, then replace that message's content
+ * with the hook-produced parts. Mirrors the interview bridge mutation
+ * semantics: only the trailing message is touched so earlier messages stay
+ * byte-for-byte identical (provider prompt-cache prefix reuse). */
+export async function applyCommandMarkerToContext(
+  event: V2SessionContextEvent,
+  commandBefore: V1CommandBeforeHook,
+): Promise<void> {
+  const trailing = event.messages.at(-1);
+  if (trailing?.role !== 'user') return;
+  const text = textFromContent(trailing.content);
+  const parsed = parseCommandMarker(text);
+  if (!parsed) return;
+
+  const output = { parts: [] as V1CommandPart[] };
+  await commandBefore(
+    {
+      command: parsed.name,
+      sessionID: event.sessionID,
+      arguments: parsed.args.trim(),
+    },
+    output,
+  );
+
+  if (output.parts.length > 0) {
+    trailing.content = output.parts.map((part) => ({ ...part }));
+    return;
+  }
+  // Hook produced nothing: strip the marker and leave the raw args text.
+  trailing.content = [{ type: 'text', text: stripCommandMarker(text) }];
+}
+
+/** Deps injected into the single session context hook. */
+export interface V2SessionContextHandlerDeps {
+  /** Interview bridge handleContext (transcript projection + /interview
+   * marker dispatch). */
+  interviewHandleContext: (event: V2SessionContextEvent) => Promise<void>;
+  /** v1 `command.execute.before` hook (generic command marker dispatch). */
+  commandBefore?: V1CommandBeforeHook;
+  /** v1 `chat.message` hook (agent tracking). */
+  chatMessage?: (
+    input: { sessionID: string; agent?: string },
+    output: unknown,
+  ) => Promise<void>;
+  /** v1 `experimental.chat.system.transform` hook. */
+  systemTransform?: (
+    input: unknown,
+    output: { system: string[] },
+  ) => Promise<void>;
+  /** v1 `experimental.chat.messages.transform` hook. */
+  messagesTransform?: (
+    input: unknown,
+    output: {
+      messages: Array<{ info: { role: string }; parts: unknown[] }>;
+    },
+  ) => Promise<void>;
+}
+
+/** Build the single `ctx.session.hook("context")` handler: interview marker
+ * bridge, generic command marker dispatch, chat.message agent tracking, and
+ * the v1 system/messages transforms — each independently try/catch-guarded. */
+export function createSessionContextHandler(
+  deps: V2SessionContextHandlerDeps,
+): (event: V2SessionContextEvent) => Promise<void> {
+  return async (event) => {
+    // Interview marker bridge (transcript projection + /interview).
+    try {
+      await deps.interviewHandleContext(event);
+    } catch (err) {
+      log('[v2] interview context bridge failed', String(err));
+    }
+    // Generic command marker dispatch (deepwork / reflect / loop).
+    if (deps.commandBefore) {
+      try {
+        await applyCommandMarkerToContext(event, deps.commandBefore);
+      } catch (err) {
+        log('[v2] command context bridge failed', String(err));
+      }
+    }
+    // Agent tracking (chat.message equivalent).
+    if (deps.chatMessage) {
+      try {
+        await deps.chatMessage(
+          { sessionID: event.sessionID, agent: event.agent },
+          undefined,
+        );
+      } catch (err) {
+        log('[v2] chat.message bridge failed', String(err));
+      }
+    }
+    // System transform: v2 SystemPart[] -> v1 string[] -> mutate -> back.
+    if (deps.systemTransform && Array.isArray(event.system)) {
+      try {
+        const sysStrings = event.system.map((s) => s.text ?? '');
+        await deps.systemTransform(
+          { sessionID: event.sessionID },
+          { system: sysStrings },
+        );
+        event.system = sysStrings.map((text) => ({
+          type: 'text' as const,
+          text,
+        }));
+      } catch (err) {
+        log('[v2] system transform bridge failed', String(err));
+      }
+    }
+    // Messages transform: v2 Message.content -> v1 {info, parts} -> back.
+    // Pass the full v2 message as `info` (preserves id/metadata identity;
+    // isMessageWithParts only needs info.role + parts) with content as
+    // `parts` (shared ref so in-place part edits propagate). The transform
+    // can splice/reorder/replace the array (background-job-board
+    // injection does), so rebuild event.messages from the transformed
+    // v1messages rather than index-based content copy-back.
+    if (deps.messagesTransform && Array.isArray(event.messages)) {
+      try {
+        const v1messages = event.messages.map((m) => ({
+          info: m,
+          parts: m.content,
+        }));
+        await deps.messagesTransform({}, { messages: v1messages });
+        event.messages = v1messages.map((m) => {
+          const info = m.info as { content?: unknown };
+          info.content = m.parts;
+          return m.info;
+        }) as V2SessionContextEvent['messages'];
+      } catch (err) {
+        log('[v2] messages transform bridge failed', String(err));
+      }
+    }
+  };
+}
+
 export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
   return async (ctx: V2Context): Promise<V2Cleanup> => {
     const sessionId = new Date()
@@ -30,6 +282,15 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
       .replace(/[-:]/g, '')
       .slice(0, 15);
     initLogger(sessionId);
+    // Capability guard: some hosts load this same `setup` with a reduced or
+    // TUI-side context where agent/tool/session/event domains are missing.
+    // Skip registration instead of crashing the host (and retry-storming).
+    if (!ctx || typeof ctx.agent?.transform !== 'function') {
+      log(
+        '[v2] setup skipped: host context lacks agent.transform (TUI-side or reduced host)',
+      );
+      return async () => {};
+    }
     log('[v2] setup invoked', { app: ctx.app, cwd: process.cwd() });
 
     const directory = process.cwd();
@@ -170,22 +431,15 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
     try {
       const entries = Object.entries(synthCommands ?? {});
       if (entries.length > 0) {
+        const submitCommand = createSessionSubmit(ctx);
         const reg = await ctx.command.transform((draft) => {
-          for (const [name, cmd] of entries) {
-            try {
-              draft.update(name, (c) => {
-                c.name = name;
-                if (typeof cmd.template === 'string') c.template = cmd.template;
-                if (typeof cmd.description === 'string')
-                  c.description = cmd.description;
-              });
-            } catch (err) {
-              log('[v2] command adapt failed', { name, err: String(err) });
-            }
-          }
+          registerSynthCommands(draft, entries, submitCommand);
         });
         disposers.push(() => reg.dispose());
-        log('[v2] commands registered', { count: entries.length });
+        log('[v2] commands registered', {
+          // Includes `interview`, which the bridge registers below.
+          count: entries.length,
+        });
       }
     } catch (err) {
       log('[v2] command.transform failed', String(err));
@@ -207,18 +461,14 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
       log('[v2] interview command registration failed', String(err));
     }
 
+    // ── Session context hook: command markers + system/messages transforms ──
+    // One registration handles: the interview marker bridge, generic command
+    // marker dispatch (deepwork/reflect/loop), chat.message agent tracking,
+    // and the v1 system/messages transforms.
     try {
-      const reg = await ctx.session.hook('context', async (event) =>
-        interviewBridge.handleContext(event),
-      );
-      disposers.push(() => reg.dispose());
-      log('[v2] interview context bridge registered');
-    } catch (err) {
-      log('[v2] interview context bridge failed', String(err));
-    }
-
-    // ── System + messages transforms (session context hook) ──
-    try {
+      const commandBefore = v1Hooks['command.execute.before'] as
+        | V1CommandBeforeHook
+        | undefined;
       const systemTransform = v1Hooks['experimental.chat.system.transform'] as
         | ((i: unknown, o: { system: string[] }) => Promise<void>)
         | undefined;
@@ -239,62 +489,16 @@ export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
           ) => Promise<void>)
         | undefined;
 
-      if (systemTransform || messagesTransform || chatMessage) {
-        const reg = await ctx.session.hook('context', async (event) => {
-          // Agent tracking (chat.message equivalent).
-          if (chatMessage) {
-            try {
-              await chatMessage(
-                { sessionID: event.sessionID, agent: event.agent },
-                undefined,
-              );
-            } catch (err) {
-              log('[v2] chat.message bridge failed', String(err));
-            }
-          }
-          // System transform: v2 SystemPart[] -> v1 string[] -> mutate -> back.
-          if (systemTransform && Array.isArray(event.system)) {
-            try {
-              const sysStrings = event.system.map((s) => s.text ?? '');
-              await systemTransform(
-                { sessionID: event.sessionID },
-                { system: sysStrings },
-              );
-              event.system = sysStrings.map((text) => ({
-                type: 'text' as const,
-                text,
-              }));
-            } catch (err) {
-              log('[v2] system transform bridge failed', String(err));
-            }
-          }
-          // Messages transform: v2 Message.content -> v1 {info, parts} -> back.
-          // Pass the full v2 message as `info` (preserves id/metadata identity;
-          // isMessageWithParts only needs info.role + parts) with content as
-          // `parts` (shared ref so in-place part edits propagate). The transform
-          // can splice/reorder/replace the array (background-job-board
-          // injection does), so rebuild event.messages from the transformed
-          // v1messages rather than index-based content copy-back.
-          if (messagesTransform && Array.isArray(event.messages)) {
-            try {
-              const v1messages = event.messages.map((m) => ({
-                info: m,
-                parts: m.content,
-              }));
-              await messagesTransform({}, { messages: v1messages });
-              event.messages = v1messages.map((m) => {
-                const info = m.info as { content?: unknown };
-                info.content = m.parts;
-                return m.info;
-              }) as V2SessionContextEvent['messages'];
-            } catch (err) {
-              log('[v2] messages transform bridge failed', String(err));
-            }
-          }
-        });
-        disposers.push(() => reg.dispose());
-        log('[v2] session context hook registered');
-      }
+      const handler = createSessionContextHandler({
+        interviewHandleContext: (event) => interviewBridge.handleContext(event),
+        commandBefore,
+        chatMessage,
+        systemTransform,
+        messagesTransform,
+      });
+      const reg = await ctx.session.hook('context', handler);
+      disposers.push(() => reg.dispose());
+      log('[v2] session context hook registered');
     } catch (err) {
       log('[v2] session.hook(context) failed', String(err));
     }

+ 32 - 7
src/v2/types.ts

@@ -17,14 +17,26 @@ export interface V2AgentDraft {
 export interface V2ToolDraft {
   add(tool: Record<string, unknown>): void;
 }
+/** A v2 command definition passed to `command.transform` drafts. The command
+ * body runs `execute` directly (no template field). */
+export interface V2CommandDefinition {
+  name: string;
+  description?: string;
+  execute: (input: {
+    sessionID: string;
+    prompt: {
+      text: string;
+      files?: unknown[];
+      agents?: unknown[];
+      skills?: unknown[];
+    };
+    delivery?: unknown;
+  }) => Promise<void>;
+}
+/** Command transform draft. v2 command drafts are add-only;
+ * `V2CommandDraft` mirrors the `add()` shape. */
 export interface V2CommandDraft {
-  list(): Array<Record<string, unknown>>;
-  get(name: string): Record<string, unknown> | undefined;
-  update(
-    name: string,
-    update: (command: Record<string, unknown>) => void,
-  ): void;
-  remove(name: string): void;
+  add(def: V2CommandDefinition): void;
 }
 export interface V2SessionContextEvent {
   readonly sessionID: string;
@@ -84,12 +96,25 @@ export interface V2Context {
       name: 'context',
       cb: (event: V2SessionContextEvent) => Promise<void>,
     ): Promise<V2Registration>;
+    /** v2 session.prompt — flat PromptInput ({sessionID, text, files?,
+     * agents?, skills?, metadata?, delivery?, resume?}). */
+    prompt?(input: Record<string, unknown>): Promise<unknown>;
+    /** v2 session.synthetic — like prompt but not persisted as user input. */
+    synthetic?(input: Record<string, unknown>): Promise<unknown>;
+    /** v2 session.rename ({sessionID, title}). */
+    rename?(input: Record<string, unknown>): Promise<unknown>;
+    /** v2 session.switchAgent ({sessionID, agent}). */
+    switchAgent?(input: Record<string, unknown>): Promise<unknown>;
   };
   event: {
     subscribe(): AsyncIterable<Record<string, unknown>>;
   };
 }
 
+/** The v2 session domain (context hook + runtime-probed methods), declared
+ * once so adapters share the exact shape. */
+export type V2Session = V2Context['session'];
+
 export type V2Cleanup = () => Promise<void> | void;
 
 /** Parsed v2 Model.Ref derived from a v1 "provider/model" string. */