commandmate 0.3.5 → 0.4.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.
Files changed (161) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +19 -23
  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 +69 -55
  19. package/.next/required-server-files.json +1 -1
  20. package/.next/server/app/_not-found/page.js +1 -1
  21. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/api/app/update-check/route.js +1 -1
  24. package/.next/server/app/api/repositories/clone/route.js +1 -1
  25. package/.next/server/app/api/repositories/route.js +8 -8
  26. package/.next/server/app/api/repositories/scan/route.js +1 -1
  27. package/.next/server/app/api/worktrees/[id]/capture/route.js +1 -2
  28. package/.next/server/app/api/worktrees/[id]/capture/route.js.nft.json +1 -1
  29. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  30. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
  31. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  32. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  33. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  34. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -1
  35. package/.next/server/app/api/worktrees/[id]/schedules/route.js +2 -2
  36. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  37. package/.next/server/app/api/worktrees/[id]/terminal/route.js +1 -1
  38. package/.next/server/app/api/worktrees/[id]/terminal/route.js.nft.json +1 -1
  39. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
  40. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
  41. package/.next/server/app/api/worktrees/route.js +1 -1
  42. package/.next/server/app/login/page.js +1 -1
  43. package/.next/server/app/login/page.js.nft.json +1 -1
  44. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/page.js +3 -3
  46. package/.next/server/app/page.js.nft.json +1 -1
  47. package/.next/server/app/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/proxy/[...path]/route.js +4 -4
  49. package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
  50. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  51. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  52. package/.next/server/app/worktrees/[id]/page.js +6 -6
  53. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  54. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app/worktrees/[id]/terminal/page.js +2 -4
  56. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
  57. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app-paths-manifest.json +8 -8
  59. package/.next/server/chunks/{3294.js → 1628.js} +3 -3
  60. package/.next/server/chunks/185.js +36 -0
  61. package/.next/server/chunks/3860.js +1 -1
  62. package/.next/server/chunks/4893.js +2 -2
  63. package/.next/server/chunks/4952.js +1 -1
  64. package/.next/server/chunks/5488.js +6 -6
  65. package/.next/server/chunks/7425.js +34 -31
  66. package/.next/server/chunks/7566.js +2 -2
  67. package/.next/server/chunks/8199.js +1 -0
  68. package/.next/server/chunks/8585.js +1 -1
  69. package/.next/server/chunks/8693.js +1 -1
  70. package/.next/server/middleware-build-manifest.js +1 -1
  71. package/.next/server/middleware-manifest.json +5 -5
  72. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  73. package/.next/server/pages/500.html +1 -1
  74. package/.next/server/server-reference-manifest.json +1 -1
  75. package/.next/static/chunks/12-00c528d46a0a0a1d.js +1 -0
  76. package/.next/static/chunks/{13.feeafc7cc620f8c4.js → 13.b9521543496f4468.js} +1 -1
  77. package/.next/static/chunks/1334.bfedf44ee9fe2761.js +1 -0
  78. package/.next/static/chunks/143.eb6b4671490cd223.js +1 -0
  79. package/.next/static/chunks/{3574.7a94c27e6a496a56.js → 1442.74b5f4de9a4b4e1b.js} +1 -1
  80. package/.next/static/chunks/2083-b5bed0c77cc53281.js +1 -0
  81. package/.next/static/chunks/2725.eb2d236c8030711c.js +1 -0
  82. package/.next/static/chunks/3398-3d40a17387bd554b.js +1 -0
  83. package/.next/static/chunks/3516.3c576047408cae6b.js +1 -0
  84. package/.next/static/chunks/3559.422c6ca760b85750.js +1 -0
  85. package/.next/static/chunks/3956.52c5b9a0071a641d.js +1 -0
  86. package/.next/static/chunks/4012.32b576a4fa621774.js +1 -0
  87. package/.next/static/chunks/4212.e7ba1009bc1da62d.js +131 -0
  88. package/.next/static/chunks/4303.caf91e86105d5e70.js +1 -0
  89. package/.next/static/chunks/4327.4dcda9b6fab6a385.js +82 -0
  90. package/.next/static/chunks/4671.d86d21d0dfdace41.js +1 -0
  91. package/.next/static/chunks/5518.ec88dcb5a27b17fe.js +1 -0
  92. package/.next/static/chunks/6434.08d262283371d333.js +1 -0
  93. package/.next/static/chunks/{656.5e2de0173f5a06bd.js → 656.dc26b973d07d9627.js} +5 -5
  94. package/.next/static/chunks/7119.01777af21b55740c.js +1 -0
  95. package/.next/static/chunks/7293.fb88bb102af4aa04.js +1 -0
  96. package/.next/static/chunks/8913-40625650292eb3d0.js +1 -0
  97. package/.next/static/chunks/8977.fc18b8260cd8bc1f.js +1 -0
  98. package/.next/static/chunks/9552.d959149efd41e84b.js +1 -0
  99. package/.next/static/chunks/app/layout-7198a7a49aa21a97.js +1 -0
  100. package/.next/static/chunks/app/page-7498cf75e69d9227.js +1 -0
  101. package/.next/static/chunks/app/worktrees/[id]/files/[...path]/page-0599f64a8e80d255.js +1 -0
  102. package/.next/static/chunks/app/worktrees/[id]/page-94ad7a1ce1f0c440.js +1 -0
  103. package/.next/static/chunks/app/worktrees/[id]/terminal/page-175b618c047bc992.js +1 -0
  104. package/.next/static/chunks/d3ac728e.daf595a898e9b720.js +1 -0
  105. package/.next/static/chunks/webpack-f7111aab807d73b9.js +1 -0
  106. package/.next/static/css/f7dc01350168df01.css +3 -0
  107. package/.next/trace +5 -5
  108. package/README.md +66 -56
  109. package/dist/server/server.js +5 -0
  110. package/dist/server/src/lib/auto-yes-manager.js +58 -18
  111. package/dist/server/src/lib/claude-session.js +9 -3
  112. package/dist/server/src/lib/cli-session.js +60 -10
  113. package/dist/server/src/lib/cli-tools/codex.js +7 -7
  114. package/dist/server/src/lib/cli-tools/gemini.js +3 -0
  115. package/dist/server/src/lib/cli-tools/opencode-config.js +179 -33
  116. package/dist/server/src/lib/cli-tools/opencode.js +5 -0
  117. package/dist/server/src/lib/cli-tools/vibe-local.js +3 -0
  118. package/dist/server/src/lib/cmate-parser.js +7 -7
  119. package/dist/server/src/lib/db-migrations.js +18 -1
  120. package/dist/server/src/lib/errors.js +153 -0
  121. package/dist/server/src/lib/prompt-answer-sender.js +3 -0
  122. package/dist/server/src/lib/prompt-detector.js +49 -7
  123. package/dist/server/src/lib/resource-cleanup.js +257 -0
  124. package/dist/server/src/lib/schedule-manager.js +269 -83
  125. package/dist/server/src/lib/tmux-capture-cache.js +221 -0
  126. package/dist/server/src/lib/tmux.js +41 -20
  127. package/dist/server/src/types/markdown-editor.js +9 -1
  128. package/package.json +11 -8
  129. package/.next/server/chunks/539.js +0 -35
  130. package/.next/server/chunks/7458.js +0 -1
  131. package/.next/server/chunks/7808.js +0 -1
  132. package/.next/static/chunks/1038-3509435b68c0967e.js +0 -1
  133. package/.next/static/chunks/1098.49268c9fe1b028fa.js +0 -1
  134. package/.next/static/chunks/2335-98a211e00b94c7ac.js +0 -1
  135. package/.next/static/chunks/3559.f073f72c4466ce0e.js +0 -1
  136. package/.next/static/chunks/3843.3fdda732987f7bb8.js +0 -1
  137. package/.next/static/chunks/4212.52c1bb34fc97d0d0.js +0 -131
  138. package/.next/static/chunks/4327.157a4c226d919531.js +0 -60
  139. package/.next/static/chunks/4362.7bd6f0282e49d79b.js +0 -1
  140. package/.next/static/chunks/4721.40615a5f4f32b5fb.js +0 -1
  141. package/.next/static/chunks/5112.17318d1c6b28044b.js +0 -1
  142. package/.next/static/chunks/6406.9653f0d41ab85059.js +0 -1
  143. package/.next/static/chunks/6792.3c01ac4dda4b5c6d.js +0 -1
  144. package/.next/static/chunks/8091-d65d2ab6daed23c6.js +0 -1
  145. package/.next/static/chunks/8125.245a9df052d274fb.js +0 -1
  146. package/.next/static/chunks/8522.1607e96011c66877.js +0 -1
  147. package/.next/static/chunks/8841.dadeb1ece8e46004.js +0 -1
  148. package/.next/static/chunks/8885.f8d9912b40d74811.js +0 -1
  149. package/.next/static/chunks/9178-88850a7c48deea07.js +0 -1
  150. package/.next/static/chunks/9552.b7dfb7903ead934b.js +0 -1
  151. package/.next/static/chunks/app/layout-9110f9a5e41c6bf4.js +0 -1
  152. package/.next/static/chunks/app/page-9e523a8f415bc707.js +0 -1
  153. package/.next/static/chunks/app/worktrees/[id]/files/[...path]/page-4a3c0861367e0391.js +0 -1
  154. package/.next/static/chunks/app/worktrees/[id]/page-8fb4dc30b58a5681.js +0 -1
  155. package/.next/static/chunks/app/worktrees/[id]/terminal/page-5d85a7e508ce36d3.js +0 -1
  156. package/.next/static/chunks/d3ac728e.6c9c508274d4d2d5.js +0 -1
  157. package/.next/static/chunks/webpack-81c97591dd5567ac.js +0 -1
  158. package/.next/static/css/45b3a41370668314.css +0 -3
  159. /package/.next/static/chunks/{30d07d85-393352a92199f695.js → 30d07d85.1dc99a921fc18e34.js} +0 -0
  160. /package/.next/static/{p3hosTZoJ22r35fWwUoLr → dwGMLEU53HOvFOWqiZOT0}/_buildManifest.js +0 -0
  161. /package/.next/static/{p3hosTZoJ22r35fWwUoLr → dwGMLEU53HOvFOWqiZOT0}/_ssgManifest.js +0 -0
