@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.
- 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/data/chat/useChat.ts +40 -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/security/auth.ts +11 -1
- package/package.json +1 -1
|
@@ -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(
|
|
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 (!
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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
|
-
<
|
|
486
|
-
<
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
518
|
-
|
|
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 {
|
|
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
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
{
|
|
40
|
-
|
|
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
|
-
<
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
+
}
|
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,
|
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",
|
|
@@ -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(
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -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(
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -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;
|
package/lib/security/auth.ts
CHANGED
|
@@ -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.
|