crewly 1.2.0 → 1.2.3

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 (91) hide show
  1. package/config/constants.ts +5 -0
  2. package/config/roles/architect/prompt.md +7 -5
  3. package/config/roles/backend-developer/prompt.md +8 -4
  4. package/config/roles/content-strategist/prompt.md +12 -4
  5. package/config/roles/designer/prompt.md +8 -4
  6. package/config/roles/developer/prompt.md +7 -4
  7. package/config/roles/frontend-developer/prompt.md +8 -4
  8. package/config/roles/fullstack-dev/prompt.md +8 -4
  9. package/config/roles/generalist/prompt.md +7 -4
  10. package/config/roles/ops/prompt.md +7 -4
  11. package/config/roles/orchestrator/prompt.md +22 -1
  12. package/config/roles/product-manager/prompt.md +8 -4
  13. package/config/roles/qa/prompt.md +8 -4
  14. package/config/roles/qa-engineer/prompt.md +8 -4
  15. package/config/roles/sales/prompt.md +8 -4
  16. package/config/roles/support/prompt.md +8 -4
  17. package/config/roles/tpm/prompt.md +8 -4
  18. package/config/skills/orchestrator/delegate-task/execute.sh +7 -5
  19. package/config/skills/orchestrator/delegate-task/instructions.md +22 -17
  20. package/config/templates/agent-claude-md.md +10 -5
  21. package/dist/backend/backend/src/constants.d.ts +18 -63
  22. package/dist/backend/backend/src/constants.d.ts.map +1 -1
  23. package/dist/backend/backend/src/constants.js +17 -68
  24. package/dist/backend/backend/src/constants.js.map +1 -1
  25. package/dist/backend/backend/src/controllers/chat/chat.controller.d.ts.map +1 -1
  26. package/dist/backend/backend/src/controllers/chat/chat.controller.js +39 -0
  27. package/dist/backend/backend/src/controllers/chat/chat.controller.js.map +1 -1
  28. package/dist/backend/backend/src/index.d.ts.map +1 -1
  29. package/dist/backend/backend/src/index.js +1 -0
  30. package/dist/backend/backend/src/index.js.map +1 -1
  31. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts +2 -13
  32. package/dist/backend/backend/src/services/agent/agent-registration.service.d.ts.map +1 -1
  33. package/dist/backend/backend/src/services/agent/agent-registration.service.js +105 -130
  34. package/dist/backend/backend/src/services/agent/agent-registration.service.js.map +1 -1
  35. package/dist/backend/backend/src/services/agent/gemini-runtime.service.d.ts +1 -1
  36. package/dist/backend/backend/src/services/agent/gemini-runtime.service.js +8 -8
  37. package/dist/backend/backend/src/services/agent/gemini-runtime.service.js.map +1 -1
  38. package/dist/backend/backend/src/services/event-bus/event-bus.service.d.ts +30 -27
  39. package/dist/backend/backend/src/services/event-bus/event-bus.service.d.ts.map +1 -1
  40. package/dist/backend/backend/src/services/event-bus/event-bus.service.js +128 -53
  41. package/dist/backend/backend/src/services/event-bus/event-bus.service.js.map +1 -1
  42. package/dist/backend/backend/src/services/messaging/message-queue.service.js +1 -1
  43. package/dist/backend/backend/src/services/messaging/message-queue.service.js.map +1 -1
  44. package/dist/backend/backend/src/services/messaging/queue-processor.service.d.ts.map +1 -1
  45. package/dist/backend/backend/src/services/messaging/queue-processor.service.js +47 -10
  46. package/dist/backend/backend/src/services/messaging/queue-processor.service.js.map +1 -1
  47. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.d.ts +35 -3
  48. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.d.ts.map +1 -1
  49. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js +123 -25
  50. package/dist/backend/backend/src/services/monitoring/activity-monitor.service.js.map +1 -1
  51. package/dist/backend/backend/src/services/orchestrator/orchestrator-heartbeat-monitor.service.d.ts +6 -0
  52. package/dist/backend/backend/src/services/orchestrator/orchestrator-heartbeat-monitor.service.d.ts.map +1 -1
  53. package/dist/backend/backend/src/services/orchestrator/orchestrator-heartbeat-monitor.service.js +25 -3
  54. package/dist/backend/backend/src/services/orchestrator/orchestrator-heartbeat-monitor.service.js.map +1 -1
  55. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js +1 -1
  56. package/dist/backend/backend/src/services/session/pty/pty-session-backend.js.map +1 -1
  57. package/dist/backend/backend/src/services/session/session-command-helper.d.ts.map +1 -1
  58. package/dist/backend/backend/src/services/session/session-command-helper.js +29 -2
  59. package/dist/backend/backend/src/services/session/session-command-helper.js.map +1 -1
  60. package/dist/backend/backend/src/utils/terminal-output.utils.d.ts +2 -1
  61. package/dist/backend/backend/src/utils/terminal-output.utils.d.ts.map +1 -1
  62. package/dist/backend/backend/src/utils/terminal-output.utils.js +2 -28
  63. package/dist/backend/backend/src/utils/terminal-output.utils.js.map +1 -1
  64. package/dist/backend/backend/src/utils/terminal-string-ops.d.ts +183 -0
  65. package/dist/backend/backend/src/utils/terminal-string-ops.d.ts.map +1 -0
  66. package/dist/backend/backend/src/utils/terminal-string-ops.js +717 -0
  67. package/dist/backend/backend/src/utils/terminal-string-ops.js.map +1 -0
  68. package/dist/backend/backend/src/websocket/terminal.gateway.d.ts.map +1 -1
  69. package/dist/backend/backend/src/websocket/terminal.gateway.js +22 -27
  70. package/dist/backend/backend/src/websocket/terminal.gateway.js.map +1 -1
  71. package/dist/backend/config/constants.d.ts +5 -0
  72. package/dist/backend/config/constants.d.ts.map +1 -1
  73. package/dist/backend/config/constants.js +5 -0
  74. package/dist/backend/config/constants.js.map +1 -1
  75. package/dist/cli/backend/src/constants.d.ts +18 -63
  76. package/dist/cli/backend/src/constants.d.ts.map +1 -1
  77. package/dist/cli/backend/src/constants.js +17 -68
  78. package/dist/cli/backend/src/constants.js.map +1 -1
  79. package/dist/cli/backend/src/utils/terminal-output.utils.d.ts +2 -1
  80. package/dist/cli/backend/src/utils/terminal-output.utils.d.ts.map +1 -1
  81. package/dist/cli/backend/src/utils/terminal-output.utils.js +2 -28
  82. package/dist/cli/backend/src/utils/terminal-output.utils.js.map +1 -1
  83. package/dist/cli/backend/src/utils/terminal-string-ops.d.ts +183 -0
  84. package/dist/cli/backend/src/utils/terminal-string-ops.d.ts.map +1 -0
  85. package/dist/cli/backend/src/utils/terminal-string-ops.js +717 -0
  86. package/dist/cli/backend/src/utils/terminal-string-ops.js.map +1 -0
  87. package/dist/cli/config/constants.d.ts +5 -0
  88. package/dist/cli/config/constants.d.ts.map +1 -1
  89. package/dist/cli/config/constants.js +5 -0
  90. package/dist/cli/config/constants.js.map +1 -1
  91. package/package.json +2 -1
