bosun 0.35.2 → 0.35.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.35.2",
3
+ "version": "0.35.3",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache 2.0",
@@ -68,7 +68,8 @@
68
68
  "./compat": "./compat.mjs",
69
69
  "./task-cli": "./task-cli.mjs",
70
70
  "./github-auth-manager": "./github-auth-manager.mjs",
71
- "./git-commit-helpers": "./git-commit-helpers.mjs"
71
+ "./git-commit-helpers": "./git-commit-helpers.mjs",
72
+ "./opencode-shell": "./opencode-shell.mjs"
72
73
  },
73
74
  "bin": {
74
75
  "bosun": "cli.mjs",
@@ -160,6 +161,7 @@
160
161
  "maintenance.mjs",
161
162
  "merge-strategy.mjs",
162
163
  "monitor.mjs",
164
+ "opencode-shell.mjs",
163
165
  "postinstall.mjs",
164
166
  "pr-cleanup-daemon.mjs",
165
167
  "preflight.mjs",
@@ -240,6 +242,7 @@
240
242
  "@anthropic-ai/claude-agent-sdk": "latest",
241
243
  "@github/copilot-sdk": "latest",
242
244
  "@openai/codex-sdk": "latest",
245
+ "@opencode-ai/sdk": "latest",
243
246
  "@preact/signals": "1.3.1",
244
247
  "@whiskeysockets/baileys": "^7.0.0-rc.9",
245
248
  "ajv": "^8.18.0",
package/primary-agent.mjs CHANGED
@@ -39,6 +39,18 @@ import {
39
39
  resetClaudeSession,
40
40
  initClaudeShell,
41
41
  } from "./claude-shell.mjs";
42
+ import {
43
+ execOpencodePrompt,
44
+ steerOpencodePrompt,
45
+ isOpencodeBusy,
46
+ getSessionInfo as getOpencodeSessionInfo,
47
+ resetSession as resetOpencodeSession,
48
+ initOpencodeShell,
49
+ getActiveSessionId as getOpencodeSessionId,
50
+ listSessions as listOpencodeSessions,
51
+ switchSession as switchOpencodeSession,
52
+ createSession as createOpencodeSession,
53
+ } from "./opencode-shell.mjs";
42
54
  import { getModelsForExecutor, normalizeExecutorKey } from "./task-complexity.mjs";
43
55
 
44
56
  /** Valid agent interaction modes */
@@ -179,6 +191,34 @@ const ADAPTERS = {
179
191
  return execClaudePrompt(fullCmd, {});
180
192
  },
181
193
  },
194
+ "opencode-sdk": {
195
+ name: "opencode-sdk",
196
+ provider: "OPENCODE",
197
+ displayName: "OpenCode",
198
+ exec: (msg, opts) => execOpencodePrompt(msg, { persistent: true, ...opts }),
199
+ steer: steerOpencodePrompt,
200
+ isBusy: isOpencodeBusy,
201
+ getInfo: () => getOpencodeSessionInfo(),
202
+ reset: resetOpencodeSession,
203
+ init: async () => {
204
+ await initOpencodeShell();
205
+ return true;
206
+ },
207
+ getSessionId: getOpencodeSessionId,
208
+ listSessions: listOpencodeSessions,
209
+ switchSession: switchOpencodeSession,
210
+ createSession: createOpencodeSession,
211
+ sdkCommands: ["/status", "/model", "/sessions", "/clear"],
212
+ execSdkCommand: async (command, args) => {
213
+ const cmd = command.startsWith("/") ? command : `/${command}`;
214
+ if (cmd === "/clear") {
215
+ await resetOpencodeSession();
216
+ return "Session cleared.";
217
+ }
218
+ const fullCmd = args ? `${cmd} ${args}` : cmd;
219
+ return execOpencodePrompt(fullCmd, { persistent: true });
220
+ },
221
+ },
182
222
  };
183
223
 
