@saadnvd1/agent-os 0.3.821 → 0.3.825
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/api/fork/cancel/route.ts +19 -0
- package/app/api/fork/children/route.ts +52 -0
- package/app/api/fork/result/route.ts +29 -0
- package/app/api/fork/return/route.ts +20 -0
- package/app/api/fork/route.ts +32 -0
- package/app/api/fork/wait/route.ts +27 -0
- package/app/api/load/heavy/lease/route.ts +26 -0
- package/app/api/sessions/[id]/archive/route.ts +17 -4
- package/app/api/sessions/[id]/route.ts +2 -3
- package/app/page.tsx +0 -5
- package/components/Chat/ChatPanel.tsx +33 -206
- package/components/Chat/Forks.tsx +278 -0
- package/components/Chat/ReplyingTo.tsx +24 -0
- package/components/Chat/TimelineRows.tsx +194 -0
- package/components/Pane/DesktopTabBar.tsx +0 -6
- package/components/Pane/MobileTabBar.tsx +0 -6
- package/components/Pane/MobileViewPicker.test.ts +1 -8
- package/components/Pane/MobileViewPicker.tsx +0 -11
- package/components/Pane/PaneViewToggle.test.ts +0 -2
- package/components/Pane/PaneViewToggle.tsx +0 -17
- package/components/Pane/index.tsx +0 -50
- package/components/SessionList/useDoneAction.ts +18 -3
- package/content/whats-new.json +16 -0
- package/contexts/PaneContext.tsx +1 -1
- package/data/done/index.ts +26 -2
- package/data/fork/index.ts +83 -0
- package/lib/chat/commands.ts +15 -0
- package/lib/chat/drivers/copilot-ask.ts +38 -5
- package/lib/chat/runner.ts +8 -2
- package/lib/chat/worker/extras.ts +7 -1
- package/lib/chat/worker/host.ts +2 -0
- package/lib/config/client-config-types.ts +7 -0
- package/lib/config/client-config.ts +2 -0
- package/lib/db/migrations/20261102T0100_forks.ts +87 -0
- package/lib/done/archive.ts +21 -1
- package/lib/experiments/registry.ts +8 -0
- package/lib/fork/api.ts +10 -0
- package/lib/fork/brief.ts +63 -0
- package/lib/fork/conversation.ts +75 -0
- package/lib/fork/deliver.ts +91 -0
- package/lib/fork/extras.ts +53 -0
- package/lib/fork/guard.ts +323 -0
- package/lib/fork/heavy-lease.ts +104 -0
- package/lib/fork/held.ts +26 -0
- package/lib/fork/ledger.ts +324 -0
- package/lib/fork/options.ts +46 -0
- package/lib/fork/point.ts +41 -0
- package/lib/fork/scratch.ts +67 -0
- package/lib/fork/settle.ts +215 -0
- package/lib/fork/snapshot.ts +69 -0
- package/lib/fork/start.ts +273 -0
- package/lib/fork/tick.ts +54 -0
- package/lib/fork/types.ts +79 -0
- package/lib/fork/view.ts +116 -0
- package/lib/fork/waits.ts +83 -0
- package/lib/fork/worktree.ts +115 -0
- package/lib/orchestrator/shell-guard.ts +6 -3
- package/lib/orchestrator/write-guard.ts +1 -1
- package/lib/security/demo.ts +1 -2
- package/lib/sessions/end.ts +5 -0
- package/package.json +1 -1
- package/server.ts +2 -0
- package/app/api/orchestrate/spawn/route.ts +0 -54
- package/app/api/orchestrate/workers/[id]/route.ts +0 -89
- package/app/api/orchestrate/workers/route.ts +0 -31
- package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
- package/components/ConductorPanel.tsx +0 -258
- package/lib/mcp-config.ts +0 -80
- package/lib/orchestration.ts +0 -463
- package/mcp/orchestration-server.ts +0 -441
|
@@ -14,7 +14,6 @@ import type { TerminalHandle } from "@/components/Terminal";
|
|
|
14
14
|
import type { Session, Project } from "@/lib/db";
|
|
15
15
|
import { sessionRegistry } from "@/lib/client/session-registry";
|
|
16
16
|
import { cn } from "@/lib/utils";
|
|
17
|
-
import { ConductorPanel } from "@/components/ConductorPanel";
|
|
18
17
|
import { useFileEditor } from "@/hooks/useFileEditor";
|
|
19
18
|
import { MobileTabBar } from "./MobileTabBar";
|
|
20
19
|
import { PairSwitch } from "@/components/Analyst/PairSwitch";
|
|
@@ -208,14 +207,6 @@ export const Pane = memo(function Pane({
|
|
|
208
207
|
// File editor state - lifted here so it persists across view switches
|
|
209
208
|
const fileEditor = useFileEditor();
|
|
210
209
|
|
|
211
|
-
// Check if this session is a conductor (has workers)
|
|
212
|
-
const workerCount = useMemo(() => {
|
|
213
|
-
if (!session) return 0;
|
|
214
|
-
return sessions.filter((s) => s.conductor_session_id === session.id).length;
|
|
215
|
-
}, [session, sessions]);
|
|
216
|
-
|
|
217
|
-
const isConductor = workerCount > 0;
|
|
218
|
-
|
|
219
210
|
// A merged task keeps the log AgentOS wrote of it.
|
|
220
211
|
const merged =
|
|
221
212
|
!!session &&
|
|
@@ -477,10 +468,8 @@ export const Pane = memo(function Pane({
|
|
|
477
468
|
sessions={sessions}
|
|
478
469
|
projects={projects}
|
|
479
470
|
viewMode={viewMode}
|
|
480
|
-
isConductor={isConductor}
|
|
481
471
|
hasLog={hasLog}
|
|
482
472
|
hasReport={hasReport}
|
|
483
|
-
workerCount={workerCount}
|
|
484
473
|
showGit={showGit}
|
|
485
474
|
onMenuClick={onMenuClick}
|
|
486
475
|
onViewModeChange={setViewMode}
|
|
@@ -501,10 +490,8 @@ export const Pane = memo(function Pane({
|
|
|
501
490
|
sessions={sessions}
|
|
502
491
|
viewMode={viewMode}
|
|
503
492
|
isFocused={isFocused}
|
|
504
|
-
isConductor={isConductor}
|
|
505
493
|
hasLog={hasLog}
|
|
506
494
|
hasReport={hasReport}
|
|
507
|
-
workerCount={workerCount}
|
|
508
495
|
canSplit={canSplit}
|
|
509
496
|
canClose={canClose}
|
|
510
497
|
hasAttachedTmux={!!activeTab?.attachedTmux}
|
|
@@ -587,24 +574,6 @@ export const Pane = memo(function Pane({
|
|
|
587
574
|
onOpenInFiles={isLocalSession ? openInFiles : undefined}
|
|
588
575
|
/>
|
|
589
576
|
)}
|
|
590
|
-
|
|
591
|
-
{/* Workers */}
|
|
592
|
-
{viewMode === "workers" && session && (
|
|
593
|
-
<ConductorPanel
|
|
594
|
-
conductorSessionId={session.id}
|
|
595
|
-
onAttachToWorker={(workerId) => {
|
|
596
|
-
setViewMode("terminal");
|
|
597
|
-
const worker = sessions.find((s) => s.id === workerId);
|
|
598
|
-
if (worker && terminalRef) {
|
|
599
|
-
terminalRef.attach({
|
|
600
|
-
sessionName: worker.tmux_name || `claude-${workerId}`,
|
|
601
|
-
hostId: worker.host_id,
|
|
602
|
-
attachOnly: true,
|
|
603
|
-
});
|
|
604
|
-
}
|
|
605
|
-
}}
|
|
606
|
-
/>
|
|
607
|
-
)}
|
|
608
577
|
</div>
|
|
609
578
|
) : (
|
|
610
579
|
<ResizablePanelGroup
|
|
@@ -663,25 +632,6 @@ export const Pane = memo(function Pane({
|
|
|
663
632
|
onOpenInFiles={isLocalSession ? openInFiles : undefined}
|
|
664
633
|
/>
|
|
665
634
|
)}
|
|
666
|
-
|
|
667
|
-
{/* Workers */}
|
|
668
|
-
{viewMode === "workers" && session && (
|
|
669
|
-
<ConductorPanel
|
|
670
|
-
conductorSessionId={session.id}
|
|
671
|
-
onAttachToWorker={(workerId) => {
|
|
672
|
-
setViewMode("terminal");
|
|
673
|
-
const worker = sessions.find((s) => s.id === workerId);
|
|
674
|
-
if (worker && terminalRef) {
|
|
675
|
-
terminalRef.attach({
|
|
676
|
-
sessionName:
|
|
677
|
-
worker.tmux_name || `claude-${workerId}`,
|
|
678
|
-
hostId: worker.host_id,
|
|
679
|
-
attachOnly: true,
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
}}
|
|
683
|
-
/>
|
|
684
|
-
)}
|
|
685
635
|
</div>
|
|
686
636
|
</ResizablePanel>
|
|
687
637
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toast } from "sonner";
|
|
2
2
|
import type { Session } from "@/lib/db";
|
|
3
3
|
import {
|
|
4
|
+
ForkChildrenOpenError,
|
|
4
5
|
useArchiveSession,
|
|
5
6
|
useDoneSession,
|
|
6
7
|
useResurrectSession,
|
|
@@ -57,8 +58,8 @@ export function useDoneAction() {
|
|
|
57
58
|
export function useArchiveAction() {
|
|
58
59
|
const archive = useArchiveSession();
|
|
59
60
|
const unarchive = useUnarchiveSession();
|
|
60
|
-
|
|
61
|
-
archive.mutate(session.id, {
|
|
61
|
+
const run = (session: Session, children?: "stop" | "keep") =>
|
|
62
|
+
archive.mutate(children ? { id: session.id, children } : session.id, {
|
|
62
63
|
onSuccess: () =>
|
|
63
64
|
toast.success(`Archived ${session.name}`, {
|
|
64
65
|
action: {
|
|
@@ -66,8 +67,22 @@ export function useArchiveAction() {
|
|
|
66
67
|
onClick: () => unarchive.mutate(session.id),
|
|
67
68
|
},
|
|
68
69
|
}),
|
|
69
|
-
onError: (e) =>
|
|
70
|
+
onError: (e) =>
|
|
71
|
+
e instanceof ForkChildrenOpenError
|
|
72
|
+
? toast(e.message, {
|
|
73
|
+
duration: 15000,
|
|
74
|
+
action: {
|
|
75
|
+
label: "Stop them",
|
|
76
|
+
onClick: () => run(session, "stop"),
|
|
77
|
+
},
|
|
78
|
+
cancel: {
|
|
79
|
+
label: "Keep them going",
|
|
80
|
+
onClick: () => run(session, "keep"),
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
: toast.error(e.message, { duration: 12000 }),
|
|
70
84
|
});
|
|
85
|
+
return (session: Session) => run(session);
|
|
71
86
|
}
|
|
72
87
|
|
|
73
88
|
// Restore from a session's menu: the same conversation, its worktree back.
|
package/content/whats-new.json
CHANGED
|
@@ -1921,6 +1921,22 @@
|
|
|
1921
1921
|
"area": "AgentOS",
|
|
1922
1922
|
"text": "You can set your name in Settings, and AgentOS uses it in chats and notes instead of a fixed name.",
|
|
1923
1923
|
"url": "https://github.com/saadnvd1/agent-os/pull/550"
|
|
1924
|
+
},
|
|
1925
|
+
{
|
|
1926
|
+
"id": 551,
|
|
1927
|
+
"date": "2026-11-01",
|
|
1928
|
+
"kind": "fixed",
|
|
1929
|
+
"area": "Chat",
|
|
1930
|
+
"text": "Questions from GitHub Copilot chats now always show up at the latest message after the chat restarts, instead of going missing.",
|
|
1931
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/551"
|
|
1932
|
+
},
|
|
1933
|
+
{
|
|
1934
|
+
"id": 552,
|
|
1935
|
+
"date": "2026-11-01",
|
|
1936
|
+
"kind": "fixed",
|
|
1937
|
+
"area": "Chat",
|
|
1938
|
+
"text": "Cancelled questions from GitHub Copilot chats now clear right away instead of staying on screen.",
|
|
1939
|
+
"url": "https://github.com/saadnvd1/agent-os/pull/552"
|
|
1924
1940
|
}
|
|
1925
1941
|
]
|
|
1926
1942
|
}
|
package/contexts/PaneContext.tsx
CHANGED
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
import { useViewport } from "@/hooks/useViewport";
|
|
30
30
|
|
|
31
31
|
export type ViewMode =
|
|
32
|
-
"terminal" | "files" | "git" | "
|
|
32
|
+
"terminal" | "files" | "git" | "log" | "artifacts" | "report";
|
|
33
33
|
|
|
34
34
|
export interface PaneViewState {
|
|
35
35
|
viewMode: ViewMode;
|
package/data/done/index.ts
CHANGED
|
@@ -73,9 +73,33 @@ export function useDoneSession() {
|
|
|
73
73
|
);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// Archiving a session whose forks are still running asks what to do with them.
|
|
77
|
+
export class ForkChildrenOpenError extends Error {
|
|
78
|
+
constructor(
|
|
79
|
+
message: string,
|
|
80
|
+
readonly open: number
|
|
81
|
+
) {
|
|
82
|
+
super(message);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
export function useArchiveSession() {
|
|
77
|
-
return useDoneMutation(
|
|
78
|
-
|
|
87
|
+
return useDoneMutation(
|
|
88
|
+
async (input: string | { id: string; children: "stop" | "keep" }) => {
|
|
89
|
+
const { id, children } =
|
|
90
|
+
typeof input === "string" ? { id: input, children: undefined } : input;
|
|
91
|
+
const res = await post(
|
|
92
|
+
`/api/sessions/${id}/archive`,
|
|
93
|
+
children ? { children } : undefined
|
|
94
|
+
);
|
|
95
|
+
const data = await res
|
|
96
|
+
.clone()
|
|
97
|
+
.json()
|
|
98
|
+
.catch(() => null);
|
|
99
|
+
if (res.status === 409 && typeof data?.children === "number")
|
|
100
|
+
throw new ForkChildrenOpenError(data.error, data.children);
|
|
101
|
+
return json(res);
|
|
102
|
+
}
|
|
79
103
|
);
|
|
80
104
|
}
|
|
81
105
|
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import type { ChildView, GroupView } from "@/lib/fork/types";
|
|
3
|
+
|
|
4
|
+
export type { ChildView, GroupView };
|
|
5
|
+
|
|
6
|
+
export interface ForkChildrenView {
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
// Set when this session is itself a fork child.
|
|
9
|
+
asChild: {
|
|
10
|
+
parentId: string;
|
|
11
|
+
parentName: string;
|
|
12
|
+
n: number;
|
|
13
|
+
label: string;
|
|
14
|
+
state: ChildView["state"];
|
|
15
|
+
worktree: boolean;
|
|
16
|
+
} | null;
|
|
17
|
+
children: ChildView[];
|
|
18
|
+
groups: GroupView[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const forkKeys = {
|
|
22
|
+
all: ["forks"] as const,
|
|
23
|
+
children: (sessionId: string) => ["forks", "children", sessionId] as const,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
async function json<T>(res: Response): Promise<T> {
|
|
27
|
+
const data = await res.json();
|
|
28
|
+
if (!res.ok) throw new Error(data.error || "Request failed");
|
|
29
|
+
return data;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A session's forks. Only asked while the experiment is on; refetched
|
|
33
|
+
// every few seconds while any is still going (results land by themselves).
|
|
34
|
+
export function useForkChildren(sessionId: string, enabled: boolean) {
|
|
35
|
+
return useQuery({
|
|
36
|
+
queryKey: forkKeys.children(sessionId),
|
|
37
|
+
queryFn: async () =>
|
|
38
|
+
json<ForkChildrenView>(
|
|
39
|
+
await fetch(
|
|
40
|
+
`/api/fork/children?session=${encodeURIComponent(sessionId)}`
|
|
41
|
+
)
|
|
42
|
+
),
|
|
43
|
+
enabled,
|
|
44
|
+
staleTime: 2_000,
|
|
45
|
+
refetchInterval: (q) =>
|
|
46
|
+
q.state.data?.children.some((c) => c.state !== "settled") ||
|
|
47
|
+
q.state.data?.asChild
|
|
48
|
+
? 4_000
|
|
49
|
+
: false,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function useStartForks(sessionId: string) {
|
|
54
|
+
const queryClient = useQueryClient();
|
|
55
|
+
return useMutation({
|
|
56
|
+
mutationFn: async (input: { instructions: string[]; worktree: boolean }) =>
|
|
57
|
+
json<{ children: { id: string; n: number; label: string }[] }>(
|
|
58
|
+
await fetch("/api/fork", {
|
|
59
|
+
method: "POST",
|
|
60
|
+
headers: { "Content-Type": "application/json" },
|
|
61
|
+
body: JSON.stringify({ session: sessionId, ...input }),
|
|
62
|
+
})
|
|
63
|
+
),
|
|
64
|
+
onSuccess: () =>
|
|
65
|
+
queryClient.invalidateQueries({ queryKey: forkKeys.children(sessionId) }),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function useCancelForks(sessionId: string) {
|
|
70
|
+
const queryClient = useQueryClient();
|
|
71
|
+
return useMutation({
|
|
72
|
+
mutationFn: async (input: { ref?: string; all?: boolean }) =>
|
|
73
|
+
json<{ cancelled: string[] }>(
|
|
74
|
+
await fetch("/api/fork/cancel", {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { "Content-Type": "application/json" },
|
|
77
|
+
body: JSON.stringify({ session: sessionId, ...input }),
|
|
78
|
+
})
|
|
79
|
+
),
|
|
80
|
+
onSuccess: () =>
|
|
81
|
+
queryClient.invalidateQueries({ queryKey: forkKeys.children(sessionId) }),
|
|
82
|
+
});
|
|
83
|
+
}
|
package/lib/chat/commands.ts
CHANGED
|
@@ -83,6 +83,21 @@ 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
|
+
|
|
86
101
|
export interface MenuGroup {
|
|
87
102
|
label: string;
|
|
88
103
|
commands: ChatCommand[];
|
|
@@ -66,6 +66,8 @@ export function askUserQuestion(a: AskArgs): ChatQuestion {
|
|
|
66
66
|
};
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
const requestKey = (id: string | number) => `${typeof id}:${id}`;
|
|
70
|
+
|
|
69
71
|
const text = (t: string, isError = false): CallToolResult => ({
|
|
70
72
|
content: [{ type: "text", text: t }],
|
|
71
73
|
...(isError ? { isError } : {}),
|
|
@@ -105,8 +107,10 @@ export function isAskUserCall(call: P): boolean {
|
|
|
105
107
|
export class CopilotAskServer {
|
|
106
108
|
private http?: http.Server;
|
|
107
109
|
private secret = crypto.randomBytes(32).toString("hex");
|
|
108
|
-
private next = 0;
|
|
109
110
|
private live = new Set<{ close(): Promise<void> }>();
|
|
111
|
+
// Waiting ask_user calls by JSON-RPC request id. A cancel arrives as its
|
|
112
|
+
// own POST, on a fresh MCP server, so only this map can match it.
|
|
113
|
+
private waiting = new Map<string, string>();
|
|
110
114
|
|
|
111
115
|
constructor(private pending: PendingApprovals) {}
|
|
112
116
|
|
|
@@ -119,22 +123,49 @@ export class CopilotAskServer {
|
|
|
119
123
|
"Ask the user a question and wait for the answer. Use it when you need a decision or missing information instead of asking in plain text. Offer options for a choice (multiSelect to allow several); with no options the user types an answer. Blocks until they answer.",
|
|
120
124
|
inputSchema: askInput,
|
|
121
125
|
},
|
|
122
|
-
async (args) => {
|
|
126
|
+
async (args, extra) => {
|
|
123
127
|
const q = askUserQuestion(args);
|
|
124
|
-
|
|
125
|
-
|
|
128
|
+
// Unique across worker restarts: a card's id is its chat row, so a
|
|
129
|
+
// reused id would rewrite an old card up the history instead.
|
|
130
|
+
const id = `approval-ask-${crypto.randomUUID()}`;
|
|
131
|
+
const asked = this.pending.ask({
|
|
132
|
+
id,
|
|
126
133
|
toolName: "AskUserQuestion",
|
|
127
134
|
title: "Questions",
|
|
128
135
|
input: { questions: [q] },
|
|
129
136
|
questions: [q],
|
|
130
137
|
canAlways: false,
|
|
131
138
|
});
|
|
132
|
-
|
|
139
|
+
// Copilot gave up on the call (its timeout, or the connection went):
|
|
140
|
+
// nobody is waiting for this answer any more.
|
|
141
|
+
const expire = () => this.pending.respond(id, { decision: "expired" });
|
|
142
|
+
if (extra.signal.aborted) expire();
|
|
143
|
+
else extra.signal.addEventListener("abort", expire, { once: true });
|
|
144
|
+
const key = requestKey(extra.requestId);
|
|
145
|
+
this.waiting.set(key, id);
|
|
146
|
+
try {
|
|
147
|
+
return askUserResult(q, await asked);
|
|
148
|
+
} finally {
|
|
149
|
+
if (this.waiting.get(key) === id) this.waiting.delete(key);
|
|
150
|
+
}
|
|
133
151
|
}
|
|
134
152
|
);
|
|
135
153
|
return server;
|
|
136
154
|
}
|
|
137
155
|
|
|
156
|
+
// notifications/cancelled names the request to give up on. An id with no
|
|
157
|
+
// waiting call (answered already, or never ours) is ignored.
|
|
158
|
+
private cancelWaiting(body: unknown): void {
|
|
159
|
+
for (const m of Array.isArray(body) ? body : [body]) {
|
|
160
|
+
const msg = m as { method?: unknown; params?: { requestId?: unknown } };
|
|
161
|
+
const rid = msg?.params?.requestId;
|
|
162
|
+
if (msg?.method !== "notifications/cancelled") continue;
|
|
163
|
+
if (typeof rid !== "string" && typeof rid !== "number") continue;
|
|
164
|
+
const card = this.waiting.get(requestKey(rid));
|
|
165
|
+
if (card) this.pending.respond(card, { decision: "expired" });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
138
169
|
private allowed(req: http.IncomingMessage): number {
|
|
139
170
|
if (req.headers.origin) return 403;
|
|
140
171
|
const host = (req.headers.host ?? "").replace(/:\d+$/, "");
|
|
@@ -176,6 +207,7 @@ export class CopilotAskServer {
|
|
|
176
207
|
res.writeHead(400).end();
|
|
177
208
|
return;
|
|
178
209
|
}
|
|
210
|
+
this.cancelWaiting(body);
|
|
179
211
|
const server = this.build();
|
|
180
212
|
const transport = new StreamableHTTPServerTransport({
|
|
181
213
|
sessionIdGenerator: undefined,
|
|
@@ -222,6 +254,7 @@ export class CopilotAskServer {
|
|
|
222
254
|
// and stops listening.
|
|
223
255
|
close(): void {
|
|
224
256
|
this.pending.expireAll();
|
|
257
|
+
this.waiting.clear();
|
|
225
258
|
const server = this.http;
|
|
226
259
|
this.http = undefined;
|
|
227
260
|
server?.close();
|
package/lib/chat/runner.ts
CHANGED
|
@@ -87,6 +87,7 @@ import { chatHold, chatRefusal } from "./hold";
|
|
|
87
87
|
import { getHost } from "../hosts";
|
|
88
88
|
import { hostLink } from "../hosts/remote-api";
|
|
89
89
|
import { ownerUi } from "../owner";
|
|
90
|
+
import { heldForNext } from "../fork/held";
|
|
90
91
|
|
|
91
92
|
export {
|
|
92
93
|
refreshCapabilities,
|
|
@@ -432,10 +433,15 @@ export async function sendChat(
|
|
|
432
433
|
id?: string;
|
|
433
434
|
} & SentBy
|
|
434
435
|
): Promise<void> {
|
|
435
|
-
const
|
|
436
|
-
if (!
|
|
436
|
+
const typed = input.text.trim();
|
|
437
|
+
if (!typed && !input.images?.length && !input.files?.length) return;
|
|
437
438
|
if (input.id && taken(sessionId, input.id)) return;
|
|
438
439
|
refuseAway(sessionId);
|
|
440
|
+
// Results of forks the reader started come first in their next message.
|
|
441
|
+
const text =
|
|
442
|
+
!input.from && !input.origin && !input.peer
|
|
443
|
+
? heldForNext(sessionId) + typed
|
|
444
|
+
: typed;
|
|
439
445
|
const id = input.id ?? `user-${Date.now()}-${randomUUID().slice(0, 5)}`;
|
|
440
446
|
// Its worktree is still being set up, its task hasn't launched (held by
|
|
441
447
|
// Pause, say), or it's moving or landing: the message waits its turn.
|
|
@@ -2,6 +2,7 @@ import type { Session } from "../../db";
|
|
|
2
2
|
import type { ChatStartOptions } from "../driver";
|
|
3
3
|
import { refreshSettings } from "../refresh";
|
|
4
4
|
import { sessionProviderEnv } from "../../models/launch";
|
|
5
|
+
import { childExtras } from "../../fork/extras";
|
|
5
6
|
|
|
6
7
|
export type RoleExtras = Pick<
|
|
7
8
|
ChatStartOptions,
|
|
@@ -30,7 +31,12 @@ export async function roleExtras(session: Session): Promise<RoleExtras> {
|
|
|
30
31
|
ownExtras(session),
|
|
31
32
|
sessionProviderEnv(session),
|
|
32
33
|
]);
|
|
33
|
-
|
|
34
|
+
const fork = childExtras(session);
|
|
35
|
+
return {
|
|
36
|
+
...extras,
|
|
37
|
+
...(fork?.toolGuard && { toolGuard: fork.toolGuard }),
|
|
38
|
+
...((env || fork) && { env: { ...env, ...fork?.env } }),
|
|
39
|
+
};
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
async function ownExtras(session: Session): Promise<RoleExtras> {
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import os from "os";
|
|
8
8
|
import { db, type Session } from "../../db";
|
|
9
9
|
import { agentEnv } from "../../agents/launch";
|
|
10
|
+
import { forkBrief } from "../../fork/extras";
|
|
10
11
|
import { sessionRunningBrief } from "../../project-config/session";
|
|
11
12
|
import { BUS_BRIEF } from "../../agents/brief";
|
|
12
13
|
import { resolveModelForAgent } from "../../model-catalog";
|
|
@@ -250,6 +251,7 @@ export class ChatHost {
|
|
|
250
251
|
BUS_BRIEF,
|
|
251
252
|
visuals && (orchestrator ? ORCHESTRATOR_VISUALS_BRIEF : VISUALS_BRIEF),
|
|
252
253
|
!orchestrator && COMPACT_BRIEF,
|
|
254
|
+
forkBrief(session),
|
|
253
255
|
]
|
|
254
256
|
.filter(Boolean)
|
|
255
257
|
.join("\n\n"),
|
|
@@ -32,6 +32,13 @@ export interface ClientConfig {
|
|
|
32
32
|
voice: VoiceLimits;
|
|
33
33
|
/** The owner's own name for text that refers to them; null until set. */
|
|
34
34
|
ownerName: string | null;
|
|
35
|
+
// Parallel forks (an experiment, off unless turned on in Settings).
|
|
36
|
+
forks?: {
|
|
37
|
+
enabled: boolean;
|
|
38
|
+
maxPerFork: number;
|
|
39
|
+
summaryChars: number;
|
|
40
|
+
deliveryModes: string[];
|
|
41
|
+
};
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
// What a chat accepts, served from lib/attachments/policy.ts. Sizes in bytes.
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
RUN_TRIGGER_LABELS,
|
|
8
8
|
} from "../recipes/status-labels";
|
|
9
9
|
import { TASK_STATE_LABELS } from "../tasks/state-labels";
|
|
10
|
+
import { forkConfig } from "../fork/options";
|
|
10
11
|
import { cachedCheck } from "../updates/check";
|
|
11
12
|
import {
|
|
12
13
|
CLIENT_CONFIG_VERSION,
|
|
@@ -44,5 +45,6 @@ export function clientConfig(): ClientConfig {
|
|
|
44
45
|
attachments: ATTACHMENT_LIMITS,
|
|
45
46
|
voice: VOICE_LIMITS,
|
|
46
47
|
ownerName: ownerName(),
|
|
48
|
+
forks: forkConfig(),
|
|
47
49
|
};
|
|
48
50
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261102T0100_forks",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
// The children ledger (lib/fork): one group per `aos fork` call, one row
|
|
7
|
+
// per child. Tables of their own: columns on sessions would rebuild the
|
|
8
|
+
// session_rows view. No FKs to sessions, like chat_views; a trigger below
|
|
9
|
+
// drops a deleted session's rows.
|
|
10
|
+
db.exec(`
|
|
11
|
+
CREATE TABLE IF NOT EXISTS fork_groups (
|
|
12
|
+
id TEXT PRIMARY KEY,
|
|
13
|
+
parent_session_id TEXT NOT NULL,
|
|
14
|
+
request_id TEXT NOT NULL UNIQUE,
|
|
15
|
+
started_by TEXT NOT NULL CHECK (started_by IN ('agent', 'user')),
|
|
16
|
+
context TEXT NOT NULL CHECK (context IN ('fork', 'brief')),
|
|
17
|
+
delivery TEXT NOT NULL CHECK (delivery IN ('wake', 'next')),
|
|
18
|
+
fork_item_id TEXT,
|
|
19
|
+
created_at INTEGER NOT NULL,
|
|
20
|
+
last_settled_at INTEGER
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS session_children (
|
|
25
|
+
child_session_id TEXT PRIMARY KEY,
|
|
26
|
+
parent_session_id TEXT NOT NULL,
|
|
27
|
+
group_id TEXT NOT NULL REFERENCES fork_groups(id) ON DELETE CASCADE,
|
|
28
|
+
position INTEGER NOT NULL,
|
|
29
|
+
label TEXT NOT NULL,
|
|
30
|
+
instruction TEXT NOT NULL,
|
|
31
|
+
worktree INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
state TEXT NOT NULL,
|
|
33
|
+
hold TEXT,
|
|
34
|
+
outcome TEXT,
|
|
35
|
+
summary TEXT,
|
|
36
|
+
result TEXT,
|
|
37
|
+
result_kind TEXT,
|
|
38
|
+
fell_back INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
cut_message_id TEXT,
|
|
40
|
+
idle_since INTEGER,
|
|
41
|
+
started_at INTEGER,
|
|
42
|
+
settled_at INTEGER,
|
|
43
|
+
tokens_in INTEGER,
|
|
44
|
+
tokens_out INTEGER,
|
|
45
|
+
cache_read INTEGER,
|
|
46
|
+
heavy_runs INTEGER NOT NULL DEFAULT 0,
|
|
47
|
+
delivered_at INTEGER,
|
|
48
|
+
delivery_id TEXT,
|
|
49
|
+
used TEXT,
|
|
50
|
+
created_at INTEGER NOT NULL
|
|
51
|
+
)
|
|
52
|
+
`);
|
|
53
|
+
// A parent's `aos wait children`: delivered the moment it holds.
|
|
54
|
+
db.exec(`
|
|
55
|
+
CREATE TABLE IF NOT EXISTS fork_waits (
|
|
56
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
57
|
+
parent_session_id TEXT NOT NULL,
|
|
58
|
+
mode TEXT NOT NULL CHECK (mode IN ('all', 'any')),
|
|
59
|
+
created_at INTEGER NOT NULL,
|
|
60
|
+
deadline INTEGER NOT NULL,
|
|
61
|
+
closed_at INTEGER
|
|
62
|
+
)
|
|
63
|
+
`);
|
|
64
|
+
db.exec(
|
|
65
|
+
`CREATE INDEX IF NOT EXISTS idx_fork_waits_open
|
|
66
|
+
ON fork_waits(parent_session_id) WHERE closed_at IS NULL`
|
|
67
|
+
);
|
|
68
|
+
db.exec(
|
|
69
|
+
`CREATE INDEX IF NOT EXISTS idx_children_parent
|
|
70
|
+
ON session_children(parent_session_id, settled_at)`
|
|
71
|
+
);
|
|
72
|
+
db.exec(
|
|
73
|
+
`CREATE INDEX IF NOT EXISTS idx_children_undelivered
|
|
74
|
+
ON session_children(parent_session_id)
|
|
75
|
+
WHERE settled_at IS NOT NULL AND delivered_at IS NULL`
|
|
76
|
+
);
|
|
77
|
+
db.exec(`
|
|
78
|
+
CREATE TRIGGER IF NOT EXISTS fork_children_session_deleted
|
|
79
|
+
AFTER DELETE ON sessions
|
|
80
|
+
BEGIN
|
|
81
|
+
DELETE FROM session_children WHERE child_session_id = OLD.id;
|
|
82
|
+
DELETE FROM fork_groups WHERE parent_session_id = OLD.id;
|
|
83
|
+
DELETE FROM fork_waits WHERE parent_session_id = OLD.id;
|
|
84
|
+
END
|
|
85
|
+
`);
|
|
86
|
+
},
|
|
87
|
+
} satisfies Migration;
|
package/lib/done/archive.ts
CHANGED
|
@@ -7,6 +7,20 @@ import { statusOf } from "../orchestrator/facts";
|
|
|
7
7
|
import { kickWaits } from "../bus/waits/kick";
|
|
8
8
|
import { peerSessionLink } from "../hosts/peer-actions";
|
|
9
9
|
import { endLinkOf } from "../analyst/endLink";
|
|
10
|
+
import { openChildren } from "../fork/ledger";
|
|
11
|
+
import { cancelChildren } from "../fork/settle";
|
|
12
|
+
|
|
13
|
+
// Archiving a session whose forks are still running asks first.
|
|
14
|
+
export class ForkChildrenOpen extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
name: string,
|
|
17
|
+
readonly open: number
|
|
18
|
+
) {
|
|
19
|
+
super(
|
|
20
|
+
`${name} has ${open} ${open === 1 ? "fork" : "forks"} still running: stop them or keep them going`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
10
24
|
|
|
11
25
|
export interface ArchivedView {
|
|
12
26
|
id: string;
|
|
@@ -81,7 +95,10 @@ export function listArchived(workspaceId?: string): ArchivedView[] {
|
|
|
81
95
|
|
|
82
96
|
// Puts a session away on purpose. Nothing is merged or stopped here: an
|
|
83
97
|
// agent still working, a task with an open PR and an orchestrator stay.
|
|
84
|
-
export async function archiveChecked(
|
|
98
|
+
export async function archiveChecked(
|
|
99
|
+
id: string,
|
|
100
|
+
opts: { children?: "stop" | "keep" } = {}
|
|
101
|
+
): Promise<Session> {
|
|
85
102
|
const s = queries.getSession(db).get(id) as Session | undefined;
|
|
86
103
|
if (!s) throw new Error("Session not found");
|
|
87
104
|
if (s.archived_at) throw new Error(`${s.name} is already archived`);
|
|
@@ -99,6 +116,9 @@ export async function archiveChecked(id: string): Promise<Session> {
|
|
|
99
116
|
throw new Error(
|
|
100
117
|
`${s.name} is still working: wait for it, or stop it first`
|
|
101
118
|
);
|
|
119
|
+
const open = openChildren(s.id).length;
|
|
120
|
+
if (open && !opts.children) throw new ForkChildrenOpen(s.name, open);
|
|
121
|
+
if (open && opts.children === "stop") await cancelChildren(s.id);
|
|
102
122
|
archiveSession(s.id);
|
|
103
123
|
return queries.getSession(db).get(id) as Session;
|
|
104
124
|
}
|
|
@@ -26,6 +26,14 @@ export const EXPERIMENTS = [
|
|
|
26
26
|
status: "experimental",
|
|
27
27
|
suggests: true,
|
|
28
28
|
},
|
|
29
|
+
{
|
|
30
|
+
id: "forks",
|
|
31
|
+
name: "Parallel forks",
|
|
32
|
+
description:
|
|
33
|
+
"A session can start several children that know what it knows, work in the background on independent questions, and report back, so the main session isn't blocked.",
|
|
34
|
+
area: "sessions",
|
|
35
|
+
status: "experimental",
|
|
36
|
+
},
|
|
29
37
|
] as const satisfies readonly Experiment[];
|
|
30
38
|
|
|
31
39
|
export type ExperimentId = (typeof EXPERIMENTS)[number]["id"];
|
package/lib/fork/api.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
|
|
3
|
+
export const fail = (error: unknown, status = 400) =>
|
|
4
|
+
NextResponse.json(
|
|
5
|
+
{ error: error instanceof Error ? error.message : String(error) },
|
|
6
|
+
{ status }
|
|
7
|
+
);
|
|
8
|
+
|
|
9
|
+
export const text = (v: unknown): string | null =>
|
|
10
|
+
typeof v === "string" && v.trim() ? v : null;
|