@@ -14,6 +14,8 @@ import { ContextWindowMonitorService } from './context-window-monitor.service.js
14
14
  import { SubAgentMessageQueue } from '../messaging/sub-agent-message-queue.service.js';
15
15
  import { AgentSuspendService } from './agent-suspend.service.js';
16
16
  import { stripAnsiCodes } from '../../utils/terminal-output.utils.js';
17
+ import { isPromptLine, containsSpinnerOrWorkingIndicator, containsProcessingIndicator, containsBusyStatusBar, containsRewindMode, containsGeminiProcessingKeywords, extractChatPrefix, stripTuiLineBorders, matchTuiPromptLine, } from '../../utils/terminal-string-ops.js';
18
+ import { PtyActivityTrackerService } from './pty-activity-tracker.service.js';
17
19
  /**
18
20
  * Service responsible for the complex, multi-step process of agent initialization and registration.
19
21
  * Isolates the complex state management of agent startup with progressive escalation.
@@ -47,16 +49,10 @@ export class AgentRegistrationService {
47
49
  // which causes multiple Ctrl+C presses that can crash the runtime.
48
50
  sessionDeliveryMutex = new Map();
49
51
  // Terminal patterns are now centralized in TERMINAL_PATTERNS constant
50
- // Keeping these as static getters for backwards compatibility within the class
52
+ // Keeping prompt chars as static getter for backwards compatibility within the class
51
53
  static get CLAUDE_PROMPT_INDICATORS() {
52
54
  return TERMINAL_PATTERNS.PROMPT_CHARS;
53
55
  }
54
- static get CLAUDE_PROMPT_STREAM_PATTERN() {
55
- return TERMINAL_PATTERNS.PROMPT_STREAM;
56
- }
57
- static get CLAUDE_PROCESSING_INDICATORS() {
58
- return TERMINAL_PATTERNS.PROCESSING_INDICATORS;
59
- }
60
56
  constructor(_legacyTmuxService, // Legacy parameter for backwards compatibility
61
57
  projectRoot, storageService) {
62
58
  this.logger = LoggerService.getInstance().createComponentLogger('AgentRegistrationService');
@@ -1508,6 +1504,14 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
1508
1504
  await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_SESSION_NAME, sessionName);
1509
1505
  await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_ROLE, role);
1510
1506
  await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.CREWLY_API_URL, `http://localhost:${WEB_CONSTANTS.PORTS.BACKEND}`);
1507
+ // Pass Gemini API key to gemini-cli agents so they authenticate
1508
+ // with the paid API key instead of the free-tier Google login.
1509
+ if (runtimeType === RUNTIME_TYPES.GEMINI_CLI) {
1510
+ const geminiApiKey = process.env[ENV_CONSTANTS.GEMINI_API_KEY];
1511
+ if (geminiApiKey) {
1512
+ await sessionHelper.setEnvironmentVariable(sessionName, ENV_CONSTANTS.GEMINI_API_KEY, geminiApiKey);
1513
+ }
1514
+ }
1511
1515
  this.logger.info('Agent session created and environment variables set, initializing with registration', {
1512
1516
  sessionName,
1513
1517
  role,
@@ -1699,9 +1703,16 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
1699
1703
  // Use robust message delivery with proper waiting mechanism
1700
1704
  const delivered = await this.sendMessageWithRetry(sessionName, message, 3, runtimeType);
1701
1705
  if (!delivered) {
1706
+ // Check if the agent is actively processing (busy) — the queue
1707
+ // processor can re-queue instead of permanently failing the message.
1708
+ // Use PTY idle time instead of regex patterns for robust cross-runtime detection.
1709
+ const idleMs = PtyActivityTrackerService.getInstance().getIdleTimeMs(sessionName);
1710
+ const isBusy = idleMs < SESSION_COMMAND_DELAYS.AGENT_BUSY_IDLE_THRESHOLD_MS;
1702
1711
  return {
1703
1712
  success: false,
1704
- error: 'Failed to deliver message after multiple attempts',
1713
+ error: isBusy
1714
+ ? '[AGENT_BUSY] Failed to deliver message — agent is actively processing'
1715
+ : 'Failed to deliver message after multiple attempts',
1705
1716
  };
1706
1717
  }
1707
1718
  this.logger.info('Message sent to agent successfully', {
@@ -1761,9 +1772,6 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
1761
1772
  if (!session) {
1762
1773
  return false;
1763
1774
  }
1764
- // Use runtime-specific pattern for stream detection to avoid false positives
1765
- // (e.g. Gemini's `> ` pattern matching markdown blockquotes in Claude Code output)
1766
- const streamPattern = this.getPromptPatternForRuntime(runtimeType);
1767
1775
  return new Promise((resolve) => {
1768
1776
  let resolved = false;
1769
1777
  const pollInterval = EVENT_DELIVERY_CONSTANTS.AGENT_READY_POLL_INTERVAL;
@@ -1810,9 +1818,11 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
1810
1818
  if (resolved)
1811
1819
  return;
1812
1820
  // Strip ANSI escape sequences before testing — raw PTY data contains
1813
- // cursor positioning, color codes, etc. that break regex matching (#106)
1821
+ // cursor positioning, color codes, etc. that break pattern matching (#106)
1814
1822
  const cleanData = stripAnsiCodes(data);
1815
- if (streamPattern.test(cleanData)) {
1823
+ // Check each line for prompt pattern (string-based, no regex)
1824
+ const hasPromptInStream = cleanData.split('\n').some(line => line.trim().length > 0 && isPromptLine(line, runtimeType));
1825
+ if (hasPromptInStream) {
1816
1826
  // Double-check with capturePane to avoid false positives from partial data
1817
1827
  const output = sessionHelper.capturePane(sessionName);
1818
1828
  if (this.isClaudeAtPrompt(output, runtimeType)) {
@@ -2045,7 +2055,7 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2045
2055
  }
2046
2056
  // Phase 1: Wait for Claude to be at prompt before sending
2047
2057
  if (!messageSent) {
2048
- const isAtPrompt = AgentRegistrationService.CLAUDE_PROMPT_STREAM_PATTERN.test(buffer);
2058
+ const isAtPrompt = buffer.split('\n').some(line => line.trim().length > 0 && isPromptLine(line));
2049
2059
  if (isAtPrompt) {
2050
2060
  sendMessageNow();
2051
2061
  }
@@ -2056,9 +2066,9 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2056
2066
  return; // Wait for Enter to be sent
2057
2067
  }
2058
2068
  // Look for processing indicators confirming delivery
2059
- const hasProcessingIndicator = AgentRegistrationService.CLAUDE_PROCESSING_INDICATORS.some((pattern) => pattern.test(buffer));
2069
+ const hasProcessingIndicator = containsProcessingIndicator(buffer);
2060
2070
  // Also check if prompt disappeared (Claude is working)
2061
- const promptStillVisible = AgentRegistrationService.CLAUDE_PROMPT_STREAM_PATTERN.test(buffer);
2071
+ const promptStillVisible = buffer.split('\n').some(line => line.trim().length > 0 && isPromptLine(line));
2062
2072
  // Use constant for minimum buffer check (P3.2 fix)
2063
2073
  if (hasProcessingIndicator || (!promptStillVisible && buffer.length > EVENT_DELIVERY_CONSTANTS.MIN_BUFFER_FOR_PROCESSING_DETECTION)) {
2064
2074
  this.logger.debug('Message delivery confirmed (event-driven)', {
@@ -2107,12 +2117,10 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2107
2117
  if (!this.isClaudeAtPrompt(output, runtimeType)) {
2108
2118
  if (attempt === maxAttempts) {
2109
2119
  // On the final attempt, check if the agent is DEFINITELY busy
2110
- // before force-delivering. If we see "esc to interrupt" or
2111
- // processing indicators, the agent is actively working and
2112
- // force-delivery risks corrupting its current task.
2113
- const tailForBusyCheck = output.slice(-2000);
2114
- const isBusy = TERMINAL_PATTERNS.BUSY_STATUS_BAR.test(tailForBusyCheck) ||
2115
- TERMINAL_PATTERNS.PROCESSING_WITH_TEXT.test(tailForBusyCheck);
2120
+ // before force-delivering. Use PTY idle time for robust
2121
+ // cross-runtime detection instead of fragile regex patterns.
2122
+ const idleMs = PtyActivityTrackerService.getInstance().getIdleTimeMs(sessionName);
2123
+ const isBusy = idleMs < SESSION_COMMAND_DELAYS.AGENT_BUSY_IDLE_THRESHOLD_MS;
2116
2124
  if (isBusy) {
2117
2125
  this.logger.warn('Agent is busy (processing indicators detected), skipping force delivery', {
2118
2126
  sessionName,
@@ -2175,8 +2183,30 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2175
2183
  // for focus cycling and overlay dismissal.
2176
2184
  if (isClaudeCode) {
2177
2185
  if (attempt > 1) {
2186
+ // On retry: Ctrl+C to cancel stale input, then PTY resize to force
2187
+ // TUI re-render (SIGWINCH), then Tab to cycle Ink focus.
2178
2188
  await sessionHelper.sendCtrlC(sessionName);
2179
2189
  await delay(300);
2190
+ try {
2191
+ const session = sessionHelper.getSession(sessionName);
2192
+ if (session) {
2193
+ session.resize(81, 25);
2194
+ await delay(200);
2195
+ session.resize(80, 24);
2196
+ await delay(300);
2197
+ }
2198
+ }
2199
+ catch { /* non-fatal */ }
2200
+ await sessionHelper.sendKey(sessionName, 'Tab');
2201
+ await delay(300);
2202
+ }
2203
+ else {
2204
+ // First attempt: lightweight focus nudge — Tab key cycles Ink's
2205
+ // focusNext() to ensure the InputPrompt is active. This prevents
2206
+ // the "write succeeds but TUI ignores it" failure mode without
2207
+ // the overhead of Ctrl+C or PTY resize.
2208
+ await sessionHelper.sendKey(sessionName, 'Tab');
2209
+ await delay(200);
2180
2210
  }
