commandmate 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-build-manifest.json +19 -23
  3. package/.next/app-path-routes-manifest.json +1 -1
  4. package/.next/build-manifest.json +5 -5
  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/react-loadable-manifest.json +69 -55
  19. package/.next/required-server-files.json +1 -1
  20. package/.next/server/app/_not-found/page.js +1 -1
  21. package/.next/server/app/_not-found/page.js.nft.json +1 -1
  22. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  23. package/.next/server/app/api/app/update-check/route.js +1 -1
  24. package/.next/server/app/api/repositories/clone/route.js +1 -1
  25. package/.next/server/app/api/repositories/route.js +8 -8
  26. package/.next/server/app/api/repositories/scan/route.js +1 -1
  27. package/.next/server/app/api/worktrees/[id]/capture/route.js +1 -2
  28. package/.next/server/app/api/worktrees/[id]/capture/route.js.nft.json +1 -1
  29. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  30. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
  31. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  32. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  33. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  34. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -1
  35. package/.next/server/app/api/worktrees/[id]/schedules/route.js +2 -2
  36. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  37. package/.next/server/app/api/worktrees/[id]/terminal/route.js +1 -1
  38. package/.next/server/app/api/worktrees/[id]/terminal/route.js.nft.json +1 -1
  39. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
  40. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
  41. package/.next/server/app/api/worktrees/route.js +1 -1
  42. package/.next/server/app/login/page.js +1 -1
  43. package/.next/server/app/login/page.js.nft.json +1 -1
  44. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/page.js +3 -3
  46. package/.next/server/app/page.js.nft.json +1 -1
  47. package/.next/server/app/page_client-reference-manifest.js +1 -1
  48. package/.next/server/app/proxy/[...path]/route.js +4 -4
  49. package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
  50. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  51. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  52. package/.next/server/app/worktrees/[id]/page.js +6 -6
  53. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  54. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app/worktrees/[id]/terminal/page.js +2 -4
  56. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
  57. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  58. package/.next/server/app-paths-manifest.json +8 -8
  59. package/.next/server/chunks/{3294.js → 1628.js} +3 -3
  60. package/.next/server/chunks/185.js +36 -0
  61. package/.next/server/chunks/3860.js +1 -1
  62. package/.next/server/chunks/4893.js +2 -2
  63. package/.next/server/chunks/4952.js +1 -1
  64. package/.next/server/chunks/5488.js +6 -6
  65. package/.next/server/chunks/7425.js +34 -31
  66. package/.next/server/chunks/7566.js +2 -2
  67. package/.next/server/chunks/8199.js +1 -0
  68. package/.next/server/chunks/8585.js +1 -1
  69. package/.next/server/chunks/8693.js +1 -1
  70. package/.next/server/middleware-build-manifest.js +1 -1
  71. package/.next/server/middleware-manifest.json +5 -5
  72. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  73. package/.next/server/pages/500.html +1 -1
  74. package/.next/server/server-reference-manifest.json +1 -1
  75. package/.next/static/chunks/12-00c528d46a0a0a1d.js +1 -0
  76. package/.next/static/chunks/{13.feeafc7cc620f8c4.js → 13.b9521543496f4468.js} +1 -1
  77. package/.next/static/chunks/1334.bfedf44ee9fe2761.js +1 -0
  78. package/.next/static/chunks/143.eb6b4671490cd223.js +1 -0
  79. package/.next/static/chunks/{3574.7a94c27e6a496a56.js → 1442.74b5f4de9a4b4e1b.js} +1 -1
  80. package/.next/static/chunks/2083-b5bed0c77cc53281.js +1 -0
  81. package/.next/static/chunks/2725.eb2d236c8030711c.js +1 -0
  82. package/.next/static/chunks/3398-3d40a17387bd554b.js +1 -0
  83. package/.next/static/chunks/3516.3c576047408cae6b.js +1 -0
  84. package/.next/static/chunks/3559.422c6ca760b85750.js +1 -0
  85. package/.next/static/chunks/3956.52c5b9a0071a641d.js +1 -0
  86. package/.next/static/chunks/4012.32b576a4fa621774.js +1 -0
  87. package/.next/static/chunks/4212.e7ba1009bc1da62d.js +131 -0
  88. package/.next/static/chunks/4303.caf91e86105d5e70.js +1 -0
  89. package/.next/static/chunks/4327.4dcda9b6fab6a385.js +82 -0
  90. package/.next/static/chunks/4671.d86d21d0dfdace41.js +1 -0
  91. package/.next/static/chunks/5518.ec88dcb5a27b17fe.js +1 -0
  92. package/.next/static/chunks/6434.08d262283371d333.js +1 -0
  93. package/.next/static/chunks/{656.5e2de0173f5a06bd.js → 656.dc26b973d07d9627.js} +5 -5
  94. package/.next/static/chunks/7119.01777af21b55740c.js +1 -0
  95. package/.next/static/chunks/7293.fb88bb102af4aa04.js +1 -0
  96. package/.next/static/chunks/8913-40625650292eb3d0.js +1 -0
  97. package/.next/static/chunks/8977.fc18b8260cd8bc1f.js +1 -0
  98. package/.next/static/chunks/9552.d959149efd41e84b.js +1 -0
  99. package/.next/static/chunks/app/layout-7198a7a49aa21a97.js +1 -0
  100. package/.next/static/chunks/app/page-7498cf75e69d9227.js +1 -0
  101. package/.next/static/chunks/app/worktrees/[id]/files/[...path]/page-0599f64a8e80d255.js +1 -0
  102. package/.next/static/chunks/app/worktrees/[id]/page-94ad7a1ce1f0c440.js +1 -0
  103. package/.next/static/chunks/app/worktrees/[id]/terminal/page-175b618c047bc992.js +1 -0
  104. package/.next/static/chunks/d3ac728e.daf595a898e9b720.js +1 -0
  105. package/.next/static/chunks/webpack-f7111aab807d73b9.js +1 -0
  106. package/.next/static/css/f7dc01350168df01.css +3 -0
  107. package/.next/trace +5 -5
  108. package/README.md +66 -56
  109. package/dist/server/server.js +5 -0
  110. package/dist/server/src/lib/auto-yes-manager.js +58 -18
  111. package/dist/server/src/lib/claude-session.js +9 -3
  112. package/dist/server/src/lib/cli-session.js +60 -10
  113. package/dist/server/src/lib/cli-tools/codex.js +7 -7
  114. package/dist/server/src/lib/cli-tools/gemini.js +3 -0
  115. package/dist/server/src/lib/cli-tools/opencode-config.js +179 -33
  116. package/dist/server/src/lib/cli-tools/opencode.js +5 -0
  117. package/dist/server/src/lib/cli-tools/vibe-local.js +3 -0
  118. package/dist/server/src/lib/cmate-parser.js +7 -7
  119. package/dist/server/src/lib/db-migrations.js +18 -1
  120. package/dist/server/src/lib/errors.js +153 -0
  121. package/dist/server/src/lib/prompt-answer-sender.js +3 -0
  122. package/dist/server/src/lib/prompt-detector.js +49 -7
  123. package/dist/server/src/lib/resource-cleanup.js +257 -0
  124. package/dist/server/src/lib/schedule-manager.js +269 -83
  125. package/dist/server/src/lib/tmux-capture-cache.js +221 -0
  126. package/dist/server/src/lib/tmux.js +41 -20
  127. package/dist/server/src/types/markdown-editor.js +9 -1
  128. package/package.json +11 -8
  129. package/.next/server/chunks/539.js +0 -35
  130. package/.next/server/chunks/7458.js +0 -1
  131. package/.next/server/chunks/7808.js +0 -1
  132. package/.next/static/chunks/1038-3509435b68c0967e.js +0 -1
  133. package/.next/static/chunks/1098.49268c9fe1b028fa.js +0 -1
  134. package/.next/static/chunks/2335-98a211e00b94c7ac.js +0 -1
  135. package/.next/static/chunks/3559.f073f72c4466ce0e.js +0 -1
  136. package/.next/static/chunks/3843.3fdda732987f7bb8.js +0 -1
  137. package/.next/static/chunks/4212.52c1bb34fc97d0d0.js +0 -131
  138. package/.next/static/chunks/4327.157a4c226d919531.js +0 -60
  139. package/.next/static/chunks/4362.7bd6f0282e49d79b.js +0 -1
  140. package/.next/static/chunks/4721.40615a5f4f32b5fb.js +0 -1
  141. package/.next/static/chunks/5112.17318d1c6b28044b.js +0 -1
  142. package/.next/static/chunks/6406.9653f0d41ab85059.js +0 -1
  143. package/.next/static/chunks/6792.3c01ac4dda4b5c6d.js +0 -1
  144. package/.next/static/chunks/8091-d65d2ab6daed23c6.js +0 -1
  145. package/.next/static/chunks/8125.245a9df052d274fb.js +0 -1
  146. package/.next/static/chunks/8522.1607e96011c66877.js +0 -1
  147. package/.next/static/chunks/8841.dadeb1ece8e46004.js +0 -1
  148. package/.next/static/chunks/8885.f8d9912b40d74811.js +0 -1
  149. package/.next/static/chunks/9178-88850a7c48deea07.js +0 -1
  150. package/.next/static/chunks/9552.b7dfb7903ead934b.js +0 -1
  151. package/.next/static/chunks/app/layout-9110f9a5e41c6bf4.js +0 -1
  152. package/.next/static/chunks/app/page-9e523a8f415bc707.js +0 -1
  153. package/.next/static/chunks/app/worktrees/[id]/files/[...path]/page-4a3c0861367e0391.js +0 -1
  154. package/.next/static/chunks/app/worktrees/[id]/page-8fb4dc30b58a5681.js +0 -1
  155. package/.next/static/chunks/app/worktrees/[id]/terminal/page-5d85a7e508ce36d3.js +0 -1
  156. package/.next/static/chunks/d3ac728e.6c9c508274d4d2d5.js +0 -1
  157. package/.next/static/chunks/webpack-81c97591dd5567ac.js +0 -1
  158. package/.next/static/css/45b3a41370668314.css +0 -3
  159. /package/.next/static/chunks/{30d07d85-393352a92199f695.js → 30d07d85.1dc99a921fc18e34.js} +0 -0
  160. /package/.next/static/{p3hosTZoJ22r35fWwUoLr → dwGMLEU53HOvFOWqiZOT0}/_buildManifest.js +0 -0
  161. /package/.next/static/{p3hosTZoJ22r35fWwUoLr → dwGMLEU53HOvFOWqiZOT0}/_ssgManifest.js +0 -0
