opencodekit 0.9.2 → 0.10.0

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.
@@ -1,140 +1,190 @@
1
1
  /**
2
2
  * OpenCode Enforcer Plugin
3
- * Warns when session goes idle with incomplete TODOs - prevents abandoned work
3
+ * ENFORCES continuation when session idles with incomplete TODOs
4
4
  *
5
- * Inspired by oh-my-opencode's todo-continuation-enforcer
5
+ * Upgrade from notification-only to actual enforcement:
6
+ * Uses client.session.promptAsync() to inject continuation prompt
6
7
  */
7
8
 
8
9
  import type { Plugin } from "@opencode-ai/plugin";
9
- import { exec } from "child_process";
10
- import { readFileSync } from "fs";
11
-
12
- // Notification helpers
13
- function isWSL(): boolean {
14
- try {
15
- const release = readFileSync("/proc/version", "utf8").toLowerCase();
16
- return release.includes("microsoft") || release.includes("wsl");
17
- } catch {
18
- return false;
19
- }
20
- }
10
+ import { notify } from "./lib/notify";
21
11
 
22
- function escapeShell(str: string): string {
23
- return str.replace(/"/g, '\\"').replace(/\$/g, "\\$").replace(/`/g, "\\`");
12
+ interface TodoItem {
13
+ id: string;
14
+ content: string;
15
+ status: "pending" | "in_progress" | "completed" | "cancelled";
16
+ priority: "high" | "medium" | "low";
24
17
  }
25
18
 
26
- function notify(title: string, message: string): void {
27
- const platform = process.platform;
28
- const safeTitle = title ? String(title) : "Notification";
29
- const safeMessage = message ? String(message) : "";
30
- const escapedTitle = escapeShell(safeTitle);
31
- const escapedMessage = escapeShell(safeMessage);
32
-
33
- let command: string;
34
-
35
- if (platform === "darwin") {
36
- command = `osascript -e 'display notification "${escapedMessage}" with title "${escapedTitle}"'`;
37
- } else if (platform === "linux") {
38
- command = `notify-send "${escapedTitle}" "${escapedMessage}"`;
39
- if (isWSL()) {
40
- command = `(${command}) 2>&1 || echo "WSL notification failed"`;
41
- }
42
- } else if (platform === "win32") {
43
- command = `powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${escapedMessage}', '${escapedTitle}')"`;
44
- } else {
45
- return;
46
- }
47
-
48
- exec(command, () => {});
49
- }
19
+ // Cooldown to prevent rapid-fire enforcement (5 minutes)
20
+ const ENFORCEMENT_COOLDOWN_MS = 5 * 60 * 1000;
50
21
 
51
- interface TodoItem {
52
- status: string;
53
- content?: string;
22
+ // Track last enforcement time per session
23
+ const lastEnforcement = new Map<string, number>();
24
+
25
+ // Track todos per session
26
+ const sessionTodos = new Map<string, TodoItem[]>();
27
+
28
+ /**
29
+ * Build continuation prompt based on incomplete TODOs
30
+ */
31
+ function buildContinuationPrompt(incomplete: TodoItem[]): string {
32
+ const highPriority = incomplete.filter((t) => t.priority === "high");
33
+ const inProgress = incomplete.filter((t) => t.status === "in_progress");
34
+
35
+ let prompt = "You stopped with incomplete work. Continue immediately.\n\n";
36
+
37
+ if (inProgress.length > 0) {
38
+ prompt += "**In Progress (finish these first):**\n";
39
+ inProgress.forEach((t) => {
40
+ prompt += `- ${t.content}\n`;
41
+ });
42
+ prompt += "\n";
43
+ }
44
+
45
+ if (highPriority.length > 0) {
46
+ const remaining = highPriority.filter((t) => t.status !== "in_progress");
47
+ if (remaining.length > 0) {
48
+ prompt += "**High Priority:**\n";
49
+ remaining.forEach((t) => {
50
+ prompt += `- ${t.content}\n`;
51
+ });
52
+ prompt += "\n";
53
+ }
54
+ }
55
+
56
+ const others = incomplete.filter(
57
+ (t) => t.priority !== "high" && t.status !== "in_progress",
58
+ );
59
+ if (others.length > 0) {
60
+ prompt += "**Remaining:**\n";
61
+ others.slice(0, 5).forEach((t) => {
62
+ prompt += `- ${t.content}\n`;
63
+ });
64
+ if (others.length > 5) {
65
+ prompt += `- ... and ${others.length - 5} more\n`;
66
+ }
67
+ }
68
+
69
+ prompt += "\nPick up where you left off. Do not ask for confirmation.";
70
+
71
+ return prompt;
54
72
  }
55
73
 
56
- export const EnforcerPlugin: Plugin = async ({ client }) => {
57
- const sessionTodos = new Map<string, { pending: number; total: number }>();
58
-
59
- client.app
60
- .log({
61
- body: {
62
- service: "enforcer-plugin",
63
- level: "info",
64
- message:
65
- "🛡️ Enforcer Plugin loaded - TODO completion enforcement active",
66
- },
67
- })
68
- .catch(() => {});
69
-
70
- return {
71
- event: async ({ event }) => {
72
- const props = event.properties as Record<string, unknown>;
73
-
74
- if (event.type === "todo.updated") {
75
- const sessionId = props?.sessionID as string | undefined;
76
- const todos = props?.todos as TodoItem[] | undefined;
77
-
78
- if (sessionId && todos) {
79
- const pending = todos.filter(
80
- (t) => t.status === "pending" || t.status === "in_progress",
81
- ).length;
82
- const total = todos.length;
83
- sessionTodos.set(sessionId, { pending, total });
84
-
85
- if (pending > 0) {
86
- client.app
87
- .log({
88
- body: {
89
- service: "enforcer-plugin",
90
- level: "debug",
91
- message: `📋 Session ${sessionId.slice(0, 8)}: ${pending}/${total} TODOs remaining`,
92
- },
93
- })
94
- .catch(() => {});
95
- }
96
- }
97
- }
98
-
99
- if (event.type === "session.idle") {
100
- const sessionId = props?.sessionID as string | undefined;
101
- if (!sessionId) return;
102
-
103
- const todoState = sessionTodos.get(sessionId);
104
-
105
- if (todoState && todoState.pending > 0) {
106
- const message = `${todoState.pending} incomplete TODO(s) remaining`;
107
-
108
- client.app
109
- .log({
110
- body: {
111
- service: "enforcer-plugin",
112
- level: "warn",
113
- message: `⚠️ Session idle with ${message}. Consider completing before stopping.`,
114
- },
115
- })
116
- .catch(() => {});
117
-
118
- notify("Incomplete TODOs", message);
119
- } else if (todoState && todoState.total > 0) {
120
- client.app
121
- .log({
122
- body: {
123
- service: "enforcer-plugin",
124
- level: "info",
125
- message: `✅ All ${todoState.total} TODOs completed`,
126
- },
127
- })
128
- .catch(() => {});
129
- }
130
- }
131
-
132
- if (event.type === "session.deleted") {
133
- const sessionId = props?.sessionID as string | undefined;
134
- if (sessionId) {
135
- sessionTodos.delete(sessionId);
136
- }
137
- }
138
- },
139
- };
74
+ export const EnforcerPlugin: Plugin = async ({ client, $ }) => {
75
+ return {
76
+ event: async ({ event }) => {
77
+ const props = event.properties as Record<string, unknown>;
78
+
79
+ // Track TODO updates
80
+ if (event.type === "todo.updated") {
81
+ const sessionId = props?.sessionID as string | undefined;
82
+ const todos = props?.todos as TodoItem[] | undefined;
83
+
84
+ if (sessionId && todos) {
85
+ sessionTodos.set(sessionId, todos);
86
+ }
87
+ }
88
+
89
+ // Enforce on session idle
90
+ if (event.type === "session.idle") {
91
+ const sessionId = props?.sessionID as string | undefined;
92
+ if (!sessionId) return;
93
+
94
+ const todos = sessionTodos.get(sessionId) || [];
95
+ const incomplete = todos.filter(
96
+ (t) => t.status === "pending" || t.status === "in_progress",
97
+ );
98
+
99
+ // No incomplete TODOs - nothing to enforce
100
+ if (incomplete.length === 0) return;
101
+
102
+ // Check cooldown to prevent spam
103
+ const now = Date.now();
104
+ const lastTime = lastEnforcement.get(sessionId) || 0;
105
+ if (now - lastTime < ENFORCEMENT_COOLDOWN_MS) {
106
+ return; // Still in cooldown
107
+ }
108
+
109
+ // Count high priority and in-progress
110
+ const highPriority = incomplete.filter((t) => t.priority === "high");
111
+ const inProgress = incomplete.filter((t) => t.status === "in_progress");
112
+
113
+ // Only enforce for high-priority or in-progress items
114
+ // Low priority pending items don't warrant forced continuation
115
+ if (highPriority.length === 0 && inProgress.length === 0) {
116
+ // Just notify for low-priority items
117
+ await notify(
118
+ $,
119
+ "Session Idle",
120
+ `📋 ${incomplete.length} TODO${incomplete.length > 1 ? "s" : ""} remaining`,
121
+ );
122
+ return;
123
+ }
124
+
125
+ // Update cooldown
126
+ lastEnforcement.set(sessionId, now);
127
+
128
+ // Build and send continuation prompt
129
+ const continuationPrompt = buildContinuationPrompt(incomplete);
130
+
131
+ try {
132
+ await client.session.promptAsync({
133
+ path: { id: sessionId },
134
+ body: {
135
+ parts: [
136
+ {
137
+ type: "text",
138
+ text: continuationPrompt,
139
+ },
140
+ ],
141
+ },
142
+ });
143
+
144
+ client.app
145
+ .log({
146
+ body: {
147
+ service: "enforcer",
148
+ level: "info",
149
+ message: `Enforced continuation: ${inProgress.length} in-progress, ${highPriority.length} high-priority TODOs`,
150
+ },
151
+ })
152
+ .catch(() => {});
153
+
154
+ await notify(
155
+ $,
156
+ "Enforcer",
157
+ `Continuing ${inProgress.length + highPriority.length} incomplete TODOs`,
158
+ );
159
+ } catch (error) {
160
+ // Fallback to notification if prompt fails
161
+ client.app
162
+ .log({
163
+ body: {
164
+ service: "enforcer",
165
+ level: "warn",
166
+ message: `Enforcement failed: ${(error as Error).message}`,
167
+ },
168
+ })
169
+ .catch(() => {});
170
+
171
+ const message =
172
+ highPriority.length > 0
173
+ ? `🔴 ${highPriority.length} high-priority TODO${highPriority.length > 1 ? "s" : ""} incomplete`
174
+ : `🟡 ${inProgress.length} TODO${inProgress.length > 1 ? "s" : ""} still in progress`;
175
+
176
+ await notify($, "Session Idle", message);
177
+ }
178
+ }
179
+
180
+ // Cleanup on session delete
181
+ if (event.type === "session.deleted") {
182
+ const sessionId = props?.sessionID as string | undefined;
183
+ if (sessionId) {
184
+ sessionTodos.delete(sessionId);
185
+ lastEnforcement.delete(sessionId);
186
+ }
187
+ }
188
+ },
189
+ };
140
190
  };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * OpenCode AGENTS.md Hierarchy Injector Plugin
3
+ *
4
+ * Walks up from read file's directory to project root, collecting ALL AGENTS.md
5
+ * files and injecting them into context. Solves the limitation where OpenCode's
6
+ * findUp only finds the first match.
7
+ *
8
+ * Injection order: root → specific (T-shaped context loading)
9
+ */
10
+
11
+ import { existsSync, readFileSync } from "fs";
12
+ import { dirname, join, resolve } from "path";
13
+ import type { Plugin } from "@opencode-ai/plugin";
14
+
15
+ // Cache injected directories per session to avoid duplicates
16
+ const sessionInjectedDirs = new Map<string, Set<string>>();
17
+
18
+ /**
19
+ * Walk up from a directory to root, collecting all AGENTS.md files
20
+ */
21
+ function collectAgentsMdFiles(
22
+ startDir: string,
23
+ projectRoot: string,
24
+ ): { path: string; content: string }[] {
25
+ const files: { path: string; content: string }[] = [];
26
+ let currentDir = resolve(startDir);
27
+ const root = resolve(projectRoot);
28
+
29
+ // Walk up until we hit project root or filesystem root
30
+ while (currentDir.startsWith(root) || currentDir === root) {
31
+ const agentsMdPath = join(currentDir, "AGENTS.md");
32
+
33
+ if (existsSync(agentsMdPath)) {
34
+ try {
35
+ const content = readFileSync(agentsMdPath, "utf-8");
36
+ files.push({ path: agentsMdPath, content });
37
+ } catch {
38
+ // Skip unreadable files
39
+ }
40
+ }
41
+
42
+ // Move up one directory
43
+ const parentDir = dirname(currentDir);
44
+ if (parentDir === currentDir) break; // Hit filesystem root
45
+ currentDir = parentDir;
46
+ }
47
+
48
+ // Reverse so root comes first (T-shaped: general → specific)
49
+ return files.reverse();
50
+ }
51
+
52
+ /**
53
+ * Check if a file path is a code file (not AGENTS.md itself)
54
+ */
55
+ function isCodeFile(filePath: string): boolean {
56
+ const lowerPath = filePath.toLowerCase();
57
+
58
+ // Skip AGENTS.md files themselves
59
+ if (lowerPath.endsWith("agents.md")) return false;
60
+
61
+ // Skip common non-code files
62
+ if (lowerPath.includes("node_modules/")) return false;
63
+ if (lowerPath.includes("/.git/")) return false;
64
+
65
+ return true;
66
+ }
67
+
68
+ /**
69
+ * Format injected AGENTS.md content
70
+ */
71
+ function formatInjection(
72
+ files: { path: string; content: string }[],
73
+ projectRoot: string,
74
+ ): string {
75
+ if (files.length === 0) return "";
76
+
77
+ const sections = files.map((f) => {
78
+ const relativePath = f.path.replace(projectRoot, "").replace(/^\//, "");
79
+ return `<!-- AGENTS.md from: ${relativePath} -->\n${f.content}`;
80
+ });
81
+
82
+ return (
83
+ "\n\n---\n## Injected AGENTS.md Hierarchy\n\n" +
84
+ sections.join("\n\n---\n\n") +
85
+ "\n---\n\n"
86
+ );
87
+ }
88
+
89
+ export const InjectorPlugin: Plugin = async ({ directory, worktree }) => {
90
+ // Use worktree if available (git worktree), otherwise use directory
91
+ const projectRoot = worktree || directory;
92
+
93
+ return {
94
+ "tool.execute.after": async (input, output) => {
95
+ // Only process read tool
96
+ if (input.tool !== "read") return;
97
+
98
+ // Get the file path from metadata
99
+ const filePath = output.metadata?.filePath as string | undefined;
100
+ if (!filePath) return;
101
+
102
+ // Skip non-code files
103
+ if (!isCodeFile(filePath)) return;
104
+
105
+ // Get or create session cache
106
+ let injectedDirs = sessionInjectedDirs.get(input.sessionID);
107
+ if (!injectedDirs) {
108
+ injectedDirs = new Set();
109
+ sessionInjectedDirs.set(input.sessionID, injectedDirs);
110
+ }
111
+
112
+ // Get the directory of the read file
113
+ const fileDir = dirname(resolve(filePath));
114
+
115
+ // Check if we've already injected for this directory chain
116
+ if (injectedDirs.has(fileDir)) return;
117
+
118
+ // Collect AGENTS.md files
119
+ const agentsFiles = collectAgentsMdFiles(fileDir, projectRoot);
120
+
121
+ // Skip if no AGENTS.md files found
122
+ if (agentsFiles.length === 0) return;
123
+
124
+ // Mark this directory as injected
125
+ injectedDirs.add(fileDir);
126
+
127
+ // Also mark parent directories to avoid redundant injections
128
+ let dir = fileDir;
129
+ while (dir.startsWith(projectRoot) && dir !== projectRoot) {
130
+ injectedDirs.add(dir);
131
+ dir = dirname(dir);
132
+ }
133
+
134
+ // Inject at the beginning of output
135
+ const injection = formatInjection(agentsFiles, projectRoot);
136
+ output.output = injection + output.output;
137
+ },
138
+
139
+ event: async ({ event }) => {
140
+ // Clean up cache when session is deleted
141
+ if (event.type === "session.deleted") {
142
+ const props = event.properties as Record<string, unknown>;
143
+ const sessionId = props?.sessionID as string | undefined;
144
+ if (sessionId) {
145
+ sessionInjectedDirs.delete(sessionId);
146
+ }
147
+ }
148
+ },
149
+ };
150
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Shared notification utilities for OpenCode plugins
3
+ * Uses Bun's shell API ($) as recommended by official docs
4
+ */
5
+
6
+ // Cache WSL detection result
7
+ let _isWSL: boolean | null = null;
8
+
9
+ /**
10
+ * Check if running in WSL (cached)
11
+ */
12
+ export function isWSL(): boolean {
13
+ if (_isWSL !== null) return _isWSL;
14
+ try {
15
+ const fs = require("fs");
16
+ const release = fs.readFileSync("/proc/version", "utf8").toLowerCase();
17
+ _isWSL = release.includes("microsoft") || release.includes("wsl");
18
+ } catch {
19
+ _isWSL = false;
20
+ }
21
+ return _isWSL ?? false;
22
+ }
23
+
24
+ /**
25
+ * Send native notification using Bun shell API
26
+ * @param $ - Bun shell from plugin context
27
+ * @param title - Notification title
28
+ * @param message - Notification body
29
+ */
30
+ export async function notify(
31
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
32
+ $: any,
33
+ title: string,
34
+ message: string,
35
+ ): Promise<void> {
36
+ const platform = process.platform;
37
+ const safeTitle = title || "OpenCode";
38
+ const safeMessage = message || "";
39
+
40
+ try {
41
+ if (platform === "darwin") {
42
+ await $`osascript -e ${'display notification "' + safeMessage + '" with title "' + safeTitle + '"'}`;
43
+ } else if (platform === "linux") {
44
+ if (isWSL()) {
45
+ // WSL: try notify-send, fail silently
46
+ await $`notify-send ${safeTitle} ${safeMessage}`.catch(() => {});
47
+ } else {
48
+ await $`notify-send ${safeTitle} ${safeMessage}`;
49
+ }
50
+ } else if (platform === "win32") {
51
+ // Windows: PowerShell toast (fire and forget)
52
+ await $`powershell -Command "Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.MessageBox]::Show('${safeMessage}', '${safeTitle}')"`.catch(
53
+ () => {},
54
+ );
55
+ }
56
+ } catch {
57
+ // Notifications are best-effort, never throw
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Threshold configuration for context monitoring
63
+ */
64
+ export const THRESHOLDS = {
65
+ MODERATE: 70,
66
+ URGENT: 85,
67
+ CRITICAL: 95,
68
+ } as const;
69
+
70
+ /**
71
+ * Token statistics from session events
72
+ */
73
+ export interface TokenStats {
74
+ used: number;
75
+ limit: number;
76
+ percentage?: number;
77
+ }
78
+
79
+ /**
80
+ * Calculate context percentage from token stats
81
+ */
82
+ export function getContextPercentage(stats: TokenStats): number {
83
+ if (stats.percentage) return stats.percentage;
84
+ if (stats.limit > 0) return Math.round((stats.used / stats.limit) * 100);
85
+ return 0;
86
+ }