@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.
Files changed (70) hide show
  1. package/app/api/fork/cancel/route.ts +19 -0
  2. package/app/api/fork/children/route.ts +52 -0
  3. package/app/api/fork/result/route.ts +29 -0
  4. package/app/api/fork/return/route.ts +20 -0
  5. package/app/api/fork/route.ts +32 -0
  6. package/app/api/fork/wait/route.ts +27 -0
  7. package/app/api/load/heavy/lease/route.ts +26 -0
  8. package/app/api/sessions/[id]/archive/route.ts +17 -4
  9. package/app/api/sessions/[id]/route.ts +2 -3
  10. package/app/page.tsx +0 -5
  11. package/components/Chat/ChatPanel.tsx +33 -206
  12. package/components/Chat/Forks.tsx +278 -0
  13. package/components/Chat/ReplyingTo.tsx +24 -0
  14. package/components/Chat/TimelineRows.tsx +194 -0
  15. package/components/Pane/DesktopTabBar.tsx +0 -6
  16. package/components/Pane/MobileTabBar.tsx +0 -6
  17. package/components/Pane/MobileViewPicker.test.ts +1 -8
  18. package/components/Pane/MobileViewPicker.tsx +0 -11
  19. package/components/Pane/PaneViewToggle.test.ts +0 -2
  20. package/components/Pane/PaneViewToggle.tsx +0 -17
  21. package/components/Pane/index.tsx +0 -50
  22. package/components/SessionList/useDoneAction.ts +18 -3
  23. package/content/whats-new.json +16 -0
  24. package/contexts/PaneContext.tsx +1 -1
  25. package/data/done/index.ts +26 -2
  26. package/data/fork/index.ts +83 -0
  27. package/lib/chat/commands.ts +15 -0
  28. package/lib/chat/drivers/copilot-ask.ts +38 -5
  29. package/lib/chat/runner.ts +8 -2
  30. package/lib/chat/worker/extras.ts +7 -1
  31. package/lib/chat/worker/host.ts +2 -0
  32. package/lib/config/client-config-types.ts +7 -0
  33. package/lib/config/client-config.ts +2 -0
  34. package/lib/db/migrations/20261102T0100_forks.ts +87 -0
  35. package/lib/done/archive.ts +21 -1
  36. package/lib/experiments/registry.ts +8 -0
  37. package/lib/fork/api.ts +10 -0
  38. package/lib/fork/brief.ts +63 -0
  39. package/lib/fork/conversation.ts +75 -0
  40. package/lib/fork/deliver.ts +91 -0
  41. package/lib/fork/extras.ts +53 -0
  42. package/lib/fork/guard.ts +323 -0
  43. package/lib/fork/heavy-lease.ts +104 -0
  44. package/lib/fork/held.ts +26 -0
  45. package/lib/fork/ledger.ts +324 -0
  46. package/lib/fork/options.ts +46 -0
  47. package/lib/fork/point.ts +41 -0
  48. package/lib/fork/scratch.ts +67 -0
  49. package/lib/fork/settle.ts +215 -0
  50. package/lib/fork/snapshot.ts +69 -0
  51. package/lib/fork/start.ts +273 -0
  52. package/lib/fork/tick.ts +54 -0
  53. package/lib/fork/types.ts +79 -0
  54. package/lib/fork/view.ts +116 -0
  55. package/lib/fork/waits.ts +83 -0
  56. package/lib/fork/worktree.ts +115 -0
  57. package/lib/orchestrator/shell-guard.ts +6 -3
  58. package/lib/orchestrator/write-guard.ts +1 -1
  59. package/lib/security/demo.ts +1 -2
  60. package/lib/sessions/end.ts +5 -0
  61. package/package.json +1 -1
  62. package/server.ts +2 -0
  63. package/app/api/orchestrate/spawn/route.ts +0 -54
  64. package/app/api/orchestrate/workers/[id]/route.ts +0 -89
  65. package/app/api/orchestrate/workers/route.ts +0 -31
  66. package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
  67. package/components/ConductorPanel.tsx +0 -258
  68. package/lib/mcp-config.ts +0 -80
  69. package/lib/orchestration.ts +0 -463
  70. 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
- return (session: Session) =>
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) => toast.error(e.message, { duration: 12000 }),
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.
@@ -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
  }
@@ -29,7 +29,7 @@ import {
29
29
  import { useViewport } from "@/hooks/useViewport";
30
30
 
31
31
  export type ViewMode =
32
- "terminal" | "files" | "git" | "workers" | "log" | "artifacts" | "report";
32
+ "terminal" | "files" | "git" | "log" | "artifacts" | "report";
33
33
 
34
34
  export interface PaneViewState {
35
35
  viewMode: ViewMode;
@@ -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(async (id: string) =>
78
- json(await post(`/api/sessions/${id}/archive`))
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
+ }
@@ -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
- const o = await this.pending.ask({
125
- id: `approval-ask-${++this.next}`,
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
- return askUserResult(q, o);
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();
@@ -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 text = input.text.trim();
436
- if (!text && !input.images?.length && !input.files?.length) return;
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
- return env ? { ...extras, env } : extras;
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> {
@@ -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;
@@ -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(id: string): Promise<Session> {
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"];
@@ -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;