commandmate 0.1.12 → 0.2.1

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 (163) hide show
  1. package/.env.example +4 -9
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/app-build-manifest.json +24 -24
  4. package/.next/app-path-routes-manifest.json +1 -1
  5. package/.next/build-manifest.json +7 -7
  6. package/.next/cache/.tsbuildinfo +1 -1
  7. package/.next/cache/config.json +3 -3
  8. package/.next/cache/webpack/client-production/0.pack +0 -0
  9. package/.next/cache/webpack/client-production/1.pack +0 -0
  10. package/.next/cache/webpack/client-production/2.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack +0 -0
  12. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  13. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  14. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  15. package/.next/cache/webpack/server-production/0.pack +0 -0
  16. package/.next/cache/webpack/server-production/index.pack +0 -0
  17. package/.next/next-server.js.nft.json +1 -1
  18. package/.next/prerender-manifest.json +1 -1
  19. package/.next/react-loadable-manifest.json +7 -7
  20. package/.next/required-server-files.json +1 -1
  21. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/server/app/_not-found.html +1 -1
  23. package/.next/server/app/_not-found.rsc +2 -2
  24. package/.next/server/app/api/hooks/claude-done/route.js +1 -19
  25. package/.next/server/app/api/hooks/claude-done/route.js.nft.json +1 -1
  26. package/.next/server/app/api/repositories/clone/[jobId]/route.js +1 -1
  27. package/.next/server/app/api/repositories/clone/[jobId]/route.js.nft.json +1 -1
  28. package/.next/server/app/api/repositories/clone/route.js +1 -1
  29. package/.next/server/app/api/repositories/clone/route.js.nft.json +1 -1
  30. package/.next/server/app/api/repositories/excluded/route.js +36 -0
  31. package/.next/server/app/api/repositories/excluded/route.js.nft.json +1 -0
  32. package/.next/server/app/api/repositories/excluded.body +1 -0
  33. package/.next/server/app/api/repositories/excluded.meta +1 -0
  34. package/.next/server/app/api/repositories/restore/route.js +36 -0
  35. package/.next/server/app/api/repositories/restore/route.js.nft.json +1 -0
  36. package/.next/server/app/api/repositories/route.js +36 -1
  37. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  38. package/.next/server/app/api/repositories/scan/route.js +1 -1
  39. package/.next/server/app/api/repositories/sync/route.js +36 -1
  40. package/.next/server/app/api/slash-commands/route.js +1 -1
  41. package/.next/server/app/api/slash-commands.body +1 -1
  42. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  43. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
  44. package/.next/server/app/api/worktrees/[id]/capture/route.js +2 -2
  45. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  46. package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  48. package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
  49. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  50. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  51. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  52. package/.next/server/app/api/worktrees/[id]/logs/route.js +1 -1
  53. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  54. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  55. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  56. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  57. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  58. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  59. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  60. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  61. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  62. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  63. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  64. package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
  65. package/.next/server/app/api/worktrees/[id]/terminal/route.js +1 -1
  66. package/.next/server/app/api/worktrees/route.js +1 -1
  67. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  68. package/.next/server/app/index.html +2 -2
  69. package/.next/server/app/index.rsc +3 -3
  70. package/.next/server/app/page.js +7 -7
  71. package/.next/server/app/page.js.nft.json +1 -1
  72. package/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app/proxy/[...path]/route.js +2 -2
  74. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  75. package/.next/server/app/worktrees/[id]/page.js +4 -4
  76. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  77. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  78. package/.next/server/app/worktrees/[id]/simple-terminal/page_client-reference-manifest.js +1 -1
  79. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  80. package/.next/server/app-paths-manifest.json +10 -8
  81. package/.next/server/chunks/5488.js +36 -0
  82. package/.next/server/chunks/6550.js +1 -1
  83. package/.next/server/chunks/7425.js +53 -50
  84. package/.next/server/chunks/7536.js +1 -0
  85. package/.next/server/chunks/8174.js +23 -0
  86. package/.next/server/chunks/9367.js +19 -0
  87. package/.next/server/middleware-build-manifest.js +1 -1
  88. package/.next/server/middleware-manifest.json +2 -28
  89. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  90. package/.next/server/pages/404.html +1 -1
  91. package/.next/server/pages/500.html +1 -1
  92. package/.next/server/server-reference-manifest.json +1 -1
  93. package/.next/static/chunks/4327.740cc7fe2d0b5049.js +60 -0
  94. package/.next/static/chunks/4343-ebe884a2a80eb033.js +1 -0
  95. package/.next/static/chunks/6568-38a33aa67d82e12b.js +1 -0
  96. package/.next/static/chunks/816-c254f4e2406e696a.js +1 -0
  97. package/.next/static/chunks/app/layout-4804cfba519283cf.js +1 -0
  98. package/.next/static/chunks/app/page-3926224c4cdf315b.js +1 -0
  99. package/.next/static/chunks/app/worktrees/[id]/page-8bd88bdc29607413.js +1 -0
  100. package/.next/static/chunks/main-b6d727aa9248d4f2.js +1 -0
  101. package/.next/static/chunks/{webpack-3fc79fab9bb738d7.js → webpack-4f85dcef6279c6ee.js} +1 -1
  102. package/.next/static/css/28be35e4727ae7ef.css +3 -0
  103. package/.next/trace +5 -5
  104. package/.next/types/app/api/repositories/excluded/route.ts +343 -0
  105. package/.next/types/app/api/repositories/restore/route.ts +343 -0
  106. package/README.md +2 -2
  107. package/dist/cli/commands/init.d.ts.map +1 -1
  108. package/dist/cli/commands/init.js +2 -13
  109. package/dist/cli/commands/start.d.ts.map +1 -1
  110. package/dist/cli/commands/start.js +3 -7
  111. package/dist/cli/config/security-messages.d.ts +11 -0
  112. package/dist/cli/config/security-messages.d.ts.map +1 -0
  113. package/dist/cli/config/security-messages.js +29 -0
  114. package/dist/cli/types/index.d.ts +0 -1
  115. package/dist/cli/types/index.d.ts.map +1 -1
  116. package/dist/cli/utils/daemon.d.ts.map +1 -1
  117. package/dist/cli/utils/daemon.js +3 -7
  118. package/dist/cli/utils/env-setup.d.ts +0 -4
  119. package/dist/cli/utils/env-setup.d.ts.map +1 -1
  120. package/dist/cli/utils/env-setup.js +0 -14
  121. package/dist/cli/utils/security-logger.d.ts.map +1 -1
  122. package/dist/cli/utils/security-logger.js +1 -2
  123. package/dist/server/server.js +25 -2
  124. package/dist/server/src/lib/auto-yes-manager.js +100 -11
  125. package/dist/server/src/lib/claude-poller.js +341 -0
  126. package/dist/server/src/lib/claude-session.js +48 -19
  127. package/dist/server/src/lib/cli-patterns.js +69 -6
  128. package/dist/server/src/lib/cli-tools/base.js +7 -1
  129. package/dist/server/src/lib/cli-tools/codex.js +14 -2
  130. package/dist/server/src/lib/cli-tools/manager.js +27 -0
  131. package/dist/server/src/lib/cli-tools/types.js +7 -0
  132. package/dist/server/src/lib/cli-tools/validation.js +41 -0
  133. package/dist/server/src/lib/db-repository.js +482 -0
  134. package/dist/server/src/lib/db.js +23 -0
  135. package/dist/server/src/lib/env.js +0 -17
  136. package/dist/server/src/lib/logger.js +0 -4
  137. package/dist/server/src/lib/prompt-detector.js +297 -109
  138. package/dist/server/src/lib/response-poller.js +73 -27
  139. package/dist/server/src/lib/tmux.js +48 -0
  140. package/dist/server/src/lib/ws-server.js +12 -1
  141. package/dist/server/src/types/sidebar.js +16 -31
  142. package/dist/server/src/types/slash-commands.js +2 -0
  143. package/package.json +1 -1
  144. package/.next/server/chunks/1318.js +0 -29
  145. package/.next/server/chunks/2597.js +0 -1
  146. package/.next/server/chunks/2648.js +0 -1
  147. package/.next/server/chunks/9703.js +0 -31
  148. package/.next/server/chunks/9723.js +0 -19
  149. package/.next/server/edge-runtime-webpack.js +0 -2
  150. package/.next/server/edge-runtime-webpack.js.map +0 -1
  151. package/.next/server/src/middleware.js +0 -14
  152. package/.next/server/src/middleware.js.map +0 -1
  153. package/.next/static/chunks/2853-d11a80b03c9a1640.js +0 -1
  154. package/.next/static/chunks/4327.3b84aa049900fdeb.js +0 -60
  155. package/.next/static/chunks/816-7e340dad784be28c.js +0 -1
  156. package/.next/static/chunks/9365-733d8c05712d2888.js +0 -1
  157. package/.next/static/chunks/app/layout-37e55f11dcc8b1bf.js +0 -1
  158. package/.next/static/chunks/app/page-fe35d61f14b90a51.js +0 -1
  159. package/.next/static/chunks/app/worktrees/[id]/page-58fcf2e63c056743.js +0 -1
  160. package/.next/static/chunks/main-a960f4a5e1a2f598.js +0 -1
  161. package/.next/static/css/376b339640084689.css +0 -3
  162. /package/.next/static/{564GHwluX5xIv9qpqLJV2 → oUD-A998xeBoez6zsrTH3}/_buildManifest.js +0 -0
  163. /package/.next/static/{564GHwluX5xIv9qpqLJV2 → oUD-A998xeBoez6zsrTH3}/_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 {
@@ -147,14 +122,175 @@ const TEXT_INPUT_PATTERNS = [
147
122
  /custom/i,
148
123
  /differently/i,
149
124
  ];
125
+ /**
126
+ * Pattern for ❯ (U+276F) indicator lines used by Claude CLI to mark the default selection.
127
+ * Used in Pass 1 (existence check) and Pass 2 (option collection) of the 2-pass detection.
128
+ * Anchored at both ends -- ReDoS safe (S4-001).
129
+ */
130
+ const DEFAULT_OPTION_PATTERN = /^\s*\u276F\s*(\d+)\.\s*(.+)$/;
131
+ /**
132
+ * Pattern for normal option lines (no ❯ indicator, just leading whitespace + number).
133
+ * Only applied in Pass 2 when ❯ indicator existence is confirmed by Pass 1.
134
+ * Anchored at both ends -- ReDoS safe (S4-001).
135
+ */
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
+ }
218
+ /**
219
+ * Defensive check: protection against future unknown false positive patterns.
220
+ * Note: The actual false positive pattern in Issue #161 ("1. Create file\n2. Run tests")
221
+ * IS consecutive from 1, so this validation alone does not prevent it.
222
+ * The primary defense layers are: Layer 1 (thinking check in caller) + Layer 2 (2-pass
223
+ * cursor detection). This function provides Layer 3 defense against future unknown
224
+ * patterns with scattered/non-consecutive numbering.
225
+ *
226
+ * [S3-010] This validation assumes Claude CLI always uses consecutive numbering
227
+ * starting from 1. If in the future Claude CLI is observed to filter choices and
228
+ * output non-consecutive numbers (e.g., 1, 2, 4), consider relaxing this validation
229
+ * (e.g., only check starts-from-1, remove consecutive requirement).
230
+ */
231
+ function isConsecutiveFromOne(numbers) {
232
+ if (numbers.length === 0)
233
+ return false;
234
+ if (numbers[0] !== 1)
235
+ return false;
236
+ for (let i = 1; i < numbers.length; i++) {
237
+ if (numbers[i] !== numbers[i - 1] + 1)
238
+ return false;
239
+ }
240
+ return true;
241
+ }
242
+ /**
243
+ * Continuation line detection for multiline option text wrapping.
244
+ * Detects lines that are part of a previous option's text, wrapped due to terminal width.
245
+ *
246
+ * Called within detectMultipleChoicePrompt() Pass 2 reverse scan, only when
247
+ * options.length > 0 (at least one option already detected):
248
+ * const rawLine = lines[i]; // Original line with indentation preserved
249
+ * const line = lines[i].trim(); // Trimmed line
250
+ * if (options.length > 0 && line && !line.match(/^[-─]+$/)) {
251
+ * if (isContinuationLine(rawLine, line)) { continue; }
252
+ * }
253
+ *
254
+ * Each condition's responsibility:
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.
260
+ * - isShortFragment: Short fragment (< 5 chars, e.g., filename tail)
261
+ * - isPathContinuation: Path string continuation (Issue #181)
262
+ *
263
+ * @param rawLine - Original line with indentation preserved (lines[i])
264
+ * @param line - Trimmed line (lines[i].trim())
265
+ * @returns true if the line should be treated as a continuation of a previous option
266
+ */
267
+ function isContinuationLine(rawLine, line) {
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;
276
+ // Short fragment (< 5 chars, excluding question-ending lines)
277
+ const isShortFragment = line.length < 5 && !endsWithQuestion;
278
+ // Path string continuation: lines starting with / or ~, or alphanumeric-only fragments (2+ chars)
279
+ const isPathContinuation = /^[\/~]/.test(line) || (line.length >= 2 && /^[a-zA-Z0-9_-]+$/.test(line));
280
+ return !!hasLeadingSpaces || isShortFragment || isPathContinuation;
281
+ }
150
282
  /**
151
283
  * Detect multiple choice prompts (numbered list with ❯ indicator)
152
284
  *
153
- * This function scans the output from bottom to top looking for numbered options
154
- * with a selection indicator (❯). It requires at least 2 options and a default
155
- * indicator to be considered a valid prompt.
285
+ * Uses a 2-pass detection approach (Issue #161):
286
+ * - Pass 1: Scan 50-line window for indicator lines (defaultOptionPattern).
287
+ * If no lines found, immediately return isPrompt: false.
288
+ * - Pass 2: Only if ❯ was found, re-scan collecting options using both
289
+ * defaultOptionPattern (isDefault=true) and normalOptionPattern (isDefault=false).
290
+ *
291
+ * This prevents normal numbered lists from being accumulated in the options array.
156
292
  *
157
- * Example:
293
+ * Example of valid prompt:
158
294
  * Do you want to proceed?
159
295
  * ❯ 1. Yes
160
296
  * 2. No
@@ -163,38 +299,67 @@ const TEXT_INPUT_PATTERNS = [
163
299
  * @param output - The tmux output to analyze (typically captured from tmux pane)
164
300
  * @returns Detection result with prompt data if a valid multiple choice prompt is found
165
301
  */