184
224
  function envFlagEnabled(value) {
@@ -296,6 +336,8 @@ function normalizePrimaryAgent(value) {
296
336
  return "copilot-sdk";
297
337
  if (["claude", "claude-sdk", "claude_code", "claude-code"].includes(raw))
298
338
  return "claude-sdk";
339
+ if (["opencode", "opencode-sdk", "open-code"].includes(raw))
340
+ return "opencode-sdk";
299
341
  return raw;
300
342
  }
301
343
 
@@ -312,6 +354,7 @@ function executorToAdapter(executor) {
312
354
  const key = normalizeExecutorKey(executor);
313
355
  if (key === "copilot") return "copilot-sdk";
314
356
  if (key === "claude") return "claude-sdk";
357
+ if (key === "opencode") return "opencode-sdk";
315
358
  return "codex-sdk";
316
359
  }
317
360
 
package/setup.mjs CHANGED
@@ -1592,6 +1592,31 @@ const EXECUTOR_PRESETS = {
1592
1592
  role: "primary",
1593
1593
  },
1594
1594
  ],
1595
+ "opencode-only": [
1596
+ {
1597
+ name: "opencode-default",
1598
+ executor: "OPENCODE",
1599
+ variant: "DEFAULT",
1600
+ weight: 100,
1601
+ role: "primary",
1602
+ },
1603
+ ],
1604
+ "opencode-codex": [
1605
+ {
1606
+ name: "opencode-default",
1607
+ executor: "OPENCODE",
1608
+ variant: "DEFAULT",
1609
+ weight: 60,
1610
+ role: "primary",
1611
+ },
1612
+ {
1613
+ name: "codex-backup",
1614
+ executor: "CODEX",
1615
+ variant: "DEFAULT",
1616
+ weight: 40,
1617
+ role: "backup",
1618
+ },
1619
+ ],
1595
1620
  triple: [
1596
1621
  {
1597
1622
  name: "copilot-claude",
@@ -1958,7 +1983,7 @@ function normalizeSetupConfiguration({
1958
1983
  {
1959
1984
  const primaryExec = configJson.executors.find((e) => e.role === "primary");
1960
1985
  if (primaryExec) {
1961
- const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk" };
1986
+ const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk", OPENCODE: "opencode-sdk" };
1962
1987
  env.PRIMARY_AGENT = env.PRIMARY_AGENT ||
1963
1988
  sdkMap[String(primaryExec.executor).toUpperCase()] || "codex-sdk";
1964
1989
  }
@@ -2684,6 +2709,8 @@ async function main() {
2684
2709
  "Copilot + Codex (50/50 split)",
2685
2710
  "Copilot only (Claude Opus 4.6)",
2686
2711
  "Claude only (direct API)",
2712
+ "OpenCode only (local OpenCode server)",
2713
+ "OpenCode + Codex (60/40 split)",
2687
2714
  "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
2688
2715
  "Custom — I'll define my own executors",
2689
2716
  ]
@@ -2692,6 +2719,8 @@ async function main() {
2692
2719
  "Copilot + Codex (50/50 split)",
2693
2720
  "Copilot only (Claude Opus 4.6)",
2694
2721
  "Claude only (direct API)",
2722
+ "OpenCode only (local OpenCode server)",
2723
+ "OpenCode + Codex (60/40 split)",
2695
2724
  "Triple (Copilot Claude 40%, Codex 35%, Copilot GPT 25%)",
2696
2725
  ];
2697
2726
 
@@ -2702,8 +2731,8 @@ async function main() {
2702
2731
  );
2703
2732
 
2704
2733
  const presetNames = isAdvancedSetup
2705
- ? ["codex-only", "copilot-codex", "copilot-only", "claude-only", "triple", "custom"]
2706
- : ["codex-only", "copilot-codex", "copilot-only", "claude-only", "triple"];
2734
+ ? ["codex-only", "copilot-codex", "copilot-only", "claude-only", "opencode-only", "opencode-codex", "triple", "custom"]
2735
+ : ["codex-only", "copilot-codex", "copilot-only", "claude-only", "opencode-only", "opencode-codex", "triple"];
2707
2736
  const presetKey = presetNames[presetIdx] || "codex-only";
2708
2737
 
2709
2738
  if (presetKey === "custom") {
@@ -5183,7 +5212,7 @@ async function runNonInteractive({
5183
5212
  (e) => e.role === "primary",
5184
5213
  );
5185
5214
  if (primaryExec) {
5186
- const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk" };
5215
+ const sdkMap = { CODEX: "codex-sdk", COPILOT: "copilot-sdk", CLAUDE: "claude-sdk", OPENCODE: "opencode-sdk" };
5187
5216
  env.PRIMARY_AGENT = sdkMap[String(primaryExec.executor).toUpperCase()] || "codex-sdk";
5188
5217
  }
5189
5218
  }
package/task-executor.mjs CHANGED
@@ -1869,6 +1869,7 @@ async function commentOnIssue(task, commentBody) {
1869
1869
  * @property {Function} onTaskFailed - callback(task, error)
1870
1870
  * @property {Function} sendTelegram - optional telegram notifier function
1871
1871
  * @property {Object} agentPrompts - optional prompt templates loaded from config
1872
+ * @property {boolean} workflowOwnsTaskLifecycle - Delegate finalization/recovery to workflow automation
1872
1873
  */
1873
1874
 
1874
1875
  /**
@@ -1919,6 +1920,7 @@ class TaskExecutor {
1919
1920
  onTaskFailed: null,
1920
1921
  sendTelegram: null,
1921
1922
  agentPrompts: {},
1923
+ workflowOwnsTaskLifecycle: false,
1922
1924
  };
1923
1925
 
1924
1926
  const merged = { ...defaults, ...options };
@@ -1954,6 +1956,7 @@ class TaskExecutor {
1954
1956
  this.onTaskCompleted = merged.onTaskCompleted;
1955
1957
  this.onTaskFailed = merged.onTaskFailed;
1956
1958
  this.sendTelegram = merged.sendTelegram;
1959
+ this.workflowOwnsTaskLifecycle = merged.workflowOwnsTaskLifecycle === true;
1957
1960
  this._agentPrompts =
1958
1961
  merged.agentPrompts && typeof merged.agentPrompts === "object"
1959
1962
  ? merged.agentPrompts
@@ -5288,6 +5291,26 @@ class TaskExecutor {
5288
5291
  output: (result.output || "").slice(0, 500),
5289
5292
  }).catch(() => {});
5290
5293
 
5294
+ if (result.success && this.workflowOwnsTaskLifecycle) {
5295
+ result.finalized = true;
5296
+ result.finalizationReason = null;
5297
+ if (!result.worktreePath) result.worktreePath = worktreePath || null;
5298
+ if (!result.branch) {
5299
+ result.branch =
5300
+ task.branchName ||
5301
+ task.meta?.branch_name ||
5302
+ null;
5303
+ }
5304
+ if (!result.baseBranch) {
5305
+ result.baseBranch = baseBranch || null;
5306
+ }
5307
+ console.log(
5308
+ `${tag} workflow-owned lifecycle active — delegating finalization/review handoff to workflows`,
5309
+ );
5310
+ this.onTaskCompleted?.(task, result);
5311
+ return;
5312
+ }
5313
+
5291
5314
  if (result.success) {
5292
5315
  console.log(
5293
5316
  `${tag} completed successfully (${result.attempts} attempt(s))`,
@@ -5729,21 +5752,27 @@ class TaskExecutor {
5729
5752
  this.pauseFor(5 * 60 * 1000, "rate-limit");
5730
5753
  }
5731
5754
 
5732
- try {
5733
- await transitionTaskStatus(task.id, "todo", {
5734
- source: "task-executor-failed",
5735
- taskTitle,
5736
- branch: result?.branch || task?.branchName || null,
5737
- baseBranch: result?.baseBranch || baseBranch || null,
5738
- worktreePath,
5739
- error: result?.error || null,
5740
- });
5741
- } catch {
5742
- /* best-effort */
5755
+ if (!this.workflowOwnsTaskLifecycle) {
5756
+ try {
5757
+ await transitionTaskStatus(task.id, "todo", {
5758
+ source: "task-executor-failed",
5759
+ taskTitle,
5760
+ branch: result?.branch || task?.branchName || null,
5761
+ baseBranch: result?.baseBranch || baseBranch || null,
5762
+ worktreePath,
5763
+ error: result?.error || null,
5764
+ });
5765
+ } catch {
5766
+ /* best-effort */
5767
+ }
5768
+ this.sendTelegram?.(
5769
+ `❌ Task failed: "${task.title}" — ${(result.error || "").slice(0, 200)}`,
5770
+ );
5771
+ } else {
5772
+ console.log(
5773
+ `${tag} workflow-owned lifecycle active — delegating failure recovery to workflows`,
5774
+ );
5743
5775
  }
5744
- this.sendTelegram?.(
5745
- `❌ Task failed: "${task.title}" — ${(result.error || "").slice(0, 200)}`,
5746
- );
5747
5776
  this.onTaskFailed?.(task, result);
5748
5777
  }
5749
5778
  }
package/ui/app.js CHANGED
@@ -122,7 +122,7 @@ import {
122
122
  import { DashboardTab } from "./tabs/dashboard.js";
123
123
  import { TasksTab } from "./tabs/tasks.js";
124
124
  import { ChatTab } from "./tabs/chat.js";
125
- import { AgentsTab } from "./tabs/agents.js";
125
+ import { AgentsTab, FleetSessionsTab } from "./tabs/agents.js";
126
126
  import { InfraTab } from "./tabs/infra.js";
127
127
  import { ControlTab } from "./tabs/control.js";
128
128
  import { LogsTab } from "./tabs/logs.js";
@@ -431,6 +431,7 @@ const TAB_COMPONENTS = {
431
431
  tasks: TasksTab,
432
432
  chat: ChatTab,
433
433
  agents: AgentsTab,
434
+ "fleet-sessions": FleetSessionsTab,
434
435
  infra: InfraTab,
435
436
  control: ControlTab,
436
437
  logs: LogsTab,
@@ -560,6 +561,7 @@ function SidebarNav({ collapsed = false, onToggle }) {
560
561
  ${TAB_CONFIG.map((tab) => {
561
562
  const isActive = activeTab.value === tab.id;
562
563
  const isHome = tab.id === "dashboard";
564
+ const isChild = !!tab.parent;
563
565
  let badge = 0;
564
566
  if (tab.id === "tasks") {
565
567
  badge = getActiveTaskCount();
@@ -569,8 +571,8 @@ function SidebarNav({ collapsed = false, onToggle }) {
569
571
  return html`
570
572
  <button
571
573
  key=${tab.id}
572
- class="sidebar-nav-item ${isActive ? "active" : ""}"
573
- style="position:relative"
574
+ class="sidebar-nav-item ${isActive ? "active" : ""} ${isChild ? "sidebar-nav-child" : ""}"
575
+ style=${`position:relative${isChild ? ";padding-left:28px;font-size:0.85em" : ""}`}
574
576
  aria-label=${tab.label}
575
577
  aria-current=${isActive ? "page" : null}
576
578
  title=${collapsed ? tab.label : undefined}
@@ -1582,7 +1584,7 @@ function App() {
1582
1584
  useEffect(() => {
1583
1585
  const el = mainRef.current;
1584
1586
  if (!el) return;
1585
- const swipeTabs = TAB_CONFIG.filter((t) => t.id !== "settings");
1587
+ const swipeTabs = TAB_CONFIG.filter((t) => t.id !== "settings" && !t.parent);
1586
1588
  let startX = 0;
1587
1589
  let startY = 0;
1588
1590
  let startTime = 0;
@@ -1643,11 +1645,12 @@ function App() {
1643
1645
  }, []);
1644
1646
 
1645
1647
  const CurrentTab = TAB_COMPONENTS[activeTab.value] || DashboardTab;
1646
- const isChatOrAgents = activeTab.value === "chat" || activeTab.value === "agents";
1647
- const showSessionRail = isDesktop && isChatOrAgents;
1648
+ const isChatOrAgents = activeTab.value === "chat" || activeTab.value === "agents" || activeTab.value === "fleet-sessions";
1649
+ const isChat = activeTab.value === "chat";
1650
+ const showSessionRail = isDesktop && isChat;
1648
1651
  const showInspector = isDesktop && isChatOrAgents;
1649
1652
  const showBottomNav = !isDesktop;
1650
- const railSessionType = activeTab.value === "agents" ? "task" : "primary";
1653
+ const railSessionType = "primary";
1651
1654
  const showDrawerToggles = isTablet;
1652
1655
  const showInspectorToggle = isTablet && isChatOrAgents;
1653
1656
 
@@ -16,6 +16,7 @@ import {
16
16
  loadSessionMessages,
17
17
  loadSessions,
18
18
  sessionsData,
19
+ sessionPagination,
19
20
  } from "./session-list.js";
20
21
  import {
21
22
  pendingMessages,
@@ -444,11 +445,12 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
444
445
  const [paused, setPaused] = useState(false);
445
446
  const [autoScroll, setAutoScroll] = useState(true);
446
447
  const [unreadCount, setUnreadCount] = useState(0);
447
- const [visibleCount, setVisibleCount] = useState(200);
448
+ const [visibleCount, setVisibleCount] = useState(20);
448
449
  const [showStreamMeta, setShowStreamMeta] = useState(false);
449
450
  const [pendingAttachments, setPendingAttachments] = useState([]);
450
451
  const [uploadingAttachments, setUploadingAttachments] = useState(false);
451
452
  const [dragActive, setDragActive] = useState(false);
453
+ const [loadingOlder, setLoadingOlder] = useState(false);
452
454
  const [filters, setFilters] = useState({
453
455
  tool: false,
454
456
  result: false,
@@ -611,10 +613,23 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
611
613
  const refreshMessages = useCallback(async () => {
612
614
  if (!sessionId) return;
613
615
  setLoading(true);
614
- const res = await loadSessionMessages(sessionId).finally(() => setLoading(false));
616
+ const res = await loadSessionMessages(sessionId, { limit: 20 }).finally(() => setLoading(false));
615
617
  setLoadError(res?.ok ? null : res?.error || "unavailable");
616
618
  }, [sessionId]);
617
619
 
620
+ /** Load older messages (triggered by scroll-to-top or "Load older" button) */
621
+ const loadOlderMessages = useCallback(async () => {
622
+ if (!sessionId || loadingOlder) return;
623
+ const pag = sessionPagination.value;
624
+ if (!pag || !pag.hasMore) return;
625
+ setLoadingOlder(true);
626
+ const newOffset = Math.max(0, pag.offset - 20);
627
+ const limit = pag.offset - newOffset;
628
+ if (limit <= 0) { setLoadingOlder(false); return; }
629
+ await loadSessionMessages(sessionId, { limit, offset: newOffset, prepend: true })
630
+ .finally(() => setLoadingOlder(false));
631
+ }, [sessionId, loadingOlder]);
632
+
618
633
  /* Load messages on mount; WS push via initSessionWsListener handles real-time */
619
634
  useEffect(() => {
620
635
  if (!sessionId) return;
@@ -624,7 +639,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
624
639
  if (!active) return;
625
640
  if (!paused) {
626
641
  setLoading(true);
627
- loadSessionMessages(sessionId).then((res) => {
642
+ loadSessionMessages(sessionId, { limit: 20 }).then((res) => {
628
643
  if (active) setLoadError(res?.ok ? null : res?.error || "unavailable");
629
644
  }).finally(() => {
630
645
  if (active) setLoading(false);
@@ -637,7 +652,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
637
652
  // Fallback: poll slowly as safety net (30s) - WS does the heavy lifting
638
653
  const interval = setInterval(() => {
639
654
  if (active && !paused) {
640
- loadSessionMessages(sessionId).then((res) => {
655
+ loadSessionMessages(sessionId, { limit: 20 }).then((res) => {
641
656
  if (active && res?.ok === false) setLoadError(res?.error || "unavailable");
642
657
  });
643
658
  }
@@ -673,7 +688,7 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
673
688
 
674
689
  /* Reset visible window when session or filters change */
675
690
  useEffect(() => {
676
- setVisibleCount(200);
691
+ setVisibleCount(20);
677
692
  setUnreadCount(0);
678
693
  setAutoScroll(true);
679
694
  }, [sessionId, filterKey]);
@@ -1076,16 +1091,23 @@ export function ChatView({ sessionId, readOnly = false, embedded = false }) {
1076
1091
  `}
1077
1092
 
1078
1093
  <div class="chat-messages" ref=${messagesRef}>
1079
- ${hasMoreMessages && html`
1094
+ ${(hasMoreMessages || sessionPagination.value?.hasMore) && html`
1080
1095
  <div class="chat-load-earlier">
1081
1096
  <button
1082
1097
  class="btn btn-ghost btn-sm"
1083
- onClick=${() => setVisibleCount((prev) => prev + 200)}
1098
+ disabled=${loadingOlder}
1099
+ onClick=${() => {
1100
+ if (hasMoreMessages) {
1101
+ setVisibleCount((prev) => prev + 20);
1102
+ } else if (sessionPagination.value?.hasMore) {
1103
+ loadOlderMessages();
1104
+ }
1105
+ }}
1084
1106
  >
1085
- Load earlier messages
1107
+ ${loadingOlder ? "Loading…" : "Load older messages"}
1086
1108
  </button>
1087
1109
  <span class="chat-load-count">
1088
- Showing ${visibleMessages.length} of ${filteredMessages.length}
1110
+ Showing ${visibleMessages.length} of ${sessionPagination.value?.total || filteredMessages.length}
1089
1111
  </span>
1090
1112
  </div>
1091
1113
  `}
@@ -17,6 +17,8 @@ export const sessionsData = signal([]);
17
17
  export const selectedSessionId = signal(null);
18
18
  export const sessionMessages = signal([]);
19
19
  export const sessionsError = signal(null);
20
+ /** Pagination metadata from the last loadSessionMessages call */
21
+ export const sessionPagination = signal(null);
20
22
 
21
23
  let _wsListenerReady = false;
22
24
 
@@ -42,20 +44,34 @@ export async function loadSessions(filter = {}) {
42
44
  }
43
45
  }
44
46
 
45
- export async function loadSessionMessages(id) {
47
+ export async function loadSessionMessages(id, opts = {}) {
46
48
  try {
47
- const url = sessionPath(id);
49
+ let url = sessionPath(id);
48
50
  if (!url) return { ok: false, error: "invalid" };
51
+ const params = new URLSearchParams();
52
+ if (opts.limit) params.set("limit", String(opts.limit));
53
+ if (opts.offset != null) params.set("offset", String(opts.offset));
54
+ const qs = params.toString();
55
+ if (qs) url += `?${qs}`;
49
56
  const res = await apiFetch(url, { _silent: true });
50
57
  if (res?.session) {
51
58
  const normalized = dedupeMessages(res.session.messages || []);
52
- sessionMessages.value = normalized;
53
- return { ok: true, messages: normalized };
59
+ if (opts.prepend && sessionMessages.value?.length) {
60
+ // Prepend older messages (loading history on scroll up)
61
+ const merged = dedupeMessages([...normalized, ...sessionMessages.value]);
62
+ sessionMessages.value = merged;
63
+ } else {
64
+ sessionMessages.value = normalized;
65
+ }
66
+ sessionPagination.value = res.pagination || null;
67
+ return { ok: true, messages: normalized, pagination: res.pagination || null };
54
68
  }
55
69
  sessionMessages.value = [];
70
+ sessionPagination.value = null;
56
71
  return { ok: false, error: "empty" };
57
72
  } catch {
58
73
  sessionMessages.value = [];
74
+ sessionPagination.value = null;
59
75
  return { ok: false, error: "unavailable" };
60
76
  }
61
77
  }
@@ -20,6 +20,7 @@ const ROUTE_TABS = new Set([
20
20
  "chat",
21
21
  "workflows",
22
22
  "agents",
23
+ "fleet-sessions",
23
24
  "control",
24
25
  "infra",
25
26
  "logs",
@@ -232,6 +233,7 @@ export const TAB_CONFIG = [
232
233
  { id: "chat", label: "Chat", icon: "chat" },
233
234
  { id: "workflows", label: "Flows", icon: "workflow" },
234
235
  { id: "agents", label: "Fleet", icon: "cpu" },
236
+ { id: "fleet-sessions", label: "Sessions", icon: "chat", parent: "agents" },
235
237
  { id: "control", label: "Control", icon: "sliders" },
236
238
  { id: "infra", label: "Infra", icon: "server" },
237
239
  { id: "logs", label: "Logs", icon: "terminal" },
package/ui/tabs/agents.js CHANGED
@@ -1503,14 +1503,7 @@ export function AgentsTab() {
1503
1503
  </div>
1504
1504
 
1505
1505
  <div class="fleet-span">
1506
- <${FleetSessionsPanel}
1507
- slots=${slots}
1508
- onOpenWorkspace=${openWorkspace}
1509
- onForceStop=${handleForceStop}
1510
- />
1511
- </div>
1512
-
1513
- ${agents.length > 0 &&
1506
+ ${agents.length > 0 &&
1514
1507
  html`
1515
1508
  <div class="fleet-span">
1516
1509
  <${Collapsible} title="Agent Threads" defaultOpen=${false}>
@@ -1971,3 +1964,68 @@ function FleetSessionsPanel({ slots, onOpenWorkspace, onForceStop }) {
1971
1964
  <//>
1972
1965
  `;
1973
1966
  }
1967
+
1968
+ /* ─── Fleet Sessions Tab (standalone) ─── */
1969
+ export function FleetSessionsTab() {
1970
+ const executor = executorData.value;
1971
+ const execData = executor?.data;
1972
+ const slots = execData?.slots || [];
1973
+
1974
+ useEffect(() => {
1975
+ let active = true;
1976
+ const refreshTaskSessions = () => {
1977
+ if (!active) return;
1978
+ loadSessions({ type: "task" });
1979
+ };
1980
+ refreshTaskSessions();
1981
+ const interval = setInterval(refreshTaskSessions, 5000);
1982
+ return () => {
1983
+ active = false;
1984
+ clearInterval(interval);
1985
+ };
1986
+ }, []);
1987
+
1988
+ /* Force stop a specific agent slot */
1989
+ const handleForceStop = async (slot) => {
1990
+ const ok = await showConfirm(
1991
+ `Force-stop agent working on "${truncate(slot.taskTitle || slot.taskId || "task", 40)}"?`,
1992
+ );
1993
+ if (!ok) return;
1994
+ haptic("heavy");
1995
+ try {
1996
+ await apiFetch("/api/executor/stop-slot", {
1997
+ method: "POST",
1998
+ body: JSON.stringify({ slotIndex: slot.index, taskId: slot.taskId }),
1999
+ });
2000
+ showToast("Stop signal sent", "success");
2001
+ scheduleRefresh(200);
2002
+ } catch {
2003
+ /* toast via apiFetch */
2004
+ }
2005
+ };
2006
+
2007
+ /* Open workspace viewer for an agent */
2008
+ const [selectedAgent, setSelectedAgent] = useState(null);
2009
+ const openWorkspace = (slot, i) => {
2010
+ haptic();
2011
+ setSelectedAgent({ ...slot, index: i });
2012
+ };
2013
+
2014
+ return html`
2015
+ <div class="fleet-layout">
2016
+ <div class="fleet-span">
2017
+ <${FleetSessionsPanel}
2018
+ slots=${slots}
2019
+ onOpenWorkspace=${openWorkspace}
2020
+ onForceStop=${handleForceStop}
2021
+ />
2022
+ </div>
2023
+ </div>
2024
+ ${selectedAgent && html`
2025
+ <${WorkspaceViewer}
2026
+ agent=${selectedAgent}
2027
+ onClose=${() => setSelectedAgent(null)}
2028
+ />
2029
+ `}
2030
+ `;
2031
+ }