bosun 0.41.8 → 0.41.10

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 (58) hide show
  1. package/.env.example +1 -1
  2. package/README.md +23 -1
  3. package/agent/agent-event-bus.mjs +31 -2
  4. package/agent/agent-pool.mjs +275 -40
  5. package/agent/agent-prompts.mjs +9 -1
  6. package/agent/agent-supervisor.mjs +22 -0
  7. package/agent/autofix.mjs +1 -1
  8. package/agent/primary-agent.mjs +115 -5
  9. package/cli.mjs +3 -2
  10. package/config/config.mjs +47 -33
  11. package/config/context-shredding-config.mjs +1 -1
  12. package/config/repo-root.mjs +41 -33
  13. package/desktop/main.mjs +350 -25
  14. package/desktop/preload.cjs +8 -0
  15. package/desktop/preload.mjs +19 -0
  16. package/entrypoint.mjs +332 -0
  17. package/git/sdk-conflict-resolver.mjs +1 -1
  18. package/infra/health-status.mjs +72 -0
  19. package/infra/library-manager.mjs +58 -1
  20. package/infra/maintenance.mjs +1 -2
  21. package/infra/monitor.mjs +26 -8
  22. package/infra/session-tracker.mjs +30 -3
  23. package/package.json +12 -4
  24. package/server/bosun-mcp-server.mjs +1004 -0
  25. package/server/setup-web-server.mjs +288 -259
  26. package/server/ui-server.mjs +1323 -26
  27. package/shell/claude-shell.mjs +14 -1
  28. package/shell/codex-config.mjs +1 -1
  29. package/shell/codex-model-profiles.mjs +170 -30
  30. package/shell/codex-shell.mjs +63 -18
  31. package/shell/opencode-providers.mjs +20 -8
  32. package/task/task-executor.mjs +28 -0
  33. package/task/task-store.mjs +13 -4
  34. package/telegram/telegram-sentinel.mjs +54 -3
  35. package/tools/list-todos.mjs +7 -1
  36. package/ui/app.js +3 -2
  37. package/ui/components/agent-selector.js +127 -0
  38. package/ui/components/session-list.js +15 -10
  39. package/ui/demo-defaults.js +334 -336
  40. package/ui/modules/router.js +2 -0
  41. package/ui/modules/state.js +13 -5
  42. package/ui/tabs/chat.js +3 -0
  43. package/ui/tabs/library.js +284 -52
  44. package/ui/tabs/tasks.js +5 -13
  45. package/ui/tabs/workflows.js +766 -3
  46. package/workflow/workflow-engine.mjs +246 -5
  47. package/workflow/workflow-nodes/definitions.mjs +37 -0
  48. package/workflow/workflow-nodes.mjs +1014 -184
  49. package/workflow/workflow-templates.mjs +0 -5
  50. package/workflow-templates/_helpers.mjs +253 -0
  51. package/workflow-templates/agents.mjs +199 -226
  52. package/workflow-templates/github.mjs +106 -16
  53. package/workflow-templates/sub-workflows.mjs +233 -0
  54. package/workflow-templates/task-execution.mjs +125 -471
  55. package/workflow-templates/task-lifecycle.mjs +11 -48
  56. package/workspace/command-diagnostics.mjs +460 -0
  57. package/workspace/context-cache.mjs +396 -28
  58. package/workspace/worktree-manager.mjs +1 -1
@@ -437,9 +437,22 @@ async function saveState() {
437
437
  }
438
438
 
