commandmate 0.3.3 → 0.3.5

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 (103) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +13 -13
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +5 -5
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/config.json +3 -3
  7. package/.next/cache/webpack/client-production/0.pack +0 -0
  8. package/.next/cache/webpack/client-production/1.pack +0 -0
  9. package/.next/cache/webpack/client-production/2.pack +0 -0
  10. package/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  12. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  13. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  14. package/.next/cache/webpack/server-production/0.pack +0 -0
  15. package/.next/cache/webpack/server-production/index.pack +0 -0
  16. package/.next/next-server.js.nft.json +1 -1
  17. package/.next/prerender-manifest.json +1 -1
  18. package/.next/react-loadable-manifest.json +13 -7
  19. package/.next/required-server-files.json +1 -1
  20. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  21. package/.next/server/app/api/app/update-check/route.js +1 -1
  22. package/.next/server/app/api/repositories/route.js +2 -2
  23. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  24. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  25. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
  26. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  27. package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
  28. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js +1 -1
  29. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js.nft.json +1 -1
  30. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js +1 -1
  31. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js.nft.json +1 -1
  32. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  33. package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
  34. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  35. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  36. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  37. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  38. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  39. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  40. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  41. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  42. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -1
  43. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js.nft.json +1 -1
  44. package/.next/server/app/api/worktrees/[id]/schedules/route.js +1 -1
  45. package/.next/server/app/api/worktrees/[id]/schedules/route.js.nft.json +1 -1
  46. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  47. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  48. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  50. package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
  51. package/.next/server/app/api/worktrees/route.js +1 -1
  52. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  53. package/.next/server/app/login/page.js +1 -1
  54. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app/page_client-reference-manifest.js +1 -1
  56. package/.next/server/app/proxy/[...path]/route.js +1 -1
  57. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app/worktrees/[id]/page.js +4 -4
  59. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  60. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  61. package/.next/server/app-paths-manifest.json +4 -4
  62. package/.next/server/chunks/2314.js +1 -1
  63. package/.next/server/chunks/3074.js +1 -1
  64. package/.next/server/chunks/4952.js +1 -0
  65. package/.next/server/chunks/539.js +3 -3
  66. package/.next/server/chunks/5795.js +1 -1
  67. package/.next/server/chunks/6228.js +1 -1
  68. package/.next/server/chunks/7425.js +52 -43
  69. package/.next/server/chunks/7566.js +1 -1
  70. package/.next/server/chunks/8693.js +1 -1
  71. package/.next/server/middleware-build-manifest.js +1 -1
  72. package/.next/server/middleware-manifest.json +5 -5
  73. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  74. package/.next/server/pages/500.html +1 -1
  75. package/.next/server/server-reference-manifest.json +1 -1
  76. package/.next/static/chunks/{4327.740cc7fe2d0b5049.js → 4327.157a4c226d919531.js} +14 -14
  77. package/.next/static/chunks/5970.0df906ad5a9c9147.js +1 -0
  78. package/.next/static/chunks/{8091-274bc0716106e7fc.js → 8091-d65d2ab6daed23c6.js} +1 -1
  79. package/.next/static/chunks/app/login/page-010f02fd4b0dbc48.js +1 -0
  80. package/.next/static/chunks/app/worktrees/[id]/page-8fb4dc30b58a5681.js +1 -0
  81. package/.next/static/chunks/webpack-81c97591dd5567ac.js +1 -0
  82. package/.next/static/css/45b3a41370668314.css +3 -0
  83. package/.next/trace +5 -5
  84. package/dist/server/src/lib/claude-executor.js +14 -3
  85. package/dist/server/src/lib/cli-patterns.js +99 -20
  86. package/dist/server/src/lib/cli-tools/manager.js +5 -3
  87. package/dist/server/src/lib/cli-tools/opencode-config.js +236 -0
  88. package/dist/server/src/lib/cli-tools/opencode.js +188 -0
  89. package/dist/server/src/lib/cli-tools/types.js +47 -6
  90. package/dist/server/src/lib/cli-tools/vibe-local.js +12 -3
  91. package/dist/server/src/lib/db-migrations.js +17 -1
  92. package/dist/server/src/lib/db.js +39 -2
  93. package/dist/server/src/lib/prompt-detector.js +23 -4
  94. package/dist/server/src/lib/response-poller.js +392 -28
  95. package/package.json +5 -4
  96. package/.next/server/chunks/9446.js +0 -1
  97. package/.next/static/chunks/app/login/page-2d42204ba87cd136.js +0 -1
  98. package/.next/static/chunks/app/worktrees/[id]/page-78580947c201d698.js +0 -1
  99. package/.next/static/chunks/webpack-3c0ee3ce5b546818.js +0 -1
  100. package/.next/static/css/e85de230ef5ddc40.css +0 -3
  101. /package/.next/static/chunks/app/{page-060057e02b841125.js → page-9e523a8f415bc707.js} +0 -0
  102. /package/.next/static/{O7EDFfAYQNe_HRbORxQAC → p3hosTZoJ22r35fWwUoLr}/_buildManifest.js +0 -0
  103. /package/.next/static/{O7EDFfAYQNe_HRbORxQAC → p3hosTZoJ22r35fWwUoLr}/_ssgManifest.js +0 -0