@@ -2,6 +2,7 @@
2
2
  /**
3
3
  * Schedule Manager
4
4
  * Issue #294: Manages scheduled execution of claude -p commands
5
+ * Issue #409: Performance optimization with mtime caching and batch upsert
5
6
  *
6
7
  * Uses a single timer to periodically scan all worktrees for CMATE.md changes
7
8
  * and execute scheduled tasks via croner cron expressions.
@@ -10,20 +11,30 @@
10
11
  * - globalThis for hot reload persistence (same as auto-yes-manager.ts)
11
12
  * - Single timer for all worktrees (60 second polling interval)
12
13
  * - SIGKILL fire-and-forget for stopAllSchedules (< 1ms, within 3s graceful shutdown)
14
+ * - mtime caching to skip unchanged CMATE.md files (Issue #409)
13
15
  *
14
16
  * [S3-001] stopAllSchedules() uses synchronous process.kill for immediate cleanup
15
17
  * [S3-010] initScheduleManager() is called after initializeWorktrees()
16
18
  */
19
+ var __importDefault = (this && this.__importDefault) || function (mod) {
20
+ return (mod && mod.__esModule) ? mod : { "default": mod };
21
+ };
17
22
  Object.defineProperty(exports, "__esModule", { value: true });
18
23
  exports.MAX_CONCURRENT_SCHEDULES = exports.POLL_INTERVAL_MS = void 0;
