@varveai/adit-hooks 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/dist/adapters/claude-code.d.ts +9 -0
  3. package/dist/adapters/claude-code.d.ts.map +1 -0
  4. package/dist/adapters/claude-code.js +349 -0
  5. package/dist/adapters/claude-code.js.map +1 -0
  6. package/dist/adapters/index.d.ts +12 -0
  7. package/dist/adapters/index.d.ts.map +1 -0
  8. package/dist/adapters/index.js +29 -0
  9. package/dist/adapters/index.js.map +1 -0
  10. package/dist/adapters/opencode.d.ts +18 -0
  11. package/dist/adapters/opencode.d.ts.map +1 -0
  12. package/dist/adapters/opencode.js +961 -0
  13. package/dist/adapters/opencode.js.map +1 -0
  14. package/dist/adapters/registry.d.ts +31 -0
  15. package/dist/adapters/registry.d.ts.map +1 -0
  16. package/dist/adapters/registry.js +102 -0
  17. package/dist/adapters/registry.js.map +1 -0
  18. package/dist/adapters/stub.d.ts +24 -0
  19. package/dist/adapters/stub.d.ts.map +1 -0
  20. package/dist/adapters/stub.js +60 -0
  21. package/dist/adapters/stub.js.map +1 -0
  22. package/dist/adapters/types.d.ts +107 -0
  23. package/dist/adapters/types.d.ts.map +1 -0
  24. package/dist/adapters/types.js +8 -0
  25. package/dist/adapters/types.js.map +1 -0
  26. package/dist/common/context.d.ts +19 -0
  27. package/dist/common/context.d.ts.map +1 -0
  28. package/dist/common/context.js +94 -0
  29. package/dist/common/context.js.map +1 -0
  30. package/dist/handlers/index.d.ts +2 -0
  31. package/dist/handlers/index.d.ts.map +1 -0
  32. package/dist/handlers/index.js +2 -0
  33. package/dist/handlers/index.js.map +1 -0
  34. package/dist/handlers/unified.d.ts +13 -0
  35. package/dist/handlers/unified.d.ts.map +1 -0
  36. package/dist/handlers/unified.js +304 -0
  37. package/dist/handlers/unified.js.map +1 -0
  38. package/dist/index.d.ts +10 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +32 -0
  41. package/dist/index.js.map +1 -0
  42. package/package.json +37 -0
