@saadnvd1/agent-os 0.3.521 → 0.3.524

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 (39) hide show
  1. package/README.md +57 -0
  2. package/app/api/sessions/[id]/simulator/route.ts +32 -0
  3. package/app/api/sessions/[id]/simulator/run/route.ts +37 -0
  4. package/components/Chat/BackgroundTasks.tsx +76 -10
  5. package/components/Chat/ChatPanel.tsx +90 -34
  6. package/components/Chat/Subagent.tsx +25 -20
  7. package/components/Chat/Tools.tsx +70 -39
  8. package/components/Chat/background.tsx +114 -0
  9. package/data/chat/useChat.ts +40 -0
  10. package/lib/chat/activity.ts +4 -2
  11. package/lib/chat/background.ts +77 -0
  12. package/lib/chat/driver.ts +6 -0
  13. package/lib/chat/drivers/claude-mapper.ts +44 -6
  14. package/lib/chat/drivers/claude.ts +4 -0
  15. package/lib/chat/events.ts +14 -0
  16. package/lib/chat/registry.ts +2 -0
  17. package/lib/chat/runner.ts +20 -0
  18. package/lib/chat/settings.ts +1 -0
  19. package/lib/chat/socket.ts +9 -0
  20. package/lib/chat/worker/client.ts +33 -0
  21. package/lib/chat/worker/host.ts +13 -0
  22. package/lib/chat/worker/main.ts +1 -1
  23. package/lib/chat/worker/protocol.ts +2 -0
  24. package/lib/db/migrations.ts +19 -0
  25. package/lib/project-config/agentos.schema.json +19 -0
  26. package/lib/project-config/brief.ts +3 -0
  27. package/lib/project-config/schema.ts +10 -0
  28. package/lib/security/auth.ts +11 -1
  29. package/lib/sessions/end.ts +3 -0
  30. package/lib/sessions/stop.ts +3 -0
  31. package/lib/simulator/agent-device.ts +258 -0
  32. package/lib/simulator/available.ts +5 -0
  33. package/lib/simulator/brief.ts +18 -0
  34. package/lib/simulator/http.ts +16 -0
  35. package/lib/simulator/index.ts +458 -0
  36. package/lib/simulator/lease.ts +169 -0
  37. package/lib/simulator/simctl.ts +167 -0
  38. package/package.json +1 -1
  39. package/server.ts +16 -0
@@ -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