circuschief 0.7.0 → 1.0.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 (185) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/agents/adapters/CodexAdapter.js +5 -4
  3. package/packages/server/src/api/canvas.js +22 -57
  4. package/packages/server/src/api/commandButtons.js +16 -15
  5. package/packages/server/src/api/index.js +2 -0
  6. package/packages/server/src/api/kanban.js +4 -2
  7. package/packages/server/src/api/projects-commandButtons.js +6 -6
  8. package/packages/server/src/api/projects-helpers.js +20 -3
  9. package/packages/server/src/api/projects-session-create.js +109 -0
  10. package/packages/server/src/api/projects-session-defaults.js +51 -0
  11. package/packages/server/src/api/projects-session-helpers.js +57 -5
  12. package/packages/server/src/api/projects-templates.js +38 -0
  13. package/packages/server/src/api/projects.js +28 -170
  14. package/packages/server/src/api/providers.js +11 -1
  15. package/packages/server/src/api/sessions-commands.js +46 -25
  16. package/packages/server/src/api/sessions-lifecycle.js +10 -10
  17. package/packages/server/src/api/sessions-patch.js +45 -1
  18. package/packages/server/src/api/sessions.js +6 -5
  19. package/packages/server/src/database.js +0 -2
  20. package/packages/server/src/db/DatabaseManager.js +5 -1
  21. package/packages/server/src/db/ProjectDefaultsRepository.js +3 -3
  22. package/packages/server/src/db/ProviderRepository.js +87 -32
  23. package/packages/server/src/db/SessionRepository.js +2 -1
  24. package/packages/server/src/db/SessionTemplateRepository.js +23 -2
  25. package/packages/server/src/db/index.js +0 -3
  26. package/packages/server/src/db/migrations/index.js +60 -7
  27. package/packages/server/src/db/migrations/kanbanMigrations.js +1 -1
  28. package/packages/server/src/db/migrations/miscMigrations.js +59 -184
  29. package/packages/server/src/db/migrations/projectsMigrations.js +31 -0
  30. package/packages/server/src/db/migrations/providerCommitAttributionMigrations.js +30 -0
  31. package/packages/server/src/db/migrations/providerMigrations.js +165 -0
  32. package/packages/server/src/db/migrations/sessionTableRecreate.js +136 -0
  33. package/packages/server/src/db/migrations/sessionsMigrations.js +18 -5
  34. package/packages/server/src/db/seedBaselineData.js +137 -0
  35. package/packages/server/src/db/session-helpers.js +32 -4
  36. package/packages/server/src/middleware/sessionLookup.js +81 -8
  37. package/packages/server/src/schema.sql +153 -132
  38. package/packages/server/src/scripts/backupDatabase.js +21 -0
  39. package/packages/server/src/scripts/dbUtils.js +81 -0
  40. package/packages/server/src/scripts/inspectDatabaseSchema.js +81 -0
  41. package/packages/server/src/scripts/validateDatabaseBaseline.js +212 -0
  42. package/packages/server/src/services/codexSpawnHelper.js +9 -0
  43. package/packages/server/src/services/commandButtonPrompts.js +14 -12
  44. package/packages/server/src/services/commandRunner.js +7 -1
  45. package/packages/server/src/services/e2eSpawnCapture.js +147 -0
  46. package/packages/server/src/services/gitCommitAttribution.js +150 -0
  47. package/packages/server/src/services/gitDiff.js +132 -0
  48. package/packages/server/src/services/gitRepoUrl.js +174 -0
  49. package/packages/server/src/services/gitService.js +48 -311
  50. package/packages/server/src/services/gitSessionSetup.js +11 -1
  51. package/packages/server/src/services/gitWorktree.js +127 -0
  52. package/packages/server/src/services/kanbanTriggers.js +6 -3
  53. package/packages/server/src/services/nodeSpawnHelper.js +9 -0
  54. package/packages/server/src/services/prUrlService.js +3 -3
  55. package/packages/server/src/services/queryParamBuilder.js +90 -0
  56. package/packages/server/src/services/sessionDuplicator.js +1 -5
  57. package/packages/server/src/services/sessionExecution.js +56 -108
  58. package/packages/server/src/services/sessionPrompts.js +12 -47
  59. package/packages/server/src/services/sessionProvider.js +10 -0
  60. package/packages/server/src/services/summaryService.js +5 -3
  61. package/packages/server/src/services/summaryStaleCheck.js +23 -4
  62. package/packages/server/src/services/templateTriggerService.js +3 -1
  63. package/packages/shared/src/constants.js +3 -0
  64. package/packages/shared/src/contracts/commandButtons.js +16 -2
  65. package/packages/shared/src/contracts/projects.js +2 -2
  66. package/packages/shared/src/contracts/providers.js +60 -0
  67. package/packages/shared/src/contracts/sessions.js +29 -2
  68. package/packages/shared/src/contracts/templates.js +12 -2
  69. package/packages/shared/src/types.js +1 -9
  70. package/packages/shared/src/utils.js +2 -2
  71. package/packages/web/dist/assets/{ActiveSessionsView-UJsCILDL.js → ActiveSessionsView-Cxh8mHmB.js} +1 -1
  72. package/packages/web/dist/assets/{AgentLogsView-BGFPLjLa.js → AgentLogsView-xdfI2bR6.js} +2 -2
  73. package/packages/web/dist/assets/ApiClient-DfbJwzpz.js +1 -0
  74. package/packages/web/dist/assets/ArchiveConfirmModal-DXZYdzHR.js +1 -0
  75. package/packages/web/dist/assets/ArchiveConfirmModal-DeoCVGXt.css +1 -0
  76. package/packages/web/dist/assets/CommandButtonDetailView-D8xfqLAp.js +1 -0
  77. package/packages/web/dist/assets/CommandButtonDetailView-D9zjx9ME.css +1 -0
  78. package/packages/web/dist/assets/EffortLevelSelector-D2Hdzc_8.js +1 -0
  79. package/packages/web/dist/assets/{GeneralSettingsView-DsHChEhv.js → GeneralSettingsView-sPXkLlLy.js} +1 -1
  80. package/packages/web/dist/assets/InputWithButton-B-o0DgMH.js +1 -0
  81. package/packages/web/dist/assets/{InputWithButton-cYdrEmTs.css → InputWithButton-D9HMvfR5.css} +1 -1
  82. package/packages/web/dist/assets/{InterpolationHelp-CIkOSkWX.js → InterpolationHelp-Dxn1li4l.js} +1 -1
  83. package/packages/web/dist/assets/MarkdownEditor-D4Kbb-9l.js +2 -0
  84. package/packages/web/dist/assets/ModelSelector-72C7MUH4.js +1 -0
  85. package/packages/web/dist/assets/{ModelSelector-D8hbTRIt.css → ModelSelector-BNYKujL-.css} +1 -1
  86. package/packages/web/dist/assets/NewSessionView-BR_COfgW.js +3 -0
  87. package/packages/web/dist/assets/NewSessionView-DBl7T2Xp.css +1 -0
  88. package/packages/web/dist/assets/ProjectEditView-DbqTbA0q.css +1 -0
  89. package/packages/web/dist/assets/ProjectEditView-WImU7sNd.js +1 -0
  90. package/packages/web/dist/assets/{ProjectListView-B9FuWESY.js → ProjectListView-CYmmAcBD.js} +1 -1
  91. package/packages/web/dist/assets/{ProjectNewView-D62jYlBL.js → ProjectNewView-DEhqw3Jv.js} +1 -1
  92. package/packages/web/dist/assets/ProvidersView-XZh3jkmH.js +1 -0
  93. package/packages/web/dist/assets/ProvidersView-bZemq_Rv.css +1 -0
  94. package/packages/web/dist/assets/QuickResponsesPanel-BqmnTd-D.js +1 -0
  95. package/packages/web/dist/assets/QuickResponsesPanel-dk-Rj8xx.css +1 -0
  96. package/packages/web/dist/assets/ResizableTextarea-BQNw5e0C.css +1 -0
  97. package/packages/web/dist/assets/ResizableTextarea-DpWdIAP6.js +1 -0
  98. package/packages/web/dist/assets/SessionCard-Bw77-KwD.js +1 -0
  99. package/packages/web/dist/assets/SessionDetailView-B59TEkr-.js +36 -0
  100. package/packages/web/dist/assets/SessionDetailView-CKVBnR4T.css +1 -0
  101. package/packages/web/dist/assets/{SessionFormOptions-DYUISplS.js → SessionFormOptions-hqijxc0S.js} +1 -1
  102. package/packages/web/dist/assets/SessionListView-3-xx6EVs.css +1 -0
  103. package/packages/web/dist/assets/SessionListView-DYXHM9I-.js +1 -0
  104. package/packages/web/dist/assets/{SessionLogStream-DpUE6Xsh.js → SessionLogStream-5NfVr9pF.js} +6 -6
  105. package/packages/web/dist/assets/{SettingsView-BC055tIA.js → SettingsView-DI8ncOAV.js} +1 -1
  106. package/packages/web/dist/assets/{SlashCommandWizard-DmTyNG9O.js → SlashCommandWizard-BQ_rMzn-.js} +1 -1
  107. package/packages/web/dist/assets/{SlashCommandWizard-Dn7sNaBd.css → SlashCommandWizard-DJzw3LP5.css} +1 -1
  108. package/packages/web/dist/assets/{SummarySettingsView-BgnRCwlq.js → SummarySettingsView-C2Qs35mm.js} +1 -1
  109. package/packages/web/dist/assets/TemplateDetailView-B5NI2oTR.css +1 -0
  110. package/packages/web/dist/assets/TemplateDetailView-zVkIvgtu.js +1 -0
  111. package/packages/web/dist/assets/{commandButtons-D4RPpLiu.js → commandButtons-CoU3G4zK.js} +1 -1
  112. package/packages/web/dist/assets/index-9yF1uCCA.js +1 -0
  113. package/packages/web/dist/assets/index-BKstCaYU.js +1 -0
  114. package/packages/web/dist/assets/index-BhbH7eOk.js +1 -0
  115. package/packages/web/dist/assets/{index-BGwH4Cfn.js → index-BjuRttEY.js} +3 -3
  116. package/packages/web/dist/assets/index-Bo7PdwM5.js +1 -0
  117. package/packages/web/dist/assets/index-C2QFVD7d.js +83 -0
  118. package/packages/web/dist/assets/index-C7Ww2auW.js +1 -0
  119. package/packages/web/dist/assets/index-CAGdsDh7.js +1 -0
  120. package/packages/web/dist/assets/index-CLRsVASf.js +3 -0
  121. package/packages/web/dist/assets/{index-Bn5xdGFM.js → index-CP-SxOlV.js} +1 -1
  122. package/packages/web/dist/assets/index-CslU0psO.js +1 -0
  123. package/packages/web/dist/assets/index-DI4NxaWD.js +1 -0
  124. package/packages/web/dist/assets/index-DOzONENy.js +1 -0
  125. package/packages/web/dist/assets/index-DUa7adFh.js +1 -0
  126. package/packages/web/dist/assets/index-DZBpETI5.js +1 -0
  127. package/packages/web/dist/assets/index-DsjWqc6R.js +7 -0
  128. package/packages/web/dist/assets/index-c99Bo3JV.js +1 -0
  129. package/packages/web/dist/assets/index-mT1JpxDc.js +1 -0
  130. package/packages/web/dist/assets/index-rkQx2tso.js +1 -0
  131. package/packages/web/dist/assets/{index-Cs2nxhrT.css → index-uySCcnA_.css} +1 -1
  132. package/packages/web/dist/assets/projectDefaults-B8esIcYq.js +1 -0
  133. package/packages/web/dist/assets/{projects-BUiOGmmb.js → projects-C-8PSxKi.js} +1 -1
  134. package/packages/web/dist/assets/{providers-Bh1ZiiJi.js → providers-oXifvvqN.js} +1 -1
  135. package/packages/web/dist/assets/sessions-Nq5VafSf.js +1 -0
  136. package/packages/web/dist/assets/{settings-Z4AVVmkJ.js → settings-DtpuiyT6.js} +1 -1
  137. package/packages/web/dist/index.html +2 -2
  138. package/packages/server/src/api/sessions-notes.js +0 -51
  139. package/packages/server/src/db/SessionNoteRepository.js +0 -60
  140. package/packages/web/dist/assets/ApiClient-B4YTtyY4.js +0 -1
  141. package/packages/web/dist/assets/ArchiveConfirmModal-BQ-4gI0R.css +0 -1
  142. package/packages/web/dist/assets/ArchiveConfirmModal-OFaj_uX5.js +0 -1
  143. package/packages/web/dist/assets/CommandButtonDetailView-D8S258uP.js +0 -1
  144. package/packages/web/dist/assets/CommandButtonDetailView-DBm3rzhw.css +0 -1
  145. package/packages/web/dist/assets/EffortLevelSelector-C2378L8e.js +0 -1
  146. package/packages/web/dist/assets/InputWithButton-Ci15ox0a.js +0 -1
  147. package/packages/web/dist/assets/MarkdownEditor-5-bexzUT.js +0 -2
  148. package/packages/web/dist/assets/ModelSelector-BMpR0DPr.js +0 -1
  149. package/packages/web/dist/assets/NewSessionView-BCqtIgWH.js +0 -3
  150. package/packages/web/dist/assets/NewSessionView-CUUdHkfv.css +0 -1
  151. package/packages/web/dist/assets/ProjectEditView-D9sK0fdH.css +0 -1
  152. package/packages/web/dist/assets/ProjectEditView-RFaxHhAX.js +0 -1
  153. package/packages/web/dist/assets/ProvidersView-DDKMIQWZ.js +0 -1
  154. package/packages/web/dist/assets/ProvidersView-DE82G_5W.css +0 -1
  155. package/packages/web/dist/assets/QuickResponseSettings-B8188A1D.css +0 -1
  156. package/packages/web/dist/assets/QuickResponseSettings-CDm5vwP7.js +0 -1
  157. package/packages/web/dist/assets/QuickResponsesPanel-BlFDvnZ2.css +0 -1
  158. package/packages/web/dist/assets/QuickResponsesPanel-DZ_Lre_l.js +0 -1
  159. package/packages/web/dist/assets/ResizableTextarea-DiIOEGjN.js +0 -1
  160. package/packages/web/dist/assets/ResizableTextarea-DsU3TVwF.css +0 -1
  161. package/packages/web/dist/assets/SessionCard-DmjnVYWn.js +0 -1
  162. package/packages/web/dist/assets/SessionDetailView-CL7nmfiB.js +0 -36
  163. package/packages/web/dist/assets/SessionDetailView-CupIkI7u.css +0 -1
  164. package/packages/web/dist/assets/SessionListView-BcxGz4aC.js +0 -1
  165. package/packages/web/dist/assets/SessionListView-fHlQyecX.css +0 -1
  166. package/packages/web/dist/assets/TemplateDetailView-BlhOmLUX.js +0 -1
  167. package/packages/web/dist/assets/TemplateDetailView-DT2m06W7.css +0 -1
  168. package/packages/web/dist/assets/index-4rhEeO0B.js +0 -1
  169. package/packages/web/dist/assets/index-9vb2KaAd.js +0 -1
  170. package/packages/web/dist/assets/index-B0CvZXuN.js +0 -7
  171. package/packages/web/dist/assets/index-B6G18FqB.js +0 -82
  172. package/packages/web/dist/assets/index-BUhvkAdF.js +0 -1
  173. package/packages/web/dist/assets/index-BcnkUk2o.js +0 -1
  174. package/packages/web/dist/assets/index-CNwkdB0T.js +0 -1
  175. package/packages/web/dist/assets/index-CfL84oGW.js +0 -1
  176. package/packages/web/dist/assets/index-CkmxO8Mm.js +0 -1
  177. package/packages/web/dist/assets/index-Cpy4-yv3.js +0 -1
  178. package/packages/web/dist/assets/index-CrAQJmoZ.js +0 -1
  179. package/packages/web/dist/assets/index-D6Ky9vJe.js +0 -3
  180. package/packages/web/dist/assets/index-DfrE0gAC.js +0 -1
  181. package/packages/web/dist/assets/index-KwEyz0F3.js +0 -1
  182. package/packages/web/dist/assets/index-OfCywayk.js +0 -1
  183. package/packages/web/dist/assets/index-PDesaJc6.js +0 -1
  184. package/packages/web/dist/assets/index-uB6nhSvz.js +0 -1
  185. package/packages/web/dist/assets/sessions-DH1R-NhV.js +0 -1
