commandmate 0.3.4 → 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 (97) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +12 -12
  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/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  57. package/.next/server/app/worktrees/[id]/page.js +4 -4
  58. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  59. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  60. package/.next/server/app-paths-manifest.json +7 -7
  61. package/.next/server/chunks/3074.js +1 -1
  62. package/.next/server/chunks/4952.js +1 -0
  63. package/.next/server/chunks/539.js +3 -3
  64. package/.next/server/chunks/5795.js +1 -1
  65. package/.next/server/chunks/7425.js +28 -25
  66. package/.next/server/chunks/7566.js +1 -1
  67. package/.next/server/chunks/8693.js +1 -1
  68. package/.next/server/middleware-build-manifest.js +1 -1
  69. package/.next/server/middleware-manifest.json +5 -5
  70. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  71. package/.next/server/pages/500.html +1 -1
  72. package/.next/server/pages-manifest.json +1 -1
  73. package/.next/server/server-reference-manifest.json +1 -1
  74. package/.next/static/chunks/{4327.740cc7fe2d0b5049.js → 4327.157a4c226d919531.js} +14 -14
  75. package/.next/static/chunks/5970.0df906ad5a9c9147.js +1 -0
  76. package/.next/static/chunks/{8091-c0e955616dd86f82.js → 8091-d65d2ab6daed23c6.js} +1 -1
  77. package/.next/static/chunks/app/login/page-010f02fd4b0dbc48.js +1 -0
  78. package/.next/static/chunks/app/worktrees/[id]/page-8fb4dc30b58a5681.js +1 -0
  79. package/.next/static/chunks/webpack-81c97591dd5567ac.js +1 -0
  80. package/.next/static/css/45b3a41370668314.css +3 -0
  81. package/.next/trace +5 -5
  82. package/dist/server/src/lib/claude-executor.js +14 -3
  83. package/dist/server/src/lib/cli-patterns.js +99 -20
  84. package/dist/server/src/lib/cli-tools/manager.js +5 -3
  85. package/dist/server/src/lib/cli-tools/opencode-config.js +236 -0
  86. package/dist/server/src/lib/cli-tools/opencode.js +188 -0
  87. package/dist/server/src/lib/cli-tools/types.js +12 -5
  88. package/dist/server/src/lib/db.js +18 -0
  89. package/dist/server/src/lib/response-poller.js +367 -19
  90. package/package.json +2 -1
  91. package/.next/server/chunks/9446.js +0 -1
  92. package/.next/static/chunks/app/login/page-2d42204ba87cd136.js +0 -1
  93. package/.next/static/chunks/app/worktrees/[id]/page-9c0c64488c17db3c.js +0 -1
  94. package/.next/static/chunks/webpack-3c0ee3ce5b546818.js +0 -1
  95. package/.next/static/css/fa3df0e6f437f2ba.css +0 -3
  96. /package/.next/static/{BiyH3zkbySg7ZWTeZuXqj → p3hosTZoJ22r35fWwUoLr}/_buildManifest.js +0 -0
  97. /package/.next/static/{BiyH3zkbySg7ZWTeZuXqj → p3hosTZoJ22r35fWwUoLr}/_ssgManifest.js +0 -0
@@ -13,7 +13,7 @@ exports.isValidVibeLocalContextWindow = isValidVibeLocalContextWindow;
13
13
  * T2.1: Single source of truth for CLI tool IDs
14
14
  * CLIToolType is derived from this constant (DRY principle)
15
15
  */
16
- exports.CLI_TOOL_IDS = ['claude', 'codex', 'gemini', 'vibe-local'];
16
+ exports.CLI_TOOL_IDS = ['claude', 'codex', 'gemini', 'vibe-local', 'opencode'];
17
17
  /**
18
18
  * CLI tool display names for UI rendering
19
19
  * Issue #368: Centralized display name mapping
@@ -26,6 +26,7 @@ exports.CLI_TOOL_DISPLAY_NAMES = {
26
26
  codex: 'Codex',
27
27
  gemini: 'Gemini',
28
28
  'vibe-local': 'Vibe Local',
29
+ opencode: 'OpenCode',
29
30
  };
30
31
  /**
31
32
  * Check if a string is a valid CLIToolType
@@ -99,10 +100,16 @@ function isValidVibeLocalContextWindow(value) {
99
100
  value <= exports.VIBE_LOCAL_CONTEXT_WINDOW_MAX);
100
101
  }
101
102
  /**
102
- * Ollama model name validation pattern.
103
- * Allows: alphanumeric start, followed by alphanumeric, dots, underscores, colons, slashes, hyphens.
104
- * Max 100 characters. Used for defense-in-depth validation at point of use.
103
+ * Ollama model name validation pattern (API/DB layer).
104
+ * Requires alphanumeric first character, followed by alphanumeric, dots, underscores,
105
+ * colons, slashes, hyphens. No explicit length limit (DB schema handles storage limits).
105
106
  *
106
- * [SEC-001] Shared between API route validation and CLI command construction
107
+ * [SEC-001] Shared between API route validation and CLI command construction.
108
+ *
109
+ * Note: opencode-config.ts has a separate OLLAMA_MODEL_PATTERN with a 100-character
110
+ * length limit (`{1,100}`) for DoS protection when parsing Ollama API responses.
111
+ * The patterns are intentionally different: this one enforces first-character constraints
112
+ * for user-facing validation, while the opencode-config version adds length limits
113
+ * for untrusted external API data.
107
114
  */
