kiku-agent 1.3.2 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/README.md +69 -12
  2. package/package.json +2 -1
  3. package/server/agent/agentCore.js +319 -46
  4. package/server/agent/appPaths.js +31 -0
  5. package/server/agent/audioPlayer.js +339 -12
  6. package/server/agent/autoContext.js +193 -0
  7. package/server/agent/catalogEntityResolver.js +170 -28
  8. package/server/agent/clarifyingQuestions.js +137 -35
  9. package/server/agent/cli.js +177 -48
  10. package/server/agent/cloudAdapters.js +116 -0
  11. package/server/agent/commandRegistry.js +409 -80
  12. package/server/agent/commands.js +1056 -125
  13. package/server/agent/config.js +1088 -690
  14. package/server/agent/contextCompactor.js +213 -21
  15. package/server/agent/cpuMetrics.js +199 -0
  16. package/server/agent/engineService.js +591 -0
  17. package/server/agent/eval/modules/evaluation/runner.js +4 -14
  18. package/server/agent/eval/modules/evaluation/scoring.js +15 -3
  19. package/server/agent/eval/modules/server/evalApi.js +5 -5
  20. package/server/agent/eval/modules/ui/evalUi.js +7 -4
  21. package/server/agent/eventBus.js +1 -0
  22. package/server/agent/ink/main.jsx +196 -191
  23. package/server/agent/jobManager.js +11 -1
  24. package/server/agent/lanDiscovery.js +721 -23
  25. package/server/agent/llmClient.js +317 -71
  26. package/server/agent/lmStudioWakeup.js +517 -65
  27. package/server/agent/locationService.js +797 -0
  28. package/server/agent/mcpQuizData.js +509 -0
  29. package/server/agent/memory/contextAllocator.js +19 -7
  30. package/server/agent/memory.js +3 -1
  31. package/server/agent/modelCompatibility.js +352 -0
  32. package/server/agent/modelDefaults.js +1 -0
  33. package/server/agent/modelManager.js +46 -2
  34. package/server/agent/newsWatch.js +9 -3
  35. package/server/agent/processManager.js +229 -0
  36. package/server/agent/prompts.js +174 -8
  37. package/server/agent/routingInterceptor.js +9 -1
  38. package/server/agent/sanitizer.js +41 -6
  39. package/server/agent/searchQueryPlanner.js +202 -0
  40. package/server/agent/session.js +59 -4
  41. package/server/agent/tests/agentFollowUps.test.js +128 -2
  42. package/server/agent/tests/agentMusicControl.test.js +245 -0
  43. package/server/agent/tests/apiRouter.test.js +1 -1
  44. package/server/agent/tests/autoContext.test.js +176 -0
  45. package/server/agent/tests/bilibiliEmbed.test.js +45 -0
  46. package/server/agent/tests/chatHardening.test.js +121 -0
  47. package/server/agent/tests/chineseWebSearchEscalation.test.js +62 -0
  48. package/server/agent/tests/clarifyingQuestions.test.js +33 -0
  49. package/server/agent/tests/cliAutomation.test.js +38 -1
  50. package/server/agent/tests/cloudProviders.test.js +91 -3
  51. package/server/agent/tests/commands.test.js +92 -3
  52. package/server/agent/tests/contextIntelligence.test.js +50 -3
  53. package/server/agent/tests/cpuMetrics.test.js +101 -0
  54. package/server/agent/tests/decisionAndSearchEnhancements.test.js +32 -0
  55. package/server/agent/tests/edgeCasesScenarios.test.js +82 -0
  56. package/server/agent/tests/engineService.test.js +436 -0
  57. package/server/agent/tests/engineUnload.test.js +94 -0
  58. package/server/agent/tests/envCommand.test.js +69 -0
  59. package/server/agent/tests/evalMetrics.test.js +8 -0
  60. package/server/agent/tests/fetchApi.test.js +99 -0
  61. package/server/agent/tests/hybridWebSearch.test.js +2 -0
  62. package/server/agent/tests/lanDiscovery.test.js +5 -0
  63. package/server/agent/tests/lanInterfaces.test.js +510 -0
  64. package/server/agent/tests/lmStudioWakeup.test.js +278 -0
  65. package/server/agent/tests/locationTool.test.js +278 -0
  66. package/server/agent/tests/memoryRoute.test.js +173 -0
  67. package/server/agent/tests/memoryUiNormalization.test.js +75 -0
  68. package/server/agent/tests/modelCompatibility.test.js +148 -0
  69. package/server/agent/tests/modelManager.test.js +32 -0
  70. package/server/agent/tests/musicControlUiBridge.test.js +113 -0
  71. package/server/agent/tests/nextGenArchitecture.test.js +4 -2
  72. package/server/agent/tests/openDomainPrompt.test.js +197 -0
  73. package/server/agent/tests/openDomainSearch.test.js +121 -0
  74. package/server/agent/tests/processManager.test.js +144 -0
  75. package/server/agent/tests/providerPersistence.test.js +10 -5
  76. package/server/agent/tests/quickActionsAndModals.test.js +180 -0
  77. package/server/agent/tests/searchQueryPlanner.test.js +108 -0
  78. package/server/agent/tests/securityAuditRemediation.test.js +16 -9
  79. package/server/agent/tests/securityAuditRemediations2.test.js +154 -0
  80. package/server/agent/tests/securityHardening.test.js +4 -4
  81. package/server/agent/tests/sessionAndCopy.test.js +39 -4
  82. package/server/agent/tests/sidebarControls.test.js +34 -0
  83. package/server/agent/tests/statefulMemory.test.js +8 -1
  84. package/server/agent/tests/textProtocolLoop.test.js +71 -0
  85. package/server/agent/tests/textToolParsing.test.js +48 -0
  86. package/server/agent/tests/toolRoutingRegressions.test.js +41 -0
  87. package/server/agent/tests/unifiedEndpoints.test.js +13 -6
  88. package/server/agent/tests/v110Features.test.js +1 -1
  89. package/server/agent/tests/vramEstimator.test.js +75 -0
  90. package/server/agent/tests/webChatDocs.test.js +199 -0
  91. package/server/agent/tests/webChatInferenceControl.test.js +298 -0
  92. package/server/agent/tests/webChatInspection.test.js +256 -5
  93. package/server/agent/tests/webChatMcp.test.js +94 -0
  94. package/server/agent/tests/webExtract.test.js +23 -0
  95. package/server/agent/tests/webSources.test.js +46 -12
  96. package/server/agent/textToolProtocol.js +173 -46
  97. package/server/agent/toolRouter.js +318 -44
  98. package/server/agent/tools.js +877 -96
  99. package/server/agent/ui.js +39 -34
  100. package/server/agent/vramService.js +35 -54
  101. package/server/agent/webSearch.js +441 -162
  102. package/server/agent/websearch/orchestrator.js +15 -5
  103. package/server/agent/websearch/resilience/cache.js +9 -2
  104. package/server/agent/websearch/security/ssrf.js +41 -10
  105. package/server/agent/youtubeAuth.js +5 -20
  106. package/server/apiRouter.js +5558 -4422
  107. package/server/mcp/data/discography.js +2 -2
  108. package/server/mcp/examples/antigravity.example.json +17 -0
  109. package/server/mcp/handler.js +8 -4
  110. package/server/mcp/security.js +220 -21
  111. package/server/mcp/server.js +2 -1
  112. package/server/mcp/tests/connection.test.js +14 -8
  113. package/server/mcp/tests/mcpKeyAuth.test.js +156 -0
  114. package/server/mcp/tools.js +7 -1
  115. package/server/supabase_migration_prefix.sql +102 -0
  116. package/server/supabase_schema.sql +18 -18
  117. package/server/web-chat/core/connection.js +179 -218
  118. package/server/web-chat/core/conversationContext.js +9 -2
  119. package/server/web-chat/core/directCatalogEngine.js +52 -28
  120. package/server/web-chat/core/dramaKnowledge.js +33 -22
  121. package/server/web-chat/core/followUpEngine.js +735 -81
  122. package/server/web-chat/core/futureEventGuard.js +5 -1
  123. package/server/web-chat/core/musicControlAction.js +70 -0
  124. package/server/web-chat/core/security.js +32 -21
  125. package/server/web-chat/core/toolInspector.js +6 -6
  126. package/server/web-chat/core/urlGuard.js +45 -0
  127. package/server/web-chat/router.js +204 -157
  128. package/server/web-chat/routes/chatRoute.js +885 -488
  129. package/server/web-chat/routes/engineRoute.js +270 -0
  130. package/server/web-chat/routes/exportRoute.js +1 -1
  131. package/server/web-chat/routes/lanDiscoveryRoute.js +17 -2
  132. package/server/web-chat/routes/lmStudioLoadRoute.js +172 -0
  133. package/server/web-chat/routes/mcpRoute.js +386 -0
  134. package/server/web-chat/routes/memoryRoute.js +253 -0
  135. package/server/web-chat/routes/providerRoute.js +49 -16
  136. package/server/web-chat/routes/staticRoute.js +306 -17
  137. package/server/web-chat/routes/statusRoute.js +194 -73
  138. package/server/web-chat/routes/suggestRoute.js +11 -3
  139. package/server/web-chat/routes/verifyRoute.js +26 -4
  140. package/server/web-chat/server.js +7 -6
  141. package/server/web-chat/ui/docsBuilder.js +3558 -0
  142. package/server/web-chat/ui/htmlBuilder.js +12 -0
  143. package/server/web-chat/ui/scripts/client.js +3401 -1302
  144. package/server/web-chat/ui/scripts/webchatI18n.js +155 -153
  145. package/server/web-chat/ui/settingsBuilder.js +7870 -0
  146. package/server/web-chat/ui/styles/lan-card.css +165 -0
  147. package/server/web-chat/ui/styles/main.css +4289 -447
  148. package/server/web-chat/ui/sw.js +64 -0
  149. package/server/web-chat/ui/templates/body.html +407 -281
  150. package/src/data/awardsData.js +146 -146
  151. package/src/data/musicVideoData.js +6 -4
  152. package/src/lib/vram/estimator.js +195 -19
