circuschief 0.2.1 → 0.3.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 (117) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/api/git.js +15 -0
  3. package/packages/server/src/api/projects-commandButtons.js +96 -0
  4. package/packages/server/src/api/projects-session-helpers.js +51 -33
  5. package/packages/server/src/api/projects.js +102 -111
  6. package/packages/server/src/api/sessions-archive.js +2 -1
  7. package/packages/server/src/api/sessions-lifecycle.js +2 -1
  8. package/packages/server/src/api/sessions-patch.js +2 -0
  9. package/packages/server/src/config.js +6 -0
  10. package/packages/server/src/db/DatabaseManager.js +1 -1
  11. package/packages/server/src/db/ProjectRepository.js +17 -3
  12. package/packages/server/src/db/migrations/index.js +7 -0
  13. package/packages/server/src/db/migrations/miscMigrations.js +78 -1
  14. package/packages/server/src/db/migrations/projectsMigrations.js +4 -0
  15. package/packages/server/src/index.js +7 -1
  16. package/packages/server/src/services/gitService.js +42 -6
  17. package/packages/server/src/services/gitSessionSetup.js +4 -3
  18. package/packages/server/src/services/kanbanTriggers.js +1 -0
  19. package/packages/server/src/services/sessionExecution.js +2 -2
  20. package/packages/server/src/services/templateTriggerService.js +1 -0
  21. package/packages/shared/src/contracts/projects.js +3 -0
  22. package/packages/shared/src/types.js +4 -3
  23. package/packages/web/dist/assets/ActiveSessionsView-BVco8bPU.css +1 -0
  24. package/packages/web/dist/assets/ActiveSessionsView-HgcjR-fv.js +1 -0
  25. package/packages/web/dist/assets/{AgentLogsView-D4l0N9ZA.js → AgentLogsView-gnpmy7Ck.js} +1 -1
  26. package/packages/web/dist/assets/{ApiClient-Dbs1H78V.js → ApiClient-CcqJ-GAv.js} +1 -1
  27. package/packages/web/dist/assets/{ArchiveConfirmModal-CQZeuYBz.css → ArchiveConfirmModal-BQ-4gI0R.css} +1 -1
  28. package/packages/web/dist/assets/ArchiveConfirmModal-NDLcfHE9.js +1 -0
  29. package/packages/web/dist/assets/CommandButtonDetailView-UGoO1Rhg.js +1 -0
  30. package/packages/web/dist/assets/EffortLevelSelector-B9STpy07.js +1 -0
  31. package/packages/web/dist/assets/{GeneralSettingsView-BqCzCX-z.js → GeneralSettingsView-Bvp1zmeb.js} +1 -1
  32. package/packages/web/dist/assets/InputWithButton-cYdrEmTs.css +1 -0
  33. package/packages/web/dist/assets/{PathChooser-CXFxb8Oj.js → InputWithButton-kjjnlpHt.js} +1 -1
  34. package/packages/web/dist/assets/InterpolationHelp-Cwyt_Kff.js +1 -0
  35. package/packages/web/dist/assets/MarkdownEditor-CfuhardG.js +2 -0
  36. package/packages/web/dist/assets/{ModelSelector-DSxaZWBL.js → ModelSelector-vRSpXjhL.js} +1 -1
  37. package/packages/web/dist/assets/{NewSessionView-BsI7JtO9.js → NewSessionView-CJqnlwjP.js} +2 -2
  38. package/packages/web/dist/assets/{NewSessionView-Byoi1XdQ.css → NewSessionView-D_Hi7M9g.css} +1 -1
  39. package/packages/web/dist/assets/ProjectEditView-I36pvIJH.css +1 -0
  40. package/packages/web/dist/assets/ProjectEditView-LZhWNyZD.js +1 -0
  41. package/packages/web/dist/assets/ProjectListView-CumumZN-.css +1 -0
  42. package/packages/web/dist/assets/ProjectListView-DxpzlBb6.js +1 -0
  43. package/packages/web/dist/assets/ProjectNewView-CaLXfFzd.css +1 -0
  44. package/packages/web/dist/assets/ProjectNewView-X1y2iuaX.js +1 -0
  45. package/packages/web/dist/assets/{ProvidersView-CgAr0qms.js → ProvidersView-DC-v48Oj.js} +1 -1
  46. package/packages/web/dist/assets/{ProvidersView-B_QQF3RM.css → ProvidersView-uD8SKWpA.css} +1 -1
  47. package/packages/web/dist/assets/{QuickResponseSettings-uDDpwaza.js → QuickResponseSettings-DOZUtaRb.js} +1 -1
  48. package/packages/web/dist/assets/{QuickResponsesPanel-D0qs0Fm_.js → QuickResponsesPanel-BboDyvfE.js} +1 -1
  49. package/packages/web/dist/assets/{ResizableTextarea-_kHi1Mg3.js → ResizableTextarea-BIUrLAki.js} +1 -1
  50. package/packages/web/dist/assets/{SessionCard-Be1-bK0C.js → SessionCard-D9a5ABHO.js} +1 -1
  51. package/packages/web/dist/assets/{SessionDetailView-mnGRMaLY.css → SessionDetailView-D_p4QqDv.css} +1 -1
  52. package/packages/web/dist/assets/SessionDetailView-DpnAZfLe.js +36 -0
  53. package/packages/web/dist/assets/{SessionFormOptions-DvhOyP6z.js → SessionFormOptions-16tOEDkt.js} +1 -1
  54. package/packages/web/dist/assets/{SessionListView-CuHsWj85.js → SessionListView-BxayFy5a.js} +1 -1
  55. package/packages/web/dist/assets/{SessionLogStream-Da_GniUZ.js → SessionLogStream-DMUzZOml.js} +6 -6
  56. package/packages/web/dist/assets/SettingsView-IKYDEAFw.js +1 -0
  57. package/packages/web/dist/assets/{SlashCommandWizard-B_8ifpxN.js → SlashCommandWizard-CCEtEF98.js} +1 -1
  58. package/packages/web/dist/assets/{SummarySettingsView-KvgSGHdd.js → SummarySettingsView-Cq_0Bsk4.js} +1 -1
  59. package/packages/web/dist/assets/{TemplateDetailView-BhOjYIvS.js → TemplateDetailView-Vj413FOj.js} +1 -1
  60. package/packages/web/dist/assets/{commandButtons-B4OYZP0J.js → commandButtons-BqI7V84_.js} +1 -1
  61. package/packages/web/dist/assets/index-B0PaFn8J.js +1 -0
  62. package/packages/web/dist/assets/index-B0uKzlP3.js +1 -0
  63. package/packages/web/dist/assets/index-BOj2hM4W.js +1 -0
  64. package/packages/web/dist/assets/index-BVH1Fur5.js +1 -0
  65. package/packages/web/dist/assets/index-Bm4uwnFi.js +1 -0
  66. package/packages/web/dist/assets/index-C82-hypB.js +7 -0
  67. package/packages/web/dist/assets/index-C98wGWOV.js +1 -0
  68. package/packages/web/dist/assets/index-CQt0yAHQ.js +1 -0
  69. package/packages/web/dist/assets/index-CTe3Gmxt.js +1 -0
  70. package/packages/web/dist/assets/{index-BqVgX_Jy.js → index-Crwsqc5t.js} +3 -3
  71. package/packages/web/dist/assets/{index-BHVnr8MO.js → index-CzgbuOA8.js} +2 -2
  72. package/packages/web/dist/assets/index-D8oIVxkt.js +1 -0
  73. package/packages/web/dist/assets/index-DIrFNnos.js +1 -0
  74. package/packages/web/dist/assets/index-DVkCrevC.js +3 -0
  75. package/packages/web/dist/assets/index-DXvlnigm.js +1 -0
  76. package/packages/web/dist/assets/{index-CSOPrlmq.js → index-MnvuyUxj.js} +3 -3
  77. package/packages/web/dist/assets/index-mMvVdgsE.js +1 -0
  78. package/packages/web/dist/assets/index-okhXrCbj.js +1 -0
  79. package/packages/web/dist/assets/index-yghSjW4j.js +1 -0
  80. package/packages/web/dist/assets/{projects-B2du-GX8.js → projects-BpAyb9ba.js} +1 -1
  81. package/packages/web/dist/assets/{providers-B__J6FX0.js → providers-Di1AKmPp.js} +1 -1
  82. package/packages/web/dist/assets/{sessions-VDrd87yA.js → sessions-GqIMf9RF.js} +1 -1
  83. package/packages/web/dist/assets/{settings-CZ7Pc-Pt.js → settings-j6pMR_H2.js} +1 -1
  84. package/packages/web/dist/assets/useSummaryHelpers-GVg7sMWF.js +1 -0
  85. package/packages/web/dist/index.html +1 -1
  86. package/packages/web/dist/assets/ActiveSessionsView-3697sD8N.js +0 -1
  87. package/packages/web/dist/assets/ActiveSessionsView-DfYXc6dz.css +0 -1
  88. package/packages/web/dist/assets/ArchiveConfirmModal-Bv3vGOMM.js +0 -1
  89. package/packages/web/dist/assets/CommandButtonDetailView-Bk_SHxpu.js +0 -1
  90. package/packages/web/dist/assets/EffortLevelSelector-VfBEelvO.js +0 -1
  91. package/packages/web/dist/assets/InterpolationHelp-Dc1Y0T6v.js +0 -1
  92. package/packages/web/dist/assets/MarkdownEditor-DwBQkZbs.js +0 -2
  93. package/packages/web/dist/assets/PathChooser-BoMGzeg2.css +0 -1
  94. package/packages/web/dist/assets/ProjectEditView-Bes4Mib4.js +0 -1
  95. package/packages/web/dist/assets/ProjectEditView-DNwBUNRk.css +0 -1
  96. package/packages/web/dist/assets/ProjectListView-C55H1JHQ.css +0 -1
  97. package/packages/web/dist/assets/ProjectListView-DzEu-C36.js +0 -1
  98. package/packages/web/dist/assets/ProjectNewView-CpgE4R-l.css +0 -1
  99. package/packages/web/dist/assets/ProjectNewView-Cv-iEAgl.js +0 -1
  100. package/packages/web/dist/assets/SessionDetailView-DUYb7qTA.js +0 -36
  101. package/packages/web/dist/assets/SettingsView-5RDCXNUa.js +0 -1
  102. package/packages/web/dist/assets/index-80Qu7W6P.js +0 -1
  103. package/packages/web/dist/assets/index-B8_Iqwcq.js +0 -1
  104. package/packages/web/dist/assets/index-B9JErft2.js +0 -1
  105. package/packages/web/dist/assets/index-BarVnQIj.js +0 -3
  106. package/packages/web/dist/assets/index-BsvRdU0B.js +0 -1
  107. package/packages/web/dist/assets/index-Bugg2M-E.js +0 -1
  108. package/packages/web/dist/assets/index-C2Pjy-M8.js +0 -1
  109. package/packages/web/dist/assets/index-CS_wb_Vj.js +0 -1
  110. package/packages/web/dist/assets/index-ClzNIdCp.js +0 -1
  111. package/packages/web/dist/assets/index-Cn9Ajkye.js +0 -1
  112. package/packages/web/dist/assets/index-CucpVX4L.js +0 -1
  113. package/packages/web/dist/assets/index-D9hZYvW3.js +0 -1
  114. package/packages/web/dist/assets/index-DA0dK_PG.js +0 -7
  115. package/packages/web/dist/assets/index-DgpSn-jR.js +0 -1
  116. package/packages/web/dist/assets/index-HZwIyC9t.js +0 -1
  117. 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.3.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);