24
+ exports.batchUpsertSchedules = batchUpsertSchedules;
19
25
  exports.initScheduleManager = initScheduleManager;
20
26
  exports.stopAllSchedules = stopAllSchedules;
27
+ exports.stopScheduleForWorktree = stopScheduleForWorktree;
21
28
  exports.getActiveScheduleCount = getActiveScheduleCount;
22
29
  exports.isScheduleManagerInitialized = isScheduleManagerInitialized;
30
+ exports.getScheduleWorktreeIds = getScheduleWorktreeIds;
23
31
  const crypto_1 = require("crypto");
32
+ const fs_1 = require("fs");
33
+ const path_1 = __importDefault(require("path"));
24
34
  const croner_1 = require("croner");
25
35
  const cmate_parser_1 = require("./cmate-parser");
26
36
  const claude_executor_1 = require("./claude-executor");
37
+ const cmate_constants_1 = require("../config/cmate-constants");
27
38
  // =============================================================================
28
39
  // Constants
29
40
  // =============================================================================
@@ -40,6 +51,8 @@ function getManagerState() {
40
51
  timerId: null,
41
52
  schedules: new Map(),
42
53
  initialized: false,
54
+ isSyncing: false,
55
+ cmateFileCache: new Map(),
43
56
  };
44
57
  }
45
58
  return globalThis.__scheduleManagerStates;
@@ -60,6 +73,38 @@ function getLazyDbInstance() {
60
73
  return getDbInstance();
61
74
  }
62
75
  // =============================================================================
76
+ // CMATE.md mtime Helper
77
+ // =============================================================================
78
+ /**
79
+ * Get the modification time (mtimeMs) of the CMATE.md file in a worktree directory.
80
+ *
81
+ * Trust boundary (SEC4-003): worktreePath is DB-derived from getAllWorktrees()
82
+ * and was validated by validateWorktreePath() at worktree registration time.
83
+ * Therefore, path traversal re-validation is not needed here.
84
+ * readCmateFile() has validateCmatePath() because it can be called externally,
85
+ * whereas getCmateMtime() is an internal function called only from syncSchedules().
86
+ *
87
+ * @param worktreePath - Path to the worktree directory (DB-derived, trusted)
88
+ * @returns mtimeMs value, or null if the file does not exist or cannot be read
89
+ */
90
+ function getCmateMtime(worktreePath) {
91
+ // Uses CMATE_FILENAME from @/config/cmate-constants directly (DR1-001, CR2-001)
92
+ const filePath = path_1.default.join(worktreePath, cmate_constants_1.CMATE_FILENAME);
93
+ try {
94
+ return (0, fs_1.statSync)(filePath).mtimeMs;
95
+ }
96
+ catch (error) {
97
+ if (error instanceof Error &&
98
+ 'code' in error &&
99
+ error.code === 'ENOENT') {
100
+ return null;
101
+ }
102
+ // Permission errors etc. - log and treat as no file (SEC4-004-note)
103
+ console.warn(`[schedule-manager] Failed to stat ${filePath}:`, error);
104
+ return null;
105
+ }
106
+ }
107
+ // =============================================================================
63
108
  // DB Operations
64
109
  // =============================================================================
