@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.
- package/LICENSE +21 -0
- package/dist/adapters/claude-code.d.ts +9 -0
- package/dist/adapters/claude-code.d.ts.map +1 -0
- package/dist/adapters/claude-code.js +349 -0
- package/dist/adapters/claude-code.js.map +1 -0
- package/dist/adapters/index.d.ts +12 -0
- package/dist/adapters/index.d.ts.map +1 -0
- package/dist/adapters/index.js +29 -0
- package/dist/adapters/index.js.map +1 -0
- package/dist/adapters/opencode.d.ts +18 -0
- package/dist/adapters/opencode.d.ts.map +1 -0
- package/dist/adapters/opencode.js +961 -0
- package/dist/adapters/opencode.js.map +1 -0
- package/dist/adapters/registry.d.ts +31 -0
- package/dist/adapters/registry.d.ts.map +1 -0
- package/dist/adapters/registry.js +102 -0
- package/dist/adapters/registry.js.map +1 -0
- package/dist/adapters/stub.d.ts +24 -0
- package/dist/adapters/stub.d.ts.map +1 -0
- package/dist/adapters/stub.js +60 -0
- package/dist/adapters/stub.js.map +1 -0
- package/dist/adapters/types.d.ts +107 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +8 -0
- package/dist/adapters/types.js.map +1 -0
- package/dist/common/context.d.ts +19 -0
- package/dist/common/context.d.ts.map +1 -0
- package/dist/common/context.js +94 -0
- package/dist/common/context.js.map +1 -0
- package/dist/handlers/index.d.ts +2 -0
- package/dist/handlers/index.d.ts.map +1 -0
- package/dist/handlers/index.js +2 -0
- package/dist/handlers/index.js.map +1 -0
- package/dist/handlers/unified.d.ts +13 -0
- package/dist/handlers/unified.d.ts.map +1 -0
- package/dist/handlers/unified.js +304 -0
- package/dist/handlers/unified.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +1 -0
- 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
|