@@ -1,21 +1,42 @@
1
1
  import { exec } from 'child_process';
2
2
  import { promisify } from 'util';
3
- import path from 'path';
4
- import { realpath } from 'fs/promises';
3
+ export {
4
+ _setManagedHooksPath,
5
+ clearWorktreeCommitAttribution,
6
+ configureWorktreeCommitAttribution,
7
+ ensureWorktreeCommitAttributionHook,
8
+ getManagedHooksPath,
9
+ } from './gitCommitAttribution.js';
10
+ export {
11
+ normalizeGitRemoteUrl,
12
+ getRepositoryUrl,
13
+ detectWorktreePath,
14
+ } from './gitRepoUrl.js';
15
+ export {
16
+ getDiff,
17
+ getStagedDiff,
18
+ getUntrackedFiles,
19
+ getDiffAgainstBranch,
20
+ getStagedDiffAgainstBranch,
21
+ getDiffBetweenRefs,
22
+ getModifiedFilesCount,
23
+ } from './gitDiff.js';
24
+ export {
25
+ branchExists,
26
+ checkoutBranch,
27
+ createWorktree,
28
+ removeWorktree,
29
+ createWorktreeForBranch,
30
+ } from './gitWorktree.js';
5
31
 
6
32
  const execAsync = promisify(exec);
