@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.
@@ -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 row = overlap ? { ...s, overlap } : s;
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 { ForksStrip, forkLabel } from "./Forks";
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
- sessionId: string;
111
- quote: string;
112
- own: boolean;
113
- } | null>(null);
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 === dismissed ? null : 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 normal quote in the draft.
199
+ // Leaving Fork mode keeps the selection as a quote chip and the typed text.
194
200
  const leaveFork = () => {
195
- if (fork?.quote)
196
- setInsert({
197
- text: quoteMarkdown(fork.quote),
198
- at: Date.now(),
199
- append: true,
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("Started fork");
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
- const compact = composerCompacts(text, context, {
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
- text,
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: () => toast.success("Started fork"),
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
- <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">
40
- <Quote aria-hidden className="mt-1 size-3.5 shrink-0" />
41
- <p className="line-clamp-2 min-w-0 flex-1">{fork.quote}</p>
42
- <button
43
- type="button"
44
- onMouseDown={(e) => e.preventDefault()}
45
- onClick={fork.onDropQuote}
46
- aria-label="Remove quote"
47
- className="hover:text-foreground flex size-8 shrink-0 items-center justify-center"
48
- >
49
- <X aria-hidden className="size-4" />
50
- </button>
51
- </div>
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}`;
@@ -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
  }
@@ -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(err instanceof Error ? err.message : String(err)));
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: err instanceof Error ? err.message : String(err),
161
+ message: startFailureMessage(err),
161
162
  });
162
- fail(err);
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
+ }
@@ -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 = this.refreshedNote ?? (this.restartedNote && RESTARTED_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;
@@ -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 session = await (
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;
@@ -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 = `fork-${groupId}`;
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: `Forked ${children.length === 1 ? "1 copy" : `${children.length} copies`}: ${children.map((c) => c.label).join(" · ")}`,
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
@@ -71,6 +71,8 @@ export interface ChildView {
71
71
  delivered: boolean;
72
72
  // A question or approval it's waiting on.
73
73
  asks: string | null;
74
+ // What it is doing now (its latest tool), while it runs.
75
+ activity: string | null;
74
76
  }
75
77
 
76
78
  export interface GroupView {
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) => ({ ...childView(c), n: i + 1 })),
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.833",
3
+ "version": "0.3.836",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"