@@ -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,16 +58,23 @@ 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;
67
+ const { name, workingDirectory, systemPrompt, onSessionCreated, onSessionDeleted, worktreePath } = result.data;
68
+
69
+ const pathError = await validateWorktreePath(worktreePath);
70
+ if (pathError) {
71
+ return res.status(400).json({ error: pathError });
72
+ }
73
+
41
74
  const project = projects.create(name, workingDirectory, systemPrompt || null, {
42
75
  onSessionCreated: onSessionCreated || null,
43
76
  onSessionDeleted: onSessionDeleted || null,
77
+ worktreePath: worktreePath || null,
44
78
  });
45
79
  res.status(201).json(project);
46
80
  });
@@ -55,7 +89,7 @@ router.get('/:id', (req, res) => {
55
89
  });
56
90
 
57
91
  // PUT /api/projects/:id - Update project
58
- router.put('/:id', (req, res) => {
92
+ router.put('/:id', async (req, res) => {
59
93
  const project = projects.getById(req.params.id);
60
94
  if (!project) {
61
95
  return res.status(404).json({ error: ERR_PROJECT_NOT_FOUND });
@@ -66,6 +100,13 @@ router.put('/:id', (req, res) => {
66
100
  return res.status(400).json({ error: result.error.issues[0].message });
67
101
  }
68
102
 
103
+ if (result.data.worktreePath !== undefined) {
104
+ const pathError = await validateWorktreePath(result.data.worktreePath);
105
+ if (pathError) {
106
+ return res.status(400).json({ error: pathError });
107
+ }
108
+ }
109
+
69
110
  const updated = projects.update(req.params.id, result.data);
70
111
  res.json(updated);
71
112
  });
@@ -183,24 +224,13 @@ async function validateAndPrepareSessionConfig(reqBody, reqFiles, projectId, pro
183
224
  return { config, nextTemplateId };
184
225
  }
185
226
 
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
-
227
+ /**
228
+ * Create the session row and apply any post-create updates.
229
+ * Returns the created session (already persisted in DB).
230
+ */
231
+ function createSessionRow(projectId, config, nextTemplateId, initialStatus) {
202
232
  const sessionName = config.name || generateInitialName(config.prompt);
203
- const session = sessions.create(req.params.id, sessionName, config.prompt, {
233
+ const session = sessions.create(projectId, sessionName, config.prompt, {
204
234
  mode: config.mode,
205
235
  thinkingEnabled: config.thinkingEnabled,
206
236
  gitBranch: config.gitBranch,
@@ -210,7 +240,6 @@ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, a
210
240
  effortLevel: config.effortLevel,
211
241
  });
212
242
 
213
- // Apply optional post-create updates (next template + scheduling) in one pass
214
243
  const postCreateUpdate = {
215
244
  ...(nextTemplateId ? { nextTemplateId } : {}),
216
245
  ...buildSchedulingUpdate(config, initialStatus),
@@ -218,24 +247,68 @@ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, a
218
247
  if (Object.keys(postCreateUpdate).length > 0) {
219
248
  sessions.update(session.id, postCreateUpdate);
220
249
  }
250
+ return session;
251
+ }
221
252
 
222
- // Setup git environment, start session, and broadcast
253
+ /**
254
+ * Run setupAndStartSession and translate any failure into an error response,
255
+ * marking the session as errored and broadcasting the update.
256
+ */
257
+ async function startSessionOrFail(req, res, { session, config, project }) {
223
258
  try {
224
259
  const { updatedSession } = await setupAndStartSession({
225
260
  session, config, project, projectId: req.params.id, files: config.files,
226
261
  });
227
- res.status(201).json(updatedSession);
262
+ return res.status(201).json(updatedSession);
228
263
  } catch (error) {
229
264
  console.error('Git setup error:', error);
230
265
  const updatedSession = sessions.update(session.id, { status: 'error', error: error.message });
231
-
232
266
  broadcastToProject(req.params.id, WS_MESSAGE_TYPES.SESSION_UPDATED, {
233
267
  projectId: req.params.id,
234
268
  sessionId: session.id,
235
269
  session: updatedSession,
236
270
  });
271
+ return res.status(500).json({ error: `Git setup failed: ${error.message}` });
272
+ }
273
+ }
237
274
 
238
- res.status(500).json({ error: `Git setup failed: ${error.message}` });
275
+ // POST /api/projects/:id/sessions - Create session
276
+ // Supports both JSON and multipart/form-data (for file attachments)
277
+ router.post('/:id/sessions', uploadMiddleware('files', 10), handleUploadError, async (req, res) => {
278
+ // Outer try/catch so any synchronous or asynchronous throw produces an HTTP
279
+ // response rather than leaving the socket hanging (which manifests as
280
+ // "socket hang up" on the client side). Without this, an unhandled rejection
281
+ // from validation, DB repositories, or template resolution could cause the
282
+ // intermittent flake observed in file-attachments.test.js.
283
+ let session = null;
284
+ try {
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 prepared = await validateAndPrepareSessionConfig(req.body, req.files, req.params.id, project);
291
+ if (prepared.error) {
292
+ return res.status(prepared.status).json({ error: prepared.error });
293
+ }
294
+
295
+ const { config, nextTemplateId } = prepared;
296
+ const initialStatus = determineInitialStatus(config);
297
+ session = createSessionRow(req.params.id, config, nextTemplateId, initialStatus);
298
+ return await startSessionOrFail(req, res, { session, config, project });
299
+ } catch (error) {
300
+ console.error('Session creation error:', error);
301
+
302
+ // If the session row was already created, mark it as errored so it isn't left dangling.
303
+ if (session && session.id) {
304
+ try {
305
+ sessions.update(session.id, { status: 'error', error: error.message });
306
+ } catch (updateError) {
307
+ console.error('Failed to mark session as errored:', updateError);
308
+ }
309
+ }
310
+
311
+ return res.status(500).json({ error: error.message || 'Internal server error' });
239
312
  }
240
313
  });
241
314
 
@@ -269,90 +342,8 @@ router.post('/:id/templates', (req, res) => {
269
342
  res.status(201).json(template);
270
343
  });
271
344
 
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
- });
345
+ // Command button routes are mounted as a sub-router
346
+ router.use('/:id/command-buttons', projectCommandButtonsRouter);
356
347
 
357
348
  // GET /api/projects/:id/session-defaults - Get session defaults for project
358
349
  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
+ }
@@ -19,7 +19,7 @@ export class DatabaseManager {
19
19
  * @param {string} dbPath - Path to database file (use ':memory:' for in-memory)
20
20
  * @returns {Database.Database}
21
21
  */
22
- init(dbPath = 'circuschief.db') {
22
+ init(dbPath) {
23
23
  this.#db = new Database(dbPath);
24
24
 
25
25
  // Enable WAL mode and foreign keys
@@ -19,7 +19,10 @@ export class ProjectRepository extends BaseRepository {
19
19
  onSessionDeleted: row.on_session_deleted,
20
20
  prPollInterval: row.pr_poll_interval,
21
21
  repoUrl: row.repo_url,
22
+ worktreePath: row.worktree_path,
22
23
  kanbanEnabled: row.kanban_enabled === undefined ? true : Boolean(row.kanban_enabled),
24
+ sessionCount: row.session_count ?? 0,
25
+ lastActivityAt: row.last_activity_at ?? null,
23
26
  createdAt: row.created_at,
24
27
  updatedAt: row.updated_at,
25
28
  };
@@ -34,11 +37,12 @@ export class ProjectRepository extends BaseRepository {
34
37
  prPollInterval = 60000,
35
38
  repoUrl = null,
36
39
  kanbanEnabled = true,
40
+ worktreePath = null,
37
41
  } = options;
38
42
  this.db
39
43
  .prepare(
40
- `INSERT INTO projects (id, name, working_directory, system_prompt, on_session_created, on_session_deleted, pr_poll_interval, repo_url, kanban_enabled, created_at, updated_at)
41
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
44
+ `INSERT INTO projects (id, name, working_directory, system_prompt, on_session_created, on_session_deleted, pr_poll_interval, repo_url, kanban_enabled, worktree_path, created_at, updated_at)
45
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
42
46
  )
43
47
  .run(
44
48
  id,
@@ -50,6 +54,7 @@ export class ProjectRepository extends BaseRepository {
50
54
  prPollInterval,
51
55
  repoUrl,
52
56
  kanbanEnabled ? 1 : 0,
57
+ worktreePath,
53
58
  now,
54
59
  now
55
60
  );
@@ -57,7 +62,15 @@ export class ProjectRepository extends BaseRepository {
57
62
  }
58
63
 
59
64
  getAll() {
60
- const rows = this.db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all();
65
+ const rows = this.db.prepare(`
66
+ SELECT p.*,
67
+ COUNT(CASE WHEN s.archived = 0 THEN s.id END) as session_count,
68
+ MAX(s.updated_at) as last_activity_at
69
+ FROM projects p
70
+ LEFT JOIN sessions s ON s.project_id = p.id
71
+ GROUP BY p.id
72
+ ORDER BY p.updated_at DESC
73
+ `).all();
61
74
  return this.mapAll(rows);
62
75
  }
63
76
 
@@ -73,6 +86,7 @@ export class ProjectRepository extends BaseRepository {
73
86
  onSessionDeleted: { column: 'on_session_deleted' },
74
87
  prPollInterval: { column: 'pr_poll_interval' },
75
88
  repoUrl: { column: 'repo_url' },
89
+ worktreePath: { column: 'worktree_path' },
76
90
  kanbanEnabled: { column: 'kanban_enabled', transform: (v) => v ? 1 : 0 },
77
91
  };
78
92
 
@@ -65,6 +65,7 @@ export const allMigrations = validateMigrations([
65
65
  p.get('projects-add-on_session_created'),
66
66
  p.get('projects-add-on_session_deleted'),
67
67
  p.get('projects-add-repo_url'),
68
+ p.get('projects-add-worktree_path'),
68
69
  p.get('projects-drop-summary-columns'),
69
70
 
70
71
  // --- Sessions scheduling columns ---
@@ -196,4 +197,10 @@ export const allMigrations = validateMigrations([
196
197
 
197
198
  // --- Seed default global quick responses ---
198
199
  m.get('quick_responses-seed-defaults'),
200
+
201
+ // --- Seed default global session templates ---
202
+ m.get('session_templates-seed-defaults'),
203
+
204
+ // --- Update built-in Opus model to 4.7 ---
205
+ m.get('providers-update-built-in-opus-4-7'),
199
206
  ]);