@polderlabs/bizar 4.3.0 → 4.4.1

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 (57) hide show
  1. package/bizar-dash/src/server/opencode-sdk.mjs +72 -0
  2. package/bizar-dash/src/server/routes/background.mjs +92 -0
  3. package/bizar-dash/src/server/routes/chat.mjs +300 -123
  4. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +26 -0
  5. package/bizar-dash/src/server/routes/tasks.mjs +59 -1
  6. package/bizar-dash/src/server/task-delegator.mjs +154 -8
  7. package/bizar-dash/src/server/tasks-store.mjs +50 -2
  8. package/bizar-dash/src/web/components/background/AttachButton.tsx +96 -0
  9. package/bizar-dash/src/web/components/background/TmuxAttachCard.tsx +122 -0
  10. package/bizar-dash/src/web/components/chat/AgentNode.tsx +127 -0
  11. package/bizar-dash/src/web/components/chat/AgentTree.tsx +80 -0
  12. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +76 -0
  13. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +144 -0
  14. package/bizar-dash/src/web/components/chat/ChatRail.tsx +387 -0
  15. package/bizar-dash/src/web/components/chat/ChatThread.tsx +28 -7
  16. package/bizar-dash/src/web/components/chat/JumpToLatest.tsx +58 -0
  17. package/bizar-dash/src/web/components/chat/MessageBlock.tsx +260 -0
  18. package/bizar-dash/src/web/components/chat/SessionRowMenu.tsx +206 -0
  19. package/bizar-dash/src/web/components/chat/StreamingIndicator.tsx +33 -5
  20. package/bizar-dash/src/web/components/chat/_legacy.ts +30 -0
  21. package/bizar-dash/src/web/components/chat/index.ts +11 -0
  22. package/bizar-dash/src/web/components/chat/useChat.ts +345 -167
  23. package/bizar-dash/src/web/components/tasks/BacklogPanel.css +109 -0
  24. package/bizar-dash/src/web/components/tasks/BacklogPanel.tsx +209 -0
  25. package/bizar-dash/src/web/styles/chat.css +1536 -133
  26. package/bizar-dash/src/web/views/BackgroundAgents.tsx +3 -0
  27. package/bizar-dash/src/web/views/Chat.tsx +147 -57
  28. package/bizar-dash/src/web/views/Tasks.tsx +23 -1
  29. package/cli/bg.mjs +94 -71
  30. package/cli/bin.mjs +36 -8
  31. package/cli/service-controller.mjs +587 -0
  32. package/cli/service-controller.test.mjs +92 -0
  33. package/cli/service.mjs +162 -14
  34. package/config/agents/baldr.md +2 -0
  35. package/config/agents/browser-harness.md +2 -0
  36. package/config/agents/forseti.md +2 -0
  37. package/config/agents/frigg.md +2 -0
  38. package/config/agents/heimdall.md +2 -0
  39. package/config/agents/hermod.md +2 -0
  40. package/config/agents/mimir.md +2 -0
  41. package/config/agents/odin.md +2 -0
  42. package/config/agents/quick.md +2 -0
  43. package/config/agents/semble-search.md +2 -0
  44. package/config/agents/thor.md +2 -0
  45. package/config/agents/tyr.md +2 -0
  46. package/config/agents/vidarr.md +2 -0
  47. package/config/agents/vor.md +2 -0
  48. package/config/opencode.json.template +1 -0
  49. package/install.sh +448 -787
  50. package/package.json +2 -2
  51. package/packages/sdk/package.json +20 -0
  52. package/packages/sdk/src/client.ts +5 -0
  53. package/packages/sdk/src/errors.ts +11 -2
  54. package/packages/sdk/src/index.ts +19 -0
  55. package/packages/sdk/src/opencode-events.ts +134 -0
  56. package/packages/sdk/src/opencode-types.ts +66 -0
  57. package/packages/sdk/src/opencode.ts +335 -0
@@ -21,6 +21,7 @@ import { useModal } from '../components/Modal';
21
21
  import { useToast } from '../components/Toast';
22
22
  import { BgStatusBadge } from '../components/BgStatusBadge';
23
23
  import { openKillConfirmDialog } from '../components/KillConfirmDialog';
24
+ import { TmuxAttachCard } from '../components/background/TmuxAttachCard';
24
25
  import { api } from '../lib/api';
