@saadnvd1/agent-os 0.3.521 → 0.3.523

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.
@@ -13,6 +13,7 @@ import {
13
13
  X,
14
14
  } from "lucide-react";
15
15
  import type { ChatItem } from "@/lib/chat/events";
16
+ import { outputTail } from "@/lib/chat/background";
16
17
  import { formatElapsed } from "@/lib/chat/elapsed";
17
18
  import { cn } from "@/lib/utils";
18
19
  import { useNow } from "./useNow";
@@ -22,6 +23,8 @@ type Task = Extract<ChatItem, { kind: "task" }>;
22
23
  // Finished tasks stay listed this long, then the chip clears.
23
24
  const RECENT_MS = 5 * 60 * 1000;
24
25
  const POLL_MS = 2000;
26
+ // Running tasks whose last line of output is followed while the tray is open.
27
+ const TAILED = 5;
25
28
 
26
29
  function TaskIcon({ task }: { task: Task }) {
27
30
  if (task.status === "running")
@@ -49,16 +52,23 @@ function TaskRow({
49
52
  task,
50
53
  now,
51
54
  output,
55
+ tail,
56
+ highlight,
52
57
  onStop,
53
58
  onLoadOutput,
54
59
  }: {
55
60
  task: Task;
56
61
  now: number;
57
62
  output: string | null | undefined;
63
+ // Follows its output for the line under its name.
64
+ tail: boolean;
65
+ // Brought here from the call it came from.
66
+ highlight: boolean;
58
67
  onStop: () => void;
59
68
  onLoadOutput: (taskId: string) => void;
60
69
  }) {
61
- const [open, setOpen] = useState(false);
70
+ const [open, setOpen] = useState(highlight);
71
+ const rowRef = useRef<HTMLDivElement>(null);
62
72
  const running = task.status === "running";
63
73
  const { taskId } = task;
64
74
  const outputRef = useRef<HTMLPreElement>(null);
@@ -69,18 +79,32 @@ function TaskRow({
69
79
  if (el) el.scrollTop = el.scrollHeight;
70
80
  }, [output]);
71
81
 
72
- // While open, keep the output fresh for as long as the task runs.
73
82
  useEffect(() => {
74
- if (!open) return;
83
+ if (!highlight) return;
84
+ setOpen(true);
85
+ rowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" });
86
+ }, [highlight]);
87
+
88
+ // While open (or tailed), keep the output fresh for as long as it runs.
89
+ useEffect(() => {
90
+ if (!open && !(tail && running)) return;
75
91
  onLoadOutput(taskId);
76
92
  if (!running) return;
77
93
  const t = setInterval(() => onLoadOutput(taskId), POLL_MS);
78
94
  return () => clearInterval(t);
79
- }, [open, running, taskId, onLoadOutput]);
95
+ }, [open, tail, running, taskId, onLoadOutput]);
80
96
 
81
97
  const took = formatElapsed((task.endedAt ?? now) - task.createdAt);
