commandmate 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +11 -11
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +2 -2
  5. package/.next/cache/.tsbuildinfo +1 -1
  6. package/.next/cache/config.json +3 -3
  7. package/.next/cache/webpack/client-production/0.pack +0 -0
  8. package/.next/cache/webpack/client-production/1.pack +0 -0
  9. package/.next/cache/webpack/client-production/2.pack +0 -0
  10. package/.next/cache/webpack/client-production/index.pack +0 -0
  11. package/.next/cache/webpack/client-production/index.pack.old +0 -0
  12. package/.next/cache/webpack/edge-server-production/0.pack +0 -0
  13. package/.next/cache/webpack/edge-server-production/index.pack +0 -0
  14. package/.next/cache/webpack/server-production/0.pack +0 -0
  15. package/.next/cache/webpack/server-production/index.pack +0 -0
  16. package/.next/next-server.js.nft.json +1 -1
  17. package/.next/prerender-manifest.json +1 -1
  18. package/.next/required-server-files.json +1 -1
  19. package/.next/routes-manifest.json +1 -1
  20. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  21. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/.next/server/app/api/app/update-check/route.js +1 -1
  23. package/.next/server/app/api/external-apps/[id]/health/route.js +1 -1
  24. package/.next/server/app/api/external-apps/[id]/route.js +1 -1
  25. package/.next/server/app/api/external-apps/route.js +1 -1
  26. package/.next/server/app/api/hooks/claude-done/route.js +1 -1
  27. package/.next/server/app/api/repositories/clone/[jobId]/route.js +1 -1
  28. package/.next/server/app/api/repositories/clone/route.js +1 -1
  29. package/.next/server/app/api/repositories/excluded/route.js +7 -7
  30. package/.next/server/app/api/repositories/restore/route.js +3 -3
  31. package/.next/server/app/api/repositories/route.js +13 -11
  32. package/.next/server/app/api/repositories/scan/route.js +1 -1
  33. package/.next/server/app/api/repositories/sync/route.js +3 -3
  34. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  35. package/.next/server/app/api/worktrees/[id]/cli-tool/route.js +1 -1
  36. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  37. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js +1 -0
  38. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js.nft.json +1 -0
  39. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js +9 -0
  40. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js.nft.json +1 -0
  41. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
  42. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  43. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  44. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  45. package/.next/server/app/api/worktrees/[id]/logs/route.js +2 -2
  46. package/.next/server/app/api/worktrees/[id]/memos/[memoId]/route.js +1 -1
  47. package/.next/server/app/api/worktrees/[id]/memos/route.js +1 -1
  48. package/.next/server/app/api/worktrees/[id]/messages/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  50. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  51. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  52. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -0
  53. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js.nft.json +1 -0
  54. package/.next/server/app/api/worktrees/[id]/schedules/route.js +4 -0
  55. package/.next/server/app/api/worktrees/[id]/schedules/route.js.nft.json +1 -0
  56. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  57. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  58. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  59. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  60. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
  61. package/.next/server/app/api/worktrees/[id]/tree/route.js +1 -1
  62. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
  63. package/.next/server/app/api/worktrees/[id]/viewed/route.js +1 -1
  64. package/.next/server/app/api/worktrees/route.js +1 -1
  65. package/.next/server/app/login/page.js.nft.json +1 -1
  66. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  67. package/.next/server/app/page.js.nft.json +1 -1
  68. package/.next/server/app/page_client-reference-manifest.js +1 -1
  69. package/.next/server/app/proxy/[...path]/route.js +1 -1
  70. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  71. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  72. package/.next/server/app/worktrees/[id]/page.js +8 -3
  73. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  74. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.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/2314.js +1 -0
  79. package/.next/server/chunks/3860.js +1 -1
  80. package/.next/server/chunks/6228.js +1 -0
  81. package/.next/server/chunks/7425.js +85 -30
  82. package/.next/server/chunks/7536.js +1 -1
  83. package/.next/server/chunks/7566.js +2 -2
  84. package/.next/server/functions-config-manifest.json +1 -1
  85. package/.next/server/middleware-manifest.json +5 -5
  86. package/.next/server/pages/500.html +1 -1
  87. package/.next/server/server-reference-manifest.json +1 -1
  88. package/.next/static/chunks/app/worktrees/[id]/page-0c889ab3f30d5af7.js +1 -0
  89. package/.next/static/css/bd6065b03ddb3efd.css +3 -0
  90. package/.next/trace +5 -5
  91. package/.next/types/app/api/worktrees/[id]/execution-logs/[logId]/route.ts +343 -0
  92. package/.next/types/app/api/worktrees/[id]/execution-logs/route.ts +343 -0
  93. package/.next/types/app/api/worktrees/[id]/schedules/[scheduleId]/route.ts +343 -0
  94. package/.next/types/app/api/worktrees/[id]/schedules/route.ts +343 -0
  95. package/dist/cli/utils/docs-reader.d.ts.map +1 -1
  96. package/dist/cli/utils/docs-reader.js +1 -0
  97. package/dist/server/server.js +5 -0
  98. package/dist/server/src/config/cmate-constants.js +79 -0
  99. package/dist/server/src/config/schedule-config.js +54 -0
  100. package/dist/server/src/lib/claude-executor.js +147 -0
  101. package/dist/server/src/lib/claude-session.js +31 -6
  102. package/dist/server/src/lib/cli-patterns.js +1 -1
  103. package/dist/server/src/lib/cmate-parser.js +240 -0
  104. package/dist/server/src/lib/db-instance.js +3 -0
  105. package/dist/server/src/lib/db-migrations.js +96 -2
  106. package/dist/server/src/lib/env-sanitizer.js +57 -0
  107. package/dist/server/src/lib/response-poller.js +3 -2
  108. package/dist/server/src/lib/schedule-manager.js +397 -0
  109. package/dist/server/src/types/cmate.js +6 -0
  110. package/package.json +2 -1
  111. package/.next/static/chunks/app/worktrees/[id]/page-9418e49bdc1de02c.js +0 -1
  112. package/.next/static/css/b9ea6a4fad17dc32.css +0 -3
  113. /package/.next/static/{clTo9tuAoPMLcGRuVENfO → j8HFvzDZj7tHjAnhpXUno}/_buildManifest.js +0 -0
  114. /package/.next/static/{clTo9tuAoPMLcGRuVENfO → j8HFvzDZj7tHjAnhpXUno}/_ssgManifest.js +0 -0
