@saadnvd1/agent-os 0.3.697 → 0.3.700

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 (88) hide show
  1. package/app/api/bus/send/route.ts +11 -1
  2. package/app/api/bus/wait/route.ts +22 -0
  3. package/app/api/peer/hello/reveal/route.ts +6 -0
  4. package/app/api/peer/hello/route.ts +9 -0
  5. package/app/api/peer/hello/status/route.ts +6 -0
  6. package/app/api/peer/ping/route.ts +10 -0
  7. package/app/api/peers/account/route.ts +9 -0
  8. package/app/api/peers/gate.test.ts +111 -0
  9. package/app/api/peers/link/[id]/confirm/route.ts +27 -0
  10. package/app/api/peers/link/[id]/route.ts +20 -0
  11. package/app/api/peers/link/route.ts +23 -0
  12. package/app/api/peers/pending/[id]/accept/route.ts +31 -0
  13. package/app/api/peers/pending/[id]/decline/route.ts +13 -0
  14. package/app/api/peers/pending/route.ts +7 -0
  15. package/app/api/sessions/route.ts +18 -1
  16. package/app/page.tsx +2 -0
  17. package/components/Hosts/AccountMachines.tsx +180 -0
  18. package/components/Hosts/HostRow.tsx +44 -35
  19. package/components/Hosts/HostsDialog.tsx +49 -37
  20. package/components/Hosts/IncomingLinkSheet.tsx +228 -0
  21. package/components/Hosts/LinkCodeSheet.tsx +186 -0
  22. package/components/Hosts/sheet.ts +3 -0
  23. package/components/SessionList/RowSubtitle.tsx +11 -1
  24. package/components/Welcome/ConnectBlock.tsx +1 -1
  25. package/data/peers/index.ts +2 -0
  26. package/data/peers/keys.ts +6 -0
  27. package/data/peers/queries.ts +141 -0
  28. package/lib/__fixtures__/connect-transport.ts +93 -0
  29. package/lib/__fixtures__/peer-machine.ts +45 -0
  30. package/lib/__fixtures__/peer-rpc.ts +224 -0
  31. package/lib/agents/brief.ts +3 -1
  32. package/lib/bus/format.ts +25 -4
  33. package/lib/bus/index.ts +113 -106
  34. package/lib/bus/resolve.ts +16 -2
  35. package/lib/bus/targets.ts +122 -0
  36. package/lib/bus/waits/kick.ts +12 -0
  37. package/lib/bus/waits/lines.ts +69 -0
  38. package/lib/bus/waits/register.ts +83 -0
  39. package/lib/bus/waits/store.ts +196 -0
  40. package/lib/bus/waits/tick.ts +192 -0
  41. package/lib/connect/control-api.ts +108 -2
  42. package/lib/connect/heartbeat.ts +11 -3
  43. package/lib/connect/serve.ts +2 -0
  44. package/lib/db/migrations/20261020T1258_peer_links.ts +49 -0
  45. package/lib/db/migrations/20261020T1345_peer_nonces.ts +18 -0
  46. package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
  47. package/lib/db/types.ts +6 -0
  48. package/lib/done/archive.ts +2 -0
  49. package/lib/hosts/index.ts +28 -7
  50. package/lib/hosts/peer-socket.ts +3 -1
  51. package/lib/hosts/read-capped-bytes.ts +23 -0
  52. package/lib/hosts/remote-api.ts +112 -41
  53. package/lib/orchestrator/describe.ts +13 -0
  54. package/lib/orchestrator/facts.ts +17 -0
  55. package/lib/peer/account.ts +198 -0
  56. package/lib/peer/allowlist.ts +136 -0
  57. package/lib/peer/audit.ts +144 -0
  58. package/lib/peer/cli.ts +24 -0
  59. package/lib/peer/client.ts +188 -0
  60. package/lib/peer/code.ts +3 -0
  61. package/lib/peer/envelope.ts +424 -0
  62. package/lib/peer/hello-route.ts +32 -0
  63. package/lib/peer/hello.ts +346 -0
  64. package/lib/peer/internal.ts +22 -0
  65. package/lib/peer/keys.ts +122 -0
  66. package/lib/peer/link-cli.ts +233 -0
  67. package/lib/peer/links.ts +96 -0
  68. package/lib/peer/machines.ts +72 -0
  69. package/lib/peer/nonce-store.ts +23 -0
  70. package/lib/peer/notify.ts +32 -0
  71. package/lib/peer/pairing-out.ts +382 -0
  72. package/lib/peer/pairing.ts +403 -0
  73. package/lib/peer/pin.ts +55 -0
  74. package/lib/peer/replay.ts +81 -0
  75. package/lib/peer/rpc-intake.ts +258 -0
  76. package/lib/peer/rpc-server.ts +369 -0
  77. package/lib/schedules/when.ts +1 -1
  78. package/lib/security/auth.ts +51 -6
  79. package/lib/security/gate.ts +38 -12
  80. package/lib/security/route-guard.ts +3 -1
  81. package/lib/session-artifacts/peer.ts +12 -1
  82. package/lib/sessions/end.ts +3 -0
  83. package/lib/tasks/contract/authorize.ts +4 -0
  84. package/lib/worktree-sweep/testTemp.ts +2 -0
  85. package/package.json +1 -1
  86. package/scripts/agent-os +2 -0
  87. package/scripts/lib/commands.sh +2 -0
  88. package/server.ts +21 -0
