circuschief 0.2.1 → 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 (121) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/api/git.js +15 -0
  3. package/packages/server/src/api/index.js +13 -2
  4. package/packages/server/src/api/projects-commandButtons.js +96 -0
  5. package/packages/server/src/api/projects-session-helpers.js +51 -33
  6. package/packages/server/src/api/projects.js +109 -113
  7. package/packages/server/src/api/sessions-archive.js +2 -1
  8. package/packages/server/src/api/sessions-lifecycle.js +2 -1
  9. package/packages/server/src/api/sessions-patch.js +2 -0
  10. package/packages/server/src/config.js +6 -0
  11. package/packages/server/src/database.js +1 -0
  12. package/packages/server/src/db/DatabaseManager.js +12 -1
  13. package/packages/server/src/db/ProjectRepository.js +22 -5
  14. package/packages/server/src/db/index.js +4 -0
  15. package/packages/server/src/db/migrations/index.js +7 -0
  16. package/packages/server/src/db/migrations/miscMigrations.js +78 -1
  17. package/packages/server/src/db/migrations/projectsMigrations.js +4 -0
  18. package/packages/server/src/index.js +10 -4
  19. package/packages/server/src/services/gitService.js +42 -6
  20. package/packages/server/src/services/gitSessionSetup.js +4 -3
  21. package/packages/server/src/services/kanbanTriggers.js +1 -0
  22. package/packages/server/src/services/schedulerService.js +32 -0
  23. package/packages/server/src/services/sessionExecution.js +2 -2
  24. package/packages/server/src/services/templateTriggerService.js +1 -0
  25. package/packages/shared/src/contracts/projects.js +4 -1
  26. package/packages/shared/src/types.js +4 -3
  27. package/packages/web/dist/assets/ActiveSessionsView-BVco8bPU.css +1 -0
  28. package/packages/web/dist/assets/ActiveSessionsView-D1daFFvI.js +1 -0
  29. package/packages/web/dist/assets/{AgentLogsView-D4l0N9ZA.js → AgentLogsView-B_NDIx2_.js} +1 -1
  30. package/packages/web/dist/assets/{ApiClient-Dbs1H78V.js → ApiClient-CcqJ-GAv.js} +1 -1
  31. package/packages/web/dist/assets/ArchiveConfirmModal-BHqbCCX2.js +1 -0
  32. package/packages/web/dist/assets/{ArchiveConfirmModal-CQZeuYBz.css → ArchiveConfirmModal-BQ-4gI0R.css} +1 -1
  33. package/packages/web/dist/assets/CommandButtonDetailView-B9crZey8.js +1 -0
  34. package/packages/web/dist/assets/EffortLevelSelector-HuOPegKI.js +1 -0
  35. package/packages/web/dist/assets/{GeneralSettingsView-BqCzCX-z.js → GeneralSettingsView-CzBagrDs.js} +1 -1
  36. package/packages/web/dist/assets/{PathChooser-CXFxb8Oj.js → InputWithButton-Cplkm8Ze.js} +1 -1
  37. package/packages/web/dist/assets/InputWithButton-cYdrEmTs.css +1 -0
  38. package/packages/web/dist/assets/InterpolationHelp-bG_y10VY.js +1 -0
  39. package/packages/web/dist/assets/MarkdownEditor-CXDVTLvp.js +2 -0
  40. package/packages/web/dist/assets/{ModelSelector-DSxaZWBL.js → ModelSelector-CgpqdZtV.js} +1 -1
  41. package/packages/web/dist/assets/{NewSessionView-BsI7JtO9.js → NewSessionView-91V-4qLi.js} +2 -2
  42. package/packages/web/dist/assets/{NewSessionView-Byoi1XdQ.css → NewSessionView-D_Hi7M9g.css} +1 -1
  43. package/packages/web/dist/assets/ProjectEditView-C3bOYnD6.js +1 -0
  44. package/packages/web/dist/assets/ProjectEditView-CpeKj-_w.css +1 -0
  45. package/packages/web/dist/assets/ProjectListView-BEo1p4dO.js +1 -0
  46. package/packages/web/dist/assets/ProjectListView-CumumZN-.css +1 -0
  47. package/packages/web/dist/assets/ProjectNewView-CXGjy3OL.js +1 -0
  48. package/packages/web/dist/assets/ProjectNewView-CaLXfFzd.css +1 -0
  49. package/packages/web/dist/assets/{ProvidersView-CgAr0qms.js → ProvidersView-CMAnPTEX.js} +1 -1
  50. package/packages/web/dist/assets/{ProvidersView-B_QQF3RM.css → ProvidersView-uD8SKWpA.css} +1 -1
  51. package/packages/web/dist/assets/{QuickResponseSettings-uDDpwaza.js → QuickResponseSettings-Ds4X-J-t.js} +1 -1
  52. package/packages/web/dist/assets/{QuickResponsesPanel-D0qs0Fm_.js → QuickResponsesPanel-BY2v23c5.js} +1 -1
  53. package/packages/web/dist/assets/{ResizableTextarea-_kHi1Mg3.js → ResizableTextarea-C2s6_7X9.js} +1 -1
  54. package/packages/web/dist/assets/{SessionCard-Be1-bK0C.js → SessionCard-C7vFzR16.js} +1 -1
  55. package/packages/web/dist/assets/{SessionDetailView-mnGRMaLY.css → SessionDetailView-BL83oPiI.css} +1 -1
  56. package/packages/web/dist/assets/SessionDetailView-D3f0FEV1.js +36 -0
  57. package/packages/web/dist/assets/{SessionFormOptions-DvhOyP6z.js → SessionFormOptions-3LfqiLiR.js} +1 -1
  58. package/packages/web/dist/assets/{SessionListView-CuHsWj85.js → SessionListView-NGW-u434.js} +1 -1
  59. package/packages/web/dist/assets/{SessionLogStream-Da_GniUZ.js → SessionLogStream-NR-AS676.js} +6 -6
  60. package/packages/web/dist/assets/SettingsView-DKINDb2z.js +1 -0
  61. package/packages/web/dist/assets/{SlashCommandWizard-B_8ifpxN.js → SlashCommandWizard-PXipO1yA.js} +1 -1
  62. package/packages/web/dist/assets/{SummarySettingsView-KvgSGHdd.js → SummarySettingsView-D_2bSsYD.js} +1 -1
  63. package/packages/web/dist/assets/{TemplateDetailView-BhOjYIvS.js → TemplateDetailView-C5rbgXwU.js} +1 -1
  64. package/packages/web/dist/assets/{commandButtons-B4OYZP0J.js → commandButtons-D_-wR8zJ.js} +1 -1
  65. package/packages/web/dist/assets/index-BCazaXF8.js +1 -0
  66. package/packages/web/dist/assets/index-BmQRt229.js +3 -0
  67. package/packages/web/dist/assets/index-CCQGqJXX.js +1 -0
  68. package/packages/web/dist/assets/index-CcCiJkwz.js +1 -0
  69. package/packages/web/dist/assets/index-Cs-001Bx.js +1 -0
  70. package/packages/web/dist/assets/index-CxiSnR0R.js +1 -0
  71. package/packages/web/dist/assets/index-D-lQSDZh.js +1 -0
  72. package/packages/web/dist/assets/{index-CSOPrlmq.js → index-D1Lg5reX.js} +3 -3
  73. package/packages/web/dist/assets/index-D9VgH58U.js +1 -0
  74. package/packages/web/dist/assets/index-D9Z6zsGS.js +1 -0
  75. package/packages/web/dist/assets/index-DP2i58hO.js +1 -0
  76. package/packages/web/dist/assets/index-DaL3nu0U.js +1 -0
  77. package/packages/web/dist/assets/index-Dfy1JGZs.js +1 -0
  78. package/packages/web/dist/assets/index-DgIOe3cM.js +1 -0
  79. package/packages/web/dist/assets/index-Dp3Eg3c0.js +1 -0
  80. package/packages/web/dist/assets/{index-BHVnr8MO.js → index-DveLfEiG.js} +2 -2
  81. package/packages/web/dist/assets/index-DyQ22-ut.js +1 -0
  82. package/packages/web/dist/assets/{index-BqVgX_Jy.js → index-Krfrs3sc.js} +3 -3
  83. package/packages/web/dist/assets/index-gylMFbgn.js +7 -0
  84. package/packages/web/dist/assets/{projects-B2du-GX8.js → projects-CMJJca64.js} +1 -1
  85. package/packages/web/dist/assets/{providers-B__J6FX0.js → providers-BCtNZWYw.js} +1 -1
  86. package/packages/web/dist/assets/{sessions-VDrd87yA.js → sessions-CoRS-wuR.js} +1 -1
  87. package/packages/web/dist/assets/{settings-CZ7Pc-Pt.js → settings-BN_W4nwV.js} +1 -1
  88. package/packages/web/dist/assets/useSummaryHelpers-GVg7sMWF.js +1 -0
  89. package/packages/web/dist/index.html +1 -1
  90. package/packages/web/dist/assets/ActiveSessionsView-3697sD8N.js +0 -1
  91. package/packages/web/dist/assets/ActiveSessionsView-DfYXc6dz.css +0 -1
  92. package/packages/web/dist/assets/ArchiveConfirmModal-Bv3vGOMM.js +0 -1
  93. package/packages/web/dist/assets/CommandButtonDetailView-Bk_SHxpu.js +0 -1
  94. package/packages/web/dist/assets/EffortLevelSelector-VfBEelvO.js +0 -1
  95. package/packages/web/dist/assets/InterpolationHelp-Dc1Y0T6v.js +0 -1
  96. package/packages/web/dist/assets/MarkdownEditor-DwBQkZbs.js +0 -2
  97. package/packages/web/dist/assets/PathChooser-BoMGzeg2.css +0 -1
  98. package/packages/web/dist/assets/ProjectEditView-Bes4Mib4.js +0 -1
  99. package/packages/web/dist/assets/ProjectEditView-DNwBUNRk.css +0 -1
  100. package/packages/web/dist/assets/ProjectListView-C55H1JHQ.css +0 -1
  101. package/packages/web/dist/assets/ProjectListView-DzEu-C36.js +0 -1
  102. package/packages/web/dist/assets/ProjectNewView-CpgE4R-l.css +0 -1
  103. package/packages/web/dist/assets/ProjectNewView-Cv-iEAgl.js +0 -1
  104. package/packages/web/dist/assets/SessionDetailView-DUYb7qTA.js +0 -36
  105. package/packages/web/dist/assets/SettingsView-5RDCXNUa.js +0 -1
  106. package/packages/web/dist/assets/index-80Qu7W6P.js +0 -1
  107. package/packages/web/dist/assets/index-B8_Iqwcq.js +0 -1
  108. package/packages/web/dist/assets/index-B9JErft2.js +0 -1
  109. package/packages/web/dist/assets/index-BarVnQIj.js +0 -3
  110. package/packages/web/dist/assets/index-BsvRdU0B.js +0 -1
  111. package/packages/web/dist/assets/index-Bugg2M-E.js +0 -1
  112. package/packages/web/dist/assets/index-C2Pjy-M8.js +0 -1
  113. package/packages/web/dist/assets/index-CS_wb_Vj.js +0 -1
  114. package/packages/web/dist/assets/index-ClzNIdCp.js +0 -1
  115. package/packages/web/dist/assets/index-Cn9Ajkye.js +0 -1
  116. package/packages/web/dist/assets/index-CucpVX4L.js +0 -1
  117. package/packages/web/dist/assets/index-D9hZYvW3.js +0 -1
  118. package/packages/web/dist/assets/index-DA0dK_PG.js +0 -7
  119. package/packages/web/dist/assets/index-DgpSn-jR.js +0 -1
  120. package/packages/web/dist/assets/index-HZwIyC9t.js +0 -1
  121. package/packages/web/dist/assets/index-SS3wA2sI.js +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "circuschief",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "Local-first web UI for managing Claude Code sessions",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,6 +4,21 @@ import * as gitService from '../services/gitService.js';