@@ -1,8 +1,17 @@
1
1
  import net from 'node:net';
2
2
  import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { promises as fsp } from 'node:fs';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
3
7
  import { isPrivateIPv4 } from './sanitizer.js';
8
+ import { KIKU_DATA_DIR } from './appPaths.js';
4
9
 
5
10
  export const DEFAULT_LLM_PORTS = Object.freeze([1234, 8000, 11434, 8080]);
11
+ export const LAN_INTERFACE_EXEC_TIMEOUT_MS = 4000;
12
+ export const EXPAND_CIDR_MAX_HOSTS = 65536;
13
+
14
+ const execFileAsync = promisify(execFile);
6
15
 
7
16
  const CACHE_TTL_MS = 60_000;
8
17
 
@@ -27,6 +36,57 @@ function parseIpv4(host) {
27
36
  return octets;
28
37
  }
29
38
 
39
+ export function netmaskToPrefix(netmask) {
40
+ const octets = parseIpv4(netmask);
41
+ if (!octets) return null;
42
+ let bits = 0;
43
+ let seenZero = false;
44
+ for (const octet of octets) {
45
+ for (let bit = 7; bit >= 0; bit--) {
46
+ const on = (octet >> bit) & 1;
47
+ if (on) {
48
+ if (seenZero) return null;
49
+ bits++;
50
+ } else {
51
+ seenZero = true;
52
+ }
53
+ }
54
+ }
55
+ return bits;
56
+ }
57
+
58
+ export function netmaskFromPrefix(prefix) {
59
+ const n = Number(prefix);
60
+ if (!Number.isInteger(n) || n < 0 || n > 32) return null;
61
+ const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
62
+ return uint32ToIp(mask);
63
+ }
64
+
65
+ export function usableHostCount(prefix) {
66
+ const n = Number(prefix);
67
+ if (!Number.isInteger(n) || n < 0 || n > 32) return 0;
68
+ const size = 2 ** (32 - n);
69
+ return n <= 30 ? Math.max(0, size - 2) : size;
70
+ }
71
+
72
+ export function networkCidr(address, prefix) {
73
+ const octets = parseIpv4(address);
74
+ const n = Number(prefix);
75
+ if (!octets || !Number.isInteger(n) || n < 0 || n > 32) return null;
76
+ const mask = n === 0 ? 0 : (0xffffffff << (32 - n)) >>> 0;
77
+ const network = (ipv4ToUint32(octets) & mask) >>> 0;
78
+ return `${uint32ToIp(network)}/${n}`;
79
+ }
80
+
81
+ export function prefixFromEntry(entry = {}) {
82
+ const cidrText = String(entry.cidr || '');
83
+ const cidrPrefix = cidrText.includes('/') ? Number(cidrText.split('/')[1]) : NaN;
84
+ if (Number.isInteger(cidrPrefix) && cidrPrefix >= 0 && cidrPrefix <= 32) return cidrPrefix;
85
+ const fromMask = netmaskToPrefix(entry.netmask);
86
+ if (fromMask !== null) return fromMask;
87
+ return 24;
88
+ }
89
+
30
90
  export function getLanCandidateSubnets(interfaces = os.networkInterfaces()) {
31
91
  const seen = new Set();
32
92
  const subnets = [];
@@ -37,23 +97,40 @@ export function getLanCandidateSubnets(interfaces = os.networkInterfaces()) {
37
97
  if (!octets || octets[0] === 127) continue;
38
98
  if (octets[0] === 169 && octets[1] === 254) continue;
39
99
  if (!isPrivateIPv4(entry.address)) continue;
40
- const cidr = `${octets[0]}.${octets[1]}.${octets[2]}.0/24`;
100
+ const prefix = prefixFromEntry(entry);
101
+ const cidr = networkCidr(entry.address, prefix);
41
102
  if (seen.has(cidr)) continue;
42
103
  seen.add(cidr);
43
- subnets.push({ cidr, interfaceName: name, address: entry.address });
104
+ subnets.push({
105
+ cidr,
106
+ interfaceName: name,
107
+ address: entry.address,
108
+ netmask: netmaskFromPrefix(prefix),
109
+ prefix,
110
+ hostCount: usableHostCount(prefix),
111
+ });
44
112
  }
45
113
  }