@@ -2,9 +2,10 @@
2
2
  /**
3
3
  * OpenCode configuration file generator
4
4
  * Issue #379: Generates opencode.json with Ollama provider configuration
5
+ * Issue #398: Added LM Studio provider support
5
6
  *
6
7
  * @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
+ * This module handles Ollama/LM Studio HTTP API calls and config file I/O,
8
9
  * while opencode.ts handles tmux session management.
9
10
  */
10
11
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
@@ -41,7 +42,9 @@ var __importStar = (this && this.__importStar) || (function () {
41
42
  };
42
43
  })();
43
44
  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.LM_STUDIO_MODEL_PATTERN = exports.MAX_LM_STUDIO_MODELS = exports.LM_STUDIO_BASE_URL = exports.LM_STUDIO_API_URL = exports.OLLAMA_MODEL_PATTERN = exports.MAX_OLLAMA_MODELS = exports.OLLAMA_BASE_URL = exports.OLLAMA_API_URL = void 0;
46
+ exports.fetchOllamaModels = fetchOllamaModels;
47
+ exports.fetchLmStudioModels = fetchLmStudioModels;
45
48
  exports.ensureOpencodeConfig = ensureOpencodeConfig;
46
49
  const fs = __importStar(require("fs"));
47
50
  const path = __importStar(require("path"));
