commandmate 0.2.0 → 0.2.2

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 (109) 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 +2 -2
  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/index.pack +0 -0
  13. package/.next/cache/webpack/server-production/0.pack +0 -0
  14. package/.next/cache/webpack/server-production/index.pack +0 -0
  15. package/.next/next-server.js.nft.json +1 -1
  16. package/.next/prerender-manifest.json +1 -1
  17. package/.next/required-server-files.json +1 -1
  18. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  19. package/.next/server/app/_not-found.html +1 -1
  20. package/.next/server/app/_not-found.rsc +1 -1
  21. package/.next/server/app/api/external-apps/[id]/health/route.js.nft.json +1 -1
  22. package/.next/server/app/api/external-apps/[id]/route.js.nft.json +1 -1
  23. package/.next/server/app/api/external-apps/route.js.nft.json +1 -1
  24. package/.next/server/app/api/hooks/claude-done/route.js.nft.json +1 -1
  25. package/.next/server/app/api/repositories/clone/[jobId]/route.js.nft.json +1 -1
  26. package/.next/server/app/api/repositories/clone/route.js.nft.json +1 -1
  27. package/.next/server/app/api/repositories/excluded/route.js +8 -8
  28. package/.next/server/app/api/repositories/excluded/route.js.nft.json +1 -1
  29. package/.next/server/app/api/repositories/restore/route.js +7 -7
  30. package/.next/server/app/api/repositories/restore/route.js.nft.json +1 -1
  31. package/.next/server/app/api/repositories/route.js +5 -5
  32. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  33. package/.next/server/app/api/repositories/scan/route.js.nft.json +1 -1
  34. package/.next/server/app/api/repositories/sync/route.js +5 -5
  35. package/.next/server/app/api/repositories/sync/route.js.nft.json +1 -1
  36. package/.next/server/app/api/slash-commands.body +1 -1
  37. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  38. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
  39. package/.next/server/app/api/worktrees/[id]/capture/route.js +2 -2
  40. package/.next/server/app/api/worktrees/[id]/cli-tool/route.js.nft.json +1 -1
  41. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  42. package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
  43. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js.nft.json +1 -1
  44. package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
  45. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  46. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/[id]/logs/route.js.nft.json +1 -1
  48. package/.next/server/app/api/worktrees/[id]/memos/[memoId]/route.js.nft.json +1 -1
  49. package/.next/server/app/api/worktrees/[id]/memos/route.js.nft.json +1 -1
  50. package/.next/server/app/api/worktrees/[id]/messages/route.js.nft.json +1 -1
  51. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  52. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  53. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  54. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  55. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  56. package/.next/server/app/api/worktrees/[id]/search/route.js.nft.json +1 -1
  57. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  58. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js.nft.json +1 -1
  59. package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
  60. package/.next/server/app/api/worktrees/[id]/terminal/route.js +1 -1
  61. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js.nft.json +1 -1
  62. package/.next/server/app/api/worktrees/[id]/tree/route.js.nft.json +1 -1
  63. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js.nft.json +1 -1
  64. package/.next/server/app/api/worktrees/[id]/viewed/route.js.nft.json +1 -1
  65. package/.next/server/app/api/worktrees/route.js +1 -1
  66. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  67. package/.next/server/app/index.html +2 -2
  68. package/.next/server/app/index.rsc +2 -2
  69. package/.next/server/app/page_client-reference-manifest.js +1 -1
  70. package/.next/server/app/proxy/[...path]/route.js.nft.json +1 -1
  71. package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
  72. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app/worktrees/[id]/page.js +5 -5
  74. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/worktrees/[id]/simple-terminal/page_client-reference-manifest.js +1 -1
  76. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app-paths-manifest.json +9 -9
  78. package/.next/server/chunks/5488.js +5 -5
  79. package/.next/server/chunks/7536.js +1 -1
  80. package/.next/server/chunks/8174.js +6 -6
  81. package/.next/server/chunks/9367.js +2 -2
  82. package/.next/server/functions-config-manifest.json +1 -1
  83. package/.next/server/pages/404.html +1 -1
  84. package/.next/server/pages/500.html +1 -1
  85. package/.next/server/server-reference-manifest.json +1 -1
  86. package/.next/static/chunks/6568-c65d7e4d7db7655b.js +1 -0
  87. package/.next/static/chunks/{2957-327e43ef4c12808f.js → 9325-9e98829c1e75f42f.js} +1 -1
  88. package/.next/static/chunks/app/worktrees/[id]/files/[...path]/{page-9e5adf57cbbbdf05.js → page-7eb14f8043796805.js} +1 -1
  89. package/.next/static/chunks/app/worktrees/[id]/page-912c3c4c66821d99.js +1 -0
  90. package/.next/static/css/d4b58a1129eff6af.css +3 -0
  91. package/.next/trace +5 -5
  92. package/dist/server/server.js +25 -2
  93. package/dist/server/src/config/auto-yes-config.js +53 -0
  94. package/dist/server/src/lib/auto-yes-manager.js +94 -11
  95. package/dist/server/src/lib/claude-poller.js +4 -0
  96. package/dist/server/src/lib/claude-session.js +54 -19
  97. package/dist/server/src/lib/cli-patterns.js +100 -4
  98. package/dist/server/src/lib/cli-tools/codex.js +16 -3
  99. package/dist/server/src/lib/db-repository.js +482 -0
  100. package/dist/server/src/lib/pasted-text-helper.js +58 -0
  101. package/dist/server/src/lib/prompt-detector.js +199 -109
  102. package/dist/server/src/lib/response-poller.js +74 -27
  103. package/dist/server/src/lib/tmux.js +48 -0
  104. package/package.json +1 -1
  105. package/.next/static/chunks/6568-38a33aa67d82e12b.js +0 -1
  106. package/.next/static/chunks/app/worktrees/[id]/page-d64624eb67af57c0.js +0 -1
  107. package/.next/static/css/28be35e4727ae7ef.css +0 -3
  108. /package/.next/static/{bdUePCj-b9Gv5okYGp49O → HhG0EHeG9E4wTJ4sqnLdv}/_buildManifest.js +0 -0
  109. /package/.next/static/{bdUePCj-b9Gv5okYGp49O → HhG0EHeG9E4wTJ4sqnLdv}/_ssgManifest.js +0 -0
