@saadnvd1/agent-os 0.3.698 → 0.3.715

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 (226) hide show
  1. package/README.md +1 -0
  2. package/app/api/bus/groups/route.ts +44 -0
  3. package/app/api/bus/send/route.ts +11 -1
  4. package/app/api/bus/spawn/route.ts +12 -1
  5. package/app/api/bus/wait/route.ts +22 -0
  6. package/app/api/claims/[id]/route.ts +20 -0
  7. package/app/api/claims/check/route.ts +23 -0
  8. package/app/api/claims/check.test.ts +113 -0
  9. package/app/api/claims/exchange/route.ts +42 -0
  10. package/app/api/claims/outside.test.ts +90 -0
  11. package/app/api/claims/route.test.ts +193 -0
  12. package/app/api/claims/route.ts +73 -0
  13. package/app/api/hosts/[id]/route.ts +9 -1
  14. package/app/api/peer/hello/reveal/route.ts +6 -0
  15. package/app/api/peer/hello/route.ts +9 -0
  16. package/app/api/peer/hello/status/route.ts +6 -0
  17. package/app/api/peer/unlink/route.ts +17 -0
  18. package/app/api/peers/[hostId]/audit/route.ts +14 -0
  19. package/app/api/peers/[hostId]/route.ts +20 -0
  20. package/app/api/peers/account/route.ts +9 -0
  21. package/app/api/peers/gate.test.ts +174 -0
  22. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  23. package/app/api/peers/link/[id]/route.ts +20 -0
  24. package/app/api/peers/link/route.ts +23 -0
  25. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  26. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  27. package/app/api/peers/pending/route.test.ts +37 -0
  28. package/app/api/peers/pending/route.ts +14 -0
  29. package/app/api/session-groups/[id]/restore/route.ts +11 -0
  30. package/app/api/session-groups/[id]/route.ts +25 -0
  31. package/app/api/session-groups/order/route.ts +15 -0
  32. package/app/api/session-groups/route.ts +21 -0
  33. package/app/api/sessions/[id]/fork/route.ts +3 -0
  34. package/app/api/sessions/[id]/group/route.ts +18 -0
  35. package/app/api/sessions/[id]/route.ts +14 -4
  36. package/app/api/sessions/[id]/summarize/route.ts +2 -0
  37. package/app/api/sessions/claims.test.ts +122 -0
  38. package/app/api/sessions/group/route.ts +17 -0
  39. package/app/api/sessions/route.ts +31 -13
  40. package/app/page.tsx +7 -0
  41. package/components/Claims/ClaimFiles.tsx +38 -0
  42. package/components/Claims/ClaimRow.tsx +82 -0
  43. package/components/Claims/ClaimsSheet.tsx +176 -0
  44. package/components/Claims/OverlapPill.tsx +31 -0
  45. package/components/Claims/StartClaimsNotice.tsx +70 -0
  46. package/components/Claims/claims-ui.test.ts +161 -0
  47. package/components/Claims/format.ts +57 -0
  48. package/components/Claims/index.ts +3 -0
  49. package/components/Draft/DraftPanel.tsx +40 -2
  50. package/components/Hosts/AccountMachines.tsx +194 -0
  51. package/components/Hosts/HostRow.tsx +195 -45
  52. package/components/Hosts/HostsDialog.tsx +49 -37
  53. package/components/Hosts/IncomingLinkSheet.tsx +246 -0
  54. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  55. package/components/Hosts/PeerActivitySheet.tsx +111 -0
  56. package/components/Hosts/sheet.ts +3 -0
  57. package/components/SessionList/GroupShelves.tsx +195 -0
  58. package/components/SessionList/MoveToGroupMenu.tsx +105 -0
  59. package/components/SessionList/RowSubtitle.tsx +18 -2
  60. package/components/SessionList/SelectionToolbar.tsx +52 -1
  61. package/components/SessionList/SessionRow.tsx +1 -0
  62. package/components/SessionList/SessionRowMenu.tsx +17 -0
  63. package/components/SessionList/SessionShelves.tsx +5 -0
  64. package/components/SessionList/SidebarBody.tsx +6 -0
  65. package/components/SessionList/SidebarContextMenu.test.ts +104 -0
  66. package/components/SessionList/SidebarContextMenu.tsx +59 -0
  67. package/components/SessionList/SidebarHeader.tsx +5 -0
  68. package/components/SessionList/WorkspaceSwitcher.tsx +23 -6
  69. package/components/SessionList/index.tsx +18 -14
  70. package/components/SessionList/useSidebarData.ts +10 -0
  71. package/components/Welcome/ConnectBlock.tsx +1 -1
  72. package/components/ui/notice.tsx +46 -0
  73. package/components/ui/status-pill.tsx +39 -0
  74. package/data/claims/index.ts +2 -0
  75. package/data/claims/keys.ts +5 -0
  76. package/data/claims/queries.ts +47 -0
  77. package/data/peers/index.ts +2 -0
  78. package/data/peers/keys.ts +7 -0
  79. package/data/peers/queries.ts +172 -0
  80. package/data/push/topics.ts +4 -0
  81. package/data/session-groups/index.ts +156 -0
  82. package/data/sessions/index.ts +0 -1
  83. package/data/sessions/pin.ts +2 -6
  84. package/data/sessions/queries.ts +10 -29
  85. package/data/sessions/shelf.ts +2 -6
  86. package/data/tasks/index.ts +2 -0
  87. package/hooks/useSessions.ts +0 -12
  88. package/lib/__fixtures__/peer-rpc.ts +65 -33
  89. package/lib/agents/brief.ts +3 -1
  90. package/lib/agents/spawn.ts +4 -0
  91. package/lib/bus/format.ts +25 -4
  92. package/lib/bus/index.ts +121 -106
  93. package/lib/bus/resolve.ts +16 -2
  94. package/lib/bus/targets.ts +122 -0
  95. package/lib/bus/waits/kick.ts +12 -0
  96. package/lib/bus/waits/lines.ts +69 -0
  97. package/lib/bus/waits/register.ts +83 -0
  98. package/lib/bus/waits/store.ts +196 -0
  99. package/lib/bus/waits/tick.ts +192 -0
  100. package/lib/claims/collectLocal.ts +121 -0
  101. package/lib/claims/describe.ts +41 -0
  102. package/lib/claims/exchange.ts +91 -0
  103. package/lib/claims/goal.ts +45 -0
  104. package/lib/claims/index.ts +10 -0
  105. package/lib/claims/loop.ts +51 -0
  106. package/lib/claims/machine.ts +21 -0
  107. package/lib/claims/mine.ts +43 -0
  108. package/lib/claims/notifyOverlaps.ts +101 -0
  109. package/lib/claims/onMerged.ts +85 -0
  110. package/lib/claims/outside.ts +36 -0
  111. package/lib/claims/overlap.ts +87 -0
  112. package/lib/claims/overlapCache.ts +86 -0
  113. package/lib/claims/overlapText.ts +36 -0
  114. package/lib/claims/peers.ts +56 -0
  115. package/lib/claims/startCheck.ts +100 -0
  116. package/lib/claims/startWarning.ts +29 -0
  117. package/lib/claims/store.ts +366 -0
  118. package/lib/claims/types.ts +102 -0
  119. package/lib/claims/view.ts +80 -0
  120. package/lib/claims/yield.ts +24 -0
  121. package/lib/connect/control-api.ts +108 -2
  122. package/lib/connect/heartbeat.ts +11 -3
  123. package/lib/connect/serve.ts +2 -0
  124. package/lib/db/changes.ts +6 -0
  125. package/lib/db/migrations/20261020T1400_work_claims.ts +35 -0
  126. package/lib/db/migrations/20261020T1515_peer_unlinked.ts +22 -0
  127. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  128. package/lib/db/migrations/20261020T1700_clean_connect_names.ts +18 -0
  129. package/lib/db/migrations/20261020T1800_session_groups.ts +49 -0
  130. package/lib/db/migrations/20261020T1900_task_queue_after_claim.ts +13 -0
  131. package/lib/db/migrations/20261020T2000_claim_notices.ts +20 -0
  132. package/lib/db/migrations/20261020T2100_claims_via_host.ts +21 -0
  133. package/lib/db/migrations/20261020T2200_work_claims_watch.ts +10 -0
  134. package/lib/db/queries.ts +0 -40
  135. package/lib/db/session-parts/columns.ts +1 -0
  136. package/lib/db/types.ts +20 -8
  137. package/lib/doctor/checks.ts +64 -1
  138. package/lib/done/archive.ts +2 -0
  139. package/lib/git-remote.ts +43 -0
  140. package/lib/hosts/index.ts +10 -1
  141. package/lib/orchestration.ts +2 -0
  142. package/lib/orchestrator/act.ts +54 -4
  143. package/lib/orchestrator/brief.ts +2 -1
  144. package/lib/orchestrator/claim-wait.ts +71 -0
  145. package/lib/orchestrator/conditions.ts +28 -1
  146. package/lib/orchestrator/describe.ts +19 -1
  147. package/lib/orchestrator/facts.ts +22 -0
  148. package/lib/orchestrator/serve.ts +37 -2
  149. package/lib/orchestrator/tool-names.ts +1 -0
  150. package/lib/orchestrator/tool-schemas.ts +31 -1
  151. package/lib/orchestrator/tools.ts +3 -1
  152. package/lib/orchestrator/watcher.ts +19 -1
  153. package/lib/peer/account.ts +270 -0
  154. package/lib/peer/allowlist.ts +1 -1
  155. package/lib/peer/cli.ts +24 -0
  156. package/lib/peer/client.ts +26 -1
  157. package/lib/peer/code.ts +3 -0
  158. package/lib/peer/hello-route.ts +32 -0
  159. package/lib/peer/hello.ts +346 -0
  160. package/lib/peer/link-cli.ts +248 -0
  161. package/lib/peer/links.ts +117 -5
  162. package/lib/peer/machines.ts +84 -0
  163. package/lib/peer/name.ts +32 -0
  164. package/lib/peer/notify.ts +32 -0
  165. package/lib/peer/pairing-out.ts +382 -0
  166. package/lib/peer/pairing.ts +432 -0
  167. package/lib/peer/pin.ts +62 -0
  168. package/lib/peer/reasons.ts +7 -0
  169. package/lib/peer/revoke.ts +75 -0
  170. package/lib/peer/rpc-intake.ts +37 -8
  171. package/lib/peer/rpc-server.ts +72 -6
  172. package/lib/peer/unlink.ts +62 -0
  173. package/lib/peer/unlinked-proof.ts +44 -0
  174. package/lib/projects.ts +5 -2
  175. package/lib/schedules/when.ts +1 -1
  176. package/lib/security/auth.ts +10 -2
  177. package/lib/security/demo.ts +7 -0
  178. package/lib/security/gate.ts +3 -0
  179. package/lib/session-groups/assignSessions.ts +81 -0
  180. package/lib/session-groups/cli.ts +61 -0
  181. package/lib/session-groups/createGroup.ts +28 -0
  182. package/lib/session-groups/index.ts +21 -0
  183. package/lib/session-groups/removeGroup.ts +36 -0
  184. package/lib/session-groups/reorderGroups.ts +31 -0
  185. package/lib/session-groups/resolveGroup.ts +19 -0
  186. package/lib/session-groups/route.ts +32 -0
  187. package/lib/session-groups/store.ts +85 -0
  188. package/lib/session-groups/types.ts +25 -0
  189. package/lib/session-groups/updateGroup.ts +35 -0
  190. package/lib/session-groups/workspace.ts +21 -0
  191. package/lib/sessions/end.ts +3 -0
  192. package/lib/sessions/launch.ts +5 -2
  193. package/lib/sidebar/rollup.ts +1 -0
  194. package/lib/sidebar/row-menu.ts +2 -0
  195. package/lib/sidebar/shelves.ts +90 -15
  196. package/lib/sidebar/visible-order.ts +5 -0
  197. package/lib/tasks/hold-checks.ts +3 -1
  198. package/lib/tasks/holds.ts +3 -0
  199. package/lib/tasks/index.ts +5 -0
  200. package/lib/tasks/queue-rows.ts +8 -1
  201. package/lib/tasks/queue-start.ts +1 -0
  202. package/lib/tasks/queue-types.ts +9 -1
  203. package/lib/tasks/queue.ts +15 -5
  204. package/lib/tasks/schedule-types.ts +1 -0
  205. package/lib/test-temp.ts +19 -3
  206. package/lib/workspaces.ts +11 -3
  207. package/lib/worktree-sweep/testTemp.ts +23 -106
  208. package/package.json +1 -1
  209. package/scripts/agent-os +3 -1
  210. package/scripts/autodeploy +2 -0
  211. package/scripts/autodeploy.test.ts +31 -1
  212. package/scripts/claim-hook.sh +31 -0
  213. package/scripts/claim-hook.test.ts +230 -0
  214. package/scripts/lib/cli-links.sh +36 -0
  215. package/scripts/lib/commands.sh +5 -0
  216. package/scripts/lib/machine.sh +27 -2
  217. package/scripts/lib/machine.test.ts +25 -3
  218. package/scripts/sync-cli-links +7 -0
  219. package/scripts/sync-cli-links.test.ts +69 -0
  220. package/server.ts +6 -0
  221. package/stores/claimsUi.ts +22 -0
  222. package/app/api/groups/[...path]/route.ts +0 -136
  223. package/app/api/groups/route.ts +0 -93
  224. package/data/groups/index.ts +0 -1
  225. package/data/groups/mutations.ts +0 -95
  226. package/hooks/useGroups.ts +0 -37
