micode 0.6.0 → 0.7.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 (84) hide show
  1. package/README.md +64 -331
  2. package/package.json +9 -14
  3. package/src/agents/artifact-searcher.ts +46 -0
  4. package/src/agents/brainstormer.ts +145 -0
  5. package/src/agents/codebase-analyzer.ts +75 -0
  6. package/src/agents/codebase-locator.ts +71 -0
  7. package/src/agents/commander.ts +138 -0
  8. package/src/agents/executor.ts +215 -0
  9. package/src/agents/implementer.ts +99 -0
  10. package/src/agents/index.ts +44 -0
  11. package/src/agents/ledger-creator.ts +113 -0
  12. package/src/agents/pattern-finder.ts +70 -0
  13. package/src/agents/planner.ts +230 -0
  14. package/src/agents/project-initializer.ts +264 -0
  15. package/src/agents/reviewer.ts +102 -0
  16. package/src/config-loader.ts +89 -0
  17. package/src/hooks/artifact-auto-index.ts +111 -0
  18. package/src/hooks/auto-clear-ledger.ts +230 -0
  19. package/src/hooks/auto-compact.ts +241 -0
  20. package/src/hooks/comment-checker.ts +120 -0
  21. package/src/hooks/context-injector.ts +163 -0
  22. package/src/hooks/context-window-monitor.ts +106 -0
  23. package/src/hooks/file-ops-tracker.ts +96 -0
  24. package/src/hooks/ledger-loader.ts +78 -0
  25. package/src/hooks/preemptive-compaction.ts +183 -0
  26. package/src/hooks/session-recovery.ts +258 -0
  27. package/src/hooks/token-aware-truncation.ts +189 -0
  28. package/src/index.ts +258 -0
  29. package/src/tools/artifact-index/index.ts +269 -0
  30. package/src/tools/artifact-index/schema.sql +44 -0
  31. package/src/tools/artifact-search.ts +49 -0
  32. package/src/tools/ast-grep/index.ts +189 -0
  33. package/src/tools/background-task/manager.ts +374 -0
  34. package/src/tools/background-task/tools.ts +145 -0
  35. package/src/tools/background-task/types.ts +68 -0
  36. package/src/tools/btca/index.ts +82 -0
  37. package/src/tools/look-at.ts +210 -0
  38. package/src/tools/pty/buffer.ts +49 -0
  39. package/src/tools/pty/index.ts +34 -0
  40. package/src/tools/pty/manager.ts +159 -0
  41. package/src/tools/pty/tools/kill.ts +68 -0
  42. package/src/tools/pty/tools/list.ts +55 -0
  43. package/src/tools/pty/tools/read.ts +152 -0
  44. package/src/tools/pty/tools/spawn.ts +78 -0
  45. package/src/tools/pty/tools/write.ts +97 -0
  46. package/src/tools/pty/types.ts +62 -0
  47. package/src/utils/model-limits.ts +36 -0
  48. package/dist/agents/artifact-searcher.d.ts +0 -2
  49. package/dist/agents/brainstormer.d.ts +0 -2
  50. package/dist/agents/codebase-analyzer.d.ts +0 -2
  51. package/dist/agents/codebase-locator.d.ts +0 -2
  52. package/dist/agents/commander.d.ts +0 -3
  53. package/dist/agents/executor.d.ts +0 -2
  54. package/dist/agents/implementer.d.ts +0 -2
  55. package/dist/agents/index.d.ts +0 -15
  56. package/dist/agents/ledger-creator.d.ts +0 -2
  57. package/dist/agents/pattern-finder.d.ts +0 -2
  58. package/dist/agents/planner.d.ts +0 -2
  59. package/dist/agents/project-initializer.d.ts +0 -2
  60. package/dist/agents/reviewer.d.ts +0 -2
  61. package/dist/config-loader.d.ts +0 -20
  62. package/dist/hooks/artifact-auto-index.d.ts +0 -19
  63. package/dist/hooks/auto-clear-ledger.d.ts +0 -11
  64. package/dist/hooks/auto-compact.d.ts +0 -9
  65. package/dist/hooks/comment-checker.d.ts +0 -9
  66. package/dist/hooks/context-injector.d.ts +0 -15
  67. package/dist/hooks/context-window-monitor.d.ts +0 -15
  68. package/dist/hooks/file-ops-tracker.d.ts +0 -26
  69. package/dist/hooks/ledger-loader.d.ts +0 -16
  70. package/dist/hooks/preemptive-compaction.d.ts +0 -9
  71. package/dist/hooks/session-recovery.d.ts +0 -9
  72. package/dist/hooks/token-aware-truncation.d.ts +0 -15
  73. package/dist/index.d.ts +0 -3
  74. package/dist/index.js +0 -16267
  75. package/dist/tools/artifact-index/index.d.ts +0 -38
  76. package/dist/tools/artifact-search.d.ts +0 -17
  77. package/dist/tools/ast-grep/index.d.ts +0 -88
  78. package/dist/tools/background-task/manager.d.ts +0 -27
  79. package/dist/tools/background-task/tools.d.ts +0 -41
  80. package/dist/tools/background-task/types.d.ts +0 -53
  81. package/dist/tools/btca/index.d.ts +0 -19
  82. package/dist/tools/look-at.d.ts +0 -11
  83. package/dist/utils/model-limits.d.ts +0 -7
  84. /package/{dist/tools/background-task/index.d.ts → src/tools/background-task/index.ts} +0 -0
