@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,161 @@
1
+ import { createElement } from "react";
2
+ import { renderToStaticMarkup } from "react-dom/server";
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+ import { claimKeys } from "@/data/claims/keys";
5
+ import { claimsUi, claimsUiActions } from "@/stores/claimsUi";
6
+ import { ClaimFiles } from "./ClaimFiles";
7
+ import { OverlapPill } from "./OverlapPill";
8
+ import { StartClaimsNotice } from "./StartClaimsNotice";
9
+ import { focusView, groupByRepo, lastHeardText, startedText } from "./format";
10
+ import type { Claim } from "@/lib/claims/types";
11
+
12
+ const overlap = {
13
+ strength: "strong" as const,
14
+ machine: "box",
15
+ goal: "Rework login",
16
+ shared: 3,
17
+ claimId: "r-1",
18
+ mineId: "s-1",
19
+ };
20
+
21
+ beforeEach(() => claimsUiActions.setOpen(false));
22
+
23
+ describe("OverlapPill", () => {
24
+ it("reads Overlaps: <machine>, amber when strong", () => {
25
+ const html = renderToStaticMarkup(createElement(OverlapPill, { overlap }));
26
+ expect(html).toContain("Overlaps: box");
27
+ expect(html).toContain("text-amber-700");
28
+ expect(html).toContain('data-overlap="strong"');
29
+ });
30
+
31
+ it("is muted when weak", () => {
32
+ const html = renderToStaticMarkup(
33
+ createElement(OverlapPill, { overlap: { ...overlap, strength: "weak" } })
34
+ );
35
+ expect(html).toContain("text-muted-foreground");
36
+ expect(html).not.toContain("text-amber-700");
37
+ });
38
+
39
+ it("opens the claims sheet on that pair when tapped", () => {
40
+ const el = OverlapPill({ overlap }) as { props: { onClick: () => void } };
41
+ el.props.onClick();
42
+ expect(claimsUi.open).toBe(true);
43
+ expect([...(claimsUi.focus ?? [])]).toEqual(["s-1", "r-1"]);
44
+ claimsUiActions.showAll();
45
+ expect(claimsUi.focus).toBeNull();
46
+ });
47
+ });
48
+
49
+ describe("StartClaimsNotice", () => {
50
+ const warnings = [
51
+ {
52
+ strength: "strong" as const,
53
+ kinds: ["files" as const],
54
+ shared: ["a.ts", "b.ts"],
55
+ claim: {
56
+ id: "r-1",
57
+ goal: "Rework login",
58
+ machine: "box",
59
+ branch: "feature/login",
60
+ sessionName: null,
61
+ },
62
+ },
63
+ ];
64
+ const html = renderToStaticMarkup(
65
+ createElement(StartClaimsNotice, {
66
+ warnings,
67
+ busy: false,
68
+ onStart: () => {},
69
+ onDismiss: () => {},
70
+ })
71
+ );
72
+
73
+ it("names the work and offers both choices", () => {
74
+ expect(html).toContain("Rework login");
75
+ expect(html).toContain("on box");
76
+ expect(html).toContain("a.ts, b.ts");
77
+ expect(html).toContain("Start anyway");
78
+ expect(html).toContain("Start after it");
79
+ });
80
+
81
+ it("renders nothing without a warning", () => {
82
+ expect(
83
+ renderToStaticMarkup(
84
+ createElement(StartClaimsNotice, {
85
+ warnings: [],
86
+ busy: false,
87
+ onStart: () => {},
88
+ onDismiss: () => {},
89
+ })
90
+ )
91
+ ).toBe("");
92
+ });
93
+ });
94
+
95
+ describe("claims keys and helpers", () => {
96
+ it("keys the list by project", () => {
97
+ expect(claimKeys.list()).toEqual(["claims", "list", ""]);
98
+ expect(claimKeys.list("p1")).toEqual(["claims", "list", "p1"]);
99
+ expect(claimKeys.list("p1").slice(0, 1)).toEqual(claimKeys.all);
100
+ });
101
+
102
+ it("words ages and last heard", () => {
103
+ expect(startedText({ startedAt: 0 }, 12 * 60_000)).toBe("started 12m ago");
104
+ expect(startedText({ startedAt: 0 }, 5_000)).toBe("just started");
105
+ expect(lastHeardText(0, 6 * 60_000)).toBe("last heard 6m ago");
106
+ expect(lastHeardText(null, 0)).toBe("this machine");
107
+ });
108
+
109
+ const c = (id: string, repo: string, startedAt: number) =>
110
+ ({ id, repo, startedAt, files: [], filesMore: 0 }) as unknown as Claim;
111
+
112
+ it("groups by repo, oldest work first", () => {
113
+ const groups = groupByRepo([
114
+ c("a", "r2", 5),
115
+ c("b", "r1", 9),
116
+ c("c", "r2", 1),
117
+ ]);
118
+ expect(groups.map((g) => [g.repo, g.claims.map((x) => x.id)])).toEqual([
119
+ ["r2", ["c", "a"]],
120
+ ["r1", ["b"]],
121
+ ]);
122
+ });
123
+
124
+ it("narrows to one pair", () => {
125
+ const view = {
126
+ claims: [c("a", "r", 1), c("b", "r", 2), c("z", "r", 3)],
127
+ overlaps: [
128
+ {
129
+ a: "a",
130
+ b: "b",
131
+ kinds: ["files" as const],
132
+ shared: [],
133
+ strength: "weak" as const,
134
+ },
135
+ {
136
+ a: "b",
137
+ b: "z",
138
+ kinds: ["files" as const],
139
+ shared: [],
140
+ strength: "weak" as const,
141
+ },
142
+ ],
143
+ };
144
+ const f = focusView(view, ["a", "b"]);
145
+ expect(f.claims.map((x) => x.id)).toEqual(["a", "b"]);
146
+ expect(f.overlaps).toHaveLength(1);
147
+ expect(focusView(view, null)).toBe(view);
148
+ });
149
+
150
+ it("marks shared files in a claim's list", () => {
151
+ const html = renderToStaticMarkup(
152
+ createElement(ClaimFiles, {
153
+ files: ["a.ts", "b.ts"],
154
+ more: 2,
155
+ shared: new Set(["a.ts"]),
156
+ })
157
+ );
158
+ expect(html).toContain("and 2 more");
159
+ expect(html.match(/text-amber-700/g)).toHaveLength(1);
160
+ });
161
+ });
@@ -0,0 +1,57 @@
1
+ import type { Claim } from "@/lib/claims/types";
2
+ import type { ClaimsView } from "@/lib/claims/view";
3
+
4
+ const span = (ms: number) => {
5
+ const mins = Math.floor(Math.max(0, ms) / 60_000);
6
+ if (mins < 1) return "under a minute";
7
+ if (mins < 60) return `${mins}m`;
8
+ if (mins < 24 * 60) return `${Math.floor(mins / 60)}h`;
9
+ return `${Math.floor(mins / (24 * 60))}d`;
10
+ };
11
+
12
+ /** "started 12m ago". */
13
+ export const startedText = (c: Pick<Claim, "startedAt">, now: number) => {
14
+ const s = span(now - c.startedAt);
15
+ return s === "under a minute" ? "just started" : `started ${s} ago`;
16
+ };
17
+
18
+ /** "last heard 6m ago". */
19
+ export const lastHeardText = (at: number | null, now: number) => {
20
+ if (at === null) return "this machine";
21
+ const s = span(now - at);
22
+ return s === "under a minute" ? "heard just now" : `last heard ${s} ago`;
23
+ };
24
+
25
+ /** A machine not heard from for this long is shown as quiet. */
26
+ export const QUIET_MS = 3 * 60_000;
27
+
28
+ export interface RepoGroup {
29
+ repo: string;
30
+ claims: Claim[];
31
+ }
32
+
33
+ /** Claims by repository, the repository with the oldest work first. */
34
+ export function groupByRepo(claims: Claim[]): RepoGroup[] {
35
+ const byRepo = new Map<string, Claim[]>();
36
+ for (const c of claims)
37
+ byRepo.set(c.repo, [...(byRepo.get(c.repo) ?? []), c]);
38
+ return [...byRepo]
39
+ .map(([repo, list]) => ({
40
+ repo,
41
+ claims: [...list].sort((a, b) => a.startedAt - b.startedAt),
42
+ }))
43
+ .sort((a, b) => a.claims[0].startedAt - b.claims[0].startedAt);
44
+ }
45
+
46
+ /** The claims and overlaps of one pair, or everything when no pair is set. */
47
+ export function focusView(
48
+ view: Pick<ClaimsView, "claims" | "overlaps">,
49
+ focus: string[] | null
50
+ ) {
51
+ if (!focus) return view;
52
+ const ids = new Set(focus);
53
+ return {
54
+ claims: view.claims.filter((c) => ids.has(c.id)),
55
+ overlaps: view.overlaps.filter((o) => ids.has(o.a) && ids.has(o.b)),
56
+ };
57
+ }
@@ -0,0 +1,3 @@
1
+ export { ClaimsSheet } from "./ClaimsSheet";
2
+ export { OverlapPill } from "./OverlapPill";
3
+ export { StartClaimsNotice } from "./StartClaimsNotice";
@@ -21,6 +21,7 @@ import { useAgentStatusQuery } from "@/data/agents";
21
21
  import { useProjectMachinesQuery } from "@/data/projects";
