commandmate 0.2.12 → 0.3.0

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 (155) hide show
  1. package/.env.example +21 -2
  2. package/.next/BUILD_ID +1 -1
  3. package/.next/app-build-manifest.json +30 -22
  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 +2 -2
  20. package/.next/required-server-files.json +1 -1
  21. package/.next/routes-manifest.json +1 -1
  22. package/.next/server/app/_not-found/page.js +1 -1
  23. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  24. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  25. package/.next/server/app/api/app/update-check/route.js +1 -1
  26. package/.next/server/app/api/auth/login/route.js +1 -0
  27. package/.next/server/app/api/auth/login/route.js.nft.json +1 -0
  28. package/.next/server/app/api/auth/logout/route.js +1 -0
  29. package/.next/server/app/api/auth/logout/route.js.nft.json +1 -0
  30. package/.next/server/app/api/auth/status/route.js +1 -0
  31. package/.next/server/app/api/auth/status/route.js.nft.json +1 -0
  32. package/.next/server/app/api/hooks/claude-done/route.js +1 -1
  33. package/.next/server/app/api/hooks/claude-done/route.js.nft.json +1 -1
  34. package/.next/server/app/api/repositories/route.js +2 -2
  35. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  36. package/.next/server/app/api/slash-commands/route.js +1 -1
  37. package/.next/server/app/api/slash-commands/route.js.nft.json +1 -1
  38. package/.next/server/app/api/slash-commands.body +1 -1
  39. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  40. package/.next/server/app/api/worktrees/[id]/auto-yes/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]/interrupt/route.js +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 +1 -1
  46. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  48. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  50. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  51. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  52. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  53. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  54. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  55. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  56. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  57. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js.nft.json +1 -1
  58. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +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/route.js +1 -1
  61. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  62. package/.next/server/app/login/page.js +1 -0
  63. package/.next/server/app/login/page.js.nft.json +1 -0
  64. package/.next/server/app/login/page_client-reference-manifest.js +1 -0
  65. package/.next/server/app/page.js +2 -2
  66. package/.next/server/app/page.js.nft.json +1 -1
  67. package/.next/server/app/page_client-reference-manifest.js +1 -1
  68. package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
  69. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  70. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  71. package/.next/server/app/worktrees/[id]/page.js +3 -3
  72. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  73. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  74. package/.next/server/app/worktrees/[id]/terminal/page.js +1 -1
  75. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +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 +13 -9
  78. package/.next/server/chunks/3013.js +1 -0
  79. package/.next/server/chunks/3074.js +1 -0
  80. package/.next/server/chunks/{1287.js → 3294.js} +2 -2
  81. package/.next/server/chunks/3860.js +1 -1
  82. package/.next/server/chunks/4893.js +2 -2
  83. package/.next/server/chunks/539.js +35 -0
  84. package/.next/server/chunks/5795.js +1 -0
  85. package/.next/server/chunks/7536.js +1 -1
  86. package/.next/server/chunks/7566.js +19 -0
  87. package/.next/server/chunks/8693.js +1 -1
  88. package/.next/server/edge-runtime-webpack.js +2 -0
  89. package/.next/server/edge-runtime-webpack.js.map +1 -0
  90. package/.next/server/functions-config-manifest.json +1 -1
  91. package/.next/server/middleware-build-manifest.js +1 -1
  92. package/.next/server/middleware-manifest.json +28 -2
  93. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  94. package/.next/server/pages/500.html +1 -1
  95. package/.next/server/server-reference-manifest.json +1 -1
  96. package/.next/server/src/middleware.js +14 -0
  97. package/.next/server/src/middleware.js.map +1 -0
  98. package/.next/static/chunks/{2626.2125083a1ff3b80a.js → 6163.f672451d4575decf.js} +1 -1
  99. package/.next/static/chunks/{656.d72f25ce819bd77e.js → 656.5e2de0173f5a06bd.js} +1 -1
  100. package/.next/static/chunks/8091-925542bdfc843dce.js +1 -0
  101. package/.next/static/chunks/8528-4d554d3b94d4cf9b.js +1 -0
  102. package/.next/static/chunks/app/{layout-07755491d5d57242.js → layout-9110f9a5e41c6bf4.js} +1 -1
  103. package/.next/static/chunks/app/login/page-2d42204ba87cd136.js +1 -0
  104. package/.next/static/chunks/app/page-238b5a70d8c101e9.js +1 -0
  105. package/.next/static/chunks/app/worktrees/[id]/page-9418e49bdc1de02c.js +1 -0
  106. package/.next/static/chunks/main-db79434ee4a6c931.js +1 -0
  107. package/.next/static/chunks/webpack-3c0ee3ce5b546818.js +1 -0
  108. package/.next/static/css/b9ea6a4fad17dc32.css +3 -0
  109. package/.next/trace +5 -5
  110. package/.next/types/app/api/auth/login/route.ts +343 -0
  111. package/.next/types/app/api/auth/logout/route.ts +343 -0
  112. package/.next/types/app/api/auth/status/route.ts +343 -0
  113. package/.next/types/app/login/page.ts +79 -0
  114. package/README.md +6 -1
  115. package/dist/cli/commands/init.d.ts.map +1 -1
  116. package/dist/cli/commands/init.js +2 -0
  117. package/dist/cli/commands/start.d.ts +2 -0
  118. package/dist/cli/commands/start.d.ts.map +1 -1
  119. package/dist/cli/commands/start.js +159 -14
  120. package/dist/cli/commands/status.d.ts.map +1 -1
  121. package/dist/cli/commands/status.js +4 -0
  122. package/dist/cli/config/security-messages.d.ts +3 -1
  123. package/dist/cli/config/security-messages.d.ts.map +1 -1
  124. package/dist/cli/config/security-messages.js +6 -2
  125. package/dist/cli/index.js +17 -0
  126. package/dist/cli/types/index.d.ts +17 -0
  127. package/dist/cli/types/index.d.ts.map +1 -1
  128. package/dist/cli/utils/daemon.d.ts.map +1 -1
  129. package/dist/cli/utils/daemon.js +16 -3
  130. package/dist/config/auth-config.d.ts +43 -0
  131. package/dist/config/auth-config.d.ts.map +1 -0
  132. package/dist/config/auth-config.js +112 -0
  133. package/dist/lib/auth.d.ts +104 -0
  134. package/dist/lib/auth.d.ts.map +1 -0
  135. package/dist/lib/auth.js +250 -0
  136. package/dist/server/server.js +123 -12
  137. package/dist/server/src/config/auth-config.js +112 -0
  138. package/dist/server/src/lib/auth.js +250 -0
  139. package/dist/server/src/lib/auto-yes-manager.js +180 -96
  140. package/dist/server/src/lib/ip-restriction.js +241 -0
  141. package/dist/server/src/lib/response-poller.js +92 -39
  142. package/dist/server/src/lib/ws-server.js +63 -33
  143. package/dist/server/src/types/slash-commands.js +1 -0
  144. package/package.json +2 -2
  145. package/.next/server/chunks/9238.js +0 -35
  146. package/.next/server/chunks/9367.js +0 -19
  147. package/.next/static/chunks/5970-2e18108d0cabd8af.js +0 -1
  148. package/.next/static/chunks/816-af44cb865b0c980e.js +0 -1
  149. package/.next/static/chunks/app/page-a6593b9640df66a6.js +0 -1
  150. package/.next/static/chunks/app/worktrees/[id]/page-c99258f57461962c.js +0 -1
  151. package/.next/static/chunks/main-f00f82f1cf18dd99.js +0 -1
  152. package/.next/static/chunks/webpack-af8567a485ade35a.js +0 -1
  153. package/.next/static/css/897ffb669f47c97b.css +0 -3
  154. /package/.next/static/{ym6mA6Dl9wX62h3AoYO45 → clTo9tuAoPMLcGRuVENfO}/_buildManifest.js +0 -0
  155. /package/.next/static/{ym6mA6Dl9wX62h3AoYO45 → clTo9tuAoPMLcGRuVENfO}/_ssgManifest.js +0 -0