25
26
  import { Ws } from '../lib/ws';
26
27
  import { cn, formatTime, truncate } from '../lib/utils';
@@ -283,6 +284,8 @@ function BackgroundAgentCard({
283
284
  <Trash2 size={14} /> Kill
284
285
  </Button>
285
286
  </div>
287
+
288
+ <TmuxAttachCard instance={inst} />
286
289
  </Card>
287
290
  );
288
291
  }
@@ -1,13 +1,22 @@
1
- // src/views/Chat.tsx — Gemini-inspired dark chat orchestrator.
2
- // Composer state (agent, model, text, attachments) is local.
3
- // useChat handles messages, sessions, send, polling.
1
+ // src/views/Chat.tsx — Gemini-inspired dark chat orchestrator (v3.22).
2
+ //
3
+ // v3.22 redesigned layout: 3-column grid (rail / thread / info).
4
+ // The legacy `.chat-shell / .chat-body / .chat-sessions / .chat-main /
5
+ // .chat-info` wrapper classes are still available for MobileChat (which
6
+ // uses its own view); this file uses the new `.chat-page` grid.
7
+ //
8
+ // The `chat-thread` section is a vertical grid (head / scroll / composer).
9
+ // The scrollable message list lives inside the legacy `ChatThread`
10
+ // component (still wrapped in its own `.chat-thread` div with className
11
+ // `legacy` to opt into the original styles — see chat.css).
4
12
 
5
- import { useEffect, useRef, useState } from 'react';
13
+ import { useEffect, useMemo, useRef, useState } from 'react';
6
14
  import { ChatTopBar } from '../components/chat/ChatTopBar';
7
- import { SessionList } from '../components/chat/SessionList';
15
+ import { ChatRail } from '../components/chat/ChatRail';
8
16
  import { ChatThread } from '../components/chat/ChatThread';
9
- import { FloatingComposer } from '../components/chat/FloatingComposer';
10
- import { InfoPanel } from '../components/chat/InfoPanel';
17
+ import { ChatComposer } from '../components/chat/ChatComposer';
18
+ import { ChatInfoPanel } from '../components/chat/ChatInfoPanel';
19
+ import { JumpToLatest } from '../components/chat/JumpToLatest';
11
20
  import { useChat } from '../components/chat/useChat';
12
21
  import { useSlashCommands } from '../components/chat/useSlashCommands';
13
22
  import { useToast } from '../components/Toast';
@@ -28,7 +37,6 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
28
37
  const toast = useToast();
29
38
  const modal = useModal();
30
39
 
31
- // ── Delegate to useChat: messages, sessions, send, polling ──────────────
32
40
  const chat = useChat(snapshot, settings, initialTaskId ?? '');
33
41
 
34
42
  // ── Composer state (local) ───────────────────────────────────────────────
@@ -39,12 +47,13 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
39
47
  const [creating, setCreating] = useState(false);
40
48
 
41
49
  const fileInputRef = useRef<HTMLInputElement>(null);
42
- const inputRef = useRef<HTMLTextAreaElement>(null);
43
50
 
44
51
  // ── Slash commands ───────────────────────────────────────────────────────
45
52
  const { allCommands, suggestions, setQuery } = useSlashCommands(snapshot);
46
53
 
47
- useEffect(() => { setQuery(text); }, [text, setQuery]);
54
+ useEffect(() => {
55
+ setQuery(text);
56
+ }, [text, setQuery]);
48
57
 
49
58
  // ── File attach ──────────────────────────────────────────────────────────
50
59
  const onAttach = () => fileInputRef.current?.click();
@@ -68,6 +77,7 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
68
77
  setText('');
69
78
  setQuery('');
70
79
  chat.onSend(msg, agent, model, attachments);
80
+ chat.jumpToLatest();
71
81
  };
72
82
 
73
83
  // ── Create session ───────────────────────────────────────────────────────
@@ -96,7 +106,9 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
96
106
  children: <p style={{ margin: 0 }}>This action cannot be undone.</p>,
97
107
  footer: (
98
108
  <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
99
- <Button variant="secondary" size="sm" onClick={() => modal.close()}>Cancel</Button>
109
+ <Button variant="secondary" size="sm" onClick={() => modal.close()}>
110
+ Cancel
111
+ </Button>
100
112
  <Button
101
113
  variant="danger"
102
114
  size="sm"
@@ -112,7 +124,42 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
112
124
  });