@@ -0,0 +1,258 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+
3
+ // Error patterns we can recover from
4
+ const RECOVERABLE_ERRORS = {
5
+ TOOL_RESULT_MISSING: "tool_result block(s) missing",
6
+ THINKING_BLOCK_ORDER: "thinking blocks must be at the start",
7
+ THINKING_DISABLED: "thinking is not enabled",
8
+ EMPTY_CONTENT: "content cannot be empty",
9
+ INVALID_TOOL_RESULT: "tool_result must follow tool_use",
10
+ } as const;
11
+
12
+ type RecoverableErrorType = keyof typeof RECOVERABLE_ERRORS;
13
+
14
+ interface RecoveryState {
15
+ processingErrors: Set<string>;
16
+ recoveryAttempts: Map<string, number>;
17
+ }
18
+
19
+ const MAX_RECOVERY_ATTEMPTS = 3;
20
+
21
+ function extractErrorInfo(error: unknown): { message: string; messageIndex?: number } | null {
22
+ if (!error) return null;
23
+
24
+ let errorStr: string;
25
+ if (typeof error === "string") {
26
+ errorStr = error;
27
+ } else if (error instanceof Error) {
28
+ errorStr = error.message;
29
+ } else {
30
+ errorStr = JSON.stringify(error);
31
+ }
32
+
33
+ const errorLower = errorStr.toLowerCase();
34
+
35
+ // Extract message index if present (e.g., "messages.5" or "message 5")
36
+ const indexMatch = errorStr.match(/messages?[.\s](\d+)/i);
37
+ const messageIndex = indexMatch ? parseInt(indexMatch[1], 10) : undefined;
38
+
39
+ return { message: errorLower, messageIndex };
40
+ }
41
+
42
+ function identifyErrorType(errorMessage: string): RecoverableErrorType | null {
43
+ for (const [type, pattern] of Object.entries(RECOVERABLE_ERRORS)) {
44
+ if (errorMessage.includes(pattern.toLowerCase())) {
45
+ return type as RecoverableErrorType;
46
+ }
47
+ }
48
+ return null;
49
+ }
50
+
51
+ export function createSessionRecoveryHook(ctx: PluginInput) {
52
+ const state: RecoveryState = {
53
+ processingErrors: new Set(),
54
+ recoveryAttempts: new Map(),
55
+ };
56
+
57
+ async function getSessionMessages(sessionID: string): Promise<unknown[]> {
58
+ try {
59
+ const resp = await ctx.client.session.messages({
60
+ path: { id: sessionID },
61
+ query: { directory: ctx.directory },
62
+ });
63
+ return (resp as { data?: unknown[] }).data || [];
64
+ } catch {
65
+ return [];
66
+ }
67
+ }
68
+
69
+ async function abortSession(sessionID: string): Promise<void> {
70
+ try {
71
+ await ctx.client.session.abort({
72
+ path: { id: sessionID },
73
+ query: { directory: ctx.directory },
74
+ });
75
+ } catch {
76
+ // Ignore abort errors
77
+ }
78
+ }
79
+
80
+ async function resumeSession(
81
+ sessionID: string,
82
+ providerID?: string,
83
+ modelID?: string,
84
+ agent?: string,
85
+ ): Promise<void> {
86
+ try {
87
+ // Find last user message to resume from
88
+ const messages = await getSessionMessages(sessionID);
89
+ const lastUserMsg = [...messages].reverse().find((m) => {
90
+ const msg = m as Record<string, unknown>;
91
+ const info = msg.info as Record<string, unknown> | undefined;
92
+ return info?.role === "user";
93
+ });
94
+
95
+ if (!lastUserMsg) return;
96
+
97
+ const parts = (lastUserMsg as Record<string, unknown>).parts as Array<{
98
+ type: string;
99
+ text?: string;
100
+ }>;
101
+ const text = parts?.find((p) => p.type === "text")?.text;
102
+
103
+ if (!text) return;
104
+
105
+ // Resume with continue prompt
106
+ await ctx.client.session.prompt({
107
+ path: { id: sessionID },
108
+ body: {
109
+ parts: [{ type: "text", text: "Continue from where you left off." }],
110
+ ...(providerID && modelID ? { providerID, modelID } : {}),
111
+ ...(agent ? { agent } : {}),
112
+ },
113
+ query: { directory: ctx.directory },
114
+ });
115
+ } catch {
116
+ // Resume failed - user will need to manually continue
117
+ }
118
+ }
119
+
120
+ async function attemptRecovery(
121
+ sessionID: string,
122
+ errorType: RecoverableErrorType,
123
+ providerID?: string,
124
+ modelID?: string,
125
+ agent?: string,
126
+ ): Promise<boolean> {
127
+ const recoveryKey = `${sessionID}:${errorType}`;
128
+
129
+ // Check recovery attempts
130
+ const attempts = state.recoveryAttempts.get(recoveryKey) || 0;
131
+ if (attempts >= MAX_RECOVERY_ATTEMPTS) {
132
+ await ctx.client.tui
133
+ .showToast({
134
+ body: {
135
+ title: "Recovery Failed",
136
+ message: `Max attempts reached for ${errorType}. Manual intervention needed.`,
137
+ variant: "error",
138
+ duration: 5000,
139
+ },
140
+ })
141
+ .catch(() => {});
142
+ return false;
143
+ }
144
+
145
+ state.recoveryAttempts.set(recoveryKey, attempts + 1);
146
+
147
+ await ctx.client.tui
148
+ .showToast({
149
+ body: {
150
+ title: "Session Recovery",
151
+ message: `Recovering from ${errorType.toLowerCase().replace(/_/g, " ")}...`,
152
+ variant: "warning",
153
+ duration: 3000,
154
+ },
155
+ })
156
+ .catch(() => {});
157
+
158
+ // Abort current session to stop the error state
159
+ await abortSession(sessionID);
160
+
161
+ // Wait a moment for abort to complete
162
+ await new Promise((resolve) => setTimeout(resolve, 500));
163
+
164
+ // Attempt resume
165
+ await resumeSession(sessionID, providerID, modelID, agent);
166
+
167
+ await ctx.client.tui
168
+ .showToast({
169
+ body: {
170
+ title: "Recovery Complete",
171
+ message: "Session resumed. Continuing...",
172
+ variant: "success",
173
+ duration: 3000,
174
+ },
175
+ })
176
+ .catch(() => {});
177
+
178
+ return true;
179
+ }
180
+
181
+ return {
182
+ event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
183
+ const props = event.properties as Record<string, unknown> | undefined;
184
+
185
+ // Cleanup on session delete
186
+ if (event.type === "session.deleted") {
187
+ const sessionInfo = props?.info as { id?: string } | undefined;
188
+ if (sessionInfo?.id) {
189
+ // Clean up all recovery attempts for this session
190
+ for (const key of state.recoveryAttempts.keys()) {
191
+ if (key.startsWith(sessionInfo.id)) {
192
+ state.recoveryAttempts.delete(key);
193
+ }
194
+ }
195
+ for (const key of state.processingErrors) {
196
+ if (key.startsWith(sessionInfo.id)) {
197
+ state.processingErrors.delete(key);
198
+ }
199
+ }
200
+ }
201
+ return;
202
+ }
203
+
204
+ // Handle session errors
205
+ if (event.type === "session.error") {
206
+ const sessionID = props?.sessionID as string | undefined;
207
+ const error = props?.error;
208
+
209
+ if (!sessionID || !error) return;
210
+
211
+ const errorInfo = extractErrorInfo(error);
212
+ if (!errorInfo) return;
213
+
214
+ const errorType = identifyErrorType(errorInfo.message);
215
+ if (!errorType) return;
216
+
217
+ // Prevent duplicate processing
218
+ const errorKey = `${sessionID}:${errorType}:${Date.now()}`;
219
+ if (state.processingErrors.has(errorKey)) return;
220
+ state.processingErrors.add(errorKey);
221
+
222
+ // Clear old error keys after 10 seconds
223
+ setTimeout(() => state.processingErrors.delete(errorKey), 10000);
224
+
225
+ // Attempt recovery
226
+ await attemptRecovery(sessionID, errorType);
227
+ }
228
+
229
+ // Handle message errors
230
+ if (event.type === "message.updated") {
231
+ const info = props?.info as Record<string, unknown> | undefined;
232
+ const sessionID = info?.sessionID as string | undefined;
233
+ const error = info?.error;
234
+
235
+ if (!sessionID || !error) return;
236
+
237
+ const errorInfo = extractErrorInfo(error);
238
+ if (!errorInfo) return;
239
+
240
+ const errorType = identifyErrorType(errorInfo.message);
241
+ if (!errorType) return;
242
+
243
+ // Prevent duplicate processing
244
+ const errorKey = `${sessionID}:${errorType}:${Date.now()}`;
245
+ if (state.processingErrors.has(errorKey)) return;
246
+ state.processingErrors.add(errorKey);
247
+
248
+ setTimeout(() => state.processingErrors.delete(errorKey), 10000);
249
+
250
+ const providerID = info.providerID as string | undefined;
251
+ const modelID = info.modelID as string | undefined;
252
+ const agent = info.agent as string | undefined;
253
+
254
+ await attemptRecovery(sessionID, errorType, providerID, modelID, agent);
255
+ }
256
+ },
257
+ };
258
+ }
@@ -0,0 +1,189 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+
3
+ // Tools that benefit from truncation
4
+ const TRUNCATABLE_TOOLS = ["grep", "Grep", "glob", "Glob", "ast_grep_search"];
5
+
6
+ // Token estimation (conservative: 4 chars = 1 token)
7
+ const CHARS_PER_TOKEN = 4;
8
+ const DEFAULT_CONTEXT_LIMIT = 200_000;
9
+ const DEFAULT_MAX_OUTPUT_TOKENS = 50_000;
10
+ const SAFETY_MARGIN = 0.5; // Keep 50% headroom
11
+ const PRESERVE_HEADER_LINES = 3;
12
+
13
+ function estimateTokens(text: string): number {
14
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
15
+ }
16
+
17
+ function truncateToTokenLimit(
18
+ output: string,
19
+ maxTokens: number,
20
+ preserveLines: number = PRESERVE_HEADER_LINES,
21
+ ): string {
22
+ const currentTokens = estimateTokens(output);
23
+
24
+ if (currentTokens <= maxTokens) {
25
+ return output;
26
+ }
27
+
28
+ const lines = output.split("\n");
29
+
30
+ // Preserve header lines
31
+ const headerLines = lines.slice(0, preserveLines);
32
+ const remainingLines = lines.slice(preserveLines);
33
+
34
+ // Calculate available tokens for content
35
+ const headerTokens = estimateTokens(headerLines.join("\n"));
36
+ const truncationMsgTokens = 50; // Reserve for truncation message
37
+ const availableTokens = maxTokens - headerTokens - truncationMsgTokens;
38
+
39
+ if (availableTokens <= 0) {
40
+ return `${headerLines.join("\n")}\n\n[Output truncated - context window limit reached]`;
41
+ }
42
+
43
+ // Accumulate lines until we hit the limit
44
+ const resultLines: string[] = [];
45
+ let usedTokens = 0;
46
+ let truncatedCount = 0;
47
+
48
+ for (const line of remainingLines) {
49
+ const lineTokens = estimateTokens(line);
50
+ if (usedTokens + lineTokens > availableTokens) {
51
+ truncatedCount = remainingLines.length - resultLines.length;
52
+ break;
53
+ }
54
+ resultLines.push(line);
55
+ usedTokens += lineTokens;
56
+ }
57
+
58
+ if (truncatedCount === 0) {
59
+ return output;
60
+ }
61
+
62
+ return [
63
+ ...headerLines,
64
+ ...resultLines,
65
+ "",
66
+ `[${truncatedCount} more lines truncated due to context window limit]`,
67
+ ].join("\n");
68
+ }
69
+
70
+ interface TruncationState {
71
+ sessionTokenUsage: Map<string, { used: number; limit: number }>;
72
+ }
73
+
74
+ export function createTokenAwareTruncationHook(ctx: PluginInput) {
75
+ const state: TruncationState = {
76
+ sessionTokenUsage: new Map(),
77
+ };
78
+
79
+ async function updateTokenUsage(sessionID: string): Promise<{ used: number; limit: number }> {
80
+ try {
81
+ const resp = await ctx.client.session.messages({
82
+ path: { id: sessionID },
83
+ query: { directory: ctx.directory },
84
+ });
85
+
86
+ const messages = (resp as { data?: unknown[] }).data;
87
+ if (!Array.isArray(messages) || messages.length === 0) {
88
+ return { used: 0, limit: DEFAULT_CONTEXT_LIMIT };
89
+ }
90
+
91
+ // Find last assistant message with usage info
92
+ const lastAssistant = [...messages].reverse().find((m) => {
93
+ const msg = m as Record<string, unknown>;
94
+ const info = msg.info as Record<string, unknown> | undefined;
95
+ return info?.role === "assistant";
96
+ }) as Record<string, unknown> | undefined;
97
+
98
+ if (!lastAssistant) {
99
+ return { used: 0, limit: DEFAULT_CONTEXT_LIMIT };
100
+ }
101
+
102
+ const info = lastAssistant.info as Record<string, unknown> | undefined;
103
+ const usage = info?.usage as Record<string, unknown> | undefined;
104
+
105
+ const inputTokens = (usage?.inputTokens as number) || 0;
106
+ const cacheRead = (usage?.cacheReadInputTokens as number) || 0;
107
+ const used = inputTokens + cacheRead;
108
+
109
+ // Get model limit (simplified - use default for now)
110
+ const limit = DEFAULT_CONTEXT_LIMIT;
111
+
112
+ const result = { used, limit };
113
+ state.sessionTokenUsage.set(sessionID, result);
114
+ return result;
115
+ } catch {
116
+ return state.sessionTokenUsage.get(sessionID) || { used: 0, limit: DEFAULT_CONTEXT_LIMIT };
117
+ }
118
+ }
119
+
120
+ function calculateMaxOutputTokens(used: number, limit: number): number {
121
+ const remaining = limit - used;
122
+ const available = Math.floor(remaining * SAFETY_MARGIN);
123
+
124
+ if (available <= 0) {
125
+ return 0;
126
+ }
127
+
128
+ return Math.min(available, DEFAULT_MAX_OUTPUT_TOKENS);
129
+ }
130
+
131
+ return {
132
+ // Update token usage when assistant messages are received
133
+ event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
134
+ const props = event.properties as Record<string, unknown> | undefined;
135
+
136
+ if (event.type === "session.deleted") {
137
+ const sessionInfo = props?.info as { id?: string } | undefined;
138
+ if (sessionInfo?.id) {
139
+ state.sessionTokenUsage.delete(sessionInfo.id);
140
+ }
141
+ return;
142
+ }
143
+
144
+ // Update usage on message updates
145
+ if (event.type === "message.updated") {
146
+ const info = props?.info as Record<string, unknown> | undefined;
147
+ const sessionID = info?.sessionID as string | undefined;
148
+ if (sessionID && info?.role === "assistant") {
149
+ await updateTokenUsage(sessionID);
150
+ }
151
+ }
152
+ },
153
+
154
+ // Truncate tool output
155
+ "tool.execute.after": async (input: { name: string; sessionID: string }, output: { output?: string }) => {
156
+ // Only truncate specific tools
157
+ if (!TRUNCATABLE_TOOLS.includes(input.name)) {
158
+ return;
159
+ }
160
+
161
+ if (!output.output || typeof output.output !== "string") {
162
+ return;
163
+ }
164
+
165
+ try {
166
+ // Get current token usage
167
+ const { used, limit } = await updateTokenUsage(input.sessionID);
168
+ const maxTokens = calculateMaxOutputTokens(used, limit);
169
+
170
+ if (maxTokens <= 0) {
171
+ output.output = "[Output suppressed - context window exhausted. Consider compacting.]";
172
+ return;
173
+ }
174
+
175
+ // Truncate if needed
176
+ const currentTokens = estimateTokens(output.output);
177
+ if (currentTokens > maxTokens) {
178
+ output.output = truncateToTokenLimit(output.output, maxTokens);
179
+ }
180
+ } catch {
181
+ // On error, apply static truncation as fallback
182
+ const currentTokens = estimateTokens(output.output);
183
+ if (currentTokens > DEFAULT_MAX_OUTPUT_TOKENS) {
184
+ output.output = truncateToTokenLimit(output.output, DEFAULT_MAX_OUTPUT_TOKENS);
185
+ }
186
+ }
187
+ },
188
+ };
189
+ }
package/src/index.ts ADDED
@@ -0,0 +1,258 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ import type { McpLocalConfig } from "@opencode-ai/sdk";
3
+
4
+ // Agents
5
+ import { agents, PRIMARY_AGENT_NAME } from "./agents";
6
+
7
+ // Tools
8
+ import { ast_grep_search, ast_grep_replace, checkAstGrepAvailable } from "./tools/ast-grep";
9
+ import { btca_ask, checkBtcaAvailable } from "./tools/btca";
10
+ import { look_at } from "./tools/look-at";
11
+ import { artifact_search } from "./tools/artifact-search";
12
+
13
+ // Hooks
14
+ import { createAutoCompactHook } from "./hooks/auto-compact";
15
+ import { createContextInjectorHook } from "./hooks/context-injector";
16
+ import { createSessionRecoveryHook } from "./hooks/session-recovery";
17
+ import { createTokenAwareTruncationHook } from "./hooks/token-aware-truncation";
18
+ import { createContextWindowMonitorHook } from "./hooks/context-window-monitor";
19
+ import { createCommentCheckerHook } from "./hooks/comment-checker";
20
+ import { createAutoClearLedgerHook } from "./hooks/auto-clear-ledger";
21
+ import { createLedgerLoaderHook } from "./hooks/ledger-loader";
22
+ import { createArtifactAutoIndexHook } from "./hooks/artifact-auto-index";
23
+ import { createFileOpsTrackerHook } from "./hooks/file-ops-tracker";
24
+
25
+ // Background Task System
26
+ import { BackgroundTaskManager, createBackgroundTaskTools } from "./tools/background-task";
27
+
28
+ // PTY System
29
+ import { PTYManager, createPtyTools } from "./tools/pty";
30
+
31
+ // Config loader
32
+ import { loadMicodeConfig, mergeAgentConfigs } from "./config-loader";
33
+
34
+ // Think mode: detect keywords and enable extended thinking
35
+ const THINK_KEYWORDS = [
36
+ /\bthink\s*(hard|deeply|carefully|through)\b/i,
37
+ /\bthink\b.*\b(about|on|through)\b/i,
38
+ /\b(deeply|carefully)\s*think\b/i,
39
+ /\blet('s|s)?\s*think\b/i,
40
+ ];
41
+
42
+ function detectThinkKeyword(text: string): boolean {
43
+ return THINK_KEYWORDS.some((pattern) => pattern.test(text));
44
+ }
45
+
46
+ // MCP server configurations
47
+ const MCP_SERVERS: Record<string, McpLocalConfig> = {
48
+ context7: {
49
+ type: "local",
50
+ command: ["npx", "-y", "@upstash/context7-mcp@latest"],
51
+ },
52
+ };
53
+
54
+ // Environment-gated research MCP servers
55
+ if (process.env.PERPLEXITY_API_KEY) {
56
+ MCP_SERVERS.perplexity = {
57
+ type: "local",
58
+ command: ["npx", "-y", "@anthropic/mcp-perplexity"],
59
+ };
60
+ }
61
+
62
+ if (process.env.FIRECRAWL_API_KEY) {
63
+ MCP_SERVERS.firecrawl = {
64
+ type: "local",
65
+ command: ["npx", "-y", "firecrawl-mcp"],
66
+ };
67
+ }
68
+
69
+ const OpenCodeConfigPlugin: Plugin = async (ctx) => {
70
+ // Validate external tool dependencies at startup
71
+ const astGrepStatus = await checkAstGrepAvailable();
72
+ if (!astGrepStatus.available) {
73
+ console.warn(`[micode] ${astGrepStatus.message}`);
74
+ }
75
+
76
+ const btcaStatus = await checkBtcaAvailable();
77
+ if (!btcaStatus.available) {
78
+ console.warn(`[micode] ${btcaStatus.message}`);
79
+ }
80
+
81
+ // Load user config for model overrides
82
+ const userConfig = await loadMicodeConfig();
83
+ if (userConfig?.agents) {
84
+ console.log(`[micode] Loaded model overrides for: ${Object.keys(userConfig.agents).join(", ")}`);
85
+ }
86
+
87
+ // Think mode state per session
88
+ const thinkModeState = new Map<string, boolean>();
89
+
90
+ // Hooks
91
+ const autoCompactHook = createAutoCompactHook(ctx);
92
+ const contextInjectorHook = createContextInjectorHook(ctx);
93
+ const autoClearLedgerHook = createAutoClearLedgerHook(ctx);
94
+ const ledgerLoaderHook = createLedgerLoaderHook(ctx);
95
+ const sessionRecoveryHook = createSessionRecoveryHook(ctx);
96
+ const tokenAwareTruncationHook = createTokenAwareTruncationHook(ctx);
97
+ const contextWindowMonitorHook = createContextWindowMonitorHook(ctx);
98
+ const commentCheckerHook = createCommentCheckerHook(ctx);
99
+ const artifactAutoIndexHook = createArtifactAutoIndexHook(ctx);
100
+ const fileOpsTrackerHook = createFileOpsTrackerHook(ctx);
101
+
102
+ // Background Task System
103
+ const backgroundTaskManager = new BackgroundTaskManager(ctx);
104
+ const backgroundTaskTools = createBackgroundTaskTools(backgroundTaskManager);
105
+
106
+ // PTY System
107
+ const ptyManager = new PTYManager();
108
+ const ptyTools = createPtyTools(ptyManager);
109
+
110
+ return {
111
+ // Tools
112
+ tool: {
113
+ ast_grep_search,
114
+ ast_grep_replace,
115
+ btca_ask,
116
+ look_at,
117
+ artifact_search,
118
+ ...backgroundTaskTools,
119
+ ...ptyTools,
120
+ },
121
+
122
+ config: async (config) => {
123
+ // Allow all permissions globally - no prompts
124
+ config.permission = {
125
+ ...config.permission,
126
+ edit: "allow",
127
+ bash: "allow",
128
+ webfetch: "allow",
129
+ doom_loop: "allow",
130
+ external_directory: "allow",
131
+ };
132
+
133
+ // Merge user config overrides into plugin agents
134
+ const mergedAgents = mergeAgentConfigs(agents, userConfig);
135
+
136
+ // Add our agents - our agents override OpenCode defaults, demote built-in build/plan to subagent
137
+ config.agent = {
138
+ ...config.agent, // OpenCode defaults first
139
+ build: { ...config.agent?.build, mode: "subagent" },
140
+ plan: { ...config.agent?.plan, mode: "subagent" },
141
+ triage: { ...config.agent?.triage, mode: "subagent" },
142
+ docs: { ...config.agent?.docs, mode: "subagent" },
143
+ // Our agents override - spread these LAST so they take precedence
144
+ ...Object.fromEntries(Object.entries(mergedAgents).filter(([k]) => k !== PRIMARY_AGENT_NAME)),
145
+ [PRIMARY_AGENT_NAME]: mergedAgents[PRIMARY_AGENT_NAME],
146
+ };
147
+
148
+ // Add MCP servers (plugin servers override defaults)
149
+ config.mcp = {
150
+ ...config.mcp,
151
+ ...MCP_SERVERS,
152
+ };
153
+
154
+ // Add commands
155
+ config.command = {
156
+ ...config.command,
157
+ init: {
158
+ description: "Initialize project with ARCHITECTURE.md and CODE_STYLE.md",
159
+ agent: "project-initializer",
160
+ template: `Initialize this project. $ARGUMENTS`,
161
+ },
162
+ ledger: {
163
+ description: "Create or update continuity ledger for session state",
164
+ agent: "ledger-creator",
165
+ template: `Update the continuity ledger. $ARGUMENTS`,
166
+ },
167
+ search: {
168
+ description: "Search past handoffs, plans, and ledgers",
169
+ agent: "artifact-searcher",
170
+ template: `Search for: $ARGUMENTS`,
171
+ },
172
+ };
173
+ },
174
+
175
+ "chat.message": async (input, output) => {
176
+ // Extract text from user message
177
+ const text = output.parts
178
+ .filter((p) => p.type === "text" && "text" in p)
179
+ .map((p) => (p as { text: string }).text)
180
+ .join(" ");
181
+
182
+ // Track if think mode was requested
183
+ thinkModeState.set(input.sessionID, detectThinkKeyword(text));
184
+ },
185
+
186
+ "chat.params": async (input, output) => {
187
+ // Inject ledger context first (highest priority)
188
+ await ledgerLoaderHook["chat.params"](input, output);
189
+
190
+ // Inject project context files
191
+ await contextInjectorHook["chat.params"](input, output);
192
+
193
+ // Inject context window status
194
+ await contextWindowMonitorHook["chat.params"](input, output);
195
+
196
+ // If think mode was requested, increase thinking budget
197
+ if (thinkModeState.get(input.sessionID)) {
198
+ output.options = {
199
+ ...output.options,
200
+ thinking: {
201
+ type: "enabled",
202
+ budget_tokens: 32000,
203
+ },
204
+ };
205
+ }
206
+ },
207
+
208
+ // Tool output processing
209
+ "tool.execute.after": async (
210
+ input: { tool: string; sessionID: string; callID: string; args?: Record<string, unknown> },
211
+ output: { output?: string },
212
+ ) => {
213
+ // Token-aware truncation
214
+ await tokenAwareTruncationHook["tool.execute.after"]({ name: input.tool, sessionID: input.sessionID }, output);
215
+
216
+ // Comment checker for Edit tool
217
+ await commentCheckerHook["tool.execute.after"]({ tool: input.tool, args: input.args }, output);
218
+
219
+ // Directory-aware context injection for Read/Edit
220
+ await contextInjectorHook["tool.execute.after"]({ tool: input.tool, args: input.args }, output);
221
+
222
+ // Auto-index artifacts when written to thoughts/ directories
223
+ await artifactAutoIndexHook["tool.execute.after"]({ tool: input.tool, args: input.args }, output);
224
+
225
+ // Track file operations for ledger
226
+ await fileOpsTrackerHook["tool.execute.after"](
227
+ { tool: input.tool, sessionID: input.sessionID, args: input.args },
228
+ output,
229
+ );
230
+ },
231
+
232
+ event: async ({ event }) => {
233
+ // Session cleanup (think mode + PTY)
234
+ if (event.type === "session.deleted") {
235
+ const props = event.properties as { info?: { id?: string } } | undefined;
236
+ if (props?.info?.id) {
237
+ thinkModeState.delete(props.info.id);
238
+ ptyManager.cleanupBySession(props.info.id);
239
+ }
240
+ }
241
+
242
+ // Run all event hooks
243
+ await autoCompactHook.event({ event });
244
+ await autoClearLedgerHook.event({ event });
245
+ await sessionRecoveryHook.event({ event });
246
+ await tokenAwareTruncationHook.event({ event });
247
+ await contextWindowMonitorHook.event({ event });
248
+
249
+ // Background task manager event handling
250
+ backgroundTaskManager.handleEvent(event);
251
+
252
+ // File ops tracker cleanup
253
+ await fileOpsTrackerHook.event({ event });
254
+ },
255
+ };
256
+ };
257
+
258
+ export default OpenCodeConfigPlugin;