@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
@@ -1,16 +1,77 @@
1
1
  "use client";
2
2
 
3
+ import { useState } from "react";
3
4
  import { Button } from "@/components/ui/button";
4
- import { Monitor, Server, Trash2, PlugZap, Loader2, Link2 } from "lucide-react";
5
+ import {
6
+ Activity,
7
+ ExternalLink,
8
+ Monitor,
9
+ Server,
10
+ Trash2,
11
+ PlugZap,
12
+ Loader2,
13
+ Link2,
14
+ Unlink,
15
+ } from "lucide-react";
16
+ import { Notice } from "@/components/ui/notice";
5
17
  import type { Host } from "@/lib/db";
6
18
  import { useDeleteHost, useLinkHost, useTestHost } from "@/data/hosts";
19
+ import { useUnlinkPeer } from "@/data/peers";
20
+ import { NEEDS_PRO, UPGRADE_URL } from "@/lib/peer/reasons";
7
21
  import { cn } from "@/lib/utils";
22
+ import { PeerActivitySheet } from "./PeerActivitySheet";
23
+
24
+ function connectLine(host: Host): string {
25
+ if (host.peer_state === "active")
26
+ return "Linked through Connect · tasks run on its AgentOS";
27
+ if (host.peer_state === "suspended") return "Through Connect · suspended";
28
+ if (host.peer_state === "unlinked")
29
+ return `Through Connect · ${host.peer_note ?? "unlinked"}`;
30
+ return "Through Connect · not linked";
31
+ }
32
+
33
+ /** Why a Connect link is suspended, with the way out when there is one. */
34
+ function SuspendedNote({ reason }: { reason: string | null | undefined }) {
35
+ const plan = reason === NEEDS_PRO;
36
+ return (
37
+ <Notice
38
+ tone="warning"
39
+ className="mt-2"
40
+ action={
41
+ plan && (
42
+ <Button
43
+ asChild
44
+ variant="outline"
45
+ size="sm"
46
+ className="min-h-11 sm:min-h-8"
47
+ >
48
+ <a href={UPGRADE_URL} target="_blank" rel="noopener noreferrer">
49
+ Upgrade
50
+ <ExternalLink className="h-3.5 w-3.5" />
51
+ </a>
52
+ </Button>
53
+ )
54
+ }
55
+ >
56
+ {plan
57
+ ? "Linking machines needs Pro. Calls both ways are refused until you upgrade; it comes back without linking again."
58
+ : `Suspended: ${reason ?? "no reason given"}. Calls both ways are refused; it comes back on its own if the machine returns to your account.`}
59
+ </Notice>
60
+ );
61
+ }
8
62
 
