@saadnvd1/agent-os 0.3.700 → 0.3.723

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 (306) hide show
  1. package/README.md +2 -0
  2. package/app/api/agent-roles/route.test.ts +67 -0
  3. package/app/api/agent-roles/route.ts +61 -0
  4. package/app/api/analyst/[id]/recommendations/[recId]/again/route.ts +15 -0
  5. package/app/api/analyst/[id]/recommendations/[recId]/dismiss/route.ts +13 -0
  6. package/app/api/analyst/[id]/recommendations/[recId]/send/route.ts +13 -0
  7. package/app/api/analyst/[id]/review/route.ts +22 -0
  8. package/app/api/analyst/[id]/route.ts +44 -0
  9. package/app/api/analyst/route.ts +10 -0
  10. package/app/api/bus/groups/route.ts +44 -0
  11. package/app/api/bus/spawn/route.ts +34 -2
  12. package/app/api/claims/[id]/route.ts +20 -0
  13. package/app/api/claims/check/route.ts +23 -0
  14. package/app/api/claims/check.test.ts +113 -0
  15. package/app/api/claims/exchange/route.ts +42 -0
  16. package/app/api/claims/outside.test.ts +90 -0
  17. package/app/api/claims/route.test.ts +193 -0
  18. package/app/api/claims/route.ts +73 -0
  19. package/app/api/hosts/[id]/route.ts +9 -1
  20. package/app/api/models/availability/route.ts +9 -0
  21. package/app/api/models/choices/route.ts +9 -0
  22. package/app/api/peer/unlink/route.ts +17 -0
  23. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  24. package/app/api/peers/[hostId]/route.ts +20 -0
  25. package/app/api/peers/gate.test.ts +64 -1
  26. package/app/api/peers/pending/route.test.ts +37 -0
  27. package/app/api/peers/pending/route.ts +11 -4
  28. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  29. package/app/api/session-groups/[id]/route.ts +25 -0
  30. package/app/api/session-groups/order/route.ts +15 -0
  31. package/app/api/session-groups/route.ts +21 -0
  32. package/app/api/sessions/[id]/analyst/route.ts +11 -0
  33. package/app/api/sessions/[id]/crew/route.ts +38 -0
  34. package/app/api/sessions/[id]/export/estimate/route.ts +23 -0
  35. package/app/api/sessions/[id]/export/route.test.ts +125 -0
  36. package/app/api/sessions/[id]/export/route.ts +34 -0
  37. package/app/api/sessions/[id]/fork/route.ts +3 -0
  38. package/app/api/sessions/[id]/group/route.ts +18 -0
  39. package/app/api/sessions/[id]/model/route.ts +49 -0
  40. package/app/api/sessions/[id]/route.ts +14 -4
  41. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  42. package/app/api/sessions/claims.test.ts +122 -0
  43. package/app/api/sessions/group/route.ts +17 -0
  44. package/app/api/sessions/route.ts +31 -14
  45. package/app/page.tsx +10 -0
  46. package/components/Chat/Messages.tsx +104 -10
  47. package/components/Claims/ClaimFiles.tsx +38 -0
  48. package/components/Claims/ClaimRow.tsx +82 -0
  49. package/components/Claims/ClaimsSheet.tsx +176 -0
  50. package/components/Claims/OverlapPill.tsx +31 -0
  51. package/components/Claims/StartClaimsNotice.tsx +70 -0
  52. package/components/Claims/claims-ui.test.ts +161 -0
  53. package/components/Claims/format.ts +57 -0
  54. package/components/Claims/index.ts +3 -0
  55. package/components/Draft/DraftChips.tsx +6 -1
  56. package/components/Draft/DraftPanel.tsx +90 -3
  57. package/components/Draft/ModelUnavailableDialog.tsx +78 -0
  58. package/components/Draft/RoleChips.tsx +110 -0
  59. package/components/Export/ExportActions.tsx +136 -0
  60. package/components/Export/ExportSheet.tsx +217 -0
  61. package/components/Export/RangePicker.tsx +144 -0
  62. package/components/Export/Seg.tsx +38 -0
  63. package/components/Export/TurnSelectBar.tsx +45 -0
  64. package/components/Hosts/AccountMachines.tsx +32 -18
  65. package/components/Hosts/HostRow.tsx +155 -14
  66. package/components/Hosts/IncomingLinkSheet.tsx +28 -10
  67. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  68. package/components/Models/AgentRoleCard.tsx +114 -0
  69. package/components/Models/AgentRolesSection.tsx +132 -0
  70. package/components/Models/ModelBadges.tsx +48 -0
  71. package/components/Models/ModelsSection.tsx +4 -2
  72. package/components/Models/RoleModelSelect.tsx +87 -0
  73. package/components/Pane/DesktopTabBar.tsx +2 -0
  74. package/components/Pane/MobileTabBar.tsx +12 -0
  75. package/components/Pane/PaneMenu.tsx +10 -0
  76. package/components/SessionList/GroupShelves.tsx +195 -0
  77. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  78. package/components/SessionList/RowSubtitle.tsx +7 -1
  79. package/components/SessionList/SelectionToolbar.tsx +52 -1
  80. package/components/SessionList/SessionRow.tsx +19 -7
  81. package/components/SessionList/SessionRowMenu.tsx +23 -0
  82. package/components/SessionList/SessionShelves.tsx +5 -0
  83. package/components/SessionList/SidebarBody.tsx +6 -0
  84. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  85. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  86. package/components/SessionList/SidebarHeader.tsx +5 -0
  87. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  88. package/components/SessionList/index.tsx +18 -14
  89. package/components/SessionList/useSidebarData.ts +10 -0
  90. package/components/Sessions/RolePill.tsx +196 -0
  91. package/components/Settings/SettingsDialog.tsx +9 -0
  92. package/components/Share/ShareDialog.tsx +13 -0
  93. package/components/ui/notice.tsx +46 -0
  94. package/components/ui/status-pill.tsx +39 -0
  95. package/data/analyst/index.ts +3 -0
  96. package/data/analyst/keys.ts +6 -0
  97. package/data/analyst/mutations.ts +116 -0
  98. package/data/analyst/queries.ts +35 -0
  99. package/data/claims/index.ts +2 -0
  100. package/data/claims/keys.ts +5 -0
  101. package/data/claims/queries.ts +47 -0
  102. package/data/exports/index.ts +2 -0
  103. package/data/exports/keys.ts +3 -0
  104. package/data/exports/queries.ts +51 -0
  105. package/data/models/index.ts +98 -0
  106. package/data/peers/keys.ts +1 -0
  107. package/data/peers/queries.ts +31 -0
  108. package/data/push/topics.ts +9 -0
  109. package/data/session-groups/index.ts +156 -0
  110. package/data/sessions/index.ts +0 -1
  111. package/data/sessions/pin.ts +2 -6
  112. package/data/sessions/queries.ts +36 -29
  113. package/data/sessions/shelf.ts +2 -6
  114. package/data/tasks/index.ts +2 -0
  115. package/hooks/useSessions.ts +0 -12
  116. package/lib/__fixtures__/peer-rpc.ts +65 -33
  117. package/lib/agents/brief.ts +1 -0
  118. package/lib/agents/spawn.ts +4 -0
  119. package/lib/analyst/__fixtures__/items.ts +57 -0
  120. package/lib/analyst/__fixtures__/mocks.ts +49 -0
  121. package/lib/analyst/brief.ts +17 -0
  122. package/lib/analyst/buildDigest.ts +204 -0
  123. package/lib/analyst/createLink.ts +98 -0
  124. package/lib/analyst/endLink.ts +31 -0
  125. package/lib/analyst/forward.ts +109 -0
  126. package/lib/analyst/index.ts +16 -0
  127. package/lib/analyst/linkView.ts +38 -0
  128. package/lib/analyst/onTurnEnd.ts +119 -0
  129. package/lib/analyst/parseReview.ts +41 -0
  130. package/lib/analyst/pushDigest.ts +143 -0
  131. package/lib/analyst/resume.ts +35 -0
  132. package/lib/analyst/route.ts +50 -0
  133. package/lib/analyst/signals.ts +121 -0
  134. package/lib/analyst/store.ts +303 -0
  135. package/lib/analyst/types.ts +140 -0
  136. package/lib/bus/condense.ts +5 -1
  137. package/lib/bus/index.ts +13 -0
  138. package/lib/bus/read-scope.ts +12 -0
  139. package/lib/bus/role-alias.ts +70 -0
  140. package/lib/bus/targets.ts +3 -0
  141. package/lib/chat/drivers/opencode-mapper.ts +55 -1
  142. package/lib/chat/drivers/pi-approvals.ts +7 -4
  143. package/lib/chat/drivers/pi-mapper.ts +38 -0
  144. package/lib/chat/drivers/pi-text-tools.ts +104 -0
  145. package/lib/chat/drivers/pi.ts +8 -3
  146. package/lib/chat/drivers/turn-items.ts +36 -11
  147. package/lib/chat/drivers/turn-outcome.ts +108 -0
  148. package/lib/chat/events.ts +8 -0
  149. package/lib/chat/registry.ts +13 -0
  150. package/lib/chat/store.ts +19 -0
  151. package/lib/chat/turns.ts +1 -0
  152. package/lib/chat/worker/extras.ts +4 -2
  153. package/lib/claims/collectLocal.ts +123 -0
  154. package/lib/claims/describe.ts +41 -0
  155. package/lib/claims/exchange.ts +91 -0
  156. package/lib/claims/goal.ts +45 -0
  157. package/lib/claims/index.ts +10 -0
  158. package/lib/claims/loop.ts +51 -0
  159. package/lib/claims/machine.ts +21 -0
  160. package/lib/claims/mine.ts +43 -0
  161. package/lib/claims/notifyOverlaps.ts +101 -0
  162. package/lib/claims/onMerged.ts +85 -0
  163. package/lib/claims/outside.ts +36 -0
  164. package/lib/claims/overlap.ts +87 -0
  165. package/lib/claims/overlapCache.ts +86 -0
  166. package/lib/claims/overlapText.ts +36 -0
  167. package/lib/claims/peers.ts +56 -0
  168. package/lib/claims/startCheck.ts +100 -0
  169. package/lib/claims/startWarning.ts +29 -0
  170. package/lib/claims/store.ts +366 -0
  171. package/lib/claims/types.ts +102 -0
  172. package/lib/claims/view.ts +80 -0
  173. package/lib/claims/yield.ts +24 -0
  174. package/lib/db/changes.ts +12 -0
  175. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  176. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  177. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  178. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  179. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  180. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  181. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  182. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  183. package/lib/db/migrations/20261021T1400_session_agent_roles.ts +20 -0
  184. package/lib/db/migrations/20261021T1430_analyst_links.ts +52 -0
  185. package/lib/db/queries.ts +0 -40
  186. package/lib/db/session-parts/columns.ts +3 -0
  187. package/lib/db/types.ts +19 -8
  188. package/lib/doctor/checks.ts +64 -1
  189. package/lib/done/archive.ts +2 -0
  190. package/lib/drafts.ts +4 -0
  191. package/lib/export/build.ts +470 -0
  192. package/lib/export/compact.ts +156 -0
  193. package/lib/export/estimate.ts +83 -0
  194. package/lib/export/fixtures.ts +45 -0
  195. package/lib/export/html.tsx +195 -0
  196. package/lib/export/json.ts +39 -0
  197. package/lib/export/load.ts +64 -0
  198. package/lib/export/markdown.ts +127 -0
  199. package/lib/export/request.ts +36 -0
  200. package/lib/export/service.ts +142 -0
  201. package/lib/export/summary.ts +86 -0
  202. package/lib/export/turns.ts +63 -0
  203. package/lib/export/view.ts +173 -0
  204. package/lib/git-remote.ts +43 -0
  205. package/lib/hosts/index.ts +10 -1
  206. package/lib/models/agent-binding.ts +102 -0
  207. package/lib/models/agent-roles-view.ts +157 -0
  208. package/lib/models/agent-roles.ts +87 -0
  209. package/lib/models/availability-refs.ts +17 -0
  210. package/lib/models/availability.ts +91 -0
  211. package/lib/models/catalog.ts +156 -0
  212. package/lib/models/config.ts +27 -0
  213. package/lib/models/locality.ts +33 -0
  214. package/lib/models/providers.ts +11 -0
  215. package/lib/models/resolve.ts +50 -12
  216. package/lib/models/roles.ts +12 -0
  217. package/lib/needs-you.ts +1 -1
  218. package/lib/orchestration.ts +2 -0
  219. package/lib/orchestrator/act.ts +54 -4
  220. package/lib/orchestrator/brief.ts +2 -1
  221. package/lib/orchestrator/claim-wait.ts +71 -0
  222. package/lib/orchestrator/conditions.ts +28 -1
  223. package/lib/orchestrator/describe.ts +6 -1
  224. package/lib/orchestrator/facts.ts +12 -2
  225. package/lib/orchestrator/read.ts +5 -1
  226. package/lib/orchestrator/serve.ts +37 -2
  227. package/lib/orchestrator/tool-names.ts +1 -0
  228. package/lib/orchestrator/tool-schemas.ts +31 -1
  229. package/lib/orchestrator/tools.ts +3 -1
  230. package/lib/orchestrator/untrusted.ts +5 -0
  231. package/lib/orchestrator/watcher.ts +19 -1
  232. package/lib/peer/account.ts +74 -2
  233. package/lib/peer/allowlist.ts +1 -1
  234. package/lib/peer/client.ts +26 -1
  235. package/lib/peer/link-cli.ts +19 -4
  236. package/lib/peer/links.ts +117 -5
  237. package/lib/peer/machines.ts +14 -2
  238. package/lib/peer/name.ts +32 -0
  239. package/lib/peer/pairing.ts +34 -5
  240. package/lib/peer/pin.ts +10 -3
  241. package/lib/peer/reasons.ts +7 -0
  242. package/lib/peer/revoke.ts +75 -0
  243. package/lib/peer/rpc-server.ts +53 -5
  244. package/lib/peer/unlink.ts +62 -0
  245. package/lib/peer/unlinked-proof.ts +44 -0
  246. package/lib/projects.ts +6 -2
  247. package/lib/security/demo.ts +7 -0
  248. package/lib/session-artifacts/agentos-files.ts +94 -0
  249. package/lib/session-artifacts/collect.ts +17 -0
  250. package/lib/session-groups/assignSessions.ts +81 -0
  251. package/lib/session-groups/cli.ts +61 -0
  252. package/lib/session-groups/createGroup.ts +28 -0
  253. package/lib/session-groups/index.ts +21 -0
  254. package/lib/session-groups/removeGroup.ts +36 -0
  255. package/lib/session-groups/reorderGroups.ts +31 -0
  256. package/lib/session-groups/resolveGroup.ts +19 -0
  257. package/lib/session-groups/route.ts +32 -0
  258. package/lib/session-groups/store.ts +85 -0
  259. package/lib/session-groups/types.ts +25 -0
  260. package/lib/session-groups/updateGroup.ts +35 -0
  261. package/lib/session-groups/workspace.ts +21 -0
  262. package/lib/sessions/crew.ts +72 -0
  263. package/lib/sessions/launch.ts +54 -11
  264. package/lib/settings-url.ts +1 -0
  265. package/lib/share/markdown.ts +2 -2
  266. package/lib/share/redact.ts +16 -10
  267. package/lib/share/render.tsx +1 -1
  268. package/lib/share/snapshot.ts +2 -2
  269. package/lib/sidebar/rollup.ts +1 -0
  270. package/lib/sidebar/row-menu.ts +8 -1
  271. package/lib/sidebar/shelves.ts +90 -15
  272. package/lib/sidebar/visible-order.ts +5 -0
  273. package/lib/tasks/hold-checks.ts +3 -1
  274. package/lib/tasks/holds.ts +3 -0
  275. package/lib/tasks/index.ts +11 -0
  276. package/lib/tasks/queue-rows.ts +8 -1
  277. package/lib/tasks/queue-start.ts +1 -0
  278. package/lib/tasks/queue-types.ts +9 -1
  279. package/lib/tasks/queue.ts +15 -5
  280. package/lib/tasks/schedule-types.ts +1 -0
  281. package/lib/test-temp.ts +19 -3
  282. package/lib/workspaces.ts +11 -3
  283. package/lib/worktree-deps/caches.ts +3 -2
  284. package/lib/worktree-deps/clone.ts +3 -1
  285. package/lib/worktree-deps/paths.ts +26 -0
  286. package/lib/worktree-sweep/testTemp.ts +23 -106
  287. package/package.json +1 -1
  288. package/scripts/agent-os +1 -1
  289. package/scripts/autodeploy +2 -0
  290. package/scripts/autodeploy.test.ts +31 -1
  291. package/scripts/claim-hook.sh +31 -0
  292. package/scripts/claim-hook.test.ts +230 -0
  293. package/scripts/lib/cli-links.sh +36 -0
  294. package/scripts/lib/commands.sh +3 -0
  295. package/scripts/lib/machine.sh +27 -2
  296. package/scripts/lib/machine.test.ts +25 -3
  297. package/scripts/sync-cli-links +7 -0
  298. package/scripts/sync-cli-links.test.ts +69 -0
  299. package/server.ts +12 -1
  300. package/stores/claimsUi.ts +22 -0
  301. package/stores/exportUi.ts +87 -0
  302. package/app/api/groups/[...path]/route.ts +0 -136
  303. package/app/api/groups/route.ts +0 -93
  304. package/data/groups/index.ts +0 -1
  305. package/data/groups/mutations.ts +0 -95
  306. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,31 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { listGroups } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Sets a workspace's group order. `ids` may name only some of them (a move