@@ -0,0 +1,250 @@
1
+ "use strict";
2
+ /**
3
+ * Token Authentication Core Module
4
+ * Issue #331: Token authentication and HTTPS support
5
+ *
6
+ * CONSTRAINTS:
7
+ * - C001: No Next.js module dependencies (next/headers, next/server, etc.)
8
+ * This module must be compatible with CLI build (tsconfig.cli.json)
9
+ * - S001: Token verification uses crypto.timingSafeEqual() (timing-safe comparison)
10
+ * - S002: AUTH_EXCLUDED_PATHS matching uses === (exact match, no startsWith)
11
+ */
12
+ var __importDefault = (this && this.__importDefault) || function (mod) {
13
+ return (mod && mod.__esModule) ? mod : { "default": mod };
14
+ };
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.DEFAULT_COOKIE_MAX_AGE_SECONDS = exports.RATE_LIMIT_CONFIG = exports.isValidTokenHash = exports.DEFAULT_EXPIRE_DURATION_MS = exports.computeExpireAt = exports.parseDuration = exports.AUTH_EXCLUDED_PATHS = exports.AUTH_COOKIE_NAME = void 0;
17
+ exports.generateToken = generateToken;
18
+ exports.hashToken = hashToken;
19
+ exports.verifyToken = verifyToken;
20
+ exports.parseCookies = parseCookies;
21
+ exports.isAuthEnabled = isAuthEnabled;
22
+ exports.getTokenMaxAge = getTokenMaxAge;
23
+ exports.isHttpsEnabled = isHttpsEnabled;
24
+ exports.buildAuthCookieOptions = buildAuthCookieOptions;
25
+ exports.createRateLimiter = createRateLimiter;
26
+ const crypto_1 = __importDefault(require("crypto"));
27
+ // Import and re-export shared constants and functions from Edge Runtime-compatible config
28
+ const auth_config_1 = require("../config/auth-config");
29
+ Object.defineProperty(exports, "AUTH_COOKIE_NAME", { enumerable: true, get: function () { return auth_config_1.AUTH_COOKIE_NAME; } });
30
+ Object.defineProperty(exports, "AUTH_EXCLUDED_PATHS", { enumerable: true, get: function () { return auth_config_1.AUTH_EXCLUDED_PATHS; } });
31
+ Object.defineProperty(exports, "parseDuration", { enumerable: true, get: function () { return auth_config_1.parseDuration; } });
32
+ Object.defineProperty(exports, "computeExpireAt", { enumerable: true, get: function () { return auth_config_1.computeExpireAt; } });
33
+ Object.defineProperty(exports, "DEFAULT_EXPIRE_DURATION_MS", { enumerable: true, get: function () { return auth_config_1.DEFAULT_EXPIRE_DURATION_MS; } });
34
+ Object.defineProperty(exports, "isValidTokenHash", { enumerable: true, get: function () { return auth_config_1.isValidTokenHash; } });
35
+ /** Rate limiting configuration for brute-force protection */
36
+ exports.RATE_LIMIT_CONFIG = {
37
+ /** Maximum failed attempts before lockout */
38
+ maxAttempts: 5,
39
+ /** Lockout duration in ms (15 minutes) */
40
+ lockoutDuration: 15 * 60 * 1000,
41
+ /** Cleanup interval in ms (1 hour) */
42
+ cleanupInterval: 60 * 60 * 1000,
43
+ };
44
+ /** Fallback cookie maxAge in seconds when no explicit expiry is set (24 hours) */
45
+ exports.DEFAULT_COOKIE_MAX_AGE_SECONDS = 24 * 60 * 60;
46
+ // ============================================================
47
+ // Module-level state (initialized from env at import time)
48
+ // ============================================================
49
+ /** The stored hash of the authentication token */
50
+ const storedTokenHash = (() => {
51
+ const hash = process.env.CM_AUTH_TOKEN_HASH || undefined;
52
+ if (!hash)
53
+ return undefined;
54
+ // Store validation result to avoid type predicate narrowing to 'never' in else branch
55
+ const valid = (0, auth_config_1.isValidTokenHash)(hash);
56
+ if (!valid) {
57
+ console.error(`[Security] CM_AUTH_TOKEN_HASH is not a valid 64-character hex string (got ${hash.length} chars). Authentication will be disabled.`);
58
+ return undefined;
59
+ }
60
+ return hash;
61
+ })();
62
+ /** Token expiration timestamp (ms since epoch) */
63
+ const expireAt = (0, auth_config_1.computeExpireAt)();
64
+ // ============================================================
65
+ // Token Functions
66
+ // ============================================================
67
+ /**
68
+ * Generate a cryptographically secure random token
69
+ * @returns 64-character hex string (32 bytes of entropy)
70
+ */
71
+ function generateToken() {
72
+ return crypto_1.default.randomBytes(32).toString('hex');
73
+ }
74
+ /**
75
+ * Hash a token using SHA-256
76
+ * @param token - The plain text token to hash
77
+ * @returns 64-character hex string (SHA-256 hash)
78
+ */
79
+ function hashToken(token) {
80
+ return crypto_1.default.createHash('sha256').update(token).digest('hex');
81
+ }
82
+ /**
83
+ * Verify a token against the stored hash
84
+ * S001: Uses crypto.timingSafeEqual() for timing-safe comparison
85
+ *
86
+ * @param token - The plain text token to verify
87
+ * @returns true if the token is valid and not expired
88
+ */
89
+ function verifyToken(token) {
90
+ if (!storedTokenHash) {
91
+ return false;
92
+ }
93
+ // Guard against undefined/null/empty token
94
+ if (!token || typeof token !== 'string') {
95
+ return false;
96
+ }
97
+ // Check expiration
98
+ if (expireAt !== null && Date.now() > expireAt) {
99
+ return false;
100
+ }
101
+ const tokenHash = hashToken(token);
102
+ // S001: timing-safe comparison to prevent timing attacks
103
+ const hashBuffer = Buffer.from(tokenHash, 'hex');
104
+ const storedBuffer = Buffer.from(storedTokenHash, 'hex');
105
+ if (hashBuffer.length !== storedBuffer.length) {
106
+ return false;
107
+ }
108
+ return crypto_1.default.timingSafeEqual(hashBuffer, storedBuffer);
109
+ }
110
+ // ============================================================
111
+ // Cookie Parsing (for WebSocket authentication)
112
+ // ============================================================
113
+ /**
114
+ * Parse a Cookie header string into key-value pairs
115
+ * Used by WebSocket upgrade handler where next/headers is not available
116
+ *
117
+ * @param cookieHeader - Raw Cookie header string
118
+ * @returns Parsed cookies as Record<string, string>
119
+ */
120
+ function parseCookies(cookieHeader) {
121
+ const cookies = {};
122
+ if (!cookieHeader) {
123
+ return cookies;
124
+ }
125
+ const pairs = cookieHeader.split(';');
126
+ for (const pair of pairs) {
127
+ const eqIndex = pair.indexOf('=');
128
+ if (eqIndex === -1) {
129
+ continue;
130
+ }
131
+ const name = pair.substring(0, eqIndex).trim();
132
+ const value = pair.substring(eqIndex + 1).trim();
133
+ if (name) {
134
+ cookies[name] = value;
135
+ }
136
+ }
137
+ return cookies;
138
+ }
139
+ // ============================================================
140
+ // Auth State Functions
141
+ // ============================================================
142
+ /**
143
+ * Check if authentication is enabled.
144
+ * Returns true only when CM_AUTH_TOKEN_HASH is set AND passes format validation.
145
+ * This prevents the state where auth appears enabled but login is impossible
146
+ * (e.g., when the hash value is malformed).
147
+ */
148
+ function isAuthEnabled() {
149
+ return !!storedTokenHash;
150
+ }
151
+ /**
152
+ * Calculate the Cookie maxAge in seconds (remaining token lifetime)
153
+ * @returns maxAge in seconds, or 0 if expired/no expiry
154
+ */
155
+ function getTokenMaxAge() {
156
+ if (expireAt === null) {
157
+ return 0;
158
+ }
159
+ const remaining = expireAt - Date.now();
160
+ if (remaining <= 0) {
161
+ return 0;
162
+ }
163
+ return Math.floor(remaining / 1000);
164
+ }
165
+ /**
166
+ * Check if HTTPS is enabled based on certificate environment variable
167
+ * @returns true if CM_HTTPS_CERT is set (indicating TLS certificates are configured)
168
+ */
169
+ function isHttpsEnabled() {
170
+ return !!process.env.CM_HTTPS_CERT;
171
+ }
172
+ /**
173
+ * Build authentication cookie options with consistent security settings.
174
+ * Centralizes cookie configuration to enforce HttpOnly, SameSite, and Secure flags.
175
+ *
176
+ * @param maxAge - Cookie max age in seconds. Use 0 to clear the cookie.
177
+ * @returns Cookie options object compatible with Next.js response.cookies.set()
178
+ */
179
+ function buildAuthCookieOptions(maxAge) {
180
+ return {
181
+ httpOnly: true,
182
+ sameSite: 'strict',
183
+ secure: isHttpsEnabled(),
184
+ maxAge,
185
+ path: '/',
186
+ };
187
+ }
188
+ /**
189
+ * Create a rate limiter for brute-force protection
190
+ * Uses in-memory Map with periodic cleanup
191
+ *
192
+ * @returns RateLimiter instance with checkLimit, recordFailure, recordSuccess, destroy
193
+ */
194
+ function createRateLimiter() {
195
+ const entries = new Map();
196
+ // Periodic cleanup of expired entries
197
+ const cleanupTimer = setInterval(() => {
198
+ const now = Date.now();
199
+ for (const [ip, entry] of entries) {
200
+ // Remove entries whose lockout has expired (or was never set)
201
+ // and whose last attempt is older than the lockout duration
202
+ const isLockoutExpired = entry.lockedUntil === null || now > entry.lockedUntil;
203
+ const isStale = now - entry.lastAttempt > exports.RATE_LIMIT_CONFIG.lockoutDuration;
204
+ if (isLockoutExpired && isStale) {
205
+ entries.delete(ip);
206
+ }
207
+ }
208
+ }, exports.RATE_LIMIT_CONFIG.cleanupInterval);
209
+ // Ensure timer doesn't prevent process exit
210
+ if (cleanupTimer.unref) {
211
+ cleanupTimer.unref();
212
+ }
213
+ return {
214
+ checkLimit(ip) {
215
+ const entry = entries.get(ip);
216
+ if (!entry) {
217
+ return { allowed: true };
218
+ }
219
+ // Check if lockout has expired
220
+ if (entry.lockedUntil !== null) {
221
+ const now = Date.now();
222
+ if (now < entry.lockedUntil) {
223
+ const retryAfter = Math.ceil((entry.lockedUntil - now) / 1000);
224
+ return { allowed: false, retryAfter };
225
+ }
226
+ // Lockout expired - reset entry
227
+ entry.attempts = 0;
228
+ entry.lockedUntil = null;
229
+ }
230
+ return { allowed: true };
231
+ },
232
+ recordFailure(ip) {
233
+ const now = Date.now();
234
+ const entry = entries.get(ip) || { attempts: 0, lockedUntil: null, lastAttempt: now };
235
+ entry.attempts++;
236
+ entry.lastAttempt = now;
237
+ if (entry.attempts >= exports.RATE_LIMIT_CONFIG.maxAttempts) {
238
+ entry.lockedUntil = now + exports.RATE_LIMIT_CONFIG.lockoutDuration;
239
+ }
240
+ entries.set(ip, entry);
241
+ },
242
+ recordSuccess(ip) {
243
+ entries.delete(ip);
244
+ },
245
+ destroy() {
246
+ clearInterval(cleanupTimer);
247
+ entries.clear();
248
+ },
249
+ };
250
+ }
@@ -22,6 +22,10 @@ exports.clearAllPollerStates = clearAllPollerStates;
22
22
  exports.getLastServerResponseTimestamp = getLastServerResponseTimestamp;