9
63
  export function HostRow({ host }: { host: Host }) {
10
64
  const deleteHost = useDeleteHost();
11
65
  const testHost = useTestHost();
12
66
  const linkHost = useLinkHost();
67
+ const unlink = useUnlinkPeer();
68
+ const [activity, setActivity] = useState(false);
69
+ const [confirmUnlink, setConfirmUnlink] = useState(false);
13
70
  const isLocal = host.id === "local";
71
+ const viaConnect = host.kind === "connect";
72
+ const pinned =
73
+ viaConnect &&
74
+ (host.peer_state === "active" || host.peer_state === "suspended");
14
75
  const result = testHost.data;
15
76
  const Icon = isLocal ? Monitor : Server;
16
77
 
@@ -23,54 +84,90 @@ export function HostRow({ host }: { host: Host }) {
23
84
  <p className="text-muted-foreground truncate text-xs">
24
85
  {isLocal
25
86
  ? "Where agent-os is running"
26
- : host.linked
27
- ? `${host.ssh_target} · tasks run on its AgentOS`
28
- : host.ssh_target}
87
+ : viaConnect
88
+ ? connectLine(host)
89
+ : host.linked
90
+ ? `${host.ssh_target} · tasks run on its AgentOS`
91
+ : host.ssh_target}
29
92
  </p>
30
93
  </div>
31
94
  {!isLocal && (
32
95
  <>
33
- <Button
34
- variant="ghost"
35
- size="icon-sm"
36
- className="h-11 w-11 sm:h-8 sm:w-8"
37
- onClick={() => testHost.mutate(host.id)}
38
- disabled={testHost.isPending}
39
- aria-label="Test connection"
40
- >
41
- {testHost.isPending ? (
42
- <Loader2 className="h-4 w-4 animate-spin" />
43
- ) : (
44
- <PlugZap className="h-4 w-4" />
45
- )}
46
- </Button>
47
- <Button
48
- variant="ghost"
49
- size="icon-sm"
50
- className="h-11 w-11 sm:h-8 sm:w-8"
51
- onClick={() => linkHost.mutate(host.id)}
52
- disabled={linkHost.isPending}
53
- aria-label={
54
- host.linked ? "Link its AgentOS again" : "Link its AgentOS"
55
- }
56
- title="Pair with the AgentOS running there, so tasks can run on it"
57
- >
58
- {linkHost.isPending ? (
59
- <Loader2 className="h-4 w-4 animate-spin" />
60
- ) : (
61
- <Link2 className="h-4 w-4" />
62
- )}
63
- </Button>
64
- <Button
65
- variant="ghost"
66
- size="icon-sm"
67
- className="h-11 w-11 sm:h-8 sm:w-8"
68
- onClick={() => deleteHost.mutate(host.id)}
69
- disabled={deleteHost.isPending}
70
- aria-label="Remove machine"
71
- >
72
- <Trash2 className="h-4 w-4" />
73
- </Button>
96
+ {!viaConnect && (
97
+ <>
98
+ <Button
99
+ variant="ghost"
100
+ size="icon-sm"
101
+ className="h-11 w-11 sm:h-8 sm:w-8"
102
+ onClick={() => testHost.mutate(host.id)}
103
+ disabled={testHost.isPending}
104
+ aria-label="Test connection"
105
+ >
106
+ {testHost.isPending ? (
107
+ <Loader2 className="h-4 w-4 animate-spin" />
108
+ ) : (
109
+ <PlugZap className="h-4 w-4" />
110
+ )}
111
+ </Button>
112
+ <Button
113
+ variant="ghost"
114
+ size="icon-sm"
115
+ className="h-11 w-11 sm:h-8 sm:w-8"
116
+ onClick={() => linkHost.mutate(host.id)}
117
+ disabled={linkHost.isPending}
118
+ aria-label={
119
+ host.linked ? "Link its AgentOS again" : "Link its AgentOS"
120
+ }
121
+ title="Pair with the AgentOS running there, so tasks can run on it"
122
+ >
123
+ {linkHost.isPending ? (
124
+ <Loader2 className="h-4 w-4 animate-spin" />
125
+ ) : (
126
+ <Link2 className="h-4 w-4" />
127
+ )}
128
+ </Button>
129
+ </>
130
+ )}
131
+ {viaConnect && host.peer_state && (
132
+ <Button
133
+ variant="ghost"
134
+ size="icon-sm"
135
+ className="h-11 w-11 sm:h-8 sm:w-8"
136
+ onClick={() => setActivity(true)}
137
+ aria-label="Activity"
138
+ title="Every call to and from this machine"
139
+ >
140
+ <Activity className="h-4 w-4" />
141
+ </Button>
142
+ )}
143
+ {pinned ? (
144
+ <Button
145
+ variant="ghost"
146
+ size="icon-sm"
147
+ className="h-11 w-11 sm:h-8 sm:w-8"
148
+ onClick={() => setConfirmUnlink(true)}
149
+ disabled={unlink.isPending || confirmUnlink}
150
+ aria-label="Unlink"
151
+ title="Unlink: it can no longer call this machine, nor this one it"
152
+ >
153
+ {unlink.isPending ? (
154
+ <Loader2 className="h-4 w-4 animate-spin" />
155
+ ) : (
156
+ <Unlink className="h-4 w-4" />
157
+ )}
158
+ </Button>
159
+ ) : (
160
+ <Button
161
+ variant="ghost"
162
+ size="icon-sm"
163
+ className="h-11 w-11 sm:h-8 sm:w-8"
164
+ onClick={() => deleteHost.mutate(host.id)}
165
+ disabled={deleteHost.isPending}
166
+ aria-label="Remove machine"
167
+ >
168
+ <Trash2 className="h-4 w-4" />
169
+ </Button>
170
+ )}
74
171
  </>
75
172
  )}
76
173
  </div>
@@ -98,6 +195,59 @@ export function HostRow({ host }: { host: Host }) {
98
195
  {linkHost.error.message}
99
196
  </p>
100
197
  )}