22
22
  import { modelChoices, useModelsQuery } from "@/data/models";
23
23
  import type { AgentProbe } from "@/lib/agents/probe";
24
+ import { RoleChips } from "./RoleChips";
24
25
  import { ChipHeading, ChipItem, ChipMenu, ChipToggle } from "./Chip";
25
26
  import {
26
27
  DEFAULT_PRIORITY,
@@ -168,7 +169,7 @@ export function DraftChips({
168
169
  </ChipMenu>
169
170
  )}
170
171
 
171
- {!draft.openPr && (
172
+ {!draft.openPr && !draft.agentRole && (
172
173
  <ChipMenu icon={Bot} name="Agent" label={agentLabel(draft.agentType)}>
173
174
  {AGENT_OPTIONS.map((o) => (
174
175
  <ChipItem
@@ -189,6 +190,10 @@ export function DraftChips({
189
190
  </ChipMenu>
190
191
  )}
191
192
 
193
+ {!draft.openPr && local && (
194
+ <RoleChips draft={draft} onChange={onChange} />
195
+ )}
196
+
192
197
  {project && local && git?.isGitRepo && (
193
198
  <ChipMenu
194
199
  icon={GitBranch}
@@ -14,12 +14,18 @@ import { useHostsQuery } from "@/data/hosts";
14
14
  import { useGitCheck } from "@/data/git/queries";
15
15
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
16
16
  import { useLaunchSession } from "@/data/sessions";
17
+ import { ModelUnavailableError } from "@/data/sessions/queries";
18
+ import { isAgentRole } from "@/lib/models/agent-roles";
17
19
  import { useCreateTask } from "@/data/tasks";
20
+ import { useCheckStart } from "@/data/claims";
21
+ import { StartClaimsNotice } from "@/components/Claims";
22
+ import type { StartWarning } from "@/lib/claims/startWarning";
18
23
  import { usePanes } from "@/contexts/PaneContext";
19
24
  import { setPendingPrompt } from "@/stores/initialPrompt";
20
25
  import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
21
26
  import { DraftChips } from "./DraftChips";
22
27
  import { DraftHeading } from "./DraftHeading";
28
+ import { ModelUnavailableDialog } from "./ModelUnavailableDialog";
23
29
  import { draftModels } from "./draftModels";
24
30
 
25
31
  // A new session before its first send: choices on top, a composer below.
@@ -53,8 +59,22 @@ export function DraftPanel({
53
59
  const launch = useLaunchSession();
54
60
  const { workspace } = useSelectedWorkspace();
55
61
  const createTask = useCreateTask();
62
+ const checkStart = useCheckStart();
56
63
  const [prefill, setPrefill] = useState<{ text: string; at: number }>();
57
- const pending = launch.isPending || createTask.isPending;
64
+ // A task start that overlaps claimed work waits here for the person's choice.
65
+ const [held, setHeld] = useState<{
66
+ text: string;
67
+ warnings: StartWarning[];
68
+ } | null>(null);
69
+ // A role's model server that didn't answer, with what was being sent.
70
+ const [down, setDown] = useState<{
71
+ error: ModelUnavailableError;
72
+ text: string;
73
+ images: ChatImage[];
74
+ files: ChatFile[];
75
+ } | null>(null);
76
+ const pending =
77
+ launch.isPending || createTask.isPending || checkStart.isPending;
58
78
 
59
79
  if (!hydrated) return null;
60
80
  if (!draft)
@@ -67,15 +87,32 @@ export function DraftPanel({
67
87
  </div>
68
88
  );
69
89
 
70
- const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
90
+ const send = async (
91
+ text: string,
92
+ images: ChatImage[],
93
+ files: ChatFile[],
94
+ claim?: { after?: string; onUnavailable?: "fallback" }
95
+ ) => {
71
96
  try {
72
97
  if (draft.openPr) {
73
98
  if (!project) throw new Error("Pick a project for a task");
74
99
  if (images.length || files.length)
75
100
  throw new Error("A task takes text only");
101
+ if (!claim && draft.hostId === project.host_id) {
102
+ const warnings = await checkStart.mutateAsync({
103
+ projectId: project.id,
104
+ prompt: text,
105
+ });
106
+ if (warnings.length) {
107
+ setHeld({ text, warnings });
108
+ return;
109
+ }
110
+ }
111
+ setHeld(null);
76
112
  const { session, queued } = await createTask.mutateAsync({
77
113
  projectId: project.id,
78
114
  prompt: text,
115
+ after: claim?.after,
79
116
  model: draft.taskModel ?? undefined,
80
117
  priority: draft.taskPriority,
81
118
  baseBranch: draft.baseBranch ?? undefined,
@@ -102,7 +139,17 @@ export function DraftPanel({
102
139
  ? draft.hostId
103
140
  : undefined,
104
141
  agentType: draft.agentType,
105
- model: draft.model,
142
+ model: isAgentRole(draft.agentRole)
143
+ ? (draft.roleModel ?? "")
144
+ : draft.model,
145
+ ...(isAgentRole(draft.agentRole)
146
+ ? {
147
+ agentRole: draft.agentRole,
148
+ ...(claim?.onUnavailable && {
149
+ onUnavailable: claim.onUnavailable,
150
+ }),
151
+ }
152
+ : {}),
106
153
  access: draft.access,
107
154
  useWorktree: draft.useWorktree && !!git?.isGitRepo,
108
155
  baseBranch: draft.baseBranch,
@@ -126,6 +173,10 @@ export function DraftPanel({
126
173
  session.host_id
127
174
  );
128
175
  } catch (error) {
176
+ if (error instanceof ModelUnavailableError) {
177
+ setDown({ error, text, images, files });
178
+ return;
179
+ }
129
180
  setPrefill({ text, at: Date.now() });
130
181
  toast.error(error instanceof Error ? error.message : String(error));
131
182
  }
@@ -145,6 +196,42 @@ export function DraftPanel({
145
196
  git={git}
146
197
  onChange={(patch) => draftsActions.update(draft.id, patch)}
147
198
  />
199
+ <ModelUnavailableDialog
200
+ error={down?.error ?? null}
201
+ busy={pending}
202
+ onClose={() => {
203
+ if (down) setPrefill({ text: down.text, at: Date.now() });
204
+ setDown(null);
205
+ }}
206
+ onRetry={() => {
207
+ const d = down;
208
+ setDown(null);
209
+ if (d) void send(d.text, d.images, d.files);
210
+ }}
211
+ onFallback={() => {
212
+ const d = down;
213
+ setDown(null);
214
+ if (d)
215
+ void send(d.text, d.images, d.files, {
216
+ onUnavailable: "fallback",
217
+ });
218
+ }}
219
+ onPick={() => {
220
+ if (down) setPrefill({ text: down.text, at: Date.now() });
221
+ setDown(null);
222
+ }}
223
+ />
224
+ {held && (
225
+ <StartClaimsNotice
226
+ warnings={held.warnings}
227
+ busy={pending}
228
+ onStart={(after) => void send(held.text, [], [], { after })}
229
+ onDismiss={() => {
230
+ setPrefill({ text: held.text, at: Date.now() });
231
+ setHeld(null);
232
+ }}
233
+ />
234
+ )}
148
235
  <Composer
149
236
  draftKey={draftComposerKey(draft.id)}
150
237
  attachScope="draft"
@@ -0,0 +1,78 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import {
5
+ Dialog,
6
+ DialogContent,
7
+ DialogDescription,
8
+ DialogHeader,
9
+ DialogTitle,
10
+ } from "@/components/ui/dialog";
11
+ import { AGENT_ROLE_DEFS, isAgentRole } from "@/lib/models/agent-roles";
12
+ import type { ModelUnavailableError } from "@/data/sessions/queries";
13
+
14
+ // A session in a role couldn't start: its model's server isn't answering.
15
+ // A local model never turns into a cloud one on its own: starting on the
16
+ // role's fallback is a choice made here.
17
+ export function ModelUnavailableDialog({
18
+ error,
19
+ busy,
20
+ onRetry,
21
+ onFallback,
22
+ onPick,
23
+ onClose,
24
+ }: {
25
+ error: ModelUnavailableError | null;
26
+ busy: boolean;
27
+ onRetry: () => void;
28
+ onFallback: () => void;
29
+ onPick: () => void;
30
+ onClose: () => void;
31
+ }) {
32
+ const role =
33
+ error && isAgentRole(error.role)
34
+ ? AGENT_ROLE_DEFS[error.role].label
35
+ : "role";
36
+ const name = (ref: string) => ref.split("/").slice(1).join("/");
37
+ return (
38
+ <Dialog open={!!error} onOpenChange={(o) => !o && onClose()}>
39
+ <DialogContent>
40
+ <DialogHeader>
41
+ <DialogTitle>Can&apos;t start the {role}</DialogTitle>
42
+ <DialogDescription>
43
+ {error ? `${name(error.ref)}'s server isn't answering.` : ""}
44
+ </DialogDescription>
45
+ </DialogHeader>
46
+ {error && (
47
+ <p className="bg-muted/60 text-muted-foreground rounded-lg px-3 py-2 font-mono text-xs break-words">
48
+ {error.message}
49
+ </p>
50
+ )}
51
+ <div className="flex flex-col gap-2">
52
+ <Button className="h-11" disabled={busy} onClick={onRetry}>
53
+ Retry
54
+ </Button>
55
+ {error?.fallback && (
56
+ <Button
57
+ variant="outline"
58
+ className="h-11"
59
+ disabled={busy}
60
+ onClick={onFallback}
61
+ >
62
+ Start on {name(error.fallback)}
63
+ </Button>
64
+ )}
65
+ <Button variant="outline" className="h-11" onClick={onPick}>
66
+ Pick another model
67
+ </Button>
68
+ </div>
69
+ {error?.fallback && (
70
+ <p className="text-muted-foreground text-xs">
71
+ Starting on the fallback sends your code to wherever that model
72
+ runs.
73
+ </p>
74
+ )}
75
+ </DialogContent>
76
+ </Dialog>
77
+ );
78
+ }
@@ -0,0 +1,110 @@
1
+ "use client";
2
+
3
+ import { Cpu, Users } from "lucide-react";
4
+ import {
5
+ AvailabilityDot,
6
+ LocalityBadge,
7
+ } from "@/components/Models/ModelBadges";
8
+ import { useAgentRolesQuery, useModelChoices } from "@/data/models";
9
+ import type { Draft } from "@/lib/drafts";
10
+ import {
11
+ AGENT_ROLES,
12
+ AGENT_ROLE_DEFS,
13
+ isAgentRole,
14
+ } from "@/lib/models/agent-roles";
15
+ import { ChipItem, ChipMenu } from "./Chip";
16
+
17
+ // A draft's role and the model it runs on. The role's binding gives the
18
+ // model (Settings > Agent roles); the model chip overrides it for this
19
+ // session. Both lists come from the server.
20
+ export function RoleChips({
21
+ draft,
22
+ onChange,
23
+ }: {
24
+ draft: Draft;
25
+ onChange: (patch: Partial<Draft>) => void;
26
+ }) {
27
+ const roles = useAgentRolesQuery("settings");
28
+ const { data: choices = [] } = useModelChoices(!!draft.agentRole);
29
+ const role = isAgentRole(draft.agentRole) ? draft.agentRole : null;
30
+ const bound = roles.data?.roles.find((r) => r.id === role);
31
+ const override = draft.roleModel
32
+ ? choices.find((c) => c.ref === draft.roleModel)
33
+ : undefined;
34
+ const current = override ?? bound?.choice ?? null;
35
+ const modelName = (ref: string | undefined | null) =>
36
+ ref ? ref.split("/").slice(1).join("/") : "…";
37
+ return (
38
+ <>
39
+ <ChipMenu
40
+ icon={Users}
41
+ name="Role"
42
+ label={role ? AGENT_ROLE_DEFS[role].label : "No role"}
43
+ >
44
+ <ChipItem
45
+ checked={!role}
46
+ onSelect={() => onChange({ agentRole: null, roleModel: null })}
47
+ hint="A plain session on the interactive model"
48
+ >
49
+ No role
50
+ </ChipItem>
51
+ {AGENT_ROLES.map((r) => {
52
+ const view = roles.data?.roles.find((x) => x.id === r);
53
+ return (
54
+ <ChipItem
55
+ key={r}
56
+ checked={r === role}
57
+ onSelect={() =>
58
+ onChange({ agentRole: r, roleModel: null, openPr: false })
59
+ }
60
+ hint={`${AGENT_ROLE_DEFS[r].purpose}${view?.binding ? ` · ${modelName(view.binding.ref)}` : ""}`}
61
+ >
62
+ {AGENT_ROLE_DEFS[r].label}
63
+ </ChipItem>
64
+ );
65
+ })}
66
+ </ChipMenu>
67
+ {role && (
68
+ <ChipMenu
69
+ icon={Cpu}
70
+ name="Model"
71
+ label={
72
+ draft.roleModel
73
+ ? modelName(draft.roleModel)
74
+ : `Role model: ${modelName(bound?.binding?.ref)}`
75
+ }
76
+ >
77
+ <ChipItem
78
+ checked={!draft.roleModel}
79
+ onSelect={() => onChange({ roleModel: null })}
80
+ hint={bound?.binding ? modelName(bound.binding.ref) : undefined}
81
+ >
82
+ Role model
83
+ </ChipItem>
84
+ {choices.map((c) => (
85
+ <ChipItem
86
+ key={c.ref}
87
+ checked={c.ref === draft.roleModel}
88
+ onSelect={() => onChange({ roleModel: c.ref })}
89
+ hint={c.group}
90
+ >
91
+ <span className="flex items-center gap-1.5">
92
+ <AvailabilityDot availability={c.availability} />
93
+ {c.label}
94
+ <LocalityBadge locality={c.locality} />
95
+ </span>
96
+ </ChipItem>
97
+ ))}
98
+ </ChipMenu>
99
+ )}
100
+ {role && current?.availability.state === "down" && (
101
+ <span
102
+ role="status"
103
+ className="text-destructive flex h-11 items-center text-xs md:h-8"
104
+ >
105
+ {modelName(current.ref)} isn&apos;t answering
106
+ </span>
107
+ )}
108
+ </>
109
+ );
110
+ }