108
115
  exports.OLLAMA_MODEL_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/;
@@ -19,6 +19,7 @@ exports.getMessages = getMessages;
19
19
  exports.getLastUserMessage = getLastUserMessage;
20
20
  exports.getLastMessage = getLastMessage;
21
21
  exports.deleteAllMessages = deleteAllMessages;
22
+ exports.deleteMessageById = deleteMessageById;
22
23
  exports.deleteMessagesByCliTool = deleteMessagesByCliTool;
23
24
  exports.getSessionState = getSessionState;
24
25
  exports.updateSessionState = updateSessionState;
@@ -480,6 +481,23 @@ function deleteAllMessages(db, worktreeId) {
480
481
  stmt.run(worktreeId);
481
482
  console.log(`[deleteAllMessages] Deleted all messages for worktree: ${worktreeId}`);
482
483
  }
484
+ /**
485
+ * Delete a single message by its ID
486
+ * Used to clean up orphaned user messages (e.g., when a user re-sends a message
487
+ * after the previous one received no response).
488
+ *
489
+ * @param db - Database instance
490
+ * @param messageId - ID of the message to delete
491
+ * @returns True if a message was deleted, false otherwise
492
+ */
493
+ function deleteMessageById(db, messageId) {
494
+ const stmt = db.prepare(`
495
+ DELETE FROM chat_messages
496
+ WHERE id = ?
497
+ `);
498
+ const result = stmt.run(messageId);
499
+ return result.changes > 0;
500
+ }
483
501
  /**
484
502
  * Delete messages for a specific CLI tool in a worktree
485
503
  * Issue #4: T4.2 - Individual CLI tool session termination (MF3-001)
@@ -1,7 +1,7 @@
1
1
  "use strict";
2
2
  /**
3
3
  * CLI Tool response polling.
4
- * Periodically checks tmux sessions for CLI tool responses (Claude, Codex, Gemini).
4
+ * Periodically checks tmux sessions for CLI tool responses (Claude, Codex, Gemini, Vibe Local, OpenCode).
5
5
  *
6
6
  * Key responsibilities:
7
7
  * - Extract completed responses from tmux output (extractResponse)
@@ -13,10 +13,23 @@
13
13
  * - DR-004: Tail-line windowing for thinking detection in extractResponse
14
14
  * - MF-001 fix: Same windowing applied to checkForResponse thinking check
15
15
  * - SF-003: RESPONSE_THINKING_TAIL_LINE_COUNT constant tracks STATUS_THINKING_LINE_COUNT
16
+ *
17
+ * Issue #379 additions:
18
+ * - OpenCode completion detection via Build summary line (isOpenCodeComplete)
19
+ * - OpenCode response cleaning (cleanOpenCodeResponse)
20
+ * - OpenCode extraction stop conditions (OPENCODE_PROMPT_PATTERN, OPENCODE_PROMPT_AFTER_RESPONSE)
16
21
  */
17
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.extractTuiContentLines = extractTuiContentLines;
24
+ exports.findOverlapIndex = findOverlapIndex;
25
+ exports.initTuiAccumulator = initTuiAccumulator;
26
+ exports.accumulateTuiContent = accumulateTuiContent;
27
+ exports.getAccumulatedContent = getAccumulatedContent;
28
+ exports.clearTuiAccumulator = clearTuiAccumulator;
18
29
  exports.cleanClaudeResponse = cleanClaudeResponse;
19
30
  exports.cleanGeminiResponse = cleanGeminiResponse;
31
+ exports.isOpenCodeComplete = isOpenCodeComplete;
32
+ exports.cleanOpenCodeResponse = cleanOpenCodeResponse;
20
33
  exports.resolveExtractionStartIndex = resolveExtractionStartIndex;
21
34
  exports.startPolling = startPolling;
22
35
  exports.stopPolling = stopPolling;
@@ -101,6 +114,163 @@ function buildPromptExtractionResult(lines, lastCapturedLine, totalLines, buffer
101
114
  bufferReset,
102
115
  };
103
116
  }
