@saadnvd1/agent-os 0.3.698 → 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.
- package/app/api/bus/send/route.ts +11 -1
- package/app/api/bus/wait/route.ts +22 -0
- package/app/api/peer/hello/reveal/route.ts +6 -0
- package/app/api/peer/hello/route.ts +9 -0
- package/app/api/peer/hello/status/route.ts +6 -0
- package/app/api/peers/account/route.ts +9 -0
- package/app/api/peers/gate.test.ts +111 -0
- package/app/api/peers/link/[id]/confirm/route.ts +27 -0
- package/app/api/peers/link/[id]/route.ts +20 -0
- package/app/api/peers/link/route.ts +23 -0
- package/app/api/peers/pending/[id]/accept/route.ts +31 -0
- package/app/api/peers/pending/[id]/decline/route.ts +13 -0
- package/app/api/peers/pending/route.ts +7 -0
- package/app/api/sessions/route.ts +18 -1
- package/app/page.tsx +2 -0
- package/components/Hosts/AccountMachines.tsx +180 -0
- package/components/Hosts/HostRow.tsx +44 -35
- package/components/Hosts/HostsDialog.tsx +49 -37
- package/components/Hosts/IncomingLinkSheet.tsx +228 -0
- package/components/Hosts/LinkCodeSheet.tsx +186 -0
- package/components/Hosts/sheet.ts +3 -0
- package/components/SessionList/RowSubtitle.tsx +11 -1
- package/components/Welcome/ConnectBlock.tsx +1 -1
- package/data/peers/index.ts +2 -0
- package/data/peers/keys.ts +6 -0
- package/data/peers/queries.ts +141 -0
- package/lib/agents/brief.ts +3 -1
- package/lib/bus/format.ts +25 -4
- package/lib/bus/index.ts +113 -106
- package/lib/bus/resolve.ts +16 -2
- package/lib/bus/targets.ts +122 -0
- package/lib/bus/waits/kick.ts +12 -0
- package/lib/bus/waits/lines.ts +69 -0
- package/lib/bus/waits/register.ts +83 -0
- package/lib/bus/waits/store.ts +196 -0
- package/lib/bus/waits/tick.ts +192 -0
- package/lib/connect/control-api.ts +108 -2
- package/lib/connect/heartbeat.ts +11 -3
- package/lib/connect/serve.ts +2 -0
- package/lib/db/migrations/20261020T1600_bus_waits.ts +35 -0
- package/lib/db/types.ts +4 -0
- package/lib/done/archive.ts +2 -0
- package/lib/orchestrator/describe.ts +13 -0
- package/lib/orchestrator/facts.ts +17 -0
- package/lib/peer/account.ts +198 -0
- package/lib/peer/cli.ts +24 -0
- package/lib/peer/code.ts +3 -0
- package/lib/peer/hello-route.ts +32 -0
- package/lib/peer/hello.ts +346 -0
- package/lib/peer/link-cli.ts +233 -0
- package/lib/peer/machines.ts +72 -0
- package/lib/peer/notify.ts +32 -0
- package/lib/peer/pairing-out.ts +382 -0
- package/lib/peer/pairing.ts +403 -0
- package/lib/peer/pin.ts +55 -0
- package/lib/peer/rpc-intake.ts +37 -8
- package/lib/peer/rpc-server.ts +19 -1
- package/lib/schedules/when.ts +1 -1
- package/lib/security/auth.ts +10 -2
- package/lib/security/gate.ts +3 -0
- package/lib/sessions/end.ts +3 -0
- package/package.json +1 -1
- package/scripts/agent-os +2 -0
- package/scripts/lib/commands.sh +2 -0
- package/server.ts +3 -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 {
|
|
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,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
|
|
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
|
+
}
|
|
@@ -11,6 +11,7 @@ export function HostRow({ host }: { host: Host }) {
|
|
|
11
11
|
const testHost = useTestHost();
|
|
12
12
|
const linkHost = useLinkHost();
|
|
13
13
|
const isLocal = host.id === "local";
|
|
14
|
+
const viaConnect = host.kind === "connect";
|
|
14
15
|
const result = testHost.data;
|
|
15
16
|
const Icon = isLocal ? Monitor : Server;
|
|
16
17
|
|
|
@@ -23,51 +24,59 @@ export function HostRow({ host }: { host: Host }) {
|
|
|
23
24
|
<p className="text-muted-foreground truncate text-xs">
|
|
24
25
|
{isLocal
|
|
25
26
|
? "Where agent-os is running"
|
|
26
|
-
:
|
|
27
|
-
?
|
|
28
|
-
|
|
27
|
+
: viaConnect
|
|
28
|
+
? host.linked
|
|
29
|
+
? "Linked through Connect · tasks run on its AgentOS"
|
|
30
|
+
: "Through Connect · not linked"
|
|
31
|
+
: host.linked
|
|
32
|
+
? `${host.ssh_target} · tasks run on its AgentOS`
|
|
33
|
+
: host.ssh_target}
|
|
29
34
|
</p>
|
|
30
35
|
</div>
|
|
31
36
|
{!isLocal && (
|
|
32
37
|
<>
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
38
|
+
{!viaConnect && (
|
|
39
|
+
<>
|
|
40
|
+
<Button
|
|
41
|
+
variant="ghost"
|
|
42
|
+
size="icon-sm"
|
|
43
|
+
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
44
|
+
onClick={() => testHost.mutate(host.id)}
|
|
45
|
+
disabled={testHost.isPending}
|
|
46
|
+
aria-label="Test connection"
|
|
47
|
+
>
|
|
48
|
+
{testHost.isPending ? (
|
|
49
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
50
|
+
) : (
|
|
51
|
+
<PlugZap className="h-4 w-4" />
|
|
52
|
+
)}
|
|
53
|
+
</Button>
|
|
54
|
+
<Button
|
|
55
|
+
variant="ghost"
|
|
56
|
+
size="icon-sm"
|
|
57
|
+
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
58
|
+
onClick={() => linkHost.mutate(host.id)}
|
|
59
|
+
disabled={linkHost.isPending}
|
|
60
|
+
aria-label={
|
|
61
|
+
host.linked ? "Link its AgentOS again" : "Link its AgentOS"
|
|
62
|
+
}
|
|
63
|
+
title="Pair with the AgentOS running there, so tasks can run on it"
|
|
64
|
+
>
|
|
65
|
+
{linkHost.isPending ? (
|
|
66
|
+
<Loader2 className="h-4 w-4 animate-spin" />
|
|
67
|
+
) : (
|
|
68
|
+
<Link2 className="h-4 w-4" />
|
|
69
|
+
)}
|
|
70
|
+
</Button>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
64
73
|
<Button
|
|
65
74
|
variant="ghost"
|
|
66
75
|
size="icon-sm"
|
|
67
76
|
className="h-11 w-11 sm:h-8 sm:w-8"
|
|
68
77
|
onClick={() => deleteHost.mutate(host.id)}
|
|
69
78
|
disabled={deleteHost.isPending}
|
|
70
|
-
aria-label="Remove machine"
|
|
79
|
+
aria-label={viaConnect ? "Unlink and remove" : "Remove machine"}
|
|
71
80
|
>
|
|
72
81
|
<Trash2 className="h-4 w-4" />
|
|
73
82
|
</Button>
|