@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.
- package/README.md +11 -0
- package/app/api/welcome/route.ts +22 -0
- package/app/page.tsx +6 -0
- package/app/welcome/page.tsx +6 -0
- package/components/Chat/BackgroundTasks.tsx +76 -10
- package/components/Chat/ChatPanel.tsx +90 -34
- package/components/Chat/Subagent.tsx +25 -20
- package/components/Chat/Tools.tsx +70 -39
- package/components/Chat/background.tsx +114 -0
- package/components/Projects/AddProject/AddProjectDialog.tsx +2 -2
- package/components/Settings/SettingsDialog.tsx +9 -0
- package/components/Settings/SetupSection.tsx +37 -0
- package/components/Welcome/AgentList.tsx +73 -0
- package/components/Welcome/AgentRow.tsx +102 -0
- package/components/Welcome/ProjectStep.tsx +100 -0
- package/components/Welcome/SetupTerminal.tsx +64 -0
- package/components/Welcome/WelcomeDialog.tsx +153 -0
- package/data/agents/index.ts +16 -1
- package/data/chat/useChat.ts +40 -0
- package/data/welcome/index.ts +36 -0
- package/hooks/useWelcome.ts +70 -0
- package/lib/agents/probe.ts +27 -0
- package/lib/agents/setup-commands.ts +54 -0
- package/lib/chat/activity.ts +4 -2
- package/lib/chat/background.ts +77 -0
- package/lib/chat/driver.ts +6 -0
- package/lib/chat/drivers/claude-mapper.ts +44 -6
- package/lib/chat/drivers/claude.ts +4 -0
- package/lib/chat/events.ts +14 -0
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +20 -0
- package/lib/chat/settings.ts +1 -0
- package/lib/chat/socket.ts +9 -0
- package/lib/chat/worker/client.ts +33 -0
- package/lib/chat/worker/host.ts +13 -0
- package/lib/chat/worker/main.ts +1 -1
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/db/migrations.ts +14 -0
- package/lib/security/auth.ts +11 -1
- package/lib/settings-url.ts +1 -0
- package/lib/welcome-url.ts +33 -0
- package/lib/welcome.ts +88 -0
- package/package.json +1 -1
- package/stores/addProject.ts +19 -1
- package/stores/welcomeUi.ts +26 -0
package/data/agents/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/data/chat/useChat.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/agents/probe.ts
CHANGED
|
@@ -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
|
+
}
|
package/lib/chat/activity.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/lib/chat/driver.ts
CHANGED
|
@@ -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
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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 {
|
package/lib/chat/events.ts
CHANGED
|
@@ -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 }
|
package/lib/chat/registry.ts
CHANGED
|
@@ -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;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -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
|
package/lib/chat/settings.ts
CHANGED
|
@@ -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);
|
package/lib/chat/socket.ts
CHANGED
|
@@ -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",
|