117
+ /**
118
+ * Number of lines from the end of accumulated content to use as
119
+ * fingerprint for overlap detection with the next capture.
120
+ */
121
+ const OVERLAP_FINGERPRINT_SIZE = 10;
122
+ /**
123
+ * Per-session TUI response accumulator storage.
124
+ * Key: pollerKey ("worktreeId:cliToolId")
125
+ */
126
+ const tuiResponseAccumulator = new Map();
127
+ /**
128
+ * Normalize a single OpenCode TUI line by removing ANSI codes and border glyphs.
129
+ * Returns an empty string when the line has no meaningful content after cleanup.
130
+ */
131
+ function normalizeOpenCodeLine(line) {
132
+ return (0, cli_patterns_1.stripBoxDrawing)((0, cli_patterns_1.stripAnsi)(line))
133
+ .replace(/^\u2503\s?/, '')
134
+ .replace(/\s*\u2503$/, '')
135
+ .trim();
136
+ }
137
+ /**
138
+ * Extract meaningful content lines from raw TUI output.
139
+ * Strips ANSI codes, box-drawing characters, and lines matching OPENCODE_SKIP_PATTERNS.
140
+ *
141
+ * @param rawOutput - Raw tmux capture-pane output
142
+ * @returns Array of cleaned, non-empty content lines
143
+ *
144
+ * @internal Exported for unit testing
145
+ */
146
+ function extractTuiContentLines(rawOutput) {
147
+ const lines = rawOutput.split('\n');
148
+ const contentLines = [];
149
+ for (const line of lines) {
150
+ const cleaned = normalizeOpenCodeLine(line);
151
+ if (!cleaned)
152
+ continue;
153
+ const shouldSkip = cli_patterns_1.OPENCODE_SKIP_PATTERNS.some(pattern => pattern.test(cleaned));
154
+ if (shouldSkip)
155
+ continue;
156
+ // Also skip the Build summary line (completion indicator)
157
+ if (cli_patterns_1.OPENCODE_RESPONSE_COMPLETE.test(cleaned))
158
+ continue;
159
+ contentLines.push(cleaned);
160
+ }
161
+ return contentLines;
162
+ }
163
+ /**
164
+ * Find the overlap index between previously accumulated lines and newly captured lines.
165
+ * Searches for the longest suffix of `previous` that matches a prefix of `current`.
166
+ *
167
+ * @param previous - Previously accumulated lines (fingerprint subset)
168
+ * @param current - Newly captured content lines
169
+ * @returns Number of overlapping lines (0 if no overlap found)
170
+ *
171
+ * @internal Exported for unit testing
172
+ */
173
+ function findOverlapIndex(previous, current) {
174
+ if (previous.length === 0 || current.length === 0)
175
+ return 0;
176
+ // Try decreasing overlap sizes: full fingerprint down to 1 line
177
+ const maxOverlap = Math.min(previous.length, current.length);
178
+ for (let overlapSize = maxOverlap; overlapSize >= 1; overlapSize--) {
179
+ // Check if the last `overlapSize` lines of previous match
180
+ // the first `overlapSize` lines of current
181
+ const prevSlice = previous.slice(-overlapSize);
182
+ const currSlice = current.slice(0, overlapSize);
183
+ let matches = true;
184
+ for (let i = 0; i < overlapSize; i++) {
185
+ if (prevSlice[i] !== currSlice[i]) {
186
+ matches = false;
187
+ break;
188
+ }
189
+ }
190
+ if (matches) {
191
+ return overlapSize;
192
+ }
193
+ }
194
+ return 0;
195
+ }
196
+ /**
197
+ * Initialize the TUI accumulator for a polling session.
198
+ *
199
+ * @param pollerKey - Poller key ("worktreeId:cliToolId")
200
+ *
201
+ * @internal Exported for unit testing
202
+ */
203
+ function initTuiAccumulator(pollerKey) {
204
+ tuiResponseAccumulator.set(pollerKey, {
205
+ lines: [],
206
+ lastFingerprint: [],
207
+ pollCount: 0,
208
+ });
209
+ }
210
+ /**
211
+ * Accumulate TUI content from a new capture into the session buffer.
212
+ * Detects overlap with previous capture and appends only new lines.
213
+ *
214
+ * @param pollerKey - Poller key ("worktreeId:cliToolId")
215
+ * @param rawOutput - Raw tmux capture-pane output
216
+ *
217
+ * @internal Exported for unit testing
218
+ */
219
+ function accumulateTuiContent(pollerKey, rawOutput) {
220
+ const state = tuiResponseAccumulator.get(pollerKey);
221
+ if (!state)
222
+ return;
223
+ const contentLines = extractTuiContentLines(rawOutput);
224
+ if (contentLines.length === 0)
225
+ return;
226
+ state.pollCount++;
227
+ if (state.lines.length === 0) {
228
+ // First capture: seed with all content
229
+ state.lines = [...contentLines];
230
+ }
231
+ else {
232
+ // Subsequent captures: find overlap and append new lines
233
+ const overlapCount = findOverlapIndex(state.lastFingerprint, contentLines);
234
+ if (overlapCount > 0) {
235
+ // Append only lines after the overlap
236
+ const newLines = contentLines.slice(overlapCount);
237
+ state.lines.push(...newLines);
238
+ }
239
+ else {
240
+ // No overlap found: append all content (completeness over dedup)
241
+ state.lines.push(...contentLines);
242
+ }
243
+ }
244
+ // Update fingerprint for next poll
245
+ state.lastFingerprint = contentLines.slice(-OVERLAP_FINGERPRINT_SIZE);
246
+ }
247
+ /**
248
+ * Get the accumulated content as a single string.
249
+ *
250
+ * @param pollerKey - Poller key ("worktreeId:cliToolId")
251
+ * @returns Accumulated content joined by newlines, or empty string if no accumulator
252
+ *
253
+ * @internal Exported for unit testing
254
+ */
255
+ function getAccumulatedContent(pollerKey) {
256
+ const state = tuiResponseAccumulator.get(pollerKey);
257
+ if (!state || state.lines.length === 0)
258
+ return '';
259
+ return state.lines.join('\n');
260
+ }
261
+ /**
262
+ * Clear the TUI accumulator for a polling session.
263
+ *
264
+ * @param pollerKey - Poller key ("worktreeId:cliToolId")
265
+ *
266
+ * @internal Exported for unit testing
267
+ */
268
+ function clearTuiAccumulator(pollerKey) {
269
+ tuiResponseAccumulator.delete(pollerKey);
270
+ }
271
+ // ============================================================================
272
+ // Poller State Management
273
+ // ============================================================================
104
274
  /**
105
275
  * Active pollers map: "worktreeId:cliToolId" -> NodeJS.Timeout
106
276
  */