@@ -0,0 +1,70 @@
1
+ "use client";
2
+
3
+ import { Button } from "@/components/ui/button";
4
+ import { Notice } from "@/components/ui/notice";
5
+ import type { StartWarning } from "@/lib/claims/startWarning";
6
+
7
+ // Above the composer when a person's task start overlaps claimed work. They
8
+ // choose: start anyway, queue it behind that work, or go back and edit.
9
+ export function StartClaimsNotice({
10
+ warnings,
11
+ busy,
12
+ onStart,
13
+ onDismiss,
14
+ }: {
15
+ warnings: StartWarning[];
16
+ busy: boolean;
17
+ onStart: (after?: string) => void;
18
+ onDismiss: () => void;
19
+ }) {
20
+ const top = warnings[0];
21
+ if (!top) return null;
22
+ const files = top.shared.slice(0, 3).join(", ");
23
+ return (
24
+ <Notice
25
+ tone="warning"
26
+ action={
27
+ <div className="flex flex-wrap gap-2">
28
+ <Button
29
+ size="sm"
30
+ variant="outline"
31
+ className="min-h-11 md:min-h-8"
32
+ disabled={busy}
33
+ onClick={() => onStart()}
34
+ >
35
+ Start anyway
36
+ </Button>
37
+ <Button
38
+ size="sm"
39
+ className="min-h-11 md:min-h-8"
40
+ disabled={busy}
41
+ onClick={() => onStart(`claim:${top.claim.id}`)}
42
+ >
43
+ Start after it
44
+ </Button>
45
+ <Button
46
+ size="sm"
47
+ variant="ghost"
48
+ className="min-h-11 md:min-h-8"
49
+ disabled={busy}
50
+ onClick={onDismiss}
51
+ >
52
+ Edit
53
+ </Button>
54
+ </div>
55
+ }
56
+ >
57
+ <p className="font-medium">
58
+ {top.strength === "strong"
59
+ ? "Someone is already on this"
60
+ : "This may overlap"}
61
+ {warnings.length > 1 ? ` (${warnings.length} claims)` : ""}
62
+ </p>
63
+ <p className="text-muted-foreground text-xs break-words">
64
+ “{top.claim.goal}” on {top.claim.machine}
65
+ {top.claim.branch ? ` (${top.claim.branch})` : ""}
66
+ {files ? ` touches ${files}` : ""}
67
+ </p>
68
+ </Notice>
69
+ );
70
+ }
@@ -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";
@@ -15,6 +15,9 @@ import { useGitCheck } from "@/data/git/queries";
15
15
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
16
16
  import { useLaunchSession } from "@/data/sessions";
