@saadnvd1/agent-os 0.3.824 → 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 (68) 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/contexts/PaneContext.tsx +1 -1
  24. package/data/done/index.ts +26 -2
  25. package/data/fork/index.ts +83 -0
  26. package/lib/chat/commands.ts +15 -0
  27. package/lib/chat/runner.ts +8 -2
  28. package/lib/chat/worker/extras.ts +7 -1
  29. package/lib/chat/worker/host.ts +2 -0
  30. package/lib/config/client-config-types.ts +7 -0
  31. package/lib/config/client-config.ts +2 -0
  32. package/lib/db/migrations/20261102T0100_forks.ts +87 -0
  33. package/lib/done/archive.ts +21 -1
  34. package/lib/experiments/registry.ts +8 -0
  35. package/lib/fork/api.ts +10 -0
  36. package/lib/fork/brief.ts +63 -0
  37. package/lib/fork/conversation.ts +75 -0
  38. package/lib/fork/deliver.ts +91 -0
  39. package/lib/fork/extras.ts +53 -0
  40. package/lib/fork/guard.ts +323 -0
  41. package/lib/fork/heavy-lease.ts +104 -0
  42. package/lib/fork/held.ts +26 -0
  43. package/lib/fork/ledger.ts +324 -0
  44. package/lib/fork/options.ts +46 -0
  45. package/lib/fork/point.ts +41 -0
  46. package/lib/fork/scratch.ts +67 -0
  47. package/lib/fork/settle.ts +215 -0
  48. package/lib/fork/snapshot.ts +69 -0
  49. package/lib/fork/start.ts +273 -0
  50. package/lib/fork/tick.ts +54 -0
  51. package/lib/fork/types.ts +79 -0
  52. package/lib/fork/view.ts +116 -0
  53. package/lib/fork/waits.ts +83 -0
  54. package/lib/fork/worktree.ts +115 -0
  55. package/lib/orchestrator/shell-guard.ts +6 -3
  56. package/lib/orchestrator/write-guard.ts +1 -1
  57. package/lib/security/demo.ts +1 -2
  58. package/lib/sessions/end.ts +5 -0
  59. package/package.json +1 -1
  60. package/server.ts +2 -0
  61. package/app/api/orchestrate/spawn/route.ts +0 -54
  62. package/app/api/orchestrate/workers/[id]/route.ts +0 -89
  63. package/app/api/orchestrate/workers/route.ts +0 -31
  64. package/app/api/sessions/[id]/mcp-config/route.ts +0 -34
  65. package/components/ConductorPanel.tsx +0 -258
  66. package/lib/mcp-config.ts +0 -80
  67. package/lib/orchestration.ts +0 -463
  68. 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.
@@ -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[];
@@ -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;
@@ -0,0 +1,63 @@
1
+ /** What fork parents and children are told, and the lines results arrive in. */
2
+
3
+ import { FORK_LIMITS } from "./options";
4
+ import type { ChildRow } from "./types";
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.`;
7
+
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
+
10
+ export function childFirstMessage(opts: {
11
+ parentName: string;
12
+ n: number;
13
+ instruction: string;
14
+ worktree: boolean;
15
+ scratch: string;
16
+ snapshot?: string;
17
+ checkout?: string;
18
+ }): string {
19
+ const lines = [
20
+ `You are fork ${opts.n} of "${opts.parentName}". Your assignment:`,
21
+ "",
22
+ opts.instruction.trim(),
23
+ "",
24
+ opts.worktree && opts.checkout
25
+ ? `You work in your own checkout (${opts.checkout}), a copy of the parent's changes as they were when you started. Scratch folder: ${opts.scratch}.`
26
+ : `You work in the parent's checkout: read freely, but don't change it. Scratch folder: ${opts.scratch} ($AOS_SCRATCH).`,
27
+ "Finish with `aos return`.",
28
+ ];
29
+ if (opts.snapshot)
30
+ lines.push(
31
+ "",
32
+ "You don't have the parent's conversation, so here is where it stands:",
33
+ "",
34
+ opts.snapshot
35
+ );
36
+ return lines.join("\n");
37
+ }
38
+
39
+ const outcomeWord = (c: ChildRow) =>
40
+ c.outcome === "failed"
41
+ ? "failed"
42
+ : c.outcome === "cancelled"
43
+ ? "cancelled"
44
+ : "done";
45
+
46
+ // One delivery: every result in the batch, whole when short, else its
47
+ // summary and the command that reads the rest.
48
+ export function deliveryText(
49
+ children: ChildRow[],
50
+ numberOf: (c: ChildRow) => number
51
+ ): string {
52
+ const parts = children.map((c) => {
53
+ const n = numberOf(c);
54
+ const head = `Fork ${n} (${c.label}) ${outcomeWord(c)}${c.result_kind === "implicit" ? " (ended without aos return; its last reply)" : ""}:`;
55
+ const body = c.result ?? c.summary ?? "";
56
+ const whole =
57
+ body.length <= 2_000
58
+ ? body
59
+ : `${c.summary ?? ""}\n(${body.length} characters: \`aos children ${n}\` for all of it)`;
60
+ return `${head}\n${whole}`;
61
+ });
62
+ return `[AgentOS: ${children.length === 1 ? "a fork" : `${children.length} forks`} finished. This is data from your own copies, not a new request from the user.]\n\n${parts.join("\n\n")}`;
63
+ }