@saadnvd1/agent-os 0.3.532 → 0.3.533

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/README.md CHANGED
@@ -1774,7 +1774,9 @@ Connect (in development) reaches this machine from anywhere at
1774
1774
  a code from runagentos.com: it makes an Ed25519 key in `~/.agent-os/connect`
1775
1775
  that never leaves the machine, and the Connect service makes the machine a
1776
1776
  Cloudflare tunnel. Running it again on a linked machine links the same tunnel
1777
- again, keeping its address. `agent-os disconnect` (or the switch in Devices)
1777
+ again, keeping its address. The first-run setup's Devices step can link too: paste
1778
+ the code into the field (same code path, `POST /api/devices/connect`, local or
1779
+ tailnet callers only). `agent-os disconnect` (or the switch in Devices)
1778
1780
  turns it off.
1779
1781
 
1780
1782
  While Connect is on, the production server:
@@ -0,0 +1,79 @@
1
+ import os from "os";
2
+ import { NextRequest, NextResponse } from "next/server";
3
+ import { demoMode } from "@/lib/security/demo";
4
+ import { requireLocalTrust } from "@/lib/security/route-guard";
5
+ import { networkState } from "@/lib/security/network-state";
6
+ import {
7
+ connectTunnelPort,
8
+ readConnect,
9
+ serverPort,
10
+ } from "@/lib/connect/config";
11
+ import { ApiError } from "@/lib/connect/control-api";
12
+ import { cleanLinkCode, linkWithCode } from "@/lib/connect/link";
13
+ import { connectStatus } from "@/lib/connect/serve";
14
+
15
+ let linking = false;
16
+
17
+ const nameFrom = (raw: unknown) => {
18
+ const name =
19
+ typeof raw === "string"
20
+ ? raw
21
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
22
+ .trim()
23
+ .slice(0, 64)
24
+ : "";
25
+ return name || os.hostname();
26
+ };
27
+
28
+ // POST /api/devices/connect {code, name?} - link this machine with a code
29
+ // from runagentos.com, the same way `agent-os connect` does.
30
+ export async function POST(request: NextRequest) {
31
+ const refused = requireLocalTrust(request);
32
+ if (refused) return refused;
33
+ if (demoMode())
34
+ return NextResponse.json({ error: "Not in the demo." }, { status: 403 });
35
+ const body = (await request.json().catch(() => ({}))) as {
36
+ code?: unknown;
37
+ name?: unknown;
38
+ };
39
+ const code = cleanLinkCode(body.code);
40
+ if (!code)
41
+ return NextResponse.json(
42
+ { error: "Paste the code from runagentos.com." },
43
+ { status: 400 }
44
+ );
45
+ if (readConnect().ok)
46
+ return NextResponse.json(
47
+ { error: "This machine is already linked." },
48
+ { status: 409 }
49
+ );
50
+ const port = connectTunnelPort(serverPort());
51
+ if (!port)
52
+ return NextResponse.json(
53
+ { error: "AGENTOS_CONNECT_PORT isn't a usable port" },
54
+ { status: 500 }
55
+ );
56
+ if (linking)
57
+ return NextResponse.json({ error: "Already linking." }, { status: 409 });
58
+ linking = true;
59
+ try {
60
+ await linkWithCode({ code, name: nameFrom(body.name), port });
61
+ } catch (err) {
62
+ if (err instanceof ApiError && err.status >= 400 && err.status < 500)
63
+ return NextResponse.json(
64
+ { error: `That code didn't work: ${err.message}` },
65
+ { status: 400 }
66
+ );
67
+ console.error(`connect: link failed: ${(err as Error).message}`);
68
+ return NextResponse.json(
69
+ { error: "Couldn't link. Check the code and try again." },
70
+ { status: 502 }
71
+ );
72
+ } finally {
73
+ linking = false;
74
+ }
75
+ // The running server picks the new files up within a couple of seconds.
76
+ for (let i = 0; i < 12 && !connectStatus().enrolled; i++)
77
+ await new Promise((r) => setTimeout(r, 250));
78
+ return NextResponse.json(await networkState());
79
+ }
@@ -3,15 +3,7 @@
3
3
  import { Cloud } from "lucide-react";