@@ -1,13 +1,17 @@
1
1
  "use strict";
2
2
  /**
3
- * Claude CLI Executor
4
- * Issue #294: Executes claude -p commands for scheduled executions
3
+ * CLI Command Executor (non-interactive mode)
4
+ * Issue #294: Executes CLI tool commands for scheduled executions
5
+ * Issue #379: Added OpenCode support (opencode run)
6
+ *
7
+ * Supported tools: claude, codex, gemini, vibe-local, opencode
5
8
  *
6
9
  * Security:
7
10
  * - Uses execFile (not exec) to prevent shell injection
8
11
  * - Sanitizes environment variables via env-sanitizer.ts
9
12
  * - Limits output size to prevent memory exhaustion
10
13
  * - Enforces execution timeout
14
+ * - Validates cliToolId against ALLOWED_CLI_TOOLS whitelist [SEC-001]
11
15
  */
12
16
  Object.defineProperty(exports, "__esModule", { value: true });
13
17
  exports.ALLOWED_CLI_TOOLS = exports.MAX_MESSAGE_LENGTH = exports.EXECUTION_TIMEOUT_MS = exports.MAX_STORED_OUTPUT_SIZE = exports.MAX_OUTPUT_SIZE = void 0;
@@ -30,7 +34,7 @@ exports.EXECUTION_TIMEOUT_MS = 5 * 60 * 1000;
30
34
  /** Maximum message length sent to claude -p */
31
35
  exports.MAX_MESSAGE_LENGTH = 10000;
32
36
  /** Allowed CLI tool identifiers for scheduled execution */
33
- exports.ALLOWED_CLI_TOOLS = new Set(['claude', 'codex', 'gemini', 'vibe-local']);
37
+ exports.ALLOWED_CLI_TOOLS = new Set(['claude', 'codex', 'gemini', 'vibe-local', 'opencode']);
34
38
  // =============================================================================
35
39
  // Executor
36
40
  // =============================================================================
