@saadnvd1/agent-os 0.3.826 → 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 +2 -0
- package/app/layout.tsx +3 -0
- package/components/Chat/ChatPanel.tsx +94 -23
- package/components/Chat/EventRow.tsx +15 -1
- package/components/Chat/ForkResults.tsx +109 -0
- package/components/Chat/Forks.tsx +54 -103
- package/components/Chat/QuoteButton.tsx +59 -21
- package/components/Chat/TimelineRows.tsx +1 -0
- package/components/Chat/composer/ComposerBody.tsx +14 -2
- package/components/Chat/composer/ForkHeader.tsx +55 -0
- package/components/Chat/composer/Toolbar.tsx +43 -7
- package/components/Chat/quote-selection.ts +6 -2
- package/content/whats-new.json +16 -0
- package/data/fork/index.ts +6 -1
- package/lib/chat/commands.ts +0 -15
- package/lib/chat/events.ts +3 -0
- package/lib/fork/brief.ts +4 -2
- package/lib/fork/deliver.ts +6 -1
- package/lib/fork/ledger.ts +5 -0
- package/lib/fork/settle.ts +8 -4
- package/lib/fork/types.ts +5 -1
- package/lib/fork/view.ts +4 -1
- package/lib/updates/generated.ts +29 -0
- package/lib/updates/run.ts +16 -6
- package/package.json +1 -1
- package/scripts/autodeploy +4 -0
- package/scripts/autodeploy.test.ts +6 -1
- package/scripts/generated-files.txt +3 -0
- package/scripts/generated.test.ts +77 -0
- package/scripts/lib/build-swap.sh +4 -0
- package/scripts/lib/generated.sh +18 -0
- package/scripts/redeploy +1 -0
- package/scripts/redeploy.test.ts +1 -0
- package/scripts/update-e2e.test.ts +2 -0
|
@@ -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 {
|
|
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()}`, [
|
|
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
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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>;
|
|
@@ -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
|
-
!
|
|
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
|
-
{
|
|
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=
|
|
187
|
-
|
|
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
|
-
|
|
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(
|
|
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" };
|
package/content/whats-new.json
CHANGED
|
@@ -1937,6 +1937,22 @@
|
|
|
1937
1937
|
"area": "Chat",
|
|
1938
1938
|
"text": "Cancelled questions from GitHub Copilot chats now clear right away instead of staying on screen.",
|
|
1939
1939
|
"url": "https://github.com/saadnvd1/agent-os/pull/552"
|
|
1940
|
+
},
|
|
1941
|
+
{
|
|
1942
|
+
"id": 555,
|
|
1943
|
+
"date": "2026-11-02",
|
|
1944
|
+
"kind": "fixed",
|
|
1945
|
+
"area": "Updates",
|
|
1946
|
+
"text": "Automatic updates no longer get stuck after the first one because of a file the build rewrites.",
|
|
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"
|
|
1940
1956
|
}
|
|
1941
1957
|
]
|
|
1942
1958
|
}
|
package/data/fork/index.ts
CHANGED
|
@@ -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: {
|
|
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",
|
package/lib/chat/commands.ts
CHANGED
|
@@ -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[];
|
package/lib/chat/events.ts
CHANGED
|
@@ -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
|
-
: "
|
|
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.
|
package/lib/fork/deliver.ts
CHANGED
|
@@ -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: {
|
|
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
|
}
|
package/lib/fork/ledger.ts
CHANGED
|
@@ -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
|
package/lib/fork/settle.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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`,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
|
|
4
|
+
/** Files the build rewrites in a checkout; one list for the updater and the deploy scripts. */
|
|
5
|
+
export function generatedFiles(root: string): string[] {
|
|
6
|
+
try {
|
|
7
|
+
return fs
|
|
8
|
+
.readFileSync(path.join(root, "scripts/generated-files.txt"), "utf8")
|
|
9
|
+
.split("\n")
|
|
10
|
+
.map((l) => l.trim())
|
|
11
|
+
.filter((l) => l && !l.startsWith("#"));
|
|
12
|
+
} catch {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Splits `git status --porcelain` paths into generated files and real local changes. */
|
|
18
|
+
export function splitDirty(
|
|
19
|
+
porcelain: string,
|
|
20
|
+
generated: string[]
|
|
21
|
+
): { generated: string[]; blocking: string[] } {
|
|
22
|
+
const out = { generated: [] as string[], blocking: [] as string[] };
|
|
23
|
+
for (const line of porcelain.split("\n")) {
|
|
24
|
+
const file = line.slice(3).trim();
|
|
25
|
+
if (!file) continue;
|
|
26
|
+
(generated.includes(file) ? out.generated : out.blocking).push(file);
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}
|