166
- 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;
167
305
  const lines = output.split('\n');
168
- // Look for lines that match the pattern: [optional leading spaces] [❯ or spaces] [number]. [text]
169
- // Note: ANSI codes sometimes cause spaces to be lost after stripping, so we use \s* instead of \s+
170
- const optionPattern = /^\s*([❯ ]\s*)?(\d+)\.\s*(.+)$/;
171
- const options = [];
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);
315
+ // ==========================================================================
316
+ // Pass 1: Check for ❯ indicator existence in scan window
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.
319
+ // ==========================================================================
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);
331
+ }
332
+ }
333
+ // ==========================================================================
334
+ // Pass 2: Collect options (executed when Pass 1 passes or is skipped)
335
+ // Scan from end to find options, using both patterns.
336
+ // ==========================================================================
337
+ const collectedOptions = [];
172
338
  let questionEndIndex = -1;
173
- let firstOptionIndex = -1;
174
- // Scan from the end to find options
175
- // Increased from 20 to 50 to handle multi-line wrapped options
176
- for (let i = lines.length - 1; i >= 0 && i >= lines.length - 50; i--) {
339
+ for (let i = effectiveEnd - 1; i >= scanStart; i--) {
177
340
  const line = lines[i].trim();
178
- const rawLine = lines[i]; // Keep original indentation for checking
179
- const match = line.match(optionPattern);
180
- if (match) {
181
- const hasDefault = Boolean(match[1] && match[1].includes('❯'));
182
- const number = parseInt(match[2], 10);
183
- const label = match[3].trim();
184
- // Insert at beginning since we're scanning backwards
185
- options.unshift({ number, label, isDefault: hasDefault });
186
- if (firstOptionIndex === -1) {
187
- firstOptionIndex = i;
188
- }
341
+ // Try DEFAULT_OPTION_PATTERN first (❯ indicator)
342
+ const defaultMatch = line.match(DEFAULT_OPTION_PATTERN);
343
+ if (defaultMatch) {
344
+ const number = parseInt(defaultMatch[1], 10);
345
+ const label = defaultMatch[2].trim();
346
+ collectedOptions.unshift({ number, label, isDefault: true });
347
+ continue;
348
+ }
349
+ // Try NORMAL_OPTION_PATTERN (no indicator)
350
+ const normalMatch = line.match(NORMAL_OPTION_PATTERN);
351
+ if (normalMatch) {
352
+ const number = parseInt(normalMatch[1], 10);
353
+ const label = normalMatch[2].trim();
354
+ collectedOptions.unshift({ number, label, isDefault: false });
355
+ continue;
189
356
  }
190
- else if (options.length > 0 && line && !line.match(/^[-─]+$/)) {
191
- // Check if this is a continuation line (indented line between options)
192
- // Continuation lines typically start with spaces (like " work/github...")
193
- // Also treat very short lines (< 5 chars) as potential word-wrap fragments
194
- const hasLeadingSpaces = rawLine.match(/^\s{2,}[^\d]/) && !rawLine.match(/^\s*\d+\./);
195
- const isShortFragment = line.length < 5 && !line.endsWith('?');
196
- const isContinuationLine = hasLeadingSpaces || isShortFragment;
197
- if (isContinuationLine) {
357
+ // Non-option line handling
358
+ if (collectedOptions.length > 0 && line && !SEPARATOR_LINE_PATTERN.test(line)) {
359
+ // Check if this is a continuation line (indented line between options,
360
+ // or path/filename fragments from terminal width wrapping - Issue #181)
361
+ const rawLine = lines[i]; // Original line with indentation preserved
362
+ if (isContinuationLine(rawLine, line)) {
198
363
  // Skip continuation lines and continue scanning for more options
199
364
  continue;
200
365
  }
@@ -203,13 +368,34 @@ function detectMultipleChoicePrompt(output) {
203
368
  break;
204
369
  }
205
370
  }
206
- // Must have at least 2 options AND at least one with ❯ indicator to be considered a prompt
207
- const hasDefaultIndicator = options.some(opt => opt.isDefault);
208
- if (options.length < 2 || !hasDefaultIndicator) {
209
- return {
210
- isPrompt: false,
211
- cleanContent: output.trim(),
212
- };
371
+ // Layer 3: Consecutive number validation (defensive measure)
372
+ const optionNumbers = collectedOptions.map(opt => opt.number);
373
+ if (!isConsecutiveFromOne(optionNumbers)) {
374
+ return noPromptResult(output);
375
+ }
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
+ }
213
399
  }
214
400
  // Extract question text
215
401
  let question = '';
@@ -218,7 +404,7 @@ function detectMultipleChoicePrompt(output) {
218
404
  const questionLines = [];
219
405
  for (let i = Math.max(0, questionEndIndex - 5); i <= questionEndIndex; i++) {
220
406
  const line = lines[i].trim();
221
- if (line && !line.match(/^[-─]+$/)) {
407
+ if (line && !SEPARATOR_LINE_PATTERN.test(line)) {
222
408
  questionLines.push(line);
223
409
  }
224
410
  }
@@ -233,7 +419,7 @@ function detectMultipleChoicePrompt(output) {
233
419
  promptData: {
234
420
  type: 'multiple_choice',
235
421
  question: question.trim(),
236
- options: options.map(opt => {
422
+ options: collectedOptions.map(opt => {
237
423
  // Check if this option requires text input using module-level patterns
238
424
  const requiresTextInput = TEXT_INPUT_PATTERNS.some(pattern => pattern.test(opt.label));
239
425
  return {
@@ -272,7 +458,8 @@ function getAnswerInput(answer, promptType = 'yes_no') {
272
458
  if (/^\d+$/.test(normalized)) {
273
459
  return normalized;
274
460
  }
275
- 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.');
276
463
  }
277
464
  // Handle yes/no prompts
278
465
  if (normalized === 'yes' || normalized === 'y') {
@@ -281,5 +468,6 @@ function getAnswerInput(answer, promptType = 'yes_no') {
281
468
  if (normalized === 'no' || normalized === 'n') {
282
469
  return 'n';
283
470
  }
284
- 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'.");
285
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
@@ -209,14 +256,12 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
209
256
  // Permission prompts appear after normal responses and need special handling
210
257
  if (cliToolId === 'claude') {
211
258
  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);
259
+ const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
215
260
  if (promptDetection.isPrompt) {
216
261
  // Return the full output as a complete interactive prompt
217
262
  // Use the cleaned output without ANSI codes
218
263
  return {
219
- response: cleanFullOutput,
264
+ response: (0, cli_patterns_1.stripAnsi)(fullOutput),
220
265
  isComplete: true,
221
266
  lineCount: totalLines,
222
267
  };
@@ -285,9 +330,10 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
285
330
  responseLines.push(line);
286
331
  }
287
332
  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)) {
333
+ // DR-004: Check only the tail of the response for thinking indicators.
334
+ // Prevents false blocking when completed thinking summaries appear in the response body.
335
+ const responseTailLines = response.split('\n').slice(-RESPONSE_THINKING_TAIL_LINE_COUNT).join('\n');
336
+ if (thinkingPattern.test(responseTailLines)) {
291
337
  return {
292
338
  response: '',
293
339
  isComplete: false,
@@ -347,18 +393,13 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
347
393
  lineCount: totalLines,
348
394
  };
349
395
  }
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('⠏')) {
396
+ // Check for auth/loading states that should not be treated as complete responses.
397
+ // Braille spinner characters are shared with CLAUDE_SPINNER_CHARS in cli-patterns.ts.
398
+ const LOADING_INDICATORS = [
399
+ 'Waiting for auth',
400
+ '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏',
401
+ ];
402
+ if (LOADING_INDICATORS.some(indicator => response.includes(indicator))) {
362
403
  return {
363
404
  response: '',
364
405
  isComplete: false,
@@ -382,7 +423,7 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
382
423
  // Check if this is an interactive prompt (yes/no or multiple choice)
383
424
  // Interactive prompts don't have the ">" prompt and separator, so we need to detect them separately
384
425
  const fullOutput = lines.join('\n');
385
- const promptDetection = (0, prompt_detector_1.detectPrompt)(fullOutput);
426
+ const promptDetection = detectPromptWithOptions(fullOutput, cliToolId);
386
427
  if (promptDetection.isPrompt) {
387
428
  // This is an interactive prompt - consider it complete
388
429
  return {
@@ -454,12 +495,17 @@ async function checkForResponse(worktreeId, cliToolId) {
454
495
  // Extract response
455
496
  const result = extractResponse(output, lastCapturedLine, cliToolId);
456
497
  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
498
+ // No new output or response not yet complete.
499
+ // If CLI tool is actively thinking, mark any pending prompts as answered.
500
+ // This handles cases where user responded to prompts directly via terminal.
501
+ //
502
+ // DR-004 windowing: Only check tail lines (same as extractResponse thinking check)
503
+ // to avoid false matches on completed thinking summaries in scrollback.
504
+ // Previously (MF-001), full-text check caused false positives.
460
505
  const { thinkingPattern } = (0, cli_patterns_1.getCliToolPatterns)(cliToolId);
461
506
  const cleanOutput = (0, cli_patterns_1.stripAnsi)(output);
462
- if (thinkingPattern.test(cleanOutput)) {
507
+ const tailLines = cleanOutput.split('\n').slice(-RESPONSE_THINKING_TAIL_LINE_COUNT).join('\n');
508
+ if (thinkingPattern.test(tailLines)) {
463
509
  const answeredCount = (0, db_1.markPendingPromptsAsAnswered)(db, worktreeId, cliToolId);
464
510
  if (answeredCount > 0) {
465
511
  console.log(`Marked ${answeredCount} pending prompt(s) as answered (thinking detected) for ${worktreeId}`);
@@ -479,7 +525,7 @@ async function checkForResponse(worktreeId, cliToolId) {
479
525
  return false;
480
526
  }
481
527
  // Response is complete! Check if it's a prompt
482
- const promptDetection = (0, prompt_detector_1.detectPrompt)(result.response);
528
+ const promptDetection = detectPromptWithOptions(result.response, cliToolId);
483
529
  if (promptDetection.isPrompt) {
484
530
  // This is a prompt - save as prompt message
485
531
  (0, db_1.clearInProgressMessageId)(db, worktreeId, cliToolId);