113
125
  };
114
126
 
115
- // ── Pin / copy are handled directly via chat.* ──────────────────────────
127
+ // ── Derived: sessions with display state overlaid ────────────────────────
128
+ const displaySessions = useMemo(
129
+ () => chat.sessions.map((s) => chat.getSessionDisplay(s)),
130
+ [chat.sessions, chat.getSessionDisplay],
131
+ );
132
+ const displayOpencodeSessions = useMemo(
133
+ () => chat.opencodeSessions.map((s) => chat.getSessionDisplay(s)),
134
+ [chat.opencodeSessions, chat.getSessionDisplay],
135
+ );
136
+
137
+ // ── Current session for the thread head subtitle ────────────────────────
138
+ const activeSessionDisplay = useMemo(() => {
139
+ const id = chat.activeSource === 'opencode'
140
+ ? chat.activeOpencodeSessionId ?? ''
141
+ : chat.sessionId;
142
+ if (!id) return null;
143
+ return (
144
+ displaySessions.find((s) => s.id === id) ??
145
+ displayOpencodeSessions.find((s) => s.id === id) ??
146
+ null
147
+ );
148
+ }, [
149
+ chat.activeSource,
150
+ chat.activeOpencodeSessionId,
151
+ chat.sessionId,
152
+ displaySessions,
153
+ displayOpencodeSessions,
154
+ ]);
155
+
156
+ const threadSubtitle = (() => {
157
+ const base = `${agent || 'Odin'} · ${snapshot.activeProject?.name ?? 'no project'}`;
158
+ const state = activeSessionDisplay?.state ?? 'idle';
159
+ if (state === 'streaming') return `Replying · ${base}`;
160
+ if (state === 'awaiting') return `Your turn · ${base}`;
161
+ return `${base} · idle`;
162
+ })();
116
163
 
