@saadnvd1/agent-os 0.3.828 → 0.3.833

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -68,6 +68,8 @@ It ends by offering `agent-os setup`. If another device can't reach the machine
68
68
 
69
69
  Then open AgentOS, add a project from ⌘K, and press ⌥N. The [install guide](docs/guides/install.md) covers Windows (WSL2), the supported agents, first run and the `agent-os` commands. To use it from your phone, see the [phone guide](docs/guides/phone.md).
70
70
 
71
+ AgentOS keeps itself up to date: once a release is two hours old and nothing has been working for 10 minutes, it installs it, and puts the previous version back if the new one doesn't come up healthy. Turn it off or change the idle time in **Settings → Updates**, or pin it with `AGENTOS_AUTO_UPDATE=off` and `AGENTOS_AUTO_UPDATE_IDLE_MIN`. Details are in [Automatic updates](docs/guides/install.md#automatic-updates).
72
+
71
73
  ## Connect
72
74
 
73
75
  AgentOS Connect is live. It gives your machine a private address at runagentos.com, so your phone reaches it from any network with nothing to open on your router and no VPN.
package/app/layout.tsx CHANGED
@@ -97,6 +97,9 @@ export default function RootLayout({
97
97
  </SerwistProvider>
98
98
  <Toaster
99
99
  position="top-center"
100
+ // Below the header, not over its title.
101
+ offset={{ top: 56 }}
102
+ mobileOffset={{ top: 64 }}
100
103
  closeButton
101
104
  toastOptions={{
102
105
  className: "!bg-card !border-border !text-foreground !shadow-lg",
@@ -14,14 +14,11 @@ import { useChat } from "@/data/chat/useChat";
14
14
  import { PausedBanner } from "./PausedBanner";
15
15
  import { groupTimeline } from "@/lib/chat/group";
16
16
  import type { ChatItem } from "@/lib/chat/events";
17
- import {
18
- isForkCommand,
19
- isFreshCommand,
20
- withFork,
21
- withFresh,
22
- } from "@/lib/chat/commands";
17
+ import { isFreshCommand, withFresh } from "@/lib/chat/commands";
23
18
  import { useExperiment } from "@/data/experiments";
24
- import { ForkDialog, ForksStrip } from "./Forks";
19
+ import { useStartForks } from "@/data/fork";
20
+ import { toast } from "sonner";
21
+ import { ForksStrip, forkLabel } from "./Forks";
25
22
  import { ReplyingTo } from "./ReplyingTo";
26
23
  import { compactsFirst } from "@/lib/chat/pending";
27
24
  import { useFreshStart } from "@/data/sessions";
@@ -107,8 +104,14 @@ export function ChatPanel({
107
104
  append?: boolean;
108
105
  }>();
109
106
  const forksOn = useExperiment("forks");
110
- const [forkOpen, setForkOpen] = useState(false);
111
- const withForkIf = (c: typeof commands) => (forksOn ? withFork(c) : c);
107
+ const startFork = useStartForks(sessionId);
108
+ // 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;
112
115
  const {
113
116
  items,
114
117
  hasMore,
@@ -187,12 +190,33 @@ export function ChatPanel({
187
190
  append?: boolean;
188
191
  }>();
189
192
 
193
+ // Leaving Fork mode keeps the selection as a normal quote in the draft.
194
+ const leaveFork = () => {
195
+ if (fork?.quote)
196
+ setInsert({
197
+ text: quoteMarkdown(fork.quote),
198
+ at: Date.now(),
199
+ append: true,
200
+ });
201
+ setForkDraft(null);
202
+ };
203
+
190
204
  // Esc sets the composer's ghost text aside, or else stops the turn
191
205
  // (lib/chat/escape), from anywhere in this panel.
192
206
  const rootRef = useRef<HTMLDivElement>(null);
193
- const escape = useRef({ running, interrupt, suggestion });
207
+ const escape = useRef({
208
+ running,
209
+ interrupt,
210
+ suggestion,
211
+ leaveFork: null as (() => void) | null,
212
+ });
194
213
  useEffect(() => {
195
- escape.current = { running, interrupt, suggestion };
214
+ escape.current = {
215
+ running,
216
+ interrupt,
217
+ suggestion,
218
+ leaveFork: fork ? leaveFork : null,
219
+ };
196
220
  });
197
221
  useEffect(() => {
198
222
  const onKey = (e: KeyboardEvent) => {
@@ -203,6 +227,17 @@ export function ChatPanel({
203
227
  const overlay = !!document.querySelector(OVERLAY_SELECTOR);
204
228
  const ghost = !!root.querySelector(GHOST_SELECTOR);
205
229
  const composer = (e.target as Element).closest?.(".chat-composer");
230
+ // Fork mode ends first; the typed text stays as a normal draft.
231
+ if (
232
+ e.key === "Escape" &&
233
+ escape.current.leaveFork &&
234
+ composer &&
235
+ !overlay
236
+ ) {
237
+ e.preventDefault();
238
+ escape.current.leaveFork();
239
+ return;
240
+ }
206
241
  const action = escapeAction(
207
242
  e,
208
243
  escape.current.running,
@@ -233,6 +268,10 @@ export function ChatPanel({
233
268
  setInsert({ text: quoteMarkdown(t), at: Date.now(), append: true }),
234
269
  focusComposer
235
270
  );
271
+ const forkQuote = (text: string) => {
272
+ setForkDraft({ sessionId, quote: text, own: false });
273
+ focusComposer();
274
+ };
236
275
  const togglePlan = plan === null ? undefined : () => setPlan(!plan);
237
276
  const sendText = (text: string) => {
238
277
  toEnd();
@@ -643,7 +682,11 @@ export function ChatPanel({
643
682
  onStop={stopTask}
644
683
  onLoadOutput={loadTaskOutput}
645
684
  />
646
- <QuoteButton rootRef={scrollRef} onQuote={quote} />
685
+ <QuoteButton
686
+ rootRef={scrollRef}
687
+ onQuote={quote}
688
+ onFork={forksOn ? forkQuote : undefined}
689
+ />
647
690
  <PausedBanner sessionId={sessionId} />
648
691
  <ForksStrip sessionId={sessionId} />
649
692
  <Queue
@@ -682,14 +725,49 @@ export function ChatPanel({
682
725
  insert={insert}
683
726
  running={running}
684
727
  disabled={!connected}
685
- placeholder={connected ? `Message ${sessionName}` : "Reconnecting…"}
728
+ placeholder={
729
+ !connected
730
+ ? "Reconnecting…"
731
+ : fork
732
+ ? "What should the fork do?"
733
+ : `Message ${sessionName}`
734
+ }
735
+ fork={
736
+ fork
737
+ ? {
738
+ quote: fork.quote,
739
+ own: fork.own,
740
+ onOwn: () => setForkDraft({ ...fork, own: !fork.own }),
741
+ onLeave: leaveFork,
742
+ onDropQuote: () => setForkDraft({ ...fork, quote: "" }),
743
+ }
744
+ : undefined
745
+ }
686
746
  onSend={(text, images, files) => {
687
747
  if (freshStart && isFreshCommand(text)) {
688
748
  startFresh.mutate(sessionId);
689
749
  return;
690
750
  }
691
- if (forksOn && isForkCommand(text)) {
692
- setForkOpen(true);
751
+ if (fork) {
752
+ startFork.mutate(
753
+ {
754
+ instructions: [(quoteMarkdown(fork.quote) + text).trim()],
755
+ worktree: fork.own,
756
+ // The result comes back by itself, as a card, without a message from the reader.
757
+ delivery: "wake",
758
+ labels: [forkLabel(text, fork.quote)],
759
+ },
760
+ {
761
+ onSuccess: () => {
762
+ toast.success("Started fork");
763
+ setForkDraft(null);
764
+ },
765
+ onError: (e) => {
766
+ toast.error(e.message);
767
+ setInsert({ text, at: Date.now() });
768
+ },
769
+ }
770
+ );
693
771
  return;
694
772
  }
695
773
  toEnd();
@@ -711,7 +789,7 @@ export function ChatPanel({
711
789
  }
712
790
  }}
713
791
  onStop={interrupt}
714
- commands={withForkIf(freshStart ? withFresh(commands) : commands)}
792
+ commands={freshStart ? withFresh(commands) : commands}
715
793
  onRefreshCommands={refreshCommands}
716
794
  refreshingCommands={refreshingCommands}
717
795
  models={models}
@@ -731,13 +809,6 @@ export function ChatPanel({
731
809
  focusKey={active ? sessionId : null}
732
810
  />
733
811
  </div>
734
- {forksOn && (
735
- <ForkDialog
736
- sessionId={sessionId}
737
- open={forkOpen}
738
- onOpenChange={setForkOpen}
739
- />
740
- )}
741
812
  {undoPreview && (
742
813
  <UndoDialog
743
814
  preview={undoPreview.preview}
@@ -13,6 +13,7 @@ import {
13
13
  } from "lucide-react";
14
14
  import type { ChatItem, ChatOrigin, ChatOriginKind } from "@/lib/chat/events";
15
15
  import { eventParts, originOf } from "@/lib/chat/origin";
16
+ import { ForkResults } from "./ForkResults";
16
17
  import { SessionAnchor } from "./SessionAnchor";
17
18
  import { cn } from "@/lib/utils";
18
19
  import { UserMessage } from "./Messages";
@@ -155,13 +156,24 @@ function Row({
155
156
  export function EventRow({
156
157
  item,
157
158
  origin,
159
+ onQuote,
158
160
  }: {
159
161
  item: UserItem;
160
162
  origin: ChatOrigin;
163
+ onQuote?: (text: string) => void;
161
164
  }) {
162
165
  const { body, decided } = eventParts(item, origin);
163
166
  const decidedLabel = `${ownerInUi(useOwnerName(), true)} decided`;
164
167
  const decision = origin.kind === "decision";
168
+ if (origin.forks)
169
+ return (
170
+ <ForkResults
171
+ parent={origin.forks.parent}
172
+ ids={origin.forks.ids}
173
+ fallback={body}
174
+ onQuote={onQuote}
175
+ />
176
+ );
165
177
  return (
166
178
  <div className="flex flex-col gap-1">
167
179
  {decided.map((d, i) => (
@@ -207,11 +219,13 @@ export function EventRow({
207
219
  export function UserTurn({
208
220
  item,
209
221
  onUndo,
222
+ onQuote,
210
223
  }: {
211
224
  item: UserItem;
212
225
  onUndo?: () => void;
226
+ onQuote?: (text: string) => void;
213
227
  }) {
214
228
  const origin = originOf(item);
215
- if (origin) return <EventRow item={item} origin={origin} />;
229
+ if (origin) return <EventRow item={item} origin={origin} onQuote={onQuote} />;
216
230
  return <UserMessage item={item} onUndo={onUndo} />;
217
231
  }
@@ -0,0 +1,109 @@
1
+ "use client";
2
+
3
+ import { GitFork, Quote, RotateCcw } from "lucide-react";
4
+ import { useForkChildren, type ChildView } from "@/data/fork";
5
+ import { Button } from "@/components/ui/button";
6
+ import { StatusPill } from "@/components/ui/status-pill";
7
+ import { SessionAnchor } from "./SessionAnchor";
8
+ import { emptyNote, stateLabel, useRunAgain } from "./Forks";
9
+
10
+ const took = (c: ChildView) => {
11
+ if (!c.startedAt || !c.settledAt) return null;
12
+ const s = Math.max(1, Math.round((c.settledAt - c.startedAt) / 1000));
13
+ return s < 60
14
+ ? `${s}s`
15
+ : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
16
+ };
17
+
18
+ function Card({
19
+ c,
20
+ onQuote,
21
+ again,
22
+ }: {
23
+ c: ChildView;
24
+ onQuote?: (text: string) => void;
25
+ again: ReturnType<typeof useRunAgain>;
26
+ }) {
27
+ const { text, tone } = stateLabel(c);
28
+ const empty = c.outcome === "empty";
29
+ const body = empty ? emptyNote(c) : (c.excerpt ?? c.summary ?? "");
30
+ return (
31
+ <section
32
+ aria-label={`Fork ${c.n} result`}
33
+ className="bg-card popover-surface space-y-2 rounded-2xl p-3"
34
+ >
35
+ <div className="flex items-center gap-2">
36
+ <GitFork aria-hidden className="text-muted-foreground size-4" />
37
+ <span className="text-muted-foreground font-mono text-[11px] tracking-wider uppercase">
38
+ Fork {c.n}
39
+ </span>
40
+ <StatusPill tone={tone}>{text}</StatusPill>
41
+ <span className="text-muted-foreground ml-auto text-xs">{took(c)}</span>
42
+ </div>
43
+ <p className="text-sm font-medium">{c.label.replace(/^\d+\.\s*/, "")}</p>
44
+ <p
45
+ className={
46
+ empty
47
+ ? "text-sm text-amber-600 dark:text-amber-400"
48
+ : "text-muted-foreground line-clamp-6 text-sm whitespace-pre-wrap"
49
+ }
50
+ >
51
+ {body}
52
+ </p>
53
+ <div className="flex flex-wrap gap-2">
54
+ <Button asChild size="sm" className="min-h-11">
55
+ <SessionAnchor sessionId={c.id}>Open fork</SessionAnchor>
56
+ </Button>
57
+ {empty ? (
58
+ <Button
59
+ size="sm"
60
+ variant="secondary"
61
+ className="min-h-11"
62
+ disabled={again.pending}
63
+ onClick={() => again.run(c)}
64
+ >
65
+ <RotateCcw aria-hidden className="size-4" /> Run again
66
+ </Button>
67
+ ) : (
68
+ onQuote &&
69
+ body && (
70
+ <Button
71
+ size="sm"
72
+ variant="secondary"
73
+ className="min-h-11"
74
+ onClick={() => onQuote(c.excerpt ?? c.summary ?? "")}
75
+ >
76
+ <Quote aria-hidden className="size-4" /> Quote
77
+ </Button>
78
+ )
79
+ )}
80
+ </div>
81
+ </section>
82
+ );
83
+ }
84
+
85
+ // A delivery of fork results, as one card per fork in the parent's chat.
86
+ export function ForkResults({
87
+ parent,
88
+ ids,
89
+ onQuote,
90
+ fallback,
91
+ }: {
92
+ parent: string;
93
+ ids: string[];
94
+ fallback: string;
95
+ onQuote?: (text: string) => void;
96
+ }) {
97
+ const { data } = useForkChildren(parent, true);
98
+ const again = useRunAgain(parent);
99
+ const cards = (data?.children ?? []).filter((c) => ids.includes(c.id));
100
+ if (!cards.length)
101
+ return <p className="text-muted-foreground text-xs">{fallback}</p>;
102
+ return (
103
+ <div className="flex flex-col gap-2">
104
+ {cards.map((c) => (
105
+ <Card key={c.id} c={c} onQuote={onQuote} again={again} />
106
+ ))}
107
+ </div>
108
+ );
109
+ }
@@ -1,10 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { useState } from "react";
4
- import { GitFork, Plus, Square } from "lucide-react";
4
+ import { GitFork, Square } from "lucide-react";
5
5
  import { toast } from "sonner";
6
6
  import { useExperiment } from "@/data/experiments";
7
- import { useClientConfig } from "@/data/client-config/queries";
8
7
  import {
9
8
  useCancelForks,
10
9
  useForkChildren,
@@ -12,17 +11,7 @@ import {
12
11
  type ChildView,
13
12
  } from "@/data/fork";
14
13
  import { Button } from "@/components/ui/button";
15
- import {
16
- Dialog,
17
- DialogContent,
18
- DialogDescription,
19
- DialogFooter,
20
- DialogHeader,
21
- DialogTitle,
22
- } from "@/components/ui/dialog";
23
14
  import { StatusPill } from "@/components/ui/status-pill";
24
- import { Switch } from "@/components/ui/switch";
25
- import { Textarea } from "@/components/ui/textarea";
26
15
  import { SessionAnchor } from "./SessionAnchor";
27
16
 
28
17
  type Tone = "amber" | "muted" | "green" | "primary" | "red";
@@ -34,22 +23,55 @@ export function stateLabel(c: ChildView): { text: string; tone: Tone } {
34
23
  if (c.state === "needs_input") return { text: "Needs you", tone: "amber" };
35
24
  if (c.outcome === "failed") return { text: "Failed", tone: "red" };
36
25
  if (c.outcome === "cancelled") return { text: "Stopped", tone: "muted" };
26
+ if (c.outcome === "empty") return { text: "No result", tone: "amber" };
37
27
  return { text: "Done", tone: "green" };
38
28
  }
39
29
 
30
+ // The strip's name for a fork: what was asked, else what was quoted, so a
31
+ // quote-first instruction isn't labelled with its "> ".
32
+ export const forkLabel = (typed: string, quote: string): string => {
33
+ const line = (typed.trim() || quote.trim()).split("\n")[0].trim();
34
+ return line.length > 60 ? `${line.slice(0, 59)}…` : line;
35
+ };
36
+
37
+ // What a fork that ended with nothing to read says in place of a result.
38
+ export const emptyNote = (c: ChildView) =>
39
+ c.summary && c.summary !== "ended without a result"
40
+ ? `Ended with “${c.summary}”, not an answer`
41
+ : "Ended without a result";
42
+
43
+ // Starts the same instruction again, for a fork that came back with nothing.
44
+ export function useRunAgain(sessionId: string) {
45
+ const start = useStartForks(sessionId);
46
+ return {
47
+ pending: start.isPending,
48
+ run: (c: ChildView) =>
49
+ start.mutate(
50
+ { instructions: [c.instruction], worktree: c.worktree },
51
+ {
52
+ onSuccess: () => toast.success("Started fork"),
53
+ onError: (e) => toast.error(e.message),
54
+ }
55
+ ),
56
+ };
57
+ }
58
+
40
59
  function Row({
41
60
  c,
42
61
  onStop,
43
62
  stopping,
63
+ again,
44
64
  }: {
45
65
  c: ChildView;
66
+ again: ReturnType<typeof useRunAgain>;
46
67
  onStop: () => void;
47
68
  stopping: boolean;
48
69
  }) {
49
70
  const { text, tone } = stateLabel(c);
50
71
  const open = c.state !== "settled";
72
+ const empty = c.outcome === "empty";
51
73
  const note =
52
- c.summary ??
74
+ (empty ? emptyNote(c) : c.summary) ??
53
75
  (c.asks
54
76
  ? `Asks: ${c.asks}`
55
77
  : c.state === "queued"
@@ -65,7 +87,13 @@ function Row({
65
87
  {c.n}. {c.label.replace(/^\d+\.\s*/, "")}
66
88
  </p>
67
89
  {note && (
68
- <p className="text-muted-foreground truncate text-xs">
90
+ <p
91
+ className={
92
+ empty
93
+ ? "truncate text-xs text-amber-600 dark:text-amber-400"
94
+ : "text-muted-foreground truncate text-xs"
95
+ }
96
+ >
69
97
  {note}
70
98
  {c.resultKind === "implicit" && c.outcome === "ok"
71
99
  ? " · ended without reporting"
@@ -73,6 +101,16 @@ function Row({
73
101
  </p>
74
102
  )}
75
103
  </div>
104
+ {empty && (
105
+ <button
106
+ type="button"
107
+ disabled={again.pending}
108
+ onClick={() => again.run(c)}
109
+ className="text-primary flex min-h-11 shrink-0 items-center px-2 text-sm"
110
+ >
111
+ Run again
112
+ </button>
113
+ )}
76
114
  <SessionAnchor
77
115
  sessionId={c.id}
78
116
  className="text-primary flex min-h-11 shrink-0 items-center px-2 text-sm"
@@ -100,6 +138,7 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
100
138
  const on = useExperiment("forks");
101
139
  const { data } = useForkChildren(sessionId, on);
102
140
  const cancel = useCancelForks(sessionId);
141
+ const again = useRunAgain(sessionId);
103
142
  const [collapsed, setCollapsed] = useState(false);
104
143
  if (!on || !data) return null;
105
144
  if (data.asChild) return <ForkOfBanner info={data.asChild} />;
@@ -150,6 +189,7 @@ export function ForksStrip({ sessionId }: { sessionId: string }) {
150
189
  key={c.id}
151
190
  c={c}
152
191
  stopping={cancel.isPending}
192
+ again={again}
153
193
  onStop={() => stop(String(c.n))}
154
194
  />
155
195
  ))}
@@ -187,92 +227,3 @@ function ForkOfBanner({
187
227
  </section>
188
228
  );
189
229
  }
190
-
191
- // "/fork": instructions for N copies of this session.
192
- export function ForkDialog({
193
- sessionId,
194
- open,
195
- onOpenChange,
196
- }: {
197
- sessionId: string;
198
- open: boolean;
199
- onOpenChange: (open: boolean) => void;
200
- }) {
201
- const start = useStartForks(sessionId);
202
- const max = useClientConfig().data?.forks?.maxPerFork ?? 8;
203
- const [texts, setTexts] = useState<string[]>([""]);
204
- const [worktree, setWorktree] = useState(false);
205
- const filled = texts.map((t) => t.trim()).filter(Boolean);
206
- const submit = () =>
207
- start.mutate(
208
- { instructions: filled, worktree },
209
- {
210
- onSuccess: (r) => {
211
- toast.success(
212
- `Started ${r.children.length} ${r.children.length === 1 ? "fork" : "forks"}`
213
- );
214
- setTexts([""]);
215
- setWorktree(false);
216
- onOpenChange(false);
217
- },
218
- onError: (e) => toast.error(e.message),
219
- }
220
- );
221
- return (
222
- <Dialog open={open} onOpenChange={onOpenChange}>
223
- <DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-lg">
224
- <DialogHeader>
225
- <DialogTitle>Fork this session</DialogTitle>
226
- <DialogDescription>
227
- Each copy knows what this session knows and works in the background.
228
- You keep going; results are added to your next message.
229
- </DialogDescription>
230
- </DialogHeader>
231
- <div className="space-y-2">
232
- {texts.map((t, i) => (
233
- <Textarea
234
- key={i}
235
- value={t}
236
- rows={2}
237
- aria-label={`Fork ${i + 1}`}
238
- placeholder={`What should fork ${i + 1} do?`}
239
- onChange={(e) =>
240
- setTexts(texts.map((x, j) => (j === i ? e.target.value : x)))
241
- }
242
- />
243
- ))}
244
- {texts.length < max && (
245
- <Button
246
- type="button"
247
- variant="ghost"
248
- size="sm"
249
- className="min-h-11"
250
- onClick={() => setTexts([...texts, ""])}
251
- >
252
- <Plus aria-hidden className="size-4" /> Another
253
- </Button>
254
- )}
255
- <label className="flex min-h-11 items-center justify-between gap-3 text-sm">
256
- <span>
257
- Own checkout
258
- <span className="text-muted-foreground block text-xs">
259
- For forks that edit code. Off: they read this checkout and leave
260
- it alone.
261
- </span>
262
- </span>
263
- <Switch checked={worktree} onCheckedChange={setWorktree} />
264
- </label>
265
- </div>
266
- <DialogFooter>
267
- <Button
268
- className="min-h-11"
269
- disabled={!filled.length || start.isPending}
270
- onClick={submit}
271
- >
272
- {filled.length > 1 ? `Start ${filled.length} forks` : "Start fork"}
273
- </Button>
274
- </DialogFooter>
275
- </DialogContent>
276
- </Dialog>
277
- );
278
- }
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { useEffect, useId, useState, type RefObject } from "react";
4
- import { Quote } from "lucide-react";
4
+ import { GitFork, Quote } from "lucide-react";
5
5
  import { cn } from "@/lib/utils";
6
6
  import { useCoarsePointer } from "./composer/useCoarsePointer";
7
7
  import { useKeybindings } from "@/hooks/useKeybindings";
@@ -19,7 +19,7 @@ interface Picked {
19
19
  at: Placement | null;
20
20
  }
21
21
 
22
- function readSelection(root: HTMLElement): Picked | null {
22
+ function readSelection(root: HTMLElement, half?: number): Picked | null {
23
23
  const sel = window.getSelection();
24
24
  const pick = sel && quotePick(sel, root);
25
25
  if (!pick) return null;
@@ -30,7 +30,10 @@ function readSelection(root: HTMLElement): Picked | null {
30
30
  left: Math.min(...rects.map((r) => r.left)),
31
31
  right: Math.max(...rects.map((r) => r.right)),
32
32
  };
33
- return { text: pick.text, at: placeQuote(box, root.getBoundingClientRect()) };
33
+ return {
34
+ text: pick.text,
35
+ at: placeQuote(box, root.getBoundingClientRect(), undefined, half),
36
+ };
34
37
  }
35
38
 
36
39
  // "Quote" for a selection in a reply. With a mouse it floats over the
@@ -39,12 +42,16 @@ function readSelection(root: HTMLElement): Picked | null {
39
42
  export function QuoteButton({
40
43
  rootRef,
41
44
  onQuote,
45
+ onFork,
42
46
  }: {
43
47
  rootRef: RefObject<HTMLElement | null>;
44
48
  onQuote: (text: string) => void;
49
+ // Only given while forks are on: a second button beside Quote.
50
+ onFork?: (text: string) => void;
45
51
  }) {
46
52
  const [picked, setPicked] = useState<Picked | null>(null);
47
53
  const coarse = useCoarsePointer();
54
+ const wide = !!onFork;
48
55
 
49
56
  useEffect(() => {
50
57
  let frame = 0;
@@ -52,7 +59,7 @@ export function QuoteButton({
52
59
  cancelAnimationFrame(frame);
53
60
  frame = requestAnimationFrame(() => {
54
61
  const root = rootRef.current;
55
- setPicked(root ? readSelection(root) : null);
62
+ setPicked(root ? readSelection(root, wide ? 110 : undefined) : null);
56
63
  });
57
64
  };
58
65
  document.addEventListener("selectionchange", update);
@@ -62,7 +69,7 @@ export function QuoteButton({
62
69
  document.removeEventListener("selectionchange", update);
63
70
  window.removeEventListener("scroll", update, true);
64
71
  };
65
- }, [rootRef]);
72
+ }, [rootRef, wide]);
66
73
 
67
74
  const shown = !!picked && (coarse || !!picked.at);
68
75
  const quote = quoteAction(picked?.text, {
@@ -70,27 +77,58 @@ export function QuoteButton({
70
77
  clearSelection: () => window.getSelection()?.removeAllRanges(),
71
78
  hide: () => setPicked(null),
72
79
  });
80
+ const fork = quoteAction(picked?.text, {
81
+ onQuote: onFork ?? onQuote,
82
+ clearSelection: () => window.getSelection()?.removeAllRanges(),
83
+ hide: () => setPicked(null),
84
+ });
73
85
  // One source per button: each open chat pane has its own.
74
- useKeybindings(`chat-quote:${useId()}`, [quoteEnter(shown, quote)]);
86
+ useKeybindings(`chat-quote:${useId()}`, [
87
+ quoteEnter(shown, quote),
88
+ ...(onFork
89
+ ? [{ ...quoteEnter(shown, fork), id: "chat.fork", keys: "f" }]
90
+ : []),
91
+ ]);
75
92
 
76
93
  if (!picked) return null;
94
+ const pill =
95
+ "bg-card popover-surface hover:bg-muted flex min-h-11 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium md:min-h-8 md:px-3 pointer-coarse:min-h-11";
77
96
  const button = (
78
- <button
79
- type="button"
80
- // Pressing it mustn't clear the selection before it's read.
81
- onMouseDown={(e) => e.preventDefault()}
82
- onClick={quote}
83
- className="bg-card popover-surface hover:bg-muted flex min-h-11 items-center gap-1.5 rounded-full px-3.5 text-sm font-medium md:min-h-8 md:px-3 pointer-coarse:min-h-11"
84
- >
85
- <Quote className="h-3.5 w-3.5" />
86
- Quote
87
- <kbd
88
- aria-hidden
89
- className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
97
+ <div className="flex items-center gap-1.5">
98
+ <button
99
+ type="button"
100
+ // Pressing it mustn't clear the selection before it's read.
101
+ onMouseDown={(e) => e.preventDefault()}
102
+ onClick={quote}
103
+ className={pill}
90
104
  >
91
- ↵
92
- </kbd>
93
- </button>
105
+ <Quote className="h-3.5 w-3.5" />
106
+ Quote
107
+ <kbd
108
+ aria-hidden
109
+ className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
110
+ >
111
+ ↵
112
+ </kbd>
113
+ </button>
114
+ {onFork && (
115
+ <button
116
+ type="button"
117
+ onMouseDown={(e) => e.preventDefault()}
118
+ onClick={fork}
119
+ className={pill}
120
+ >
121
+ <GitFork className="h-3.5 w-3.5" />
122
+ Fork
123
+ <kbd
124
+ aria-hidden
125
+ className="text-muted-foreground bg-muted hidden rounded px-1 font-mono text-[10px] leading-4 md:inline"
126
+ >
127
+ F
128
+ </kbd>
129
+ </button>
130
+ )}
131
+ </div>
94
132
  );
95
133
  if (coarse)
96
134
  return <div className="mb-2 flex justify-center select-none">{button}</div>;
@@ -55,6 +55,7 @@ function Item({
55
55
  return (
56
56
  <UserTurn
57
57
  item={item}
58
+ onQuote={actions.onQuote}
58
59
  onUndo={
59
60
  item.checkpoint && actions.onUndo
60
61
  ? () => actions.onUndo?.(item.id)
@@ -32,6 +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
36
  import { useCoarsePointer } from "./useCoarsePointer";
36
37
  import { useMentions } from "./useMentions";
37
38
  import { Attachments } from "./Attachments";
@@ -52,6 +53,9 @@ export interface ComposerProps {
52
53
  running: boolean;
53
54
  disabled?: boolean;
54
55
  placeholder: string;
56
+ // The send button's label, when Enter does something else than send.
57
+ // Fork mode: the quote chip, pill and own-checkout choice.
58
+ fork?: ForkComposer;
55
59
  onSend: (text: string, images: ChatImage[], files: ChatFile[]) => void;
56
60
  // Where attached files are kept: the session's id, or "draft" before one exists.
57
61
  attachScope: string;
@@ -94,6 +98,7 @@ export function ComposerBody({
94
98
  running,
95
99
  disabled,
96
100
  placeholder,
101
+ fork,
97
102
  onSend,
98
103
  onStop,
99
104
  commands = [],
@@ -270,7 +275,11 @@ export function ComposerBody({
270
275
  const { dragging, dropProps, addFiles } = attach;
271
276
 
272
277
  const empty =
273
- !text.trim() && !images.length && !files.length && !attach.uploads.length;
278
+ !fork?.quote &&
279
+ !text.trim() &&
280
+ !images.length &&
281
+ !files.length &&
282
+ !attach.uploads.length;
274
283
  const submit = () => {
275
284
  if (empty || disabled || attach.uploading) return;
276
285
  const used = leadingCommand(text)?.slice(1);
@@ -358,9 +367,10 @@ export function ComposerBody({
358
367
  {...dropProps}
359
368
  className={cn(
360
369
  "bg-card popover-surface rounded-2xl p-2 transition-shadow",
361
- dragging && "ring-primary ring-2"
370
+ (dragging || fork) && "ring-primary ring-2"
362
371
  )}
363
372
  >
373
+ {fork && <ForkHeader fork={fork} />}
364
374
  <Attachments
365
375
  images={images}
366
376
  files={files}
@@ -393,6 +403,8 @@ export function ComposerBody({
393
403
  onStop={onStop}
394
404
  canSend={!disabled && !empty && !attach.uploading}
395
405
  onSend={submit}
406
+ sendLabel={fork ? "Start fork" : undefined}
407
+ fork={fork}
396
408
  />
397
409
  </div>
398
410
  </div>
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { GitFork, Quote, X } from "lucide-react";
4
+
5
+ export interface ForkComposer {
6
+ // The selection being forked; the instruction is this plus what's typed.
7
+ quote: string;
8
+ own: boolean;
9
+ onOwn: () => void;
10
+ onLeave: () => void;
11
+ onDropQuote: () => void;
12
+ }
13
+
14
+ // Top of the composer in Fork mode: the pill that marks it, what the keys do,
15
+ // and the selection as a chip.
16
+ export function ForkHeader({ fork }: { fork: ForkComposer }) {
17
+ return (
18
+ <div className="mb-1.5 space-y-1.5">
19
+ <div className="flex items-center gap-2">
20
+ <span className="bg-primary/10 text-primary flex h-8 items-center gap-1.5 rounded-lg pr-1 pl-2.5 text-xs font-medium">
21
+ <GitFork aria-hidden className="size-3.5" />
22
+ Fork
23
+ <button
24
+ type="button"
25
+ onMouseDown={(e) => e.preventDefault()}
26
+ onClick={fork.onLeave}
27
+ aria-label="Cancel fork"
28
+ className="hover:bg-primary/15 flex size-6 items-center justify-center rounded-md"
29
+ >
30
+ <X aria-hidden className="size-3.5" />
31
+ </button>
32
+ </span>
33
+ <span className="flex-1" />
34
+ <span className="text-muted-foreground hidden text-xs md:inline">
35
+ ↵ start · Esc cancel
36
+ </span>
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
+ )}
53
+ </div>
54
+ );
55
+ }
@@ -3,6 +3,8 @@
3
3
  import { useRef, useState } from "react";
4
4
  import {
5
5
  ArrowUp,
6
+ Folder,
7
+ GitFork,
6
8
  Paperclip,
7
9
  ListChecks,
8
10
  MonitorUp,
@@ -41,6 +43,8 @@ export function Toolbar({
41
43
  onStop,
42
44
  canSend,
43
45
  onSend,
46
+ sendLabel = "Send",
47
+ fork,
44
48
  }: {
45
49
  plain: boolean;
46
50
  onTogglePlain: () => void;
@@ -65,6 +69,9 @@ export function Toolbar({
65
69
  onStop: () => void;
66
70
  canSend: boolean;
67
71
  onSend: () => void;
72
+ sendLabel?: string;
73
+ // Fork mode: the own-checkout choice, and a send button that says what it does.
74
+ fork?: { own: boolean; onOwn: () => void };
68
75
  }) {
69
76
  const fileRef = useRef<HTMLInputElement>(null);
70
77
  const formatting = plain ? "Formatting off" : "Formatting on";
@@ -103,7 +110,7 @@ export function Toolbar({
103
110
  e.target.value = "";
104
111
  }}
105
112
  />
106
- {canCapture && (
113
+ {canCapture && !fork && (
107
114
  <Button
108
115
  type="button"
109
116
  variant="ghost"
@@ -135,10 +142,28 @@ export function Toolbar({
135
142
  <span className={plan ? "" : "hidden sm:inline"}>Plan</span>
136
143
  </button>
137
144
  )}
138
- {onSetAccess && access && (
145
+ {fork && (
146
+ <button
147
+ type="button"
148
+ aria-pressed={fork.own}
149
+ title="Own checkout lets the fork edit code; off, it reads this checkout and leaves it alone"
150
+ onMouseDown={(e) => e.preventDefault()}
151
+ onClick={fork.onOwn}
152
+ className={cn(
153
+ "flex h-10 shrink-0 items-center gap-1 rounded-lg px-2 text-xs",
154
+ fork.own
155
+ ? "bg-primary/10 text-primary"
156
+ : "text-muted-foreground hover:text-foreground hover:bg-foreground/[0.05]"
157
+ )}
158
+ >
159
+ <Folder className="h-3.5 w-3.5" />
160
+ {fork.own ? "Own checkout" : "Reads only"}
161
+ </button>
162
+ )}
163
+ {!fork && onSetAccess && access && (
139
164
  <AccessPicker access={access} onPick={onSetAccess} />
140
165
  )}
141
- {onSetModel && (
166
+ {!fork && onSetModel && (
142
167
  <ModelPicker
143
168
  models={models}
144
169
  model={model}
@@ -150,7 +175,7 @@ export function Toolbar({
150
175
  />
151
176
  )}
152
177
  <span className="flex-1" />
153
- {accessory}
178
+ {!fork && accessory}
154
179
  <Button
155
180
  type="button"
156
181
  variant="ghost"
@@ -183,14 +208,25 @@ export function Toolbar({
183
208
  <Button
184
209
  type="button"
185
210
  size="icon"
186
- aria-label="Send"
187
- className="h-10 w-10 shrink-0 rounded-xl"
211
+ aria-label={sendLabel}
212
+ title={sendLabel}
213
+ className={cn(
214
+ "h-10 shrink-0 rounded-xl",
215
+ fork ? "w-auto gap-1.5 px-3.5" : "w-10"
216
+ )}
188
217
  disabled={!canSend}
189
218
  // Tapping send mustn't blur the editor and drop the keyboard.
190
219
  onMouseDown={(e) => e.preventDefault()}
191
220
  onClick={onSend}
192
221
  >
193
- <ArrowUp className="h-4 w-4" />
222
+ {fork ? (
223
+ <>
224
+ <GitFork className="h-3.5 w-3.5" />
225
+ Start fork
226
+ </>
227
+ ) : (
228
+ <ArrowUp className="h-4 w-4" />
229
+ )}
194
230
  </Button>
195
231
  )}
196
232
  </div>
@@ -103,9 +103,13 @@ export interface Placement {
103
103
 
104
104
  // Where the floating button goes, kept inside the visible chat (`view`),
105
105
  // which ends above the composer. Null when the selection is scrolled out.
106
- export function placeQuote(sel: Box, view: Box, size = 48): Placement | null {
106
+ export function placeQuote(
107
+ sel: Box,
108
+ view: Box,
109
+ size = 48,
110
+ half = 60
111
+ ): Placement | null {
107
112
  if (sel.bottom < view.top || sel.top > view.bottom) return null;
108
- const half = 60;
109
113
  const mid = (sel.left + sel.right) / 2;
110
114
  const x = Math.min(Math.max(mid, view.left + half), view.right - half);
111
115
  if (sel.top - size >= view.top) return { x, y: sel.top, side: "above" };
@@ -1945,6 +1945,14 @@
1945
1945
  "area": "Updates",
1946
1946
  "text": "Automatic updates no longer get stuck after the first one because of a file the build rewrites.",
1947
1947
  "url": "https://github.com/saadnvd1/agent-os/pull/555"
1948
+ },
1949
+ {
1950
+ "id": 558,
1951
+ "date": "2026-11-02",
1952
+ "kind": "improved",
1953
+ "area": "AgentOS",
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
+ "url": "https://github.com/saadnvd1/agent-os/pull/558"
1948
1956
  }
1949
1957
  ]
1950
1958
  }
@@ -53,7 +53,12 @@ export function useForkChildren(sessionId: string, enabled: boolean) {
53
53
  export function useStartForks(sessionId: string) {
54
54
  const queryClient = useQueryClient();
55
55
  return useMutation({
56
- mutationFn: async (input: { instructions: string[]; worktree: boolean }) =>
56
+ mutationFn: async (input: {
57
+ instructions: string[];
58
+ worktree: boolean;
59
+ labels?: string[];
60
+ delivery?: "wake" | "next";
61
+ }) =>
57
62
  json<{ children: { id: string; n: number; label: string }[] }>(
58
63
  await fetch("/api/fork", {
59
64
  method: "POST",
@@ -83,21 +83,6 @@ export function withFresh(commands: ChatCommand[]): ChatCommand[] {
83
83
  : [FRESH_COMMAND, ...commands];
84
84
  }
85
85
 
86
- // AgentOS's own command when forks are on: opens the fork sheet.
87
- export const FORK_COMMAND: ChatCommand = {
88
- name: "fork",
89
- description: "Start background copies of this session",
90
- };
91
-
92
- export const isForkCommand = (text: string): boolean =>
93
- text.trim().toLowerCase() === "/fork";
94
-
95
- export function withFork(commands: ChatCommand[]): ChatCommand[] {
96
- return commands.some((c) => c.name === "fork")
97
- ? commands
98
- : [FORK_COMMAND, ...commands];
99
- }
100
-
101
86
  export interface MenuGroup {
102
87
  label: string;
103
88
  commands: ChatCommand[];
@@ -106,6 +106,9 @@ export interface ChatOrigin {
106
106
  body?: string;
107
107
  // Lines of an event batch that are the reader's answers to asks.
108
108
  decided?: string[];
109
+ // A fork delivery: the parent and the children it carries, shown as result
110
+ // cards (lib/fork/deliver).
111
+ forks?: { parent: string; ids: string[] };
109
112
  // The tasks it is about, by name, set where the event is delivered.
110
113
  subjects?: { id: string; name: string }[];
111
114
  // Only Away mode's own lines: the turn it starts isn't anyone's activity
package/lib/fork/brief.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { FORK_LIMITS } from "./options";
4
4
  import type { ChildRow } from "./types";
5
5
 
6
- export const PARENT_BRIEF = `Forks: when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own throwaway checkout). Fork only work that doesn't depend on your next step.`;
6
+ export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also when you have independent side work, \`aos fork "<instruction>" ["<instruction>" …]\` starts that many background copies of you (up to ${FORK_LIMITS.maxPerFork}) that know what you know. It returns at once; you keep working. Their results arrive as a message when they finish, or after \`aos wait children --all|--any [--timeout 30m]\` (end your turn; you're woken with them). \`aos children\` lists them, \`aos children <n>\` shows a full result, \`aos cancel <n>|--all\` stops them. Children have your abilities and work in your checkout: tell one that has to edit code to \`--worktree\` (its own throwaway checkout). Fork only work that doesn't depend on your next step.`;
7
7
 
8
8
  export const CHILD_BRIEF = `You are a fork child: a background copy of another session, started to do one piece of work and report back. You have its abilities, not its checkout: don't change the parent's files, push, open a PR or fork. Put temp work (scripts, repros, notes) in $AOS_SCRATCH. Run builds and tests only through \`aos heavy -- <command>\`, which queues you with the other forks. To ask the parent something, \`aos send <parent> "<question>"\`; that doesn't finish you. When done, \`aos return "<result>"\` (first line: the answer in a sentence; then the detail; \`--failed\` if you couldn't do it). Your reply when you end your turn without it is used as the result, after a short wait.`;
9
9
 
@@ -41,7 +41,9 @@ const outcomeWord = (c: ChildRow) =>
41
41
  ? "failed"
42
42
  : c.outcome === "cancelled"
43
43
  ? "cancelled"
44
- : "done";
44
+ : c.outcome === "empty"
45
+ ? "ended with no result"
46
+ : "done";
45
47
 
46
48
  // One delivery: every result in the batch, whole when short, else its
47
49
  // summary and the command that reads the rest.
@@ -34,7 +34,12 @@ async function send(parentId: string, deliveryId: string, rows: ChildRow[]) {
34
34
  text,
35
35
  queue: true,
36
36
  from: "AgentOS",
37
- origin: { kind: "event", label: "Forks", body },
37
+ origin: {
38
+ kind: "event",
39
+ label: "Forks",
40
+ body,
41
+ forks: { parent: parentId, ids: rows.map((c) => c.child_session_id) },
42
+ },
38
43
  });
39
44
  markDelivered(deliveryId);
40
45
  }
@@ -176,6 +176,11 @@ export function summaryOf(text: string): string {
176
176
  return one.length <= max ? one : `${one.slice(0, max - 1)}…`;
177
177
  }
178
178
 
179
+ // A result that says nothing: blank, or one bare flag (`--help`) a child took
180
+ // for its answer. A quoted sentence that starts with dashes is still an answer.
181
+ export const isNonAnswer = (text: string | null | undefined): boolean =>
182
+ !text?.trim() || /^--?[a-z][a-z-]*$/i.test(text.trim());
183
+
179
184
  export const capResult = (text: string): string =>
180
185
  text.length <= FORK_LIMITS.resultChars
181
186
  ? text
@@ -22,6 +22,7 @@ import {
22
22
  updateResult,
23
23
  childrenOf,
24
24
  findChild,
25
+ isNonAnswer,
25
26
  markDeliveredIds,
26
27
  } from "./ledger";
27
28
  import { FORK_LIMITS } from "./options";
@@ -120,7 +121,11 @@ export async function returnChild(
120
121
  if (!row) throw new Error("Only a fork child can return a result");
121
122
  const body = text.trim();
122
123
  if (!body) throw new Error('Give the result: aos return "<result>"');
123
- const outcome: ChildOutcome = failed ? "failed" : "ok";
124
+ const outcome: ChildOutcome = failed
125
+ ? "failed"
126
+ : isNonAnswer(body)
127
+ ? "empty"
128
+ : "ok";
124
129
  if (
125
130
  await settleAndDeliver(childId, {
126
131
  outcome,
@@ -205,10 +210,9 @@ export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
205
210
  // It asked its parent something and is waiting for the answer.
206
211
  if (openWaits().some((w) => w.waiter_id === row.child_session_id)) return;
207
212
  const reply = lastReply(row.child_session_id);
208
- if (!reply) return;
209
213
  await settleAndDeliver(row.child_session_id, {
210
- outcome: "ok",
211
- summary: summaryOf(reply),
214
+ outcome: isNonAnswer(reply) ? "empty" : "ok",
215
+ summary: reply ? summaryOf(reply) : "ended without a result",
212
216
  result: reply,
213
217
  kind: "implicit",
214
218
  });
package/lib/fork/types.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  export type ChildState =
2
2
  "queued" | "starting" | "running" | "needs_input" | "settled";
3
3
 
4
- export type ChildOutcome = "ok" | "failed" | "cancelled";
4
+ // `empty`: it ended, but with nothing to read (blank, or a bare flag like
5
+ // "--help"), so it isn't shown as a finished answer.
6
+ export type ChildOutcome = "ok" | "failed" | "cancelled" | "empty";
5
7
 
6
8
  // `implicit`: the child's turn ended without `aos return`, so its final
7
9
  // answer stands in (kept apart so stats can tell them).
@@ -59,6 +61,8 @@ export interface ChildView {
59
61
  hold: string | null;
60
62
  outcome: ChildOutcome | null;
61
63
  summary: string | null;
64
+ // The start of its result, for the card in the parent's chat.
65
+ excerpt: string | null;
62
66
  resultKind: ResultKind | null;
63
67
  worktree: boolean;
64
68
  fellBack: boolean;
package/lib/fork/view.ts CHANGED
@@ -13,6 +13,7 @@ export const childView = (c: ChildRow): ChildView => ({
13
13
  hold: c.hold,
14
14
  outcome: c.outcome,
15
15
  summary: c.summary,
16
+ excerpt: c.result ? c.result.slice(0, 600) : null,
16
17
  resultKind: c.result_kind,
17
18
  worktree: !!c.worktree,
18
19
  fellBack: !!c.fell_back,
@@ -61,6 +62,7 @@ export interface ForkStats {
61
62
  ok: number;
62
63
  failed: number;
63
64
  cancelled: number;
65
+ empty: number;
64
66
  implicit: number;
65
67
  fellBack: number;
66
68
  medianSeconds: number | null;
@@ -83,6 +85,7 @@ export function statsOf(rows: ChildRow[]): ForkStats {
83
85
  ok: done.filter((c) => c.outcome === "ok").length,
84
86
  failed: done.filter((c) => c.outcome === "failed").length,
85
87
  cancelled: done.filter((c) => c.outcome === "cancelled").length,
88
+ empty: done.filter((c) => c.outcome === "empty").length,
86
89
  implicit: done.filter(
87
90
  (c) => c.result_kind === "implicit" && c.outcome === "ok"
88
91
  ).length,
@@ -106,7 +109,7 @@ export function statsText(s: ForkStats): string {
106
109
  const pct = (n: number) =>
107
110
  s.settled ? `${Math.round((n / s.settled) * 100)}%` : "-";
108
111
  return [
109
- `${s.children} children, ${s.settled} settled (${s.ok} ok, ${s.failed} failed, ${s.cancelled} cancelled)`,
112
+ `${s.children} children, ${s.settled} settled (${s.ok} ok, ${s.failed} failed, ${s.cancelled} cancelled, ${s.empty} no result)`,
110
113
  `returned without aos return: ${s.implicit} (${pct(s.implicit)}); started from a brief, not a fork: ${s.fellBack}`,
111
114
  `median time to settle: ${s.medianSeconds ?? "-"}s`,
112
115
  `tokens: ${s.tokensIn} in, ${s.tokensOut} out, ${s.cacheRead} cache read`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.828",
3
+ "version": "0.3.833",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"
@@ -8,7 +8,7 @@
8
8
  # Sourced by scripts/redeploy; run from the repository root.
9
9
 
10
10
  # shellcheck source=generated.sh
11
- source scripts/lib/generated.sh
11
+ source "$(dirname "${BASH_SOURCE[0]}")/generated.sh"
12
12
 
13
13
  LIVE=.next
14
14
  NEXT_BUILD=.next-build
@@ -19,6 +19,8 @@ const SCRIPTS = [
19
19
  "lib/build-swap.sh",
20
20
  "lib/pnpm.sh",
21
21
  "lib/deps-swap.sh",
22
+ "lib/generated.sh",
23
+ "generated-files.txt",
22
24
  "lib/update-verify.mjs",
23
25
  ];
24
26