17
17
  import { useCreateTask } from "@/data/tasks";
18
+ import { useCheckStart } from "@/data/claims";
19
+ import { StartClaimsNotice } from "@/components/Claims";
20
+ import type { StartWarning } from "@/lib/claims/startWarning";
18
21
  import { usePanes } from "@/contexts/PaneContext";
19
22
  import { setPendingPrompt } from "@/stores/initialPrompt";
20
23
  import { draftsActions, draftsStore, newDraft } from "@/stores/drafts";
@@ -53,8 +56,15 @@ export function DraftPanel({
53
56
  const launch = useLaunchSession();
54
57
  const { workspace } = useSelectedWorkspace();
55
58
  const createTask = useCreateTask();
59
+ const checkStart = useCheckStart();
56
60
  const [prefill, setPrefill] = useState<{ text: string; at: number }>();
57
- const pending = launch.isPending || createTask.isPending;
61
+ // A task start that overlaps claimed work waits here for the person's choice.
62
+ const [held, setHeld] = useState<{
63
+ text: string;
64
+ warnings: StartWarning[];
65
+ } | null>(null);
66
+ const pending =
67
+ launch.isPending || createTask.isPending || checkStart.isPending;
58
68
 
59
69
  if (!hydrated) return null;
60
70
  if (!draft)
@@ -67,15 +77,32 @@ export function DraftPanel({
67
77
  </div>
68
78
  );
69
79
 
70
- const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
80
+ const send = async (
81
+ text: string,
82
+ images: ChatImage[],
83
+ files: ChatFile[],
84
+ claim?: { after?: string }
85
+ ) => {
71
86
  try {
72
87
  if (draft.openPr) {
73
88
  if (!project) throw new Error("Pick a project for a task");
74
89
  if (images.length || files.length)
75
90
  throw new Error("A task takes text only");
91
+ if (!claim && draft.hostId === project.host_id) {
92
+ const warnings = await checkStart.mutateAsync({
93
+ projectId: project.id,
94
+ prompt: text,
95
+ });
96
+ if (warnings.length) {
97
+ setHeld({ text, warnings });
98
+ return;
99
+ }
100
+ }
101
+ setHeld(null);
76
102
  const { session, queued } = await createTask.mutateAsync({
77
103
  projectId: project.id,
78
104
  prompt: text,
105
+ after: claim?.after,
79
106
  model: draft.taskModel ?? undefined,
80
107
  priority: draft.taskPriority,
81
108
  baseBranch: draft.baseBranch ?? undefined,
@@ -145,6 +172,17 @@ export function DraftPanel({
145
172
  git={git}
146
173
  onChange={(patch) => draftsActions.update(draft.id, patch)}
147
174
  />
175
+ {held && (
176
+ <StartClaimsNotice
177
+ warnings={held.warnings}
178
+ busy={pending}
179
+ onStart={(after) => void send(held.text, [], [], { after })}
180
+ onDismiss={() => {
181
+ setPrefill({ text: held.text, at: Date.now() });
182
+ setHeld(null);
183
+ }}
184
+ />
185
+ )}
148
186
  <Composer
149
187
  draftKey={draftComposerKey(draft.id)}
150
188
  attachScope="draft"
@@ -0,0 +1,194 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { ExternalLink, Link2, Loader2, RefreshCw } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Notice } from "@/components/ui/notice";
7
+ import { LinkForm } from "@/components/Welcome/ConnectBlock";
8
+ import {
9
+ useAccountMachines,
10
+ useRefreshAccount,
11
+ useStartLink,
12
+ } from "@/data/peers";
13
+ import { UPGRADE_URL } from "@/lib/peer/reasons";
14
+ import type { AccountMachine } from "@/lib/peer/machines";
15
+ import { cn } from "@/lib/utils";
16
+ import { WaitingLinks } from "./IncomingLinkSheet";
17
+ import { LinkCodeSheet } from "./LinkCodeSheet";
18
+
19
+ function MachineRow({
20
+ machine,
21
+ canLink,
22
+ onLink,
23
+ starting,
24
+ }: {
25
+ machine: AccountMachine;
26
+ canLink: boolean;
27
+ onLink: () => void;
28
+ starting: boolean;
29
+ }) {
30
+ const linked = machine.linkState === "active";
31
+ return (
32
+ <div
33
+ className={cn(
34
+ "bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5",
35
+ !canLink && !linked && "opacity-60"
36
+ )}
37
+ >
38
+ <span
39
+ className={cn(
40
+ "h-2 w-2 shrink-0 rounded-full",
41
+ machine.online ? "bg-emerald-500" : "bg-muted-foreground/40"
42
+ )}
43
+ aria-label={machine.online ? "Online" : "Offline"}
44
+ />
45
+ <div className="min-w-0 flex-1">
46
+ <p className="truncate text-sm font-medium">{machine.name}</p>
47
+ <p className="text-muted-foreground truncate text-xs">
48
+ {[
49
+ machine.online ? "Online" : "Offline",
50
+ machine.version && `AgentOS ${machine.version}`,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" · ")}
54
+ </p>
55
+ </div>
56
+ {linked ? (
57
+ <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
58
+ Linked
59
+ </span>
60
+ ) : machine.linkState === "suspended" ? (
61
+ <span
62
+ className="text-xs text-amber-700 dark:text-amber-400"
63
+ title={machine.suspendedReason ?? undefined}
64
+ >
65
+ Suspended
66
+ </span>
67
+ ) : (
68
+ <Button
69
+ variant="outline"
70
+ size="sm"
71
+ className="min-h-11 sm:min-h-8"
72
+ disabled={!canLink || starting}
73
+ onClick={onLink}
74
+ >
75
+ {starting ? (
76
+ <Loader2 className="h-4 w-4 animate-spin" />
77
+ ) : (
78
+ <Link2 className="h-4 w-4" />
79
+ )}
80
+ Link
81
+ </Button>
82
+ )}
83
+ </div>
84
+ );
85
+ }
86
+
87
+ /** Machines → "On your AgentOS account": link them through Connect. */
88
+ export function AccountMachines() {
89
+ const { data, isPending, error } = useAccountMachines(true);
90
+ const refresh = useRefreshAccount();
91
+ const start = useStartLink();
92
+ const [linkId, setLinkId] = useState<string | null>(null);
93
+ const [target, setTarget] = useState<string | null>(null);
94
+
95
+ const link = (machineId: string) => {
96
+ setTarget(machineId);
97
+ start.mutate(machineId, { onSuccess: (l) => setLinkId(l.id) });
98
+ };
99
+
100
+ if (isPending)
101
+ return <div className="bg-muted/40 h-14 animate-pulse rounded-lg" />;
102
+ if (error) return <p className="text-destructive text-xs">{error.message}</p>;
103
+ if (!data.enrolled)
104
+ return (
105
+ <div className="space-y-2 rounded-lg border p-3">
106
+ <p className="text-sm font-medium">
107
+ Connect this machine to link it with your others
108
+ </p>
109
+ <LinkForm />
110
+ </div>
111
+ );
112
+
113
+ const entitled = data.entitlements?.linkMachines === true;
114
+ return (
115
+ <section className="space-y-2" aria-label="On your AgentOS account">
116
+ <div className="flex items-center justify-between gap-2">
117
+ <p className="text-sm font-medium">On your AgentOS account</p>
118
+ <Button
119
+ variant="ghost"
120
+ size="icon-sm"
121
+ className="h-11 w-11 sm:h-8 sm:w-8"
122
+ aria-label="Check your account again"
123
+ disabled={refresh.isPending}
124
+ onClick={() => refresh.mutate()}
125
+ >
126
+ <RefreshCw
127
+ className={cn("h-4 w-4", refresh.isPending && "animate-spin")}
128
+ />
129
+ </Button>
130
+ </div>
131
+ {data.uncheckedSince && (
132
+ <Notice tone="warning">
133
+ Couldn&apos;t check your account since{" "}
134
+ {new Date(data.uncheckedSince).toLocaleString([], {
135
+ dateStyle: "medium",
136
+ timeStyle: "short",
137
+ })}
138
+ . Machines already linked keep working; new links wait until it&apos;s
139
+ back.
140
+ </Notice>
141
+ )}
142
+ {data.entitlements && !entitled && (
143
+ <Notice
144
+ action={
145
+ <Button asChild variant="outline" className="min-h-11 sm:min-h-9">
146
+ <a href={UPGRADE_URL} target="_blank" rel="noopener noreferrer">
147
+ Upgrade
148
+ <ExternalLink className="h-3.5 w-3.5" />
149
+ </a>
150
+ </Button>
151
+ }
152
+ >
153
+ Link your machines with Pro: tasks move between them over any network,
154
+ end-to-end encrypted.
155
+ </Notice>
156
+ )}
157
+ {entitled && <WaitingLinks />}
158
+ {data.error && (
159
+ <p className="text-muted-foreground text-xs">{data.error}</p>
160
+ )}
161
+ {data.machines.length === 0 ? (
162
+ <p className="text-muted-foreground text-xs">
163
+ No other machines on your account yet. Run{" "}
164
+ <code className="font-mono">agent-os connect</code> on another
165
+ machine, then link it here.
166
+ </p>
167
+ ) : (
168
+ data.machines.map((m) => (
169
+ <MachineRow
170
+ key={m.id}
171
+ machine={m}
172
+ canLink={entitled}
173
+ starting={start.isPending && target === m.id}
174
+ onLink={() => link(m.id)}
175
+ />
176
+ ))
177
+ )}
178
+ {start.error && (
179
+ <p className="text-destructive text-xs">{start.error.message}</p>
180
+ )}
181
+ <LinkCodeSheet
182
+ linkId={linkId}
183
+ onClose={() => {
184
+ setLinkId(null);
185
+ start.reset();
186
+ }}
187
+ onRetry={() => {
188
+ setLinkId(null);
189
+ if (target) link(target);
190
+ }}
191
+ />
192
+ </section>
193
+ );
194
+ }