7
33
 
8
- // Cache for default branch detection per repository
9
- // Key: directory path, Value: { branch: string, timestamp: number }
34
+ // Cache for default branch detection: directory -> { branch, timestamp }
10
35
  const defaultBranchCache = new Map();
11
36
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
12
37
  const MAX_CACHE_SIZE = 100; // Maximum number of repositories to cache
13
38
 
14
- // Configurable logger for warning messages
15
- // Can be overridden via setLogger() for custom logging behavior
16
- let logger = {
17
- warn: (...args) => console.warn(...args),
18
- };
39
+ import { _setWorktreeLogger } from './gitWorktree.js';
19
40
 
20
41
  /**
21
42
  * Set a custom logger for git service warnings.
@@ -24,19 +45,15 @@ let logger = {
24
45
  * @param {Function} customLogger.warn - Function to handle warning messages
25
46
  */
26
47
  export function setLogger(customLogger) {
27
- logger = customLogger;
48
+ _setWorktreeLogger(customLogger);
28
49
  }
29
50
 
30
- /**
31
- * Evict oldest entries from cache if it exceeds MAX_CACHE_SIZE.
32
- * Uses LRU-like eviction based on timestamp.
33
- */
51
+ /** Evict oldest cache entries if size exceeds MAX_CACHE_SIZE (LRU-like). */
34
52
  function evictOldestCacheEntries() {
35
53
  if (defaultBranchCache.size <= MAX_CACHE_SIZE) {
36
54
  return;
37
55
  }
38
56
 
39
- // Sort entries by timestamp and remove oldest ones
40
57
  const entries = [...defaultBranchCache.entries()];
41
58
  entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
42
59
 
@@ -46,36 +63,27 @@ function evictOldestCacheEntries() {
46
63
  }
47
64
  }
