@saadnvd1/agent-os 0.3.851 → 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 (128) 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/Chat/Forks.tsx +24 -2
  13. package/components/Orchestrator/AskCard.tsx +6 -0
  14. package/components/Orchestrator/NeedsYouStrip.tsx +7 -2
  15. package/components/Pane/index.tsx +5 -7
  16. package/components/Schedules/ScheduleForm.tsx +2 -1
  17. package/components/SessionList/RowSubtitle.tsx +2 -1
  18. package/components/SessionList/SessionRow.tsx +2 -1
  19. package/components/SessionList/useDoneAction.ts +8 -2
  20. package/components/SessionList/useSidebarData.ts +8 -3
  21. package/content/whats-new.json +24 -0
  22. package/data/done/index.ts +7 -1
  23. package/hooks/useNeedsYou.ts +10 -4
  24. package/hooks/useOrchestratorHeader.ts +2 -1
  25. package/lib/analyst/createLink.ts +3 -1
  26. package/lib/away/report.ts +2 -1
  27. package/lib/bus/format.ts +4 -1
  28. package/lib/bus/index.ts +9 -2
  29. package/lib/bus/read-scope.ts +4 -3
  30. package/lib/bus/resolve.ts +4 -1
  31. package/lib/bus/role-alias.ts +2 -1
  32. package/lib/bus/targets.ts +8 -3
  33. package/lib/bus/waits/tick.ts +2 -1
  34. package/lib/chat/compact-context.ts +2 -1
  35. package/lib/chat/mention-roots.ts +2 -1
  36. package/lib/chat/runner.ts +3 -2
  37. package/lib/chat/settings.ts +3 -2
  38. package/lib/chat/worker/extras.ts +4 -1
  39. package/lib/chat/worker/host.ts +6 -5
  40. package/lib/db/migrations/20261102T1944_leads.ts +142 -0
  41. package/lib/db/migrations/20261102T2300_needs_you_chat_per_session.ts +24 -0
  42. package/lib/db/session-parts/columns.ts +2 -0
  43. package/lib/db/types.ts +10 -2
  44. package/lib/done/archive.ts +3 -2
  45. package/lib/done/auto-archive.ts +2 -1
  46. package/lib/done/bulk.ts +4 -3
  47. package/lib/done/index.ts +3 -2
  48. package/lib/done/plan.ts +5 -2
  49. package/lib/experiments/registry.ts +11 -0
  50. package/lib/experiments/view.ts +3 -1
  51. package/lib/fork/brief.ts +2 -2
  52. package/lib/fork/extras.ts +4 -2
  53. package/lib/fork/guard.ts +4 -2
  54. package/lib/fork/scratch.ts +102 -12
  55. package/lib/fork/settle.ts +31 -7
  56. package/lib/fork/start.ts +5 -4
  57. package/lib/fork/tick.ts +19 -0
  58. package/lib/fork/types.ts +2 -0
  59. package/lib/fork/view.ts +10 -0
  60. package/lib/fork/worktree.ts +7 -3
  61. package/lib/health/env/sendFixNotes.ts +3 -2
  62. package/lib/hosts/peer-actions.ts +2 -1
  63. package/lib/hosts/peer-sessions.ts +3 -1
  64. package/lib/leads/actor.ts +16 -0
  65. package/lib/leads/adopt.ts +150 -0
  66. package/lib/leads/area.ts +77 -0
  67. package/lib/leads/brief-common.ts +122 -0
  68. package/lib/leads/brief.ts +155 -0
  69. package/lib/leads/charters.ts +99 -0
  70. package/lib/leads/deliver.ts +24 -0
  71. package/lib/leads/digest.ts +166 -0
  72. package/lib/leads/direction.ts +202 -0
  73. package/lib/leads/home.ts +196 -0
  74. package/lib/leads/index.ts +8 -0
  75. package/lib/leads/memos.ts +70 -0
  76. package/lib/leads/messages.ts +112 -0
  77. package/lib/leads/profiles.ts +83 -0
  78. package/lib/leads/roles.ts +114 -0
  79. package/lib/leads/settings.ts +55 -0
  80. package/lib/leads/templates.ts +51 -0
  81. package/lib/leads/tools.ts +159 -0
  82. package/lib/leads/types.ts +88 -0
  83. package/lib/leads/usage.ts +46 -0
  84. package/lib/learning-cards/input.ts +2 -1
  85. package/lib/live/drive.ts +2 -1
  86. package/lib/models/launch.ts +2 -1
  87. package/lib/models/session-role.ts +2 -1
  88. package/lib/needs-you.ts +12 -6
  89. package/lib/notify/index.ts +2 -1
  90. package/lib/orchestrator/act.ts +3 -1
  91. package/lib/orchestrator/ask-tool.ts +10 -0
  92. package/lib/orchestrator/ask-view.ts +2 -0
  93. package/lib/orchestrator/asks.ts +48 -8
  94. package/lib/orchestrator/brief.ts +32 -67
  95. package/lib/orchestrator/digest.ts +3 -2
  96. package/lib/orchestrator/facts.ts +2 -1
  97. package/lib/orchestrator/full-access.ts +2 -1
  98. package/lib/orchestrator/home.ts +8 -7
  99. package/lib/orchestrator/mcp-servers.ts +7 -6
  100. package/lib/orchestrator/notes.ts +15 -4
  101. package/lib/orchestrator/overview.ts +3 -1
  102. package/lib/orchestrator/recipe-tools.ts +2 -1
  103. package/lib/orchestrator/reminders.ts +2 -1
  104. package/lib/orchestrator/tools.ts +43 -13
  105. package/lib/orchestrator/worker.ts +45 -13
  106. package/lib/orchestrator/write-guard.ts +2 -2
  107. package/lib/projects.ts +3 -2
  108. package/lib/resurrect/find.ts +2 -1
  109. package/lib/resurrect/index.ts +3 -2
  110. package/lib/schedules/index.ts +3 -2
  111. package/lib/schedules/start.ts +2 -1
  112. package/lib/schedules/store.ts +3 -2
  113. package/lib/session-audit/sources.ts +4 -3
  114. package/lib/session-groups/workspace.ts +2 -1
  115. package/lib/sessions/launch.ts +2 -1
  116. package/lib/sidebar/machines.ts +2 -1
  117. package/lib/sidebar/row-actions.ts +2 -1
  118. package/lib/sidebar/row-badge.ts +3 -1
  119. package/lib/sidebar/row-menu.ts +6 -3
  120. package/lib/sidebar/shelves.ts +3 -3
  121. package/lib/status/collect.ts +4 -3
  122. package/lib/tasks/held-replies.ts +2 -1
  123. package/lib/tasks/move-targets.ts +2 -1
  124. package/lib/tasks/pause.ts +2 -1
  125. package/lib/tasks/starter.ts +3 -2
  126. package/lib/workspaces.ts +4 -3
  127. package/lib/worktree-sweep/findSettled.ts +2 -1
  128. package/package.json +1 -1