4
4
  import { Switch } from "@/components/ui/switch";
5
5
  import type { ConnectStatus } from "@/lib/connect/serve";
6
-
7
- const STATE: Record<ConnectStatus["state"], string> = {
8
- up: "Connected",
9
- connecting: "Connecting…",
10
- denied: "Refused by Connect",
11
- off: "Off",
12
- refused: "Not running here",
13
- error: "Problem",
14
- };
6
+ import { CONNECT_STATE as STATE, connectView } from "@/lib/connect/view";
15
7
 
16
8
  export function ConnectCard({
17
9
  connect,
@@ -22,12 +14,7 @@ export function ConnectCard({
22
14
  canManage: boolean;
23
15
  onToggle: (on: boolean) => void;
24
16
  }) {
25
- const enrolled = !!connect.enrolled || !!connect.hostname;
26
- const on =
27
- connect.state === "up" ||
28
- connect.state === "connecting" ||
29
- connect.state === "denied" ||
30
- (connect.state === "error" && !!connect.enrolled);
17
+ const { linked: enrolled, on } = connectView(connect);
31
18
  return (
32
19
  <div className="bg-muted/40 flex items-start gap-3 rounded-lg px-3 py-2.5">
33
20
  <Cloud className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0" />
@@ -0,0 +1,26 @@
1
+ import type { Bell } from "lucide-react";
2
+
3
+ export function Block({
4
+ icon: Icon,
5
+ title,
6
+ detail,
7
+ children,
8
+ }: {
9
+ icon: typeof Bell;
10
+ title: string;
11
+ detail: string;
12
+ children: React.ReactNode;
13
+ }) {
14
+ return (
15
+ <section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
16
+ <div className="flex items-start gap-3">
17
+ <Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
18
+ <div className="min-w-0 flex-1">
19
+ <h3 className="text-sm font-medium">{title}</h3>
20
+ <p className="text-muted-foreground text-xs">{detail}</p>
21
+ </div>
22
+ </div>
23
+ {children}
24
+ </section>
25
+ );
26
+ }
@@ -0,0 +1,169 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ExternalLink, Globe } from "lucide-react";
5
+ import { CopyButton } from "@/components/Chat/CopyButton";
6
+ import { canManageDevices } from "@/components/Devices/DevicesPanel";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import { Switch } from "@/components/ui/switch";
10
+ import {
11
+ useDevicesQuery,
12
+ useLinkConnect,
13
+ useNetworkQuery,
14
+ useUpdateNetwork,
15
+ } from "@/data/devices";
16
+ import { CONNECT_SITE, CONNECT_STATE, connectView } from "@/lib/connect/view";
17
+ import { Block } from "./Block";
18
+
19
+ const COMMAND = "agent-os connect";
20
+
21
+ function Command() {
22
+ return (
23
+ <div className="text-muted-foreground flex items-center gap-1 text-xs">
24
+ <span>Or run</span>
25
+ <code className="text-foreground font-mono">{COMMAND}</code>
26
+ <span>in a terminal.</span>
27
+ <CopyButton text={COMMAND} label="Copy command" />
28
+ </div>
29
+ );
30
+ }
31
+
32
+ function LinkForm() {
33
+ const [code, setCode] = useState("");
34
+ const link = useLinkConnect();
35
+ const submit = () => {
36
+ if (code.trim() && !link.isPending) link.mutate(code.trim());
37
+ };
38
+ return (
39
+ <div className="flex min-w-0 flex-col gap-2">
40
+ <p className="text-muted-foreground text-xs">
41
+ Get a code at{" "}
42
+ <a
43
+ href={CONNECT_SITE}
44
+ target="_blank"
45
+ rel="noopener noreferrer"
46
+ className="text-foreground inline-flex items-center gap-1 underline underline-offset-2"
47
+ >
48
+ runagentos.com
49
+ <ExternalLink className="size-3" />
50
+ </a>{" "}
51
+ (Connect a machine), then paste it here.
52
+ </p>
53
+ <form
54
+ className="flex gap-2"
55
+ onSubmit={(e) => {
56
+ e.preventDefault();
57
+ submit();
58
+ }}
59
+ >
60
+ <Input
61
+ value={code}
62
+ onChange={(e) => setCode(e.target.value)}
63
+ aria-label="Link code"
64
+ placeholder="Link code"
65
+ autoComplete="off"
66
+ autoCapitalize="off"
67
+ spellCheck={false}
68
+ className="min-h-11 flex-1 font-mono sm:min-h-9"
69
+ />
70
+ <Button
71
+ type="submit"
72
+ variant="outline"
73
+ className="min-h-11 sm:min-h-9"
74
+ disabled={!code.trim() || link.isPending}
75
+ >
76
+ {link.isPending ? "Linking…" : "Link"}
77
+ </Button>
78
+ </form>
79
+ {link.error && (
80
+ <p className="text-destructive text-xs">{link.error.message}</p>
81
+ )}
82
+ <Command />
83
+ </div>
84
+ );
85
+ }
86
+
87
+ /** This machine's Connect: link it, then turn it on or off. */
88
+ export function ConnectBlock() {
89
+ // The running server follows a change within a couple of seconds.
90
+ const [wanted, setWanted] = useState<boolean | null>(null);
91
+ const network = useNetworkQuery(true, wanted === null ? false : 2000);
92
+ const devices = useDevicesQuery(true);
93
+ const update = useUpdateNetwork();
94
+ const canManage = canManageDevices(devices.data?.current.via);
95
+ const view = network.data ? connectView(network.data.connect) : null;
96
+
97
+ useEffect(() => {
98
+ if (wanted === null) return;
99
+ const t = setTimeout(() => setWanted(null), 6000);
100
+ return () => clearTimeout(t);
101
+ }, [wanted]);
102
+
103
+ return (
104
+ <Block
105
+ icon={Globe}
106
+ title="Use it away from home"
107
+ detail="Connect gives this machine a private address at runagentos.com, so your phone reaches it from anywhere. Nothing to open on your router."
108
+ >
109
+ {network.isPending ? (
110
+ <div className="bg-muted/40 h-12 animate-pulse rounded-lg" />
111
+ ) : network.isError || !view ? (
112
+ <p className="text-destructive text-xs">
113
+ {network.error?.message ?? "Couldn't read Connect."}
114
+ </p>
115
+ ) : !view.linked ? (
116
+ canManage ? (
117
+ <LinkForm />
118
+ ) : (
119
+ <div className="flex flex-col gap-2">
120
+ <p className="text-muted-foreground text-xs">
121
+ Link it on the machine running AgentOS.
122
+ </p>
123
+ <Command />
124
+ </div>
125
+ )
126
+ ) : (
127
+ <div className="flex items-start gap-3">
128
+ <div className="min-w-0 flex-1">
129
+ <p className="text-sm font-medium">
130
+ Linked{" "}
131
+ <span className="text-muted-foreground font-normal">
132
+ · {CONNECT_STATE[network.data.connect.state]}
133
+ </span>
134
+ </p>
135
+ <p className="text-muted-foreground text-xs break-all">
136
+ {view.hostname ? (
137
+ <>
138
+ Reachable at{" "}
139
+ <span className="text-foreground font-mono">
140
+ https://{view.hostname}
141
+ </span>
142
+ </>
143
+ ) : (
144
+ "The address appears once the tunnel starts."
145
+ )}
146
+ {network.data.connect.reason &&
147
+ network.data.connect.state !== "off"
148
+ ? ` ${network.data.connect.reason.replace(/\.$/, "")}.`
149
+ : ""}
150
+ </p>
151
+ {update.error && (
152
+ <p className="text-destructive text-xs">{update.error.message}</p>
153
+ )}
154
+ </div>
155
+ <Switch
156
+ checked={wanted ?? view.on}
157
+ disabled={!view.canToggle || !canManage}
158
+ onCheckedChange={(connect) => {
159
+ setWanted(connect);
160
+ update.mutate({ connect });
161
+ }}
162
+ className="mt-0.5"
163
+ aria-label="Connect"
164
+ />
165
+ </div>
166
+ )}
167
+ </Block>
168
+ );
169
+ }
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useState } from "react";
4
4
  import { Bell, Laptop, Smartphone } from "lucide-react";
5
+ import { Block } from "./Block";
6
+ import { ConnectBlock } from "./ConnectBlock";
5
7
  import { AddDevicePanel } from "@/components/Devices/AddDevicePanel";
6
8
  import {
7
9
  NotificationsSection,
@@ -9,31 +11,6 @@ import {
9
11
  } from "@/components/Settings/NotificationsSection";
10
12
  import { Button } from "@/components/ui/button";
11
13
 
12
- function Block({
13
- icon: Icon,
14
- title,
15
- detail,
16
- children,
17
- }: {
18
- icon: typeof Bell;
19
- title: string;
20
- detail: string;
21
- children: React.ReactNode;
22
- }) {
23
- return (
24
- <section className="bg-muted/40 flex min-w-0 flex-col gap-3 rounded-lg px-3 py-3">
25
- <div className="flex items-start gap-3">
26
- <Icon className="text-muted-foreground mt-0.5 size-4 shrink-0" />
27
- <div className="min-w-0 flex-1">
28
- <h3 className="text-sm font-medium">{title}</h3>
29
- <p className="text-muted-foreground text-xs">{detail}</p>
30
- </div>
31
- </div>
32
- {children}
33
- </section>
34
- );
35
- }
36
-
37
14
  /**
38
15
  * Pair a phone, link another machine, set up notifications. Every part is
39
16
  * optional and lives in Settings too.
@@ -51,7 +28,7 @@ export function DevicesStep({
51
28
  <Block
52
29
  icon={Smartphone}
53
30
  title="Pair your phone"
54
- detail="Scan a code to use AgentOS from your phone."
31
+ detail="Scan a code to use AgentOS from your phone on this network."
55
32
  >
56
33
  {pairing ? (
57
34
  <AddDevicePanel onDone={() => setPairing(false)} />
@@ -65,6 +42,7 @@ export function DevicesStep({
65
42
  </Button>
66
43
  )}
67
44
  </Block>
45
+ <ConnectBlock />
68
46
  <Block
69
47
  icon={Laptop}
70
48
  title="Link another machine"
@@ -26,12 +26,32 @@ export function useDevicesQuery(enabled = true) {
26
26
  });
27
27
  }
28
28
 
29
- export function useNetworkQuery(enabled = true) {
29
+ export function useNetworkQuery(
30
+ enabled = true,
31
+ refetchMs: number | false = false
32
+ ) {
30
33
  return useQuery({
31
34
  queryKey: deviceKeys.network(),
32
35
  queryFn: async () =>
33
36
  json<NetworkState>(await fetch("/api/devices/network")),
34
37
  enabled,
38
+ refetchInterval: refetchMs,
39
+ });
40
+ }
41
+
42
+ /** Links this machine to Connect with a code from runagentos.com. */
43
+ export function useLinkConnect() {
44
+ const queryClient = useQueryClient();
45
+ return useMutation({
46
+ mutationFn: async (code: string) =>
47
+ json<NetworkState>(
48
+ await fetch("/api/devices/connect", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/json" },
51
+ body: JSON.stringify({ code }),
52
+ })
53
+ ),
54
+ onSuccess: (data) => queryClient.setQueryData(deviceKeys.network(), data),
35
55
  });
36
56
  }
37
57
 
@@ -21,13 +21,8 @@ import {
21
21
  serverPort,
22
22
  setConnectEnabled,
23
23
  } from "./config";
24
- import { enrol } from "./enrol";
25
- import {
26
- checkApiUrl,
27
- DEFAULT_API,
28
- registerWithCode,
29
- requestTunnel,
30
- } from "./control-api";
24
+ import { linkWithCode } from "./link";
25
+ import { checkApiUrl, requestTunnel } from "./control-api";
31
26
 
32
27
  const arg = (flag: string) => {
33
28
  const i = process.argv.indexOf(flag);
@@ -83,23 +78,16 @@ async function main() {
83
78
  config = { ...config, hostname };
84
79
  saveConnect(config, dir);
85
80
  } else {
86
- const apiUrl = checkApiUrl(
87
- process.env.CONNECT_API_URL || DEFAULT_API,
88
- operator
89
- );
90
81
  const code = await linkCode();
91
82
  if (!code)
92
83
  throw new Error(
93
84
  "get a code from runagentos.com (Connect a machine), then run agent-os connect and paste it"
94
85
  );
95
- config = await enrol({
86
+ config = await linkWithCode({
96
87
  dir,
97
- register: registerWithCode(
98
- apiUrl,
99
- code,
100
- arg("--name") || os.hostname(),
101
- port
102
- ),
88
+ code,
89
+ name: arg("--name") || os.hostname(),
90
+ port,
103
91
  });
104
92
  }
105
93
  console.log(`\n Machine ${config.machineId}`);
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Linking this machine to the Connect service with a code from
3
+ * runagentos.com. Shared by `agent-os connect` and the setup's link form.
4
+ */
5
+
6
+ import { type ConnectConfig } from "./config";
7
+ import { DEFAULT_API, checkApiUrl, registerWithCode } from "./control-api";
8
+ import { enrol } from "./enrol";
9
+
10
+ /** The code as typed, or null when it can't be one. */
11
+ export function cleanLinkCode(raw: unknown): string | null {
12
+ if (typeof raw !== "string") return null;
13
+ const code = raw.trim();
14
+ if (!code || code.length > 128 || /[\s\u0000-\u001f\u007f-\u009f]/.test(code))
15
+ return null;
16
+ return code;
17
+ }
18
+
19
+ export async function linkWithCode(opts: {
20
+ code: string;
21
+ name: string;
22
+ port: number;
23
+ dir?: string;
24
+ env?: NodeJS.ProcessEnv;
25
+ }): Promise<ConnectConfig> {
26
+ const env = opts.env ?? process.env;
27
+ const apiUrl = checkApiUrl(
28
+ env.CONNECT_API_URL || DEFAULT_API,
29
+ env.AGENTOS_CONNECT_OPERATOR === "1"
30
+ );
31
+ return enrol({
32
+ dir: opts.dir,
33
+ register: registerWithCode(apiUrl, opts.code, opts.name, opts.port),
34
+ });
35
+ }
@@ -195,7 +195,18 @@ export function startConnect(
195
195
  ? "AGENTOS_CONNECT_PORT isn't a usable port"
196
196
  : decision.reason;
197
197
  if (readConnect(dir).ok) log(`connect: not starting: ${reason}`);
198
- base = { state: "refused", reason };
198
+ // Still says whether the machine is linked, read when asked.
199
+ g.__agentosConnect = () => {
200
+ const r = readConnect(dir);
201
+ return {
202
+ state: "refused",
203
+ reason,
204
+ ...(r.ok && {
205
+ enrolled: true,
206
+ hostname: r.files.config.hostname || undefined,
207
+ }),
208
+ };
209
+ };
199
210
  return { hostname: () => null as string | null, close: () => {} };
200
211
  }
201
212
 
@@ -0,0 +1,28 @@
1
+ import type { ConnectStatus } from "./serve";
2
+
3
+ export const CONNECT_SITE = "https://runagentos.com";
4
+
5
+ export const CONNECT_STATE: Record<ConnectStatus["state"], string> = {
6
+ up: "Connected",
7
+ connecting: "Connecting…",
8
+ denied: "Refused by Connect",
9
+ off: "Off",
10
+ refused: "Not running here",
11
+ error: "Problem",
12
+ };
13
+
14
+ /** What the UI needs to know about Connect on this machine. */
15
+ export function connectView(s: ConnectStatus) {
16
+ const linked = !!s.enrolled || !!s.hostname;
17
+ const on =
18
+ s.state === "up" ||
19
+ s.state === "connecting" ||
20
+ s.state === "denied" ||
21
+ (s.state === "error" && !!s.enrolled);
22
+ return {
23
+ linked,
24
+ on,
25
+ canToggle: linked && s.state !== "refused",
26
+ hostname: s.hostname,
27
+ };
28
+ }
@@ -54,6 +54,7 @@ export const STATIC_SIBLINGS = [
54
54
  "availability",
55
55
  "browse",
56
56
  "clone",
57
+ "connect",
57
58
  "detect",
58
59
  "done-idle",
59
60
  "held-replies",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.532",
3
+ "version": "0.3.533",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"