setup.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224
  1. /**
  2. * v2 setup orchestration.
  3. *
  4. * Returns the `setup(ctx)` function v2 calls via `default.setup`. The setup
  5. * wraps the existing v1 factory (reusing ALL build logic) and translates the
  6. * returned v1 `Hooks` into v2 registrations: agent/tool/command transforms,
  7. * a single session context hook (system/messages transforms, chat.message
  8. * tracking, and interview + generic command marker dispatch), the native
  9. * `session.prompt` hook (once-per-admission chat.message fidelity, with a
  10. * context-hook fallback on older hosts), tool execute hooks, and the event
  11. * stream. Each bridge is independently try/catch-guarded.
  12. */
  13. import { loadPluginConfig } from '../config/loader';
  14. import { InterviewConfigSchema } from '../config/schema';
  15. import {
  16. type SyntheticPartCacheHint,
  17. setDefaultSyntheticPartCacheHint,
  18. } from '../hooks/cache-safe-injection';
  19. import { OhMyOpenCodeLite } from '../index';
  20. import type { McpConfig } from '../mcp/types';
  21. import { INTERNAL_INITIATOR_METADATA_KEY } from '../utils/internal-initiator';
  22. import { initLogger, log } from '../utils/logger';
  23. import { adaptTool, applyAgentToDraft } from './adapters';
  24. import { buildPluginInput, resolveV2Directory } from './client-shim';
  25. import { subagentArgsToV1, toolNameToV1, v1ArgsToSubagent } from './delegation';
  26. import { mapV2EventToV1 } from './event-adapter';
  27. import { createV2InterviewBridge } from './interview-bridge';
  28. import {
  29. createSessionSubmit,
  30. textFromContent,
  31. type V2CommandSubmit,
  32. } from './session-submit';
  33. import type {
  34. V2Cleanup,
  35. V2CommandDefinition,
  36. V2CommandDraft,
  37. V2Context,
  38. V2SessionContextEvent,
  39. V2SessionPromptEvent,
  40. V2ToolAfterEvent,
  41. V2ToolBeforeEvent,
  42. } from './types';
  43. /** v1 `command.execute.before` hook shape (see src/index.ts wiring). */
  44. export type V1CommandBeforeHook = (
  45. input: { command: string; sessionID: string; arguments: string },
  46. output: {
  47. parts: Array<{
  48. type: string;
  49. text?: string;
  50. synthetic?: boolean;
  51. metadata?: Record<string, unknown>;
  52. }>;
  53. },
  54. ) => Promise<void>;
  55. /** v1 command hook part shape. */
  56. type V1CommandPart = {
  57. type: string;
  58. text?: string;
  59. synthetic?: boolean;
  60. metadata?: Record<string, unknown>;
  61. };
  62. /** Wrap slash-command arguments in the generic v2 command marker. v2 command
  63. * drafts are add-only (no `template`), so `execute` submits this marker as a
  64. * plain user prompt and the session context hook recovers it below. */
  65. export function wrapCommandMarker(name: string, args: string): string {
  66. return `<omos-cmd-command data-name="${name}">${args}</omos-cmd-command>`;
  67. }
  68. // Whole-text anchored: v2 writes the marker as the entire submitted prompt,
  69. // so whole-text anchoring is the contract. A user-typed embedded marker must
  70. // not hijack dispatch in the merged session context hook.
  71. const COMMAND_MARKER_PATTERN =
  72. /^\s*<omos-cmd-command\s+data-name="([\w.-]+)">([\s\S]*?)<\/omos-cmd-command>\s*$/;
  73. export interface ParsedCommandMarker {
  74. name: string;
  75. args: string;
  76. }
  77. /** Parse the generic command marker from a message text, if present. */
  78. export function parseCommandMarker(
  79. text: string,
  80. ): ParsedCommandMarker | undefined {
  81. const match = text.match(COMMAND_MARKER_PATTERN);
  82. if (!match) return undefined;
  83. return { name: match[1], args: match[2] };
  84. }
  85. /** Strip the marker tags from marker-only `text`, leaving the raw args. */
  86. export function stripCommandMarker(text: string): string {
  87. // Function replacer: a string replacer would interpret `$`-sequences in
  88. // the captured args. Group 1 is the command name; group 2 the args.
  89. return text.replace(
  90. COMMAND_MARKER_PATTERN,
  91. (_match, _name: string, args: string) => args,
  92. );
  93. }
  94. /** Register one v1 synth command on a v2 command draft. Uses `add` when
  95. * present; callers wrap per-command in try/catch so a throwing `draft.add`
  96. * only skips that command. */
  97. export function createCommandRegistration(
  98. draft: V2CommandDraft,
  99. name: string,
  100. cmd: { description?: string },
  101. submit: V2CommandSubmit,
  102. ): void {
  103. if (typeof draft.add !== 'function') {
  104. log('[v2] command draft has no add', { name });
  105. return;
  106. }
  107. const definition: V2CommandDefinition = {
  108. name,
  109. ...(typeof cmd.description === 'string'
  110. ? { description: cmd.description }
  111. : {}),
  112. execute: async (invocation) => {
  113. // Never throw: v2 surfaces command execution errors to the user.
  114. try {
  115. await submit(
  116. invocation?.sessionID ?? '',
  117. wrapCommandMarker(name, invocation?.prompt?.text ?? ''),
  118. );
  119. } catch (err) {
  120. log('[v2] command submit failed', { name, err: String(err) });
  121. }
  122. },
  123. };
  124. draft.add(definition);
  125. }
  126. /** Register the v1 synth commands on a v2 command draft. `interview` is
  127. * owned by the interview bridge's own registration (whose context hook owns
  128. * the interview marker), so it is skipped here — a duplicate `draft.add`
  129. * would break `/interview` on host builds that are first-wins or throw on
  130. * duplicates. */
  131. export function registerSynthCommands(
  132. draft: V2CommandDraft,
  133. entries: Array<[string, { description?: string }]>,
  134. submit: V2CommandSubmit,
  135. ): void {
  136. for (const [name, cmd] of entries) {
  137. if (name === 'interview') continue; // owned by the interview bridge registration below
  138. try {
  139. createCommandRegistration(draft, name, cmd, submit);
  140. } catch (err) {
  141. log('[v2] command adapt failed', { name, err: String(err) });
  142. }
  143. }
  144. }
  145. /** Dispatch a generic command marker found in the trailing user message to
  146. * the v1 `command.execute.before` hook, then replace that message's content
  147. * with the hook-produced parts. Mirrors the interview bridge mutation
  148. * semantics: only the trailing message is touched so earlier messages stay
  149. * byte-for-byte identical (provider prompt-cache prefix reuse). */
  150. export async function applyCommandMarkerToContext(
  151. event: V2SessionContextEvent,
  152. commandBefore: V1CommandBeforeHook,
  153. ): Promise<void> {
  154. const trailing = event.messages.at(-1);
  155. if (trailing?.role !== 'user') return;
  156. const text = textFromContent(trailing.content);
  157. const parsed = parseCommandMarker(text);
  158. if (!parsed) return;
  159. const output = { parts: [] as V1CommandPart[] };
  160. await commandBefore(
  161. {
  162. command: parsed.name,
  163. sessionID: event.sessionID,
  164. arguments: parsed.args.trim(),
  165. },
  166. output,
  167. );
  168. if (output.parts.length > 0) {
  169. trailing.content = output.parts.map((part) => ({ ...part }));
  170. return;
  171. }
  172. // Hook produced nothing: strip the marker and leave the raw args text.
  173. trailing.content = [{ type: 'text', text: stripCommandMarker(text) }];
  174. }
  175. /** Payload the v1 `chat.message` bridge feeds its consumers (a subset of
  176. * the real v1 hook input — see src/index.ts wiring). */
  177. export type V1ChatMessageInput = {
  178. sessionID: string;
  179. agent?: string;
  180. model?: { providerID: string; modelID: string; variant?: string };
  181. messageID?: string;
  182. parts?: unknown[];
  183. };
  184. /** Deps injected into the single session context hook. */
  185. export interface V2SessionContextHandlerDeps {
  186. /** Interview bridge handleContext (transcript projection + /interview
  187. * marker dispatch). */
  188. interviewHandleContext: (event: V2SessionContextEvent) => Promise<void>;
  189. /** v1 `command.execute.before` hook (generic command marker dispatch). */
  190. commandBefore?: V1CommandBeforeHook;
  191. /** v1 `chat.message` hook (per-request context emulation). Omitted when
  192. * the native v2 `session.prompt` hook owns message-scoped delivery. */
  193. chatMessage?: (input: V1ChatMessageInput, output: unknown) => Promise<void>;
  194. /** Native prompt-hook mode: records per-session agent/model from
  195. * context events and forwards newly learned state to the v1
  196. * `chat.message` hook (see createSessionPromptBridge). */
  197. observeContextAgent?: (event: V2SessionContextEvent) => Promise<void>;
  198. /** Agent known for a session, from the agent-learned state the
  199. * session-prompt bridge / context events maintain. Used to enrich
  200. * transcript user messages the v1 injection gates key on when the
  201. * context event itself carries no agent. */
  202. knownAgentForSession?: (sessionID: string) => string | undefined;
  203. /** v1 `experimental.chat.system.transform` hook. */
  204. systemTransform?: (
  205. input: unknown,
  206. output: { system: string[] },
  207. ) => Promise<void>;
  208. /** v1 `experimental.chat.messages.transform` hook. */
  209. messagesTransform?: (
  210. input: unknown,
  211. output: {
  212. messages: Array<{ info: { role: string }; parts: unknown[] }>;
  213. },
  214. ) => Promise<void>;
  215. /** CacheHint stamped on parts injected while the bridged messages
  216. * transform runs (v2 ContentPart.cache; v1 bytes never change — see
  217. * cache-safe-injection). */
  218. syntheticPartCacheHint?: SyntheticPartCacheHint;
  219. }
  220. /** Build the single `ctx.session.hook("context")` handler: interview marker
  221. * bridge, generic command marker dispatch, chat.message agent tracking, and
  222. * the v1 system/messages transforms — each independently try/catch-guarded. */
  223. export function createSessionContextHandler(
  224. deps: V2SessionContextHandlerDeps,
  225. ): (event: V2SessionContextEvent) => Promise<void> {
  226. return async (event) => {
  227. // Interview marker bridge (transcript projection + /interview).
  228. try {
  229. await deps.interviewHandleContext(event);
  230. } catch (err) {
  231. log('[v2] interview context bridge failed', String(err));
  232. }
  233. // Generic command marker dispatch (deepwork / reflect / loop).
  234. if (deps.commandBefore) {
  235. try {
  236. await applyCommandMarkerToContext(event, deps.commandBefore);
  237. } catch (err) {
  238. log('[v2] command context bridge failed', String(err));
  239. }
  240. }
  241. // Agent/model discovery (native prompt-hook mode): the prompt hook
  242. // fires before the first context event, so first-admission agent/model
  243. // must be discovered here and forwarded to the v1 chat.message hook
  244. // (once per newly learned state, not per request).
  245. if (deps.observeContextAgent) {
  246. try {
  247. await deps.observeContextAgent(event);
  248. } catch (err) {
  249. log('[v2] chat.message agent-discovery bridge failed', String(err));
  250. }
  251. }
  252. // Agent tracking (chat.message equivalent, per-request emulation —
  253. // only when the native prompt hook did NOT take over).
  254. if (deps.chatMessage) {
  255. try {
  256. const userMessage = [...event.messages]
  257. .reverse()
  258. .find((message) => message.role === 'user');
  259. await deps.chatMessage(
  260. {
  261. sessionID: event.sessionID,
  262. agent: event.agent,
  263. ...(userMessage?.id ? { messageID: userMessage.id } : {}),
  264. },
  265. undefined,
  266. );
  267. } catch (err) {
  268. log('[v2] chat.message bridge failed', String(err));
  269. }
  270. }
  271. // System transform: v2 SystemPart[] -> v1 string[] -> mutate -> back.
  272. if (deps.systemTransform && Array.isArray(event.system)) {
  273. try {
  274. const sysStrings = event.system.map((s) => s.text ?? '');
  275. await deps.systemTransform(
  276. { sessionID: event.sessionID },
  277. { system: sysStrings },
  278. );
  279. event.system = sysStrings.map((text) => ({
  280. type: 'text' as const,
  281. text,
  282. }));
  283. } catch (err) {
  284. log('[v2] system transform bridge failed', String(err));
  285. }
  286. }
  287. // Messages transform: v2 Message.content -> v1 {info, parts} -> back.
  288. // Pass the full v2 message as `info` (preserves id/metadata identity;
  289. // isMessageWithParts only needs info.role + parts) with content as
  290. // `parts` (shared ref so in-place part edits propagate). The transform
  291. // can splice/reorder/replace the array (background-job-board
  292. // injection does), so rebuild event.messages from the transformed
  293. // v1messages rather than index-based content copy-back.
  294. if (deps.messagesTransform && Array.isArray(event.messages)) {
  295. // Transcript identity enrichment (v2-only): live v2 hosts carry
  296. // only {id, time, text, type} on transcript user messages, but the
  297. // bridged v1 injection gates (phase-reminder, background-job-board,
  298. // post-file-tool-nudge) key on user-message info.sessionID /
  299. // info.agent — without this stamp every injection skips on v2.
  300. // Metadata-only (envelope fields; parts/content bytes untouched)
  301. // and strictly absence-gated: host-provided values always win.
  302. // Idempotent across context events — a message stamped once never
  303. // qualifies for stamping again.
  304. const knownAgent =
  305. typeof event.agent === 'string' && event.agent
  306. ? event.agent
  307. : deps.knownAgentForSession?.(event.sessionID);
  308. for (const message of event.messages) {
  309. if (message.role !== 'user') continue;
  310. if (message.sessionID === undefined) {
  311. message.sessionID = event.sessionID;
  312. }
  313. if (message.agent === undefined && knownAgent) {
  314. message.agent = knownAgent;
  315. }
  316. }
  317. // CacheHint tagging (v2-only): parts injected through
  318. // cache-safe-injection while the bridged transform runs carry an
  319. // ephemeral cache hint (v2 ContentPart.cache), so providers cap the
  320. // injected zone's cache contribution. Scoped set/restore — the v1
  321. // pipeline never executes inside this wrapper, so v1 payload bytes
  322. // never change (pinned by the v1 snapshot/property suites).
  323. const restoreCacheHint = deps.syntheticPartCacheHint
  324. ? setDefaultSyntheticPartCacheHint(deps.syntheticPartCacheHint)
  325. : undefined;
  326. try {
  327. const v1messages = event.messages.map((m) => ({
  328. info: m,
  329. parts: m.content,
  330. }));
  331. await deps.messagesTransform({}, { messages: v1messages });
  332. event.messages = v1messages.map((m) => {
  333. const info = m.info as { content?: unknown };
  334. info.content = m.parts;
  335. return m.info;
  336. }) as V2SessionContextEvent['messages'];
  337. } catch (err) {
  338. log('[v2] messages transform bridge failed', String(err));
  339. } finally {
  340. restoreCacheHint?.();
  341. }
  342. }
  343. };
  344. }
  345. /** Cap on per-session bookkeeping maps (FIFO eviction) — mirrors the
  346. * tool-loop guard's MAX_TRACKED_SESSIONS rationale. */
  347. const MAX_PROMPT_BRIDGE_SESSIONS = 1024;
  348. function pruneSessionMap<T>(map: Map<string, T>): void {
  349. while (map.size > MAX_PROMPT_BRIDGE_SESSIONS) {
  350. const oldest = map.keys().next().value as string | undefined;
  351. if (oldest === undefined) break;
  352. map.delete(oldest);
  353. }
  354. }
  355. /** v2 Model.Ref from a context event (`{id, providerID, variant?}`) →
  356. * v1 chat.message model (`{providerID, modelID, variant?}`). */
  357. function v1ModelFromContext(
  358. model: Record<string, unknown> | undefined,
  359. ): { providerID: string; modelID: string; variant?: string } | undefined {
  360. if (!model) return undefined;
  361. const id = model.id;
  362. const providerID = model.providerID;
  363. if (typeof id !== 'string' || typeof providerID !== 'string') {
  364. return undefined;
  365. }
  366. return {
  367. providerID,
  368. modelID: id,
  369. ...(typeof model.variant === 'string' ? { variant: model.variant } : {}),
  370. };
  371. }
  372. export interface V2SessionPromptBridge {
  373. /** `ctx.session.hook("prompt")` handler — one v1 chat.message delivery
  374. * per admitted input (dedupe by messageID). */
  375. handlePrompt(event: V2SessionPromptEvent): Promise<void>;
  376. /** Record per-session agent/model from context events; forward NEWLY
  377. * learned state to the v1 chat.message hook. */
  378. observeContext(event: V2SessionContextEvent): Promise<void>;
  379. /** Latest agent known for a session from the learned state above (the
  380. * identity source for transcript user-message enrichment). */
  381. agentForSession(sessionID: string): string | undefined;
  382. }
  383. /**
  384. * Native `session.prompt` hook → v1 `chat.message` bridge.
  385. *
  386. * v2's prompt hook fires ONCE per admitted input — endpoint prompts AND
  387. * subagent-tool child prompts (synthetic/shell/compaction inputs skip
  388. * it) — with the eventual inbox User `messageID`, the exact identity the
  389. * v1 chat.message consumers key on (task-session-manager +
  390. * orchestrator-wake `observeChatMessage`, toolLoopGuard
  391. * `observeNewUserMessage`). The context-hook emulation cannot provide
  392. * this: it fires per LLM request and has no prompt parts, so
  393. * `observeChatMessage`'s non-synthetic-part gate never passed on v2.
  394. *
  395. * The prompt payload carries NO agent/model, so `observeContext` learns
  396. * them from the (immediately following) context events and forwards
  397. * first-seen/changed state — preserving the v1 timing where the session
  398. * agent is known before the first tool call of a turn.
  399. *
  400. * Child-session filtering: none, deliberately — the context-hook
  401. * emulation never filtered child sessions either, and every consumer
  402. * gates itself (e.g. `shouldManageSession`).
  403. */
  404. export function createSessionPromptBridge(
  405. chatMessage: (input: V1ChatMessageInput, output: unknown) => Promise<void>,
  406. ): V2SessionPromptBridge {
  407. /** Last admitted messageID per session (once-per-admission dedupe). */
  408. const seenAdmissions = new Map<string, string>();
  409. /** Latest known agent/model per session (learned from context). */
  410. const sessionState = new Map<
  411. string,
  412. { agent?: string; model?: { providerID: string; modelID: string } }
  413. >();
  414. function trailingUserId(event: V2SessionContextEvent): string | undefined {
  415. const id = [...event.messages]
  416. .reverse()
  417. .find((message) => message.role === 'user')?.id;
  418. return typeof id === 'string' && id ? id : undefined;
  419. }
  420. return {
  421. async handlePrompt(event) {
  422. if (!event || typeof event !== 'object') return;
  423. const sessionID = event.sessionID;
  424. const messageID = event.messageID;
  425. if (typeof sessionID !== 'string' || !sessionID) return;
  426. if (typeof messageID !== 'string' || !messageID) return;
  427. if (seenAdmissions.get(sessionID) === messageID) return;
  428. seenAdmissions.set(sessionID, messageID);
  429. pruneSessionMap(seenAdmissions);
  430. const state = sessionState.get(sessionID);
  431. const prompt: Record<string, unknown> = isRecord(event.prompt)
  432. ? event.prompt
  433. : {};
  434. // Internal-initiator admissions (v2 orchestrator-wake queue prompts)
  435. // arrive as prompt `metadata` — the part metadata cannot survive the
  436. // text-only v2 translation (see client-shim). Restore it onto the
  437. // text part so isInternalInitiatorPart consumers classify the
  438. // admission as internal (wake admissions must not rearm the
  439. // no-progress cap or clear wake timers as user activity would).
  440. const internalInitiator =
  441. isRecord(event.metadata) &&
  442. event.metadata[INTERNAL_INITIATOR_METADATA_KEY] === true;
  443. // Rebuild the v1 parts view: observeChatMessage gates on a
  444. // non-synthetic text/file part being present.
  445. const parts: Array<Record<string, unknown>> = [];
  446. if (typeof prompt.text === 'string' && prompt.text) {
  447. parts.push(
  448. internalInitiator
  449. ? {
  450. type: 'text',
  451. text: prompt.text,
  452. synthetic: true,
  453. metadata: { [INTERNAL_INITIATOR_METADATA_KEY]: true },
  454. }
  455. : { type: 'text', text: prompt.text },
  456. );
  457. }
  458. if (Array.isArray(prompt.files)) {
  459. for (const file of prompt.files) {
  460. if (isRecord(file)) parts.push({ type: 'file', ...file });
  461. }
  462. }
  463. try {
  464. await chatMessage(
  465. {
  466. sessionID,
  467. messageID,
  468. ...(state?.agent ? { agent: state.agent } : {}),
  469. ...(state?.model ? { model: state.model } : {}),
  470. ...(parts.length > 0 ? { parts } : {}),
  471. },
  472. undefined,
  473. );
  474. } catch (err) {
  475. log('[v2] prompt-hook chat.message bridge failed', String(err));
  476. }
  477. },
  478. async observeContext(event) {
  479. if (!event || typeof event !== 'object') return;
  480. const sessionID = event.sessionID;
  481. if (typeof sessionID !== 'string' || !sessionID) return;
  482. const agent =
  483. typeof event.agent === 'string' && event.agent
  484. ? event.agent
  485. : undefined;
  486. const model = v1ModelFromContext(event.model);
  487. const previous = sessionState.get(sessionID);
  488. if (
  489. previous &&
  490. previous.agent === agent &&
  491. ((previous.model === undefined && model === undefined) ||
  492. (previous.model !== undefined &&
  493. model !== undefined &&
  494. previous.model.providerID === model.providerID &&
  495. previous.model.modelID === model.modelID))
  496. ) {
  497. return; // nothing newly learned — once-per-admission fidelity holds
  498. }
  499. sessionState.set(sessionID, {
  500. ...(agent ? { agent } : {}),
  501. ...(model ? { model } : {}),
  502. });
  503. pruneSessionMap(sessionState);
  504. try {
  505. await chatMessage(
  506. {
  507. sessionID,
  508. ...(agent ? { agent } : {}),
  509. ...(model ? { model } : {}),
  510. ...(trailingUserId(event)
  511. ? { messageID: trailingUserId(event) }
  512. : {}),
  513. },
  514. undefined,
  515. );
  516. } catch (err) {
  517. log('[v2] agent-discovery chat.message bridge failed', String(err));
  518. }
  519. },
  520. agentForSession(sessionID) {
  521. return sessionState.get(sessionID)?.agent;
  522. },
  523. };
  524. }
  525. /** The v2→v1 tool.execute bridge pair produced by
  526. * `createToolExecuteBridges`. */
  527. export interface V2ToolBridgeEvents {
  528. beforeBridge: (
  529. event: Record<string, unknown> & { input: unknown },
  530. ) => Promise<void>;
  531. afterBridge: (
  532. event: Record<string, unknown> & { result?: unknown },
  533. ) => Promise<void>;
  534. }
  535. function isRecord(value: unknown): value is Record<string, unknown> {
  536. return typeof value === 'object' && value !== null;
  537. }
  538. function textContent(value: unknown): string {
  539. if (typeof value === 'string') return value;
  540. if (!Array.isArray(value)) return '';
  541. return value
  542. .filter(isRecord)
  543. .filter((part) => part.type === 'text')
  544. .map((part) => (typeof part.text === 'string' ? part.text : ''))
  545. .join('');
  546. }
  547. function renderOutput(value: unknown): string {
  548. if (typeof value === 'string') return value;
  549. if (value === undefined) return '';
  550. try {
  551. const serialized = JSON.stringify(value);
  552. return serialized ?? String(value);
  553. } catch {
  554. return String(value);
  555. }
  556. }
  557. /** Formatted error text from a v2 execute.after `error` payload (string,
  558. * Error-like `{message}`, or structured record). Empty string when the
  559. * host provided nothing. */
  560. function errorTextOf(error: unknown): string {
  561. if (typeof error === 'string') return error;
  562. if (isRecord(error) && typeof error.message === 'string' && error.message) {
  563. return error.message;
  564. }
  565. return renderOutput(error);
  566. }
  567. /**
  568. * Copy a v1 after-hook's string output back into v2 without changing the
  569. * representation chosen by the v2 tool. In particular, image/file parts
  570. * must survive a v1 hook which can only see the concatenated text output.
  571. */
  572. function updateToolResultContent(
  573. original: unknown,
  574. originalText: string,
  575. updated: unknown,
  576. ): unknown {
  577. const text = typeof updated === 'string' ? updated : renderOutput(updated);
  578. if (typeof original === 'string') return text;
  579. if (!Array.isArray(original)) return updated;
  580. // The common after-hook mutation appends a warning. Put only the suffix on
  581. // the last text part so mixed content keeps its original ordering.
  582. if (text.startsWith(originalText) && text.length > originalText.length) {
  583. const suffix = text.slice(originalText.length);
  584. for (let index = original.length - 1; index >= 0; index -= 1) {
  585. const part = original[index];
  586. if (isRecord(part) && part.type === 'text') {
  587. return original.map((entry, entryIndex) =>
  588. entryIndex === index
  589. ? { ...part, text: `${part.text ?? ''}${suffix}` }
  590. : entry,
  591. );
  592. }
  593. }
  594. }
  595. let replacedTextPart = false;
  596. const content = original.map((part) => {
  597. if (!isRecord(part) || part.type !== 'text') return part;
  598. if (replacedTextPart) return { ...part, text: '' };
  599. replacedTextPart = true;
  600. return { ...part, text };
  601. });
  602. if (!replacedTextPart && text !== '') {
  603. content.push({ type: 'text', text });
  604. }
  605. return content;
  606. }
  607. /** Build the tool.execute.before/after v2→v1 bridges, including the
  608. * `subagent`→`task` delegation normalization. Exported for tests. */
  609. export function createToolExecuteBridges(
  610. before:
  611. | ((
  612. i: { tool: string; sessionID: string; callID: string },
  613. o: { args: unknown },
  614. ) => Promise<void>)
  615. | undefined,
  616. after: ((i: unknown, o: unknown) => Promise<void>) | undefined,
  617. ): V2ToolBridgeEvents {
  618. const beforeBridge = async (
  619. event: Record<string, unknown> & { input: unknown },
  620. ): Promise<void> => {
  621. if (!before) return;
  622. const e = event as unknown as V2ToolBeforeEvent;
  623. const isDelegation = e.tool.toLowerCase() === 'subagent';
  624. const argsView = isDelegation
  625. ? subagentArgsToV1(e.input)
  626. : { ...(e.input as object) };
  627. const out: { args: unknown } = { args: argsView };
  628. // Rethrow: v2 rejects the tool call when execute.before fails, which is
  629. // how the v1 anti-duplicate / relaunch-lease guards enforce on v2.
  630. await before(
  631. { tool: toolNameToV1(e.tool), sessionID: e.sessionID, callID: e.id },
  632. out,
  633. );
  634. // Hooks like apply-patch replace output.args with recovered/normalized
  635. // arguments; write back (translated back to v2 names for delegation)
  636. // so v2 executes the repaired input instead of the original.
  637. e.input = isDelegation
  638. ? v1ArgsToSubagent(out.args as Record<string, unknown>)
  639. : out.args;
  640. };
  641. const afterBridge = async (
  642. event: Record<string, unknown> & { result?: unknown },
  643. ): Promise<void> => {
  644. if (!after) return;
  645. const e = event as unknown as V2ToolAfterEvent;
  646. const isDelegation = e.tool.toLowerCase() === 'subagent';
  647. // v2 execute.after is status-discriminated: `completed` → mutable
  648. // result; `error` → `error` payload (result may be absent or stale).
  649. // Absent status (older hosts) keeps the completed path. On error the
  650. // v1 output is synthesized from the error text — that is exactly the
  651. // v1 shape, where a failed tool's model-visible output WAS the error
  652. // message — so error-recovery consumers (json-error-recovery appends
  653. // its reminder to output.output) still run meaningfully. An errored
  654. // call never presents its result content as a successful output.
  655. const errored = e.status === 'error';
  656. // Map v2 Tool.Result.content (string | Content[]) -> v1 output.output
  657. // string; the v1 after-hooks (postFileToolNudge, jsonErrorRecovery,
  658. // taskSessionManagerAfter) read output.output to decide nudges.
  659. const result = e.result as
  660. | {
  661. content?: unknown;
  662. output?: unknown;
  663. metadata?: Record<string, unknown>;
  664. }
  665. | undefined;
  666. const rawContent = result?.content;
  667. const hasRenderableContent =
  668. result !== undefined &&
  669. (typeof rawContent === 'string' ||
  670. (Array.isArray(rawContent) && rawContent.length > 0));
  671. const rawOutput = result?.output;
  672. const content = errored
  673. ? errorTextOf(e.error)
  674. : hasRenderableContent
  675. ? textContent(rawContent)
  676. : renderOutput(rawOutput);
  677. const originalMetadata = result?.metadata;
  678. const initialTitle =
  679. isRecord(result?.metadata) && typeof result.metadata.title === 'string'
  680. ? result.metadata.title
  681. : '';
  682. const output: {
  683. output: unknown;
  684. title: string;
  685. metadata: Record<string, unknown>;
  686. } = {
  687. output: content,
  688. title: initialTitle,
  689. metadata: isRecord(originalMetadata) ? originalMetadata : {},
  690. };
  691. await after(
  692. {
  693. tool: toolNameToV1(e.tool),
  694. sessionID: e.sessionID,
  695. callID: e.id,
  696. args: isDelegation ? subagentArgsToV1(e.input) : e.input,
  697. },
  698. output,
  699. );
  700. if (result) {
  701. const updatedText =
  702. typeof output.output === 'string'
  703. ? output.output
  704. : renderOutput(output.output);
  705. if (updatedText !== content) {
  706. if (errored) {
  707. // Errored call: the model-visible content is the synthesized
  708. // error text plus whatever the hook appended (e.g. the
  709. // json-error-recovery reminder). Written as plain string
  710. // content — never keep a stale/empty result content looking
  711. // like a successful output.
  712. result.content = updatedText;
  713. } else if (hasRenderableContent) {
  714. result.content = updateToolResultContent(
  715. rawContent,
  716. content,
  717. output.output,
  718. );
  719. } else if (Object.hasOwn(result, 'output')) {
  720. // Keep output as the machine-readable value. The hook's transformed
  721. // text belongs in the model-visible content field.
  722. result.content = updatedText;
  723. }
  724. }
  725. const metadataChanged =
  726. isRecord(output.metadata) &&
  727. output.metadata !== originalMetadata &&
  728. (isRecord(originalMetadata) || Object.keys(output.metadata).length > 0);
  729. if (metadataChanged) {
  730. result.metadata = output.metadata;
  731. }
  732. if (output.title !== initialTitle) {
  733. result.metadata = {
  734. ...(isRecord(result.metadata) ? result.metadata : {}),
  735. title: output.title,
  736. };
  737. }
  738. }
  739. };
  740. return { beforeBridge, afterBridge };
  741. }
  742. /** v1 McpConfig → v2 Mcp.ServerConfig(字段几乎同构;仅剔除 undefined)。 */
  743. export function adaptMcpServer(v1: McpConfig): Record<string, unknown> {
  744. const out: Record<string, unknown> = { type: v1.type };
  745. if (v1.type === 'remote') {
  746. out.url = v1.url;
  747. if (v1.headers) out.headers = v1.headers;
  748. if (v1.oauth === false) out.oauth = false;
  749. } else {
  750. out.command = v1.command;
  751. if (v1.environment) out.environment = v1.environment;
  752. }
  753. return out;
  754. }
  755. export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
  756. return async (ctx: V2Context): Promise<V2Cleanup> => {
  757. const sessionId = new Date()
  758. .toISOString()
  759. .replace(/[-:]/g, '')
  760. .slice(0, 15);
  761. initLogger(sessionId);
  762. // Capability guard: some hosts load this same `setup` with a reduced or
  763. // TUI-side context where agent/tool/session/event domains are missing.
  764. // Skip registration instead of crashing the host (and retry-storming).
  765. if (!ctx || typeof ctx.agent?.transform !== 'function') {
  766. log(
  767. '[v2] setup skipped: host context lacks agent.transform (TUI-side or reduced host)',
  768. );
  769. return async () => {};
  770. }
  771. log('[v2] setup invoked', { app: ctx.app, cwd: process.cwd() });
  772. // Directory/location resolution lives in the shim now (single source);
  773. // setup still needs the directory for config loading and tool adapters.
  774. const directory = resolveV2Directory(ctx);
  775. const disposers: Array<() => Promise<void> | void> = [];
  776. let v1Hooks: Record<string, unknown> | undefined;
  777. try {
  778. log('[v2] importing v1 factory...');
  779. // Capability probe: v2 one-shot generation (`ctx.generate.text`),
  780. // probed structurally since V2Context stays minimal by design.
  781. // Powers the smartfetch secondary-model summaries without a temp
  782. // session; absent on older hosts → no `experimental_v2` key at all.
  783. const generateText = (
  784. ctx as {
  785. generate?: {
  786. text?: (input: {
  787. prompt: string;
  788. model?: { id: string; providerID: string; variant?: string };
  789. }) => Promise<{ text: string }>;
  790. };
  791. }
  792. ).generate?.text;
  793. const generateChannel =
  794. typeof generateText === 'function'
  795. ? {
  796. generateText: (
  797. prompt: string,
  798. model?: { id: string; providerID: string; variant?: string },
  799. ) => generateText({ prompt, ...(model ? { model } : {}) }),
  800. }
  801. : undefined;
  802. log('[v2] ctx.generate.text', {
  803. available: typeof generateText === 'function',
  804. });
  805. const pluginInput = buildPluginInput(ctx, generateChannel);
  806. log('[v2] calling OhMyOpenCodeLite...');
  807. v1Hooks = (await OhMyOpenCodeLite(
  808. pluginInput as never,
  809. )) as unknown as Record<string, unknown>;
  810. log('[v2] v1 factory initialized', {
  811. agents: Object.keys((v1Hooks as { agent?: object }).agent ?? {}).length,
  812. tools: Object.keys((v1Hooks as { tool?: object }).tool ?? {}).length,
  813. });
  814. } catch (err) {
  815. log('[v2] FATAL: v1 factory init failed', String(err));
  816. console.error('[oh-my-opencode-slim][v2] factory init failed:', err);
  817. // Don't hard-fail the whole plugin; register nothing and stay loaded.
  818. return async () => {};
  819. }
  820. if (!v1Hooks) return async () => {};
  821. const interviewConfig = InterviewConfigSchema.parse(
  822. loadPluginConfig(directory).interview ?? {},
  823. );
  824. const interviewBridge = createV2InterviewBridge(ctx, interviewConfig);
  825. disposers.push(() => interviewBridge.dispose());
  826. // Resolve agents/commands via the v1 config() hook (model resolution etc.).
  827. let resolvedAgents: Record<string, Record<string, unknown>> | undefined;
  828. let synthCommands:
  829. | Record<string, { template?: string; description?: string }>
  830. | undefined;
  831. try {
  832. const synth: Record<string, unknown> = {};
  833. const configFn = v1Hooks.config as
  834. | ((c: Record<string, unknown>) => Promise<void>)
  835. | undefined;
  836. if (configFn) {
  837. await configFn(synth);
  838. if (synth.agent && typeof synth.agent === 'object') {
  839. resolvedAgents = synth.agent as Record<
  840. string,
  841. Record<string, unknown>
  842. >;
  843. }
  844. const cmd = synth.command as
  845. | Record<string, { template?: string; description?: string }>
  846. | undefined;
  847. if (cmd) synthCommands = cmd;
  848. }
  849. } catch (err) {
  850. log(
  851. '[v2] config() hook failed (continuing with raw agents)',
  852. String(err),
  853. );
  854. }
  855. if (!resolvedAgents) {
  856. resolvedAgents =
  857. (v1Hooks.agent as Record<string, Record<string, unknown>>) ?? {};
  858. }
  859. // ── Agents ──
  860. try {
  861. const reg = await ctx.agent.transform((draft) => {
  862. for (const [name, cfg] of Object.entries(resolvedAgents ?? {})) {
  863. try {
  864. applyAgentToDraft(draft, name, cfg);
  865. } catch (err) {
  866. log('[v2] agent adapt failed', { name, err: String(err) });
  867. }
  868. }
  869. // Make orchestrator the default primary agent.
  870. if (resolvedAgents?.orchestrator) {
  871. try {
  872. draft.default('orchestrator');
  873. } catch {
  874. /* default() optional */
  875. }
  876. }
  877. });
  878. disposers.push(() => reg.dispose());
  879. log('[v2] agents registered', {
  880. count: Object.keys(resolvedAgents ?? {}).length,
  881. });
  882. } catch (err) {
  883. log('[v2] agent.transform failed', String(err));
  884. }
  885. // ── Tools ──
  886. try {
  887. const tools = (v1Hooks.tool ?? {}) as Record<
  888. string,
  889. Record<string, unknown>
  890. >;
  891. const toolEntries = Object.entries(tools);
  892. if (toolEntries.length > 0) {
  893. // Precompute JSON schemas from zod shapes (zod is bundled in v2 build).
  894. const zod = (await import('zod')) as unknown as {
  895. object?: (s: unknown) => unknown;
  896. toJSONSchema?: (s: unknown) => unknown;
  897. };
  898. const schemaFor = (def: Record<string, unknown>): unknown => {
  899. const args = def.args;
  900. if (!args || typeof args !== 'object') {
  901. return { type: 'object', properties: {} };
  902. }
  903. try {
  904. const obj = zod.object?.(args);
  905. if (zod.toJSONSchema && obj) return zod.toJSONSchema(obj);
  906. } catch {
  907. /* fall through */
  908. }
  909. return { type: 'object', properties: {} };
  910. };
  911. const reg = await ctx.tool.transform((draft) => {
  912. for (const [name, def] of toolEntries) {
  913. try {
  914. // adaptTool stamps `options: { codemode: false }` on every
  915. // registration (CodeMode opt-out) — without it v2's
  916. // Tool.snapshot() confines the tool to the `execute` tool's
  917. // JS runtime instead of the model-visible tool catalog.
  918. draft.add(adaptTool(name, def, directory, schemaFor(def)));
  919. } catch (err) {
  920. log('[v2] tool adapt failed', { name, err: String(err) });
  921. }
  922. }
  923. });
  924. disposers.push(() => reg.dispose());
  925. log('[v2] tools registered', { count: toolEntries.length });
  926. }
  927. } catch (err) {
  928. log('[v2] tool.transform failed', String(err));
  929. }
  930. // ── Built-in MCPs (ctx.mcp.transform, v2 ≥ #45408) ──
  931. try {
  932. const mcps = (v1Hooks.mcp ?? {}) as Record<string, McpConfig>;
  933. const entries = Object.entries(mcps);
  934. if (entries.length > 0 && typeof ctx.mcp?.transform === 'function') {
  935. const reg = await ctx.mcp.transform((draft) => {
  936. for (const [name, cfg] of entries) {
  937. try {
  938. draft.set(name, adaptMcpServer(cfg));
  939. } catch (err) {
  940. log('[v2] mcp adapt failed', { name, err: String(err) });
  941. }
  942. }
  943. });
  944. disposers.push(() => reg.dispose());
  945. log('[v2] mcp servers registered', { count: entries.length });
  946. } else if (entries.length > 0) {
  947. log('[v2] ctx.mcp.transform unavailable; MCPs stay config-only');
  948. }
  949. } catch (err) {
  950. log('[v2] mcp.transform failed', String(err));
  951. }
  952. // ── Commands (deepwork / reflect / loop slash commands) ──
  953. try {
  954. const entries = Object.entries(synthCommands ?? {});
  955. if (entries.length > 0) {
  956. const submitCommand = createSessionSubmit(ctx);
  957. const reg = await ctx.command.transform((draft) => {
  958. registerSynthCommands(draft, entries, submitCommand);
  959. });
  960. disposers.push(() => reg.dispose());
  961. log('[v2] commands registered', {
  962. // Includes `interview`, which the bridge registers below.
  963. count: entries.length,
  964. });
  965. }
  966. } catch (err) {
  967. log('[v2] command.transform failed', String(err));
  968. }
  969. // `/interview` is a v2 command marker. The context bridge consumes the
  970. // rendered marker and delegates the actual behavior to the interview
  971. // service without expanding the global v2 client shim.
  972. try {
  973. const reg = await ctx.command.transform((draft) => {
  974. try {
  975. interviewBridge.registerCommand(draft);
  976. } catch (err) {
  977. log('[v2] interview command adapt failed', String(err));
  978. }
  979. });
  980. disposers.push(() => reg.dispose());
  981. } catch (err) {
  982. log('[v2] interview command registration failed', String(err));
  983. }
  984. // ── Session context hook: command markers + system/messages transforms ──
  985. // One registration handles: the interview marker bridge, generic command
  986. // marker dispatch (deepwork/reflect/loop), chat.message agent tracking
  987. // (or agent/model discovery when the native prompt hook is active), and
  988. // the v1 system/messages transforms.
  989. try {
  990. const commandBefore = v1Hooks['command.execute.before'] as
  991. | V1CommandBeforeHook
  992. | undefined;
  993. const systemTransform = v1Hooks['experimental.chat.system.transform'] as
  994. | ((i: unknown, o: { system: string[] }) => Promise<void>)
  995. | undefined;
  996. const messagesTransform = v1Hooks[
  997. 'experimental.chat.messages.transform'
  998. ] as
  999. | ((
  1000. i: unknown,
  1001. o: {
  1002. messages: Array<{ info: { role: string }; parts: unknown[] }>;
  1003. },
  1004. ) => Promise<void>)
  1005. | undefined;
  1006. const chatMessage = v1Hooks['chat.message'] as
  1007. | ((i: V1ChatMessageInput, o: unknown) => Promise<void>)
  1008. | undefined;
  1009. // Native per-admission prompt hook (v2): `session.prompt` fires once
  1010. // per admitted input with the eventual inbox User messageID — the
  1011. // identity v1 chat.message consumers key on. When the host supports
  1012. // it, the context hook's per-request chat.message emulation narrows
  1013. // to agent/model discovery; older v2 hosts (hook name rejected)
  1014. // keep the full emulation.
  1015. let promptBridge: V2SessionPromptBridge | undefined;
  1016. if (chatMessage) {
  1017. const bridge = createSessionPromptBridge(chatMessage);
  1018. try {
  1019. const promptReg = await ctx.session.hook(
  1020. 'prompt',
  1021. bridge.handlePrompt,
  1022. );
  1023. disposers.push(() => promptReg.dispose());
  1024. promptBridge = bridge;
  1025. log('[v2] native session prompt hook registered');
  1026. } catch (err) {
  1027. log(
  1028. '[v2] session.hook(prompt) unavailable; keeping chat.message context emulation',
  1029. String(err),
  1030. );
  1031. }
  1032. }
  1033. const handler = createSessionContextHandler({
  1034. interviewHandleContext: (event) => interviewBridge.handleContext(event),
  1035. commandBefore,
  1036. chatMessage: promptBridge ? undefined : chatMessage,
  1037. observeContextAgent: promptBridge?.observeContext,
  1038. // Transcript user-message enrichment falls back to the agent the
  1039. // prompt bridge learned when the context event carries none.
  1040. knownAgentForSession: (sessionID) =>
  1041. promptBridge?.agentForSession(sessionID),
  1042. systemTransform,
  1043. messagesTransform,
  1044. // v2 ContentPart cache hint for parts injected by the bridged
  1045. // transforms (v1 bytes never change — see the handler).
  1046. syntheticPartCacheHint: { type: 'ephemeral' },
  1047. });
  1048. const reg = await ctx.session.hook('context', handler);
  1049. disposers.push(() => reg.dispose());
  1050. log('[v2] session context hook registered');
  1051. } catch (err) {
  1052. log('[v2] session.hook(context) failed', String(err));
  1053. }
  1054. // ── Tool execute hooks ──
  1055. try {
  1056. const before = v1Hooks['tool.execute.before'] as
  1057. | ((
  1058. i: { tool: string; sessionID: string; callID: string },
  1059. o: { args: unknown },
  1060. ) => Promise<void>)
  1061. | undefined;
  1062. const after = v1Hooks['tool.execute.after'] as
  1063. | ((i: unknown, o: unknown) => Promise<void>)
  1064. | undefined;
  1065. const bridges = createToolExecuteBridges(before, after);
  1066. if (before) {
  1067. const reg = await ctx.tool.hook('execute.before', async (event) => {
  1068. try {
  1069. await bridges.beforeBridge(event as never);
  1070. } catch (err) {
  1071. log('[v2] tool.execute.before rejected call', String(err));
  1072. throw err; // v2 refuses the call (see createToolExecuteBridges)
  1073. }
  1074. });
  1075. disposers.push(() => reg.dispose());
  1076. }
  1077. if (after) {
  1078. const reg = await ctx.tool.hook('execute.after', async (event) => {
  1079. try {
  1080. await bridges.afterBridge(event as never);
  1081. } catch (err) {
  1082. log('[v2] tool.execute.after bridge failed', String(err));
  1083. }
  1084. });
  1085. disposers.push(() => reg.dispose());
  1086. }
  1087. log('[v2] tool hooks registered', { before: !!before, after: !!after });
  1088. } catch (err) {
  1089. log('[v2] tool.hook registration failed', String(err));
  1090. }
  1091. // ── Event stream ──
  1092. try {
  1093. const eventHook = v1Hooks.event as
  1094. | ((i: { event: Record<string, unknown> }) => Promise<void>)
  1095. | undefined;
  1096. if (eventHook || interviewBridge) {
  1097. const iter = ctx.event.subscribe();
  1098. const eventIterator = iter[Symbol.asyncIterator]();
  1099. let eventStopped = false;
  1100. void (async () => {
  1101. try {
  1102. while (!eventStopped) {
  1103. const next = await eventIterator.next();
  1104. if (next.done) break;
  1105. try {
  1106. // interviewBridge keeps the RAW v2 event; the v1 eventHook
  1107. // loop iterates raw + synthesized v1 shapes (idle,
  1108. // early-registration created, message.updated telemetry).
  1109. await interviewBridge.handleEvent(next.value);
  1110. if (eventHook) {
  1111. for (const ev of mapV2EventToV1(next.value)) {
  1112. await eventHook({ event: ev });
  1113. }
  1114. }
  1115. } catch (err) {
  1116. log('[v2] event handler failed', String(err));
  1117. }
  1118. }
  1119. } catch (err) {
  1120. log('[v2] event stream ended', String(err));
  1121. }
  1122. })();
  1123. disposers.push(async () => {
  1124. eventStopped = true;
  1125. await eventIterator.return?.();
  1126. });
  1127. log('[v2] event stream subscribed');
  1128. }
  1129. } catch (err) {
  1130. log('[v2] event.subscribe failed', String(err));
  1131. }
  1132. // ── Health check: surface silent zero-registration failures ──
  1133. // Every bridge is fail-soft; without this, a fully broken registration
  1134. // would look like a successful load with an empty session.
  1135. if (disposers.length === 0) {
  1136. console.error(
  1137. '[oh-my-opencode-slim][v2] WARNING: no bridges registered — ' +
  1138. 'the plugin loaded but registered nothing. Check the plugin log.',
  1139. );
  1140. log('[v2] health check: zero bridges registered');
  1141. } else {
  1142. log('[v2] health check passed', { bridges: disposers.length });
  1143. }
  1144. const dispose = v1Hooks.dispose as (() => Promise<void>) | undefined;
  1145. return async () => {
  1146. log('[v2] dispose invoked');
  1147. for (const d of disposers) {
  1148. try {
  1149. await d();
  1150. } catch (err) {
  1151. log('[v2] disposer failed', String(err));
  1152. }
  1153. }
  1154. // v1 dispose synthesizes `server.instance.disposed` into the v1 event
  1155. // consumers (orchestrator-wake scheduler timers/state, task-session
  1156. // manager) — without it, host teardown would leak wake timers.
  1157. try {
  1158. log('[v2] v1 dispose hook invoked');
  1159. await dispose?.();
  1160. } catch (err) {
  1161. log('[v2] v1 dispose failed', String(err));
  1162. }
  1163. };
  1164. };
  1165. }