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
@@ -19,7 +19,7 @@ const db_1 = require("./db");
19
19
  * Current schema version
20
20
  * Increment this when adding new migrations
21
21
  */
22
- exports.CURRENT_SCHEMA_VERSION = 16;
22
+ exports.CURRENT_SCHEMA_VERSION = 17;
23
23
  /**
24
24
  * Migration registry
25
25
  * All migrations should be added to this array in order
@@ -717,6 +717,100 @@ const migrations = [
717
717
  `);
718
718
  console.log('✓ Removed issue_no column from external_apps table');
719
719
  }
720
+ },
721
+ {
722
+ version: 17,
723
+ name: 'add-scheduled-executions-and-execution-logs',
724
+ up: (db) => {
725
+ // Issue #294: Schedule execution feature
726
+ // [S3-002] Clean up orphan records BEFORE creating new tables with FK constraints
727
+ // These records may exist if worktrees/repositories were deleted while FK was disabled
728
+ db.exec(`
729
+ DELETE FROM chat_messages WHERE worktree_id NOT IN (SELECT id FROM worktrees);
730
+ `);
731
+ db.exec(`
732
+ DELETE FROM session_states WHERE worktree_id NOT IN (SELECT id FROM worktrees);
733
+ `);
734
+ db.exec(`
735
+ DELETE FROM worktree_memos WHERE worktree_id NOT IN (SELECT id FROM worktrees);
736
+ `);
737
+ db.exec(`
738
+ UPDATE clone_jobs SET repository_id = NULL
739
+ WHERE repository_id IS NOT NULL AND repository_id NOT IN (SELECT id FROM repositories);
740
+ `);
741
+ // Create scheduled_executions table
742
+ db.exec(`
743
+ CREATE TABLE scheduled_executions (
744
+ id TEXT PRIMARY KEY,
745
+ worktree_id TEXT NOT NULL,
746
+ cli_tool_id TEXT DEFAULT 'claude',
747
+ name TEXT NOT NULL,
748
+ message TEXT NOT NULL,
749
+ cron_expression TEXT,
750
+ enabled INTEGER DEFAULT 1,
751
+ last_executed_at INTEGER,
752
+ next_execute_at INTEGER,
753
+ created_at INTEGER NOT NULL,
754
+ updated_at INTEGER NOT NULL,
755
+ UNIQUE(worktree_id, name),
756
+ FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
757
+ );
758
+ `);
759
+ // Create index on worktree_id for scheduled_executions
760
+ db.exec(`
761
+ CREATE INDEX idx_scheduled_executions_worktree
762
+ ON scheduled_executions(worktree_id);
763
+ `);
764
+ // Create index on enabled for filtering active schedules
765
+ db.exec(`
766
+ CREATE INDEX idx_scheduled_executions_enabled
767
+ ON scheduled_executions(enabled);
768
+ `);
769
+ // Create execution_logs table
770
+ db.exec(`
771
+ CREATE TABLE execution_logs (
772
+ id TEXT PRIMARY KEY,
773
+ schedule_id TEXT NOT NULL,
774
+ worktree_id TEXT NOT NULL,
775
+ message TEXT NOT NULL,
776
+ result TEXT,
777
+ exit_code INTEGER,
778
+ status TEXT DEFAULT 'running' CHECK(status IN ('running', 'completed', 'failed', 'timeout', 'cancelled')),
779
+ started_at INTEGER NOT NULL,
780
+ completed_at INTEGER,
781
+ created_at INTEGER NOT NULL,
782
+ FOREIGN KEY (schedule_id) REFERENCES scheduled_executions(id) ON DELETE CASCADE,
783
+ FOREIGN KEY (worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE
784
+ );
785
+ `);
786
+ // Create indexes for execution_logs
787
+ db.exec(`
788
+ CREATE INDEX idx_execution_logs_schedule
789
+ ON execution_logs(schedule_id);
790
+ `);
791
+ db.exec(`
792
+ CREATE INDEX idx_execution_logs_worktree
793
+ ON execution_logs(worktree_id);
794
+ `);
795
+ db.exec(`
796
+ CREATE INDEX idx_execution_logs_status
797
+ ON execution_logs(status);
798
+ `);
799
+ console.log('✓ Cleaned up orphan records');
800
+ console.log('✓ Created scheduled_executions table');
801
+ console.log('✓ Created execution_logs table');
802
+ console.log('✓ Created indexes for schedule tables');
803
+ },
804
+ down: (db) => {
805
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_status');
806
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_worktree');
807
+ db.exec('DROP INDEX IF EXISTS idx_execution_logs_schedule');
808
+ db.exec('DROP TABLE IF EXISTS execution_logs');
809
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_enabled');
810
+ db.exec('DROP INDEX IF EXISTS idx_scheduled_executions_worktree');
811
+ db.exec('DROP TABLE IF EXISTS scheduled_executions');
812
+ console.log('✓ Dropped scheduled_executions and execution_logs tables');
813
+ }
720
814
  }
721
815
  ];
722
816
  /**
@@ -904,7 +998,7 @@ function validateSchema(db) {
904
998
  ORDER BY name
905
999
  `).all();
906
1000
  const tableNames = tables.map(t => t.name);
907
- const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs'];
1001
+ const requiredTables = ['worktrees', 'chat_messages', 'session_states', 'schema_version', 'worktree_memos', 'external_apps', 'repositories', 'clone_jobs', 'scheduled_executions', 'execution_logs'];
908
1002
  const missingTables = requiredTables.filter(t => !tableNames.includes(t));
909
1003
  if (missingTables.length > 0) {
910
1004
  console.error('Missing required tables:', missingTables.join(', '));
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ /**
3
+ * Environment Variable Sanitizer
4
+ * Issue #294: Sanitizes environment variables for child processes
5
+ *
6
+ * Removes sensitive environment variables (auth tokens, certificates, database paths)
7
+ * before spawning child processes like `claude -p`.
8
+ *
9
+ * [S1-001/S4-001] Centralized sensitive key management
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.SENSITIVE_ENV_KEYS = void 0;
13
+ exports.sanitizeEnvForChildProcess = sanitizeEnvForChildProcess;
14
+ /**
15
+ * List of environment variable keys that must be removed before
16
+ * passing environment to child processes.
17
+ *
18
+ * These include authentication tokens, TLS certificates, IP restriction
19
+ * settings, and database paths that should not be inherited by spawned
20
+ * CLI tool processes.
21
+ */
22
+ exports.SENSITIVE_ENV_KEYS = [
23
+ 'CLAUDECODE',
24
+ 'CM_AUTH_TOKEN_HASH',
25
+ 'CM_AUTH_EXPIRE',
26
+ 'CM_HTTPS_KEY',
27
+ 'CM_HTTPS_CERT',
28
+ 'CM_ALLOWED_IPS',
29
+ 'CM_TRUST_PROXY',
30
+ 'CM_DB_PATH',
31
+ ];
32
+ /**
33
+ * Create a sanitized copy of process.env suitable for child processes.
34
+ *
35
+ * Removes all keys listed in SENSITIVE_ENV_KEYS from the environment.
36
+ * Non-sensitive variables (PATH, HOME, NODE_ENV, etc.) are preserved.
37
+ *
38
+ * @returns A shallow copy of process.env with sensitive keys removed
39
+ *
40
+ * @example
41
+ * ```typescript
42
+ * import { execFile } from 'child_process';
43
+ * import { sanitizeEnvForChildProcess } from './env-sanitizer';
44
+ *
45
+ * execFile('claude', ['-p', message], {
46
+ * env: sanitizeEnvForChildProcess(),
47
+ * cwd: worktreePath,
48
+ * });
49
+ * ```
50
+ */
51
+ function sanitizeEnvForChildProcess() {
52
+ const env = { ...process.env };
53
+ for (const key of exports.SENSITIVE_ENV_KEYS) {
54
+ delete env[key];
55
+ }
56
+ return env;
57
+ }
@@ -35,9 +35,10 @@ const cli_patterns_1 = require("./cli-patterns");
35
35
  */
