@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,11 @@
1
+ import { sessionLinkView } from "@/lib/analyst";
2
+ import { run } from "@/lib/analyst/route";
3
+
4
+ type Ctx = { params: Promise<{ id: string }> };
5
+
6
+ // GET /api/sessions/:id/analyst - the live pairing this session is in, as
7
+ // coder or analyst, or null.
8
+ export async function GET(_request: Request, { params }: Ctx) {
9
+ const { id } = await params;
10
+ return run(() => ({ link: sessionLinkView(id) }));
11
+ }
@@ -0,0 +1,38 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db } from "@/lib/db";
3
+ import { crewMembers } from "@/lib/sessions/crew";
4
+ import { modelCatalog } from "@/lib/models/catalog";
5
+
6
+ type RouteParams = { params: Promise<{ id: string }> };
7
+
8
+ // GET /api/sessions/:id/crew - the session's role and the sessions it works
9
+ // with (same crew), each with its model and whether that is local or cloud.
10
+ export async function GET(_request: NextRequest, { params }: RouteParams) {
11
+ const { id } = await params;
12
+ const row = db
13
+ .prepare(`SELECT agent_role, crew_id FROM session_rows WHERE id = ?`)
14
+ .get(id) as
15
+ { agent_role: string | null; crew_id: string | null } | undefined;
16
+ if (!row)
17
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
18
+ const choices = new Map(modelCatalog().map((c) => [c.ref, c]));
19
+ const members = row.crew_id
20
+ ? crewMembers(row.crew_id).map((m) => {
21
+ const ref = m.model
22
+ ? `${m.agentType === "pi" ? "pi" : (m.modelProvider ?? "anthropic")}/${m.model}`
23
+ : null;
24
+ const choice = ref ? choices.get(ref) : undefined;
25
+ return {
26
+ ...m,
27
+ ref,
28
+ locality: choice?.locality ?? "cloud",
29
+ availability: choice?.availability.state ?? "unknown",
30
+ };
31
+ })
32
+ : [];
33
+ return NextResponse.json({
34
+ agentRole: row.agent_role,
35
+ crewId: row.crew_id,
36
+ members,
37
+ });
38
+ }
@@ -0,0 +1,23 @@
1
+ import { NextResponse } from "next/server";
2
+ import { parseExportRequest } from "@/lib/export/request";
3
+ import { estimate } from "@/lib/export/service";
4
+ import { readJson, refuseDemo, toResponse } from "@/lib/share/route";
5
+
6
+ // POST /api/sessions/:id/export/estimate - what an export would hold: size,
7
+ // tokens, the turns to pick from, and warnings. No file text.
8
+ export async function POST(
9
+ request: Request,
10
+ { params }: { params: Promise<{ id: string }> }
11
+ ) {
12
+ const refused = refuseDemo();
13
+ if (refused) return refused;
14
+ const read = await readJson(request);
15
+ if ("refused" in read) return read.refused;
16
+ try {
17
+ const { id } = await params;
18
+ const req = parseExportRequest(read.body);
19
+ return NextResponse.json(await estimate(id, req));
20
+ } catch (err) {
21
+ return toResponse(err);
22
+ }
23
+ }
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from "crypto";
2
+ import { afterEach, describe, expect, it, vi } from "vitest";
3
+ import { tempDir } from "@/lib/test-temp";
4
+
5
+ process.env.AGENTOS_ARTIFACTS_DIR = tempDir("agentos-export-art-");
6
+ const file = await import("./route");
7
+ const estimate = await import("./estimate/route");
8
+ const { saveItem } = await import("@/lib/chat/store");
9
+ const { seedSession } = await import("@/lib/orchestrator/testing");
10
+ const { createProject } = await import("@/lib/projects");
11
+
12
+ const secret = ["gh", "p_", "c".repeat(36)].join("");
13
+ const body = {
14
+ format: "md",
15
+ mode: "compact",
16
+ redact: "all",
17
+ };
18
+
19
+ const post = (b: unknown) =>
20
+ new Request("http://localhost/x", {
21
+ method: "POST",
22
+ headers: { "content-type": "application/json" },
23
+ body: JSON.stringify(b),
24
+ });
25
+ const params = (id: string) => ({ params: Promise.resolve({ id }) });
26
+
27
+ function session(view: "chat" | "terminal" = "chat") {
28
+ const project = createProject({
29
+ name: `p-${randomUUID().slice(0, 6)}`,
30
+ workingDirectory: "/tmp/p",
31
+ });
32
+ const id = seedSession({ projectId: project.id, name: "Export me", view });
33
+ let t = 1;
34
+ const add = (i: Record<string, unknown>) =>
35
+ saveItem(id, { id: `x${t}`, createdAt: t++, ...i } as never);
36
+ add({ kind: "user", text: `key ${secret}` });
37
+ add({ kind: "assistant", text: "ok" });
38
+ add({ kind: "user", text: "second" });
39
+ add({ kind: "assistant", text: "done" });
40
+ return id;
41
+ }
42
+
43
+ afterEach(() => vi.unstubAllEnvs());
44
+
45
+ describe("POST /api/sessions/:id/export", () => {
46
+ it("returns each format as a named download with size headers", async () => {
47
+ const id = session();
48
+ for (const [format, type, ext] of [
49
+ ["md", "text/markdown", "md"],
50
+ ["json", "application/json", "json"],
51
+ ["html", "text/html", "html"],
52
+ ]) {
53
+ const res = await file.POST(post({ ...body, format }), params(id));
54
+ expect(res.status).toBe(200);
55
+ expect(res.headers.get("content-type")).toContain(type);
56
+ expect(res.headers.get("content-disposition")).toBe(
57
+ `attachment; filename="export-me-all.${ext}"`
58
+ );
59
+ expect(res.headers.get("cache-control")).toBe("no-store");
60
+ const text = await res.text();
61
+ expect(Number(res.headers.get("x-export-bytes"))).toBe(
62
+ Buffer.byteLength(text)
63
+ );
64
+ expect(Number(res.headers.get("x-export-tokens"))).toBeGreaterThan(0);
65
+ expect(text).not.toContain(secret);
66
+ }
67
+ });
68
+
69
+ it("exports a range and names it", async () => {
70
+ const id = session();
71
+ const res = await file.POST(
72
+ post({ ...body, range: { from: 2, to: 2 } }),
73
+ params(id)
74
+ );
75
+ expect(res.headers.get("content-disposition")).toContain("-turn-2.md");
76
+ const text = await res.text();
77
+ expect(text).toContain("second");
78
+ expect(text).not.toContain("## Turn 1");
79
+ });
80
+
81
+ it("404s an unknown session and 400s a terminal one", async () => {
82
+ expect((await file.POST(post(body), params("nope"))).status).toBe(404);
83
+ const res = await file.POST(post(body), params(session("terminal")));
84
+ expect(res.status).toBe(400);
85
+ });
86
+
87
+ it("400s a bad format, mode, redaction or range", async () => {
88
+ const id = session();
89
+ for (const bad of [
90
+ { ...body, format: "pdf" },
91
+ { ...body, mode: "tiny" },
92
+ { ...body, redact: "none" },
93
+ { ...body, range: { from: 0, to: 2 } },
94
+ { ...body, range: { from: 9, to: 12 } },
95
+ { ...body, range: { last: -1 } },
96
+ ])
97
+ expect((await file.POST(post(bad), params(id))).status).toBe(400);
98
+ });
99
+
100
+ it("refuses in the demo", async () => {
101
+ vi.stubEnv("AGENTOS_DEMO", "1");
102
+ const res = await file.POST(post(body), params(session()));
103
+ expect(res.status).toBe(403);
104
+ });
105
+ });
106
+
107
+ describe("POST /api/sessions/:id/export/estimate", () => {
108
+ it("returns sizes, turns and warnings, and no body text", async () => {
109
+ const id = session();
110
+ const res = await estimate.POST(post(body), params(id));
111
+ expect(res.status).toBe(200);
112
+ const text = await res.text();
113
+ const j = JSON.parse(text);
114
+ expect(j.turns.map((t: { n: number }) => t.n)).toEqual([1, 2]);
115
+ expect(j.selected).toMatchObject({ from: 1, to: 2, of: 2, turns: 2 });
116
+ expect(j.estimate.compact.bytes).toBeGreaterThan(0);
117
+ expect(j.estimate.full.bytes).toBeGreaterThan(0);
118
+ expect(j.hits.secret).toBe(1);
119
+ expect(j.warnings.map((w: { code: string }) => w.code)).toContain(
120
+ "sensitive"
121
+ );
122
+ expect(text).not.toContain("done");
123
+ expect(text).not.toContain(secret);
124
+ });
125
+ });
@@ -0,0 +1,34 @@
1
+ import { NextResponse } from "next/server";
2
+ import { parseExportRequest } from "@/lib/export/request";
3
+ import { buildFile } from "@/lib/export/service";
4
+ import { TOO_BIG, exportFileName } from "@/lib/export/view";
5
+ import { fail, readJson, refuseDemo, toResponse } from "@/lib/share/route";
6
+
7
+ // POST /api/sessions/:id/export - the session (or a range of its turns) as a
8
+ // Markdown, JSON or HTML file, redacted. Local work only.
9
+ export async function POST(
10
+ request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const refused = refuseDemo();
14
+ if (refused) return refused;
15
+ const read = await readJson(request);
16
+ if ("refused" in read) return read.refused;
17
+ try {
18
+ const { id } = await params;
19
+ const req = parseExportRequest(read.body);
20
+ const file = await buildFile(id, req);
21
+ if (file.tooBig) return fail(TOO_BIG, 413);
22
+ return new NextResponse(file.body, {
23
+ headers: {
24
+ "Content-Type": file.contentType,
25
+ "Content-Disposition": `attachment; filename="${exportFileName(file.title, file.slug, req.format)}"`,
26
+ "Cache-Control": "no-store",
27
+ "X-Export-Bytes": String(file.bytes),
28
+ "X-Export-Tokens": String(file.tokens),
29
+ },
30
+ });
31
+ } catch (err) {
32
+ return toResponse(err);
33
+ }
34
+ }
@@ -1,3 +1,4 @@
1
+ import { inheritGroup } from "@/lib/session-groups/assignSessions";
1
2
  import { NextRequest, NextResponse } from "next/server";