23
23
  exports.executeRegexWithTimeout = executeRegexWithTimeout;
24
24
  exports.checkStopCondition = checkStopCondition;
25
+ exports.validatePollingContext = validatePollingContext;
26
+ exports.captureAndCleanOutput = captureAndCleanOutput;
27
+ exports.processStopConditionDelta = processStopConditionDelta;
28
+ exports.detectAndRespondToPrompt = detectAndRespondToPrompt;
25
29
  exports.startAutoYesPolling = startAutoYesPolling;
26
30
  exports.stopAutoYesPolling = stopAutoYesPolling;
27
31
  exports.stopAllAutoYesPolling = stopAllAutoYesPolling;
@@ -196,6 +200,18 @@ function clearAllAutoYesStates() {
196
200
  // =============================================================================
197
201
  // Server-side Polling (Issue #138)
198
202
  // =============================================================================
203
+ /**
204
+ * Get poller state for a worktree.
205
+ * Returns undefined if no poller state exists.
206
+ *
207
+ * Issue #323: Introduced for consistency with getAutoYesState() accessor pattern (DR003).
208
+ *
209
+ * @param worktreeId - Worktree identifier
210
+ * @returns Poller state or undefined
211
+ */
212
+ function getPollerState(worktreeId) {
213
+ return autoYesPollerStates.get(worktreeId);
214
+ }
199
215
  /**
200
216
  * Get the number of active pollers.
201
217
  *
@@ -221,7 +237,7 @@ function clearAllPollerStates() {
221
237
  * @returns Timestamp (Date.now()) of the last server response, or null if none
222
238
  */
223
239
  function getLastServerResponseTimestamp(worktreeId) {
224
- const pollerState = autoYesPollerStates.get(worktreeId);
240
+ const pollerState = getPollerState(worktreeId);
225
241
  return pollerState?.lastServerResponseTimestamp ?? null;
226
242
  }
227
243
  /**
@@ -231,7 +247,7 @@ function getLastServerResponseTimestamp(worktreeId) {
231
247
  * @param timestamp - Timestamp value (Date.now())
232
248
  */
233
249
  function updateLastServerResponseTimestamp(worktreeId, timestamp) {
234
- const pollerState = autoYesPollerStates.get(worktreeId);
250
+ const pollerState = getPollerState(worktreeId);
235
251
  if (pollerState) {
236
252
  pollerState.lastServerResponseTimestamp = timestamp;
237
253
  }
@@ -242,7 +258,7 @@ function updateLastServerResponseTimestamp(worktreeId, timestamp) {
242
258
  * @param worktreeId - Worktree identifier
243
259
  */
244
260
  function resetErrorCount(worktreeId) {
245
- const pollerState = autoYesPollerStates.get(worktreeId);
261
+ const pollerState = getPollerState(worktreeId);
246
262
  if (pollerState) {
247
263
  pollerState.consecutiveErrors = 0;
248
264
  pollerState.currentInterval = exports.POLLING_INTERVAL_MS;
@@ -254,7 +270,7 @@ function resetErrorCount(worktreeId) {
254
270
  * @param worktreeId - Worktree identifier
255
271
  */
256
272
  function incrementErrorCount(worktreeId) {
257
- const pollerState = autoYesPollerStates.get(worktreeId);
273
+ const pollerState = getPollerState(worktreeId);
258
274
  if (pollerState) {
259
275
  pollerState.consecutiveErrors++;
260
276
  pollerState.currentInterval = calculateBackoffInterval(pollerState.consecutiveErrors);
@@ -342,137 +358,205 @@ function checkStopCondition(worktreeId, cleanOutput) {
342
358
  }
343
359
  return false;
344
360
  }
361
+ // =============================================================================
362
+ // Extracted Functions for pollAutoYes (Issue #323: SRP decomposition)
363
+ // =============================================================================
345
364
  /**
346
- * Internal polling function that recursively schedules itself via setTimeout.
347
- * Captures tmux output, detects prompts, and sends auto-responses when appropriate.
365
+ * Validate that polling context is still valid.
366
+ * Checks pollerState existence and auto-yes enabled state.
348
367
  *
349
- * Includes duplicate prevention (Issue #306): skips prompts that have already
350
- * been answered (tracked via lastAnsweredPromptKey) and applies a cooldown
351
- * interval (COOLDOWN_INTERVAL_MS) after successful responses.
368
+ * @sideeffect When returning 'expired', calls stopAutoYesPolling() to clean up
369
+ * the poller state. This side-effect is intentional - separating the check from
370
+ * the cleanup would risk callers forgetting to call stopAutoYesPolling() (DR002).
352
371
  *
372
+ * @internal Exported for testing purposes only.
373
+ * @precondition worktreeId is validated by isValidWorktreeId() in startAutoYesPolling()
374
+ * gateway before being registered in globalThis Map. This function assumes worktreeId
375
+ * has already passed that validation.
353
376
  * @param worktreeId - Worktree identifier
354
- * @param cliToolId - CLI tool type being polled
377
+ * @param pollerState - Current poller state (or undefined if not found)
378
+ * @returns 'valid' | 'stopped' | 'expired'
355
379
  */
356
- async function pollAutoYes(worktreeId, cliToolId) {
357
- // Check if poller was stopped
358
- const pollerState = autoYesPollerStates.get(worktreeId);
380
+ function validatePollingContext(worktreeId, pollerState) {
359
381
  if (!pollerState)
360
- return;
361
- // Check if auto-yes is still enabled
382
+ return 'stopped';
362
383
  const autoYesState = getAutoYesState(worktreeId);
363
384
  if (!autoYesState?.enabled || isAutoYesExpired(autoYesState)) {
364
385
  stopAutoYesPolling(worktreeId);
365
- return;
386
+ return 'expired';
366
387
  }
388
+ return 'valid';
389
+ }
390
+ /**
391
+ * Capture tmux session output and strip ANSI escape codes.
392
+ *
393
+ * @internal Exported for testing purposes only.
394
+ * @precondition worktreeId is validated by isValidWorktreeId() in startAutoYesPolling()
395
+ * gateway before being registered in globalThis Map. This function assumes worktreeId
396
+ * has already passed that validation.
397
+ * @param worktreeId - Worktree identifier
398
+ * @param cliToolId - CLI tool type being polled
399
+ * @returns Cleaned output string (ANSI stripped)
400
+ */
401
+ async function captureAndCleanOutput(worktreeId, cliToolId) {
402
+ // 5000 lines: matches the existing pollAutoYes() line limit (IC002).
403
+ // captureSessionOutput() default is 1000 lines, but tmux buffer capture
404
+ // requires 5000 to avoid truncating long outputs.
405
+ const output = await (0, cli_session_1.captureSessionOutput)(worktreeId, cliToolId, 5000);
406
+ return (0, cli_patterns_1.stripAnsi)(output);
407
+ }
408
+ /**
409
+ * Process stop condition check using delta-based approach.
410
+ * Manages baseline length, extracts new content, and delegates
411
+ * pattern matching to existing checkStopCondition().
412
+ *
413
+ * Note: This is a higher-level function that internally calls
414
+ * checkStopCondition() (L409, Issue #314) for regex pattern matching.
415
+ *
416
+ * @internal Exported for testing purposes only.
417
+ * @precondition worktreeId is validated by isValidWorktreeId() in startAutoYesPolling()
418
+ * gateway before being registered in globalThis Map. This function assumes worktreeId
419
+ * has already passed that validation.
420
+ * @param worktreeId - Worktree identifier
421
+ * @param pollerState - Current poller state (mutated: stopCheckBaselineLength updated)
422
+ * @param cleanOutput - ANSI-stripped terminal output
423
+ * @returns true if stop condition matched and auto-yes was disabled
424
+ */
425
+ function processStopConditionDelta(worktreeId, pollerState, cleanOutput) {
426
+ if (pollerState.stopCheckBaselineLength < 0) {
427
+ // First poll: set baseline, skip stop condition check
428
+ pollerState.stopCheckBaselineLength = cleanOutput.length;
429
+ return false;
430
+ }
431
+ const baseline = pollerState.stopCheckBaselineLength;
432
+ if (cleanOutput.length > baseline) {
433
+ // Output grew: check only new content (delta)
434
+ const newContent = cleanOutput.substring(baseline);
435
+ pollerState.stopCheckBaselineLength = cleanOutput.length;
436
+ return checkStopCondition(worktreeId, newContent);
437
+ }
438
+ else if (cleanOutput.length < baseline) {
439
+ // Buffer shrank (old lines dropped from scrollback): reset baseline
440
+ pollerState.stopCheckBaselineLength = cleanOutput.length;
441
+ }
442
+ // If length unchanged: no new content, skip check
443
+ return false;
444
+ }
445
+ /**
446
+ * Detect prompt in terminal output, resolve auto-answer, and send response.
447
+ * Handles the complete flow: detection -> duplicate check -> answer resolution ->
448
+ * tmux send -> timestamp/error-count update -> promptKey recording.
449
+ *
450
+ * Note: Cooldown scheduling is NOT this function's responsibility.
451
+ * The caller (pollAutoYes() orchestrator) determines scheduling interval
452
+ * based on the return value ('responded' -> cooldown, others -> normal interval).
453
+ *
454
+ * @internal Exported for testing purposes only.
455
+ * @precondition worktreeId is validated by isValidWorktreeId() in startAutoYesPolling()
456
+ * gateway before being registered in globalThis Map. This function assumes worktreeId
457
+ * has already passed that validation.
458
+ * @param worktreeId - Worktree identifier
459
+ * @param pollerState - Current poller state (mutated: lastAnsweredPromptKey updated)
460
+ * @param cliToolId - CLI tool type
461
+ * @param cleanOutput - ANSI-stripped terminal output
462
+ * @returns 'responded' | 'no_prompt' | 'duplicate' | 'no_answer' | 'error'
463
+ */
464
+ async function detectAndRespondToPrompt(worktreeId, pollerState, cliToolId, cleanOutput) {
367
465
  try {
368
- // 1. Capture tmux output
369
- const output = await (0, cli_session_1.captureSessionOutput)(worktreeId, cliToolId, 5000);
370
- // 2. Strip ANSI codes
371
- const cleanOutput = (0, cli_patterns_1.stripAnsi)(output);
372
- // 2.5. Skip prompt detection during thinking state (Issue #161, Layer 1)
373
- // This prevents false positive detection of numbered lists in CLI output
374
- // while Claude is actively processing (thinking/planning).
375
- //
376
- // Issue #191: Apply windowing to detectThinking() to prevent stale thinking
377
- // summary lines (e.g., "· Simmering...") from blocking prompt detection.
378
- // Window size matches detectPrompt()'s multiple_choice scan range (50 lines).
379
- //
380
- // Safety: Claude CLI does not emit prompts during thinking, so narrowing
381
- // the window cannot cause false auto-responses (see IA-003 in design doc).
382
- //
383
- // Processing order: stripAnsi -> split -> slice -> join
384
- // stripAnsi is applied BEFORE split to ensure ANSI escape sequences spanning
385
- // line boundaries do not affect line counting (IA-002).
386
- //
387
- // Boundary case: if buffer has fewer than 50 lines, slice(-50) returns the
388
- // entire array (Array.prototype.slice specification), which is safe degradation
389
- // equivalent to pre-fix behavior (IA-001).
390
- const recentLines = cleanOutput.split('\n').slice(-exports.THINKING_CHECK_LINE_COUNT).join('\n');
391
- if ((0, cli_patterns_1.detectThinking)(cliToolId, recentLines)) {
392
- scheduleNextPoll(worktreeId, cliToolId);
393
- return;
394
- }
395
- // 2.7. Check stop condition (Issue #314)
396
- // After thinking check, before prompt detection: if terminal output matches
397
- // the stop pattern, disable auto-yes and stop polling immediately.
398
- //
399
- // Delta-based check: Only check NEW output since Auto-Yes was enabled.
400
- // On the first poll, establish the baseline output length (skip check to
401
- // avoid matching pre-existing terminal content like shell prompts or paths).
402
- // On subsequent polls, check only the output delta (new content appended).
403
- // If the buffer shrank (tmux scrollback shifted), reset baseline and skip
404
- // that cycle to avoid false positives from old content.
405
- if (pollerState.stopCheckBaselineLength < 0) {
406
- // First poll: set baseline, skip stop condition check
407
- pollerState.stopCheckBaselineLength = cleanOutput.length;
408
- }
409
- else {
410
- const baseline = pollerState.stopCheckBaselineLength;
411
- if (cleanOutput.length > baseline) {
412
- // Output grew: check only new content (delta)
413
- const newContent = cleanOutput.substring(baseline);
414
- pollerState.stopCheckBaselineLength = cleanOutput.length;
415
- if (checkStopCondition(worktreeId, newContent)) {
416
- return;
417
- }
418
- }
419
- else if (cleanOutput.length < baseline) {
420
- // Buffer shrank (old lines dropped from scrollback): reset baseline
421
- pollerState.stopCheckBaselineLength = cleanOutput.length;
422
- }
423
- // If length unchanged: no new content, skip check
424
- }
425
- // 3. Detect prompt
466
+ // 1. Detect prompt
426
467
  const promptOptions = (0, cli_patterns_1.buildDetectPromptOptions)(cliToolId);
427
468
  const promptDetection = (0, prompt_detector_1.detectPrompt)(cleanOutput, promptOptions);
428
469
  if (!promptDetection.isPrompt || !promptDetection.promptData) {
429
470
  // No prompt detected - reset lastAnsweredPromptKey (Issue #306)
430
471
  pollerState.lastAnsweredPromptKey = null;
431
- scheduleNextPoll(worktreeId, cliToolId);
432
- return;
472
+ return 'no_prompt';
433
473
  }
434
- // Issue #306: Check for duplicate prompt before responding
474
+ // 2. Check for duplicate prompt (Issue #306)
435
475
  const promptKey = (0, prompt_key_1.generatePromptKey)(promptDetection.promptData);
436
476
  if (isDuplicatePrompt(pollerState, promptKey)) {
437
- scheduleNextPoll(worktreeId, cliToolId);
438
- return;
477
+ return 'duplicate';
439
478
  }
440
- // 4. Resolve auto answer
479
+ // 3. Resolve auto answer
441
480
  const answer = (0, auto_yes_resolver_1.resolveAutoAnswer)(promptDetection.promptData);
442
481
  if (answer === null) {
443
- // Cannot auto-answer this prompt
444
- scheduleNextPoll(worktreeId, cliToolId);
445
- return;
482
+ return 'no_answer';
446
483
  }
447
- // 5. Send answer to tmux
484
+ // 4. Send answer to tmux
448
485
  const manager = manager_1.CLIToolManager.getInstance();
449
486
  const cliTool = manager.getTool(cliToolId);
450
487
  const sessionName = cliTool.getSessionName(worktreeId);
451
- // Issue #287 Bug2: Uses shared sendPromptAnswer() to unify logic
452
- // with route.ts, including cursor-key navigation for Claude Code
453
- // multiple-choice prompts and fallback handling.
454
488
  await (0, prompt_answer_sender_1.sendPromptAnswer)({
455
489
  sessionName,
456
490
  answer,
457
491
  cliToolId,
458
492
  promptData: promptDetection.promptData,
459
493
  });
460
- // 6. Update timestamp
494
+ // 5. Update timestamp and reset error count
461
495
  updateLastServerResponseTimestamp(worktreeId, Date.now());
462
- // 7. Reset error count on success
463
496
  resetErrorCount(worktreeId);
464
- // Issue #306: Record answered prompt key and apply cooldown
497
+ // 6. Record answered prompt key
465
498
  pollerState.lastAnsweredPromptKey = promptKey;
466
499
  // Log success (without sensitive content)
467
500
  console.info(`[Auto-Yes Poller] Sent response for worktree: ${worktreeId}`);
468
- // Issue #306: Apply cooldown interval after successful response (early return)
469
- scheduleNextPoll(worktreeId, cliToolId, exports.COOLDOWN_INTERVAL_MS);
501
+ return 'responded';
502
+ }
503
+ catch (error) {
504
+ // IC003: This catch handles errors from prompt detection/sending only.
505
+ // incrementErrorCount is called here, and this function never throws,
506
+ // preventing double incrementErrorCount in the outer pollAutoYes() catch.
507
+ incrementErrorCount(worktreeId);
508
+ console.warn(`[Auto-Yes Poller] Error in detectAndRespondToPrompt for worktree ${worktreeId}: ${getErrorMessage(error)}`);
509
+ return 'error';
510
+ }
511
+ }
512
+ /**
513
+ * Internal polling function that recursively schedules itself via setTimeout.
514
+ * Orchestrates the polling flow by delegating to extracted functions.
515
+ *
516
+ * Issue #323: Refactored from ~139 lines to ~30 lines as an orchestrator.
517
+ * Each responsibility is delegated to a focused function:
518
+ * - validatePollingContext(): Pre-condition checks
519
+ * - captureAndCleanOutput(): tmux output capture + ANSI cleanup
520
+ * - processStopConditionDelta(): Stop condition delta-based check
521
+ * - detectAndRespondToPrompt(): Prompt detection + auto-response
522
+ *
523
+ * @param worktreeId - Worktree identifier
524
+ * @param cliToolId - CLI tool type being polled
525
+ */
526
+ async function pollAutoYes(worktreeId, cliToolId) {
527
+ // 1. Validate context
528
+ const pollerState = getPollerState(worktreeId);
529
+ const contextResult = validatePollingContext(worktreeId, pollerState);
530
+ if (contextResult !== 'valid')
470
531
  return;
532
+ // pollerState is guaranteed non-null after 'valid' check
533
+ try {
534
+ // 2. Capture and clean output
535
+ const cleanOutput = await captureAndCleanOutput(worktreeId, cliToolId);
536
+ // 3. Thinking check (inline - policy decision about window size)
537
+ // Issue #161 Layer 1 / Issue #191: windowing applied to detectThinking()
538
+ const recentLines = cleanOutput.split('\n').slice(-exports.THINKING_CHECK_LINE_COUNT).join('\n');
539
+ if ((0, cli_patterns_1.detectThinking)(cliToolId, recentLines)) {
540
+ scheduleNextPoll(worktreeId, cliToolId);
541
+ return;
542
+ }
543
+ // 4. Stop condition delta check (Issue #314)
544
+ if (processStopConditionDelta(worktreeId, pollerState, cleanOutput)) {
545
+ return;
546
+ }
547
+ // 5. Detect and respond to prompt
548
+ const result = await detectAndRespondToPrompt(worktreeId, pollerState, cliToolId, cleanOutput);
549
+ if (result === 'responded') {
550
+ // Issue #306: Apply cooldown interval after successful response
551
+ scheduleNextPoll(worktreeId, cliToolId, exports.COOLDOWN_INTERVAL_MS);
552
+ return;
553
+ }
471
554
  }
472
555
  catch (error) {
473
- // Increment error count on failure
556
+ // IC003: This catch handles captureAndCleanOutput() or processStopConditionDelta()
557
+ // errors only. detectAndRespondToPrompt() catches its own errors and returns
558
+ // 'error' instead of throwing (preventing double incrementErrorCount).
474
559
  incrementErrorCount(worktreeId);
475
- // Log error (without sensitive details)
476
560
  console.warn(`[Auto-Yes Poller] Error for worktree ${worktreeId}: ${getErrorMessage(error)}`);
477
561
  }
478
562
  // Schedule next poll (catch block fallthrough or other paths)
@@ -485,7 +569,7 @@ async function pollAutoYes(worktreeId, cliToolId) {
485
569
  * Type: number | undefined (optional parameter).
486
570
  */
487
571
  function scheduleNextPoll(worktreeId, cliToolId, overrideInterval) {
488
- const pollerState = autoYesPollerStates.get(worktreeId);
572
+ const pollerState = getPollerState(worktreeId);
489
573
  if (!pollerState)
490
574
  return;
491
575
  // S4-F003: Floor guard - polling interval must not be below POLLING_INTERVAL_MS
@@ -548,7 +632,7 @@ function startAutoYesPolling(worktreeId, cliToolId) {
548
632
  * @param worktreeId - Worktree identifier
549
633
  */
550
634
  function stopAutoYesPolling(worktreeId) {
551
- const pollerState = autoYesPollerStates.get(worktreeId);
635
+ const pollerState = getPollerState(worktreeId);
552
636
  if (!pollerState)
553
637
  return;
554
638
  // Clear timer