@@ -8,6 +8,25 @@ exports.detectPrompt = detectPrompt;
8
8
  exports.getAnswerInput = getAnswerInput;
9
9
  const logger_1 = require("./logger");
10
10
  const logger = (0, logger_1.createLogger)('prompt-detector');
11
+ /**
12
+ * Yes/no pattern definitions for data-driven matching.
13
+ * Each entry defines a regex pattern and its associated default option.
14
+ * Patterns are evaluated in order; the first match wins.
15
+ *
16
+ * Pattern format:
17
+ * - regex: Must have a capture group (1) for the question text
18
+ * - defaultOption: 'yes', 'no', or undefined (no default)
19
+ */
20
+ const YES_NO_PATTERNS = [
21
+ // (y/n) - no default
22
+ { regex: /^(.+)\s+\(y\/n\)\s*$/m },
23
+ // [y/N] - default no
24
+ { regex: /^(.+)\s+\[y\/N\]\s*$/m, defaultOption: 'no' },
25
+ // [Y/n] - default yes
26
+ { regex: /^(.+)\s+\[Y\/n\]\s*$/m, defaultOption: 'yes' },
27
+ // (yes/no) - no default
28
+ { regex: /^(.+)\s+\(yes\/no\)\s*$/m },
29
+ ];
11
30
  /**
12
31
  * Detect if output contains an interactive prompt
13
32
  *
@@ -29,7 +48,7 @@ const logger = (0, logger_1.createLogger)('prompt-detector');
29
48
  * // result.promptData.question === 'Do you want to proceed?'
30
49
  * ```
31
50
  */