6
+ // up or down sends the whole list; a stale list from another device leaves
7
+ // newer groups at the end), but never one from another workspace.
8
+ export function reorderGroups(
9
+ workspaceId: string | null,
10
+ ids: unknown
11
+ ): SessionGroup[] {
12
+ if (!Array.isArray(ids) || ids.some((i) => typeof i !== "string"))
13
+ throw new GroupError("invalid", "ids is a list of group ids");
14
+ const current = listGroups(workspaceId);
15
+ const known = new Set(current.map((g) => g.id));
16
+ const wanted = [...new Set(ids as string[])];
17
+ const stranger = wanted.find((i) => !known.has(i));
18
+ if (stranger)
19
+ throw new GroupError("workspace", "A group is not in this workspace");
20
+ const order = [
21
+ ...wanted,
22
+ ...current.map((g) => g.id).filter((i) => !wanted.includes(i)),
23
+ ];
24
+ const db = getDb();
25
+ const set = db.prepare(
26
+ `UPDATE session_groups SET sort_order = ?, updated_at = datetime('now')
27
+ WHERE id = ? AND sort_order != ?`
28
+ );
29
+ db.transaction(() => order.forEach((id, i) => set.run(i, id, i)))();
30
+ return listGroups(workspaceId);
31
+ }
@@ -0,0 +1,19 @@
1
+ import { groupByName, listGroups } from "./store";
2
+ import { GroupError, type SessionGroup } from "./types";
3
+
4
+ // A group named by a caller (`aos --group`, an orchestrator tool): it must
5
+ // exist in that workspace, and the error lists the ones that do.
6
+ export function resolveGroup(
7
+ workspaceId: string | null,
8
+ name: string
9
+ ): SessionGroup {
10
+ const found = groupByName(workspaceId, name);
11
+ if (found) return found;
12
+ const names = listGroups(workspaceId).map((g) => `"${g.name}"`);
13
+ throw new GroupError(
14
+ "not-found",
15
+ names.length
16
+ ? `No group "${name.trim()}" in this workspace. Groups: ${names.join(", ")}`
17
+ : `No group "${name.trim()}": this workspace has no groups`
18
+ );
19
+ }
@@ -0,0 +1,32 @@
1
+ import { NextResponse } from "next/server";
2
+ import type { z } from "zod";
3
+ import { GROUP_STATUS, GroupError } from "./types";
4
+
5
+ // Runs a group action for a route: a body that fails its schema is a 400, a
6
+ // GroupError becomes its status code.
7
+ export async function groupRoute<S extends z.ZodType>(
8
+ request: Request,
9
+ schema: S,
10
+ act: (body: z.infer<S>) => unknown
11
+ ): Promise<NextResponse> {
12
+ const parsed = schema.safeParse(await request.json().catch(() => null));
13
+ if (!parsed.success)
14
+ return NextResponse.json(
15
+ { error: parsed.error.issues[0]?.message ?? "Bad request" },
16
+ { status: 400 }
17
+ );
18
+ return run(() => act(parsed.data));
19
+ }
20
+
21
+ export function run(act: () => unknown): NextResponse {
22
+ try {
23
+ return NextResponse.json(act());
24
+ } catch (err) {
25
+ if (err instanceof GroupError)
26
+ return NextResponse.json(
27
+ { error: err.message, code: err.code },
28
+ { status: GROUP_STATUS[err.code] }
29
+ );
30
+ throw err;
31
+ }
32
+ }
@@ -0,0 +1,85 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { GroupError, NAME_MAX, type SessionGroup } from "./types";
3
+
4
+ interface GroupRow extends Omit<SessionGroup, "collapsed"> {
5
+ collapsed: number;
6
+ deleted_at: string | null;
7
+ }
8
+
9
+ const COLUMNS = `id, workspace_id, name, sort_order, collapsed, created_at,
10
+ updated_at, deleted_at`;
11
+
12
+ const toGroup = ({ deleted_at: _, ...row }: GroupRow): SessionGroup => ({
13
+ ...row,
14
+ collapsed: !!row.collapsed,
15
+ });
16
+
17
+ export function cleanName(name: unknown): string {
18
+ const trimmed =
19
+ typeof name === "string" ? name.trim().replace(/\s+/g, " ") : "";
20
+ if (!trimmed) throw new GroupError("invalid", "A group needs a name");
21
+ if (trimmed.length > NAME_MAX)
22
+ throw new GroupError(
23
+ "invalid",
24
+ `A group name is at most ${NAME_MAX} characters`
25
+ );
26
+ return trimmed;
27
+ }
28
+
29
+ // Live groups (not deleted), in the order to show them within a workspace.
30
+ export function listGroups(workspaceId?: string | null): SessionGroup[] {
31
+ const rows = (
32
+ workspaceId === undefined
33
+ ? getDb()
34
+ .prepare(
35
+ `SELECT ${COLUMNS} FROM session_groups WHERE deleted_at IS NULL
36
+ ORDER BY sort_order, created_at`
37
+ )
38
+ .all()
39
+ : getDb()
40
+ .prepare(
41
+ `SELECT ${COLUMNS} FROM session_groups WHERE deleted_at IS NULL
42
+ AND COALESCE(workspace_id, '') = COALESCE(?, '')
43
+ ORDER BY sort_order, created_at`
44
+ )
45
+ .all(workspaceId)
46
+ ) as GroupRow[];
47
+ return rows.map(toGroup);
48
+ }
49
+
50
+ // Live groups' names by id, for labelling.
51
+ export function groupNames(): Map<string, string> {
52
+ return new Map(listGroups().map((g) => [g.id, g.name]));
53
+ }
54
+
55
+ // Any group by id, deleted or not, with whether it is deleted.
56
+ export function findGroup(
57
+ id: string
58
+ ): (SessionGroup & { deleted: boolean }) | null {
59
+ const row = getDb()
60
+ .prepare(`SELECT ${COLUMNS} FROM session_groups WHERE id = ?`)
61
+ .get(id) as GroupRow | undefined;
62
+ return row ? { ...toGroup(row), deleted: !!row.deleted_at } : null;
63
+ }
64
+
65
+ // A live group by id, or the error a caller should surface.
66
+ export function liveGroup(id: unknown): SessionGroup {
67
+ const group = typeof id === "string" ? findGroup(id) : null;
68
+ if (!group) throw new GroupError("not-found", "Group not found");
69
+ if (group.deleted) throw new GroupError("deleted", "That group was deleted");
70
+ return group;
71
+ }
72
+
73
+ export function groupByName(
74
+ workspaceId: string | null,
75
+ name: string
76
+ ): SessionGroup | null {
77
+ const row = getDb()
78
+ .prepare(
79
+ `SELECT ${COLUMNS} FROM session_groups
80
+ WHERE deleted_at IS NULL AND lower(name) = lower(?)
81
+ AND COALESCE(workspace_id, '') = COALESCE(?, '')`
82
+ )
83
+ .get(name.trim(), workspaceId) as GroupRow | undefined;
84
+ return row ? toGroup(row) : null;
85
+ }
@@ -0,0 +1,25 @@
1
+ export type { SessionGroup } from "@/lib/sidebar/shelves";
2
+
3
+ export type GroupErrorCode =
4
+ "not-found" | "duplicate" | "workspace" | "invalid" | "deleted";
5
+
6
+ // Thrown for anything the caller got wrong; routes map the code to a status.
7
+ export class GroupError extends Error {
8
+ constructor(
9
+ readonly code: GroupErrorCode,
10
+ message: string
11
+ ) {
12
+ super(message);
13
+ this.name = "GroupError";
14
+ }
15
+ }
16
+
17
+ export const GROUP_STATUS: Record<GroupErrorCode, number> = {
18
+ "not-found": 404,
19
+ duplicate: 409,
20
+ workspace: 409,
21
+ invalid: 400,
22
+ deleted: 409,
23
+ };
24
+
25
+ export const NAME_MAX = 60;
@@ -0,0 +1,35 @@
1
+ import { getDb } from "@/lib/db";
2
+ import { cleanName, groupByName, liveGroup } from "./store";
3
+ import { GroupError, type SessionGroup } from "./types";
4
+
5
+ // Rename and fold. Folding is stored here so every device agrees.
6
+ export function updateGroup(
7
+ id: string,
8
+ patch: { name?: unknown; collapsed?: unknown }
9
+ ): SessionGroup {
10
+ const group = liveGroup(id);
11
+ const sets: string[] = [];
12
+ const args: (string | number)[] = [];
13
+ if (patch.name !== undefined) {
14
+ const clean = cleanName(patch.name);
15
+ const clash = groupByName(group.workspace_id, clean);
16
+ if (clash && clash.id !== id)
17
+ throw new GroupError("duplicate", `There is already a group "${clean}"`);
18
+ sets.push("name = ?");
19
+ args.push(clean);
20
+ }
21
+ if (patch.collapsed !== undefined) {
22
+ if (typeof patch.collapsed !== "boolean")
23
+ throw new GroupError("invalid", "collapsed must be a boolean");
24
+ sets.push("collapsed = ?");
25
+ args.push(patch.collapsed ? 1 : 0);
26
+ }
27
+ if (sets.length)
28
+ getDb()
29
+ .prepare(
30
+ `UPDATE session_groups SET ${sets.join(", ")},
31
+ updated_at = datetime('now') WHERE id = ?`
32
+ )
33
+ .run(...args, id);
34
+ return liveGroup(id);
35
+ }
@@ -0,0 +1,21 @@
1
+ import { getDb } from "@/lib/db";
2
+
3
+ // The workspace a session belongs to: its project's, else the one kept on the
4
+ // session (an orchestrator, or a scratch session in no project). Same rule as
5
+ // inWorkspace in lib/sidebar/shelves, read from the database.
6
+ export function sessionWorkspaceId(
7
+ sessionId: string
8
+ ): string | null | undefined {
9
+ const row = getDb()
10
+ .prepare(
11
+ `SELECT s.role AS role, s.workspace_id AS own, p.workspace_id AS byProject
12
+ FROM sessions s LEFT JOIN projects p ON p.id = s.project_id
13
+ WHERE s.id = ?`
14
+ )
15
+ .get(sessionId) as
16
+ | { role: string | null; own: string | null; byProject: string | null }
17
+ | undefined;
18
+ if (!row) return undefined;
19
+ if (row.role === "orchestrator") return row.own;
20
+ return row.byProject ?? row.own;
21
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * A crew: the sessions started in roles that work together. Its id is the
3
+ * session that began it, so a crew needs no table of its own.
4
+ */
5
+
6
+ import { db } from "../db";
7
+ import type { AgentRole } from "../models/agent-roles";
8
+
9
+ const crewOf = (id: string | null | undefined): string | null =>
10
+ id
11
+ ? ((
12
+ db.prepare(`SELECT crew_id FROM session_rows WHERE id = ?`).get(id) as
13
+ { crew_id: string | null } | undefined
14
+ )?.crew_id ?? null)
15
+ : null;
16
+
17
+ // Puts a session in a role and a crew: the one named, else its starter's (or
18
+ // its calling session's), else a new one it begins.
19
+ export function joinCrew(
20
+ id: string,
21
+ role: AgentRole,
22
+ from: {
23
+ crewId?: string | null;
24
+ startedBy?: string | null;
25
+ parent?: string | null;
26
+ }
27
+ ): string {
28
+ const crew =
29
+ from.crewId || crewOf(from.startedBy) || crewOf(from.parent) || id;
30
+ db.prepare(
31
+ `UPDATE sessions SET agent_role = ?, crew_id = ? WHERE id = ?`
32
+ ).run(role, crew, id);
33
+ return crew;
34
+ }
35
+
36
+ export interface CrewMember {
37
+ id: string;
38
+ name: string;
39
+ agentRole: string | null;
40
+ model: string | null;
41
+ modelProvider: string | null;
42
+ agentType: string;
43
+ taskStatus: string | null;
44
+ }
45
+
46
+ export function crewMembers(crewId: string): CrewMember[] {
47
+ return (
48
+ db
49
+ .prepare(
50
+ `SELECT id, name, agent_role, model, model_provider, agent_type, task_status
51
+ FROM session_rows WHERE crew_id = ? AND archived_at IS NULL
52
+ ORDER BY created_at`
53
+ )
54
+ .all(crewId) as {
55
+ id: string;
56
+ name: string;
57
+ agent_role: string | null;
58
+ model: string | null;
59
+ model_provider: string | null;
60
+ agent_type: string;
61
+ task_status: string | null;
62
+ }[]
63
+ ).map((r) => ({
64
+ id: r.id,
65
+ name: r.name,
66
+ agentRole: r.agent_role,
67
+ model: r.model,
68
+ modelProvider: r.model_provider,
69
+ agentType: r.agent_type,
70
+ taskStatus: r.task_status,
71
+ }));
72
+ }
@@ -3,6 +3,7 @@
3
3
  * then (for a worktree) the setup in the background, then the first message.
4
4
  */
5
5
 
6
+ import { fileInGroup } from "../session-groups/assignSessions";
6
7
  import { agentosHome } from "../agentos-home";
7
8
  import type { StartVia } from "../tasks/starter";
8
9
  import fs from "fs";
@@ -15,6 +16,9 @@ import { discoversModels, resolveModelForAgent } from "../model-catalog";
15
16
  import { isDiscoveredModel } from "../chat/discovered-models";
16
17
  import { parseOverride, resolveRole, setSessionModel } from "../models/resolve";
17
18
  import { ANTHROPIC } from "../models/roles";
19
+ import { joinCrew } from "./crew";
20
+ import { prepareRoleStart } from "../models/agent-binding";
21
+ import { isAgentRole, roleBrief, type AgentRole } from "../models/agent-roles";
18
22
  import { getProject } from "../projects";
19
23
  import { getWorkspace } from "../workspaces";
20
24
  import { getHost, isRemoteHost } from "../hosts";
@@ -82,8 +86,16 @@ export interface LaunchInput {
82
86
  // The workspace selected when it was started. A session in a project
83
87
  // follows the project; one in none keeps this (see inWorkspace).
84
88
  workspaceId?: string | null;
85
- groupPath?: string;
86
89
  systemPrompt?: string | null;
90
+ // Start it in a role: the model comes from the role's binding (or
91
+ // `model`), and its brief joins the instructions.
92
+ agentRole?: AgentRole | null;
93
+ // The crew it joins: the id of the session that began it.
94
+ crewId?: string | null;
95
+ // When the role's model is down, start on the role's fallback instead.
96
+ useFallback?: boolean;
97
+ // The user's group it starts in (lib/session-groups).
98
+ groupId?: string;
87
99
  }
88
100
 
89
101
  export interface Launched {
@@ -259,7 +271,17 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
259
271
  if (scratch && projectPath === SCRATCH_DIR)
260
272
  await fs.promises.mkdir(SCRATCH_DIR, { recursive: true });
261
273
 
262
- const agentType = input.agentType;
274
+ if (input.agentRole && (remote || !isAgentRole(input.agentRole)))
275
+ throw new Error("A role session runs on this machine");
276
+ const roleStart = input.agentRole
277
+ ? await prepareRoleStart(input.agentRole, {
278
+ workspaceId: project?.workspace_id ?? home,
279
+ projectId: project && !project.is_uncategorized ? project.id : null,
280
+ override: input.model?.trim() || null,
281
+ useFallback: input.useFallback,
282
+ })
283
+ : null;
284
+ const agentType: AgentType = roleStart?.agentType ?? input.agentType;
263
285
  const view =
264
286
  input.view === "terminal" || !supportsChat(agentType) || remote
265
287
  ? "terminal"
@@ -271,20 +293,30 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
271
293
  const picked = input.startedBy ? parseOverride(input.model) : null;
272
294
  if (picked && typeof picked !== "string")
273
295
  throw new Error(`model: a session takes one "provider/model"`);
274
- const routed =
275
- agentType === "claude" && (picked || !input.model?.trim()) && !remote
296
+ const routed = roleStart
297
+ ? roleStart.agentType === "claude"
298
+ ? roleStart.routed
299
+ : null
300
+ : agentType === "claude" && (picked || !input.model?.trim()) && !remote
276
301
  ? resolveRole(role, {
277
302
  workspaceId: project?.workspace_id ?? home,
278
303
  override: picked,
279
304
  })
280
305
  : null;
281
- if (picked && routed && routed.provider.id !== ANTHROPIC && view !== "chat")
306
+ if (
307
+ (picked || roleStart) &&
308
+ routed &&
309
+ routed.provider.id !== ANTHROPIC &&
310
+ view !== "chat"
311
+ )
282
312
  throw new Error("Another provider's models run only as a chat");
283
313
  const interactive =
284
314
  routed && (routed.provider.id === ANTHROPIC || view === "chat")
285
315
  ? routed
286
316
  : null;
287
- const wanted = input.model?.trim() || project?.default_model;
317
+ const wanted = roleStart
318
+ ? roleStart.model
319
+ : input.model?.trim() || project?.default_model;
288
320
  // An agent without a static list runs what it reports for this folder.
289
321
  const discovered =
290
322
  !interactive &&
@@ -293,9 +325,11 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
293
325
  (await isDiscoveredModel(agentType, projectPath, wanted));
294
326
  const model = interactive
295
327
  ? interactive.model
296
- : discovered && wanted
297
- ? wanted
298
- : resolveModelForAgent(agentType, wanted);
328
+ : roleStart
329
+ ? roleStart.model
330
+ : discovered && wanted
331
+ ? wanted
332
+ : resolveModelForAgent(agentType, wanted);
299
333
  const text = input.prompt?.trim() ?? "";
300
334
  const naming: Naming = text
301
335
  ? await nameFor(text, projectPath, input.name)
@@ -316,8 +350,10 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
316
350
  cwd,
317
351
  input.parentSessionId ?? null,
318
352
  model,
319
- input.systemPrompt ?? null,
320
- input.groupPath ?? "sessions",
353
+ [input.systemPrompt, roleBrief(input.agentRole)]
354
+ .filter(Boolean)
355
+ .join("\n\n") || null,
356
+ "sessions",
321
357
  agentType,
322
358
  input.autoApprove || input.access === "full" ? 1 : 0,
323
359
  project?.id ?? "uncategorized",
@@ -335,6 +371,13 @@ async function launch(input: LaunchInput, id: string): Promise<Launched> {
335
371
  id
336
372
  );
337
373
  setHome(id, home);
374
+ if (input.agentRole)
375
+ joinCrew(id, input.agentRole, {
376
+ crewId: input.crewId,
377
+ startedBy: input.startedBy,
378
+ parent: input.parentSessionId,
379
+ });
380
+ if (input.groupId) fileInGroup(id, input.groupId);
338
381
  if (interactive) setSessionModel(id, interactive);
339
382
  if (input.access)
340
383
  db.prepare(`UPDATE sessions SET chat_access = ? WHERE id = ?`).run(
@@ -9,6 +9,7 @@ export const SETTINGS_SECTIONS = [
9
9
  "approvals",
10
10
  "workspaces",
11
11
  "models",
12
+ "agent-roles",
12
13
  "devices",
13
14
  "shares",
14
15
  "notifications",
@@ -83,13 +83,13 @@ export function htmlToMarkdown(html: string, demote = 0): string {
83
83
  }
84
84
 
85
85
  /** A fenced block whose fence is longer than any backtick run inside. */
86
- function fenced(text: string): string {
86
+ export function fenced(text: string, lang = ""): string {
87
87
  const longest = Math.max(
88
88
  2,
89
89
  ...(text.match(/`+/g) ?? []).map((m) => m.length)
90
90
  );
91
91
  const fence = "`".repeat(longest + 1);
92
- return `${fence}\n${text}\n${fence}`;
92
+ return `${fence}${lang}\n${text}\n${fence}`;
93
93
  }
94
94
 
95
95
  function itemToMarkdown(item: SnapshotItem): string {
@@ -97,10 +97,12 @@ function sub(
97
97
 
98
98
  export function redactForShare(
99
99
  input: string,
100
- ctx: ShareContext = NO_CONTEXT
100
+ ctx: ShareContext = NO_CONTEXT,
101
+ opts: { only?: Kind[] } = {}
101
102
  ): { text: string; hits: Hits } {
102
103
  const hits = noHits();
103
104
  let t = input;
105
+ const on = (k: Kind) => !opts.only || opts.only.includes(k);
104
106
 
105
107
  // 1. Credentials by shape.
106
108
  t = sub(t, PRIVATE_KEY_BLOCK, SECRET, hits, "secret");
@@ -143,7 +145,7 @@ export function redactForShare(
143
145
  }
144
146
 
145
147
  // 3. Home paths, and the account name before an @.
146
- if (ctx.home.length > 1)
148
+ if (on("path") && ctx.home.length > 1)
147
149
  t = sub(
148
150
  t,
149
151
  new RegExp(escape(ctx.home) + "(?![\\w.-])", "g"),
@@ -151,8 +153,8 @@ export function redactForShare(
151
153
  hits,
152
154
  "path"
153
155
  );
154
- t = sub(t, new RegExp(HOME.source, "g"), "~", hits, "path");
155
- if (ctx.user.length > 2)
156
+ if (on("path")) t = sub(t, new RegExp(HOME.source, "g"), "~", hits, "path");
157
+ if (on("path") && ctx.user.length > 2)
156
158
  t = sub(
157
159
  t,
158
160
  new RegExp(`\\b${escape(ctx.user)}(?=@)`, "g"),
@@ -162,14 +164,16 @@ export function redactForShare(
162
164
  );
163
165
 
164
166
  // 4. Addresses.
165
- t = sub(t, IPV4, "[ip]", hits, "ip", (m) => KEEP_IPV4.has(m));
166
- t = sub(t, IPV6, "[ip]", hits, "ip", (m) => KEEP_IPV6.has(m));
167
+ if (on("ip")) {
168
+ t = sub(t, IPV4, "[ip]", hits, "ip", (m) => KEEP_IPV4.has(m));
169
+ t = sub(t, IPV6, "[ip]", hits, "ip", (m) => KEEP_IPV6.has(m));
170
+ }
167
171
 
168
172
  // 5. This machine's names, and private-network suffixes.
169
173
  const names = [...new Set(ctx.hosts.filter((h) => h.length > 2))].sort(
170
174
  (a, b) => b.length - a.length
171
175
  );
172
- if (names.length)
176
+ if (on("host") && names.length)
173
177
  t = sub(
174
178
  t,
175
179
  new RegExp(
@@ -180,11 +184,13 @@ export function redactForShare(
180
184
  hits,
181
185
  "host"
182
186
  );
183
- t = sub(t, CONNECT_HOST, "[host]", hits, "host");
184
- t = sub(t, LOCAL_HOST, "[host]", hits, "host");
187
+ if (on("host")) {
188
+ t = sub(t, CONNECT_HOST, "[host]", hits, "host");
189
+ t = sub(t, LOCAL_HOST, "[host]", hits, "host");
190
+ }
185
191
 
186
192
  // 6. Email addresses.
187
- t = sub(t, EMAIL, "[email]", hits, "email");
193
+ if (on("email")) t = sub(t, EMAIL, "[email]", hits, "email");
188
194
 
189
195
  return { text: t, hits };
190
196
  }
@@ -60,7 +60,7 @@ const components: Components = {
60
60
  img: ({ alt }) => <span>[image{alt ? `: ${alt}` : ""}]</span>,
61
61
  };
62
62
 
63
- function Markdown({ text }: { text: string }) {
63
+ export function Markdown({ text }: { text: string }) {
64
64
  return (
65
65
  <ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
66
66
  {text}
@@ -44,7 +44,7 @@ export interface Snapshot {
44
44
 
45
45
  export const MAX_TOOL_INPUT = 4_000;
46
46
  export const MAX_TOOL_OUTPUT = 20_000;
47
- const HIDDEN_FILE = "[contents of a secret file hidden]";
47
+ export const HIDDEN_FILE = "[contents of a secret file hidden]";
48
48
  const NO_IMAGE = "[image not shared]";
49
49
 
50
50
  const clip = (s: string, max: number) =>
@@ -55,7 +55,7 @@ const clip = (s: string, max: number) =>
55
55
  const excerptOf = (s: string) => s.replace(/\s+/g, " ").trim().slice(0, 120);
56
56
 
57
57
  // Every string in a tool's input, to see whether it names a secret file.
58
- function strings(v: unknown, out: string[] = []): string[] {
58
+ export function strings(v: unknown, out: string[] = []): string[] {
59
59
  if (typeof v === "string") out.push(v);
60
60
  else if (Array.isArray(v)) v.forEach((x) => strings(x, out));
61
61
  else if (v && typeof v === "object")
@@ -28,6 +28,7 @@ export function rollups(
28
28
  const rows = flatten([
29
29
  ...shelves.orchestrators,
30
30
  ...shelves.pinned,
31
+ ...shelves.groups.flatMap((g) => g.rows),
31
32
  ...shelves.needsYou,
32
33
  ...shelves.working,
33
34
  ...shelves.inReview,
@@ -16,11 +16,13 @@ export type RowMenuItem =
16
16
  | "open-recipe"
17
17
  | "copy-link"
18
18
  | "share"
19
+ | "export"
19
20
  | "fork"
20
21
  | "fresh-start"
21
22
  | "new-conversation"
22
23
  | "move-machine"
23
24
  | "move-project"
25
+ | "move-group"
24
26
  | "check-ins"
25
27
  | "save-recipe"
26
28
  | "run-again"
@@ -120,7 +122,11 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
120
122
  input.hasCard && "card",
121
123
  input.recipeRun && "open-recipe",
122
124
  ]),
123
- ...group("share", ["copy-link", s.view === "chat" && "share"]),
125
+ ...group("share", [
126
+ "copy-link",
127
+ s.view === "chat" && "share",
128
+ s.view === "chat" && "export",
129
+ ]),
124
130
  ...group("restart", [
125
131
  s.agent_type === "claude" && agentRow && live && "fork",
126
132
  agentRow && live && "fresh-start",
@@ -133,6 +139,7 @@ export function rowMenu(input: RowMenuInput): RowMenuPlan {
133
139
  ...group("move", [
134
140
  input.machineTargets > 0 && "move-machine",
135
141
  agentRow && input.otherProjects > 0 && live && "move-project",
142
+ agentRow && "move-group",
136
143
  ]),
137
144
  ...group("automate", [
138
145
  input.hasWorkspace &&