@@ -75,10 +78,49 @@ exports.MAX_OLLAMA_MODELS = 100;
75
78
  * Length limit provides DoS protection against excessively long model names from Ollama API.
76
79
  */
77
80
  exports.OLLAMA_MODEL_PATTERN = /^[a-zA-Z0-9._:/-]{1,100}$/;
81
+ /**
82
+ * [SEC-001] SSRF Prevention: LM Studio API URL is hardcoded.
83
+ * This value MUST NOT be derived from environment variables, config files,
84
+ * or user input. OWASP A10:2021
85
+ */
86
+ exports.LM_STUDIO_API_URL = 'http://localhost:1234/v1/models';
87
+ /**
88
+ * [SEC-001] SSRF Prevention: LM Studio base URL for opencode.json config.
89
+ * Same policy as LM_STUDIO_API_URL.
90
+ */
91
+ exports.LM_STUDIO_BASE_URL = 'http://localhost:1234/v1';
92
+ /** Maximum number of LM Studio models to include in config (DoS prevention) */
93
+ exports.MAX_LM_STUDIO_MODELS = 100;
94
+ /**
95
+ * LM Studio model ID validation pattern (with length limit).
96
+ * Allows: alphanumeric, dots, underscores, colons, slashes, @, hyphens.
97
+ * Max 200 characters (length encoded in regex).
98
+ *
99
+ * [SEC-001] Defense-in-depth validation at point of use.
100
+ *
101
+ * Character set rationale:
102
+ * - a-zA-Z0-9._:/- : Common character set shared with Ollama (model names, org/model format)
103
+ * - @ : HuggingFace revision format support (e.g., org/model@revision)
104
+ *
105
+ * Actual model ID examples:
106
+ * - lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF (54 chars)
107
+ * - TheBloke/Mistral-7B-Instruct-v0.2-GGUF (41 chars)
108
+ *
109
+ * Length limit rationale: Actual model IDs max ~60 chars; 200 provides
110
+ * sufficient safety margin for org+model+quantization+revision.
111
+ *
112
+ * Note: Hyphen `-` is placed at the end of the character class to avoid
113
+ * the need for escaping in the regex.
114
+ */
115
+ exports.LM_STUDIO_MODEL_PATTERN = /^[a-zA-Z0-9._:/@-]{1,200}$/;
78
116
  /** Ollama API request timeout in milliseconds */
79
117
  const OLLAMA_API_TIMEOUT_MS = 3000;
80
118
  /** Maximum Ollama API response size (1MB) [D4-007] */
81
119
  const MAX_OLLAMA_RESPONSE_SIZE = 1 * 1024 * 1024;
120
+ /** LM Studio API request timeout in milliseconds */
121
+ const LM_STUDIO_API_TIMEOUT_MS = 3000;
122
+ /** Maximum LM Studio API response size (1MB) */
123
+ const MAX_LM_STUDIO_RESPONSE_SIZE = 1 * 1024 * 1024;
82
124
  /** Config file name */
83
125
  const CONFIG_FILE_NAME = 'opencode.json';
84
126
  // =============================================================================
@@ -141,25 +183,22 @@ function validateWorktreePath(worktreePath) {
141
183
  return realPath;
142
184
  }
143
185
  // =============================================================================
144
- // Main function
186
+ // Provider Functions
145
187
  // =============================================================================
