@saadnvd1/agent-os 0.3.520 → 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.
Files changed (45) hide show
  1. package/README.md +11 -0
  2. package/app/api/welcome/route.ts +22 -0
  3. package/app/page.tsx +6 -0
  4. package/app/welcome/page.tsx +6 -0
  5. package/components/Chat/BackgroundTasks.tsx +76 -10
  6. package/components/Chat/ChatPanel.tsx +90 -34
  7. package/components/Chat/Subagent.tsx +25 -20
  8. package/components/Chat/Tools.tsx +70 -39
  9. package/components/Chat/background.tsx +114 -0
  10. package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
  11. package/components/Settings/SettingsDialog.tsx +9 -0
  12. package/components/Settings/SetupSection.tsx +37 -0
  13. package/components/Welcome/AgentList.tsx +73 -0
  14. package/components/Welcome/AgentRow.tsx +102 -0
  15. package/components/Welcome/ProjectStep.tsx +100 -0
  16. package/components/Welcome/SetupTerminal.tsx +64 -0
  17. package/components/Welcome/WelcomeDialog.tsx +153 -0
  18. package/data/agents/index.ts +16 -1
  19. package/data/chat/useChat.ts +40 -0
  20. package/data/welcome/index.ts +36 -0
  21. package/hooks/useWelcome.ts +70 -0
  22. package/lib/agents/probe.ts +27 -0
  23. package/lib/agents/setup-commands.ts +54 -0
  24. package/lib/chat/activity.ts +4 -2
  25. package/lib/chat/background.ts +77 -0
  26. package/lib/chat/driver.ts +6 -0
  27. package/lib/chat/drivers/claude-mapper.ts +44 -6
  28. package/lib/chat/drivers/claude.ts +4 -0
  29. package/lib/chat/events.ts +14 -0
  30. package/lib/chat/registry.ts +2 -0
  31. package/lib/chat/runner.ts +20 -0
  32. package/lib/chat/settings.ts +1 -0
  33. package/lib/chat/socket.ts +9 -0
  34. package/lib/chat/worker/client.ts +33 -0
  35. package/lib/chat/worker/host.ts +13 -0
  36. package/lib/chat/worker/main.ts +1 -1
  37. package/lib/chat/worker/protocol.ts +2 -0
  38. package/lib/db/migrations.ts +14 -0
  39. package/lib/security/auth.ts +11 -1
  40. package/lib/settings-url.ts +1 -0
  41. package/lib/welcome-url.ts +33 -0
  42. package/lib/welcome.ts +88 -0
  43. package/package.json +1 -1
  44. package/stores/addProject.ts +19 -1
  45. package/stores/welcomeUi.ts +26 -0
@@ -1,4 +1,4 @@
1
- import { useQuery } from "@tanstack/react-query";
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
2
  import type { AgentProbe } from "@/lib/agents/probe";
3
3
 
4
4
  export const agentKeys = {
@@ -20,3 +20,18 @@ export function useAgentStatusQuery() {
20
20
  refetchOnMount: "always",
21
21
  });
22
22
  }
