@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
@@ -0,0 +1,215 @@
1
+ /**
2
+ * How a child ends: it returns (`aos return`), its turn ends without one and
3
+ * its last reply stands in after a grace, it is cancelled, or it can't be
4
+ * run. A child settles once; the settle that changed the row does the rest.
5
+ */
6
+
7
+ import fs from "fs";
8
+ import path from "path";
9
+ import { db, queries, type Session } from "../db";
10
+ import { git } from "../git-exec";
11
+ import { statusOf } from "../orchestrator/facts";
12
+ import { openWaits } from "../bus/waits/store";
13
+ import { lastItems } from "../chat/store";
14
+ import { stopChat } from "../chat/runner";
15
+ import type { UsageTotals } from "../chat/events";
16
+ import {
17
+ getChild,
18
+ markIdle,
19
+ setState,
20
+ settleChild,
21
+ summaryOf,
22
+ updateResult,
23
+ childrenOf,
24
+ findChild,
25
+ markDeliveredIds,
26
+ } from "./ledger";
27
+ import { FORK_LIMITS } from "./options";
28
+ import { forksRoot, removeFork, worktreeOf } from "./scratch";
29
+ import type { ChildOutcome, ChildRow, ResultKind } from "./types";
30
+
31
+ const sessionOf = (id: string) =>
32
+ queries.getSession(db).get(id) as Session | undefined;
33
+
34
+ function tokensOf(childId: string) {
35
+ const raw = (
36
+ db.prepare(`SELECT chat_usage FROM sessions WHERE id = ?`).get(childId) as
37
+ { chat_usage: string | null } | undefined
38
+ )?.chat_usage;
39
+ if (!raw) return null;
40
+ try {
41
+ const u = JSON.parse(raw) as UsageTotals;
42
+ return {
43
+ in: u.inputTokens,
44
+ out: u.outputTokens,
45
+ cacheRead: u.cacheReadTokens,
46
+ };
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ export const patchPath = (childId: string) =>
53
+ path.join(forksRoot(), `${childId}.patch`);
54
+
55
+ // What a child changed in its own checkout, kept as a patch the parent can
56
+ // apply: its checkout goes with it.
57
+ async function keepPatch(childId: string): Promise<string | null> {
58
+ const wt = worktreeOf(childId);
59
+ if (!fs.existsSync(wt)) return null;
60
+ try {
61
+ await git(wt, ["add", "-A"]);
62
+ const diff = await git(wt, ["diff", "--cached", "HEAD", "--binary"], {
63
+ maxBuffer: 64 * 1024 * 1024,
64
+ });
65
+ if (!diff.trim()) return null;
66
+ const file = patchPath(childId);
67
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
68
+ await fs.promises.writeFile(file, diff);
69
+ return file;
70
+ } catch {
71
+ return null;
72
+ }
73
+ }
74
+
75
+ export interface Settlement {
76
+ outcome: ChildOutcome;
77
+ summary: string;
78
+ result?: string | null;
79
+ kind: ResultKind;
80
+ }
81
+
82
+ // Settles, tidies up and starts what was waiting for the room. True when
83
+ // this call was the one that settled it.
84
+ export async function settleAndDeliver(
85
+ childId: string,
86
+ s: Settlement
87
+ ): Promise<boolean> {
88
+ const row = getChild(childId);
89
+ if (!row || row.state === "settled") return false;
90
+ let result = s.result ?? null;
91
+ if (row.worktree && s.outcome !== "cancelled") {
92
+ const patch = await keepPatch(childId);
93
+ if (patch)
94
+ result =
95
+ `${result ?? ""}\n\nIts changes are in ${patch} (apply with \`git apply ${patch}\`).`.trim();
96
+ }
97
+ const changed = settleChild(childId, {
98
+ ...s,
99
+ result,
100
+ tokens: tokensOf(childId),
101
+ });
102
+ if (!changed) return false;
103
+ // Cancelled by its parent or the user: they know, so nothing is sent.
104
+ if (s.outcome === "cancelled") markDeliveredIds([childId]);
105
+ await removeFork(childId).catch(() => undefined);
106
+ const { admitQueued } = await import("./start");
107
+ await admitQueued(row.parent_session_id).catch(() => undefined);
108
+ const { kickForks } = await import("./tick");
109
+ kickForks();
110
+ return true;
111
+ }
112
+
113
+ // `aos return`. After settling it replaces the result, shown as updated.
114
+ export async function returnChild(
115
+ childId: string,
116
+ text: string,
117
+ failed: boolean
118
+ ): Promise<"settled" | "updated"> {
119
+ const row = getChild(childId);
120
+ if (!row) throw new Error("Only a fork child can return a result");
121
+ const body = text.trim();
122
+ if (!body) throw new Error('Give the result: aos return "<result>"');
123
+ const outcome: ChildOutcome = failed ? "failed" : "ok";
124
+ if (
125
+ await settleAndDeliver(childId, {
126
+ outcome,
127
+ summary: summaryOf(body),
128
+ result: body,
129
+ kind: "explicit",
130
+ })
131
+ )
132
+ return "settled";
133
+ updateResult(childId, summaryOf(body), body, outcome);
134
+ return "updated";
135
+ }
136
+
137
+ export async function cancelChild(childId: string): Promise<boolean> {
138
+ const row = getChild(childId);
139
+ if (!row || row.state === "settled") return false;
140
+ if (row.state !== "queued") stopChat(childId);
141
+ return settleAndDeliver(childId, {
142
+ outcome: "cancelled",
143
+ summary: "cancelled",
144
+ kind: "implicit",
145
+ });
146
+ }
147
+
148
+ export async function cancelChildren(
149
+ parentId: string,
150
+ ref?: string
151
+ ): Promise<ChildRow[]> {
152
+ const targets = ref
153
+ ? [findChild(parentId, ref)]
154
+ : childrenOf(parentId).filter((c) => c.state !== "settled");
155
+ const done: ChildRow[] = [];
156
+ for (const c of targets)
157
+ if (await cancelChild(c.child_session_id)) done.push(c);
158
+ return done;
159
+ }
160
+
161
+ const lastReply = (childId: string): string => {
162
+ const said = lastItems(childId, 40)
163
+ .reverse()
164
+ .find((i) => i.kind === "assistant" && i.text.trim());
165
+ return said?.kind === "assistant" ? said.text.trim() : "";
166
+ };
167
+
168
+ // One look at a child that hasn't settled: needing an answer, still working,
169
+ // gone, or idle long enough that its last reply is its result.
170
+ export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
171
+ if (row.state === "queued" || row.state === "settled") return;
172
+ const session = sessionOf(row.child_session_id);
173
+ if (!session || session.archived_at) {
174
+ await settleAndDeliver(row.child_session_id, {
175
+ outcome: "failed",
176
+ summary: "its session was closed before it returned",
177
+ kind: "implicit",
178
+ });
179
+ return;
180
+ }
181
+ const { status, activity } = await statusOf(session);
182
+ if (status === "waiting") {
183
+ setState(
184
+ row.child_session_id,
185
+ "needs_input",
186
+ activity ?? "waiting for an answer"
187
+ );
188
+ markIdle(row.child_session_id, null);
189
+ return;
190
+ }
191
+ if (status === "running" || status === "dead") {
192
+ if (row.state !== "running" || row.hold)
193
+ setState(row.child_session_id, "running");
194
+ markIdle(row.child_session_id, null);
195
+ return;
196
+ }
197
+ if (row.state === "starting") return;
198
+ if (row.state !== "running" || row.hold)
199
+ setState(row.child_session_id, "running");
200
+ if (row.idle_since == null) {
201
+ markIdle(row.child_session_id, now);
202
+ return;
203
+ }
204
+ if (now - row.idle_since < FORK_LIMITS.implicitGraceMs) return;
205
+ // It asked its parent something and is waiting for the answer.
206
+ if (openWaits().some((w) => w.waiter_id === row.child_session_id)) return;
207
+ const reply = lastReply(row.child_session_id);
208
+ if (!reply) return;
209
+ await settleAndDeliver(row.child_session_id, {
210
+ outcome: "ok",
211
+ summary: summaryOf(reply),
212
+ result: reply,
213
+ kind: "implicit",
214
+ });
215
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * What a child that can't have a copy of the conversation is told about
3
+ * where its parent stands: built from AgentOS's own records, the same pieces
4
+ * as the note after a compaction (lib/chat/compact-context).
5
+ */
6
+
7
+ import os from "node:os";
8
+ import { db, type Session } from "../db";
9
+ import { statusPorcelain } from "../git-exec";
10
+ import { lastItems } from "../chat/store";
11
+ import { editedFiles } from "../chat/working-state";
12
+
13
+ const EXCHANGES = 6;
14
+ const CLIP = 700;
15
+ const MAX = 10_000;
16
+
17
+ const clip = (text: string, max = CLIP) =>
18
+ text.length > max ? `${text.slice(0, max)}…` : text;
19
+
20
+ export async function parentSnapshot(parentId: string): Promise<string> {
21
+ const session = db
22
+ .prepare(
23
+ `SELECT name, working_directory, task_prompt FROM session_rows WHERE id = ?`
24
+ )
25
+ .get(parentId) as
26
+ Pick<Session, "name" | "working_directory" | "task_prompt"> | undefined;
27
+ if (!session) return "";
28
+ const parts: string[] = [`Parent session: ${session.name}`];
29
+ if (session.task_prompt?.trim())
30
+ parts.push(`## Its task\n${clip(session.task_prompt, 4_000)}`);
31
+ const said = lastItems(parentId, 60)
32
+ .filter((i) => i.kind === "user" || i.kind === "assistant")
33
+ .slice(-EXCHANGES * 2)
34
+ .map((i) =>
35
+ i.kind === "user"
36
+ ? `User: ${clip(i.text)}`
37
+ : i.kind === "assistant"
38
+ ? `Agent: ${clip(i.text)}`
39
+ : ""
40
+ )
41
+ .filter(Boolean);
42
+ if (said.length) parts.push(`## The latest exchanges\n${said.join("\n\n")}`);
43
+ const todos = lastItems(parentId, 1, "todos").find((i) => i.kind === "todos");
44
+ if (todos?.kind === "todos" && todos.todos.length)
45
+ parts.push(
46
+ `## Its todo list\n${todos.todos.map((t) => `- [${t.status}] ${t.text}`).join("\n")}`
47
+ );
48
+ const files = editedFiles(
49
+ lastItems(parentId, 400, "tool").filter((i) => i.kind === "tool")
50
+ );
51
+ if (files.length)
52
+ parts.push(
53
+ `## Files it was editing\n${files.map((f) => `- ${f}`).join("\n")}`
54
+ );
55
+ try {
56
+ const status = await statusPorcelain(
57
+ session.working_directory.replace(/^~/, os.homedir()),
58
+ { timeout: 3_000, maxBuffer: 64 * 1024 }
59
+ );
60
+ if (status.trim())
61
+ parts.push(
62
+ `## git status\n${status.split("\n").slice(0, 40).join("\n")}`
63
+ );
64
+ } catch {
65
+ // Not a repository, or git is slow: the rest stands.
66
+ }
67
+ const text = parts.join("\n\n");
68
+ return text.length > MAX ? `${text.slice(0, MAX)}\n… (cut)` : text;
69
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Starting forks: the ledger rows first (so a crash or a repeated request
3
+ * never doubles them), then each child's session in the background as the
4
+ * limits allow. A parent's first open child always starts, so a parent that
5
+ * holds a slot never waits on its own children.
6
+ */
7
+
8
+ import os from "os";
9
+ import { randomUUID } from "crypto";
10
+ import { db, queries, type Session } from "../db";
11
+ import type { AgentType } from "../providers";
12
+ import { launchSession } from "../sessions/launch";
13
+ import { sendChat } from "../chat/runner";
14
+ import { saveItem } from "../chat/store";
15
+ import { emit } from "../chat/registry";
16
+ import { runInBackground } from "../async-operations";
17
+ import { getProject } from "../projects";
18
+ import { limitOf, runningIn, startingIn } from "../tasks/queue-rows";
19
+ import { currentLevel, loadEnabled } from "../load/monitor";
20
+ import { childFirstMessage } from "./brief";
21
+ import { ForkError, forkConversation } from "./conversation";
22
+ import {
23
+ childrenOf,
24
+ getChild,
25
+ groupByRequest,
26
+ groupChildren,
27
+ insertGroup,
28
+ markFellBack,
29
+ newGroupId,
30
+ numberOf,
31
+ openChildren,
32
+ setCut,
33
+ setState,
34
+ } from "./ledger";
35
+ import { FORK_LIMITS, FORKS_OFF, forksOn, type DeliveryMode } from "./options";
36
+ import { ensureScratch, removeFork } from "./scratch";
37
+ import { parentSnapshot } from "./snapshot";
38
+ import { createForkWorktree } from "./worktree";
39
+ import type { ChildRow } from "./types";
40
+
41
+ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
42
+
43
+ const sessionOf = (id: string) =>
44
+ queries.getSession(db).get(id) as Session | undefined;
45
+
46
+ export interface ForkRequest {
47
+ parentId: string;
48
+ // The caller's key for this call; a repeat returns the same group.
49
+ requestId: string;
50
+ instructions: string[];
51
+ labels?: string[];
52
+ worktree?: boolean;
53
+ startedBy: "agent" | "user";
54
+ delivery?: DeliveryMode;
55
+ }
56
+
57
+ export interface ForkStarted {
58
+ groupId: string;
59
+ repeat: boolean;
60
+ children: { id: string; n: number; label: string }[];
61
+ }
62
+
63
+ // Why this session can't fork, or null.
64
+ export function refusal(parent: Session | undefined): string | null {
65
+ if (!forksOn()) return FORKS_OFF;
66
+ if (!parent) return "Unknown session";
67
+ if (getChild(parent.id)) return "A fork child can't fork";
68
+ if (parent.role === "orchestrator")
69
+ return "An orchestrator doesn't fork: start tasks or sessions instead";
70
+ if (parent.archived_at) return "That session is archived";
71
+ if (parent.view !== "chat") return "Forks work from a chat session";
72
+ if (parent.host_id && parent.host_id !== "local")
73
+ return "Forks run on this machine only";
74
+ return null;
75
+ }
76
+
77
+ const labelFor = (instruction: string, i: number) => {
78
+ const words = instruction.trim().split(/\s+/).slice(0, 5).join(" ");
79
+ return `${i + 1}. ${words.length > 40 ? `${words.slice(0, 39)}…` : words}`;
80
+ };
81
+
82
+ export function startFork(req: ForkRequest): ForkStarted {
83
+ const parent = sessionOf(req.parentId);
84
+ const refused = refusal(parent);
85
+ if (refused || !parent) throw new Error(refused ?? "Unknown session");
86
+ const again = groupByRequest(req.requestId);
87
+ if (again)
88
+ return {
89
+ groupId: again.id,
90
+ repeat: true,
91
+ children: groupChildren(again.id).map((c) => ({
92
+ id: c.child_session_id,
93
+ n: numberOf(parent.id, c.child_session_id),
94
+ label: c.label,
95
+ })),
96
+ };
97
+ const texts = req.instructions.map((t) => t.trim()).filter(Boolean);
98
+ if (!texts.length) throw new Error("Give each fork an instruction");
99
+ if (texts.length > FORK_LIMITS.maxPerFork)
100
+ throw new Error(`At most ${FORK_LIMITS.maxPerFork} forks at a time`);
101
+ if (texts.some((t) => t.length > FORK_LIMITS.instructionChars))
102
+ throw new Error(
103
+ `An instruction is over ${FORK_LIMITS.instructionChars} characters: put the detail in a file and point at it`
104
+ );
105
+ if (
106
+ openChildren(parent.id).length + texts.length >
107
+ FORK_LIMITS.maxOpenPerParent
108
+ )
109
+ throw new Error(
110
+ `${FORK_LIMITS.maxOpenPerParent} forks open at once is the most: wait for some to finish`
111
+ );
112
+ const claude = parent.agent_type === "claude";
113
+ // Only the Claude driver runs a guard, so any other agent's children get
114
+ // their own checkout instead.
115
+ const worktree = !claude || !!req.worktree;
116
+ const groupId = newGroupId();
117
+ const children = texts.map((instruction, i) => ({
118
+ childId: randomUUID(),
119
+ label: req.labels?.[i]?.trim() || labelFor(instruction, i),
120
+ instruction,
121
+ worktree,
122
+ }));
123
+ const noteId = `fork-${groupId}`;
124
+ insertGroup({
125
+ id: groupId,
126
+ parentId: parent.id,
127
+ requestId: req.requestId,
128
+ startedBy: req.startedBy,
129
+ context: claude ? "fork" : "brief",
130
+ delivery: req.delivery ?? (req.startedBy === "agent" ? "wake" : "next"),
131
+ forkItemId: noteId,
132
+ children,
133
+ });
134
+ const item = {
135
+ id: noteId,
136
+ kind: "note" as const,
137
+ tone: "note" as const,
138
+ createdAt: Date.now(),
139
+ text: `Forked ${children.length === 1 ? "1 copy" : `${children.length} copies`}: ${children.map((c) => c.label).join(" · ")}`,
140
+ };
141
+ saveItem(parent.id, item);
142
+ emit(parent.id, { type: "item", item });
143
+ const out = children.map((c) => ({
144
+ id: c.childId,
145
+ n: numberOf(parent.id, c.childId),
146
+ label: c.label,
147
+ }));
148
+ runInBackground(() => admitQueued(parent.id), `fork-admit-${groupId}`);
149
+ return { groupId, repeat: false, children: out };
150
+ }
151
+
152
+ const red = () => loadEnabled() && currentLevel() === "red";
153
+
154
+ // Whether one more child of this parent may start, when it has none open.
155
+ function mayStart(parent: Session, running: number): boolean {
156
+ if (running === 0) return true;
157
+ if (running >= FORK_LIMITS.maxRunningPerParent || red()) return false;
158
+ const project = getProject(parent.project_id ?? "");
159
+ const ws = project?.workspace_id ?? null;
160
+ const limit = limitOf(ws);
161
+ if (limit == null) return true;
162
+ const busy =
163
+ runningIn({ workspaceId: ws, projectId: parent.project_id ?? "" }, true)
164
+ .length + (ws ? startingIn(ws) : 0);
165
+ return busy < limit;
166
+ }
167
+
168
+ const STARTED = new Set<ChildRow["state"]>([
169
+ "starting",
170
+ "running",
171
+ "needs_input",
172
+ ]);
173
+
174
+ // Starts what the limits allow, oldest first. Called when forks are made,
175
+ // when one settles, and by the tick.
176
+ export async function admitQueued(parentId: string): Promise<void> {
177
+ const parent = sessionOf(parentId);
178
+ if (!parent || parent.archived_at) return;
179
+ const all = childrenOf(parentId);
180
+ let running = all.filter((c) => STARTED.has(c.state)).length;
181
+ for (const c of all) {
182
+ if (c.state !== "queued") continue;
183
+ if (!mayStart(parent, running)) break;
184
+ setState(c.child_session_id, "starting");
185
+ running++;
186
+ runInBackground(
187
+ () => startChild(c.child_session_id),
188
+ `fork-child-${c.child_session_id}`
189
+ );
190
+ }
191
+ }
192
+
193
+ async function startChild(childId: string): Promise<void> {
194
+ const row = getChild(childId);
195
+ if (!row) return;
196
+ const parent = sessionOf(row.parent_session_id);
197
+ if (!parent) return;
198
+ try {
199
+ const parentCwd = expand(parent.working_directory);
200
+ await ensureScratch(childId);
201
+ const checkout = row.worktree
202
+ ? await createForkWorktree({ childId, parentCwd })
203
+ : null;
204
+ const cwd = checkout?.cwd ?? parentCwd;
205
+ const n = numberOf(parent.id, childId);
206
+ await launchSession({
207
+ id: childId,
208
+ projectId: parent.project_id,
209
+ workingDirectory: checkout?.cwd ?? parent.working_directory,
210
+ agentType: parent.agent_type as AgentType,
211
+ model: parent.model,
212
+ access: parent.chat_access,
213
+ autoApprove: !!parent.auto_approve,
214
+ name: `${parent.name} · fork ${n}`,
215
+ view: "chat",
216
+ parentSessionId: parent.id,
217
+ startedVia: "session",
218
+ });
219
+ db.prepare(
220
+ `UPDATE sessions SET conductor_session_id = ?, model_provider = ? WHERE id = ?`
221
+ ).run(parent.id, parent.model_provider, childId);
222
+ let snapshot: string | undefined;
223
+ if (parent.agent_type === "claude" && parent.claude_session_id) {
224
+ try {
225
+ const forked = await forkConversation({
226
+ claudeSessionId: parent.claude_session_id,
227
+ parentCwd,
228
+ childCwd: cwd,
229
+ title: `fork ${n} of ${parent.name}`,
230
+ });
231
+ db.prepare(
232
+ `UPDATE sessions SET claude_session_id = ?, chat_resume_at = NULL WHERE id = ?`
233
+ ).run(forked.claudeSessionId, childId);
234
+ setCut(childId, forked.cut);
235
+ } catch (error) {
236
+ if (!(error instanceof ForkError)) throw error;
237
+ markFellBack(childId);
238
+ snapshot = await parentSnapshot(parent.id);
239
+ }
240
+ } else snapshot = await parentSnapshot(parent.id);
241
+ const instruction = row.instruction;
242
+ await sendChat(childId, {
243
+ id: `fork-start-${childId}`,
244
+ text: childFirstMessage({
245
+ parentName: parent.name,
246
+ n,
247
+ instruction,
248
+ worktree: !!row.worktree,
249
+ scratch: await ensureScratch(childId),
250
+ snapshot,
251
+ checkout: checkout?.root,
252
+ }),
253
+ from: "AgentOS",
254
+ origin: {
255
+ kind: "event",
256
+ label: `Fork ${n} of ${parent.name}`,
257
+ sessionId: parent.id,
258
+ body: instruction,
259
+ },
260
+ });
261
+ setState(childId, "running");
262
+ } catch (error) {
263
+ const why = error instanceof Error ? error.message : String(error);
264
+ const { settleAndDeliver } = await import("./settle");
265
+ await removeFork(childId).catch(() => undefined);
266
+ await settleAndDeliver(childId, {
267
+ outcome: "failed",
268
+ summary: `couldn't start: ${why.split("\n")[0]}`,
269
+ result: `Fork didn't start: ${why}`,
270
+ kind: "implicit",
271
+ });
272
+ }
273
+ }
@@ -0,0 +1,54 @@
1
+ /** The fork watcher: looks at open children, delivers results, runs waits. */
2
+
3
+ import { db } from "../db";
4
+ import { admitQueued } from "./start";
5
+ import { childrenOf } from "./ledger";
6
+ import { deliverDue } from "./deliver";
7
+ import { lookAtChild } from "./settle";
8
+ import { tickForkWaits } from "./waits";
9
+ import { forksOn } from "./options";
10
+ import type { ChildRow } from "./types";
11
+
12
+ const EVERY_MS = 5000;
13
+ let running = false;
14
+ let timer: NodeJS.Timeout | null = null;
15
+ let kicked = false;
16
+
17
+ export async function forksTick(now = Date.now()): Promise<void> {
18
+ if (running || !forksOn()) return;
19
+ running = true;
20
+ try {
21
+ const open = db
22
+ .prepare(`SELECT * FROM session_children WHERE state != 'settled'`)
23
+ .all() as ChildRow[];
24
+ for (const row of open) {
25
+ try {
26
+ await lookAtChild(row, now);
27
+ } catch (error) {
28
+ console.error(`[forks] child ${row.child_session_id}:`, error);
29
+ }
30
+ }
31
+ for (const parent of new Set(open.map((c) => c.parent_session_id)))
32
+ if (childrenOf(parent).some((c) => c.state === "queued"))
33
+ await admitQueued(parent);
34
+ await tickForkWaits(now);
35
+ await deliverDue(now);
36
+ } finally {
37
+ running = false;
38
+ }
39
+ }
40
+
41
+ export function kickForks(): void {
42
+ if (kicked) return;
43
+ kicked = true;
44
+ setImmediate(() => {
45
+ kicked = false;
46
+ void forksTick();
47
+ });
48
+ }
49
+
50
+ export function startForkWatcher(): void {
51
+ if (timer) return;
52
+ timer = setInterval(() => void forksTick(), EVERY_MS);
53
+ timer.unref();
54
+ }
@@ -0,0 +1,79 @@
1
+ export type ChildState =
2
+ "queued" | "starting" | "running" | "needs_input" | "settled";
3
+
4
+ export type ChildOutcome = "ok" | "failed" | "cancelled";
5
+
6
+ // `implicit`: the child's turn ended without `aos return`, so its final
7
+ // answer stands in (kept apart so stats can tell them).
8
+ export type ResultKind = "explicit" | "implicit";
9
+
10
+ export interface GroupRow {
11
+ id: string;
12
+ parent_session_id: string;
13
+ request_id: string;
14
+ started_by: "agent" | "user";
15
+ context: "fork" | "brief";
16
+ delivery: "wake" | "next";
17
+ fork_item_id: string | null;
18
+ created_at: number;
19
+ last_settled_at: number | null;
20
+ }
21
+
22
+ export interface ChildRow {
23
+ child_session_id: string;
24
+ parent_session_id: string;
25
+ group_id: string;
26
+ position: number;
27
+ label: string;
28
+ instruction: string;
29
+ worktree: number;
30
+ state: ChildState;
31
+ hold: string | null;
32
+ outcome: ChildOutcome | null;
33
+ summary: string | null;
34
+ result: string | null;
35
+ result_kind: ResultKind | null;
36
+ fell_back: number;
37
+ cut_message_id: string | null;
38
+ idle_since: number | null;
39
+ started_at: number | null;
40
+ settled_at: number | null;
41
+ tokens_in: number | null;
42
+ tokens_out: number | null;
43
+ cache_read: number | null;
44
+ heavy_runs: number;
45
+ delivered_at: number | null;
46
+ delivery_id: string | null;
47
+ used: "opened" | "mentioned" | null;
48
+ created_at: number;
49
+ }
50
+
51
+ // What a client shows for one child.
52
+ export interface ChildView {
53
+ id: string;
54
+ n: number;
55
+ group: string;
56
+ label: string;
57
+ instruction: string;
58
+ state: ChildState;
59
+ hold: string | null;
60
+ outcome: ChildOutcome | null;
61
+ summary: string | null;
62
+ resultKind: ResultKind | null;
63
+ worktree: boolean;
64
+ fellBack: boolean;
65
+ startedAt: number | null;
66
+ settledAt: number | null;
67
+ delivered: boolean;
68
+ // A question or approval it's waiting on.
69
+ asks: string | null;
70
+ }
71
+
72
+ export interface GroupView {
73
+ id: string;
74
+ delivery: "wake" | "next";
75
+ context: "fork" | "brief";
76
+ startedBy: "agent" | "user";
77
+ createdAt: number;
78
+ stats: string | null;
79
+ }