bosun 0.40.4 → 0.40.6

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 (40) hide show
  1. package/.env.example +13 -0
  2. package/README.md +9 -2
  3. package/agent/agent-pool.mjs +16 -7
  4. package/agent/agent-supervisor.mjs +81 -28
  5. package/agent/autofix.mjs +50 -36
  6. package/agent/fleet-coordinator.mjs +2 -1
  7. package/agent/hook-profiles.mjs +1 -1
  8. package/agent/review-agent.mjs +48 -19
  9. package/bosun.schema.json +51 -0
  10. package/config/context-shredding-config.mjs +143 -0
  11. package/infra/library-manager.mjs +717 -9
  12. package/infra/maintenance.mjs +9 -4
  13. package/infra/monitor.mjs +55 -8
  14. package/kanban/ve-kanban.mjs +12 -2
  15. package/kanban/vk-log-stream.mjs +4 -1
  16. package/package.json +3 -2
  17. package/server/setup-web-server.mjs +112 -10
  18. package/server/ui-server.mjs +146 -14
  19. package/setup.mjs +25 -16
  20. package/shell/codex-config.mjs +19 -8
  21. package/shell/copilot-shell.mjs +55 -7
  22. package/task/task-assessment.mjs +17 -9
  23. package/task/task-executor.mjs +37 -0
  24. package/telegram/telegram-bot.mjs +5 -4
  25. package/ui/demo-defaults.js +146 -24
  26. package/ui/demo.html +73 -2
  27. package/ui/modules/settings-schema.js +9 -0
  28. package/ui/setup.html +95 -0
  29. package/ui/tabs/library.js +31 -6
  30. package/ui/tabs/tasks.js +615 -85
  31. package/ui/tabs/telemetry.js +72 -22
  32. package/voice/voice-agents-sdk.mjs +5 -1
  33. package/workflow/workflow-engine.mjs +100 -16
  34. package/workflow/workflow-nodes.mjs +146 -9
  35. package/workflow-templates/github.mjs +11 -8
  36. package/workflow-templates/task-batch.mjs +1 -1
  37. package/workflow-templates/task-lifecycle.mjs +16 -3
  38. package/workspace/context-cache.mjs +399 -6
  39. package/workspace/workspace-manager.mjs +22 -16
  40. package/workspace/worktree-manager.mjs +7 -7
package/.env.example CHANGED
@@ -196,6 +196,19 @@ VOICE_FALLBACK_MODE=browser
196
196
  # Executor used by voice tool delegations for complex requests
197
197
  VOICE_DELEGATE_EXECUTOR=codex-sdk
198
198
 
199
+ # ─── Context Shredding / Live Tool Compaction ───────────────────────────────
200
+ # Traditional shredding trims older context turns. Live tool compaction runs
201
+ # earlier: it summarizes large, noisy command outputs before they ever land in
202
+ # the active turn, while preserving a `bosun --tool-log <id>` retrieval path.
203
+ # CONTEXT_SHREDDING_ENABLED=true
204
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ENABLED=false
205
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MODE=auto
206
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_CHARS=4000
207
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_TARGET_CHARS=1800
208
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_SAVINGS_PCT=15
209
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_MIN_RUNTIME_MS=2000
210
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_BLOCK_STRUCTURED_OUTPUT=true
211
+ # CONTEXT_SHREDDING_LIVE_TOOL_COMPACTION_ALLOW_COMMANDS=grep,rg,find,findstr,select-string,ag,ack,sift,fd,where,which,ls,dir,tree,git,go,npm,pnpm,yarn,npx,bun,node,python,python3,pytest,pip,pip3,poetry,docker,kubectl,helm,terraform,ansible,ansible-playbook,journalctl,tail,get-content,cargo,gradle,maven,mvn,javac,tsc,jest,vitest,deno,make,cmake,bazel,buck,nx,turbo,rush,composer,bundle
199
212
  # ─── Desktop Portal ────────────────────────────────────────────────────────
200
213
  # Auto-start bosun daemon when the desktop portal launches (default: true)
201
214
  # BOSUN_DESKTOP_AUTO_START_DAEMON=true
package/README.md CHANGED
@@ -183,8 +183,15 @@ npm run hooks:install
183
183
 
