circuschief 0.1.4 → 0.2.1

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 (81) hide show
  1. package/package.json +1 -1
  2. package/packages/server/src/api/commands.js +50 -55
  3. package/packages/server/src/api/projects-helpers.js +13 -4
  4. package/packages/server/src/api/projects.js +33 -18
  5. package/packages/server/src/cli.js +82 -0
  6. package/packages/server/src/db/AgentCallLogRepository.js +30 -31
  7. package/packages/server/src/db/ConversationRepository.js +27 -16
  8. package/packages/server/src/db/ProjectRepository.js +21 -31
  9. package/packages/server/src/db/QuickResponseRepository.js +14 -19
  10. package/packages/server/src/db/migrations/sessionsMigrations.js +61 -61
  11. package/packages/server/src/index.js +42 -29
  12. package/packages/server/src/services/commandRunner.js +52 -99
  13. package/packages/server/src/services/kanbanTriggers.js +83 -56
  14. package/packages/server/src/services/schedulerService.js +68 -44
  15. package/packages/server/src/services/sessionExecution.js +102 -61
  16. package/packages/server/src/services/sessionManager.js +63 -37
  17. package/packages/server/src/services/summaryService.js +56 -53
  18. package/packages/server/src/services/templateTriggerService.js +58 -31
  19. package/packages/server/src/ws/WebSocketManager.js +5 -0
  20. package/packages/web/dist/assets/ActiveSessionsView-3697sD8N.js +1 -0
  21. package/packages/web/dist/assets/ActiveSessionsView-DfYXc6dz.css +1 -0
  22. package/packages/web/dist/assets/{AgentLogsView-c42v_j_5.js → AgentLogsView-D4l0N9ZA.js} +1 -1
  23. package/packages/web/dist/assets/{ArchiveConfirmModal-DBuOmtXu.js → ArchiveConfirmModal-Bv3vGOMM.js} +1 -1
  24. package/packages/web/dist/assets/{CommandButtonDetailView-CkKJ3Htz.js → CommandButtonDetailView-Bk_SHxpu.js} +1 -1
  25. package/packages/web/dist/assets/{EffortLevelSelector-BHJHSqul.js → EffortLevelSelector-VfBEelvO.js} +1 -1
  26. package/packages/web/dist/assets/{GeneralSettingsView-CdxfteZ2.js → GeneralSettingsView-BqCzCX-z.js} +1 -1
  27. package/packages/web/dist/assets/{InterpolationHelp-DabnHhE4.js → InterpolationHelp-Dc1Y0T6v.js} +1 -1
  28. package/packages/web/dist/assets/MarkdownEditor-DwBQkZbs.js +2 -0
  29. package/packages/web/dist/assets/{ModelSelector-BWIU4ud7.js → ModelSelector-DSxaZWBL.js} +1 -1
  30. package/packages/web/dist/assets/{NewSessionView-BIZl8QlH.js → NewSessionView-BsI7JtO9.js} +2 -2
  31. package/packages/web/dist/assets/{PathChooser-nhat_Pz4.js → PathChooser-CXFxb8Oj.js} +1 -1
  32. package/packages/web/dist/assets/{ProjectEditView-DD-2_VrW.js → ProjectEditView-Bes4Mib4.js} +1 -1
  33. package/packages/web/dist/assets/{ProjectListView-BOWbfoXQ.js → ProjectListView-DzEu-C36.js} +1 -1
  34. package/packages/web/dist/assets/{ProjectNewView-DC4uvSn2.js → ProjectNewView-Cv-iEAgl.js} +1 -1
  35. package/packages/web/dist/assets/ProvidersView-CgAr0qms.js +1 -0
  36. package/packages/web/dist/assets/{QuickResponseSettings-Bk9mq96x.js → QuickResponseSettings-uDDpwaza.js} +1 -1
  37. package/packages/web/dist/assets/{QuickResponsesPanel-BRvcnkQr.js → QuickResponsesPanel-D0qs0Fm_.js} +1 -1
  38. package/packages/web/dist/assets/{ResizableTextarea-CwGM4P3c.js → ResizableTextarea-_kHi1Mg3.js} +1 -1
  39. package/packages/web/dist/assets/{SessionCard-BGDVHU9u.js → SessionCard-Be1-bK0C.js} +1 -1
  40. package/packages/web/dist/assets/{SessionCard-D20G3bX8.css → SessionCard-CcqIjL8q.css} +1 -1
  41. package/packages/web/dist/assets/{SessionDetailView-CHYrx2Ab.js → SessionDetailView-DUYb7qTA.js} +17 -17
  42. package/packages/web/dist/assets/{SessionDetailView-7bWgC7Es.css → SessionDetailView-mnGRMaLY.css} +1 -1
  43. package/packages/web/dist/assets/{SessionFormOptions-8qvL25ca.js → SessionFormOptions-DvhOyP6z.js} +1 -1
  44. package/packages/web/dist/assets/{SessionListView-BAIBtJF7.css → SessionListView-78k6TTz6.css} +1 -1
  45. package/packages/web/dist/assets/SessionListView-CuHsWj85.js +1 -0
  46. package/packages/web/dist/assets/{SessionLogStream-B-w3n4c3.js → SessionLogStream-Da_GniUZ.js} +1 -1
  47. package/packages/web/dist/assets/{SettingsView-Dd0ZJ4Nv.js → SettingsView-5RDCXNUa.js} +1 -1
  48. package/packages/web/dist/assets/{SlashCommandWizard-CzyLjsdJ.js → SlashCommandWizard-B_8ifpxN.js} +1 -1
  49. package/packages/web/dist/assets/{SummarySettingsView-DTbh7uAF.js → SummarySettingsView-KvgSGHdd.js} +1 -1
  50. package/packages/web/dist/assets/{TemplateDetailView-BOnhkdtH.js → TemplateDetailView-BhOjYIvS.js} +1 -1
  51. package/packages/web/dist/assets/{commandButtons-CY87n64i.js → commandButtons-B4OYZP0J.js} +1 -1
  52. package/packages/web/dist/assets/{index-DxboI9i-.js → index-80Qu7W6P.js} +1 -1
  53. package/packages/web/dist/assets/{index-NzLFVaCi.js → index-B8_Iqwcq.js} +1 -1
  54. package/packages/web/dist/assets/{index-aCw-iXPX.js → index-B9JErft2.js} +1 -1
  55. package/packages/web/dist/assets/{index-Ce6sL47U.js → index-BHVnr8MO.js} +1 -1
  56. package/packages/web/dist/assets/{index-BXUcbV4K.js → index-BarVnQIj.js} +1 -1
  57. package/packages/web/dist/assets/{index--OtPwBbF.js → index-BqVgX_Jy.js} +3 -3
  58. package/packages/web/dist/assets/{index-gMpnPf1V.js → index-BsvRdU0B.js} +1 -1
  59. package/packages/web/dist/assets/{index-BRUlEEHm.js → index-Bugg2M-E.js} +1 -1
  60. package/packages/web/dist/assets/{index-Dx0sYW7H.js → index-C2Pjy-M8.js} +1 -1
  61. package/packages/web/dist/assets/{index-DkLkDgig.js → index-CSOPrlmq.js} +23 -23
  62. package/packages/web/dist/assets/{index-CO4EBOFw.js → index-CS_wb_Vj.js} +1 -1
  63. package/packages/web/dist/assets/{index-CjHb9rXv.js → index-ClzNIdCp.js} +1 -1
  64. package/packages/web/dist/assets/{index-DPwwgloE.js → index-Cn9Ajkye.js} +1 -1
  65. package/packages/web/dist/assets/{index-i1o916sk.js → index-CucpVX4L.js} +1 -1
  66. package/packages/web/dist/assets/{index-C6m-WfqP.js → index-D9hZYvW3.js} +1 -1
  67. package/packages/web/dist/assets/{index-jGjvGBfk.js → index-DA0dK_PG.js} +1 -1
  68. package/packages/web/dist/assets/{index-Bi4bQ_UB.js → index-DgpSn-jR.js} +1 -1
  69. package/packages/web/dist/assets/{index-DcA6pqXV.js → index-HZwIyC9t.js} +1 -1
  70. package/packages/web/dist/assets/{index-BshkV3r5.js → index-SS3wA2sI.js} +1 -1
  71. package/packages/web/dist/assets/{projects-C2Y29PSJ.js → projects-B2du-GX8.js} +1 -1
  72. package/packages/web/dist/assets/{providers-CeJXuo0Q.js → providers-B__J6FX0.js} +1 -1
  73. package/packages/web/dist/assets/sessions-VDrd87yA.js +1 -0
  74. package/packages/web/dist/assets/{settings-BplIxCbi.js → settings-CZ7Pc-Pt.js} +1 -1
  75. package/packages/web/dist/index.html +1 -1
  76. package/packages/web/dist/assets/ActiveSessionsView-BryJ-V3f.js +0 -1
  77. package/packages/web/dist/assets/ActiveSessionsView-ofSvx-K1.css +0 -1
  78. package/packages/web/dist/assets/MarkdownEditor-k4zBLGqU.js +0 -2
  79. package/packages/web/dist/assets/ProvidersView-DT5afh1V.js +0 -1
  80. package/packages/web/dist/assets/SessionListView-927Yq6Il.js +0 -1
  81. package/packages/web/dist/assets/sessions-CMby7ij3.js +0 -1