198
+ {host.peer_state === "suspended" && (
199
+ <SuspendedNote reason={host.peer_note} />
200
+ )}
201
+ {confirmUnlink && (
202
+ <Notice
203
+ tone="warning"
204
+ className="mt-2"
205
+ action={
206
+ <div className="flex gap-2">
207
+ <Button
208
+ variant="ghost"
209
+ size="sm"
210
+ className="min-h-11 sm:min-h-8"
211
+ onClick={() => setConfirmUnlink(false)}
212
+ >
213
+ Cancel
214
+ </Button>
215
+ <Button
216
+ variant="destructive"
217
+ size="sm"
218
+ className="min-h-11 sm:min-h-8"
219
+ onClick={() => {
220
+ setConfirmUnlink(false);
221
+ unlink.mutate(host.id);
222
+ }}
223
+ >
224
+ Unlink
225
+ </Button>
226
+ </div>
227
+ }
228
+ >
229
+ Unlink {host.name}? It&apos;s unlinked on both machines, and linking
230
+ them again needs a fresh pairing with new codes.
231
+ </Notice>
232
+ )}
233
+ {unlink.data && !unlink.data.told && (
234
+ <p className="text-muted-foreground mt-1.5 text-xs">
235
+ Unlinked here. {host.name} couldn&apos;t be told now; its next call is
236
+ refused and it will show as unlinked.
237
+ </p>
238
+ )}
239
+ {unlink.error && (
240
+ <p className="text-destructive mt-1.5 text-xs">
241
+ {unlink.error.message}
242
+ </p>
243
+ )}
244
+ {viaConnect && (
245
+ <PeerActivitySheet
246
+ hostId={activity ? host.id : null}
247
+ name={host.name}
248
+ onClose={() => setActivity(false)}
249
+ />
250
+ )}
101
251
  {deleteHost.error && (
102
252
  <p className="text-destructive mt-1.5 text-xs">
103
253
  {deleteHost.error.message}
@@ -11,6 +11,7 @@ import {
11
11
  import { Button } from "@/components/ui/button";
12
12
  import { Input } from "@/components/ui/input";
13
13
  import { useHostsQuery, useCreateHost } from "@/data/hosts";
14
+ import { AccountMachines } from "./AccountMachines";
14
15
  import { HostRow } from "./HostRow";
15
16
 
16
17
  interface HostsDialogProps {
@@ -39,17 +40,19 @@ export function HostsDialog({ open, onClose }: HostsDialogProps) {
39
40
 
40
41
  return (
41
42
  <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
42
- <DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto">
43
+ <DialogContent className="max-h-[90vh] max-w-lg overflow-y-auto [&>*]:min-w-0">
43
44
  <DialogHeader>
44
45
  <DialogTitle>Machines</DialogTitle>
45
46
  <DialogDescription>
46
- Sessions run in tmux on any machine you can reach with ssh keys.
47
- Projects pick a machine; tmux sessions already running there show up
48
- under the project whose folder they are in.
47
+ Link your other machines to start tasks on them and move tasks
48
+ between them. Through AgentOS Connect it works over any network.
49
49
  </DialogDescription>
50
50
  </DialogHeader>
51
51
 
52
+ <AccountMachines />
53
+
52
54
  <div className="space-y-2">
55
+ <p className="text-sm font-medium">Machines here</p>
53
56
  {isPending && (
54
57
  <div className="bg-muted/40 h-14 animate-pulse rounded-lg" />
55
58
  )}
@@ -58,39 +61,48 @@ export function HostsDialog({ open, onClose }: HostsDialogProps) {
58
61
  ))}
59
62
  </div>
60
63
 
61
- <form onSubmit={handleAdd} className="space-y-2 pt-2">
62
- <p className="text-sm font-medium">Add a machine</p>
63
- <div className="flex flex-col gap-2 sm:flex-row">
64
- <Input
65
- placeholder="Name (e.g. devbox)"
66
- value={name}
67
- onChange={(e) => setName(e.target.value)}
68
- className="h-11 sm:h-9"
69
- />
70
- <Input
71
- placeholder="ssh target (user@host or alias)"
72
- value={sshTarget}
73
- onChange={(e) => setSshTarget(e.target.value)}
74
- autoCapitalize="none"
75
- autoCorrect="off"
76
- spellCheck={false}
77
- className="h-11 sm:h-9"
78
- />
79
- </div>
80
- {createHost.error && (
81
- <p className="text-sm text-red-400">{createHost.error.message}</p>
82
- )}
83
- <div className="flex justify-end">
84
- <Button
85
- type="submit"
86
- disabled={
87
- !name.trim() || !sshTarget.trim() || createHost.isPending
88
- }
89
- >
90
- Add machine
91
- </Button>
92
- </div>
93
- </form>
64
+ <details className="group">
65
+ <summary className="text-muted-foreground flex min-h-11 cursor-pointer items-center text-sm select-none sm:min-h-9">
66
+ Other ways to link (Tailscale or ssh)
67
+ </summary>
68
+ <p className="text-muted-foreground pb-2 text-xs">
69
+ Sessions run in tmux on any machine you can reach with ssh keys.
70
+ Projects pick a machine; tmux sessions already running there show up
71
+ under the project whose folder they are in.
72
+ </p>
73
+ <form onSubmit={handleAdd} className="space-y-2">
74
+ <div className="flex flex-col gap-2 sm:flex-row">
75
+ <Input
76
+ placeholder="Name (e.g. devbox)"
77
+ value={name}
78
+ onChange={(e) => setName(e.target.value)}
79
+ className="h-11 sm:h-9"
80
+ />
81
+ <Input
82
+ placeholder="ssh target (user@host or alias)"
83
+ value={sshTarget}
84
+ onChange={(e) => setSshTarget(e.target.value)}
85
+ autoCapitalize="none"
86
+ autoCorrect="off"
87
+ spellCheck={false}
88
+ className="h-11 sm:h-9"
89
+ />
90
+ </div>
91
+ {createHost.error && (
92
+ <p className="text-sm text-red-400">{createHost.error.message}</p>
93
+ )}
94
+ <div className="flex justify-end">
95
+ <Button
96
+ type="submit"
97
+ disabled={
98
+ !name.trim() || !sshTarget.trim() || createHost.isPending
99
+ }
100
+ >
101
+ Add machine
102
+ </Button>
103
+ </div>
104
+ </form>
105
+ </details>
94
106
  </DialogContent>
95
107
  </Dialog>
96
108
  );
@@ -0,0 +1,246 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { CheckCircle2, ShieldAlert, X } from "lucide-react";
5
+ import {
6
+ Dialog,
7
+ DialogContent,
8
+ DialogDescription,
9
+ DialogHeader,
10
+ DialogTitle,
11
+ } from "@/components/ui/dialog";
12
+ import { Button } from "@/components/ui/button";
13
+ import { Input } from "@/components/ui/input";
14
+ import { useAnswerLink, usePendingLinks } from "@/data/peers";
15
+ import { showCode } from "@/lib/peer/code";
16
+ import type { PendingView } from "@/lib/peer/pairing";
17
+ import { relativeTime } from "@/lib/schedules/when";
18
+ import { SHEET } from "./sheet";
19
+
20
+ /** Its Connect id and when it was last online, next to the account's name. */
21
+ function Origin({ request }: { request: PendingView }) {
22
+ const seen = request.lastSeenAt ? Date.parse(request.lastSeenAt) : NaN;
23
+ return (
24
+ <span className="text-muted-foreground font-mono text-xs">
25
+ {request.machineId.slice(0, 8)}
26
+ {Number.isFinite(seen) && ` · seen ${relativeTime(seen)}`}
27
+ </span>
28
+ );
29
+ }
30
+
31
+ const asking = (r: PendingView) =>
32
+ r.state === "accepted" ? "is linked: finish there" : "wants to link";
33
+
34
+ /** On the machine being asked: type the code the other one shows. */
35
+ export function IncomingLinkSheet({
36
+ request,
37
+ onClose,
38
+ }: {
39
+ request: PendingView | null;
40
+ onClose: () => void;
41
+ }) {
42
+ const [code, setCode] = useState("");
43
+ const { accept, decline } = useAnswerLink();
44
+ const digits = code.replace(/\D/g, "");
45
+ const busy = accept.isPending || decline.isPending;
46
+ const close = () => {
47
+ setCode("");
48
+ accept.reset();
49
+ onClose();
50
+ };
51
+ const left = (accept.error as { attemptsLeft?: number } | null)?.attemptsLeft;
52
+ // Held by the server too, so a reload on this machine still shows it.
53
+ const done =
54
+ accept.data ??
55
+ (request?.confirmCode
56
+ ? { name: request.name, confirmCode: request.confirmCode }
57
+ : null);
58
+
59
+ if (done)
60
+ return (
61
+ <Dialog open={!!request} onOpenChange={(o) => !o && close()}>
62
+ <DialogContent className={SHEET}>
63
+ <DialogHeader>
64
+ <DialogTitle>Finish on {done.name}</DialogTitle>
65
+ <DialogDescription>
66
+ {done.name} is linked here. Type this code on {done.name} so it
67
+ links back to this machine.
68
+ </DialogDescription>
69
+ </DialogHeader>
70
+ <p
71
+ className="text-center font-mono text-4xl font-semibold tracking-[0.2em] tabular-nums sm:text-5xl"
72
+ aria-label={`Code ${done.confirmCode.split("").join(" ")}`}
73
+ data-testid="confirm-code"
74
+ >
75
+ {showCode(done.confirmCode)}
76
+ </p>
77
+ <p className="flex items-center justify-center gap-2 text-sm">
78
+ <CheckCircle2 className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
79
+ Code matched
80
+ </p>
81
+ <div className="flex justify-end">
82
+ <Button className="min-h-11 sm:min-h-9" onClick={close}>
83
+ Done
84
+ </Button>
85
+ </div>
86
+ </DialogContent>
87
+ </Dialog>
88
+ );
89
+
90
+ return (
91
+ <Dialog open={!!request} onOpenChange={(o) => !o && close()}>
92
+ <DialogContent className={SHEET}>
93
+ <DialogHeader>
94
+ <DialogTitle>Link {request?.name}?</DialogTitle>
95
+ <DialogDescription>
96
+ {request?.name} asked to link with this machine. Type the code it
97
+ shows to allow it.
98
+ </DialogDescription>
99
+ {request && <Origin request={request} />}
100
+ </DialogHeader>
101
+ <div className="flex gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
102
+ <ShieldAlert className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
103
+ <p>
104
+ Linked machines can start agents and run code on this machine. Only
105
+ link one you own.
106
+ </p>
107
+ </div>
108
+ <form
109
+ className="flex flex-col gap-2"
110
+ onSubmit={(e) => {
111
+ e.preventDefault();
112
+ if (request && digits.length === 6)
113
+ accept.mutate({ id: request.id, code: digits });
114
+ }}
115
+ >
116
+ <Input
117
+ value={code}
118
+ onChange={(e) => setCode(e.target.value.slice(0, 7))}
119
+ inputMode="numeric"
120
+ autoComplete="one-time-code"
121
+ placeholder="6-digit code"
122
+ aria-label="Code shown on the other machine"
123
+ className="h-12 text-center font-mono text-2xl tracking-[0.3em]"
124
+ autoFocus
125
+ />
126
+ {accept.error && (
127
+ <p className="text-destructive text-sm">
128
+ {accept.error.message}
129
+ {left ? ` ${left} ${left === 1 ? "try" : "tries"} left.` : ""}
130
+ </p>
131
+ )}
132
+ <div className="flex justify-end gap-2">
133
+ <Button
134
+ type="button"
135
+ variant="ghost"
136
+ className="min-h-11 sm:min-h-9"
137
+ disabled={busy}
138
+ onClick={() =>
139
+ request && decline.mutate(request.id, { onSuccess: close })
140
+ }
141
+ >
142
+ Decline
143
+ </Button>
144
+ <Button
145
+ type="submit"
146
+ className="min-h-11 sm:min-h-9"
147
+ disabled={busy || digits.length !== 6 || left === 0}
148
+ >
149
+ Link
150
+ </Button>
151
+ </div>
152
+ </form>
153
+ </DialogContent>
154
+ </Dialog>
155
+ );
156
+ }
157
+
158
+ /** A banner on every page while a machine is waiting for its code here. */
159
+ export function IncomingLinks() {
160
+ const { data: requests = [] } = usePendingLinks();
161
+ // Held while open, so a request voided by a wrong code still says why.
162
+ const [open, setOpen] = useState<PendingView | null>(null);
163
+ // Hidden until a request not seen yet arrives; it covers the header on phones.
164
+ const [later, setLater] = useState<string | null>(null);
165
+ const shown = requests.filter((r) => !later?.includes(r.id));
166
+ const first = shown[0];
167
+ const hidden = shown.length === 0;
168
+ return (
169
+ <>
170
+ {first && !open && !hidden && (
171
+ <div
172
+ role="status"
173
+ className="popover-surface fixed top-[max(0.75rem,env(safe-area-inset-top))] left-1/2 z-50 flex w-[min(28rem,calc(100%-1.5rem))] -translate-x-1/2 items-center gap-3 rounded-xl px-3 py-2 shadow-lg"
174
+ >
175
+ <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
176
+ <div className="flex min-w-0 flex-1 flex-col">
177
+ <p className="truncate text-sm">
178
+ <span className="font-medium">{first.name}</span> {asking(first)}
179
+ {shown.length > 1 ? ` (+${shown.length - 1} more)` : ""}
180
+ </p>
181
+ <Origin request={first} />
182
+ </div>
183
+ <Button
184
+ size="sm"
185
+ className="min-h-11 sm:min-h-8"
186
+ onClick={() => setOpen(first)}
187
+ >
188
+ {first.state === "accepted" ? "Show code" : "Review"}
189
+ </Button>
190
+ <Button
191
+ size="icon"
192
+ variant="ghost"
193
+ aria-label="Later"
194
+ className="h-11 w-11 shrink-0 sm:h-8 sm:w-8"
195
+ onClick={() =>
196
+ setLater(`${later ?? ""} ${shown.map((r) => r.id).join(" ")}`)
197
+ }
198
+ >
199
+ <X className="h-4 w-4" />
200
+ </Button>
201
+ </div>
202
+ )}
203
+ <IncomingLinkSheet
204
+ request={open}
205
+ onClose={() => {
206
+ // Answered, or put off: either way it stops covering the page.
207
+ if (open) setLater(`${later ?? ""} ${open.id}`);
208
+ setOpen(null);
209
+ }}
210
+ />
211
+ </>
212
+ );
213
+ }
214
+
215
+ /** In Machines: the requests waiting here, for after the banner was put off. */
216
+ export function WaitingLinks() {
217
+ const { data: requests = [] } = usePendingLinks();
218
+ const [open, setOpen] = useState<PendingView | null>(null);
219
+ if (requests.length === 0 && !open) return null;
220
+ return (
221
+ <>
222
+ {requests.map((r) => (
223
+ <div
224
+ key={r.id}
225
+ className="flex items-center gap-3 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2"
226
+ >
227
+ <ShieldAlert className="h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
228
+ <div className="flex min-w-0 flex-1 flex-col">
229
+ <p className="truncate text-sm">
230
+ <span className="font-medium">{r.name}</span> {asking(r)}
231
+ </p>
232
+ <Origin request={r} />
233
+ </div>
234
+ <Button
235
+ size="sm"
236
+ className="min-h-11 sm:min-h-8"
237
+ onClick={() => setOpen(r)}
238
+ >
239
+ {r.state === "accepted" ? "Show code" : "Type code"}
240
+ </Button>
241
+ </div>
242
+ ))}
243
+ <IncomingLinkSheet request={open} onClose={() => setOpen(null)} />
244
+ </>
245
+ );
246
+ }