48
65
 
49
- /**
50
- * Safely fetch from origin remote.
51
- * Logs a warning if fetch fails but does not throw.
52
- * @param {string} directory - The git repository directory
53
- * @returns {Promise<boolean>} - True if fetch succeeded, false otherwise
54
- */
55
- async function safeFetchOrigin(directory) {
56
- try {
57
- await git(directory, 'fetch origin');
58
- return true;
59
- } catch (err) {
60
- // No origin or network unavailable, proceed without fetch
61
- logger.warn('Could not fetch from origin, proceeding with local refs:', err.message);
62
- return false;
63
- }
64
- }
65
-
66
66
  /**
67
67
  * Execute a git command in a directory
68
68
  * @param {string} directory
69
69
  * @param {string} command
70
+ * @param {Object} [opts]
71
+ * @param {Object} [opts.env]
72
+ * @param {number} [opts.timeout]
70
73
  * @returns {Promise<string>}
71
74
  */
72
- async function git(directory, command, opts = {}) {
75
+ export async function git(directory, command, opts = {}) {
73
76
  const execOpts = { cwd: directory };
74
77
  if (opts.env) execOpts.env = opts.env;
78
+ if (opts.timeout) execOpts.timeout = opts.timeout;
75
79
  const { stdout } = await execAsync(`git ${command}`, execOpts);
76
80
  return stdout.trim();
77
81
  }
78
82
 
83
+ function shellQuote(value) {
84
+ return `'${String(value).replace(/'/g, `'\\''`)}'`;
85
+ }
86
+
79
87
  /**
80
88
  * Detect the default branch using git commands (symbolic-ref, rev-parse).
81
89
  * @param {string} directory
@@ -237,242 +245,9 @@ export async function getCurrentBranch(directory) {
237
245
  }
238
246
  }
239
247
 
240
- /**
241
- * Create a new worktree
242
- * @param {string} directory
243
- * @param {string} branch
244
- * @param {string} worktreePath
245
- * @param {Object} options
246
- * @param {boolean} options.skipFetch - Skip fetching from origin (default: false)
247
- * @returns {Promise<{path: string, branch: string}>}
248
- */
249
- export async function createWorktree(directory, branch, worktreePath, options = {}) {
250
- const { skipFetch = false } = options;
251
-
252
- // Fetch latest from origin to ensure we have up-to-date default branch
253
- if (!skipFetch) {
254
- await safeFetchOrigin(directory);
255
- }
256
-
257
- // Get the default branch from origin (main or master)
258
- const defaultBranch = await getOriginDefaultBranch(directory);
259
- // Base new branch on origin's default branch to avoid including unrelated commits from HEAD
260
- // Use --no-track to prevent the new branch from tracking the start-point (main/master)
261
- await git(directory, `worktree add --no-track "${worktreePath}" -b "${branch}" ${defaultBranch}`);
262
- return { path: worktreePath, branch };
263
- }
264
-
265
- /**
266
- * Remove a worktree
267
- * @param {string} directory
268
- * @param {string} path
269
- * @param {boolean} force - Force removal even if worktree has uncommitted changes
270
- */
271
- export async function removeWorktree(directory, worktreePath, force = false) {
272
- const forceFlag = force ? '--force' : '';
273
- await git(directory, `worktree remove ${forceFlag} "${worktreePath}"`);
274
- }
275
-
276
- /**
277
- * Get diff for a directory
278
- * @param {string} directory
279
- * @returns {Promise<string>}
280
- */
281
- export async function getDiff(directory) {
282
- try {
283
- return await git(directory, 'diff');
284
- } catch {
285
- return '';
286
- }
287
- }
288
-
289
- /**
290
- * Get staged diff for a directory
291
- * @param {string} directory
292
- * @returns {Promise<string>}
293
- */
294
- export async function getStagedDiff(directory) {
295
- try {
296
- return await git(directory, 'diff --cached');
297
- } catch {
298
- return '';
299
- }
300
- }
301
-
302
- /**
303
- * Check if a branch exists
304
- * @param {string} directory
305
- * @param {string} branch
306
- * @returns {Promise<boolean>}
307
- */
308
- export async function branchExists(directory, branch) {
309
- try {
310
- await git(directory, `rev-parse --verify refs/heads/${branch}`);
311
- return true;
312
- } catch {
313
- return false;
314
- }
315
- }
316
-
317
- /**
318
- * Checkout a branch, creating it if it doesn't exist
319
- * @param {string} directory
320
- * @param {string} branch
321
- * @returns {Promise<void>}
322
- */
323
- export async function checkoutBranch(directory, branch) {
324
- const exists = await branchExists(directory, branch);
325
- if (exists) {
326
- await git(directory, `checkout "${branch}"`);
327
- } else {
328
- await git(directory, `checkout -b "${branch}"`);
329
- }
330
- }
331
-
332
- /**
333
- * Create a worktree for a branch (creates branch if it doesn't exist)
334
- * @param {string} directory - Main repo directory
335
- * @param {string} branch - Branch name
336
- * @param {string} worktreePath - Path for the new worktree
337
- * @param {Object} options
338
- * @param {boolean} options.skipFetch - Skip fetching from origin (default: false)
339
- * @returns {Promise<{path: string, branch: string}>}
340
- */
341
- export async function createWorktreeForBranch(directory, branch, worktreePath, options = {}) {
342
- const { skipFetch = false } = options;
343
-
344
- // Fetch latest from origin to ensure we have up-to-date default branch
345
- if (!skipFetch) {
346
- await safeFetchOrigin(directory);
347
- }
348
-
349
- const exists = await branchExists(directory, branch);
350
- if (exists) {
351
- await git(directory, `worktree add "${worktreePath}" "${branch}"`);
352
- } else {
353
- // Get the default branch from origin (main or master)
354
- const defaultBranch = await getOriginDefaultBranch(directory);
355
- // Base new branch on origin's default branch to avoid including unrelated commits from HEAD
356
- // Use --no-track to prevent the new branch from tracking the start-point (main/master)
357
- await git(directory, `worktree add --no-track -b "${branch}" "${worktreePath}" ${defaultBranch}`);
358
- }
359
- return { path: worktreePath, branch };
360
- }
361
-
362
- /**
363
- * Get list of untracked files
364
- * @param {string} directory
365
- * @returns {Promise<string[]>}
366
- */
367
- export async function getUntrackedFiles(directory) {
368
- try {
369
- const output = await git(directory, 'ls-files --others --exclude-standard');
370
- if (!output) return [];
371
- return output.split('\n').filter((line) => line.trim());
372
- } catch {
373
- return [];
374
- }
375
- }
376
-
377
- /**
378
- * Get diff for a directory compared to a specific branch
379
- * @param {string} directory
380
- * @param {string} branch - Branch to compare against (e.g., 'origin/main')
381
- * @returns {Promise<string>}
382
- */
383
- export async function getDiffAgainstBranch(directory, branch) {
384
- try {
385
- return await git(directory, `diff ${branch}`);
386
- } catch {
387
- return '';
388
- }
389
- }
390
-
391
- /**
392
- * Get staged diff for a directory compared to a specific branch
393
- * @param {string} directory
394
- * @param {string} branch - Branch to compare against (e.g., 'origin/main')
395
- * @returns {Promise<string>}
396
- */
397
- export async function getStagedDiffAgainstBranch(directory, branch) {
398
- try {
399
- return await git(directory, `diff --cached ${branch}`);
400
- } catch {
401
- return '';
402
- }
403
- }
404
-
405
- /**
406
- * Get diff between two git refs (e.g., comparing HEAD to origin/main)
407
- * This shows the committed changes between two refs, ignoring working tree state
408
- * @param {string} directory
409
- * @param {string} fromRef - Base ref (e.g., 'origin/main')
410
- * @param {string} toRef - Target ref (e.g., 'HEAD')
411
- * @returns {Promise<string>}
412
- */
413
- export async function getDiffBetweenRefs(directory, fromRef, toRef) {
414
- try {
415
- return await git(directory, `diff ${fromRef} ${toRef}`);
416
- } catch {
417
- return '';
418
- }
419
- }
420
-
421
- /**
422
- * Get count of files modified/added compared to a branch
423
- * Includes committed changes + staged + unstaged + untracked files
424
- * @param {string} directory - The git repository directory
425
- * @param {string} branch - Branch to compare against (e.g., 'origin/main')
426
- * @returns {Promise<number>} - Total count of unique files modified/added
427
- */
428
- export async function getModifiedFilesCount(directory, branch) {
429
- try {
430
- // Get all modified files in one command using --name-only
431
- // This includes: committed changes vs branch + staged
432
- const committedAndStaged = await git(
433
- directory,
434
- `diff --name-only ${branch}...HEAD`
435
- );
436
-
437
- // Get unstaged changes (working tree vs index)
438
- const unstaged = await git(directory, 'diff --name-only');
439
-
440
- // Get untracked files
441
- const untracked = await getUntrackedFiles(directory);
442
-
443
- // Combine all files into a Set to get unique count
444
- const allFiles = new Set();
445
-
446
- // Parse committed+staged files
447
- if (committedAndStaged) {
448
- committedAndStaged.split('\n').forEach(f => {
449
- if (f.trim()) allFiles.add(f.trim());
450
- });
451
- }
452
-
453
- // Parse unstaged files
454
- if (unstaged) {
455
- unstaged.split('\n').forEach(f => {
456
- if (f.trim()) allFiles.add(f.trim());
457
- });
458
- }
459
-
460
- // Add untracked files
461
- untracked.forEach(f => allFiles.add(f));
462
-
463
- return allFiles.size;
464
- } catch (error) {
465
- logger.warn(`Failed to get modified files count for ${directory}:`, error.message);
466
- return 0;
467
- }
468
- }
469
-
470
248
  /**
471
249
  * Get the git author info from the global config (~/.gitconfig).
472
- *
473
- * Uses `--global` so that a contaminated local config (e.g. one that
474
- * already has Claude Code's identity) is bypassed.
475
- *
250
+ * Uses `--global` so that a contaminated local config is bypassed.
476
251
  * @param {string} directory
477
252
  * @param {Object} [options]
478
253
  * @param {Object} [options.env] - Custom environment variables (useful for tests)
@@ -493,17 +268,12 @@ export async function getGitAuthor(directory, { env } = {}) {
493
268
 
494
269
  /**
495
270
  * Pin the human developer's git identity in a worktree's config.
496
- *
497
- * Reads user.name/user.email from the main project directory and writes
498
- * them into the worktree-specific config (--worktree). This ensures the
499
- * human is always the commit Author, even if the session's environment
500
- * tries to override it. Claude Code already adds its own Co-Authored-By
501
- * trailer via its system prompt, so no hook is needed.
502
- *
503
- * Only call this for worktree directories, not the main repo.
504
- *
271
+ * Reads user.name/user.email from the main project directory and writes them
272
+ * into the worktree-specific config (--worktree). Only call for worktree dirs.
505
273
  * @param {string} worktreePath - The worktree directory
506
274
  * @param {string} projectDir - The main project directory (to read author from)
275
+ * @param {Object} [options]
276
+ * @param {Object} [options.env] - Custom environment variables (useful for tests)
507
277
  * @returns {Promise<boolean>} - True if author was pinned
508
278
  */
509
279
  export async function pinAuthorInWorktree(worktreePath, projectDir, { env } = {}) {
@@ -515,42 +285,9 @@ export async function pinAuthorInWorktree(worktreePath, projectDir, { env } = {}
515
285
 
516
286
  // Pin the human's identity in the worktree config so they are always
517
287
  // the commit Author, regardless of what the session does later
518
- await git(worktreePath, `config --worktree user.name "${author.name}"`);
519
- await git(worktreePath, `config --worktree user.email "${author.email}"`);
288
+ await git(worktreePath, `config --worktree user.name ${shellQuote(author.name)}`);
289
+ await git(worktreePath, `config --worktree user.email ${shellQuote(author.email)}`);
520
290
 
521
291
  return true;
522
292
  }
523
293
 
524
- /**
525
- * Detect the worktree path for a directory by inspecting existing worktrees.
526
- * If external worktrees exist, uses the parent directory of the first one.
527
- * Otherwise, falls back to {directory}/.worktrees.
528
- * @param {string} directory - The git repository directory
529
- * @returns {Promise<{worktreePath: string, source: 'detected' | 'default'}>}
530
- */
531
- export async function detectWorktreePath(directory) {
532
- const isRepo = await isGitRepo(directory);
533
- if (!isRepo) {
534
- return { worktreePath: path.join(directory, '.worktrees'), source: 'default' };
535
- }
536
-
537
- // Resolve symlinks for consistent path comparison (e.g., /var -> /private/var on macOS)
538
- let resolvedDir;
539
- try {
540
- resolvedDir = await realpath(directory);
541
- } catch {
542
- resolvedDir = path.resolve(directory);
543
- }
544
-
545
- const worktrees = await getWorktrees(directory);
546
- // Filter out the main worktree (its path === directory or resolves to it)
547
- const externalWorktrees = worktrees.filter(wt => path.resolve(wt.path) !== resolvedDir);
548
-
549
- if (externalWorktrees.length > 0) {
550
- // Use the parent directory of the first external worktree
551
- const parentDir = path.dirname(path.resolve(externalWorktrees[0].path));
552
- return { worktreePath: parentDir, source: 'detected' };
553
- }
554
-
555
- return { worktreePath: path.join(resolvedDir, '.worktrees'), source: 'default' };
556
- }
@@ -9,9 +9,17 @@ import * as gitService from './gitService.js';
9
9
  * @param {string|null} options.gitBranch - Branch name
10
10
  * @param {string} options.sessionId - Session ID
11
11
  * @param {string|null} [options.worktreeBasePath] - Custom base path for worktrees (overrides default .worktrees)
12
+ * @param {string|null} [options.commitAttributionOverride] - Deprecated; attribution is process-scoped at agent launch
12
13
  * @returns {Promise<{workingDirectory: string, gitWorktree: string|null}>}
13
14
  */
14
- export async function setupGitForSession({ projectDir, gitMode, gitBranch, sessionId, worktreeBasePath }) {
15
+ export async function setupGitForSession({
16
+ projectDir,
17
+ gitMode,
18
+ gitBranch,
19
+ sessionId,
20
+ worktreeBasePath,
21
+ commitAttributionOverride = null,
22
+ }) {
15
23
  // No git operations if gitMode is not specified
16
24
  if (!gitMode || !gitBranch) {
17
25
  return {
@@ -35,6 +43,8 @@ export async function setupGitForSession({ projectDir, gitMode, gitBranch, sessi
35
43
  await gitService.createWorktreeForBranch(projectDir, gitBranch, worktreePath);
36
44
  // Pin the human developer's git identity so they are the commit Author
37
45
  await gitService.pinAuthorInWorktree(worktreePath, projectDir);
46
+ void commitAttributionOverride;
47
+ await gitService.ensureWorktreeCommitAttributionHook(worktreePath);
38
48
  return {
39
49
  workingDirectory: worktreePath,
40
50
  gitWorktree: worktreePath,
@@ -0,0 +1,127 @@
1
+ import { git, getOriginDefaultBranch } from './gitService.js';
2
+
3
+ // Configurable logger for warning messages
4
+ let logger = {
5
+ warn: (...args) => console.warn(...args),
6
+ };
7
+
8
+ /**
9
+ * Set a custom logger for git worktree warnings.
10
+ * @param {Object} customLogger - Logger object with a warn method
11
+ */
12
+ export function _setWorktreeLogger(customLogger) {
13
+ logger = customLogger;
14
+ }
15
+
16
+ /**
17
+ * Safely fetch from origin remote.
18
+ * Logs a warning if fetch fails but does not throw.
19
+ * @param {string} directory - The git repository directory
20
+ * @returns {Promise<boolean>} - True if fetch succeeded, false otherwise
21
+ */
22
+ async function safeFetchOrigin(directory) {
23
+ try {
24
+ await git(directory, 'fetch origin');
25
+ return true;
26
+ } catch (err) {
27
+ // No origin or network unavailable, proceed without fetch
28
+ logger.warn('Could not fetch from origin, proceeding with local refs:', err.message);
29
+ return false;
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Check if a branch exists
35
+ * @param {string} directory
36
+ * @param {string} branch
37
+ * @returns {Promise<boolean>}
38
+ */
39
+ export async function branchExists(directory, branch) {
40
+ try {
41
+ await git(directory, `rev-parse --verify refs/heads/${branch}`);
42
+ return true;
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Checkout a branch, creating it if it doesn't exist
50
+ * @param {string} directory
51
+ * @param {string} branch
52
+ * @returns {Promise<void>}
53
+ */
54
+ export async function checkoutBranch(directory, branch) {
55
+ const exists = await branchExists(directory, branch);
56
+ if (exists) {
57
+ await git(directory, `checkout "${branch}"`);
58
+ } else {
59
+ await git(directory, `checkout -b "${branch}"`);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Create a new worktree
65
+ * @param {string} directory
66
+ * @param {string} branch
67
+ * @param {string} worktreePath
68
+ * @param {Object} options
69
+ * @param {boolean} options.skipFetch - Skip fetching from origin (default: false)
70
+ * @returns {Promise<{path: string, branch: string}>}
71
+ */
72
+ export async function createWorktree(directory, branch, worktreePath, options = {}) {
73
+ const { skipFetch = false } = options;
74
+
75
+ // Fetch latest from origin to ensure we have up-to-date default branch
76
+ if (!skipFetch) {
77
+ await safeFetchOrigin(directory);
78
+ }
79
+
80
+ // Get the default branch from origin (main or master)
81
+ const defaultBranch = await getOriginDefaultBranch(directory);
82
+ // Base new branch on origin's default branch to avoid including unrelated commits from HEAD
83
+ // Use --no-track to prevent the new branch from tracking the start-point (main/master)
84
+ await git(directory, `worktree add --no-track "${worktreePath}" -b "${branch}" ${defaultBranch}`);
85
+ return { path: worktreePath, branch };
86
+ }
87
+
88
+ /**
89
+ * Remove a worktree
90
+ * @param {string} directory
91
+ * @param {string} path
92
+ * @param {boolean} force - Force removal even if worktree has uncommitted changes
93
+ */
94
+ export async function removeWorktree(directory, worktreePath, force = false) {
95
+ const forceFlag = force ? '--force' : '';
96
+ await git(directory, `worktree remove ${forceFlag} "${worktreePath}"`);
97
+ }
98
+
99
+ /**
100
+ * Create a worktree for a branch (creates branch if it doesn't exist)
101
+ * @param {string} directory - Main repo directory
102
+ * @param {string} branch - Branch name
103
+ * @param {string} worktreePath - Path for the new worktree
104
+ * @param {Object} options
105
+ * @param {boolean} options.skipFetch - Skip fetching from origin (default: false)
106
+ * @returns {Promise<{path: string, branch: string}>}
107
+ */
108
+ export async function createWorktreeForBranch(directory, branch, worktreePath, options = {}) {
109
+ const { skipFetch = false } = options;
110
+
111
+ // Fetch latest from origin to ensure we have up-to-date default branch
112
+ if (!skipFetch) {
113
+ await safeFetchOrigin(directory);
114
+ }
115
+
116
+ const exists = await branchExists(directory, branch);
117
+ if (exists) {
118
+ await git(directory, `worktree add "${worktreePath}" "${branch}"`);
119
+ } else {
120
+ // Get the default branch from origin (main or master)
121
+ const defaultBranch = await getOriginDefaultBranch(directory);
122
+ // Base new branch on origin's default branch to avoid including unrelated commits from HEAD
123
+ // Use --no-track to prevent the new branch from tracking the start-point (main/master)
124
+ await git(directory, `worktree add --no-track -b "${branch}" "${worktreePath}" ${defaultBranch}`);
125
+ }
126
+ return { path: worktreePath, branch };
127
+ }
@@ -5,11 +5,11 @@ import {
5
5
  projects,
6
6
  } from '../database.js';
7
7
  import { broadcastToProject } from '../websocket.js';
8
- import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
8
+ import { WS_MESSAGE_TYPES, DEFAULT_RESCHEDULE_DELAY_MINUTES } from '../../../shared/src/index.js';
9
9
  import { renderTemplatePrompt, getRootSession } from './templateTriggerService.js';
10
10
  import { setupGitForSession } from './gitSessionSetup.js';
11
11
  import { runSession } from './sessionManager.js';
12
- import { resolveAgentTypeFromModel } from './sessionProvider.js';
12
+ import { resolveAgentTypeFromModel, resolveProviderMetadataFromModel } from './sessionProvider.js';
13
13
 
14
14
  // Maximum depth for recursive lane-entry template triggers
15
15
  export const MAX_LANE_TRIGGER_DEPTH = 5;
@@ -56,6 +56,8 @@ export async function determineWorkingDirectory(parentSession, project, gitOptio
56
56
  gitBranch: gitOptions.gitBranch || null,
57
57
  sessionId: gitOptions.sessionId,
58
58
  worktreeBasePath: project.worktreePath || null,
59
+ commitAttributionOverride:
60
+ resolveProviderMetadataFromModel(gitOptions.model)?.commitAttributionOverride ?? null,
59
61
  });
60
62
  return { workingDirectory: gitSetup.workingDirectory, gitWorktree: gitSetup.gitWorktree };
61
63
  }
@@ -190,6 +192,7 @@ export async function triggerOnEnterTemplate(sessionId, lane, options = {}) {
190
192
  gitMode: settings.gitMode,
191
193
  gitBranch: settings.gitBranch,
192
194
  sessionId: newSession.id,
195
+ model: settings.model,
193
196
  });
194
197
  if (gitWorktree) {
195
198
  sessions.update(newSession.id, { gitWorktree });
@@ -250,7 +253,7 @@ async function buildChildSessionFromPrompt(lane, session, depth) {
250
253
  if (lane.onEnterAutoRescheduleEnabled) {
251
254
  Object.assign(sessionUpdates, {
252
255
  autoRescheduleEnabled: true,
253
- rescheduleDelayMinutes: lane.onEnterRescheduleDelayMinutes || 15,
256
+ rescheduleDelayMinutes: lane.onEnterRescheduleDelayMinutes || DEFAULT_RESCHEDULE_DELAY_MINUTES,
254
257
  rescheduleOnTokenLimit: lane.onEnterRescheduleOnTokenLimit ?? true,
255
258
  rescheduleOnServiceError: lane.onEnterRescheduleOnServiceError ?? true,
256
259
  maxRescheduleCount: lane.onEnterMaxRescheduleCount || null,
@@ -1,5 +1,10 @@
1
1
  import { spawn } from 'child_process';
2
2
  import path from 'path';
3
+ import {
4
+ captureSpawnAttempt,
5
+ createCapturedSpawnProcess,
6
+ isE2ESpawnCaptureEnabled,
7
+ } from './e2eSpawnCapture.js';
3
8
 
4
9
  /**
5
10
  * Get the directory containing the current Node.js executable.
@@ -42,6 +47,10 @@ export function createRobustEnv(baseEnv = process.env) {
42
47
  export function createClaudeCodeSpawner() {
43
48
  return (options) => {
44
49
  const { command, args, cwd, env, signal } = options;
50
+ if (isE2ESpawnCaptureEnabled()) {
51
+ captureSpawnAttempt('claude-code', options);
52
+ return createCapturedSpawnProcess('claude-code');
53
+ }
45
54
 
46
55
  // Replace 'node' with the absolute path to the current Node executable
47
56
  // This ensures we use the same Node that's running our app
@@ -126,8 +126,8 @@ export async function extractPrUrlIfNeeded(sessionId) {
126
126
  const session = sessions.getById(sessionId);
127
127
  if (!session) return;
128
128
 
129
- // Skip if session already has a PR URL
130
- if (session.prUrl) return;
129
+ // Skip if session already has a PR URL or the user cleared automatic PR linking.
130
+ if (session.prUrl || session.prUrlAutoLinkDisabled) return;
131
131
 
132
132
  // Extract PR URL from messages
133
133
  const prUrl = extractPrUrlFromMessages(sessionId);
@@ -145,7 +145,7 @@ export async function extractPrUrlIfNeeded(sessionId) {
145
145
  if (session.parentSessionId) {
146
146
  const rootId = sessions.getRootSessionId(sessionId);
147
147
  const root = rootId && rootId !== sessionId ? sessions.getById(rootId) : null;
148
- if (root && !root.prUrl) {
148
+ if (root && !root.prUrl && !root.prUrlAutoLinkDisabled) {
149
149
  sessions.update(root.id, { prUrl });
150
150
  console.log(`[PrUrlService] Propagated PR URL from session ${sessionId} to root ${root.id}: ${prUrl}`);
151
151