23
+
24
+ // Asks the server to probe again now, not from its minute-old answer, and
25
+ // puts the result where the status query reads it.
26
+ export function useRecheckAgents() {
27
+ const client = useQueryClient();
28
+ return useMutation({
29
+ mutationFn: async () => {
30
+ const res = await fetch("/api/agents/status?fresh");
31
+ if (!res.ok) throw new Error("Couldn't check the agents");
32
+ return ((await res.json()) as { agents: Record<string, AgentProbe> })
33
+ .agents;
34
+ },
35
+ onSuccess: (agents) => client.setQueryData(agentKeys.status(), agents),
36
+ });
37
+ }
@@ -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,
@@ -0,0 +1,36 @@
1
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
+ import type { WelcomeGate } from "@/lib/welcome";
3
+
4
+ export const welcomeKeys = {
5
+ all: ["welcome"] as const,
6
+ };
7
+
8
+ export function useWelcomeQuery() {
9
+ return useQuery({
10
+ queryKey: welcomeKeys.all,
11
+ queryFn: async () => {
12
+ const res = await fetch("/api/welcome");
13
+ if (!res.ok) throw new Error("Couldn't check first-run setup");
14
+ return (await res.json()) as WelcomeGate;
15
+ },
16
+ // Asked once per load: it only flips when setup is done here.
17
+ staleTime: Infinity,
18
+ retry: false,
19
+ });
20
+ }
21
+
22
+ export function useCompleteWelcome() {
23
+ const client = useQueryClient();
24
+ return useMutation({
25
+ mutationFn: async () => {
26
+ const res = await fetch("/api/welcome", {
27
+ method: "POST",
28
+ headers: { "content-type": "application/json" },
29
+ body: JSON.stringify({ action: "complete" }),
30
+ });
31
+ if (!res.ok) throw new Error("Couldn't save setup");
32
+ return (await res.json()) as WelcomeGate;
33
+ },
34
+ onSuccess: (gate) => client.setQueryData(welcomeKeys.all, gate),
35
+ });
36
+ }
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { subscribe } from "valtio";
5
+ import { useCompleteWelcome, useWelcomeQuery } from "@/data/welcome";
6
+ import { hrefWithWelcome, welcomeFromHref } from "@/lib/welcome-url";
7
+ import { newDraft } from "@/stores/drafts";
8
+ import { welcomeUi, welcomeUiActions } from "@/stores/welcomeUi";
9
+
10
+ function fromAddress() {
11
+ const at = welcomeFromHref(window.location.href);
12
+ if (at.open) welcomeUiActions.open(at.step);
13
+ else if (welcomeUi.open) welcomeUiActions.close();
14
+ }
15
+
16
+ /**
17
+ * `?welcome=<step>` opens the first-run welcome at that step, on load and on
18
+ * back/forward; the address follows it. Moving between steps adds a history
19
+ * entry, so Back goes to the step before. A fresh install with setup still to
20
+ * do opens it by itself, once.
21
+ */
22
+ export function useWelcomeUrl() {
23
+ const { data: gate } = useWelcomeQuery();
24
+ const asked = useRef(false);
25
+
26
+ useEffect(() => {
27
+ fromAddress();
28
+ let was = { open: welcomeUi.open, step: welcomeUi.step };
29
+ const unsubscribe = subscribe(welcomeUi, () => {
30
+ const href = window.location.href;
31
+ const next = hrefWithWelcome(href, {
32
+ open: welcomeUi.open,
33
+ step: welcomeUi.step,
34
+ });
35
+ const url = new URL(href);
36
+ if (next !== url.pathname + url.search + url.hash) {
37
+ const move = was.open && welcomeUi.open && was.step !== welcomeUi.step;
38
+ window.history[move ? "pushState" : "replaceState"](
39
+ window.history.state,
40
+ "",
41
+ next
42
+ );
43
+ }
44
+ was = { open: welcomeUi.open, step: welcomeUi.step };
45
+ });
46
+ window.addEventListener("popstate", fromAddress);
47
+ return () => {
48
+ unsubscribe();
49
+ window.removeEventListener("popstate", fromAddress);
50
+ };
51
+ }, []);
52
+
53
+ useEffect(() => {
54
+ if (!gate?.show || asked.current || welcomeUi.open) return;
55
+ asked.current = true;
56
+ welcomeUiActions.open("agents");
57
+ }, [gate?.show]);
58
+ }
59
+
60
+ /** Setup is over, finished or skipped: remember it and close. */
61
+ export function useEndWelcome() {
62
+ const { mutate } = useCompleteWelcome();
63
+ return (start?: boolean) => {
64
+ const projectId = welcomeUi.projectId;
65
+ mutate();
66
+ welcomeUiActions.close();
67
+ welcomeUiActions.setProject(null);
68
+ if (start && projectId) newDraft({ kind: "project", projectId });
69
+ };
70
+ }
@@ -64,7 +64,34 @@ type AuthCheck = (
64
64
  env: NodeJS.ProcessEnv
65
65
  ) => Promise<AgentAuth> | AgentAuth;
