agentboss 0.1.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 (53) hide show
  1. package/README.md +34 -0
  2. package/bin/aboss.js +288 -0
  3. package/client/dist/assets/index-C1wFD_Vo.css +1 -0
  4. package/client/dist/assets/index-DBj1Ujlx.js +137 -0
  5. package/client/dist/index.html +34 -0
  6. package/package.json +64 -0
  7. package/server/analysis/daily-aggregator.js +258 -0
  8. package/server/analysis/difficulty.js +129 -0
  9. package/server/analysis/dimensions/ai-knowledge.js +172 -0
  10. package/server/analysis/dimensions/ai-tools.js +161 -0
  11. package/server/analysis/dimensions/judgement.js +107 -0
  12. package/server/analysis/dimensions/llm-merge.js +57 -0
  13. package/server/analysis/dimensions/output-quality.js +167 -0
  14. package/server/analysis/dimensions/problem-definition.js +104 -0
  15. package/server/analysis/dimensions/system-thinking.js +225 -0
  16. package/server/analysis/evidence-builder.js +104 -0
  17. package/server/analysis/job.js +273 -0
  18. package/server/analysis/report-builder.js +581 -0
  19. package/server/analysis/scoring-v2.js +72 -0
  20. package/server/analysis/text-signals.js +179 -0
  21. package/server/analysis/thresholds-v2.js +358 -0
  22. package/server/api/advice.js +124 -0
  23. package/server/api/analysis.js +141 -0
  24. package/server/api/execution.js +330 -0
  25. package/server/api/metrics.js +277 -0
  26. package/server/api/overview.js +308 -0
  27. package/server/api/project.js +255 -0
  28. package/server/api/reports.js +125 -0
  29. package/server/api/sessions.js +118 -0
  30. package/server/api/settings.js +119 -0
  31. package/server/db/connection.js +175 -0
  32. package/server/db/queries.js +1051 -0
  33. package/server/db/schema.js +487 -0
  34. package/server/etl/active-time.js +150 -0
  35. package/server/etl/backfill-subagents.js +178 -0
  36. package/server/etl/claude-code.js +826 -0
  37. package/server/etl/detect.js +341 -0
  38. package/server/etl/judge-filter.js +117 -0
  39. package/server/etl/opencode.js +606 -0
  40. package/server/execution/job.js +662 -0
  41. package/server/execution/prompt.js +227 -0
  42. package/server/execution/runner.js +218 -0
  43. package/server/index.js +94 -0
  44. package/server/llm/advice-prompt.js +339 -0
  45. package/server/llm/advice.js +384 -0
  46. package/server/llm/analysis-prompt.js +162 -0
  47. package/server/llm/cli-runner.js +249 -0
  48. package/server/llm/judge-prompts.js +179 -0
  49. package/server/llm/judge.js +118 -0
  50. package/server/llm/project-advice-prompt.js +332 -0
  51. package/server/llm/project-advice.js +491 -0
  52. package/server/llm/session-analyzer.js +122 -0
  53. package/server/utils/project.js +80 -0
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Execution prompt builder — turns an AdviceItem + its source session
3
+ * into a single self-contained instruction for `opencode run` / `claude -p`
4
+ * to run inside the session's project directory.
5
+ *
6
+ * Spec: docs/superpowers/specs/2026-06-13-advice-execution-design.md §6
7
+ *
8
+ * # Independence
9
+ *
10
+ * Execution has its own sentinel (EXECUTION_SENTINEL). Registered in
11
+ * server/etl/judge-filter.js so the resulting CLI session gets filtered
12
+ * out of the user's own data. This file does NOT import from
13
+ * judge-prompts or advice-prompt; the three families are decoupled.
14
+ *
15
+ * # Versioning
16
+ *
17
+ * EXECUTION_PROMPT_VERSION is informational for the MVP — execution
18
+ * results are immutable facts (something either happened or didn't),
19
+ * so we don't store the version per-run and don't invalidate history
20
+ * on bump. Bump anyway to keep audit trails.
21
+ *
22
+ * @author Felix
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Constants
29
+ // ---------------------------------------------------------------------------
30
+
31
+ const EXECUTION_SENTINEL = '[ABOSS-EXEC]';
32
+ const EXECUTION_PROMPT_VERSION = 1;
33
+
34
+ /** Max chars per recent-user-message snippet sent to the executor. */
35
+ const MSG_CAP_CHARS = 400;
36
+ const MAX_RECENT_MSGS = 5;
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function trimTo(s, n) {
43
+ if (!s) return '';
44
+ return s.length > n ? s.slice(0, n) + '…' : s;
45
+ }
46
+
47
+ function fmtRecent(messages) {
48
+ if (!Array.isArray(messages) || messages.length === 0) return '(无)';
49
+ return messages
50
+ .map((m, i) => `${i + 1}. ${trimTo(String(m.text || '').replace(/\s+/g, ' '), MSG_CAP_CHARS)}`)
51
+ .join('\n');
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Builder
56
+ // ---------------------------------------------------------------------------
57
+
58
+ /**
59
+ * Assemble the executor prompt.
60
+ *
61
+ * @param {object} input
62
+ * @param {object} input.advice AdviceItem (with category attached)
63
+ * @param {string} input.advice.category e.g. 'cost'
64
+ * @param {string} input.advice.severity
65
+ * @param {string} input.advice.title
66
+ * @param {string} input.advice.why
67
+ * @param {string} input.advice.action
68
+ * @param {string} input.advice.evidence
69
+ * @param {object} input.session
70
+ * @param {string} input.session.project
71
+ * @param {string} input.session.title
72
+ * @param {string} input.session.model
73
+ * @param {number} input.session.durationMinutes
74
+ * @param {number} input.session.messageCount
75
+ * @param {{role:string, text:string}[]} input.recentUserMessages
76
+ * @returns {string}
77
+ */
78
+ function buildExecutionPrompt({ advice, session, recentUserMessages }) {
79
+ const a = advice || {};
80
+ const s = session || {};
81
+ return `${EXECUTION_SENTINEL}(内部标记,忽略本行)
82
+
83
+ # 任务
84
+
85
+ 我是 Agent Boss,在分析一段开发者与 AI 助手过去的会话时,得出了一条
86
+ 可执行的改进建议。请在当前项目里实施这条建议,然后按指定 JSON 格式
87
+ 报告你的行动。
88
+
89
+ # 来源会话
90
+
91
+ 项目: ${s.project || '未知'}
92
+ 标题: ${s.title || '(无标题)'}
93
+ 模型: ${s.model || '未知'}
94
+ 时长: ${s.durationMinutes ?? '?'} 分钟
95
+ 消息: ${s.messageCount ?? '?'} 条
96
+
97
+ # 改进建议
98
+
99
+ 类别: ${a.category || '?'}
100
+ 严重度: ${a.severity || '?'}
101
+ 标题: ${a.title || '(无)'}
102
+
103
+ 现状: ${a.why || '(无)'}
104
+ 动作: ${a.action || '(无)'}
105
+ 依据: ${a.evidence || '(无)'}
106
+
107
+ # 最近 ${Math.min((recentUserMessages || []).length, MAX_RECENT_MSGS)} 条用户消息(供你定位上下文,不代表当前任务)
108
+
109
+ ${fmtRecent(recentUserMessages)}
110
+
111
+ # 报告格式
112
+
113
+ 完成后(无论成功失败),请在你的最后一条消息里输出一段严格 JSON:
114
+
115
+ {
116
+ "ok": true | false,
117
+ "summary": "≤80 字 总结你做了什么(或为什么没做)",
118
+ "files_changed": ["相对路径", ...],
119
+ "notes": "可选,任何补充"
120
+ }
121
+
122
+ 如果你判断这条建议不应该执行(过时、与代码冲突、需要更多信息),
123
+ 直接返回 ok=false 并在 notes 里说明,不要硬改。`;
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Project-level execution prompt
128
+ // ---------------------------------------------------------------------------
129
+
130
+ /**
131
+ * Build a prompt for executing a PROJECT-LEVEL AdviceItem.
132
+ *
133
+ * The big differences vs. session-level:
134
+ * • the AdviceItem describes a cross-session pattern, not a single
135
+ * conversation problem — so we don't have "recent user messages" to
136
+ * give as context. Instead we pass the cross-session patterns the
137
+ * analyser surfaced.
138
+ * • the action is typically "build a skill", "change a config", "set
139
+ * up a workflow" — durable changes to the project, not single-shot
140
+ * code edits.
141
+ *
142
+ * Re-uses the same EXECUTION_SENTINEL so judge-filter.js keeps filtering
143
+ * the executor's own session out of the user's ETL.
144
+ *
145
+ * @param {object} input
146
+ * @param {object} input.advice AdviceItem (with .category)
147
+ * @param {object} input.project
148
+ * @param {string} input.project.path
149
+ * @param {string} input.project.scope 'daily'|'weekly'|'all'
150
+ * @param {string} input.project.windowFrom
151
+ * @param {string} input.project.windowTo
152
+ * @param {number} input.project.sessionCount number of sessions analysed
153
+ * @param {string[]} input.crossSessionPatterns
154
+ * @returns {string}
155
+ */
156
+ function buildProjectExecutionPrompt({ advice, project, crossSessionPatterns }) {
157
+ const a = advice || {};
158
+ const p = project || {};
159
+ const windowLabel =
160
+ p.scope === 'all' ? '全部历史'
161
+ : (p.windowFrom && p.windowTo)
162
+ ? (p.windowFrom === p.windowTo ? p.windowFrom : `${p.windowFrom} → ${p.windowTo}`)
163
+ : '?';
164
+ const patterns = Array.isArray(crossSessionPatterns) && crossSessionPatterns.length
165
+ ? crossSessionPatterns.map((s, i) => `${i + 1}. ${s}`).join('\n')
166
+ : '(无)';
167
+
168
+ return `${EXECUTION_SENTINEL}(内部标记,忽略本行)
169
+
170
+ # 任务
171
+
172
+ 我是 Agent Boss,在分析这个项目最近多次开发会话之后,得出了一条
173
+ **项目级**的可执行改进建议。请在当前项目里实施这条建议。
174
+
175
+ 项目级建议通常是**持久化的改变**——新增 / 修改一个 opencode skill、
176
+ 调整某个配置、写一份惯例文档、补一段脚本——目的是让以后所有会话都
177
+ 能从中受益,而不是修一段具体的业务代码。
178
+
179
+ # 项目
180
+
181
+ 路径: ${p.path || '?'}
182
+ 分析范围: ${p.scope || '?'} · ${windowLabel}
183
+ 覆盖会话数: ${p.sessionCount ?? '?'}
184
+
185
+ # 跨会话观察到的模式
186
+
187
+ ${patterns}
188
+
189
+ # 改进建议
190
+
191
+ 类别: ${a.category || '?'}
192
+ 严重度: ${a.severity || '?'}
193
+ 标题: ${a.title || '(无)'}
194
+
195
+ 现状: ${a.why || '(无)'}
196
+ 动作: ${a.action || '(无)'}
197
+ 依据: ${a.evidence || '(无)'}
198
+
199
+ # 实施原则
200
+
201
+ 1. 优先做**可复用的**改变(skill / 配置 / 文档),而不是某次具体业务代码。
202
+ 2. 如果建议是"建立 skill",在 \`.opencode/skills/\` 或 \`.claude/\` 之类的
203
+ 惯例位置下创建合适文件;不存在的目录请自行创建。
204
+ 3. 不要为了执行这条建议去修改正在跑的应用功能代码,除非建议明确要求。
205
+ 4. 不要 push / commit;只修改工作区文件。
206
+
207
+ # 报告格式
208
+
209
+ 完成后(无论成功失败),请在你的最后一条消息里输出一段严格 JSON:
210
+
211
+ {
212
+ "ok": true | false,
213
+ "summary": "≤80 字 总结你做了什么(或为什么没做)",
214
+ "files_changed": ["相对路径", ...],
215
+ "notes": "可选,任何补充"
216
+ }
217
+
218
+ 如果你判断这条建议不应该执行(过时、与项目现状冲突、需要更多信息),
219
+ 直接返回 ok=false 并在 notes 里说明,不要硬改。`;
220
+ }
221
+
222
+ module.exports = {
223
+ EXECUTION_SENTINEL,
224
+ EXECUTION_PROMPT_VERSION,
225
+ buildExecutionPrompt,
226
+ buildProjectExecutionPrompt,
227
+ };
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Long-running CLI runner for advice execution.
3
+ *
4
+ * Spawns `opencode run` or `claude -p` in a given cwd, feeds the prompt
5
+ * via stdin, streams stdout/stderr, and resolves with a result blob.
6
+ * NEVER throws on normal failures (no-cli / timeout / spawn-error / non-zero
7
+ * exit) — the caller (server/execution/job.js) just inspects `ok`.
8
+ *
9
+ * Why NOT cli-runner.js / runJudge:
10
+ * • runJudge is contracted for short (<30s) JSON-strict calls with a
11
+ * 2-wide global semaphore — perfect for E1/O1/advice but wrong here.
12
+ * • execution is open-ended (minutes), can produce megabytes, and uses
13
+ * a 1-wide global lock managed by job.js (not by runner).
14
+ *
15
+ * Spec: docs/superpowers/specs/2026-06-13-advice-execution-design.md §7
16
+ *
17
+ * @author Felix
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const { spawn } = require('child_process');
23
+
24
+ // ---------------------------------------------------------------------------
25
+ // Defaults
26
+ // ---------------------------------------------------------------------------
27
+
28
+ const DEFAULT_TIMEOUT_MS = 10 * 60_000; // 10 minutes
29
+ const DEFAULT_MAX_BYTES = 256 * 1024; // 256 KB per stream
30
+
31
+ const CLI_TO_ARGV = {
32
+ opencode: () => ['run'],
33
+ claude: () => ['-p'],
34
+ };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // Best-effort CLI detection
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Try `bin --version`; resolves true on exit code 0 within 5 s.
42
+ * Caches per-binary forever (PATH changes during a single aboss process
43
+ * are rare).
44
+ */
45
+ const _detectCache = new Map();
46
+
47
+ function canSpawn(bin) {
48
+ if (_detectCache.has(bin)) return _detectCache.get(bin);
49
+ const p = new Promise((resolve) => {
50
+ let done = false;
51
+ const settle = (v) => { if (!done) { done = true; resolve(v); } };
52
+ try {
53
+ const proc = spawn(bin, ['--version'], {
54
+ stdio: 'ignore',
55
+ shell: process.platform === 'win32',
56
+ });
57
+ proc.on('error', () => settle(false));
58
+ proc.on('exit', (code) => settle(code === 0));
59
+ setTimeout(() => { try { proc.kill('SIGKILL'); } catch {} settle(false); }, 5000);
60
+ } catch { settle(false); }
61
+ });
62
+ _detectCache.set(bin, p);
63
+ return p;
64
+ }
65
+
66
+ /** Public: clear detection cache (tests / settings reload). */
67
+ function _resetDetectCache() { _detectCache.clear(); }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Core runner
71
+ // ---------------------------------------------------------------------------
72
+
73
+ /**
74
+ * Run the chosen executor in `cwd`, feed `prompt` via stdin, collect output.
75
+ *
76
+ * @param {object} opts
77
+ * @param {'opencode'|'claude'} opts.executor
78
+ * @param {string} opts.prompt
79
+ * @param {string} opts.cwd absolute path, must exist
80
+ * @param {number} [opts.timeoutMs=600_000] SIGKILL after this many ms
81
+ * @param {number} [opts.maxBytes=262_144] truncate each of stdout/stderr
82
+ * @param {(cancel:Function)=>void} [opts.onSpawn] receives a cancel fn once
83
+ * the process is alive; calling
84
+ * it SIGKILLs the child and
85
+ * resolves with reason='cancelled'
86
+ *
87
+ * @returns {Promise<
88
+ * { ok: true, exitCode, stdout, stderr, durationMs, executor }
89
+ * | { ok: false, reason: 'no-cli'|'spawn-error'|'timeout'|'cancelled'|'exit-non-zero',
90
+ * exitCode, stdout, stderr, durationMs, executor, error }
91
+ * >}
92
+ */
93
+ async function runExecutor(opts) {
94
+ const {
95
+ executor,
96
+ prompt,
97
+ cwd,
98
+ timeoutMs = DEFAULT_TIMEOUT_MS,
99
+ maxBytes = DEFAULT_MAX_BYTES,
100
+ onSpawn,
101
+ } = opts;
102
+
103
+ if (!CLI_TO_ARGV[executor]) {
104
+ return _fail('spawn-error', 0, '', '', 0, executor, `unknown executor: ${executor}`);
105
+ }
106
+ if (!prompt || typeof prompt !== 'string') {
107
+ return _fail('spawn-error', 0, '', '', 0, executor, 'empty prompt');
108
+ }
109
+
110
+ const available = await canSpawn(executor);
111
+ if (!available) {
112
+ return _fail('no-cli', 0, '', '', 0, executor, `${executor} not on PATH`);
113
+ }
114
+
115
+ return new Promise((resolve) => {
116
+ let done = false;
117
+ const start = Date.now();
118
+ const settle = (v) => { if (!done) { done = true; clearTimeout(timer); resolve(v); } };
119
+
120
+ let proc;
121
+ try {
122
+ proc = spawn(executor, CLI_TO_ARGV[executor](), {
123
+ cwd,
124
+ stdio: ['pipe', 'pipe', 'pipe'],
125
+ shell: process.platform === 'win32',
126
+ windowsHide: true,
127
+ });
128
+ } catch (err) {
129
+ return settle(_fail('spawn-error', 0, '', '', Date.now() - start, executor, err.message));
130
+ }
131
+
132
+ // Expose a cancel handle for the job layer.
133
+ let cancelled = false;
134
+ if (typeof onSpawn === 'function') {
135
+ onSpawn(() => {
136
+ cancelled = true;
137
+ try { proc.kill('SIGKILL'); } catch {}
138
+ });
139
+ }
140
+
141
+ let stdout = '';
142
+ let stderr = '';
143
+ let stdoutTrunc = false;
144
+ let stderrTrunc = false;
145
+ let _timedOut = false;
146
+
147
+ proc.stdout.on('data', (chunk) => {
148
+ if (stdoutTrunc) return;
149
+ stdout += chunk.toString('utf8');
150
+ if (stdout.length > maxBytes) {
151
+ stdout = stdout.slice(0, maxBytes) + `\n[…truncated at ${maxBytes} B…]`;
152
+ stdoutTrunc = true;
153
+ // Don't kill here — let stderr still flow. Kill only if BOTH overflow.
154
+ if (stderrTrunc) try { proc.kill('SIGKILL'); } catch {}
155
+ }
156
+ });
157
+ proc.stderr.on('data', (chunk) => {
158
+ if (stderrTrunc) return;
159
+ stderr += chunk.toString('utf8');
160
+ if (stderr.length > maxBytes) {
161
+ stderr = stderr.slice(0, maxBytes) + `\n[…truncated at ${maxBytes} B…]`;
162
+ stderrTrunc = true;
163
+ if (stdoutTrunc) try { proc.kill('SIGKILL'); } catch {}
164
+ }
165
+ });
166
+
167
+ proc.on('error', (err) => settle(
168
+ _fail('spawn-error', 0, stdout, stderr, Date.now() - start, executor, err.message)
169
+ ));
170
+
171
+ proc.on('exit', (code, signal) => {
172
+ const durationMs = Date.now() - start;
173
+ if (cancelled) {
174
+ return settle(_fail('cancelled', code ?? 0, stdout, stderr, durationMs, executor,
175
+ signal ? `killed by ${signal}` : 'cancelled'));
176
+ }
177
+ if (signal === 'SIGKILL' && _timedOut) {
178
+ return settle(_fail('timeout', code ?? 0, stdout, stderr, durationMs, executor,
179
+ `killed after ${timeoutMs}ms`));
180
+ }
181
+ if (code !== 0) {
182
+ return settle(_fail('exit-non-zero', code, stdout, stderr, durationMs, executor,
183
+ `exit ${code}`));
184
+ }
185
+ settle({
186
+ ok: true, exitCode: code, stdout, stderr, durationMs, executor,
187
+ });
188
+ });
189
+
190
+ // Pipe prompt via stdin then close — opencode/claude both read from
191
+ // stdin when no positional prompt is given after run/-p.
192
+ try {
193
+ proc.stdin.on('error', () => {}); // EPIPE if CLI exits before we're done
194
+ proc.stdin.end(prompt, 'utf8');
195
+ } catch {
196
+ // Already handled by 'error' event.
197
+ }
198
+
199
+ // Timeout (declared above, set here so the close-handler distinguishes
200
+ // a SIGKILL from a 10-min wall-clock kill versus user cancel).
201
+ const timer = setTimeout(() => {
202
+ _timedOut = true;
203
+ try { proc.kill('SIGKILL'); } catch {}
204
+ }, timeoutMs);
205
+ });
206
+ }
207
+
208
+ function _fail(reason, exitCode, stdout, stderr, durationMs, executor, error) {
209
+ return { ok: false, reason, exitCode, stdout, stderr, durationMs, executor, error };
210
+ }
211
+
212
+ module.exports = {
213
+ runExecutor,
214
+ canSpawn,
215
+ _resetDetectCache,
216
+ DEFAULT_TIMEOUT_MS,
217
+ DEFAULT_MAX_BYTES,
218
+ };
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Express server for Agent Boss.
3
+ * Serves REST API and static frontend files.
4
+ *
5
+ * @author Felix
6
+ */
7
+
8
+ const path = require('path');
9
+ const express = require('express');
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Route factories
13
+ // ---------------------------------------------------------------------------
14
+
15
+ const createReportsRouter = require('./api/reports');
16
+ const createMetricsRouter = require('./api/metrics');
17
+ const createSessionsRouter = require('./api/sessions');
18
+ const createSettingsRouter = require('./api/settings');
19
+ const createAnalysisRouter = require('./api/analysis');
20
+ const createOverviewRouter = require('./api/overview');
21
+ const createAdviceRouter = require('./api/advice');
22
+ const createProjectRouter = require('./api/project');
23
+ const createExecutionRouter = require('./api/execution');
24
+
25
+ const { cleanupOrphans } = require('./execution/job');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Server factory
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Create and configure the Express application.
33
+ *
34
+ * @param {object} db sql.js Database instance (boss.db)
35
+ * @returns {import('express').Express}
36
+ */
37
+ function createServer(db) {
38
+ // Mark any execution_run rows left in 'pending' / 'running' by a crashed
39
+ // previous process as failed. Has to happen before any API mounts so a
40
+ // user opening the page right after restart doesn't see a stale "still
41
+ // running" badge. See server/execution/job.js::cleanupOrphans.
42
+ const orphans = cleanupOrphans(db);
43
+ if (orphans > 0) {
44
+ console.log(`[execution] cleaned ${orphans} orphan run(s) from previous process`);
45
+ }
46
+
47
+ const app = express();
48
+
49
+ // --- Middleware -----------------------------------------------------------
50
+ app.use(express.json({ limit: '512kb' }));
51
+
52
+ // --- API routes -----------------------------------------------------------
53
+ app.use('/api/overview', createOverviewRouter(db));
54
+ app.use('/api/reports', createReportsRouter(db));
55
+ app.use('/api/metrics', createMetricsRouter(db));
56
+ app.use('/api/sessions', createSessionsRouter(db));
57
+ app.use('/api/settings', createSettingsRouter(db));
58
+ app.use('/api/analysis', createAnalysisRouter(db));
59
+ app.use('/api/advice', createAdviceRouter(db));
60
+ app.use('/api/project', createProjectRouter(db));
61
+ app.use('/api/execution', createExecutionRouter(db));
62
+
63
+ // --- Static files ---------------------------------------------------------
64
+ const clientDist = path.join(__dirname, '..', 'client', 'dist');
65
+ app.use(express.static(clientDist));
66
+
67
+ // --- SPA fallback ---------------------------------------------------------
68
+ app.get('*', (_req, res) => {
69
+ res.sendFile(path.join(clientDist, 'index.html'));
70
+ });
71
+
72
+ return app;
73
+ }
74
+
75
+ /**
76
+ * Start the Express server on the given port.
77
+ *
78
+ * @param {object} db sql.js Database instance (boss.db)
79
+ * @param {number} port TCP port to listen on
80
+ * @returns {Promise<import('http').Server>}
81
+ */
82
+ function startServer(db, port) {
83
+ const app = createServer(db);
84
+
85
+ return new Promise((resolve, reject) => {
86
+ const server = app.listen(port, () => {
87
+ console.log(`Agent Boss server listening on http://localhost:${port}`);
88
+ resolve(server);
89
+ });
90
+ server.on('error', (err) => reject(err));
91
+ });
92
+ }
93
+
94
+ module.exports = { createServer, startServer };