146
188
  /**
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.
189
+ * Fetch model list from Ollama API.
190
+ * Returns empty object on any failure (non-fatal).
150
191
  *
151
- * @param worktreePath - Worktree directory path (from DB)
192
+ * Extracted from ensureOpencodeConfig() for SRP compliance.
193
+ * All error paths (non-200 response, size exceeded, invalid structure, exceptions)
194
+ * return empty object {} instead of throwing.
195
+ *
196
+ * @returns Model map (key: model name, value: { name: display name })
152
197
  * @internal
153
198
  */
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
199
+ // TODO: If a 3rd provider is added, extract common HTTP fetch logic
200
+ // to fetchWithTimeout(url, timeoutMs, maxResponseSize): Promise<string | null>
201
+ async function fetchOllamaModels() {
163
202
  const models = {};
164
203
  try {
165
204
  const controller = new AbortController();
@@ -170,20 +209,20 @@ async function ensureOpencodeConfig(worktreePath) {
170
209
  clearTimeout(timeoutId);
171
210
  });
172
211
  if (!response.ok) {
173
- console.warn(`Ollama API returned status ${response.status}, skipping opencode.json generation`);
174
- return;
212
+ console.warn(`Ollama API returned status ${response.status}, skipping model fetch`);
213
+ return {};
175
214
  }
176
215
  // [D4-007] Response size check
177
216
  const text = await response.text();
178
217
  if (text.length > MAX_OLLAMA_RESPONSE_SIZE) {
179
- console.warn('Ollama API response too large, skipping opencode.json generation');
180
- return;
218
+ console.warn('Ollama API response too large, skipping model fetch');
219
+ return {};
181
220
  }
182
221
  // Parse and validate response structure [D4-007]
183
222
  const data = JSON.parse(text);
184
223
  if (!data || !Array.isArray(data.models)) {
185
- console.warn('Invalid Ollama API response structure, skipping opencode.json generation');
186
- return;
224
+ console.warn('Invalid Ollama API response structure, skipping model fetch');
225
+ return {};
187
226
  }
188
227
  // Limit model count (DoS prevention)
189
228
  const modelList = data.models.slice(0, exports.MAX_OLLAMA_MODELS);
@@ -199,26 +238,133 @@ async function ensureOpencodeConfig(worktreePath) {
199
238
  catch (error) {
200
239
  // Non-fatal: Ollama may not be running [D4-002]
201
240
  if (error instanceof Error && error.name === 'AbortError') {
202
- console.warn('Ollama API timeout, skipping opencode.json generation');
241
+ console.warn('Ollama API timeout, skipping model fetch');
242
+ }
243
+ else {
244
+ console.warn('Failed to fetch Ollama models, skipping model fetch');
245
+ }
246
+ }
247
+ return models;
248
+ }
249
+ /**
250
+ * Fetch model list from LM Studio OpenAI-compatible API.
251
+ * Returns empty object on any failure (non-fatal).
252
+ *
253
+ * LM Studio provides an OpenAI-compatible API at /v1/models.
254
+ * Response format: { data: [{ id: string, object: string, ... }] }
255
+ * Model IDs are used as-is for display names (no details available).
256
+ *
257
+ * Model IDs are validated with LM_STUDIO_MODEL_PATTERN and used as
258
+ * opencode.json model keys. JSON.stringify() ensures proper escaping
259
+ * to prevent JSON structure corruption via malicious model IDs. [SEC-005]
260
+ *
261
+ * @returns Model map (key: model id, value: { name: model id })
262
+ * @internal
263
+ */
264
+ // TODO: If a 3rd provider is added, extract common HTTP fetch logic
265
+ // to fetchWithTimeout(url, timeoutMs, maxResponseSize): Promise<string | null>
266
+ async function fetchLmStudioModels() {
267
+ const models = {};
268
+ try {
269
+ const controller = new AbortController();
270
+ const timeoutId = setTimeout(() => controller.abort(), LM_STUDIO_API_TIMEOUT_MS);
271
+ const response = await fetch(exports.LM_STUDIO_API_URL, {
272
+ signal: controller.signal,
273
+ }).finally(() => {
274
+ clearTimeout(timeoutId);
275
+ });
276
+ if (!response.ok) {
277
+ console.warn(`LM Studio API returned status ${response.status}, skipping model fetch`);
278
+ return {};
279
+ }
280
+ const text = await response.text();
281
+ if (text.length > MAX_LM_STUDIO_RESPONSE_SIZE) {
282
+ console.warn('LM Studio API response too large, skipping model fetch');
283
+ return {};
284
+ }
285
+ const data = JSON.parse(text);
286
+ if (!data || !Array.isArray(data.data)) {
287
+ console.warn('Invalid LM Studio API response structure, skipping model fetch');
288
+ return {};
289
+ }
290
+ const modelList = data.data.slice(0, exports.MAX_LM_STUDIO_MODELS);
291
+ for (const model of modelList) {
292
+ if (typeof model?.id !== 'string')
293
+ continue;
294
+ if (!exports.LM_STUDIO_MODEL_PATTERN.test(model.id))
295
+ continue;
296
+ models[model.id] = { name: model.id };
297
+ }
298
+ }
299
+ catch (error) {
300
+ if (error instanceof Error && error.name === 'AbortError') {
301
+ console.warn('LM Studio API timeout, skipping model fetch');
203
302
  }
204
303
  else {
205
- console.warn('Failed to fetch Ollama models, skipping opencode.json generation');
304
+ console.warn('Failed to fetch LM Studio models, skipping model fetch');
206
305
  }
306
+ }
307
+ return models;
308
+ }
309
+ // =============================================================================
310
+ // Main function
311
+ // =============================================================================
312
+ /**
313
+ * Ensure opencode.json exists in the worktree directory.
314
+ * If the file already exists, it is NOT overwritten (respects user configuration).
315
+ * If both Ollama and LM Studio are not running, the function returns without error
316
+ * and does not generate opencode.json.
317
+ *
318
+ * Provider configuration is built dynamically: only providers with models are included.
319
+ * If a 3rd provider is added, consider refactoring to a data-driven design
320
+ * (providerDefinitions array + loop) instead of inline if-branches. [KISS]
321
+ *
322
+ * @param worktreePath - Worktree directory path (from DB)
323
+ * @internal
324
+ */
325
+ async function ensureOpencodeConfig(worktreePath) {
326
+ // Validate path [D4-004]
327
+ const validatedPath = validateWorktreePath(worktreePath);
328
+ const configPath = path.join(validatedPath, CONFIG_FILE_NAME);
329
+ // Skip if config already exists (respect user configuration)
330
+ if (fs.existsSync(configPath)) {
331
+ return;
332
+ }
333
+ // Fetch models from both providers in parallel.
334
+ // Each function catches all exceptions internally and returns {} on failure,
335
+ // so Promise.all will never reject.
336
+ const [ollamaModels, lmStudioModels] = await Promise.all([
337
+ fetchOllamaModels(),
338
+ fetchLmStudioModels(),
339
+ ]);
340
+ // Dynamic provider configuration: only include providers with models
341
+ const provider = {};
342
+ if (Object.keys(ollamaModels).length > 0) {
343
+ provider.ollama = {
344
+ npm: '@ai-sdk/openai-compatible',
345
+ name: 'Ollama (local)',
346
+ options: { baseURL: exports.OLLAMA_BASE_URL },
347
+ models: ollamaModels,
348
+ };
349
+ }
350
+ if (Object.keys(lmStudioModels).length > 0) {
351
+ provider.lmstudio = {
352
+ npm: '@ai-sdk/openai-compatible',
353
+ name: 'LM Studio (local)',
354
+ options: { baseURL: exports.LM_STUDIO_BASE_URL },
355
+ models: lmStudioModels,
356
+ };
357
+ }
358
+ // Both providers have 0 models: skip opencode.json generation
359
+ if (Object.keys(provider).length === 0) {
207
360
  return;
208
361
  }
209
362
  // [D4-005] Generate config using JSON.stringify (not template literals).
210
363
  // JSON.stringify ensures proper escaping of model names and other values,
211
- // preventing JSON injection via maliciously crafted Ollama model metadata.
364
+ // preventing JSON injection via maliciously crafted model metadata.
212
365
  const config = {
213
366
  $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
- },
367
+ provider,
222
368
  };
223
369
  try {
224
370
  fs.writeFileSync(configPath, JSON.stringify(config, null, 2), {
@@ -14,6 +14,7 @@ exports.OpenCodeTool = exports.OPENCODE_INIT_WAIT_MS = exports.OPENCODE_PANE_HEI
14
14
  const base_1 = require("./base");
15
15
  const tmux_1 = require("../tmux");
16
16
  const pasted_text_helper_1 = require("../pasted-text-helper");
17
+ const tmux_capture_cache_1 = require("../tmux-capture-cache");
17
18
  const opencode_config_1 = require("./opencode-config");
18
19
  const child_process_1 = require("child_process");
19
20
  const util_1 = require("util");
@@ -137,6 +138,8 @@ class OpenCodeTool extends base_1.BaseCLITool {
137
138
  if (message.includes('\n')) {
138
139
  await (0, pasted_text_helper_1.detectAndResendIfPastedText)(sessionName);
139
140
  }
141
+ // Issue #405: Invalidate cache after sending message
142
+ (0, tmux_capture_cache_1.invalidateCache)(sessionName);
140
143
  console.log(`Sent message to OpenCode session: ${sessionName}`);
141
144
  }
142
145
  catch (error) {
@@ -176,6 +179,8 @@ class OpenCodeTool extends base_1.BaseCLITool {
176
179
  // Step 5: Session does not exist, attempt kill anyway (cleanup stale tmux sessions)
177
180
  await (0, tmux_1.killSession)(sessionName);
178
181
  }
182
+ // Issue #405: Invalidate cache after session kill
183
+ (0, tmux_capture_cache_1.invalidateCache)(sessionName);
179
184
  console.log(`Stopped OpenCode session: ${sessionName}`);
180
185
  }
181
186
  catch (error) {
@@ -14,6 +14,7 @@ const base_1 = require("./base");
14
14
  const types_1 = require("./types");
15
15
  const tmux_1 = require("../tmux");
16
16
  const pasted_text_helper_1 = require("../pasted-text-helper");
17
+ const tmux_capture_cache_1 = require("../tmux-capture-cache");
17
18
  const db_instance_1 = require("../db-instance");
18
19
  const db_1 = require("../db");
19
20
  /**
@@ -132,6 +133,8 @@ class VibeLocalTool extends base_1.BaseCLITool {
132
133
  if (message.includes('\n')) {
133
134
  await (0, pasted_text_helper_1.detectAndResendIfPastedText)(sessionName);
134
135
  }
136
+ // Issue #405: Invalidate cache after sending message
137
+ (0, tmux_capture_cache_1.invalidateCache)(sessionName);
135
138
  console.log(`✓ Sent message to Vibe Local session: ${sessionName}`);
136
139
  }
137
140
  catch (error) {
@@ -22,7 +22,7 @@ exports.validateCmatePath = validateCmatePath;
22
22
  exports.parseCmateFile = parseCmateFile;
23
23
  exports.parseSchedulesSection = parseSchedulesSection;
24
24
  exports.readCmateFile = readCmateFile;
25
- const fs_1 = require("fs");
25
+ const promises_1 = require("fs/promises");
26
26
  const path_1 = __importDefault(require("path"));
27
27
  const types_1 = require("../lib/cli-tools/types");
28
28
  const schedule_config_1 = require("../config/schedule-config");
@@ -64,9 +64,9 @@ function sanitizeMessageContent(content) {
64
64
  * @returns true if path is valid and within worktree directory
65
65
  * @throws Error if path traversal is detected
66
66
  */
67
- function validateCmatePath(filePath, worktreeDir) {
68
- const realFilePath = (0, fs_1.realpathSync)(filePath);
69
- const realWorktreeDir = (0, fs_1.realpathSync)(worktreeDir);
67
+ async function validateCmatePath(filePath, worktreeDir) {
68
+ const realFilePath = await (0, promises_1.realpath)(filePath);
69
+ const realWorktreeDir = await (0, promises_1.realpath)(worktreeDir);
70
70
  // Ensure the file is within the worktree directory
71
71
  if (!realFilePath.startsWith(realWorktreeDir + path_1.default.sep) &&
72
72
  realFilePath !== path_1.default.join(realWorktreeDir, cmate_constants_1.CMATE_FILENAME)) {
@@ -243,12 +243,12 @@ function parseSchedulesSection(rows) {
243
243
  * @returns Parsed CmateConfig, or null if the file doesn't exist
244
244
  * @throws Error if path traversal is detected
245
245
  */
246
- function readCmateFile(worktreeDir) {
246
+ async function readCmateFile(worktreeDir) {
247
247
  const filePath = path_1.default.join(worktreeDir, cmate_constants_1.CMATE_FILENAME);
248
248
  try {
249
249
  // Validate path before reading
250
- validateCmatePath(filePath, worktreeDir);
251
- const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
250
+ await validateCmatePath(filePath, worktreeDir);
251
+ const content = await (0, promises_1.readFile)(filePath, 'utf-8');
252
252
  return parseCmateFile(content);
253
253
  }
254
254
  catch (error) {
@@ -19,7 +19,7 @@ const db_1 = require("./db");
19
19
  * Current schema version
20
20
  * Increment this when adding new migrations
21
21
  */
22
- exports.CURRENT_SCHEMA_VERSION = 20;
22
+ exports.CURRENT_SCHEMA_VERSION = 21;
23
23
  /**
24
24
  * Migration registry
25
25
  * All migrations should be added to this array in order
@@ -876,6 +876,23 @@ const migrations = [
876
876
  // vibe_local_context_window is a nullable INTEGER column; harmless if unused
877
877
  console.log('No rollback for vibe_local_context_window column (SQLite limitation)');
878
878
  }
879
+ },
880
+ {
881
+ version: 21,
882
+ name: 'add-scheduled-executions-worktree-enabled-index',
883
+ up: (db) => {
884
+ // Issue #409: Add composite index for schedule sync performance
885
+ // Used by syncSchedules() to batch-query schedules by worktree_id + enabled
886
+ db.exec(`
887
+ CREATE INDEX IF NOT EXISTS idx_scheduled_executions_worktree_enabled
888
+ ON scheduled_executions(worktree_id, enabled);
889
+ `);
890
+ console.log('✓ Created composite index idx_scheduled_executions_worktree_enabled');
891
+ },
892
+ down: (db) => {
893
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_worktree_enabled');
894
+ console.log('✓ Dropped idx_scheduled_executions_worktree_enabled index');
895
+ }
879
896
  }
880
897
  ];
881
898
  /**
@@ -0,0 +1,153 @@
1
+ "use strict";
2
+ /**
3
+ * Error Definitions
4
+ * Issue #136: Phase 1 - Foundation
5
+ *
6
+ * Centralized error handling for the application.
7
+ * SF-SEC-003: Separates client-facing and internal error messages.
8
+ *
9
+ * @module errors
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.AppError = exports.ErrorCode = void 0;
13
+ exports.createAppError = createAppError;
14
+ exports.isAppError = isAppError;
15
+ exports.wrapError = wrapError;
16
+ exports.getErrorMessage = getErrorMessage;
17
+ /**
18
+ * Standard error codes used throughout the application
19
+ * These codes are safe to expose to clients.
20
+ */
21
+ exports.ErrorCode = {
22
+ // Input validation errors
23
+ INVALID_ISSUE_NO: 'INVALID_ISSUE_NO',
24
+ ISSUE_NO_OUT_OF_RANGE: 'ISSUE_NO_OUT_OF_RANGE',
25
+ INVALID_BRANCH_NAME: 'INVALID_BRANCH_NAME',
26
+ BRANCH_NAME_TOO_LONG: 'BRANCH_NAME_TOO_LONG',
27
+ INVALID_PORT: 'INVALID_PORT',
28
+ // Resource errors
29
+ PORT_EXHAUSTED: 'PORT_EXHAUSTED',
30
+ PORT_IN_USE: 'PORT_IN_USE',
31
+ WORKTREE_NOT_FOUND: 'WORKTREE_NOT_FOUND',
32
+ WORKTREE_ALREADY_EXISTS: 'WORKTREE_ALREADY_EXISTS',
33
+ PID_FILE_EXISTS: 'PID_FILE_EXISTS',
34
+ PROCESS_NOT_RUNNING: 'PROCESS_NOT_RUNNING',
35
+ // Security errors
36
+ PATH_TRAVERSAL: 'PATH_TRAVERSAL',
37
+ UNAUTHORIZED: 'UNAUTHORIZED',
38
+ FORBIDDEN: 'FORBIDDEN',
39
+ // System errors
40
+ DATABASE_ERROR: 'DATABASE_ERROR',
41
+ FILESYSTEM_ERROR: 'FILESYSTEM_ERROR',
42
+ GIT_ERROR: 'GIT_ERROR',
43
+ TIMEOUT: 'TIMEOUT',
44
+ // Generic errors
45
+ UNKNOWN_ERROR: 'UNKNOWN_ERROR',
46
+ };
47
+ /**
48
+ * Application-specific error class
49
+ *
50
+ * @example
51
+ * ```typescript
52
+ * throw new AppError('INVALID_ISSUE_NO', 'Issue number must be a positive integer', { received: -1 });
53
+ * ```
54
+ */
55
+ class AppError extends Error {
56
+ /**
57
+ * Error code - safe to expose to clients
58
+ */
59
+ code;
60
+ /**
61
+ * Additional details - may contain sensitive info, log only
62
+ */
63
+ details;
64
+ /**
65
+ * Timestamp when error occurred
66
+ */
67
+ timestamp;
68
+ constructor(code, message, details) {
69
+ super(message);
70
+ this.name = 'AppError';
71
+ this.code = code;
72
+ this.details = details;
73
+ this.timestamp = new Date().toISOString();
74
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
75
+ if (Error.captureStackTrace) {
76
+ Error.captureStackTrace(this, AppError);
77
+ }
78
+ }
79
+ /**
80
+ * Create a client-safe representation (excludes sensitive details)
81
+ */
82
+ toClientError() {
83
+ return {
84
+ code: this.code,
85
+ message: this.message,
86
+ };
87
+ }
88
+ /**
89
+ * Create a log-safe representation (includes details for debugging)
90
+ */
91
+ toLogError() {
92
+ return {
93
+ code: this.code,
94
+ message: this.message,
95
+ details: this.details,
96
+ timestamp: this.timestamp,
97
+ };
98
+ }
99
+ }
100
+ exports.AppError = AppError;
101
+ /**
102
+ * Factory function to create AppError
103
+ *
104
+ * @param code - Error code from ErrorCode enum
105
+ * @param message - Human-readable error message
106
+ * @param details - Optional additional details (logged, not sent to client)
107
+ * @returns AppError instance
108
+ *
109
+ * @example
110
+ * ```typescript
111
+ * throw createAppError(ErrorCode.PORT_EXHAUSTED, 'No available ports in range', { range: [3001, 3100] });
112
+ * ```
113
+ */
114
+ function createAppError(code, message, details) {
115
+ return new AppError(code, message, details);
116
+ }
117
+ /**
118
+ * Type guard to check if an error is an AppError
119
+ *
120
+ * @param error - Error to check
121
+ * @returns true if error is an AppError
122
+ */
123
+ function isAppError(error) {
124
+ return error instanceof AppError;
125
+ }
126
+ /**
127
+ * Wrap unknown error into AppError
128
+ *
129
+ * @param error - Unknown error
130
+ * @param defaultCode - Default error code if error is not AppError
131
+ * @returns AppError instance
132
+ */
133
+ function wrapError(error, defaultCode = exports.ErrorCode.UNKNOWN_ERROR) {
134
+ if (isAppError(error)) {
135
+ return error;
136
+ }
137
+ if (error instanceof Error) {
138
+ return new AppError(defaultCode, error.message, { originalError: error.name });
139
+ }
140
+ return new AppError(defaultCode, String(error));
141
+ }
142
+ /**
143
+ * Get error message from unknown error
144
+ *
145
+ * @param error - Unknown error
146
+ * @returns Error message string
147
+ */
148
+ function getErrorMessage(error) {
149
+ if (error instanceof Error) {
150
+ return error.message;
151
+ }
152
+ return String(error);
153
+ }
@@ -9,6 +9,7 @@
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
10
  exports.sendPromptAnswer = sendPromptAnswer;
11
11
  const tmux_1 = require("./tmux");
12
+ const tmux_capture_cache_1 = require("./tmux-capture-cache");
12
13
  /** Regex pattern to detect checkbox-style multi-select options */
13
14
  const CHECKBOX_OPTION_PATTERN = /^\[[ x]\] /;
14
15
  /**
@@ -86,4 +87,6 @@ async function sendPromptAnswer(params) {
86
87
  // Send Enter
87
88
  await (0, tmux_1.sendKeys)(sessionName, '', true);
88
89
  }
90
+ // Issue #405: Invalidate cache after sending prompt answer
91
+ (0, tmux_capture_cache_1.invalidateCache)(sessionName);
89
92
  }
@@ -6,8 +6,22 @@
6
6
  Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.detectPrompt = detectPrompt;
8
8
  exports.getAnswerInput = getAnswerInput;
9
+ exports.resetDetectPromptCache = resetDetectPromptCache;
9
10
  const logger_1 = require("./logger");
10
11
  const logger = (0, logger_1.createLogger)('prompt-detector');
12
+ /**
13
+ * Last output tail used for duplicate log suppression.
14
+ * Only the last 50 lines of the output are compared.
15
+ * This is a performance optimization to reduce log I/O -- it does NOT
16
+ * affect detectPrompt()'s return value in any way.
17
+ *
18
+ * Uses pure module-scope (not globalThis) since Hot Reload reset is
19
+ * acceptable for log-only caching. Same pattern as ip-restriction.ts
20
+ * module-scope cache. [S2-004]
21
+ *
22
+ * @internal
23
+ */
24
+ let lastOutputTail = null;
11
25
  /**
12
26
  * Maximum number of lines to retain in rawContent.
13
27
  * Tail lines are preserved (instruction text typically appears just before the prompt).
@@ -109,8 +123,22 @@ function yesNoPromptResult(question, cleanContent, rawContent, defaultOption) {
109
123
  * ```
110
124
  */
111
125
  function detectPrompt(output, options) {
112
- logger.debug('detectPrompt:start', { outputLength: output.length });
126
+ // D2-001: Extract tail 50 lines for duplicate log suppression.
127
+ // Reuse `lines` for both dedup check and yes/no pattern matching below.
128
+ // detectMultipleChoicePrompt() has its own independent split() in its
129
+ // own scope -- this is intentional function encapsulation [S1-004][S2-001].
113
130
  const lines = output.split('\n');
131
+ const tailForDedup = lines.slice(-50).join('\n');
132
+ const isDuplicate = tailForDedup === lastOutputTail;
133
+ // D2-002: Only log on new (non-duplicate) output [S1-001 SRP tradeoff:
134
+ // dedup logic is inlined here because it needs direct access to output.
135
+ // If log suppression grows complex (e.g., per-worktree cache), extract
136
+ // to shouldSuppressLog(output): boolean helper.]
137
+ if (!isDuplicate) {
138
+ logger.debug('detectPrompt:start', { outputLength: output.length });
139
+ }
140
+ // D2-003: Update cache (affects logging only, never return values)
141
+ lastOutputTail = tailForDedup;
114
142
  // [SF-003] [MF-S2-001] Expanded from 10 to 20 lines for rawContent coverage
115
143
  const lastLines = lines.slice(-20).join('\n');
116
144
  // Pattern 0: Multiple choice (numbered options with ❯ indicator)
@@ -121,11 +149,14 @@ function detectPrompt(output, options) {
121
149
  // 3. Cancel
122
150
  const multipleChoiceResult = detectMultipleChoicePrompt(output, options);
123
151
  if (multipleChoiceResult.isPrompt) {
124
- logger.info('detectPrompt:multipleChoice', {
125
- isPrompt: true,
126
- question: multipleChoiceResult.promptData?.question,
127
- optionsCount: multipleChoiceResult.promptData?.options?.length,
128
- });
152
+ // D2-004: Suppress duplicate multipleChoice info log
153
+ if (!isDuplicate) {
154
+ logger.info('detectPrompt:multipleChoice', {
155
+ isPrompt: true,
156
+ question: multipleChoiceResult.promptData?.question,
157
+ optionsCount: multipleChoiceResult.promptData?.options?.length,
158
+ });
159
+ }
129
160
  return multipleChoiceResult;
130
161
  }
131
162
  // Patterns 1-4: Yes/no patterns (data-driven matching)
@@ -148,7 +179,10 @@ function detectPrompt(output, options) {
148
179
  return yesNoPromptResult(question, content || 'Approve?', trimmedLastLines);
149
180
  }
150
181
  // No prompt detected
151
- logger.debug('detectPrompt:complete', { isPrompt: false });
182
+ // D2-005: Suppress duplicate complete log
183
+ if (!isDuplicate) {
184
+ logger.debug('detectPrompt:complete', { isPrompt: false });
185
+ }
152
186
  return {
153
187
  isPrompt: false,
154
188
  cleanContent: output.trim(),
@@ -697,3 +731,11 @@ function getAnswerInput(answer, promptType = 'yes_no') {
697
731
  // SEC-003: Fixed error message without user input to prevent log injection
698
732
  throw new Error("Invalid answer for yes/no prompt. Expected 'yes', 'no', 'y', or 'n'.");
699
733
  }
734
+ /**
735
+ * Reset the duplicate log suppression cache.
736
+ * Intended for test isolation only.
737
+ * @internal
738
+ */
739
+ function resetDetectPromptCache() {
740
+ lastOutputTail = null;
741
+ }