@saadnvd1/agent-os 0.3.833 → 0.3.836
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/app/api/sessions/route.ts +7 -1
- package/app/globals.css +14 -0
- package/components/Chat/ChatPanel.tsx +41 -29
- package/components/Chat/Forks.tsx +16 -1
- package/components/Chat/Messages.tsx +14 -0
- package/components/Chat/WorktreeRemovedNotice.tsx +17 -0
- package/components/Chat/composer/ComposerBody.tsx +10 -1
- package/components/Chat/composer/ForkHeader.tsx +26 -15
- package/components/Chat/fork-draft.test.ts +57 -0
- package/components/Chat/fork-draft.ts +34 -0
- package/content/whats-new.json +16 -0
- package/lib/chat/socket.ts +7 -3
- package/lib/chat/start-error.ts +13 -0
- package/lib/chat/worker/cwd.ts +77 -0
- package/lib/chat/worker/host.ts +11 -2
- package/lib/chat/worker/main.ts +11 -2
- package/lib/db/types.ts +2 -0
- package/lib/fork/note.ts +6 -0
- package/lib/fork/start.ts +6 -2
- package/lib/fork/types.ts +2 -0
- package/lib/fork/view.ts +14 -1
- package/package.json +1 -1
|
@@ -10,6 +10,7 @@ import { ModelUnavailableError } from "@/lib/models/agent-binding";
|
|
|
10
10
|
import { namesLine, waitBadges } from "@/lib/bus/waits/store";
|
|
11
11
|
import { claimsSnapshot } from "@/lib/claims";
|
|
12
12
|
import { isProjectRef } from "@/lib/tasks/project-ref";
|
|
13
|
+
import { worktreeRemoved } from "@/lib/chat/worker/cwd";
|
|
13
14
|
import { omitNulls } from "@/lib/sessions/omit-null";
|
|
14
15
|
|
|
15
16
|
// The sidebar's Done shelf lists this many finished tasks; ?done=all lists
|
|
@@ -34,7 +35,12 @@ export async function GET(request?: NextRequest) {
|
|
|
34
35
|
const sessions = rows.map((s) => {
|
|
35
36
|
const b = badges.get(s.id);
|
|
36
37
|
const overlap = claims.bySession.get(s.id);
|
|
37
|
-
const
|
|
38
|
+
const gone = worktreeRemoved(s);
|
|
39
|
+
const row = {
|
|
40
|
+
...s,
|
|
41
|
+
...(overlap && { overlap }),
|
|
42
|
+
...(gone && { worktree_removed: true }),
|
|
43
|
+
};
|
|
38
44
|
return omitNulls(
|
|
39
45
|
b
|
|
40
46
|
? {
|
package/app/globals.css
CHANGED
|
@@ -334,3 +334,17 @@ body {
|
|
|
334
334
|
::highlight(conv-search) {
|
|
335
335
|
background-color: hsl(var(--find-match));
|
|
336
336
|
}
|
|
337
|
+
|
|
338
|
+
/* Toast close button inside the toast's right edge, not half outside the left. */
|
|
339
|
+
[data-sonner-toaster]
|
|
340
|
+
[data-sonner-toast][data-styled="true"]
|
|
341
|
+
[data-close-button] {
|
|
342
|
+
left: auto;
|
|
343
|
+
right: 6px;
|
|
344
|
+
top: 50%;
|
|
345
|
+
margin-top: -10px;
|
|
346
|
+
transform: none;
|
|
347
|
+
background: transparent;
|
|
348
|
+
border-color: transparent;
|
|
349
|
+
color: hsl(var(--muted-foreground));
|
|
350
|
+
}
|
|
@@ -8,17 +8,24 @@ import {
|
|
|
8
8
|
useRef,
|
|
9
9
|
useState,
|
|
10
10
|
} from "react";
|
|
11
|
-
import { ArrowDown, Loader2 } from "lucide-react";
|
|
11
|
+
import { ArrowDown, GitFork, Loader2 } from "lucide-react";
|
|
12
12
|
import type { ClipboardEvent } from "react";
|
|
13
13
|
import { useChat } from "@/data/chat/useChat";
|
|
14
14
|
import { PausedBanner } from "./PausedBanner";
|
|
15
|
+
import { WorktreeRemovedNotice } from "./WorktreeRemovedNotice";
|
|
15
16
|
import { groupTimeline } from "@/lib/chat/group";
|
|
16
17
|
import type { ChatItem } from "@/lib/chat/events";
|
|
17
18
|
import { isFreshCommand, withFresh } from "@/lib/chat/commands";
|
|
18
19
|
import { useExperiment } from "@/data/experiments";
|
|
19
20
|
import { useStartForks } from "@/data/fork";
|
|
20
21
|
import { toast } from "sonner";
|
|
21
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
leaveForkMode,
|
|
24
|
+
placeholderFor,
|
|
25
|
+
suggestionFor,
|
|
26
|
+
type ForkDraft,
|
|
27
|
+
} from "./fork-draft";
|
|
28
|
+
import { ForksStrip, forkLabel, startedToast } from "./Forks";
|
|
22
29
|
import { ReplyingTo } from "./ReplyingTo";
|
|
23
30
|
import { compactsFirst } from "@/lib/chat/pending";
|
|
24
31
|
import { useFreshStart } from "@/data/sessions";
|
|
@@ -106,12 +113,11 @@ export function ChatPanel({
|
|
|
106
113
|
const forksOn = useExperiment("forks");
|
|
107
114
|
const startFork = useStartForks(sessionId);
|
|
108
115
|
// Set while a selection is being forked; per session, like asIs below.
|
|
109
|
-
const [forkDraft, setForkDraft] = useState<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const fork = forksOn && forkDraft?.sessionId === sessionId ? forkDraft : null;
|
|
116
|
+
const [forkDraft, setForkDraft] = useState<ForkDraft | null>(null);
|
|
117
|
+
const draftHere = forkDraft?.sessionId === sessionId ? forkDraft : null;
|
|
118
|
+
const fork = forksOn && draftHere?.mode ? draftHere : null;
|
|
119
|
+
const quoteChip =
|
|
120
|
+
draftHere && !draftHere.mode && draftHere.quote ? draftHere : null;
|
|
115
121
|
const {
|
|
116
122
|
items,
|
|
117
123
|
hasMore,
|
|
@@ -181,7 +187,7 @@ export function ChatPanel({
|
|
|
181
187
|
|
|
182
188
|
// The suggestion the reader set aside (typed over it, or Esc) stays hidden.
|
|
183
189
|
const [dismissed, setDismissed] = useState<string | null>(null);
|
|
184
|
-
const shownSuggestion = suggestion
|
|
190
|
+
const shownSuggestion = suggestionFor(suggestion, dismissed, !!fork);
|
|
185
191
|
const dismissSuggestion = useCallback((s: string) => setDismissed(s), []);
|
|
186
192
|
const history = useMemo(() => sentPrompts(items), [items]);
|
|
187
193
|
const [insert, setInsert] = useState<{
|
|
@@ -190,15 +196,13 @@ export function ChatPanel({
|
|
|
190
196
|
append?: boolean;
|
|
191
197
|
}>();
|
|
192
198
|
|
|
193
|
-
// Leaving Fork mode keeps the selection as a
|
|
199
|
+
// Leaving Fork mode keeps the selection as a quote chip and the typed text.
|
|
194
200
|
const leaveFork = () => {
|
|
195
|
-
if (fork
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
});
|
|
201
|
-
setForkDraft(null);
|
|
201
|
+
if (!fork) return;
|
|
202
|
+
setForkDraft(leaveForkMode(fork));
|
|
203
|
+
toast("Fork mode off · your text is kept", {
|
|
204
|
+
icon: <GitFork aria-hidden className="size-4" />,
|
|
205
|
+
});
|
|
202
206
|
};
|
|
203
207
|
|
|
204
208
|
// Esc sets the composer's ghost text aside, or else stops the turn
|
|
@@ -269,7 +273,7 @@ export function ChatPanel({
|
|
|
269
273
|
focusComposer
|
|
270
274
|
);
|
|
271
275
|
const forkQuote = (text: string) => {
|
|
272
|
-
setForkDraft({ sessionId, quote: text, own: false });
|
|
276
|
+
setForkDraft({ sessionId, quote: text, own: false, mode: true });
|
|
273
277
|
focusComposer();
|
|
274
278
|
};
|
|
275
279
|
const togglePlan = plan === null ? undefined : () => setPlan(!plan);
|
|
@@ -688,6 +692,7 @@ export function ChatPanel({
|
|
|
688
692
|
onFork={forksOn ? forkQuote : undefined}
|
|
689
693
|
/>
|
|
690
694
|
<PausedBanner sessionId={sessionId} />
|
|
695
|
+
<WorktreeRemovedNotice sessionId={sessionId} />
|
|
691
696
|
<ForksStrip sessionId={sessionId} />
|
|
692
697
|
<Queue
|
|
693
698
|
queue={queue}
|
|
@@ -725,13 +730,7 @@ export function ChatPanel({
|
|
|
725
730
|
insert={insert}
|
|
726
731
|
running={running}
|
|
727
732
|
disabled={!connected}
|
|
728
|
-
placeholder={
|
|
729
|
-
!connected
|
|
730
|
-
? "Reconnecting…"
|
|
731
|
-
: fork
|
|
732
|
-
? "What should the fork do?"
|
|
733
|
-
: `Message ${sessionName}`
|
|
734
|
-
}
|
|
733
|
+
placeholder={placeholderFor(connected, !!fork, sessionName)}
|
|
735
734
|
fork={
|
|
736
735
|
fork
|
|
737
736
|
? {
|
|
@@ -743,6 +742,14 @@ export function ChatPanel({
|
|
|
743
742
|
}
|
|
744
743
|
: undefined
|
|
745
744
|
}
|
|
745
|
+
quoteChip={
|
|
746
|
+
quoteChip
|
|
747
|
+
? {
|
|
748
|
+
text: quoteChip.quote,
|
|
749
|
+
onDrop: () => setForkDraft(null),
|
|
750
|
+
}
|
|
751
|
+
: undefined
|
|
752
|
+
}
|
|
746
753
|
onSend={(text, images, files) => {
|
|
747
754
|
if (freshStart && isFreshCommand(text)) {
|
|
748
755
|
startFresh.mutate(sessionId);
|
|
@@ -758,8 +765,8 @@ export function ChatPanel({
|
|
|
758
765
|
labels: [forkLabel(text, fork.quote)],
|
|
759
766
|
},
|
|
760
767
|
{
|
|
761
|
-
onSuccess: () => {
|
|
762
|
-
toast.success(
|
|
768
|
+
onSuccess: (res) => {
|
|
769
|
+
toast.success(startedToast(res.children.map((c) => c.n)));
|
|
763
770
|
setForkDraft(null);
|
|
764
771
|
},
|
|
765
772
|
onError: (e) => {
|
|
@@ -771,14 +778,19 @@ export function ChatPanel({
|
|
|
771
778
|
return;
|
|
772
779
|
}
|
|
773
780
|
toEnd();
|
|
774
|
-
|
|
781
|
+
// A quote kept from Fork mode goes out ahead of what was typed.
|
|
782
|
+
const body = quoteChip
|
|
783
|
+
? (quoteMarkdown(quoteChip.quote) + text).trim()
|
|
784
|
+
: text;
|
|
785
|
+
if (quoteChip) setForkDraft(null);
|
|
786
|
+
const compact = composerCompacts(body, context, {
|
|
775
787
|
state,
|
|
776
788
|
thresholdTokens: compactTokens,
|
|
777
789
|
asIs,
|
|
778
790
|
});
|
|
779
791
|
setAsIs(false);
|
|
780
792
|
send(
|
|
781
|
-
|
|
793
|
+
body,
|
|
782
794
|
images.length ? images : undefined,
|
|
783
795
|
compact,
|
|
784
796
|
files.length ? files : undefined
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
useStartForks,
|
|
11
11
|
type ChildView,
|
|
12
12
|
} from "@/data/fork";
|
|
13
|
+
import { formatElapsed } from "@/lib/chat/elapsed";
|
|
13
14
|
import { Button } from "@/components/ui/button";
|
|
14
15
|
import { StatusPill } from "@/components/ui/status-pill";
|
|
15
16
|
import { SessionAnchor } from "./SessionAnchor";
|
|
@@ -34,6 +35,9 @@ export const forkLabel = (typed: string, quote: string): string => {
|
|
|
34
35
|
return line.length > 60 ? `${line.slice(0, 59)}…` : line;
|
|
35
36
|
};
|
|
36
37
|
|
|
38
|
+
export const startedToast = (ns: number[]): string =>
|
|
39
|
+
ns.length === 1 ? `Started fork ${ns[0]}` : `Started forks ${ns.join(", ")}`;
|
|
40
|
+
|
|
37
41
|
// What a fork that ended with nothing to read says in place of a result.
|
|
38
42
|
export const emptyNote = (c: ChildView) =>
|
|
39
43
|
c.summary && c.summary !== "ended without a result"
|
|
@@ -49,13 +53,23 @@ export function useRunAgain(sessionId: string) {
|
|
|
49
53
|
start.mutate(
|
|
50
54
|
{ instructions: [c.instruction], worktree: c.worktree },
|
|
51
55
|
{
|
|
52
|
-
onSuccess: () =>
|
|
56
|
+
onSuccess: (res) =>
|
|
57
|
+
toast.success(startedToast(res.children.map((x) => x.n))),
|
|
53
58
|
onError: (e) => toast.error(e.message),
|
|
54
59
|
}
|
|
55
60
|
),
|
|
56
61
|
};
|
|
57
62
|
}
|
|
58
63
|
|
|
64
|
+
// "2m 14s · reading sync/worker.ts": how long it has gone and its latest step.
|
|
65
|
+
export const runningNote = (c: ChildView, now = Date.now()): string | null => {
|
|
66
|
+
const parts = [
|
|
67
|
+
c.startedAt ? formatElapsed(now - c.startedAt) : null,
|
|
68
|
+
c.activity,
|
|
69
|
+
].filter(Boolean);
|
|
70
|
+
return parts.length ? parts.join(" · ") : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
59
73
|
function Row({
|
|
60
74
|
c,
|
|
61
75
|
onStop,
|
|
@@ -72,6 +86,7 @@ function Row({
|
|
|
72
86
|
const empty = c.outcome === "empty";
|
|
73
87
|
const note =
|
|
74
88
|
(empty ? emptyNote(c) : c.summary) ??
|
|
89
|
+
(c.state === "running" ? runningNote(c) : null) ??
|
|
75
90
|
(c.asks
|
|
76
91
|
? `Asks: ${c.asks}`
|
|
77
92
|
: c.state === "queued"
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
Check,
|
|
10
10
|
ChevronRight,
|
|
11
11
|
Download,
|
|
12
|
+
GitFork,
|
|
12
13
|
Circle,
|
|
13
14
|
CircleDot,
|
|
14
15
|
Quote,
|
|
@@ -41,6 +42,7 @@ import {
|
|
|
41
42
|
} from "@/components/Bookmarks/BookmarkButton";
|
|
42
43
|
import { useOwnerName } from "@/data/client-config/queries";
|
|
43
44
|
import { ownerVerb } from "@/lib/owner-label";
|
|
45
|
+
import { FORK_NOTE_PREFIX } from "@/lib/fork/note";
|
|
44
46
|
|
|
45
47
|
type Of<K extends ChatItem["kind"]> = Extract<ChatItem, { kind: K }>;
|
|
46
48
|
|
|
@@ -481,12 +483,24 @@ const NOTE_LABEL = {
|
|
|
481
483
|
away: "Away",
|
|
482
484
|
} as const;
|
|
483
485
|
|
|
486
|
+
// Where forks were started: one quiet line, not a boxed note.
|
|
487
|
+
function ForkMarker({ item }: { item: Of<"note"> }) {
|
|
488
|
+
return (
|
|
489
|
+
<div className="text-muted-foreground flex min-w-0 items-center gap-2 text-xs">
|
|
490
|
+
<GitFork aria-hidden className="size-3.5 shrink-0" />
|
|
491
|
+
<span className="truncate">{item.text}</span>
|
|
492
|
+
<i className="bg-border h-px w-3 shrink-0" />
|
|
493
|
+
</div>
|
|
494
|
+
);
|
|
495
|
+
}
|
|
496
|
+
|
|
484
497
|
export function NoteLine({ item }: { item: Of<"note"> }) {
|
|
485
498
|
const name = useOwnerName();
|
|
486
499
|
const label =
|
|
487
500
|
item.tone === "escalation"
|
|
488
501
|
? ownerVerb(name, "decides", "decide")
|
|
489
502
|
: NOTE_LABEL[item.tone];
|
|
503
|
+
if (item.id.startsWith(FORK_NOTE_PREFIX)) return <ForkMarker item={item} />;
|
|
490
504
|
return (
|
|
491
505
|
<div
|
|
492
506
|
className={cn(
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useSessionsQuery } from "@/data/sessions";
|
|
4
|
+
import { Notice } from "@/components/ui/notice";
|
|
5
|
+
|
|
6
|
+
// Above the composer once a finished task's worktree is removed: its chat
|
|
7
|
+
// still answers from the project's checkout, but can't change anything.
|
|
8
|
+
export function WorktreeRemovedNotice({ sessionId }: { sessionId: string }) {
|
|
9
|
+
const { data } = useSessionsQuery();
|
|
10
|
+
const session = data?.sessions.find((s) => s.id === sessionId);
|
|
11
|
+
if (!session?.worktree_removed) return null;
|
|
12
|
+
return (
|
|
13
|
+
<Notice className="mb-2">
|
|
14
|
+
Merged · worktree removed. Questions work; changes need a new task.
|
|
15
|
+
</Notice>
|
|
16
|
+
);
|
|
17
|
+
}
|
|
@@ -32,7 +32,7 @@ import { offeredSuggestion } from "@/lib/chat/suggestion";
|
|
|
32
32
|
import { historyStep } from "@/lib/chat/history";
|
|
33
33
|
import { markdownParagraphs } from "@/lib/chat/quote";
|
|
34
34
|
import { insertMarkdown } from "./insert";
|
|
35
|
-
import { ForkHeader, type ForkComposer } from "./ForkHeader";
|
|
35
|
+
import { ForkHeader, QuoteChip, type ForkComposer } from "./ForkHeader";
|
|
36
36
|
import { useCoarsePointer } from "./useCoarsePointer";
|
|
37
37
|
import { useMentions } from "./useMentions";
|
|
38
38
|
import { Attachments } from "./Attachments";
|
|
@@ -56,6 +56,8 @@ export interface ComposerProps {
|
|
|
56
56
|
// The send button's label, when Enter does something else than send.
|
|
57
57
|
// Fork mode: the quote chip, pill and own-checkout choice.
|
|
58
58
|
fork?: ForkComposer;
|
|
59
|
+
// After Fork mode: the selection stays as a chip on a normal reply.
|
|
60
|
+
quoteChip?: { text: string; onDrop: () => void };
|
|
59
61
|
onSend: (text: string, images: ChatImage[], files: ChatFile[]) => void;
|
|
60
62
|
// Where attached files are kept: the session's id, or "draft" before one exists.
|
|
61
63
|
attachScope: string;
|
|
@@ -99,6 +101,7 @@ export function ComposerBody({
|
|
|
99
101
|
disabled,
|
|
100
102
|
placeholder,
|
|
101
103
|
fork,
|
|
104
|
+
quoteChip,
|
|
102
105
|
onSend,
|
|
103
106
|
onStop,
|
|
104
107
|
commands = [],
|
|
@@ -276,6 +279,7 @@ export function ComposerBody({
|
|
|
276
279
|
|
|
277
280
|
const empty =
|
|
278
281
|
!fork?.quote &&
|
|
282
|
+
!quoteChip &&
|
|
279
283
|
!text.trim() &&
|
|
280
284
|
!images.length &&
|
|
281
285
|
!files.length &&
|
|
@@ -371,6 +375,11 @@ export function ComposerBody({
|
|
|
371
375
|
)}
|
|
372
376
|
>
|
|
373
377
|
{fork && <ForkHeader fork={fork} />}
|
|
378
|
+
{quoteChip && (
|
|
379
|
+
<div className="mb-1.5">
|
|
380
|
+
<QuoteChip text={quoteChip.text} onDrop={quoteChip.onDrop} />
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
374
383
|
<Attachments
|
|
375
384
|
images={images}
|
|
376
385
|
files={files}
|
|
@@ -35,21 +35,32 @@ export function ForkHeader({ fork }: { fork: ForkComposer }) {
|
|
|
35
35
|
↵ start · Esc cancel
|
|
36
36
|
</span>
|
|
37
37
|
</div>
|
|
38
|
-
{fork.quote &&
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
38
|
+
{fork.quote && <QuoteChip text={fork.quote} onDrop={fork.onDropQuote} />}
|
|
39
|
+
</div>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The selection as a chip above the box; it goes out ahead of what's typed.
|
|
44
|
+
export function QuoteChip({
|
|
45
|
+
text,
|
|
46
|
+
onDrop,
|
|
47
|
+
}: {
|
|
48
|
+
text: string;
|
|
49
|
+
onDrop: () => void;
|
|
50
|
+
}) {
|
|
51
|
+
return (
|
|
52
|
+
<div className="bg-muted/60 text-muted-foreground flex items-start gap-2 rounded-lg py-1.5 pr-1 pl-2.5 text-sm">
|
|
53
|
+
<Quote aria-hidden className="mt-1 size-3.5 shrink-0" />
|
|
54
|
+
<p className="line-clamp-2 min-w-0 flex-1">{text}</p>
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
58
|
+
onClick={onDrop}
|
|
59
|
+
aria-label="Remove quote"
|
|
60
|
+
className="hover:text-foreground flex size-8 shrink-0 items-center justify-center"
|
|
61
|
+
>
|
|
62
|
+
<X aria-hidden className="size-4" />
|
|
63
|
+
</button>
|
|
53
64
|
</div>
|
|
54
65
|
);
|
|
55
66
|
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
FORK_PLACEHOLDER,
|
|
4
|
+
leaveForkMode,
|
|
5
|
+
placeholderFor,
|
|
6
|
+
suggestionFor,
|
|
7
|
+
type ForkDraft,
|
|
8
|
+
} from "./fork-draft";
|
|
9
|
+
import { forkLabel, runningNote, startedToast } from "./Forks";
|
|
10
|
+
import { startedText } from "@/lib/fork/note";
|
|
11
|
+
import type { ChildView } from "@/lib/fork/types";
|
|
12
|
+
|
|
13
|
+
const draft: ForkDraft = {
|
|
14
|
+
sessionId: "s",
|
|
15
|
+
quote: "the text",
|
|
16
|
+
own: true,
|
|
17
|
+
mode: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe("Fork mode composer", () => {
|
|
21
|
+
it("hides the parent's suggestion and uses the fork placeholder", () => {
|
|
22
|
+
expect(
|
|
23
|
+
suggestionFor("what does --rollback --to do", null, true)
|
|
24
|
+
).toBeNull();
|
|
25
|
+
expect(suggestionFor("next", null, false)).toBe("next");
|
|
26
|
+
expect(suggestionFor("next", "next", false)).toBeNull();
|
|
27
|
+
expect(placeholderFor(true, true, "Work")).toBe(FORK_PLACEHOLDER);
|
|
28
|
+
expect(placeholderFor(true, false, "Work")).toBe("Message Work");
|
|
29
|
+
expect(placeholderFor(false, true, "Work")).toBe("Reconnecting…");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("keeps the quote and drops only the Fork mode on leaving", () => {
|
|
33
|
+
expect(leaveForkMode(draft)).toEqual({ ...draft, mode: false });
|
|
34
|
+
expect(leaveForkMode({ ...draft, quote: "" })).toBeNull();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("fork copy", () => {
|
|
39
|
+
it("words the started marker and toast", () => {
|
|
40
|
+
expect(startedText([1], ["Check it"])).toBe("Fork 1 started · Check it");
|
|
41
|
+
expect(startedText([2, 3], ["a", "b"])).toBe("Forks 2, 3 started · a · b");
|
|
42
|
+
expect(startedToast([4])).toBe("Started fork 4");
|
|
43
|
+
expect(forkLabel("", "> q")).toBe("> q");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("says how long a running fork has gone and what it's doing", () => {
|
|
47
|
+
const c = {
|
|
48
|
+
startedAt: 1_000,
|
|
49
|
+
activity: "Read sync/worker.ts",
|
|
50
|
+
} as ChildView;
|
|
51
|
+
expect(runningNote(c, 1_000 + 134_000)).toBe(
|
|
52
|
+
"2m 14s · Read sync/worker.ts"
|
|
53
|
+
);
|
|
54
|
+
expect(runningNote({ ...c, activity: null }, 6_000)).toBe("5s");
|
|
55
|
+
expect(runningNote({ ...c, startedAt: null, activity: null })).toBeNull();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// A selection being forked, and what's left of it after leaving Fork mode.
|
|
2
|
+
export interface ForkDraft {
|
|
3
|
+
sessionId: string;
|
|
4
|
+
quote: string;
|
|
5
|
+
own: boolean;
|
|
6
|
+
// Off after leaving Fork mode: the quote stays as a chip on a normal reply.
|
|
7
|
+
mode: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const FORK_PLACEHOLDER = "What should the fork do?";
|
|
11
|
+
|
|
12
|
+
// Esc or the pill's x: only the Fork pill goes; the quote chip stays when
|
|
13
|
+
// there is one, and what was typed stays in the composer.
|
|
14
|
+
export const leaveForkMode = (draft: ForkDraft): ForkDraft | null =>
|
|
15
|
+
draft.quote ? { ...draft, mode: false } : null;
|
|
16
|
+
|
|
17
|
+
// The parent's guess has no place in Fork mode, which asks its own question.
|
|
18
|
+
export const suggestionFor = (
|
|
19
|
+
suggestion: string | null,
|
|
20
|
+
dismissed: string | null,
|
|
21
|
+
inForkMode: boolean
|
|
22
|
+
): string | null =>
|
|
23
|
+
inForkMode || suggestion === dismissed ? null : suggestion;
|
|
24
|
+
|
|
25
|
+
export const placeholderFor = (
|
|
26
|
+
connected: boolean,
|
|
27
|
+
inForkMode: boolean,
|
|
28
|
+
sessionName: string
|
|
29
|
+
): string =>
|
|
30
|
+
!connected
|
|
31
|
+
? "Reconnecting…"
|
|
32
|
+
: inForkMode
|
|
33
|
+
? FORK_PLACEHOLDER
|
|
34
|
+
: `Message ${sessionName}`;
|
package/content/whats-new.json
CHANGED
|
@@ -1953,6 +1953,22 @@
|
|
|
1953
1953
|
"area": "AgentOS",
|
|
1954
1954
|
"text": "Select text in a chat reply and tap Fork to hand it to a background copy of the session; its result comes back as a card.",
|
|
1955
1955
|
"url": "https://github.com/saadnvd1/agent-os/pull/558"
|
|
1956
|
+
},
|
|
1957
|
+
{
|
|
1958
|
+
"id": 560,
|
|
1959
|
+
"date": "2026-11-02",
|
|
1960
|
+
"kind": "fixed",
|
|
1961
|
+
"area": "Chat",
|
|
1962
|
+
"text": "You can ask follow-up questions in a task's chat after its work has merged, with a note that changes need a new task.",
|
|
1963
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/560"
|
|
1964
|
+
},
|
|
1965
|
+
{
|
|
1966
|
+
"id": 561,
|
|
1967
|
+
"date": "2026-11-02",
|
|
1968
|
+
"kind": "improved",
|
|
1969
|
+
"area": "AgentOS",
|
|
1970
|
+
"text": "Forking from a selection is tidier: the fork pill can be dismissed while keeping your quote and text, and started forks show a one-line marker and what they're doing.",
|
|
1971
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/561"
|
|
1956
1972
|
}
|
|
1957
1973
|
]
|
|
1958
1974
|
}
|
package/lib/chat/socket.ts
CHANGED
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import { setComposerModel } from "../models/session-model";
|
|
33
33
|
import { stopChatTurn } from "./stop";
|
|
34
34
|
import { clientSend } from "./client-send";
|
|
35
|
+
import { startFailureMessage } from "./start-error";
|
|
35
36
|
import { searchChat } from "./search";
|
|
36
37
|
import { DEMO_CHAT_READS, handleDemoChat } from "./demo";
|
|
37
38
|
import { titleChatFromMessage } from "../session-titles";
|
|
@@ -97,7 +98,7 @@ export function serveChatSocket(
|
|
|
97
98
|
const fail = (err: unknown) => {
|
|
98
99
|
console.error(`[chat ${sessionId}] message failed:`, err);
|
|
99
100
|
if (!sessionExists(sessionId)) return gone();
|
|
100
|
-
reply(errorItem(
|
|
101
|
+
reply(errorItem(startFailureMessage(err)));
|
|
101
102
|
};
|
|
102
103
|
|
|
103
104
|
if (!sessionExists(sessionId)) return gone();
|
|
@@ -157,9 +158,12 @@ function onChatMessage(
|
|
|
157
158
|
reply({
|
|
158
159
|
type: "send_failed",
|
|
159
160
|
id,
|
|
160
|
-
message:
|
|
161
|
+
message: startFailureMessage(err),
|
|
161
162
|
});
|
|
162
|
-
|
|
163
|
+
// A message with an id shows its failure on itself; the rest of the
|
|
164
|
+
// chat hears of it once, here.
|
|
165
|
+
if (id) console.error(`[chat ${sessionId}] send failed:`, err);
|
|
166
|
+
else fail(err);
|
|
163
167
|
}
|
|
164
168
|
);
|
|
165
169
|
// "Session 4" is named after its first message.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// What a person reads when a chat's worker can't start. The raw error (a
|
|
2
|
+
// socket ENOENT, usually) stays in the server log.
|
|
3
|
+
export function startFailureMessage(error: unknown): string {
|
|
4
|
+
const raw = error instanceof Error ? error.message : String(error);
|
|
5
|
+
if (!/^The chat worker didn't start/.test(raw)) return raw;
|
|
6
|
+
const reason = /ENOENT|ECONNREFUSED/.test(raw)
|
|
7
|
+
? "it stopped while starting"
|
|
8
|
+
: raw
|
|
9
|
+
.replace(/^The chat worker didn't start:?\s*/, "")
|
|
10
|
+
.split("\n")[0]
|
|
11
|
+
.slice(0, 120) || "it didn't answer";
|
|
12
|
+
return `Couldn't start the agent: ${reason}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { getProject } from "../../projects";
|
|
5
|
+
import type { Session } from "../../db";
|
|
6
|
+
|
|
7
|
+
const expand = (p: string) => p.replace(/^~/, os.homedir());
|
|
8
|
+
const isDir = (p: string) => {
|
|
9
|
+
try {
|
|
10
|
+
return fs.statSync(p).isDirectory();
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const WORKTREE_REMOVED_NOTE =
|
|
17
|
+
"(This task's PR merged and its worktree was removed. You are now running in the project's main checkout: do not edit files or commit here. Questions are fine; if more changes are needed, say a follow-up task is required.)";
|
|
18
|
+
|
|
19
|
+
// The project's own checkout, for a session whose folder is gone; null when
|
|
20
|
+
// that is missing too or the session never had a project.
|
|
21
|
+
function projectRoot(session: Session): string | null {
|
|
22
|
+
const project = session.project_id ? getProject(session.project_id) : null;
|
|
23
|
+
const root = project ? expand(project.working_directory) : null;
|
|
24
|
+
return root && isDir(root) ? root : null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Where a session's agent runs. A task's worktree is removed when it
|
|
28
|
+
// merges, but its chat can still be asked a question: it runs in the
|
|
29
|
+
// project's checkout instead.
|
|
30
|
+
export function resolveChatCwd(session: Session): {
|
|
31
|
+
session: Session;
|
|
32
|
+
removedFrom: string | null;
|
|
33
|
+
} {
|
|
34
|
+
const dir = expand(session.working_directory);
|
|
35
|
+
if (isDir(dir)) return { session, removedFrom: null };
|
|
36
|
+
const root = projectRoot(session);
|
|
37
|
+
if (!root) return { session, removedFrom: null };
|
|
38
|
+
return {
|
|
39
|
+
session: { ...session, working_directory: root },
|
|
40
|
+
removedFrom: dir,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Claude finds a conversation under its folder's name in its config dir, so
|
|
45
|
+
// a resume from a new folder needs the transcript copied there.
|
|
46
|
+
export function carryClaudeTranscript(
|
|
47
|
+
from: string,
|
|
48
|
+
to: string,
|
|
49
|
+
claudeSessionId: string | null
|
|
50
|
+
): void {
|
|
51
|
+
if (!claudeSessionId) return;
|
|
52
|
+
const base = path.join(
|
|
53
|
+
process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"),
|
|
54
|
+
"projects"
|
|
55
|
+
);
|
|
56
|
+
const name = (d: string) => d.replace(/[^a-zA-Z0-9]/g, "-");
|
|
57
|
+
const src = path.join(base, name(from), `${claudeSessionId}.jsonl`);
|
|
58
|
+
const dest = path.join(base, name(to), `${claudeSessionId}.jsonl`);
|
|
59
|
+
try {
|
|
60
|
+
if (!fs.existsSync(src) || fs.existsSync(dest)) return;
|
|
61
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
62
|
+
fs.copyFileSync(src, dest);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.error(
|
|
65
|
+
"Couldn't carry the transcript to the project folder:",
|
|
66
|
+
error
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A finished task whose worktree is gone: the sidebar's cue for the notice.
|
|
72
|
+
export function worktreeRemoved(session: Session): boolean {
|
|
73
|
+
if (!session.worktree_path || !session.task_status) return false;
|
|
74
|
+
if (session.task_status === "running") return false;
|
|
75
|
+
if (session.host_id && session.host_id !== "local") return false;
|
|
76
|
+
return !isDir(expand(session.worktree_path));
|
|
77
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -8,6 +8,7 @@ import os from "os";
|
|
|
8
8
|
import { db, type Session } from "../../db";
|
|
9
9
|
import { agentEnv } from "../../agents/launch";
|
|
10
10
|
import { forkBrief } from "../../fork/extras";
|
|
11
|
+
import { WORKTREE_REMOVED_NOTE } from "./cwd";
|
|
11
12
|
import { sessionRunningBrief } from "../../project-config/session";
|
|
12
13
|
import { BUS_BRIEF } from "../../agents/brief";
|
|
13
14
|
import { resolveModelForAgent } from "../../model-catalog";
|
|
@@ -196,7 +197,9 @@ export class ChatHost {
|
|
|
196
197
|
private session: Session,
|
|
197
198
|
private emit: (e: WorkerEvent) => void,
|
|
198
199
|
// What a session's role adds: its brief and its own tools.
|
|
199
|
-
extras: RoleExtras = {}
|
|
200
|
+
extras: RoleExtras = {},
|
|
201
|
+
// Its worktree was removed: it runs in the project's checkout.
|
|
202
|
+
private worktreeGone = false
|
|
200
203
|
) {
|
|
201
204
|
this.gate = new GuestGate((note) => {
|
|
202
205
|
if (!this.busy()) return;
|
|
@@ -611,7 +614,13 @@ export class ChatHost {
|
|
|
611
614
|
if (handOver) this.stopsUpTo = this.turnsStarted;
|
|
612
615
|
if (now) this.nowPending++;
|
|
613
616
|
this.stopping = null;
|
|
614
|
-
const note =
|
|
617
|
+
const note =
|
|
618
|
+
[
|
|
619
|
+
this.worktreeGone && WORKTREE_REMOVED_NOTE,
|
|
620
|
+
this.refreshedNote ?? (this.restartedNote && RESTARTED_NOTE),
|
|
621
|
+
]
|
|
622
|
+
.filter(Boolean)
|
|
623
|
+
.join("\n") || null;
|
|
615
624
|
const text = agentText(note ? `${note}\n\n${m.text}` : m.text, m.files);
|
|
616
625
|
if (this.refreshedNote) {
|
|
617
626
|
this.refreshedNote = null;
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -41,9 +41,17 @@ async function main(sessionId: string) {
|
|
|
41
41
|
console.error(`No session ${sessionId}`);
|
|
42
42
|
process.exit(1);
|
|
43
43
|
}
|
|
44
|
-
const
|
|
44
|
+
const routed = await (
|
|
45
45
|
await import("../../models/launch")
|
|
46
46
|
).routeOrchestrator(row);
|
|
47
|
+
const { resolveChatCwd, carryClaudeTranscript } = await import("./cwd");
|
|
48
|
+
const { session, removedFrom } = resolveChatCwd(routed);
|
|
49
|
+
if (removedFrom && session.agent_type === "claude")
|
|
50
|
+
carryClaudeTranscript(
|
|
51
|
+
removedFrom,
|
|
52
|
+
session.working_directory,
|
|
53
|
+
session.claude_session_id
|
|
54
|
+
);
|
|
47
55
|
|
|
48
56
|
const clients = new Set<net.Socket>();
|
|
49
57
|
const emit = (e: WorkerEvent) => {
|
|
@@ -60,7 +68,8 @@ async function main(sessionId: string) {
|
|
|
60
68
|
emit(e);
|
|
61
69
|
if (e.type === "state") armIdle();
|
|
62
70
|
},
|
|
63
|
-
extras
|
|
71
|
+
extras,
|
|
72
|
+
!!removedFrom
|
|
64
73
|
);
|
|
65
74
|
function armIdle() {
|
|
66
75
|
clearTimeout(idleTimer);
|
package/lib/db/types.ts
CHANGED
|
@@ -87,6 +87,8 @@ export interface Session {
|
|
|
87
87
|
// Task priority (null reads as normal) and timed pause (lib/tasks/pause.ts):
|
|
88
88
|
// pausing, paused or resuming, until when (null: until resumed).
|
|
89
89
|
priority?: string | null;
|
|
90
|
+
// Set only by GET /api/sessions: a finished task whose worktree is gone.
|
|
91
|
+
worktree_removed?: boolean;
|
|
90
92
|
pause_state?: "pausing" | "paused" | "resuming" | null;
|
|
91
93
|
paused_until?: string | null;
|
|
92
94
|
pause_id?: number | null;
|
package/lib/fork/note.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The one line the parent's chat keeps where forks were started. */
|
|
2
|
+
|
|
3
|
+
export const FORK_NOTE_PREFIX = "fork-";
|
|
4
|
+
|
|
5
|
+
export const startedText = (ns: number[], labels: string[]): string =>
|
|
6
|
+
`${ns.length === 1 ? `Fork ${ns[0]}` : `Forks ${ns.join(", ")}`} started · ${labels.join(" · ")}`;
|
package/lib/fork/start.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { getProject } from "../projects";
|
|
|
18
18
|
import { limitOf, runningIn, startingIn } from "../tasks/queue-rows";
|
|
19
19
|
import { currentLevel, loadEnabled } from "../load/monitor";
|
|
20
20
|
import { childFirstMessage } from "./brief";
|
|
21
|
+
import { FORK_NOTE_PREFIX, startedText } from "./note";
|
|
21
22
|
import { ForkError, forkConversation } from "./conversation";
|
|
22
23
|
import {
|
|
23
24
|
childrenOf,
|
|
@@ -120,7 +121,7 @@ export function startFork(req: ForkRequest): ForkStarted {
|
|
|
120
121
|
instruction,
|
|
121
122
|
worktree,
|
|
122
123
|
}));
|
|
123
|
-
const noteId =
|
|
124
|
+
const noteId = `${FORK_NOTE_PREFIX}${groupId}`;
|
|
124
125
|
insertGroup({
|
|
125
126
|
id: groupId,
|
|
126
127
|
parentId: parent.id,
|
|
@@ -136,7 +137,10 @@ export function startFork(req: ForkRequest): ForkStarted {
|
|
|
136
137
|
kind: "note" as const,
|
|
137
138
|
tone: "note" as const,
|
|
138
139
|
createdAt: Date.now(),
|
|
139
|
-
text:
|
|
140
|
+
text: startedText(
|
|
141
|
+
children.map((c) => numberOf(parent.id, c.childId)),
|
|
142
|
+
children.map((c) => c.label)
|
|
143
|
+
),
|
|
140
144
|
};
|
|
141
145
|
saveItem(parent.id, item);
|
|
142
146
|
emit(parent.id, { type: "item", item });
|
package/lib/fork/types.ts
CHANGED
package/lib/fork/view.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/** What clients and `aos children` show, and the numbers behind them. */
|
|
2
2
|
|
|
3
|
+
import { lastItems } from "../chat/store";
|
|
3
4
|
import { childrenOf, getGroup } from "./ledger";
|
|
4
5
|
import type { ChildRow, ChildView, GroupView } from "./types";
|
|
5
6
|
|
|
@@ -21,8 +22,16 @@ export const childView = (c: ChildRow): ChildView => ({
|
|
|
21
22
|
settledAt: c.settled_at,
|
|
22
23
|
delivered: c.delivered_at != null,
|
|
23
24
|
asks: c.state === "needs_input" ? c.hold : null,
|
|
25
|
+
activity: null,
|
|
24
26
|
});
|
|
25
27
|
|
|
28
|
+
// The title of the latest tool a running child used, to say what it is doing.
|
|
29
|
+
const activityOf = (c: ChildRow): string | null => {
|
|
30
|
+
if (c.state !== "running") return null;
|
|
31
|
+
const tool = lastItems(c.child_session_id, 1, "tool")[0];
|
|
32
|
+
return tool && tool.kind === "tool" ? tool.title : null;
|
|
33
|
+
};
|
|
34
|
+
|
|
26
35
|
export function childrenView(parentId: string): {
|
|
27
36
|
children: ChildView[];
|
|
28
37
|
groups: GroupView[];
|
|
@@ -45,7 +54,11 @@ export function childrenView(parentId: string): {
|
|
|
45
54
|
});
|
|
46
55
|
}
|
|
47
56
|
return {
|
|
48
|
-
children: rows.map((c, i) => ({
|
|
57
|
+
children: rows.map((c, i) => ({
|
|
58
|
+
...childView(c),
|
|
59
|
+
n: i + 1,
|
|
60
|
+
activity: activityOf(c),
|
|
61
|
+
})),
|
|
49
62
|
groups,
|
|
50
63
|
};
|
|
51
64
|
}
|