@saadnvd1/agent-os 0.3.853 → 0.3.859

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 (123) hide show
  1. package/app/api/leads/adopt/route.ts +35 -0
  2. package/app/api/leads/proposals/route.ts +52 -0
  3. package/app/api/leads/route.test.ts +95 -0
  4. package/app/api/leads/route.ts +60 -0
  5. package/app/api/sessions/[id]/fresh/route.ts +2 -1
  6. package/app/api/sessions/[id]/route.ts +9 -1
  7. package/app/api/tmux/kill-all/route.ts +2 -1
  8. package/app/api/workspaces/[id]/orchestrator/tools/route.test.ts +43 -0
  9. package/app/api/workspaces/[id]/orchestrator/tools/route.ts +14 -11
  10. package/components/Analyst/pair.ts +2 -1
  11. package/components/Chat/ChatPanel.tsx +1 -0
  12. package/components/Orchestrator/AskCard.tsx +6 -0
  13. package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
  14. package/components/Pane/index.tsx +5 -7
  15. package/components/Schedules/ScheduleForm.tsx +2 -1
  16. package/components/SessionList/RowSubtitle.tsx +2 -1
  17. package/components/SessionList/SessionRow.tsx +2 -1
  18. package/components/SessionList/useSidebarData.ts +8 -3
  19. package/content/whats-new.json +16 -0
  20. package/hooks/useNeedsYou.ts +10 -4
  21. package/hooks/useOrchestratorHeader.ts +2 -1
  22. package/lib/analyst/createLink.ts +3 -1
  23. package/lib/away/report.ts +2 -1
  24. package/lib/bus/format.ts +4 -1
  25. package/lib/bus/index.ts +9 -2
  26. package/lib/bus/read-scope.ts +4 -3
  27. package/lib/bus/resolve.ts +4 -1
  28. package/lib/bus/role-alias.ts +2 -1
  29. package/lib/bus/targets.ts +8 -3
  30. package/lib/bus/waits/tick.ts +2 -1
  31. package/lib/chat/compact-context.ts +2 -1
  32. package/lib/chat/mention-roots.ts +2 -1
  33. package/lib/chat/runner.ts +3 -2
  34. package/lib/chat/settings.ts +3 -2
  35. package/lib/chat/worker/extras.ts +4 -1
  36. package/lib/chat/worker/host.ts +6 -5
  37. package/lib/db/migrations/20261102T1944_leads.ts +142 -0
  38. package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
  39. package/lib/db/session-parts/columns.ts +2 -0
  40. package/lib/db/types.ts +10 -2
  41. package/lib/done/archive.ts +3 -2
  42. package/lib/done/auto-archive.ts +2 -1
  43. package/lib/done/bulk.ts +4 -3
  44. package/lib/done/index.ts +3 -2
  45. package/lib/done/plan.ts +5 -2
  46. package/lib/experiments/registry.ts +11 -0
  47. package/lib/experiments/view.ts +3 -1
  48. package/lib/fork/brief.ts +2 -2
  49. package/lib/fork/extras.ts +4 -2
  50. package/lib/fork/guard.ts +4 -2
  51. package/lib/fork/scratch.ts +102 -12
  52. package/lib/fork/settle.ts +31 -7
  53. package/lib/fork/start.ts +5 -4
  54. package/lib/fork/tick.ts +19 -0
  55. package/lib/fork/worktree.ts +7 -3
  56. package/lib/health/env/sendFixNotes.ts +3 -2
  57. package/lib/hosts/peer-actions.ts +2 -1
  58. package/lib/hosts/peer-sessions.ts +3 -1
  59. package/lib/leads/actor.ts +16 -0
  60. package/lib/leads/adopt.ts +150 -0
  61. package/lib/leads/area.ts +77 -0
  62. package/lib/leads/brief-common.ts +122 -0
  63. package/lib/leads/brief.ts +155 -0
  64. package/lib/leads/charters.ts +99 -0
  65. package/lib/leads/deliver.ts +24 -0
  66. package/lib/leads/digest.ts +166 -0
  67. package/lib/leads/direction.ts +202 -0
  68. package/lib/leads/home.ts +196 -0
  69. package/lib/leads/index.ts +8 -0
  70. package/lib/leads/memos.ts +70 -0
  71. package/lib/leads/messages.ts +112 -0
  72. package/lib/leads/profiles.ts +83 -0
  73. package/lib/leads/roles.ts +114 -0
  74. package/lib/leads/settings.ts +55 -0
  75. package/lib/leads/templates.ts +51 -0
  76. package/lib/leads/tools.ts +159 -0
  77. package/lib/leads/types.ts +88 -0
  78. package/lib/leads/usage.ts +46 -0
  79. package/lib/learning-cards/input.ts +2 -1
  80. package/lib/live/drive.ts +2 -1
  81. package/lib/models/launch.ts +2 -1
  82. package/lib/models/session-role.ts +2 -1
  83. package/lib/needs-you.ts +12 -6
  84. package/lib/notify/index.ts +2 -1
  85. package/lib/orchestrator/act.ts +3 -1
  86. package/lib/orchestrator/ask-tool.ts +10 -0
  87. package/lib/orchestrator/ask-view.ts +2 -0
  88. package/lib/orchestrator/asks.ts +48 -8
  89. package/lib/orchestrator/brief.ts +32 -67
  90. package/lib/orchestrator/digest.ts +3 -2
  91. package/lib/orchestrator/facts.ts +2 -1
  92. package/lib/orchestrator/full-access.ts +2 -1
  93. package/lib/orchestrator/home.ts +8 -7
  94. package/lib/orchestrator/mcp-servers.ts +7 -6
  95. package/lib/orchestrator/notes.ts +15 -4
  96. package/lib/orchestrator/overview.ts +3 -1
  97. package/lib/orchestrator/recipe-tools.ts +2 -1
  98. package/lib/orchestrator/reminders.ts +2 -1
  99. package/lib/orchestrator/tools.ts +43 -13
  100. package/lib/orchestrator/worker.ts +45 -13
  101. package/lib/orchestrator/write-guard.ts +2 -2
  102. package/lib/projects.ts +3 -2
  103. package/lib/resurrect/find.ts +2 -1
  104. package/lib/resurrect/index.ts +3 -2
  105. package/lib/schedules/index.ts +3 -2
  106. package/lib/schedules/start.ts +2 -1
  107. package/lib/schedules/store.ts +3 -2
  108. package/lib/session-audit/sources.ts +4 -3
  109. package/lib/session-groups/workspace.ts +2 -1
  110. package/lib/sessions/launch.ts +2 -1
  111. package/lib/sidebar/machines.ts +2 -1
  112. package/lib/sidebar/row-actions.ts +2 -1
  113. package/lib/sidebar/row-badge.ts +3 -1
  114. package/lib/sidebar/row-menu.ts +6 -3
  115. package/lib/sidebar/shelves.ts +3 -3
  116. package/lib/status/collect.ts +4 -3
  117. package/lib/tasks/held-replies.ts +2 -1
  118. package/lib/tasks/move-targets.ts +2 -1
  119. package/lib/tasks/pause.ts +2 -1
  120. package/lib/tasks/starter.ts +3 -2
  121. package/lib/workspaces.ts +4 -3
  122. package/lib/worktree-sweep/findSettled.ts +2 -1
  123. package/package.json +1 -1