439
439
  function extractTaskHeading(msg) {
440
- // Prompt first line is "# TASKID — Task Title" (from _buildTaskPrompt).
440
+ // Prompt first line is "# Task: Task Title" (or legacy "# TASKID — Task Title").
441
441
  // Return just the task title portion, or a short fallback.
442
442
  const firstLine = msg.split(/\r?\n/)[0].replace(/^#+\s*/, '').trim();
443
+ if (!firstLine) return 'Execute Task';
444
+ const lowerLine = firstLine.toLowerCase();
445
+ if (lowerLine.startsWith('task')) {
446
+ let index = 4;
447
+ while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
448
+ const separator = firstLine[index];
449
+ if (separator === ':' || separator === '-' || separator === '\u2014') {
450
+ index += 1;
451
+ while (index < firstLine.length && /\s/.test(firstLine[index])) index += 1;
452
+ const heading = firstLine.slice(index).trim();
453
+ if (heading) return heading;
454
+ }
455
+ }
443
456
  const dashIdx = firstLine.indexOf(' \u2014 ');
444
457
  const heading = dashIdx !== -1 ? firstLine.slice(dashIdx + 3).trim() : firstLine;
445
458
  return heading || 'Execute Task';
@@ -1345,7 +1345,7 @@ function ensureModelProviderSectionsFromEnv(toml, env = process.env) {
1345
1345
  try {
1346
1346
  const parsed = new URL(String(activeBaseUrl || ""));
1347
1347
  const host = String(parsed.hostname || "").toLowerCase();
1348
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
1348
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com");
1349
1349
  } catch {
1350
1350
  return false;
1351
1351
  }
@@ -9,6 +9,33 @@ function clean(value) {
9
9
  return String(value ?? "").trim();
10
10
  }
11
11
 
12
+ function isAzureOpenAIBaseUrl(value) {
13
+ try {
14
+ const parsed = value instanceof URL ? value : new URL(String(value || ""));
15
+ const host = String(parsed.hostname || "").toLowerCase();
16
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com");
17
+ } catch {
18
+ return false;
19
+ }
20
+ }
21
+
22
+ function normalizeAzureOpenAIBaseUrl(value) {
23
+ const raw = clean(value);
24
+ if (!raw) return "";
25
+ try {
26
+ const parsed = new URL(raw);
27
+ if (!isAzureOpenAIBaseUrl(parsed)) {
28
+ return raw;
29
+ }
30
+ parsed.pathname = "/openai/v1";
31
+ parsed.search = "";
32
+ parsed.hash = "";
33
+ return parsed.toString().replace(/\/+$/, "");
34
+ } catch {
35
+ return raw;
36
+ }
37
+ }
38
+
12
39
  function normalizeProfileName(value, fallback = DEFAULT_ACTIVE_PROFILE) {
13
40
  const raw = clean(value).toLowerCase();
14
41
  if (!raw) return fallback;
@@ -19,20 +46,6 @@ function profilePrefix(name) {
19
46
  return `CODEX_MODEL_PROFILE_${name.toUpperCase()}_`;
20
47
  }
21
48
 
22
- function inferGlobalProvider(env) {
23
- const baseUrl = clean(env.OPENAI_BASE_URL).toLowerCase();
24
- if ((() => {
25
- try {
26
- const parsed = new URL(baseUrl);
27
- const host = String(parsed.hostname || "").toLowerCase();
28
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com");
29
- } catch {
30
- return false;
31
- }
32
- })()) return "azure";
33
- return "openai";
34
- }
35
-
36
49
  function normalizeProvider(value, fallback) {
37
50
  const raw = clean(value).toLowerCase();
38
51
  if (!raw) return fallback;
@@ -48,6 +61,17 @@ function normalizeProvider(value, fallback) {
48
61
  return fallback;
49
62
  }
50
63
 
64
+ function hasEnvValue(env, key) {
65
+ return Boolean(key && clean(env?.[key]));
66
+ }
67
+
68
+ function inferProviderKindFromSection(name, section, fallback = "openai") {
69
+ if (isAzureOpenAIBaseUrl(section?.baseUrl)) return "azure";
70
+ const normalized = normalizeProvider(name, "");
71
+ if (normalized) return normalized;
72
+ return fallback;
73
+ }
74
+
51
75
  function readProfileField(env, profileName, field) {
52
76
  return clean(env[`${profilePrefix(profileName)}${field}`]);
53
77
  }
@@ -57,8 +81,9 @@ function profileRecord(env, profileName, globalProvider) {
57
81
  readProfileField(env, profileName, "PROVIDER"),
58
82
  globalProvider,
59
83
  );
84
+ const explicitModel = readProfileField(env, profileName, "MODEL");
60
85
  const model =
61
- readProfileField(env, profileName, "MODEL") ||
86
+ explicitModel ||
62
87
  (profileName === "xl" ? "gpt-5.3-codex" : profileName === "m" ? "gpt-5.1-codex-mini" : "");
63
88
  const baseUrl = readProfileField(env, profileName, "BASE_URL");
64
89
  const apiKey = readProfileField(env, profileName, "API_KEY");
@@ -68,6 +93,7 @@ function profileRecord(env, profileName, globalProvider) {
68
93
  name: profileName,
69
94
  provider,
70
95
  model,
96
+ modelIsExplicit: Boolean(explicitModel),
71
97
  baseUrl,
72
98
  apiKey,
73
99
  apiKeyEnv,
@@ -75,19 +101,101 @@ function profileRecord(env, profileName, globalProvider) {
75
101
  };
76
102
  }
77
103
 
78
- function readCodexConfigTopLevelModel() {
104
+ function readCodexConfigRuntimeDefaults() {
79
105
  try {
80
106
  const configPath = resolve(homedir(), ".codex", "config.toml");
81
- if (!existsSync(configPath)) return "";
107
+ if (!existsSync(configPath)) {
108
+ return { model: "", modelProvider: "", providers: {} };
109
+ }
82
110
  const content = readFileSync(configPath, "utf8");
83
111
  const head = content.split(/\n\[/)[0] || "";
84
- const match = head.match(/^\s*model\s*=\s*"([^"]+)"/m);
85
- return match ? match[1].trim() : "";
112
+ const modelMatch = head.match(/^\s*model\s*=\s*"([^"]+)"/m);
113
+ const modelProviderMatch = head.match(/^\s*model_provider\s*=\s*"([^"]+)"/m);
114
+ const providers = {};
115
+ const providerSectionRegex = /^\[model_providers\.([^\]]+)\]\s*([\s\S]*?)(?=^\[[^\]]+\]|$(?![\s\S]))/gm;
116
+ for (const match of content.matchAll(providerSectionRegex)) {
117
+ const [, rawName = "", body = ""] = match;
118
+ const name = clean(rawName);
119
+ if (!name) continue;
120
+ const baseUrlMatch = body.match(/^\s*base_url\s*=\s*"([^"]+)"/m);
121
+ const envKeyMatch = body.match(/^\s*env_key\s*=\s*"([^"]+)"/m);
122
+ providers[name] = {
123
+ name,
124
+ baseUrl: clean(baseUrlMatch?.[1]),
125
+ envKey: clean(envKeyMatch?.[1]),
126
+ };
127
+ }
128
+ return {
129
+ model: clean(modelMatch?.[1]),
130
+ modelProvider: clean(modelProviderMatch?.[1]),
131
+ providers,
132
+ };
86
133
  } catch {
87
- return "";
134
+ return { model: "", modelProvider: "", providers: {} };
88
135
  }
89
136
  }