2
3
  import { randomUUID } from "crypto";
3
4
  import { getDb, queries, type Session, type Message } from "@/lib/db";
@@ -63,6 +64,8 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
63
64
  parent.host_id
64
65
  );
65
66
 
67
+ inheritGroup(parentId, newId);
68
+
66
69
  // NOTE: We do NOT copy claude_session_id here.
67
70
  // When the forked session is first attached, it will use --fork-session flag
68
71
  // with the parent's claude_session_id to create a new branched conversation.
@@ -0,0 +1,18 @@
1
+ import { z } from "zod";
2
+ import { assignSessions } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const body = z.object({ groupId: z.string().nullable() });
6
+
7
+ // POST /api/sessions/:id/group { groupId | null } - join a group of the
8
+ // session's workspace, or leave.
9
+ export async function POST(
10
+ request: Request,
11
+ { params }: { params: Promise<{ id: string }> }
12
+ ) {
13
+ const { id } = await params;
14
+ return groupRoute(request, body, (b) => ({
15
+ ok: true,
16
+ ...assignSessions([id], b.groupId),
17
+ }));
18
+ }
@@ -0,0 +1,49 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { db, type Session } from "@/lib/db";
3
+ import { setChatModel } from "@/lib/chat/settings";
4
+ import { providerFor, sessionRole } from "@/lib/models/resolve";
5
+ import { switchSessionModel } from "@/lib/models/session-model";
6
+ import { PI } from "@/lib/models/providers";
7
+ import { formatModelRef, parseModelRef } from "@/lib/models/roles";
8
+
9
+ // POST /api/sessions/:id/model {ref} - move a running session to another
10
+ // model ("provider/model", or "pi/<provider>/<model>" for a Pi session),
11
+ // keeping its conversation.
12
+ export async function POST(
13
+ request: NextRequest,
14
+ { params }: { params: Promise<{ id: string }> }
15
+ ) {
16
+ const { id } = await params;
17
+ const { ref } = (await request.json().catch(() => ({}))) as { ref?: unknown };
18
+ const session = db
19
+ .prepare(`SELECT * FROM session_rows WHERE id = ?`)
20
+ .get(id) as Session | undefined;
21
+ if (!session)
22
+ return NextResponse.json({ error: "Session not found" }, { status: 404 });
23
+ if (typeof ref !== "string" || !ref.trim())
24
+ return NextResponse.json({ error: "ref is required" }, { status: 400 });
25
+ try {
26
+ const parsed = parseModelRef(ref);
27
+ if (session.agent_type === "pi") {
28
+ if (parsed.provider !== PI)
29
+ throw new Error(
30
+ "A Pi session runs on a Pi model (pi/<provider>/<model>)"
31
+ );
32
+ await setChatModel(id, parsed.model);
33
+ } else {
34
+ if (parsed.provider === PI)
35
+ throw new Error("Only a Pi session can move to a Pi model");
36
+ await switchSessionModel(id, {
37
+ role: sessionRole(session),
38
+ ref: formatModelRef(parsed),
39
+ provider: providerFor(parsed.provider),
40
+ model: parsed.model,
41
+ source: "task",
42
+ });
43
+ }
44
+ return NextResponse.json({ ok: true });
45
+ } catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return NextResponse.json({ error: message }, { status: 400 });
48
+ }
49
+ }
@@ -19,6 +19,7 @@ import { statusDetector } from "@/lib/status-detector";
19
19
  import { deleteItems } from "@/lib/chat/store";
