bosun 0.34.3 → 0.34.4

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.
package/agent-pool.mjs CHANGED
@@ -75,6 +75,25 @@ const HARD_TIMEOUT_BUFFER_MS = 5 * 60_000; // 5 minutes
75
75
 
76
76
  /** Tag for console logging */
77
77
  const TAG = "[agent-pool]";
78
+ const MAX_PROMPT_BYTES = 180_000;
79
+
80
+ function sanitizeAndBoundPrompt(text) {
81
+ if (typeof text !== "string") return "";
82
+ // eslint-disable-next-line no-control-regex
83
+ const sanitized = text.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
84
+ const bytes = Buffer.byteLength(sanitized, "utf8");
85
+ if (bytes <= MAX_PROMPT_BYTES) return sanitized;
86
+ const buf = Buffer.from(sanitized, "utf8").slice(0, MAX_PROMPT_BYTES);
87
+ const truncated = buf.toString("utf8");
88
+ const removedBytes = bytes - MAX_PROMPT_BYTES;
89
+ console.warn(
90
+ `${TAG} prompt truncated: ${bytes} → ${MAX_PROMPT_BYTES} bytes (removed ${removedBytes})`,
91
+ );
92
+ return (
93
+ truncated +
94
+ `\n\n[...prompt truncated — ${removedBytes} bytes removed to stay within API limits]`
95
+ );
96
+ }
78
97
 
79
98
  function envFlagEnabled(value) {
80
99
  const raw = String(value ?? "")
@@ -243,13 +262,8 @@ function hasSdkPrerequisites(name) {
243
262
  return { ok: true, reason: null };
244
263
  }
245
264
  if (name === "copilot") {
246
- // Copilot needs either a token or VS Code context
247
- const hasToken = process.env.COPILOT_CLI_TOKEN || process.env.GITHUB_TOKEN;
248
- // Copilot also works from VS Code extension context — check for common indicators
249
- const hasVsCode = process.env.VSCODE_PID || process.env.COPILOT_AGENT_HOST;
250
- if (!hasToken && !hasVsCode) {
251
- return { ok: false, reason: "no COPILOT_CLI_TOKEN or GITHUB_TOKEN" };
252
- }
265
+ // Copilot auth can come from multiple sources (OAuth manager, gh auth,
266
+ // VS Code Copilot login, env tokens). Don't block execution here.
253
267
  return { ok: true, reason: null };
254
268
  }
255
269
  if (name === "claude") {
@@ -775,7 +789,11 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
775
789
  // Codex steering: send a follow-up message to the live thread
776
790
  // Note: the thread is consumed in the streaming loop below, so
777
791
  // additional runStreamed calls are queued by the SDK
778
- thread.runStreamed(steerPrompt, { signal: controller?.signal }).catch(() => {});
792
+ thread
793
+ .runStreamed(sanitizeAndBoundPrompt(steerPrompt), {
794
+ signal: controller?.signal,
795
+ })
796
+ .catch(() => {});
779
797
  });
780
798
  }
781
799
 