90
137
 
138
+ function readCodexConfigTopLevelModel() {
139
+ return readCodexConfigRuntimeDefaults().model;
140
+ }
141
+
142
+ function selectConfigProviderForRuntime(configDefaults, env, preferredProvider = "") {
143
+ const providers = configDefaults?.providers || {};
144
+ const preferred = clean(preferredProvider).toLowerCase();
145
+ const entries = Object.values(providers).map((section) => ({
146
+ ...section,
147
+ provider: inferProviderKindFromSection(section.name, section, preferred || "openai"),
148
+ }));
149
+ const matchingEntries = preferred
150
+ ? entries.filter((section) => section.provider === preferred)
151
+ : entries;
152
+ const envBackedEntries = matchingEntries.filter(
153
+ (section) => !section.envKey || hasEnvValue(env, section.envKey),
154
+ );
155
+ const preferredNames = preferred === "azure"
156
+ ? ["azure"]
157
+ : preferred === "openai"
158
+ ? ["openai"]
159
+ : [];
160
+ const findNamed = (sections) => preferredNames
161
+ .map((name) => sections.find((section) => section.name === name))
162
+ .find(Boolean);
163
+
164
+ const configuredName = clean(configDefaults?.modelProvider);
165
+ if (configuredName) {
166
+ const configuredSection = providers[configuredName];
167
+ if (configuredSection) {
168
+ const configured = {
169
+ ...configuredSection,
170
+ provider: inferProviderKindFromSection(
171
+ configuredSection.name,
172
+ configuredSection,
173
+ preferred || "openai",
174
+ ),
175
+ };
176
+ const preferredMatches = !preferred || configured.provider === preferred;
177
+ if (preferredMatches && (!configured.envKey || hasEnvValue(env, configured.envKey))) {
178
+ return configured;
179
+ }
180
+ }
181
+ }
182
+
183
+ return (
184
+ findNamed(envBackedEntries) ||
185
+ envBackedEntries[0] ||
186
+ findNamed(matchingEntries) ||
187
+ matchingEntries[0] ||
188
+ null
189
+ );
190
+ }
191
+
192
+ function inferGlobalProvider(env, configDefaults = null) {
193
+ const baseUrl = clean(env.OPENAI_BASE_URL).toLowerCase();
194
+ if (isAzureOpenAIBaseUrl(baseUrl)) return "azure";
195
+ const configured = selectConfigProviderForRuntime(configDefaults, env);
196
+ return configured?.provider || "openai";
197
+ }
198
+
91
199
  /**
92
200
  * Resolve codex model/provider profile configuration from env vars.
93
201
  * Applies active profile values onto runtime env keys (`CODEX_MODEL`,
@@ -96,6 +204,7 @@ function readCodexConfigTopLevelModel() {
96
204
  */