66
66
 
67
+ // Claude Code keeps who is signed in under `oauthAccount` in its config, at
68
+ // $CLAUDE_CONFIG_DIR/.claude.json, or ~/.claude.json without that variable.
69
+ function claudeAuth(env: NodeJS.ProcessEnv): AgentAuth {
70
+ const dir = env.CLAUDE_CONFIG_DIR?.trim();
71
+ const configDir = dir || home(".claude");
72
+ const config = dir ? path.join(dir, ".claude.json") : home(".claude.json");
73
+ if (
74
+ anyEnv(env, [
75
+ "ANTHROPIC_API_KEY",
76
+ "ANTHROPIC_AUTH_TOKEN",
77
+ "CLAUDE_CODE_OAUTH_TOKEN",
78
+ "CLAUDE_CODE_USE_BEDROCK",
79
+ "CLAUDE_CODE_USE_VERTEX",
80
+ ]) ||
81
+ hasJsonKeys(path.join(configDir, "settings.json"), "apiKeyHelper")
82
+ )
83
+ return "ready";
84
+ if (hasJsonKeys(config, "oauthAccount")) return "ready";
85
+ // A config without an account is a CLI that has never signed in; no config
86
+ // at all tells nothing.
87
+ return hasJsonKeys(config) ? "needs-login" : "unknown";
88
+ }
89
+
67
90
  const AUTH: Partial<Record<ProviderId, { check: AuthCheck; hint: string }>> = {
91
+ claude: {
92
+ check: (_cli, env) => claudeAuth(env),
93
+ hint: "Run `claude auth login` in a terminal",
94
+ },
68
95
  codex: {
69
96
  // Exits 0 signed in, 1 not.
70
97
  check: async (cli, env) => {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The official commands the welcome runs in its terminal to install an agent
3
+ * CLI or sign it in. Typed into the user's own shell, never run by the
4
+ * server, so they are the same lines the install script (scripts/lib/ai-clis.sh)
5
+ * uses.
6
+ */
7
+
8
+ import type { ProviderId } from "../providers/registry";
9
+
10
+ export interface SetupCommands {
11
+ install?: string;
12
+ signIn?: string;
13
+ docs?: string;
14
+ }
15
+
16
+ export const SETUP_COMMANDS: Partial<Record<ProviderId, SetupCommands>> = {
17
+ claude: {
18
+ install: "curl -fsSL https://claude.ai/install.sh | bash",
19
+ signIn: "claude auth login",
20
+ },
21
+ codex: {
22
+ install: "npm install -g @openai/codex",
23
+ signIn: "codex login",
24
+ },
25
+ gemini: {
26
+ install: "npm install -g @google/gemini-cli",
27
+ signIn: "gemini",
28
+ },
29
+ kilocode: { install: "npm install -g @kilocode/cli", signIn: "kilo" },
30
+ cursor: { signIn: "cursor-agent login", docs: "https://cursor.com/cli" },
31
+ amp: { signIn: "amp login", docs: "https://ampcode.com" },
32
+ pi: { signIn: "pi" },
33
+ // No installer line we can vouch for from here; its own site has it.
34
+ opencode: { docs: "https://opencode.ai/docs" },
35
+ };
36
+
37
+ // The ones the welcome shows up front; the rest wait behind "Show all".
38
+ export const FEATURED_AGENTS: ProviderId[] = [
39
+ "claude",
40
+ "codex",
41
+ "gemini",
42
+ "opencode",
43
+ ];
44
+
45
+ export type AgentState = "missing" | "needs-login" | "ready" | "installed";
46
+
47
+ export function agentState(p: {
48
+ installed: boolean;
49
+ auth: "ready" | "needs-login" | "unknown";
50
+ }): AgentState {
51
+ if (!p.installed) return "missing";
52
+ if (p.auth === "needs-login") return "needs-login";
53
+ return p.auth === "ready" ? "ready" : "installed";
54
+ }
@@ -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",