@@ -39,6 +39,7 @@ const worktrees_1 = require("./src/lib/worktrees");
39
39
  const db_instance_1 = require("./src/lib/db-instance");
40
40
  const response_poller_1 = require("./src/lib/response-poller");
41
41
  const auto_yes_manager_1 = require("./src/lib/auto-yes-manager");
42
+ const schedule_manager_1 = require("./src/lib/schedule-manager");
42
43
  const db_migrations_1 = require("./src/lib/db-migrations");
43
44
  const env_1 = require("./src/lib/env");
44
45
  const db_repository_1 = require("./src/lib/db-repository");
@@ -227,6 +228,8 @@ app.prepare().then(() => {
227
228
  console.log(`> WebSocket server ready`);
228
229
  // Initialize worktrees after server starts
229
230
  await initializeWorktrees();
231
+ // [S3-010] Initialize schedule manager AFTER worktrees are ready
232
+ (0, schedule_manager_1.initScheduleManager)();
230
233
  });
231
234
  // Graceful shutdown with timeout
232
235
  let isShuttingDown = false;
@@ -241,6 +244,8 @@ app.prepare().then(() => {
241
244
  (0, response_poller_1.stopAllPolling)();
242
245
  // Issue #138: Stop all auto-yes pollers
243
246
  (0, auto_yes_manager_1.stopAllAutoYesPolling)();
247
+ // Issue #294: Stop all scheduled executions (SIGKILL fire-and-forget)
248
+ (0, schedule_manager_1.stopAllSchedules)();
244
249
  // Close WebSocket connections immediately (don't wait)
245
250
  (0, ws_server_1.closeWebSocket)();
246
251
  // Force exit after 3 seconds if graceful shutdown fails
@@ -0,0 +1,79 @@
1
+ "use strict";
2
+ /**
3
+ * CMATE.md Shared Constants
4
+ * Issue #294: Constants shared between server-side parser and client-side validator
5
+ *
6
+ * This module has NO Node.js dependencies (no 'fs'), so it can be safely
7
+ * imported from both server and client code.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.MAX_SCHEDULE_ENTRIES = exports.MAX_CRON_EXPRESSION_LENGTH = exports.NAME_PATTERN = exports.CONTROL_CHAR_PATTERN = exports.CMATE_FILENAME = void 0;
11
+ exports.sanitizeContent = sanitizeContent;
12
+ exports.isValidCronExpression = isValidCronExpression;
13
+ // =============================================================================
14
+ // File
15
+ // =============================================================================
16
+ /** CMATE.md filename */
17
+ exports.CMATE_FILENAME = 'CMATE.md';
18
+ // =============================================================================
19
+ // Sanitization
20
+ // =============================================================================
21
+ /**
22
+ * Unicode control character regex for sanitization.
23
+ * Matches: C0 control chars (except \t \n \r), C1 control chars,
24
+ * zero-width characters, directional control characters.
25
+ *
26
+ * NOTE: No /g flag on the export — callers must use String.replace(pattern, '')
27
+ * with /g or String.replaceAll() to avoid lastIndex state issues.
28
+ *
29
+ * [S4-002] Strips potentially dangerous Unicode control characters
30
+ */
31
+ exports.CONTROL_CHAR_PATTERN =
32
+ // eslint-disable-next-line no-control-regex
33
+ /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F\x80-\x9F\u200B-\u200F\u2028-\u202F\uFEFF]/;
34
+ /**
35
+ * Remove Unicode control characters from a string.
36
+ * Preserves tabs (\t), newlines (\n), and carriage returns (\r).
37
+ *
38
+ * @param content - Raw string to sanitize
39
+ * @returns Sanitized string with control characters removed
40
+ */
41
+ function sanitizeContent(content) {
42
+ // Use RegExp constructor with /g to avoid lastIndex state on the shared pattern
43
+ return content.replace(new RegExp(exports.CONTROL_CHAR_PATTERN.source, 'g'), '');
44
+ }
45
+ // =============================================================================
46
+ // Name Validation
47
+ // =============================================================================
48
+ /**
49
+ * Name validation pattern.
50
+ * Allows: ASCII word chars, Japanese chars (CJK, Hiragana, Katakana, Symbols),
51
+ * spaces, and hyphens. Length: 1-100 characters.
52
+ *
53
+ * [S4-011] Prevents injection through name field
54
+ */
55
+ exports.NAME_PATTERN = /^[\w\u3000-\u303F\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uF900-\uFAFF\s-]{1,100}$/;
56
+ // =============================================================================
57
+ // Limits
58
+ // =============================================================================
59
+ /** Maximum cron expression length */
60
+ exports.MAX_CRON_EXPRESSION_LENGTH = 100;
61
+ /** Maximum number of schedule entries per worktree */
62
+ exports.MAX_SCHEDULE_ENTRIES = 100;
63
+ // =============================================================================
64
+ // Cron Validation
65
+ // =============================================================================
66
+ /**
67
+ * Validate a cron expression.
68
+ * Checks length and basic format (5-6 fields separated by spaces).
69
+ *
70
+ * @param expression - Cron expression to validate
71
+ * @returns true if the expression appears valid
72
+ */
73
+ function isValidCronExpression(expression) {
74
+ if (expression.length > exports.MAX_CRON_EXPRESSION_LENGTH) {
75
+ return false;
76
+ }
77
+ const parts = expression.trim().split(/\s+/);
78
+ return parts.length >= 5 && parts.length <= 6;
79
+ }
@@ -0,0 +1,54 @@
1
+ "use strict";
2
+ /**
3
+ * Schedule Execution Configuration Constants
4
+ * Issue #294: Centralized constants for schedule-related API routes
5
+ *
6
+ * Eliminates duplication of validation constants and UUID validation
7
+ * across schedules/route.ts, schedules/[scheduleId]/route.ts,
8
+ * and execution-logs/[logId]/route.ts.
9
+ *
10
+ * [S4-014] UUID v4 format validation
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.UUID_V4_PATTERN = exports.DEFAULT_PERMISSIONS = exports.CODEX_SANDBOXES = exports.CLAUDE_PERMISSIONS = exports.MAX_SCHEDULE_CRON_LENGTH = exports.MAX_SCHEDULE_MESSAGE_LENGTH = exports.MAX_SCHEDULE_NAME_LENGTH = void 0;
14
+ exports.isValidUuidV4 = isValidUuidV4;
15
+ // =============================================================================
16
+ // Validation Constants
17
+ // =============================================================================
18
+ /** Maximum schedule name length */
19
+ exports.MAX_SCHEDULE_NAME_LENGTH = 100;
20
+ /** Maximum message length for schedule execution */
21
+ exports.MAX_SCHEDULE_MESSAGE_LENGTH = 10000;
22
+ /** Maximum cron expression length */
23
+ exports.MAX_SCHEDULE_CRON_LENGTH = 100;
24
+ // =============================================================================
25
+ // Permission Constants
26
+ // =============================================================================
27
+ /** Allowed permission values for claude CLI (--permission-mode) */
28
+ exports.CLAUDE_PERMISSIONS = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
29
+ /** Allowed sandbox values for codex CLI (--sandbox) */
30
+ exports.CODEX_SANDBOXES = ['read-only', 'workspace-write', 'danger-full-access'];
31
+ /** Default permission per CLI tool */
32
+ exports.DEFAULT_PERMISSIONS = {
33
+ claude: 'acceptEdits',
34
+ codex: 'workspace-write',
35
+ };
36
+ // =============================================================================
37
+ // UUID Validation
38
+ // =============================================================================
39
+ /**
40
+ * UUID v4 validation pattern.
41
+ * Matches standard UUID v4 format: xxxxxxxx-xxxx-4xxx-[89ab]xxx-xxxxxxxxxxxx
42
+ *
43
+ * [S4-014] Used to validate schedule IDs and execution log IDs
44
+ */
45
+ exports.UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
46
+ /**
47
+ * Validate that a string is a valid UUID v4 format.
48
+ *
49
+ * @param id - String to validate
50
+ * @returns true if the string matches UUID v4 format
51
+ */
52
+ function isValidUuidV4(id) {
53
+ return exports.UUID_V4_PATTERN.test(id);
54
+ }
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ /**
3
+ * Claude CLI Executor
4
+ * Issue #294: Executes claude -p commands for scheduled executions
5
+ *
6
+ * Security:
7
+ * - Uses execFile (not exec) to prevent shell injection
8
+ * - Sanitizes environment variables via env-sanitizer.ts
9
+ * - Limits output size to prevent memory exhaustion
10
+ * - Enforces execution timeout
11
+ */
12
+ Object.defineProperty(exports, "__esModule", { value: true });
13
+ exports.ALLOWED_CLI_TOOLS = exports.MAX_MESSAGE_LENGTH = exports.EXECUTION_TIMEOUT_MS = exports.MAX_STORED_OUTPUT_SIZE = exports.MAX_OUTPUT_SIZE = void 0;
14
+ exports.truncateOutput = truncateOutput;
15
+ exports.buildCliArgs = buildCliArgs;
16
+ exports.executeClaudeCommand = executeClaudeCommand;
17
+ exports.getActiveProcesses = getActiveProcesses;
18
+ const child_process_1 = require("child_process");
19
+ const env_sanitizer_1 = require("./env-sanitizer");
20
+ const cli_patterns_1 = require("./cli-patterns");
21
+ // =============================================================================
22
+ // Constants
23
+ // =============================================================================
24
+ /** Maximum output buffer size for execFile (1MB) */
25
+ exports.MAX_OUTPUT_SIZE = 1 * 1024 * 1024;
26
+ /** Maximum output size stored in DB (100KB) */
27
+ exports.MAX_STORED_OUTPUT_SIZE = 100 * 1024;
28
+ /** Execution timeout in milliseconds (5 minutes) */
29
+ exports.EXECUTION_TIMEOUT_MS = 5 * 60 * 1000;
30
+ /** Maximum message length sent to claude -p */
31
+ exports.MAX_MESSAGE_LENGTH = 10000;
32
+ /** Allowed CLI tool identifiers for scheduled execution */
33
+ exports.ALLOWED_CLI_TOOLS = new Set(['claude', 'codex']);
34
+ // =============================================================================
35
+ // Executor
36
+ // =============================================================================
37
+ /**
38
+ * Truncate output to MAX_STORED_OUTPUT_SIZE bytes.
39
+ * Appends a truncation notice if truncated.
40
+ *
41
+ * @param output - Raw output string
42
+ * @returns Truncated output string
43
+ */
44
+ function truncateOutput(output) {
45
+ if (Buffer.byteLength(output, 'utf-8') <= exports.MAX_STORED_OUTPUT_SIZE) {
46
+ return output;
47
+ }
48
+ // Truncate to MAX_STORED_OUTPUT_SIZE bytes
49
+ const buffer = Buffer.from(output, 'utf-8');
50
+ const truncated = buffer.subarray(0, exports.MAX_STORED_OUTPUT_SIZE).toString('utf-8');
51
+ return truncated + '\n\n--- Output truncated (exceeded 100KB limit) ---';
52
+ }
53
+ /**
54
+ * Build CLI arguments for non-interactive execution based on CLI tool type.
55
+ *
56
+ * - claude: -p <message> --output-format text --permission-mode <permission>
57
+ * - codex: exec <message> --sandbox <permission>
58
+ * - others: -p <message> (fallback)
59
+ *
60
+ * @param message - Prompt message
61
+ * @param cliToolId - CLI tool identifier
62
+ * @param permission - Permission mode (claude: --permission-mode, codex: --sandbox)
63
+ * @returns Array of CLI arguments
64
+ */
65
+ function buildCliArgs(message, cliToolId, permission) {
66
+ switch (cliToolId) {
67
+ case 'codex':
68
+ return ['exec', message, '--sandbox', permission ?? 'workspace-write'];
69
+ case 'claude':
70
+ default:
71
+ return ['-p', message, '--output-format', 'text', '--permission-mode', permission ?? 'acceptEdits'];
72
+ }
73
+ }
74
+ /**
75
+ * Execute a CLI command in a worktree directory.
76
+ *
77
+ * @param message - Prompt message to send
78
+ * @param cwd - Working directory (worktree path from DB)
79
+ * @param cliToolId - CLI tool to use (default: 'claude')
80
+ * @param permission - Permission mode (claude: --permission-mode, codex: --sandbox)
81
+ * @returns Execution result with output and status
82
+ */
83
+ async function executeClaudeCommand(message, cwd, cliToolId = 'claude', permission) {
84
+ // Validate cliToolId against whitelist [SEC-001]
85
+ if (!exports.ALLOWED_CLI_TOOLS.has(cliToolId)) {
86
+ return {
87
+ output: '',
88
+ exitCode: null,
89
+ status: 'failed',
90
+ error: `Invalid CLI tool: ${cliToolId}`,
91
+ };
92
+ }
93
+ // Validate message length
94
+ const truncatedMessage = message.length > exports.MAX_MESSAGE_LENGTH
95
+ ? message.substring(0, exports.MAX_MESSAGE_LENGTH)
96
+ : message;
97
+ const args = buildCliArgs(truncatedMessage, cliToolId, permission);
98
+ return new Promise((resolve) => {
99
+ const child = (0, child_process_1.execFile)(cliToolId, args, {
100
+ cwd,
101
+ env: (0, env_sanitizer_1.sanitizeEnvForChildProcess)(),
102
+ maxBuffer: exports.MAX_OUTPUT_SIZE,
103
+ timeout: exports.EXECUTION_TIMEOUT_MS,
104
+ }, (error, stdout, stderr) => {
105
+ if (error) {
106
+ const isTimeout = error.killed || error.code === 'ETIMEDOUT';
107
+ const rawOutput = (0, cli_patterns_1.stripAnsi)(stdout || stderr || error.message);
108
+ const output = truncateOutput(rawOutput);
109
+ resolve({
110
+ output,
111
+ exitCode: error.code ? parseInt(String(error.code), 10) || null : null,
112
+ status: isTimeout ? 'timeout' : 'failed',
113
+ error: error.message,
114
+ });
115
+ return;
116
+ }
117
+ const rawOutput = (0, cli_patterns_1.stripAnsi)(stdout || '');
118
+ const output = truncateOutput(rawOutput);
119
+ resolve({
120
+ output,
121
+ exitCode: 0,
122
+ status: 'completed',
123
+ });
124
+ });
125
+ // Close stdin immediately to prevent hanging on yes/no prompts
126
+ child.stdin?.end();
127
+ // Return the child process PID for tracking
128
+ if (child.pid) {
129
+ // Store PID in global active processes for cleanup on shutdown
130
+ const activeProcesses = getActiveProcesses();
131
+ activeProcesses.set(child.pid, child);
132
+ child.on('exit', () => {
133
+ activeProcesses.delete(child.pid);
134
+ });
135
+ }
136
+ });
137
+ }
138
+ /**
139
+ * Get the global active processes map.
140
+ * Uses globalThis for hot reload persistence.
141
+ */
142
+ function getActiveProcesses() {
143
+ if (!globalThis.__scheduleActiveProcesses) {
144
+ globalThis.__scheduleActiveProcesses = new Map();
145
+ }
146
+ return globalThis.__scheduleActiveProcesses;
147
+ }
@@ -136,6 +136,17 @@ exports.CLAUDE_PROMPT_POLL_INTERVAL = 200;
136
136
  * 40 is an empirical threshold with safety margin.
137
137
  */
138
138
  const MAX_SHELL_PROMPT_LENGTH = 40;
139
+ /**
140
+ * Number of tail lines used for error pattern detection in isSessionHealthy()
141
+ *
142
+ * Error patterns are only searched within the last N lines of pane output,
143
+ * not the entire buffer. This prevents false negatives where historical
144
+ * (already recovered) errors in the scrollback trigger unhealthy detection.
145
+ *
146
+ * 10 lines provides sufficient window to catch recent errors while ignoring
147
+ * historical ones that have scrolled up.
148
+ */
149
+ const HEALTH_CHECK_ERROR_TAIL_LINES = 10;
139
150
  /**
140
151
  * Cached Claude CLI path
141
152
  */
@@ -265,21 +276,31 @@ async function isSessionHealthy(sessionName) {
265
276
  if (trimmed === '') {
266
277
  return { healthy: false, reason: 'empty output' };
267
278
  }
268
- // S2-F010: Error pattern detection (HealthCheckResult format)
279
+ // Active state detection: check for Claude prompt BEFORE error patterns.
280
+ // This prevents false negatives where historical (recovered) errors in
281
+ // the pane scrollback cause a currently-active session to be marked unhealthy.
282
+ if (cli_patterns_1.CLAUDE_PROMPT_PATTERN.test(trimmed)) {
283
+ return { healthy: true };
284
+ }
285
+ // S2-F010: Error pattern detection - limited to tail lines only.
286
+ // Only the last HEALTH_CHECK_ERROR_TAIL_LINES lines are searched, so
287
+ // historical errors that have scrolled up do not trigger false negatives.
288
+ const allLines = trimmed.split('\n').filter(line => line.trim() !== '');
289
+ const tailLines = allLines.slice(-HEALTH_CHECK_ERROR_TAIL_LINES);
290
+ const tailText = tailLines.join('\n');
269
291
  // MF-001: Check error patterns from cli-patterns.ts (SRP - pattern management centralized)
270
292
  for (const pattern of cli_patterns_1.CLAUDE_SESSION_ERROR_PATTERNS) {
271
- if (trimmed.includes(pattern)) {
293
+ if (tailText.includes(pattern)) {
272
294
  return { healthy: false, reason: `error pattern: ${pattern}` };
273
295
  }
274
296
  }
275
297
  for (const regex of cli_patterns_1.CLAUDE_SESSION_ERROR_REGEX_PATTERNS) {
276
- if (regex.test(trimmed)) {
298
+ if (regex.test(tailText)) {
277
299
  return { healthy: false, reason: `error pattern: ${regex.source}` };
278
300
  }
279
301
  }
280
302
  // S2-F002: Extract last line after empty line filtering
281
- const lines = trimmed.split('\n').filter(line => line.trim() !== '');
282
- const lastLine = lines[lines.length - 1]?.trim() ?? '';
303
+ const lastLine = allLines[allLines.length - 1]?.trim() ?? '';
283
304
  // F006: Line length check BEFORE SHELL_PROMPT_ENDINGS check (early return)
284
305
  if (lastLine.length >= MAX_SHELL_PROMPT_LENGTH) {
285
306
  // Long lines are not shell prompts -> treat as healthy (early return)
@@ -415,7 +436,11 @@ async function isClaudeRunning(worktreeId) {
415
436
  // MF-S3-001: Verify session health to avoid reporting broken sessions as running
416
437
  // S2-F001: await + extract .healthy to maintain boolean return type
417
438
  const result = await isSessionHealthy(sessionName);
418
- return result.healthy;
439
+ if (!result.healthy) {
440
+ console.warn(`[isClaudeRunning] Session ${sessionName} unhealthy: ${result.reason}`);
441
+ return false;
442
+ }
443
+ return true;
419
444
  }
420
445
  /**
421
446
  * Get Claude session state
@@ -268,7 +268,7 @@ exports.CLAUDE_SESSION_ERROR_PATTERNS = [
268
268
  * SEC-SF-004: See CLAUDE_SESSION_ERROR_PATTERNS JSDoc for pattern maintenance process.
269
269
  */
270
270
  exports.CLAUDE_SESSION_ERROR_REGEX_PATTERNS = [
271
- /Error:.*Claude/,
271
+ /^Error:.*Claude Code/,
272
272
  ];
273
273
  function buildDetectPromptOptions(cliToolId) {
274
274
  if (cliToolId === 'claude') {
@@ -0,0 +1,240 @@
1
+ "use strict";
2
+ /**
3
+ * CMATE.md Parser
4
+ * Issue #294: Schedule execution feature
5
+ *
6
+ * Parses CMATE.md files (Markdown table format) from worktree root directories.
7
+ * Provides a generic table parser and a specialized schedule section parser.
8
+ *
9
+ * Security:
10
+ * - Path traversal prevention (realpath + worktree directory validation)
11
+ * - Unicode control character sanitization
12
+ * - Name validation with strict pattern matching
13
+ * - Cron expression validation
14
+ */
15
+ var __importDefault = (this && this.__importDefault) || function (mod) {
16
+ return (mod && mod.__esModule) ? mod : { "default": mod };
17
+ };
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.MIN_CRON_INTERVAL = exports.CONTROL_CHAR_REGEX = exports.isValidCronExpression = exports.MAX_SCHEDULE_ENTRIES = exports.MAX_CRON_EXPRESSION_LENGTH = exports.NAME_PATTERN = exports.CMATE_FILENAME = void 0;
20
+ exports.sanitizeMessageContent = sanitizeMessageContent;
21
+ exports.validateCmatePath = validateCmatePath;
22
+ exports.parseCmateFile = parseCmateFile;
23
+ exports.parseSchedulesSection = parseSchedulesSection;
24
+ exports.readCmateFile = readCmateFile;
25
+ const fs_1 = require("fs");
26
+ const path_1 = __importDefault(require("path"));
27
+ const schedule_config_1 = require("../config/schedule-config");
28
+ const cmate_constants_1 = require("../config/cmate-constants");
29
+ Object.defineProperty(exports, "CMATE_FILENAME", { enumerable: true, get: function () { return cmate_constants_1.CMATE_FILENAME; } });
30
+ Object.defineProperty(exports, "NAME_PATTERN", { enumerable: true, get: function () { return cmate_constants_1.NAME_PATTERN; } });
31
+ Object.defineProperty(exports, "MAX_CRON_EXPRESSION_LENGTH", { enumerable: true, get: function () { return cmate_constants_1.MAX_CRON_EXPRESSION_LENGTH; } });
32
+ Object.defineProperty(exports, "MAX_SCHEDULE_ENTRIES", { enumerable: true, get: function () { return cmate_constants_1.MAX_SCHEDULE_ENTRIES; } });
33
+ Object.defineProperty(exports, "isValidCronExpression", { enumerable: true, get: function () { return cmate_constants_1.isValidCronExpression; } });
34
+ /**
35
+ * @deprecated Use CONTROL_CHAR_PATTERN from '../config/cmate-constants' instead.
36
+ * Kept for backward compatibility with existing tests.
37
+ */
38
+ exports.CONTROL_CHAR_REGEX = new RegExp(cmate_constants_1.CONTROL_CHAR_PATTERN.source, 'g');
39
+ /** Minimum cron interval pattern (every minute) */
40
+ exports.MIN_CRON_INTERVAL = '* * * * *';
41
+ // =============================================================================
42
+ // Sanitization
43
+ // =============================================================================
44
+ /**
45
+ * Remove Unicode control characters from a string.
46
+ * Preserves tabs (\t), newlines (\n), and carriage returns (\r).
47
+ *
48
+ * @param content - Raw string to sanitize
49
+ * @returns Sanitized string with control characters removed
50
+ */
51
+ function sanitizeMessageContent(content) {
52
+ return (0, cmate_constants_1.sanitizeContent)(content);
53
+ }
54
+ // =============================================================================
55
+ // Path Validation
56
+ // =============================================================================
57
+ /**
58
+ * Validate that a CMATE.md file path is within the expected worktree directory.
59
+ * Prevents path traversal attacks by resolving symlinks and verifying containment.
60
+ *
61
+ * @param filePath - Path to CMATE.md file
62
+ * @param worktreeDir - Expected worktree directory
63
+ * @returns true if path is valid and within worktree directory
64
+ * @throws Error if path traversal is detected
65
+ */
66
+ function validateCmatePath(filePath, worktreeDir) {
67
+ const realFilePath = (0, fs_1.realpathSync)(filePath);
68
+ const realWorktreeDir = (0, fs_1.realpathSync)(worktreeDir);
69
+ // Ensure the file is within the worktree directory
70
+ if (!realFilePath.startsWith(realWorktreeDir + path_1.default.sep) &&
71
+ realFilePath !== path_1.default.join(realWorktreeDir, cmate_constants_1.CMATE_FILENAME)) {
72
+ throw new Error(`Path traversal detected: ${filePath} is not within ${worktreeDir}`);
73
+ }
74
+ return true;
75
+ }
76
+ // =============================================================================
77
+ // Generic Markdown Table Parser
78
+ // =============================================================================
79
+ /**
80
+ * Parse a CMATE.md file into a generic structure.
81
+ * Returns a Map where keys are section names (from ## headers)
82
+ * and values are arrays of row data (each row is an array of cell values).
83
+ *
84
+ * [S1-010] Generic design: returns Map<string, string[][]>
85
+ *
86
+ * @param content - Raw CMATE.md file content
87
+ * @returns Map of section name to table rows
88
+ */
89
+ function parseCmateFile(content) {
90
+ const result = new Map();
91
+ const lines = content.split('\n');
92
+ let currentSection = null;
93
+ let headerParsed = false;
94
+ let separatorParsed = false;
95
+ for (const line of lines) {
96
+ const trimmed = line.trim();
97
+ // Detect section headers (## SectionName)
98
+ const headerMatch = trimmed.match(/^##\s+(.+)$/);
99
+ if (headerMatch) {
100
+ currentSection = headerMatch[1].trim();
101
+ headerParsed = false;
102
+ separatorParsed = false;
103
+ if (!result.has(currentSection)) {
104
+ result.set(currentSection, []);
105
+ }
106
+ continue;
107
+ }
108
+ // Skip empty lines
109
+ if (!trimmed) {
110
+ continue;
111
+ }
112
+ // Skip non-table lines
113
+ if (!trimmed.startsWith('|')) {
114
+ continue;
115
+ }
116
+ if (!currentSection) {
117
+ continue;
118
+ }
119
+ // Parse table row
120
+ if (!headerParsed) {
121
+ // First row is header - skip it
122
+ headerParsed = true;
123
+ continue;
124
+ }
125
+ if (!separatorParsed) {
126
+ // Second row is separator (|---|---|) - skip it
127
+ if (trimmed.match(/^\|[\s-:|]+\|$/)) {
128
+ separatorParsed = true;
129
+ continue;
130
+ }
131
+ // If it's not a separator, treat it as data
132
+ separatorParsed = true;
133
+ }
134
+ // Parse data row
135
+ const cells = trimmed
136
+ .split('|')
137
+ .slice(1, -1) // Remove leading and trailing empty strings from split
138
+ .map((cell) => cell.trim());
139
+ if (cells.length > 0) {
140
+ result.get(currentSection).push(cells);
141
+ }
142
+ }
143
+ return result;
144
+ }
145
+ // =============================================================================
146
+ // Schedule Section Parser
147
+ // =============================================================================
148
+ /**
149
+ * Parse the Schedules section of a CMATE.md file into typed ScheduleEntry objects.
150
+ *
151
+ * Expected table format:
152
+ * | Name | Cron | Message | CLI Tool | Enabled |
153
+ * |------|------|---------|----------|---------|
154
+ * | daily-review | 0 9 * * * | Review code changes | claude | true |
155
+ *
156
+ * Entries with invalid names, cron expressions, or missing required fields
157
+ * are silently skipped with a console.warn.
158
+ *
159
+ * @param rows - Raw table rows from parseCmateFile() for the Schedules section
160
+ * @returns Array of validated ScheduleEntry objects
161
+ */
162
+ function parseSchedulesSection(rows) {
163
+ const entries = [];
164
+ for (const row of rows) {
165
+ if (entries.length >= cmate_constants_1.MAX_SCHEDULE_ENTRIES) {
166
+ console.warn(`[cmate-parser] Maximum schedule entries (${cmate_constants_1.MAX_SCHEDULE_ENTRIES}) reached, skipping remaining`);
167
+ break;
168
+ }
169
+ // Minimum required columns: Name, Cron, Message
170
+ if (row.length < 3) {
171
+ console.warn('[cmate-parser] Skipping row with insufficient columns:', row);
172
+ continue;
173
+ }
174
+ const [name, cronExpression, message, cliToolId, enabledStr, permissionStr] = row;
175
+ // Validate name
176
+ const sanitizedName = sanitizeMessageContent(name);
177
+ if (!cmate_constants_1.NAME_PATTERN.test(sanitizedName)) {
178
+ console.warn(`[cmate-parser] Skipping entry with invalid name: "${sanitizedName}"`);
179
+ continue;
180
+ }
181
+ // Validate cron expression
182
+ if (!(0, cmate_constants_1.isValidCronExpression)(cronExpression)) {
183
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with invalid cron: "${cronExpression}"`);
184
+ continue;
185
+ }
186
+ // Sanitize message
187
+ const sanitizedMessage = sanitizeMessageContent(message);
188
+ if (!sanitizedMessage) {
189
+ console.warn(`[cmate-parser] Skipping entry "${sanitizedName}" with empty message`);
190
+ continue;
191
+ }
192
+ // Parse enabled (default: true)
193
+ const enabled = enabledStr === undefined ||
194
+ enabledStr === '' ||
195
+ enabledStr.toLowerCase() === 'true';
196
+ // Parse permission with validation
197
+ const resolvedCliToolId = cliToolId?.trim() || 'claude';
198
+ const defaultPermission = schedule_config_1.DEFAULT_PERMISSIONS[resolvedCliToolId] ?? '';
199
+ let permission = permissionStr?.trim() || defaultPermission;
200
+ // Validate permission against allowed values
201
+ const allowedValues = resolvedCliToolId === 'codex' ? schedule_config_1.CODEX_SANDBOXES : schedule_config_1.CLAUDE_PERMISSIONS;
202
+ if (permission && !allowedValues.includes(permission)) {
203
+ console.warn(`[cmate-parser] Invalid permission "${permission}" for ${resolvedCliToolId} in entry "${sanitizedName}", using default "${defaultPermission}"`);
204
+ permission = defaultPermission;
205
+ }
206
+ entries.push({
207
+ name: sanitizedName,
208
+ cronExpression: cronExpression.trim(),
209
+ message: sanitizedMessage,
210
+ cliToolId: resolvedCliToolId,
211
+ enabled,
212
+ permission,
213
+ });
214
+ }
215
+ return entries;
216
+ }
217
+ /**
218
+ * Read and parse a CMATE.md file from a worktree directory.
219
+ *
220
+ * @param worktreeDir - Path to the worktree directory
221
+ * @returns Parsed CmateConfig, or null if the file doesn't exist
222
+ * @throws Error if path traversal is detected
223
+ */
224
+ function readCmateFile(worktreeDir) {
225
+ const filePath = path_1.default.join(worktreeDir, cmate_constants_1.CMATE_FILENAME);
226
+ try {
227
+ // Validate path before reading
228
+ validateCmatePath(filePath, worktreeDir);
229
+ const content = (0, fs_1.readFileSync)(filePath, 'utf-8');
230
+ return parseCmateFile(content);
231
+ }
232
+ catch (error) {
233
+ if (error instanceof Error &&
234
+ 'code' in error &&
235
+ error.code === 'ENOENT') {
236
+ return null;
237
+ }
238
+ throw error;
239
+ }
240
+ }
@@ -45,6 +45,9 @@ function getDbInstance() {
45
45
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
46
46
  }
47
47
  dbInstance = new better_sqlite3_1.default(dbPath);
48
+ // Issue #294: Enable foreign key enforcement BEFORE migrations
49
+ // This ensures ON DELETE CASCADE works correctly for all tables
50
+ dbInstance.pragma('foreign_keys = ON');
48
51
  (0, db_migrations_1.runMigrations)(dbInstance);
49
52
  }
50
53
  return dbInstance;