36
36
  const POLLING_INTERVAL = 2000;
37
37
  /**
38
- * Maximum polling duration in milliseconds (default: 5 minutes)
38
+ * Maximum polling duration in milliseconds (default: 30 minutes)
39
+ * Previously 5 minutes, which caused silent polling stops for long-running tasks.
39
40
  */
40
- const MAX_POLLING_DURATION = 5 * 60 * 1000;
41
+ const MAX_POLLING_DURATION = 30 * 60 * 1000;
41
42
  /**
42
43
  * Number of tail lines to check for active thinking indicators in response extraction.
43
44
  *
@@ -0,0 +1,397 @@
1
+ "use strict";
2
+ /**
3
+ * Schedule Manager
4
+ * Issue #294: Manages scheduled execution of claude -p commands
5
+ *
6
+ * Uses a single timer to periodically scan all worktrees for CMATE.md changes
7
+ * and execute scheduled tasks via croner cron expressions.
8
+ *
9
+ * Patterns:
10
+ * - globalThis for hot reload persistence (same as auto-yes-manager.ts)
11
+ * - Single timer for all worktrees (60 second polling interval)
12
+ * - SIGKILL fire-and-forget for stopAllSchedules (< 1ms, within 3s graceful shutdown)
13
+ *
14
+ * [S3-001] stopAllSchedules() uses synchronous process.kill for immediate cleanup
15
+ * [S3-010] initScheduleManager() is called after initializeWorktrees()
16
+ */
17
+ Object.defineProperty(exports, "__esModule", { value: true });
18
+ exports.MAX_CONCURRENT_SCHEDULES = exports.POLL_INTERVAL_MS = void 0;
19
+ exports.initScheduleManager = initScheduleManager;
20
+ exports.stopAllSchedules = stopAllSchedules;
21
+ exports.getActiveScheduleCount = getActiveScheduleCount;
22
+ exports.isScheduleManagerInitialized = isScheduleManagerInitialized;
23
+ const crypto_1 = require("crypto");
24
+ const croner_1 = require("croner");
25
+ const cmate_parser_1 = require("./cmate-parser");
26
+ const claude_executor_1 = require("./claude-executor");
27
+ // =============================================================================
28
+ // Constants
29
+ // =============================================================================
30
+ /** Polling interval for CMATE.md changes (60 seconds) */
31
+ exports.POLL_INTERVAL_MS = 60 * 1000;
32
+ /** Maximum number of concurrent schedules across all worktrees */
33
+ exports.MAX_CONCURRENT_SCHEDULES = 100;
34
+ /**
35
+ * Get or initialize the global manager state.
36
+ */
37
+ function getManagerState() {
38
+ if (!globalThis.__scheduleManagerStates) {
39
+ globalThis.__scheduleManagerStates = {
40
+ timerId: null,
41
+ schedules: new Map(),
42
+ initialized: false,
43
+ };
44
+ }
45
+ return globalThis.__scheduleManagerStates;
46
+ }
47
+ // =============================================================================
48
+ // Lazy DB Accessor
49
+ // =============================================================================
50
+ /**
51
+ * Lazy-load the DB instance to avoid circular import issues.
52
+ * The db-instance module is loaded at runtime via require() because
53
+ * schedule-manager.ts is imported early in the server lifecycle.
54
+ *
55
+ * @returns The SQLite database instance
56
+ */
57
+ function getLazyDbInstance() {
58
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
59
+ const { getDbInstance } = require('./db-instance');
60
+ return getDbInstance();
61
+ }
62
+ // =============================================================================
63
+ // DB Operations
64
+ // =============================================================================
65
+ /**
66
+ * Get all worktrees from the database.
67
+ *
68
+ * @returns Array of worktree rows with id and path
69
+ */
70
+ function getAllWorktrees() {
71
+ try {
72
+ const db = getLazyDbInstance();
73
+ return db.prepare('SELECT id, path FROM worktrees').all();
74
+ }
75
+ catch (error) {
76
+ console.error('[schedule-manager] Failed to get worktrees:', error);
77
+ return [];
78
+ }
79
+ }
80
+ /**
81
+ * Upsert a schedule entry into the database.
82
+ * If a schedule with the same worktree_id and name exists, it is updated.
83
+ * Otherwise, a new schedule is created.
84
+ *
85
+ * @param worktreeId - The worktree ID to associate the schedule with
86
+ * @param entry - The schedule entry from CMATE.md
87
+ * @returns The schedule ID (existing or newly created)
88
+ */
89
+ function upsertSchedule(worktreeId, entry) {
90
+ const db = getLazyDbInstance();
91
+ const now = Date.now();
92
+ // Check if schedule already exists
93
+ const existing = db.prepare('SELECT id FROM scheduled_executions WHERE worktree_id = ? AND name = ?').get(worktreeId, entry.name);
94
+ if (existing) {
95
+ db.prepare(`
96
+ UPDATE scheduled_executions
97
+ SET message = ?, cron_expression = ?, cli_tool_id = ?, enabled = ?, updated_at = ?
98
+ WHERE id = ?
99
+ `).run(entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, existing.id);
100
+ return existing.id;
101
+ }
102
+ const id = (0, crypto_1.randomUUID)();
103
+ db.prepare(`
104
+ INSERT INTO scheduled_executions (id, worktree_id, name, message, cron_expression, cli_tool_id, enabled, created_at, updated_at)
105
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
106
+ `).run(id, worktreeId, entry.name, entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, now);
107
+ return id;
108
+ }
109
+ /**
110
+ * Create an execution log entry in 'running' status.
111
+ *
112
+ * @param scheduleId - The parent schedule ID
113
+ * @param worktreeId - The worktree ID
114
+ * @param message - The execution message/prompt
115
+ * @returns The new execution log ID
116
+ */
117
+ function createExecutionLog(scheduleId, worktreeId, message) {
118
+ const db = getLazyDbInstance();
119
+ const now = Date.now();
120
+ const id = (0, crypto_1.randomUUID)();
121
+ db.prepare(`
122
+ INSERT INTO execution_logs (id, schedule_id, worktree_id, message, status, started_at, created_at)
123
+ VALUES (?, ?, ?, ?, 'running', ?, ?)
124
+ `).run(id, scheduleId, worktreeId, message, now, now);
125
+ return id;
126
+ }
127
+ /**
128
+ * Update an execution log entry with results.
129
+ *
130
+ * @param logId - The execution log ID to update
131
+ * @param status - The final execution status
132
+ * @param result - The execution output or error message
133
+ * @param exitCode - The process exit code, or null if unknown
134
+ */
135
+ function updateExecutionLog(logId, status, result, exitCode) {
136
+ const db = getLazyDbInstance();
137
+ const now = Date.now();
138
+ db.prepare(`
139
+ UPDATE execution_logs SET status = ?, result = ?, exit_code = ?, completed_at = ? WHERE id = ?
140
+ `).run(status, result, exitCode, now, logId);
141
+ }
142
+ /**
143
+ * Update the last_executed_at timestamp for a schedule.
144
+ *
145
+ * @param scheduleId - The schedule ID to update
146
+ */
147
+ function updateScheduleLastExecuted(scheduleId) {
148
+ const db = getLazyDbInstance();
149
+ const now = Date.now();
150
+ db.prepare('UPDATE scheduled_executions SET last_executed_at = ?, updated_at = ? WHERE id = ?')
151
+ .run(now, now, scheduleId);
152
+ }
153
+ /**
154
+ * Recovery: mark all 'running' execution logs as 'failed' on startup.
155
+ * This handles the case where the server was killed while executions
156
+ * were still in progress.
157
+ */
158
+ function recoverRunningLogs() {
159
+ try {
160
+ const db = getLazyDbInstance();
161
+ const now = Date.now();
162
+ const result = db.prepare("UPDATE execution_logs SET status = 'failed', completed_at = ? WHERE status = 'running'").run(now);
163
+ if (result.changes > 0) {
164
+ console.warn(`[schedule-manager] Recovered ${result.changes} stale running execution(s) to failed status`);
165
+ }
166
+ }
167
+ catch (error) {
168
+ console.error('[schedule-manager] Failed to recover running logs:', error);
169
+ }
170
+ }
171
+ /**
172
+ * Disable DB schedules that are no longer present in CMATE.md.
173
+ * Sets enabled = 0 for schedules belonging to the given worktrees
174
+ * that are not in the activeScheduleIds set.
175
+ * Skips records already disabled to avoid unnecessary DB writes.
176
+ *
177
+ * @param activeScheduleIds - Set of schedule IDs currently active from CMATE.md
178
+ * @param worktreeIds - Array of worktree IDs that were scanned
179
+ */
180
+ function disableStaleSchedules(activeScheduleIds, worktreeIds) {
181
+ if (worktreeIds.length === 0)
182
+ return;
183
+ try {
184
+ const db = getLazyDbInstance();
185
+ const now = Date.now();
186
+ const placeholders = worktreeIds.map(() => '?').join(',');
187
+ // Get enabled schedules for the scanned worktrees
188
+ const rows = db.prepare(`SELECT id FROM scheduled_executions WHERE worktree_id IN (${placeholders}) AND enabled = 1`).all(...worktreeIds);
189
+ let disabledCount = 0;
190
+ const updateStmt = db.prepare('UPDATE scheduled_executions SET enabled = 0, updated_at = ? WHERE id = ?');
191
+ for (const row of rows) {
192
+ if (!activeScheduleIds.has(row.id)) {
193
+ updateStmt.run(now, row.id);
194
+ disabledCount++;
195
+ }
196
+ }
197
+ if (disabledCount > 0) {
198
+ console.log(`[schedule-manager] Disabled ${disabledCount} stale DB schedule(s)`);
199
+ }
200
+ }
201
+ catch (error) {
202
+ console.error('[schedule-manager] Failed to disable stale schedules:', error);
203
+ }
204
+ }
205
+ // =============================================================================
206
+ // Schedule Execution
207
+ // =============================================================================
208
+ /**
209
+ * Execute a scheduled task.
210
+ * Guards against concurrent execution of the same schedule.
211
+ *
212
+ * @param state - The schedule state to execute
213
+ */
214
+ async function executeSchedule(state) {
215
+ if (state.isExecuting) {
216
+ console.warn(`[schedule-manager] Skipping concurrent execution for schedule ${state.entry.name}`);
217
+ return;
218
+ }
219
+ state.isExecuting = true;
220
+ const logId = createExecutionLog(state.scheduleId, state.worktreeId, state.entry.message);
221
+ try {
222
+ const db = getLazyDbInstance();
223
+ const worktree = db.prepare('SELECT path FROM worktrees WHERE id = ?').get(state.worktreeId);
224
+ if (!worktree) {
225
+ updateExecutionLog(logId, 'failed', 'Worktree not found', null);
226
+ return;
227
+ }
228
+ const result = await (0, claude_executor_1.executeClaudeCommand)(state.entry.message, worktree.path, state.entry.cliToolId, state.entry.permission);
229
+ updateExecutionLog(logId, result.status, result.output, result.exitCode);
230
+ updateScheduleLastExecuted(state.scheduleId);
231
+ console.log(`[schedule-manager] Executed ${state.entry.name}: ${result.status}`);
232
+ }
233
+ catch (error) {
234
+ const errorMessage = error instanceof Error ? error.message : String(error);
235
+ updateExecutionLog(logId, 'failed', errorMessage, null);
236
+ console.error(`[schedule-manager] Execution error for ${state.entry.name}:`, errorMessage);
237
+ }
238
+ finally {
239
+ state.isExecuting = false;
240
+ }
241
+ }
242
+ // =============================================================================
243
+ // CMATE.md Sync
244
+ // =============================================================================
245
+ /**
246
+ * Sync schedules from CMATE.md files for all worktrees.
247
+ * Reads CMATE.md from each worktree, upserts schedules to DB,
248
+ * creates/updates cron jobs, and removes stale schedules.
249
+ */
250
+ function syncSchedules() {
251
+ const manager = getManagerState();
252
+ const worktrees = getAllWorktrees();
253
+ // Track which scheduleIds are still valid
254
+ const activeScheduleIds = new Set();
255
+ for (const worktree of worktrees) {
256
+ try {
257
+ const config = (0, cmate_parser_1.readCmateFile)(worktree.path);
258
+ if (!config)
259
+ continue;
260
+ const scheduleRows = config.get('Schedules');
261
+ if (!scheduleRows)
262
+ continue;
263
+ const entries = (0, cmate_parser_1.parseSchedulesSection)(scheduleRows);
264
+ for (const entry of entries) {
265
+ if (manager.schedules.size >= exports.MAX_CONCURRENT_SCHEDULES) {
266
+ console.warn(`[schedule-manager] MAX_CONCURRENT_SCHEDULES (${exports.MAX_CONCURRENT_SCHEDULES}) reached`);
267
+ return;
268
+ }
269
+ const scheduleId = upsertSchedule(worktree.id, entry);
270
+ activeScheduleIds.add(scheduleId);
271
+ // Check if this schedule already has a running cron job
272
+ const existingState = manager.schedules.get(scheduleId);
273
+ if (existingState) {
274
+ // Update entry if changed
275
+ existingState.entry = entry;
276
+ continue;
277
+ }
278
+ if (!entry.enabled || !entry.cronExpression)
279
+ continue;
280
+ // Create new cron job
281
+ try {
282
+ const cronJob = new croner_1.Cron(entry.cronExpression, {
283
+ paused: false,
284
+ protect: true, // Prevent overlapping
285
+ });
286
+ const state = {
287
+ scheduleId,
288
+ worktreeId: worktree.id,
289
+ cronJob,
290
+ isExecuting: false,
291
+ entry,
292
+ };
293
+ // Schedule execution
294
+ cronJob.schedule(() => {
295
+ void executeSchedule(state);
296
+ });
297
+ manager.schedules.set(scheduleId, state);
298
+ console.log(`[schedule-manager] Scheduled ${entry.name} (${entry.cronExpression})`);
299
+ }
300
+ catch (cronError) {
301
+ console.warn(`[schedule-manager] Invalid cron for ${entry.name}:`, cronError);
302
+ }
303
+ }
304
+ }
305
+ catch (error) {
306
+ console.error(`[schedule-manager] Error syncing schedules for worktree ${worktree.id}:`, error);
307
+ }
308
+ }
309
+ // Clean up schedules that no longer exist in CMATE.md
310
+ for (const [scheduleId, state] of manager.schedules) {
311
+ if (!activeScheduleIds.has(scheduleId)) {
312
+ state.cronJob.stop();
313
+ manager.schedules.delete(scheduleId);
314
+ console.log(`[schedule-manager] Removed stale schedule ${state.entry.name}`);
315
+ }
316
+ }
317
+ // Disable DB records for schedules no longer in CMATE.md
318
+ const worktreeIds = worktrees.map(w => w.id);
319
+ disableStaleSchedules(activeScheduleIds, worktreeIds);
320
+ }
321
+ // =============================================================================
322
+ // Manager Lifecycle
323
+ // =============================================================================
324
+ /**
325
+ * Initialize the schedule manager.
326
+ * Must be called after initializeWorktrees() completes.
327
+ *
328
+ * [S3-010] Called after await initializeWorktrees() in server.ts
329
+ */
330
+ function initScheduleManager() {
331
+ const manager = getManagerState();
332
+ if (manager.initialized) {
333
+ console.log('[schedule-manager] Already initialized, skipping');
334
+ return;
335
+ }
336
+ console.log('[schedule-manager] Initializing...');
337
+ // Recovery: mark stale running logs as failed
338
+ recoverRunningLogs();
339
+ // Initial sync
340
+ syncSchedules();
341
+ // Start periodic sync timer
342
+ manager.timerId = setInterval(() => {
343
+ syncSchedules();
344
+ }, exports.POLL_INTERVAL_MS);
345
+ manager.initialized = true;
346
+ console.log(`[schedule-manager] Initialized with ${manager.schedules.size} schedule(s)`);
347
+ }
348
+ /**
349
+ * Stop all schedules and clean up resources.
350
+ * Uses synchronous SIGKILL fire-and-forget for immediate cleanup.
351
+ *
352
+ * [S3-001] Designed to complete within gracefulShutdown's 3-second timeout
353
+ */
354
+ function stopAllSchedules() {
355
+ const manager = getManagerState();
356
+ // Stop the polling timer
357
+ if (manager.timerId !== null) {
358
+ clearInterval(manager.timerId);
359
+ manager.timerId = null;
360
+ }
361
+ // Stop all cron jobs
362
+ for (const [, state] of manager.schedules) {
363
+ try {
364
+ state.cronJob.stop();
365
+ }
366
+ catch {
367
+ // Ignore errors during cleanup
368
+ }
369
+ }
370
+ manager.schedules.clear();
371
+ // Kill all active child processes (fire-and-forget SIGKILL)
372
+ const activeProcesses = (0, claude_executor_1.getActiveProcesses)();
373
+ for (const [pid] of activeProcesses) {
374
+ try {
375
+ process.kill(pid, 'SIGKILL');
376
+ }
377
+ catch {
378
+ // Process may have already exited - ignore
379
+ }
380
+ }
381
+ activeProcesses.clear();
382
+ manager.initialized = false;
383
+ console.log('[schedule-manager] All schedules stopped');
384
+ }
385
+ /**
386
+ * Get the current number of active schedules.
387
+ * Useful for monitoring and testing.
388
+ */
389
+ function getActiveScheduleCount() {
390
+ return getManagerState().schedules.size;
391
+ }
392
+ /**
393
+ * Check if the schedule manager is initialized.
394
+ */
395
+ function isScheduleManagerInitialized() {
396
+ return getManagerState().initialized;
397
+ }
@@ -0,0 +1,6 @@
1
+ "use strict";
2
+ /**
3
+ * CMATE.md related type definitions
4
+ * Issue #294: Schedule execution feature
5
+ */
6
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "commandmate",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Git worktree management with Claude CLI and tmux sessions",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -51,6 +51,7 @@
51
51
  "autoprefixer": "^10.4.22",
52
52
  "better-sqlite3": "^12.4.1",
53
53
  "commander": "^14.0.2",
54
+ "croner": "^10.0.1",
54
55
  "date-fns": "^4.1.0",
55
56
  "dotenv": "^17.2.3",
56
57
  "gray-matter": "^4.0.3",