117
164
  return (
118
165
  <div className="chat-shell">
@@ -125,35 +172,71 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
125
172
  onToggleInfo={() => {}}
126
173
  onOpenOverview={() => setActiveTab?.('overview')}
127
174
  />
128
- <div className="chat-body">
129
- <aside className="chat-sessions">
130
- <SessionList
131
- sessions={chat.sessions}
132
- opencodeSessions={chat.opencodeSessions}
133
- activeSessionId={chat.sessionId}
134
- activeOpencodeSessionId={chat.activeOpencodeSessionId}
135
- activeProject={snapshot.activeProject}
136
- onCreateSession={handleCreateSession}
137
- onSelectSession={chat.selectBizarSession}
138
- onSelectOpencodeSession={(s) => chat.loadOpencodeSession(s.id)}
139
- creating={creating}
140
- />
141
- </aside>
142
-
143
- <main className="chat-main">
144
- <ChatThread
145
- messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
146
- loading={chat.loading}
147
- activeProject={snapshot.activeProject}
148
- sessionId={chat.activeSource === 'opencode' ? (chat.activeOpencodeSessionId ?? chat.sessionId) : chat.sessionId}
149
- pinned={chat.pinned}
150
- onPickSuggestion={(t) => setText(t)}
151
- onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
152
- onDelete={handleDelete}
153
- onTogglePin={chat.togglePin}
154
- onRegenerate={chat.onRegenerate}
155
- />
156
- <FloatingComposer
175
+ <div className="chat-page">
176
+ {/* ── rail ──────────────────────────────────────────────────────── */}
177
+ <ChatRail
178
+ sessions={displaySessions}
179
+ opencodeSessions={displayOpencodeSessions}
180
+ activeSessionId={chat.sessionId}
181
+ activeOpencodeSessionId={chat.activeOpencodeSessionId}
182
+ activeProject={snapshot.activeProject}
183
+ creating={creating}
184
+ onCreateSession={handleCreateSession}
185
+ onSelectSession={chat.selectBizarSession}
186
+ onSelectOpencodeSession={(s) => chat.loadOpencodeSession(s.id)}
187
+ onRenameSession={(id, title) => chat.renameSession(id, title)}
188
+ onDeleteSession={(id) => chat.deleteSession(id)}
189
+ />
190
+
191
+ {/* ── thread ───────────────────────────────────────────────────── */}
192
+ <section className="chat-thread-section">
193
+ <div className="chat-thread-head">
194
+ <div>
195
+ <div className="chat-thread-title">
196
+ {activeSessionDisplay?.title ?? chat.sessionId ?? 'New chat'}
197
+ </div>
198
+ <div
199
+ className={`chat-thread-sub chat-muted state-${activeSessionDisplay?.state ?? 'idle'}`}
200
+ >
201
+ <span className="chat-thread-dot" />
202
+ {threadSubtitle}
203
+ </div>
204
+ </div>
205
+ <div className="chat-thread-actions">
206
+ <button className="btn btn-ghost" title="Rename" type="button">
207
+ <span className="mono">rename</span>
208
+ </button>
209
+ </div>
210
+ </div>
211
+
212
+ <div className="chat-thread-scroll" ref={chat.listRef} onScroll={chat.handleScroll}>
213
+ <ChatThread
214
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
215
+ loading={chat.loading}
216
+ activeProject={snapshot.activeProject}
217
+ sessionId={
218
+ chat.activeSource === 'opencode'
219
+ ? (chat.activeOpencodeSessionId ?? chat.sessionId)
220
+ : chat.sessionId
221
+ }
222
+ pinned={chat.pinned}
223
+ onPickSuggestion={(t) => setText(t)}
224
+ onCopy={(m) => chat.copyMessage(m as Parameters<typeof chat.copyMessage>[0])}
225
+ onDelete={handleDelete}
226
+ onTogglePin={chat.togglePin}
227
+ onRegenerate={chat.onRegenerate}
228
+ />
229
+ </div>
230
+
231
+ {!chat.stickToBottom && (
232
+ <JumpToLatest
233
+ streaming={activeSessionDisplay?.state === 'streaming'}
234
+ newMessageCount={chat.newMessageCount}
235
+ onClick={chat.jumpToLatest}
236
+ />
237
+ )}
238
+
239
+ <ChatComposer
157
240
  agent={agent}
158
241
  setAgent={setAgent}
159
242
  model={model}
@@ -168,24 +251,31 @@ export function Chat({ snapshot, settings, setActiveTab, initialTaskId, onClearT
168
251
  onPickSuggestion={(cmd) => setText(`${cmd.split(' ')[0]} `)}
169
252
  agents={snapshot.agents || []}
170
253
  onAttach={onAttach}
171
- sessionsOpen={true}
172
- infoOpen={true}
173
254
  />
174
- <input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={onFiles} />
175
- </main>
176
-
177
- <aside className="chat-info">
178
- <InfoPanel
179
- sessionId={chat.activeSource === 'opencode' ? (chat.activeOpencodeSessionId ?? chat.sessionId) : chat.sessionId}
180
- messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
181
- pinned={chat.pinned}
182
- agent={agent}
183
- model={model}
184
- agents={snapshot.agents || []}
185
- mcps={snapshot.mcps || []}
186
- allCommands={allCommands}
255
+ <input
256
+ ref={fileInputRef}
257
+ type="file"
258
+ multiple
259
+ style={{ display: 'none' }}
260
+ onChange={onFiles}
187
261
  />
188
- </aside>
262
+ </section>
263
+
264
+ {/* ── info panel ──────────────────────────────────────────────── */}
265
+ <ChatInfoPanel
266
+ sessionId={
267
+ chat.activeSource === 'opencode'
268
+ ? (chat.activeOpencodeSessionId ?? chat.sessionId)
269
+ : chat.sessionId
270
+ }
271
+ messages={chat.activeSource === 'opencode' ? chat.opencodeMessages : chat.bizarMessages}
272
+ pinned={chat.pinned}
273
+ agent={agent}
274
+ model={model}
275
+ agents={snapshot.agents || []}
276
+ mcps={snapshot.mcps || []}
277
+ allCommands={allCommands}
278
+ />
189
279
  </div>
190
280
  </div>
191
281
  );
@@ -30,6 +30,7 @@ import {
30
30
  RefreshCw,
31
31
  Send,
32
32
  FileText,
33
+ Inbox,
33
34
  } from 'lucide-react';
34
35
  import { Button } from '../components/Button';
35
36
  import { Card, CardTitle } from '../components/Card';
@@ -45,6 +46,7 @@ import { api, enhancePrompt } from '../lib/api';
45
46
  import { cn, formatRelative, priorityColors, autoTitleFromContent } from '../lib/utils';
46
47
  import type { Agent, Settings, Snapshot, Task } from '../lib/types';
47
48
  import { openArtifactViewer } from '../components/ArtifactViewer';
49
+ import { BacklogPanel } from '../components/tasks/BacklogPanel';
48
50
 
49
51
  type Props = {
50
52
  snapshot: Snapshot;
@@ -319,6 +321,10 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
319
321
  openArtifactViewer(modal, artifactId);
320
322
  };
321
323
 
324
+ // v3.22 — Backlog panel visibility.
325
+ const [showBacklog, setShowBacklog] = useState(false);
326
+ const backlogCount = (snapshot.tasks || []).filter((t) => t.status === 'backlog').length;
327
+
322
328
  return (
323
329
  <div className="view view-tasks">
324
330
  <header className="view-header">
@@ -447,9 +453,25 @@ export function Tasks({ snapshot, refreshSnapshot, setActiveTab }: Props) {
447
453
  >
448
454
  <Plus size={14} /> Add
449
455
  </Button>
456
+ {backlogCount > 0 && (
457
+ <Button
458
+ variant={showBacklog ? 'accent' : 'ghost'}
459
+ size="sm"
460
+ onClick={() => setShowBacklog((v) => !v)}
461
+ title={showBacklog ? 'Hide backlog' : 'Show backlog'}
462
+ >
463
+ <Inbox size={14} />
464
+ Backlog
465
+ <span className="badge">{backlogCount}</span>
466
+ </Button>
467
+ )}
450
468
  </div>
451
469
  </div>
452
470
 
471
+ {showBacklog && backlogCount > 0 && (
472
+ <BacklogPanel agents={snapshot.agents || []} onRefresh={refreshSnapshot} />
473
+ )}
474
+
453
475
  {selected.size > 0 && (
454
476
  <div className="task-bulk-bar">
455
477
  <span className="text-sm">
@@ -900,7 +922,7 @@ function formatDuration(seconds: number): string {
900
922
  return `${m}m`;
901
923
  }
902
924
 
903
- function openTaskModal(
925
+ export function openTaskModal(
904
926
  modal: ReturnType<typeof useModal>,
905
927
  toast: ReturnType<typeof useToast>,
906
928
  task: Task | null,
package/cli/bg.mjs CHANGED
@@ -15,10 +15,11 @@
15
15
  * kill <id> Kill a running agent
16
16
  * logs <id> Tail the agent's log file
17
17
  *
18
- * The "view" subcommand is the headline feature: it gives the user
19
- * one terminal window with all running agents visible at once via
20
- * tmux splits. This is the antidote to "is the agent doing
21
- * anything?" — you can SEE them all working in parallel.
18
+ * The "view" subcommand (v3.22) is the headline feature: it creates a
19
+ * tmux control session (`bizar-bg-view`) with one pane per running
20
+ * agent (each pane attached to the agent's own tmux session, tiled).
21
+ * This is the antidote to "is the agent doing anything?" — you can
22
+ * SEE them all working in parallel.
22
23
  */
23
24
  import { execFileSync, spawn, spawnSync } from 'node:child_process';
24
25
  import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
@@ -245,59 +246,112 @@ async function runKill(instanceId) {
245
246
  // --- view ----------------------------------------------------------------
246
247
 
247
248
  /**
248
- * Build a tmux "control session" that splits into N panes, each
249
- * showing the log of one running agent. The session name is fixed
250
- * (`bgr_view`) so subsequent `bizar bg view` calls reuse it.
249
+ * View subcommand (v3.22):
250
+ * - Detects tmux. If missing, prints per-instance `bizar bg logs <id>`
251
+ * fallback instructions and exits 0.
252
+ * - Creates a fresh tmux control session `bizar-bg-view` with one pane
253
+ * per running instance, each pane running `tmux attach -t <session>`.
254
+ * - Caps at 16 panes (beyond that, tmux tiled layout degrades).
255
+ * - Zero running instances → prints "no running agents" and exits 0.
251
256
  */
252
- function buildTmuxControlSession() {
257
+
258
+ async function runView() {
253
259
  const all = readAllBgInstances();
254
- const tmux = listBgrTmuxSessions();
260
+ const tmuxSessions = listBgrTmuxSessions();
255
261
  const running = all.filter(
262
+ (e) => e.data.status === 'running' || e.data.status === 'pending',
263
+ );
264
+
265
+ // Zero running agents — not an error.
266
+ if (running.length === 0) {
267
+ console.log(chalk.dim('\n No running background agents.\n'));
268
+ return 0;
269
+ }
270
+
271
+ // Tmux presence check. If missing, print fallback instructions and exit 0
272
+ // (not an error — the operator can still use per-instance logs).
273
+ if (!which('tmux')) {
274
+ console.log(chalk.yellow('\n ⚠ tmux is not installed on this host.\n'));
275
+ console.log(chalk.dim(' You can inspect individual agents via:\n'));
276
+ for (const r of running) {
277
+ console.log(chalk.cyan(` bizar bg logs ${r.data.instanceId}`));
278
+ }
279
+ console.log();
280
+ return 0;
281
+ }
282
+
283
+ // Filter to instances that actually have a live tmux session.
284
+ const active = running.filter(
256
285
  (e) =>
257
- (e.data.status === 'running' || e.data.status === 'pending') &&
258
286
  e.data.sessionId &&
259
- tmux.includes(tmuxSessionForSessionId(e.data.sessionId)),
287
+ tmuxSessions.includes(tmuxSessionForSessionId(e.data.sessionId)),
260
288
  );
261
289
 
262
- if (running.length === 0) {
263
- return { ok: false, reason: 'no-running-agents' };
290
+ if (active.length === 0) {
291
+ console.log(chalk.dim('\n No agents with active tmux sessions found.\n'));
292
+ console.log(chalk.dim(' State files exist but no tmux sessions are running.\n'));
293
+ console.log(chalk.dim(' Run `bizar bg list` to inspect state.\n'));
294
+ return 0;
264
295
  }
265
296
 
266
- // 1. Create a fresh control session (or replace the existing one).
297
+ // Cap at 16 panes beyond that, tiled layout is unusable.
298
+ const capped = active.slice(0, 16);
299
+ const overflow = active.length - capped.length;
300
+
301
+ // Kill any stale bizar-bg-view session, then create a fresh one.
267
302
  try {
268
- execFileSync('tmux', ['kill-session', '-t', 'bgr_view'], { stdio: 'pipe' });
303
+ execFileSync('tmux', ['kill-session', '-t', 'bizar-bg-view'], { stdio: 'pipe' });
269
304
  } catch {
270
- // didn't exist
305
+ /* didn't exist */
271
306
  }
272
307
  execFileSync('tmux', [
273
- 'new-session', '-d', '-s', 'bgr_view',
308
+ 'new-session', '-d', '-s', 'bizar-bg-view',
274
309
  '-x', '220', '-y', '50',
275
310
  ], { stdio: 'pipe' });
276
311
 
277
- // 2. For each running agent after the first, split the pane
278
- // and attach a tail in the new pane.
279
- for (let i = 1; i < running.length; i++) {
280
- const splitCmd = i % 2 === 1 ? 'split-window -h -t bgr_view' : 'split-window -v -t bgr_view';
281
- execFileSync('tmux', splitCmd.split(' '), { stdio: 'pipe' });
282
- }
283
- // Apply tiled layout for a clean grid.
284
- execFileSync('tmux', ['select-layout', '-t', 'bgr_view', 'tiled'], { stdio: 'pipe' });
285
-
286
- // 3. Send a label + tail command to each pane.
287
- for (let i = 0; i < running.length; i++) {
288
- const r = running[i];
289
- const tmuxName = tmuxSessionForSessionId(r.data.sessionId);
290
- const logPath = r.data.logPath || join(homedir(), '.cache', 'bizar', 'logs', `${r.data.sessionId}.log`);
291
- const header = `echo "═══ ${r.data.agent} │ ${r.data.instanceId} │ ${tmuxName} ═══"; tail -n 200 -F "${logPath}"`;
292
- // Use send-keys so the echo+tail are sent in sequence, then Enter
293
- // to run them. Target the pane by index in the bgr_view session.
312
+ // Pane 0 first instance
313
+ const firstSession = tmuxSessionForSessionId(capped[0].data.sessionId);
314
+ execFileSync('tmux', [
315
+ 'send-keys', '-t', 'bizar-bg-view:0.0',
316
+ `tmux attach -t ${firstSession}`, 'Enter',
317
+ ], { stdio: 'pipe' });
318
+
319
+ // Split for remaining instances (2..N)
320
+ for (let i = 1; i < capped.length; i++) {
321
+ execFileSync('tmux', ['split-window', '-v', '-t', 'bizar-bg-view'], { stdio: 'pipe' });
322
+ const sessionName = tmuxSessionForSessionId(capped[i].data.sessionId);
294
323
  execFileSync('tmux', [
295
- 'send-keys', '-t', `bgr_view:0.${i}`,
296
- header, 'Enter',
324
+ 'send-keys', '-t', `bizar-bg-view:0.${i}`,
325
+ `tmux attach -t ${sessionName}`, 'Enter',
297
326
  ], { stdio: 'pipe' });
298
327
  }
299
328
 
300
- return { ok: true, count: running.length, session: 'bgr_view' };
329
+ // Tiled layout for a clean grid.
330
+ execFileSync('tmux', ['select-layout', '-t', 'bizar-bg-view', 'tiled'], { stdio: 'pipe' });
331
+
332
+ console.log(chalk.green(`\n ✓ Built tmux view session with ${capped.length} pane(s)\n`));
333
+
334
+ if (overflow > 0) {
335
+ console.log(chalk.yellow(` ⚠ ${overflow} more agent(s) not shown (16-pane cap):\n`));
336
+ for (const r of active.slice(16)) {
337
+ const sn = tmuxSessionForSessionId(r.data.sessionId);
338
+ console.log(chalk.dim(` bizar bg logs ${r.data.instanceId} — tmux attach -t ${sn}`));
339
+ }
340
+ console.log();
341
+ }
342
+
343
+ // Open an OS terminal window attached to the control session.
344
+ const open = openTerminalAttached('bizar-bg-view');
345
+ if (open.ok) {
346
+ console.log(chalk.green(` ✓ Opened ${open.terminal} attached to "bizar-bg-view"\n`));
347
+ console.log(chalk.dim(' Tip: `tmux attach -t bizar-bg-view` from any terminal.\n'));
348
+ } else {
349
+ console.log(chalk.yellow(` ⚠ Could not open terminal: ${open.error}\n`));
350
+ console.log(chalk.dim(' Run in a terminal you can see:\n'));
351
+ console.log(chalk.cyan(` tmux attach -t bizar-bg-view\n`));
352
+ }
353
+
354
+ return 0;
301
355
  }
302
356
 
303
357
  /**
@@ -362,39 +416,7 @@ function openTerminalAttached(tmuxSession) {
362
416
  return { ok: false, error: `unsupported platform: ${platform}` };
363
417
  }
364
418
 
365
- async function runView() {
366
- if (!which('tmux')) {
367
- console.log(chalk.red('\n ✗ tmux is not installed. Install it and retry.'));
368
- console.log(chalk.dim(' macOS: brew install tmux'));
369
- console.log(chalk.dim(' Linux: apt-get install tmux (or your distro equivalent)'));
370
- console.log(chalk.dim(' Windows: choco install tmux\n'));
371
- return 1;
372
- }
373
-
374
- const build = buildTmuxControlSession();
375
- if (!build.ok) {
376
- if (build.reason === 'no-running-agents') {
377
- console.log(chalk.dim('\n No running background agents to view.\n'));
378
- console.log(chalk.dim(' Spawn one with `bizar_spawn_background` (Odin) or via the dashboard,\n'));
379
- console.log(chalk.dim(' then re-run this command.\n'));
380
- return 1;
381
- }
382
- console.log(chalk.red(`\n ✗ Failed to build control session: ${build.error || build.reason}\n`));
383
- return 1;
384
- }
385
419
 
386
- console.log(chalk.green(`\n ✓ Built tmux control session with ${build.count} pane(s)\n`));
387
- const open = openTerminalAttached(build.session);
388
- if (open.ok) {
389
- console.log(chalk.green(` ✓ Opened ${open.terminal} attached to tmux session "${build.session}"\n`));
390
- console.log(chalk.dim(` Tip: \`tmux attach -t ${build.session}\` from any terminal.\n`));
391
- } else {
392
- console.log(chalk.yellow(` ⚠ Could not open a terminal window: ${open.error}\n`));
393
- console.log(chalk.dim(' Run one of the following in a terminal you can see:\n'));
394
- console.log(chalk.cyan(` tmux attach -t ${build.session}\n`));
395
- }
396
- return 0;
397
- }
398
420
 
399
421
  // --- Help ----------------------------------------------------------------
400
422
 
@@ -416,8 +438,9 @@ function showHelp() {
416
438
  and a log file (default ~/.cache/bizar/logs/<sessionId>.log).
417
439
 
418
440
  The \`view\` subcommand is the headline feature: it creates a new
419
- tmux session (\`bgr_view\`) with one pane per running agent, then
420
- opens a new OS terminal window attached to it. You can see all
441
+ tmux session (\`bizar-bg-view\`) with one pane per running agent,
442
+ each pane attached to the agent's own tmux session (tiled layout),
443
+ then opens a new OS terminal window attached to it. You can see all
421
444
  your agents working in parallel at a glance.
422
445
  `);
423
446
  }
package/cli/bin.mjs CHANGED
@@ -291,17 +291,27 @@ function showServiceHelp() {
291
291
  bizar service — Manage the background service daemon
292
292
 
293
293
  Usage:
294
- bizar service start Start the service in background
295
- bizar service stop Stop the running service
296
- bizar service status Show whether the service is running
297
- bizar service logs Tail the service log
298
- bizar service follow Follow the service log until Ctrl-C
294
+ bizar service start Start the service in background
295
+ bizar service stop Stop the running service
296
+ bizar service status Show whether the service is running
297
+ bizar service logs Tail the service log
298
+ bizar service follow Follow the service log until Ctrl-C
299
+ bizar service install Register with systemd / launchd / scheduled task
300
+ bizar service install --force Re-install even when the unit matches
301
+ bizar service uninstall Remove the OS-level autostart
302
+ bizar service uninstall --force Force-uninstall even when nothing is registered
299
303
 
300
304
  Description:
301
305
  The service watches per-project schedules (cron / interval / once)
302
306
  and runs them at the right time. It logs to
303
307
  ${bizarConfigDir}/service.log and writes its PID to
304
308
  ${bizarConfigDir}/service.pid.
309
+
310
+ install registers the daemon under the OS init system — systemd user
311
+ unit on Linux, launchd LaunchAgent on macOS, scheduled task
312
+ ("BizarDashboardService", ONSTART, HIGHEST) on Windows. After
313
+ install, a normal user does not need to run \`bizar service start\`
314
+ for the dashboard background process — the OS does it at login.
305
315
  `);
306
316
  }
307
317
 
@@ -613,9 +623,11 @@ async function runTestGate() {
613
623
  }
614
624
 
615
625
  /**
616
- * Service commands — start / stop / status / logs.
626
+ * Service commands — start / stop / status / logs / install / uninstall.
617
627
  *
618
- * Implementation lives in cli/service.mjs (created below).
628
+ * Implementation lives in cli/service.mjs. The install / uninstall
629
+ * branches delegate to cli/service-controller.mjs for OS-level
630
+ * registration (systemd / launchd / schtasks).
619
631
  */
620
632
  async function runServiceCommand(sub) {
621
633
  const { runService } = await import('./service.mjs');
@@ -820,7 +832,23 @@ async function runDash(dashArgs) {
820
832
 
821
833
  switch (sub) {
822
834
  case 'start':
823
- await dashModule.start(subOpts);
835
+ // v4.4.0 — When `--bg` is passed, spawn the dashboard as a
836
+ // detached child process via the dash module's startInBackground
837
+ // helper, then return. Without this, runDash calls startDashboard
838
+ // in-process; once the bin's main() resolves, the bin process
839
+ // exits and takes the dashboard's HTTP server with it. The
840
+ // downstream effect is "(intermediate value)(intermediate value)
841
+ // (intermediate value) is not a function or its return value is
842
+ // not iterable" — a pre-existing crash signature in this codepath.
843
+ if (subOpts.bg) {
844
+ // Spawn the dashboard as a detached child process. The child's
845
+ // argv is `node cli.mjs start --bg` so the dash module's own
846
+ // CLI dispatch hits the background branch and runs
847
+ // startDashboard in the child, not the parent.
848
+ await dashModule.startInBackground(['start', ...(subOpts.subArgs || [])]);
849
+ } else {
850
+ await dashModule.start(subOpts);
851
+ }
824
852
  break;
825
853
  case 'stop':
826
854
  await dashModule.stop(subOpts);