46
114
  return subnets;
47
115
  }
48
116
 
49
- export function expandCidrToHosts(cidr) {
50
- const base = String(cidr || '').split('/')[0];
117
+ export function expandCidrToHosts(cidr, { maxHosts = EXPAND_CIDR_MAX_HOSTS } = {}) {
118
+ const [base, prefixStr] = String(cidr || '').split('/');
51
119
  const octets = parseIpv4(base);
52
- if (!octets) throw new Error(`Invalid CIDR "${cidr}"`);
53
- const hosts = [];
54
- for (let last = 1; last <= 254; last++) {
55
- hosts.push(`${octets[0]}.${octets[1]}.${octets[2]}.${last}`);
120
+ const prefix = prefixStr === undefined || prefixStr === '' ? 24 : Number(prefixStr);
121
+ if (!octets || !Number.isInteger(prefix) || prefix < 8 || prefix > 32) {
122
+ throw new Error(`Invalid CIDR "${cidr}"`);
123
+ }
124
+ const network = (ipv4ToUint32(octets) & (0xffffffff << (32 - prefix))) >>> 0;
125
+ const size = 2 ** (32 - prefix);
126
+ const usable = prefix <= 30 ? size - 2 : size;
127
+ if (usable > maxHosts) {
128
+ throw new Error(`CIDR "${cidr}" expands to ${usable.toLocaleString()} hosts (cap ${maxHosts.toLocaleString()}). Scan a smaller prefix instead.`);
56
129
  }
130
+ const first = prefix <= 30 ? network + 1 : network;
131
+ const last = prefix <= 30 ? network + size - 2 : network + size - 1;
132
+ const hosts = [];
133
+ for (let ip = first; ip <= last; ip++) hosts.push(uint32ToIp(ip >>> 0));
57
134
  return hosts;
58
135
  }
59
136
 
@@ -71,7 +148,6 @@ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {})
71
148
  const list = Array.isArray(specs) ? specs : String(specs || '').split(',');
72
149
  const out = [];
73
150
  const seen = new Set();
74
- let planned = 0;
75
151
 
76
152
  const pushIp = value => {
77
153
  const ip = uint32ToIp(value);
@@ -94,14 +170,14 @@ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {})
94
170
  }
95
171
  const network = prefix === 0 ? 0 : (ipv4ToUint32(octets) & (0xffffffff << (32 - prefix))) >>> 0;
96
172
  const size = 2 ** (32 - prefix);
97
- const usable = prefix <= 30 ? size - 2 : size;
98
- planned += usable;
99
- if (planned > maxHosts) {
100
- throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
101
- }
102
173
  const first = prefix <= 30 ? network + 1 : network;
103
174
  const last = prefix <= 30 ? network + size - 2 : network + size - 1;
104
- for (let ip = first; ip <= last; ip++) pushIp(ip >>> 0);
175
+ for (let ip = first; ip <= last; ip++) {
176
+ pushIp(ip >>> 0);
177
+ if (out.length > maxHosts) {
178
+ throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
179
+ }
180
+ }
105
181
  } else if (spec.includes('-')) {
106
182
  const [startStr, endStr] = spec.split('-');
107
183
  const startOctets = parseIpv4(startStr);
@@ -112,23 +188,615 @@ export function expandTargets(specs, { maxHosts = DEFAULT_MAX_SCAN_HOSTS } = {})
112
188
  const start = ipv4ToUint32(startOctets);
113
189
  const end = ipv4ToUint32(endOctets);
114
190
  if (end < start) throw new Error(`Invalid IP range "${spec}" (start is after end)`);
115
- const count = end - start + 1;
116
- planned += count;
117
- if (planned > maxHosts) {
118
- throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
191
+ for (let ip = start; ip <= end; ip++) {
192
+ pushIp(ip >>> 0);
193
+ if (out.length > maxHosts) {
194
+ throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
195
+ }
119
196
  }
120
- for (let ip = start; ip <= end; ip++) pushIp(ip >>> 0);
121
197
  } else {
122
198
  const octets = parseIpv4(spec);
123
199
  if (!octets) throw new Error(`Invalid IP address "${spec}"`);
124
- planned += 1;
125
200
  pushIp(ipv4ToUint32(octets));
201
+ if (out.length > maxHosts) {
202
+ throw new Error(`Scan target limit exceeded (>${maxHosts} hosts). Narrow the range or raise KIKU_LAN_SCAN_MAX_HOSTS.`);
203
+ }
126
204
  }
127
205
  }
128
206
 
129
207
  return out;
130
208
  }
131
209
 