@@ -1,16 +1,26 @@
1
1
  import { NextRequest, NextResponse } from "next/server";
2
2
  import { sendMessage } from "@/lib/bus";
3
+ import { parseTimeout } from "@/lib/bus/waits/register";
3
4
 
4
5
  // from: the sending session's id, or null when the user sends from the UI.
5
6
  // The message is stored either way; delivery says whether it reached the
6
7
  // recipient ("delivered", "queued" behind a busy turn, or "failed" + why).
7
8
  export async function POST(request: NextRequest) {
8
9
  try {
9
- const { from = null, to, body } = await request.json();
10
+ const {
11
+ from = null,
12
+ to,
13
+ body,
14
+ await: waits = false,
15
+ timeout,
16
+ replyTo,
17
+ } = await request.json();
10
18
  const sent = await sendMessage({
11
19
  fromId: from,
12
20
  to: String(to ?? ""),
13
21
  body: String(body ?? ""),
22
+ replyTo: replyTo == null ? undefined : Number(replyTo),
23
+ awaitMs: waits ? parseTimeout(timeout) : undefined,
14
24
  });
15
25
  return NextResponse.json(sent, { status: 201 });
16
26
  } catch (error) {
@@ -0,0 +1,22 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { registerWait } from "@/lib/bus/waits/register";
3
+
4
+ // `aos wait <session> --until idle|merged|reply`: records the wait and
5
+ // returns; the sweep wakes the sender when it holds (lib/bus/waits/tick).
6
+ export async function POST(request: NextRequest) {
7
+ try {
8
+ const { from, to, until, timeout } = await request.json();
9
+ if (typeof from !== "string" || !from)
10
+ throw new Error("aos wait runs from inside an AgentOS session");
11
+ const result = await registerWait({
12
+ fromId: from,
13
+ to: String(to ?? ""),
14
+ until: String(until ?? ""),
15
+ timeout: timeout == null ? null : String(timeout),
16
+ });
17
+ return NextResponse.json(result, { status: 201 });
18
+ } catch (error) {
19
+ const message = error instanceof Error ? error.message : String(error);
20
+ return NextResponse.json({ error: message }, { status: 400 });
21
+ }
22
+ }
@@ -0,0 +1,6 @@
1
+ import { helloRoute } from "@/lib/peer/hello-route";
2
+ import { receiveReveal } from "@/lib/peer/pairing";
3
+
4
+ // POST /api/peer/hello/reveal - public: the asking machine reveals the nonce
5
+ // its hello committed to.
6
+ export const POST = helloRoute((body) => receiveReveal(body));
@@ -0,0 +1,9 @@
1
+ import { helloRoute } from "@/lib/peer/hello-route";
2
+ import { receiveHello } from "@/lib/peer/pairing";
3
+ import { SOURCE_HEADER } from "@/lib/security/auth";
4
+
5
+ // POST /api/peer/hello - public: another machine on this Connect account
6
+ // asks to link (lib/peer/pairing.ts).
7
+ export const POST = helloRoute((body, request) =>
8
+ receiveHello(body, undefined, request.headers.get(SOURCE_HEADER) ?? "unknown")
9
+ );
@@ -0,0 +1,6 @@
1
+ import { helloRoute } from "@/lib/peer/hello-route";
2
+ import { receiveStatus } from "@/lib/peer/pairing";
3
+
4
+ // POST /api/peer/hello/status - public: the asking machine learns, signed,
5
+ // whether the code was typed here.
6
+ export const POST = helloRoute((body) => receiveStatus(body));
@@ -0,0 +1,10 @@
1
+ import { NextResponse } from "next/server";
2
+ import { appVersion } from "@/lib/connect/heartbeat";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ // What a linked machine asks first: which version answers, and which peer
7
+ // features it speaks. Nothing about this machine's sessions or paths.
8
+ export function GET() {
9
+ return NextResponse.json({ version: appVersion(), capabilities: ["rpc-v1"] });
10
+ }
@@ -0,0 +1,9 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { accountMachines } from "@/lib/peer/machines";
3
+
4
+ // GET /api/peers/account[?refresh=1] - the other machines on this Connect
5
+ // account, whether each is linked here, and whether the plan allows linking.
6
+ export async function GET(request: NextRequest) {
7
+ const refresh = request.nextUrl.searchParams.get("refresh") === "1";
8
+ return NextResponse.json(await accountMachines(refresh));
9
+ }
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { NextRequest } from "next/server";
3
+ import { mintDevice } from "@/lib/security/devices";
4
+ import { isPublicPath } from "@/lib/security/auth";
5
+
6
+ const accept = await import("./pending/[id]/accept/route");
7
+ const decline = await import("./pending/[id]/decline/route");
8
+ const link = await import("./link/route");
9
+ const cancel = await import("./link/[id]/route");
10
+ const confirm = await import("./link/[id]/confirm/route");
11
+ const hello = await import("../peer/hello/route");
12
+
13
+ const plain = mintDevice("phone").device.id;
14
+ const approver = mintDevice("tablet", null, true).device.id;
15
+
16
+ const req = (
17
+ path: string,
18
+ o: { trust?: string; device?: string; body?: string; method?: string } = {}
19
+ ) =>
20
+ new NextRequest(`http://localhost${path}`, {
21
+ method: o.method ?? "POST",
22
+ headers: {
23
+ ...(o.trust && { "x-agentos-trust": o.trust }),
24
+ ...(o.device && { "x-agentos-device": o.device }),
25
+ "content-type": "application/json",
26
+ },
27
+ body: o.body ?? JSON.stringify({ code: "123456" }),
28
+ });
29
+ const params = { params: Promise.resolve({ id: "nope" }) };
30
+
31
+ describe("linking routes", () => {
32
+ it("take a code only from this machine, the tailnet or a Can approve device", async () => {
33
+ for (const who of [
34
+ {},
35
+ { trust: "device", device: plain },
36
+ { trust: "device", device: "unknown" },
37
+ { trust: "peer" },
38
+ { trust: "open" },
39
+ ]) {
40
+ const r = await accept.POST(
41
+ req("/api/peers/pending/nope/accept", who),
42
+ params
43
+ );
44
+ expect(r.status).toBe(403);
45
+ expect((await decline.POST(req("/x", who), params)).status).toBe(403);
46
+ expect((await confirm.POST(req("/x", who), params)).status).toBe(403);
47
+ expect((await link.POST(req("/x", { ...who, body: "{}" }))).status).toBe(
48
+ 403
49
+ );
50
+ expect(
51
+ (
52
+ await cancel.DELETE(
53
+ req("/x", { ...who, method: "DELETE", body: "" }),
54
+ params
55
+ )
56
+ ).status
57
+ ).toBe(403);
58
+ }
59
+ // Past the gate: there is no such request.
60
+ for (const who of [
61
+ { trust: "loopback" },
62
+ { trust: "tailnet" },
63
+ { trust: "device", device: approver },
64
+ ])
65
+ expect(
66
+ (await accept.POST(req("/api/peers/pending/nope/accept", who), params))
67
+ .status
68
+ ).toBe(404);
69
+ expect(
70
+ (await confirm.POST(req("/x", { trust: "loopback" }), params)).status
71
+ ).toBe(404);
72
+ expect(
73
+ (await link.POST(req("/x", { trust: "loopback", body: "{}" }))).status
74
+ ).toBe(400);
75
+ for (const body of ['{"code":482913}', "{", "{}"])
76
+ for (const route of [accept, confirm])
77
+ expect(
78
+ (await route.POST(req("/x", { trust: "loopback", body }), params))
79
+ .status
80
+ ).toBe(400);
81
+ });
82
+
83
+ it("caps a hello at 4 KiB and refuses junk", async () => {
84
+ const big = await hello.POST(
85
+ req("/api/peer/hello", { body: "x".repeat(5000) })
86
+ );
87
+ expect(big.status).toBe(413);
88
+ expect(
89
+ (await hello.POST(req("/api/peer/hello", { body: "{" }))).status
90
+ ).toBe(400);
91
+ expect(big.headers.get("cache-control")).toBe("no-store");
92
+ });
93
+
94
+ it("are public only for the three hello paths", () => {
95
+ for (const p of [
96
+ "/api/peer/hello",
97
+ "/api/peer/hello/reveal",
98
+ "/api/peer/hello/status",
99
+ ])
100
+ expect(isPublicPath(p)).toBe(true);
101
+ for (const p of [
102
+ "/api/peer/hello/x",
103
+ "/api/peer/hello/../../pair/start",
104
+ "/api/peers/pending",
105
+ "/api/peers/account",
106
+ "/api/peers/link",
107
+ "/api/peers/link/x/confirm",
108
+ ])
109
+ expect(isPublicPath(p)).toBe(false);
110
+ });
111
+ });
@@ -0,0 +1,27 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { confirmLink } from "@/lib/peer/pairing-out";
5
+
6
+ const bodySchema = z.object({ code: z.string().max(32) });
7
+
8
+ // POST /api/peers/link/:id/confirm {code} - the code the other machine shows
9
+ // once it took ours, typed here: only then is it pinned. Same gate as
10
+ // starting the link.
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const gate = requireApprover(request);
16
+ if (!gate.ok) return gate.response;
17
+ const body = bodySchema.safeParse(await request.json().catch(() => null));
18
+ if (!body.success)
19
+ return NextResponse.json({ error: "code required" }, { status: 400 });
20
+ const result = confirmLink((await params).id, body.data.code);
21
+ if (!result.ok)
22
+ return NextResponse.json(
23
+ { error: result.error, attemptsLeft: result.attemptsLeft },
24
+ { status: result.status }
25
+ );
26
+ return NextResponse.json({ link: result.link });
27
+ }
@@ -0,0 +1,20 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { cancelLink, outgoingLink } from "@/lib/peer/pairing-out";
4
+
5
+ type Ctx = { params: Promise<{ id: string }> };
6
+
7
+ // GET /api/peers/link/:id - a link this machine started: its code and state.
8
+ export async function GET(_request: NextRequest, { params }: Ctx) {
9
+ const link = outgoingLink((await params).id);
10
+ if (!link)
11
+ return NextResponse.json({ error: "No such request" }, { status: 404 });
12
+ return NextResponse.json({ link });
13
+ }
14
+
15
+ // DELETE /api/peers/link/:id - stop waiting for it.
16
+ export async function DELETE(request: NextRequest, { params }: Ctx) {
17
+ const gate = requireApprover(request);
18
+ if (!gate.ok) return gate.response;
19
+ return NextResponse.json({ cancelled: cancelLink((await params).id) });
20
+ }
@@ -0,0 +1,23 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { LinkError, startLink } from "@/lib/peer/pairing-out";
5
+
6
+ const bodySchema = z.object({ machineId: z.string().min(1).max(64) });
7
+
8
+ // POST /api/peers/link {machineId} - ask an account machine to link. Once
9
+ // linked it can run code here too, so only a trusted place may start it.
10
+ export async function POST(request: NextRequest) {
11
+ const gate = requireApprover(request);
12
+ if (!gate.ok) return gate.response;
13
+ const body = bodySchema.safeParse(await request.json().catch(() => null));
14
+ if (!body.success)
15
+ return NextResponse.json({ error: "machineId required" }, { status: 400 });
16
+ try {
17
+ return NextResponse.json({ link: await startLink(body.data.machineId) });
18
+ } catch (error) {
19
+ const status = error instanceof LinkError ? error.status : 500;
20
+ const message = error instanceof Error ? error.message : String(error);
21
+ return NextResponse.json({ error: message }, { status });
22
+ }
23
+ }
@@ -0,0 +1,31 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import { requireApprover } from "@/lib/security/approver";
4
+ import { acceptRequest } from "@/lib/peer/pairing";
5
+
6
+ const bodySchema = z.object({ code: z.string().max(32) });
7
+
8
+ // POST /api/peers/pending/:id/accept {code} - the code the other machine
9
+ // shows, typed here. Linking lets that machine run code here, so it takes
10
+ // this machine, the tailnet or a device with Can approve.
11
+ export async function POST(
12
+ request: NextRequest,
13
+ { params }: { params: Promise<{ id: string }> }
14
+ ) {
15
+ const gate = requireApprover(request);
16
+ if (!gate.ok) return gate.response;
17
+ const body = bodySchema.safeParse(await request.json().catch(() => null));
18
+ if (!body.success)
19
+ return NextResponse.json({ error: "code required" }, { status: 400 });
20
+ const result = await acceptRequest((await params).id, body.data.code);
21
+ if (!result.ok)
22
+ return NextResponse.json(
23
+ { error: result.error, attemptsLeft: result.attemptsLeft },
24
+ { status: result.status }
25
+ );
26
+ return NextResponse.json({
27
+ hostId: result.hostId,
28
+ name: result.name,
29
+ confirmCode: result.confirmCode,
30
+ });
31
+ }
@@ -0,0 +1,13 @@
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { requireApprover } from "@/lib/security/approver";
3
+ import { declineRequest } from "@/lib/peer/pairing";
4
+
5
+ // POST /api/peers/pending/:id/decline
6
+ export async function POST(
7
+ request: NextRequest,
8
+ { params }: { params: Promise<{ id: string }> }
9
+ ) {
10
+ const gate = requireApprover(request);
11
+ if (!gate.ok) return gate.response;
12
+ return NextResponse.json({ declined: declineRequest((await params).id) });
13
+ }
@@ -0,0 +1,7 @@
1
+ import { NextResponse } from "next/server";
2
+ import { pendingRequests } from "@/lib/peer/pairing";
3
+
4
+ // GET /api/peers/pending - machines asking to link here, waiting for the code.
5
+ export function GET() {
6
+ return NextResponse.json({ requests: pendingRequests() });
7
+ }
@@ -4,6 +4,7 @@ import { isValidAgentType } from "@/lib/providers";
4
4
  import { clientSend } from "@/lib/chat/client-send";
5
5
  import { CHAT_ACCESS } from "@/lib/chat/events";
6
6
  import { launchSession } from "@/lib/sessions/launch";
7
+ import { namesLine, waitBadges } from "@/lib/bus/waits/store";
7
8
  import { isProjectRef } from "@/lib/tasks/project-ref";
8
9
 
9
10
  // GET /api/sessions - List all sessions and groups
@@ -13,7 +14,23 @@ export async function GET() {
13
14
  // Merged tasks are listed (the sidebar files them under Done). Dropped
14
15
  // tasks live in the Tasks panel, a moved one on the machine it moved to,
15
16
  // and archived sessions in the Archived view: none is in the sidebar.
16
- const sessions = queries.getSidebarSessions(db).all() as Session[];
17
+ const badges = waitBadges();
18
+ const sessions = (queries.getSidebarSessions(db).all() as Session[]).map(
19
+ (s) => {
20
+ const b = badges.get(s.id);
21
+ return b
22
+ ? {
23
+ ...s,
24
+ waiting_on: b.on.length
25
+ ? namesLine(b.on.map((w) => w.name))
26
+ : null,
27
+ waited_by: b.by.length
28
+ ? namesLine(b.by.map((w) => w.name))
29
+ : null,
30
+ }
31
+ : s;
32
+ }
33
+ );
17
34
  const groups = queries.getAllGroups(db).all() as Group[];
18
35
 
19
36
  // Convert expanded from 0/1 to boolean
package/app/page.tsx CHANGED
@@ -34,6 +34,7 @@ if (typeof window !== "undefined") {
34
34
  import { PaneProvider, usePanes } from "@/contexts/PaneContext";
35
35
  import { Pane } from "@/components/Pane";
36
36
  import { AwayReport } from "@/components/Away/AwayReport";
37
+ import { IncomingLinks } from "@/components/Hosts/IncomingLinkSheet";
37
38
  import type { BrowserNotifications } from "@/components/Settings/NotificationsSection";
38
39
  import { useNotifications } from "@/hooks/useNotifications";
39
40
  import { useViewport } from "@/hooks/useViewport";
@@ -161,6 +162,7 @@ const AppDialogs = memo(function AppDialogs({
161
162
  <MoveDialog />
162
163
  <ShareDialog />
163
164
  <AwayReport />
165
+ <IncomingLinks />
164
166
  <CommandPalette />
165
167
  </>
166
168
  );
@@ -0,0 +1,180 @@
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 { LinkForm } from "@/components/Welcome/ConnectBlock";
7
+ import {
8
+ useAccountMachines,
9
+ useRefreshAccount,
10
+ useStartLink,
11
+ } from "@/data/peers";
12
+ import { CONNECT_SITE } from "@/lib/connect/view";
13
+ import type { AccountMachine } from "@/lib/peer/machines";
14
+ import { cn } from "@/lib/utils";
15
+ import { WaitingLinks } from "./IncomingLinkSheet";
16
+ import { LinkCodeSheet } from "./LinkCodeSheet";
17
+
18
+ function MachineRow({
19
+ machine,
20
+ canLink,
21
+ onLink,
22
+ starting,
23
+ }: {
24
+ machine: AccountMachine;
25
+ canLink: boolean;
26
+ onLink: () => void;
27
+ starting: boolean;
28
+ }) {
29
+ const linked = machine.linkState === "active";
30
+ return (
31
+ <div
32
+ className={cn(
33
+ "bg-muted/40 flex items-center gap-3 rounded-lg px-3 py-2.5",
34
+ !canLink && !linked && "opacity-60"
35
+ )}
36
+ >
37
+ <span
38
+ className={cn(
39
+ "h-2 w-2 shrink-0 rounded-full",
40
+ machine.online ? "bg-emerald-500" : "bg-muted-foreground/40"
41
+ )}
42
+ aria-label={machine.online ? "Online" : "Offline"}
43
+ />
44
+ <div className="min-w-0 flex-1">
45
+ <p className="truncate text-sm font-medium">{machine.name}</p>
46
+ <p className="text-muted-foreground truncate text-xs">
47
+ {[
48
+ machine.online ? "Online" : "Offline",
49
+ machine.version && `AgentOS ${machine.version}`,
50
+ ]
51
+ .filter(Boolean)
52
+ .join(" · ")}
53
+ </p>
54
+ </div>
55
+ {linked ? (
56
+ <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
57
+ Linked
58
+ </span>
59
+ ) : machine.linkState === "suspended" ? (
60
+ <span className="text-muted-foreground text-xs">Suspended</span>
61
+ ) : (
62
+ <Button
63
+ variant="outline"
64
+ size="sm"
65
+ className="min-h-11 sm:min-h-8"
66
+ disabled={!canLink || starting}
67
+ onClick={onLink}
68
+ >
69
+ {starting ? (
70
+ <Loader2 className="h-4 w-4 animate-spin" />
71
+ ) : (
72
+ <Link2 className="h-4 w-4" />
73
+ )}
74
+ Link
75
+ </Button>
76
+ )}
77
+ </div>
78
+ );
79
+ }
80
+
81
+ /** Machines → "On your AgentOS account": link them through Connect. */
82
+ export function AccountMachines() {
83
+ const { data, isPending, error } = useAccountMachines(true);
84
+ const refresh = useRefreshAccount();
85
+ const start = useStartLink();
86
+ const [linkId, setLinkId] = useState<string | null>(null);
87
+ const [target, setTarget] = useState<string | null>(null);
88
+
89
+ const link = (machineId: string) => {
90
+ setTarget(machineId);
91
+ start.mutate(machineId, { onSuccess: (l) => setLinkId(l.id) });
92
+ };
93
+
94
+ if (isPending)
95
+ return <div className="bg-muted/40 h-14 animate-pulse rounded-lg" />;
96
+ if (error) return <p className="text-destructive text-xs">{error.message}</p>;
97
+ if (!data.enrolled)
98
+ return (
99
+ <div className="space-y-2 rounded-lg border p-3">
100
+ <p className="text-sm font-medium">
101
+ Connect this machine to link it with your others
102
+ </p>
103
+ <LinkForm />
104
+ </div>
105
+ );
106
+
107
+ const entitled = data.entitlements?.linkMachines === true;
108
+ return (
109
+ <section className="space-y-2" aria-label="On your AgentOS account">
110
+ <div className="flex items-center justify-between gap-2">
111
+ <p className="text-sm font-medium">On your AgentOS account</p>
112
+ <Button
113
+ variant="ghost"
114
+ size="icon-sm"
115
+ className="h-11 w-11 sm:h-8 sm:w-8"
116
+ aria-label="Check your account again"
117
+ disabled={refresh.isPending}
118
+ onClick={() => refresh.mutate()}
119
+ >
120
+ <RefreshCw
121
+ className={cn("h-4 w-4", refresh.isPending && "animate-spin")}
122
+ />
123
+ </Button>
124
+ </div>
125
+ {data.entitlements && !entitled && (
126
+ <div className="flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center">
127
+ <p className="flex-1 text-sm">
128
+ Link your machines with Pro: tasks move between them over any
129
+ network, end-to-end encrypted.
130
+ </p>
131
+ <Button asChild variant="outline" className="min-h-11 sm:min-h-9">
132
+ <a
133
+ href={`${CONNECT_SITE}/settings/billing`}
134
+ target="_blank"
135
+ rel="noopener noreferrer"
136
+ >
137
+ Upgrade
138
+ <ExternalLink className="h-3.5 w-3.5" />
139
+ </a>
140
+ </Button>
141
+ </div>
142
+ )}
143
+ {entitled && <WaitingLinks />}
144
+ {data.error && (
145
+ <p className="text-muted-foreground text-xs">{data.error}</p>
146
+ )}
147
+ {data.machines.length === 0 ? (
148
+ <p className="text-muted-foreground text-xs">
149
+ No other machines on your account yet. Run{" "}
150
+ <code className="font-mono">agent-os connect</code> on another
151
+ machine, then link it here.
152
+ </p>
153
+ ) : (
154
+ data.machines.map((m) => (
155
+ <MachineRow
156
+ key={m.id}
157
+ machine={m}
158
+ canLink={entitled}
159
+ starting={start.isPending && target === m.id}
160
+ onLink={() => link(m.id)}
161
+ />
162
+ ))
163
+ )}
164
+ {start.error && (
165
+ <p className="text-destructive text-xs">{start.error.message}</p>
166
+ )}
167
+ <LinkCodeSheet
168
+ linkId={linkId}
169
+ onClose={() => {
170
+ setLinkId(null);
171
+ start.reset();
172
+ }}
173
+ onRetry={() => {
174
+ setLinkId(null);
175
+ if (target) link(target);
176
+ }}
177
+ />
178
+ </section>
179
+ );
180
+ }