@@ -10,6 +10,65 @@ const TABLE_SESSIONS = 'sessions';
10
10
  // Column type constants
11
11
  const COL_INTEGER_DEFAULT_0 = 'INTEGER DEFAULT 0';
12
12
 
13
+ /**
14
+ * SQL column definition for the sessions table with updated status CHECK constraint.
15
+ */
16
+ const SESSIONS_BASE_COLUMNS = `
17
+ id TEXT PRIMARY KEY,
18
+ project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
19
+ name TEXT NOT NULL,
20
+ status TEXT NOT NULL DEFAULT 'starting' CHECK (status IN ('starting', 'running', 'waiting', 'stopped', 'completed', 'error', 'scheduled')),
21
+ mode TEXT NOT NULL DEFAULT 'standard' CHECK (mode IN ('plan', 'standard', 'yolo')),
22
+ thinking_enabled INTEGER NOT NULL DEFAULT 0,
23
+ git_branch TEXT,
24
+ git_worktree TEXT,
25
+ pr_url TEXT,
26
+ error TEXT,
27
+ effort_level TEXT CHECK(effort_level IN ('low', 'medium', 'high', 'max', 'auto')),
28
+ cost_usd REAL DEFAULT 0,
29
+ claude_session_id TEXT,
30
+ model TEXT,
31
+ next_template_id TEXT REFERENCES session_templates(id) ON DELETE SET NULL,
32
+ parent_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
33
+ input_tokens INTEGER DEFAULT 0,
34
+ output_tokens INTEGER DEFAULT 0,
35
+ cache_read_input_tokens INTEGER DEFAULT 0,
36
+ cache_creation_input_tokens INTEGER DEFAULT 0,
37
+ web_search_requests INTEGER DEFAULT 0,
38
+ context_window INTEGER DEFAULT 200000,
39
+ archived INTEGER NOT NULL DEFAULT 0,
40
+ starred INTEGER NOT NULL DEFAULT 0,
41
+ manually_named INTEGER NOT NULL DEFAULT 0,
42
+ scheduled_at INTEGER DEFAULT NULL,
43
+ reschedule_delay_minutes INTEGER DEFAULT 15,
44
+ auto_reschedule_enabled INTEGER DEFAULT 0,
45
+ reschedule_on_token_limit INTEGER DEFAULT 1,
46
+ reschedule_on_service_error INTEGER DEFAULT 1,
47
+ max_reschedule_count INTEGER DEFAULT NULL,
48
+ max_total_tokens INTEGER DEFAULT NULL,
49
+ reschedule_count INTEGER DEFAULT 0,
50
+ reschedule_at_token_count INTEGER DEFAULT NULL,
51
+ created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
52
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
53
+ `;
54
+
55
+ /**
56
+ * All possible column names that may exist in the sessions table for migration SELECT.
57
+ */
58
+ const SESSIONS_ALL_COLUMNS = [
59
+ 'id', 'project_id', 'name', 'status', 'mode', 'thinking_enabled',
60
+ 'git_branch', 'git_worktree', 'pr_url', 'error', 'effort_level',
61
+ 'cost_usd', 'claude_session_id', 'model', 'next_template_id',
62
+ 'parent_session_id', 'input_tokens', 'output_tokens',
63
+ 'cache_read_input_tokens', 'cache_creation_input_tokens',
64
+ 'web_search_requests', 'context_window', 'archived', 'starred',
65
+ 'manually_named', 'scheduled_at', 'reschedule_delay_minutes',
66
+ 'auto_reschedule_enabled', 'reschedule_on_token_limit',
67
+ 'reschedule_on_service_error', 'max_reschedule_count',
68
+ 'max_total_tokens', 'reschedule_count', 'reschedule_at_token_count',
69
+ 'created_at', 'updated_at',
70
+ ];
71
+
13
72
  /**
14
73
  * Migrate sessions table to include 'stopped' and 'scheduled' in status CHECK constraint.
15
74
  * SQLite doesn't support ALTER TABLE to modify constraints, so we recreate the table.
@@ -22,76 +81,17 @@ function migrateSessionsStatusConstraint(db) {
22
81
  return;
23
82
  }
24
83
 
25
- // Get all columns from the current table to preserve data
26
84
  const columnNames = getColumns(db, TABLE_SESSIONS);
27
-
28
- const baseColumns = `
29
- id TEXT PRIMARY KEY,
30
- project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
31
- name TEXT NOT NULL,
32
- status TEXT NOT NULL DEFAULT 'starting' CHECK (status IN ('starting', 'running', 'waiting', 'stopped', 'completed', 'error', 'scheduled')),
33
- mode TEXT NOT NULL DEFAULT 'standard' CHECK (mode IN ('plan', 'standard', 'yolo')),
34
- thinking_enabled INTEGER NOT NULL DEFAULT 0,
35
- git_branch TEXT,
36
- git_worktree TEXT,
37
- pr_url TEXT,
38
- error TEXT,
39
- effort_level TEXT CHECK(effort_level IN ('low', 'medium', 'high', 'max', 'auto')),
40
- cost_usd REAL DEFAULT 0,
41
- claude_session_id TEXT,
42
- model TEXT,
43
- next_template_id TEXT REFERENCES session_templates(id) ON DELETE SET NULL,
44
- parent_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL,
45
- input_tokens INTEGER DEFAULT 0,
46
- output_tokens INTEGER DEFAULT 0,
47
- cache_read_input_tokens INTEGER DEFAULT 0,
48
- cache_creation_input_tokens INTEGER DEFAULT 0,
49
- web_search_requests INTEGER DEFAULT 0,
50
- context_window INTEGER DEFAULT 200000,
51
- archived INTEGER NOT NULL DEFAULT 0,
52
- starred INTEGER NOT NULL DEFAULT 0,
53
- manually_named INTEGER NOT NULL DEFAULT 0,
54
- scheduled_at INTEGER DEFAULT NULL,
55
- reschedule_delay_minutes INTEGER DEFAULT 15,
56
- auto_reschedule_enabled INTEGER DEFAULT 0,
57
- reschedule_on_token_limit INTEGER DEFAULT 1,
58
- reschedule_on_service_error INTEGER DEFAULT 1,
59
- max_reschedule_count INTEGER DEFAULT NULL,
60
- max_total_tokens INTEGER DEFAULT NULL,
61
- reschedule_count INTEGER DEFAULT 0,
62
- reschedule_at_token_count INTEGER DEFAULT NULL,
63
- created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
64
- updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
65
- `;
66
-
67
- const selectColumns = [
68
- 'id', 'project_id', 'name', 'status', 'mode', 'thinking_enabled',
69
- 'git_branch', 'git_worktree', 'pr_url', 'error', 'effort_level',
70
- 'cost_usd', 'claude_session_id', 'model', 'next_template_id',
71
- 'parent_session_id', 'input_tokens', 'output_tokens',
72
- 'cache_read_input_tokens', 'cache_creation_input_tokens',
73
- 'web_search_requests', 'context_window', 'archived', 'starred',
74
- 'manually_named', 'scheduled_at', 'reschedule_delay_minutes',
75
- 'auto_reschedule_enabled', 'reschedule_on_token_limit',
76
- 'reschedule_on_service_error', 'max_reschedule_count',
77
- 'max_total_tokens', 'reschedule_count', 'reschedule_at_token_count',
78
- 'created_at', 'updated_at',
79
- ]
85
+ const selectColumns = SESSIONS_ALL_COLUMNS
80
86
  .filter((col) => columnNames.includes(col))
81
87
  .join(', ');
82
88
 
83
89
  db.exec(`
84
- CREATE TABLE sessions_new (
85
- ${baseColumns}
86
- );
87
-
90
+ CREATE TABLE sessions_new (${SESSIONS_BASE_COLUMNS});
88
91
  INSERT INTO sessions_new (${selectColumns})
89
92
  SELECT ${selectColumns} FROM sessions;
90
-
91
93
  DROP TABLE sessions;
92
-
93
94
  ALTER TABLE sessions_new RENAME TO sessions;
94
-
95
95
  CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
96
96
  CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
97
97
  CREATE INDEX IF NOT EXISTS idx_sessions_archived ON sessions(archived);
@@ -1,14 +1,16 @@
1
1
  import { createServer } from 'http';
2
- import { parseArgs } from 'node:util';
3
2
  import { execSync } from 'child_process';
4
3
  import { createApp } from './app.js';
5
4
  import { initDatabase } from './database.js';
6
- import { initWebSocket } from './websocket.js';
7
- import { DEFAULT_SERVER_PORT } from '../../shared/src/index.js';
5
+ import { initWebSocket, webSocketManager } from './websocket.js';
6
+ import { parseCliOptions } from './cli.js';
7
+ import { settings } from './db/index.js';
8
8
  import * as prStatusService from './services/prStatusService.js';
9
9
  import * as systemMonitor from './services/systemMonitor.js';
10
10
  import { schedulerService } from './services/schedulerService.js';
11
11
  import * as sessionManager from './services/sessionManager.js';
12
+ import { clearScheduledTimers } from './services/summaryService.js';
13
+ import { commandRunner } from './services/commandRunner.js';
12
14
 
13
15
  /**
14
16
  * Validate Node.js environment at startup.
@@ -27,17 +29,7 @@ function validateNodeEnvironment() {
27
29
  }
28
30
  }
29
31
 
30
- const { values } = parseArgs({
31
- options: {
32
- port: {
33
- type: 'string',
34
- short: 'p',
35
- default: String(DEFAULT_SERVER_PORT),
36
- },
37
- },
38
- });
39
-
40
- const port = parseInt(values.port, 10);
32
+ const { port, disableAnalytics } = parseCliOptions();
41
33
  process.env.PORT = String(port);
42
34
  const production = process.env.NODE_ENV === 'production';
43
35
  const dbPath = process.env.DB_PATH || 'circuschief.db';
@@ -57,6 +49,12 @@ validateNodeEnvironment();
57
49
  initDatabase(dbPath);
58
50
  console.log(`Database initialized: ${dbPath}`);
59
51
 
52
+ // Apply --no-analytics flag to persisted settings
53
+ if (disableAnalytics) {
54
+ settings.setGeneralSettings({ disableAnalytics: true });
55
+ console.log('Analytics disabled via --no-analytics flag');
56
+ }
57
+
60
58
  // Create Express app
61
59
  const app = createApp({ production });
62
60
 
@@ -77,30 +75,45 @@ prStatusService.start();
77
75
  systemMonitor.start();
78
76
 
79
77
  // Graceful shutdown
80
- process.on('SIGTERM', () => {
81
- console.log('SIGTERM received, shutting down gracefully');
82
- schedulerService.stop();
83
- prStatusService.stop();
84
- systemMonitor.stop();
85
- server.close(() => {
86
- console.log('Server closed');
87
- process.exit(0);
88
- });
89
- });
78
+ let shuttingDown = false;
79
+ function shutdown(signal) {
80
+ if (shuttingDown) return;
81
+ shuttingDown = true;
82
+ console.log(`${signal} received, shutting down gracefully`);
83
+
84
+ // Safety net: force exit after 5 seconds
85
+ const forceTimeout = setTimeout(() => {
86
+ console.error('Graceful shutdown timed out, forcing exit');
87
+ process.exit(1);
88
+ }, 5000);
89
+ forceTimeout.unref();
90
90
 
91
- process.on('SIGINT', () => {
92
- console.log('SIGINT received, shutting down gracefully');
91
+ // Stop periodic services
93
92
  schedulerService.stop();
94
93
  prStatusService.stop();
95
94
  systemMonitor.stop();
95
+
96
+ // Clear dangling timers from summary service
97
+ clearScheduledTimers();
98
+
99
+ // Kill child processes spawned by commandRunner
100
+ commandRunner.shutdownAll();
101
+
102
+ // Close all WebSocket connections (must happen before server.close())
103
+ webSocketManager.close();
104
+
105
+ // Close HTTP server (now unblocked since WS clients are terminated)
96
106
  server.close(() => {
97
107
  console.log('Server closed');
98
108
  process.exit(0);
99
109
  });
100
- });
110
+ }
111
+
112
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
113
+ process.on('SIGINT', () => shutdown('SIGINT'));
101
114
 
102
115
  // Start server on all interfaces
103
116
  server.listen(port, '0.0.0.0', () => {
104
- console.log(`Server running on http://0.0.0.0:${port}`);
105
- console.log(`WebSocket available at ws://0.0.0.0:${port}/ws`);
117
+ console.log(`Circus Chief running on http://localhost:${port}`);
118
+ console.log(`WebSocket available at ws://localhost:${port}/ws`);
106
119
  });
@@ -33,11 +33,10 @@ export class CommandRunner {
33
33
  * Wrap command with platform-specific TTY allocation.
34
34
  */