2181
2211
  }
2182
2212
  else {
@@ -2311,7 +2341,7 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2311
2341
  // definitive proof the agent accepted and is working.
2312
2342
  // Only check spinner/⏺ chars — NOT text words like
2313
2343
  // "thinking" which appear in historical response text.
2314
- if (TERMINAL_PATTERNS.PROCESSING.test(currentOutput)) {
2344
+ if (containsSpinnerOrWorkingIndicator(currentOutput)) {
2315
2345
  this.logger.debug('Processing indicators detected — message accepted', {
2316
2346
  sessionName,
2317
2347
  attempt,
@@ -2346,7 +2376,7 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2346
2376
  await delay(SESSION_COMMAND_DELAYS.MESSAGE_PROCESSING_DELAY);
2347
2377
  // Verify recovery
2348
2378
  const postEnterOutput = sessionHelper.capturePane(sessionName);
2349
- if (TERMINAL_PATTERNS.PROCESSING.test(postEnterOutput) ||
2379
+ if (containsSpinnerOrWorkingIndicator(postEnterOutput) ||
2350
2380
  !this.isClaudeAtPrompt(postEnterOutput, runtimeType)) {
2351
2381
  this.logger.info('Enter recovery from prompt successful', {
2352
2382
  sessionName,
@@ -2393,7 +2423,7 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2393
2423
  await delay(SESSION_COMMAND_DELAYS.MESSAGE_PROCESSING_DELAY);
2394
2424
  // Verify recovery: check if processing started
2395
2425
  const recoveryOutput = sessionHelper.capturePane(sessionName);
2396
- if (TERMINAL_PATTERNS.PROCESSING.test(recoveryOutput)) {
2426
+ if (containsSpinnerOrWorkingIndicator(recoveryOutput)) {
2397
2427
  this.logger.info('Enter recovery successful — processing started', {
2398
2428
  sessionName,
2399
2429
  attempt,
@@ -2480,9 +2510,9 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2480
2510
  .filter((line) => !beforeLines.has(line))
2481
2511
  .join('\n');
2482
2512
  }
2483
- const hasProcessingIndicators = TERMINAL_PATTERNS.PROCESSING_WITH_TEXT.test(newContent || afterOutput.slice(-500));
2513
+ const hasProcessingIndicators = containsProcessingIndicator(newContent || afterOutput.slice(-500));
2484
2514
  const hasGeminiIndicators = newContent.length > 0
2485
- && /reading|thinking|processing|analyzing|generating|searching/i.test(newContent);
2515
+ && containsGeminiProcessingKeywords(newContent);
2486
2516
  const significantLengthChange = Math.abs(lengthDiff) > 10;
2487
2517
  // For Gemini CLI, contentChanged alone is sufficient evidence of
2488
2518
  // delivery. The TUI redraws minimally (lengthDiff can be as low as
@@ -2561,19 +2591,11 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2561
2591
  }
2562
2592
  }
2563
2593
  }
2564
- // Verification failed, but the message was physically written to the PTY.
2565
- // If the session is still alive, the agent will likely process it — return
2566
- // true to avoid false "Failed to deliver" errors shown to users (#99).
2567
- const backend = getSessionBackendSync();
2568
- const childAlive = backend?.isChildProcessAlive?.(sessionName);
2569
- if (childAlive !== false) {
2570
- this.logger.warn('Message delivery verification inconclusive but session alive — assuming success', {
2571
- sessionName,
2572
- maxAttempts,
2573
- messageLength: message.length,
2574
- });
2575
- return true;
2576
- }
2594
+ this.logger.warn('Message delivery verification failed all attempts exhausted', {
2595
+ sessionName,
2596
+ maxAttempts,
2597
+ messageLength: message.length,
2598
+ });
2577
2599
  this.logger.error('Message delivery failed after all retry attempts', {
2578
2600
  sessionName,
2579
2601
  maxAttempts,
@@ -2601,13 +2623,13 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2601
2623
  }
2602
2624
  // Extract a search token from the message:
2603
2625
  // Strip [CHAT:uuid] prefix if present, then take the first 40 chars
2604
- const chatPrefixMatch = message.match(/^\[CHAT:[^\]]+\]\s*/);
2605
- const contentAfterPrefix = chatPrefixMatch
2606
- ? message.slice(chatPrefixMatch[0].length)
2626
+ const { prefixLength } = extractChatPrefix(message);
2627
+ const contentAfterPrefix = prefixLength > 0
2628
+ ? message.slice(prefixLength)
2607
2629
  : message;
2608
2630
  const searchToken = contentAfterPrefix.slice(0, 40).trim();
2609
2631
  // Also use [CHAT: as a secondary token if message has a CHAT prefix
2610
- const chatToken = chatPrefixMatch ? '[CHAT:' : null;
2632
+ const chatToken = prefixLength > 0 ? '[CHAT:' : null;
2611
2633
  // Check last 20 non-empty lines for either token.
2612
2634
  // Gemini CLI TUI has status bars at the bottom (branch, sandbox, model info)
2613
2635
  // that push input content further up. 5 lines was insufficient.
@@ -2615,7 +2637,7 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2615
2637
  const linesToCheck = lines.slice(-20);
2616
2638
  const isStuck = linesToCheck.some((line) => {
2617
2639
  // Strip TUI box-drawing borders before checking (Gemini CLI wraps content in │...│)
2618
- const stripped = line.replace(/^[│┃║|\s]+/, '').replace(/[│┃║|\s]+$/, '');
2640
+ const stripped = stripTuiLineBorders(line);
2619
2641
  if (searchToken && (line.includes(searchToken) || stripped.includes(searchToken)))
2620
2642
  return true;
2621
2643
  if (chatToken && (line.includes(chatToken) || stripped.includes(chatToken)))
@@ -2665,9 +2687,9 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2665
2687
  return false;
2666
2688
  // Extract search token: first 30 chars of the message content
2667
2689
  // (after stripping any [CHAT:uuid] prefix)
2668
- const chatPrefixMatch = message.match(/^\[CHAT:[^\]]+\]\s*/);
2669
- const contentAfterPrefix = chatPrefixMatch
2670
- ? message.slice(chatPrefixMatch[0].length)
2690
+ const { prefixLength } = extractChatPrefix(message);
2691
+ const contentAfterPrefix = prefixLength > 0
2692
+ ? message.slice(prefixLength)
2671
2693
  : message;
2672
2694
  const searchToken = contentAfterPrefix.slice(0, 30).trim();
2673
2695
  if (!searchToken)
@@ -2677,14 +2699,13 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2677
2699
  // Or without borders: > text
2678
2700
  // The prompt line is the line with `> ` followed by actual content.
2679
2701
  const lines = output.split('\n');
2680
- const promptLineRegex = /^[│┃║|\s]*>\s+(.+)/;
2681
2702
  for (let i = lines.length - 1; i >= Math.max(0, lines.length - 25); i--) {
2682
2703
  const line = lines[i];
2683
- const match = line.match(promptLineRegex);
2684
- if (match) {
2685
- const promptContent = match[1].replace(/[│┃║|\s]+$/, '').trim();
2704
+ const promptContent = matchTuiPromptLine(line);
2705
+ if (promptContent !== null) {
2706
+ const trimmedContent = stripTuiLineBorders(promptContent).trim();
2686
2707
  // Check if the prompt line content contains our message text
2687
- if (promptContent.length > 5 && promptContent.includes(searchToken)) {
2708
+ if (trimmedContent.length > 5 && trimmedContent.includes(searchToken)) {
2688
2709
  this.logger.warn('Text stuck at TUI prompt — Enter was not pressed', {
2689
2710
  sessionName,
2690
2711
  searchToken: searchToken.slice(0, 20),
@@ -2784,6 +2805,22 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2784
2805
  const sessionHelper = this._sessionHelper;
2785
2806
  if (!sessionHelper)
2786
2807
  return;
2808
+ // --- Part 0: Rewind mode detection (all sessions) ---
2809
+ // If Claude Code is stuck in Rewind mode (triggered by ESC during
2810
+ // processing), send 'q' to exit before any other recovery attempts.
2811
+ for (const sessionName of sessionHelper.listSessions()) {
2812
+ try {
2813
+ const output = sessionHelper.capturePane(sessionName);
2814
+ if (containsRewindMode(output)) {
2815
+ this.logger.warn('Rewind mode detected, sending q to exit', { sessionName });
2816
+ sessionHelper.writeRaw(sessionName, 'q');
2817
+ await delay(500);
2818
+ }
2819
+ }
2820
+ catch {
2821
+ // Non-fatal: session may have been destroyed
2822
+ }
2823
+ }
2787
2824
  // --- Part 1: Existing TUI prompt-line scanning (unchanged) ---
2788
2825
  for (const [sessionName] of this.tuiSessionRegistry) {
2789
2826
  try {
@@ -2797,19 +2834,19 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2797
2834
  continue;
2798
2835
  // Look for any text sitting on the prompt line
2799
2836
  const lines = output.split('\n');
2800
- const promptLineRegex = /^[│┃║|\s]*>\s+(.+)/;
2801
2837
  for (let i = lines.length - 1; i >= Math.max(0, lines.length - 25); i--) {
2802
- const match = lines[i].match(promptLineRegex);
2803
- if (match) {
2804
- const promptContent = match[1].replace(/[│┃║|\s]+$/, '').trim();
2838
+ const promptContent = matchTuiPromptLine(lines[i]);
2839
+ if (promptContent !== null) {
2840
+ const trimmedContent = stripTuiLineBorders(promptContent).trim();
2805
2841
  // Only act on substantial text (> 10 chars) to avoid false positives
2806
2842
  // from TUI rendering artifacts or short status text
2807
- if (promptContent.length > 10) {
2843
+ if (trimmedContent.length > 10) {
2808
2844
  // Skip known Gemini CLI idle placeholder text that sits at
2809
2845
  // the `> ` prompt when no user input is present. These are
2810
2846
  // NOT stuck messages — they are TUI decoration.
2811
- const isPlaceholder = /^Type your message/i.test(promptContent) ||
2812
- /^@[\w/.]+/.test(promptContent); // e.g., "@path/to/file"
2847
+ const lowerContent = trimmedContent.toLowerCase();
2848
+ const isPlaceholder = lowerContent.startsWith('type your message') ||
2849
+ trimmedContent.startsWith('@'); // e.g., "@path/to/file"
2813
2850
  if (isPlaceholder) {
2814
2851
  break;
2815
2852
  }
@@ -2904,8 +2941,8 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2904
2941
  */
2905
2942
  trackSentMessage(sessionName, message) {
2906
2943
  // Extract a search snippet: skip [CHAT:uuid] prefix, take first 80 chars
2907
- const prefixMatch = message.match(/^\[CHAT:[^\]]+\]\s*/);
2908
- const contentStart = prefixMatch ? prefixMatch[0].length : 0;
2944
+ const { prefixLength } = extractChatPrefix(message);
2945
+ const contentStart = prefixLength;
2909
2946
  // Normalize whitespace: messages may contain \n from enhanced templates.
2910
2947
  // Terminal bottom text is join(' '), so \n in snippet would never match.
2911
2948
  const snippet = message.slice(contentStart, contentStart + 80).replace(/\s+/g, ' ').trim();
@@ -2919,27 +2956,10 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2919
2956
  // Ensure the background scanner is running
2920
2957
  this.startStuckMessageDetector();
2921
2958
  }
2922
- /**
2923
- * Get the runtime-specific prompt regex pattern.
2924
- * Avoids false positives by using narrow patterns when runtime is known.
2925
- *
2926
- * @param runtimeType - The runtime type (claude-code, gemini-cli, etc.)
2927
- * @returns The appropriate prompt detection regex
2928
- */
2929
- getPromptPatternForRuntime(runtimeType) {
2930
- if (runtimeType === RUNTIME_TYPES.CLAUDE_CODE)
2931
- return TERMINAL_PATTERNS.CLAUDE_CODE_PROMPT;
2932
- if (runtimeType === RUNTIME_TYPES.GEMINI_CLI)
2933
- return TERMINAL_PATTERNS.GEMINI_CLI_PROMPT;
2934
- if (runtimeType === RUNTIME_TYPES.CODEX_CLI)
2935
- return TERMINAL_PATTERNS.CODEX_CLI_PROMPT;
2936
- return TERMINAL_PATTERNS.PROMPT_STREAM;
2937
- }
2938
2959
  /**
2939
2960
  * Check if the agent appears to be at an input prompt.
2940
- * Looks for prompt indicators (❯, ⏵, $, ❯❯, ⏵⏵) in terminal output.
2941
- * Also checks for busy indicators (esc to interrupt, spinners, ⏺)
2942
- * to avoid false negatives when the agent is processing.
2961
+ * Delegates to the regex-free isAgentAtPrompt() from terminal-string-ops,
2962
+ * with additional logging and per-line prompt detection using isPromptLine().
2943
2963
  *
2944
2964
  * @param terminalOutput - The terminal output to check
2945
2965
  * @param runtimeType - The runtime type for pattern selection
@@ -2956,57 +2976,12 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
2956
2976
  // Use 5000 chars to accommodate large tool outputs that push the prompt
2957
2977
  // further back in the buffer (#106).
2958
2978
  const tailSection = terminalOutput.slice(-5000);
2959
- const isGemini = runtimeType === RUNTIME_TYPES.GEMINI_CLI;
2960
- const isClaudeCode = runtimeType === RUNTIME_TYPES.CLAUDE_CODE;
2961
- const isCodex = runtimeType === RUNTIME_TYPES.CODEX_CLI;
2962
- const streamPattern = this.getPromptPatternForRuntime(runtimeType);
2963
2979
  // Check for prompt FIRST. Processing indicators like "thinking" or "analyzing"
2964
2980
  // can appear in the agent's previous response text and persist in the terminal
2965
2981
  // scroll buffer, causing false negatives if checked before the prompt.
2966
- if (streamPattern.test(tailSection)) {
2967
- return true;
2968
- }
2969
- // Fallback: check last several lines for prompt indicators.
2970
- // The prompt may not be on the very last line due to status bars,
2971
- // notifications, or terminal wrapping below the prompt.
2972
2982
  const lines = tailSection.split('\n').filter((line) => line.trim().length > 0);
2973
2983
  const linesToCheck = lines.slice(-10);
2974
- const hasPrompt = linesToCheck.some((line) => {
2975
- const trimmed = line.trim();
2976
- // Strip TUI box-drawing borders that Gemini CLI and other TUI frameworks
2977
- // wrap around prompts. Covers full Unicode box-drawing range (#106).
2978
- const stripped = trimmed
2979
- .replace(/^[\u2500-\u257F|+\-═║╭╮╰╯]+\s*/, '')
2980
- .replace(/\s*[\u2500-\u257F|+\-═║╭╮╰╯]+$/, '');
2981
- // Claude Code prompts: ❯, ⏵, $ alone on a line
2982
- if (!isGemini && !isCodex) {
2983
- if (['❯', '⏵', '$'].some(ch => trimmed === ch || stripped === ch)) {
2984
- return true;
2985
- }
2986
- // ❯❯ = bypass permissions prompt (idle).
2987
- // Matches "❯❯", "❯❯ ", and "❯❯ bypass permissions on (shift+tab to cycle)".
2988
- // Note: ⏵⏵ appears in the status bar but is visible both when idle AND
2989
- // busy, so it cannot be used as a reliable prompt indicator.
2990
- if (trimmed.startsWith('❯❯')) {
2991
- return true;
2992
- }
2993
- }
2994
- // Gemini CLI prompts: > or ! followed by space
2995
- if (!isClaudeCode) {
2996
- if (isCodex) {
2997
- // Codex prompt uses `›`; avoid plain `> ` to prevent false-positives
2998
- // from markdown blockquotes in agent output.
2999
- if (trimmed.startsWith('› ') || stripped.startsWith('› ')) {
3000
- return true;
3001
- }
3002
- }
3003
- else if (trimmed.startsWith('> ') || trimmed.startsWith('! ') ||
3004
- stripped.startsWith('> ') || stripped.startsWith('! ')) {
3005
- return true;
3006
- }
3007
- }
3008
- return false;
3009
- });
2984
+ const hasPrompt = linesToCheck.some(line => isPromptLine(line, runtimeType));
3010
2985
  if (hasPrompt) {
3011
2986
  return true;
3012
2987
  }
@@ -3014,14 +2989,14 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
3014
2989
  // Check last 10 lines (not just 5) because tool output can push processing
3015
2990
  // indicators further up while the status bar stays at the bottom.
3016
2991
  const recentLines = linesToCheck.join('\n');
3017
- if (TERMINAL_PATTERNS.PROCESSING_WITH_TEXT.test(recentLines)) {
2992
+ if (containsProcessingIndicator(recentLines)) {
3018
2993
  this.logger.debug('Processing indicators present near bottom of output');
3019
2994
  return false;
3020
2995
  }
3021
2996
  // Check for "esc to interrupt" in the status bar — this is a definitive
3022
2997
  // busy signal. Claude Code only shows this text while actively processing.
3023
2998
  // It disappears when the agent returns to idle at the prompt.
3024
- if (TERMINAL_PATTERNS.BUSY_STATUS_BAR.test(recentLines)) {
2999
+ if (containsBusyStatusBar(recentLines)) {
3025
3000
  this.logger.debug('Busy status bar detected (esc to interrupt)');
3026
3001
  return false;
3027
3002
  }
@@ -3279,7 +3254,7 @@ After checking in, just say "Ready for tasks" and wait for me to send you work.`
3279
3254
  return false;
3280
3255
  const currentOutput = sessionHelper.capturePane(sessionName);
3281
3256
  // Processing indicators (spinners) = definitive success
3282
- if (TERMINAL_PATTERNS.PROCESSING.test(currentOutput)) {
3257
+ if (containsSpinnerOrWorkingIndicator(currentOutput)) {
3283
3258
  this.logger.debug('Kickoff delivered — processing indicators detected', {
3284
3259
  sessionName, checkIndex: i,
3285
3260
  });