setup.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  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. * the session context hook (system + message transforms), tool execute hooks,
  8. * and the event stream. Each bridge is independently try/catch-guarded.
  9. */
  10. import { OhMyOpenCodeLite } from '../index';
  11. import { initLogger, log } from '../utils/logger';
  12. import { adaptTool, applyAgentToDraft } from './adapters';
  13. import { buildPluginInput } from './client-shim';
  14. import type {
  15. V2Cleanup,
  16. V2Context,
  17. V2ToolAfterEvent,
  18. V2ToolBeforeEvent,
  19. } from './types';
  20. export function createV2Setup(): (ctx: V2Context) => Promise<V2Cleanup> {
  21. return async (ctx: V2Context): Promise<V2Cleanup> => {
  22. const sessionId = new Date()
  23. .toISOString()
  24. .replace(/[-:]/g, '')
  25. .slice(0, 15);
  26. initLogger(sessionId);
  27. log('[v2] setup invoked', { app: ctx.app, cwd: process.cwd() });
  28. const directory = process.cwd();
  29. const disposers: Array<() => Promise<void> | void> = [];
  30. let v1Hooks: Record<string, unknown> | undefined;
  31. try {
  32. log('[v2] importing v1 factory...');
  33. const pluginInput = buildPluginInput(directory);
  34. log('[v2] calling OhMyOpenCodeLite...');
  35. v1Hooks = (await OhMyOpenCodeLite(
  36. pluginInput as never,
  37. )) as unknown as Record<string, unknown>;
  38. log('[v2] v1 factory initialized', {
  39. agents: Object.keys((v1Hooks as { agent?: object }).agent ?? {}).length,
  40. tools: Object.keys((v1Hooks as { tool?: object }).tool ?? {}).length,
  41. });
  42. } catch (err) {
  43. log('[v2] FATAL: v1 factory init failed', String(err));
  44. console.error('[oh-my-opencode-slim][v2] factory init failed:', err);
  45. // Don't hard-fail the whole plugin; register nothing and stay loaded.
  46. return async () => {};
  47. }
  48. if (!v1Hooks) return async () => {};
  49. // Resolve agents/commands via the v1 config() hook (model resolution etc.).
  50. let resolvedAgents: Record<string, Record<string, unknown>> | undefined;
  51. let synthCommands:
  52. | Record<string, { template?: string; description?: string }>
  53. | undefined;
  54. try {
  55. const synth: Record<string, unknown> = {};
  56. const configFn = v1Hooks.config as
  57. | ((c: Record<string, unknown>) => Promise<void>)
  58. | undefined;
  59. if (configFn) {
  60. await configFn(synth);
  61. if (synth.agent && typeof synth.agent === 'object') {
  62. resolvedAgents = synth.agent as Record<
  63. string,
  64. Record<string, unknown>
  65. >;
  66. }
  67. const cmd = synth.command as
  68. | Record<string, { template?: string; description?: string }>
  69. | undefined;
  70. if (cmd) synthCommands = cmd;
  71. }
  72. } catch (err) {
  73. log(
  74. '[v2] config() hook failed (continuing with raw agents)',
  75. String(err),
  76. );
  77. }
  78. if (!resolvedAgents) {
  79. resolvedAgents =
  80. (v1Hooks.agent as Record<string, Record<string, unknown>>) ?? {};
  81. }
  82. // ── Agents ──
  83. try {
  84. const reg = await ctx.agent.transform((draft) => {
  85. for (const [name, cfg] of Object.entries(resolvedAgents ?? {})) {
  86. try {
  87. applyAgentToDraft(draft, name, cfg);
  88. } catch (err) {
  89. log('[v2] agent adapt failed', { name, err: String(err) });
  90. }
  91. }
  92. // Make orchestrator the default primary agent.
  93. if (resolvedAgents?.orchestrator) {
  94. try {
  95. draft.default('orchestrator');
  96. } catch {
  97. /* default() optional */
  98. }
  99. }
  100. });
  101. disposers.push(() => reg.dispose());
  102. log('[v2] agents registered', {
  103. count: Object.keys(resolvedAgents ?? {}).length,
  104. });
  105. } catch (err) {
  106. log('[v2] agent.transform failed', String(err));
  107. }
  108. // ── Tools ──
  109. try {
  110. const tools = (v1Hooks.tool ?? {}) as Record<
  111. string,
  112. Record<string, unknown>
  113. >;
  114. const toolEntries = Object.entries(tools);
  115. if (toolEntries.length > 0) {
  116. // Precompute JSON schemas from zod shapes (zod is bundled in v2 build).
  117. const zod = (await import('zod')) as unknown as {
  118. object?: (s: unknown) => unknown;
  119. toJSONSchema?: (s: unknown) => unknown;
  120. };
  121. const schemaFor = (def: Record<string, unknown>): unknown => {
  122. const args = def.args;
  123. if (!args || typeof args !== 'object') {
  124. return { type: 'object', properties: {} };
  125. }
  126. try {
  127. const obj = zod.object?.(args);
  128. if (zod.toJSONSchema && obj) return zod.toJSONSchema(obj);
  129. } catch {
  130. /* fall through */
  131. }
  132. return { type: 'object', properties: {} };
  133. };
  134. const reg = await ctx.tool.transform((draft) => {
  135. for (const [name, def] of toolEntries) {
  136. try {
  137. draft.add(adaptTool(name, def, directory, schemaFor(def)));
  138. } catch (err) {
  139. log('[v2] tool adapt failed', { name, err: String(err) });
  140. }
  141. }
  142. });
  143. disposers.push(() => reg.dispose());
  144. log('[v2] tools registered', { count: toolEntries.length });
  145. }
  146. } catch (err) {
  147. log('[v2] tool.transform failed', String(err));
  148. }
  149. // ── Commands (deepwork / reflect / loop slash commands) ──
  150. try {
  151. const entries = Object.entries(synthCommands ?? {});
  152. if (entries.length > 0) {
  153. const reg = await ctx.command.transform((draft) => {
  154. for (const [name, cmd] of entries) {
  155. try {
  156. draft.update(name, (c) => {
  157. c.name = name;
  158. if (typeof cmd.template === 'string') c.template = cmd.template;
  159. if (typeof cmd.description === 'string')
  160. c.description = cmd.description;
  161. });
  162. } catch (err) {
  163. log('[v2] command adapt failed', { name, err: String(err) });
  164. }
  165. }
  166. });
  167. disposers.push(() => reg.dispose());
  168. log('[v2] commands registered', { count: entries.length });
  169. }
  170. } catch (err) {
  171. log('[v2] command.transform failed', String(err));
  172. }
  173. // ── System + messages transforms (session context hook) ──
  174. try {
  175. const systemTransform = v1Hooks['experimental.chat.system.transform'] as
  176. | ((i: unknown, o: { system: string[] }) => Promise<void>)
  177. | undefined;
  178. const messagesTransform = v1Hooks[
  179. 'experimental.chat.messages.transform'
  180. ] as
  181. | ((
  182. i: unknown,
  183. o: {
  184. messages: Array<{ info: { role: string }; parts: unknown[] }>;
  185. },
  186. ) => Promise<void>)
  187. | undefined;
  188. const chatMessage = v1Hooks['chat.message'] as
  189. | ((
  190. i: { sessionID: string; agent?: string },
  191. o: unknown,
  192. ) => Promise<void>)
  193. | undefined;
  194. if (systemTransform || messagesTransform || chatMessage) {
  195. const reg = await ctx.session.hook('context', async (event) => {
  196. // Agent tracking (chat.message equivalent).
  197. if (chatMessage) {
  198. try {
  199. await chatMessage(
  200. { sessionID: event.sessionID, agent: event.agent },
  201. undefined,
  202. );
  203. } catch (err) {
  204. log('[v2] chat.message bridge failed', String(err));
  205. }
  206. }
  207. // System transform: v2 SystemPart[] -> v1 string[] -> mutate -> back.
  208. if (systemTransform && Array.isArray(event.system)) {
  209. try {
  210. const sysStrings = event.system.map((s) => s.text ?? '');
  211. await systemTransform(
  212. { sessionID: event.sessionID },
  213. { system: sysStrings },
  214. );
  215. event.system = sysStrings.map((text) => ({
  216. type: 'text' as const,
  217. text,
  218. }));
  219. } catch (err) {
  220. log('[v2] system transform bridge failed', String(err));
  221. }
  222. }
  223. // Messages transform: v2 Message.content -> v1 {info, parts} -> back.
  224. if (messagesTransform && Array.isArray(event.messages)) {
  225. try {
  226. const v1messages = event.messages.map((m) => ({
  227. info: { role: m.role },
  228. parts: m.content,
  229. }));
  230. await messagesTransform({}, { messages: v1messages });
  231. event.messages.forEach((m, i) => {
  232. m.content = (v1messages[i]?.parts ?? []) as Array<
  233. Record<string, unknown>
  234. >;
  235. });
  236. } catch (err) {
  237. log('[v2] messages transform bridge failed', String(err));
  238. }
  239. }
  240. });
  241. disposers.push(() => reg.dispose());
  242. log('[v2] session context hook registered');
  243. }
  244. } catch (err) {
  245. log('[v2] session.hook(context) failed', String(err));
  246. }
  247. // ── Tool execute hooks ──
  248. try {
  249. const before = v1Hooks['tool.execute.before'] as
  250. | ((
  251. i: { tool: string; sessionID: string; callID: string },
  252. o: { args: unknown },
  253. ) => Promise<void>)
  254. | undefined;
  255. const after = v1Hooks['tool.execute.after'] as
  256. | ((i: unknown, o: unknown) => Promise<void>)
  257. | undefined;
  258. if (before) {
  259. const reg = await ctx.tool.hook('execute.before', async (event) => {
  260. const e = event as V2ToolBeforeEvent;
  261. try {
  262. await before(
  263. { tool: e.tool, sessionID: e.sessionID, callID: e.id },
  264. { args: e.input },
  265. );
  266. } catch (err) {
  267. log('[v2] tool.execute.before bridge failed', String(err));
  268. }
  269. });
  270. disposers.push(() => reg.dispose());
  271. }
  272. if (after) {
  273. const reg = await ctx.tool.hook('execute.after', async (event) => {
  274. const e = event as V2ToolAfterEvent;
  275. // Map v2 Tool.Result.content (string | Content[]) -> v1 output.output
  276. // string; the v1 after-hooks (postFileToolNudge, jsonErrorRecovery,
  277. // taskSessionManagerAfter) read output.output to decide nudges.
  278. const result = e.result as
  279. | {
  280. content?: unknown;
  281. metadata?: Record<string, unknown>;
  282. }
  283. | undefined;
  284. const rawContent = result?.content;
  285. const content =
  286. typeof rawContent === 'string'
  287. ? rawContent
  288. : Array.isArray(rawContent)
  289. ? (rawContent as Array<{ type?: string; text?: string }>)
  290. .filter((p) => p?.type === 'text')
  291. .map((p) => p.text ?? '')
  292. .join('')
  293. : '';
  294. try {
  295. await after(
  296. {
  297. tool: e.tool,
  298. sessionID: e.sessionID,
  299. callID: e.id,
  300. args: e.input,
  301. },
  302. { output: content, title: '', metadata: result?.metadata ?? {} },
  303. );
  304. } catch (err) {
  305. log('[v2] tool.execute.after bridge failed', String(err));
  306. }
  307. });
  308. disposers.push(() => reg.dispose());
  309. }
  310. log('[v2] tool hooks registered', { before: !!before, after: !!after });
  311. } catch (err) {
  312. log('[v2] tool.hook registration failed', String(err));
  313. }
  314. // ── Event stream ──
  315. try {
  316. const eventHook = v1Hooks.event as
  317. | ((i: { event: Record<string, unknown> }) => Promise<void>)
  318. | undefined;
  319. if (eventHook) {
  320. const iter = ctx.event.subscribe();
  321. void (async () => {
  322. try {
  323. for await (const ev of iter) {
  324. try {
  325. await eventHook({ event: ev });
  326. } catch (err) {
  327. log('[v2] event handler failed', String(err));
  328. }
  329. }
  330. } catch (err) {
  331. log('[v2] event stream ended', String(err));
  332. }
  333. })();
  334. log('[v2] event stream subscribed');
  335. }
  336. } catch (err) {
  337. log('[v2] event.subscribe failed', String(err));
  338. }
  339. // ── Health check: surface silent zero-registration failures ──
  340. // Every bridge is fail-soft; without this, a fully broken registration
  341. // would look like a successful load with an empty session.
  342. if (disposers.length === 0) {
  343. console.error(
  344. '[oh-my-opencode-slim][v2] WARNING: no bridges registered — ' +
  345. 'the plugin loaded but registered nothing. Check the plugin log.',
  346. );
  347. log('[v2] health check: zero bridges registered');
  348. } else {
  349. log('[v2] health check passed', { bridges: disposers.length });
  350. }
  351. const dispose = v1Hooks.dispose as (() => Promise<void>) | undefined;
  352. return async () => {
  353. log('[v2] dispose invoked');
  354. for (const d of disposers) {
  355. try {
  356. await d();
  357. } catch (err) {
  358. log('[v2] disposer failed', String(err));
  359. }
  360. }
  361. try {
  362. await dispose?.();
  363. } catch (err) {
  364. log('[v2] v1 dispose failed', String(err));
  365. }
  366. };
  367. };
  368. }