bosun 0.41.7 → 0.41.9

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 (43) hide show
  1. package/README.md +23 -1
  2. package/agent/agent-event-bus.mjs +31 -2
  3. package/agent/agent-pool.mjs +251 -11
  4. package/agent/agent-prompts.mjs +5 -1
  5. package/agent/agent-supervisor.mjs +22 -0
  6. package/agent/primary-agent.mjs +115 -5
  7. package/cli.mjs +3 -2
  8. package/config/config.mjs +4 -1
  9. package/desktop/main.mjs +350 -25
  10. package/desktop/preload.cjs +8 -0
  11. package/desktop/preload.mjs +19 -0
  12. package/entrypoint.mjs +332 -0
  13. package/infra/health-status.mjs +72 -0
  14. package/infra/library-manager.mjs +58 -1
  15. package/infra/maintenance.mjs +1 -2
  16. package/infra/monitor.mjs +25 -7
  17. package/infra/session-tracker.mjs +30 -3
  18. package/package.json +10 -4
  19. package/server/bosun-mcp-server.mjs +1004 -0
  20. package/server/setup-web-server.mjs +287 -258
  21. package/server/ui-server.mjs +218 -23
  22. package/shell/claude-shell.mjs +14 -1
  23. package/shell/codex-model-profiles.mjs +166 -29
  24. package/shell/codex-shell.mjs +56 -18
  25. package/shell/opencode-providers.mjs +20 -8
  26. package/task/task-executor.mjs +28 -0
  27. package/task/task-store.mjs +13 -4
  28. package/tools/list-todos.mjs +7 -1
  29. package/ui/app.js +3 -2
  30. package/ui/components/agent-selector.js +127 -0
  31. package/ui/components/session-list.js +2 -0
  32. package/ui/demo-defaults.js +6 -6
  33. package/ui/modules/router.js +2 -0
  34. package/ui/modules/state.js +13 -5
  35. package/ui/tabs/chat.js +3 -0
  36. package/ui/tabs/library.js +284 -52
  37. package/ui/tabs/tasks.js +5 -13
  38. package/workflow/workflow-engine.mjs +16 -4
  39. package/workflow/workflow-nodes/definitions.mjs +37 -0
  40. package/workflow/workflow-nodes.mjs +489 -153
  41. package/workflow/workflow-templates.mjs +0 -5
  42. package/workflow-templates/github.mjs +106 -16
  43. package/workspace/worktree-manager.mjs +1 -1
@@ -94,4 +94,23 @@ contextBridge.exposeInMainWorld("veDesktop", {
94
94
  setScope: (id, isGlobal) =>
95
95
  ipcRenderer.invoke("bosun:shortcuts:setScope", { id, isGlobal }),
96
96
  },
97
+
98
+ /**
99
+ * Remote connection settings API.
100
+ * Available in the renderer via `window.veDesktop.connection.*`
101
+ */
102
+ connection: {
103
+ /** Get the current remote connection config + active status. */
104
+ get: () => ipcRenderer.invoke("bosun:connection:get"),
105
+
106
+ /** Save remote connection config. { enabled, endpoint, apiKey } */
107
+ set: (config) => ipcRenderer.invoke("bosun:connection:set", config),
108
+
109
+ /** Test connectivity to a remote endpoint. { endpoint, apiKey } */
110
+ test: (endpoint, apiKey) =>
111
+ ipcRenderer.invoke("bosun:connection:test", { endpoint, apiKey }),
112
+
113
+ /** Open the interactive remote connection setup dialog. */
114
+ setup: () => ipcRenderer.invoke("bosun:connection:setup"),
115
+ },
97
116
  });
