palmier 0.8.9 → 0.8.11
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 +13 -7
- package/dist/commands/info.js +5 -0
- package/dist/commands/init.js +17 -6
- package/dist/commands/pair.js +0 -3
- package/dist/network.d.ts +4 -0
- package/dist/network.js +100 -0
- package/dist/pwa/assets/{index-1gs4vwFo.js → index-B7S0YoMo.js} +45 -45
- package/dist/pwa/assets/{index-DQJHVyP6.css → index-DhphickB.css} +1 -1
- package/dist/pwa/assets/{web-BqVsIFtP.js → web-4WNPL7z3.js} +1 -1
- package/dist/pwa/assets/{web-lefgO9YR.js → web-Bpd2nO1M.js} +1 -1
- package/dist/pwa/assets/{web-DrSNtZ3i.js → web-DjwsAB0V.js} +1 -1
- package/dist/pwa/index.html +2 -2
- package/dist/pwa/service-worker.js +1 -1
- package/dist/rpc-handler.js +2 -0
- package/dist/transports/http-transport.d.ts +0 -1
- package/dist/transports/http-transport.js +8 -11
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
- package/palmier-server/README.md +1 -1
- package/palmier-server/pwa/src/App.css +8 -4
- package/palmier-server/pwa/src/components/ConnectionStatusIcon.tsx +54 -13
- package/palmier-server/pwa/src/constants.ts +1 -1
- package/palmier-server/pwa/src/contexts/HostConnectionContext.tsx +13 -4
- package/palmier-server/pwa/src/contexts/HostStoreContext.tsx +12 -0
- package/palmier-server/pwa/src/pages/Dashboard.tsx +6 -2
- package/palmier-server/pwa/src/pages/PairHost.tsx +0 -2
- package/palmier-server/pwa/src/pages/PairSetup.tsx +5 -2
- package/palmier-server/pwa/src/types.ts +1 -1
- package/palmier-server/spec.md +7 -4
- package/src/commands/info.ts +6 -0
- package/src/commands/init.ts +16 -6
- package/src/commands/pair.ts +0 -3
- package/src/network.ts +96 -0
- package/src/rpc-handler.ts +2 -0
- package/src/transports/http-transport.ts +8 -11
- package/src/types.ts +5 -0
|
@@ -103,18 +103,27 @@ export function HostConnectionProvider({ children }: { children: ReactNode }) {
|
|
|
103
103
|
const expectedHostId = activeHost.hostId;
|
|
104
104
|
|
|
105
105
|
async function probe() {
|
|
106
|
+
console.log("[HOST/LAN] probing", lanUrl);
|
|
106
107
|
try {
|
|
107
108
|
const res = await fetch(`${lanUrl}/health`, {
|
|
108
109
|
signal: AbortSignal.timeout(LAN_PROBE_TIMEOUT_MS),
|
|
109
110
|
});
|
|
110
111
|
if (cancelled) return;
|
|
111
|
-
if (!res.ok) {
|
|
112
|
+
if (!res.ok) {
|
|
113
|
+
console.log("[HOST/LAN] probe got non-ok:", res.status);
|
|
114
|
+
setLanReachable(false);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
112
117
|
const data = await res.json() as { hostId?: string };
|
|
113
118
|
const reachable = data.hostId === expectedHostId;
|
|
119
|
+
if (!reachable) console.log("[HOST/LAN] hostId mismatch — got:", data.hostId, "expected:", expectedHostId);
|
|
120
|
+
else console.log("[HOST/LAN] reachable");
|
|
114
121
|
setLanReachable(reachable);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (!cancelled) {
|
|
124
|
+
console.log("[HOST/LAN] probe failed:", err instanceof Error ? err.message : err);
|
|
125
|
+
setLanReachable(false);
|
|
126
|
+
}
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
129
|
|
|
@@ -14,6 +14,7 @@ interface HostStoreContextValue {
|
|
|
14
14
|
addPairedHost(host: PairedHost): void;
|
|
15
15
|
removePairedHost(hostId: string): void;
|
|
16
16
|
renamePairedHost(hostId: string, name: string): void;
|
|
17
|
+
setHostLanUrl(hostId: string, lanUrl: string | undefined): void;
|
|
17
18
|
setActiveHostId(hostId: string): void;
|
|
18
19
|
getActiveHost(): PairedHost | null;
|
|
19
20
|
}
|
|
@@ -113,6 +114,16 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
|
113
114
|
);
|
|
114
115
|
}, []);
|
|
115
116
|
|
|
117
|
+
const setHostLanUrl = useCallback((hostId: string, lanUrl: string | undefined) => {
|
|
118
|
+
setPairedHosts((prev) =>
|
|
119
|
+
prev.map((h) => {
|
|
120
|
+
if (h.hostId !== hostId) return h;
|
|
121
|
+
if (h.lanUrl === lanUrl) return h;
|
|
122
|
+
return { ...h, lanUrl };
|
|
123
|
+
})
|
|
124
|
+
);
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
116
127
|
const setActiveHostId = useCallback((hostId: string) => {
|
|
117
128
|
setActiveHostIdState(hostId);
|
|
118
129
|
}, []);
|
|
@@ -128,6 +139,7 @@ export function HostStoreProvider({ children }: { children: ReactNode }) {
|
|
|
128
139
|
addPairedHost,
|
|
129
140
|
removePairedHost,
|
|
130
141
|
renamePairedHost,
|
|
142
|
+
setHostLanUrl,
|
|
131
143
|
setActiveHostId,
|
|
132
144
|
getActiveHost,
|
|
133
145
|
}}>
|
|
@@ -45,7 +45,7 @@ interface PermissionPrompt { permissions: RequiredPermission[]; sessionName?: st
|
|
|
45
45
|
interface InputPrompt { questions: string[]; description?: string; sessionName?: string }
|
|
46
46
|
|
|
47
47
|
export default function Dashboard() {
|
|
48
|
-
const { pairedHosts, activeHostId, removePairedHost } = useHostStore();
|
|
48
|
+
const { pairedHosts, activeHostId, removePairedHost, setHostLanUrl } = useHostStore();
|
|
49
49
|
const { connected, request, subscribeEvents, unauthorized } = useHostConnection();
|
|
50
50
|
const navigate = useNavigate();
|
|
51
51
|
const location = useLocation();
|
|
@@ -90,6 +90,7 @@ export default function Dashboard() {
|
|
|
90
90
|
host_platform?: string;
|
|
91
91
|
capability_tokens?: Record<string, string | null>;
|
|
92
92
|
pending_prompts?: PendingPrompt[];
|
|
93
|
+
lan_url?: string | null;
|
|
93
94
|
}>("host.info")
|
|
94
95
|
.then((result) => {
|
|
95
96
|
setAgents(result.agents ?? []);
|
|
@@ -99,6 +100,9 @@ export default function Dashboard() {
|
|
|
99
100
|
const version = result.version ?? null;
|
|
100
101
|
setDaemonVersion(version);
|
|
101
102
|
setUpdateRequired(!!version && isOlderThan(version, MIN_HOST_VERSION));
|
|
103
|
+
if (activeHostId) {
|
|
104
|
+
setHostLanUrl(activeHostId, result.lan_url ?? undefined);
|
|
105
|
+
}
|
|
102
106
|
|
|
103
107
|
// Seed modal state from already-pending prompts.
|
|
104
108
|
const confirms = new Map<string, ConfirmPrompt>();
|
|
@@ -132,7 +136,7 @@ export default function Dashboard() {
|
|
|
132
136
|
setInputValues(inputVals);
|
|
133
137
|
})
|
|
134
138
|
.catch(() => { /* silent — update-required prompt guards the broken case */ });
|
|
135
|
-
}, [connected, activeHostId, request]);
|
|
139
|
+
}, [connected, activeHostId, request, setHostLanUrl]);
|
|
136
140
|
|
|
137
141
|
// Always-on event subscription for modal lifecycle. Independent of which tab
|
|
138
142
|
// is active. Task-card status updates happen inside TasksView while mounted.
|
|
@@ -11,7 +11,6 @@ import type { PairedHost } from "../types";
|
|
|
11
11
|
interface PairResponse {
|
|
12
12
|
hostId: string;
|
|
13
13
|
clientToken: string;
|
|
14
|
-
directUrl?: string;
|
|
15
14
|
hostName?: string;
|
|
16
15
|
}
|
|
17
16
|
|
|
@@ -82,7 +81,6 @@ export default function PairHost() {
|
|
|
82
81
|
hostId: response.hostId,
|
|
83
82
|
clientToken: response.clientToken,
|
|
84
83
|
directUrl: isLoopback ? window.location.origin : undefined,
|
|
85
|
-
lanUrl: !isLoopback ? response.directUrl : undefined,
|
|
86
84
|
...(response.hostName ? { name: response.hostName } : {}),
|
|
87
85
|
};
|
|
88
86
|
|
|
@@ -6,12 +6,13 @@ import CapabilityToggles from "../components/CapabilityToggles";
|
|
|
6
6
|
|
|
7
7
|
interface HostInfoResponse {
|
|
8
8
|
capability_tokens?: Record<string, string | null>;
|
|
9
|
+
lan_url?: string | null;
|
|
9
10
|
}
|
|
10
11
|
|
|
11
12
|
export default function PairSetup() {
|
|
12
13
|
const navigate = useNavigate();
|
|
13
14
|
const { connected, request } = useHostConnection();
|
|
14
|
-
const { getActiveHost } = useHostStore();
|
|
15
|
+
const { getActiveHost, setHostLanUrl } = useHostStore();
|
|
15
16
|
const activeHost = getActiveHost();
|
|
16
17
|
const activeClientToken = activeHost?.clientToken ?? null;
|
|
17
18
|
|
|
@@ -26,16 +27,18 @@ export default function PairSetup() {
|
|
|
26
27
|
|
|
27
28
|
useEffect(() => {
|
|
28
29
|
if (!connected || !activeHost) return;
|
|
30
|
+
const activeHostId = activeHost.hostId;
|
|
29
31
|
let cancelled = false;
|
|
30
32
|
request<HostInfoResponse>("host.info")
|
|
31
33
|
.then((res) => {
|
|
32
34
|
if (cancelled) return;
|
|
33
35
|
setCapabilityTokens(res.capability_tokens ?? {});
|
|
36
|
+
setHostLanUrl(activeHostId, res.lan_url ?? undefined);
|
|
34
37
|
setLoaded(true);
|
|
35
38
|
})
|
|
36
39
|
.catch(() => { if (!cancelled) setLoaded(true); });
|
|
37
40
|
return () => { cancelled = true; };
|
|
38
|
-
}, [connected, activeHost, request]);
|
|
41
|
+
}, [connected, activeHost, request, setHostLanUrl]);
|
|
39
42
|
|
|
40
43
|
return (
|
|
41
44
|
<div className="pair-setup">
|
|
@@ -66,6 +66,6 @@ export interface PairedHost {
|
|
|
66
66
|
name?: string;
|
|
67
67
|
/** If set, all communication uses HTTP to this URL instead of NATS. Set only for loopback (Local mode). */
|
|
68
68
|
directUrl?: string;
|
|
69
|
-
/** Host's LAN URL
|
|
69
|
+
/** Host's LAN URL, refreshed from each `host.info` response so laptop/DHCP IP changes propagate. Native Capacitor app probes for reachability and routes RPC over HTTP when reachable; events stay on NATS. */
|
|
70
70
|
lanUrl?: string;
|
|
71
71
|
}
|
package/palmier-server/spec.md
CHANGED
|
@@ -91,13 +91,16 @@ Each host machine is provisioned via `palmier init`, an interactive wizard that
|
|
|
91
91
|
|
|
92
92
|
1. Detects installed agent CLIs.
|
|
93
93
|
2. Asks which HTTP port to use (default 7256).
|
|
94
|
-
3.
|
|
95
|
-
4.
|
|
96
|
-
5.
|
|
97
|
-
6.
|
|
94
|
+
3. Detects the OS default network interface (used as the source for the host's LAN URL in `host.info` responses).
|
|
95
|
+
4. Shows a summary of task storage directory, local access URL, detected agents, and any existing tasks to recover. Asks for confirmation before proceeding.
|
|
96
|
+
5. Registers with the Palmier server via `POST <url>/api/hosts/register` — server returns `{ hostId, natsUrl, natsWsUrl, natsJwt, natsNkeySeed }`.
|
|
97
|
+
6. Saves config to `~/.config/palmier/host.json` (includes `httpPort`, `defaultInterface`, NATS credentials).
|
|
98
|
+
7. Installs a systemd user service (Linux), user LaunchAgent (macOS), or Task Scheduler entry (Windows) and auto-enters pair mode.
|
|
98
99
|
|
|
99
100
|
The daemon automatically recovers existing tasks by reinstalling their system timers on startup.
|
|
100
101
|
|
|
102
|
+
`defaultInterface` is captured once at `init` time. On each `host.info` RPC, the host re-reads the current IPv4 of that interface (`getInterfaceIpv4(config.defaultInterface)`) so DHCP-assigned IP changes on the same adapter propagate to clients without a re-pair. Users should re-run `palmier init` if they physically switch adapters (e.g., Ethernet ↔ WiFi, adding a new tethered interface).
|
|
103
|
+
|
|
101
104
|
The `serve` daemon always starts an HTTP server bound to `0.0.0.0:<port>`. Two access modes are available:
|
|
102
105
|
|
|
103
106
|
**Local mode** (always available, loopback only):
|
package/src/commands/info.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { loadConfig } from "../config.js";
|
|
2
2
|
import { loadClients } from "../client-store.js";
|
|
3
|
+
import { buildLanUrl } from "../network.js";
|
|
3
4
|
|
|
4
5
|
export async function infoCommand(): Promise<void> {
|
|
5
6
|
const config = loadConfig();
|
|
6
7
|
const clients = loadClients();
|
|
8
|
+
const port = config.httpPort ?? 7256;
|
|
9
|
+
|
|
10
|
+
const lanUrl = buildLanUrl(port, config.defaultInterface);
|
|
7
11
|
|
|
8
12
|
console.log(`Host ID: ${config.hostId}`);
|
|
9
13
|
console.log(`Project root: ${config.projectRoot}`);
|
|
14
|
+
console.log(`Local URL: http://localhost:${port}`);
|
|
15
|
+
console.log(`LAN URL: ${lanUrl ?? "(default route interface unavailable — pair a device or check network)"}`);
|
|
10
16
|
|
|
11
17
|
if (config.agents && config.agents.length > 0) {
|
|
12
18
|
console.log(`Agents: ${config.agents.map((a) => a.label).join(", ")}`);
|
package/src/commands/init.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { loadConfig, saveConfig } from "../config.js";
|
|
|
3
3
|
import { detectAgents } from "../agents/agent.js";
|
|
4
4
|
import { getPlatform } from "../platform/index.js";
|
|
5
5
|
import { pairCommand } from "./pair.js";
|
|
6
|
-
import {
|
|
6
|
+
import { detectDefaultInterface, getInterfaceIpv4 } from "../network.js";
|
|
7
7
|
import { listTasks } from "../task.js";
|
|
8
8
|
import type { HostConfig } from "../types.js";
|
|
9
9
|
|
|
@@ -42,16 +42,25 @@ export async function initCommand(): Promise<void> {
|
|
|
42
42
|
const parsed = parseInt(portAnswer.trim(), 10);
|
|
43
43
|
if (parsed > 0 && parsed < 65536) httpPort = parsed;
|
|
44
44
|
|
|
45
|
-
const
|
|
45
|
+
const defaultInterface = (await detectDefaultInterface()) ?? undefined;
|
|
46
|
+
const lanIp = defaultInterface ? getInterfaceIpv4(defaultInterface) : null;
|
|
46
47
|
|
|
47
48
|
console.log(`\n${bold("Setup summary:")}\n`);
|
|
48
49
|
console.log(` ${dim("Task storage:")} ${bold(process.cwd())}`);
|
|
49
50
|
console.log(` All tasks and execution data will be stored here.\n`);
|
|
50
|
-
console.log(` ${dim("Local
|
|
51
|
+
console.log(` ${dim("Local:")} ${cyan(`http://localhost:${httpPort}`)}`);
|
|
51
52
|
console.log(` Open in a browser on this machine — no internet required.\n`);
|
|
52
|
-
console.log(` ${dim("Remote
|
|
53
|
-
console.log(` Pair
|
|
54
|
-
console.log(`
|
|
53
|
+
console.log(` ${dim("Remote (web):")} ${cyan("https://app.palmier.me")}`);
|
|
54
|
+
console.log(` Pair a browser on any device. Traffic always goes through the relay.\n`);
|
|
55
|
+
console.log(` ${dim("Remote (app):")} ${cyan("https://github.com/caihongxu/palmier-android/releases")}`);
|
|
56
|
+
if (lanIp) {
|
|
57
|
+
console.log(` Download the Android APK. The app uses LAN for direct RPC`);
|
|
58
|
+
console.log(` on the same network (detected ${cyan(`http://${lanIp}:${httpPort}`)}),`);
|
|
59
|
+
console.log(` otherwise the relay.\n`);
|
|
60
|
+
} else {
|
|
61
|
+
console.log(` Download the Android APK. Traffic will go through the relay —`);
|
|
62
|
+
console.log(` ${red("could not detect a LAN interface")} for direct RPC.\n`);
|
|
63
|
+
}
|
|
55
64
|
console.log(` ${dim("Agents:")} ${agents.map((a) => a.label).join(", ")}\n`);
|
|
56
65
|
|
|
57
66
|
const existingTasks = listTasks(process.cwd());
|
|
@@ -102,6 +111,7 @@ export async function initCommand(): Promise<void> {
|
|
|
102
111
|
natsNkeySeed: registerResponse.natsNkeySeed,
|
|
103
112
|
agents,
|
|
104
113
|
httpPort,
|
|
114
|
+
defaultInterface,
|
|
105
115
|
};
|
|
106
116
|
|
|
107
117
|
saveConfig(config);
|
package/src/commands/pair.ts
CHANGED
|
@@ -4,7 +4,6 @@ import { StringCodec } from "nats";
|
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { connectNats } from "../nats-client.js";
|
|
6
6
|
import { addClient } from "../client-store.js";
|
|
7
|
-
import { detectLanIp } from "../transports/http-transport.js";
|
|
8
7
|
import type { HostConfig } from "../types.js";
|
|
9
8
|
|
|
10
9
|
const CODE_CHARS = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no O/0/I/1/L
|
|
@@ -20,11 +19,9 @@ export function generatePairingCode(): string {
|
|
|
20
19
|
|
|
21
20
|
function buildPairResponse(config: HostConfig, label?: string) {
|
|
22
21
|
const client = addClient(label);
|
|
23
|
-
const port = config.httpPort ?? 7256;
|
|
24
22
|
return {
|
|
25
23
|
hostId: config.hostId,
|
|
26
24
|
clientToken: client.token,
|
|
27
|
-
directUrl: `http://${detectLanIp()}:${port}`,
|
|
28
25
|
hostName: os.hostname(),
|
|
29
26
|
};
|
|
30
27
|
}
|
package/src/network.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as os from "node:os";
|
|
2
|
+
import * as dgram from "node:dgram";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Resolve the name of the network interface used for the IPv4 default route.
|
|
6
|
+
* Falls back to the first non-internal IPv4 interface when the gateway lookup
|
|
7
|
+
* fails — `default-gateway` shells out to `wmic` on Windows, which was removed
|
|
8
|
+
* in Windows 11 24H2.
|
|
9
|
+
*/
|
|
10
|
+
function findInterfaceByIp(ip: string): string | null {
|
|
11
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
12
|
+
for (const addr of addrs ?? []) {
|
|
13
|
+
if (addr.family === "IPv4" && addr.address === ip) return name;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Ask the kernel which local IPv4 would route to an external address. No packet is sent. */
|
|
20
|
+
function probeOutboundIp(): Promise<string | null> {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const sock = dgram.createSocket("udp4");
|
|
23
|
+
const cleanup = (ip: string | null) => { try { sock.close(); } catch { /* ignore */ } resolve(ip); };
|
|
24
|
+
sock.on("error", () => cleanup(null));
|
|
25
|
+
try {
|
|
26
|
+
sock.connect(80, "8.8.8.8", () => {
|
|
27
|
+
const addr = sock.address();
|
|
28
|
+
cleanup(addr.address && addr.address !== "0.0.0.0" ? addr.address : null);
|
|
29
|
+
});
|
|
30
|
+
} catch {
|
|
31
|
+
cleanup(null);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type IpClass = 0 | 1 | 2 | 3;
|
|
37
|
+
|
|
38
|
+
/** Lower score = more preferred. 0=192.168, 1=10.x, 2=172.16-31, 3=everything else. */
|
|
39
|
+
function ipClass(ip: string): IpClass {
|
|
40
|
+
const parts = ip.split(".").map(Number);
|
|
41
|
+
if (parts.length !== 4 || parts.some((p) => Number.isNaN(p))) return 3;
|
|
42
|
+
const [a, b] = parts;
|
|
43
|
+
if (a === 192 && b === 168) return 0;
|
|
44
|
+
if (a === 10) return 1;
|
|
45
|
+
if (a === 172 && b >= 16 && b <= 31) return 2;
|
|
46
|
+
return 3;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Names that commonly belong to virtual/VPN adapters we'd rather skip. */
|
|
50
|
+
const VIRTUAL_NAME_PATTERNS = [
|
|
51
|
+
"vethernet", "virtualbox", "vmware", "hyper-v", "docker", "bridge",
|
|
52
|
+
"tailscale", "wireguard", "meta", "vpn", "tun", "tap", "loopback",
|
|
53
|
+
"wsl", "utun",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
function isVirtualName(name: string): boolean {
|
|
57
|
+
const lower = name.toLowerCase();
|
|
58
|
+
return VIRTUAL_NAME_PATTERNS.some((p) => lower.includes(p));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function detectDefaultInterface(): Promise<string | null> {
|
|
62
|
+
const probedIp = await probeOutboundIp();
|
|
63
|
+
if (probedIp) {
|
|
64
|
+
const name = findInterfaceByIp(probedIp);
|
|
65
|
+
if (name && !isVirtualName(name)) return name;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Candidate = { name: string; klass: IpClass; virtual: boolean };
|
|
69
|
+
const candidates: Candidate[] = [];
|
|
70
|
+
for (const [name, addrs] of Object.entries(os.networkInterfaces())) {
|
|
71
|
+
for (const addr of addrs ?? []) {
|
|
72
|
+
if (addr.family !== "IPv4" || addr.internal) continue;
|
|
73
|
+
candidates.push({ name, klass: ipClass(addr.address), virtual: isVirtualName(name) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
candidates.sort((a, b) => {
|
|
77
|
+
if (a.virtual !== b.virtual) return a.virtual ? 1 : -1;
|
|
78
|
+
return a.klass - b.klass;
|
|
79
|
+
});
|
|
80
|
+
return candidates[0]?.name ?? null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getInterfaceIpv4(interfaceName: string): string | null {
|
|
84
|
+
const addrs = os.networkInterfaces()[interfaceName];
|
|
85
|
+
if (!addrs) return null;
|
|
86
|
+
for (const addr of addrs) {
|
|
87
|
+
if (addr.family === "IPv4" && !addr.internal) return addr.address;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function buildLanUrl(port: number, interfaceName: string | undefined): string | null {
|
|
93
|
+
if (!interfaceName) return null;
|
|
94
|
+
const ip = getInterfaceIpv4(interfaceName);
|
|
95
|
+
return ip ? `http://${ip}:${port}` : null;
|
|
96
|
+
}
|
package/src/rpc-handler.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { getCapabilityDevice, setCapabilityDevice, clearCapabilityDevice, type D
|
|
|
15
15
|
import { currentVersion, performUpdate } from "./update-checker.js";
|
|
16
16
|
import { parseReportFiles, parseTaskOutcome, stripPalmierMarkers } from "./commands/run.js";
|
|
17
17
|
import { clearTaskQueue } from "./event-queues.js";
|
|
18
|
+
import { buildLanUrl } from "./network.js";
|
|
18
19
|
import type { HostConfig, ParsedTask, RpcMessage, ConversationMessage } from "./types.js";
|
|
19
20
|
|
|
20
21
|
export function parseResultFrontmatter(raw: string): Record<string, unknown> {
|
|
@@ -153,6 +154,7 @@ export function createRpcHandler(config: HostConfig, nc?: NatsConnection) {
|
|
|
153
154
|
host_platform: process.platform,
|
|
154
155
|
capability_tokens: capabilities,
|
|
155
156
|
pending_prompts: listPending(),
|
|
157
|
+
lan_url: buildLanUrl(config.httpPort ?? 7256, config.defaultInterface),
|
|
156
158
|
};
|
|
157
159
|
}
|
|
158
160
|
|
|
@@ -6,6 +6,8 @@ import { validateClient, addClient } from "../client-store.js";
|
|
|
6
6
|
import { registerPending } from "../pending-requests.js";
|
|
7
7
|
import * as fs from "node:fs";
|
|
8
8
|
import type { HostConfig, RpcMessage, RequiredPermission } from "../types.js";
|
|
9
|
+
import { saveConfig } from "../config.js";
|
|
10
|
+
import { detectDefaultInterface } from "../network.js";
|
|
9
11
|
import { agentToolMap, agentResources, ToolError, type ToolContext } from "../mcp-tools.js";
|
|
10
12
|
import { handleMcpRequest, getAgentName, getResourceSubscriptions } from "../mcp-handler.js";
|
|
11
13
|
import { getTaskDir } from "../task.js";
|
|
@@ -72,16 +74,12 @@ interface PendingPair {
|
|
|
72
74
|
|
|
73
75
|
const pendingPairs = new Map<string, PendingPair>();
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return iface.address;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
77
|
+
async function persistDefaultInterface(config: HostConfig): Promise<void> {
|
|
78
|
+
const iface = await detectDefaultInterface();
|
|
79
|
+
if (iface && iface !== config.defaultInterface) {
|
|
80
|
+
config.defaultInterface = iface;
|
|
81
|
+
saveConfig(config);
|
|
83
82
|
}
|
|
84
|
-
return "127.0.0.1";
|
|
85
83
|
}
|
|
86
84
|
|
|
87
85
|
export async function startHttpTransport(
|
|
@@ -371,11 +369,10 @@ export async function startHttpTransport(
|
|
|
371
369
|
if (!pending) { sendJson(res, 401, { error: "Invalid code" }); return; }
|
|
372
370
|
|
|
373
371
|
const client = addClient(label);
|
|
374
|
-
|
|
372
|
+
await persistDefaultInterface(config);
|
|
375
373
|
const response: Record<string, unknown> = {
|
|
376
374
|
hostId: config.hostId,
|
|
377
375
|
clientToken: client.token,
|
|
378
|
-
directUrl: `http://${ip}:${port}`,
|
|
379
376
|
hostName: os.hostname(),
|
|
380
377
|
};
|
|
381
378
|
|
package/src/types.ts
CHANGED
|
@@ -10,6 +10,11 @@ export interface HostConfig {
|
|
|
10
10
|
agents?: Array<{ key: string; label: string; supportsPermissions: boolean; supportsYolo: boolean }>;
|
|
11
11
|
|
|
12
12
|
httpPort?: number;
|
|
13
|
+
|
|
14
|
+
/** OS network interface name of the IPv4 default route, captured on the
|
|
15
|
+
* most recent `palmier pair`. Used to resolve the host's LAN URL live for
|
|
16
|
+
* `host.info` so the client can track IP changes (laptop roaming, DHCP). */
|
|
17
|
+
defaultInterface?: string;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
export interface TaskFrontmatter {
|