210
+ const VIRTUAL_IFACE_RE = /(docker|veth\d*|br-[0-9a-f]|bridge|vmnet|vbox|virtualbox|tailscale|twingate|wg\d*|wireguard|utun\d*|ppp\d*|hamachi|zerotier|zt\d*|lxc|vethernet|hyper-v|\bvpn\b|\btap\b|\btun\d*)/i;
211
+ const WIFI_IFACE_RE = /(wi-?fi|wlan\d*|ath\d*|wlp\d+s\d+)/i;
212
+ const ETHERNET_IFACE_RE = /^(eth\d+|ethernet(\s*\d*)?|eno\d+|ens\d+|em\d+|enp\d+s\d+|lan\d*)$/i;
213
+
214
+ export function inferInterfaceType(name = '', detail = '') {
215
+ const text = `${name} ${detail}`.trim();
216
+ if (!text) return 'unknown';
217
+ if (/^(lo(\d+)?|loopback)(?![a-z])/i.test(text)) return 'loopback';
218
+ if (VIRTUAL_IFACE_RE.test(text)) return 'virtual';
219
+ if (/local area connection/i.test(text)) return 'virtual';
220
+ if (WIFI_IFACE_RE.test(text)) return 'wifi';
221
+ if (ETHERNET_IFACE_RE.test(text)) return 'ethernet';
222
+ return 'unknown';
223
+ }
224
+
225
+ function normalizeIfaceStatus(value) {
226
+ const text = String(value || '').trim().toLowerCase();
227
+ if (!text) return 'unknown';
228
+ if (['up', 'active', 'associated', 'connected', 'enabled', 'ok', '2'].includes(text)) return 'up';
229
+ if (['down', 'inactive', 'disconnected', 'disabled', 'dormant', '1', 'testing', 'notpresent', 'lowerlayerdown'].includes(text)) return 'down';
230
+ return 'unknown';
231
+ }
232
+
233
+ function buildInterfaceBaseline(interfaces = {}) {
234
+ const list = [];
235
+ let index = 0;
236
+ for (const [name, entries] of Object.entries(interfaces || {})) {
237
+ const all = Array.isArray(entries) ? entries : [];
238
+ const v4 = all.filter(e => e && e.family === 'IPv4' && e.address);
239
+ const internal = all.length > 0 && all.every(e => e && e.internal);
240
+ list.push({
241
+ index: index++,
242
+ name,
243
+ displayName: name,
244
+ detail: '',
245
+ internal,
246
+ mac: (all.find(e => e && e.mac && !/^0{2}(:0{2}){5}$/i.test(e.mac)) || {}).mac || null,
247
+ type: inferInterfaceType(name, ''),
248
+ status: internal ? 'up' : 'unknown',
249
+ speed: null,
250
+ gateway: null,
251
+ isDefaultRoute: false,
252
+ addresses: v4.map(e => {
253
+ const prefix = prefixFromEntry(e);
254
+ return {
255
+ address: e.address,
256
+ netmask: e.netmask || netmaskFromPrefix(prefix),
257
+ prefix,
258
+ cidr: networkCidr(e.address, prefix),
259
+ };
260
+ }),
261
+ });
262
+ }
263
+ return list;
264
+ }
265
+
266
+ function matchBaseline(baseline, predicate) {
267
+ return baseline.find(predicate) || null;
268
+ }
269
+
270
+ function matchByName(baseline, name) {
271
+ const target = String(name || '').trim().toLowerCase();
272
+ if (!target) return null;
273
+ return matchBaseline(baseline, i => i.name.toLowerCase() === target)
274
+ || matchBaseline(baseline, i => (i.displayName || '').toLowerCase() === target);
275
+ }
276
+
277
+ function safeJsonParse(text) {
278
+ try {
279
+ const parsed = JSON.parse(String(text || ''));
280
+ return parsed === undefined ? null : parsed;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function asArray(value) {
287
+ if (Array.isArray(value)) return value;
288
+ if (value && typeof value === 'object') return [value];
289
+ return [];
290
+ }
291
+
292
+ export function parseWindowsAdapters(jsonText) {
293
+ const rows = asArray(safeJsonParse(jsonText));
294
+ return rows.map(row => ({
295
+ name: String(row?.Name || row?.name || '').trim(),
296
+ description: String(row?.InterfaceDescription || row?.description || '').trim(),
297
+ status: normalizeIfaceStatus(row?.Status ?? row?.status),
298
+ speed: row?.LinkSpeed ?? row?.speed ?? null,
299
+ mediaType: String(row?.MediaType || row?.mediaType || ''),
300
+ interfaceIndex: Number(row?.InterfaceIndex ?? row?.ifIndex ?? NaN),
301
+ })).filter(a => a.name);
302
+ }
303
+
304
+ export function parseWindowsDefaultRoutes(jsonText) {
305
+ const rows = asArray(safeJsonParse(jsonText));
306
+ return rows
307
+ .map(row => ({
308
+ interfaceIndex: Number(row?.InterfaceIndex ?? row?.ifIndex ?? NaN),
309
+ gateway: String(row?.NextHop || row?.gateway || '').trim(),
310
+ metric: Number(row?.RouteMetric ?? row?.metric ?? 0),
311
+ }))
312
+ .filter(r => Number.isInteger(r.interfaceIndex) && parseIpv4(r.gateway));
313
+ }
314
+
315
+ export async function enrichWindowsInterfaces(baseline, run) {
316
+ const snapshot = safeJsonParse(await run('powershell.exe', [
317
+ '-NoProfile', '-NonInteractive', '-Command',
318
+ "@{ adapters = @(Get-NetAdapter -IncludeHidden | Select-Object Name,InterfaceDescription,Status,LinkSpeed,MediaType,InterfaceIndex); routes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Select-Object InterfaceIndex,NextHop,RouteMetric) } | ConvertTo-Json -Compress -Depth 3",
319
+ ]).catch(() => ''));
320
+ const adapters = parseWindowsAdapters(JSON.stringify(snapshot?.adapters ?? []));
321
+ const routes = parseWindowsDefaultRoutes(JSON.stringify(snapshot?.routes ?? []));
322
+ const byName = new Map(adapters.map(a => [a.name.toLowerCase(), a]));
323
+ const byIndex = new Map();
324
+ for (const adapter of adapters) {
325
+ if (Number.isInteger(adapter.interfaceIndex)) byIndex.set(adapter.interfaceIndex, adapter);
326
+ }
327
+ let defaultRoute = null;
328
+ for (const route of routes) {
329
+ if (!defaultRoute || route.metric < defaultRoute.metric) defaultRoute = route;
330
+ }
331
+ const gatewayByAdapter = new Map();
332
+ for (const route of routes) {
333
+ const adapter = byIndex.get(route.interfaceIndex);
334
+ if (!adapter) continue;
335
+ const key = adapter.name.toLowerCase();
336
+ const prev = gatewayByAdapter.get(key);
337
+ if (!prev || route.metric < prev.metric) gatewayByAdapter.set(key, route);
338
+ }
339
+ const enriched = baseline.map(iface => {
340
+ const adapter = byName.get(iface.name.toLowerCase());
341
+ if (!adapter) return iface;
342
+ const next = { ...iface };
343
+ if (adapter.description) {
344
+ next.displayName = iface.name;
345
+ next.detail = adapter.description;
346
+ }
347
+ if (adapter.status !== 'unknown') next.status = adapter.status;
348
+ if (adapter.speed !== null && adapter.speed !== undefined) next.speed = String(adapter.speed);
349
+ const nameType = inferInterfaceType(iface.name, adapter.description);
350
+ if (nameType === 'virtual') {
351
+ next.type = 'virtual';
352
+ } else {
353
+ const mediaLower = adapter.mediaType.toLowerCase();
354
+ if (mediaLower.includes('802.11')) next.type = 'wifi';
355
+ else if (mediaLower.includes('802.3')) next.type = 'ethernet';
356
+ else if (next.type === 'unknown' && nameType !== 'unknown') next.type = nameType;
357
+ }
358
+ const gw = gatewayByAdapter.get(iface.name.toLowerCase());
359
+ if (gw) {
360
+ next.gateway = gw.gateway;
361
+ if (defaultRoute && gw.gateway === defaultRoute.gateway && gw.interfaceIndex === defaultRoute.interfaceIndex) {
362
+ next.isDefaultRoute = true;
363
+ }
364
+ }
365
+ return next;
366
+ });
367
+ let nextIndex = enriched.reduce((top, i) => Math.max(top, Number(i.index) || 0), -1) + 1;
368
+ for (const adapter of adapters) {
369
+ if (enriched.some(i => i.name.toLowerCase() === adapter.name.toLowerCase())) continue;
370
+ const nameType = inferInterfaceType(adapter.name, adapter.description);
371
+ let type = 'unknown';
372
+ if (nameType === 'virtual') type = 'virtual';
373
+ else {
374
+ const mediaLower = adapter.mediaType.toLowerCase();
375
+ if (mediaLower.includes('802.11')) type = 'wifi';
376
+ else if (mediaLower.includes('802.3')) type = 'ethernet';
377
+ else if (nameType !== 'unknown') type = nameType;
378
+ }
379
+ const gw = gatewayByAdapter.get(adapter.name.toLowerCase());
380
+ enriched.push({
381
+ index: nextIndex++,
382
+ name: adapter.name,
383
+ displayName: adapter.name,
384
+ detail: adapter.description || '',
385
+ internal: false,
386
+ mac: null,
387
+ type,
388
+ status: adapter.status !== 'unknown' ? adapter.status : 'down',
389
+ speed: adapter.speed !== null && adapter.speed !== undefined ? String(adapter.speed) : null,
390
+ gateway: gw ? gw.gateway : null,
391
+ isDefaultRoute: Boolean(gw && defaultRoute && gw.gateway === defaultRoute.gateway && gw.interfaceIndex === defaultRoute.interfaceIndex),
392
+ addresses: [],
393
+ });
394
+ }
395
+ return enriched;
396
+ }
397
+
398
+ function flagsCarryUp(flags) {
399
+ const list = Array.isArray(flags) ? flags : String(flags || '').split(/[\s,<>|]+/);
400
+ return list.some(f => String(f || '').trim().toUpperCase() === 'UP');
401
+ }
402
+
403
+ function statusWithFlagFallback(rawStatus, flags) {
404
+ const status = normalizeIfaceStatus(rawStatus);
405
+ if (status === 'unknown' && flagsCarryUp(flags)) return 'up';
406
+ return status;
407
+ }
408
+
409
+ export function parseLinuxIpAddr(jsonText) {
410
+ const rows = asArray(safeJsonParse(jsonText));
411
+ return rows.map(row => ({
412
+ name: String(row?.ifname || row?.ifname === 0 ? row.ifname : (row?.ifname || '')).trim(),
413
+ status: statusWithFlagFallback(row?.operstate, row?.flags),
414
+ mac: String(row?.address || '').trim() || null,
415
+ mtu: Number(row?.mtu) || null,
416
+ })).filter(a => a.name);
417
+ }
418
+
419
+ export function parseLinuxIpAddrText(text) {
420
+ const links = [];
421
+ const blocks = String(text || '').split(/^(?=\d+:\s+\S+:)/m);
422
+ for (const block of blocks) {
423
+ const header = /^(\d+):\s+([^:]+):\s*<([^>]*)>.*state\s+(\S+)/im.exec(block);
424
+ if (!header) continue;
425
+ const name = header[2].trim().split('@')[0];
426
+ if (!name) continue;
427
+ links.push({
428
+ name,
429
+ status: statusWithFlagFallback(header[4], header[3]),
430
+ mac: /link\/ether\s+([0-9a-f:]{17})/i.exec(block)?.[1]?.toLowerCase() || null,
431
+ mtu: Number(/mtu\s+(\d+)/i.exec(block)?.[1]) || null,
432
+ });
433
+ }
434
+ return links;
435
+ }
436
+
437
+ export function parseLinuxDefaultRoutes(jsonText) {
438
+ const rows = asArray(safeJsonParse(jsonText));
439
+ return rows
440
+ .map(row => ({
441
+ device: String(row?.dev || row?.device || '').trim(),
442
+ gateway: String(row?.gateway || '').trim(),
443
+ metric: Number(row?.metric ?? 0),
444
+ }))
445
+ .filter(r => r.device && parseIpv4(r.gateway));
446
+ }
447
+
448
+ export async function enrichLinuxInterfaces(baseline, run) {
449
+ let links = [];
450
+ try {
451
+ links = parseLinuxIpAddr(await run('ip', ['-j', 'addr']));
452
+ } catch {
453
+ links = [];
454
+ }
455
+ if (links.length === 0) {
456
+ try {
457
+ links = parseLinuxIpAddrText(await run('ip', ['addr']));
458
+ } catch {
459
+ links = [];
460
+ }
461
+ }
462
+ const byName = new Map(links.map(l => [l.name, l]));
463
+ const routes = parseLinuxDefaultRoutes(await run('ip', ['-j', 'route', 'show', 'default']).catch(() => ''));
464
+ let defaultRoute = null;
465
+ for (const route of routes) {
466
+ if (!defaultRoute || route.metric < defaultRoute.metric) defaultRoute = route;
467
+ }
468
+ return baseline.map(iface => {
469
+ const next = { ...iface };
470
+ const link = byName.get(iface.name);
471
+ if (link) {
472
+ if (link.status !== 'unknown') next.status = link.status;
473
+ if (!next.mac && link.mac) next.mac = link.mac;
474
+ if (next.type === 'unknown') next.type = inferInterfaceType(iface.name, '');
475
+ }
476
+ const route = routes.find(r => r.device === iface.name);
477
+ if (route) {
478
+ next.gateway = route.gateway;
479
+ if (defaultRoute && defaultRoute.device === iface.name) next.isDefaultRoute = true;
480
+ }
481
+ return next;
482
+ });
483
+ }
484
+
485
+ export function parseMacHardwarePorts(text) {
486
+ const ports = [];
487
+ const blocks = String(text || '').split(/\n\s*\n/);
488
+ for (const block of blocks) {
489
+ const port = /Hardware Port:\s*(.+)/i.exec(block)?.[1]?.trim();
490
+ const device = /Device:\s*(\S+)/i.exec(block)?.[1]?.trim();
491
+ if (port && device) ports.push({ port, device });
492
+ }
493
+ return ports;
494
+ }
495
+
496
+ export function parseMacDefaultRoute(text) {
497
+ const gateway = /gateway:\s*([0-9.]+)/i.exec(String(text || ''))?.[1] || null;
498
+ const device = /interface:\s*(\S+)/i.exec(String(text || ''))?.[1] || null;
499
+ return {
500
+ gateway: gateway && parseIpv4(gateway) ? gateway : null,
501
+ device,
502
+ };
503
+ }
504
+
505
+ export function parseMacIfconfig(text) {
506
+ const result = new Map();
507
+ const blocks = String(text || '').split(/^(?=\S+:)/m);
508
+ for (const block of blocks) {
509
+ const name = /^([A-Za-z0-9_.]+):/.exec(block)?.[1];
510
+ if (!name) continue;
511
+ const statusText = /status:\s*(\S+)/i.exec(block)?.[1] || '';
512
+ const media = /media:\s*([^\n<]+)/i.exec(block)?.[1]?.trim() || '';
513
+ const flags = /^.+flags=\d+<([^>]+)>/m.exec(block)?.[1] || '';
514
+ let status = normalizeIfaceStatus(statusText);
515
+ if (status === 'unknown') {
516
+ status = /,UP,/i.test(`,${flags},`) ? 'up' : status;
517
+ }
518
+ result.set(name, { status, media });
519
+ }
520
+ return result;
521
+ }
522
+
523
+ export async function enrichMacInterfaces(baseline, run) {
524
+ const [ifconfigText, routeText, portsText] = await Promise.all([
525
+ run('ifconfig', []).catch(() => ''),
526
+ run('route', ['-n', 'get', 'default']).catch(() => ''),
527
+ run('networksetup', ['-listallhardwareports']).catch(() => ''),
528
+ ]);
529
+ const states = parseMacIfconfig(ifconfigText);
530
+ const defRoute = parseMacDefaultRoute(routeText);
531
+ const ports = parseMacHardwarePorts(portsText);
532
+ const portByDevice = new Map(ports.map(p => [p.device, p.port]));
533
+ return baseline.map(iface => {
534
+ const next = { ...iface };
535
+ const state = states.get(iface.name);
536
+ if (state) {
537
+ if (state.status !== 'unknown') next.status = state.status;
538
+ if (next.type === 'unknown') {
539
+ const mediaLower = state.media.toLowerCase();
540
+ if (/wi-?fi|wlan|802\.11/.test(mediaLower)) next.type = 'wifi';
541
+ else if (/ethernet|thunderbolt|1000base|100base|10gbase/.test(mediaLower)) next.type = 'ethernet';
542
+ else next.type = inferInterfaceType(iface.name, state.media);
543
+ }
544
+ }
545
+ const port = portByDevice.get(iface.name);
546
+ if (port) {
547
+ next.displayName = `${port} (${iface.name})`;
548
+ if (next.type === 'unknown') next.type = inferInterfaceType(port, '');
549
+ }
550
+ if (defRoute.device === iface.name) {
551
+ next.gateway = defRoute.gateway;
552
+ next.isDefaultRoute = true;
553
+ }
554
+ return next;
555
+ });
556
+ }
557
+
558
+ function defaultExecFn(cmd, args, { timeoutMs = LAN_INTERFACE_EXEC_TIMEOUT_MS } = {}) {
559
+ return execFileAsync(cmd, args, { timeout: timeoutMs, windowsHide: true, maxBuffer: 4 * 1024 * 1024 })
560
+ .then(result => String(result?.stdout ?? ''));
561
+ }
562
+
563
+ export async function listLanInterfaces({
564
+ interfaces = os.networkInterfaces(),
565
+ platform = process.platform,
566
+ execFn = null,
567
+ timeoutMs = LAN_INTERFACE_EXEC_TIMEOUT_MS,
568
+ } = {}) {
569
+ const baseline = buildInterfaceBaseline(interfaces);
570
+ const enricher = platform === 'win32'
571
+ ? enrichWindowsInterfaces
572
+ : platform === 'linux'
573
+ ? enrichLinuxInterfaces
574
+ : platform === 'darwin'
575
+ ? enrichMacInterfaces
576
+ : null;
577
+ if (!enricher) return baseline;
578
+ const run = execFn || ((cmd, args) => defaultExecFn(cmd, args, { timeoutMs }));
579
+ try {
580
+ return await enricher(baseline, run);
581
+ } catch {
582
+ return baseline;
583
+ }
584
+ }
585
+
586
+ function ifacePrivateCidrs(iface) {
587
+ const cidrs = [];
588
+ for (const addr of iface?.addresses || []) {
589
+ if (!addr?.address || !isPrivateIPv4(addr.address)) continue;
590
+ if (addr.address.startsWith('127.') || addr.address.startsWith('169.254.')) continue;
591
+ if (addr.cidr) cidrs.push(addr.cidr);
592
+ }
593
+ return [...new Set(cidrs)];
594
+ }
595
+
596
+ export function classifyScanToken(token = '') {
597
+ const spec = String(token || '').trim();
598
+ if (!spec) return 'empty';
599
+ if (spec.includes('/')) return 'subnet';
600
+ if (/^\d+\.\d+\.\d+\.\d+\s*-\s*\d+\.\d+\.\d+\.\d+$/.test(spec)) return 'subnet';
601
+ if (parseIpv4(spec)) return 'subnet';
602
+ if (/^\d+\.\d+\.\d+\.\d+/.test(spec)) return 'subnet';
603
+ return 'name';
604
+ }
605
+
606
+ export async function resolveScanSubnets({
607
+ iface = null,
608
+ subnets = null,
609
+ interfaces = null,
610
+ platform = process.platform,
611
+ execFn = null,
612
+ maxHosts = DEFAULT_MAX_SCAN_HOSTS,
613
+ } = {}) {
614
+ if (Array.isArray(subnets) && subnets.length > 0) return [...subnets];
615
+ if (iface === null || iface === undefined || String(iface).trim() === '' || String(iface).trim().toLowerCase() === 'auto') {
616
+ return null;
617
+ }
618
+ const spec = String(iface).trim();
619
+ if (classifyScanToken(spec) === 'subnet' && !/^\d+$/.test(spec)) {
620
+ return [spec];
621
+ }
622
+ const list = await listLanInterfaces({ ...(interfaces ? { interfaces } : {}), platform, execFn });
623
+ let matched = null;
624
+ if (/^\d+$/.test(spec)) {
625
+ matched = list[Number(spec)] || null;
626
+ } else {
627
+ matched = matchByName(list, spec);
628
+ if (!matched) {
629
+ const lower = spec.toLowerCase();
630
+ const partials = list.filter(i =>
631
+ i.name.toLowerCase().includes(lower) || (i.displayName || '').toLowerCase().includes(lower));
632
+ if (partials.length === 1) {
633
+ matched = partials[0];
634
+ } else if (partials.length > 1) {
635
+ throw new Error(`Ambiguous interface "${spec}" — matches: ${partials.map(i => `${i.index}:${i.name}`).join(', ')}. Use the index from /lan ifs.`);
636
+ }
637
+ }
638
+ }
639
+ if (!matched) {
640
+ const available = list.map(i => `${i.index}:${i.name}`).join(', ') || 'none';
641
+ throw new Error(`Unknown LAN interface "${spec}". Available: ${available}`);
642
+ }
643
+ if (matched.internal || matched.type === 'loopback') {
644
+ throw new Error(`Interface "${matched.name}" is internal/loopback and has no LAN subnet to scan`);
645
+ }
646
+ const cidrs = ifacePrivateCidrs(matched);
647
+ if (cidrs.length === 0) {
648
+ throw new Error(`Interface "${matched.name}" has no private IPv4 subnet to scan`);
649
+ }
650
+ const cap = Number(maxHosts) > 0 ? Math.floor(Number(maxHosts)) : DEFAULT_MAX_SCAN_HOSTS;
651
+ let total = 0;
652
+ for (const cidr of cidrs) {
653
+ const prefix = Number(String(cidr).split('/')[1]);
654
+ total += Number.isInteger(prefix) ? usableHostCount(prefix) : 0;
655
+ }
656
+ if (total > cap) {
657
+ const first = String(cidrs[0]).split('/')[0].split('.').slice(0, 3).join('.');
658
+ throw new Error(
659
+ `Interface "${matched.name}" covers ${cidrs.join(', ')} (${total.toLocaleString()} hosts) — over the ${cap.toLocaleString()}-host cap. ` +
660
+ `Scan a slice instead, e.g. /lan scan ${first}.0/24, or raise KIKU_LAN_SCAN_MAX_HOSTS.`
661
+ );
662
+ }
663
+ return cidrs;
664
+ }
665
+
666
+ export function parseLanScanArgs(restTokens = []) {
667
+ const tokens = [...(restTokens || []).map(t => String(t ?? '').trim()).filter(Boolean)];
668
+ let ports = null;
669
+ let iface = null;
670
+ let force = false;
671
+ let error = null;
672
+
673
+ const takeOption = (names) => {
674
+ for (let i = 0; i < tokens.length; i++) {
675
+ const token = tokens[i];
676
+ for (const name of names) {
677
+ if (token === name) {
678
+ const value = tokens[i + 1] ?? null;
679
+ tokens.splice(i, 2);
680
+ return value;
681
+ }
682
+ if (token.startsWith(`${name}=`)) {
683
+ tokens.splice(i, 1);
684
+ return token.slice(name.length + 1);
685
+ }
686
+ }
687
+ }
688
+ return undefined;
689
+ };
690
+
691
+ const forceIdx = tokens.findIndex(t => t === '--force' || t === '-f');
692
+ if (forceIdx >= 0) {
693
+ force = true;
694
+ tokens.splice(forceIdx, 1);
695
+ }
696
+
697
+ const portsRaw = takeOption(['--ports']);
698
+ if (portsRaw !== undefined) {
699
+ const parsed = String(portsRaw || '').split(',').map(Number)
700
+ .filter(p => Number.isInteger(p) && p >= 1 && p <= 65535);
701
+ if (!portsRaw || parsed.length === 0) {
702
+ error = 'ports';
703
+ } else {
704
+ ports = [...new Set(parsed)];
705
+ }
706
+ }
707
+
708
+ const ifaceRaw = takeOption(['--iface', '--interface']);
709
+ if (ifaceRaw !== undefined) {
710
+ if (!ifaceRaw || !String(ifaceRaw).trim()) {
711
+ error = error || 'iface';
712
+ } else {
713
+ iface = String(ifaceRaw).trim();
714
+ }
715
+ }
716
+
717
+ const subnets = tokens.join(',').split(',').map(s => s.trim()).filter(Boolean);
718
+ return {
719
+ subnets: subnets.length > 0 ? subnets : null,
720
+ iface,
721
+ ports,
722
+ force,
723
+ error,
724
+ };
725
+ }
726
+
727
+ export function describeLanInterface(iface = {}) {
728
+ const cidrs = (iface.addresses || []).map(a => a?.cidr).filter(Boolean).join(', ') || 'no IPv4';
729
+ const parts = [
730
+ `[${iface.index ?? '?'}] ${iface.displayName || iface.name || 'unknown'}`,
731
+ `type=${iface.type || 'unknown'}`,
732
+ `status=${iface.status || 'unknown'}`,
733
+ `subnet=${cidrs}`,
734
+ ];
735
+ if (iface.gateway) parts.push(`gw=${iface.gateway}`);
736
+ if (iface.isDefaultRoute) parts.push('default');
737
+ if (iface.internal) parts.push('loopback');
738
+ return parts.join(' • ');
739
+ }
740
+
741
+ export const LAN_STATE_FILENAME = 'lan-state.json';
742
+
743
+ export function lanStateFile(dataDir = KIKU_DATA_DIR) {
744
+ return path.join(String(dataDir || KIKU_DATA_DIR), LAN_STATE_FILENAME);
745
+ }
746
+
747
+ export async function loadLanState({ dataDir = KIKU_DATA_DIR } = {}) {
748
+ try {
749
+ const raw = await fsp.readFile(lanStateFile(dataDir), 'utf8');
750
+ const parsed = JSON.parse(raw);
751
+ if (!parsed || typeof parsed !== 'object') return { lastIface: null, lastScan: null };
752
+ return {
753
+ lastIface: typeof parsed.lastIface === 'string' ? parsed.lastIface : null,
754
+ lastScan: parsed.lastScan && typeof parsed.lastScan === 'object' ? parsed.lastScan : null,
755
+ };
756
+ } catch {
757
+ return { lastIface: null, lastScan: null };
758
+ }
759
+ }
760
+
761
+ export async function saveLanState(patch = {}, { dataDir = KIKU_DATA_DIR } = {}) {
762
+ const current = await loadLanState({ dataDir });
763
+ const next = {
764
+ lastIface: patch.lastIface !== undefined ? patch.lastIface : current.lastIface,
765
+ lastScan: patch.lastScan !== undefined ? patch.lastScan : current.lastScan,
766
+ };
767
+ await fsp.mkdir(String(dataDir || KIKU_DATA_DIR), { recursive: true });
768
+ await fsp.writeFile(lanStateFile(dataDir), JSON.stringify(next, null, 2), 'utf8');
769
+ return next;
770
+ }
771
+
772
+ export function describeLanScanAge(savedAt, now = Date.now()) {
773
+ const then = Number(new Date(savedAt || 0).getTime());
774
+ if (!Number.isFinite(then) || then <= 0) return 'unknown age';
775
+ const minutes = Math.max(0, Math.floor((now - then) / 60000));
776
+ if (minutes < 1) return 'just now';
777
+ if (minutes < 60) return `${minutes}m ago`;
778
+ const hours = Math.floor(minutes / 60);
779
+ if (hours < 24) return `${hours}h ago`;
780
+ return `${Math.floor(hours / 24)}d ago`;
781
+ }
782
+
783
+ export function pickLanEndpointModel(endpoint = {}, wanted = null) {
784
+ const models = Array.isArray(endpoint.models) ? endpoint.models.filter(Boolean) : [];
785
+ if (models.length === 0) return { model: null, matched: false };
786
+ const query = String(wanted || '').trim();
787
+ if (!query) return { model: models[0], matched: true, via: 'first' };
788
+ if (/^\d+$/.test(query)) {
789
+ const pick = models[Number(query) - 1];
790
+ return pick ? { model: pick, matched: true, via: 'index' } : { model: null, matched: false };
791
+ }
792
+ const lower = query.toLowerCase();
793
+ const exact = models.find(m => String(m).toLowerCase() === lower);
794
+ if (exact) return { model: exact, matched: true, via: 'exact' };
795
+ const partials = models.filter(m => String(m).toLowerCase().includes(lower));
796
+ if (partials.length === 1) return { model: partials[0], matched: true, via: 'substring' };
797
+ return { model: null, matched: false, ambiguous: partials };
798
+ }
799
+
132
800
  function assertAllPrivate(hosts) {
133
801
  const offenders = hosts.filter(h => {
134
802
  const octets = parseIpv4(h);
@@ -300,6 +968,10 @@ export async function scanLan({
300
968
  probeTimeoutMs = 2500,
301
969
  concurrency = 128,
302
970
  subnets = null,
971
+ iface = null,
972
+ interfaces = null,
973
+ platform = process.platform,
974
+ execFn = null,
303
975
  extraHosts = [],
304
976
  maxHosts = Number(process.env.KIKU_LAN_SCAN_MAX_HOSTS) || DEFAULT_MAX_SCAN_HOSTS,
305
977
  connector = defaultTcpProbe,
@@ -314,12 +986,37 @@ export async function scanLan({
314
986
  );
315
987
  if (portList.length === 0) throw new Error('At least one valid port is required');
316
988
 
317
- const subnetList = subnets && subnets.length > 0 ? subnets : getLanCandidateSubnets().map(s => s.cidr);
989
+ const explicitSubnets = Array.isArray(subnets) && subnets.length > 0 ? [...subnets] : null;
990
+ const ifaceSubnets = explicitSubnets
991
+ ? null
992
+ : await resolveScanSubnets({ iface, interfaces, platform, execFn, maxHosts });
993
+ let subnetList = explicitSubnets || ifaceSubnets;
994
+ let skippedSubnets = [];
995
+ if (!subnetList) {
996
+ const ordered = getLanCandidateSubnets(interfaces ?? os.networkInterfaces())
997
+ .sort((a, b) => a.hostCount - b.hostCount);
998
+ const picked = [];
999
+ let used = 0;
1000
+ for (const candidate of ordered) {
1001
+ if (used + candidate.hostCount <= maxHosts) {
1002
+ picked.push(candidate);
1003
+ used += candidate.hostCount;
1004
+ } else {
1005
+ skippedSubnets.push({
1006
+ cidr: candidate.cidr,
1007
+ interfaceName: candidate.interfaceName,
1008
+ hostCount: candidate.hostCount,
1009
+ reason: 'over-budget',
1010
+ });
1011
+ }
1012
+ }
1013
+ subnetList = picked.map(c => c.cidr);
1014
+ }
318
1015
  const detectedHosts = expandTargets(subnetList, { maxHosts });
319
1016
  const hostList = [...new Set(['127.0.0.1', ...detectedHosts, ...extraHosts.map(String)])];
320
1017
  assertAllPrivate(hostList);
321
1018
 
322
- const cacheKey = JSON.stringify({ ports: portList, hosts: hostList });
1019
+ const cacheKey = JSON.stringify({ ports: portList, hosts: hostList, iface: iface ?? null });
323
1020
  if (!force && scanCache && scanCache.key === cacheKey && now() - scanCache.at < CACHE_TTL_MS) {
324
1021
  return { ...scanCache.result, cached: true };
325
1022
  }
@@ -356,6 +1053,7 @@ export async function scanLan({
356
1053
  const result = {
357
1054
  scannedAt: new Date(started).toISOString(),
358
1055
  subnets: subnetList,
1056
+ skippedSubnets,
359
1057
  hostsScanned: hostList.length,
360
1058
  ports: portList,
361
1059
  targetsScanned: targets.length,