35
35
  #wrapCommandForPlatform(command) {
36
- const osType = platform();
37
- if (osType === 'linux') {
38
- return `script -q -e -c ${JSON.stringify(command)} /dev/null`;
39
- }
40
- return `script -q /dev/null sh -c ${JSON.stringify(command)}`;
36
+ const cmd = JSON.stringify(command);
37
+ return platform() === 'linux'
38
+ ? `script -q -e -c ${cmd} /dev/null`
39
+ : `script -q /dev/null sh -c ${cmd}`;
41
40
  }
42
41
 
43
42
  /**
@@ -62,8 +61,7 @@ export class CommandRunner {
62
61
  */
63
62
  #flushOutputBuffer(entryInput, runId) {
64
63
  const entry = entryInput;
65
- if (!entry.outputBuffer) return;
66
- if (!entry.sessionId || !entry.buttonId) return;
64
+ if (!entry.outputBuffer || !entry.sessionId || !entry.buttonId) return;
67
65
  if (!commandRuns || typeof commandRuns.appendOutput !== 'function') return;
68
66
  try {
69
67
  commandRuns.appendOutput(runId, entry.outputBuffer);
@@ -104,21 +102,31 @@ export class CommandRunner {
104
102
 
105
103
  this.processes.delete(runId);
106
104
  if (onComplete) onComplete(exitCode, entry.output);
107
- // Use ?? 1 (not signal-specific codes like 143 for SIGTERM) because:
108
- // - Exit codes >128 indicate signal termination (convention: 128 + signal number)
109
- // - Normalizing to 1 simplifies error handling for consumers
110
- // - The signal information is already logged above for debugging
105
+ // Normalize to 1 on signal termination (signal info already logged above)
111
106
  return exitCode ?? 1;
112
107
  }
113
108
 
114
- /**
115
- * Run a command and stream output via callback
116
- *
117
- * @param {{ runId: string, command: string, workingDirectory: string }} params - Command parameters
118
- * @param {{ onOutput?: function, onComplete?: function, onError?: function }} callbacks - Callback functions
119
- * @param {{ sessionId?: string, buttonId?: string }} metadata - Optional metadata
120
- * @returns {Promise<number>} Exit code
121
- */
109
+ /** Handle a run failure (process error or setup exception). Flushes output, marks failed, resolves. */
110
+ #handleRunFailure({ entry: entryParam, runId, err, onError, resolve }) {
111
+ const entry = entryParam;
112
+ let errorOutput = `[Error] Failed to execute command: ${err.message}`;
113
+ if (entry) {
114
+ const remaining = entry.outputProcessor.flush();
115
+ if (remaining) { entry.output += remaining; entry.outputBuffer += remaining; }
116
+ this.#flushOutputBuffer(entry, runId);
117
+ errorOutput = entry.output;
118
+ }
119
+ const msg = entry ? `Failed to execute command: ${err.message}` : `Error running command: ${err.message}`;
120
+ console.error(`[commandRunner.run] Error for runId: ${runId}`, err);
121
+ if (onError) onError(msg);
122
+ if (commandRuns && typeof commandRuns.complete === 'function') {
123
+ try { commandRuns.complete(runId, 1, errorOutput); } catch (dbErr) { console.warn(`[commandRunner.run] DB error for runId: ${runId}`, dbErr.message); }
124
+ }
125
+ this.processes.delete(runId);
126
+ resolve(1);
127
+ }
128
+
129
+ /** Run a command and stream output via callback. Returns exit code. */
122
130
  async run(params, callbacks = {}, metadata = {}) {
123
131
  const { runId, command, workingDirectory } = params;
124
132
  const { onOutput, onComplete, onError } = callbacks;
@@ -139,7 +147,6 @@ export class CommandRunner {
139
147
  const entry = this.#createProcessEntry(child, sessionId, buttonId);
140
148
  this.processes.set(runId, entry);
141
149
 
142
- // Buffer timer management
143
150
  const clearBufferTimer = () => {
144
151
  if (entry.bufferFlushTimer) {
145
152
  clearInterval(entry.bufferFlushTimer);
@@ -148,7 +155,6 @@ export class CommandRunner {
148
155
  };
149
156
  entry.bufferFlushTimer = setInterval(() => this.#flushOutputBuffer(entry, runId), this.outputBufferFlushInterval);
150
157
 
151
- // Data handler for both stdout and stderr
152
158
  const handleData = (data) => {
153
159
  const text = entry.outputProcessor.process(data.toString());
154
160
  if (text) {
@@ -163,20 +169,7 @@ export class CommandRunner {
163
169
 
164
170
  child.on('error', (err) => {
165
171
  clearBufferTimer();
166
- const remainingText = entry.outputProcessor.flush();
167
- if (remainingText) {
168
- entry.output += remainingText;
169
- entry.outputBuffer += remainingText;
170
- }
171
- this.#flushOutputBuffer(entry, runId);
172
- const msg = `Failed to execute command: ${err.message}`;
173
- console.error(`[commandRunner.run] Error for runId: ${runId}`, err);
174
- if (onError) onError(msg);
175
- if (commandRuns && typeof commandRuns.complete === 'function') {
176
- try { commandRuns.complete(runId, 1, entry.output); } catch (dbErr) { console.warn('[commandRunner.run] Warning: Error completing run in database for runId:', runId, dbErr.message); }
177
- }
178
- this.processes.delete(runId);
179
- resolve(1);
172
+ this.#handleRunFailure({ entry, runId, err, onError, resolve });
180
173
  });
181
174
 
182
175
  child.on('close', (exitCode, signal) => {
@@ -190,28 +183,12 @@ export class CommandRunner {
190
183
  resolve(this.#handleProcessClose({ entry, runId, exitCode, signal }, onComplete));
191
184
  });
192
185
  } catch (err) {
193
- const msg = `Error running command: ${err.message}`;
194
- console.error(`[commandRunner.run] Exception for runId: ${runId}`, err);
195
- if (onError) onError(msg);
196
- // Mark as error in database (if available) and persist the error message
197
- if (commandRuns && typeof commandRuns.complete === 'function') {
198
- try {
199
- commandRuns.complete(runId, 1, `[Error] ${msg}`);
200
- } catch (dbErr) {
201
- console.warn(`[commandRunner.run] Warning: Error marking failed run in database for runId: ${runId}`, dbErr.message);
202
- }
203
- }
204
- this.processes.delete(runId);
205
- resolve(1);
186
+ this.#handleRunFailure({ entry: null, runId, err, onError, resolve });
206
187
  }
207
188
  });
208
189
  }
209
190
 
210
- /**
211
- * Kill a running process
212
- * @param {string} runId
213
- * @returns {boolean} True if process was killed, false if not found
214
- */
191
+ /** Kill a running process. Returns true if killed, false if not found. */
215
192
  kill(runId) {
216
193
  const entry = this.processes.get(runId);
217
194
  if (!entry) {
@@ -234,22 +211,10 @@ export class CommandRunner {
234
211
 
235
212
  // Give it a moment to terminate gracefully, then force kill
236
213
  setTimeout(() => {
237
- // Check if process is still in our map (not yet closed)
238
- if (this.processes.has(runId)) {
239
- console.log(`[commandRunner.kill] Process still running, sending SIGKILL to runId: ${runId}`);
240
- try {
241
- process.kill(-pid, 'SIGKILL');
242
- } catch (e) {
243
- // Fallback to killing just the process if process group kill fails
244
- try {
245
- entry.process.kill('SIGKILL');
246
- } catch (err) {
247
- // Process may have already exited
248
- console.log(`[commandRunner.kill] SIGKILL failed, process may have exited: ${err.message}`);
249
- }
250
- }
251
- } else {
252
- console.log(`[commandRunner.kill] Process already exited for runId: ${runId}`);
214
+ if (!this.processes.has(runId)) return;
215
+ console.log(`[commandRunner.kill] Process still running, sending SIGKILL to runId: ${runId}`);
216
+ try { process.kill(-pid, 'SIGKILL'); } catch {
217
+ try { entry.process.kill('SIGKILL'); } catch { /* already dead */ }
253
218
  }
254
219
  }, 1000);
255
220
 
@@ -279,30 +244,30 @@ export class CommandRunner {
279
244
  }
280
245
  }
281
246
 
282
- /**
283
- * Get all active runs
284
- * @returns {Map} Map of runId -> process info
285
- */
247
+ /** Terminate all active child processes (called during graceful shutdown). */
248
+ shutdownAll() {
249
+ const sendSignal = (sig) => {
250
+ for (const [, entry] of this.processes) {
251
+ try { process.kill(-entry.process.pid, sig); } catch {
252
+ try { entry.process.kill(sig); } catch { /* already dead */ }
253
+ }
254
+ }
255
+ };
256
+ sendSignal('SIGTERM');
257
+ setTimeout(() => sendSignal('SIGKILL'), 1000).unref();
258
+ }
259
+
260
+ /** Get all active runs as a new Map. */
286
261
  getActiveRuns() {
287
262
  return new Map(this.processes);
288
263
  }
289
264
 
290
- /**
291
- * Check if a run is active
292
- * @param {string} runId
293
- * @returns {boolean}
294
- */
265
+ /** Check if a run is active. */
295
266
  isRunning(runId) {
296
267
  return this.processes.has(runId);
297
268
  }
298
269
 
299
- /**
300
- * Get all running commands for a project
301
- * Used for merging in-memory running commands with completed runs from the database
302
- * @param {string} projectId
303
- * @param {Function} getSessionById - Function to look up session by ID
304
- * @returns {Array} Running command runs
305
- */
270
+ /** Get all running commands for a project (merges in-memory with completed DB runs). */
306
271
  getRunningByProjectId(projectId, getSessionById) {
307
272
  const results = [];
308
273
  for (const [runId, entry] of this.processes.entries()) {
@@ -324,15 +289,7 @@ export class CommandRunner {
324
289
  return results;
325
290
  }
326
291
 
327
- /**
328
- * Get all active runs for a specific session (both running and recent completed)
329
- * @param {string} sessionId
330
- * @returns {Array} Array of run info objects
331
- */
332
- /**
333
- * Mark an orphaned run as error in the database
334
- * @param {Object} dbRun - The database run record
335
- */
292
+ /** Mark an orphaned run as error in the database. */
336
293
  #markOrphanedRunAsError(dbRun) {
337
294
  console.log(
338
295
  `[commandRunner.getRunsBySession] Orphaned run detected: ${dbRun.id}, marking as error`
@@ -348,11 +305,7 @@ export class CommandRunner {
348
305
  }
349
306
  }
350
307
 
351
- /**
352
- * Process a database run record and return a normalized run object
353
- * @param {Object} dbRun - The database run record
354
- * @returns {Object} Normalized run object
355
- */
308
+ /** Process a database run record and return a normalized run object. */
356
309
  #processDbRun(dbRun) {
357
310
  let status = dbRun.status;
358
311
  let exitCode = dbRun.exitCode;
@@ -120,6 +120,45 @@ export function getTemplateSessionSettings(template, session) {
120
120
  * @param {Object} [options] - Options
121
121
  * @param {number} [options.depth=0] - Current recursion depth
122
122
  */
123
+ /**
124
+ * Create and configure a child session from a template for lane entry.
125
+ * @param {Object} template
126
+ * @param {Object} session - Parent session
127
+ * @param {Object} lane
128
+ * @param {number} depth - Current trigger depth
129
+ * @returns {{ newSession: Object, renderedPrompt: string, settings: Object }}
130
+ */
131
+ async function buildChildSessionFromTemplate(template, session, lane, depth) {
132
+ // Render prompt with session context
133
+ const parentSummary = sessionSummaries.getBySessionId(session.id);
134
+ const rootSession = getRootSession(session);
135
+ const rootSummary = sessionSummaries.getBySessionId(rootSession.id);
136
+ const renderedPrompt = await renderTemplatePrompt(
137
+ template.prompt,
138
+ { parentSession: session, parentSummary, rootSession, rootSummary }
139
+ );
140
+
141
+ // Get settings and create session
142
+ const settings = getTemplateSessionSettings(template, session);
143
+ const newSession = sessions.create(session.projectId, `${template.name} (lane: ${lane.name})`, renderedPrompt, {
144
+ mode: settings.mode,
145
+ thinkingEnabled: settings.thinkingEnabled,
146
+ gitBranch: settings.gitBranch,
147
+ status: 'starting',
148
+ model: settings.model,
149
+ });
150
+
151
+ // Configure session
152
+ sessions.update(newSession.id, {
153
+ parentSessionId: session.id,
154
+ nextTemplateId: template.nextTemplateId || null,
155
+ targetLaneId: template.targetLaneId || null,
156
+ laneTriggerDepth: depth + 1,
157
+ });
158
+
159
+ return { newSession, renderedPrompt, settings };
160
+ }
161
+
123
162
  export async function triggerOnEnterTemplate(sessionId, lane, options = {}) {
124
163
  const { depth = 0 } = options;
125
164
 
@@ -141,32 +180,7 @@ export async function triggerOnEnterTemplate(sessionId, lane, options = {}) {
141
180
  console.log(`Kanban: Triggering on-enter template "${template.name}" for session "${session.name}" entering lane "${lane.name}"`);
142
181
 
143
182
  try {
144
- // Render prompt with session context
145
- const parentSummary = sessionSummaries.getBySessionId(sessionId);
146
- const rootSession = getRootSession(session);
147
- const rootSummary = sessionSummaries.getBySessionId(rootSession.id);
148
- const renderedPrompt = await renderTemplatePrompt(
149
- template.prompt,
150
- { parentSession: session, parentSummary, rootSession, rootSummary }
151
- );
152
-
153
- // Get settings and create session
154
- const settings = getTemplateSessionSettings(template, session);
155
- const newSession = sessions.create(session.projectId, `${template.name} (lane: ${lane.name})`, renderedPrompt, {
156
- mode: settings.mode,
157
- thinkingEnabled: settings.thinkingEnabled,
158
- gitBranch: settings.gitBranch,
159
- status: 'starting',
160
- model: settings.model,
161
- });
162
-
163
- // Configure session
164
- sessions.update(newSession.id, {
165
- parentSessionId: session.id,
166
- nextTemplateId: template.nextTemplateId || null,
167
- targetLaneId: template.targetLaneId || null,
168
- laneTriggerDepth: depth + 1,
169
- });
183
+ const { newSession, renderedPrompt, settings } = await buildChildSessionFromTemplate(template, session, lane, depth);
170
184
 
171
185
  // Determine working directory
172
186
  const { workingDirectory, gitWorktree } = await determineWorkingDirectory(session, project, {
@@ -203,6 +217,48 @@ export async function triggerOnEnterTemplate(sessionId, lane, options = {}) {
203
217
  * @param {Object} [options] - Options
204
218
  * @param {number} [options.depth=0] - Current recursion depth
205
219
  */
220
+ /**
221
+ * Create and configure a child session from a lane's on-enter prompt.
222
+ * @param {Object} lane
223
+ * @param {Object} session - Parent session
224
+ * @param {number} depth - Current trigger depth
225
+ * @returns {Promise<{ newSession: Object, renderedPrompt: string, settings: Object }>}
226
+ */
227
+ async function buildChildSessionFromPrompt(lane, session, depth) {
228
+ // Render prompt with session context
229
+ const parentSummary = sessionSummaries.getBySessionId(session.id);
230
+ const rootSession = getRootSession(session);
231
+ const rootSummary = sessionSummaries.getBySessionId(rootSession.id);
232
+ const renderedPrompt = await renderTemplatePrompt(
233
+ lane.onEnterPrompt,
234
+ { parentSession: session, parentSummary, rootSession, rootSummary }
235
+ );
236
+
237
+ // Get settings and create session
238
+ const settings = getLaneSessionSettings(lane, session);
239
+ const newSession = sessions.create(session.projectId, `Lane prompt (lane: ${lane.name})`, renderedPrompt, {
240
+ ...settings,
241
+ status: 'starting',
242
+ });
243
+
244
+ // Configure session
245
+ const sessionUpdates = { parentSessionId: session.id, laneTriggerDepth: depth + 1 };
246
+ if (lane.onEnterAutoRescheduleEnabled) {
247
+ Object.assign(sessionUpdates, {
248
+ autoRescheduleEnabled: true,
249
+ rescheduleDelayMinutes: lane.onEnterRescheduleDelayMinutes || 15,
250
+ rescheduleOnTokenLimit: lane.onEnterRescheduleOnTokenLimit ?? true,
251
+ rescheduleOnServiceError: lane.onEnterRescheduleOnServiceError ?? true,
252
+ maxRescheduleCount: lane.onEnterMaxRescheduleCount || null,
253
+ maxTotalTokens: lane.onEnterMaxTotalTokens || null,
254
+ rescheduleAtTokenCount: lane.onEnterRescheduleAtTokenCount || null,
255
+ });
256
+ }
257
+ sessions.update(newSession.id, sessionUpdates);
258
+
259
+ return { newSession, renderedPrompt, settings };
260
+ }
261
+
206
262
  export async function triggerOnEnterPrompt(sessionId, lane, options = {}) {
207
263
  const { depth = 0 } = options;
208
264
 
@@ -218,36 +274,7 @@ export async function triggerOnEnterPrompt(sessionId, lane, options = {}) {
218
274
  console.log(`Kanban: Triggering on-enter prompt for session "${session.name}" entering lane "${lane.name}"`);
219
275
 
220
276
  try {
221
- // Render prompt with session context
222
- const parentSummary = sessionSummaries.getBySessionId(sessionId);
223
- const rootSession = getRootSession(session);
224
- const rootSummary = sessionSummaries.getBySessionId(rootSession.id);
225
- const renderedPrompt = await renderTemplatePrompt(
226
- lane.onEnterPrompt,
227
- { parentSession: session, parentSummary, rootSession, rootSummary }
228
- );
229
-
230
- // Get settings and create session
231
- const settings = getLaneSessionSettings(lane, session);
232
- const newSession = sessions.create(session.projectId, `Lane prompt (lane: ${lane.name})`, renderedPrompt, {
233
- ...settings,
234
- status: 'starting',
235
- });
236
-
237
- // Configure session
238
- const sessionUpdates = { parentSessionId: session.id, laneTriggerDepth: depth + 1 };
239
- if (lane.onEnterAutoRescheduleEnabled) {
240
- Object.assign(sessionUpdates, {
241
- autoRescheduleEnabled: true,
242
- rescheduleDelayMinutes: lane.onEnterRescheduleDelayMinutes || 15,
243
- rescheduleOnTokenLimit: lane.onEnterRescheduleOnTokenLimit ?? true,
244
- rescheduleOnServiceError: lane.onEnterRescheduleOnServiceError ?? true,
245
- maxRescheduleCount: lane.onEnterMaxRescheduleCount || null,
246
- maxTotalTokens: lane.onEnterMaxTotalTokens || null,
247
- rescheduleAtTokenCount: lane.onEnterRescheduleAtTokenCount || null,
248
- });
249
- }
250
- sessions.update(newSession.id, sessionUpdates);
277
+ const { newSession, renderedPrompt, settings } = await buildChildSessionFromPrompt(lane, session, depth);
251
278
 
252
279
  // Determine working directory
253
280
  const { workingDirectory, gitWorktree } = await determineWorkingDirectory(session, project);