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,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;
@@ -90,15 +103,174 @@ function incompleteResult(lineCount) {
90
103
  * @param findRecentUserPromptIndex - Callback to locate the most recent user prompt
91
104
  * @returns ExtractionResult with isComplete: true and ANSI-stripped response
92
105
  */
93
- function buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex) {
106
+ function buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex, promptDetection) {
94
107
  const startIndex = resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
95
108
  const extractedLines = lines.slice(startIndex);
96
109
  return {
97
110
  response: (0, cli_patterns_1.stripAnsi)(extractedLines.join('\n')),
98
111
  isComplete: true,
99
112
  lineCount: totalLines,
113
+ promptDetection,
114
+ bufferReset,
100
115
  };
101
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
+ // ============================================================================
102
274
  /**
103
275
  * Active pollers map: "worktreeId:cliToolId" -> NodeJS.Timeout
104
276
  */
@@ -250,13 +422,91 @@ function cleanGeminiResponse(response) {
250
422
  }
251
423
  return cleanedLines.join('\n').trim();
252
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
+ }
253
502
  /**
254
503
  * Determine the start index for response extraction based on buffer state.
255
504
  * Shared between normal response extraction and prompt detection paths.
256
505
  *
257
- * Implements a 4-branch decision tree for startIndex determination:
506
+ * Implements a 5-branch decision tree for startIndex determination:
258
507
  * 1. bufferWasReset -> findRecentUserPromptIndex(40) + 1, or 0 if not found
259
- * 2. cliToolId === 'codex' -> Math.max(0, lastCapturedLine)
508
+ * 2a. cliToolId === 'opencode' -> findRecentUserPromptIndex(totalLines) + 1, or 0
509
+ * 2b. cliToolId === 'codex' -> Math.max(0, lastCapturedLine)
260
510
  * 3. lastCapturedLine >= totalLines - 5 (scroll boundary) ->
261
511
  * findRecentUserPromptIndex(50) + 1, or totalLines - 40 if not found
262
512
  * 4. Normal case -> Math.max(0, lastCapturedLine)
@@ -289,6 +539,15 @@ function cleanGeminiResponse(response) {
289
539
  function resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex) {
290
540
  // Defensive validation: clamp negative values to 0 (Stage 4 SF-001)
291
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
+ }
292
551
  // Compute bufferWasReset internally (MF-001: responsibility boundary)
293
552
  const bufferWasReset = lastCapturedLine >= totalLines || bufferReset;
294
553
  // Branch 1: Buffer was reset - find the most recent user prompt as anchor
@@ -296,7 +555,7 @@ function resolveExtractionStartIndex(lastCapturedLine, totalLines, bufferReset,
296
555
  const foundUserPrompt = findRecentUserPromptIndex(40);
297
556
  return foundUserPrompt >= 0 ? foundUserPrompt + 1 : 0;
298
557
  }
299
- // Branch 2: Codex uses lastCapturedLine directly (Codex-specific TUI behavior)
558
+ // Branch 2b: Codex uses lastCapturedLine directly (Codex-specific TUI behavior)
300
559
  if (cliToolId === 'codex') {
301
560
  return Math.max(0, lastCapturedLine);
302
561
  }
@@ -337,18 +596,47 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
337
596
  if (!bufferReset && totalLines < lastCapturedLine - 5) {
338
597
  return null;
339
598
  }
340
- // 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.
341
604
  const checkLineCount = 20;
342
605
  const startLine = Math.max(0, totalLines - checkLineCount);
343
606
  const linesToCheck = lines.slice(startLine);
344
- const outputToCheck = linesToCheck.join('\n');
607
+ const outputToCheck = cliToolId === 'opencode'
608
+ ? (0, cli_patterns_1.stripAnsi)(lines.join('\n'))
609
+ : linesToCheck.join('\n');
345
610
  // Get tool-specific patterns from shared module
346
611
  const { promptPattern, separatorPattern, thinkingPattern, skipPatterns } = (0, cli_patterns_1.getCliToolPatterns)(cliToolId);
347
612
  const findRecentUserPromptIndex = (windowSize = 60) => {
348
613
  // User prompt pattern: supports legacy '>' and new '❯' for Claude
349
- const userPromptPattern = cliToolId === 'codex'
350
- ? /^›\s+(?!Implement|Find and fix|Type|Summarize)/
351
- : /^[>❯]\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
+ }
352
640
  for (let i = totalLines - 1; i >= Math.max(0, totalLines - windowSize); i--) {
353
641
  const cleanLine = (0, cli_patterns_1.stripAnsi)(lines[i]);
354
642
  if (userPromptPattern.test(cleanLine)) {
@@ -357,14 +645,20 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
357
645
  }
358
646
  return -1;
359
647
  };
360
- // Early check for Claude permission prompts (before extraction logic)
361
- // Permission prompts appear after normal responses and need special handling
362
- if (cliToolId === 'claude') {
648
+ // Early check for interactive prompts (before extraction logic)
649
+ // Permission prompts appear after normal responses and need special handling.
650
+ // Issue #372: Codex command confirmation prompts ( 1. Yes, proceed) match
651
+ // CODEX_PROMPT_PATTERN, causing isCodexOrGeminiComplete to fire prematurely.
652
+ // Early detection ensures prompt options are preserved in the extraction result.
653
+ if (cliToolId === 'claude' || cliToolId === 'codex') {
363
654
  const fullOutput = lines.join('\n');
364
655
  const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
365
656
  if (promptDetection.isPrompt) {
366
- // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards
367
- return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
657
+ // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards.
658
+ // Issue #372: Carry promptDetection through ExtractionResult so checkForResponse()
659
+ // can use it directly, avoiding a second detection on the (potentially truncated)
660
+ // extracted portion which may miss the › indicator line.
661
+ return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex, promptDetection);
368
662
  }
369
663
  }
370
664
  // Strip ANSI codes before pattern matching
@@ -379,7 +673,9 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
379
673
  // Claude: require both prompt and separator
380
674
  const isCodexOrGeminiComplete = (cliToolId === 'codex' || cliToolId === 'gemini' || cliToolId === 'vibe-local') && hasPrompt && !isThinking;
381
675
  const isClaudeComplete = cliToolId === 'claude' && hasPrompt && hasSeparator && !isThinking;
382
- 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) {
383
679
  // CLI tool has completed response
384
680
  // Extract the response content from lastCapturedLine to the separator (not just last 20 lines)
385
681
  const responseLines = [];
@@ -400,6 +696,13 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
400
696
  endIndex = i;
401
697
  break;
402
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
+ }
403
706
  // Skip lines matching any skip pattern (check against clean line)
404
707
  const shouldSkip = skipPatterns.some(pattern => pattern.test(cleanLine));
405
708
  if (shouldSkip) {
@@ -462,20 +765,42 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
462
765
  return incompleteResult(totalLines);
463
766
  }
464
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
+ }
465
783
  return {
466
784
  response,
467
785
  isComplete: true,
468
786
  lineCount: endIndex, // Use endIndex instead of totalLines to track where we actually stopped
787
+ bufferReset,
469
788
  };
470
789
  }
471
790
  // Check if this is an interactive prompt (yes/no or multiple choice)
472
791
  // Interactive prompts don't have the ">" prompt and separator, so we need to detect them separately
473
- const fullOutput = lines.join('\n');
474
- const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
475
- if (promptDetection.isPrompt) {
476
- // Prompt detection uses full buffer for accuracy, but return only lastCapturedLine onwards
477
- // stripAnsi is applied inside buildPromptExtractionResult (Stage 4 MF-001: XSS risk mitigation)
478
- return buildPromptExtractionResult(lines, lastCapturedLine, totalLines, bufferReset, cliToolId, findRecentUserPromptIndex);
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
+ }
479
804
  }
480
805
  // Not a prompt, but we may have a partial response in progress (even if Claude shows a spinner)
481
806
  const responseLines = [];
@@ -525,6 +850,7 @@ async function checkForResponse(worktreeId, cliToolId) {
525
850
  // Check if CLI tool session is running
526
851
  const running = await (0, cli_session_1.isSessionRunning)(worktreeId, cliToolId);
527
852
  if (!running) {
853
+ console.log(`[checkForResponse] Session not running for ${worktreeId} (${cliToolId}), stopping poller`);
528
854
  stopPolling(worktreeId, cliToolId);
529
855
  return false;
530
856
  }
@@ -533,6 +859,11 @@ async function checkForResponse(worktreeId, cliToolId) {
533
859
  const lastCapturedLine = sessionState?.lastCapturedLine || 0;
534
860
  // Capture current output
535
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
+ }
536
867
  // Extract response
537
868
  const result = extractResponse(output, lastCapturedLine, cliToolId);
538
869
  if (!result || !result.isComplete) {
@@ -554,19 +885,30 @@ async function checkForResponse(worktreeId, cliToolId) {
554
885
  }
555
886
  return false;
556
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';
557
893
  // CRITICAL FIX: If lineCount == lastCapturedLine AND there's no in-progress message,
558
894
  // this response has already been saved. Skip to prevent duplicates.
559
- if (result.lineCount === lastCapturedLine && !sessionState?.inProgressMessageId) {
895
+ // Issue #372: Skip when buffer reset detected (TUI redraw may coincidentally match lineCount).
896
+ if (!isFullScreenTui && !result.bufferReset && result.lineCount === lastCapturedLine && !sessionState?.inProgressMessageId) {
560
897
  return false;
561
898
  }
562
899
  // Additional duplicate prevention: check if savePendingAssistantResponse
563
- // already saved this content by comparing line counts
564
- if (result.lineCount <= lastCapturedLine) {
900
+ // already saved this content by comparing line counts.
901
+ // Issue #372: Skip this check when buffer reset is detected (TUI redraw, screen clear).
902
+ // Codex TUI redraws cause totalLines to shrink, making lineCount < lastCapturedLine.
903
+ if (!result.bufferReset && !isFullScreenTui && result.lineCount <= lastCapturedLine) {
565
904
  console.log(`[checkForResponse] Already saved up to line ${lastCapturedLine}, skipping (result: ${result.lineCount})`);
566
905
  return false;
567
906
  }
568
- // Response is complete! Check if it's a prompt
569
- const promptDetection = detectPromptWithOptions(result.response, cliToolId);
907
+ // Response is complete! Check if it's a prompt.
908
+ // Issue #372: Prefer the prompt detection carried from extractResponse() early check,
909
+ // which uses the full tmux output for accuracy. The extracted portion (result.response)
910
+ // may be truncated and miss the › indicator line when lastCapturedLine falls just before it.
911
+ const promptDetection = result.promptDetection ?? detectPromptWithOptions(result.response, cliToolId);
570
912
  if (promptDetection.isPrompt) {
571
913
  // This is a prompt - save as prompt message
572
914
  (0, db_1.clearInProgressMessageId)(db, worktreeId, cliToolId);
@@ -595,6 +937,7 @@ async function checkForResponse(worktreeId, cliToolId) {
595
937
  ? (0, claude_output_1.parseClaudeOutput)(result.response)
596
938
  : undefined;
597
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
598
941
  let cleanedResponse = result.response;
599
942
  if (cliToolId === 'gemini') {
600
943
  cleanedResponse = cleanGeminiResponse(result.response);
@@ -602,6 +945,14 @@ async function checkForResponse(worktreeId, cliToolId) {
602
945
  else if (cliToolId === 'claude') {
603
946
  cleanedResponse = cleanClaudeResponse(result.response);
604
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
+ }
605
956
  // If cleaned response is empty or just "[No content]", skip saving
606
957
  // This prevents creating messages for shell setup commands that get filtered out
607
958
  if (!cleanedResponse || cleanedResponse.trim() === '' || cleanedResponse === '[No content]') {
@@ -621,8 +972,9 @@ async function checkForResponse(worktreeId, cliToolId) {
621
972
  }
622
973
  // Race condition prevention: re-check session state before saving
623
974
  // savePendingAssistantResponse may have already saved this content concurrently
975
+ // Issue #379: Skip for OpenCode full-screen TUI (fixed buffer size, lineCount never advances)
624
976
  const currentSessionState = (0, db_1.getSessionState)(db, worktreeId, cliToolId);
625
- if (currentSessionState && result.lineCount <= currentSessionState.lastCapturedLine) {
977
+ if (!isFullScreenTui && currentSessionState && result.lineCount <= currentSessionState.lastCapturedLine) {
626
978
  console.log(`[checkForResponse] Race condition detected, skipping save (result: ${result.lineCount}, current: ${currentSessionState.lastCapturedLine})`);
627
979
  return false;
628
980
  }
@@ -643,6 +995,12 @@ async function checkForResponse(worktreeId, cliToolId) {
643
995
  (0, ws_server_1.broadcastMessage)('message', { worktreeId, message });
644
996
  // Update session state
645
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
+ }
646
1004
  return true;
647
1005
  }
648
1006
  catch (error) {
@@ -668,6 +1026,10 @@ function startPolling(worktreeId, cliToolId) {
668
1026
  stopPolling(worktreeId, cliToolId);
669
1027
  // Record start time
670
1028
  pollingStartTimes.set(pollerKey, Date.now());
1029
+ // Initialize TUI accumulator for OpenCode (Layer 2 safety net)
1030
+ if (cliToolId === 'opencode') {
1031
+ initTuiAccumulator(pollerKey);
1032
+ }
671
1033
  // Start polling with setTimeout chain to prevent race conditions
672
1034
  scheduleNextResponsePoll(worktreeId, cliToolId);
673
1035
  }
@@ -715,6 +1077,8 @@ function stopPolling(worktreeId, cliToolId) {
715
1077
  activePollers.delete(pollerKey);
716
1078
  pollingStartTimes.delete(pollerKey);
717
1079
  }
1080
+ // Clean up TUI accumulator if present
1081
+ clearTuiAccumulator(pollerKey);
718
1082
  }
719
1083
  /**
720
1084
  * Stop all active pollers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commandmate",
3
- "version": "0.3.3",
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",
@@ -88,14 +89,14 @@
88
89
  "@types/uuid": "^10.0.0",
89
90
  "@types/ws": "^8.18.1",
90
91
  "@vitejs/plugin-react": "^5.1.1",
91
- "@vitest/coverage-v8": "^4.0.16",
92
- "@vitest/ui": "^4.0.9",
92
+ "@vitest/coverage-v8": "^4.0.18",
93
+ "@vitest/ui": "^4.0.18",
93
94
  "eslint": "^8.57.0",
94
95
  "eslint-config-next": "^14.2.35",
95
96
  "tailwindcss": "^3.4.18",
96
97
  "tsc-alias": "~1.8.16",
97
98
  "tsx": "^4.20.6",
98
99
  "typescript": "^5.5.0",
99
- "vitest": "^4.0.16"
100
+ "vitest": "^4.0.18"
100
101
  }
101
102
  }