97
205
  export function resolveCodexProfileRuntime(envInput = process.env) {
98
206
  const sourceEnv = { ...envInput };
207
+ const configDefaults = readCodexConfigRuntimeDefaults();
99
208
  const activeProfile = normalizeProfileName(
100
209
  sourceEnv.CODEX_MODEL_PROFILE,
101
210
  DEFAULT_ACTIVE_PROFILE,
@@ -105,7 +214,7 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
105
214
  DEFAULT_SUBAGENT_PROFILE,
106
215
  );
107
216
 
108
- const globalProvider = inferGlobalProvider(sourceEnv);
217
+ const globalProvider = inferGlobalProvider(sourceEnv, configDefaults);
109
218
  const active = profileRecord(sourceEnv, activeProfile, globalProvider);
110
219
  const sub = profileRecord(sourceEnv, subagentProfile, globalProvider);
111
220
 
@@ -116,24 +225,51 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
116
225
  if (active.model) {
117
226
  env.CODEX_MODEL = active.model;
118
227
  }
119
- if (active.baseUrl) {
120
- env.OPENAI_BASE_URL = active.baseUrl;
121
- }
122
228
 
123
229
  const profileApiKey = active.apiKey;
124
230
  const resolvedProvider = active.provider || globalProvider;
231
+ const configProvider = selectConfigProviderForRuntime(
232
+ configDefaults,
233
+ sourceEnv,
234
+ resolvedProvider,
235
+ );
236
+ const runtimeBaseUrl =
237
+ clean(active.baseUrl) ||
238
+ clean(env.OPENAI_BASE_URL) ||
239
+ clean(configProvider?.baseUrl);
240
+ if (runtimeBaseUrl) {
241
+ const normalizedBaseUrl =
242
+ resolvedProvider === "azure"
243
+ ? normalizeAzureOpenAIBaseUrl(runtimeBaseUrl)
244
+ : runtimeBaseUrl;
245
+ env.OPENAI_BASE_URL = normalizedBaseUrl;
246
+ active.baseUrl = normalizedBaseUrl;
247
+ }
248
+
249
+ if (!profileApiKey && configProvider?.envKey && hasEnvValue(sourceEnv, configProvider.envKey)) {
250
+ const configuredApiKey = clean(sourceEnv[configProvider.envKey]);
251
+ if (resolvedProvider === "azure") {
252
+ env.AZURE_OPENAI_API_KEY = configuredApiKey;
253
+ if (!env.OPENAI_API_KEY) {
254
+ env.OPENAI_API_KEY = configuredApiKey;
255
+ }
256
+ } else {
257
+ env.OPENAI_API_KEY = configuredApiKey;
258
+ }
259
+ }
125
260
 
126
261
  // Azure deployments often differ from default model names.
127
262
  // If the env is using Azure and the model is still the default,
128
263
  // prefer the top-level ~/.codex/config.toml model when present.
129
- const activeModelExplicit =
130
- Boolean(readProfileField(sourceEnv, activeProfile, "MODEL")) ||
131
- Boolean(clean(sourceEnv.CODEX_MODEL));
132
- if (
264
+ // The hardcoded profile fallback (e.g. "gpt-5.3-codex" for xl) should NOT
265
+ // block the config.toml override — only explicit env or profile fields should.
266
+ const runtimeModelExplicit = Boolean(clean(sourceEnv.CODEX_MODEL));
267
+ const shouldPreferAzureConfigModel =
133
268
  resolvedProvider === "azure" &&
134
269
  configModel &&
135
- (!activeModelExplicit || clean(env.CODEX_MODEL) === "gpt-5.3-codex")
136
- ) {
270
+ !active.modelIsExplicit &&
271
+ !runtimeModelExplicit;
272
+ if (shouldPreferAzureConfigModel) {
137
273
  env.CODEX_MODEL = configModel;
138
274
  active.model = configModel;
139
275
  }
@@ -173,5 +309,9 @@ export function resolveCodexProfileRuntime(envInput = process.env) {
173
309
  active,
174
310
  subagent: sub,
175
311
  provider: resolvedProvider,
312
+ /** The config.toml provider section selected for this runtime. */
313
+ configProvider: configProvider
314
+ ? { name: configProvider.name, envKey: configProvider.envKey, baseUrl: configProvider.baseUrl }
315
+ : null,
176
316
  };
177
317
  }
@@ -78,6 +78,63 @@ function normalizeTimeoutMs(value, { fallback = DEFAULT_TIMEOUT_MS, label = "tim
78
78
  return normalized;
79
79
  }
80
80
 
81
+ function isAzureOpenAIBaseUrl(value) {
82
+ try {
83
+ const parsed = new URL(String(value || ""));
84
+ const host = String(parsed.hostname || "").toLowerCase();
85
+ return host === "openai.azure.com" || host.endsWith(".openai.azure.com") || host.endsWith(".cognitiveservices.azure.com");
86
+ } catch {
87
+ return false;
88
+ }
89
+ }
90
+
91
+ function buildCodexSdkRuntime(streamProviderOverrides, envInput = process.env) {
92
+ const resolved = resolveCodexProfileRuntime(envInput);
93
+ const { env: resolvedEnv, configProvider } = resolved;
94
+ const baseUrl = resolvedEnv.OPENAI_BASE_URL || "";
95
+ const isAzure = isAzureOpenAIBaseUrl(baseUrl);
96
+ const env = { ...resolvedEnv };
97
+
98
+ delete env.OPENAI_BASE_URL;
99
+
100
+ // Use the config.toml provider section name and env_key when available,
101
+ // so Bosun's config override is consistent with the user's config.toml
102
+ // instead of hardcoding "azure" / "AZURE_OPENAI_API_KEY".
103
+ const providerSectionName = (isAzure && configProvider?.name) || (isAzure ? "azure" : "openai");
104
+ const providerEnvKey = (isAzure && configProvider?.envKey) || (isAzure ? "AZURE_OPENAI_API_KEY" : "OPENAI_API_KEY");
105
+
106
+ if (isAzure && env.OPENAI_API_KEY && !env.AZURE_OPENAI_API_KEY) {
107
+ env.AZURE_OPENAI_API_KEY = env.OPENAI_API_KEY;
108
+ }
109
+
110
+ const providerName = isAzure ? "azure" : "openai";
111
+ const config = {
112
+ model_providers: {
113
+ [providerSectionName]: isAzure
114
+ ? {
115
+ name: "Azure OpenAI",
116
+ base_url: baseUrl,
117
+ env_key: providerEnvKey,
118
+ wire_api: "responses",
119
+ ...streamProviderOverrides,
120
+ }
121
+ : streamProviderOverrides,
122
+ },
123
+ };
124
+
125
+ if (isAzure && env.CODEX_MODEL) {
126
+ config.model_provider = providerSectionName;
127
+ config.model = env.CODEX_MODEL;
128
+ }
129
+
130
+ return {
131
+ env,
132
+ config,
133
+ providerName,
134
+ streamIdleTimeoutMs: streamProviderOverrides.stream_idle_timeout_ms,
135
+ };
136
+ }
137
+
81
138
  function getInternalExecutorStreamConfig() {
82
139
  try {
83
140
  const cfg = loadConfig();
@@ -443,9 +500,6 @@ async function getThread() {
443
500
  if (activeThread) return activeThread;
444
501
  const threadOptions = buildThreadOptions();
445
502
 
446
- const { env: resolvedEnv } = resolveCodexProfileRuntime(process.env);
447
- Object.assign(process.env, resolvedEnv);
448
-
449
503
  if (!codexInstance) {
450
504
  const Cls = await loadCodexSdk();
451
505
  if (!Cls) throw new Error("Codex SDK not available");
@@ -454,23 +508,16 @@ async function getThread() {
454
508
  // even if config.toml hasn't been patched by codex-config.mjs yet.
455
509
  // This is the most reliable path for Azure/Foundry deployments where
456
510
  // dropped SSE streams ("response.failed") are the dominant failure mode.
457
- const providerName = (() => {
458
- try {
459
- const parsed = new URL(String(resolvedEnv.OPENAI_BASE_URL || ""));
460
- const host = String(parsed.hostname || "").toLowerCase();
461
- return host === "openai.azure.com" || host.endsWith(".openai.azure.com")
462
- ? "azure"
463
- : "openai";
464
- } catch {
465
- return "openai";
466
- }
467
- })();
468
511
  const STREAM_IDLE_TIMEOUT_MS = 3_600_000; // 60 min — matches Azure max stream lifetime
469
512
  const streamProviderOverrides = {
470
513
  stream_idle_timeout_ms: STREAM_IDLE_TIMEOUT_MS,
471
514
  stream_max_retries: 15,
472
515
  request_max_retries: 6,
473
516
  };
517
+ const runtime = buildCodexSdkRuntime(streamProviderOverrides, process.env);
518
+
519
+ Object.assign(process.env, runtime.env);
520
+ delete process.env.OPENAI_BASE_URL;
474
521
 
475
522
  codexInstance = new Cls({
476
523
  config: {
@@ -481,13 +528,11 @@ async function getThread() {
481
528
  undo: true,
482
529
  steer: true,
483
530
  },
484
- model_providers: {
485
- [providerName]: streamProviderOverrides,
486
- },
531
+ ...runtime.config,
487
532
  },
488
533
  });
489
534
 
490
- console.log(`[codex-shell] created Codex instance (provider=${providerName}, stream_idle_timeout=${STREAM_IDLE_TIMEOUT_MS}ms, stream_max_retries=${streamProviderOverrides.stream_max_retries})`);
535
+ console.log(`[codex-shell] created Codex instance (provider=${runtime.providerName}, stream_idle_timeout=${runtime.streamIdleTimeoutMs}ms, stream_max_retries=${streamProviderOverrides.stream_max_retries})`);
491
536
  }
492
537
 
493
538
  const transport = resolveCodexTransport();
@@ -91,22 +91,33 @@ async function discoverViaSDK(existingClient = null) {
91
91
  // Try to connect to an already-running server
92
92
  try {
93
93
  const port = Number(process.env.OPENCODE_PORT || "4096");
94
- const result = sdk.createOpencodeClient({
95
- hostname: "127.0.0.1",
96
- port,
97
- timeout: 5_000,
98
- });
99
- client = result;
94
+ try {
95
+ client = sdk.createOpencodeClient({
96
+ baseUrl: `http://127.0.0.1:${port}`,
97
+ timeout: 5_000,
98
+ });
99
+ } catch {
100
+ client = sdk.createOpencodeClient({
101
+ hostname: "127.0.0.1",
102
+ port,
103
+ timeout: 5_000,
104
+ });
105
+ }
100
106
  } catch {
101
107
  return null;
102
108
  }
103
109
  }
104
110
 
105
111
  try {
112
+ const directory = process.cwd();
113
+ const requestOptions = directory
114
+ ? { query: { directory } }
115
+ : undefined;
116
+
106
117
  // Fetch provider list + auth methods in parallel
107
118
  const [providerRes, authRes] = await Promise.all([
108
- client.provider.list().catch(() => null),
109
- client.provider.auth().catch(() => null),
119
+ client.provider.list(requestOptions).catch(() => null),
120
+ client.provider.auth(requestOptions).catch(() => null),
110
121
  ]);
111
122
 
112
123
  if (!providerRes?.data) return null;
@@ -481,3 +492,4 @@ export function buildExecutorEntry(providerID, modelFullId, overrides = {}) {
481
492
  export function invalidateCache() {
482
493
  _providerCache = { data: null, ts: 0 };
483
494
  }
495
+
@@ -4278,6 +4278,34 @@ class TaskExecutor {
4278
4278
  };
4279
4279
  }
4280
4280
 
4281
+ resetTaskThrottleState(taskId, options = {}) {
4282
+ const key = normalizeTaskIdKey(taskId);
4283
+ if (!key) return false;
4284
+
4285
+ let changed = false;
4286
+ if (options.clearNoCommit !== false && this._noCommitCounts.delete(key)) {
4287
+ changed = true;
4288
+ }
4289
+ if (options.clearSkipUntil !== false && this._skipUntil.delete(key)) {
4290
+ changed = true;
4291
+ }
4292
+ if (options.clearCooldowns !== false && this._taskCooldowns.delete(key)) {
4293
+ changed = true;
4294
+ }
4295
+ if (this._idleContinueCounts.delete(key)) {
4296
+ changed = true;
4297
+ }
4298
+ if (this._repoAreaBlockedTasks.has(key)) {
4299
+ this._repoAreaBlockedTasks.delete(key);
4300
+ changed = true;
4301
+ }
4302
+
4303
+ if (changed) {
4304
+ this._saveNoCommitState();
4305
+ }
4306
+ return changed;
4307
+ }
4308
+
4281
4309
  setBacklogReplenishmentConfig(patch = {}) {
4282
4310
  if (!patch || typeof patch !== "object") {
4283
4311
  return this.getBacklogReplenishmentConfig();
@@ -101,15 +101,25 @@ function resolveBosunHomeDir() {
101
101
  return resolve(base, "bosun");
102
102
  }
103
103
 
104
+ function resolveExplicitRepoRoot() {
105
+ const explicit = String(process.env.REPO_ROOT || "").trim();
106
+ if (!explicit) return null;
107
+ return resolve(explicit);
108
+ }
109
+
104
110
  function resolvePersistentStorePath() {
105
- const repoRoot = inferRepoRoot(process.cwd());
106
- if (repoRoot) {
107
- return resolve(repoRoot, ".bosun", ".cache", "kanban-state.json");
111
+ const explicitRepoRoot = resolveExplicitRepoRoot();
112
+ if (explicitRepoRoot) {
113
+ return resolve(explicitRepoRoot, ".bosun", ".cache", "kanban-state.json");
108
114
  }
109
115
  const bosunHome = resolveBosunHomeDir();
110
116
  if (bosunHome) {
111
117
  return resolve(bosunHome, ".cache", "kanban-state.json");
112
118
  }
119
+ const repoRoot = inferRepoRoot(process.cwd());
120
+ if (repoRoot) {
121
+ return resolve(repoRoot, ".bosun", ".cache", "kanban-state.json");
122
+ }
113
123
  return resolve(__dirname, "..", ".cache", "kanban-state.json");
114
124
  }
115
125
 
@@ -2831,4 +2841,3 @@ export function getStaleInReviewTasks(maxAgeMs) {
2831
2841
  (t) => t.status === "inreview" && t.lastActivityAt < cutoff,
2832
2842
  );
2833
2843
  }
2834
-
@@ -41,7 +41,7 @@ import {
41
41
  writeFileSync,
42
42
  } from "node:fs";
43
43
  import { readFile, writeFile, unlink } from "node:fs/promises";
44
- import { resolve, dirname } from "node:path";
44
+ import { resolve, dirname, isAbsolute } from "node:path";
45
45
  import { fileURLToPath } from "node:url";
46
46
  import { spawn } from "node:child_process";
47
47
  import os from "node:os";
@@ -61,7 +61,55 @@ import {
61
61
 
62
62
  const __filename = fileURLToPath(import.meta.url);
63
63
  const __dirname = dirname(__filename);
64
- const repoRoot = resolveRepoRoot();
64
+
65
+ // Resolve repo root from BOSUN_HOME config first, then fall back to git detection.
66
+ // This avoids depending on cwd being inside a git repo (daemon/sentinel may start
67
+ // from homedir or any arbitrary directory).
68
+ function _resolveSentinelRepoRoot() {
69
+ // 1. Explicit env
70
+ if (process.env.REPO_ROOT) {
71
+ const r = resolve(process.env.REPO_ROOT);
72
+ if (existsSync(r)) return r;
73
+ }
74
+ // 2. Check BOSUN_HOME workspace config for a primary repo
75
+ const bosunDir = resolveBosunConfigDir();
76
+ if (bosunDir) {
77
+ for (const cfgName of ["bosun.config.json", ".bosun.json", "bosun.json"]) {
78
+ const cfgPath = resolve(bosunDir, cfgName);
79
+ if (!existsSync(cfgPath)) continue;
80
+ try {
81
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
82
+ // Check workspace repos
83
+ const workspaces = Array.isArray(cfg.workspaces) ? cfg.workspaces : [];
84
+ for (const ws of workspaces) {
85
+ const repos = ws?.repos || ws?.repositories || [];
86
+ if (!Array.isArray(repos)) continue;
87
+ for (const repo of repos) {
88
+ const name = typeof repo === "string" ? repo.split("/").pop() : (repo?.name || repo?.id || "");
89
+ if (!name) continue;
90
+ const wsId = ws?.id || "default";
91
+ const repoPath = resolve(bosunDir, "workspaces", wsId, name);
92
+ if (existsSync(resolve(repoPath, ".git"))) return repoPath;
93
+ }
94
+ }
95
+ // Check flat repositories
96
+ const repos = cfg.repositories || cfg.repos || [];
97
+ if (Array.isArray(repos)) {
98
+ for (const repo of repos) {
99
+ const repoPath = typeof repo === "string" ? repo : (repo?.path || repo?.repoRoot);
100
+ if (!repoPath) continue;
101
+ const resolved = resolve(isAbsolute(repoPath) ? repoPath : resolve(bosunDir, repoPath));
102
+ if (existsSync(resolve(resolved, ".git"))) return resolved;
103
+ }
104
+ }
105
+ } catch { /* invalid config */ }
106
+ }
107
+ }
108
+ // 3. Fall back to standard repo-root resolution (git detection)
109
+ return resolveRepoRoot();
110
+ }
111
+
112
+ const repoRoot = _resolveSentinelRepoRoot();
65
113
  const cacheDir = resolve(repoRoot, ".cache");
66
114
 
67
115
  function resolveBosunConfigDir() {
@@ -1654,8 +1702,11 @@ async function startAndWaitForMonitor(reason) {
1654
1702
  {
1655
1703
  detached: true,
1656
1704
  stdio: "ignore",
1705
+ windowsHide: process.platform === "win32",
1657
1706
  env: spawnEnv,
1658
- cwd: repoRoot,
1707
+ // Use repoRoot if it has a .git dir; otherwise use homedir so spawn
1708
+ // never inherits a non-existent or non-repo cwd.
1709
+ cwd: existsSync(resolve(repoRoot, ".git")) ? repoRoot : os.homedir(),
1659
1710
  },
1660
1711
  );
1661
1712
 
@@ -2,9 +2,10 @@
2
2
  /**
3
3
  * list-todos — Scan codebase for TODO/FIXME/HACK/XXX/BUG comment markers
4
4
  *
5
- * Usage: node list-todos.mjs [rootDir] [--json]
5
+ * Usage: node list-todos.mjs [rootDir] [--json] [--help]
6
6
  * rootDir defaults to cwd
7
7
  * --json emit JSON array instead of human-readable output
8
+ * --help show usage and exit without scanning
8
9
  *
9
10
  * Exit 0 always (informational tool).
10
11
  */
@@ -12,6 +13,11 @@ import { readdirSync, readFileSync } from "node:fs";
12
13
  import { resolve, relative, extname } from "node:path";
13
14
 
14
15
  const args = process.argv.slice(2);
16
+ if (args.includes("--help") || args.includes("-h") || args.includes("help")) {
17
+ console.log("Usage: node list-todos.mjs [rootDir] [--json] [--help]");
18
+ console.log("Scans for TODO/FIXME/HACK/XXX/TEMP/BUG comment markers.");
19
+ process.exit(0);
20
+ }
15
21
  const jsonMode = args.includes("--json");
16
22
  const ROOT = args.find((a) => !a.startsWith("-")) || process.cwd();
17
23
 
package/ui/app.js CHANGED
@@ -314,7 +314,7 @@ import { LogsTab } from "./tabs/logs.js";
314
314
  import { TelemetryTab } from "./tabs/telemetry.js";
315
315
  import { SettingsTab } from "./tabs/settings.js";
316
316
  import { WorkflowsTab } from "./tabs/workflows.js";
317
- import { LibraryTab } from "./tabs/library.js";
317
+ import { LibraryTab, LibraryMarketplaceTab } from "./tabs/library.js";
318
318
  import { ManualFlowsTab } from "./tabs/manual-flows.js";
319
319
 
320
320
  /* ── Shared components ── */
@@ -603,6 +603,7 @@ const TAB_COMPONENTS = {
603
603
  workflows: WorkflowsTab,
604
604
  "manual-flows": ManualFlowsTab,
605
605
  library: LibraryTab,
606
+ marketplace: LibraryMarketplaceTab,
606
607
  settings: SettingsTab,
607
608
  };
608
609
 
@@ -1228,7 +1229,7 @@ function InspectorPanel({ onResizeStart, onResizeReset, showResizer }) {
1228
1229
  * Bottom Navigation
1229
1230
  * ═══════════════════════════════════════════════ */
1230
1231
  const PRIMARY_NAV_TABS = ["dashboard", "chat", "tasks", "agents"];
1231
- const MORE_NAV_TABS = ["control", "infra", "logs", "telemetry", "library", "workflows", "manual-flows", "settings"];
1232
+ const MORE_NAV_TABS = ["control", "infra", "logs", "telemetry", "library", "marketplace", "workflows", "manual-flows", "settings"];
1232
1233
 
1233
1234
  function getTabsById(ids) {
1234
1235
  return ids