@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
@@ -0,0 +1,75 @@
1
+ /**
2
+ * A copy of a Claude conversation for a child: the SDK's forkSession, cut
3
+ * where lib/fork/point says, so the child knows what the parent knew and
4
+ * its prompt cache lines up. Claude finds a conversation only under the
5
+ * folder it starts in, so a child in another folder gets the copy moved.
6
+ */
7
+
8
+ import fs from "fs";
9
+ import os from "os";
10
+ import {
11
+ forkSession,
12
+ getSessionMessages,
13
+ } from "@anthropic-ai/claude-agent-sdk";
14
+ import {
15
+ readTranscript,
16
+ rewritePaths,
17
+ transcriptPath,
18
+ writeTranscript,
19
+ } from "../tasks/transcript";
20
+ import { cutPoint } from "./point";
21
+
22
+ export interface ForkedConversation {
23
+ // The new conversation's id: the child resumes it.
24
+ claudeSessionId: string;
25
+ // The entry of the parent's it was cut at.
26
+ cut: string;
27
+ }
28
+
29
+ export class ForkError extends Error {
30
+ override readonly name = "ForkError";
31
+ }
32
+
33
+ export async function forkConversation(opts: {
34
+ claudeSessionId: string;
35
+ parentCwd: string;
36
+ childCwd: string;
37
+ title?: string;
38
+ }): Promise<ForkedConversation> {
39
+ const { claudeSessionId, parentCwd, childCwd } = opts;
40
+ let cut: string | null;
41
+ try {
42
+ const entries = await getSessionMessages(claudeSessionId, {
43
+ dir: parentCwd,
44
+ });
45
+ cut = cutPoint(entries);
46
+ } catch (error) {
47
+ throw new ForkError(`can't read the conversation: ${String(error)}`);
48
+ }
49
+ if (!cut) throw new ForkError("nothing to fork yet");
50
+ let forked: string;
51
+ try {
52
+ forked = (
53
+ await forkSession(claudeSessionId, {
54
+ dir: parentCwd,
55
+ upToMessageId: cut,
56
+ title: opts.title,
57
+ })
58
+ ).sessionId;
59
+ } catch (error) {
60
+ throw new ForkError(`the fork failed: ${String(error)}`);
61
+ }
62
+ if (childCwd !== parentCwd) {
63
+ const text = await readTranscript(parentCwd, forked);
64
+ if (!text) throw new ForkError("the fork wasn't written");
65
+ const home = os.homedir();
66
+ await writeTranscript(
67
+ childCwd,
68
+ forked,
69
+ rewritePaths(text, { cwd: parentCwd, home }, { cwd: childCwd, home })
70
+ );
71
+ // The parent's folder keeps its own conversations only.
72
+ await fs.promises.rm(transcriptPath(parentCwd, forked), { force: true });
73
+ }
74
+ return { claudeSessionId: forked, cut };
75
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Handing results to the parent. They arrive as one message at a turn
3
+ * boundary (queued behind a running turn, waking an idle one), batched so
4
+ * a burst of children is one wake. Each delivery is claimed in the ledger
5
+ * under an id before it is sent, and the chat takes a repeated id as a
6
+ * no-op, so a crash between the two sends it once.
7
+ */
8
+
9
+ import { randomUUID } from "crypto";
10
+ import { db, queries, type Session } from "../db";
11
+ import { sendChat } from "../chat/runner";
12
+ import {
13
+ claimDelivery,
14
+ markDelivered,
15
+ openChildren,
16
+ parentsWithUndelivered,
17
+ undelivered,
18
+ unsettledWithDelivery,
19
+ } from "./ledger";
20
+ import { FORK_LIMITS } from "./options";
21
+ import { openForkWait } from "./waits";
22
+ import { viewOf, wakes } from "./held";
23
+ import type { ChildRow } from "./types";
24
+
25
+ const sessionOf = (id: string) =>
26
+ queries.getSession(db).get(id) as Session | undefined;
27
+
28
+ async function send(parentId: string, deliveryId: string, rows: ChildRow[]) {
29
+ const parent = sessionOf(parentId);
30
+ if (!parent || parent.archived_at) return;
31
+ const { text, body } = viewOf(parentId, rows);
32
+ await sendChat(parentId, {
33
+ id: deliveryId,
34
+ text,
35
+ queue: true,
36
+ from: "AgentOS",
37
+ origin: { kind: "event", label: "Forks", body },
38
+ });
39
+ markDelivered(deliveryId);
40
+ }
41
+
42
+ // Whether this batch has gathered long enough: quiet for the window, or
43
+ // the first one has waited the cap, or nothing else is still running.
44
+ function due(parentId: string, rows: ChildRow[], now: number): boolean {
45
+ if (!openChildren(parentId).length) return true;
46
+ const times = rows.map((c) => c.settled_at ?? now);
47
+ return (
48
+ now - Math.max(...times) >= FORK_LIMITS.batchWindowMs ||
49
+ now - Math.min(...times) >= FORK_LIMITS.batchCapMs
50
+ );
51
+ }
52
+
53
+ export async function deliverDue(now = Date.now()): Promise<void> {
54
+ for (const parentId of parentsWithUndelivered()) {
55
+ try {
56
+ // Sent but not marked: the same id again.
57
+ const claimed = unsettledWithDelivery(parentId);
58
+ if (claimed) {
59
+ await send(parentId, claimed, claimDelivery([], claimed));
60
+ continue;
61
+ }
62
+ // A wait delivers its own when it holds.
63
+ if (openForkWait(parentId)) continue;
64
+ const rows = undelivered(parentId).filter(wakes);
65
+ if (!rows.length || !due(parentId, rows, now)) continue;
66
+ const id = `fork-d-${randomUUID().slice(0, 8)}`;
67
+ await send(
68
+ parentId,
69
+ id,
70
+ claimDelivery(
71
+ rows.map((c) => c.child_session_id),
72
+ id
73
+ )
74
+ );
75
+ } catch (error) {
76
+ console.error(`[forks] delivery to ${parentId}:`, error);
77
+ }
78
+ }
79
+ }
80
+
81
+ // Sends these right now (a wait that held), marking them so the tick
82
+ // doesn't send them again.
83
+ export async function deliverNow(parentId: string, rows: ChildRow[]) {
84
+ if (!rows.length) return;
85
+ const id = `fork-d-${randomUUID().slice(0, 8)}`;
86
+ const claimed = claimDelivery(
87
+ rows.map((c) => c.child_session_id),
88
+ id
89
+ );
90
+ if (claimed.length) await send(parentId, id, claimed);
91
+ }
@@ -0,0 +1,53 @@
1
+ /** What a chat worker adds for fork parents and children. */
2
+
3
+ import os from "os";
4
+ import type { Session } from "../db";
5
+ import type { ToolGuard } from "../chat/driver";
6
+ import { CHILD_BRIEF, PARENT_BRIEF } from "./brief";
7
+ import { forkToolGuard } from "./guard";
8
+ import { getChild } from "./ledger";
9
+ import { forksOn } from "./options";
10
+ import { scratchOf, worktreeOf } from "./scratch";
11
+ import { db } from "../db";
12
+
13
+ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
14
+
15
+ // The brief a session gets last (after everything it shares with its
16
+ // parent, so their prompts match as far as they can).
17
+ export function forkBrief(session: Session): string | null {
18
+ if (getChild(session.id)) return CHILD_BRIEF;
19
+ if (!forksOn() || session.role === "orchestrator" || session.view !== "chat")
20
+ return null;
21
+ return PARENT_BRIEF;
22
+ }
23
+
24
+ // A child's environment and guard: its scratch folder, who it is, and the
25
+ // checks that keep it off its parent's checkout. Only the Claude driver
26
+ // runs a guard; other agents' children work in a checkout of their own.
27
+ export function childExtras(
28
+ session: Session
29
+ ): { env: Record<string, string>; toolGuard?: ToolGuard } | null {
30
+ const child = getChild(session.id);
31
+ if (!child) return null;
32
+ const parent = db
33
+ .prepare(`SELECT working_directory FROM sessions WHERE id = ?`)
34
+ .get(child.parent_session_id) as { working_directory: string } | undefined;
35
+ const scratch = scratchOf(session.id);
36
+ const env = {
37
+ AOS_FORK_CHILD: "1",
38
+ AOS_SCRATCH: scratch,
39
+ AOS_FORK_PARENT: child.parent_session_id,
40
+ };
41
+ if (session.agent_type !== "claude") return { env };
42
+ return {
43
+ env,
44
+ toolGuard: forkToolGuard({
45
+ parentCheckout: expand(
46
+ parent?.working_directory ?? session.working_directory
47
+ ),
48
+ scratch,
49
+ ownCheckout: child.worktree ? worktreeOf(session.id) : null,
50
+ cwd: expand(session.working_directory),
51
+ }),
52
+ };
53
+ }
@@ -0,0 +1,323 @@
1
+ /**
2
+ * What a fork child may not do (the checks run in its chat worker before
3
+ * every tool call, whatever its permissions say). It has its parent's tools
4
+ * and access; this only keeps it from changing the parent's checkout, which
5
+ * the parent keeps working in, and from anything that ships: no push, no
6
+ * PR, no fork of its own. Temp work goes in its scratch folder; a child
7
+ * that has to edit code gets its own throwaway checkout (`--worktree`).
8
+ *
9
+ * Like the orchestrator's guard it reads honest mistakes, not a determined
10
+ * bypass: an interpreter's inline code or a script can write anything.
11
+ */
12
+
13
+ import fs from "fs";
14
+ import path from "path";
15
+ import type { ToolGuard, ToolGuardResult } from "../chat/driver";
16
+ import { heavyLabel } from "../load/heavy";
17
+ import {
18
+ invocations,
19
+ positionals,
20
+ type Invocation,
21
+ } from "../orchestrator/shell-guard";
22
+ import { parseShell, UNKNOWN } from "../orchestrator/shell-parse";
23
+ import { physicalPath, WRITE_TOOLS, within } from "../orchestrator/write-guard";
24
+
25
+ export interface ForkGuardContext {
26
+ // The parent's checkout: never changed from here.
27
+ parentCheckout: string;
28
+ // The child's scratch folder: free to write.
29
+ scratch: string;
30
+ // Its own throwaway checkout, when it has one: free to write.
31
+ ownCheckout: string | null;
32
+ // Where the child runs; relative paths start here.
33
+ cwd: string;
34
+ }
35
+
36
+ const WHERE = (c: ForkGuardContext) =>
37
+ c.ownCheckout
38
+ ? `work in your own checkout (${c.ownCheckout}) or ${c.scratch}`
39
+ : `put temp work in ${c.scratch} (AOS_SCRATCH), or ask for \`aos fork --worktree\` to edit code`;
40
+
41
+ const noWrite = (c: ForkGuardContext, what: string) =>
42
+ `Refused: ${what} would change the parent's checkout, which it is working in. ${WHERE(c)}.`;
43
+
44
+ // Commands whose every path argument is something they change.
45
+ const MUTATORS = new Set([
46
+ "rm",
47
+ "rmdir",
48
+ "mv",
49
+ "touch",
50
+ "mkdir",
51
+ "ln",
52
+ "install",
53
+ "truncate",
54
+ "chmod",
55
+ "chown",
56
+ "tee",
57
+ "unlink",
58
+ "shred",
59
+ "patch",
60
+ "ditto",
61
+ ]);
62
+ const GIT_CHANGES_TREE = new Set([
63
+ "add",
64
+ "commit",
65
+ "checkout",
66
+ "switch",
67
+ "reset",
68
+ "restore",
69
+ "stash",
70
+ "apply",
71
+ "merge",
72
+ "rebase",
73
+ "clean",
74
+ "cherry-pick",
75
+ "revert",
76
+ "rm",
77
+ "mv",
78
+ "pull",
79
+ "am",
80
+ "bisect",
81
+ "submodule",
82
+ ]);
83
+ const GIT_CHANGES_REFS = new Set([
84
+ "update-ref",
85
+ "symbolic-ref",
86
+ "gc",
87
+ "prune",
88
+ "worktree",
89
+ "replace",
90
+ "filter-branch",
91
+ "stash",
92
+ ]);
93
+ const INSTALLS = new Set([
94
+ "install",
95
+ "i",
96
+ "add",
97
+ "ci",
98
+ "remove",
99
+ "rm",
100
+ "uninstall",
101
+ "update",
102
+ "up",
103
+ "link",
104
+ "dedupe",
105
+ "prune",
106
+ ]);
107
+ const PACKAGE_MANAGERS = new Set(["npm", "pnpm", "yarn", "bun"]);
108
+
109
+ const expand = (word: string, ctx: ForkGuardContext) =>
110
+ word
111
+ .replaceAll("${AOS_SCRATCH}", ctx.scratch)
112
+ .replaceAll("$AOS_SCRATCH", ctx.scratch);
113
+
114
+ async function real(p: string, base: string): Promise<string | null> {
115
+ if (p.includes(UNKNOWN)) return null;
116
+ try {
117
+ return await physicalPath(path.resolve(base, p));
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ // Whether a path is somewhere the child may not write.
124
+ async function protectedPath(
125
+ p: string,
126
+ base: string,
127
+ c: ForkGuardContext
128
+ ): Promise<boolean> {
129
+ const r = await real(p, base);
130
+ if (!r) return false;
131
+ const [parent, scratch, own] = await Promise.all([
132
+ physicalPath(c.parentCheckout),
133
+ physicalPath(c.scratch),
134
+ c.ownCheckout ? physicalPath(c.ownCheckout) : Promise.resolve(null),
135
+ ]);
136
+ if (within(r, scratch) || (own && within(r, own))) return false;
137
+ return within(r, parent);
138
+ }
139
+
140
+ // A file tool's refusal, or null.
141
+ export async function fileRefusal(
142
+ c: ForkGuardContext,
143
+ tool: string,
144
+ input: Record<string, unknown>
145
+ ): Promise<ToolGuardResult> {
146
+ const arg = WRITE_TOOLS[tool];
147
+ if (!arg) return null;
148
+ const file = input[arg];
149
+ if (typeof file !== "string") return "No file path to check.";
150
+ return (await protectedPath(file, c.cwd, c))
151
+ ? noWrite(c, `${tool} on ${file}`)
152
+ : null;
153
+ }
154
+
155
+ const gitSub = (args: string[]) => {
156
+ const rest = [...args];
157
+ while (rest.length) {
158
+ const a = rest[0];
159
+ if (a === "-C" || a === "-c" || a === "--git-dir" || a === "--work-tree")
160
+ rest.splice(0, 2);
161
+ else if (a.startsWith("-")) rest.shift();
162
+ else break;
163
+ }
164
+ return rest;
165
+ };
166
+
167
+ const gitDir = (args: string[], cwd: string): string => {
168
+ const i = args.indexOf("-C");
169
+ return i >= 0 && args[i + 1] ? path.resolve(cwd, args[i + 1]) : cwd;
170
+ };
171
+
172
+ async function invocationRefusal(
173
+ inv: Invocation,
174
+ cwd: string,
175
+ c: ForkGuardContext
176
+ ): Promise<string | null> {
177
+ const { name, args } = inv;
178
+ const pos = positionals(args);
179
+ if (name === "aos") {
180
+ if (pos[0] === "fork")
181
+ return "Refused: a fork can't start forks. Do the work yourself, or say in your return what should be split off.";
182
+ if (pos[0] === "heavy") return null;
183
+ }
184
+ if (name === "git" || name === "gh") {
185
+ if (name === "git" && pos[0] === "push")
186
+ return "Refused: a fork never pushes. Report what you found in `aos return`.";
187
+ if (
188
+ name === "gh" &&
189
+ pos[0] === "pr" &&
190
+ /^(create|merge|close|edit|ready|reopen|review|comment)$/.test(
191
+ pos[1] ?? ""
192
+ )
193
+ )
194
+ return "Refused: a fork never opens or changes a PR. Report what you found in `aos return`.";
195
+ }
196
+ const heavy = heavyLabel([name, ...args].join(" "));
197
+ if (heavy)
198
+ return `Refused: a whole ${heavy} run goes through \`aos heavy -- <command>\`, which queues fork runs so several forks don't load the machine at once.`;
199
+
200
+ if (name === "git") {
201
+ const sub = gitSub(args);
202
+ const verb = sub[0] ?? "";
203
+ const dir = gitDir(args, cwd);
204
+ if (
205
+ GIT_CHANGES_REFS.has(verb) ||
206
+ (verb === "branch" &&
207
+ !sub
208
+ .slice(1)
209
+ .every((a) =>
210
+ /^(-l|--list|-a|-r|-v|-vv|--show-current|--contains|--merged|--no-merged)$/.test(
211
+ a
212
+ )
213
+ )) ||
214
+ (verb === "tag" &&
215
+ sub.length > 1 &&
216
+ !sub.slice(1).every((a) => /^(-l|--list|-n\d*)$/.test(a)))
217
+ )
218
+ return `Refused: git ${verb} would change the repository's shared state. Read with log, show, diff and status; ${WHERE(c)}.`;
219
+ if (GIT_CHANGES_TREE.has(verb) && (await protectedPath(".", dir, c)))
220
+ return noWrite(c, `git ${verb}`);
221
+ }
222
+ if (PACKAGE_MANAGERS.has(name)) {
223
+ const verb =
224
+ args[0] === "run" || args[0]?.startsWith("-") ? "" : (args[0] ?? "");
225
+ if (INSTALLS.has(verb)) {
226
+ const prefix = args.findIndex(
227
+ (a) => a === "--prefix" || a === "-C" || a === "--cwd"
228
+ );
229
+ const dir =
230
+ prefix >= 0 && args[prefix + 1]
231
+ ? path.resolve(cwd, args[prefix + 1])
232
+ : cwd;
233
+ if (await protectedPath(".", dir, c))
234
+ return noWrite(c, `${name} ${verb}`);
235
+ if (c.ownCheckout && within(dir, c.ownCheckout)) {
236
+ const linked = await fs.promises
237
+ .lstat(path.join(c.ownCheckout, "node_modules"))
238
+ .then(
239
+ (s) => s.isSymbolicLink(),
240
+ () => false
241
+ );
242
+ if (linked)
243
+ return `Refused: ${name} ${verb} here would write through the link to the parent's packages. Say in your return which packages you needed.`;
244
+ }
245
+ }
246
+ }
247
+ if (
248
+ /^(vitest|jest|npx|pnpm|yarn|npm|bun)$/.test(name) &&
249
+ args.some((a) =>
250
+ /^(--update|-u|--update-snapshot|--updateSnapshot)$/.test(a)
251
+ ) &&
252
+ (await protectedPath(".", cwd, c))
253
+ )
254
+ return noWrite(c, "updating snapshots");
255
+ if (MUTATORS.has(name)) {
256
+ for (const t of pos)
257
+ if (await protectedPath(expand(t, c), cwd, c))
258
+ return noWrite(c, `${name} ${t}`);
259
+ }
260
+ if (name === "cp" && pos.length > 1) {
261
+ const dest = pos[pos.length - 1];
262
+ if (await protectedPath(expand(dest, c), cwd, c))
263
+ return noWrite(c, `cp to ${dest}`);
264
+ }
265
+ if (name === "sed" && args.some((a) => /^(-[a-zA-Z]*i|--in-place)/.test(a))) {
266
+ for (const t of pos.slice(1))
267
+ if (await protectedPath(expand(t, c), cwd, c))
268
+ return noWrite(c, `sed -i ${t}`);
269
+ }
270
+ if (name === "perl" && args.some((a) => /^-[a-zA-Z]*i/.test(a))) {
271
+ for (const t of pos)
272
+ if (await protectedPath(expand(t, c), cwd, c))
273
+ return noWrite(c, `perl -i ${t}`);
274
+ }
275
+ if (name === "dd") {
276
+ const of = args.find((a) => a.startsWith("of="));
277
+ if (of && (await protectedPath(of.slice(3), cwd, c)))
278
+ return noWrite(c, "dd");
279
+ }
280
+ return null;
281
+ }
282
+
283
+ // A shell command line's refusal, or null.
284
+ export async function shellRefusal(
285
+ c: ForkGuardContext,
286
+ command: unknown
287
+ ): Promise<string | null> {
288
+ if (typeof command !== "string" || !command.trim())
289
+ return "Refused: no command to check.";
290
+ const parsed = parseShell(expand(command, c));
291
+ if (typeof parsed === "string") return `Refused: ${parsed}.`;
292
+ let cwd = c.cwd;
293
+ for (const cmd of parsed) {
294
+ const invs = invocations(cmd);
295
+ if (typeof invs === "string") return `Refused: ${invs}.`;
296
+ for (const f of cmd.writes) {
297
+ if (f === "/dev/null" || f.startsWith("/dev/fd/")) continue;
298
+ if (await protectedPath(f, cwd, c))
299
+ return noWrite(c, `redirecting to ${f}`);
300
+ }
301
+ for (const inv of invs) {
302
+ if (inv.name === "cd" || inv.name === "pushd") {
303
+ const to = positionals(inv.args)[0];
304
+ cwd = !to
305
+ ? cwd
306
+ : to.includes(UNKNOWN) || to === "-"
307
+ ? cwd
308
+ : path.resolve(cwd, to);
309
+ continue;
310
+ }
311
+ const why = await invocationRefusal(inv, cwd, c);
312
+ if (why) return why;
313
+ }
314
+ }
315
+ return null;
316
+ }
317
+
318
+ export function forkToolGuard(c: ForkGuardContext): ToolGuard {
319
+ return async (tool, input) => {
320
+ if (tool === "Bash") return shellRefusal(c, input.command);
321
+ return fileRefusal(c, tool, input);
322
+ };
323
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Slots for the builds and test runs of fork children. `aos heavy` from a
3
+ * fork child asks for one and waits its turn, so N children never run N
4
+ * suites at once, and none start while the machine's load reads red.
5
+ * First come, first served; a lease the child's CLI stopped renewing (or
6
+ * whose process is gone) is taken back.
7
+ */
8
+
9
+ import { currentLevel, loadEnabled } from "../load/monitor";
10
+ import { bumpHeavy } from "./ledger";
11
+ import { FORK_LIMITS } from "./options";
12
+
13
+ interface Lease {
14
+ sessionId: string;
15
+ pid: number;
16
+ askedAt: number;
17
+ seen: number;
18
+ grantedAt: number | null;
19
+ }
20
+
21
+ // A holder that hasn't asked again within this long is gone.
22
+ export const LEASE_STALE_MS = 30_000;
23
+
24
+ const g = globalThis as unknown as { __agentosForkLeases?: Lease[] };
25
+ const leases = (g.__agentosForkLeases ??= []);
26
+
27
+ const alive = (pid: number) => {
28
+ try {
29
+ process.kill(pid, 0);
30
+ return true;
31
+ } catch (error) {
32
+ return (error as NodeJS.ErrnoException).code === "EPERM";
33
+ }
34
+ };
35
+
36
+ export function slotsNow(): number {
37
+ if (!loadEnabled()) return FORK_LIMITS.heavySlotsCalm;
38
+ return currentLevel() === "green" || currentLevel() === null
39
+ ? FORK_LIMITS.heavySlotsCalm
40
+ : FORK_LIMITS.heavySlots;
41
+ }
42
+
43
+ function reap(now: number) {
44
+ for (let i = leases.length - 1; i >= 0; i--) {
45
+ const l = leases[i];
46
+ if (now - l.seen > LEASE_STALE_MS || !alive(l.pid)) leases.splice(i, 1);
47
+ }
48
+ }
49
+
50
+ function grant(now: number) {
51
+ const held = leases.filter((l) => l.grantedAt !== null).length;
52
+ const room = Math.max(0, slotsNow() - held);
53
+ if (!room) return;
54
+ // No new run while the machine is loaded; what already runs finishes.
55
+ if (loadEnabled() && currentLevel() === "red") return;
56
+ let n = room;
57
+ for (const l of leases) {
58
+ if (n === 0) break;
59
+ if (l.grantedAt === null) {
60
+ l.grantedAt = now;
61
+ n--;
62
+ bumpHeavy(l.sessionId);
63
+ }
64
+ }
65
+ }
66
+
67
+ export interface LeaseState {
68
+ granted: boolean;
69
+ // Runs queued before this one, or running in its way.
70
+ ahead: number;
71
+ }
72
+
73
+ // Asks for a slot, or renews the one held; the same caller asks again until
74
+ // it is granted.
75
+ export function requestLease(
76
+ sessionId: string,
77
+ pid: number,
78
+ now = Date.now()
79
+ ): LeaseState {
80
+ reap(now);
81
+ let mine = leases.find((l) => l.sessionId === sessionId && l.pid === pid);
82
+ if (mine) mine.seen = now;
83
+ else
84
+ leases.push(
85
+ (mine = { sessionId, pid, askedAt: now, seen: now, grantedAt: null })
86
+ );
87
+ grant(now);
88
+ if (mine.grantedAt !== null) return { granted: true, ahead: 0 };
89
+ return {
90
+ granted: false,
91
+ ahead: leases.filter(
92
+ (l) => l !== mine && (l.grantedAt !== null || l.askedAt <= mine!.askedAt)
93
+ ).length,
94
+ };
95
+ }
96
+
97
+ export function releaseLease(sessionId: string, pid: number): void {
98
+ const i = leases.findIndex((l) => l.sessionId === sessionId && l.pid === pid);
99
+ if (i >= 0) leases.splice(i, 1);
100
+ grant(Date.now());
101
+ }
102
+
103
+ export const leaseCount = () => leases.length;
104
+ export const resetLeases = () => void leases.splice(0, leases.length);
@@ -0,0 +1,26 @@
1
+ import { deliveryText } from "./brief";
2
+ import { getGroup, markDeliveredIds, numberOf, undelivered } from "./ledger";
3
+ import type { ChildRow } from "./types";
4
+
5
+ // A delivery as the parent's agent reads it, and as chat shows it.
6
+ export const viewOf = (parentId: string, rows: ChildRow[]) => ({
7
+ text: deliveryText(rows, (c) => numberOf(parentId, c.child_session_id)),
8
+ body: rows
9
+ .map(
10
+ (c) =>
11
+ `Fork ${numberOf(parentId, c.child_session_id)} (${c.label}): ${c.summary ?? c.outcome}`
12
+ )
13
+ .join("\n"),
14
+ });
15
+
16
+ // Results that are the user's to see first: held for their next message.
17
+ export const wakes = (c: ChildRow) => getGroup(c.group_id)?.delivery !== "next";
18
+
19
+ // The user's own forks report in their next message to the parent: the
20
+ // results come first in it, then what they wrote.
21
+ export function heldForNext(parentId: string): string {
22
+ const rows = undelivered(parentId).filter((c) => !wakes(c));
23
+ if (!rows.length) return "";
24
+ markDeliveredIds(rows.map((c) => c.child_session_id));
25
+ return `${viewOf(parentId, rows).text}\n\n`;
26
+ }