@polderlabs/bizar 5.4.0 → 5.5.0

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 (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -1,15 +1,24 @@
1
1
  // src/views/BackgroundAgents.tsx — dashboard tab listing active background-agent
2
- // instances (status === 'pending' | 'running'). Polls /api/background every 5s
3
- // as a safety net and refreshes on `background:change` WS events for near-real-time
4
- // updates. Per-instance actions: View live output (auto-refreshing modal), Open
5
- // in tmux (shows attach command), Kill (with confirm dialog).
2
+ // instances.
3
+ //
4
+ // v5.x full UI redesign for the 7 background-agent integration gaps:
5
+ // - "+ Spawn" button + `SpawnAgentModal` for direct dashboard spawns
6
+ // - Pause / Resume / Steer actions per instance
7
+ // - WebSocket-driven streaming output (`bg:output` events)
8
+ // - Tool-call history collapsible list per instance
9
+ // - Polling 5s safety net preserved
6
10
  import { useCallback, useEffect, useRef, useState, type UIEvent } from 'react';
7
11
  import {
8
12
  Activity as ActivityIcon,
9
13
  Copy,
10
14
  Eye,
11
15
  Loader2,
16
+ Pause,
17
+ PauseCircle,
18
+ Play,
19
+ Plus,
12
20
  RefreshCw,
21
+ Send,
13
22
  Terminal,
14
23
  Trash2,
15
24
  } from 'lucide-react';
@@ -22,6 +31,7 @@ import { useToast } from '../components/Toast';
22
31
  import { BgStatusBadge } from '../components/BgStatusBadge';
23
32
  import { openKillConfirmDialog } from '../components/KillConfirmDialog';
24
33
  import { TmuxAttachCard } from '../components/background/TmuxAttachCard';
34
+ import { SpawnAgentModal } from './SpawnAgentModal';
25
35
  import { api } from '../lib/api';
26
36
  import { Ws } from '../lib/ws';
27
37
  import { cn, formatTime, truncate } from '../lib/utils';
@@ -30,6 +40,7 @@ import type {
30
40
  BackgroundOutputResponse,
31
41
  BackgroundTmuxResponse,
32
42
  BgInstance,
43
+ BgToolCall,
33
44
  Settings,
34
45
  Snapshot,
35
46
  } from '../lib/types';
@@ -43,11 +54,16 @@ type Props = {
43
54
  };
44
55
 
45
56
  const POLL_INTERVAL_MS = 5_000;
46
- const OUTPUT_REFRESH_MS = 2_000;
57
+ const OUTPUT_REFRESH_MS = 3_000; // fallback poll (used if WS is unavailable)
47
58
  const PROMPT_TRUNCATE = 120;
59
+ const STREAM_BUFFER_LIMIT = 32_000; // chars
48
60
 
49
61
  function isActive(b: BgInstance): boolean {
50
- return b.status === 'pending' || b.status === 'running';
62
+ return b.status === 'pending' || b.status === 'running' || b.status === 'paused';
63
+ }
64
+
65
+ function isPaused(b: BgInstance): boolean {
66
+ return b.status === 'paused';
51
67
  }
52
68
 
53
69
  function formatDuration(ms: number): string {
@@ -68,6 +84,7 @@ export function BackgroundAgents(_props: Props) {
68
84
  const [loading, setLoading] = useState(true);
69
85
  const [error, setError] = useState<string | null>(null);
70
86
  const [, setTick] = useState(0);
87
+ const [showSpawn, setShowSpawn] = useState(false);
71
88
 
72
89
  const load = useCallback(async () => {
73
90
  try {
@@ -94,15 +111,17 @@ export function BackgroundAgents(_props: Props) {
94
111
  return () => clearInterval(id);
95
112
  }, []);
96
113
 
97
- // WS subscription — refetch on any background:change / cleanup event.
98
- // Matches the Settings.tsx pattern of opening a private WS instance.
99
- // Acceptable because background change volume is low and the dashboard
100
- // already creates parallel WS connections for other tabs.
114
+ // WS subscription — refetch on any background:* event.
101
115
  useEffect(() => {
102
116
  const ws = new Ws();
103
117
  const off = ws.on((msg) => {
104
118
  const t = (msg as { type?: string }).type;
105
- if (t === 'background:change' || t === 'background:cleanup') {
119
+ if (
120
+ t === 'background:change' ||
121
+ t === 'background:cleanup' ||
122
+ t === 'bg:progress' ||
123
+ t === 'bg:tool-call'
124
+ ) {
106
125
  load();
107
126
  }
108
127
  });
@@ -133,11 +152,40 @@ export function BackgroundAgents(_props: Props) {
133
152
 
134
153
  const onKill = (b: BgInstance) => {
135
154
  openKillConfirmDialog(modal, toast, b.instanceId, b.agent || b.prompt, () => {
136
- // Optimistic remove — the WS broadcast + polling safety net will reconcile.
137
155
  setInstances((cur) => cur.filter((x) => x.instanceId !== b.instanceId));
138
156
  });
139
157
  };
140
158
 
159
+ const onPause = async (b: BgInstance) => {
160
+ try {
161
+ const r = await api.post<{ ok: boolean; error?: string }>(
162
+ `/background/${encodeURIComponent(b.instanceId)}/pause`,
163
+ {},
164
+ );
165
+ if (!r?.ok) toast.error(r?.error || 'Pause failed.');
166
+ else toast.info(`Paused ${b.instanceId.slice(0, 14)}…`);
167
+ } catch (err) {
168
+ toast.error(`Pause failed: ${(err as Error).message}`);
169
+ }
170
+ };
171
+
172
+ const onResume = async (b: BgInstance) => {
173
+ try {
174
+ const r = await api.post<{ ok: boolean; error?: string }>(
175
+ `/background/${encodeURIComponent(b.instanceId)}/resume`,
176
+ {},
177
+ );
178
+ if (!r?.ok) toast.error(r?.error || 'Resume failed.');
179
+ else toast.info(`Resumed ${b.instanceId.slice(0, 14)}…`);
180
+ } catch (err) {
181
+ toast.error(`Resume failed: ${(err as Error).message}`);
182
+ }
183
+ };
184
+
185
+ const onSteer = (b: BgInstance) => {
186
+ openSteerModal(modal, toast, b, load);
187
+ };
188
+
141
189
  // ─── Render ───────────────────────────────────────────────────────────
142
190
 
143
191
  if (loading) {
@@ -177,16 +225,34 @@ export function BackgroundAgents(_props: Props) {
177
225
  {active.length} active · {instances.length} total in store
178
226
  </p>
179
227
  </div>
180
- <Button variant="ghost" size="sm" onClick={load} title="Refresh now">
181
- <RefreshCw size={14} /> Refresh
182
- </Button>
228
+ <div className="bg-active-header-actions">
229
+ <Button variant="primary" size="sm" onClick={() => setShowSpawn(true)}>
230
+ <Plus size={14} /> Spawn
231
+ </Button>
232
+ <Button variant="ghost" size="sm" onClick={load} title="Refresh now">
233
+ <RefreshCw size={14} /> Refresh
234
+ </Button>
235
+ </div>
183
236
  </div>
184
237
 
238
+ <SpawnAgentModal
239
+ open={showSpawn}
240
+ onClose={() => setShowSpawn(false)}
241
+ onCreated={() => {
242
+ load();
243
+ }}
244
+ />
245
+
185
246
  {active.length === 0 ? (
186
247
  <EmptyState
187
248
  icon={<ActivityIcon size={32} />}
188
249
  title="No active background agents"
189
- message="Spawn one from the Agents tab or via the bizar_spawn_background tool."
250
+ message="Use the Spawn button above or call bizar_spawn_background from an agent."
251
+ action={
252
+ <Button variant="primary" onClick={() => setShowSpawn(true)}>
253
+ <Plus size={14} /> Spawn your first agent
254
+ </Button>
255
+ }
190
256
  />
191
257
  ) : (
192
258
  <div className="bg-active-list">
@@ -197,6 +263,9 @@ export function BackgroundAgents(_props: Props) {
197
263
  onViewOutput={() => onOpenOutput(b)}
198
264
  onOpenTmux={() => onOpenTmux(b)}
199
265
  onKill={() => onKill(b)}
266
+ onPause={isPaused(b) ? undefined : () => onPause(b)}
267
+ onResume={isPaused(b) ? () => onResume(b) : undefined}
268
+ onSteer={() => onSteer(b)}
200
269
  />
201
270
  ))}
202
271
  </div>
@@ -212,16 +281,23 @@ function BackgroundAgentCard({
212
281
  onViewOutput,
213
282
  onOpenTmux,
214
283
  onKill,
284
+ onPause,
285
+ onResume,
286
+ onSteer,
215
287
  }: {
216
288
  inst: BgInstance;
217
289
  onViewOutput: () => void;
218
290
  onOpenTmux: () => void;
219
291
  onKill: () => void;
292
+ onPause?: () => void;
293
+ onResume?: () => void;
294
+ onSteer: () => void;
220
295
  }) {
221
296
  const startedMs = typeof inst.startedAt === 'number' ? inst.startedAt : 0;
222
297
  const startedLabel = startedMs ? formatTime(startedMs) : '—';
223
298
  const durationMs = startedMs ? Date.now() - startedMs : 0;
224
299
  const prompt = truncate(inst.prompt, PROMPT_TRUNCATE);
300
+ const progress = typeof inst.progress === 'number' ? inst.progress : null;
225
301
 
226
302
  return (
227
303
  <Card className="bg-active-card" variant="elevated">
@@ -229,6 +305,11 @@ function BackgroundAgentCard({
229
305
  <div className="bg-active-card-id">
230
306
  <code className="mono">{inst.instanceId}</code>
231
307
  <BgStatusBadge status={inst.status || 'pending'} dot />
308
+ {isPaused(inst) && (
309
+ <span className="bg-active-card-paused-tag">
310
+ <PauseCircle size={12} /> paused
311
+ </span>
312
+ )}
232
313
  </div>
233
314
  <div className="bg-active-card-agent">
234
315
  <span className="bg-active-card-agent-label">Agent</span>
@@ -245,13 +326,24 @@ function BackgroundAgentCard({
245
326
  </div>
246
327
  )}
247
328
 
248
- {typeof inst.progress === 'number' && (
329
+ {progress !== null && (
249
330
  <div className="progress-bar" aria-label="Progress">
250
331
  <div
251
332
  className="progress-fill"
252
- style={{ width: `${Math.max(0, Math.min(100, inst.progress))}%` }}
333
+ style={{
334
+ width: `${
335
+ progress < 0
336
+ ? 100
337
+ : Math.max(0, Math.min(100, progress))
338
+ }%`,
339
+ }}
253
340
  />
254
- <span className="progress-label">{Math.round(inst.progress)}%</span>
341
+ <span className="progress-label">
342
+ {progress < 0
343
+ ? '…'
344
+ : `${Math.round(progress)}%`}
345
+ {inst.progressMessage ? ` · ${inst.progressMessage}` : ''}
346
+ </span>
255
347
  </div>
256
348
  )}
257
349
 
@@ -265,6 +357,22 @@ function BackgroundAgentCard({
265
357
  <span>
266
358
  <strong>Tool calls:</strong> {inst.toolCallCount ?? 0}
267
359
  </span>
360
+ {inst.processId && (
361
+ <span>
362
+ <strong>PID:</strong> <code className="mono">{inst.processId}</code>
363
+ </span>
364
+ )}
365
+ {Array.isArray(inst.tags) && inst.tags.length > 0 && (
366
+ <span>
367
+ <strong>Tags:</strong>{' '}
368
+ {inst.tags.map((t, i) => (
369
+ <span key={t} className="tag-pill">
370
+ {t}
371
+ {i < inst.tags!.length - 1 ? ' ' : ''}
372
+ </span>
373
+ ))}
374
+ </span>
375
+ )}
268
376
  {inst.tmuxSession && (
269
377
  <span className={cn(inst.tmuxActive && 'bg-active-meta-tmux-live')}>
270
378
  <strong>tmux:</strong> <code className="mono">{inst.tmuxSession}</code>
@@ -277,6 +385,29 @@ function BackgroundAgentCard({
277
385
  <Button variant="secondary" size="sm" onClick={onViewOutput}>
278
386
  <Eye size={14} /> View output
279
387
  </Button>
388
+ {onPause && (
389
+ <Button
390
+ variant="secondary"
391
+ size="sm"
392
+ onClick={onPause}
393
+ title="Pause subprocess (POSIX)"
394
+ >
395
+ <Pause size={14} /> Pause
396
+ </Button>
397
+ )}
398
+ {onResume && (
399
+ <Button
400
+ variant="secondary"
401
+ size="sm"
402
+ onClick={onResume}
403
+ title="Resume subprocess (POSIX)"
404
+ >
405
+ <Play size={14} /> Resume
406
+ </Button>
407
+ )}
408
+ <Button variant="ghost" size="sm" onClick={onSteer} title="Steer with new instructions">
409
+ <Send size={14} /> Steer
410
+ </Button>
280
411
  <Button variant="ghost" size="sm" onClick={onOpenTmux}>
281
412
  <Terminal size={14} /> Open in tmux
282
413
  </Button>
@@ -305,27 +436,45 @@ function openOutputModal(
305
436
 
306
437
  function OutputModalBody({ instanceId }: { instanceId: string }) {
307
438
  const toast = useToast();
439
+ // Buffer of recent lines for streaming output.
440
+ const bufRef = useRef<string[]>([]);
308
441
  const [output, setOutput] = useState<string>('');
309
442
  const [loading, setLoading] = useState(true);
310
443
  const [error, setError] = useState<string | null>(null);
311
444
  const [available, setAvailable] = useState(true);
445
+ const [streamingActive, setStreamingActive] = useState(true);
446
+ const [toolCalls, setToolCalls] = useState<BgToolCall[]>([]);
447
+ const [showToolCalls, setShowToolCalls] = useState(true);
312
448
  const refBottom = useRef<HTMLDivElement | null>(null);
313
449
  const refAutoscroll = useRef(true);
314
450
 
451
+ const recomputeOutput = useCallback(() => {
452
+ setOutput(bufRef.current.join('\n'));
453
+ }, []);
454
+
455
+ // Seed from /output, then optionally from disk. The endpoint is the
456
+ // tmux `capture-pane` for legacy instances; for dashboard-spawned
457
+ // ones it gracefully returns `available: false` and we rely on WS.
315
458
  const load = useCallback(async () => {
316
459
  try {
317
460
  const r = await api.get<BackgroundOutputResponse>(
318
- `/background/${encodeURIComponent(instanceId)}/output?lines=200`,
461
+ `/background/${encodeURIComponent(instanceId)}/output?lines=300`,
319
462
  );
320
- setOutput(r?.output ?? '');
321
463
  setAvailable(r?.available !== false);
464
+ if (r?.output && bufRef.current.length === 0) {
465
+ // Seed the buffer exactly once. Subsequent loads are no-ops
466
+ // for the buffer (WS appends will keep it current).
467
+ const lines = r.output.split(/\r?\n/);
468
+ bufRef.current = lines.slice(-300);
469
+ recomputeOutput();
470
+ }
322
471
  setError(null);
323
472
  } catch (err) {
324
473
  setError((err as Error)?.message || 'Failed to load output.');
325
474
  } finally {
326
475
  setLoading(false);
327
476
  }
328
- }, [instanceId]);
477
+ }, [instanceId, recomputeOutput]);
329
478
 
330
479
  useEffect(() => {
331
480
  load();
@@ -333,6 +482,68 @@ function OutputModalBody({ instanceId }: { instanceId: string }) {
333
482
  return () => clearInterval(id);
334
483
  }, [load]);
335
484
 
485
+ // Subscribe to bg:output events for true streaming. Falls back to
486
+ // polling if the WS never connects (we still render the seeded
487
+ // text). The OutputModalBody owns its own WS so streams survive
488
+ // modal re-opens and don't share state with the list WS.
489
+ useEffect(() => {
490
+ const ws = new Ws();
491
+ let isOpen = false;
492
+ const offStatus = ws.onStatus((s) => {
493
+ isOpen = s === 'connected';
494
+ setStreamingActive(isOpen);
495
+ });
496
+ const offMsg = ws.on((msg) => {
497
+ const m = msg as { type?: string; instanceId?: string; line?: string };
498
+ if (m.type === 'bg:output' && m.instanceId === instanceId && typeof m.line === 'string') {
499
+ bufRef.current.push(m.line);
500
+ // Cap buffer length; keep most recent N chars.
501
+ let total = 0;
502
+ for (let i = bufRef.current.length - 1; i >= 0; i--) {
503
+ total += (bufRef.current[i]?.length ?? 0) + 1;
504
+ if (total > STREAM_BUFFER_LIMIT) {
505
+ bufRef.current = bufRef.current.slice(i + 1);
506
+ break;
507
+ }
508
+ }
509
+ setOutput(bufRef.current.join('\n'));
510
+ }
511
+ });
512
+ return () => {
513
+ offStatus();
514
+ offMsg();
515
+ ws.close();
516
+ };
517
+ }, [instanceId]);
518
+
519
+ // Fetch tool-call history once (and refresh on bg:tool-call events).
520
+ useEffect(() => {
521
+ let cancelled = false;
522
+ const refresh = async () => {
523
+ try {
524
+ const r = await api.get<{ toolCalls: BgToolCall[] }>(
525
+ `/background/${encodeURIComponent(instanceId)}/tool-calls`,
526
+ );
527
+ if (!cancelled && Array.isArray(r?.toolCalls)) {
528
+ setToolCalls(r.toolCalls);
529
+ }
530
+ } catch {
531
+ /* fall through; the list is optional */
532
+ }
533
+ };
534
+ refresh();
535
+ const ws = new Ws();
536
+ const off = ws.on((msg) => {
537
+ const m = msg as { type?: string; instanceId?: string };
538
+ if (m.type === 'bg:tool-call' && m.instanceId === instanceId) refresh();
539
+ });
540
+ return () => {
541
+ cancelled = true;
542
+ off();
543
+ ws.close();
544
+ };
545
+ }, [instanceId]);
546
+
336
547
  // Auto-scroll to bottom only if user is already near the bottom.
337
548
  useEffect(() => {
338
549
  if (!refAutoscroll.current) return;
@@ -358,9 +569,21 @@ function OutputModalBody({ instanceId }: { instanceId: string }) {
358
569
  <div className="bg-output-modal">
359
570
  <div className="bg-output-toolbar">
360
571
  <span className="muted">
361
- {available ? `${output.length} chars · auto-refresh 2s` : 'Output unavailable'}
572
+ {streamingActive
573
+ ? `streaming · ${output.length} chars`
574
+ : available
575
+ ? `${output.length} chars · auto-refresh ${OUTPUT_REFRESH_MS / 1000}s (WS idle)`
576
+ : 'Output unavailable (no tmux session)'}
362
577
  </span>
363
578
  <div className="bg-output-toolbar-actions">
579
+ <Button
580
+ variant="ghost"
581
+ size="sm"
582
+ onClick={() => setShowToolCalls((v) => !v)}
583
+ title="Toggle tool-call history"
584
+ >
585
+ {showToolCalls ? 'Hide tools' : 'Show tools'}
586
+ </Button>
364
587
  <Button variant="ghost" size="sm" onClick={load} title="Refresh now">
365
588
  <RefreshCw size={14} /> Refresh
366
589
  </Button>
@@ -375,20 +598,69 @@ function OutputModalBody({ instanceId }: { instanceId: string }) {
375
598
  </div>
376
599
  ) : error ? (
377
600
  <div className="bg-output-error">{error}</div>
378
- ) : !output ? (
379
- <div className="bg-output-empty muted">No output captured yet.</div>
380
601
  ) : (
381
- <div className="bg-output-scroll" onScroll={onScroll}>
382
- <pre className="bg-output-pre mono">
383
- <code>{output}</code>
384
- </pre>
385
- <div ref={refBottom} />
602
+ <div className="bg-output-body">
603
+ <div className="bg-output-scroll" onScroll={onScroll}>
604
+ <pre className="bg-output-pre mono">
605
+ <code>
606
+ {output || (
607
+ <span className="muted">No output captured yet — waiting for the agent to start…</span>
608
+ )}
609
+ </code>
610
+ </pre>
611
+ <div ref={refBottom} />
612
+ </div>
613
+ {showToolCalls && (
614
+ <ToolCallHistory calls={toolCalls} />
615
+ )}
386
616
  </div>
387
617
  )}
388
618
  </div>
389
619
  );
390
620
  }
391
621
 
622
+ // ─── Tool-call history panel ───────────────────────────────────────────────
623
+
624
+ function ToolCallHistory({ calls }: { calls: BgToolCall[] }) {
625
+ return (
626
+ <aside className="bg-tool-calls" aria-label="Tool call history">
627
+ <h4 className="bg-tool-calls-title">Tool calls ({calls.length})</h4>
628
+ {calls.length === 0 ? (
629
+ <p className="muted bg-tool-calls-empty">No tool calls recorded yet.</p>
630
+ ) : (
631
+ <ol className="bg-tool-calls-list">
632
+ {calls.slice().reverse().map((c) => {
633
+ const dur =
634
+ c.endedAt && c.startedAt
635
+ ? `${Math.max(0, c.endedAt - c.startedAt)}ms`
636
+ : '…';
637
+ return (
638
+ <li key={c.id} className={`bg-tool-call bg-tool-call-${c.status}`}>
639
+ <div className="bg-tool-call-head">
640
+ <code className="mono bg-tool-call-name">{c.name}</code>
641
+ <span className={`badge badge-${c.status === 'error' ? 'danger' : c.status === 'ok' ? 'success' : 'neutral'}`}>
642
+ {c.status}
643
+ </span>
644
+ <span className="bg-tool-call-dur muted">{dur}</span>
645
+ </div>
646
+ {c.args && (
647
+ <pre className="bg-tool-call-args mono">{c.args}</pre>
648
+ )}
649
+ {c.result && (
650
+ <pre className="bg-tool-call-result mono">{c.result.slice(0, 240)}</pre>
651
+ )}
652
+ {c.error && (
653
+ <pre className="bg-tool-call-error mono">{c.error}</pre>
654
+ )}
655
+ </li>
656
+ );
657
+ })}
658
+ </ol>
659
+ )}
660
+ </aside>
661
+ );
662
+ }
663
+
392
664
  // ─── Tmux attach modal ─────────────────────────────────────────────────────
393
665
 
394
666
  function openTmuxModal(
@@ -403,6 +675,103 @@ function openTmuxModal(
403
675
  });
404
676
  }
405
677
 
678
+ // ─── Steer modal ──────────────────────────────────────────────────────────
679
+
680
+ function openSteerModal(
681
+ modal: ReturnType<typeof useModal>,
682
+ toast: ReturnType<typeof useToast>,
683
+ inst: BgInstance,
684
+ refresh: () => void,
685
+ ) {
686
+ modal.open({
687
+ title: `Steer ${inst.agent || inst.instanceId}`,
688
+ width: 600,
689
+ children: (
690
+ <SteerModalBody
691
+ instanceId={inst.instanceId}
692
+ toast={toast}
693
+ onCreated={refresh}
694
+ onClose={() => modal.close()}
695
+ />
696
+ ),
697
+ });
698
+ }
699
+
700
+ function SteerModalBody({
701
+ instanceId,
702
+ toast,
703
+ onCreated,
704
+ onClose,
705
+ }: {
706
+ instanceId: string;
707
+ toast: ReturnType<typeof useToast>;
708
+ onCreated: () => void;
709
+ onClose: () => void;
710
+ }) {
711
+ const [message, setMessage] = useState('');
712
+ const [submitting, setSubmitting] = useState(false);
713
+ const [error, setError] = useState<string | null>(null);
714
+
715
+ const onSubmit = async () => {
716
+ if (!message.trim()) {
717
+ setError('Message is required.');
718
+ return;
719
+ }
720
+ setSubmitting(true);
721
+ try {
722
+ const r = await api.post<{
723
+ ok: boolean;
724
+ newInstanceId?: string;
725
+ error?: string;
726
+ }>(`/background/${encodeURIComponent(instanceId)}/steer`, { message });
727
+ if (!r?.ok) {
728
+ setError(r?.error || 'Steer failed.');
729
+ return;
730
+ }
731
+ toast.success(
732
+ `Steered → new instance ${(r.newInstanceId ?? '').slice(0, 14)}…`,
733
+ );
734
+ onCreated();
735
+ onClose();
736
+ } catch (err) {
737
+ setError((err as Error).message);
738
+ } finally {
739
+ setSubmitting(false);
740
+ }
741
+ };
742
+
743
+ return (
744
+ <div className="bg-steer-modal">
745
+ <p className="muted">
746
+ Steer kills the current subprocess and spawns a new one with the
747
+ original prompt + your message appended. The new instance is
748
+ linked to this one as <code>parentInstanceId</code>.
749
+ </p>
750
+ <textarea
751
+ className="field-input bg-steer-textarea"
752
+ rows={6}
753
+ value={message}
754
+ onChange={(e) => setMessage(e.target.value)}
755
+ placeholder="What do you want the agent to do differently?"
756
+ />
757
+ {error && <div className="bg-steer-error">{error}</div>}
758
+ <div className="bg-steer-actions">
759
+ <Button variant="ghost" size="sm" onClick={onClose}>
760
+ Cancel
761
+ </Button>
762
+ <Button
763
+ variant="primary"
764
+ size="sm"
765
+ onClick={onSubmit}
766
+ disabled={submitting || !message.trim()}
767
+ >
768
+ {submitting ? 'Steering…' : 'Steer'}
769
+ </Button>
770
+ </div>
771
+ </div>
772
+ );
773
+ }
774
+
406
775
  function TmuxModalBody({ info, cmd }: { info: BackgroundTmuxResponse; cmd: string }) {
407
776
  const [copied, setCopied] = useState(false);
408
777
  const onCopy = async () => {
@@ -286,19 +286,19 @@ function SettingsViewInner({ settings: initial, refreshSnapshot, settingsMode, s
286
286
  */}
287
287
 
288
288
  <div className={cn('settings-grid', activeSection && 'settings-grid-filtered')} data-active-section={activeSection || undefined}>
289
- {(showAll || sectionOf('theme')) && <ThemeSection {...sp} />}
290
- {(showAll || sectionOf('updates')) && <UpdatesSection />}
291
- {(showAll || sectionOf('layout') || sectionOf('general')) && <GeneralSection {...sp} autoSave={autoSave} />}
292
- {(showAll || sectionOf('env-vars')) && <EnvVarsSection />}
293
- {(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} />}
289
+ {(showAll || sectionOf('theme')) && <div id="settings-theme"><ThemeSection {...sp} /></div>}
290
+ {(showAll || sectionOf('updates')) && <div id="settings-updates"><UpdatesSection /></div>}
291
+ {(showAll || sectionOf('layout') || sectionOf('general')) && <div id="settings-general"><GeneralSection {...sp} autoSave={autoSave} /></div>}
292
+ {(showAll || sectionOf('env-vars')) && <div id="settings-env-vars"><EnvVarsSection /></div>}
293
+ {(showAll || sectionOf('network') || sectionOf('service') || sectionOf('tailscale')) && <div id="settings-network"><NetworkSection tailscale={tailscale} tailscaleDraft={tailscaleDraft} setTailscaleDraft={setTailscaleDraft} onTailscaleToggle={onTailscaleToggle} /></div>}
294
294
  {(showAll || sectionOf('network') || sectionOf('tailscale')) && <TailscaleSettings initialStatus={tailscale} />}
295
- {(showAll || sectionOf('notifications')) && <NotificationsSection {...sp} />}
296
- {(showAll || sectionOf('auth')) && <AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} />}
297
- {(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <AgentSection {...sp} autoSave={autoSave} />}
298
- {(showAll || sectionOf('system-llm')) && <SystemLlmSection {...sp} />}
299
- {(showAll || sectionOf('headroom')) && <HeadroomSection settings={settings} setSettings={setSettings} setDirty={setDirty} />}
300
- {(showAll || sectionOf('activity-log') || sectionOf('about')) && <ActivitySection about={about} />}
301
- {(showAll || sectionOf('workspaces')) && <WorkspacesSection />}
295
+ {(showAll || sectionOf('notifications')) && <div id="settings-notifications"><NotificationsSection {...sp} /></div>}
296
+ {(showAll || sectionOf('auth')) && <div id="settings-auth"><AuthSection settings={settings} authStatus={authStatus} setAuthStatus={setAuthStatus} /></div>}
297
+ {(showAll || sectionOf('agents') || sectionOf('dashboard') || sectionOf('background')) && <div id="settings-agents"><AgentSection {...sp} autoSave={autoSave} /></div>}
298
+ {(showAll || sectionOf('system-llm')) && <div id="settings-system-llm"><SystemLlmSection {...sp} /></div>}
299
+ {(showAll || sectionOf('headroom')) && <div id="settings-headroom"><HeadroomSection settings={settings} setSettings={setSettings} setDirty={setDirty} /></div>}
300
+ {(showAll || sectionOf('activity-log') || sectionOf('about')) && <div id="settings-activity-log"><ActivitySection about={about} /></div>}
301
+ {(showAll || sectionOf('workspaces')) && <div id="settings-workspaces"><WorkspacesSection /></div>}
302
302
  </div>
303
303
  </div>
304
304
  );