98
+ const line =
99
+ running && !open ? (outputTail(output) ?? task.lastToolName) : null;
82
100
  return (
83
- <div className="rounded-lg">
101
+ <div
102
+ ref={rowRef}
103
+ className={cn(
104
+ "rounded-lg transition-colors duration-700",
105
+ highlight && "bg-foreground/[0.05]"
106
+ )}
107
+ >
84
108
  <div className="flex min-h-11 items-center gap-2 px-1.5 md:min-h-9">
85
109
  <button
86
110
  type="button"
@@ -113,6 +137,11 @@ function TaskRow({
113
137
  </button>
114
138
  )}
115
139
  </div>
140
+ {line && (
141
+ <p className="text-muted-foreground truncate pr-2 pb-1.5 pl-9 font-mono text-[11px]">
142
+ {line}
143
+ </p>
144
+ )}
116
145
  {open && (
117
146
  <div className="space-y-1 pb-2 pl-7">
118
147
  {task.summary && task.summary !== task.description && (
@@ -143,16 +172,24 @@ function TaskRow({
143
172
  export function BackgroundTasks({
144
173
  items,
145
174
  outputs,
175
+ reveal,
146
176
  onStop,
147
177
  onLoadOutput,
148
178
  }: {
149
179
  items: ChatItem[];
180
+ // A task to open the tray on, from the call that became it.
181
+ reveal?: { taskId: string; at: number } | null;
150
182
  outputs: Record<string, string | null>;
151
183
  onStop: (taskId: string) => void;
152
184
  onLoadOutput: (taskId: string) => void;
153
185
  }) {
154
186
  const [open, setOpen] = useState(false);
155
- const tasks = items.filter((i): i is Task => i.kind === "task");
187
+ const [pulse, setPulse] = useState(false);
188
+ const [lit, setLit] = useState<string | null>(null);
189
+ // One still blocking its call isn't in the background yet.
190
+ const tasks = items.filter(
191
+ (i): i is Task => i.kind === "task" && i.backgrounded !== false
192
+ );
156
193
  const running = tasks.filter((t) => t.status === "running" && !t.ambient);
157
194
  const now = useNow(running.length > 0);
158
195
  const shown = tasks
@@ -162,6 +199,29 @@ export function BackgroundTasks({
162
199
  Number(b.status === "running") - Number(a.status === "running") ||
163
200
  b.createdAt - a.createdAt
164
201
  );
202
+ const failed = shown.filter((t) => t.status === "failed").length;
203
+
204
+ // A task arriving gives the chip one soft pulse.
205
+ const count = running.length;
206
+ const seen = useRef(count);
207
+ useEffect(() => {
208
+ const more = count > seen.current;
209
+ seen.current = count;
210
+ if (!more) return;
211
+ setPulse(true);
212
+ const t = setTimeout(() => setPulse(false), 700);
213
+ return () => clearTimeout(t);
214
+ }, [count]);
215
+
216
+ // Asked to show a task: open the tray on it for a moment.
217
+ useEffect(() => {
218
+ if (!reveal) return;
219
+ setOpen(true);
220
+ setLit(reveal.taskId);
221
+ const t = setTimeout(() => setLit(null), 1200);
222
+ return () => clearTimeout(t);
223
+ }, [reveal]);
224
+
165
225
  if (!shown.length) return null;
166
226
 
167
227
  return (
@@ -170,24 +230,30 @@ export function BackgroundTasks({
170
230
  type="button"
171
231
  onClick={() => setOpen(!open)}
172
232
  className={cn(
173
- "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-9 items-center gap-1.5 rounded-lg px-2 text-xs md:h-7",
174
- running.length && "text-primary"
233
+ "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05] flex h-9 items-center gap-1.5 rounded-lg px-2 text-xs transition-colors duration-500 md:h-7",
234
+ running.length && "text-primary",
235
+ !running.length && failed > 0 && "text-destructive",
236
+ pulse && "bg-foreground/[0.08]"
175
237
  )}
176
238
  >
177
239
  <Layers className="h-3.5 w-3.5" />
178
240
  {running.length
179
241
  ? `${running.length} running in background`
180
- : `${shown.length} background task${shown.length === 1 ? "" : "s"}`}
242
+ : failed
243
+ ? `${failed} failed in background`
244
+ : `${shown.length} finished in background`}
181
245
  <ChevronRight
182
246
  className={cn("h-3 w-3 transition-transform", open && "rotate-90")}
183
247
  />
184
248
  </button>
185
249
  {open && (
186
250
  <div className="bg-foreground/[0.025] mt-1 max-h-80 overflow-y-auto rounded-xl px-1 py-1">
187
- {shown.map((t) => (
251
+ {shown.map((t, i) => (
188
252
  <TaskRow
189
253
  key={t.id}
190
254
  task={t}
255
+ tail={i < TAILED}
256
+ highlight={lit === t.taskId}
191
257
  now={now}
192
258
  output={outputs[t.taskId]}
193
259
  onStop={() => onStop(t.taskId)}
@@ -42,6 +42,8 @@ import { useFreshStartQuery } from "@/data/fresh-start";
42
42
  import { QuoteButton } from "./QuoteButton";
43
43
  import { ToolBodies, ToolGroup } from "./Tools";
44
44
  import { RowState, RowStore } from "./rowState";
45
+ import { Background, type BackgroundValue } from "./background";
46
+ import { backgroundKey } from "@/lib/chat/background";
45
47
  import { FindBar } from "./FindBar";
46
48
  import { FIND_EVENT, isFindKey, useFind } from "./useFind";
47
49
  import { McpServers } from "./McpServers";
@@ -253,6 +255,9 @@ export function ChatPanel({
253
255
  undoPreview,
254
256
  clearUndo,
255
257
  stopTask,
258
+ canBackground,
259
+ backgroundTask,
260
+ movingToBackground,
256
261
  loadTaskOutput,
257
262
  taskOutputs,
258
263
  queue,
@@ -380,6 +385,54 @@ export function ChatPanel({
380
385
  }),
381
386
  [toolBodies, requestToolBody, pinToolBody, connected]
382
387
  );
388
+ // The calls that block the turn and can be moved to the background.
389
+ const [reveal, setReveal] = useState<{ taskId: string; at: number } | null>(
390
+ null
391
+ );
392
+ const foregroundTasks = useMemo(() => {
393
+ const byTool = new Map<string, Extract<ChatItem, { kind: "task" }>>();
394
+ const byId = new Map<string, Extract<ChatItem, { kind: "task" }>>();
395
+ for (const i of tasks)
396
+ if (i.kind === "task") {
397
+ byId.set(i.taskId, i);
398
+ if (i.toolUseId) byTool.set(i.toolUseId, i);
399
+ }
400
+ return { byTool, byId };
401
+ }, [tasks]);
402
+ const background = useMemo<BackgroundValue>(
403
+ () => ({
404
+ supported: canBackground,
405
+ taskOfTool: (id) => foregroundTasks.byTool.get(id),
406
+ taskById: (id) => foregroundTasks.byId.get(id),
407
+ moving: movingToBackground,
408
+ send: backgroundTask,
409
+ reveal: (taskId) => setReveal({ taskId, at: Date.now() }),
410
+ }),
411
+ [canBackground, foregroundTasks, movingToBackground, backgroundTask]
412
+ );
413
+ // Ctrl+B moves every call that blocks the turn, from anywhere in this panel.
414
+ const canMove = useMemo(
415
+ () =>
416
+ canBackground &&
417
+ [...foregroundTasks.byId.values()].some(
418
+ (t) => t.status === "running" && t.backgrounded === false
419
+ ),
420
+ [canBackground, foregroundTasks]
421
+ );
422
+ const moveRef = useRef({ canMove, backgroundTask });
423
+ useEffect(() => {
424
+ moveRef.current = { canMove, backgroundTask };
425
+ });
426
+ useEffect(() => {
427
+ const onKey = (e: KeyboardEvent) => {
428
+ if (!rootRef.current?.contains(e.target as Node)) return;
429
+ if (!backgroundKey(e, moveRef.current.canMove)) return;
430
+ e.preventDefault();
431
+ moveRef.current.backgroundTask();
432
+ };
433
+ window.addEventListener("keydown", onKey, true);
434
+ return () => window.removeEventListener("keydown", onKey, true);
435
+ }, []);
383
436
  const itemsById = useMemo(
384
437
  () => new Map(items.map((i) => [i.id, i])),
385
438
  [items]
@@ -482,41 +535,43 @@ export function ChatPanel({
482
535
  {orchestratorOf && <OrchestratorBar workspaceId={orchestratorOf} />}
483
536
  <div ref={scrollRef} className="relative min-h-0 flex-1">
484
537
  <RowState.Provider value={rowState}>
485
- <ToolBodies.Provider value={bodies}>
486
- <div
487
- // A new conversation starts over, scrolled to the end.
488
- key={sessionId}
489
- ref={listRef}
490
- onScroll={onListScroll}
491
- onCopy={onCopy}
492
- className="h-full overflow-y-auto overscroll-y-contain"
493
- >
494
- <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
495
- {hasMore && (
496
- <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
497
- {loadingOlder ? (
498
- <Loader2 className="h-4 w-4 animate-spin" />
499
- ) : (
500
- <button
501
- type="button"
502
- onClick={loadOlder}
503
- className="hover:text-foreground min-h-11 px-3"
504
- >
505
- Show earlier messages
506
- </button>
507
- )}
508
- </div>
509
- )}
510
- <SetupCard sessionId={sessionId} />
511
- {blocks.length === 0 && !queue.length && (
512
- <p className="text-muted-foreground py-16 text-center text-sm">
513
- Ask anything to start.
514
- </p>
515
- )}
538
+ <Background.Provider value={background}>
539
+ <ToolBodies.Provider value={bodies}>
540
+ <div
541
+ // A new conversation starts over, scrolled to the end.
542
+ key={sessionId}
543
+ ref={listRef}
544
+ onScroll={onListScroll}
545
+ onCopy={onCopy}
546
+ className="h-full overflow-y-auto overscroll-y-contain"
547
+ >
548
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-4 px-4 pt-5 pb-4">
549
+ {hasMore && (
550
+ <div className="text-muted-foreground flex min-h-11 items-center justify-center text-xs">
551
+ {loadingOlder ? (
552
+ <Loader2 className="h-4 w-4 animate-spin" />
553
+ ) : (
554
+ <button
555
+ type="button"
556
+ onClick={loadOlder}
557
+ className="hover:text-foreground min-h-11 px-3"
558
+ >
559
+ Show earlier messages
560
+ </button>
561
+ )}
562
+ </div>
563
+ )}
564
+ <SetupCard sessionId={sessionId} />
565
+ {blocks.length === 0 && !queue.length && (
566
+ <p className="text-muted-foreground py-16 text-center text-sm">
567
+ Ask anything to start.
568
+ </p>
569
+ )}
570
+ </div>
571
+ <TimelineRows blocks={blocks} actions={actions} />
516
572
  </div>
517
- <TimelineRows blocks={blocks} actions={actions} />
518
- </div>
519
- </ToolBodies.Provider>
573
+ </ToolBodies.Provider>
574
+ </Background.Provider>
520
575
  </RowState.Provider>
521
576
  {find.open && (
522
577
  <FindBar
@@ -551,6 +606,7 @@ export function ChatPanel({
551
606
  />
552
607
  <BackgroundTasks
553
608
  items={tasks}
609
+ reveal={reveal}
554
610
  outputs={taskOutputs}
555
611
  onStop={stopTask}
556
612
  onLoadOutput={loadTaskOutput}
@@ -4,7 +4,8 @@ import { Bot, ChevronRight } from "lucide-react";
4
4
  import type { ToolItem } from "@/lib/chat/group";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { Markdown } from "./Markdown";
7
- import { StatusIcon, useToolBody } from "./Tools";
7
+ import { ToolIcon, useToolBody } from "./Tools";
8
+ import { BackgroundButton, BackgroundState } from "./background";
8
9
  import { useRowState } from "./rowState";
9
10
 
10
11
  // Its report, loaded when the card opens if the page left it out.
@@ -23,26 +24,30 @@ export function SubagentCard({ item }: { item: ToolItem }) {
23
24
  };
24
25
  return (
25
26
  <div className="bg-foreground/[0.025] rounded-xl px-2 py-1">
26
- <button
27
- type="button"
28
- onClick={() => setOpen(!open)}
29
- className="flex min-h-10 w-full items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
30
- >
31
- <ChevronRight
32
- className={cn(
33
- "text-muted-foreground h-3 w-3 shrink-0 transition-transform",
34
- open && "rotate-90"
27
+ <div className="flex items-center gap-1">
28
+ <button
29
+ type="button"
30
+ onClick={() => setOpen(!open)}
31
+ className="flex min-h-10 min-w-0 flex-1 items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
32
+ >
33
+ <ChevronRight
34
+ className={cn(
35
+ "text-muted-foreground h-3 w-3 shrink-0 transition-transform",
36
+ open && "rotate-90"
37
+ )}
38
+ />
39
+ <Bot className="text-primary h-3.5 w-3.5 shrink-0" />
40
+ <span className="min-w-0 flex-1 truncate">{item.title}</span>
41
+ {input.subagent_type && (
42
+ <span className="text-muted-foreground hidden font-mono sm:inline">
43
+ {input.subagent_type}
44
+ </span>
35
45
  )}
36
- />
37
- <Bot className="text-primary h-3.5 w-3.5 shrink-0" />
38
- <span className="min-w-0 flex-1 truncate">{item.title}</span>
39
- {input.subagent_type && (
40
- <span className="text-muted-foreground hidden font-mono sm:inline">
41
- {input.subagent_type}
42
- </span>
43
- )}
44
- <StatusIcon status={item.status} />
45
- </button>
46
+ {item.backgroundTaskId ? <BackgroundState tool={item} /> : null}
47
+ <ToolIcon tool={item} />
48
+ </button>
49
+ <BackgroundButton tool={item} />
50
+ </div>
46
51
  {open && (
47
52
  <div className="space-y-3 px-1.5 pt-1 pb-2 pl-7">
48
53
  {input.prompt && (
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { createContext, useContext, useEffect } from "react";
4
- import { Check, ChevronRight, Loader2, Square, X } from "lucide-react";
4
+ import { Check, ChevronRight, Layers, Loader2, Square, X } from "lucide-react";
5
5
  import type { FileDiff, ToolBody } from "@/lib/chat/events";
6
6
  import type { ToolItem } from "@/lib/chat/group";
7
7
  import { lineDiff, shortPath, type DiffLine } from "@/lib/chat/diff";
@@ -9,6 +9,7 @@ import { needsBody } from "@/lib/chat/bodyCache";
9
9
  import { cn } from "@/lib/utils";
10
10
  import { formatElapsed } from "@/lib/chat/elapsed";
11
11
  import { Highlighted } from "./Code";
12
+ import { BackgroundButton, BackgroundState, useBackground } from "./background";
12
13
  import { useNow } from "./useNow";
13
14
  import { useRowState } from "./rowState";
14
15
 
@@ -48,6 +49,18 @@ export function StatusIcon({ status }: { status: ToolItem["status"] }) {
48
49
  return <Check className="text-muted-foreground/70 h-3.5 w-3.5 shrink-0" />;
49
50
  }
50
51
 
52
+ // The status of a call; one moved to the background and still running there
53
+ // shows as handed off instead of done.
54
+ export function ToolIcon({ tool }: { tool: ToolItem }) {
55
+ const bg = useBackground();
56
+ const task = tool.backgroundTaskId
57
+ ? bg.taskById(tool.backgroundTaskId)
58
+ : undefined;
59
+ if (tool.backgroundTaskId && (!task || task.status === "running"))
60
+ return <Layers className="text-muted-foreground h-3.5 w-3.5 shrink-0" />;
61
+ return <StatusIcon status={tool.status} />;
62
+ }
63
+
51
64
  const TONE: Record<DiffLine["op"], string> = {
52
65
  " ": "text-muted-foreground",
53
66
  "-": "bg-red-500/10 text-red-700 dark:text-red-300",
@@ -139,17 +152,24 @@ function ToolRow({ tool }: { tool: ToolItem }) {
139
152
  );
140
153
  return (
141
154
  <div data-item={tool.id}>
142
- <button
143
- type="button"
144
- onClick={() => setOpen(!open)}
145
- className="hover:bg-foreground/[0.04] flex min-h-9 w-full items-center gap-2 rounded-lg px-1.5 text-left md:min-h-7"
146
- >
147
- <StatusIcon status={tool.status} />
148
- <span className="min-w-0 flex-1 truncate font-mono text-xs">
149
- {tool.title}
150
- </span>
151
- <Elapsed tool={tool} />
152
- </button>
155
+ <div className="hover:bg-foreground/[0.04] flex items-center gap-1 rounded-lg pr-1">
156
+ <button
157
+ type="button"
158
+ onClick={() => setOpen(!open)}
159
+ className="flex min-h-9 min-w-0 flex-1 items-center gap-2 px-1.5 text-left md:min-h-7"
160
+ >
161
+ <ToolIcon tool={tool} />
162
+ <span className="min-w-0 flex-1 truncate font-mono text-xs">
163
+ {tool.title}
164
+ </span>
165
+ {tool.backgroundTaskId ? (
166
+ <BackgroundState tool={tool} />
167
+ ) : (
168
+ <Elapsed tool={tool} />
169
+ )}
170
+ </button>
171
+ <BackgroundButton tool={tool} />
172
+ </div>
153
173
  {open && <ToolDetail tool={tool} />}
154
174
  </div>
155
175
  );
@@ -161,39 +181,50 @@ export function ToolGroup({ tools }: { tools: ToolItem[] }) {
161
181
  const failed = tools.filter((t) => t.status === "error").length;
162
182
  const stopped = tools.filter((t) => t.status === "stopped").length;
163
183
  const edits = tools.filter((t) => t.diff || t.hasDiff).length;
184
+ const moved = tools.filter((t) => t.backgroundTaskId).length;
164
185
  const [open, setOpen] = useRowState(`steps:${tools[0].id}`, false);
165
186
  const latest =
166
187
  tools.findLast((t) => t.status === "running") ?? tools[tools.length - 1];
167
188
  return (
168
189
  <div className="bg-foreground/[0.025] rounded-xl px-2 py-1">
169
- <button
170
- type="button"
171
- onClick={() => setOpen(!open)}
172
- className="text-muted-foreground hover:text-foreground flex min-h-10 w-full items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
173
- >
174
- <ChevronRight
175
- className={cn(
176
- "h-3 w-3 shrink-0 transition-transform",
177
- open && "rotate-90"
190
+ <div className="flex items-center gap-1">
191
+ <button
192
+ type="button"
193
+ onClick={() => setOpen(!open)}
194
+ className="text-muted-foreground hover:text-foreground flex min-h-10 min-w-0 flex-1 items-center gap-2 px-1.5 text-left text-xs md:min-h-8"
195
+ >
196
+ <ChevronRight
197
+ className={cn(
198
+ "h-3 w-3 shrink-0 transition-transform",
199
+ open && "rotate-90"
200
+ )}
201
+ />
202
+ {running ? (
203
+ <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
204
+ ) : null}
205
+ <span className="min-w-0 flex-1 truncate">
206
+ {running
207
+ ? latest.title
208
+ : `${tools.length} step${tools.length === 1 ? "" : "s"}`}
209
+ {!running &&
210
+ edits > 0 &&
211
+ ` · ${edits} edit${edits === 1 ? "" : "s"}`}
212
+ </span>
213
+ {running && <Elapsed tool={latest} />}
214
+ {failed > 0 && (
215
+ <span className="text-destructive">{failed} failed</span>
178
216
  )}
179
- />
180
- {running ? (
181
- <Loader2 className="text-primary h-3.5 w-3.5 shrink-0 animate-spin" />
182
- ) : null}
183
- <span className="min-w-0 flex-1 truncate">
184
- {running
185
- ? latest.title
186
- : `${tools.length} step${tools.length === 1 ? "" : "s"}`}
187
- {!running && edits > 0 && ` · ${edits} edit${edits === 1 ? "" : "s"}`}
188
- </span>
189
- {running && <Elapsed tool={latest} />}
190
- {failed > 0 && (
191
- <span className="text-destructive">{failed} failed</span>
192
- )}
193
- {stopped > 0 && failed === 0 && (
194
- <span className="text-muted-foreground/70">stopped</span>
195
- )}
196
- </button>
217
+ {stopped > 0 && failed === 0 && (
218
+ <span className="text-muted-foreground/70">stopped</span>
219
+ )}
220
+ {!running && moved > 0 && (
221
+ <span className="text-muted-foreground/70">
222
+ {moved} in background
223
+ </span>
224
+ )}
225
+ </button>
226
+ {running && <BackgroundButton tool={latest} />}
227
+ </div>
197
228
  {open && (
198
229
  <div className="pb-1">
199
230
  {tools.map((t) => (
@@ -0,0 +1,114 @@
1
+ "use client";
2
+
3
+ import { createContext, useContext } from "react";
4
+ import { Check, ChevronRight, Layers, Loader2, X } from "lucide-react";
5
+ import type { ChatItem } from "@/lib/chat/events";
6
+ import type { ToolItem } from "@/lib/chat/group";
7
+ import { backgroundable } from "@/lib/chat/background";
8
+ import { formatElapsed } from "@/lib/chat/elapsed";
9
+ import { cn } from "@/lib/utils";
10
+ import { useNow } from "./useNow";
11
+
12
+ type Task = Extract<ChatItem, { kind: "task" }>;
13
+
14
+ export interface BackgroundValue {
15
+ // The agent can move a call to the background.
16
+ supported: boolean;
17
+ // The task a tool call started, by the call's id.
18
+ taskOfTool: (toolUseId: string) => Task | undefined;
19
+ taskById: (taskId: string) => Task | undefined;
20
+ // Calls asked to move and not yet answered ("all" for the shortcut).
21
+ moving: ReadonlySet<string>;
22
+ send: (toolUseId?: string) => void;
23
+ // Opens the tray on that task.
24
+ reveal: (taskId: string) => void;
25
+ }
26
+
27
+ const none: BackgroundValue = {
28
+ supported: false,
29
+ taskOfTool: () => undefined,
30
+ taskById: () => undefined,
31
+ moving: new Set(),
32
+ send: () => {},
33
+ reveal: () => {},
34
+ };
35
+
36
+ export const Background = createContext<BackgroundValue>(none);
37
+ export const useBackground = () => useContext(Background);
38
+
39
+ // Moves the call it sits on to the background. Only there while that can
40
+ // work: after a few seconds of running, so quick commands never show it.
41
+ export function BackgroundButton({ tool }: { tool: ToolItem }) {
42
+ const bg = useBackground();
43
+ const running = tool.status === "running";
44
+ const now = useNow(running && bg.supported);
45
+ if (!backgroundable(bg.supported, tool, bg.taskOfTool(tool.id), now))
46
+ return null;
47
+ const moving = bg.moving.has(tool.id) || bg.moving.has("all");
48
+ return (
49
+ <button
50
+ type="button"
51
+ disabled={moving}
52
+ onClick={(e) => {
53
+ e.stopPropagation();
54
+ bg.send(tool.id);
55
+ }}
56
+ title="Run in background (Ctrl+B)"
57
+ aria-label="Run in background"
58
+ className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] animate-in fade-in flex h-11 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs duration-150 disabled:opacity-70 md:h-7 md:px-2"
59
+ >
60
+ {moving ? (
61
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
62
+ ) : (
63
+ <Layers className="h-3.5 w-3.5" />
64
+ )}
65
+ <span className="hidden sm:inline">
66
+ {moving ? "Moving…" : "Background"}
67
+ </span>
68
+ </button>
69
+ );
70
+ }
71
+
72
+ // Where a call that went to the background stands: running there (opens
73
+ // the tray on it), or how it ended.
74
+ export function BackgroundState({ tool }: { tool: ToolItem }) {
75
+ const bg = useBackground();
76
+ const task = tool.backgroundTaskId
77
+ ? bg.taskById(tool.backgroundTaskId)
78
+ : undefined;
79
+ const running = task?.status === "running";
80
+ const now = useNow(running);
81
+ if (!tool.backgroundTaskId) return null;
82
+ if (!task || running)
83
+ return (
84
+ <button
85
+ type="button"
86
+ onClick={(e) => {
87
+ e.stopPropagation();
88
+ bg.reveal(tool.backgroundTaskId!);
89
+ }}
90
+ className="text-muted-foreground hover:text-foreground flex shrink-0 items-center gap-1 text-[11px]"
91
+ >
92
+ in background
93
+ <ChevronRight className="h-3 w-3" />
94
+ </button>
95
+ );
96
+ const failed = task.status === "failed";
97
+ const took = formatElapsed((task.endedAt ?? now) - task.createdAt);
98
+ return (
99
+ <span
100
+ className={cn(
101
+ "text-muted-foreground flex shrink-0 items-center gap-1 text-[11px]",
102
+ failed && "text-destructive"
103
+ )}
104
+ >
105
+ {failed ? <X className="h-3 w-3" /> : <Check className="h-3 w-3" />}
106
+ {task.status === "stopped"
107
+ ? "stopped in background"
108
+ : failed
109
+ ? "failed in background"
110
+ : "ran in background"}
111
+ <span className="font-mono tabular-nums">· {took}</span>
112
+ </span>
113
+ );
114
+ }
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import { useQueryClient } from "@tanstack/react-query";
5
+ import { toast } from "sonner";
5
6
  import { markRunning } from "../statuses/stream";
6
7
  import type {
7
8
  ApprovalDecision,
@@ -21,6 +22,7 @@ import type {
21
22
  } from "@/lib/chat/events";
22
23
  import type { SearchResult } from "@/lib/chat/find";
23
24
  import { addBody, touchBody } from "@/lib/chat/bodyCache";
25
+ import { backgroundFailure } from "@/lib/chat/background";
24
26
 
25
27
  // The index of the item with `id`, searched from the end: what changes is
26
28
  // almost always the latest.
@@ -164,6 +166,12 @@ export function useChat(
164
166
  const [taskOutputs, setTaskOutputs] = useState<Record<string, string | null>>(
165
167
  {}
166
168
  );
169
+ const [canBackground, setCanBackground] = useState(false);
170
+ // The calls asked to move to the background and not yet answered
171
+ // ("all" for the shortcut).
172
+ const [movingToBackground, setMovingToBackground] = useState<
173
+ ReadonlySet<string>
174
+ >(new Set());
167
175
  const onUndoneRef = useRef(onUndone);
168
176
  useEffect(() => {
169
177
  onUndoneRef.current = onUndone;
@@ -245,8 +253,28 @@ export function useChat(
245
253
  setModelState(m.model);
246
254
  setAccessState(m.access);
247
255
  setPlanState(m.plan);
256
+ setCanBackground(!!m.background);
248
257
  } else if (m.type === "context") {
249
258
  setContext(m.context);
259
+ } else if (m.type === "background_result") {
260
+ const key = m.toolUseId ?? "all";
261
+ setMovingToBackground((prev) => {
262
+ if (!prev.has(key)) return prev;
263
+ const next = new Set(prev);
264
+ next.delete(key);
265
+ return next;
266
+ });
267
+ const all = itemsRef.current;
268
+ const tool = all.find((i) => i.id === m.toolUseId);
269
+ const task = all.find(
270
+ (i) => i.kind === "task" && i.toolUseId === m.toolUseId
271
+ );
272
+ const message = backgroundFailure(
273
+ m,
274
+ tool?.kind === "tool" ? tool : undefined,
275
+ task?.kind === "task" ? task : undefined
276
+ );
277
+ if (message) toast(message, { duration: 2500 });
250
278
  } else if (m.type === "task_output") {
251
279
  setTaskOutputs((prev) => ({ ...prev, [m.taskId]: m.text }));
252
280
  } else if (m.type === "undo_preview") {
@@ -389,6 +417,15 @@ export function useChat(
389
417
  wsRef.current?.send(JSON.stringify({ type: "stop_task", taskId }));
390
418
  }, []);
391
419
 
420
+ // One call (or, with none given, every call that blocks the turn) goes to
421
+ // the background; the answer comes back as a toast only if it failed.
422
+ const backgroundTask = useCallback((toolUseId?: string) => {
423
+ const key = toolUseId ?? "all";
424
+ if (!wsRef.current) return;
425
+ setMovingToBackground((prev) => new Set(prev).add(key));
426
+ wsRef.current.send(JSON.stringify({ type: "background_task", toolUseId }));
427
+ }, []);
428
+
392
429
  const queueEdit = useCallback((id: string, text: string) => {
393
430
  wsRef.current?.send(JSON.stringify({ type: "queue_edit", id, text }));
394
431
  }, []);
@@ -496,6 +533,9 @@ export function useChat(
496
533
  undoPreview,
497
534
  clearUndo: () => setUndoPreview(null),
498
535
  stopTask,
536
+ canBackground,
537
+ backgroundTask,
538
+ movingToBackground,
499
539
  loadTaskOutput,
500
540
  taskOutputs,
501
541
  queue,
@@ -48,7 +48,9 @@ export function track(live: Live, e: WorkerEvent): void {
48
48
  a.tools.set(e.item.id, { label: e.item.title, since: e.item.createdAt });
49
49
  else a.tools.delete(e.item.id);
50
50
  } else if (e.type === "item" && e.item.kind === "task" && !e.item.ambient) {
51
- if (e.item.status === "running") a.tasks.add(e.item.taskId);
51
+ // One still blocking its tool call is counted as that tool.
52
+ if (e.item.status === "running" && e.item.backgrounded !== false)
53
+ a.tasks.add(e.item.taskId);
52
54
  else a.tasks.delete(e.item.taskId);
53
55
  }
54
56
  }
@@ -65,7 +67,7 @@ export function restoreActivity(sessionId: string, state: ChatState) {
65
67
  activity.tools.set(i.id, { label: i.title, since: i.createdAt });
66
68
  }
67
69
  for (const i of runningItems(sessionId, "task"))
68
- if (!i.ambient) activity.tasks.add(i.taskId);
70
+ if (!i.ambient && i.backgrounded !== false) activity.tasks.add(i.taskId);
69
71
  return activity;
70
72
  }
71
73
 
@@ -0,0 +1,77 @@
1
+ import type { ChatItem } from "./events";
2
+
3
+ type Tool = Extract<ChatItem, { kind: "tool" }>;
4
+ type Task = Extract<ChatItem, { kind: "task" }>;
5
+
6
+ // A call has to have run this long before it's offered: a quick command
7
+ // never flashes the button.
8
+ export const BACKGROUND_AFTER_MS = 3000;
9
+
10
+ // Whether a running call can be moved to the background now: the agent can
11
+ // do it, and the call is the one blocking its turn.
12
+ export function backgroundable(
13
+ supported: boolean,
14
+ tool: Pick<Tool, "status" | "createdAt">,
15
+ task: Pick<Task, "status" | "backgrounded"> | undefined,
16
+ now: number
17
+ ): boolean {
18
+ return (
19
+ supported &&
20
+ tool.status === "running" &&
21
+ task?.status === "running" &&
22
+ task.backgrounded === false &&
23
+ now - tool.createdAt >= BACKGROUND_AFTER_MS
24
+ );
25
+ }
26
+
27
+ // Ctrl+B, nothing else held, and only while there is something to move;
28
+ // otherwise the key is left to the page.
29
+ export function backgroundKey(
30
+ e: {
31
+ key: string;
32
+ ctrlKey: boolean;
33
+ metaKey: boolean;
34
+ altKey: boolean;
35
+ shiftKey: boolean;
36
+ repeat?: boolean;
37
+ isComposing?: boolean;
38
+ },
39
+ available: boolean
40
+ ): boolean {
41
+ return (
42
+ available &&
43
+ e.key.toLowerCase() === "b" &&
44
+ e.ctrlKey &&
45
+ !(e.metaKey || e.altKey || e.shiftKey || e.repeat || e.isComposing)
46
+ );
47
+ }
48
+
49
+ // Errors that mean the agent never got the request.
50
+ const UNREACHED = new Set(["unsupported", "unavailable", "timeout"]);
51
+
52
+ // What to tell the reader when moving a call didn't happen; null when it did.
53
+ export function backgroundFailure(
54
+ result: { ok: boolean; error?: string },
55
+ tool: Pick<Tool, "status"> | undefined,
56
+ task: Pick<Task, "backgrounded"> | undefined
57
+ ): string | null {
58
+ if (result.ok) return null;
59
+ if (result.error) {
60
+ return UNREACHED.has(result.error) || /went away/.test(result.error)
61
+ ? "Couldn't reach the agent. Try again"
62
+ : "Background tasks are turned off for this session";
63
+ }
64
+ if (tool && tool.status !== "running") return "That step already finished";
65
+ if (task?.backgrounded) return "Already running in the background";
66
+ return "Nothing to move to the background yet";
67
+ }
68
+
69
+ // The last line of a task's output that says anything.
70
+ export function outputTail(text: string | null | undefined): string | null {
71
+ const lines = text?.split("\n") ?? [];
72
+ for (let i = lines.length - 1; i >= 0; i--) {
73
+ const line = lines[i].trim();
74
+ if (line) return line;
75
+ }
76
+ return null;
77
+ }
@@ -82,6 +82,10 @@ export interface ChatConversation {
82
82
  setPlan(plan: boolean): Promise<void>;
83
83
  respond(id: string, answer: ApprovalDecision): void;
84
84
  stopTask(taskId: string): Promise<void>;
85
+ // Moves a blocking call (a Bash command or a subagent) to the background,
86
+ // the one with that tool_use id or every one when none is given. False
87
+ // when there was nothing to move.
88
+ backgroundTask?(toolUseId?: string): Promise<boolean>;
85
89
  // Puts files back as they were before a message (dryRun: only say what).
86
90
  undo(checkpoint: string, dryRun: boolean): Promise<UndoResult>;
87
91
  close(): void;
@@ -99,6 +103,8 @@ export interface ChatDriver {
99
103
  // as Claude does. Without it, a turn's end is that point: a message sent
100
104
  // mid-turn joins the turn or is run before it ends.
101
105
  atRest?: boolean;
106
+ // Can move a blocking call to the background (backgroundTask).
107
+ backgroundTasks?: boolean;
102
108
  start(options: ChatStartOptions): ChatConversation;
103
109
  // What the agent offers in a folder (its commands, skills and models),
104
110
  // without starting a conversation. Ends whatever it started once `signal`
@@ -72,6 +72,9 @@ export type ClaudeMessage = {
72
72
  usage?: { tool_uses?: number };
73
73
  skip_transcript?: boolean;
74
74
  ambient?: boolean;
75
+ is_backgrounded?: boolean;
76
+ // task_updated.
77
+ patch?: { is_backgrounded?: boolean };
75
78
  // prompt_suggestion: the agent's guess at the next message.
76
79
  suggestion?: string;
77
80
  // session_state_changed.
@@ -98,6 +101,10 @@ export function toCommand(c: SdkCommand): ChatCommand {
98
101
 
99
102
  const now = () => Date.now();
100
103
 
104
+ // "Command was manually backgrounded by user with ID: abc. Output is being
105
+ // written to: ..." (also "running in background with ID: abc").
106
+ const BACKGROUND_NOTICE = /^[^\n]*background(?:ed)?[^\n]*? with ID: (\w+)/;
107
+
101
108
  const INTERRUPT_NOTICE =
102
109
  /doesn't want to proceed with this tool use|Request interrupted by user/i;
103
110
 
@@ -220,6 +227,8 @@ export class ClaudeMapper {
220
227
  },
221
228
  ]
222
229
  : [];
230
+ case "task_updated":
231
+ return this.taskMoved(m);
223
232
  case "task_started":
224
233
  case "task_progress":
225
234
  case "task_notification":
@@ -264,6 +273,7 @@ export class ClaudeMapper {
264
273
  taskType: m.task_type ?? prev?.taskType,
265
274
  subagentType: m.subagent_type ?? prev?.subagentType,
266
275
  status: done ? (m.status ?? "completed") : "running",
276
+ backgrounded: m.is_backgrounded ?? prev?.backgrounded,
267
277
  endedAt: done ? now() : undefined,
268
278
  summary: m.summary ?? prev?.summary,
269
279
  outputFile: m.output_file ?? prev?.outputFile,
@@ -276,6 +286,16 @@ export class ClaudeMapper {
276
286
  return [{ type: "item", item }];
277
287
  }
278
288
 
289
+ // A foreground task moved to the background (or back).
290
+ private taskMoved(m: ClaudeMessage): DriverEvent[] {
291
+ const prev = m.task_id ? this.tasks.get(m.task_id) : undefined;
292
+ const to = m.patch?.is_backgrounded;
293
+ if (!prev || to === undefined || prev.backgrounded === to) return [];
294
+ const item: TaskItem = { ...prev, backgrounded: to };
295
+ this.tasks.set(prev.taskId, item);
296
+ return [{ type: "item", item }];
297
+ }
298
+
279
299
  // Items are created on their first text, so blocks that never send any
280
300
  // (hidden thinking, empty starts) never show up as empty bubbles.
281
301
  private stream(m: ClaudeMessage): DriverEvent[] {
@@ -401,20 +421,38 @@ export class ClaudeMapper {
401
421
  // An interrupt answers the running tool with a notice meant for the
402
422
  // model, not the reader.
403
423
  const stopped = b.is_error && INTERRUPT_NOTICE.test(text);
424
+ const moved = stopped ? undefined : this.movedTask(b.tool_use_id, text);
404
425
  const done = stopped
405
426
  ? { ...tool, status: "stopped" as const, output: undefined }
406
- : {
407
- ...tool,
408
- status: b.is_error ? ("error" as const) : ("done" as const),
409
- output: clipOutput(text),
410
- endedAt: now(),
411
- };
427
+ : moved
428
+ ? {
429
+ ...tool,
430
+ status: "done" as const,
431
+ // The notice is for the model; the task has the output.
432
+ output: undefined,
433
+ endedAt: now(),
434
+ backgroundTaskId: moved,
435
+ }
436
+ : {
437
+ ...tool,
438
+ status: b.is_error ? ("error" as const) : ("done" as const),
439
+ output: clipOutput(text),
440
+ endedAt: now(),
441
+ };
412
442
  this.tools.delete(b.tool_use_id);
413
443
  out.push({ type: "item", item: done });
414
444
  }
415
445
  return out;
416
446
  }
417
447
 
448
+ // The task a tool call became when it went to the background, from the
449
+ // task it started or, failing that, the notice it was answered with.
450
+ private movedTask(toolUseId: string, text: string): string | undefined {
451
+ for (const t of this.tasks.values())
452
+ if (t.toolUseId === toolUseId && t.backgrounded === true) return t.taskId;
453
+ return BACKGROUND_NOTICE.exec(text)?.[1];
454
+ }
455
+
418
456
  private result(m: ClaudeMessage): DriverEvent[] {
419
457
  const out: DriverEvent[] = [];
420
458
  // Tools still open when the turn ends were cut off.
@@ -129,6 +129,7 @@ export function writtenPlanFile(m: ClaudeMessage): string | undefined {
129
129
 
130
130
  export const claudeDriver: ChatDriver = {
131
131
  id: "claude",
132
+ backgroundTasks: true,
132
133
 
133
134
  // Claude Code reports its commands, skills and models while starting up,
134
135
  // before any message, so a short-lived start tells the composer what's
@@ -349,6 +350,9 @@ export const claudeDriver: ChatDriver = {
349
350
  async stopTask(taskId) {
350
351
  await q.stopTask(taskId);
351
352
  },
353
+ backgroundTask(toolUseId) {
354
+ return q.backgroundTasks(toolUseId);
355
+ },
352
356
  async undo(checkpoint, dryRun) {
353
357
  const r = await q.rewindFiles(checkpoint, { dryRun });
354
358
  return {
@@ -165,6 +165,8 @@ export type ChatItem =
165
165
  deferred?: boolean;
166
166
  hasOutput?: boolean;
167
167
  hasDiff?: boolean;
168
+ // The task this call became when it was moved to the background.
169
+ backgroundTaskId?: string;
168
170
  })
169
171
  | (Base & {
170
172
  kind: "todos";
@@ -233,6 +235,9 @@ export type ChatItem =
233
235
  taskType?: string;
234
236
  subagentType?: string;
235
237
  status: "running" | "completed" | "failed" | "stopped";
238
+ // False while it still blocks the tool call that started it; unset
239
+ // means it runs in the background.
240
+ backgrounded?: boolean;
236
241
  endedAt?: number;
237
242
  summary?: string;
238
243
  outputFile?: string;
@@ -353,10 +358,18 @@ export type ChatServerMessage =
353
358
  access: ChatAccess;
354
359
  // Plan mode: on, or null when the session can't use it.
355
360
  plan: boolean | null;
361
+ // Whether a blocking call can be moved to the background.
362
+ background?: boolean;
356
363
  }
357
364
  | { type: "context"; context: ChatContext | null }
358
365
  | { type: "undo_preview"; from: string; preview: UndoPreview }
359
366
  | { type: "task_output"; taskId: string; text: string | null }
367
+ | {
368
+ type: "background_result";
369
+ toolUseId?: string;
370
+ ok: boolean;
371
+ error?: string;
372
+ }
360
373
  | { type: "undone"; from: string; text: string };
361
374
 
362
375
  // What a browser sends.
@@ -379,6 +392,7 @@ export type ChatClientMessage =
379
392
  | ({ type: "respond"; id: string } & ApprovalDecision)
380
393
  | { type: "undo"; from: string; dryRun?: boolean }
381
394
  | { type: "stop_task"; taskId: string }
395
+ | { type: "background_task"; toolUseId?: string }
382
396
  | { type: "task_output"; taskId: string }
383
397
  // Older items, from before a page's cursor.
384
398
  | { type: "history"; before: number }
@@ -35,6 +35,8 @@ export interface Live {
35
35
  canQueue?: boolean;
36
36
  // Whether it takes "fresh" (a fresh start the reader asked for).
37
37
  canFresh?: boolean;
38
+ // Whether it takes "background_task" (an older worker drops it).
39
+ canBackground?: boolean;
38
40
  // From an older build and idle: retired once its guess at the next
39
41
  // message is in, or this fires.
40
42
  retiring?: NodeJS.Timeout;
@@ -15,6 +15,7 @@ import type {
15
15
  ApprovalDecision,
16
16
  ChatContext,
17
17
  ChatImage,
18
+ ChatServerMessage,
18
19
  ChatState,
19
20
  Compacting,
20
21
  SentBy,
@@ -304,6 +305,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
304
305
  canPlan: !!hello.caps?.includes("plan"),
305
306
  canQueue: !!hello.caps?.includes("queue"),
306
307
  canFresh: !!hello.caps?.includes("fresh"),
308
+ canBackground: !!hello.caps?.includes("background"),
307
309
  };
308
310
  // From an older build and busy, or running background work: it goes at
309
311
  // its next turn boundary, by itself if it can. Messages meanwhile wait
@@ -836,6 +838,24 @@ export function stopChatTask(sessionId: string, taskId: string): void {
836
838
  registry.live.get(sessionId)?.worker.command({ type: "stop_task", taskId });
837
839
  }
838
840
 
841
+ // Moves a blocking call to the background and says how that went.
842
+ export async function backgroundChatTask(
843
+ sessionId: string,
844
+ toolUseId: string | undefined,
845
+ reply: (m: ChatServerMessage) => void
846
+ ): Promise<void> {
847
+ const live = registry.live.get(sessionId);
848
+ const r = live?.canBackground
849
+ ? await live.worker.background(toolUseId)
850
+ : { error: "unavailable" };
851
+ reply({
852
+ type: "background_result",
853
+ toolUseId,
854
+ ok: !!r.ok,
855
+ ...(r.error && { error: r.error }),
856
+ });
857
+ }
858
+
839
859
  export function chatTaskOutput(
840
860
  sessionId: string,
841
861
  taskId: string
@@ -75,6 +75,7 @@ export function emitCapabilities(session: Session, listener?: Listener): void {
75
75
  model: chatModel(session),
76
76
  access: session.chat_access,
77
77
  plan: canPlan(session) ? !!session.chat_plan : null,
78
+ background: chatDriverFor(session.agent_type)?.backgroundTasks === true,
78
79
  };
79
80
  if (listener) listener(m);
80
81
  else emit(session.id, m);
@@ -12,6 +12,7 @@ import {
12
12
  carryOutPlan,
13
13
  chatFileSuggestions,
14
14
  chatHistory,
15
+ backgroundChatTask,
15
16
  chatTaskOutput,
16
17
  chatToolBody,
17
18
  deleteQueuedChat,
@@ -183,6 +184,14 @@ function onChatMessage(
183
184
  run(carryOutPlan(sessionId, String(msg.id)));
184
185
  else if (msg.type === "respond") respondChat(sessionId, msg.id, msg);
185
186
  else if (msg.type === "stop_task") stopChatTask(sessionId, msg.taskId);
187
+ else if (msg.type === "background_task")
188
+ run(
189
+ backgroundChatTask(
190
+ sessionId,
191
+ typeof msg.toolUseId === "string" ? msg.toolUseId : undefined,
192
+ reply
193
+ )
194
+ );
186
195
  else if (msg.type === "task_output")
187
196
  reply({
188
197
  type: "task_output",
@@ -41,6 +41,11 @@ export class WorkerClient {
41
41
 
42
42
  private files = new Map<string, (r: FileSuggestion[] | null) => void>();
43
43
 
44
+ private backgrounds = new Map<
45
+ string,
46
+ (r: { ok?: boolean; error?: string }) => void
47
+ >();
48
+
44
49
  private constructor(
45
50
  private socket: net.Socket,
46
51
  private handlers: WorkerHandlers
@@ -55,6 +60,9 @@ export class WorkerClient {
55
60
  this.undos.clear();
56
61
  for (const f of this.files.values()) f(null);
57
62
  this.files.clear();
63
+ for (const b of this.backgrounds.values())
64
+ b({ error: "The chat worker went away" });
65
+ this.backgrounds.clear();
58
66
  handlers.onClose(this.detached);
59
67
  });
60
68
  socket.on("error", () => socket.destroy());
@@ -101,6 +109,11 @@ export class WorkerClient {
101
109
  else u?.reject(new Error(e.error ?? "Undo failed"));
102
110
  return;
103
111
  }
112
+ if (e.type === "background_result") {
113
+ this.backgrounds.get(e.reqId)?.({ ok: e.ok, error: e.error });
114
+ this.backgrounds.delete(e.reqId);
115
+ return;
116
+ }
104
117
  if (e.type === "files_result") {
105
118
  this.files.get(e.reqId)?.(e.files ?? null);
106
119
  this.files.delete(e.reqId);
@@ -121,6 +134,26 @@ export class WorkerClient {
121
134
  });
122
135
  }
123
136
 
137
+ // Moves a blocking call (or all of them) to the background: whether
138
+ // anything moved, or why not (an error, or no answer in time).
139
+ background(
140
+ toolUseId?: string,
141
+ timeoutMs = 5000
142
+ ): Promise<{ ok?: boolean; error?: string }> {
143
+ const reqId = randomUUID();
144
+ return new Promise((resolve) => {
145
+ const timer = setTimeout(() => {
146
+ this.backgrounds.delete(reqId);
147
+ resolve({ error: "timeout" });
148
+ }, timeoutMs);
149
+ this.backgrounds.set(reqId, (r) => {
150
+ clearTimeout(timer);
151
+ resolve(r);
152
+ });
153
+ this.command({ type: "background_task", reqId, toolUseId });
154
+ });
155
+ }
156
+
124
157
  // The agent's own matches for an @mention; null when it can't say (an
125
158
  // older worker, an error, no answer in time).
126
159
  fileSuggestions(
@@ -712,6 +712,19 @@ export class ChatHost {
712
712
  return this.conversation.respond(cmd.id, cmd);
713
713
  case "stop_task":
714
714
  return this.conversation.stopTask(cmd.taskId);
715
+ case "background_task":
716
+ try {
717
+ if (!this.conversation.backgroundTask) throw new Error("unsupported");
718
+ const ok = await this.conversation.backgroundTask(cmd.toolUseId);
719
+ this.emit({ type: "background_result", reqId: cmd.reqId, ok });
720
+ } catch (error) {
721
+ this.emit({
722
+ type: "background_result",
723
+ reqId: cmd.reqId,
724
+ error: error instanceof Error ? error.message : String(error),
725
+ });
726
+ }
727
+ return;
715
728
  case "undo":
716
729
  try {
717
730
  const result = await this.conversation.undo(
@@ -76,7 +76,7 @@ async function main(sessionId: string) {
76
76
  state: host.state,
77
77
  compacting: host.compactingNow,
78
78
  streaming: [...host.streaming.values()],
79
- caps: ["plan", "queue", "retire", "fresh"],
79
+ caps: ["plan", "queue", "retire", "fresh", "background"],
80
80
  } satisfies WorkerEvent)}\n`
81
81
  );
82
82
  client.on(
@@ -55,6 +55,7 @@ export type WorkerCommand =
55
55
  | ({ type: "respond"; id: string } & ApprovalDecision)
56
56
  | { type: "undo"; reqId: string; checkpoint: string; dryRun: boolean }
57
57
  | { type: "stop_task"; taskId: string }
58
+ | { type: "background_task"; reqId: string; toolUseId?: string }
58
59
  // It runs an older build: it takes no new message into its agent (they
59
60
  // wait in the queue) and closes at its next turn boundary, so a current
60
61
  // worker resumes the conversation with today's tools.
@@ -99,6 +100,7 @@ export type WorkerEvent =
99
100
  // The agent's guess at the next message, or null once it's stale.
100
101
  | { type: "suggestion"; text: string | null }
101
102
  | { type: "undo_result"; reqId: string; result?: UndoResult; error?: string }
103
+ | { type: "background_result"; reqId: string; ok?: boolean; error?: string }
102
104
  | {
103
105
  type: "files_result";
104
106
  reqId: string;
@@ -162,8 +162,18 @@ export function authorize(req: AuthRequest, policy: AuthPolicy): AuthResult {
162
162
 
163
163
  function decide(req: AuthRequest, policy: AuthPolicy): AuthResult {
164
164
  const trusted = req.tunnel ? null : networkTrust(req, policy);
165
- if (trusted) return { ok: true, via: trusted };
166
165
  const token = readToken(req.headers);
166
+ if (trusted) {
167
+ // Identity only: network trust already decided access, so a token that
168
+ // is unknown, revoked or fails to resolve never turns this into a refusal.
169
+ let deviceId: string | undefined;
170
+ try {
171
+ deviceId = (token ? policy.lookup(token) : null)?.id;
172
+ } catch {}
173
+ return deviceId
174
+ ? { ok: true, via: trusted, deviceId }
175
+ : { ok: true, via: trusted };
176
+ }
167
177
  const device = token ? policy.lookup(token) : null;
168
178
  if (device) return { ok: true, via: "device", deviceId: device.id };
169
179
  // AGENTOS_AUTH=off is for a login proxy on this side of the network.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.521",
3
+ "version": "0.3.523",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"