@@ -791,7 +809,8 @@ async function launchCodexThread(prompt, cwd, timeoutMs, extra = {}) {
791
809
 
792
810
  // ── 4. Stream the turn ───────────────────────────────────────────────────
793
811
  try {
794
- const turn = await thread.runStreamed(prompt, {
812
+ const safePrompt = sanitizeAndBoundPrompt(prompt);
813
+ const turn = await thread.runStreamed(safePrompt, {
795
814
  signal: controller.signal,
796
815
  });
797
816
 
@@ -2237,7 +2256,8 @@ async function resumeCodexThread(threadId, prompt, cwd, timeoutMs, extra = {}) {
2237
2256
  let hardTimer;
2238
2257
 
2239
2258
  try {
2240
- const turn = await thread.runStreamed(prompt, {
2259
+ const safePrompt = sanitizeAndBoundPrompt(prompt);
2260
+ const turn = await thread.runStreamed(safePrompt, {
2241
2261
  signal: controller.signal,
2242
2262
  });
2243
2263
  let finalResponse = "";
package/bosun.schema.json CHANGED
@@ -49,6 +49,11 @@
49
49
  "type": "string",
50
50
  "enum": ["internal", "vk", "github", "jira"]
51
51
  },
52
+ "syncPolicy": {
53
+ "type": "string",
54
+ "enum": ["internal-primary", "bidirectional"],
55
+ "default": "internal-primary"
56
+ },
52
57
  "projectId": { "type": "string" },
53
58
  "github": {
54
59
  "type": "object",
package/desktop/main.mjs CHANGED
@@ -41,6 +41,38 @@ function parseBoolEnv(value, fallback) {
41
41
  return fallback;
42
42
  }
43
43
 
44
+ function isWslInteropRuntime() {
45
+ return Boolean(
46
+ process.env.WSL_DISTRO_NAME
47
+ || process.env.WSL_INTEROP
48
+ || (process.platform === "win32"
49
+ && String(process.env.HOME || "")
50
+ .trim()
51
+ .startsWith("/home/")),
52
+ );
53
+ }
54
+
55
+ function resolveDesktopConfigDir() {
56
+ if (process.env.BOSUN_HOME) return resolve(process.env.BOSUN_HOME);
57
+ if (process.env.BOSUN_DIR) return resolve(process.env.BOSUN_DIR);
58
+
59
+ const preferWindowsDirs = process.platform === "win32" && !isWslInteropRuntime();
60
+ const baseDir = preferWindowsDirs
61
+ ? process.env.APPDATA
62
+ || process.env.LOCALAPPDATA
63
+ || process.env.USERPROFILE
64
+ || process.env.HOME
65
+ || homedir()
66
+ : process.env.HOME
67
+ || process.env.XDG_CONFIG_HOME
68
+ || process.env.USERPROFILE
69
+ || process.env.APPDATA
70
+ || process.env.LOCALAPPDATA
71
+ || homedir();
72
+
73
+ return resolve(baseDir, "bosun");
74
+ }
75
+
44
76
  function isProcessAlive(pid) {
45
77
  try {
46
78
  process.kill(pid, 0);
@@ -223,7 +255,11 @@ async function ensureDaemonRunning() {
223
255
  async function startUiServer() {
224
256
  if (uiServerStarted) return;
225
257
  const api = await loadUiServerModule();
226
- const server = await api.startTelegramUiServer({});
258
+ const server = await api.startTelegramUiServer({
259
+ dependencies: {
260
+ configDir: resolveDesktopConfigDir(),
261
+ },
262
+ });
227
263
  if (!server) {
228
264
  throw new Error("Failed to start Telegram UI server.");
229
265
  }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * git-commit-helpers.mjs — Git commit utilities for Bosun
3
+ *
4
+ * Ensures Bosun Bot is credited in commits and PRs via GitHub's
5
+ * Co-authored-by trailer convention.
6
+ *
7
+ * GitHub App bot user ID: 262908237
8
+ * Noreply email: 262908237+bosun-ve[bot]@users.noreply.github.com
9
+ * GitHub appearance: https://github.com/apps/bosun-ve
10
+ */
11
+
12
+ const BOSUN_BOT_TRAILER =
13
+ "Co-authored-by: bosun-ve[bot] <262908237+bosun-ve[bot]@users.noreply.github.com>";
14
+
15
+ const BOSUN_PR_CREDIT =
16
+ "\n\n---\n*Created by [Bosun Bot](https://github.com/apps/bosun-ve)*";
17
+
18
+ // ── Commit message helpers ────────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Appends the Bosun bot Co-authored-by trailer to a commit message.
22
+ *
23
+ * GitHub displays the bot in the Contributors graph when this trailer is present.
24
+ * The Co-authored-by line must be separated from the message body by a blank line.
25
+ *
26
+ * @param {string} message - original commit message
27
+ * @returns {string} commit message with trailer appended
28
+ */
29
+ export function appendBosunCoAuthor(message) {
30
+ if (message.includes("Co-authored-by: bosun-ve")) return message;
31
+ const trimmed = message.trimEnd();
32
+ return `${trimmed}\n\n${BOSUN_BOT_TRAILER}`;
33
+ }
34
+
35
+ /**
36
+ * Builds a complete commit message with an optional Bosun bot credit trailer.
37
+ *
38
+ * @param {string} title - commit title (first line / summary)
39
+ * @param {string} [body] - commit body (optional extended description)
40
+ * @param {Object} [opts]
41
+ * @param {boolean} [opts.addBosunCredit=true] - whether to append the co-author trailer
42
+ * @returns {string} full commit message
43
+ */
44
+ export function buildCommitMessage(title, body = "", { addBosunCredit = true } = {}) {
45
+ const parts = [title.trimEnd()];
46
+ if (body && body.trim()) {
47
+ parts.push(""); // blank line
48
+ parts.push(body.trimEnd());
49
+ }
50
+ const base = parts.join("\n");
51
+ return addBosunCredit ? appendBosunCoAuthor(base) : base;
52
+ }
53
+
54
+ // ── PR body helpers ───────────────────────────────────────────────────────────
55
+
56
+ /**
57
+ * Appends the Bosun Bot credit footer to a PR body.
58
+ *
59
+ * @param {string} body - original PR description
60
+ * @returns {string} PR body with Bosun Bot credit appended
61
+ */
62
+ export function appendBosunPrCredit(body) {
63
+ if (body.includes("Bosun Bot") || body.includes("bosun-ve")) return body;
64
+ return body.trimEnd() + BOSUN_PR_CREDIT;
65
+ }
66
+
67
+ /**
68
+ * Returns the Bosun bot Co-authored-by trailer string (for direct use).
69
+ *
70
+ * @returns {string}
71
+ */
72
+ export function getBosunCoAuthorTrailer() {
73
+ return BOSUN_BOT_TRAILER;
74
+ }
75
+
76
+ /**
77
+ * Returns the Bosun bot PR credit footer markdown (for direct use).
78
+ *
79
+ * @returns {string}
80
+ */
81
+ export function getBosunPrCredit() {
82
+ return BOSUN_PR_CREDIT;
83
+ }
@@ -1280,6 +1280,8 @@ class GitHubIssuesAdapter {
1280
1280
  /** @type {Map<string, {fields: any, time: number}>} projectNumber → {fields, time} */
1281
1281
  this._projectFieldsCache = new Map();
1282
1282
  this._projectFieldsCacheTTL = 300_000; // 5 minutes
1283
+ /** @type {Set<string>} projects already warned for missing Status field */
1284
+ this._projectStatusFieldWarned = new Set();
1283
1285
  this._repositoryNodeId = null;
1284
1286
 
1285
1287
  // Auto-sync toggle: set GITHUB_PROJECT_AUTO_SYNC=false to disable project sync
@@ -1309,6 +1311,46 @@ class GitHubIssuesAdapter {
1309
1311
  this._issueListCache = new Map();
1310
1312
  /** @type {Map<string, {data: object|null, ts: number}>} issueNum → {data, ts} */
1311
1313
  this._sharedStateCache = new Map();
1314
+ this._lastKnownTasks = [];
1315
+ this._taskListBackoffUntil = 0;
1316
+ this._taskListBackoffMs = parseDelayMs(
1317
+ process.env.GH_TASK_LIST_BACKOFF_MS,
1318
+ 90_000,
1319
+ 5_000,
1320
+ );
1321
+ this._taskListBackoffWarnAt = 0;
1322
+ }
1323
+
1324
+ _cacheLastKnownTasks(tasks) {
1325
+ if (!Array.isArray(tasks)) return;
1326
+ this._lastKnownTasks = tasks.map((task) => ({
1327
+ ...task,
1328
+ meta: task?.meta ? { ...task.meta } : {},
1329
+ }));
1330
+ this._taskListBackoffUntil = 0;
1331
+ this._taskListBackoffWarnAt = 0;
1332
+ }
1333
+
1334
+ _setTaskListBackoff() {
1335
+ const now = Date.now();
1336
+ this._taskListBackoffUntil = Math.max(
1337
+ this._taskListBackoffUntil || 0,
1338
+ now + this._taskListBackoffMs,
1339
+ );
1340
+ }
1341
+
1342
+ _getBackoffTasks(filters = {}) {
1343
+ let fallback = this._lastKnownTasks.map((task) => ({
1344
+ ...task,
1345
+ meta: task?.meta ? { ...task.meta } : {},
1346
+ }));
1347
+
1348
+ if (filters.status) {
1349
+ const normalizedFilter = normaliseStatus(filters.status);
1350
+ fallback = fallback.filter((task) => task.status === normalizedFilter);
1351
+ }
1352
+
1353
+ return fallback;
1312
1354
  }
1313
1355
 
1314
1356
  /**
@@ -1341,7 +1383,15 @@ class GitHubIssuesAdapter {
1341
1383
  "json",
1342
1384
  ]);
1343
1385
 
1344
- if (!Array.isArray(fields)) {
1386
+ const normalizedFields = Array.isArray(fields)
1387
+ ? fields
1388
+ : Array.isArray(fields?.fields)
1389
+ ? fields.fields
1390
+ : Array.isArray(fields?.data)
1391
+ ? fields.data
1392
+ : null;
1393
+
1394
+ if (!Array.isArray(normalizedFields)) {
1345
1395
  console.warn(
1346
1396
  `${TAG} project field-list returned non-array for project ${projectNumber}`,
1347
1397
  );
@@ -1349,7 +1399,7 @@ class GitHubIssuesAdapter {
1349
1399
  }
1350
1400
 
1351
1401
  // Find the Status field
1352
- const statusField = fields.find(
1402
+ const statusField = normalizedFields.find(
1353
1403
  (f) =>
1354
1404
  f.name === "Status" &&
1355
1405
  (f.type === "SINGLE_SELECT" || f.data_type === "SINGLE_SELECT"),
@@ -1366,7 +1416,7 @@ class GitHubIssuesAdapter {
1366
1416
  // Cache the result (also cache the raw fields array for getProjectFields)
1367
1417
  this._projectFieldsCache.set(cacheKey, {
1368
1418
  fields: result,
1369
- rawFields: fields,
1419
+ rawFields: normalizedFields,
1370
1420
  time: now,
1371
1421
  });
1372
1422
 
@@ -1950,10 +2000,9 @@ class GitHubIssuesAdapter {
1950
2000
 
1951
2001
  return tasks;
1952
2002
  } catch (err) {
1953
- console.warn(
1954
- `${TAG} failed to list tasks from project ${projectNumber}: ${err.message}`,
2003
+ throw new Error(
2004
+ `project ${projectNumber} list failed: ${err?.message || err}`,
1955
2005
  );
1956
- return [];
1957
2006
  }
1958
2007
  }
1959
2008
 
@@ -1981,9 +2030,16 @@ class GitHubIssuesAdapter {
1981
2030
  // Get project fields
1982
2031
  const fields = await this._getProjectFields(projectNumber);
1983
2032
  if (!fields || !fields.statusFieldId) {
1984
- console.warn(`${TAG} cannot sync to project: no status field found`);
2033
+ const projectKey = String(projectNumber || "");
2034
+ if (!this._projectStatusFieldWarned.has(projectKey)) {
2035
+ this._projectStatusFieldWarned.add(projectKey);
2036
+ console.warn(
2037
+ `${TAG} cannot sync to project ${projectKey}: no Status field found`,
2038
+ );
2039
+ }
1985
2040
  return false;
1986
2041
  }
2042
+ this._projectStatusFieldWarned.delete(String(projectNumber || ""));
1987
2043
 
1988
2044
  // Map codex status to project status option using configurable mapping
1989
2045
  const targetStatusName = this._normalizeProjectStatus(status, true);
@@ -2256,6 +2312,24 @@ class GitHubIssuesAdapter {
2256
2312
  }
2257
2313
 
2258
2314
  async listTasks(_projectId, filters = {}) {
2315
+ const now = Date.now();
2316
+ if (this._taskListBackoffUntil > now) {
2317
+ const fallback = this._getBackoffTasks(filters);
2318
+ if (fallback.length > 0) {
2319
+ if (now - this._taskListBackoffWarnAt > 15_000) {
2320
+ this._taskListBackoffWarnAt = now;
2321
+ const sec = Math.max(
2322
+ 1,
2323
+ Math.ceil((this._taskListBackoffUntil - now) / 1000),
2324
+ );
2325
+ console.warn(
2326
+ `${TAG} task list in backoff (${sec}s remaining) — serving ${fallback.length} cached task(s)`,
2327
+ );
2328
+ }
2329
+ return fallback;
2330
+ }
2331
+ }
2332
+
2259
2333
  // If project mode is enabled, read from project board
2260
2334
  if (this._projectMode === "kanban" && this._projectNumber) {
2261
2335
  const projectNumber = await this._resolveProjectNumber();
@@ -2296,6 +2370,7 @@ class GitHubIssuesAdapter {
2296
2370
  }
2297
2371
  }
2298
2372
 
2373
+ this._cacheLastKnownTasks(filtered);
2299
2374
  return filtered;
2300
2375
  } catch (err) {
2301
2376
  console.warn(
@@ -2345,8 +2420,20 @@ class GitHubIssuesAdapter {
2345
2420
  } else {
2346
2421
  args.push("--state", "open");
2347
2422
  }
2348
- rawIssues = await this._gh(args);
2349
- this._issueListCache.set(listCacheKey, { data: rawIssues, ts: nowMs });
2423
+ try {
2424
+ rawIssues = await this._gh(args);
2425
+ this._issueListCache.set(listCacheKey, { data: rawIssues, ts: nowMs });
2426
+ } catch (err) {
2427
+ this._setTaskListBackoff();
2428
+ const fallback = this._getBackoffTasks(filters);
2429
+ if (fallback.length > 0) {
2430
+ console.warn(
2431
+ `${TAG} failed to list issues (${err.message}); serving ${fallback.length} cached task(s)`,
2432
+ );
2433
+ return fallback;
2434
+ }
2435
+ throw err;
2436
+ }
2350
2437
  }
2351
2438
 
2352
2439
  let normalized = (Array.isArray(rawIssues) ? rawIssues : []).map((i) =>
@@ -2390,6 +2477,7 @@ class GitHubIssuesAdapter {
2390
2477
  }
2391
2478
  }
2392
2479
 
2480
+ this._cacheLastKnownTasks(normalized);
2393
2481
  return normalized;
2394
2482
  }
2395
2483
 
@@ -5209,5 +5297,3 @@ export async function unmarkTaskIgnored(taskId) {
5209
5297
  );
5210
5298
  return false;
5211
5299
  }
5212
-
5213
-