commandmate 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) 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 +4 -4
  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/ollama/models/route.js +1 -0
  28. package/.next/server/app/api/ollama/models/route.js.nft.json +1 -0
  29. package/.next/server/app/api/ollama/models.body +1 -0
  30. package/.next/server/app/api/ollama/models.meta +1 -0
  31. package/.next/server/app/api/repositories/clone/[jobId]/route.js +1 -1
  32. package/.next/server/app/api/repositories/clone/route.js +1 -1
  33. package/.next/server/app/api/repositories/excluded/route.js +7 -7
  34. package/.next/server/app/api/repositories/restore/route.js +3 -3
  35. package/.next/server/app/api/repositories/route.js +13 -11
  36. package/.next/server/app/api/repositories/route.js.nft.json +1 -1
  37. package/.next/server/app/api/repositories/scan/route.js +1 -1
  38. package/.next/server/app/api/repositories/sync/route.js +3 -3
  39. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js +1 -1
  40. package/.next/server/app/api/worktrees/[id]/auto-yes/route.js.nft.json +1 -1
  41. package/.next/server/app/api/worktrees/[id]/cli-tool/route.js +1 -1
  42. package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
  43. package/.next/server/app/api/worktrees/[id]/current-output/route.js.nft.json +1 -1
  44. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js +1 -0
  45. package/.next/server/app/api/worktrees/[id]/execution-logs/[logId]/route.js.nft.json +1 -0
  46. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js +9 -0
  47. package/.next/server/app/api/worktrees/[id]/execution-logs/route.js.nft.json +1 -0
  48. package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
  49. package/.next/server/app/api/worktrees/[id]/interrupt/route.js +1 -1
  50. package/.next/server/app/api/worktrees/[id]/interrupt/route.js.nft.json +1 -1
  51. package/.next/server/app/api/worktrees/[id]/kill-session/route.js +1 -1
  52. package/.next/server/app/api/worktrees/[id]/kill-session/route.js.nft.json +1 -1
  53. package/.next/server/app/api/worktrees/[id]/logs/[filename]/route.js +1 -1
  54. package/.next/server/app/api/worktrees/[id]/logs/route.js +2 -2
  55. package/.next/server/app/api/worktrees/[id]/memos/[memoId]/route.js +1 -1
  56. package/.next/server/app/api/worktrees/[id]/memos/route.js +1 -1
  57. package/.next/server/app/api/worktrees/[id]/messages/route.js +1 -1
  58. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
  59. package/.next/server/app/api/worktrees/[id]/prompt-response/route.js.nft.json +1 -1
  60. package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
  61. package/.next/server/app/api/worktrees/[id]/respond/route.js.nft.json +1 -1
  62. package/.next/server/app/api/worktrees/[id]/route.js +1 -1
  63. package/.next/server/app/api/worktrees/[id]/route.js.nft.json +1 -1
  64. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -0
  65. package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js.nft.json +1 -0
  66. package/.next/server/app/api/worktrees/[id]/schedules/route.js +4 -0
  67. package/.next/server/app/api/worktrees/[id]/schedules/route.js.nft.json +1 -0
  68. package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
  69. package/.next/server/app/api/worktrees/[id]/send/route.js +1 -1
  70. package/.next/server/app/api/worktrees/[id]/send/route.js.nft.json +1 -1
  71. package/.next/server/app/api/worktrees/[id]/slash-commands/route.js +1 -1
  72. package/.next/server/app/api/worktrees/[id]/start-polling/route.js +1 -1
  73. package/.next/server/app/api/worktrees/[id]/start-polling/route.js.nft.json +1 -1
  74. package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
  75. package/.next/server/app/api/worktrees/[id]/tree/route.js +1 -1
  76. package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
  77. package/.next/server/app/api/worktrees/[id]/viewed/route.js +1 -1
  78. package/.next/server/app/api/worktrees/route.js +1 -1
  79. package/.next/server/app/api/worktrees/route.js.nft.json +1 -1
  80. package/.next/server/app/login/page.js.nft.json +1 -1
  81. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  82. package/.next/server/app/page.js +1 -1
  83. package/.next/server/app/page.js.nft.json +1 -1
  84. package/.next/server/app/page_client-reference-manifest.js +1 -1
  85. package/.next/server/app/proxy/[...path]/route.js +1 -1
  86. package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
  87. package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
  88. package/.next/server/app/worktrees/[id]/page.js +8 -3
  89. package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
  90. package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
  91. package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
  92. package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
  93. package/.next/server/app-paths-manifest.json +10 -5
  94. package/.next/server/chunks/2314.js +1 -0
  95. package/.next/server/chunks/3860.js +1 -1
  96. package/.next/server/chunks/4559.js +1 -1
  97. package/.next/server/chunks/539.js +10 -10
  98. package/.next/server/chunks/5853.js +1 -1
  99. package/.next/server/chunks/6228.js +1 -0
  100. package/.next/server/chunks/7425.js +112 -37
  101. package/.next/server/chunks/7566.js +1 -1
  102. package/.next/server/chunks/8693.js +1 -1
  103. package/.next/server/chunks/9446.js +1 -0
  104. package/.next/server/functions-config-manifest.json +1 -1
  105. package/.next/server/middleware-build-manifest.js +1 -1
  106. package/.next/server/middleware-manifest.json +5 -5
  107. package/.next/server/pages/500.html +1 -1
  108. package/.next/server/server-reference-manifest.json +1 -1
  109. package/.next/static/chunks/8091-274bc0716106e7fc.js +1 -0
  110. package/.next/static/chunks/app/page-060057e02b841125.js +1 -0
  111. package/.next/static/chunks/app/worktrees/[id]/page-78580947c201d698.js +1 -0
  112. package/.next/static/chunks/{main-db79434ee4a6c931.js → main-2feda12a4d321111.js} +1 -1
  113. package/.next/static/css/e85de230ef5ddc40.css +3 -0
  114. package/.next/trace +5 -5
  115. package/.next/types/app/api/ollama/models/route.ts +343 -0
  116. package/.next/types/app/api/worktrees/[id]/execution-logs/[logId]/route.ts +343 -0
  117. package/.next/types/app/api/worktrees/[id]/execution-logs/route.ts +343 -0
  118. package/.next/types/app/api/worktrees/[id]/schedules/[scheduleId]/route.ts +343 -0
  119. package/.next/types/app/api/worktrees/[id]/schedules/route.ts +343 -0
  120. package/README.md +74 -76
  121. package/dist/cli/utils/docs-reader.d.ts.map +1 -1
  122. package/dist/cli/utils/docs-reader.js +1 -0
  123. package/dist/server/server.js +5 -0
  124. package/dist/server/src/config/cmate-constants.js +79 -0
  125. package/dist/server/src/config/schedule-config.js +60 -0
  126. package/dist/server/src/lib/auto-yes-manager.js +2 -2
  127. package/dist/server/src/lib/claude-executor.js +158 -0
  128. package/dist/server/src/lib/cli-patterns.js +73 -9
  129. package/dist/server/src/lib/cli-tools/gemini.js +81 -22
  130. package/dist/server/src/lib/cli-tools/manager.js +4 -2
  131. package/dist/server/src/lib/cli-tools/types.js +64 -2
  132. package/dist/server/src/lib/cli-tools/vibe-local.js +163 -0
  133. package/dist/server/src/lib/cmate-parser.js +262 -0
  134. package/dist/server/src/lib/db-instance.js +3 -0
  135. package/dist/server/src/lib/db-migrations.js +145 -2
  136. package/dist/server/src/lib/db.js +51 -1
  137. package/dist/server/src/lib/env-sanitizer.js +57 -0
  138. package/dist/server/src/lib/prompt-detector.js +4 -3
  139. package/dist/server/src/lib/response-poller.js +22 -11
  140. package/dist/server/src/lib/schedule-manager.js +401 -0
  141. package/dist/server/src/lib/selected-agents-validator.js +99 -0
  142. package/dist/server/src/types/cmate.js +6 -0
  143. package/dist/server/src/types/sidebar.js +9 -4
  144. package/package.json +2 -1
  145. package/.next/server/chunks/7536.js +0 -1
  146. package/.next/static/chunks/8091-925542bdfc843dce.js +0 -1
  147. package/.next/static/chunks/app/page-238b5a70d8c101e9.js +0 -1
  148. package/.next/static/chunks/app/worktrees/[id]/page-a556551ce5c69dec.js +0 -1
  149. package/.next/static/css/b9ea6a4fad17dc32.css +0 -3
  150. /package/.next/static/{hmAjbCPjxX_C0Os7rphI1 → O7EDFfAYQNe_HRbORxQAC}/_buildManifest.js +0 -0
  151. /package/.next/static/{hmAjbCPjxX_C0Os7rphI1 → O7EDFfAYQNe_HRbORxQAC}/_ssgManifest.js +0 -0