@@ -0,0 +1,142 @@
1
+ import type { Migration } from "../migration-types";
2
+ import { createSessionRowsView } from "../session-parts/view";
3
+
4
+ const columnsOf = (db: Parameters<Migration["up"]>[0], table: string) =>
5
+ (db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(
6
+ (c) => c.name
7
+ );
8
+
9
+ export default {
10
+ key: "20261102T1944_leads",
11
+ up: (db) => {
12
+ // Leads (lib/leads): a lead is a standing chat like the orchestrator,
13
+ // with role 'lead' and a lead_kind; the orchestrator is the 'tech' lead
14
+ // once its workspace adds leads, and a workspace has one 'chief'.
15
+ // lead_since is when it became one, so its usage counts from then.
16
+ // Additive: a workspace without leads has none of these rows.
17
+ const sessions = columnsOf(db, "sessions");
18
+ if (!sessions.includes("lead_kind"))
19
+ db.exec(`ALTER TABLE sessions ADD COLUMN lead_kind TEXT`);
20
+ if (!sessions.includes("lead_since"))
21
+ db.exec(`ALTER TABLE sessions ADD COLUMN lead_since TEXT`);
22
+ createSessionRowsView(db);
23
+ db.exec(
24
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_lead
25
+ ON sessions(workspace_id, lead_kind) WHERE role = 'lead'`
26
+ );
27
+ db.exec(
28
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_chief
29
+ ON sessions(workspace_id) WHERE role = 'chief'`
30
+ );
31
+
32
+ // A row means the workspace added leads. The chief's autonomy starts at
33
+ // acting only when asked; a later setting lifts it without code.
34
+ db.exec(`
35
+ CREATE TABLE IF NOT EXISTS workspace_leads (
36
+ workspace_id TEXT PRIMARY KEY,
37
+ enabled_at TEXT NOT NULL DEFAULT (datetime('now')),
38
+ chief_autonomy TEXT NOT NULL DEFAULT 'on_request'
39
+ CHECK (chief_autonomy IN ('on_request', 'acts'))
40
+ )
41
+ `);
42
+ db.exec(`
43
+ CREATE TABLE IF NOT EXISTS lead_charters (
44
+ workspace_id TEXT NOT NULL,
45
+ lead_kind TEXT NOT NULL,
46
+ title TEXT NOT NULL,
47
+ area_text TEXT NOT NULL DEFAULT '',
48
+ charter_md TEXT NOT NULL DEFAULT '',
49
+ goal TEXT NOT NULL DEFAULT '',
50
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
51
+ PRIMARY KEY (workspace_id, lead_kind)
52
+ )
53
+ `);
54
+ db.exec(`
55
+ CREATE TABLE IF NOT EXISTS lead_areas (
56
+ workspace_id TEXT NOT NULL,
57
+ lead_kind TEXT NOT NULL,
58
+ kind TEXT NOT NULL CHECK (kind IN ('project', 'path', 'board')),
59
+ value TEXT NOT NULL,
60
+ PRIMARY KEY (workspace_id, lead_kind, kind, value)
61
+ )
62
+ `);
63
+ // Pinned direction quotes a chat item the person wrote; a newer pin
64
+ // supersedes an older one and nothing is deleted.
65
+ db.exec(`
66
+ CREATE TABLE IF NOT EXISTS lead_direction (
67
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68
+ workspace_id TEXT NOT NULL,
69
+ lead_kind TEXT NOT NULL,
70
+ session_id TEXT NOT NULL,
71
+ chat_item_id TEXT NOT NULL,
72
+ quote TEXT NOT NULL,
73
+ set_at TEXT NOT NULL DEFAULT (datetime('now')),
74
+ superseded_by INTEGER
75
+ )
76
+ `);
77
+ db.exec(
78
+ `CREATE INDEX IF NOT EXISTS idx_lead_direction
79
+ ON lead_direction(workspace_id, lead_kind, id)`
80
+ );
81
+ // Proposed pins (an adopted lead's, or a lead's suggestion) wait here
82
+ // until the person confirms or drops them.
83
+ db.exec(`
84
+ CREATE TABLE IF NOT EXISTS lead_pin_proposals (
85
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
86
+ workspace_id TEXT NOT NULL,
87
+ lead_kind TEXT NOT NULL,
88
+ session_id TEXT NOT NULL,
89
+ chat_item_id TEXT NOT NULL,
90
+ quote TEXT NOT NULL,
91
+ status TEXT NOT NULL DEFAULT 'open'
92
+ CHECK (status IN ('open', 'pinned', 'dropped')),
93
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
94
+ resolved_at TEXT
95
+ )
96
+ `);
97
+ db.exec(
98
+ `CREATE UNIQUE INDEX IF NOT EXISTS idx_lead_pin_proposals_open
99
+ ON lead_pin_proposals(session_id, chat_item_id) WHERE status = 'open'`
100
+ );
101
+ db.exec(`
102
+ CREATE TABLE IF NOT EXISTS lead_memos (
103
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
104
+ workspace_id TEXT NOT NULL,
105
+ lead_kind TEXT NOT NULL,
106
+ text TEXT NOT NULL,
107
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
108
+ )
109
+ `);
110
+ db.exec(
111
+ `CREATE INDEX IF NOT EXISTS idx_lead_memos
112
+ ON lead_memos(workspace_id, lead_kind, id)`
113
+ );
114
+ db.exec(`
115
+ CREATE TABLE IF NOT EXISTS lead_messages (
116
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
117
+ workspace_id TEXT NOT NULL,
118
+ from_kind TEXT NOT NULL,
119
+ to_kind TEXT NOT NULL,
120
+ thread TEXT NOT NULL,
121
+ text TEXT NOT NULL,
122
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
123
+ read_at TEXT
124
+ )
125
+ `);
126
+ db.exec(
127
+ `CREATE INDEX IF NOT EXISTS idx_lead_messages_to
128
+ ON lead_messages(workspace_id, to_kind, id)`
129
+ );
130
+ db.exec(
131
+ `CREATE INDEX IF NOT EXISTS idx_lead_messages_thread
132
+ ON lead_messages(workspace_id, thread, id)`
133
+ );
134
+
135
+ // The decision log and asks per lead; every existing row is Tech's.
136
+ for (const table of ["orchestrator_notes", "orchestrator_asks"])
137
+ if (!columnsOf(db, table).includes("lead_kind"))
138
+ db.exec(
139
+ `ALTER TABLE ${table} ADD COLUMN lead_kind TEXT NOT NULL DEFAULT 'tech'`
140
+ );
141
+ },
142
+ } satisfies Migration;
@@ -0,0 +1,24 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261102T2300_needs_you_chat_per_session",
5
+ up: (db) => {
6
+ // The Needs-you "waiting in chat" item was keyed per workspace
7
+ // (approval:<workspace id>), so one lead's waiting chat would hide
8
+ // another's. It is keyed per session now; a hidden one stays hidden on
9
+ // the workspace's orchestrator, the only chat that showed it. Safe to
10
+ // repeat: a renamed key no longer matches.
11
+ db.exec(`
12
+ UPDATE OR IGNORE needs_you_dismissed
13
+ SET key = 'approval:' || (
14
+ SELECT s.id FROM sessions s
15
+ WHERE s.role = 'orchestrator'
16
+ AND s.workspace_id = needs_you_dismissed.workspace_id)
17
+ WHERE key = 'approval:' || workspace_id
18
+ AND EXISTS (
19
+ SELECT 1 FROM sessions s
20
+ WHERE s.role = 'orchestrator'
21
+ AND s.workspace_id = needs_you_dismissed.workspace_id)
22
+ `);
23
+ },
24
+ } satisfies Migration;
@@ -186,4 +186,6 @@ export const SESSION_ROW_COLUMNS: readonly string[] = [
186
186
  "session_group_id",
187
187
  "agent_role",
188
188
  "crew_id",
189
+ "lead_kind",
190
+ "lead_since",
189
191
  ];