4
4
 
5
5
  const router = Router();
6
6
 
7
+ // GET /api/git/detect-worktree-path?directory=/path/to/repo
8
+ router.get('/detect-worktree-path', async (req, res) => {
9
+ const { directory } = req.query;
10
+ if (!directory) {
11
+ return res.status(400).json({ error: 'directory query parameter is required' });
12
+ }
13
+
14
+ try {
15
+ const result = await gitService.detectWorktreePath(directory);
16
+ res.json(result);
17
+ } catch (error) {
18
+ res.status(500).json({ error: error.message });
19
+ }
20
+ });
21
+
7
22
  // GET /api/projects/:id/git/status - Check if git repo, get branches
8
23
  router.get('/projects/:id/status', async (req, res) => {
9
24
  const project = projects.getById(req.params.id);
@@ -11,13 +11,24 @@ import providersRouter from './providers.js';
11
11
  import commandsRouter from './commands.js';
12
12
  import metricsRouter from './metrics.js';
13
13
  import kanbanRouter from './kanban.js';
14
+ import { getDbPath } from '../database.js';
15
+ import { schedulerService } from '../services/schedulerService.js';
14
16
 
15
17
  const router = Router();
16
18
 
17
19
  // Lightweight identity endpoint — lets tools (e.g. pw.sh) verify which
18
- // worktree / working directory this server instance belongs to.
20
+ // worktree / working directory this server instance belongs to, which
21
+ // DB file it is using, and whether VCR / scheduler are in the expected
22
+ // state. This endpoint is additive-safe: consumers must ignore unknown
23
+ // fields so new ones can be added freely.
19
24
  router.get('/server-info', (_req, res) => {
20
- res.json({ cwd: process.cwd() });
25
+ const vcr = process.env.VCR_MODE;
26
+ res.json({
27
+ cwd: process.cwd(),
28
+ dbPath: getDbPath(),
29
+ vcrMode: vcr && vcr.length > 0 ? vcr : null,
30
+ schedulerRunning: schedulerService.isRunning(),
31
+ });
21
32
  });
22
33
 
23
34
  router.use('/projects', projectsRouter);
@@ -0,0 +1,96 @@
1
+ import { Router } from 'express';
2
+ import { projects, commandButtons } from '../database.js';
3
+ import { CreateCommandButtonRequest, UpdateCommandButtonRequest } from '../../../shared/src/contracts/commandButtons.js';
4
+
5
+ // Error message constants
6
+ const ERR_PROJECT_NOT_FOUND = 'Project not found';
7
+ const ERR_BUTTON_NOT_FOUND = 'Command button not found';
8
+
9
+ const router = Router({ mergeParams: true });
10
+
11
+ // GET /api/projects/:id/command-buttons - List all command buttons for project
12
+ router.get('/', (req, res) => {
13
+ const project = projects.getById(req.params.id);
14
+ if (!project) {
15
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
16
+ }
17
+
18
+ const buttons = commandButtons.getByProjectId(req.params.id);
19
+ res.json(buttons);
20
+ });
21
+
22
+ // POST /api/projects/:id/command-buttons - Create new command button
23
+ router.post('/', (req, res) => {
24
+ const project = projects.getById(req.params.id);
25
+ if (!project) {
26
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
27
+ }
28
+
29
+ const result = CreateCommandButtonRequest.safeParse(req.body);
30
+ if (!result.success) {
31
+ return res.status(400).json({ error: result.error.issues[0].message });
32
+ }
33
+
34
+ const button = commandButtons.create({
35
+ projectId: req.params.id,
36
+ label: result.data.label,
37
+ command: result.data.command,
38
+ sortOrder: result.data.sortOrder,
39
+ showOnList: result.data.showOnList,
40
+ });
41
+
42
+ res.status(201).json(button);
43
+ });
44
+
45
+ // GET /api/projects/:id/command-buttons/:buttonId - Get single button
46
+ router.get('/:buttonId', (req, res) => {
47
+ const project = projects.getById(req.params.id);
48
+ if (!project) {
49
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
50
+ }
51
+
52
+ const button = commandButtons.getById(req.params.buttonId);
53
+ if (!button) {
54
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
55
+ }
56
+ res.json(button);
57
+ });
58
+
59
+ // PATCH /api/projects/:id/command-buttons/:buttonId - Update button
60
+ router.patch('/:buttonId', (req, res) => {
61
+ const project = projects.getById(req.params.id);
62
+ if (!project) {
63
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
64
+ }
65
+
66
+ const button = commandButtons.getById(req.params.buttonId);
67
+ if (!button) {
68
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
69
+ }
70
+
71
+ const result = UpdateCommandButtonRequest.safeParse(req.body);
72
+ if (!result.success) {
73
+ return res.status(400).json({ error: result.error.issues[0].message });
74
+ }
75
+
76
+ const updated = commandButtons.update(req.params.buttonId, result.data);
77
+ res.json(updated);
78
+ });
79
+
80
+ // DELETE /api/projects/:id/command-buttons/:buttonId - Delete button
81
+ router.delete('/:buttonId', (req, res) => {
82
+ const project = projects.getById(req.params.id);
83
+ if (!project) {
84
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
85
+ }
86
+
87
+ const button = commandButtons.getById(req.params.buttonId);
88
+ if (!button) {
89
+ return res.status(404).json({ error: ERR_BUTTON_NOT_FOUND });
90
+ }
91
+
92
+ commandButtons.delete(req.params.buttonId);
93
+ res.status(204).send();
94
+ });
95
+
96
+ export default router;
@@ -220,6 +220,52 @@ export function buildSchedulingUpdate(config, initialStatus) {
220
220
  return update;
221
221
  }
222
222
 
223
+ /**
224
+ * Resolve working directory and optional worktree for a new session.
225
+ * Inherits the parent's worktree when applicable.
226
+ * @param {object} params
227
+ * @returns {Promise<{ workingDirectory: string, gitWorktree: string|null }>}
228
+ */
229
+ async function resolveSessionWorkingDirectory({ session, config, project }) {
230
+ const parentSession = config.parentSessionId ? sessions.getById(config.parentSessionId) : null;
231
+ if (parentSession?.gitWorktree) {
232
+ return {
233
+ workingDirectory: parentSession.gitWorktree,
234
+ gitWorktree: parentSession.gitWorktree,
235
+ };
236
+ }
237
+
238
+ const gitSetup = await setupGitForSession({
239
+ projectDir: project.workingDirectory,
240
+ gitMode: config.gitMode || null,
241
+ gitBranch: config.gitBranch || null,
242
+ sessionId: session.id,
243
+ worktreeBasePath: project.worktreePath || null,
244
+ });
245
+ return { workingDirectory: gitSetup.workingDirectory, gitWorktree: gitSetup.gitWorktree };
246
+ }
247
+
248
+ /**
249
+ * Start a session immediately via sessionManager, recording failures on the session.
250
+ */
251
+ async function startSessionImmediately({ session, config, project, workingDirectory, sessionAttachments }) {
252
+ const resolved = await slashCommandService.resolvePromptSkillOrCommand(
253
+ workingDirectory, config.prompt, project.systemPrompt
254
+ );
255
+ const finalPrompt = resolved ? resolved.userMessage : config.prompt;
256
+ const finalSystemPrompt = resolved ? resolved.systemPrompt : project.systemPrompt;
257
+
258
+ const { runSession } = await import('../services/sessionManager.js');
259
+ runSession(session.id, finalPrompt, workingDirectory, {
260
+ systemPrompt: finalSystemPrompt,
261
+ fileAttachments: sessionAttachments,
262
+ model: config.model,
263
+ }).catch((error) => {
264
+ console.error('Session error:', error);
265
+ sessions.update(session.id, { status: 'error', error: error.message });
266
+ });
267
+ }
268
+
223
269
  /**
224
270
  * Handle git setup, session start, broadcasts, and hooks after session creation.
225
271
  * @param {object} params
@@ -231,25 +277,9 @@ export function buildSchedulingUpdate(config, initialStatus) {
231
277
  * @returns {Promise<{ updatedSession: object }>}
232
278
  */
233
279
  export async function setupAndStartSession({ session, config, project, projectId, files }) {
234
- let workingDirectory;
235
- let gitWorktree = null;
236
-
237
- // If this is a child session and the parent has a worktree, inherit it
238
- // (mirrors templateTriggerService behavior)
239
- const parentSession = config.parentSessionId ? sessions.getById(config.parentSessionId) : null;
240
- if (parentSession?.gitWorktree) {
241
- workingDirectory = parentSession.gitWorktree;
242
- gitWorktree = parentSession.gitWorktree;
243
- } else {
244
- const gitSetup = await setupGitForSession({
245
- projectDir: project.workingDirectory,
246
- gitMode: config.gitMode || null,
247
- gitBranch: config.gitBranch || null,
248
- sessionId: session.id,
249
- });
250
- workingDirectory = gitSetup.workingDirectory;
251
- gitWorktree = gitSetup.gitWorktree;
252
- }
280
+ const { workingDirectory, gitWorktree } = await resolveSessionWorkingDirectory({
281
+ session, config, project,
282
+ });
253
283
 
254
284
  if (gitWorktree) {
255
285
  sessions.update(session.id, { gitWorktree });
@@ -259,20 +289,8 @@ export async function setupAndStartSession({ session, config, project, projectId
259
289
 
260
290
  const isScheduled = config.scheduledAt && config.scheduledAt > Date.now();
261
291
  if (config.startImmediately && !isScheduled) {
262
- const resolved = await slashCommandService.resolvePromptSkillOrCommand(
263
- workingDirectory, config.prompt, project.systemPrompt
264
- );
265
- const finalPrompt = resolved ? resolved.userMessage : config.prompt;
266
- const finalSystemPrompt = resolved ? resolved.systemPrompt : project.systemPrompt;
267
-
268
- const { runSession } = await import('../services/sessionManager.js');
269
- runSession(session.id, finalPrompt, workingDirectory, {
270
- systemPrompt: finalSystemPrompt,
271
- fileAttachments: sessionAttachments,
272
- model: config.model,
273
- }).catch((error) => {
274
- console.error('Session error:', error);
275
- sessions.update(session.id, { status: 'error', error: error.message });
292
+ await startSessionImmediately({
293
+ session, config, project, workingDirectory, sessionAttachments,
276
294
  });
277
295
  }
278
296
 
@@ -1,11 +1,11 @@
1
1
  import { Router } from 'express';
2
- import { projects, sessions, sessionTemplates, commandButtons, projectDefaults, commandRuns } from '../database.js';
2
+ import { projects, sessions, sessionTemplates, projectDefaults, commandRuns } from '../database.js';
3
3
  import { commandRunner } from '../services/commandRunner.js';
4
4
  import { CreateProjectRequest, UpdateProjectRequest, ProjectSessionDefaultsRequest } from '../../../shared/src/contracts/projects.js';
5
5
  import { ProjectDefaultsRepository } from '../db/ProjectDefaultsRepository.js';
6
6
  import { CreateSessionTemplateRequest } from '../../../shared/src/contracts/templates.js';
7
- import { CreateCommandButtonRequest, UpdateCommandButtonRequest } from '../../../shared/src/contracts/commandButtons.js';
8
7
  import { broadcastToProject } from '../websocket.js';
8
+ import projectCommandButtonsRouter from './projects-commandButtons.js';
9
9
  import { WS_MESSAGE_TYPES } from '../../../shared/src/index.js';
10
10
  import { handleUploadError, uploadMiddleware } from '../middleware/upload.js';
11
11
  import {
@@ -18,10 +18,37 @@ import {
18
18
  setupAndStartSession,
19
19
  } from './projects-session-helpers.js';
20
20
  import { validateGitSettings, buildRunsBySession } from './projects-helpers.js';
21
+ import { access, constants } from 'fs/promises';
22
+ import { dirname, isAbsolute } from 'path';
21
23
 
22
24
  // Error message constants
23
25
  const ERR_PROJECT_NOT_FOUND = 'Project not found';
24
26
 
27
+ /**
28
+ * Validate a worktree path value.
29
+ * Returns null if valid, or an error message string if invalid.
30
+ * @param {string|null|undefined} worktreePath
31
+ * @returns {Promise<string|null>}
32
+ */
33
+ export async function validateWorktreePath(worktreePath) {
34
+ if (worktreePath === null || worktreePath === undefined || worktreePath === '') {
35
+ return null; // null/empty is valid (means use default)
36
+ }
37
+
38
+ if (!isAbsolute(worktreePath)) {
39
+ return 'Worktree path must be an absolute path';
40
+ }
41
+
42
+ const parent = dirname(worktreePath);
43
+ try {
44
+ await access(parent, constants.W_OK);
45
+ } catch {
46
+ return `Parent directory does not exist or is not writable: ${parent}`;
47
+ }
48
+
49
+ return null; // valid
50
+ }
51
+
25
52
  const router = Router();
26
53
 
27
54
  // GET /api/projects - List all projects
@@ -31,17 +58,29 @@ router.get('/', (_req, res) => {
31
58
  });
32
59
 
33
60
  // POST /api/projects - Create project
34
- router.post('/', (req, res) => {
61
+ router.post('/', async (req, res) => {
35
62
  const result = CreateProjectRequest.safeParse(req.body);
36
63
  if (!result.success) {
37
64
  return res.status(400).json({ error: result.error.issues[0].message });
38
65
  }
39
66
 
40
- const { name, workingDirectory, systemPrompt, onSessionCreated, onSessionDeleted } = result.data;
41
- const project = projects.create(name, workingDirectory, systemPrompt || null, {
67
+ const { name, workingDirectory, systemPrompt, onSessionCreated, onSessionDeleted, worktreePath, kanbanEnabled } = result.data;
68
+
69
+ const pathError = await validateWorktreePath(worktreePath);
70
+ if (pathError) {
71
+ return res.status(400).json({ error: pathError });
72
+ }
73
+
74
+ const createOptions = {
42
75
  onSessionCreated: onSessionCreated || null,
43
76
  onSessionDeleted: onSessionDeleted || null,
44
- });
77
+ worktreePath: worktreePath || null,
78
+ };
79
+ if (kanbanEnabled !== undefined) {
80
+ createOptions.kanbanEnabled = kanbanEnabled;
81
+ }
82
+
83
+ const project = projects.create(name, workingDirectory, systemPrompt || null, createOptions);
45
84
  res.status(201).json(project);
46
85
  });
47
86
 
@@ -55,7 +94,7 @@ router.get('/:id', (req, res) => {
55
94
  });
56
95
 
57
96
  // PUT /api/projects/:id - Update project
58
- router.put('/:id', (req, res) => {
97
+ router.put('/:id', async (req, res) => {
59
98
  const project = projects.getById(req.params.id);
60
99
  if (!project) {
61
100
  return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
@@ -66,6 +105,13 @@ router.put('/:id', (req, res) => {
66
105
  return res.status(400).json({ error: result.error.issues[0].message });
67
106
  }
68
107
 
108
+ if (result.data.worktreePath !== undefined) {
109
+ const pathError = await validateWorktreePath(result.data.worktreePath);
110
+ if (pathError) {
111
+ return res.status(400).json({ error: pathError });
112
+ }
113
+ }
114
+
69
115
  const updated = projects.update(req.params.id, result.data);
70
116
  res.json(updated);
71
117
  });
@@ -183,24 +229,13 @@ async function validateAndPrepareSessionConfig(reqBody, reqFiles, projectId, pro
183
229
  return { config, nextTemplateId };
184
230
  }
185
231
 
186
- // POST /api/projects/:id/sessions - Create session
187
- // Supports both JSON and multipart/form-data (for file attachments)
188
- router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, async (req, res) => {
189
- const project = projects.getById(req.params.id);
190
- if (!project) {
191
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
192
- }
193
-
194
- const prepared = await validateAndPrepareSessionConfig(req.body, req.files, req.params.id, project);
195
- if (prepared.error) {
196
- return res.status(prepared.status).json({ error: prepared.error });
197
- }
198
-
199
- const { config, nextTemplateId } = prepared;
200
- const initialStatus = determineInitialStatus(config);
201
-
232
+ /**
233
+ * Create the session row and apply any post-create updates.
234
+ * Returns the created session (already persisted in DB).
235
+ */
236
+ function createSessionRow(projectId, config, nextTemplateId, initialStatus) {
202
237
  const sessionName = config.name || generateInitialName(config.prompt);
203
- const session = sessions.create(req.params.id, sessionName, config.prompt, {
238
+ const session = sessions.create(projectId, sessionName, config.prompt, {
204
239
  mode: config.mode,
205
240
  thinkingEnabled: config.thinkingEnabled,
206
241
  gitBranch: config.gitBranch,
@@ -210,7 +245,6 @@ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, a
210
245
  effortLevel: config.effortLevel,
211
246
  });
212
247
 
213
- // Apply optional post-create updates (next template + scheduling) in one pass
214
248
  const postCreateUpdate = {
215
249
  ...(nextTemplateId ? { nextTemplateId } : {}),
216
250
  ...buildSchedulingUpdate(config, initialStatus),
@@ -218,24 +252,68 @@ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, a
218
252
  if (Object.keys(postCreateUpdate).length > 0) {
219
253
  sessions.update(session.id, postCreateUpdate);
220
254
  }
255
+ return session;
256
+ }
221
257
 
222
- // Setup git environment, start session, and broadcast
258
+ /**
259
+ * Run setupAndStartSession and translate any failure into an error response,
260
+ * marking the session as errored and broadcasting the update.
261
+ */
262
+ async function startSessionOrFail(req, res, { session, config, project }) {
223
263
  try {
224
264
  const { updatedSession } = await setupAndStartSession({
225
265
  session, config, project, projectId: req.params.id, files: config.files,
226
266
  });
227
- res.status(201).json(updatedSession);
267
+ return res.status(201).json(updatedSession);
228
268
  } catch (error) {
229
269
  console.error('Git setup error:', error);
230
270
  const updatedSession = sessions.update(session.id, { status: 'error', error: error.message });
231
-
232
271
  broadcastToProject(req.params.id, WS_MESSAGE_TYPES.SESSION_UPDATED, {
233
272
  projectId: req.params.id,
234
273
  sessionId: session.id,
235
274
  session: updatedSession,
236
275
  });
276
+ return res.status(500).json({ error: `Git setup failed: ${error.message}` });
277
+ }
278
+ }
279
+
280
+ // POST /api/projects/:id/sessions - Create session
281
+ // Supports both JSON and multipart/form-data (for file attachments)
282
+ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, async (req, res) => {
283
+ // Outer try/catch so any synchronous or asynchronous throw produces an HTTP
284
+ // response rather than leaving the socket hanging (which manifests as
285
+ // "socket hang up" on the client side). Without this, an unhandled rejection
286
+ // from validation, DB repositories, or template resolution could cause the
287
+ // intermittent flake observed in file-attachments.test.js.
288
+ let session = null;
289
+ try {
290
+ const project = projects.getById(req.params.id);
291
+ if (!project) {
292
+ return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
293
+ }
294
+
295
+ const prepared = await validateAndPrepareSessionConfig(req.body, req.files, req.params.id, project);
296
+ if (prepared.error) {
297
+ return res.status(prepared.status).json({ error: prepared.error });
298
+ }
299
+
300
+ const { config, nextTemplateId } = prepared;
301
+ const initialStatus = determineInitialStatus(config);
302
+ session = createSessionRow(req.params.id, config, nextTemplateId, initialStatus);
303
+ return await startSessionOrFail(req, res, { session, config, project });
304
+ } catch (error) {
305
+ console.error('Session creation error:', error);
306
+
307
+ // If the session row was already created, mark it as errored so it isn't left dangling.
308
+ if (session && session.id) {
309
+ try {
310
+ sessions.update(session.id, { status: 'error', error: error.message });
311
+ } catch (updateError) {
312
+ console.error('Failed to mark session as errored:', updateError);
313
+ }
314
+ }
237
315
 
238
- res.status(500).json({ error: `Git setup failed: ${error.message}` });
316
+ return res.status(500).json({ error: error.message || 'Internal server error' });
239
317
  }
240
318
  });
241
319
 
@@ -269,90 +347,8 @@ router.post('/:id/templates', (req, res) => {
269
347
  res.status(201).json(template);
270
348
  });
271
349
 
272
- // GET /api/projects/:id/command-buttons - List all command buttons for project
273
- router.get('/:id/command-buttons', (req, res) => {
274
- const project = projects.getById(req.params.id);
275
- if (!project) {
276
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
277
- }
278
-
279
- const buttons = commandButtons.getByProjectId(req.params.id);
280
- res.json(buttons);
281
- });
282
-
283
- // POST /api/projects/:id/command-buttons - Create new command button
284
- router.post('/:id/command-buttons', (req, res) => {
285
- const project = projects.getById(req.params.id);
286
- if (!project) {
287
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
288
- }
289
-
290
- const result = CreateCommandButtonRequest.safeParse(req.body);
291
- if (!result.success) {
292
- return res.status(400).json({ error: result.error.issues[0].message });
293
- }
294
-
295
- const button = commandButtons.create({
296
- projectId: req.params.id,
297
- label: result.data.label,
298
- command: result.data.command,
299
- sortOrder: result.data.sortOrder,
300
- showOnList: result.data.showOnList,
301
- });
302
-
303
- res.status(201).json(button);
304
- });
305
-
306
- // GET /api/projects/:id/command-buttons/:buttonId - Get single button
307
- router.get('/:id/command-buttons/:buttonId', (req, res) => {
308
- const project = projects.getById(req.params.id);
309
- if (!project) {
310
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
311
- }
312
-
313
- const button = commandButtons.getById(req.params.buttonId);
314
- if (!button) {
315
- return res.status(404).json({ error: 'Command button not found' });
316
- }
317
- res.json(button);
318
- });
319
-
320
- // PATCH /api/projects/:id/command-buttons/:buttonId - Update button
321
- router.patch('/:id/command-buttons/:buttonId', (req, res) => {
322
- const project = projects.getById(req.params.id);
323
- if (!project) {
324
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
325
- }
326
-
327
- const button = commandButtons.getById(req.params.buttonId);
328
- if (!button) {
329
- return res.status(404).json({ error: 'Command button not found' });
330
- }
331
-
332
- const result = UpdateCommandButtonRequest.safeParse(req.body);
333
- if (!result.success) {
334
- return res.status(400).json({ error: result.error.issues[0].message });
335
- }
336
-
337
- const updated = commandButtons.update(req.params.buttonId, result.data);
338
- res.json(updated);
339
- });
340
-
341
- // DELETE /api/projects/:id/command-buttons/:buttonId - Delete button
342
- router.delete('/:id/command-buttons/:buttonId', (req, res) => {
343
- const project = projects.getById(req.params.id);
344
- if (!project) {
345
- return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
346
- }
347
-
348
- const button = commandButtons.getById(req.params.buttonId);
349
- if (!button) {
350
- return res.status(404).json({ error: 'Command button not found' });
351
- }
352
-
353
- commandButtons.delete(req.params.buttonId);
354
- res.status(204).send();
355
- });
350
+ // Command button routes are mounted as a sub-router
351
+ router.use('/:id/command-buttons', projectCommandButtonsRouter);
356
352
 
357
353
  // GET /api/projects/:id/session-defaults - Get session defaults for project
358
354
  router.get('/:id/session-defaults', (req, res) => {
@@ -19,8 +19,9 @@ router.post('/:id/archive', requireSessionAndProject, async (req, res) => {
19
19
  const updated = sessions.update(req.params.id, { archived: true });
20
20
 
21
21
  // Execute project cleanup command if cleanup requested and project has one configured
22
+ // Only run for worktree sessions - branch-mode sessions have nothing to clean up
22
23
  // Skip for child sessions - they share parent's resources and shouldn't trigger teardown
23
- if (cleanup && req.project?.onSessionDeleted && !req.session_.parentSessionId) {
24
+ if (cleanup && req.project?.onSessionDeleted && req.session_.gitWorktree && !req.session_.parentSessionId) {
24
25
  executeHookAsync(req.project.onSessionDeleted, req.workingDirectory, {
25
26
  sessionId: req.session_.id,
26
27
  projectId: req.project.id,
@@ -175,8 +175,9 @@ router.delete('/:id', requireSessionAndProject, async (req, res) => {
175
175
  }
176
176
 
177
177
  // Execute on_session_deleted hook if configured (non-blocking)
178
+ // Only run for worktree sessions - branch-mode sessions have nothing to clean up
178
179
  // Skip for child sessions - they share parent's resources and shouldn't trigger teardown
179
- if (req.project?.onSessionDeleted && !req.session_.parentSessionId) {
180
+ if (req.project?.onSessionDeleted && req.session_.gitWorktree && !req.session_.parentSessionId) {
180
181
  executeHookAsync(req.project.onSessionDeleted, req.workingDirectory, {
181
182
  sessionId: req.session_.id,
182
183
  projectId: req.project.id,
@@ -115,6 +115,8 @@ const FIELD_DEFINITIONS = [
115
115
  { field: 'autoSendPendingPrompt', transform: Boolean },
116
116
  { field: 'providerId', validate: validateProviderId },
117
117
  { field: 'prUrl', validate: validatePrUrl },
118
+ // Git fields
119
+ { field: 'gitWorktree' },
118
120
  // Scheduling fields
119
121
  { field: 'scheduledAt' },
120
122
  { field: 'autoRescheduleEnabled', transform: Boolean },
@@ -0,0 +1,6 @@
1
+ import { homedir } from 'os';
2
+ import { join } from 'path';
3
+
4
+ export function getDefaultDbPath() {
5
+ return join(homedir(), '.circuschief', 'circuschief.db');
6
+ }
@@ -53,6 +53,7 @@ export {
53
53
  initDatabase,
54
54
  getDatabase,
55
55
  closeDatabase,
56
+ getDbPath,
56
57
  generateId,
57
58
  transaction,
58
59
  } from './db/index.js';
@@ -13,13 +13,15 @@ const __dirname = dirname(__filename);
13
13
  */
14
14
  export class DatabaseManager {
15
15
  #db = null;
16
+ #dbPath = null;
16
17
 
17
18
  /**
18
19
  * Initialize database with WAL mode and foreign keys
19
20
  * @param {string} dbPath - Path to database file (use ':memory:' for in-memory)
20
21
  * @returns {Database.Database}
21
22
  */
22
- init(dbPath = 'circuschief.db') {
23
+ init(dbPath) {
24
+ this.#dbPath = dbPath;
23
25
  this.#db = new Database(dbPath);
24
26
 
25
27
  // Enable WAL mode and foreign keys
@@ -36,6 +38,14 @@ export class DatabaseManager {
36
38
  return this.#db;
37
39
  }
38
40
 
41
+ /**
42
+ * Get the path the database was initialized with.
43
+ * @returns {string|null}
44
+ */
45
+ getPath() {
46
+ return this.#dbPath;
47
+ }
48
+
39
49
  /**
40
50
  * Run database migrations for existing databases.
41
51
  * Iterates over the flat, ordered list of migrations exported from
@@ -66,6 +76,7 @@ export class DatabaseManager {
66
76
  if (this.#db) {
67
77
  this.#db.close();
68
78
  this.#db = null;
79
+ this.#dbPath = null;
69
80
  }
70
81
  }
71
82