@fibscope/agent 0.1.0

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.
@@ -0,0 +1,311 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { pathToFileURL } from "node:url";
4
+ import path from "node:path";
5
+
6
+ export class FiberAutomationError extends Error {
7
+ constructor(message, code = "FIBER_AUTOMATION_ERROR", details = undefined) {
8
+ super(message);
9
+ this.name = "FiberAutomationError";
10
+ this.code = code;
11
+ this.details = details;
12
+ }
13
+ }
14
+
15
+ export function recommend(input = {}, options = {}) {
16
+ const context = normalizeAutomationContext(input);
17
+ const recommendations = [
18
+ ...recommendFromRootCauses(context),
19
+ ...recommendFromPredictions(context),
20
+ ...recommendFromDiagnostics(context),
21
+ ...recommendFromState(context, options),
22
+ ];
23
+ return dedupeRecommendations(recommendations)
24
+ .sort((left, right) => left.priority - right.priority);
25
+ }
26
+
27
+ export async function recommendFromSources(options = {}) {
28
+ const context = {};
29
+ if (options.component1State) context.component1 = options.component1State;
30
+ if (options.component2Result) context.component2 = options.component2Result;
31
+ if (options.component3Report) context.component3 = options.component3Report;
32
+
33
+ if (options.component1Module && options.rpcUrl) {
34
+ const imported = await import(resolveImportSpecifier(options.component1Module));
35
+ context.component1 = await imported.collectFiberStateReport({
36
+ rpcUrl: options.rpcUrl,
37
+ timeoutMs: options.timeoutMs ?? 8000,
38
+ });
39
+ }
40
+
41
+ if (options.component3Url) {
42
+ context.component3 = await fetchComponent3(options.component3Url, options);
43
+ }
44
+
45
+ return recommend(context, options);
46
+ }
47
+
48
+ export function normalizeAutomationContext(input = {}) {
49
+ return {
50
+ component1: input.component1 ?? input.fiberState ?? input.state ?? {},
51
+ component2: input.component2 ?? input.routeSimulation ?? input.paymentSimulation ?? {},
52
+ component3: input.component3 ?? input.observability ?? {},
53
+ rootCauses: [
54
+ ...(input.rootCauses ?? []),
55
+ ...(input.diagnoses ?? []),
56
+ ...(input.component3?.rootCauses ?? []),
57
+ ...(input.component3?.diagnostics?.rootCauses ?? []),
58
+ ],
59
+ predictions: [
60
+ ...(input.predictions ?? []),
61
+ ...(input.component2?.predictions ?? []),
62
+ ...(input.component2?.visualization?.predictions ?? []),
63
+ ],
64
+ diagnostics: [
65
+ ...(input.diagnostics ?? []),
66
+ ...(input.component1?.diagnostics ?? []),
67
+ ...(input.component3?.component1 ?? []),
68
+ ...(input.component3?.diagnostics?.component1 ?? []),
69
+ ],
70
+ };
71
+ }
72
+
73
+ function recommendFromRootCauses(context) {
74
+ return context.rootCauses.flatMap((cause) => recommendationsForCode(cause.code, {
75
+ source: "root-cause",
76
+ explanation: cause.explanation,
77
+ affectedChannel: cause.affectedChannel,
78
+ affectedPeer: cause.affectedPeer,
79
+ confidence: cause.confidence,
80
+ }));
81
+ }
82
+
83
+ function recommendFromPredictions(context) {
84
+ return context.predictions.flatMap((prediction) => recommendationsForCode(prediction.code, {
85
+ source: "prediction",
86
+ explanation: prediction.explanation,
87
+ confidence: prediction.confidence,
88
+ }));
89
+ }
90
+
91
+ function recommendFromDiagnostics(context) {
92
+ return context.diagnostics.flatMap((diagnostic) => {
93
+ const severity = String(diagnostic.severity ?? "info").toLowerCase();
94
+ const code = String(diagnostic.code ?? "UNKNOWN").toUpperCase();
95
+ if (severity === "info" && !isActionableCode(code)) return [];
96
+ return recommendationsForCode(code, {
97
+ source: "diagnostic",
98
+ explanation: diagnostic.description ?? diagnostic.title,
99
+ severity,
100
+ });
101
+ });
102
+ }
103
+
104
+ function recommendFromState(context, options) {
105
+ const state = context.component1;
106
+ const recommendations = [];
107
+ const outbound = amountMaybe(state?.liquidity?.totalOutbound);
108
+ const requested = amountMaybe(options.amount ?? context.component2?.request?.amount);
109
+ if (requested !== undefined && outbound !== undefined && requested > outbound) {
110
+ recommendations.push(recommendation(1, "Reduce payment amount", `Requested amount ${requested} exceeds outbound liquidity ${outbound}.`, "REDUCE_PAYMENT_AMOUNT", {
111
+ source: "component1-liquidity",
112
+ requested,
113
+ outbound,
114
+ }));
115
+ }
116
+ for (const channel of state?.channels ?? []) {
117
+ const capacity = amountMaybe(channel.capacity);
118
+ const local = amountMaybe(channel.localBalance);
119
+ if (capacity && local !== undefined && capacity > 0n && (local * 100n / capacity) < 15n) {
120
+ recommendations.push(recommendation(2, "Rebalance channel", `Channel ${short(channel.channelId)} has low outbound liquidity.`, "REBALANCE_CHANNEL", {
121
+ channelId: channel.channelId,
122
+ peerId: channel.peerId,
123
+ }));
124
+ }
125
+ }
126
+ return recommendations;
127
+ }
128
+
129
+ function recommendationsForCode(code, metadata = {}) {
130
+ const normalized = String(code ?? "UNKNOWN").toUpperCase();
131
+ if (normalized === "INSUFFICIENT_LIQUIDITY" || normalized === "INSUFFICIENT_OUTBOUND_LIQUIDITY" || normalized === "LOW_OUTBOUND_LIQUIDITY") {
132
+ return [
133
+ recommendation(1, "Rebalance an existing channel", metadata.explanation ?? "Outbound liquidity is insufficient for the attempted payment.", "REBALANCE_CHANNEL", metadata),
134
+ recommendation(2, "Open a new channel", "Add fresh outbound capacity through a new channel.", "OPEN_CHANNEL", metadata),
135
+ recommendation(3, "Reduce payment amount", "Retry with an amount that fits the current outbound liquidity.", "REDUCE_PAYMENT_AMOUNT", metadata),
136
+ ];
137
+ }
138
+ if (normalized === "NO_ROUTE" || normalized === "NO_CHANNELS") {
139
+ return [
140
+ recommendation(1, "Create a channel", metadata.explanation ?? "No usable route/channel is available.", "OPEN_CHANNEL", metadata),
141
+ recommendation(2, "Connect to more Fiber peers", "Improve graph reachability before retrying the payment.", "CONNECT_PEER", metadata),
142
+ ];
143
+ }
144
+ if (normalized === "CHANNEL_CLOSED" || normalized === "CHANNEL_NOT_OPEN") {
145
+ return [
146
+ recommendation(1, "Wait for channel state to settle", metadata.explanation ?? "A channel is not open.", "WAIT_CHANNEL_OPEN", metadata),
147
+ recommendation(2, "Use an alternate channel", "Route around the closed or pending channel.", "USE_ALTERNATE_ROUTE", metadata),
148
+ ];
149
+ }
150
+ if (normalized === "PEER_OFFLINE" || normalized === "NO_PEERS") {
151
+ return [
152
+ recommendation(1, "Reconnect Fiber peer", metadata.explanation ?? "A required peer is offline or no peer is connected.", "CONNECT_PEER", metadata),
153
+ recommendation(2, "Use alternate route", "Avoid the offline peer when selecting a route.", "USE_ALTERNATE_ROUTE", metadata),
154
+ ];
155
+ }
156
+ if (normalized === "HIGH_ROUTING_FEES") {
157
+ return [
158
+ recommendation(2, "Use lower-fee route", metadata.explanation ?? "The selected route has high fees.", "USE_ALTERNATE_ROUTE", metadata),
159
+ recommendation(3, "Tune fee policy", "Review peer/channel fee settings before retrying.", "TUNE_FEE_POLICY", metadata),
160
+ ];
161
+ }
162
+ if (normalized === "TIMEOUT" || normalized === "NODE_OFFLINE") {
163
+ return [
164
+ recommendation(1, "Verify FNN connectivity", metadata.explanation ?? "The local Fiber node is offline or timing out.", "VERIFY_NODE_RUNNING", metadata),
165
+ recommendation(2, "Check RPC connectivity", "Verify the configured RPC URL and network path.", "CHECK_RPC_CONNECTIVITY", metadata),
166
+ ];
167
+ }
168
+ return [
169
+ recommendation(5, "Rebuild state from observability data", metadata.explanation ?? "The failure cause is unknown.", "INSPECT_TELEMETRY", metadata),
170
+ ];
171
+ }
172
+
173
+ function isActionableCode(code) {
174
+ return [
175
+ "INSUFFICIENT_LIQUIDITY",
176
+ "INSUFFICIENT_OUTBOUND_LIQUIDITY",
177
+ "LOW_OUTBOUND_LIQUIDITY",
178
+ "NO_ROUTE",
179
+ "NO_CHANNELS",
180
+ "CHANNEL_CLOSED",
181
+ "CHANNEL_NOT_OPEN",
182
+ "PEER_OFFLINE",
183
+ "NO_PEERS",
184
+ "HIGH_ROUTING_FEES",
185
+ "TIMEOUT",
186
+ "NODE_OFFLINE",
187
+ ].includes(code);
188
+ }
189
+
190
+ function recommendation(priority, title, explanation, actionType, metadata = {}) {
191
+ return {
192
+ priority,
193
+ title,
194
+ explanation,
195
+ actionType,
196
+ metadata,
197
+ };
198
+ }
199
+
200
+ function dedupeRecommendations(items) {
201
+ const best = new Map();
202
+ for (const item of items) {
203
+ const key = `${item.actionType}:${item.title}`;
204
+ const existing = best.get(key);
205
+ if (!existing || item.priority < existing.priority) best.set(key, item);
206
+ }
207
+ return [...best.values()];
208
+ }
209
+
210
+ async function fetchComponent3(component3Url, options) {
211
+ const base = component3Url.replace(/\/$/, "");
212
+ const [diagnostics, health, metrics, alerts] = await Promise.all([
213
+ fetchJson(`${base}/diagnostics`, options),
214
+ fetchJson(`${base}/health`, options),
215
+ fetchJson(`${base}/metrics`, options),
216
+ fetchJson(`${base}/alerts`, options),
217
+ ]);
218
+ return { diagnostics, health, metrics, alerts };
219
+ }
220
+
221
+ async function fetchJson(url, options = {}) {
222
+ const controller = new AbortController();
223
+ const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 8000);
224
+ try {
225
+ const response = await fetch(url, { signal: controller.signal });
226
+ if (!response.ok) throw new FiberAutomationError(`${url} failed with HTTP ${response.status}`, "FETCH_FAILED");
227
+ return response.json();
228
+ } finally {
229
+ clearTimeout(timer);
230
+ }
231
+ }
232
+
233
+ function amountMaybe(value) {
234
+ if (value === undefined || value === null || value === "") return undefined;
235
+ if (typeof value === "bigint") return value;
236
+ if (typeof value === "number" && Number.isFinite(value)) return BigInt(Math.trunc(value));
237
+ if (typeof value === "string" && /^0x[0-9a-fA-F]+$/.test(value)) return BigInt(value);
238
+ if (typeof value === "string" && /^[0-9]+$/.test(value)) return BigInt(value);
239
+ return undefined;
240
+ }
241
+
242
+ function short(value) {
243
+ const text = String(value ?? "");
244
+ return text.length <= 14 ? text : `${text.slice(0, 8)}...${text.slice(-6)}`;
245
+ }
246
+
247
+ function resolveImportSpecifier(specifier) {
248
+ if (/^(file|data|node|https?):/.test(specifier)) return specifier;
249
+ if (specifier.startsWith(".") || specifier.includes("\\") || specifier.includes("/")) {
250
+ return pathToFileURL(path.resolve(process.cwd(), specifier)).href;
251
+ }
252
+ return specifier;
253
+ }
254
+
255
+ function stringify(value) {
256
+ return JSON.stringify(value, (_, nested) => typeof nested === "bigint" ? nested.toString() : nested, 2);
257
+ }
258
+
259
+ function parseCliArgs(argv) {
260
+ const options = {};
261
+ let command = argv[0]?.startsWith("--") ? "recommend" : (argv.shift() ?? "help");
262
+ for (let index = 0; index < argv.length; index += 1) {
263
+ const raw = argv[index];
264
+ const [flag, inline] = raw.split("=", 2);
265
+ const value = () => inline ?? argv[++index];
266
+ if (flag === "--rpc") options.rpcUrl = value();
267
+ else if (flag === "--component1-module") options.component1Module = value();
268
+ else if (flag === "--component3-url") options.component3Url = value();
269
+ else if (flag === "--amount") options.amount = value();
270
+ else if (flag === "--input") options.input = value();
271
+ else if (flag === "--json") options.json = true;
272
+ else if (flag === "--help" || flag === "-h") command = "help";
273
+ else throw new FiberAutomationError(`Unknown option ${flag}`, "CLI_USAGE_ERROR");
274
+ }
275
+ return { command, options };
276
+ }
277
+
278
+ async function readInput(pathname) {
279
+ if (!pathname) return {};
280
+ const { readFile } = await import("node:fs/promises");
281
+ return JSON.parse(await readFile(pathname, "utf8"));
282
+ }
283
+
284
+ function help() {
285
+ return [
286
+ "Fiber Automation Recommendation Engine",
287
+ "",
288
+ "Usage:",
289
+ " node recommendation.mjs recommend --input context.json --json",
290
+ " node recommendation.mjs recommend --rpc http://127.0.0.1:8227 --component1-module ../pulse/index.mjs --amount 100000000 --json",
291
+ " node recommendation.mjs recommend --component3-url http://127.0.0.1:8793 --json",
292
+ ].join("\n");
293
+ }
294
+
295
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
296
+ const { command, options } = parseCliArgs(process.argv.slice(2));
297
+ if (command === "help") {
298
+ console.log(help());
299
+ } else if (command === "recommend") {
300
+ const input = await readInput(options.input);
301
+ recommendFromSources({ ...options, ...input })
302
+ .then((items) => console.log(options.json ? stringify(items) : items.map((item) => `${item.priority}. ${item.title} (${item.actionType})`).join("\n")))
303
+ .catch((error) => {
304
+ console.error(error instanceof Error ? error.message : String(error));
305
+ process.exitCode = 1;
306
+ });
307
+ } else {
308
+ console.error(`Unknown command ${command}`);
309
+ process.exitCode = 1;
310
+ }
311
+ }
@@ -0,0 +1,293 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { constants } from "node:fs";
3
+ import { access, readFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+
6
+ export async function discoverFnnInstances(options = {}) {
7
+ const timeoutMs = options.timeoutMs ?? 1200;
8
+ const rows = process.platform === "win32" ? windowsFnnProcesses() : posixFnnProcesses();
9
+ const instances = [];
10
+
11
+ for (const row of rows) {
12
+ const parsed = parseFnnCommandLine(row.commandLine);
13
+ const configPath = parsed.configPath ? path.resolve(parsed.configPath) : undefined;
14
+ const baseDir = parsed.baseDir ? path.resolve(parsed.baseDir) : configPath ? path.dirname(configPath) : undefined;
15
+ const config = configPath ? await readFiberConfig(configPath) : {};
16
+ const rpcUrl = config.rpcUrl;
17
+ const reachable = rpcUrl ? await rpcResponds(rpcUrl, timeoutMs) : false;
18
+ instances.push({
19
+ pid: row.pid,
20
+ commandLine: row.commandLine,
21
+ configPath,
22
+ baseDir,
23
+ rpcUrl,
24
+ p2pListenAddr: config.p2pListenAddr,
25
+ p2pPort: config.p2pPort,
26
+ ckbRpcUrl: config.ckbRpcUrl,
27
+ network: config.network,
28
+ reachable,
29
+ });
30
+ }
31
+
32
+ return dedupeInstances(instances);
33
+ }
34
+
35
+ export async function chooseFnnInstance(instances, options = {}) {
36
+ const candidates = instances.filter((instance) => instance.pid && instance.rpcUrl);
37
+ if (!candidates.length) return undefined;
38
+ if (candidates.length === 1) return candidates[0];
39
+
40
+ const preferred = options.currentRpcUrl
41
+ ? candidates.find((instance) => sameUrl(instance.rpcUrl, options.currentRpcUrl))
42
+ : undefined;
43
+ if (preferred && options.preferCurrent === true) return preferred;
44
+
45
+ if (!options.interactive || typeof options.prompt !== "function") {
46
+ return candidates.find((instance) => instance.reachable) ?? candidates[0];
47
+ }
48
+
49
+ console.log("");
50
+ console.log("Running FNN instances");
51
+ candidates.forEach((instance, index) => {
52
+ console.log(` ${index + 1}. ${describeFnnInstance(instance)}`);
53
+ });
54
+
55
+ while (true) {
56
+ const answer = (await options.prompt(" Select FNN instance to bind [1]: ")).trim() || "1";
57
+ const selected = Number(answer);
58
+ if (Number.isInteger(selected) && selected >= 1 && selected <= candidates.length) return candidates[selected - 1];
59
+ console.log(` Choose a number from 1 to ${candidates.length}.`);
60
+ }
61
+ }
62
+
63
+ export function describeFnnInstance(instance) {
64
+ const status = instance.reachable ? "RPC ready" : "RPC not responding";
65
+ const rpc = instance.rpcUrl ?? "RPC unknown";
66
+ const base = instance.baseDir ?? "base dir unknown";
67
+ const network = instance.network ? `${instance.network}, ` : "";
68
+ return `pid ${instance.pid} (${network}${rpc}, ${status}) - ${base}`;
69
+ }
70
+
71
+ export function applyFnnInstanceToContext(context, instance) {
72
+ if (!instance?.rpcUrl) return context;
73
+ const parsed = new URL(instance.rpcUrl);
74
+ const rpcPort = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
75
+ const baseDir = instance.baseDir ?? context.baseDir;
76
+ const walletPath = path.join(baseDir, "ckb", "wallet.json");
77
+ return {
78
+ ...context,
79
+ baseDir,
80
+ walletConfig: context.explicitWalletConfig ? context.walletConfig : walletPath,
81
+ fiberKeyPath: path.join(baseDir, "ckb", "key"),
82
+ walletCopyPath: walletPath,
83
+ pidPath: path.join(baseDir, "fnn.pid"),
84
+ rpcHost: parsed.hostname,
85
+ rpcPort,
86
+ rpcUrl: instance.rpcUrl,
87
+ boundFnnInstance: instance,
88
+ };
89
+ }
90
+
91
+ export async function rpcResponds(rpcUrl, timeoutMs = 1500) {
92
+ try {
93
+ const result = await rpcCall(rpcUrl, "node_info", [], timeoutMs);
94
+ return Boolean(result?.pubkey || result?.node_id || result?.version);
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+
100
+ async function rpcCall(rpcUrl, method, params, timeoutMs) {
101
+ const controller = new AbortController();
102
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
103
+ try {
104
+ const response = await fetch(rpcUrl, {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: JSON.stringify({ id: 1, jsonrpc: "2.0", method, params }),
108
+ signal: controller.signal,
109
+ });
110
+ const payload = await response.json();
111
+ if (payload.error) throw new Error(payload.error.message ?? "FNN RPC error");
112
+ return payload.result;
113
+ } finally {
114
+ clearTimeout(timeout);
115
+ }
116
+ }
117
+
118
+ async function readFiberConfig(configPath) {
119
+ try {
120
+ await access(configPath, constants.F_OK);
121
+ return parseFiberConfig(await readFile(configPath, "utf8"));
122
+ } catch {
123
+ return {};
124
+ }
125
+ }
126
+
127
+ function parseFiberConfig(config) {
128
+ const lines = config.split(/\r?\n/);
129
+ let section = "";
130
+ let rpcListeningAddr;
131
+ let p2pListenAddr;
132
+ let ckbRpcUrl;
133
+ let network;
134
+
135
+ for (const rawLine of lines) {
136
+ const line = rawLine.replace(/#.*/, "");
137
+ const sectionMatch = /^([A-Za-z0-9_-]+):\s*$/.exec(line);
138
+ if (sectionMatch) {
139
+ section = sectionMatch[1];
140
+ continue;
141
+ }
142
+
143
+ if (section === "rpc") {
144
+ const match = /^\s+listening_addr:\s*["']?([^"'\s]+)["']?/.exec(line);
145
+ if (match) rpcListeningAddr = match[1];
146
+ }
147
+
148
+ if (section === "ckb") {
149
+ const match = /^\s+rpc_url:\s*["']?([^"'\s]+)["']?/.exec(line);
150
+ if (match) ckbRpcUrl = match[1];
151
+ }
152
+
153
+ if (section === "fiber") {
154
+ const listenMatch = /^\s+listening_addr:\s*["']?([^"'\s]+)["']?/.exec(line);
155
+ if (listenMatch) p2pListenAddr = listenMatch[1];
156
+ const match = /^\s+chain:\s*["']?([^"'\s]+)["']?/.exec(line);
157
+ if (match) network = match[1];
158
+ }
159
+ }
160
+
161
+ return {
162
+ network,
163
+ rpcUrl: rpcListeningAddr ? rpcUrlFromListeningAddr(rpcListeningAddr) : undefined,
164
+ p2pListenAddr,
165
+ p2pPort: p2pListenAddr ? p2pPortFromMultiaddr(p2pListenAddr) : undefined,
166
+ ckbRpcUrl,
167
+ };
168
+ }
169
+
170
+ function p2pPortFromMultiaddr(value) {
171
+ const match = /\/tcp\/(\d+)(?:\/|$)/.exec(String(value ?? ""));
172
+ return match ? Number(match[1]) : undefined;
173
+ }
174
+
175
+ function rpcUrlFromListeningAddr(value) {
176
+ const trimmed = value.trim();
177
+ const ipv6 = /^\[([^\]]+)]:(\d+)$/.exec(trimmed);
178
+ if (ipv6) return `http://[${loopbackHost(ipv6[1])}]:${ipv6[2]}`;
179
+ const ipv4 = /^([^:]+):(\d+)$/.exec(trimmed);
180
+ if (ipv4) return `http://${loopbackHost(ipv4[1])}:${ipv4[2]}`;
181
+ return undefined;
182
+ }
183
+
184
+ function loopbackHost(host) {
185
+ return host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
186
+ }
187
+
188
+ function parseFnnCommandLine(commandLine) {
189
+ const tokens = tokenizeCommandLine(commandLine);
190
+ let configPath;
191
+ let baseDir;
192
+ for (let index = 0; index < tokens.length; index += 1) {
193
+ const token = tokens[index];
194
+ if (token === "-c" || token === "--config") configPath = tokens[index + 1];
195
+ else if (token.startsWith("--config=")) configPath = token.slice("--config=".length);
196
+ else if (token === "-d" || token === "--base-dir" || token === "--data-dir") baseDir = tokens[index + 1];
197
+ else if (token.startsWith("--base-dir=")) baseDir = token.slice("--base-dir=".length);
198
+ else if (token.startsWith("--data-dir=")) baseDir = token.slice("--data-dir=".length);
199
+ }
200
+ return { configPath, baseDir };
201
+ }
202
+
203
+ function tokenizeCommandLine(commandLine) {
204
+ const tokens = [];
205
+ let current = "";
206
+ let quote = "";
207
+ for (let index = 0; index < commandLine.length; index += 1) {
208
+ const char = commandLine[index];
209
+ if (quote) {
210
+ if (char === quote) quote = "";
211
+ else current += char;
212
+ continue;
213
+ }
214
+ if (char === '"' || char === "'") {
215
+ quote = char;
216
+ continue;
217
+ }
218
+ if (/\s/.test(char)) {
219
+ if (current) {
220
+ tokens.push(current);
221
+ current = "";
222
+ }
223
+ continue;
224
+ }
225
+ current += char;
226
+ }
227
+ if (current) tokens.push(current);
228
+ return tokens;
229
+ }
230
+
231
+ function windowsFnnProcesses() {
232
+ const command = [
233
+ "$ErrorActionPreference = 'SilentlyContinue';",
234
+ "Get-CimInstance Win32_Process |",
235
+ "Where-Object { $_.Name -ieq 'fnn.exe' } |",
236
+ "Select-Object ProcessId, CommandLine |",
237
+ "ConvertTo-Json -Compress",
238
+ ].join(" ");
239
+ const result = spawnSync("powershell.exe", ["-NoProfile", "-Command", command], {
240
+ encoding: "utf8",
241
+ timeout: 7000,
242
+ windowsHide: true,
243
+ });
244
+ if (result.status !== 0 || !result.stdout.trim()) return [];
245
+ try {
246
+ const parsed = JSON.parse(result.stdout);
247
+ return (Array.isArray(parsed) ? parsed : [parsed])
248
+ .map((row) => ({
249
+ pid: Number(row.ProcessId),
250
+ commandLine: typeof row.CommandLine === "string" ? row.CommandLine : "",
251
+ }))
252
+ .filter((row) => Number.isInteger(row.pid) && row.commandLine);
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ function posixFnnProcesses() {
259
+ const result = spawnSync("ps", ["-axo", "pid=,comm=,args="], {
260
+ encoding: "utf8",
261
+ timeout: 5000,
262
+ });
263
+ if (result.status !== 0) return [];
264
+ return result.stdout
265
+ .split(/\r?\n/)
266
+ .map((line) => line.trim())
267
+ .filter(Boolean)
268
+ .map((line) => {
269
+ const match = /^(\d+)\s+(\S+)\s+(.+)$/.exec(line);
270
+ if (!match) return undefined;
271
+ return { pid: Number(match[1]), command: path.basename(match[2]), commandLine: match[3] };
272
+ })
273
+ .filter((row) => row && Number.isInteger(row.pid) && /^fnn(?:\.exe)?$/i.test(row.command));
274
+ }
275
+
276
+ function dedupeInstances(instances) {
277
+ const seen = new Set();
278
+ return instances.filter((instance) => {
279
+ if (seen.has(instance.pid)) return false;
280
+ seen.add(instance.pid);
281
+ return true;
282
+ });
283
+ }
284
+
285
+ function sameUrl(left, right) {
286
+ try {
287
+ const a = new URL(left);
288
+ const b = new URL(right);
289
+ return a.protocol === b.protocol && a.hostname === b.hostname && a.port === b.port;
290
+ } catch {
291
+ return left === right;
292
+ }
293
+ }