commandmate 0.2.0 → 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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +9 -9
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +2 -2
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/config.json +3 -3
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/1.pack +0 -0
- package/.next/cache/webpack/client-production/2.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/api/repositories/excluded/route.js +8 -8
- package/.next/server/app/api/repositories/restore/route.js +7 -7
- package/.next/server/app/api/repositories/route.js +5 -5
- package/.next/server/app/api/repositories/sync/route.js +5 -5
- package/.next/server/app/api/slash-commands.body +1 -1
- package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/capture/route.js +2 -2
- package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/terminal/route.js +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/page.js +1 -1
- package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/simple-terminal/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +7 -7
- package/.next/server/chunks/5488.js +5 -5
- package/.next/server/chunks/7536.js +1 -1
- package/.next/server/chunks/9367.js +2 -2
- package/.next/server/functions-config-manifest.json +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/app/worktrees/[id]/{page-d64624eb67af57c0.js → page-8bd88bdc29607413.js} +1 -1
- package/.next/trace +5 -5
- package/dist/server/server.js +25 -2
- package/dist/server/src/lib/auto-yes-manager.js +88 -7
- package/dist/server/src/lib/claude-poller.js +4 -0
- package/dist/server/src/lib/claude-session.js +48 -19
- package/dist/server/src/lib/cli-patterns.js +60 -4
- package/dist/server/src/lib/db-repository.js +482 -0
- package/dist/server/src/lib/prompt-detector.js +199 -109
- package/dist/server/src/lib/response-poller.js +73 -27
- package/dist/server/src/lib/tmux.js +48 -0
- package/package.json +1 -1
- /package/.next/static/{bdUePCj-b9Gv5okYGp49O → oUD-A998xeBoez6zsrTH3}/_buildManifest.js +0 -0
- /package/.next/static/{bdUePCj-b9Gv5okYGp49O → 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
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
117
|
-
if (
|
|
118
|
-
const content =
|
|
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
|
-
|
|
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 && !
|
|
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 !!
|
|
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
|
-
//
|
|
239
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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 (
|
|
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
|
|
337
|
+
const collectedOptions = [];
|
|
263
338
|
let questionEndIndex = -1;
|
|
264
|
-
for (let 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
|
-
|
|
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
|
-
|
|
354
|
+
collectedOptions.unshift({ number, label, isDefault: false });
|
|
280
355
|
continue;
|
|
281
356
|
}
|
|
282
357
|
// Non-option line handling
|
|
283
|
-
if (
|
|
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 =
|
|
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
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 && !
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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 = (
|
|
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
|
-
//
|
|
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
|
-
|
|
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 = (
|
|
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);
|
|
@@ -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
|
*
|