@@ -57,6 +61,7 @@ function truncateOutput(output) {
57
61
  * - codex: exec <message> --sandbox <permission>
58
62
  * - gemini: -p <message>
59
63
  * - vibe-local: [-p <message> -y] or [--model <model> -p <message> -y]
64
+ * - opencode: [run <message>] or [run -m ollama/<model> <message>]
60
65
  * - others: -p <message> (fallback)
61
66
  *
62
67
  * @param message - Prompt message
@@ -76,6 +81,12 @@ function buildCliArgs(message, cliToolId, permission, options) {
76
81
  return ['--model', options.model, '-p', message, '-y'];
77
82
  }
78
83
  return ['-p', message, '-y'];
84
+ case 'opencode':
85
+ // [D2-007] When model is not specified, OpenCode uses opencode.json default model
86
+ if (options?.model) {
87
+ return ['run', '-m', `ollama/${options.model}`, message];
88
+ }
89
+ return ['run', message];
79
90
  case 'claude':
80
91
  default:
81
92
  return ['-p', message, '--output-format', 'text', '--permission-mode', permission ?? 'acceptEdits'];
@@ -4,7 +4,7 @@
4
4
  * Shared between response-poller.ts and API routes
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
- exports.CLAUDE_SESSION_ERROR_REGEX_PATTERNS = exports.CLAUDE_SESSION_ERROR_PATTERNS = exports.VIBE_LOCAL_THINKING_PATTERN = exports.VIBE_LOCAL_PROMPT_PATTERN = exports.GEMINI_THINKING_PATTERN = exports.GEMINI_PROMPT_PATTERN = exports.MAX_PASTED_TEXT_RETRIES = exports.PASTED_TEXT_DETECT_DELAY = exports.PASTED_TEXT_PATTERN = exports.CODEX_SEPARATOR_PATTERN = exports.CODEX_PROMPT_PATTERN = exports.CLAUDE_TRUST_DIALOG_PATTERN = exports.CLAUDE_SEPARATOR_PATTERN = exports.CLAUDE_PROMPT_PATTERN = exports.CODEX_THINKING_PATTERN = exports.CLAUDE_THINKING_PATTERN = exports.CLAUDE_SPINNER_CHARS = void 0;
7
+ exports.CLAUDE_SESSION_ERROR_REGEX_PATTERNS = exports.CLAUDE_SESSION_ERROR_PATTERNS = exports.VIBE_LOCAL_THINKING_PATTERN = exports.VIBE_LOCAL_PROMPT_PATTERN = exports.OPENCODE_SKIP_PATTERNS = exports.OPENCODE_SEPARATOR_PATTERN = exports.OPENCODE_PROCESSING_INDICATOR = exports.OPENCODE_RESPONSE_COMPLETE = exports.OPENCODE_LOADING_PATTERN = exports.OPENCODE_THINKING_PATTERN = exports.OPENCODE_PROMPT_AFTER_RESPONSE = exports.OPENCODE_PROMPT_PATTERN = exports.GEMINI_THINKING_PATTERN = exports.GEMINI_PROMPT_PATTERN = exports.MAX_PASTED_TEXT_RETRIES = exports.PASTED_TEXT_DETECT_DELAY = exports.PASTED_TEXT_PATTERN = exports.CODEX_SEPARATOR_PATTERN = exports.CODEX_PROMPT_PATTERN = exports.CLAUDE_TRUST_DIALOG_PATTERN = exports.CLAUDE_SEPARATOR_PATTERN = exports.CLAUDE_PROMPT_PATTERN = exports.CODEX_THINKING_PATTERN = exports.CLAUDE_THINKING_PATTERN = exports.CLAUDE_SPINNER_CHARS = void 0;
8
8
  exports.detectThinking = detectThinking;
9
9
  exports.getCliToolPatterns = getCliToolPatterns;
10
10
  exports.stripAnsi = stripAnsi;
@@ -124,6 +124,66 @@ exports.GEMINI_PROMPT_PATTERN = /^[>❯]\s*$/m;
124
124
  * Gemini CLI shows braille spinner characters and status text while processing.
125
125
  */
126
126
  exports.GEMINI_THINKING_PATTERN = /[\u2800-\u28FF]|Thinking\.\.\./;
127
+ /**
128
+ * OpenCode prompt pattern (Issue #379)
129
+ * OpenCode TUI shows "Ask anything..." in the input area when waiting for user input.
130
+ * Unlike Claude/Codex (which use > or ❯), OpenCode uses a text-based prompt indicator.
131
+ */
132
+ exports.OPENCODE_PROMPT_PATTERN = /Ask anything\.\.\./;
133
+ /**
134
+ * OpenCode prompt pattern after response completion (Issue #379)
135
+ * Shows "tab agents ctrl+p commands" in the TUI status bar after a response finishes.
136
+ * Used as extraction stop condition in response-poller.ts [D2-003].
137
+ */
138
+ exports.OPENCODE_PROMPT_AFTER_RESPONSE = /tab agents\s+ctrl\+p commands/;
139
+ /**
140
+ * OpenCode thinking/processing pattern (Issue #379)
141
+ * OpenCode TUI shows "Thinking:" prefix while the Ollama model is generating a response.
142
+ * Used by detectThinking() to determine if the tool is actively processing.
143
+ */
144
+ exports.OPENCODE_THINKING_PATTERN = /Thinking:/;
145
+ /**
146
+ * OpenCode loading indicator pattern (Issue #379)
147
+ * Shows a series of 4+ filled square characters (U+2B1D) during initial loading/model warm-up.
148
+ * Filtered from response extraction via OPENCODE_SKIP_PATTERNS.
149
+ */
150
+ exports.OPENCODE_LOADING_PATTERN = /\u2B1D{4,}/;
151
+ /**
152
+ * OpenCode response completion pattern (Issue #379)
153
+ * Matches the action summary line: "&#x25A3; {Action} · model" with optional timing "· Ns".
154
+ * (U+25A3 square + action word + middle dot + model name [+ middle dot + timing]).
155
+ * Action can be "Build", "Compaction", or other OpenCode action names.
156
+ * Short responses may omit the timing portion (e.g., "▣ Build · qwen3.5:27b").
157
+ * This is the primary completion signal for OpenCode [D2-002].
158
+ */
159
+ exports.OPENCODE_RESPONSE_COMPLETE = /\u25A3\s+\w+\s+\u00b7\s+\S+(?:\s+\u00b7\s+(?:[\d]+h\s*)?(?:[\d]+m\s*)?[\d.]+s)?/;
160
+ /**
161
+ * OpenCode processing indicator pattern (Issue #379)
162
+ * Shows "esc interrupt" in the TUI status bar during active model processing.
163
+ * Filtered from response extraction via OPENCODE_SKIP_PATTERNS.
164
+ */
165
+ exports.OPENCODE_PROCESSING_INDICATOR = /esc interrupt/;
166
+ /**
167
+ * OpenCode TUI separator pattern (Issue #379)
168
+ * Matches lines composed entirely of box-drawing / TUI decoration characters.
169
+ * Covers: vertical lines (U+2503), box corners, horizontal lines, and other TUI elements.
170
+ */
171
+ exports.OPENCODE_SEPARATOR_PATTERN = /^[\u2503\u2579\u25A3\u2580\u2500\u250C\u2510\u2514\u2518\u251C\u2524\u252C\u2534\u253C]+$/;
172
+ /**
173
+ * OpenCode skip patterns for response cleaning (Issue #379)
174
+ * Lines matching any of these patterns are filtered from extracted responses.
175
+ * Includes: TUI separators, loading indicators, Build summary prefix,
176
+ * status bar prompts, processing indicators, input prompt, and pasted text markers.
177
+ */
178
+ exports.OPENCODE_SKIP_PATTERNS = [
179
+ exports.OPENCODE_SEPARATOR_PATTERN,
180
+ exports.OPENCODE_LOADING_PATTERN,
181
+ /^Build\s+/,
182
+ exports.OPENCODE_PROMPT_AFTER_RESPONSE,
183
+ exports.OPENCODE_PROCESSING_INDICATOR,
184
+ exports.OPENCODE_PROMPT_PATTERN,
185
+ exports.PASTED_TEXT_PATTERN,
186
+ ];
127
187
  /**
128
188
  * Vibe Local prompt pattern
129
189
  * vibe-local (vibe-coder) shows `ctx:N% ❯` prompt when waiting for user input.
@@ -157,6 +217,9 @@ function detectThinking(cliToolId, content) {
157
217
  case 'vibe-local':
158
218
  result = exports.VIBE_LOCAL_THINKING_PATTERN.test(content);
159
219
  break;
220
+ case 'opencode':
221
+ result = exports.OPENCODE_THINKING_PATTERN.test(content);
222
+ break;
160
223
  default:
161
224
  result = exports.CLAUDE_THINKING_PATTERN.test(content);
162
225
  }
@@ -241,6 +304,13 @@ function getCliToolPatterns(cliToolId) {
241
304
  exports.PASTED_TEXT_PATTERN, // [Pasted text #N +XX lines]
242
305
  ],
243
306
  };
307
+ case 'opencode':
308
+ return {
309
+ promptPattern: exports.OPENCODE_PROMPT_PATTERN,
310
+ separatorPattern: exports.OPENCODE_SEPARATOR_PATTERN,
311
+ thinkingPattern: exports.OPENCODE_THINKING_PATTERN,
312
+ skipPatterns: [...exports.OPENCODE_SKIP_PATTERNS],
313
+ };
244
314
  default:
245
315
  // Default to Claude patterns
246
316
  return getCliToolPatterns('claude');
@@ -279,28 +349,16 @@ function stripAnsi(str) {
279
349
  */
280
350
  function stripBoxDrawing(str) {
281
351
  return str.split('\n').map(line => {
282
- // Remove border-only lines (╭──╮, ╰──╯, │ only, etc.)
283
- if (/^[\u2502\u256D\u256E\u256F\u2570\u2500\s]+$/.test(line))
352
+ // Remove border-only lines (╭──╮, ╰──╯, │ only, ┃ only, ╹▀▀▀, etc.)
353
+ // U+2502 │ (light vertical), U+2503 ┃ (heavy vertical - OpenCode TUI)
354
+ // U+2579 ╹ (heavy up), U+2580 ▀ (upper half block - OpenCode separator)
355
+ if (/^[\u2502\u2503\u256D\u256E\u256F\u2570\u2500\u2579\u2580\s]+$/.test(line))
284
356
  return '';
285
- // Strip leading + optional space, trailing space +
286
- return line.replace(/^\u2502\s?/, '').replace(/\s*\u2502$/, '');
357
+ // Strip leading whitespace + │/┃ + optional space, trailing space + │/┃
358
+ // OpenCode TUI adds 2-space padding before ┃ borders (e.g., " ┃ content")
359
+ return line.replace(/^\s*[\u2502\u2503]\s?/, '').replace(/\s*[\u2502\u2503]$/, '');
287
360
  }).join('\n');
288
361
  }
289
- /**
290
- * Build DetectPromptOptions for a given CLI tool.
291
- * Centralizes cliToolId-to-options mapping logic (DRY - MF-001).
292
- *
293
- * prompt-detector.ts remains CLI tool independent (Issue #161 principle);
294
- * this function lives in cli-patterns.ts which already depends on CLIToolType.
295
- *
296
- * [Future extension memo (C-002)]
297
- * If CLI tool count grows significantly (currently 3), consider migrating
298
- * to a CLIToolConfig registry pattern where tool-specific settings
299
- * (including promptDetectionOptions) are managed in a Record<CLIToolType, CLIToolConfig>.
300
- *
301
- * @param cliToolId - CLI tool identifier
302
- * @returns DetectPromptOptions for the tool, or undefined for default behavior
303
- */
304
362
  /**
305
363
  * Error patterns that indicate a Claude session failed to start properly
306
364
  * Used by isSessionHealthy() to detect broken sessions (MF-001: SRP)
@@ -334,9 +392,30 @@ exports.CLAUDE_SESSION_ERROR_PATTERNS = [
334
392
  exports.CLAUDE_SESSION_ERROR_REGEX_PATTERNS = [
335
393
  /^Error:.*Claude Code/,
336
394
  ];
395
+ /**
396
+ * Build DetectPromptOptions for a given CLI tool.
397
+ * Centralizes cliToolId-to-options mapping logic (DRY - MF-001).
398
+ *
399
+ * prompt-detector.ts remains CLI tool independent (Issue #161 principle);
400
+ * this function lives in cli-patterns.ts which already depends on CLIToolType.
401
+ *
402
+ * [Future extension memo (C-002)]
403
+ * If CLI tool count grows significantly (currently 5), consider migrating
404
+ * to a CLIToolConfig registry pattern where tool-specific settings
405
+ * (including promptDetectionOptions) are managed in a Record<CLIToolType, CLIToolConfig>.
406
+ * Migration threshold: 6th tool addition triggers registry pattern migration [D1-003].
407
+ *
408
+ * @param cliToolId - CLI tool identifier
409
+ * @returns DetectPromptOptions for the tool, or undefined for default behavior
410
+ */
337
411
  function buildDetectPromptOptions(cliToolId) {
338
412
  if (cliToolId === 'claude') {
339
413
  return { requireDefaultIndicator: false };
340
414
  }
415
+ // [D2-006] OpenCode prompt "Ask anything..." does not use standard indicators (> / ❯),
416
+ // so requireDefaultIndicator must be false to avoid missing prompt detection.
417
+ if (cliToolId === 'opencode') {
418
+ return { requireDefaultIndicator: false };
419
+ }
341
420
  return undefined; // Default behavior (requireDefaultIndicator = true)
342
421
  }
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * CLI Tool Manager
4
- * Singleton class to manage multiple CLI tools (Claude, Codex, Gemini, Vibe Local)
4
+ * Singleton class to manage multiple CLI tools (Claude, Codex, Gemini, Vibe Local, OpenCode)
5
5
  */
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.CLIToolManager = void 0;
@@ -9,10 +9,11 @@ const claude_1 = require("./claude");
9
9
  const codex_1 = require("./codex");
10
10
  const gemini_1 = require("./gemini");
11
11
  const vibe_local_1 = require("./vibe-local");
12
+ const opencode_1 = require("./opencode");
12
13
  const response_poller_1 = require("../response-poller");
13
14
  /**
14
15
  * CLI Tool Manager (Singleton)
15
- * Provides centralized access to all CLI tools (Issue #368: includes Vibe Local)
16
+ * Provides centralized access to all CLI tools (Issue #368: includes Vibe Local, Issue #379: includes OpenCode)
16
17
  */
17
18
  class CLIToolManager {
18
19
  static instance;
@@ -27,6 +28,7 @@ class CLIToolManager {
27
28
  this.tools.set('codex', new codex_1.CodexTool());
28
29
  this.tools.set('gemini', new gemini_1.GeminiTool());
29
30
  this.tools.set('vibe-local', new vibe_local_1.VibeLocalTool());
31
+ this.tools.set('opencode', new opencode_1.OpenCodeTool());
30
32
  }
31
33
  /**
32
34
  * Get singleton instance
@@ -68,7 +70,7 @@ class CLIToolManager {
68
70
  * ```typescript
69
71
  * const manager = CLIToolManager.getInstance();
70
72
  * const allTools = manager.getAllTools();
71
- * console.log(allTools.map(t => t.name)); // ['Claude Code', 'Codex CLI', 'Gemini CLI']
73
+ * console.log(allTools.map(t => t.name)); // ['Claude Code', 'Codex CLI', 'Gemini CLI', 'Vibe Local', 'OpenCode']
72
74
  * ```
73
75
  */
74
76
  getAllTools() {
@@ -0,0 +1,236 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode configuration file generator
4
+ * Issue #379: Generates opencode.json with Ollama provider configuration
5
+ *
6
+ * @remarks [D1-001 SRP] Separated from opencode.ts to maintain single responsibility.
7
+ * This module handles Ollama HTTP API calls and config file I/O,
8
+ * while opencode.ts handles tmux session management.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ exports.OLLAMA_MODEL_PATTERN = exports.MAX_OLLAMA_MODELS = exports.OLLAMA_BASE_URL = exports.OLLAMA_API_URL = void 0;
45
+ exports.ensureOpencodeConfig = ensureOpencodeConfig;
46
+ const fs = __importStar(require("fs"));
47
+ const path = __importStar(require("path"));
48
+ // =============================================================================
49
+ // Constants
50
+ // =============================================================================
51
+ /**
52
+ * [SEC-001] SSRF Prevention: Ollama API URL is hardcoded.
53
+ * This value MUST NOT be derived from environment variables, config files,
54
+ * or user input. OWASP A10:2021
55
+ */
56
+ exports.OLLAMA_API_URL = 'http://localhost:11434/api/tags';
57
+ /**
58
+ * [SEC-001] SSRF Prevention: Ollama base URL for opencode.json config.
59
+ * Same policy as OLLAMA_API_URL.
60
+ */
61
+ exports.OLLAMA_BASE_URL = 'http://localhost:11434/v1';
62
+ /** Maximum number of Ollama models to include in config (DoS prevention) */
63
+ exports.MAX_OLLAMA_MODELS = 100;
64
+ /**
65
+ * Ollama model name validation pattern (with length limit).
66
+ * Allows: alphanumeric, dots, underscores, colons, slashes, hyphens.
67
+ * Max 100 characters (length encoded in regex). [D4-003]
68
+ *
69
+ * [SEC-001] Defense-in-depth validation at point of use.
70
+ *
71
+ * Note: This pattern differs from OLLAMA_MODEL_PATTERN in types.ts.
72
+ * - types.ts: `^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$` (no length limit, requires alphanumeric start)
73
+ * Used for API/DB validation where the first character constraint matters.
74
+ * - This file: `^[a-zA-Z0-9._:/-]{1,100}$` (length-limited, used for Ollama API response validation)
75
+ * Length limit provides DoS protection against excessively long model names from Ollama API.
76
+ */
77
+ exports.OLLAMA_MODEL_PATTERN = /^[a-zA-Z0-9._:/-]{1,100}$/;
78
+ /** Ollama API request timeout in milliseconds */
79
+ const OLLAMA_API_TIMEOUT_MS = 3000;
80
+ /** Maximum Ollama API response size (1MB) [D4-007] */
81
+ const MAX_OLLAMA_RESPONSE_SIZE = 1 * 1024 * 1024;
82
+ /** Config file name */
83
+ const CONFIG_FILE_NAME = 'opencode.json';
84
+ // =============================================================================
85
+ // Helpers
86
+ // =============================================================================
87
+ /**
88
+ * Format model display name with size and quantization info
89
+ */
90
+ function formatModelDisplayName(model) {
91
+ const name = String(model.name);
92
+ const details = model.details;
93
+ if (!details)
94
+ return name;
95
+ const parts = [name];
96
+ // Sanitize and extract parameter_size (e.g., "7.6B", "27.8B")
97
+ if (typeof details.parameter_size === 'string' && /^[\d.]+[BKMGT]?B?$/i.test(details.parameter_size)) {
98
+ parts.push(details.parameter_size);
99
+ }
100
+ // Sanitize and extract quantization_level (e.g., "Q4_K_M", "Q8_0")
101
+ if (typeof details.quantization_level === 'string' && /^[A-Z0-9_]{1,20}$/i.test(details.quantization_level)) {
102
+ parts.push(details.quantization_level);
103
+ }
104
+ return parts.length > 1 ? `${name} (${parts.slice(1).join(', ')})` : name;
105
+ }
106
+ /**
107
+ * Validate worktree path for path traversal prevention [D4-004].
108
+ *
109
+ * Trust chain: API layer -> DB (worktrees.path) -> startSession -> ensureOpencodeConfig.
110
+ * Although the DB stores validated paths, this function provides defense-in-depth
111
+ * by re-validating at the point of filesystem access.
112
+ *
113
+ * Steps:
114
+ * 1. path.resolve() - Normalize path (remove .., ., etc.)
115
+ * 2. fs.lstatSync() - Verify path exists and is a directory (symlink-aware)
116
+ * 3. fs.realpathSync() - Resolve symlinks to get the canonical path
117
+ *
118
+ * @param worktreePath - Path to validate
119
+ * @returns Resolved real path (after symlink resolution)
120
+ * @throws Error if path does not exist or is not a directory
121
+ * @internal
122
+ */
123
+ function validateWorktreePath(worktreePath) {
124
+ // 1. path.resolve() for normalization
125
+ const resolvedPath = path.resolve(worktreePath);
126
+ // 2. Verify the path exists and is a directory (lstatSync for symlink detection)
127
+ try {
128
+ const stat = fs.lstatSync(resolvedPath);
129
+ if (!stat.isDirectory()) {
130
+ throw new Error(`Path is not a directory: ${resolvedPath}`);
131
+ }
132
+ }
133
+ catch (error) {
134
+ if (error.code === 'ENOENT') {
135
+ throw new Error(`Path does not exist: ${resolvedPath}`);
136
+ }
137
+ throw error;
138
+ }
139
+ // 3. Resolve symlinks to get real path
140
+ const realPath = fs.realpathSync(resolvedPath);
141
+ return realPath;
142
+ }
143
+ // =============================================================================
144
+ // Main function
145
+ // =============================================================================
146
+ /**
147
+ * Ensure opencode.json exists in the worktree directory.
148
+ * If the file already exists, it is NOT overwritten (respects user configuration).
149
+ * If Ollama is not running, the function logs a warning and returns without error.
150
+ *
151
+ * @param worktreePath - Worktree directory path (from DB)
152
+ * @internal
153
+ */
154
+ async function ensureOpencodeConfig(worktreePath) {
155
+ // Validate path [D4-004]
156
+ const validatedPath = validateWorktreePath(worktreePath);
157
+ const configPath = path.join(validatedPath, CONFIG_FILE_NAME);
158
+ // Skip if config already exists (respect user configuration)
159
+ if (fs.existsSync(configPath)) {
160
+ return;
161
+ }
162
+ // Fetch models from Ollama API
163
+ const models = {};
164
+ try {
165
+ const controller = new AbortController();
166
+ const timeoutId = setTimeout(() => controller.abort(), OLLAMA_API_TIMEOUT_MS);
167
+ const response = await fetch(exports.OLLAMA_API_URL, {
168
+ signal: controller.signal,
169
+ }).finally(() => {
170
+ clearTimeout(timeoutId);
171
+ });
172
+ if (!response.ok) {
173
+ console.warn(`Ollama API returned status ${response.status}, skipping opencode.json generation`);
174
+ return;
175
+ }
176
+ // [D4-007] Response size check
177
+ const text = await response.text();
178
+ if (text.length > MAX_OLLAMA_RESPONSE_SIZE) {
179
+ console.warn('Ollama API response too large, skipping opencode.json generation');
180
+ return;
181
+ }
182
+ // Parse and validate response structure [D4-007]
183
+ const data = JSON.parse(text);
184
+ if (!data || !Array.isArray(data.models)) {
185
+ console.warn('Invalid Ollama API response structure, skipping opencode.json generation');
186
+ return;
187
+ }
188
+ // Limit model count (DoS prevention)
189
+ const modelList = data.models.slice(0, exports.MAX_OLLAMA_MODELS);
190
+ // Validate each model (whitelist approach) [D4-007]
191
+ for (const model of modelList) {
192
+ if (typeof model?.name !== 'string')
193
+ continue;
194
+ if (!exports.OLLAMA_MODEL_PATTERN.test(model.name))
195
+ continue;
196
+ models[model.name] = { name: formatModelDisplayName(model) };
197
+ }
198
+ }
199
+ catch (error) {
200
+ // Non-fatal: Ollama may not be running [D4-002]
201
+ if (error instanceof Error && error.name === 'AbortError') {
202
+ console.warn('Ollama API timeout, skipping opencode.json generation');
203
+ }
204
+ else {
205
+ console.warn('Failed to fetch Ollama models, skipping opencode.json generation');
206
+ }
207
+ return;
208
+ }
209
+ // [D4-005] Generate config using JSON.stringify (not template literals).
210
+ // JSON.stringify ensures proper escaping of model names and other values,
211
+ // preventing JSON injection via maliciously crafted Ollama model metadata.
212
+ const config = {
213
+ $schema: 'https://opencode.ai/config.json',
214
+ provider: {
215
+ ollama: {
216
+ npm: '@ai-sdk/openai-compatible',
217
+ name: 'Ollama (local)',
218
+ options: { baseURL: exports.OLLAMA_BASE_URL },
219
+ models,
220
+ },
221
+ },
222
+ };
223
+ try {
224
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
225
+ encoding: 'utf-8',
226
+ flag: 'wx',
227
+ });
228
+ }
229
+ catch (error) {
230
+ if (error.code === 'EEXIST') {
231
+ return;
232
+ }
233
+ // Non-fatal: write failure should not prevent session start
234
+ console.warn(`Failed to write opencode.json: ${error instanceof Error ? error.message : String(error)}`);
235
+ }
236
+ }
@@ -0,0 +1,188 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCode CLI tool implementation
4
+ * Issue #379: Provides integration with OpenCode TUI in interactive mode
5
+ *
6
+ * @remarks Follows the same tmux-based pattern as Claude/Codex/Gemini/VibeLocal tools.
7
+ * - startSession: launches `opencode` TUI in tmux
8
+ * - sendMessage: sends text via tmux send-keys + Enter
9
+ * - killSession: sends `/exit` command then falls back to tmux kill-session
10
+ * - interrupt(): inherits BaseCLITool default (Escape key) [D2-008]
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.OpenCodeTool = exports.OPENCODE_INIT_WAIT_MS = exports.OPENCODE_PANE_HEIGHT = exports.OPENCODE_EXIT_COMMAND = void 0;
14
+ const base_1 = require("./base");
15
+ const tmux_1 = require("../tmux");
16
+ const pasted_text_helper_1 = require("../pasted-text-helper");
17
+ const opencode_config_1 = require("./opencode-config");
18
+ const child_process_1 = require("child_process");
19
+ const util_1 = require("util");
20
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
21
+ /**
22
+ * Extract error message from unknown error type (DRY)
23
+ * Same pattern as claude-session.ts / codex.ts / gemini.ts / vibe-local.ts.
24
+ * A shared version exists in src/lib/errors.ts (getErrorMessage), but CLI tool
25
+ * modules use local copies to avoid importing the server-side error module.
26
+ * [D1-002] Future refactoring candidate: extract to BaseCLITool or a shared util.
27
+ */
28
+ function getErrorMessage(error) {
29
+ return error instanceof Error ? error.message : String(error);
30
+ }
31
+ /** OpenCode TUI graceful exit command [D1-006] */
32
+ exports.OPENCODE_EXIT_COMMAND = '/exit';
33
+ /**
34
+ * OpenCode tmux pane height (rows).
35
+ * Set to 200 to expand the TUI content area (~190 visible lines),
36
+ * allowing most responses to be captured in a single tmux capture-pane.
37
+ * OpenCode runs in alternate screen mode with no scrollback buffer,
38
+ * so only visible rows are capturable.
39
+ */
40
+ exports.OPENCODE_PANE_HEIGHT = 200;
41
+ /**
42
+ * Wait for OpenCode TUI to initialize after launch.
43
+ * Set to 15000ms to accommodate GPU model loading via Ollama.
44
+ */
45
+ exports.OPENCODE_INIT_WAIT_MS = 15000;
46
+ /**
47
+ * OpenCode CLI tool implementation
48
+ * Manages OpenCode interactive sessions using tmux
49
+ */
50
+ class OpenCodeTool extends base_1.BaseCLITool {
51
+ id = 'opencode';
52
+ name = 'OpenCode';
53
+ command = 'opencode';
54
+ // interrupt() is inherited from BaseCLITool (Escape key) [D2-008]
55
+ // OpenCode TUI supports Escape for interruption ("esc interrupt" display)
56
+ /**
57
+ * Check if OpenCode session is running for a worktree
58
+ */
59
+ async isRunning(worktreeId) {
60
+ const sessionName = this.getSessionName(worktreeId);
61
+ return await (0, tmux_1.hasSession)(sessionName);
62
+ }
63
+ /**
64
+ * Start a new OpenCode session for a worktree
65
+ * Launches `opencode` TUI in interactive mode within tmux
66
+ *
67
+ * @param worktreeId - Worktree ID
68
+ * @param worktreePath - Worktree path
69
+ */
70
+ async startSession(worktreeId, worktreePath) {
71
+ const opencodeAvailable = await this.isInstalled();
72
+ if (!opencodeAvailable) {
73
+ throw new Error('OpenCode is not installed or not in PATH');
74
+ }
75
+ const sessionName = this.getSessionName(worktreeId);
76
+ const exists = await (0, tmux_1.hasSession)(sessionName);
77
+ if (exists) {
78
+ console.log(`OpenCode session ${sessionName} already exists`);
79
+ return;
80
+ }
81
+ try {
82
+ // Generate opencode.json if not present (non-fatal on failure)
83
+ await (0, opencode_config_1.ensureOpencodeConfig)(worktreePath);
84
+ // Create tmux session with large history buffer
85
+ await (0, tmux_1.createSession)({
86
+ sessionName,
87
+ workingDirectory: worktreePath,
88
+ historyLimit: 50000,
89
+ });
90
+ // Wait a moment for the session to be created
91
+ await new Promise((resolve) => setTimeout(resolve, 100));
92
+ // Resize tmux window to 80 columns (hide sidebar for clean capture-pane output)
93
+ // [SEC-001] Uses execFile (not exec) to prevent shell meta-character injection via sessionName
94
+ try {
95
+ await execFileAsync('tmux', [
96
+ 'resize-window', '-t', sessionName,
97
+ '-x', '80', '-y', String(exports.OPENCODE_PANE_HEIGHT),
98
+ ]);
99
+ }
100
+ catch {
101
+ // Non-fatal: resize may fail in some environments
102
+ }
103
+ // Start OpenCode TUI
104
+ await (0, tmux_1.sendKeys)(sessionName, 'opencode', true);
105
+ // Wait for OpenCode to initialize (GPU model loading via Ollama)
106
+ await new Promise((resolve) => setTimeout(resolve, exports.OPENCODE_INIT_WAIT_MS));
107
+ console.log(`Started OpenCode session: ${sessionName}`);
108
+ }
109
+ catch (error) {
110
+ const errorMessage = getErrorMessage(error);
111
+ throw new Error(`Failed to start OpenCode session: ${errorMessage}`);
112
+ }
113
+ }
114
+ /**
115
+ * Send a message to OpenCode interactive session
116
+ * [D1-004] Same pattern as Codex/Gemini/VibeLocal (future Template Method candidate)
117
+ *
118
+ * @param worktreeId - Worktree ID
119
+ * @param message - Message to send
120
+ */
121
+ async sendMessage(worktreeId, message) {
122
+ const sessionName = this.getSessionName(worktreeId);
123
+ const exists = await (0, tmux_1.hasSession)(sessionName);
124
+ if (!exists) {
125
+ throw new Error(`OpenCode session ${sessionName} does not exist. Start the session first.`);
126
+ }
127
+ try {
128
+ // Send message to OpenCode (without Enter)
129
+ await (0, tmux_1.sendKeys)(sessionName, message, false);
130
+ // Wait a moment for the text to be typed
131
+ await new Promise((resolve) => setTimeout(resolve, 100));
132
+ // Send Enter key separately
133
+ await (0, tmux_1.sendSpecialKey)(sessionName, 'C-m');
134
+ // Wait a moment for the message to be processed
135
+ await new Promise((resolve) => setTimeout(resolve, 200));
136
+ // Detect [Pasted text] and resend Enter for multi-line messages
137
+ if (message.includes('\n')) {
138
+ await (0, pasted_text_helper_1.detectAndResendIfPastedText)(sessionName);
139
+ }
140
+ console.log(`Sent message to OpenCode session: ${sessionName}`);
141
+ }
142
+ catch (error) {
143
+ const errorMessage = getErrorMessage(error);
144
+ throw new Error(`Failed to send message to OpenCode: ${errorMessage}`);
145
+ }
146
+ }
147
+ /**
148
+ * Kill OpenCode session with graceful shutdown.
149
+ *
150
+ * Shutdown sequence [D1-006, D1-007]:
151
+ * 1. Check if session exists
152
+ * 2. If exists: send `/exit` TUI command for graceful shutdown
153
+ * 3. Wait 2s for OpenCode to process the exit command
154
+ * 4. Re-check session: if still running, force-kill via tmux kill-session
155
+ * 5. If session did not exist: attempt kill anyway (cleanup stale sessions)
156
+ *
157
+ * @param worktreeId - Worktree ID
158
+ */
159
+ async killSession(worktreeId) {
160
+ const sessionName = this.getSessionName(worktreeId);
161
+ try {
162
+ // Step 1: Check if the tmux session currently exists
163
+ const exists = await (0, tmux_1.hasSession)(sessionName);
164
+ if (exists) {
165
+ // Step 2: Send /exit command for graceful TUI shutdown [D1-006]
166
+ await (0, tmux_1.sendKeys)(sessionName, exports.OPENCODE_EXIT_COMMAND, true);
167
+ // Step 3: Wait for OpenCode to process the exit command
168
+ await new Promise((resolve) => setTimeout(resolve, 2000));
169
+ // Step 4: Check if session still exists; force-kill if needed [D1-007]
170
+ const stillExists = await (0, tmux_1.hasSession)(sessionName);
171
+ if (stillExists) {
172
+ await (0, tmux_1.killSession)(sessionName);
173
+ }
174
+ }
175
+ else {
176
+ // Step 5: Session does not exist, attempt kill anyway (cleanup stale tmux sessions)
177
+ await (0, tmux_1.killSession)(sessionName);
178
+ }
179
+ console.log(`Stopped OpenCode session: ${sessionName}`);
180
+ }
181
+ catch (error) {
182
+ const errorMessage = getErrorMessage(error);
183
+ console.error(`Error stopping OpenCode session: ${errorMessage}`);
184
+ throw error;
185
+ }
186
+ }
187
+ }
188
+ exports.OpenCodeTool = OpenCodeTool;