-
Notifications
You must be signed in to change notification settings - Fork 275
Expand file tree
/
Copy pathindex.ts
More file actions
457 lines (392 loc) · 15.8 KB
/
index.ts
File metadata and controls
457 lines (392 loc) · 15.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
/**
* Plannotator Plugin for OpenCode
*
* Provides a Claude Code-style planning experience with interactive plan review.
* When the agent calls submit_plan, the Plannotator UI opens for the user to
* annotate, approve, or request changes to the plan.
*
* Environment variables:
* PLANNOTATOR_REMOTE - Set to "1" or "true" for remote mode (devcontainer, SSH)
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
* PLANNOTATOR_PLAN_TIMEOUT_SECONDS - Max wait for submit_plan approval (default: 345600, set 0 to disable)
*
* @packageDocumentation
*/
import { type Plugin, tool } from "@opencode-ai/plugin";
import {
startPlannotatorServer,
handleServerReady,
} from "@plannotator/server";
import {
startReviewServer,
handleReviewServerReady,
} from "@plannotator/server/review";
import {
startAnnotateServer,
handleAnnotateServerReady,
} from "@plannotator/server/annotate";
import { getGitContext, runGitDiff } from "@plannotator/server/git";
import { writeRemoteShareLink } from "@plannotator/server/share-url";
import { resolveMarkdownFile } from "@plannotator/server/resolve-file";
// @ts-ignore - Bun import attribute for text
import indexHtml from "./plannotator.html" with { type: "text" };
const htmlContent = indexHtml as unknown as string;
// @ts-ignore - Bun import attribute for text
import reviewHtml from "./review-editor.html" with { type: "text" };
const reviewHtmlContent = reviewHtml as unknown as string;
const DEFAULT_PLAN_TIMEOUT_SECONDS = 345_600; // 96 hours
export const PlannotatorPlugin: Plugin = async (ctx) => {
// Helper to determine if sharing is enabled (lazy evaluation)
// Priority: OpenCode config > env var > default (enabled)
async function getSharingEnabled(): Promise<boolean> {
try {
const response = await ctx.client.config.get({ query: { directory: ctx.directory } });
// Config is wrapped in response.data
// @ts-ignore - share config may exist
const share = response?.data?.share;
if (share !== undefined) {
return share !== "disabled";
}
} catch {
// Config read failed, fall through to env var
}
// Fall back to env var
return process.env.PLANNOTATOR_SHARE !== "disabled";
}
// Custom share portal URL for self-hosting
function getShareBaseUrl(): string | undefined {
return process.env.PLANNOTATOR_SHARE_URL || undefined;
}
/**
* submit_plan wait timeout in seconds.
* - unset: default to 96h
* - 0: disable timeout
* - invalid/negative: fall back to default
*/
function getPlanTimeoutSeconds(): number | null {
const raw = process.env.PLANNOTATOR_PLAN_TIMEOUT_SECONDS?.trim();
if (!raw) return DEFAULT_PLAN_TIMEOUT_SECONDS;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
console.error(
`[Plannotator] Invalid PLANNOTATOR_PLAN_TIMEOUT_SECONDS="${raw}". Using default ${DEFAULT_PLAN_TIMEOUT_SECONDS}s.`
);
return DEFAULT_PLAN_TIMEOUT_SECONDS;
}
if (parsed === 0) return null;
return parsed;
}
return {
// Register submit_plan as primary-only tool (hidden from sub-agents)
config: async (opencodeConfig) => {
const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? [];
if (!existingPrimaryTools.includes("submit_plan")) {
opencodeConfig.experimental = {
...opencodeConfig.experimental,
primary_tools: [...existingPrimaryTools, "submit_plan"],
};
}
},
// Inject planning instructions into system prompt
"experimental.chat.system.transform": async (input, output) => {
// Skip for title generation requests
const existingSystem = output.system.join("\n").toLowerCase();
if (existingSystem.includes("title generator") || existingSystem.includes("generate a title")) {
return;
}
try {
// Fetch session messages to determine current agent
const messagesResponse = await ctx.client.session.messages({
path: { id: input.sessionID }
});
const messages = messagesResponse.data;
// Find last user message (reverse iteration)
let lastUserAgent: string | undefined;
if (messages) {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.info.role === "user") {
// @ts-ignore - UserMessage has agent field
lastUserAgent = msg.info.agent;
break;
}
}
}
// Skip if agent detection fails (safer)
if (!lastUserAgent) return;
// Hardcoded exclusion: build agent
if (lastUserAgent === "build") return;
// Dynamic exclusion: check agent mode via API
const agentsResponse = await ctx.client.app.agents({
query: { directory: ctx.directory }
});
const agents = agentsResponse.data;
const agent = agents?.find((a: { name: string }) => a.name === lastUserAgent);
// Skip if agent is a sub-agent
// @ts-ignore - Agent has mode field
if (agent?.mode === "subagent") return;
} catch {
// Skip injection on any error (safer)
return;
}
output.system.push(`
## Plan Submission
When you have completed your plan, you MUST call the \`submit_plan\` tool to submit it for user review.
The user will be able to:
- Review your plan visually in a dedicated UI
- Annotate specific sections with feedback
- Approve the plan to proceed with implementation
- Request changes with detailed feedback
If your plan is rejected, you will receive the user's annotated feedback. Revise your plan
based on their feedback and call submit_plan again.
Do NOT proceed with implementation until your plan is approved.
`);
},
// Listen for /plannotator-review command
event: async ({ event }) => {
// Check for command execution event
const isCommandEvent =
event.type === "command.executed" ||
event.type === "tui.command.execute";
// @ts-ignore - Event structure: event.properties.name for command.executed
const commandName = event.properties?.name || event.command || event.payload?.name;
const isReviewCommand = commandName === "plannotator-review";
if (isCommandEvent && isReviewCommand) {
ctx.client.app.log({
level: "info",
message: "Opening code review UI...",
});
// Get git context (branches, available diff options)
const gitContext = await getGitContext();
// Run git diff HEAD (uncommitted changes - default)
const { patch: rawPatch, label: gitRef, error: diffError } = await runGitDiff(
"uncommitted",
gitContext.defaultBranch
);
// Start server even if empty - user can switch diff types
const server = await startReviewServer({
rawPatch,
gitRef,
error: diffError,
origin: "opencode",
diffType: "uncommitted",
gitContext,
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent: reviewHtmlContent,
opencodeClient: ctx.client,
onReady: handleReviewServerReady,
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
// Send feedback back to the session if provided
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID for command.executed events
const sessionId = event.properties?.sessionID;
// Only try to send feedback if we have a valid session ID
if (sessionId) {
// Check agent switch setting (defaults to 'build' if not set)
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled';
const targetAgent = result.agentSwitch || 'build';
// Send feedback to agent - it will automatically respond and address it
try {
await ctx.client.session.prompt({
path: { id: sessionId },
body: {
...(shouldSwitchAgent && { agent: targetAgent }),
parts: [
{
type: "text",
text: `# Code Review Feedback\n\n${result.feedback}\n\nPlease address this feedback.`,
},
],
},
});
} catch {
// Session may not be available
}
}
}
}
// Handle /plannotator-annotate command
const isAnnotateCommand = commandName === "plannotator-annotate";
if (isCommandEvent && isAnnotateCommand) {
// @ts-ignore - Event properties contain arguments
const filePath = event.properties?.arguments || event.arguments || "";
if (!filePath) {
ctx.client.app.log({
level: "error",
message: "Usage: /plannotator-annotate <file.md>",
});
return;
}
ctx.client.app.log({
level: "info",
message: `Opening annotation UI for ${filePath}...`,
});
// Smart file resolution: exact path, case-insensitive relative, or bare filename
const projectRoot = process.cwd();
const resolved = await resolveMarkdownFile(filePath, projectRoot);
if (resolved.kind === "ambiguous") {
ctx.client.app.log({
level: "error",
message: `Ambiguous filename "${resolved.input}" — found ${resolved.matches.length} matches:\n${resolved.matches.map((m) => ` ${m}`).join("\n")}`,
});
return;
}
if (resolved.kind === "not_found") {
ctx.client.app.log({
level: "error",
message: `File not found: ${resolved.input}`,
});
return;
}
const absolutePath = resolved.path;
ctx.client.app.log({
level: "info",
message: `Resolved: ${absolutePath}`,
});
const markdown = await Bun.file(absolutePath).text();
// Start annotate server (reuses plan editor HTML)
const server = await startAnnotateServer({
markdown,
filePath: absolutePath,
origin: "opencode",
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent: htmlContent,
onReady: handleAnnotateServerReady,
});
const result = await server.waitForDecision();
await Bun.sleep(1500);
server.stop();
// Send feedback back to the session if provided
if (result.feedback) {
// @ts-ignore - Event properties contain sessionID for command.executed events
const sessionId = event.properties?.sessionID;
if (sessionId) {
try {
await ctx.client.session.prompt({
path: { id: sessionId },
body: {
parts: [
{
type: "text",
text: `# Markdown Annotations\n\nFile: ${absolutePath}\n\n${result.feedback}\n\nPlease address the annotation feedback above.`,
},
],
},
});
} catch {
// Session may not be available
}
}
}
}
},
tool: {
submit_plan: tool({
description:
"Submit your completed plan for interactive user review. The user can annotate, approve, or request changes. Call this when you have finished creating your implementation plan.",
args: {
plan: tool.schema
.string()
.describe("The complete implementation plan in markdown format"),
summary: tool.schema
.string()
.describe("A brief 1-2 sentence summary of what the plan accomplishes"),
},
async execute(args, context) {
const server = await startPlannotatorServer({
plan: args.plan,
origin: "opencode",
sharingEnabled: await getSharingEnabled(),
shareBaseUrl: getShareBaseUrl(),
htmlContent,
opencodeClient: ctx.client,
onReady: async (url, isRemote, port) => {
handleServerReady(url, isRemote, port);
if (isRemote && await getSharingEnabled()) {
await writeRemoteShareLink(args.plan, url, getShareBaseUrl(), "review the plan", "plan only").catch(() => {});
}
},
});
const timeoutSeconds = getPlanTimeoutSeconds();
const timeoutMs = timeoutSeconds === null ? null : timeoutSeconds * 1000;
const result = timeoutMs === null
? await server.waitForDecision()
: await new Promise<Awaited<ReturnType<typeof server.waitForDecision>>>((resolve) => {
const timeoutId = setTimeout(
() =>
resolve({
approved: false,
feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`,
}),
timeoutMs
);
server.waitForDecision().then((r) => {
clearTimeout(timeoutId);
resolve(r);
});
});
await Bun.sleep(1500);
server.stop();
if (result.approved) {
// Check agent switch setting (defaults to 'build' if not set)
const shouldSwitchAgent = result.agentSwitch && result.agentSwitch !== 'disabled';
const targetAgent = result.agentSwitch || 'build';
if (shouldSwitchAgent) {
// Switch TUI display to target agent
try {
await ctx.client.tui.executeCommand({
body: { command: "agent_cycle" },
});
} catch {
// Silently fail
}
// Create a user message with target agent using noReply: true
// This ensures the message is created BEFORE we return from the tool,
// so the current loop's next iteration will see it.
// noReply: true means we don't wait for a new loop to complete.
try {
await ctx.client.session.prompt({
path: { id: context.sessionID },
body: {
agent: targetAgent,
noReply: true,
parts: [{ type: "text", text: "Proceed with implementation" }],
},
});
} catch {
// Silently fail if session is busy
}
}
// If user approved with annotations, include them as notes for implementation
if (result.feedback) {
return `Plan approved with notes!
Plan Summary: ${args.summary}
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}
## Implementation Notes
The user approved your plan but added the following notes to consider during implementation:
${result.feedback}
Proceed with implementation, incorporating these notes where applicable.`;
}
return `Plan approved!
Plan Summary: ${args.summary}
${result.savedPath ? `Saved to: ${result.savedPath}` : ""}`;
} else {
return `Plan needs revision.
${result.savedPath ? `\nSaved to: ${result.savedPath}` : ""}
The user has requested changes to your plan. Please review their feedback below and revise your plan accordingly.
## User Feedback
${result.feedback}
---
Please revise your plan based on this feedback and call \`submit_plan\` again when ready.`;
}
},
}),
},
};
};
export default PlannotatorPlugin;