20
20
  import { clearQueue } from "@/lib/chat/queued";
21
21
  import { recordPreviousName } from "@/lib/session-names";
22
+ import { getProject, moveSessionToProject } from "@/lib/projects";
22
23
  import { generateBranchName, getCurrentBranch, renameBranch } from "@/lib/git";
23
24
  import { runInBackground } from "@/lib/async-operations";
24
25
  import {
@@ -206,9 +207,15 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
206
207
  updates.push("system_prompt = ?");
207
208
  values.push(body.systemPrompt);
208
209
  }
209
- if (body.groupPath !== undefined) {
210
- updates.push("group_path = ?");
211
- values.push(body.groupPath);
210
+
211
+ let clearedGroup: string | null = null;
212
+ if (typeof body.projectId === "string" && !existing.role) {
213
+ if (!getProject(body.projectId))
214
+ return NextResponse.json(
215
+ { error: "Project not found" },
216
+ { status: 404 }
217
+ );
218
+ clearedGroup = moveSessionToProject(id, body.projectId).clearedGroup;
212
219
  }
213
220
 
214
221
  if (updates.length > 0) {
@@ -221,7 +228,10 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
221
228
  }
222
229
 
223
230
  const session = queries.getSession(db).get(id) as Session;
224
- return NextResponse.json({ session });
231
+ return NextResponse.json({
232
+ session,
233
+ ...(clearedGroup && { clearedGroup }),
234
+ });
225
235
  } catch (error) {
226
236
  console.error("Error updating session:", error);
227
237
  return NextResponse.json(
@@ -1,3 +1,4 @@
1
+ import { inheritGroup } from "@/lib/session-groups/assignSessions";
1
2
  import { effectiveAutoApprove } from "@/lib/approvals/no-approvals";
2
3
  import { routedRun } from "@/lib/models/launch";
3
4
  import { NextRequest, NextResponse } from "next/server";
@@ -305,6 +306,7 @@ export async function POST(
305
306
  session.host_id
306
307
  );
307
308
 
309
+ inheritGroup(session.id, newId);
308
310
  newSession = queries.getSession(db).get(newId) as Session;
309
311
  const newTmuxSession = tmuxName;
310
312
 
@@ -0,0 +1,122 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { seedSession, seedWorkspace } from "@/lib/orchestrator/testing";
3
+
4
+ const { db } = await import("@/lib/db");
5
+ const { machineId } = await import("@/lib/claims/machine");
6
+ const { upsertLocal, upsertRemote } = await import("@/lib/claims/store");
7
+ const { refreshOverlapCache, resetOverlapCache } =
8
+ await import("@/lib/claims/overlapCache");
9
+ const { GET } = await import("./route");
10
+
11
+ const REPO = "git.example.test/team/widgets";
12
+
13
+ beforeEach(() => {
14
+ db.prepare(`DELETE FROM work_claims`).run();
15
+ resetOverlapCache();
16
+ });
17
+
18
+ function localClaim(sessionId: string, files: string[]) {
19
+ upsertLocal(
20
+ { id: machineId(), name: "here" },
21
+ {
22
+ sessionId,
23
+ sessionName: "Tidy auth",
24
+ repo: REPO,
25
+ branch: "feature/tidy",
26
+ base: "main",
27
+ goal: "Tidy auth",
28
+ files,
29
+ filesMore: 0,
30
+ prUrl: null,
31
+ }
32
+ );
33
+ }
34
+
35
+ const remote = (id: string, files: string[], branch = "feature/login") =>
36
+ upsertRemote(machineId(), "peer-1", {
37
+ id,
38
+ origin: "box-machine",
39
+ origin_name: "box",
40
+ hops: 0,
41
+ source: "session",
42
+ session_name: "Rework login sessions",
43
+ repo: REPO,
44
+ branch,
45
+ base: "main",
46
+ goal: "Rework login sessions",
47
+ files,
48
+ files_more: 0,
49
+ pr_url: null,
50
+ ttl_s: 600,
51
+ age_s: 60,
52
+ released: null,
53
+ });
54
+
55
+ const FILES = ["a/one.ts", "a/two.ts", "a/three.ts"];
56
+
57
+ const rows = async () => {
58
+ const body = await (await GET()).json();
59
+ return body as {
60
+ sessions: { id: string; overlap?: Record<string, unknown> | null }[];
61
+ claims: { id: string; fileCount: number; files?: string[] }[];
62
+ claimOverlaps: { a: string; b: string; strength: string }[];
63
+ };
64
+ };
65
+
66
+ describe("GET /api/sessions with claims", () => {
67
+ it("puts a strong overlap on the session that has it, and no other", async () => {
68
+ const t = seedWorkspace();
69
+ const mine = seedSession({ projectId: t.app.id, name: "Tidy auth" });
70
+ const other = seedSession({ projectId: t.app.id, name: "Unrelated" });
71
+ localClaim(mine, FILES);
72
+ remote("claim-box-1", FILES);
73
+ refreshOverlapCache();
74
+ const body = await rows();
75
+ expect(body.sessions.find((s) => s.id === mine)?.overlap).toEqual({
76
+ strength: "strong",
77
+ machine: "box",
78
+ goal: "Rework login sessions",
79
+ shared: 3,
80
+ claimId: "claim-box-1",
81
+ mineId: `s-${mine}`,
82
+ });
83
+ expect(body.sessions.find((s) => s.id === other)?.overlap).toBeUndefined();
84
+ });
85
+
86
+ it("marks a small file overlap weak", async () => {
87
+ const t = seedWorkspace();
88
+ const mine = seedSession({ projectId: t.app.id, name: "Tidy auth" });
89
+ localClaim(mine, ["a/1.ts", "a/2.ts", "a/3.ts", "a/4.ts", "a/5.ts"]);
90
+ remote(
91
+ "claim-box-2",
92
+ ["a/1.ts", "a/2.ts", "b/1.ts", "b/2.ts", "b/3.ts"],
93
+ "feature/other"
94
+ );
95
+ refreshOverlapCache();
96
+ const row = (await rows()).sessions.find((s) => s.id === mine);
97
+ expect(row?.overlap?.strength).toBe("weak");
98
+ });
99
+
100
+ it("lists claims without their paths, and the overlapping pairs", async () => {
101
+ const t = seedWorkspace();
102
+ const mine = seedSession({ projectId: t.app.id, name: "Tidy auth" });
103
+ localClaim(mine, FILES);
104
+ remote("claim-box-1", FILES);
105
+ refreshOverlapCache();
106
+ const body = await rows();
107
+ expect(body.claims.map((c) => c.id).sort()).toEqual(
108
+ [`s-${mine}`, "claim-box-1"].sort()
109
+ );
110
+ expect(body.claims.every((c) => c.fileCount === 3 && !c.files)).toBe(true);
111
+ expect(body.claimOverlaps).toHaveLength(1);
112
+ expect(body.claimOverlaps[0].strength).toBe("strong");
113
+ });
114
+
115
+ it("serves no claims before the loop has run", async () => {
116
+ const t = seedWorkspace();
117
+ seedSession({ projectId: t.app.id, name: "Tidy auth" });
118
+ const body = await rows();
119
+ expect(body.claims).toEqual([]);
120
+ expect(body.sessions.every((s) => !s.overlap)).toBe(true);
121
+ });
122
+ });
@@ -0,0 +1,17 @@
1
+ import { z } from "zod";
2
+ import { assignSessions } from "@/lib/session-groups";
3
+ import { groupRoute } from "@/lib/session-groups/route";
4
+
5
+ const body = z.object({
6
+ ids: z.array(z.string()).min(1).max(200),
7
+ groupId: z.string().nullable(),
8
+ });
9
+
10
+ // POST /api/sessions/group { ids, groupId | null } - the multi-select move.
11
+ // All or none: one session of another workspace refuses the lot.
12
+ export async function POST(request: Request) {
13
+ return groupRoute(request, body, (b) => ({
14
+ ok: true,
15
+ ...assignSessions(b.ids, b.groupId),
16
+ }));
17
+ }
@@ -1,13 +1,17 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
- import { getDb, queries, type Session, type Group } from "@/lib/db";
2
+ import { getDb, queries, type Session } from "@/lib/db";
3
+ import { listGroups } from "@/lib/session-groups";
3
4
  import { isValidAgentType } from "@/lib/providers";
4
5
  import { clientSend } from "@/lib/chat/client-send";
5
6
  import { CHAT_ACCESS } from "@/lib/chat/events";
6
7
  import { launchSession } from "@/lib/sessions/launch";
8
+ import { isAgentRole } from "@/lib/models/agent-roles";
9
+ import { ModelUnavailableError } from "@/lib/models/agent-binding";
7
10
  import { namesLine, waitBadges } from "@/lib/bus/waits/store";
11
+ import { claimsSnapshot } from "@/lib/claims";
8
12
  import { isProjectRef } from "@/lib/tasks/project-ref";
9
13
 
10
- // GET /api/sessions - List all sessions and groups
14
+ // GET /api/sessions - List the sidebar's sessions and the user's groups
11
15
  export async function GET() {
12
16
  try {
13
17
  const db = getDb();
@@ -15,12 +19,15 @@ export async function GET() {
15
19
  // tasks live in the Tasks panel, a moved one on the machine it moved to,
16
20
  // and archived sessions in the Archived view: none is in the sidebar.
17
21
  const badges = waitBadges();
22
+ const claims = claimsSnapshot();
18
23
  const sessions = (queries.getSidebarSessions(db).all() as Session[]).map(
19
24
  (s) => {
20
25
  const b = badges.get(s.id);
26
+ const overlap = claims.bySession.get(s.id);
27
+ const row = overlap ? { ...s, overlap } : s;
21
28
  return b
22
29
  ? {
23
- ...s,
30
+ ...row,
24
31
  waiting_on: b.on.length
25
32
  ? namesLine(b.on.map((w) => w.name))
26
33
  : null,
@@ -28,18 +35,15 @@ export async function GET() {
28
35
  ? namesLine(b.by.map((w) => w.name))
29
36
  : null,
30
37
  }
31
- : s;
38
+ : row;
32
39
  }
33
40
  );
34
- const groups = queries.getAllGroups(db).all() as Group[];
35
-
36
- // Convert expanded from 0/1 to boolean
37
- const formattedGroups = groups.map((g) => ({
38
- ...g,
39
- expanded: Boolean(g.expanded),
40
- }));
41
-
42
- return NextResponse.json({ sessions, groups: formattedGroups });
41
+ return NextResponse.json({
42
+ sessions,
43
+ sessionGroups: listGroups(),
44
+ claims: claims.claims,
45
+ claimOverlaps: claims.overlaps,
46
+ });
43
47
  } catch (error) {
44
48
  console.error("Error fetching sessions:", error);
45
49
  return NextResponse.json(
@@ -95,8 +99,10 @@ export async function POST(request: NextRequest) {
95
99
  name: str(body.name),
96
100
  view: body.view === "terminal" ? "terminal" : undefined,
97
101
  parentSessionId: str(body.parentSessionId),
98
- groupPath: str(body.groupPath) ?? undefined,
99
102
  systemPrompt: str(body.systemPrompt),
103
+ agentRole: isAgentRole(body.agentRole) ? body.agentRole : null,
104
+ crewId: str(body.crewId),
105
+ useFallback: body.onUnavailable === "fallback",
100
106
  });
101
107
  const db = getDb();
102
108
  const claudeSessionId = repeat ? null : str(body.claudeSessionId);
@@ -129,6 +135,17 @@ export async function POST(request: NextRequest) {
129
135
  { status: 201 }
130
136
  );
131
137
  } catch (error) {
138
+ if (error instanceof ModelUnavailableError)
139
+ return NextResponse.json(
140
+ {
141
+ code: error.code,
142
+ role: error.role,
143
+ ref: error.ref,
144
+ fallback: error.fallback ?? null,
145
+ error: error.message,
146
+ },
147
+ { status: 409 }
148
+ );
132
149
  const message = error instanceof Error ? error.message : String(error);
133
150
  console.error("Error creating session:", message);
134
151
  return NextResponse.json({ error: message }, { status: 400 });
package/app/page.tsx CHANGED
@@ -122,6 +122,10 @@ const LumifyHubDialogs = dynamic(
122
122
  ),
123
123
  { ssr: false }
124
124
  );
125
+ const ClaimsSheet = dynamic(
126
+ () => import("@/components/Claims/ClaimsSheet").then((m) => m.ClaimsSheet),
127
+ { ssr: false }
128
+ );
125
129
  const UsageDialog = dynamic(
126
130
  () => import("@/components/Usage/UsageDialog").then((m) => m.UsageDialog),
127
131
  { ssr: false }
@@ -134,6 +138,10 @@ const ShareDialog = dynamic(
134
138
  () => import("@/components/Share/ShareDialog").then((m) => m.ShareDialog),
135
139
  { ssr: false }
136
140
  );
141
+ const ExportSheet = dynamic(
142
+ () => import("@/components/Export/ExportSheet").then((m) => m.ExportSheet),
143
+ { ssr: false }
144
+ );
137
145
  const CommandPalette = dynamic(
138
146
  () =>
139
147
  import("@/components/CommandPalette/CommandPalette").then(
@@ -159,8 +167,10 @@ const AppDialogs = memo(function AppDialogs({
159
167
  <CleanupDialog />
160
168
  <LumifyHubDialogs />
161
169
  <UsageDialog />
170
+ <ClaimsSheet />
162
171
  <MoveDialog />
163
172
  <ShareDialog />
173
+ <ExportSheet />
164
174
  <AwayReport />
165
175
  <IncomingLinks />
166
176
  <CommandPalette />