@@ -252,13 +422,91 @@ function cleanGeminiResponse(response) {
252
422
  }
253
423
  return cleanedLines.join('\n').trim();
254
424
  }
425
+ /**
426
+ * Check if OpenCode has completed its response.
427
+ * Detects the Build summary line pattern (e.g., "&#x25A3; Build . model . 2.5s").
428
+ * [D2-002] Independent completion detection for OpenCode.
429
+ *
430
+ * Unlike Claude (prompt + separator) or Codex/Gemini (prompt + not thinking),
431
+ * OpenCode signals completion via the Build summary line, which includes
432
+ * the model name and generation timing.
433
+ *
434
+ * @param output - Cleaned tmux output to check (ANSI-stripped)
435
+ * @returns True if OpenCode response is complete
436
+ *
437
+ * @internal Exported for unit testing (response-poller-opencode.test.ts)
438
+ */
439
+ function isOpenCodeComplete(output) {
440
+ // Must have a Build completion marker AND must NOT be actively processing.
441
+ // The "esc interrupt" indicator appears in the TUI footer during model processing.
442
+ // Without this check, old Build markers from previous Q&As cause false completions.
443
+ return cli_patterns_1.OPENCODE_RESPONSE_COMPLETE.test(output) && !cli_patterns_1.OPENCODE_PROCESSING_INDICATOR.test(output);
444
+ }
445
+ /**
446
+ * Clean OpenCode TUI response by removing decoration characters and status lines,
447
+ * and trimming to only the latest response.
448
+ * [D2-009] Removes box-drawing characters, Build summary, loading indicators,
449
+ * prompt patterns, and processing indicators.
450
+ *
451
+ * Cleaning pipeline:
452
+ * 1. Split response into lines
453
+ * 2. Trim to latest response: find Build markers (▣ Build · model · time)
454
+ * and discard all content before the second-to-last marker.
455
+ * OpenCode TUI accumulates conversation history; each Q&A exchange ends
456
+ * with a Build marker. Without this trimming, savePendingAssistantResponse
457
+ * and Layer 2 accumulator would include previous Q&As in the response.
458
+ * 3. Skip empty lines
459
+ * 4. Skip lines matching any OPENCODE_SKIP_PATTERNS (TUI artifacts)
460
+ * 5. Skip Build summary line (OPENCODE_RESPONSE_COMPLETE, the completion indicator)
461
+ * 6. Join remaining lines
462
+ *
463
+ * @param response - Raw OpenCode response (may contain TUI decoration)
464
+ * @returns Cleaned response with TUI artifacts removed
465
+ *
466
+ * @internal Exported for unit testing (response-poller-opencode.test.ts)
467
+ */
468
+ function cleanOpenCodeResponse(response) {
469
+ const lines = response.split('\n');
470
+ // Step 2: Trim to latest response by finding Build markers.
471
+ // Each Q&A exchange ends with "▣ Build · model · time".
472
+ // If 2+ markers exist, only include content after the second-to-last marker.
473
+ const buildIndices = [];
474
+ for (let i = 0; i < lines.length; i++) {
475
+ const cleanLine = normalizeOpenCodeLine(lines[i]);
476
+ if (cleanLine && cli_patterns_1.OPENCODE_RESPONSE_COMPLETE.test(cleanLine)) {
477
+ buildIndices.push(i);
478
+ }
479
+ }
480
+ let startLine = 0;
481
+ if (buildIndices.length >= 2) {
482
+ startLine = buildIndices[buildIndices.length - 2] + 1;
483
+ }
484
+ const cleanedLines = [];
485
+ for (let i = startLine; i < lines.length; i++) {
486
+ // Strip ANSI escape codes and TUI border characters before pattern matching.
487
+ // Without this, embedded ANSI codes and heavy borders can break regex matches.
488
+ const cleanLine = normalizeOpenCodeLine(lines[i]);
489
+ if (!cleanLine)
490
+ continue;
491
+ // Skip lines matching any OpenCode skip pattern
492
+ const shouldSkip = cli_patterns_1.OPENCODE_SKIP_PATTERNS.some(pattern => pattern.test(cleanLine));
493
+ if (shouldSkip)
494
+ continue;
495
+ // Skip the Build summary line (completion indicator)
496
+ if (cli_patterns_1.OPENCODE_RESPONSE_COMPLETE.test(cleanLine))
497
+ continue;
498
+ cleanedLines.push(cleanLine);
499
+ }
500
+ return cleanedLines.join('\n').trim();
501
+ }
255
502
  /**
256
503
  * Determine the start index for response extraction based on buffer state.
257
504
  * Shared between normal response extraction and prompt detection paths.
258
505
  *
259
- * Implements a 4-branch decision tree for startIndex determination:
506
+ * Implements a 5-branch decision tree for startIndex determination:
260
507
  * 1. bufferWasReset -> findRecentUserPromptIndex(40) + 1, or 0 if not found
261
- * 2. cliToolId === 'codex' -> Math.max(0, lastCapturedLine)
508
+ * 2a. cliToolId === 'opencode' -> findRecentUserPromptIndex(totalLines) + 1, or 0
509
+ * 2b. cliToolId === 'codex' -> Math.max(0, lastCapturedLine)
262
510
  * 3. lastCapturedLine >= totalLines - 5 (scroll boundary) ->
263
511
  * findRecentUserPromptIndex(50) + 1, or totalLines - 40 if not found
264
512
  * 4. Normal case -> Math.max(0, lastCapturedLine)
@@ -291,6 +539,15 @@ function cleanGeminiResponse(response) {
291
539
  function resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex) {
292
540
  // Defensive validation: clamp negative values to 0 (Stage 4 SF-001)
293
541
  lastCapturedLine = Math.max(0, lastCapturedLine);
542
+ // Branch 2a (highest priority for OpenCode): OpenCode runs in alternate screen mode
543
+ // (fixed-size buffer, no scrollback). lastCapturedLine is meaningless because the buffer
544
+ // doesn't grow — it's always ~PANE_HEIGHT lines. bufferWasReset is often true because
545
+ // lastCapturedLine ≈ totalLines. Must execute BEFORE Branch 1 to avoid Branch 1's small
546
+ // window (40 lines) which fails to find the second-to-last Build marker in a 200-line pane.
547
+ if (cliToolId === 'opencode') {
548
+ const foundUserPrompt = findRecentUserPromptIndex(totalLines);
549
+ return foundUserPrompt >= 0 ? foundUserPrompt + 1 : 0;
550
+ }
294
551
  // Compute bufferWasReset internally (MF-001: responsibility boundary)
295
552
  const bufferWasReset = lastCapturedLine >= totalLines || bufferReset;
296
553
  // Branch 1: Buffer was reset - find the most recent user prompt as anchor
@@ -298,7 +555,7 @@ function resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset,
298
555
  const foundUserPrompt = findRecentUserPromptIndex(40);
299
556
  return foundUserPrompt >= 0 ? foundUserPrompt + 1 : 0;
300
557
  }
301
- // Branch 2: Codex uses lastCapturedLine directly (Codex-specific TUI behavior)
558
+ // Branch 2b: Codex uses lastCapturedLine directly (Codex-specific TUI behavior)
302
559
  if (cliToolId === 'codex') {
303
560
  return Math.max(0, lastCapturedLine);
304
561
  }
@@ -339,18 +596,47 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
339
596
  if (!bufferReset && totalLines < lastCapturedLine - 5) {
340
597
  return null;
341
598
  }
342
- // Always check the last 20 lines for completion pattern (more robust than tracking line numbers)
599
+ // Check recent lines for completion pattern.
600
+ // OpenCode TUI: content area + many empty padding lines + status bar at bottom.
601
+ // The trailing-empty-line trim above removes trailing newlines but NOT the internal
602
+ // padding between content and status bar. For OpenCode, the Build completion marker
603
+ // can be far above the last 20 lines, so we must check the full buffer.
343
604
  const checkLineCount = 20;
344
605
  const startLine = Math.max(0, totalLines - checkLineCount);
345
606
  const linesToCheck = lines.slice(startLine);
346
- const outputToCheck = linesToCheck.join('\n');
607
+ const outputToCheck = cliToolId === 'opencode'
608
+ ? (0, cli_patterns_1.stripAnsi)(lines.join('\n'))
609
+ : linesToCheck.join('\n');
347
610
  // Get tool-specific patterns from shared module
348
611
  const { promptPattern, separatorPattern, thinkingPattern, skipPatterns } = (0, cli_patterns_1.getCliToolPatterns)(cliToolId);
349
612
  const findRecentUserPromptIndex = (windowSize = 60) => {
350
613
  // User prompt pattern: supports legacy '>' and new '❯' for Claude
351
- const userPromptPattern = cliToolId === 'codex'
352
- ? /^›\s+(?!Implement|Find and fix|Type|Summarize)/
353
- : /^[>❯]\s+\S/;
614
+ let userPromptPattern;
615
+ if (cliToolId === 'codex') {
616
+ userPromptPattern = /^›\s+(?!Implement|Find and fix|Type|Summarize)/;
617
+ }
618
+ else if (cliToolId === 'opencode') {
619
+ // OpenCode TUI accumulates conversation history in a single screen.
620
+ // Each Q&A exchange ends with a "▣ Build · model · time" marker.
621
+ // "Ask anything..." does NOT appear in tmux capture content area.
622
+ // Instead, find the SECOND-TO-LAST Build marker (= end of previous exchange)
623
+ // to use as the extraction boundary. The first Build marker found (searching
624
+ // backwards) is the current response's completion; the second is the boundary.
625
+ let buildCount = 0;
626
+ for (let i = totalLines - 1; i >= Math.max(0, totalLines - windowSize); i--) {
627
+ const cleanLine = (0, cli_patterns_1.stripAnsi)(lines[i]);
628
+ if (cli_patterns_1.OPENCODE_RESPONSE_COMPLETE.test(cleanLine)) {
629
+ buildCount++;
630
+ if (buildCount === 2) {
631
+ return i;
632
+ }
633
+ }
634
+ }
635
+ return -1;
636
+ }
637
+ else {
638
+ userPromptPattern = /^[>❯]\s+\S/;
639
+ }
354
640
  for (let i = totalLines - 1; i >= Math.max(0, totalLines - windowSize); i--) {
355
641
  const cleanLine = (0, cli_patterns_1.stripAnsi)(lines[i]);
356
642
  if (userPromptPattern.test(cleanLine)) {
@@ -387,7 +673,9 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
387
673
  // Claude: require both prompt and separator
388
674
  const isCodexOrGeminiComplete = (cliToolId === 'codex' || cliToolId === 'gemini' || cliToolId === 'vibe-local') && hasPrompt && !isThinking;
389
675
  const isClaudeComplete = cliToolId === 'claude' && hasPrompt && hasSeparator && !isThinking;
390
- if (isCodexOrGeminiComplete || isClaudeComplete) {
676
+ // [D2-002] OpenCode completion: detected via Build summary line pattern (independent of prompt/separator)
677
+ const isOpenCodeDone = cliToolId === 'opencode' && isOpenCodeComplete(cleanOutputToCheck);
678
+ if (isCodexOrGeminiComplete || isClaudeComplete || isOpenCodeDone) {
391
679
  // CLI tool has completed response
392
680
  // Extract the response content from lastCapturedLine to the separator (not just last 20 lines)
393
681
  const responseLines = [];
@@ -408,6 +696,13 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
408
696
  endIndex = i;
409
697
  break;
410
698
  }
699
+ // [D2-003] For OpenCode: stop at prompt or status bar patterns
700
+ if (cliToolId === 'opencode') {
701
+ if (cli_patterns_1.OPENCODE_PROMPT_PATTERN.test(cleanLine) || cli_patterns_1.OPENCODE_PROMPT_AFTER_RESPONSE.test(cleanLine)) {
702
+ endIndex = i;
703
+ break;
704
+ }
705
+ }
411
706
  // Skip lines matching any skip pattern (check against clean line)
412
707
  const shouldSkip = skipPatterns.some(pattern => pattern.test(cleanLine));
413
708
  if (shouldSkip) {
@@ -470,6 +765,21 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
470
765
  return incompleteResult(totalLines);
471
766
  }
472
767
  }
768
+ // OpenCode banner defense: initial startup screen should not be treated as a response
769
+ if (cliToolId === 'opencode') {
770
+ const cleanResponse = (0, cli_patterns_1.stripAnsi)(response);
771
+ // If the output is very short and contains only TUI elements, treat as startup banner
772
+ if (cleanResponse.length < 50 || !cli_patterns_1.OPENCODE_RESPONSE_COMPLETE.test(cleanOutputToCheck)) {
773
+ // Check if there's actual content (not just TUI decoration)
774
+ const contentLines = cleanResponse.split('\n').filter(line => {
775
+ const trimmed = line.trim();
776
+ return trimmed && !cli_patterns_1.OPENCODE_SKIP_PATTERNS.some(p => p.test(trimmed));
777
+ });
778
+ if (contentLines.length === 0) {
779
+ return incompleteResult(totalLines);
780
+ }
781
+ }
782
+ }
473
783
  return {
474
784
  response,
475
785
  isComplete: true,
@@ -479,12 +789,18 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
479
789
  }
480
790
  // Check if this is an interactive prompt (yes/no or multiple choice)
481
791
  // Interactive prompts don't have the ">" prompt and separator, so we need to detect them separately
482
- const fullOutput = lines.join('\n');
483
- const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
484
- if (promptDetection.isPrompt) {
485
- // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards
486
- // stripAnsi is applied inside buildPromptExtractionResult (Stage 4 MF-001: XSS risk mitigation)
487
- return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex, promptDetection);
792
+ // [Issue #379] Skip general prompt detection for OpenCode when response is incomplete.
793
+ // OpenCode's "Ask anything..." prompt pattern can cause false positive prompt detection
794
+ // when combined with user input text visible in the TUI buffer, leading to duplicate
795
+ // message creation. OpenCode prompts are only relevant when completion is detected above.
796
+ if (cliToolId !== 'opencode') {
797
+ const fullOutput = lines.join('\n');
798
+ const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
799
+ if (promptDetection.isPrompt) {
800
+ // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards
801
+ // stripAnsi is applied inside buildPromptExtractionResult (Stage 4 MF-001: XSS risk mitigation)
802
+ return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex, promptDetection);
803
+ }
488
804
  }
489
805
  // Not a prompt, but we may have a partial response in progress (even if Claude shows a spinner)
490
806
  const responseLines = [];
@@ -543,6 +859,11 @@ async function checkForResponse(worktreeId, cliToolId) {
543
859
  const lastCapturedLine = sessionState?.lastCapturedLine || 0;
544
860
  // Capture current output
545
861
  const output = await (0, cli_session_1.captureSessionOutput)(worktreeId, cliToolId, 10000);
862
+ // Layer 2: Accumulate TUI content for OpenCode (for overlap tracking only).
863
+ if (cliToolId === 'opencode') {
864
+ const pollerKey = getPollerKey(worktreeId, cliToolId);
865
+ accumulateTuiContent(pollerKey, output);
866
+ }
546
867
  // Extract response
547
868
  const result = extractResponse(output, lastCapturedLine, cliToolId);
548
869
  if (!result || !result.isComplete) {
@@ -564,17 +885,22 @@ async function checkForResponse(worktreeId, cliToolId) {
564
885
  }
565
886
  return false;
566
887
  }
888
+ // Issue #379: OpenCode uses a full-screen TUI with fixed buffer size (~200 lines).
889
+ // The tmux pane doesn't grow (no scrollback); each response overwrites the same pane,
890
+ // so lineCount is always approximately equal to lastCapturedLine. Skip line-based
891
+ // duplicate detection entirely for full-screen TUIs.
892
+ const isFullScreenTui = cliToolId === 'opencode';
567
893
  // CRITICAL FIX: If lineCount == lastCapturedLine AND there's no in-progress message,
568
894
  // this response has already been saved. Skip to prevent duplicates.
569
895
  // Issue #372: Skip when buffer reset detected (TUI redraw may coincidentally match lineCount).
570
- if (!result.bufferReset && result.lineCount === lastCapturedLine && !sessionState?.inProgressMessageId) {
896
+ if (!isFullScreenTui && !result.bufferReset && result.lineCount === lastCapturedLine && !sessionState?.inProgressMessageId) {
571
897
  return false;
572
898
  }
573
899
  // Additional duplicate prevention: check if savePendingAssistantResponse
574
900
  // already saved this content by comparing line counts.
575
901
  // Issue #372: Skip this check when buffer reset is detected (TUI redraw, screen clear).
576
902
  // Codex TUI redraws cause totalLines to shrink, making lineCount < lastCapturedLine.
577
- if (!result.bufferReset && result.lineCount <= lastCapturedLine) {
903
+ if (!result.bufferReset && !isFullScreenTui && result.lineCount <= lastCapturedLine) {
578
904
  console.log(`[checkForResponse] Already saved up to line ${lastCapturedLine}, skipping (result: ${result.lineCount})`);
579
905
  return false;
580
906
  }
@@ -611,6 +937,7 @@ async function checkForResponse(worktreeId, cliToolId) {
611
937
  ? (0, claude_output_1.parseClaudeOutput)(result.response)
612
938
  : undefined;
613
939
  // Clean up responses (remove shell prompts, setup commands, and errors)
940
+ // [D2-009] Each tool has its own clean function for tool-specific artifacts
614
941
  let cleanedResponse = result.response;
615
942
  if (cliToolId === 'gemini') {
616
943
  cleanedResponse = cleanGeminiResponse(result.response);
@@ -618,6 +945,14 @@ async function checkForResponse(worktreeId, cliToolId) {
618
945
  else if (cliToolId === 'claude') {
619
946
  cleanedResponse = cleanClaudeResponse(result.response);
620
947
  }
948
+ else if (cliToolId === 'opencode') {
949
+ cleanedResponse = cleanOpenCodeResponse(result.response);
950
+ // Clear accumulator for next response cycle (Layer 2 data not used for final content;
951
+ // accumulatedContent includes all past Q&A history from the fixed-size TUI, causing
952
+ // old responses to leak into the saved message even after cleanOpenCodeResponse trimming).
953
+ const pollerKey = getPollerKey(worktreeId, cliToolId);
954
+ clearTuiAccumulator(pollerKey);
955
+ }
621
956
  // If cleaned response is empty or just "[No content]", skip saving
622
957
  // This prevents creating messages for shell setup commands that get filtered out
623
958
  if (!cleanedResponse || cleanedResponse.trim() === '' || cleanedResponse === '[No content]') {
@@ -637,8 +972,9 @@ async function checkForResponse(worktreeId, cliToolId) {
637
972
  }
638
973
  // Race condition prevention: re-check session state before saving
639
974
  // savePendingAssistantResponse may have already saved this content concurrently
975
+ // Issue #379: Skip for OpenCode full-screen TUI (fixed buffer size, lineCount never advances)
640
976
  const currentSessionState = (0, db_1.getSessionState)(db, worktreeId, cliToolId);
641
- if (currentSessionState && result.lineCount <= currentSessionState.lastCapturedLine) {
977
+ if (!isFullScreenTui && currentSessionState && result.lineCount <= currentSessionState.lastCapturedLine) {
642
978
  console.log(`[checkForResponse] Race condition detected, skipping save (result: ${result.lineCount}, current: ${currentSessionState.lastCapturedLine})`);
643
979
  return false;
644
980
  }
@@ -659,6 +995,12 @@ async function checkForResponse(worktreeId, cliToolId) {
659
995
  (0, ws_server_1.broadcastMessage)('message', { worktreeId, message });
660
996
  // Update session state
661
997
  (0, db_1.updateSessionState)(db, worktreeId, cliToolId, result.lineCount);
998
+ // For full-screen TUIs (OpenCode), stop polling after saving the response.
999
+ // Line-count based duplicate prevention doesn't work because the pane size is fixed,
1000
+ // so lineCount never advances. Polling restarts when the user sends the next message.
1001
+ if (isFullScreenTui) {
1002
+ stopPolling(worktreeId, cliToolId);
1003
+ }
662
1004
  return true;
663
1005
  }
664
1006
  catch (error) {
@@ -684,6 +1026,10 @@ function startPolling(worktreeId, cliToolId) {
684
1026
  stopPolling(worktreeId, cliToolId);
685
1027
  // Record start time
686
1028
  pollingStartTimes.set(pollerKey, Date.now());
1029
+ // Initialize TUI accumulator for OpenCode (Layer 2 safety net)
1030
+ if (cliToolId === 'opencode') {
1031
+ initTuiAccumulator(pollerKey);
1032
+ }
687
1033
  // Start polling with setTimeout chain to prevent race conditions
688
1034
  scheduleNextResponsePoll(worktreeId, cliToolId);
689
1035
  }
@@ -731,6 +1077,8 @@ function stopPolling(worktreeId, cliToolId) {
731
1077
  activePollers.delete(pollerKey);
732
1078
  pollingStartTimes.delete(pollerKey);
733
1079
  }
1080
+ // Clean up TUI accumulator if present
1081
+ clearTuiAccumulator(pollerKey);
734
1082
  }
735
1083
  /**
736
1084
  * Stop all active pollers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commandmate",
3
- "version": "0.3.4",
3
+ "version": "0.3.5",
4
4
  "description": "Git worktree management with Claude CLI and tmux sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -65,6 +65,7 @@
65
65
  "react": "^18.3.0",
66
66
  "react-dom": "^18.3.0",
67
67
  "react-markdown": "^10.1.0",
68
+ "react-qr-code": "^2.0.18",
68
69
  "rehype-highlight": "^7.0.2",
69
70
  "rehype-sanitize": "^6.0.0",
70
71
  "remark-gfm": "^4.0.1",