184
184
  If you find this project useful or would like to stay up to date with new releases, a star is appreciated!
185
185
 
186
- [![Star History Chart](https://api.star-history.com/image?repos=VirtEngine/Bosun&type=date&legend=top-left)](https://www.star-history.com/?repos=VirtEngine%2FBosun&type=date&legend=top-left)
187
-
186
+ ## Star History
187
+
188
+ <a href="https://www.star-history.com/?repos=VirtEngine%2FBosun&type=date&legend=top-left">
189
+ <picture>
190
+ <source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=VirtEngine/Bosun&type=date&theme=dark&legend=top-left" />
191
+ <source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=VirtEngine/Bosun&type=date&legend=top-left" />
192
+ <img alt="Star History Chart" src="https://api.star-history.com/image?repos=VirtEngine/Bosun&type=date&legend=top-left" />
193
+ </picture>
194
+ </a>
188
195
  ---
189
196
 
190
197
  ## License
@@ -44,6 +44,7 @@ import { fileURLToPath } from "node:url";
44
44
  import { loadConfig } from "../config/config.mjs";
45
45
  import { resolveRepoRoot, resolveAgentRepoRoot } from "../config/repo-root.mjs";
46
46
  import { resolveCodexProfileRuntime } from "../shell/codex-model-profiles.mjs";
47
+ import { resolveCopilotCliLaunchConfig } from "../shell/copilot-shell.mjs";
47
48
  import { getGitHubToken } from "../github/github-auth-manager.mjs";
48
49
  import {
49
50
  isTransientStreamError,
@@ -489,6 +490,10 @@ function shouldFallbackForSdkError(error) {
489
490
  if (!error) return false;
490
491
  const message = String(error).toLowerCase();
491
492
  if (!message) return false;
493
+ if (message.includes("protocol version mismatch")) return true;
494
+ if (message.includes("sdk expects version") && message.includes("server reports version")) {
495
+ return true;
496
+ }
492
497
  // SDK not installed / not found
493
498
  if (message.includes("not available")) return true;
494
499
  // Missing finish_reason (incomplete response)
@@ -808,6 +813,10 @@ function shouldApplySdkCooldown(error) {
808
813
  if (!error) return false;
809
814
  const message = String(error).toLowerCase();
810
815
  if (!message) return false;
816
+ if (message.includes("protocol version mismatch")) return true;
817
+ if (message.includes("sdk expects version") && message.includes("server reports version")) {
818
+ return true;
819
+ }
811
820
  if (message.includes("timeout")) return true;
812
821
  if (message.includes("rate limit") || message.includes("429")) return true;
813
822
  if (message.includes("service unavailable") || message.includes("503")) {
@@ -1448,22 +1457,22 @@ async function launchCopilotThread(prompt, cwd, timeoutMs, extra = {}) {
1448
1457
  console.warn(`${TAG} copilot MCP config write failed (non-fatal): ${mcpErr.message}`);
1449
1458
  }
1450
1459
  }
1451
- const cliArgs = buildPoolCopilotCliArgs(mcpConfigPath);
1460
+ const cliLaunch = resolveCopilotCliLaunchConfig({
1461
+ env: runtimeEnv,
1462
+ repoRoot: REPO_ROOT,
1463
+ cliArgs: buildPoolCopilotCliArgs(mcpConfigPath),
1464
+ });
1452
1465
  clientOpts = {
1453
1466
  cwd,
1454
1467
  env: clientEnv,
1455
- cliArgs,
1468
+ cliArgs: cliLaunch.cliArgs,
1456
1469
  useStdio: true,
1457
1470
  };
1458
1471
  if (token) {
1459
1472
  clientOpts.githubToken = token;
1460
1473
  clientOpts.token = token;
1461
1474
  }
1462
- const cliPath =
1463
- process.env.COPILOT_CLI_PATH ||
1464
- process.env.GITHUB_COPILOT_CLI_PATH ||
1465
- undefined;
1466
- if (cliPath) clientOpts.cliPath = cliPath;
1475
+ if (cliLaunch.cliPath) clientOpts.cliPath = cliLaunch.cliPath;
1467
1476
  }
1468
1477
  client = new CopilotClientClass(clientOpts);
1469
1478
  await client.start();
@@ -901,6 +901,16 @@ export class AgentSupervisor {
901
901
  const progress = signals.sessionProgress;
902
902
  const analysis = signals.sessionAnalysis;
903
903
  const anomalies = signals.activeAnomalies || [];
904
+ const includesAny = (text, needles) => needles.some((needle) => text.includes(needle));
905
+ const includesOrdered = (text, parts) => {
906
+ let cursor = 0;
907
+ for (const part of parts) {
908
+ const idx = text.indexOf(part, cursor);
909
+ if (idx === -1) return false;
910
+ cursor = idx + part.length;
911
+ }
912
+ return true;
913
+ };
904
914
 
905
915
  // ── Agent completely dead ──
906
916
  if (
@@ -914,7 +924,13 @@ export class AgentSupervisor {
914
924
  // ── External errors (check first — these aren't the agent's fault) ──
915
925
  if (context.error || signals.error) {
916
926
  const errText = context.error || signals.error || "";
917
- if (/429|rate.?limit|too many requests|quota exceeded/i.test(errText)) {
927
+ const errLower = String(errText).toLowerCase();
928
+ if (
929
+ errLower.includes("429") ||
930
+ includesOrdered(errLower, ["rate", "limit"]) ||
931
+ errLower.includes("too many requests") ||
932
+ errLower.includes("quota exceeded")
933
+ ) {
918
934
  // Check for flood
919
935
  if (signals.errorPatterns?.length >= 3) {
920
936
  const recent = signals.errorPatterns.slice(-5);
@@ -923,47 +939,83 @@ export class AgentSupervisor {
923
939
  }
924
940
  return SITUATION.RATE_LIMITED;
925
941
  }
926
- if (/ECONNREFUSED|ETIMEDOUT|500|502|503|fetch failed/i.test(errText)) {
942
+ if (
943
+ includesAny(errLower, ["econnrefused", "etimedout", "fetch failed"]) ||
944
+ includesAny(errLower, [" 500", "500 ", "500internal", "500internalservererror"]) ||
945
+ includesAny(errLower, [" 502", "502 ", "502badgateway", "502bad gateway"]) ||
946
+ errLower.includes(" 503")
947
+ ) {
927
948
  return SITUATION.API_ERROR;
928
949
  }
929
- if (/context.*(too long|exceeded|overflow)|max.*token/i.test(errText) ||
930
- /context_length_exceeded|prompt_too_long|prompt.*too.*long/i.test(errText) ||
931
- /This model's maximum context length/i.test(errText) ||
932
- /token_budget.*exceeded|token.*budget/i.test(errText) ||
933
- /turn_limit_reached|conversation.*too.*long/i.test(errText) ||
934
- /string_above_max_length|maximum.*number.*tokens/i.test(errText)) {
950
+ if (
951
+ (errLower.includes("context") && includesAny(errLower, ["too long", "exceeded", "overflow"])) ||
952
+ includesOrdered(errLower, ["max", "token"]) ||
953
+ includesAny(errLower, ["context_length_exceeded", "prompt_too_long", "this model's maximum context length", "turn_limit_reached", "string_above_max_length"]) ||
954
+ includesOrdered(errLower, ["prompt", "too", "long"]) ||
955
+ includesOrdered(errLower, ["token_budget", "exceeded"]) ||
956
+ includesOrdered(errLower, ["token", "budget"]) ||
957
+ includesOrdered(errLower, ["conversation", "too", "long"]) ||
958
+ includesOrdered(errLower, ["maximum", "number", "tokens"])
959
+ ) {
935
960
  return SITUATION.TOKEN_OVERFLOW;
936
961
  }
937
- if (/model.*not.*supported|model.*error|invalid.*model|model.*not.*found|model.*deprecated/i.test(errText)) {
962
+ if (
963
+ includesOrdered(errLower, ["model", "not", "supported"]) ||
964
+ includesOrdered(errLower, ["model", "error"]) ||
965
+ includesOrdered(errLower, ["invalid", "model"]) ||
966
+ includesOrdered(errLower, ["model", "not", "found"]) ||
967
+ includesOrdered(errLower, ["model", "deprecated"])
968
+ ) {
938
969
  return SITUATION.MODEL_ERROR;
939
970
  }
940
971
  // ── Auth failures — MUST come BEFORE session_expired (which has 'unauthorized')
941
- if (/invalid.?api.?key|authentication_error|permission_error/i.test(errText) ||
942
- /401 Unauthorized|403 Forbidden/i.test(errText) ||
943
- /billing_hard_limit|insufficient_quota/i.test(errText) ||
944
- /invalid.*credentials|access.?denied|not.?authorized/i.test(errText)) {
972
+ if (
973
+ includesAny(errLower, ["invalid api key", "invalid-api-key", "invalid_api_key", "authentication_error", "permission_error", "401 unauthorized", "403 forbidden", "billing_hard_limit", "insufficient_quota"]) ||
974
+ includesOrdered(errLower, ["invalid", "credentials"]) ||
975
+ includesOrdered(errLower, ["access", "denied"]) ||
976
+ includesOrdered(errLower, ["not", "authorized"])
977
+ ) {
945
978
  return SITUATION.AUTH_FAILURE;
946
979
  }
947
980
  // ── Session expired (after auth check since 'unauthorized' overlaps)
948
- if (/session.*expired|thread.*not.*found/i.test(errText) ||
949
- /invalid.*session|invalid.*token/i.test(errText)) {
981
+ if (
982
+ includesOrdered(errLower, ["session", "expired"]) ||
983
+ includesOrdered(errLower, ["thread", "not", "found"]) ||
984
+ includesOrdered(errLower, ["invalid", "session"]) ||
985
+ includesOrdered(errLower, ["invalid", "token"])
986
+ ) {
950
987
  return SITUATION.SESSION_EXPIRED;
951
988
  }
952
989
  // ── Content policy (safety filter)
953
- if (/content_policy|content.?filter|safety_system|safety.*filter/i.test(errText) ||
954
- /flagged.*content|output.*blocked|response.*blocked/i.test(errText)) {
990
+ if (
991
+ includesAny(errLower, ["content_policy", "content filter", "content-filter", "content_filter", "safety_system"]) ||
992
+ includesOrdered(errLower, ["safety", "filter"]) ||
993
+ includesOrdered(errLower, ["flagged", "content"]) ||
994
+ includesOrdered(errLower, ["output", "blocked"]) ||
995
+ includesOrdered(errLower, ["response", "blocked"])
996
+ ) {
955
997
  return SITUATION.CONTENT_POLICY;
956
998
  }
957
999
  // ── Codex sandbox/CLI errors
958
- if (/sandbox.*fail|bwrap.*error|bubblewrap/i.test(errText) ||
959
- /EPERM.*operation.*not.*permitted/i.test(errText) ||
960
- /writable_roots|namespace.*error/i.test(errText) ||
961
- /codex.*(segfault|killed|crash)/i.test(errText)) {
1000
+ if (
1001
+ includesOrdered(errLower, ["sandbox", "fail"]) ||
1002
+ includesOrdered(errLower, ["bwrap", "error"]) ||
1003
+ errLower.includes("bubblewrap") ||
1004
+ includesOrdered(errLower, ["eperm", "operation", "not", "permitted"]) ||
1005
+ errLower.includes("writable_roots") ||
1006
+ includesOrdered(errLower, ["namespace", "error"]) ||
1007
+ (errLower.includes("codex") && includesAny(errLower, ["segfault", "killed", "crash"]))
1008
+ ) {
962
1009
  return SITUATION.CODEX_SANDBOX;
963
1010
  }
964
1011
  // ── Invalid config (catch-all for config-related errors)
965
- if (/config.*invalid|config.*missing|misconfigured/i.test(errText) ||
966
- /OPENAI_API_KEY.*not.*set|ANTHROPIC_API_KEY.*not.*set/i.test(errText)) {
1012
+ if (
1013
+ includesOrdered(errLower, ["config", "invalid"]) ||
1014
+ includesOrdered(errLower, ["config", "missing"]) ||
1015
+ errLower.includes("misconfigured") ||
1016
+ includesOrdered(errLower, ["openai_api_key", "not", "set"]) ||
1017
+ includesOrdered(errLower, ["anthropic_api_key", "not", "set"])
1018
+ ) {
967
1019
  return SITUATION.INVALID_CONFIG;
968
1020
  }
969
1021
  }
@@ -1009,11 +1061,11 @@ export class AgentSupervisor {
1009
1061
  // ── Build/test/lint failures from context ──
1010
1062
  if (context.error) {
1011
1063
  const err = context.error.toLowerCase();
1012
- if (/pre-push hook.*fail/i.test(err)) return SITUATION.PRE_PUSH_FAILURE;
1013
- if (/git push.*fail|rejected.*push/i.test(err)) return SITUATION.PUSH_FAILURE;
1014
- if (/go build.*fail|compilation error|cannot find/i.test(err)) return SITUATION.BUILD_FAILURE;
1015
- if (/FAIL\s+\S+|test.*fail/i.test(err)) return SITUATION.TEST_FAILURE;
1016
- if (/golangci-lint|lint.*error/i.test(err)) return SITUATION.LINT_FAILURE;
1064
+ if (includesOrdered(err, ["pre-push hook", "fail"])) return SITUATION.PRE_PUSH_FAILURE;
1065
+ if (includesOrdered(err, ["git push", "fail"]) || includesOrdered(err, ["rejected", "push"])) return SITUATION.PUSH_FAILURE;
1066
+ if (includesOrdered(err, ["go build", "fail"]) || err.includes("compilation error") || err.includes("cannot find")) return SITUATION.BUILD_FAILURE;
1067
+ if ((err.includes("fail") && err.includes("test")) || err.includes("fail ")) return SITUATION.TEST_FAILURE;
1068
+ if (err.includes("golangci-lint") || includesOrdered(err, ["lint", "error"])) return SITUATION.LINT_FAILURE;
1017
1069
  if (/merge conflict|CONFLICT/i.test(err)) return SITUATION.GIT_CONFLICT;
1018
1070
  }
1019
1071
 
@@ -1234,3 +1286,4 @@ export class AgentSupervisor {
1234
1286
  export function createAgentSupervisor(opts) {
1235
1287
  return new AgentSupervisor(opts);
1236
1288
  }
1289
+
package/agent/autofix.mjs CHANGED
@@ -93,7 +93,6 @@ export function extractErrors(logText) {
93
93
  const atLineHeader = /^At\s+([A-Za-z]:\\[^\n:]+\.ps1):(\d+)\s+char:(\d+)/;
94
94
 
95
95
  // Pattern C: TerminatingError(X): "message"
96
- const terminatingPattern = /TerminatingError\(([^)]+)\):\s*"(.+?)"/;
97
96
 
98
97
  for (let i = 0; i < lines.length; i++) {
99
98
  const line = lines[i];
@@ -147,16 +146,24 @@ export function extractErrors(logText) {
147
146
  }
148
147
 
149
148
  // ── Check Pattern C (TerminatingError) ──────────────────────────
150
- let matchD = line.match(terminatingPattern);
151
- if (matchD) {
152
- addError({
153
- errorType: "TerminatingError",
154
- file: "unknown",
155
- line: 0,
156
- column: null,
157
- message: `${matchD[1]}: ${matchD[2].trim()}`,
158
- signature: `TerminatingError:${matchD[1]}`,
159
- });
149
+ const marker = "TerminatingError(";
150
+ const markerPos = line.indexOf(marker);
151
+ if (markerPos !== -1) {
152
+ const typeStart = markerPos + marker.length;
153
+ const typeEnd = line.indexOf(")", typeStart);
154
+ const colonPos = typeEnd >= 0 ? line.indexOf(":", typeEnd + 1) : -1;
155
+ const errorType = typeEnd >= 0 ? line.slice(typeStart, typeEnd).trim() : "";
156
+ const detail = colonPos >= 0 ? line.slice(colonPos + 1).trim().replace(/^"|"$/g, "") : "";
157
+ if (errorType && detail) {
158
+ addError({
159
+ errorType: "TerminatingError",
160
+ file: "unknown",
161
+ line: 0,
162
+ column: null,
163
+ message: `${errorType}: ${detail}`,
164
+ signature: `TerminatingError:${errorType}`,
165
+ });
166
+ }
160
167
  }
161
168
  }
162
169
 
@@ -185,18 +192,22 @@ function parseLineBlock(lines, startIdx) {
185
192
  }
186
193
 
187
194
  // Capture code line: " NNN | code..."
188
- if (i < lines.length && /^\s*\d+\s*\|/.test(lines[i])) {
189
- const codeMatch = lines[i].match(/^\s*\d+\s*\|\s*(.*)$/);
190
- if (codeMatch) codeLine = codeMatch[1].trim();
191
- i++;
195
+ if (i < lines.length) {
196
+ const raw = String(lines[i] || "");
197
+ const sep = raw.indexOf("|");
198
+ if (sep !== -1 && /^\s*\d+\s*$/.test(raw.slice(0, sep))) {
199
+ codeLine = raw.slice(sep + 1).trim();
200
+ i++;
201
+ }
192
202
  }
193
203
 
194
204
  // Skip underline and intermediate "| ..." lines, capture last "| message" line
195
205
  let lastPipeMessage = "";
196
206
  while (i < lines.length) {
197
- const pipeMatch = lines[i].match(/^\s*\|\s*(.*)$/);
198
- if (!pipeMatch) break;
199
- const content = pipeMatch[1].trim();
207
+ const raw = String(lines[i] || "");
208
+ const sep = raw.indexOf("|");
209
+ if (sep === -1 || raw.slice(0, sep).trim().length > 0) break;
210
+ const content = raw.slice(sep + 1).trim();
200
211
  // Skip underline-only lines (~~~~) and empty lines
201
212
  if (content && !/^~+$/.test(content)) {
202
213
  lastPipeMessage = content;
@@ -225,29 +236,32 @@ function parsePlusBlock(lines, startIdx) {
225
236
  let i = startIdx;
226
237
 
227
238
  // First "+ " line is usually the code
228
- if (i < lines.length && /^\s*\+\s*/.test(lines[i])) {
229
- const codeMatch = lines[i].match(/^\s*\+\s*(.*)$/);
230
- if (codeMatch) {
231
- const content = codeMatch[1].trim();
239
+ if (i < lines.length) {
240
+ const raw = String(lines[i] || "").trimStart();
241
+ if (raw.startsWith("+")) {
242
+ const content = raw.slice(1).trim();
232
243
  if (!/^~+$/.test(content)) codeLine = content;
244
+ i++;
233
245
  }
234
- i++;
235
246
  }
236
247
 
237
248
  // Subsequent "+ " lines — skip underlines, capture error type + message
238
- while (i < lines.length && /^\s*\+\s*/.test(lines[i])) {
239
- const plusMatch = lines[i].match(/^\s*\+\s*(.*)$/);
240
- if (plusMatch) {
241
- const content = plusMatch[1].trim();
242
- if (/^~+$/.test(content)) {
243
- i++;
244
- continue;
245
- }
246
- // Check for "ErrorType: message"
247
- const errMatch = content.match(/^(\w[\w.-]+):\s*(.+)$/);
248
- if (errMatch) {
249
- errorType = errMatch[1];
250
- message = errMatch[2].trim();
249
+ while (i < lines.length) {
250
+ const raw = String(lines[i] || "").trimStart();
251
+ if (!raw.startsWith("+")) break;
252
+ const content = raw.slice(1).trim();
253
+ if (/^~+$/.test(content)) {
254
+ i++;
255
+ continue;
256
+ }
257
+ // Check for "ErrorType: message"
258
+ const colonPos = content.indexOf(":");
259
+ if (colonPos > 0 && colonPos < content.length - 1) {
260
+ const maybeType = content.slice(0, colonPos).trim();
261
+ const maybeMessage = content.slice(colonPos + 1).trim();
262
+ if (/^\w[\w.-]+$/.test(maybeType) && maybeMessage) {
263
+ errorType = maybeType;
264
+ message = maybeMessage;
251
265
  }
252
266
  }
253
267
  i++;
@@ -160,7 +160,7 @@ export function normalizeGitUrl(url) {
160
160
  s = s.replace(/\.git$/, "");
161
161
 
162
162
  // Strip trailing slashes
163
- s = s.replace(/\/+$/, "");
163
+ while (s.endsWith("/")) s = s.slice(0, -1);
164
164
 
165
165
  return s.toLowerCase();
166
166
  }
@@ -857,3 +857,4 @@ export function formatFleetSummary() {
857
857
 
858
858
  return lines.join("\n");
859
859
  }
860
+
@@ -187,7 +187,7 @@ function normalizeOverrideCommands(rawValue) {
187
187
  return [];
188
188
  }
189
189
  const commands = raw
190
- .split(/\s*;;\s*|\r?\n/)
190
+ .split(/\r?\n/).flatMap((part) => part.split(";;"))
191
191
  .map((part) => part.trim())
192
192
  .filter(Boolean);
193
193
  return commands;
@@ -122,24 +122,36 @@ function extractRepoSlug(prUrl) {
122
122
 
123
123
  /**
124
124
  * Get the PR diff using `gh pr diff` or `git diff`.
125
- * @param {{ prUrl?: string, branchName?: string }} opts
125
+ * @param {{ prUrl?: string, prNumber?: number|string, repoSlug?: string, branchName?: string, cwd?: string|null }} opts
126
126
  * @returns {{ diff: string, truncated: boolean }}
127
127
  */
128
- function getPrDiff({ prUrl, branchName }) {
128
+ function getPrDiff({ prUrl, prNumber, repoSlug, branchName, cwd }) {
129
129
  let diff = "";
130
+ const commandOptions = {
131
+ encoding: "utf8",
132
+ timeout: 30_000,
133
+ stdio: ["pipe", "pipe", "pipe"],
134
+ ...(cwd ? { cwd } : {}),
135
+ };
130
136
 
131
137
  // Strategy 1: gh pr diff
132
- const prNumber = extractPrNumber(prUrl);
133
- const repoSlug = extractRepoSlug(prUrl);
134
- if (prNumber && repoSlug) {
138
+ const resolvedPrNumber = Number.isFinite(Number(prNumber))
139
+ ? Number(prNumber)
140
+ : extractPrNumber(prUrl);
141
+ const resolvedRepoSlug = String(repoSlug || "").trim() || extractRepoSlug(prUrl);
142
+ if (resolvedPrNumber) {
135
143
  try {
136
- const result = spawnSync(
137
- "gh",
138
- ["pr", "diff", String(prNumber), "--repo", repoSlug],
139
- { encoding: "utf8", timeout: 30_000, stdio: ["pipe", "pipe", "pipe"] },
140
- );
141
- if (result.status === 0 && result.stdout?.trim()) {
142
- diff = result.stdout;
144
+ const attempts = [];
145
+ if (resolvedRepoSlug) {
146
+ attempts.push(["pr", "diff", String(resolvedPrNumber), "--repo", resolvedRepoSlug]);
147
+ }
148
+ attempts.push(["pr", "diff", String(resolvedPrNumber)]);
149
+ for (const args of attempts) {
150
+ const result = spawnSync("gh", args, commandOptions);
151
+ if (result.status === 0 && result.stdout?.trim()) {
152
+ diff = result.stdout;
153
+ break;
154
+ }
143
155
  }
144
156
  } catch {
145
157
  /* fall through to git diff */
@@ -149,13 +161,16 @@ function getPrDiff({ prUrl, branchName }) {
149
161
  // Strategy 2: git diff main...<branch>
150
162
  if (!diff && branchName) {
151
163
  try {
152
- const result = spawnSync("git", ["diff", `main...${branchName}`], {
153
- encoding: "utf8",
154
- timeout: 30_000,
155
- stdio: ["pipe", "pipe", "pipe"],
156
- });
157
- if (result.status === 0 && result.stdout?.trim()) {
158
- diff = result.stdout;
164
+ const attempts = [
165
+ ["diff", `origin/main...${branchName}`],
166
+ ["diff", `main...${branchName}`],
167
+ ];
168
+ for (const args of attempts) {
169
+ const result = spawnSync("git", args, commandOptions);
170
+ if (result.status === 0 && result.stdout?.trim()) {
171
+ diff = result.stdout;
172
+ break;
173
+ }
159
174
  }
160
175
  } catch {
161
176
  /* ignore */
@@ -343,6 +358,17 @@ export class ReviewAgent {
343
358
  console.warn(`${TAG} queueReview called without task id — skipping`);
344
359
  return;
345
360
  }
361
+ const hasReviewReference = Boolean(
362
+ String(task.prUrl || "").trim() ||
363
+ String(task.prNumber || "").trim() ||
364
+ String(task.branchName || "").trim(),
365
+ );
366
+ if (!hasReviewReference) {
367
+ console.warn(
368
+ `${TAG} queueReview skipped for ${task.id}: no prUrl, prNumber, or branchName`,
369
+ );
370
+ return;
371
+ }
346
372
 
347
373
  if (this.#seen.has(task.id)) {
348
374
  console.log(`${TAG} task ${task.id} already queued/in-flight — skipping`);
@@ -463,7 +489,10 @@ export class ReviewAgent {
463
489
  // 1. Get PR diff
464
490
  const { diff, truncated } = getPrDiff({
465
491
  prUrl: task.prUrl,
492
+ prNumber: task.prNumber,
493
+ repoSlug: task.repoSlug,
466
494
  branchName: task.branchName,
495
+ cwd: task.worktreePath || null,
467
496
  });
468
497
 
469
498
  if (!diff) {
package/bosun.schema.json CHANGED
@@ -1199,6 +1199,56 @@
1199
1199
  "default": 1,
1200
1200
  "description": "Turns during which the full user prompt is preserved."
1201
1201
  },
1202
+ "liveToolCompactionEnabled": {
1203
+ "type": "boolean",
1204
+ "default": false,
1205
+ "description": "Enable command-aware live compaction of large tool outputs before they are stored in the active turn."
1206
+ },
1207
+ "liveToolCompactionMode": {
1208
+ "type": "string",
1209
+ "enum": ["off", "auto", "aggressive"],
1210
+ "default": "auto",
1211
+ "description": "off disables live compaction, auto compacts only when pressure or signal justify it, and aggressive favors stronger reduction for noisy command families."
1212
+ },
1213
+ "liveToolCompactionMinChars": {
1214
+ "type": "integer",
1215
+ "minimum": 500,
1216
+ "maximum": 500000,
1217
+ "default": 4000,
1218
+ "description": "Minimum output size before live compaction is considered."
1219
+ },
1220
+ "liveToolCompactionTargetChars": {
1221
+ "type": "integer",
1222
+ "minimum": 200,
1223
+ "maximum": 50000,
1224
+ "default": 1800,
1225
+ "description": "Target upper bound for compacted live command output before retrieval hints and metadata."
1226
+ },
1227
+ "liveToolCompactionMinSavingsPct": {
1228
+ "type": "integer",
1229
+ "minimum": 0,
1230
+ "maximum": 95,
1231
+ "default": 15,
1232
+ "description": "Discard compacted output if it does not save at least this much space."
1233
+ },
1234
+ "liveToolCompactionMinRuntimeMs": {
1235
+ "type": "integer",
1236
+ "minimum": 0,
1237
+ "maximum": 3600000,
1238
+ "default": 2000,
1239
+ "description": "When command duration metadata is present, require at least this runtime before compacting in auto mode."
1240
+ },
1241
+ "liveToolCompactionBlockStructured": {
1242
+ "type": "boolean",
1243
+ "default": true,
1244
+ "description": "Avoid live compaction for likely JSON or other structured outputs where exact bytes matter more than size."
1245
+ },
1246
+ "liveToolCompactionAllowCommands": {
1247
+ "type": "array",
1248
+ "items": { "type": "string" },
1249
+ "default": ["grep", "rg", "find", "git", "go", "npm", "pnpm", "yarn", "node", "python", "python3", "pytest", "docker", "kubectl", "cargo", "gradle", "maven", "mvn", "javac", "tsc", "jest", "vitest", "deno"],
1250
+ "description": "Command or tool families eligible for live compaction."
1251
+ },
1202
1252
  "perType": {
1203
1253
  "type": "object",
1204
1254
  "description": "Per-interaction-type config overrides. Keys: task, manual, primary, chat, voice, flow.",
@@ -1237,3 +1287,4 @@
1237
1287
  }
1238
1288
  }
1239
1289
  }
1290
+