@@ -32,6 +32,8 @@ exports.markPendingPromptsAsAnswered = markPendingPromptsAsAnswered;
32
32
  exports.updateFavorite = updateFavorite;
33
33
  exports.updateStatus = updateStatus;
34
34
  exports.updateCliToolId = updateCliToolId;
35
+ exports.updateSelectedAgents = updateSelectedAgents;
36
+ exports.updateVibeLocalModel = updateVibeLocalModel;
35
37
  exports.getMemosByWorktreeId = getMemosByWorktreeId;
36
38
  exports.getMemoById = getMemoById;
37
39
  exports.createMemo = createMemo;
@@ -44,6 +46,7 @@ exports.getWorktreeIdsByRepository = getWorktreeIdsByRepository;
44
46
  exports.deleteRepositoryWorktrees = deleteRepositoryWorktrees;
45
47
  exports.deleteWorktreesByIds = deleteWorktreesByIds;
46
48
  const crypto_1 = require("crypto");
49
+ const selected_agents_validator_1 = require("../lib/selected-agents-validator");
47
50
  function mapChatMessage(row) {
48
51
  return {
49
52
  id: row.id,
@@ -129,6 +132,16 @@ function initDatabase(db) {
129
132
  * Get latest user message per CLI tool for multiple worktrees (batch query)
130
133
  * Optimized to avoid N+1 query problem
131
134
  */
135
+ /**
136
+ * Get latest user message per CLI tool for multiple worktrees (batch query)
137
+ * Optimized to avoid N+1 query problem
138
+ *
139
+ * R4-001: SQL IN clause for cli_tool_id removed to eliminate SQLインジェクション risk.
140
+ * All cli_tool_id values are fetched; filtering happens at application layer.
141
+ * Tool count is at most 4-5, so the performance impact is negligible.
142
+ *
143
+ * R2-002: Return type changed to Partial<Record<CLIToolType, string>>
144
+ */
132
145
  function getLastMessagesByCliBatch(db, worktreeIds) {
133
146
  if (worktreeIds.length === 0) {
134
147
  return new Map();
@@ -149,7 +162,6 @@ function getLastMessagesByCliBatch(db, worktreeIds) {
149
162
  FROM chat_messages
150
163
  WHERE worktree_id IN (${placeholders})
151
164
  AND role = 'user'
152
- AND cli_tool_id IN ('claude', 'codex', 'gemini')
153
165
  )
154
166
  SELECT worktree_id, cli_tool_id, content
155
167
  FROM ranked_messages
@@ -181,6 +193,7 @@ function getWorktrees(db, repositoryPath) {
181
193
  w.id, w.name, w.path, w.repository_path, w.repository_name, w.description,
182
194
  w.last_user_message, w.last_user_message_at, w.last_message_summary,
183
195
  w.updated_at, w.favorite, w.status, w.link, w.cli_tool_id, w.last_viewed_at,
196
+ w.selected_agents, w.vibe_local_model,
184
197
  (SELECT MAX(timestamp) FROM chat_messages
185
198
  WHERE worktree_id = w.id AND role = 'assistant') as last_assistant_message_at
186
199
  FROM worktrees w
@@ -216,6 +229,8 @@ function getWorktrees(db, repositoryPath) {
216
229
  status: row.status || null,
217
230
  link: row.link || undefined,
218
231
  cliToolId: row.cli_tool_id ?? 'claude',
232
+ selectedAgents: (0, selected_agents_validator_1.parseSelectedAgents)(row.selected_agents),
233
+ vibeLocalModel: row.vibe_local_model ?? null,
219
234
  };
220
235
  });
221
236
  }
@@ -250,6 +265,7 @@ function getWorktreeById(db, id) {
250
265
  w.id, w.name, w.path, w.repository_path, w.repository_name, w.description,
251
266
  w.last_user_message, w.last_user_message_at, w.last_message_summary,
252
267
  w.updated_at, w.favorite, w.status, w.link, w.cli_tool_id, w.last_viewed_at,
268
+ w.selected_agents, w.vibe_local_model,
253
269
  (SELECT MAX(timestamp) FROM chat_messages
254
270
  WHERE worktree_id = w.id AND role = 'assistant') as last_assistant_message_at
255
271
  FROM worktrees w
@@ -276,6 +292,8 @@ function getWorktreeById(db, id) {
276
292
  status: row.status || null,
277
293
  link: row.link || undefined,
278
294
  cliToolId: row.cli_tool_id ?? 'claude',
295
+ selectedAgents: (0, selected_agents_validator_1.parseSelectedAgents)(row.selected_agents),
296
+ vibeLocalModel: row.vibe_local_model ?? null,
279
297
  };
280
298
  }
281
299
  /**
@@ -678,6 +696,38 @@ function updateCliToolId(db, id, cliToolId) {
678
696
  `);
679
697
  stmt.run(cliToolId, id);
680
698
  }
699
+ /**
700
+ * Update selected_agents for a worktree
701
+ * Issue #368: Persists the user's choice of 2 display agents
702
+ *
703
+ * @param db - Database instance
704
+ * @param id - Worktree ID
705
+ * @param selectedAgents - Tuple of 2 CLIToolType values
706
+ */
707
+ function updateSelectedAgents(db, id, selectedAgents) {
708
+ const stmt = db.prepare(`
709
+ UPDATE worktrees
710
+ SET selected_agents = ?
711
+ WHERE id = ?
712
+ `);
713
+ stmt.run(JSON.stringify(selectedAgents), id);
714
+ }
715
+ /**
716
+ * Update vibe_local_model for a worktree
717
+ * Issue #368: Persists the user's Ollama model selection for vibe-local
718
+ *
719
+ * @param db - Database instance
720
+ * @param id - Worktree ID
721
+ * @param model - Model name or null for default
722
+ */
723
+ function updateVibeLocalModel(db, id, model) {
724
+ const stmt = db.prepare(`
725
+ UPDATE worktrees
726
+ SET vibe_local_model = ?
727
+ WHERE id = ?
728
+ `);
729
+ stmt.run(model, id);
730
+ }
681
731
  /**
682
732
  * Map database row to WorktreeMemo model
683
733
  */
@@ -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
+ }
@@ -166,11 +166,12 @@ const TEXT_INPUT_PATTERNS = [
166
166
  /differently/i,
167
167
  ];
168
168
  /**
169
- * Pattern for ❯ (U+276F) indicator lines used by Claude CLI to mark the default selection.
169
+ * Pattern for ❯ (U+276F) / ● (U+25CF) indicator lines used by CLI tools to mark the default selection.
170
+ * Claude CLI uses ❯, Gemini CLI uses ●.
170
171
  * Used in Pass 1 (existence check) and Pass 2 (option collection) of the 2-pass detection.
171
172
  * Anchored at both ends -- ReDoS safe (S4-001).
172
173
  */
173
- const DEFAULT_OPTION_PATTERN = /^\s*\u276F\s*(\d+)\.\s*(.+)$/;
174
+ const DEFAULT_OPTION_PATTERN = /^\s*[\u276F\u25CF]\s*(\d+)\.\s*(.+)$/;
174
175
  /**
175
176
  * Pattern for normal option lines (no ❯ indicator, just leading whitespace + number).
176
177
  * Only applied in Pass 2 when ❯ indicator existence is confirmed by Pass 1.
@@ -567,7 +568,7 @@ function detectMultipleChoicePrompt(output, options) {
567
568
  // user input prompt (e.g., "❯ 1", "❯ /command") or idle prompt ("❯").
568
569
  // Anything above this line in the scrollback is historical conversation text,
569
570
  // not an active prompt. Stop scanning to prevent false positives.
570
- if (collectedOptions.length === 0 && line.startsWith('\u276F')) {
571
+ if (collectedOptions.length === 0 && (line.startsWith('\u276F') || line.startsWith('\u25CF'))) {
571
572
  return noPromptResult(output);
572
573
  }
573
574
  // Non-option line handling
@@ -131,7 +131,7 @@ function getPollerKey(worktreeId, cliToolId) {
131
131
  */
132
132
  function detectPromptWithOptions(output, cliToolId) {
133
133
  const promptOptions = (0, cli_patterns_1.buildDetectPromptOptions)(cliToolId);
134
- return (0, prompt_detector_1.detectPrompt)((0, cli_patterns_1.stripAnsi)(output), promptOptions);
134
+ return (0, prompt_detector_1.detectPrompt)((0, cli_patterns_1.stripBoxDrawing)((0, cli_patterns_1.stripAnsi)(output)), promptOptions);
135
135
  }
136
136
  /**
137
137
  * Clean up Claude response by removing shell setup commands, environment exports, ANSI codes, and banner
@@ -372,11 +372,12 @@ function extractResponse(output, lastCapturedLine, cliToolId) {
372
372
  const hasPrompt = promptPattern.test(cleanOutputToCheck);
373
373
  const hasSeparator = separatorPattern.test(cleanOutputToCheck);
374
374
  const isThinking = thinkingPattern.test(cleanOutputToCheck);
375
- // Codex/Gemini completion logic: prompt detected and not thinking (separator optional)
375
+ // Codex/Gemini/Vibe-Local completion logic: prompt detected and not thinking (separator optional)
376
376
  // - Codex: Interactive TUI, detects › prompt
377
- // - Gemini: Non-interactive one-shot, detects shell prompt (%, $)
377
+ // - Gemini: Interactive REPL, detects > / prompt
378
+ // - Vibe-Local: Interactive REPL, detects > prompt
378
379
  // Claude: require both prompt and separator
379
- const isCodexOrGeminiComplete = (cliToolId === 'codex' || cliToolId === 'gemini') && hasPrompt && !isThinking;
380
+ const isCodexOrGeminiComplete = (cliToolId === 'codex' || cliToolId === 'gemini' || cliToolId === 'vibe-local') && hasPrompt && !isThinking;
380
381
  const isClaudeComplete = cliToolId === 'claude' && hasPrompt && hasSeparator && !isThinking;
381
382
  if (isCodexOrGeminiComplete || isClaudeComplete) {
382
383
  // CLI tool has completed response
@@ -667,10 +668,15 @@ function startPolling(worktreeId, cliToolId) {
667
668
  stopPolling(worktreeId, cliToolId);
668
669
  // Record start time
669
670
  pollingStartTimes.set(pollerKey, Date.now());
670
- // Start polling
671
- const interval = setInterval(async () => {
672
- const startTime = pollingStartTimes.get(pollerKey);
671
+ // Start polling with setTimeout chain to prevent race conditions
672
+ scheduleNextResponsePoll(worktreeId, cliToolId);
673
+ }
674
+ /** Schedule next checkForResponse() after current one completes (setTimeout chain) */
675
+ function scheduleNextResponsePoll(worktreeId, cliToolId) {
676
+ const pollerKey = getPollerKey(worktreeId, cliToolId);
677
+ const timerId = setTimeout(async () => {
673
678
  // Check if max duration exceeded
679
+ const startTime = pollingStartTimes.get(pollerKey);
674
680
  if (startTime && Date.now() - startTime > MAX_POLLING_DURATION) {
675
681
  stopPolling(worktreeId, cliToolId);
676
682
  return;
@@ -682,8 +688,13 @@ function startPolling(worktreeId, cliToolId) {
682
688
  catch (error) {
683
689
  console.error(`[Poller] Error:`, error);
684
690
  }
691
+ // Schedule next poll ONLY after current one completes
692
+ // Guard: only if poller is still active (not stopped during checkForResponse)
693
+ if (activePollers.has(pollerKey)) {
694
+ scheduleNextResponsePoll(worktreeId, cliToolId);
695
+ }
685
696
  }, POLLING_INTERVAL);
686
- activePollers.set(pollerKey, interval);
697
+ activePollers.set(pollerKey, timerId);
687
698
  }
688
699
  /**
689
700
  * Stop polling for a worktree and CLI tool combination
@@ -698,9 +709,9 @@ function startPolling(worktreeId, cliToolId) {
698
709
  */
699
710
  function stopPolling(worktreeId, cliToolId) {
700
711
  const pollerKey = getPollerKey(worktreeId, cliToolId);
701
- const interval = activePollers.get(pollerKey);
702
- if (interval) {
703
- clearInterval(interval);
712
+ const timerId = activePollers.get(pollerKey);
713
+ if (timerId) {
714
+ clearTimeout(timerId);
704
715
  activePollers.delete(pollerKey);
705
716
  pollingStartTimes.delete(pollerKey);
706
717
  }
@@ -0,0 +1,401 @@
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, vibe_local_model FROM worktrees WHERE id = ?').get(state.worktreeId);
224
+ if (!worktree) {
225
+ updateExecutionLog(logId, 'failed', 'Worktree not found', null);
226
+ return;
227
+ }
228
+ // Build options for vibe-local model
229
+ const options = state.entry.cliToolId === 'vibe-local' && worktree.vibe_local_model
230
+ ? { model: worktree.vibe_local_model }
231
+ : undefined;
232
+ const result = await (0, claude_executor_1.executeClaudeCommand)(state.entry.message, worktree.path, state.entry.cliToolId, state.entry.permission, options);
233
+ updateExecutionLog(logId, result.status, result.output, result.exitCode);
234
+ updateScheduleLastExecuted(state.scheduleId);
235
+ console.log(`[schedule-manager] Executed ${state.entry.name}: ${result.status}`);
236
+ }
237
+ catch (error) {
238
+ const errorMessage = error instanceof Error ? error.message : String(error);
239
+ updateExecutionLog(logId, 'failed', errorMessage, null);
240
+ console.error(`[schedule-manager] Execution error for ${state.entry.name}:`, errorMessage);
241
+ }
242
+ finally {
243
+ state.isExecuting = false;
244
+ }
245
+ }
246
+ // =============================================================================
247
+ // CMATE.md Sync
248
+ // =============================================================================
249
+ /**
250
+ * Sync schedules from CMATE.md files for all worktrees.
251
+ * Reads CMATE.md from each worktree, upserts schedules to DB,
252
+ * creates/updates cron jobs, and removes stale schedules.
253
+ */
254
+ function syncSchedules() {
255
+ 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;
280
+ continue;
281
+ }
282
+ if (!entry.enabled || !entry.cronExpression)
283
+ 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
+ }
304
+ catch (cronError) {
305
+ console.warn(`[schedule-manager] Invalid cron for ${entry.name}:`, cronError);
306
+ }
307
+ }
308
+ }
309
+ catch (error) {
310
+ console.error(`[schedule-manager] Error syncing schedules for worktree ${worktree.id}:`, error);
311
+ }
312
+ }
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
+ }
320
+ }
321
+ // Disable DB records for schedules no longer in CMATE.md
322
+ const worktreeIds = worktrees.map(w => w.id);
323
+ disableStaleSchedules(activeScheduleIds, worktreeIds);
324
+ }
325
+ // =============================================================================
326
+ // Manager Lifecycle
327
+ // =============================================================================
328
+ /**
329
+ * Initialize the schedule manager.
330
+ * Must be called after initializeWorktrees() completes.
331
+ *
332
+ * [S3-010] Called after await initializeWorktrees() in server.ts
333
+ */
334
+ function initScheduleManager() {
335
+ const manager = getManagerState();
336
+ if (manager.initialized) {
337
+ console.log('[schedule-manager] Already initialized, skipping');
338
+ return;
339
+ }
340
+ console.log('[schedule-manager] Initializing...');
341
+ // Recovery: mark stale running logs as failed
342
+ recoverRunningLogs();
343
+ // Initial sync
344
+ syncSchedules();
345
+ // Start periodic sync timer
346
+ manager.timerId = setInterval(() => {
347
+ syncSchedules();
348
+ }, exports.POLL_INTERVAL_MS);
349
+ manager.initialized = true;
350
+ console.log(`[schedule-manager] Initialized with ${manager.schedules.size} schedule(s)`);
351
+ }
352
+ /**
353
+ * Stop all schedules and clean up resources.
354
+ * Uses synchronous SIGKILL fire-and-forget for immediate cleanup.
355
+ *
356
+ * [S3-001] Designed to complete within gracefulShutdown's 3-second timeout
357
+ */
358
+ function stopAllSchedules() {
359
+ const manager = getManagerState();
360
+ // Stop the polling timer
361
+ if (manager.timerId !== null) {
362
+ clearInterval(manager.timerId);
363
+ manager.timerId = null;
364
+ }
365
+ // Stop all cron jobs
366
+ for (const [, state] of manager.schedules) {
367
+ try {
368
+ state.cronJob.stop();
369
+ }
370
+ catch {
371
+ // Ignore errors during cleanup
372
+ }
373
+ }
374
+ manager.schedules.clear();
375
+ // Kill all active child processes (fire-and-forget SIGKILL)
376
+ const activeProcesses = (0, claude_executor_1.getActiveProcesses)();
377
+ for (const [pid] of activeProcesses) {
378
+ try {
379
+ process.kill(pid, 'SIGKILL');
380
+ }
381
+ catch {
382
+ // Process may have already exited - ignore
383
+ }
384
+ }
385
+ activeProcesses.clear();
386
+ manager.initialized = false;
387
+ console.log('[schedule-manager] All schedules stopped');
388
+ }
389
+ /**
390
+ * Get the current number of active schedules.
391
+ * Useful for monitoring and testing.
392
+ */
393
+ function getActiveScheduleCount() {
394
+ return getManagerState().schedules.size;
395
+ }
396
+ /**
397
+ * Check if the schedule manager is initialized.
398
+ */
399
+ function isScheduleManagerInitialized() {
400
+ return getManagerState().initialized;
401
+ }