65
110
  /**
@@ -78,33 +123,66 @@ function getAllWorktrees() {
78
123
  }
79
124
  }
80
125
  /**
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.
126
+ * Batch upsert schedule entries into the database.
127
+ * Replaces the previous per-entry upsertSchedule() function (DR1-002).
84
128
  *
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)
129
+ * Uses a single SELECT to build an existing schedules map, then performs
130
+ * all UPDATE/INSERT operations within a single transaction.
131
+ *
132
+ * Sanitization chain (SEC4-004): This function is called exclusively from
133
+ * syncSchedules(), which passes entries produced by parseSchedulesSection().
134
+ * parseSchedulesSection() calls sanitizeMessageContent() (S4-002) on all
135
+ * message fields, so entries arriving here are already sanitized.
136
+ * If batchUpsertSchedules() is called from a different code path in the
137
+ * future, an input validation layer must be added at the call site.
138
+ *
139
+ * Note on next_execute_at (CR2-002): The scheduled_executions table has a
140
+ * next_execute_at column (INTEGER, nullable) that is intentionally not
141
+ * operated on by this function, consistent with the prior upsertSchedule().
142
+ *
143
+ * @param worktreeId - The worktree ID to associate schedules with
144
+ * @param entries - Schedule entries from CMATE.md (sanitized by parseSchedulesSection)
145
+ * @returns Array of schedule IDs (existing or newly created), in the same order as entries
88
146
  */