32
- function detectPrompt(output) {
51
+ function detectPrompt(output, options) {
33
52
  logger.debug('detectPrompt:start', { outputLength: output.length });
34
53
  const lines = output.split('\n');
35
54
  const lastLines = lines.slice(-10).join('\n');
@@ -39,7 +58,7 @@ function detectPrompt(output) {
39
58
  // ❯ 1. Yes
40
59
  // 2. No
41
60
  // 3. Cancel
42
- const multipleChoiceResult = detectMultipleChoicePrompt(output);
61
+ const multipleChoiceResult = detectMultipleChoicePrompt(output, options);
43
62
  if (multipleChoiceResult.isPrompt) {
44
63
  logger.info('detectPrompt:multipleChoice', {
45
64
  isPrompt: true,
@@ -48,74 +67,30 @@ function detectPrompt(output) {
48
67
  });
49
68
  return multipleChoiceResult;
50
69
  }
51
- // Pattern 1: (y/n)
52
- const yesNoPattern = /^(.+)\s+\(y\/n\)\s*$/m;
53
- const match1 = lastLines.match(yesNoPattern);
54
- if (match1) {
55
- return {
56
- isPrompt: true,
57
- promptData: {
58
- type: 'yes_no',
59
- question: match1[1].trim(),
60
- options: ['yes', 'no'],
61
- status: 'pending',
62
- },
63
- cleanContent: match1[1].trim(),
64
- };
65
- }
66
- // Pattern 2: [y/N] (N is default)
67
- const yesNoDefaultPattern = /^(.+)\s+\[y\/N\]\s*$/m;
68
- const match2 = lastLines.match(yesNoDefaultPattern);
69
- if (match2) {
70
- return {
71
- isPrompt: true,
72
- promptData: {
73
- type: 'yes_no',
74
- question: match2[1].trim(),
75
- options: ['yes', 'no'],
76
- status: 'pending',
77
- defaultOption: 'no',
78
- },
79
- cleanContent: match2[1].trim(),
80
- };
81
- }
82
- // Pattern 3: [Y/n] (Y is default)
83
- const yesDefaultPattern = /^(.+)\s+\[Y\/n\]\s*$/m;
84
- const match3 = lastLines.match(yesDefaultPattern);
85
- if (match3) {
86
- return {
87
- isPrompt: true,
88
- promptData: {
89
- type: 'yes_no',
90
- question: match3[1].trim(),
91
- options: ['yes', 'no'],
92
- status: 'pending',
93
- defaultOption: 'yes',
94
- },
95
- cleanContent: match3[1].trim(),
96
- };
97
- }
98
- // Pattern 4: (yes/no)
99
- const yesNoFullPattern = /^(.+)\s+\(yes\/no\)\s*$/m;
100
- const match4 = lastLines.match(yesNoFullPattern);
101
- if (match4) {
102
- return {
103
- isPrompt: true,
104
- promptData: {
105
- type: 'yes_no',
106
- question: match4[1].trim(),
107
- options: ['yes', 'no'],
108
- status: 'pending',
109
- },
110
- cleanContent: match4[1].trim(),
111
- };
70
+ // Patterns 1-4: Yes/no patterns (data-driven matching)
71
+ for (const pattern of YES_NO_PATTERNS) {
72
+ const match = lastLines.match(pattern.regex);
73
+ if (match) {
74
+ const question = match[1].trim();
75
+ return {
76
+ isPrompt: true,
77
+ promptData: {
78
+ type: 'yes_no',
79
+ question,
80
+ options: ['yes', 'no'],
81
+ status: 'pending',
82
+ ...(pattern.defaultOption !== undefined && { defaultOption: pattern.defaultOption }),
83
+ },
84
+ cleanContent: question,
85
+ };
86
+ }
112
87
  }
113
88
  // Pattern 5: Approve?
114
89
  // Matches "Approve?" on its own line or at the end of a line
115
90
  const approvePattern = /^(.*?)Approve\?\s*$/m;
116
- const match5 = lastLines.match(approvePattern);
117
- if (match5) {
118
- const content = match5[1].trim();
91
+ const approveMatch = lastLines.match(approvePattern);
92
+ if (approveMatch) {
93
+ const content = approveMatch[1].trim();
119
94
  // If there's content before "Approve?", include it in the question
120
95
  const question = content ? `${content} Approve?` : 'Approve?';
121
96
  return {
@@ -159,6 +134,87 @@ const DEFAULT_OPTION_PATTERN = /^\s*\u276F\s*(\d+)\.\s*(.+)$/;
159
134
  * Anchored at both ends -- ReDoS safe (S4-001).
160
135
  */
161
136
  const NORMAL_OPTION_PATTERN = /^\s*(\d+)\.\s*(.+)$/;
137
+ /**
138
+ * Pattern for separator lines (horizontal rules).
139
+ * Matches lines consisting only of dash (-) or em-dash (─) characters.
140
+ * Used to skip separator lines in question extraction and non-option line handling.
141
+ * Anchored at both ends -- ReDoS safe (S4-001).
142
+ */
143
+ const SEPARATOR_LINE_PATTERN = /^[-─]+$/;
144
+ /**
145
+ * Creates a "no prompt detected" result.
146
+ * Centralizes the repeated pattern of returning isPrompt: false with trimmed content.
147
+ *
148
+ * @param output - The original output text
149
+ * @returns PromptDetectionResult with isPrompt: false
150
+ */
151
+ function noPromptResult(output) {
152
+ return {
153
+ isPrompt: false,
154
+ cleanContent: output.trim(),
155
+ };
156
+ }
157
+ /**
158
+ * Pattern for detecting question/selection keywords in question lines.
159
+ * CLI tools typically use these keywords in the line immediately before numbered choices.
160
+ *
161
+ * Keyword classification:
162
+ * [Observed] select, choose, pick, which, what, enter, confirm
163
+ * - Keywords confirmed in actual Claude Code / CLI tool prompts.
164
+ * [Defensive additions] how, where, type, specify, approve, accept, reject, decide, preference, option
165
+ * - Not yet observed in actual prompts, but commonly used in question sentences.
166
+ * Added defensively to reduce False Negative risk.
167
+ * - Slightly beyond YAGNI, but False Positive risk from these keywords is
168
+ * extremely low (they rarely appear in normal list headings).
169
+ * - Consider removing unused keywords if confirmed unnecessary in the future.
170
+ *
171
+ * No word boundaries (\b) used -- partial matches (e.g., "Selections:" matching "select")
172
+ * are acceptable because such headings followed by consecutive numbered lists are
173
+ * likely actual prompts. See design policy IC-004 for tradeoff analysis.
174
+ *
175
+ * Alternation-only pattern with no nested quantifiers -- ReDoS safe (SEC-S4-002).
176
+ * The pattern consists only of OR (alternation) within a non-capturing group,
177
+ * resulting in a linear-time structure (O(n)) with no backtracking risk.
178
+ * Follows the 'ReDoS safe (S4-001)' annotation convention of existing patterns.
179
+ */
180
+ const QUESTION_KEYWORD_PATTERN = /(?:select|choose|pick|which|what|how|where|enter|type|specify|confirm|approve|accept|reject|decide|preference|option)/i;
181
+ /**
182
+ * Validates whether a question line actually asks a question or requests a selection.
183
+ * Distinguishes normal heading lines ("Recommendations:", "Steps:", etc.) from
184
+ * actual question lines ("Which option?", "Select a mode:", etc.).
185
+ *
186
+ * Control character resilience (SEC-S4-004): The line parameter is passed via
187
+ * lines[questionEndIndex]?.trim(), so residual control characters from tmux
188
+ * capture-pane output (8-bit CSI (0x9B), DEC private modes, etc. not fully
189
+ * removed by stripAnsi()) may be present. However, endsWith('?') / endsWith(':')
190
+ * inspect only the last character, and QUESTION_KEYWORD_PATTERN.test() matches
191
+ * only English letter keywords, so residual control characters will not match
192
+ * any pattern and the function returns false (false-safe).
193
+ *
194
+ * Full-width colon (U+FF1A) is intentionally not supported. Claude Code/CLI
195
+ * prompts use ASCII colon. See design policy IC-008.
196
+ *
197
+ * @param line - The line to validate (trimmed)
198
+ * @returns true if the line is a question/selection request, false otherwise
199
+ */
200
+ function isQuestionLikeLine(line) {
201
+ // Empty lines are not questions
202
+ if (line.length === 0)
203
+ return false;
204
+ // Pattern 1: Lines ending with question mark (English or full-width Japanese)
205
+ // Full-width question mark (U+FF1F) support is a defensive measure: Claude Code/CLI
206
+ // displays questions in English, but this covers future multi-language support
207
+ // and third-party tool integration.
208
+ if (line.endsWith('?') || line.endsWith('\uff1f'))
209
+ return true;
210
+ // Pattern 2: Lines ending with colon that contain a selection/input keyword
211
+ // Examples: "Select an option:", "Choose a mode:", "Pick one:"
212
+ if (line.endsWith(':')) {
213
+ if (QUESTION_KEYWORD_PATTERN.test(line))
214
+ return true;
215
+ }
216
+ return false;
217
+ }
162
218
  /**
163
219
  * Defensive check: protection against future unknown false positive patterns.
164
220
  * Note: The actual false positive pattern in Issue #161 ("1. Create file\n2. Run tests")
@@ -196,7 +252,11 @@ function isConsecutiveFromOne(numbers) {
196
252
  * }
197
253
  *
198
254
  * Each condition's responsibility:
199
- * - hasLeadingSpaces: Indented non-option line (label text wrapping with indentation)
255
+ * - hasLeadingSpaces: Indented non-option line (label text wrapping with indentation).
256
+ * Excludes lines ending with '?' to prevent question lines (e.g., " Do you want
257
+ * to proceed?") from being misclassified as continuation. Claude Bash tool outputs
258
+ * question and options with identical 2-space indentation, so this exclusion allows
259
+ * the question line to be recognized as questionEndIndex instead of being skipped.
200
260
  * - isShortFragment: Short fragment (< 5 chars, e.g., filename tail)
201
261
  * - isPathContinuation: Path string continuation (Issue #181)
202
262
  *
@@ -205,13 +265,19 @@ function isConsecutiveFromOne(numbers) {
205
265
  * @returns true if the line should be treated as a continuation of a previous option
206
266
  */
207
267
  function isContinuationLine(rawLine, line) {
208
- // Indented non-option line
209
- const hasLeadingSpaces = rawLine.match(/^\s{2,}[^\d]/) && !rawLine.match(/^\s*\d+\./);
268
+ // Indented non-option line.
269
+ // Excludes lines ending with '?' or '?' (U+FF1F) because those are typically question lines
270
+ // (e.g., " Do you want to proceed?", " コピーしたい対象はどれですか?") from CLI tool output
271
+ // where both the question and options are 2-space indented. Without this exclusion,
272
+ // the question line would be misclassified as a continuation line, causing
273
+ // questionEndIndex to remain -1 and Layer 5 SEC-001 to block detection.
274
+ const endsWithQuestion = line.endsWith('?') || line.endsWith('\uff1f');
275
+ const hasLeadingSpaces = rawLine.match(/^\s{2,}[^\d]/) && !rawLine.match(/^\s*\d+\./) && !endsWithQuestion;
210
276
  // Short fragment (< 5 chars, excluding question-ending lines)
211
- const isShortFragment = line.length < 5 && !line.endsWith('?');
277
+ const isShortFragment = line.length < 5 && !endsWithQuestion;
212
278
  // Path string continuation: lines starting with / or ~, or alphanumeric-only fragments (2+ chars)
213
279
  const isPathContinuation = /^[\/~]/.test(line) || (line.length >= 2 && /^[a-zA-Z0-9_-]+$/.test(line));
214
- return !!(hasLeadingSpaces) || isShortFragment || isPathContinuation;
280
+ return !!hasLeadingSpaces || isShortFragment || isPathContinuation;
215
281
  }
216
282
  /**
217
283
  * Detect multiple choice prompts (numbered list with ❯ indicator)
@@ -233,42 +299,51 @@ function isContinuationLine(rawLine, line) {
233
299
  * @param output - The tmux output to analyze (typically captured from tmux pane)
234
300
  * @returns Detection result with prompt data if a valid multiple choice prompt is found
235
301
  */
236
- function detectMultipleChoicePrompt(output) {
302
+ function detectMultipleChoicePrompt(output, options) {
303
+ // C-003: Use ?? true for readability instead of !== false double negation
304
+ const requireDefault = options?.requireDefaultIndicator ?? true;
237
305
  const lines = output.split('\n');
238
- // Calculate scan window: last 50 lines
239
- const scanStart = Math.max(0, lines.length - 50);
306
+ // Strip trailing empty lines (tmux terminal padding) before computing scan window.
307
+ // tmux buffers often end with many empty padding lines that would shift the
308
+ // scan window away from the actual prompt content.
309
+ let effectiveEnd = lines.length;
310
+ while (effectiveEnd > 0 && lines[effectiveEnd - 1].trim() === '') {
311
+ effectiveEnd--;
312
+ }
313
+ // Calculate scan window: last 50 non-trailing-empty lines
314
+ const scanStart = Math.max(0, effectiveEnd - 50);
240
315
  // ==========================================================================
241
316
  // Pass 1: Check for ❯ indicator existence in scan window
242
- // If no ❯ lines found, there is no multiple_choice prompt.
317
+ // If no ❯ lines found and requireDefault is true, there is no multiple_choice prompt.
318
+ // When requireDefault is false, skip this gate entirely to allow ❯-less detection.
243
319
  // ==========================================================================
244
- let hasDefaultLine = false;
245
- for (let i = scanStart; i < lines.length; i++) {
246
- const line = lines[i].trim();
247
- if (DEFAULT_OPTION_PATTERN.test(line)) {
248
- hasDefaultLine = true;
249
- break;
320
+ if (requireDefault) {
321
+ let hasDefaultLine = false;
322
+ for (let i = scanStart; i < effectiveEnd; i++) {
323
+ const line = lines[i].trim();
324
+ if (DEFAULT_OPTION_PATTERN.test(line)) {
325
+ hasDefaultLine = true;
326
+ break;
327
+ }
328
+ }
329
+ if (!hasDefaultLine) {
330
+ return noPromptResult(output);
250
331
  }
251
- }
252
- if (!hasDefaultLine) {
253
- return {
254
- isPrompt: false,
255
- cleanContent: output.trim(),
256
- };
257
332
  }
258
333
  // ==========================================================================
259
- // Pass 2: Collect options (only executed when was found in Pass 1)
334
+ // Pass 2: Collect options (executed when Pass 1 passes or is skipped)
260
335
  // Scan from end to find options, using both patterns.
261
336
  // ==========================================================================
262
- const options = [];
337
+ const collectedOptions = [];
263
338
  let questionEndIndex = -1;
264
- for (let i = lines.length - 1; i >= scanStart; i--) {
339
+ for (let i = effectiveEnd - 1; i >= scanStart; i--) {
265
340
  const line = lines[i].trim();
266
341
  // Try DEFAULT_OPTION_PATTERN first (❯ indicator)
267
342
  const defaultMatch = line.match(DEFAULT_OPTION_PATTERN);
268
343
  if (defaultMatch) {
269
344
  const number = parseInt(defaultMatch[1], 10);
270
345
  const label = defaultMatch[2].trim();
271
- options.unshift({ number, label, isDefault: true });
346
+ collectedOptions.unshift({ number, label, isDefault: true });
272
347
  continue;
273
348
  }
274
349
  // Try NORMAL_OPTION_PATTERN (no ❯ indicator)
@@ -276,11 +351,11 @@ function detectMultipleChoicePrompt(output) {
276
351
  if (normalMatch) {
277
352
  const number = parseInt(normalMatch[1], 10);
278
353
  const label = normalMatch[2].trim();
279
- options.unshift({ number, label, isDefault: false });
354
+ collectedOptions.unshift({ number, label, isDefault: false });
280
355
  continue;
281
356
  }
282
357
  // Non-option line handling
283
- if (options.length > 0 && line && !line.match(/^[-─]+$/)) {
358
+ if (collectedOptions.length > 0 && line && !SEPARATOR_LINE_PATTERN.test(line)) {
284
359
  // Check if this is a continuation line (indented line between options,
285
360
  // or path/filename fragments from terminal width wrapping - Issue #181)
286
361
  const rawLine = lines[i]; // Original line with indentation preserved
@@ -294,20 +369,33 @@ function detectMultipleChoicePrompt(output) {
294
369
  }
295
370
  }
296
371
  // Layer 3: Consecutive number validation (defensive measure)
297
- const optionNumbers = options.map(opt => opt.number);
372
+ const optionNumbers = collectedOptions.map(opt => opt.number);
298
373
  if (!isConsecutiveFromOne(optionNumbers)) {
299
- return {
300
- isPrompt: false,
301
- cleanContent: output.trim(),
302
- };
374
+ return noPromptResult(output);
303
375
  }
304
- // Layer 4: Must have at least 2 options AND at least one with ❯ indicator
305
- const hasDefaultIndicator = options.some(opt => opt.isDefault);
306
- if (options.length < 2 || !hasDefaultIndicator) {
307
- return {
308
- isPrompt: false,
309
- cleanContent: output.trim(),
310
- };
376
+ // Layer 4: Must have at least 2 options. When requireDefault is true,
377
+ // also require at least one option with ❯ indicator.
378
+ const hasDefaultIndicator = collectedOptions.some(opt => opt.isDefault);
379
+ if (collectedOptions.length < 2 || (requireDefault && !hasDefaultIndicator)) {
380
+ return noPromptResult(output);
381
+ }
382
+ // Layer 5 [SEC-001]: Enhanced question line validation for requireDefaultIndicator=false.
383
+ // When requireDefault is false, apply stricter validation to prevent false positives
384
+ // from normal numbered lists (e.g., "Recommendations:\n1. Add tests\n2. Update docs").
385
+ if (!requireDefault) {
386
+ // SEC-001a: No question line found (questionEndIndex === -1) - reject.
387
+ // Prevents generic question fallback from triggering Auto-Yes
388
+ // on plain numbered lists that happen to be consecutive from 1.
389
+ if (questionEndIndex === -1) {
390
+ return noPromptResult(output);
391
+ }
392
+ // SEC-001b: Question line exists but is not actually a question/selection request.
393
+ // Validates that the question line contains a question mark or a selection keyword
394
+ // with colon, distinguishing "Select an option:" from "Recommendations:".
395
+ const questionLine = lines[questionEndIndex]?.trim() ?? '';
396
+ if (!isQuestionLikeLine(questionLine)) {
397
+ return noPromptResult(output);
398
+ }
311
399
  }
312
400
  // Extract question text
313
401
  let question = '';
@@ -316,7 +404,7 @@ function detectMultipleChoicePrompt(output) {
316
404
  const questionLines = [];
317
405
  for (let i = Math.max(0, questionEndIndex - 5); i <= questionEndIndex; i++) {
318
406
  const line = lines[i].trim();
319
- if (line && !line.match(/^[-─]+$/)) {
407
+ if (line && !SEPARATOR_LINE_PATTERN.test(line)) {
320
408
  questionLines.push(line);
321
409
  }
322
410
  }
@@ -331,7 +419,7 @@ function detectMultipleChoicePrompt(output) {
331
419
  promptData: {
332
420
  type: 'multiple_choice',
333
421
  question: question.trim(),
334
- options: options.map(opt => {
422
+ options: collectedOptions.map(opt => {
335
423
  // Check if this option requires text input using module-level patterns
336
424
  const requiresTextInput = TEXT_INPUT_PATTERNS.some(pattern => pattern.test(opt.label));
337
425
  return {
@@ -370,7 +458,8 @@ function getAnswerInput(answer, promptType = 'yes_no') {
370
458
  if (/^\d+$/.test(normalized)) {
371
459
  return normalized;
372
460
  }
373
- throw new Error(`Invalid answer for multiple choice: ${answer}. Expected a number.`);
461
+ // SEC-003: Fixed error message without user input to prevent log injection
462
+ throw new Error('Invalid answer for multiple choice prompt. Expected a number.');
374
463
  }
375
464
  // Handle yes/no prompts
376
465
  if (normalized === 'yes' || normalized === 'y') {
@@ -379,5 +468,6 @@ function getAnswerInput(answer, promptType = 'yes_no') {
379
468
  if (normalized === 'no' || normalized === 'n') {
380
469
  return 'n';
381
470
  }
382
- throw new Error(`Invalid answer: ${answer}. Expected 'yes', 'no', 'y', or 'n'.`);
471
+ // SEC-003: Fixed error message without user input to prevent log injection
472
+ throw new Error("Invalid answer for yes/no prompt. Expected 'yes', 'no', 'y', or 'n'.");
383
473
  }
@@ -1,7 +1,18 @@
1
1
  "use strict";
2
2
  /**
3
- * CLI Tool response polling
4
- * Periodically checks tmux sessions for CLI tool responses (Claude, Codex, Gemini)
3
+ * CLI Tool response polling.
4
+ * Periodically checks tmux sessions for CLI tool responses (Claude, Codex, Gemini).
5
+ *
6
+ * Key responsibilities:
7
+ * - Extract completed responses from tmux output (extractResponse)
8
+ * - Detect interactive prompts and save as prompt messages
9
+ * - Clean tool-specific artifacts from response content
10
+ * - Manage polling lifecycle (start/stop/timeout)
11
+ *
12
+ * Issue #188 improvements:
13
+ * - DR-004: Tail-line windowing for thinking detection in extractResponse
14
+ * - MF-001 fix: Same windowing applied to checkForResponse thinking check
15
+ * - SF-003: RESPONSE_THINKING_TAIL_LINE_COUNT constant tracks STATUS_THINKING_LINE_COUNT
5
16
  */
6
17
  Object.defineProperty(exports, "__esModule", { value: true });
7
18
  exports.cleanClaudeResponse = cleanClaudeResponse;
@@ -26,6 +37,22 @@ const POLLING_INTERVAL = 2000;
26
37
  * Maximum polling duration in milliseconds (default: 5 minutes)
27
38
  */
28
39
  const MAX_POLLING_DURATION = 5 * 60 * 1000;
40
+ /**
41
+ * Number of tail lines to check for active thinking indicators in response extraction.
42
+ *
43
+ * SF-003 coupling note: This value must track STATUS_THINKING_LINE_COUNT (5) in
44
+ * status-detector.ts. Both constants exist because they serve separate modules:
45
+ * - RESPONSE_THINKING_TAIL_LINE_COUNT: response-poller.ts (response extraction)
46
+ * - STATUS_THINKING_LINE_COUNT: status-detector.ts (UI status display)
47
+ * If STATUS_THINKING_LINE_COUNT changes, update this value accordingly.
48
+ *
49
+ * Why not a shared constant? status-detector.ts has no dependency on response-poller.ts
50
+ * and vice versa. Introducing a shared module would create a coupling that does not
51
+ * currently exist. The test suite validates consistency (SF-003 test).
52
+ *
53
+ * @constant
54
+ */
55
+ const RESPONSE_THINKING_TAIL_LINE_COUNT = 5;
29
56
  /**
30
57
  * Active pollers map: "worktreeId:cliToolId" -> NodeJS.Timeout
31
58
  */
@@ -40,6 +67,26 @@ const pollingStartTimes = new Map();
40
67
  function getPollerKey(worktreeId, cliToolId) {
41
68
  return `${worktreeId}:${cliToolId}`;
42
69
  }
70
+ /**
71
+ * Internal helper: detect prompt with CLI-tool-specific options.
72
+ *
73
+ * Centralizes the stripAnsi() + buildDetectPromptOptions() + detectPrompt() pipeline
74
+ * to avoid repeating this 3-step sequence across extractResponse() and checkForResponse().
75
+ *
76
+ * Design notes:
77
+ * - IA-001: stripAnsi() is applied uniformly inside this helper. It is idempotent,
78
+ * so double-application on already-stripped input is safe.
79
+ * - SF-003: Uses buildDetectPromptOptions() from cli-patterns.ts for tool-specific
80
+ * configuration (e.g., Claude's requireDefaultIndicator=false for Issue #193).
81
+ *
82
+ * @param output - Raw or pre-stripped tmux output
83
+ * @param cliToolId - CLI tool identifier for building detection options
84
+ * @returns PromptDetectionResult with isPrompt, promptData, and cleanContent
85
+ */
86
+ function detectPromptWithOptions(output, cliToolId) {
87
+ const promptOptions = (0, cli_patterns_1.buildDetectPromptOptions)(cliToolId);
88
+ return (0, prompt_detector_1.detectPrompt)((0, cli_patterns_1.stripAnsi)(output), promptOptions);
89
+ }
43
90
  /**
44
91
  * Clean up Claude response by removing shell setup commands, environment exports, ANSI codes, and banner
45
92
  * Also extracts only the LATEST response to avoid including conversation history
@@ -92,6 +139,7 @@ function cleanClaudeResponse(response) {
92
139
  /\?\s*for shortcuts\s*$/, // Shortcuts hint at end of line
93
140
  /^─{10,}$/, // Separator lines
94
141
  /^❯\s*$/, // Empty prompt lines
142
+ cli_patterns_1.PASTED_TEXT_PATTERN, // [Pasted text #N +XX lines] (Issue #212)
95
143
  ];
96
144
  // Filter out UI elements and keep only the response content
97
145
  const cleanedLines = [];
@@ -209,14 +257,12 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
209
257
  // Permission prompts appear after normal responses and need special handling
210
258
  if (cliToolId === 'claude') {
211
259
  const fullOutput = lines.join('\n');
212
- // Strip ANSI codes before prompt detection
213
- const cleanFullOutput = (0, cli_patterns_1.stripAnsi)(fullOutput);
214
- const promptDetection = (0, prompt_detector_1.detectPrompt)(cleanFullOutput);
260
+ const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
215
261
  if (promptDetection.isPrompt) {
216
262
  // Return the full output as a complete interactive prompt
217
263
  // Use the cleaned output without ANSI codes
218
264
  return {
219
- response: cleanFullOutput,
265
+ response: (0, cli_patterns_1.stripAnsi)(fullOutput),
220
266
  isComplete: true,
221
267
  lineCount: totalLines,
222
268
  };
@@ -285,9 +331,10 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
285
331
  responseLines.push(line);
286
332
  }
287
333
  const response = responseLines.join('\n').trim();
288
- // Additional check: ensure response doesn't contain thinking indicators
289
- // This prevents saving intermediate states as final responses
290
- if (thinkingPattern.test(response)) {
334
+ // DR-004: Check only the tail of the response for thinking indicators.
335
+ // Prevents false blocking when completed thinking summaries appear in the response body.
336
+ const responseTailLines = response.split('\n').slice(-RESPONSE_THINKING_TAIL_LINE_COUNT).join('\n');
337
+ if (thinkingPattern.test(responseTailLines)) {
291
338
  return {
292
339
  response: '',
293
340
  isComplete: false,
@@ -347,18 +394,13 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
347
394
  lineCount: totalLines,
348
395
  };
349
396
  }
350
- // Check for auth/loading states that should not be treated as complete responses
351
- if (response.includes('Waiting for auth') ||
352
- response.includes('⠋') ||
353
- response.includes('') ||
354
- response.includes('⠹') ||
355
- response.includes('⠸') ||
356
- response.includes('⠼') ||
357
- response.includes('⠴') ||
358
- response.includes('⠦') ||
359
- response.includes('⠧') ||
360
- response.includes('⠇') ||
361
- response.includes('⠏')) {
397
+ // Check for auth/loading states that should not be treated as complete responses.
398
+ // Braille spinner characters are shared with CLAUDE_SPINNER_CHARS in cli-patterns.ts.
399
+ const LOADING_INDICATORS = [
400
+ 'Waiting for auth',
401
+ '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏',
402
+ ];
403
+ if (LOADING_INDICATORS.some(indicator => response.includes(indicator))) {
362
404
  return {
363
405
  response: '',
364
406
  isComplete: false,
@@ -382,7 +424,7 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
382
424
  // Check if this is an interactive prompt (yes/no or multiple choice)
383
425
  // Interactive prompts don't have the ">" prompt and separator, so we need to detect them separately
384
426
  const fullOutput = lines.join('\n');
385
- const promptDetection = (0, prompt_detector_1.detectPrompt)(fullOutput);
427
+ const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
386
428
  if (promptDetection.isPrompt) {
387
429
  // This is an interactive prompt - consider it complete
388
430
  return {
@@ -454,12 +496,17 @@ async function checkForResponse(worktreeId, cliToolId) {
454
496
  // Extract response
455
497
  const result = extractResponse(output, lastCapturedLine, cliToolId);
456
498
  if (!result || !result.isComplete) {
457
- // No new output or response not yet complete
458
- // But if Claude is processing (thinking), mark any pending prompts as answered
459
- // This handles cases where user responded to prompts directly via terminal
499
+ // No new output or response not yet complete.
500
+ // If CLI tool is actively thinking, mark any pending prompts as answered.
501
+ // This handles cases where user responded to prompts directly via terminal.
502
+ //
503
+ // DR-004 windowing: Only check tail lines (same as extractResponse thinking check)
504
+ // to avoid false matches on completed thinking summaries in scrollback.
505
+ // Previously (MF-001), full-text check caused false positives.
460
506
  const { thinkingPattern } = (0, cli_patterns_1.getCliToolPatterns)(cliToolId);
461
507
  const cleanOutput = (0, cli_patterns_1.stripAnsi)(output);
462
- if (thinkingPattern.test(cleanOutput)) {
508
+ const tailLines = cleanOutput.split('\n').slice(-RESPONSE_THINKING_TAIL_LINE_COUNT).join('\n');
509
+ if (thinkingPattern.test(tailLines)) {
463
510
  const answeredCount = (0, db_1.markPendingPromptsAsAnswered)(db, worktreeId, cliToolId);
464
511
  if (answeredCount > 0) {
465
512
  console.log(`Marked ${answeredCount} pending prompt(s) as answered (thinking detected) for ${worktreeId}`);
@@ -479,7 +526,7 @@ async function checkForResponse(worktreeId, cliToolId) {
479
526
  return false;
480
527
  }
481
528
  // Response is complete! Check if it's a prompt
482
- const promptDetection = (0, prompt_detector_1.detectPrompt)(result.response);
529
+ const promptDetection = detectPromptWithOptions(result.response, cliToolId);
483
530
  if (promptDetection.isPrompt) {
484
531
  // This is a prompt - save as prompt message
485
532
  (0, db_1.clearInProgressMessageId)(db, worktreeId, cliToolId);
@@ -9,6 +9,7 @@ exports.hasSession = hasSession;
9
9
  exports.listSessions = listSessions;
10
10
  exports.createSession = createSession;
11
11
  exports.sendKeys = sendKeys;
12
+ exports.sendSpecialKeys = sendSpecialKeys;
12
13
  exports.capturePane = capturePane;
13
14
  exports.killSession = killSession;
14
15
  exports.ensureSession = ensureSession;
@@ -168,6 +169,53 @@ async function sendKeys(sessionName, keys, sendEnter = true) {
168
169
  throw new Error(`Failed to send keys to tmux session: ${errorMessage}`);
169
170
  }
170
171
  }
172
+ /**
173
+ * Allowed tmux special key names for sendSpecialKeys().
174
+ * Restricts input to prevent command injection via arbitrary tmux key names.
175
+ */
176
+ const ALLOWED_SPECIAL_KEYS = new Set([
177
+ 'Up', 'Down', 'Left', 'Right',
178
+ 'Enter', 'Space', 'Tab', 'Escape',
179
+ 'BSpace', 'DC', // Backspace, Delete
180
+ ]);
181
+ /** Delay between individual key presses for TUI apps that need processing time (ms). */
182
+ const SPECIAL_KEY_DELAY_MS = 100;
183
+ /**
184
+ * Send tmux special keys (unquoted key names like Down, Up, Enter, Space).
185
+ * Used for cursor-based navigation in CLI tool prompts (e.g., Claude Code AskUserQuestion).
186
+ *
187
+ * Keys are sent one at a time with a short delay between each press,
188
+ * because ink-based TUI apps (like Claude Code) need time to process
189
+ * each keystroke before the next one arrives.
190
+ *
191
+ * @param sessionName - Target session name
192
+ * @param keys - Array of tmux special key names (e.g., ['Down', 'Down', 'Space', 'Enter'])
193
+ * @throws {Error} If any key name is not in the allowed set, or if tmux command fails
194
+ */
195
+ async function sendSpecialKeys(sessionName, keys) {
196
+ if (keys.length === 0)
197
+ return;
198
+ // Validate all keys are in the allowed set (command injection prevention)
199
+ for (const key of keys) {
200
+ if (!ALLOWED_SPECIAL_KEYS.has(key)) {
201
+ throw new Error(`Invalid special key: ${key}`);
202
+ }
203
+ }
204
+ try {
205
+ for (let i = 0; i < keys.length; i++) {
206
+ const command = `tmux send-keys -t "${sessionName}" ${keys[i]}`;
207
+ await execAsync(command, { timeout: DEFAULT_TIMEOUT });
208
+ // Delay between key presses (skip after the last key)
209
+ if (i < keys.length - 1) {
210
+ await new Promise(resolve => setTimeout(resolve, SPECIAL_KEY_DELAY_MS));
211
+ }
212
+ }
213
+ }
214
+ catch (error) {
215
+ const errorMessage = error instanceof Error ? error.message : String(error);
216
+ throw new Error(`Failed to send special keys to tmux session: ${errorMessage}`);
217
+ }
218
+ }
171
219
  /**
172
220
  * Capture pane output from a tmux session
173
221
  *