package/entrypoint.mjs ADDED
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * entrypoint.mjs — Unified Bosun entrypoint for Docker, Desktop, and CLI.
5
+ *
6
+ * This is the single process owner for all Bosun child processes.
7
+ * It starts the unified UI server (which includes setup wizard if needed),
8
+ * spawns the monitor loop as a managed child, handles SIGTERM/SIGINT
9
+ * gracefully, and provides a /healthz endpoint.
10
+ *
11
+ * Environment detection:
12
+ * BOSUN_DOCKER=1 → container mode (bind 0.0.0.0, logs to stdout, tini as PID 1)
13
+ * BOSUN_DESKTOP=1 → Electron mode (bind 127.0.0.1, auto-open browser)
14
+ * (neither) → bare CLI mode
15
+ *
16
+ * Usage:
17
+ * node entrypoint.mjs # default start
18
+ * BOSUN_DOCKER=1 node entrypoint.mjs # Docker container
19
+ * BOSUN_DESKTOP=1 node entrypoint.mjs # Desktop app
20
+ */
21
+
22
+ import { spawn } from "node:child_process";
23
+ import { existsSync, mkdirSync } from "node:fs";
24
+ import { resolve, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import {
27
+ getHealthStatus,
28
+ markSetupComplete as _markSetupComplete,
29
+ setComponentStatus,
30
+ setShuttingDown,
31
+ setMonitorCircuitBroken,
32
+ setMode,
33
+ } from "./infra/health-status.mjs";
34
+
35
+ const __filename = fileURLToPath(import.meta.url);
36
+ const __dirname = dirname(__filename);
37
+
38
+ const TAG = "[entrypoint]";
39
+ const isDocker = process.env.BOSUN_DOCKER === "1";
40
+ const isDesktop = process.env.BOSUN_DESKTOP === "1";
41
+
42
+ // Synchronise mode flags with the shared health module
43
+ setMode({ docker: isDocker, desktop: isDesktop });
44
+
45
+ // ── Data directory resolution ───────────────────────────────────────────────
46
+ // In Docker: /data (volume mount)
47
+ // Otherwise: respect BOSUN_HOME / BOSUN_DIR, or let config.mjs resolve
48
+ function resolveDataDir() {
49
+ if (isDocker) {
50
+ const dataDir = process.env.BOSUN_HOME || "/data";
51
+ process.env.BOSUN_HOME = dataDir;
52
+ process.env.BOSUN_DIR = dataDir;
53
+ return dataDir;
54
+ }
55
+ return process.env.BOSUN_HOME || process.env.BOSUN_DIR || "";
56
+ }
57
+
58
+ // ── Child process management ────────────────────────────────────────────────
59
+
60
+ /** @type {Map<string, import("node:child_process").ChildProcess>} */
61
+ const children = new Map();
62
+
63
+ /** @type {boolean} */
64
+ let shuttingDown = false;
65
+
66
+ /** @type {boolean} */
67
+ let setupComplete = false;
68
+
69
+ // Track monitor crash history for circuit breaker
70
+ const monitorCrashTimestamps = [];
71
+ const MONITOR_CIRCUIT_BREAKER_WINDOW_MS = 60_000;
72
+ const MONITOR_CIRCUIT_BREAKER_MAX_CRASHES = 3;
73
+ let monitorCircuitBroken = false;
74
+
75
+ /**
76
+ * Spawn a managed child process.
77
+ * @param {string} name - Human label for logs
78
+ * @param {string} script - Path to the script to run
79
+ * @param {string[]} args - Arguments
80
+ * @param {object} [opts] - spawn options overrides
81
+ * @returns {import("node:child_process").ChildProcess}
82
+ */
83
+ function spawnChild(name, script, args = [], opts = {}) {
84
+ if (shuttingDown) return null;
85
+
86
+ const child = spawn(process.execPath, [script, ...args], {
87
+ cwd: opts.cwd || __dirname,
88
+ stdio: ["ignore", "pipe", "pipe"],
89
+ env: { ...process.env, ...opts.env },
90
+ });
91
+
92
+ children.set(name, child);
93
+ setComponentStatus(name, "running");
94
+
95
+ child.stdout?.on("data", (chunk) => {
96
+ process.stdout.write(chunk);
97
+ });
98
+ child.stderr?.on("data", (chunk) => {
99
+ process.stderr.write(chunk);
100
+ });
101
+
102
+ child.on("exit", (code, signal) => {
103
+ children.delete(name);
104
+ setComponentStatus(name, "stopped");
105
+
106
+ if (shuttingDown) {
107
+ console.log(`${TAG} ${name} exited (shutdown)`);
108
+ return;
109
+ }
110
+
111
+ console.warn(`${TAG} ${name} exited (code=${code} signal=${signal})`);
112
+
113
+ // Self-restart exit code (75) means monitor wants a clean re-fork
114
+ if (name === "monitor" && code === 75) {
115
+ console.log(`${TAG} monitor requested restart (exit code 75)`);
116
+ startMonitor();
117
+ return;
118
+ }
119
+
120
+ // Circuit breaker for monitor crashes
121
+ if (name === "monitor") {
122
+ const now = Date.now();
123
+ monitorCrashTimestamps.push(now);
124
+ // Trim old entries outside window
125
+ while (
126
+ monitorCrashTimestamps.length > 0 &&
127
+ now - monitorCrashTimestamps[0] > MONITOR_CIRCUIT_BREAKER_WINDOW_MS
128
+ ) {
129
+ monitorCrashTimestamps.shift();
130
+ }
131
+ if (monitorCrashTimestamps.length >= MONITOR_CIRCUIT_BREAKER_MAX_CRASHES) {
132
+ monitorCircuitBroken = true;
133
+ setMonitorCircuitBroken(true);
134
+ console.error(
135
+ `${TAG} monitor circuit breaker tripped: ${monitorCrashTimestamps.length} crashes in ${MONITOR_CIRCUIT_BREAKER_WINDOW_MS / 1000}s — stopping restarts`,
136
+ );
137
+ return;
138
+ }
139
+
140
+ // Restart with backoff
141
+ const delay = Math.min(5000 * monitorCrashTimestamps.length, 30_000);
142
+ console.log(`${TAG} restarting monitor in ${delay}ms`);
143
+ setTimeout(() => startMonitor(), delay);
144
+ }
145
+ });
146
+
147
+ child.on("error", (err) => {
148
+ console.error(`${TAG} ${name} spawn error: ${err.message}`);
149
+ children.delete(name);
150
+ setComponentStatus(name, "error");
151
+ });
152
+
153
+ return child;
154
+ }
155
+
156
+ // ── Monitor lifecycle ───────────────────────────────────────────────────────
157
+
158
+ function startMonitor() {
159
+ if (shuttingDown || monitorCircuitBroken) return;
160
+ if (children.has("monitor")) return;
161
+
162
+ const monitorScript = resolve(__dirname, "infra", "monitor.mjs");
163
+ if (!existsSync(monitorScript)) {
164
+ console.error(`${TAG} monitor script not found: ${monitorScript}`);
165
+ setComponentStatus("monitor", "error");
166
+ return;
167
+ }
168
+
169
+ const monitorArgs = [];
170
+ // Pass through relevant CLI args
171
+ if (process.env.BOSUN_DIR) {
172
+ monitorArgs.push("--config-dir", process.env.BOSUN_DIR);
173
+ }
174
+
175
+ console.log(`${TAG} starting monitor`);
176
+ spawnChild("monitor", monitorScript, monitorArgs);
177
+ }
178
+
179
+ // ── Graceful shutdown ───────────────────────────────────────────────────────
180
+
181
+ const SHUTDOWN_TIMEOUT_MS = 30_000;
182
+
183
+ async function shutdown(signal) {
184
+ if (shuttingDown) return;
185
+ shuttingDown = true;
186
+ setShuttingDown(true);
187
+ console.log(`${TAG} ${signal} received — shutting down`);
188
+
189
+ // Send SIGTERM to all children
190
+ for (const [name, child] of children.entries()) {
191
+ console.log(`${TAG} sending SIGTERM to ${name} (pid=${child.pid})`);
192
+ try {
193
+ child.kill("SIGTERM");
194
+ } catch {
195
+ /* best effort */
196
+ }
197
+ }
198
+
199
+ // Wait for children to exit, with a hard timeout
200
+ const deadline = Date.now() + SHUTDOWN_TIMEOUT_MS;
201
+ while (children.size > 0 && Date.now() < deadline) {
202
+ await new Promise((r) => setTimeout(r, 500));
203
+ }
204
+
205
+ // SIGKILL any stragglers
206
+ for (const [name, child] of children.entries()) {
207
+ console.warn(`${TAG} force-killing ${name} (pid=${child.pid})`);
208
+ try {
209
+ child.kill("SIGKILL");
210
+ } catch {
211
+ /* best effort */
212
+ }
213
+ }
214
+
215
+ process.exit(0);
216
+ }
217
+
218
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
219
+ process.on("SIGINT", () => shutdown("SIGINT"));
220
+
221
+ // Windows: ensure cleanup on exit
222
+ process.on("exit", () => {
223
+ for (const [, child] of children.entries()) {
224
+ try {
225
+ child.kill("SIGTERM");
226
+ } catch {
227
+ /* best effort */
228
+ }
229
+ }
230
+ });
231
+
232
+ // ── Health endpoint ─────────────────────────────────────────────────────────
233
+ // Re-export from the side-effect-free health module so existing callers
234
+ // that import from entrypoint.mjs keep working.
235
+ export { getHealthStatus } from "./infra/health-status.mjs";
236
+
237
+ /**
238
+ * Mark setup as complete (called from ui-server when setup finishes).
239
+ */
240
+ export function markSetupComplete() {
241
+ const wasSetupComplete = setupComplete;
242
+ setupComplete = true;
243
+ _markSetupComplete();
244
+
245
+ // If we just transitioned from "not complete" to "complete", and the process
246
+ // is still running, ensure the monitor is started when not already running.
247
+ if (!wasSetupComplete && !shuttingDown && !children.has("monitor")) {
248
+ try {
249
+ startMonitor();
250
+ } catch (err) {
251
+ // Log but do not crash if automatic monitor start fails
252
+ console.error(
253
+ `${TAG} failed to start monitor after setup completion: ${
254
+ err && err.message ? err.message : err
255
+ }`,
256
+ );
257
+ }
258
+ }
259
+ }
260
+
261
+ // ── Main startup ────────────────────────────────────────────────────────────
262
+
263
+ async function main() {
264
+ const dataDir = resolveDataDir();
265
+
266
+ console.log(`${TAG} starting bosun`);
267
+ console.log(`${TAG} mode: ${isDocker ? "docker" : isDesktop ? "desktop" : "cli"}`);
268
+ if (dataDir) {
269
+ console.log(`${TAG} data dir: ${dataDir}`);
270
+ // Ensure data directory exists
271
+ mkdirSync(dataDir, { recursive: true });
272
+ }
273
+
274
+ // ── Check if setup is needed ──────────────────────────────────────────
275
+ try {
276
+ const { shouldRunSetup } = await import("./setup.mjs");
277
+ setupComplete = !shouldRunSetup();
278
+ if (setupComplete) _markSetupComplete();
279
+ } catch {
280
+ // If setup.mjs fails to load, assume setup is needed
281
+ setupComplete = false;
282
+ }
283
+
284
+ // ── Start the unified UI server ───────────────────────────────────────
285
+ // The ui-server now handles both /setup (wizard) and / (portal)
286
+ try {
287
+ const { startTelegramUiServer } = await import("./server/ui-server.mjs");
288
+
289
+ const host = isDocker ? "0.0.0.0" : isDesktop ? "127.0.0.1" : undefined;
290
+ const port = Number(process.env.BOSUN_PORT || process.env.PORT || process.env.TELEGRAM_UI_PORT || "") || 3080;
291
+
292
+ const server = await startTelegramUiServer({
293
+ host,
294
+ port,
295
+ // Pass setup state so ui-server knows whether to redirect to /setup
296
+ setupMode: !setupComplete,
297
+ });
298
+
299
+ if (server) {
300
+ setComponentStatus("server", "running");
301
+ console.log(`${TAG} ui server started`);
302
+ } else {
303
+ console.warn(`${TAG} ui server returned null — may be a duplicate instance`);
304
+ setComponentStatus("server", "error");
305
+ }
306
+ } catch (err) {
307
+ console.error(`${TAG} failed to start ui server: ${err.message}`);
308
+ setComponentStatus("server", "error");
309
+ }
310
+
311
+ // ── Start monitor (only if setup is complete) ─────────────────────────
312
+ if (setupComplete) {
313
+ startMonitor();
314
+ } else {
315
+ console.log(`${TAG} setup not complete — monitor will start after setup finishes`);
316
+ }
317
+
318
+ // In Docker mode, log to stdout that we're ready
319
+ if (isDocker) {
320
+ console.log(`${TAG} container ready`);
321
+ }
322
+ }
323
+
324
+ // ── Entry ───────────────────────────────────────────────────────────────────
325
+
326
+ if (process.argv[1] === __filename) {
327
+ main().catch((err) => {
328
+ console.error(`${TAG} fatal: ${err.message}`);
329
+ console.error(err.stack);
330
+ process.exit(1);
331
+ });
332
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * health-status.mjs — Side-effect-free module for Bosun health reporting.
3
+ *
4
+ * This module is safe to import from any context (ui-server, entrypoint, tests)
5
+ * without triggering process spawning or other startup side effects.
6
+ */
7
+
8
+ const TAG = "[health-status]";
9
+
10
+ /** @type {{ monitor: string, server: string }} */
11
+ const componentStatus = {
12
+ monitor: "stopped",
13
+ server: "stopped",
14
+ };
15
+
16
+ let shuttingDown = false;
17
+ let setupComplete = false;
18
+ let monitorCircuitBroken = false;
19
+ const startedAt = Date.now();
20
+ let isDocker = process.env.BOSUN_DOCKER === "1";
21
+ let isDesktop = process.env.BOSUN_DESKTOP === "1";
22
+
23
+ /**
24
+ * Returns current health status object.
25
+ * @returns {object}
26
+ */
27
+ export function getHealthStatus() {
28
+ return {
29
+ status: shuttingDown ? "shutting_down" : monitorCircuitBroken ? "degraded" : "ok",
30
+ setup: setupComplete,
31
+ monitor: componentStatus.monitor,
32
+ server: componentStatus.server,
33
+ uptime: Math.floor((Date.now() - startedAt) / 1000),
34
+ docker: isDocker,
35
+ desktop: isDesktop,
36
+ };
37
+ }
38
+
39
+ /**
40
+ * Update a named component's status.
41
+ * @param {string} name
42
+ * @param {string} status
43
+ */
44
+ export function setComponentStatus(name, status) {
45
+ componentStatus[name] = status;
46
+ }
47
+
48
+ /** Mark setup as complete. */
49
+ export function markSetupComplete() {
50
+ setupComplete = true;
51
+ }
52
+
53
+ /** @returns {boolean} */
54
+ export function isSetupComplete() {
55
+ return setupComplete;
56
+ }
57
+
58
+ /** Mark the process as shutting down. */
59
+ export function setShuttingDown(value = true) {
60
+ shuttingDown = value;
61
+ }
62
+
63
+ /** Mark the monitor circuit breaker as tripped. */
64
+ export function setMonitorCircuitBroken(value = true) {
65
+ monitorCircuitBroken = value;
66
+ }
67
+
68
+ /** Allow callers (entrypoint) to override mode flags after env detection. */
69
+ export function setMode({ docker, desktop } = {}) {
70
+ if (docker !== undefined) isDocker = Boolean(docker);
71
+ if (desktop !== undefined) isDesktop = Boolean(desktop);
72
+ }
@@ -200,6 +200,58 @@ const FRAMEWORK_DOMAIN_MAP = Object.freeze({
200
200
 
201
201
  /** Resource types managed by the library */
202
202
  export const RESOURCE_TYPES = Object.freeze(["prompt", "agent", "skill", "mcp", "custom-tool"]);
203
+ export const AGENT_LIBRARY_CATEGORIES = Object.freeze(["task", "interactive", "voice"]);
204
+ export const INTERACTIVE_AGENT_MODES = Object.freeze(["ask", "agent", "plan", "web", "instant", "custom", "voice"]);
205
+
206
+ export function normalizeAgentProfileType(rawType, options = {}) {
207
+ const value = String(rawType || "").trim().toLowerCase();
208
+ if (value === "voice" || value === "task" || value === "chat") return value;
209
+ if (options?.voiceAgent === true) return "voice";
210
+ return "task";
211
+ }
212
+
213
+ export function normalizeAgentLibraryCategory(rawCategory, options = {}) {
214
+ const value = String(rawCategory || "").trim().toLowerCase();
215
+ if (AGENT_LIBRARY_CATEGORIES.includes(value)) return value;
216
+ const profileType = normalizeAgentProfileType(options?.agentType, { voiceAgent: options?.voiceAgent });
217
+ if (profileType === "voice") return "voice";
218
+ if (profileType === "chat") return "interactive";
219
+ return "task";
220
+ }
221
+
222
+ export function normalizeInteractiveAgentMode(rawMode, options = {}) {
223
+ const value = String(rawMode || "").trim().toLowerCase();
224
+ if (INTERACTIVE_AGENT_MODES.includes(value)) return value;
225
+ if (options?.agentCategory === "voice") return "voice";
226
+ if (options?.agentCategory === "interactive") return "agent";
227
+ return "";
228
+ }
229
+
230
+ export function resolveAgentProfileLibraryMetadata(entry, profile = {}) {
231
+ const agentType = normalizeAgentProfileType(profile?.agentType, {
232
+ voiceAgent: profile?.voiceAgent === true,
233
+ });
234
+ const agentCategory = normalizeAgentLibraryCategory(profile?.agentCategory, {
235
+ agentType,
236
+ voiceAgent: profile?.voiceAgent === true,
237
+ });
238
+ const interactiveMode = normalizeInteractiveAgentMode(
239
+ profile?.interactiveMode || profile?.chatMode,
240
+ { agentCategory },
241
+ );
242
+ const interactiveLabel = String(profile?.interactiveLabel || "").trim();
243
+ const explicitDropdown = profile?.showInChatDropdown;
244
+ const showInChatDropdown = agentCategory === "interactive"
245
+ ? explicitDropdown === true
246
+ : false;
247
+ return {
248
+ agentType,
249
+ agentCategory,
250
+ interactiveMode: interactiveMode || null,
251
+ interactiveLabel: interactiveLabel || null,
252
+ showInChatDropdown,
253
+ };
254
+ }
203
255
 
204
256
  // ── Helpers ───────────────────────────────────────────────────────────────────
205
257
 
@@ -497,10 +549,11 @@ function updateSkillEntryIndexCache(rootDir, index, manifestMtimeMs = 0) {
497
549
  function buildIndexedAgentProfile(rootDir, entry) {
498
550
  const profile = getEntryContent(rootDir, entry);
499
551
  if (!profile || typeof profile !== "object") return null;
552
+ const metadata = resolveAgentProfileLibraryMetadata(entry, profile);
500
553
  return {
501
554
  ...entry,
502
555
  profile,
503
- agentType: String(profile?.agentType || "task").trim().toLowerCase() || "task",
556
+ ...metadata,
504
557
  titlePatterns: toStringArray(profile?.titlePatterns),
505
558
  scopes: toStringArray(profile?.scopes),
506
559
  tags: uniqueStrings([...(entry?.tags || []), ...toStringArray(profile?.tags)]),
@@ -1129,6 +1182,10 @@ export function resolveLibraryPlan(rootDir, criteria = {}, opts = {}) {
1129
1182
  * @property {Object} [env] - extra env vars for the agent
1130
1183
  * @property {string[]} [enabledTools] - list of tool IDs enabled for this agent (null = all)
1131
1184
  * @property {string[]} [enabledMcpServers] - list of MCP server IDs enabled for this agent
1185
+ * @property {"task"|"interactive"|"voice"} [agentCategory] - library grouping/category for the profile
1186
+ * @property {"ask"|"agent"|"plan"|"web"|"instant"|"custom"|"voice"} [interactiveMode] - preferred manual interaction mode
1187
+ * @property {string} [interactiveLabel] - custom label shown for manual agent type/grouping
1188
+ * @property {boolean} [showInChatDropdown] - whether this interactive profile is shown in the chat manual-agent dropdown
1132
1189
  */
1133
1190
 
1134
1191
  /**
@@ -1355,7 +1355,7 @@ export async function runMaintenanceSweep(opts = {}) {
1355
1355
 
1356
1356
  // Guard against core.bare=true corruption that accumulates from worktree ops
1357
1357
  try {
1358
- const repoRoot = resolve(import.meta.dirname || ".", "..", "..");
1358
+ const repoRoot = resolve(import.meta.dirname || ".", "..");
1359
1359
  fixGitConfigCorruption(repoRoot);
1360
1360
  } catch {
1361
1361
  /* best-effort */
@@ -1389,4 +1389,3 @@ export async function runMaintenanceSweep(opts = {}) {
1389
1389
 
1390
1390
  return result;
1391
1391
  }
1392
-
package/infra/monitor.mjs CHANGED
@@ -510,17 +510,24 @@ async function ensureWorkflowAutomationEngine() {
510
510
  }
511
511
 
512
512
  const kanbanService = {
513
- createTask: async (taskData = {}) => {
513
+ createTask: async (projectIdOrTaskData = {}, taskDataArg = undefined) => {
514
+ const invokedWithProjectId = typeof projectIdOrTaskData === "string";
515
+ const payloadCandidate = invokedWithProjectId ? taskDataArg : projectIdOrTaskData;
516
+ const payload =
517
+ payloadCandidate && typeof payloadCandidate === "object" && !Array.isArray(payloadCandidate)
518
+ ? { ...payloadCandidate }
519
+ : {};
514
520
  const backend = getActiveKanbanBackend();
515
521
  const projectId = String(
516
- taskData?.projectId || getConfiguredKanbanProjectId(backend) || "",
522
+ (invokedWithProjectId ? projectIdOrTaskData : payload?.projectId) ||
523
+ getConfiguredKanbanProjectId(backend) ||
524
+ "",
517
525
  ).trim();
518
526
  if (!projectId) {
519
527
  throw new Error(
520
528
  `No project ID configured for backend=${backend} (required for workflow action.create_task)`,
521
529
  );
522
530
  }
523
- const payload = { ...(taskData || {}) };
524
531
  delete payload.projectId;
525
532
  return createTask(projectId, payload);
526
533
  },
@@ -1720,8 +1727,8 @@ configureExecutorTaskStatusTransitions();
1720
1727
  }
1721
1728
  }
1722
1729
 
1723
- // Guard against core.bare=true corruption on the main repo at startup
1724
- fixGitConfigCorruption(resolve(__dirname, "..", ".."));
1730
+ // Guard against core.bare=true corruption on the Bosun repo at startup.
1731
+ fixGitConfigCorruption(resolve(__dirname, ".."));
1725
1732
 
1726
1733
  function canSignalProcess(pid) {
1727
1734
  if (!Number.isFinite(pid) || pid <= 0) return false;
@@ -13284,8 +13291,15 @@ async function startProcess() {
13284
13291
  .catch((err) =>
13285
13292
  console.warn(
13286
13293
  `[workspace-monitor] failed to start for ${shortId}: ${err.message}`,
13287
- ),
13288
- );
13294
+ ),
13295
+ );
13296
+ }
13297
+ if (
13298
+ typeof engine.load === "function" &&
13299
+ ((Array.isArray(reconcile?.updatedWorkflowIds) && reconcile.updatedWorkflowIds.length > 0) ||
13300
+ Number(reconcile?.metadataUpdated || 0) > 0)
13301
+ ) {
13302
+ engine.load();
13289
13303
  }
13290
13304
  }
13291
13305
 
@@ -15509,6 +15523,8 @@ if (isExecutorDisabled()) {
15509
15523
  getTask: (taskId) => getInternalTask(taskId),
15510
15524
  setTaskStatus: (taskId, status, source) =>
15511
15525
  setInternalTaskStatus(taskId, status, source),
15526
+ updateTask: (taskId, updates) =>
15527
+ updateInternalTask(taskId, updates),
15512
15528
  // broadcastUiEvent is wired later when UI server starts via
15513
15529
  // injectUiDependencies → setBroadcastFn pattern
15514
15530
  });
@@ -15534,6 +15550,8 @@ if (isExecutorDisabled()) {
15534
15550
  getTask: (taskId) => getInternalTask(taskId),
15535
15551
  setTaskStatus: (taskId, status, source) =>
15536
15552
  setInternalTaskStatus(taskId, status, source),
15553
+ updateTask: (taskId, updates) =>
15554
+ updateInternalTask(taskId, updates),
15537
15555
  assessIntervalMs: 30_000,
15538
15556
  // ── Intervention callbacks (steering, thread management) ──
15539
15557
  forceNewThread: (taskId, reason) => {
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from "node:fs";
15
- import { resolve, dirname } from "node:path";
15
+ import { resolve, dirname, sep } from "node:path";
16
16
  import { randomBytes } from "node:crypto";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { buildSessionInsights } from "../lib/session-insights.mjs";
@@ -20,7 +20,20 @@ import { isTestRuntime } from "./test-runtime.mjs";
20
20
  import { addCompletedSession } from "./runtime-accumulator.mjs";
21
21
 
22
22
  const __dirname = dirname(fileURLToPath(import.meta.url));
23
- const SESSIONS_DIR = resolve(__dirname, "..", "logs", "sessions");
23
+ const WORKSPACE_MIRROR_MARKER = `${sep}.bosun${sep}workspaces${sep}`.toLowerCase();
24
+
25
+ function resolveSessionTrackerSourceRepoRoot(startDir = __dirname) {
26
+ const normalized = resolve(startDir);
27
+ const lower = normalized.toLowerCase();
28
+ const mirrorIndex = lower.indexOf(WORKSPACE_MIRROR_MARKER);
29
+ if (mirrorIndex >= 0) {
30
+ return normalized.slice(0, mirrorIndex);
31
+ }
32
+ return resolve(normalized, "..");
33
+ }
34
+
35
+ const SESSION_TRACKER_REPO_ROOT = resolveSessionTrackerSourceRepoRoot(__dirname);
36
+ const SESSIONS_DIR = resolve(SESSION_TRACKER_REPO_ROOT, "logs", "sessions");
24
37
 
25
38
  const TAG = "[session-tracker]";
26
39
 
@@ -56,6 +69,11 @@ function resolveSessionTrackerPersistDir(options = {}) {
56
69
  return isTestRuntime() ? null : SESSIONS_DIR;
57
70
  }
58
71
 
72
+ export const _test = Object.freeze({
73
+ resolveSessionTrackerSourceRepoRoot,
74
+ resolveSessionTrackerPersistDir,
75
+ });
76
+
59
77
  function resolveSessionMaxMessages(type, metadata, explicitMax, fallbackMax) {
60
78
  if (Number.isFinite(explicitMax)) {
61
79
  return explicitMax > 0 ? explicitMax : 0;
@@ -575,18 +593,27 @@ export class SessionTracker {
575
593
  listAllSessions() {
576
594
  const list = [];
577
595
  for (const s of this.#sessions.values()) {
596
+ const progress = s.status === "active"
597
+ ? this.getProgressStatus(s.id || s.taskId)
598
+ : null;
599
+ const derivedStatus = progress?.status === "ended"
600
+ ? "completed"
601
+ : (progress?.status || s.status);
578
602
  list.push({
579
603
  id: s.id || s.taskId,
580
604
  taskId: s.taskId,
581
605
  title: s.taskTitle || s.title || null,
582
606
  type: s.type || "task",
583
- status: s.status,
607
+ status: derivedStatus,
584
608
  workspaceId: String(s?.metadata?.workspaceId || "").trim() || null,
585
609
  workspaceDir: String(s?.metadata?.workspaceDir || "").trim() || null,
586
610
  branch: String(s?.metadata?.branch || "").trim() || null,
587
611
  turnCount: s.turnCount || 0,
588
612
  createdAt: s.createdAt || new Date(s.startedAt).toISOString(),
589
613
  lastActiveAt: s.lastActiveAt || new Date(s.lastActivityAt).toISOString(),
614
+ idleMs: progress?.idleMs ?? 0,
615
+ elapsedMs: progress?.elapsedMs ?? Math.max(0, Date.now() - Number(s.startedAt || Date.now())),
616
+ recommendation: progress?.recommendation || "none",
590
617
  preview: this.#lastMessagePreview(s),
591
618
  lastMessage: this.#lastMessagePreview(s),
592
619
  insights: s.insights || null,