package/lib/done/index.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  * its own action, archive.ts). What it will do is decided first (plan.ts).
9
9
  */
10
10
 
11
+ import { isStandingRole, RoleLabel } from "../leads/roles";
11
12
  import { endPauseOnFinish } from "../tasks/pause-hold";
12
13
  import { db, queries, type Session } from "../db";
13
14
  import { endSessionAgent } from "../sessions/end";
@@ -132,8 +133,8 @@ export async function doneSession(
132
133
  // A demo archives and nothing more: no merge, no agent to stop, no
133
134
  // worktree to touch.
134
135
  if (demoMode()) {
135
- if (s.role === "orchestrator")
136
- throw new Error("An orchestrator isn't marked done.");
136
+ if (isStandingRole(s.role))
137
+ throw new Error(`${RoleLabel(s.role)} isn't marked done.`);
137
138
  if (s.archived_at) throw new Error(`${s.name} is already archived.`);
138
139
  if (s.task_prompt) setTask(db, s.id, { task_status: "done" });
139
140
  archiveSession(s.id);
package/lib/done/plan.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * single done acts on it; the bulk preview shows it.
5
5
  */
6
6
 
7
+ import { isStandingRole, roleNoun } from "../leads/roles";
7
8
  import { db, stackQueries as sq, type Session } from "../db";
8
9
  import type { TaskPR } from "../tasks";
9
10
  import { prFor } from "../tasks/session";
@@ -90,8 +91,10 @@ export async function planDone(
90
91
  s: Session,
91
92
  callerId?: string | null
92
93
  ): Promise<DonePlan> {
93
- if (s.role === "orchestrator")
94
- return refuse(`${s.name} is a workspace's orchestrator; it can't be done`);
94
+ if (isStandingRole(s.role))
95
+ return refuse(
96
+ `${s.name} is a workspace's ${roleNoun(s.role)}; it can't be done`
97
+ );
95
98
  if (s.archived_at) return refuse(`${s.name} is already archived`);
96
99
  if (callerId && callerId === s.id)
97
100
  return refuse("A session can't mark itself done");
@@ -14,6 +14,8 @@ export interface Experiment {
14
14
  status: ExperimentStatus;
15
15
  // It shows suggestions, so an accept rate means something for it.
16
16
  suggests?: boolean;
17
+ // Built but not offered in Settings yet: it can be set, nothing lists it.
18
+ unlisted?: boolean;
17
19
  }
18
20
 
19
21
  export const EXPERIMENTS = [
@@ -34,6 +36,15 @@ export const EXPERIMENTS = [
34
36
  area: "sessions",
35
37
  status: "experimental",
36
38
  },
39
+ {
40
+ id: "leads",
41
+ name: "Leads",
42
+ description:
43
+ "Department heads and a chief of staff, per workspace. Off, nothing changes. On, you choose which workspaces get leads; each gets its own chief.",
44
+ area: "orchestrator",
45
+ status: "experimental",
46
+ unlisted: true,
47
+ },
37
48
  ] as const satisfies readonly Experiment[];
38
49
 
39
50
  export type ExperimentId = (typeof EXPERIMENTS)[number]["id"];
@@ -15,5 +15,7 @@ export const experimentView = (id: ExperimentId): ExperimentView => ({
15
15
 
16
16
  export const experimentsView = () => ({
17
17
  locked: experimentsLocked(),
18
- experiments: EXPERIMENTS.map((e) => experimentView(e.id)),
18
+ experiments: EXPERIMENTS.filter((e: Experiment) => !e.unlisted).map((e) =>
19
+ experimentView(e.id)
20
+ ),
19
21
  });
package/lib/fork/brief.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  import { FORK_LIMITS } from "./options";
4
4
  import type { ChildRow } from "./types";
5
5
 
6
- export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also 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.`;
6
+ export const PARENT_BRIEF = `Forks: when the user asks you to fork, or to do things in parallel in background copies, run \`aos fork "<instruction>" ...\` (one instruction per fork), keep working, and results arrive as a message. Also 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 checkout). Fork only work that doesn't depend on your next step.`;
7
7
 
8
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
9
 
@@ -22,7 +22,7 @@ export function childFirstMessage(opts: {
22
22
  opts.instruction.trim(),
23
23
  "",
24
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}.`
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}. Both are kept after you return.`
26
26
  : `You work in the parent's checkout: read freely, but don't change it. Scratch folder: ${opts.scratch} ($AOS_SCRATCH).`,
27
27
  "Finish with `aos return`.",
28
28
  ];
@@ -1,5 +1,6 @@
1
1
  /** What a chat worker adds for fork parents and children. */
2
2
 
3
+ import { isStandingRole } from "../leads/roles";
3
4
  import os from "os";
4
5
  import type { Session } from "../db";
5
6
  import type { ToolGuard } from "../chat/driver";
@@ -7,7 +8,7 @@ import { CHILD_BRIEF, PARENT_BRIEF } from "./brief";
7
8
  import { forkToolGuard } from "./guard";
8
9
  import { getChild } from "./ledger";
9
10
  import { forksOn } from "./options";
10
- import { scratchOf, worktreeOf } from "./scratch";
11
+ import { forksRoot, scratchOf, worktreeOf } from "./scratch";
11
12
  import { db } from "../db";
12
13
 
13
14
  const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
@@ -16,7 +17,7 @@ const expand = (p: string) => p.replace(/^~(?=$|\/)/, os.homedir());
16
17
  // parent, so their prompts match as far as they can).
17
18
  export function forkBrief(session: Session): string | null {
18
19
  if (getChild(session.id)) return CHILD_BRIEF;
19
- if (!forksOn() || session.role === "orchestrator" || session.view !== "chat")
20
+ if (!forksOn() || isStandingRole(session.role) || session.view !== "chat")
20
21
  return null;
21
22
  return PARENT_BRIEF;
22
23
  }
@@ -46,6 +47,7 @@ export function childExtras(
46
47
  parent?.working_directory ?? session.working_directory
47
48
  ),
48
49
  scratch,
50
+ forksRoot: forksRoot(),
49
51
  ownCheckout: child.worktree ? worktreeOf(session.id) : null,
50
52
  cwd: expand(session.working_directory),
51
53
  }),
package/lib/fork/guard.ts CHANGED
@@ -27,6 +27,8 @@ export interface ForkGuardContext {
27
27
  parentCheckout: string;
28
28
  // The child's scratch folder: free to write.
29
29
  scratch: string;
30
+ // Where every fork's folders live; the others' are never written.
31
+ forksRoot: string;
30
32
  // Its own throwaway checkout, when it has one: free to write.
31
33
  ownCheckout: string | null;
32
34
  // Where the child runs; relative paths start here.
@@ -39,7 +41,7 @@ const WHERE = (c: ForkGuardContext) =>
39
41
  : `put temp work in ${c.scratch} (AOS_SCRATCH), or ask for \`aos fork --worktree\` to edit code`;
40
42
 
41
43
  const noWrite = (c: ForkGuardContext, what: string) =>
42
- `Refused: ${what} would change the parent's checkout, which it is working in. ${WHERE(c)}.`;
44
+ `Refused: ${what} would change the parent's checkout or another fork's folder, which they are working in. ${WHERE(c)}.`;
43
45
 
44
46
  // Commands whose every path argument is something they change.
45
47
  const MUTATORS = new Set([
@@ -134,7 +136,7 @@ async function protectedPath(
134
136
  c.ownCheckout ? physicalPath(c.ownCheckout) : Promise.resolve(null),
135
137
  ]);
136
138
  if (within(r, scratch) || (own && within(r, own))) return false;
137
- return within(r, parent);
139
+ return within(r, parent) || within(r, await physicalPath(c.forksRoot));
138
140
  }
139
141
 
140
142
  // A file tool's refusal, or null.
@@ -1,14 +1,17 @@
1
1
  /**
2
2
  * A fork child's own folders under the data root: a scratch folder for temp
3
- * work (scripts, repros, notes), and, when it was asked for, a throwaway
4
- * checkout. Both are removed when the child settles or is dropped, and a
5
- * sweep removes what a crash left.
3
+ * work (scripts, repros, notes), and, when it was asked for, its own
4
+ * checkout. Settling a child removes nothing: they are kept while its
5
+ * session is open, and tidied up after it closes only when nothing in them
6
+ * would be lost. Deleting the child's session is the one thing that removes
7
+ * them whatever they hold.
6
8
  */
7
9
 
8
10
  import fs from "fs";
9
11
  import path from "path";
10
12
  import { agentosHome } from "../agentos-home";
11
13
  import { git } from "../git-exec";
14
+ import { describeChanges } from "../worktree-placed";
12
15
 
13
16
  export const forksRoot = () => agentosHome("forks");
14
17
  export const forkHome = (childId: string) => agentosHome("forks", childId);
@@ -17,6 +20,89 @@ export const scratchOf = (childId: string) =>
17
20
  export const worktreeOf = (childId: string) =>
18
21
  path.join(forkHome(childId), "wt");
19
22
 
23
+ // The tree the checkout started from (the parent's changes and what setup
24
+ // placed included), so what the child changed can be told from it.
25
+ export const baseOf = (childId: string) => path.join(forkHome(childId), "base");
26
+
27
+ const MAX_LISTED = 50;
28
+
29
+ // A tree of the checkout as it is now, taken through a temporary index so
30
+ // the child's own index is left alone. Ignored files aren't in it.
31
+ export async function snapshotTree(
32
+ wt: string,
33
+ childId: string
34
+ ): Promise<string> {
35
+ const index = path.join(forkHome(childId), "index.tmp");
36
+ const env = { GIT_INDEX_FILE: index };
37
+ try {
38
+ await git(wt, ["read-tree", "HEAD"], { env });
39
+ await git(wt, ["add", "-A", "--", ".", ":(exclude)node_modules"], { env });
40
+ return (await git(wt, ["write-tree"], { env })).trim();
41
+ } finally {
42
+ await fs.promises.rm(index, { force: true });
43
+ }
44
+ }
45
+
46
+ async function filesUnder(dir: string, rel = ""): Promise<string[]> {
47
+ const out: string[] = [];
48
+ const entries = await fs.promises
49
+ .readdir(path.join(dir, rel), { withFileTypes: true })
50
+ .catch(() => []);
51
+ for (const e of entries) {
52
+ if (out.length >= MAX_LISTED) break;
53
+ const r = path.join(rel, e.name);
54
+ if (e.isDirectory()) out.push(...(await filesUnder(dir, r)));
55
+ else out.push(r);
56
+ }
57
+ return out.slice(0, MAX_LISTED);
58
+ }
59
+
60
+ // What removing the child's folders would lose: files in its scratch, and
61
+ // changes in its checkout since it started. Null when that can't be told.
62
+ export async function forkLosses(childId: string): Promise<string[] | null> {
63
+ const lost = (await filesUnder(scratchOf(childId))).map(
64
+ (f) => `scratch/${f}`
65
+ );
66
+ const wt = worktreeOf(childId);
67
+ if (fs.existsSync(wt)) {
68
+ try {
69
+ const base = (
70
+ await fs.promises.readFile(baseOf(childId), "utf8").catch(() => "")
71
+ ).trim();
72
+ if (base) {
73
+ const now = await snapshotTree(wt, childId);
74
+ const names = await git(wt, ["diff", "--name-only", base, now]);
75
+ lost.push(...names.split("\n").filter(Boolean));
76
+ } else {
77
+ const st = await git(wt, ["status", "--porcelain"]);
78
+ lost.push(
79
+ ...st
80
+ .split("\n")
81
+ .filter(Boolean)
82
+ .map((l) => l.slice(3))
83
+ );
84
+ }
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+ return lost;
90
+ }
91
+
92
+ // Removes the child's folders only when nothing in them would be lost.
93
+ export async function tidyFork(childId: string): Promise<boolean> {
94
+ if (!fs.existsSync(forkHome(childId))) return true;
95
+ const lost = await forkLosses(childId);
96
+ if (lost?.length === 0) {
97
+ await removeFork(childId);
98
+ return true;
99
+ }
100
+ console.log(
101
+ `[forks] kept ${childId}: ${lost ? describeChanges(lost) : "it couldn't be checked"}`
102
+ );
103
+ return false;
104
+ }
105
+
20
106
  // Made on demand, so a settled child that is talked to again has one.
21
107
  export async function ensureScratch(childId: string): Promise<string> {
22
108
  const dir = scratchOf(childId);
@@ -50,18 +136,22 @@ export async function removeFork(childId: string): Promise<void> {
50
136
  await fs.promises.rm(home, { recursive: true, force: true, maxRetries: 3 });
51
137
  }
52
138
 
53
- // Folders of children that are settled or gone: what a crash between a
54
- // settle and its cleanup left. `keep` says whose folders are still in use.
139
+ // Folders of children whose session is closed or gone, tidied the same safe
140
+ // way: what a crash left, and children closed after they settled. `keep`
141
+ // says whose folders are still in use. Patches beside the folders stay.
55
142
  export async function sweepForks(
56
143
  keep: (childId: string) => boolean
57
- ): Promise<number> {
144
+ ): Promise<{ removed: number; kept: number }> {
58
145
  const root = forksRoot();
59
- const names = await fs.promises.readdir(root).catch(() => [] as string[]);
146
+ const entries = await fs.promises
147
+ .readdir(root, { withFileTypes: true })
148
+ .catch(() => []);
60
149
  let removed = 0;
61
- for (const name of names) {
62
- if (keep(name)) continue;
63
- await removeFork(name);
64
- removed++;
150
+ let kept = 0;
151
+ for (const e of entries) {
152
+ if (!e.isDirectory() || keep(e.name)) continue;
153
+ if (await tidyFork(e.name)) removed++;
154
+ else kept++;
65
155
  }
66
- return removed;
156
+ return { removed, kept };
67
157
  }
@@ -26,7 +26,15 @@ import {
26
26
  markDeliveredIds,
27
27
  } from "./ledger";
28
28
  import { FORK_LIMITS } from "./options";
29
- import { forksRoot, removeFork, worktreeOf } from "./scratch";
29
+ import {
30
+ baseOf,
31
+ forkLosses,
32
+ forksRoot,
33
+ scratchOf,
34
+ snapshotTree,
35
+ tidyFork,
36
+ worktreeOf,
37
+ } from "./scratch";
30
38
  import type { ChildOutcome, ChildRow, ResultKind } from "./types";
31
39
 
32
40
  const sessionOf = (id: string) =>
@@ -53,14 +61,17 @@ function tokensOf(childId: string) {
53
61
  export const patchPath = (childId: string) =>
54
62
  path.join(forksRoot(), `${childId}.patch`);
55
63
 
56
- // What a child changed in its own checkout, kept as a patch the parent can
57
- // apply: its checkout goes with it.
64
+ // What a child changed in its own checkout (not what the parent had when it
65
+ // started), kept as a patch the parent can apply.
58
66
  async function keepPatch(childId: string): Promise<string | null> {
59
67
  const wt = worktreeOf(childId);
60
68
  if (!fs.existsSync(wt)) return null;
61
69
  try {
62
- await git(wt, ["add", "-A"]);
63
- const diff = await git(wt, ["diff", "--cached", "HEAD", "--binary"], {
70
+ const base = (
71
+ await fs.promises.readFile(baseOf(childId), "utf8").catch(() => "")
72
+ ).trim();
73
+ const now = await snapshotTree(wt, childId);
74
+ const diff = await git(wt, ["diff", base || "HEAD", now, "--binary"], {
64
75
  maxBuffer: 64 * 1024 * 1024,
65
76
  });
66
77
  if (!diff.trim()) return null;
@@ -89,12 +100,19 @@ export async function settleAndDeliver(
89
100
  const row = getChild(childId);
90
101
  if (!row || row.state === "settled") return false;
91
102
  let result = s.result ?? null;
92
- if (row.worktree && s.outcome !== "cancelled") {
103
+ if (row.worktree) {
93
104
  const patch = await keepPatch(childId);
94
105
  if (patch)
95
106
  result =
96
107
  `${result ?? ""}\n\nIts changes are in ${patch} (apply with \`git apply ${patch}\`).`.trim();
97
108
  }
109
+ if (
110
+ (await forkLosses(childId).catch(() => null))?.some((f) =>
111
+ f.startsWith("scratch/")
112
+ )
113
+ )
114
+ result =
115
+ `${result ?? ""}\n\nIts scratch files are in ${scratchOf(childId)}.`.trim();
98
116
  const changed = settleChild(childId, {
99
117
  ...s,
100
118
  result,
@@ -103,7 +121,11 @@ export async function settleAndDeliver(
103
121
  if (!changed) return false;
104
122
  // Cancelled by its parent or the user: they know, so nothing is sent.
105
123
  if (s.outcome === "cancelled") markDeliveredIds([childId]);
106
- await removeFork(childId).catch(() => undefined);
124
+ // Its folders stay while its session is open; a closed one's go only
125
+ // when nothing in them would be lost.
126
+ const session = sessionOf(childId);
127
+ if (!session || session.archived_at)
128
+ await tidyFork(childId).catch(() => undefined);
107
129
  const { admitQueued } = await import("./start");
108
130
  await admitQueued(row.parent_session_id).catch(() => undefined);
109
131
  const { kickForks } = await import("./tick");
@@ -175,6 +197,8 @@ const lastReply = (childId: string): string => {
175
197
  export async function lookAtChild(row: ChildRow, now: number): Promise<void> {
176
198
  if (row.state === "queued" || row.state === "settled") return;
177
199
  const session = sessionOf(row.child_session_id);
200
+ // Its checkout is still being made: no session row yet.
201
+ if (!session && row.state === "starting") return;
178
202
  if (!session || session.archived_at) {
179
203
  await settleAndDeliver(row.child_session_id, {
180
204
  outcome: "failed",
package/lib/fork/start.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * holds a slot never waits on its own children.
6
6
  */
7
7
 
8
+ import { isStandingRole, RoleLabel } from "../leads/roles";
8
9
  import os from "os";
9
10
  import { randomUUID } from "crypto";
10
11
  import { db, queries, type Session } from "../db";
@@ -34,7 +35,7 @@ import {
34
35
  setState,
35
36
  } from "./ledger";
36
37
  import { FORK_LIMITS, FORKS_OFF, forksOn, type DeliveryMode } from "./options";
37
- import { ensureScratch, removeFork } from "./scratch";
38
+ import { ensureScratch, tidyFork } from "./scratch";
38
39
  import { parentSnapshot } from "./snapshot";
39
40
  import { createForkWorktree } from "./worktree";
40
41
  import type { ChildRow } from "./types";
@@ -66,8 +67,8 @@ export function refusal(parent: Session | undefined): string | null {
66
67
  if (!forksOn()) return FORKS_OFF;
67
68
  if (!parent) return "Unknown session";
68
69
  if (getChild(parent.id)) return "A fork child can't fork";
69
- if (parent.role === "orchestrator")
70
- return "An orchestrator doesn't fork: start tasks or sessions instead";
70
+ if (isStandingRole(parent.role))
71
+ return `${RoleLabel(parent.role)} doesn't fork: start tasks or sessions instead`;
71
72
  if (parent.archived_at) return "That session is archived";
72
73
  if (parent.view !== "chat") return "Forks work from a chat session";
73
74
  if (parent.host_id && parent.host_id !== "local")
@@ -266,7 +267,7 @@ async function startChild(childId: string): Promise<void> {
266
267
  } catch (error) {
267
268
  const why = error instanceof Error ? error.message : String(error);
268
269
  const { settleAndDeliver } = await import("./settle");
269
- await removeFork(childId).catch(() => undefined);
270
+ await tidyFork(childId).catch(() => undefined);
270
271
  await settleAndDeliver(childId, {
271
272
  outcome: "failed",
272
273
  summary: `couldn't start: ${why.split("\n")[0]}`,
package/lib/fork/tick.ts CHANGED
@@ -7,12 +7,30 @@ import { deliverDue } from "./deliver";
7
7
  import { lookAtChild } from "./settle";
8
8
  import { tickForkWaits } from "./waits";
9
9
  import { forksOn } from "./options";
10
+ import { sweepForks } from "./scratch";
10
11
  import type { ChildRow } from "./types";
11
12
 
12
13
  const EVERY_MS = 5000;
13
14
  let running = false;
14
15
  let timer: NodeJS.Timeout | null = null;
15
16
  let kicked = false;
17
+ const SWEEP_EVERY_MS = 10 * 60_000;
18
+ let sweptAt = 0;
19
+
20
+ // Folders of children whose session closed after they settled, or a crash.
21
+ async function sweepClosed(now: number): Promise<void> {
22
+ if (now - sweptAt < SWEEP_EVERY_MS) return;
23
+ sweptAt = now;
24
+ const live = db.prepare(
25
+ `SELECT 1 FROM session_children c
26
+ LEFT JOIN sessions s ON s.id = c.child_session_id
27
+ WHERE c.child_session_id = ?
28
+ AND (c.state != 'settled' OR (s.id IS NOT NULL AND s.archived_at IS NULL))`
29
+ );
30
+ await sweepForks((id) => !!live.get(id)).catch((error) =>
31
+ console.error("[forks] sweep:", error)
32
+ );
33
+ }
16
34
 
17
35
  export async function forksTick(now = Date.now()): Promise<void> {
18
36
  if (running || !forksOn()) return;
@@ -33,6 +51,7 @@ export async function forksTick(now = Date.now()): Promise<void> {
33
51
  await admitQueued(parent);
34
52
  await tickForkWaits(now);
35
53
  await deliverDue(now);
54
+ await sweepClosed(now);
36
55
  } finally {
37
56
  running = false;
38
57
  }
@@ -2,15 +2,15 @@
2
2
  * A throwaway checkout for a fork child that has to edit code to find its
3
3
  * answer (`aos fork --worktree`): detached at the parent's HEAD, with the
4
4
  * parent's uncommitted changes applied on top, so it sees what the parent
5
- * sees. No branch is made and nothing from it is merged; it is removed when
6
- * the child settles.
5
+ * sees. No branch is made and nothing from it is merged; it is kept until
6
+ * the child's session is closed with nothing changed in it.
7
7
  */
8
8
 
9
9
  import fs from "fs";
10
10
  import path from "path";
11
11
  import { git } from "../git-exec";
12
12
  import { setupWorktree } from "../env-setup";
13
- import { forkHome, worktreeOf } from "./scratch";
13
+ import { baseOf, forkHome, snapshotTree, worktreeOf } from "./scratch";
14
14
 
15
15
  const MAX_UNTRACKED = 200;
16
16
  const MAX_UNTRACKED_BYTES = 5_000_000;
@@ -70,6 +70,10 @@ export async function createForkWorktree(opts: {
70
70
  throw new Error(
71
71
  setup.steps.find((s) => !s.success)?.error ?? "worktree setup failed"
72
72
  );
73
+ await fs.promises.writeFile(
74
+ baseOf(opts.childId),
75
+ await snapshotTree(wt, opts.childId)
76
+ );
73
77
  return {
74
78
  cwd: path.join(wt, path.relative(top, cwd)),
75
79
  root: wt,
@@ -1,4 +1,5 @@
1
1
  import { db } from "@/lib/db";
2
+ import type { SessionRole } from "@/lib/db/types";
2
3
  import { MIN } from "../types";
3
4
  import { fixFor } from "./fixText";
4
5
  import { classById } from "./classes";
@@ -10,11 +11,11 @@ export const STALE_MS = 120 * MIN;
10
11
  export interface NoteDeps {
11
12
  send: (sessionId: string, body: string) => Promise<void>;
12
13
  // Waiting on a person: an approval or a question.
13
- blocked: (sessionId: string, role: "orchestrator" | null) => boolean;
14
+ blocked: (sessionId: string, role: SessionRole | null) => boolean;
14
15
  }
15
16
 
16
17
  interface Target {
17
- role: "orchestrator" | null;
18
+ role: SessionRole | null;
18
19
  archived_at: string | null;
19
20
  task_status: string | null;
20
21
  pause_state: string | null;
@@ -5,6 +5,7 @@
5
5
  * (the menu shows the same reasons: lib/hosts/remote-menu.ts).
6
6
  */
7
7
 
8
+ import { isPeerStanding } from "../leads/roles";
8
9
  import { db, type Session } from "../db";
9
10
  import { stopChat } from "../chat/runner";
10
11
  import { deleteItems } from "../chat/store";
@@ -151,7 +152,7 @@ export async function doneOnPeer(
151
152
  throw new Error(
152
153
  `${session.name} is a task on ${link.hostName}: sign it off or drop it in Tasks there.`
153
154
  );
154
- if (there.role)
155
+ if (isPeerStanding(there.role))
155
156
  throw new Error(`${session.name} isn't marked done on ${link.hostName}.`);
156
157
  const { outcome } = await hostApi<{
157
158
  outcome?: { text?: unknown; worktree?: { action?: unknown } };
@@ -5,6 +5,7 @@
5
5
  * used here are kept, checked and cleaned.
6
6
  */
7
7
 
8
+ import { isPeerStanding } from "../leads/roles";
8
9
  import type { TmuxSessionInfo } from "../status-detector";
9
10
  import { isValidTmuxName } from "./attach";
10
11
  import { syncPeerMirrors } from "./peer-sync";
@@ -113,7 +114,8 @@ export function toPeerSession(
113
114
  if (raw.host_id && raw.host_id !== "local") return null;
114
115
  // Its orchestrator and its tasks belong to its own workspace and gates:
115
116
  // they're not mirrored as plain sessions here.
116
- if (raw.role || raw.task_prompt || raw.task_status) return null;
117
+ if (isPeerStanding(raw.role) || raw.task_prompt || raw.task_status)
118
+ return null;
117
119
  const tmuxName = typeof raw.tmux_name === "string" ? raw.tmux_name : "";
118
120
  const view = raw.view === "chat" ? "chat" : "terminal";
119
121
  // A terminal is opened by its tmux name; any name it gives must be one.
@@ -0,0 +1,16 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ import type { Session } from "@/lib/db/types";
3
+
4
+ // The standing session whose tool call is running: what it sends, starts and
5
+ // notes is its own, not the orchestrator's. Outside a tool call (an event, a
6
+ // route, a timer) there is none, and everything stays the orchestrator's.
7
+ const acting = new AsyncLocalStorage<Session>();
8
+
9
+ export const asActor = <T>(session: Session, fn: () => T): T =>
10
+ acting.run(session, fn);
11
+
12
+ // The caller in this workspace, if a standing session's call is running.
13
+ export function actorIn(workspaceId: string): Session | undefined {
14
+ const s = acting.getStore();
15
+ return s && s.workspace_id === workspaceId ? s : undefined;
16
+ }