89
- function upsertSchedule(worktreeId, entry) {
147
+ function batchUpsertSchedules(worktreeId, entries) {
148
+ if (entries.length === 0)
149
+ return [];
90
150
  const db = getLazyDbInstance();
91
151
  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;
152
+ // Bulk fetch existing schedules for this worktree.
153
+ // better-sqlite3's prepare() is cached at the DB instance level (DR1-007),
154
+ // so calling it each invocation does not incur additional compilation cost.
155
+ const existingRows = db.prepare('SELECT id, name FROM scheduled_executions WHERE worktree_id = ?').all(worktreeId);
156
+ const existingByName = new Map();
157
+ for (const row of existingRows) {
158
+ existingByName.set(row.name, row.id);
101
159
  }
102
- const id = (0, crypto_1.randomUUID)();
103
- db.prepare(`
160
+ const resultIds = [];
161
+ const updateStmt = db.prepare(`
162
+ UPDATE scheduled_executions
163
+ SET message = ?, cron_expression = ?, cli_tool_id = ?, enabled = ?, updated_at = ?
164
+ WHERE id = ?
165
+ `);
166
+ const insertStmt = db.prepare(`
104
167
  INSERT INTO scheduled_executions (id, worktree_id, name, message, cron_expression, cli_tool_id, enabled, created_at, updated_at)
105
168
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
106
- `).run(id, worktreeId, entry.name, entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, now);
107
- return id;
169
+ `);
170
+ const runTransaction = db.transaction(() => {
171
+ for (const entry of entries) {
172
+ const existingId = existingByName.get(entry.name);
173
+ if (existingId) {
174
+ updateStmt.run(entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, existingId);
175
+ resultIds.push(existingId);
176
+ }
177
+ else {
178
+ const id = (0, crypto_1.randomUUID)();
179
+ insertStmt.run(id, worktreeId, entry.name, entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, now);
180
+ resultIds.push(id);
181
+ }
182
+ }
183
+ });
184
+ runTransaction();
185
+ return resultIds;
108
186
  }
109
187
  /**
110
188
  * Create an execution log entry in 'running' status.
@@ -183,6 +261,11 @@ function disableStaleSchedules(activeScheduleIds, worktreeIds) {
183
261
  try {
184
262
  const db = getLazyDbInstance();
185
263
  const now = Date.now();
264
+ // SEC4-002: Dynamic placeholder generation is SQL injection-safe because only
265
+ // '?' placeholders are interpolated into SQL; actual values are passed via
266
+ // prepared statement parameter binding. worktreeIds originates from
267
+ // getAllWorktrees() (bounded by MAX_CONCURRENT_SCHEDULES ~100), well within
268
+ // SQLite's SQLITE_MAX_VARIABLE_NUMBER (default 999).
186
269
  const placeholders = worktreeIds.map(() => '?').join(',');
187
270
  // Get enabled schedules for the scanned worktrees
188
271
  const rows = db.prepare(`SELECT id FROM scheduled_executions WHERE worktree_id IN (${placeholders}) AND enabled = 1`).all(...worktreeIds);
@@ -250,77 +333,121 @@ async function executeSchedule(state) {
250
333
  * Sync schedules from CMATE.md files for all worktrees.
251
334
  * Reads CMATE.md from each worktree, upserts schedules to DB,
252
335
  * creates/updates cron jobs, and removes stale schedules.
336
+ *
337
+ * Issue #406: Async I/O for readCmateFile() to avoid event loop blocking.
338
+ * Issue #409: Uses mtime caching to skip unchanged CMATE.md files
339
+ * and batchUpsertSchedules() for efficient DB operations.
340
+ *
341
+ * DJ-007: isSyncing guard prevents concurrent execution when async
342
+ * operations exceed the 60-second polling interval.
253
343
  */
254
- function syncSchedules() {
344
+ async function syncSchedules() {
255
345
  const manager = getManagerState();
256
- const worktrees = getAllWorktrees();
257
- // Track which scheduleIds are still valid
258
- const activeScheduleIds = new Set();
259
- for (const worktree of worktrees) {
260
- try {
261
- const config = (0, cmate_parser_1.readCmateFile)(worktree.path);
262
- if (!config)
263
- continue;
264
- const scheduleRows = config.get('Schedules');
265
- if (!scheduleRows)
266
- continue;
267
- const entries = (0, cmate_parser_1.parseSchedulesSection)(scheduleRows);
268
- for (const entry of entries) {
269
- if (manager.schedules.size >= exports.MAX_CONCURRENT_SCHEDULES) {
270
- console.warn(`[schedule-manager] MAX_CONCURRENT_SCHEDULES (${exports.MAX_CONCURRENT_SCHEDULES}) reached`);
271
- return;
272
- }
273
- const scheduleId = upsertSchedule(worktree.id, entry);
274
- activeScheduleIds.add(scheduleId);
275
- // Check if this schedule already has a running cron job
276
- const existingState = manager.schedules.get(scheduleId);
277
- if (existingState) {
278
- // Update entry if changed
279
- existingState.entry = entry;
346
+ // DJ-007: Prevent concurrent execution (SEC4-004)
347
+ if (manager.isSyncing)
348
+ return;
349
+ manager.isSyncing = true;
350
+ try {
351
+ const worktrees = getAllWorktrees();
352
+ // Track which scheduleIds are still valid
353
+ const activeScheduleIds = new Set();
354
+ for (const worktree of worktrees) {
355
+ try {
356
+ // Issue #409: Check CMATE.md mtime for change detection
357
+ const mtime = getCmateMtime(worktree.path);
358
+ const cachedMtime = manager.cmateFileCache.get(worktree.path);
359
+ if (mtime === null) {
360
+ // CMATE.md does not exist (or was deleted).
361
+ // DR1-009: By not adding to activeScheduleIds, this worktree's
362
+ // schedules will be cleaned up in Step 4 (stale cron job removal)
363
+ // and disableStaleSchedules() (DB enabled=0 update).
364
+ if (cachedMtime !== undefined) {
365
+ manager.cmateFileCache.delete(worktree.path);
366
+ }
280
367
  continue;
281
368
  }
282
- if (!entry.enabled || !entry.cronExpression)
369
+ // If mtime matches cached value, skip DB operations for this worktree
370
+ if (cachedMtime !== undefined && cachedMtime === mtime) {
371
+ // File unchanged - re-add existing schedule IDs to keep them active
372
+ for (const [scheduleId, state] of manager.schedules) {
373
+ if (state.worktreeId === worktree.id) {
374
+ activeScheduleIds.add(scheduleId);
375
+ }
376
+ }
283
377
  continue;
284
- // Create new cron job
285
- try {
286
- const cronJob = new croner_1.Cron(entry.cronExpression, {
287
- paused: false,
288
- protect: true, // Prevent overlapping
289
- });
290
- const state = {
291
- scheduleId,
292
- worktreeId: worktree.id,
293
- cronJob,
294
- isExecuting: false,
295
- entry,
296
- };
297
- // Schedule execution
298
- cronJob.schedule(() => {
299
- void executeSchedule(state);
300
- });
301
- manager.schedules.set(scheduleId, state);
302
- console.log(`[schedule-manager] Scheduled ${entry.name} (${entry.cronExpression})`);
303
378
  }
304
- catch (cronError) {
305
- console.warn(`[schedule-manager] Invalid cron for ${entry.name}:`, cronError);
379
+ // Update mtime cache
380
+ manager.cmateFileCache.set(worktree.path, mtime);
381
+ const config = await (0, cmate_parser_1.readCmateFile)(worktree.path);
382
+ if (!config)
383
+ continue;
384
+ const scheduleRows = config.get('Schedules');
385
+ if (!scheduleRows)
386
+ continue;
387
+ const entries = (0, cmate_parser_1.parseSchedulesSection)(scheduleRows);
388
+ // Issue #409: Batch upsert all entries for this worktree
389
+ const scheduleIds = batchUpsertSchedules(worktree.id, entries);
390
+ for (let i = 0; i < entries.length; i++) {
391
+ const entry = entries[i];
392
+ const scheduleId = scheduleIds[i];
393
+ if (manager.schedules.size >= exports.MAX_CONCURRENT_SCHEDULES) {
394
+ console.warn(`[schedule-manager] MAX_CONCURRENT_SCHEDULES (${exports.MAX_CONCURRENT_SCHEDULES}) reached`);
395
+ return;
396
+ }
397
+ activeScheduleIds.add(scheduleId);
398
+ // Check if this schedule already has a running cron job
399
+ const existingState = manager.schedules.get(scheduleId);
400
+ if (existingState) {
401
+ // Update entry if changed
402
+ existingState.entry = entry;
403
+ continue;
404
+ }
405
+ if (!entry.enabled || !entry.cronExpression)
406
+ continue;
407
+ // Create new cron job
408
+ try {
409
+ const cronJob = new croner_1.Cron(entry.cronExpression, {
410
+ paused: false,
411
+ protect: true, // Prevent overlapping
412
+ });
413
+ const state = {
414
+ scheduleId,
415
+ worktreeId: worktree.id,
416
+ cronJob,
417
+ isExecuting: false,
418
+ entry,
419
+ };
420
+ // Schedule execution
421
+ cronJob.schedule(() => {
422
+ void executeSchedule(state);
423
+ });
424
+ manager.schedules.set(scheduleId, state);
425
+ console.log(`[schedule-manager] Scheduled ${entry.name} (${entry.cronExpression})`);
426
+ }
427
+ catch (cronError) {
428
+ console.warn(`[schedule-manager] Invalid cron for ${entry.name}:`, cronError);
429
+ }
306
430
  }
307
431
  }
432
+ catch (error) {
433
+ console.error(`[schedule-manager] Error syncing schedules for worktree ${worktree.id}:`, error);
434
+ }
308
435
  }
309
- catch (error) {
310
- console.error(`[schedule-manager] Error syncing schedules for worktree ${worktree.id}:`, error);
436
+ // Clean up schedules that no longer exist in CMATE.md
437
+ for (const [scheduleId, state] of manager.schedules) {
438
+ if (!activeScheduleIds.has(scheduleId)) {
439
+ state.cronJob.stop();
440
+ manager.schedules.delete(scheduleId);
441
+ console.log(`[schedule-manager] Removed stale schedule ${state.entry.name}`);
442
+ }
311
443
  }
444
+ // Disable DB records for schedules no longer in CMATE.md
445
+ const worktreeIds = worktrees.map(w => w.id);
446
+ disableStaleSchedules(activeScheduleIds, worktreeIds);
312
447
  }
313
- // Clean up schedules that no longer exist in CMATE.md
314
- for (const [scheduleId, state] of manager.schedules) {
315
- if (!activeScheduleIds.has(scheduleId)) {
316
- state.cronJob.stop();
317
- manager.schedules.delete(scheduleId);
318
- console.log(`[schedule-manager] Removed stale schedule ${state.entry.name}`);
319
- }
448
+ finally {
449
+ manager.isSyncing = false;
320
450
  }
321
- // Disable DB records for schedules no longer in CMATE.md
322
- const worktreeIds = worktrees.map(w => w.id);
323
- disableStaleSchedules(activeScheduleIds, worktreeIds);
324
451
  }
325
452
  // =============================================================================
326
453
  // Manager Lifecycle
@@ -340,11 +467,11 @@ function initScheduleManager() {
340
467
  console.log('[schedule-manager] Initializing...');
341
468
  // Recovery: mark stale running logs as failed
342
469
  recoverRunningLogs();
343
- // Initial sync
344
- syncSchedules();
345
- // Start periodic sync timer
470
+ // Initial sync (DJ-002: fire-and-forget, no .catch - fail-fast for fatal errors)
471
+ void syncSchedules();
472
+ // Start periodic sync timer (DJ-003: .catch for repeated execution safety)
346
473
  manager.timerId = setInterval(() => {
347
- syncSchedules();
474
+ void syncSchedules().catch(err => console.error('[schedule-manager] Unexpected sync error:', err));
348
475
  }, exports.POLL_INTERVAL_MS);
349
476
  manager.initialized = true;
350
477
  console.log(`[schedule-manager] Initialized with ${manager.schedules.size} schedule(s)`);
@@ -372,6 +499,9 @@ function stopAllSchedules() {
372
499
  }
373
500
  }
374
501
  manager.schedules.clear();
502
+ // DR1-008: Clear mtime cache to prevent stale values from causing
503
+ // incorrect skip decisions on next initScheduleManager() call
504
+ manager.cmateFileCache.clear();
375
505
  // Kill all active child processes (fire-and-forget SIGKILL)
376
506
  const activeProcesses = (0, claude_executor_1.getActiveProcesses)();
377
507
  for (const [pid] of activeProcesses) {
@@ -386,6 +516,47 @@ function stopAllSchedules() {
386
516
  manager.initialized = false;
387
517
  console.log('[schedule-manager] All schedules stopped');
388
518
  }
519
+ /**
520
+ * Stop schedules for a specific worktree.
521
+ * Issue #404: Used during worktree deletion to prevent resource leaks.
522
+ *
523
+ * Iterates the schedules map (O(N), N<=MAX_CONCURRENT_SCHEDULES=100),
524
+ * stops cron jobs for the target worktree, and removes their entries.
525
+ * Also removes the cmateFileCache entry for the worktree path (via DB lookup).
526
+ *
527
+ * activeProcesses are NOT killed (method (c): natural reclamation).
528
+ * cronJob.stop() prevents new executions; running processes finish naturally.
529
+ *
530
+ * @param worktreeId - The worktree ID whose schedules should be stopped
531
+ */
532
+ function stopScheduleForWorktree(worktreeId) {
533
+ const manager = getManagerState();
534
+ // Stop and remove cron jobs for the target worktree
535
+ for (const [scheduleId, state] of manager.schedules) {
536
+ if (state.worktreeId === worktreeId) {
537
+ try {
538
+ state.cronJob.stop();
539
+ }
540
+ catch {
541
+ // Ignore errors during cleanup
542
+ }
543
+ manager.schedules.delete(scheduleId);
544
+ }
545
+ }
546
+ // Remove cmateFileCache entry via DB lookup (worktreeId -> path)
547
+ try {
548
+ const db = getLazyDbInstance();
549
+ const row = db.prepare('SELECT path FROM worktrees WHERE id = ?').get(worktreeId);
550
+ if (row?.path) {
551
+ manager.cmateFileCache.delete(row.path);
552
+ }
553
+ }
554
+ catch (error) {
555
+ // DB lookup failed - schedule stop already completed above (fallback)
556
+ console.warn(`[schedule-manager] Failed to resolve worktree path for cache cleanup (worktreeId: ${worktreeId}):`, error);
557
+ }
558
+ console.log(`[schedule-manager] Stopped schedules for worktree: ${worktreeId}`);
559
+ }
389
560
  /**
390
561
  * Get the current number of active schedules.
391
562
  * Useful for monitoring and testing.
@@ -399,3 +570,18 @@ function getActiveScheduleCount() {
399
570
  function isScheduleManagerInitialized() {
400
571
  return getManagerState().initialized;
401
572
  }
573
+ /**
574
+ * Get all unique worktree IDs that have active schedule entries.
575
+ * Used by periodic resource cleanup to detect orphaned entries.
576
+ *
577
+ * @internal Exported for resource-cleanup and testing purposes.
578
+ * @returns Array of unique worktree IDs present in the schedules Map
579
+ */
580
+ function getScheduleWorktreeIds() {
581
+ const manager = getManagerState();
582
+ const worktreeIds = new Set();
583
+ for (const [, state] of manager.schedules) {
584
+ worktreeIds.add(state.worktreeId);
585
+ }
586
+ return Array.from(worktreeIds);
587
+ }
@@ -0,0 +1,221 @@
1
+ "use strict";
2
+ /**
3
+ * tmux capture cache module
4
+ * Issue #405: TTL-based cache for tmux capture-pane output with singleflight deduplication
5
+ *
6
+ * Provides in-memory caching of tmux capture-pane results to eliminate redundant
7
+ * tmux process invocations. Uses globalThis pattern for Next.js hot reload persistence.
8
+ *
9
+ * Cache key: sessionName (mcbd-{cliToolId}-{worktreeId} format)
10
+ * Cache value: CacheEntry (output + metadata)
11
+ * TTL: 2 seconds (CACHE_TTL_MS)
12
+ *
13
+ * [SEC4-001] Trust Boundary: sessionName is validated by the caller chain
14
+ * (CLIToolManager.getTool(cliToolId).getSessionName()). This module does not
15
+ * perform additional sessionName validation.
16
+ *
17
+ * [DA3-002] Singleflight key uses sessionName, which contains cliToolId.
18
+ * Different cliToolIds cannot share the same singleflight entry, preventing
19
+ * error message context mismatch across callers.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.CACHE_MAX_CAPTURE_LINES = exports.CACHE_MAX_ENTRIES = exports.CACHE_TTL_MS = void 0;
23
+ exports.sliceOutput = sliceOutput;
24
+ exports.getCachedCapture = getCachedCapture;
25
+ exports.setCachedCapture = setCachedCapture;
26
+ exports.invalidateCache = invalidateCache;
27
+ exports.clearAllCache = clearAllCache;
28
+ exports.resetCacheForTesting = resetCacheForTesting;
29
+ exports.getOrFetchCapture = getOrFetchCapture;
30
+ // =========================================================================
31
+ // Constants
32
+ // =========================================================================
33
+ /** Cache TTL in milliseconds */
34
+ exports.CACHE_TTL_MS = 2000;
35
+ /** Maximum number of cache entries */
36
+ exports.CACHE_MAX_ENTRIES = 100;
37
+ /** Maximum capture lines for cache storage */
38
+ exports.CACHE_MAX_CAPTURE_LINES = 10000;
39
+ function getCache() {
40
+ return (globalThis.__tmuxCaptureCache ??= new Map());
41
+ }
42
+ function getInflight() {
43
+ return (globalThis.__tmuxCaptureCacheInflight ??= new Map());
44
+ }
45
+ // =========================================================================
46
+ // sliceOutput()
47
+ // =========================================================================
48
+ /**
49
+ * Slice output from the end to return the last requestedLines lines.
50
+ * If requestedLines >= total lines, returns the full output.
51
+ *
52
+ * @param fullOutput - Full captured output string
53
+ * @param requestedLines - Number of lines requested from the end
54
+ * @returns Sliced output string
55
+ */
56
+ function sliceOutput(fullOutput, requestedLines) {
57
+ if (fullOutput === '')
58
+ return '';
59
+ const lines = fullOutput.split('\n');
60
+ if (requestedLines >= lines.length)
61
+ return fullOutput;
62
+ return lines.slice(-requestedLines).join('\n');
63
+ }
64
+ // =========================================================================
65
+ // getCachedCapture()
66
+ // =========================================================================
67
+ /**
68
+ * Get cached capture output for a session.
69
+ * Returns null on cache miss, TTL expiration, or insufficient cached lines.
70
+ * Performs lazy eviction of TTL-expired entries.
71
+ *
72
+ * [SEC4-002] Lazy eviction only: expired entries that are never queried
73
+ * may persist in memory. Full sweep is performed in setCachedCapture().
74
+ *
75
+ * @param sessionName - tmux session name (cache key)
76
+ * @param requestedLines - Number of lines requested
77
+ * @returns Cached output (sliced to requestedLines) or null
78
+ */
79
+ function getCachedCapture(sessionName, requestedLines) {
80
+ const cache = getCache();
81
+ const entry = cache.get(sessionName);
82
+ if (!entry)
83
+ return null;
84
+ // TTL check with lazy eviction
85
+ const now = Date.now();
86
+ if (now - entry.timestamp > exports.CACHE_TTL_MS) {
87
+ cache.delete(sessionName);
88
+ return null;
89
+ }
90
+ // Insufficient cached lines check
91
+ if (requestedLines > entry.capturedLines) {
92
+ return null;
93
+ }
94
+ return sliceOutput(entry.output, requestedLines);
95
+ }
96
+ // =========================================================================
97
+ // setCachedCapture()
98
+ // =========================================================================
99
+ /**
100
+ * Store capture output in cache.
101
+ * Performs full sweep of TTL-expired entries before writing (SEC4-002).
102
+ * Enforces CACHE_MAX_ENTRIES limit by evicting oldest entry.
103
+ *
104
+ * @param sessionName - tmux session name (cache key)
105
+ * @param output - Captured output string
106
+ * @param capturedLines - Number of lines the output was captured with
107
+ */
108
+ function setCachedCapture(sessionName, output, capturedLines) {
109
+ const cache = getCache();
110
+ const now = Date.now();
111
+ // [SEC4-002] Full sweep: remove all TTL-expired entries
112
+ for (const [key, entry] of cache) {
113
+ if (now - entry.timestamp > exports.CACHE_TTL_MS) {
114
+ cache.delete(key);
115
+ }
116
+ }
117
+ // Enforce size limit (evict oldest if at capacity)
118
+ if (cache.size >= exports.CACHE_MAX_ENTRIES && !cache.has(sessionName)) {
119
+ // Find and delete oldest entry
120
+ let oldestKey = null;
121
+ let oldestTimestamp = Infinity;
122
+ for (const [key, entry] of cache) {
123
+ if (entry.timestamp < oldestTimestamp) {
124
+ oldestTimestamp = entry.timestamp;
125
+ oldestKey = key;
126
+ }
127
+ }
128
+ if (oldestKey) {
129
+ cache.delete(oldestKey);
130
+ }
131
+ }
132
+ cache.set(sessionName, {
133
+ output,
134
+ capturedLines,
135
+ timestamp: now,
136
+ });
137
+ }
138
+ // =========================================================================
139
+ // invalidateCache()
140
+ // =========================================================================
141
+ /**
142
+ * Invalidate cache for a specific session.
143
+ * [SEC4-006] Logs debug message with session name for troubleshooting.
144
+ *
145
+ * @param sessionName - tmux session name to invalidate
146
+ */
147
+ function invalidateCache(sessionName) {
148
+ const cache = getCache();
149
+ cache.delete(sessionName);
150
+ // [SEC4-006] Debug log for cache invalidation chain tracking
151
+ console.debug('invalidateCache:', { sessionName });
152
+ }
153
+ // =========================================================================
154
+ // clearAllCache()
155
+ // =========================================================================
156
+ /**
157
+ * Clear all cache entries and inflight requests.
158
+ * Used for graceful shutdown.
159
+ */
160
+ function clearAllCache() {
161
+ const cache = getCache();
162
+ cache.clear();
163
+ const inflight = getInflight();
164
+ inflight.clear();
165
+ }
166
+ // =========================================================================
167
+ // resetCacheForTesting()
168
+ // =========================================================================
169
+ /**
170
+ * Reset cache and inflight maps for test isolation.
171
+ * @internal Exported for testing purposes only.
172
+ */
173
+ function resetCacheForTesting() {
174
+ globalThis.__tmuxCaptureCache = undefined;
175
+ globalThis.__tmuxCaptureCacheInflight = undefined;
176
+ }
177
+ // =========================================================================
178
+ // getOrFetchCapture() - singleflight pattern
179
+ // =========================================================================
180
+ /**
181
+ * Get capture output from cache or fetch it.
182
+ * Implements singleflight pattern: concurrent requests for the same session
183
+ * share a single fetchFn invocation.
184
+ *
185
+ * [DA3-002] Singleflight key is sessionName which contains cliToolId,
186
+ * preventing cross-cliTool error context mismatch.
187
+ *
188
+ * @param sessionName - tmux session name
189
+ * @param requestedLines - Number of lines requested
190
+ * @param fetchFn - Function to fetch output on cache miss
191
+ * @returns Captured output (sliced to requestedLines)
192
+ */
193
+ async function getOrFetchCapture(sessionName, requestedLines, fetchFn) {
194
+ // 1. Check cache first
195
+ const cached = getCachedCapture(sessionName, requestedLines);
196
+ if (cached !== null) {
197
+ return cached;
198
+ }
199
+ // 2. Check for inflight request (singleflight)
200
+ const inflight = getInflight();
201
+ const existingPromise = inflight.get(sessionName);
202
+ if (existingPromise) {
203
+ const result = await existingPromise;
204
+ return sliceOutput(result, requestedLines);
205
+ }
206
+ // 3. Create new fetch promise
207
+ const fetchPromise = fetchFn();
208
+ inflight.set(sessionName, fetchPromise);
209
+ try {
210
+ const output = await fetchPromise;
211
+ // Cache non-empty results only [SEC4-007]
212
+ if (output.length > 0) {
213
+ setCachedCapture(sessionName, output, exports.CACHE_MAX_CAPTURE_LINES);
214
+ }
215
+ return sliceOutput(output, requestedLines);
216
+ }
217
+ finally {
218
+ // Clean up inflight entry
219
+ inflight.delete(sessionName);
220
+ }
221
+ }