@@ -0,0 +1,961 @@
1
+ /**
2
+ * OpenCode platform adapter.
3
+ *
4
+ * Maps OpenCode's plugin hook events to ADIT's internal model.
5
+ * Handles installation by generating a plugin file in .opencode/plugins/.
6
+ *
7
+ * OpenCode uses a plugin system (not config-based hooks like Claude Code).
8
+ * Plugins are JS/TS modules placed in .opencode/plugins/ that export
9
+ * hook functions. The generated plugin listens for OpenCode events and
10
+ * spawns `adit-hook` via child process to keep ADIT fail-open.
11
+ *
12
+ * OpenCode stores session data in SQLite (not a single transcript JSONL
13
+ * like Claude Code), so transcript upload is not applicable here.
14
+ * All hook events are synced to the ADIT server directly.
15
+ */
16
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
17
+ import { join } from "node:path";
18
+ const PLUGIN_FILENAME = "adit.js";
19
+ /** Slash command installed into .opencode/commands/ */
20
+ const ADIT_COMMAND = {
21
+ filename: "adit.md",
22
+ content: `---
23
+ description: ADIT — manage cloud project linking and development intents
24
+ ---
25
+
26
+ **Requested action:** \`$ARGUMENTS\`
27
+
28
+ ## Routing
29
+
30
+ Parse the requested action above and follow the **first matching rule**:
31
+
32
+ 1. Action is \`link\` (with optional flags) → call the \`adit_link\` tool. Map flags: \`--force\` → \`force: true\`, \`--skip-docs\` → \`skipDocs: true\`, \`--skip-commits\` → \`skipCommits: true\`, \`--dry-run\` → \`dryRun: true\`.
33
+ 2. Action is \`intent\` (with optional flags) → call the \`adit_intent\` tool. Map flags: \`--id <value>\` → \`id: "<value>"\`, \`--state <value>\` → \`state: "<value>"\`.
34
+ 3. No action, empty arguments, or unrecognized action → display the **Help** section below as your response. Do not call any tools.
35
+
36
+ ---
37
+
38
+ ## Help
39
+
40
+ Display the following when no valid action is provided:
41
+
42
+ ### ADIT Cloud
43
+
44
+ ADIT tracks your AI-assisted development sessions, links project context to the cloud, and helps you manage development intents (plans).
45
+
46
+ **Usage:** \`/adit <action> [options]\`
47
+
48
+ #### \`link\` — Sync project to adit-cloud
49
+
50
+ Uploads git metadata (branches, commits) and project documents for intent planning.
51
+
52
+ | Option | Description |
53
+ |---|---|
54
+ | \`--force\` | Clear cache and re-link from scratch |
55
+ | \`--skip-docs\` | Only upload git metadata, skip documents |
56
+ | \`--skip-commits\` | Skip commit history upload |
57
+ | \`--dry-run\` | Preview what would be uploaded |
58
+
59
+ #### \`intent\` — View development intents
60
+
61
+ Shows intents (development plans) and tasks from the connected adit-cloud project.
62
+
63
+ | Option | Description |
64
+ |---|---|
65
+ | \`--id <id>\` | Show a specific intent by ID |
66
+ | \`--state <state>\` | Filter by state (e.g. \`capture\`, \`execution\`, \`shipped\`) |
67
+
68
+ #### Examples
69
+
70
+ - \`/adit link\` — link the project with defaults
71
+ - \`/adit link --force --skip-docs\` — re-link, git metadata only
72
+ - \`/adit intent\` — list all intents
73
+ - \`/adit intent --state execution\` — show active intents
74
+
75
+ > **Tip:** Not logged in? Run \`npx adit cloud login\` in your terminal first.
76
+ `,
77
+ };
78
+ /** Generate custom tool file content for .opencode/tools/adit.ts */
79
+ function generateToolsContent() {
80
+ // Built as string concatenation to avoid backtick escaping issues
81
+ // (the Bun.$ tagged template must contain literal backticks in output).
82
+ const lines = [
83
+ "/**",
84
+ " * ADIT custom tools for OpenCode.",
85
+ " *",
86
+ " * Provides adit_link and adit_intent tools that the LLM can call",
87
+ " * to interact with adit-cloud project linking and intent management.",
88
+ " *",
89
+ " * @varveai/adit-auto-generated — reinstall with: adit plugin install opencode",
90
+ " */",
91
+ "",
92
+ 'import { tool } from "@opencode-ai/plugin";',
93
+ "",
94
+ "export const link = tool({",
95
+ " description:",
96
+ ' "Link the current project to adit-cloud. Uploads git metadata (branches, commits) and project documents for intent planning.",',
97
+ " args: {",
98
+ ' force: tool.schema.boolean().optional().describe("Clear cache and re-link everything from scratch"),',
99
+ ' skipDocs: tool.schema.boolean().optional().describe("Only upload git metadata, skip document upload"),',
100
+ ' skipCommits: tool.schema.boolean().optional().describe("Skip commit history upload"),',
101
+ ' dryRun: tool.schema.boolean().optional().describe("Preview what would be uploaded without actually uploading"),',
102
+ " },",
103
+ " async execute(args, context) {",
104
+ ' const flags: string[] = ["--json"];',
105
+ ' if (args.force) flags.push("--force");',
106
+ ' if (args.skipDocs) flags.push("--skip-docs");',
107
+ ' if (args.skipCommits) flags.push("--skip-commits");',
108
+ ' if (args.dryRun) flags.push("--dry-run");',
109
+ "",
110
+ ' const cmd = ["npx", "adit", "cloud", "link", ...flags];',
111
+ " const result = await Bun.$`${cmd}`.cwd(context.directory).nothrow().quiet();",
112
+ " const stdout = result.stdout.toString().trim();",
113
+ " const stderr = result.stderr.toString().trim();",
114
+ "",
115
+ " if (result.exitCode !== 0) {",
116
+ ' return "Link failed: " + (stderr || stdout || "Unknown error");',
117
+ " }",
118
+ "",
119
+ " try {",
120
+ " const data = JSON.parse(stdout);",
121
+ ' const q = data.qualified ? "qualified" : "not qualified";',
122
+ " const lines = [",
123
+ ' "**Project linked successfully!**",',
124
+ ' "",',
125
+ ' "| Field | Value |",',
126
+ ' "|---|---|",',
127
+ ' "| Project | " + data.projectName + " |",',
128
+ ' "| Server | " + data.serverUrl + " |",',
129
+ ' "| Branches | " + data.branchCount + " |",',
130
+ ' "| Commits | " + data.commitCount + " |",',
131
+ ' "| Documents | " + data.documentCount + " (" + q + ") |",',
132
+ " ];",
133
+ ' if (data.score !== null) lines.push("| Quality | " + (data.score * 100).toFixed(0) + "% |");',
134
+ " if (data.stepTimings && data.stepTimings.length > 0) {",
135
+ ' lines.push("");',
136
+ ' lines.push("**Step timings:**");',
137
+ ' lines.push("");',
138
+ ' lines.push("| Step | Duration |");',
139
+ ' lines.push("|---|---|");',
140
+ " for (const s of data.stepTimings) {",
141
+ ' const secs = (s.durationMs / 1000).toFixed(2);',
142
+ ' lines.push("| " + s.step + " | " + secs + "s |");',
143
+ " }",
144
+ " if (data.totalDurationMs !== undefined) {",
145
+ ' const total = (data.totalDurationMs / 1000).toFixed(2);',
146
+ ' lines.push("| **Total** | **" + total + "s** |");',
147
+ " }",
148
+ " }",
149
+ ' return lines.join("\\n");',
150
+ " } catch {",
151
+ " return stdout;",
152
+ " }",
153
+ " },",
154
+ "});",
155
+ "",
156
+ "export const intent = tool({",
157
+ " description:",
158
+ ' "Show intents (development plans) and tasks from the connected adit-cloud project.",',
159
+ " args: {",
160
+ ' id: tool.schema.string().optional().describe("Intent ID to show detailed view with all tasks"),',
161
+ ' state: tool.schema.string().optional().describe("Filter intents by state (e.g. capture, execution, shipped)"),',
162
+ " },",
163
+ " async execute(args, context) {",
164
+ ' const flags: string[] = ["--json"];',
165
+ ' if (args.id) flags.push("--id", args.id);',
166
+ ' if (args.state) flags.push("--state", args.state);',
167
+ "",
168
+ ' const cmd = ["npx", "adit", "cloud", "intent", ...flags];',
169
+ " const result = await Bun.$`${cmd}`.cwd(context.directory).nothrow().quiet();",
170
+ " const stdout = result.stdout.toString().trim();",
171
+ " const stderr = result.stderr.toString().trim();",
172
+ "",
173
+ " if (result.exitCode !== 0) {",
174
+ ' return "Intent query failed: " + (stderr || stdout || "Unknown error");',
175
+ " }",
176
+ "",
177
+ " try {",
178
+ " const data = JSON.parse(stdout);",
179
+ "",
180
+ " // Single intent detail",
181
+ " if (data.intent) {",
182
+ " const i = data.intent;",
183
+ " const progress = i.taskCount > 0",
184
+ ' ? i.completedTaskCount + "/" + i.taskCount + " tasks completed"',
185
+ ' : "no tasks";',
186
+ " const lines = [",
187
+ ' "### " + i.title,',
188
+ ' "",',
189
+ ' "| Field | Value |",',
190
+ ' "|---|---|",',
191
+ ' "| State | " + i.state + " |",',
192
+ ' "| Goal | " + i.businessGoal + " |",',
193
+ ' "| Progress | " + progress + " |",',
194
+ " ];",
195
+ ' if (i.linkedBranches && i.linkedBranches.length > 0) {',
196
+ ' lines.push("| Branches | " + i.linkedBranches.join(", ") + " |");',
197
+ " }",
198
+ " if (i.tasks && i.tasks.length > 0) {",
199
+ ' lines.push("");',
200
+ ' lines.push("#### Tasks");',
201
+ ' lines.push("");',
202
+ ' lines.push("| Phase | Task | Status |");',
203
+ ' lines.push("|---|---|---|");',
204
+ " for (const t of i.tasks) {",
205
+ ' const phase = t.phaseTitle || "Phase " + t.phase;',
206
+ ' lines.push("| " + phase + " | " + t.title + (t.description ? " — " + t.description : "") + " | " + t.approvalStatus + " |");',
207
+ " }",
208
+ " }",
209
+ ' if (i.acceptanceMd) {',
210
+ ' lines.push("");',
211
+ ' lines.push("#### Acceptance Criteria");',
212
+ ' lines.push("");',
213
+ ' lines.push(i.acceptanceMd);',
214
+ " }",
215
+ ' return lines.join("\\n");',
216
+ " }",
217
+ "",
218
+ " // Intent list",
219
+ " if (data.intents) {",
220
+ " if (data.intents.length === 0) {",
221
+ ' return "No intents found for this project. Create intents on adit-cloud first.";',
222
+ " }",
223
+ ' const lines = ["**" + data.intents.length + " intent(s):**", ""];',
224
+ ' lines.push("| ID | State | Intent | Progress | Goal |");',
225
+ ' lines.push("|---|---|---|---|---|");',
226
+ " for (const i of data.intents) {",
227
+ " const progress = i.taskCount > 0",
228
+ ' ? i.completedTaskCount + "/" + i.taskCount',
229
+ ' : "—";',
230
+ ' const branches = i.linkedBranches && i.linkedBranches.length > 0',
231
+ ' ? " (" + i.linkedBranches.join(", ") + ")"',
232
+ ' : "";',
233
+ ' lines.push("| " + i.id + " | " + i.state + " | " + i.title + branches + " | " + progress + " | " + i.businessGoal + " |");',
234
+ " }",
235
+ ' lines.push("");',
236
+ ' lines.push("Use `/adit intent --id <id>` to see details for a specific intent.");',
237
+ ' return lines.join("\\n");',
238
+ " }",
239
+ "",
240
+ ' return JSON.stringify(data, null, 2);',
241
+ " } catch {",
242
+ " return stdout;",
243
+ " }",
244
+ " },",
245
+ "});",
246
+ "",
247
+ ];
248
+ return lines.join("\n");
249
+ }
250
+ const ADIT_TOOLS = {
251
+ filename: "adit.ts",
252
+ };
253
+ /** Filenames of old command files to clean up during install */
254
+ const LEGACY_COMMAND_FILES = ["adit-link.md", "adit-intent.md"];
255
+ const HOOK_MAPPINGS = [
256
+ { platformEvent: "chat.message", aditHandler: "prompt-submit" },
257
+ // OpenCode has no "stop" hook key; session.idle fires when the assistant
258
+ // finishes a response turn and is the correct trigger for checkpoints.
259
+ // session.idle also triggers a forced cloud sync so data is flushed before
260
+ // the user exits (since /exit does not reliably fire a session-end event).
261
+ { platformEvent: "session.idle", aditHandler: "stop" },
262
+ { platformEvent: "session.created", aditHandler: "session-start" },
263
+ { platformEvent: "session.deleted", aditHandler: "session-end" },
264
+ // /exit does not fire session.deleted; command.executed is intercepted
265
+ // synchronously in the plugin to flush cloud sync before process exit.
266
+ { platformEvent: "command.executed", aditHandler: "session-end" },
267
+ { platformEvent: "message.part.updated", aditHandler: "notification" },
268
+ { platformEvent: "session.diff", aditHandler: "notification" },
269
+ { platformEvent: "todo.updated", aditHandler: "task-completed" },
270
+ ];
271
+ /** Map OpenCode hook/event types to ADIT hook types */
272
+ const EVENT_TO_ADIT = {
273
+ "chat.message": "prompt-submit",
274
+ "session.idle": "stop",
275
+ "session-start": "session-start",
276
+ "session-end": "session-end",
277
+ "session.created": "session-start",
278
+ "session.deleted": "session-end",
279
+ "session.error": "session-end",
280
+ "notification": "notification",
281
+ "task-completed": "task-completed",
282
+ };
283
+ /** Check if a file is an ADIT-generated plugin */
284
+ function isAditPlugin(content) {
285
+ return content.includes("@varveai/adit-auto-generated") || content.includes("adit-hook");
286
+ }
287
+ /**
288
+ * Generate the OpenCode plugin file content.
289
+ *
290
+ * The plugin listens for OpenCode events and spawns `adit-hook` as a child
291
+ * process, piping event data as JSON to stdin. This keeps ADIT fully
292
+ * isolated — errors never crash OpenCode.
293
+ *
294
+ * Hooked events:
295
+ * - chat.message (user) → prompt-submit (prompt from parts[].text)
296
+ * - session.idle → stop (checkpoint + forced cloud sync; AI finished)
297
+ * - session.created/deleted → session-start/session-end
298
+ * - session.error → session-end
299
+ * - command.executed → session-end (exit/quit/q only, synchronous)
300
+ * - message.part.updated → notification (tool results, step finishes)
301
+ * - session.diff → notification (file-level diffs)
302
+ * - todo.updated → task-completed (AI task tracking)
303
+ *
304
+ * Note: OpenCode's Plugin API has no "stop" hook key. The equivalent is the
305
+ * "session.idle" event, which fires when the assistant finishes a response.
306
+ * UserMessage has no content field; the user prompt lives in the parts array
307
+ * (TextPart items with type === "text"). Session has no model field — model
308
+ * info comes from the chat.message input arg instead.
309
+ *
310
+ * Note: /exit (/quit, /q) does NOT fire session.deleted — OpenCode just
311
+ * terminates the process. We use two layers of defense:
312
+ * 1. command.executed: fires for slash commands, uses spawnSync to block until
313
+ * the session-end hook (and cloud sync) completes before the process exits.
314
+ * 2. process.on('exit'): safety net that fires session-end synchronously if
315
+ * command.executed never fired (e.g. OpenCode calls process.exit() directly).
316
+ * Also handles SIGINT (Ctrl+C) and SIGTERM by flushing then re-raising.
317
+ * The active session ID is tracked via session.created/deleted, and a
318
+ * sessionEndFired flag prevents duplicate session-end calls.
319
+ */
320
+ function generatePluginContent(aditBinaryPath) {
321
+ // Split the binary path into command + args for spawning.
322
+ // e.g. 'node "/path/to/index.js"' → ["node", "/path/to/index.js"]
323
+ const parts = aditBinaryPath.match(/"[^"]*"|\S+/g) ?? [aditBinaryPath];
324
+ const cmd = parts[0];
325
+ const baseArgs = parts.slice(1).map((p) => p.replace(/^"|"$/g, ""));
326
+ return `// @varveai/adit-auto-generated — ADIT plugin for OpenCode
327
+ // Do not edit manually. Reinstall with: adit plugin install opencode
328
+ //
329
+ // This plugin listens for OpenCode events and forwards them to ADIT's
330
+ // hook dispatcher via child process. All errors are swallowed (fail-open).
331
+
332
+ const { spawn, spawnSync } = require("child_process");
333
+ const { mkdirSync, writeFileSync, appendFileSync, existsSync, readFileSync } = require("fs");
334
+ const path = require("path");
335
+
336
+ const ADIT_CMD = ${JSON.stringify(cmd)};
337
+ const ADIT_BASE_ARGS = ${JSON.stringify(baseArgs)};
338
+
339
+ function spawnAditHook(hookType, data) {
340
+ try {
341
+ const child = spawn(ADIT_CMD, [...ADIT_BASE_ARGS, hookType], {
342
+ stdio: ["pipe", "ignore", "ignore"],
343
+ detached: true,
344
+ env: { ...process.env, OPENCODE: "1" },
345
+ timeout: 10000,
346
+ });
347
+ child.stdin.write(JSON.stringify(data));
348
+ child.stdin.end();
349
+ child.unref();
350
+ child.on("error", () => {});
351
+ } catch (e) {
352
+ // fail-open
353
+ }
354
+ }
355
+
356
+ // Synchronous variant used on /exit — we must block until ADIT finishes
357
+ // because the process is about to terminate and a detached child would be
358
+ // orphaned before it completes the cloud sync.
359
+ function spawnAditHookSync(hookType, data) {
360
+ try {
361
+ spawnSync(ADIT_CMD, [...ADIT_BASE_ARGS, hookType], {
362
+ input: JSON.stringify(data),
363
+ stdio: ["pipe", "ignore", "ignore"],
364
+ env: { ...process.env, OPENCODE: "1" },
365
+ timeout: 10000,
366
+ });
367
+ } catch (e) {
368
+ // fail-open
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Transcript collection — fetch messages from OpenCode's local HTTP API
374
+ // and write them as JSONL so the existing transcript upload pipeline can
375
+ // handle them identically to Claude Code transcripts.
376
+ // ---------------------------------------------------------------------------
377
+
378
+ /**
379
+ * Fetch session messages via the SDK client and append new ones to a JSONL file.
380
+ * Returns the absolute path to the JSONL file, or null if nothing was written.
381
+ *
382
+ * Each line is a JSON object:
383
+ * { role, messageID, parentID?, model?, agent?, parts: [...], tokens?, cost?, time }
384
+ *
385
+ * The function is incremental: it reads a small metadata sidecar
386
+ * (.meta.json) to track how many messages were written on the previous
387
+ * call and only appends new ones.
388
+ */
389
+ /**
390
+ * Export session messages via "opencode export <sessionID>" CLI command
391
+ * and write them as JSONL for the transcript upload pipeline.
392
+ *
393
+ * OpenCode TUI does not expose an HTTP API — the SDK client's baseUrl
394
+ * defaults to localhost:4096 which is only used by "opencode serve".
395
+ * The "opencode export" command reads directly from OpenCode's SQLite
396
+ * database, so it works regardless of whether a server is running.
397
+ *
398
+ * The function is incremental: a .meta.json sidecar tracks how many
399
+ * messages were written previously, and only new messages are appended.
400
+ */
401
+ function fetchTranscript(cwd, sessionID) {
402
+ try {
403
+ if (process.env.ADIT_DEBUG) {
404
+ process.stderr.write("[adit-transcript] exporting session " + sessionID + "\\n");
405
+ }
406
+
407
+ var exportResult = spawnSync("opencode", ["export", sessionID], {
408
+ stdio: ["ignore", "pipe", "pipe"],
409
+ timeout: 15000,
410
+ env: { ...process.env },
411
+ });
412
+
413
+ if (exportResult.status !== 0 || !exportResult.stdout) {
414
+ if (process.env.ADIT_DEBUG) {
415
+ var errOut = exportResult.stderr ? exportResult.stderr.toString().trim() : "";
416
+ process.stderr.write("[adit-transcript] export failed (exit " + exportResult.status + "): " + errOut.substring(0, 500) + "\\n");
417
+ }
418
+ return null;
419
+ }
420
+
421
+ var rawOutput = exportResult.stdout.toString().trim();
422
+ if (!rawOutput) {
423
+ if (process.env.ADIT_DEBUG) {
424
+ process.stderr.write("[adit-transcript] export returned empty output\\n");
425
+ }
426
+ return null;
427
+ }
428
+
429
+ var exportData;
430
+ try {
431
+ exportData = JSON.parse(rawOutput);
432
+ } catch (parseErr) {
433
+ if (process.env.ADIT_DEBUG) {
434
+ process.stderr.write("[adit-transcript] JSON parse error: " + parseErr.message + "\\n");
435
+ process.stderr.write("[adit-transcript] raw output (first 300 chars): " + rawOutput.substring(0, 300) + "\\n");
436
+ }
437
+ return null;
438
+ }
439
+
440
+ if (process.env.ADIT_DEBUG) {
441
+ var topKeys = Array.isArray(exportData) ? "[array:" + exportData.length + "]" : Object.keys(exportData).join(",");
442
+ process.stderr.write("[adit-transcript] export data shape: " + topKeys + "\\n");
443
+ }
444
+
445
+ // The export format may vary — handle multiple shapes:
446
+ // 1. Array of messages directly
447
+ // 2. Object with .messages array
448
+ // 3. Object with .data array
449
+ var messages = [];
450
+ if (Array.isArray(exportData)) {
451
+ messages = exportData;
452
+ } else if (exportData.messages && Array.isArray(exportData.messages)) {
453
+ messages = exportData.messages;
454
+ } else if (exportData.data && Array.isArray(exportData.data)) {
455
+ messages = exportData.data;
456
+ }
457
+
458
+ if (messages.length === 0) {
459
+ if (process.env.ADIT_DEBUG) {
460
+ process.stderr.write("[adit-transcript] no messages found in export\\n");
461
+ }
462
+ return null;
463
+ }
464
+
465
+ if (process.env.ADIT_DEBUG) {
466
+ process.stderr.write("[adit-transcript] found " + messages.length + " messages\\n");
467
+ // Log shape of first message to understand the format
468
+ var firstMsg = messages[0];
469
+ var firstKeys = firstMsg ? Object.keys(firstMsg).join(",") : "empty";
470
+ process.stderr.write("[adit-transcript] first message keys: " + firstKeys + "\\n");
471
+ }
472
+
473
+ // Ensure transcript directory exists
474
+ var transcriptDir = path.join(cwd, ".adit", "transcripts");
475
+ mkdirSync(transcriptDir, { recursive: true });
476
+
477
+ var filePath = path.join(transcriptDir, "opencode-" + sessionID + ".jsonl");
478
+ var metaPath = filePath + ".meta.json";
479
+
480
+ // Read previous write count from sidecar
481
+ var prevCount = 0;
482
+ if (existsSync(metaPath)) {
483
+ try {
484
+ var meta = JSON.parse(readFileSync(metaPath, "utf-8"));
485
+ prevCount = meta.messageCount || 0;
486
+ } catch (e) { /* ignore corrupt meta */ }
487
+ }
488
+
489
+ // Only append new messages
490
+ if (messages.length <= prevCount) {
491
+ if (process.env.ADIT_DEBUG) {
492
+ process.stderr.write("[adit-transcript] no new messages (prev: " + prevCount + ", current: " + messages.length + ")\\n");
493
+ }
494
+ return filePath;
495
+ }
496
+
497
+ var newMessages = messages.slice(prevCount);
498
+ var lines = [];
499
+ for (var i = 0; i < newMessages.length; i++) {
500
+ var msg = newMessages[i];
501
+
502
+ // Normalize: the export may use { info, parts } or flat { role, ... }
503
+ var info = msg.info || msg;
504
+ var parts = msg.parts || [];
505
+
506
+ var normalizedParts = parts.map(function(p) {
507
+ if (p.type === "text") return { type: "text", text: p.text || p.content };
508
+ if (p.type === "tool") return { type: "tool", tool: p.tool, callID: p.callID, state: p.state };
509
+ if (p.type === "reasoning") return { type: "reasoning", text: p.text };
510
+ if (p.type === "step-start") return { type: "step-start" };
511
+ if (p.type === "step-finish") return { type: "step-finish", cost: p.cost, tokens: p.tokens };
512
+ return { type: p.type || "unknown" };
513
+ });
514
+
515
+ var entry = {
516
+ role: info.role,
517
+ messageID: info.id || info.messageID,
518
+ sessionID: info.sessionID || sessionID,
519
+ time: info.time || info.createdAt,
520
+ parts: normalizedParts,
521
+ };
522
+
523
+ if (info.role === "assistant") {
524
+ if (info.modelID) entry.modelID = info.modelID;
525
+ if (info.providerID) entry.providerID = info.providerID;
526
+ if (info.tokens) entry.tokens = info.tokens;
527
+ if (info.cost) entry.cost = info.cost;
528
+ if (info.finishReason) entry.finishReason = info.finishReason;
529
+ }
530
+
531
+ if (info.role === "user") {
532
+ if (info.model) entry.model = info.model;
533
+ if (info.agent) entry.agent = info.agent;
534
+ }
535
+
536
+ lines.push(JSON.stringify(entry));
537
+ }
538
+
539
+ // Append new lines to the JSONL file
540
+ var NL = String.fromCharCode(10);
541
+ var appendData = lines.join(NL) + NL;
542
+ appendFileSync(filePath, appendData);
543
+
544
+ // Update sidecar with total count
545
+ writeFileSync(metaPath, JSON.stringify({ messageCount: messages.length }));
546
+
547
+ if (process.env.ADIT_DEBUG) {
548
+ process.stderr.write("[adit-transcript] wrote " + newMessages.length + " new messages to " + filePath + "\\n");
549
+ }
550
+ return filePath;
551
+ } catch (e) {
552
+ // fail-open — transcript export is best-effort
553
+ if (process.env.ADIT_DEBUG) {
554
+ process.stderr.write("[adit-transcript] error: " + (e && e.message ? e.message : String(e)) + "\\n");
555
+ }
556
+ return null;
557
+ }
558
+ }
559
+
560
+ exports.AditPlugin = async (ctx) => {
561
+ const cwd = ctx.directory || ctx.worktree || process.cwd();
562
+ const client = ctx.client;
563
+
564
+ // Track the active session ID so we can fire session-end on /exit.
565
+ // OpenCode does not fire session.deleted when the user types /exit —
566
+ // it just terminates the process. We intercept command.executed to
567
+ // detect exit commands and block until the sync finishes.
568
+ let activeSessionId = undefined;
569
+ let sessionEndFired = false;
570
+
571
+ // Safety net: fire session-end synchronously on process exit.
572
+ // OpenCode may not emit command.executed for /exit — it might call
573
+ // process.exit() directly. Node's 'exit' event fires synchronously
574
+ // and spawnSync works inside it, ensuring cloud sync completes
575
+ // before the process terminates.
576
+ function flushOnExit() {
577
+ if (sessionEndFired || !activeSessionId) return;
578
+ sessionEndFired = true;
579
+ spawnAditHookSync("session-end", {
580
+ cwd,
581
+ session_id: activeSessionId,
582
+ reason: "exit",
583
+ });
584
+ activeSessionId = undefined;
585
+ }
586
+ process.on("exit", flushOnExit);
587
+ // SIGINT (Ctrl+C) and SIGTERM need to re-raise after flushing so the
588
+ // process actually terminates with the expected exit code / signal.
589
+ function handleSignal(signal) {
590
+ flushOnExit();
591
+ process.removeListener(signal, handleSignal);
592
+ process.kill(process.pid, signal);
593
+ }
594
+ process.on("SIGINT", handleSignal.bind(null, "SIGINT"));
595
+ process.on("SIGTERM", handleSignal.bind(null, "SIGTERM"));
596
+
597
+ const hooks = {
598
+ // Capture user prompts.
599
+ // input contains sessionID and model info; output.parts is the array of
600
+ // message parts — collect text parts to reconstruct the prompt string.
601
+ // UserMessage has no content field; the text lives in TextPart items.
602
+ "chat.message": async (input, output) => {
603
+ try {
604
+ const parts = output.parts || [];
605
+ const prompt = parts
606
+ .filter(function(p) { return p.type === "text"; })
607
+ .map(function(p) { return p.text || ""; })
608
+ .join("\\n")
609
+ .trim();
610
+
611
+ // Skip slash commands — they are not real user prompts.
612
+ // OpenCode fires chat.message for everything including /adit, /help, etc.
613
+ if (!prompt || prompt.startsWith("/")) return;
614
+
615
+ spawnAditHook("prompt-submit", {
616
+ cwd,
617
+ prompt: prompt,
618
+ session_id: input.sessionID,
619
+ model: input.model ? (input.model.providerID + "/" + input.model.modelID) : undefined,
620
+ });
621
+ } catch (e) {
622
+ // fail-open
623
+ }
624
+ },
625
+
626
+ // Capture session lifecycle + rich metadata via event bus.
627
+ // Note: there is no "stop" hook key in the OpenCode Plugin API.
628
+ // The equivalent is the "session.idle" event fired when the assistant
629
+ // finishes a response turn.
630
+ event: async ({ event }) => {
631
+ try {
632
+ const props = event.properties || {};
633
+
634
+ switch (event.type) {
635
+ // --- Assistant finished responding (replaces missing "stop" hook) ---
636
+ case "session.idle": {
637
+ // Use activeSessionId (captured from session.created with the
638
+ // proper "ses..." format) rather than props.sessionID which may
639
+ // use a different internal format.
640
+ var idleSessionId = activeSessionId || props.sessionID;
641
+
642
+ // Export transcript via "opencode export" CLI and write JSONL.
643
+ var transcriptPath = null;
644
+ if (idleSessionId) {
645
+ try {
646
+ transcriptPath = fetchTranscript(cwd, idleSessionId);
647
+ } catch (e) { /* fail-open */ }
648
+ }
649
+
650
+ spawnAditHook("stop", {
651
+ cwd,
652
+ session_id: idleSessionId,
653
+ stop_reason: "completed",
654
+ transcript_path: transcriptPath,
655
+ });
656
+ break;
657
+ }
658
+
659
+ // --- Session lifecycle ---
660
+ case "session.created": {
661
+ const info = props.info || {};
662
+ activeSessionId = info.id;
663
+ sessionEndFired = false;
664
+ spawnAditHook("session-start", {
665
+ cwd,
666
+ session_id: info.id,
667
+ source: "startup",
668
+ });
669
+ break;
670
+ }
671
+ case "session.deleted": {
672
+ const info = props.info || {};
673
+ if (activeSessionId === info.id) {
674
+ activeSessionId = undefined;
675
+ sessionEndFired = true;
676
+ }
677
+ spawnAditHook("session-end", {
678
+ cwd,
679
+ session_id: info.id,
680
+ reason: "deleted",
681
+ });
682
+ break;
683
+ }
684
+ case "session.error": {
685
+ // sessionID is optional in session.error — fall back to activeSessionId
686
+ const errorSessionId = props.sessionID || activeSessionId;
687
+ if (activeSessionId && activeSessionId === errorSessionId) {
688
+ activeSessionId = undefined;
689
+ sessionEndFired = true;
690
+ }
691
+ if (!errorSessionId) break;
692
+ spawnAditHook("session-end", {
693
+ cwd,
694
+ session_id: errorSessionId,
695
+ reason: "error",
696
+ error: props.error,
697
+ });
698
+ break;
699
+ }
700
+
701
+ // --- Message parts (tool results, step finishes) ---
702
+ case "message.part.updated": {
703
+ const part = props.part;
704
+ if (!part) break;
705
+
706
+ // Tool completion — captures tool name, input, output, timing
707
+ if (part.type === "tool" && part.state && part.state.status === "completed") {
708
+ spawnAditHook("notification", {
709
+ cwd,
710
+ session_id: part.sessionID,
711
+ notification_type: "tool_result",
712
+ title: part.state.title || part.tool,
713
+ message: "Tool " + part.tool + ": " + (part.state.title || "completed"),
714
+ tool_name: part.tool,
715
+ tool_input: part.state.input,
716
+ tool_output: part.state.output,
717
+ tool_time: part.state.time,
718
+ tool_metadata: part.state.metadata,
719
+ });
720
+ }
721
+
722
+ // Tool error
723
+ if (part.type === "tool" && part.state && part.state.status === "error") {
724
+ spawnAditHook("notification", {
725
+ cwd,
726
+ session_id: part.sessionID,
727
+ notification_type: "tool_error",
728
+ title: part.tool + " error",
729
+ message: "Tool " + part.tool + " failed: " + (part.state.error || "unknown"),
730
+ tool_name: part.tool,
731
+ tool_input: part.state.input,
732
+ error: part.state.error,
733
+ });
734
+ }
735
+
736
+ break;
737
+ }
738
+
739
+ // --- AI task tracking ---
740
+ case "todo.updated": {
741
+ const todos = props.todos;
742
+ if (!Array.isArray(todos)) break;
743
+
744
+ // Record the full todo list state as a notification so the
745
+ // complete task plan (pending, in_progress, completed) is
746
+ // captured and synced to the server.
747
+ spawnAditHook("notification", {
748
+ cwd,
749
+ session_id: props.sessionID,
750
+ notification_type: "todo_updated",
751
+ title: "Todo list updated (" + todos.length + " items)",
752
+ message: JSON.stringify(todos),
753
+ });
754
+
755
+ // Also emit individual task-completed events for completed
756
+ // todos (backward compatibility + semantic milestone tracking).
757
+ for (let i = 0; i < todos.length; i++) {
758
+ const todo = todos[i];
759
+ if (todo.status === "completed") {
760
+ spawnAditHook("task-completed", {
761
+ cwd,
762
+ session_id: props.sessionID,
763
+ task_id: todo.id,
764
+ task_subject: todo.content,
765
+ task_description: "Priority: " + (todo.priority || "medium"),
766
+ });
767
+ }
768
+ }
769
+ break;
770
+ }
771
+
772
+ // --- /exit, /quit, /q interception ---
773
+ // session.deleted does NOT fire on /exit — OpenCode just terminates.
774
+ // command.executed fires for ALL slash commands with { name, sessionID }.
775
+ // We must use spawnSync here so the cloud sync finishes before exit.
776
+ case "command.executed": {
777
+ const cmdName = (props.name || "").toLowerCase();
778
+ const isExit = cmdName === "exit" || cmdName === "quit" || cmdName === "q";
779
+ if (!isExit) break;
780
+ const exitSessionId = props.sessionID || activeSessionId;
781
+ if (!exitSessionId) break;
782
+ activeSessionId = undefined;
783
+ sessionEndFired = true;
784
+ spawnAditHookSync("session-end", {
785
+ cwd,
786
+ session_id: exitSessionId,
787
+ reason: "exit",
788
+ });
789
+ break;
790
+ }
791
+ }
792
+ } catch (e) {
793
+ // fail-open
794
+ }
795
+ },
796
+ };
797
+
798
+ return hooks;
799
+ };
800
+ `;
801
+ }
802
+ export const opencodeAdapter = {
803
+ platform: "opencode",
804
+ displayName: "OpenCode",
805
+ hookMappings: HOOK_MAPPINGS,
806
+ parseInput(raw, hookType) {
807
+ const aditHookType = EVENT_TO_ADIT[hookType] ?? hookType;
808
+ const cwd = raw.cwd ?? process.cwd();
809
+ return {
810
+ cwd,
811
+ hookType: aditHookType,
812
+ platformCli: "opencode",
813
+ platformSessionId: raw.session_id,
814
+ // Transcript (JSONL written by plugin from OpenCode API)
815
+ transcriptPath: raw.transcript_path,
816
+ // Prompt
817
+ prompt: raw.prompt,
818
+ // Stop
819
+ stopReason: raw.stop_reason,
820
+ lastAssistantMessage: raw.last_assistant_message,
821
+ // Session lifecycle
822
+ sessionSource: raw.source,
823
+ sessionEndReason: raw.reason,
824
+ model: raw.model,
825
+ // Notification (tool_result, tool_error, session_diff)
826
+ notificationMessage: raw.message,
827
+ notificationTitle: raw.title,
828
+ notificationType: raw.notification_type,
829
+ // Tool (from tool_result notifications)
830
+ toolName: raw.tool_name,
831
+ toolInput: raw.tool_input,
832
+ toolOutput: raw.tool_output,
833
+ // Task (from todo.updated)
834
+ taskId: raw.task_id,
835
+ taskSubject: raw.task_subject,
836
+ taskDescription: raw.task_description,
837
+ rawPlatformData: raw,
838
+ };
839
+ },
840
+ generateHookConfig(aditBinaryPath) {
841
+ return {
842
+ configPath: `.opencode/plugins/${PLUGIN_FILENAME}`,
843
+ content: {
844
+ plugin: generatePluginContent(aditBinaryPath),
845
+ },
846
+ };
847
+ },
848
+ async validateInstallation(projectRoot) {
849
+ const checks = [];
850
+ // Check .opencode/plugins directory
851
+ const pluginsDir = join(projectRoot, ".opencode", "plugins");
852
+ const pluginsDirExists = existsSync(pluginsDir);
853
+ checks.push({
854
+ name: ".opencode/plugins directory",
855
+ ok: pluginsDirExists,
856
+ detail: pluginsDirExists ? pluginsDir : "Not found",
857
+ });
858
+ // Check ADIT plugin file exists and is valid
859
+ const pluginPath = join(pluginsDir, PLUGIN_FILENAME);
860
+ let pluginOk = false;
861
+ let pluginDetail = "ADIT plugin not found";
862
+ if (existsSync(pluginPath)) {
863
+ try {
864
+ const content = readFileSync(pluginPath, "utf-8");
865
+ if (isAditPlugin(content)) {
866
+ pluginOk = true;
867
+ pluginDetail = `ADIT plugin installed at ${pluginPath}`;
868
+ }
869
+ else {
870
+ pluginDetail = `${pluginPath} exists but is not an ADIT plugin`;
871
+ }
872
+ }
873
+ catch {
874
+ pluginDetail = `Failed to read ${pluginPath}`;
875
+ }
876
+ }
877
+ checks.push({
878
+ name: "ADIT plugin",
879
+ ok: pluginOk,
880
+ detail: pluginDetail,
881
+ });
882
+ // Check slash command file
883
+ const cmdPath = join(projectRoot, ".opencode", "commands", ADIT_COMMAND.filename);
884
+ const cmdExists = existsSync(cmdPath);
885
+ checks.push({
886
+ name: "Command /adit",
887
+ ok: cmdExists,
888
+ detail: cmdExists ? cmdPath : "Not found",
889
+ });
890
+ // Check custom tools file
891
+ const toolPath = join(projectRoot, ".opencode", "tools", ADIT_TOOLS.filename);
892
+ const toolExists = existsSync(toolPath);
893
+ checks.push({
894
+ name: "Custom tools (adit_link, adit_intent)",
895
+ ok: toolExists,
896
+ detail: toolExists ? toolPath : "Not found",
897
+ });
898
+ return {
899
+ valid: checks.every((c) => c.ok),
900
+ checks,
901
+ };
902
+ },
903
+ async installHooks(projectRoot, aditBinaryPath) {
904
+ // Install the event-hook plugin
905
+ const pluginsDir = join(projectRoot, ".opencode", "plugins");
906
+ mkdirSync(pluginsDir, { recursive: true });
907
+ const pluginPath = join(pluginsDir, PLUGIN_FILENAME);
908
+ const content = generatePluginContent(aditBinaryPath);
909
+ writeFileSync(pluginPath, content);
910
+ // Install slash command
911
+ const commandsDir = join(projectRoot, ".opencode", "commands");
912
+ mkdirSync(commandsDir, { recursive: true });
913
+ writeFileSync(join(commandsDir, ADIT_COMMAND.filename), ADIT_COMMAND.content);
914
+ // Install custom tools
915
+ const toolsDir = join(projectRoot, ".opencode", "tools");
916
+ mkdirSync(toolsDir, { recursive: true });
917
+ writeFileSync(join(toolsDir, ADIT_TOOLS.filename), generateToolsContent());
918
+ // Clean up legacy command files from previous versions
919
+ for (const legacy of LEGACY_COMMAND_FILES) {
920
+ try {
921
+ unlinkSync(join(commandsDir, legacy));
922
+ }
923
+ catch { /* best-effort */ }
924
+ }
925
+ },
926
+ getResumeCommand(_projectRoot) {
927
+ return "opencode";
928
+ },
929
+ async uninstallHooks(projectRoot) {
930
+ // Remove the event-hook plugin
931
+ const pluginPath = join(projectRoot, ".opencode", "plugins", PLUGIN_FILENAME);
932
+ if (existsSync(pluginPath)) {
933
+ try {
934
+ const content = readFileSync(pluginPath, "utf-8");
935
+ if (isAditPlugin(content)) {
936
+ unlinkSync(pluginPath);
937
+ }
938
+ }
939
+ catch { /* best-effort */ }
940
+ }
941
+ // Remove slash command
942
+ const commandsDir = join(projectRoot, ".opencode", "commands");
943
+ try {
944
+ unlinkSync(join(commandsDir, ADIT_COMMAND.filename));
945
+ }
946
+ catch { /* best-effort */ }
947
+ // Remove custom tools
948
+ try {
949
+ unlinkSync(join(projectRoot, ".opencode", "tools", ADIT_TOOLS.filename));
950
+ }
951
+ catch { /* best-effort */ }
952
+ // Clean up legacy command files
953
+ for (const legacy of LEGACY_COMMAND_FILES) {
954
+ try {
955
+ unlinkSync(join(commandsDir, legacy));
956
+ }
957
+ catch { /* best-effort */ }
958
+ }
959
+ },
960
+ };
961
+ //# sourceMappingURL=opencode.js.map