package/lib/db/types.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import type { AgentType } from "../providers";
2
2
  import type { ChatAccess } from "../chat/events";
3
3
 
4
+ // The standing chats a workspace can have (lib/leads/roles.ts).
5
+ export type SessionRole = "orchestrator" | "lead" | "chief";
6
+
4
7
  export interface Session {
5
8
  id: string;
6
9
  name: string;
@@ -118,8 +121,13 @@ export interface Session {
118
121
  // The agent role it was started in (lib/models/agent-roles) and its crew.
119
122
  agent_role?: string | null;
120
123
  crew_id?: string | null;
121
- // A workspace's standing orchestrator chat (and that workspace), or null.
122
- role: "orchestrator" | null;
124
+ // A workspace's standing chat (and that workspace), or null: its
125
+ // orchestrator, and with Leads its other leads and chief (lib/leads).
126
+ role: SessionRole | null;
127
+ // A lead's department ('tech' for the orchestrator once leads are added)
128
+ // and when it became one.
129
+ lead_kind?: string | null;
130
+ lead_since?: string | null;
123
131
  // An orchestrator's tool-call secret.
124
132
  orch_token?: string | null;
125
133
  workspace_id: string | null;
@@ -1,6 +1,7 @@
1
1
  // Archived sessions: out of the sidebar, the needs-you count and the
2
2
  // orchestrator's view, but kept with their history and chat items.
3
3
 
4
+ import { isStandingRole, roleNoun } from "../leads/roles";
4
5
  import fs from "fs";
5
6
  import { db, queries, type Session } from "../db";
6
7
  import { statusOf } from "../orchestrator/facts";
@@ -102,9 +103,9 @@ export async function archiveChecked(
102
103
  const s = queries.getSession(db).get(id) as Session | undefined;
103
104
  if (!s) throw new Error("Session not found");
104
105
  if (s.archived_at) throw new Error(`${s.name} is already archived`);
105
- if (s.role === "orchestrator")
106
+ if (isStandingRole(s.role))
106
107
  throw new Error(
107
- `${s.name} is a workspace's orchestrator; it can't be archived`
108
+ `${s.name} is a workspace's ${roleNoun(s.role)}; it can't be archived`
108
109
  );
109
110
  if (peerSessionLink(s))
110
111
  throw new Error(`${s.name} runs on another machine: archive it there`);
@@ -3,6 +3,7 @@
3
3
  // session, an orchestrator, one that needs input or is working, or one with
4
4
  // an open PR. Mirrors the sidebar's Done placement (lib/sidebar/shelves).
5
5
 
6
+ import { sqlNoRole } from "../leads/roles";
6
7
  import { db, type Session } from "../db";
7
8
  import { fromSqliteTime } from "../session-meta";
8
9
  import { statusOf } from "../orchestrator/facts";
@@ -34,7 +35,7 @@ export async function archiveStaleDone(now = Date.now()): Promise<string[]> {
34
35
  const rows = db
35
36
  .prepare(
36
37
  `SELECT * FROM session_rows
37
- WHERE archived_at IS NULL AND pinned = 0 AND role IS NULL
38
+ WHERE archived_at IS NULL AND pinned = 0 AND ${sqlNoRole()}
38
39
  AND peer_mirror = 0 AND quick_of IS NULL
39
40
  AND (pr_status IS NULL OR pr_status != 'open')`
40
41
  )
package/lib/done/bulk.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * The preview says what will happen to each before anything does.
6
6
  */
7
7
 
8
+ import { sqlNoRole, isStandingRole, roleLabel } from "../leads/roles";
8
9
  import { db, type Session } from "../db";
9
10
  import { getProject } from "../projects";
10
11
  import { workspaceProjects } from "../orchestrator/brief";
@@ -52,7 +53,7 @@ function scopeSessions(scope: DoneScope): Session[] {
52
53
  .prepare(
53
54
  `SELECT * FROM session_rows
54
55
  WHERE project_id IN (${projectIds.map(() => "?").join(", ")})
55
- AND role IS NULL AND archived_at IS NULL
56
+ AND ${sqlNoRole()} AND archived_at IS NULL
56
57
  -- A linked machine's own sessions are cleaned up there.
57
58
  AND peer_mirror = 0
58
59
  -- A task another machine's orchestrator moved here is its to finish.
@@ -80,9 +81,9 @@ export async function idleSessions(
80
81
  export function scopeOf(session: Session): DoneScope {
81
82
  // An orchestrator acts through its tools, which Pause holds and which are
82
83
  // logged; aos would reach the same sessions past both.
83
- if (session.role === "orchestrator")
84
+ if (isStandingRole(session.role))
84
85
  throw new Error(
85
- `${session.name} is an orchestrator: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
86
+ `${session.name} is ${roleLabel(session.role)}: it moves, finishes and stops sessions with its move, done and stop tools, not aos`
86
87
  );
87
88
  const project = session.project_id ? getProject(session.project_id) : null;
88
89
  if (!project || project.is_uncategorized)
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",