@poolzin/pool-bot 2026.3.4 → 2026.3.6

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 (57) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/assets/pool-bot-icon-dark.png +0 -0
  3. package/assets/pool-bot-logo-1.png +0 -0
  4. package/assets/pool-bot-mascot.png +0 -0
  5. package/dist/agents/pi-embedded-runner/tool-result-truncation.js +62 -7
  6. package/dist/agents/poolbot-tools.js +12 -0
  7. package/dist/agents/session-write-lock.js +93 -8
  8. package/dist/agents/tools/pdf-native-providers.js +102 -0
  9. package/dist/agents/tools/pdf-tool.helpers.js +86 -0
  10. package/dist/agents/tools/pdf-tool.js +508 -0
  11. package/dist/build-info.json +3 -3
  12. package/dist/cron/normalize.js +3 -0
  13. package/dist/cron/service/jobs.js +48 -0
  14. package/dist/gateway/protocol/schema/cron.js +3 -0
  15. package/dist/gateway/server-channels.js +99 -14
  16. package/dist/gateway/server-cron.js +89 -0
  17. package/dist/gateway/server-health-probes.js +55 -0
  18. package/dist/gateway/server-http.js +5 -0
  19. package/dist/hooks/bundled/session-memory/handler.js +8 -2
  20. package/dist/infra/abort-signal.js +12 -0
  21. package/dist/infra/boundary-file-read.js +118 -0
  22. package/dist/infra/boundary-path.js +594 -0
  23. package/dist/infra/file-identity.js +12 -0
  24. package/dist/infra/fs-safe.js +377 -12
  25. package/dist/infra/hardlink-guards.js +30 -0
  26. package/dist/infra/json-utf8-bytes.js +8 -0
  27. package/dist/infra/net/fetch-guard.js +63 -13
  28. package/dist/infra/net/proxy-env.js +17 -0
  29. package/dist/infra/net/ssrf.js +74 -272
  30. package/dist/infra/path-alias-guards.js +21 -0
  31. package/dist/infra/path-guards.js +13 -1
  32. package/dist/infra/ports-probe.js +19 -0
  33. package/dist/infra/prototype-keys.js +4 -0
  34. package/dist/infra/restart-stale-pids.js +254 -0
  35. package/dist/infra/safe-open-sync.js +71 -0
  36. package/dist/infra/secure-random.js +7 -0
  37. package/dist/media/ffmpeg-limits.js +4 -0
  38. package/dist/media/input-files.js +6 -2
  39. package/dist/media/temp-files.js +12 -0
  40. package/dist/memory/embedding-chunk-limits.js +5 -2
  41. package/dist/memory/embeddings-ollama.js +91 -138
  42. package/dist/memory/embeddings-remote-fetch.js +11 -10
  43. package/dist/memory/embeddings.js +25 -9
  44. package/dist/memory/manager-embedding-ops.js +1 -1
  45. package/dist/memory/post-json.js +23 -0
  46. package/dist/memory/qmd-manager.js +272 -77
  47. package/dist/memory/remote-http.js +33 -0
  48. package/dist/plugin-sdk/windows-spawn.js +214 -0
  49. package/dist/shared/net/ip-test-fixtures.js +1 -0
  50. package/dist/shared/net/ip.js +303 -0
  51. package/dist/shared/net/ipv4.js +8 -11
  52. package/dist/shared/pid-alive.js +59 -2
  53. package/dist/test-helpers/ssrf.js +13 -0
  54. package/dist/tui/tui.js +9 -4
  55. package/dist/utils/fetch-timeout.js +12 -1
  56. package/docs/adr/003-feature-gap-analysis.md +112 -0
  57. package/package.json +10 -4
@@ -0,0 +1,214 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ function isFilePath(candidate) {
4
+ try {
5
+ return statSync(candidate).isFile();
6
+ }
7
+ catch {
8
+ return false;
9
+ }
10
+ }
11
+ export function resolveWindowsExecutablePath(command, env) {
12
+ if (command.includes("/") || command.includes("\\") || path.isAbsolute(command)) {
13
+ return command;
14
+ }
15
+ const pathValue = env.PATH ?? env.Path ?? process.env.PATH ?? process.env.Path ?? "";
16
+ const pathEntries = pathValue
17
+ .split(";")
18
+ .map((entry) => entry.trim())
19
+ .filter(Boolean);
20
+ const hasExtension = path.extname(command).length > 0;
21
+ const pathExtRaw = env.PATHEXT ??
22
+ env.Pathext ??
23
+ process.env.PATHEXT ??
24
+ process.env.Pathext ??
25
+ ".EXE;.CMD;.BAT;.COM";
26
+ const pathExt = hasExtension
27
+ ? [""]
28
+ : pathExtRaw
29
+ .split(";")
30
+ .map((ext) => ext.trim())
31
+ .filter(Boolean)
32
+ .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
33
+ for (const dir of pathEntries) {
34
+ for (const ext of pathExt) {
35
+ for (const candidateExt of [ext, ext.toLowerCase(), ext.toUpperCase()]) {
36
+ const candidate = path.join(dir, `${command}${candidateExt}`);
37
+ if (isFilePath(candidate)) {
38
+ return candidate;
39
+ }
40
+ }
41
+ }
42
+ }
43
+ return command;
44
+ }
45
+ function resolveEntrypointFromCmdShim(wrapperPath) {
46
+ if (!isFilePath(wrapperPath)) {
47
+ return null;
48
+ }
49
+ try {
50
+ const content = readFileSync(wrapperPath, "utf8");
51
+ const candidates = [];
52
+ for (const match of content.matchAll(/"([^"\r\n]*)"/g)) {
53
+ const token = match[1] ?? "";
54
+ const relMatch = token.match(/%~?dp0%?\s*[\\/]*(.*)$/i);
55
+ const relative = relMatch?.[1]?.trim();
56
+ if (!relative) {
57
+ continue;
58
+ }
59
+ const normalizedRelative = relative.replace(/[\\/]+/g, path.sep).replace(/^[\\/]+/, "");
60
+ const candidate = path.resolve(path.dirname(wrapperPath), normalizedRelative);
61
+ if (isFilePath(candidate)) {
62
+ candidates.push(candidate);
63
+ }
64
+ }
65
+ const nonNode = candidates.find((candidate) => {
66
+ const base = path.basename(candidate).toLowerCase();
67
+ return base !== "node.exe" && base !== "node";
68
+ });
69
+ return nonNode ?? null;
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ }
75
+ function resolveBinEntry(packageName, binField) {
76
+ if (typeof binField === "string") {
77
+ const trimmed = binField.trim();
78
+ return trimmed || null;
79
+ }
80
+ if (!binField || typeof binField !== "object") {
81
+ return null;
82
+ }
83
+ if (packageName) {
84
+ const preferred = binField[packageName];
85
+ if (typeof preferred === "string" && preferred.trim()) {
86
+ return preferred.trim();
87
+ }
88
+ }
89
+ for (const value of Object.values(binField)) {
90
+ if (typeof value === "string" && value.trim()) {
91
+ return value.trim();
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ function resolveEntrypointFromPackageJson(wrapperPath, packageName) {
97
+ if (!packageName) {
98
+ return null;
99
+ }
100
+ const wrapperDir = path.dirname(wrapperPath);
101
+ const packageDirs = [
102
+ path.resolve(wrapperDir, "..", packageName),
103
+ path.resolve(wrapperDir, "node_modules", packageName),
104
+ ];
105
+ for (const packageDir of packageDirs) {
106
+ const packageJsonPath = path.join(packageDir, "package.json");
107
+ if (!isFilePath(packageJsonPath)) {
108
+ continue;
109
+ }
110
+ try {
111
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
112
+ const entryRel = resolveBinEntry(packageName, packageJson.bin);
113
+ if (!entryRel) {
114
+ continue;
115
+ }
116
+ const entryPath = path.resolve(packageDir, entryRel);
117
+ if (isFilePath(entryPath)) {
118
+ return entryPath;
119
+ }
120
+ }
121
+ catch {
122
+ // Ignore malformed package metadata.
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ export function resolveWindowsSpawnProgramCandidate(params) {
128
+ const platform = params.platform ?? process.platform;
129
+ const env = params.env ?? process.env;
130
+ const execPath = params.execPath ?? process.execPath;
131
+ if (platform !== "win32") {
132
+ return {
133
+ command: params.command,
134
+ leadingArgv: [],
135
+ resolution: "direct",
136
+ };
137
+ }
138
+ const resolvedCommand = resolveWindowsExecutablePath(params.command, env);
139
+ const ext = path.extname(resolvedCommand).toLowerCase();
140
+ if (ext === ".js" || ext === ".cjs" || ext === ".mjs") {
141
+ return {
142
+ command: execPath,
143
+ leadingArgv: [resolvedCommand],
144
+ resolution: "node-entrypoint",
145
+ windowsHide: true,
146
+ };
147
+ }
148
+ if (ext === ".cmd" || ext === ".bat") {
149
+ const entrypoint = resolveEntrypointFromCmdShim(resolvedCommand) ??
150
+ resolveEntrypointFromPackageJson(resolvedCommand, params.packageName);
151
+ if (entrypoint) {
152
+ const entryExt = path.extname(entrypoint).toLowerCase();
153
+ if (entryExt === ".exe") {
154
+ return {
155
+ command: entrypoint,
156
+ leadingArgv: [],
157
+ resolution: "exe-entrypoint",
158
+ windowsHide: true,
159
+ };
160
+ }
161
+ return {
162
+ command: execPath,
163
+ leadingArgv: [entrypoint],
164
+ resolution: "node-entrypoint",
165
+ windowsHide: true,
166
+ };
167
+ }
168
+ return {
169
+ command: resolvedCommand,
170
+ leadingArgv: [],
171
+ resolution: "unresolved-wrapper",
172
+ };
173
+ }
174
+ return {
175
+ command: resolvedCommand,
176
+ leadingArgv: [],
177
+ resolution: "direct",
178
+ };
179
+ }
180
+ export function applyWindowsSpawnProgramPolicy(params) {
181
+ if (params.candidate.resolution !== "unresolved-wrapper") {
182
+ return {
183
+ command: params.candidate.command,
184
+ leadingArgv: params.candidate.leadingArgv,
185
+ resolution: params.candidate.resolution,
186
+ windowsHide: params.candidate.windowsHide,
187
+ };
188
+ }
189
+ if (params.allowShellFallback !== false) {
190
+ return {
191
+ command: params.candidate.command,
192
+ leadingArgv: [],
193
+ resolution: "shell-fallback",
194
+ shell: true,
195
+ };
196
+ }
197
+ throw new Error(`${path.basename(params.candidate.command)} wrapper resolved, but no executable/Node entrypoint could be resolved without shell execution.`);
198
+ }
199
+ export function resolveWindowsSpawnProgram(params) {
200
+ const candidate = resolveWindowsSpawnProgramCandidate(params);
201
+ return applyWindowsSpawnProgramPolicy({
202
+ candidate,
203
+ allowShellFallback: params.allowShellFallback,
204
+ });
205
+ }
206
+ export function materializeWindowsSpawnProgram(program, argv) {
207
+ return {
208
+ command: program.command,
209
+ argv: [...program.leadingArgv, ...argv],
210
+ resolution: program.resolution,
211
+ shell: program.shell,
212
+ windowsHide: program.windowsHide,
213
+ };
214
+ }
@@ -0,0 +1 @@
1
+ export const blockedIpv6MulticastLiterals = ["ff02::1", "ff05::1:3", "[ff02::1]"];
@@ -0,0 +1,303 @@
1
+ import ipaddr from "ipaddr.js";
2
+ const BLOCKED_IPV4_SPECIAL_USE_RANGES = new Set([
3
+ "unspecified",
4
+ "broadcast",
5
+ "multicast",
6
+ "linkLocal",
7
+ "loopback",
8
+ "carrierGradeNat",
9
+ "private",
10
+ "reserved",
11
+ ]);
12
+ const PRIVATE_OR_LOOPBACK_IPV4_RANGES = new Set([
13
+ "loopback",
14
+ "private",
15
+ "linkLocal",
16
+ "carrierGradeNat",
17
+ ]);
18
+ const BLOCKED_IPV6_SPECIAL_USE_RANGES = new Set([
19
+ "unspecified",
20
+ "loopback",
21
+ "linkLocal",
22
+ "uniqueLocal",
23
+ "multicast",
24
+ ]);
25
+ const RFC2544_BENCHMARK_PREFIX = [ipaddr.IPv4.parse("198.18.0.0"), 15];
26
+ const EMBEDDED_IPV4_SENTINEL_RULES = [
27
+ {
28
+ // IPv4-compatible form ::w.x.y.z (deprecated, but still seen in parser edge-cases).
29
+ matches: (parts) => parts[0] === 0 &&
30
+ parts[1] === 0 &&
31
+ parts[2] === 0 &&
32
+ parts[3] === 0 &&
33
+ parts[4] === 0 &&
34
+ parts[5] === 0,
35
+ toHextets: (parts) => [parts[6], parts[7]],
36
+ },
37
+ {
38
+ // NAT64 local-use prefix: 64:ff9b:1::/48.
39
+ matches: (parts) => parts[0] === 0x0064 &&
40
+ parts[1] === 0xff9b &&
41
+ parts[2] === 0x0001 &&
42
+ parts[3] === 0 &&
43
+ parts[4] === 0 &&
44
+ parts[5] === 0,
45
+ toHextets: (parts) => [parts[6], parts[7]],
46
+ },
47
+ {
48
+ // 6to4 prefix: 2002::/16 (IPv4 lives in hextets 1..2).
49
+ matches: (parts) => parts[0] === 0x2002,
50
+ toHextets: (parts) => [parts[1], parts[2]],
51
+ },
52
+ {
53
+ // Teredo prefix: 2001:0000::/32 (client IPv4 XOR 0xffff in hextets 6..7).
54
+ matches: (parts) => parts[0] === 0x2001 && parts[1] === 0x0000,
55
+ toHextets: (parts) => [parts[6] ^ 0xffff, parts[7] ^ 0xffff],
56
+ },
57
+ {
58
+ // ISATAP IID marker: ....:0000:5efe:w.x.y.z with u/g bits allowed in hextet 4.
59
+ matches: (parts) => (parts[4] & 0xfcff) === 0 && parts[5] === 0x5efe,
60
+ toHextets: (parts) => [parts[6], parts[7]],
61
+ },
62
+ ];
63
+ function stripIpv6Brackets(value) {
64
+ if (value.startsWith("[") && value.endsWith("]")) {
65
+ return value.slice(1, -1);
66
+ }
67
+ return value;
68
+ }
69
+ function isNumericIpv4LiteralPart(value) {
70
+ return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value);
71
+ }
72
+ function parseIpv6WithEmbeddedIpv4(raw) {
73
+ if (!raw.includes(":") || !raw.includes(".")) {
74
+ return undefined;
75
+ }
76
+ const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw);
77
+ if (!match) {
78
+ return undefined;
79
+ }
80
+ const [, prefix, embeddedIpv4, zoneSuffix = ""] = match;
81
+ if (!ipaddr.IPv4.isValidFourPartDecimal(embeddedIpv4)) {
82
+ return undefined;
83
+ }
84
+ const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10));
85
+ const high = ((octets[0] << 8) | octets[1]).toString(16);
86
+ const low = ((octets[2] << 8) | octets[3]).toString(16);
87
+ const normalizedIpv6 = `${prefix}${high}:${low}${zoneSuffix}`;
88
+ if (!ipaddr.IPv6.isValid(normalizedIpv6)) {
89
+ return undefined;
90
+ }
91
+ return ipaddr.IPv6.parse(normalizedIpv6);
92
+ }
93
+ export function isIpv4Address(address) {
94
+ return address.kind() === "ipv4";
95
+ }
96
+ export function isIpv6Address(address) {
97
+ return address.kind() === "ipv6";
98
+ }
99
+ function normalizeIpv4MappedAddress(address) {
100
+ if (!isIpv6Address(address)) {
101
+ return address;
102
+ }
103
+ if (!address.isIPv4MappedAddress()) {
104
+ return address;
105
+ }
106
+ return address.toIPv4Address();
107
+ }
108
+ export function parseCanonicalIpAddress(raw) {
109
+ const trimmed = raw?.trim();
110
+ if (!trimmed) {
111
+ return undefined;
112
+ }
113
+ const normalized = stripIpv6Brackets(trimmed);
114
+ if (!normalized) {
115
+ return undefined;
116
+ }
117
+ if (ipaddr.IPv4.isValid(normalized)) {
118
+ if (!ipaddr.IPv4.isValidFourPartDecimal(normalized)) {
119
+ return undefined;
120
+ }
121
+ return ipaddr.IPv4.parse(normalized);
122
+ }
123
+ if (ipaddr.IPv6.isValid(normalized)) {
124
+ return ipaddr.IPv6.parse(normalized);
125
+ }
126
+ return parseIpv6WithEmbeddedIpv4(normalized);
127
+ }
128
+ export function parseLooseIpAddress(raw) {
129
+ const trimmed = raw?.trim();
130
+ if (!trimmed) {
131
+ return undefined;
132
+ }
133
+ const normalized = stripIpv6Brackets(trimmed);
134
+ if (!normalized) {
135
+ return undefined;
136
+ }
137
+ if (ipaddr.isValid(normalized)) {
138
+ return ipaddr.parse(normalized);
139
+ }
140
+ return parseIpv6WithEmbeddedIpv4(normalized);
141
+ }
142
+ export function normalizeIpAddress(raw) {
143
+ const parsed = parseCanonicalIpAddress(raw);
144
+ if (!parsed) {
145
+ return undefined;
146
+ }
147
+ const normalized = normalizeIpv4MappedAddress(parsed);
148
+ return normalized.toString().toLowerCase();
149
+ }
150
+ export function isCanonicalDottedDecimalIPv4(raw) {
151
+ const trimmed = raw?.trim();
152
+ if (!trimmed) {
153
+ return false;
154
+ }
155
+ const normalized = stripIpv6Brackets(trimmed);
156
+ if (!normalized) {
157
+ return false;
158
+ }
159
+ return ipaddr.IPv4.isValidFourPartDecimal(normalized);
160
+ }
161
+ export function isLegacyIpv4Literal(raw) {
162
+ const trimmed = raw?.trim();
163
+ if (!trimmed) {
164
+ return false;
165
+ }
166
+ const normalized = stripIpv6Brackets(trimmed);
167
+ if (!normalized || normalized.includes(":")) {
168
+ return false;
169
+ }
170
+ if (isCanonicalDottedDecimalIPv4(normalized)) {
171
+ return false;
172
+ }
173
+ const parts = normalized.split(".");
174
+ if (parts.length === 0 || parts.length > 4) {
175
+ return false;
176
+ }
177
+ if (parts.some((part) => part.length === 0)) {
178
+ return false;
179
+ }
180
+ if (!parts.every((part) => isNumericIpv4LiteralPart(part))) {
181
+ return false;
182
+ }
183
+ return true;
184
+ }
185
+ export function isLoopbackIpAddress(raw) {
186
+ const parsed = parseCanonicalIpAddress(raw);
187
+ if (!parsed) {
188
+ return false;
189
+ }
190
+ const normalized = normalizeIpv4MappedAddress(parsed);
191
+ return normalized.range() === "loopback";
192
+ }
193
+ export function isPrivateOrLoopbackIpAddress(raw) {
194
+ const parsed = parseCanonicalIpAddress(raw);
195
+ if (!parsed) {
196
+ return false;
197
+ }
198
+ const normalized = normalizeIpv4MappedAddress(parsed);
199
+ if (isIpv4Address(normalized)) {
200
+ return PRIVATE_OR_LOOPBACK_IPV4_RANGES.has(normalized.range());
201
+ }
202
+ return isBlockedSpecialUseIpv6Address(normalized);
203
+ }
204
+ export function isBlockedSpecialUseIpv6Address(address) {
205
+ if (BLOCKED_IPV6_SPECIAL_USE_RANGES.has(address.range())) {
206
+ return true;
207
+ }
208
+ // ipaddr.js does not classify deprecated site-local fec0::/10 as private.
209
+ return (address.parts[0] & 0xffc0) === 0xfec0;
210
+ }
211
+ export function isRfc1918Ipv4Address(raw) {
212
+ const parsed = parseCanonicalIpAddress(raw);
213
+ if (!parsed || !isIpv4Address(parsed)) {
214
+ return false;
215
+ }
216
+ return parsed.range() === "private";
217
+ }
218
+ export function isCarrierGradeNatIpv4Address(raw) {
219
+ const parsed = parseCanonicalIpAddress(raw);
220
+ if (!parsed || !isIpv4Address(parsed)) {
221
+ return false;
222
+ }
223
+ return parsed.range() === "carrierGradeNat";
224
+ }
225
+ export function isBlockedSpecialUseIpv4Address(address, options = {}) {
226
+ const inRfc2544BenchmarkRange = address.match(RFC2544_BENCHMARK_PREFIX);
227
+ if (inRfc2544BenchmarkRange && options.allowRfc2544BenchmarkRange === true) {
228
+ return false;
229
+ }
230
+ return BLOCKED_IPV4_SPECIAL_USE_RANGES.has(address.range()) || inRfc2544BenchmarkRange;
231
+ }
232
+ function decodeIpv4FromHextets(high, low) {
233
+ const octets = [
234
+ (high >>> 8) & 0xff,
235
+ high & 0xff,
236
+ (low >>> 8) & 0xff,
237
+ low & 0xff,
238
+ ];
239
+ return ipaddr.IPv4.parse(octets.join("."));
240
+ }
241
+ export function extractEmbeddedIpv4FromIpv6(address) {
242
+ if (address.isIPv4MappedAddress()) {
243
+ return address.toIPv4Address();
244
+ }
245
+ if (address.range() === "rfc6145") {
246
+ return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
247
+ }
248
+ if (address.range() === "rfc6052") {
249
+ return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
250
+ }
251
+ for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
252
+ if (!rule.matches(address.parts)) {
253
+ continue;
254
+ }
255
+ const [high, low] = rule.toHextets(address.parts);
256
+ return decodeIpv4FromHextets(high, low);
257
+ }
258
+ return undefined;
259
+ }
260
+ export function isIpInCidr(ip, cidr) {
261
+ const normalizedIp = parseCanonicalIpAddress(ip);
262
+ if (!normalizedIp) {
263
+ return false;
264
+ }
265
+ const candidate = cidr.trim();
266
+ if (!candidate) {
267
+ return false;
268
+ }
269
+ const comparableIp = normalizeIpv4MappedAddress(normalizedIp);
270
+ if (!candidate.includes("/")) {
271
+ const exact = parseCanonicalIpAddress(candidate);
272
+ if (!exact) {
273
+ return false;
274
+ }
275
+ const comparableExact = normalizeIpv4MappedAddress(exact);
276
+ return (comparableIp.kind() === comparableExact.kind() &&
277
+ comparableIp.toString() === comparableExact.toString());
278
+ }
279
+ let parsedCidr;
280
+ try {
281
+ parsedCidr = ipaddr.parseCIDR(candidate);
282
+ }
283
+ catch {
284
+ return false;
285
+ }
286
+ const [baseAddress, prefixLength] = parsedCidr;
287
+ const comparableBase = normalizeIpv4MappedAddress(baseAddress);
288
+ if (comparableIp.kind() !== comparableBase.kind()) {
289
+ return false;
290
+ }
291
+ try {
292
+ if (isIpv4Address(comparableIp) && isIpv4Address(comparableBase)) {
293
+ return comparableIp.match([comparableBase, prefixLength]);
294
+ }
295
+ if (isIpv6Address(comparableIp) && isIpv6Address(comparableBase)) {
296
+ return comparableIp.match([comparableBase, prefixLength]);
297
+ }
298
+ return false;
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ }
@@ -1,17 +1,14 @@
1
- export function validateIPv4AddressInput(value) {
1
+ import { isCanonicalDottedDecimalIPv4 } from "./ip.js";
2
+ export function validateDottedDecimalIPv4Input(value) {
2
3
  if (!value) {
3
4
  return "IP address is required for custom bind mode";
4
5
  }
5
- const trimmed = value.trim();
6
- const parts = trimmed.split(".");
7
- if (parts.length !== 4) {
8
- return "Invalid IPv4 address (e.g., 192.168.1.100)";
9
- }
10
- if (parts.every((part) => {
11
- const n = parseInt(part, 10);
12
- return !Number.isNaN(n) && n >= 0 && n <= 255 && part === String(n);
13
- })) {
6
+ if (isCanonicalDottedDecimalIPv4(value)) {
14
7
  return undefined;
15
8
  }
16
- return "Invalid IPv4 address (each octet must be 0-255)";
9
+ return "Invalid IPv4 address (e.g., 192.168.1.100)";
10
+ }
11
+ // Backward-compatible alias for callers using the old helper name.
12
+ export function validateIPv4AddressInput(value) {
13
+ return validateDottedDecimalIPv4Input(value);
17
14
  }
@@ -1,12 +1,69 @@
1
+ import fsSync from "node:fs";
2
+ function isValidPid(pid) {
3
+ return Number.isInteger(pid) && pid > 0;
4
+ }
5
+ /**
6
+ * Check if a process is a zombie on Linux by reading /proc/<pid>/status.
7
+ * Returns false on non-Linux platforms or if the proc file can't be read.
8
+ */
9
+ function isZombieProcess(pid) {
10
+ if (process.platform !== "linux") {
11
+ return false;
12
+ }
13
+ try {
14
+ const status = fsSync.readFileSync(`/proc/${pid}/status`, "utf8");
15
+ const stateMatch = status.match(/^State:\s+(\S)/m);
16
+ return stateMatch?.[1] === "Z";
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ }
1
22
  export function isPidAlive(pid) {
2
- if (!Number.isFinite(pid) || pid <= 0) {
23
+ if (!isValidPid(pid)) {
3
24
  return false;
4
25
  }
5
26
  try {
6
27
  process.kill(pid, 0);
7
- return true;
8
28
  }
9
29
  catch {
10
30
  return false;
11
31
  }
32
+ if (isZombieProcess(pid)) {
33
+ return false;
34
+ }
35
+ return true;
36
+ }
37
+ /**
38
+ * Read the process start time (field 22 "starttime") from /proc/<pid>/stat.
39
+ * Returns the value in clock ticks since system boot, or null on non-Linux
40
+ * platforms or if the proc file can't be read.
41
+ *
42
+ * This is used to detect PID recycling: if two readings for the same PID
43
+ * return different starttimes, the PID has been reused by a different process.
44
+ */
45
+ export function getProcessStartTime(pid) {
46
+ if (process.platform !== "linux") {
47
+ return null;
48
+ }
49
+ if (!isValidPid(pid)) {
50
+ return null;
51
+ }
52
+ try {
53
+ const stat = fsSync.readFileSync(`/proc/${pid}/stat`, "utf8");
54
+ const commEndIndex = stat.lastIndexOf(")");
55
+ if (commEndIndex < 0) {
56
+ return null;
57
+ }
58
+ // The comm field (field 2) is wrapped in parens and can contain spaces,
59
+ // so split after the last ")" to get fields 3..N reliably.
60
+ const afterComm = stat.slice(commEndIndex + 1).trimStart();
61
+ const fields = afterComm.split(/\s+/);
62
+ // field 22 (starttime) = index 19 after the comm-split (field 3 is index 0).
63
+ const starttime = Number(fields[19]);
64
+ return Number.isInteger(starttime) && starttime >= 0 ? starttime : null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
12
69
  }
@@ -0,0 +1,13 @@
1
+ import { vi } from "vitest";
2
+ import * as ssrf from "../infra/net/ssrf.js";
3
+ export function mockPinnedHostnameResolution(addresses = ["93.184.216.34"]) {
4
+ return vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation(async (hostname) => {
5
+ const normalized = hostname.trim().toLowerCase().replace(/\.$/, "");
6
+ const pinnedAddresses = [...addresses];
7
+ return {
8
+ hostname: normalized,
9
+ addresses: pinnedAddresses,
10
+ lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses: pinnedAddresses }),
11
+ };
12
+ });
13
+ }
package/dist/tui/tui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { CombinedAutocompleteProvider, Container, Key, Loader, matchesKey, ProcessTerminal, Text, TUI, } from "@mariozechner/pi-tui";
1
+ import { CombinedAutocompleteProvider, Container, Key, Loader, matchesKey, ProcessTerminal, Text, TUI, truncateToWidth, } from "@mariozechner/pi-tui";
2
2
  import { resolveDefaultAgentId } from "../agents/agent-scope.js";
3
3
  import { loadConfig } from "../config/config.js";
4
4
  import { buildAgentMainSessionKey, normalizeAgentId, normalizeMainKey, parseAgentSessionKey, } from "../routing/session-key.js";
@@ -398,7 +398,9 @@ export async function runTui(opts) {
398
398
  const updateHeader = () => {
399
399
  const sessionLabel = formatSessionKey(currentSessionKey);
400
400
  const agentLabel = formatAgentLabel(currentAgentId);
401
- header.setText(theme.header(`poolbot tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`));
401
+ const headerText = `poolbot tui - ${client.connection.url} - agent ${agentLabel} - session ${sessionLabel}`;
402
+ const maxHeaderWidth = Math.max(1, (process.stdout.columns ?? 120) - 2);
403
+ header.setText(theme.header(truncateToWidth(headerText, maxHeaderWidth, "…")));
402
404
  };
403
405
  const busyStates = new Set(["sending", "waiting", "streaming", "running"]);
404
406
  let statusText = null;
@@ -520,7 +522,8 @@ export async function runTui(opts) {
520
522
  statusLoader = null;
521
523
  ensureStatusText();
522
524
  const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus;
523
- statusText?.setText(theme.dim(text));
525
+ const maxStatusWidth = Math.max(1, (process.stdout.columns ?? 120) - 2);
526
+ statusText?.setText(theme.dim(truncateToWidth(text, maxStatusWidth, "…")));
524
527
  }
525
528
  lastActivityStatus = activityStatus;
526
529
  };
@@ -566,7 +569,9 @@ export async function runTui(opts) {
566
569
  reasoningLabel,
567
570
  tokens,
568
571
  ].filter(Boolean);
569
- footer.setText(theme.dim(footerParts.join(" | ")));
572
+ const footerText = footerParts.join(" | ");
573
+ const maxFooterWidth = Math.max(1, (process.stdout.columns ?? 120) - 2);
574
+ footer.setText(theme.dim(truncateToWidth(footerText, maxFooterWidth, "…")));
570
575
  };
571
576
  const { openOverlay, closeOverlay } = createOverlayHandlers(tui, editor);
572
577
  const initialSessionAgentId = (() => {
@@ -1,3 +1,14 @@
1
+ /**
2
+ * Relay abort without forwarding the Event argument as the abort reason.
3
+ * Using .bind() avoids closure scope capture (memory leak prevention).
4
+ */
5
+ function relayAbort() {
6
+ this.abort();
7
+ }
8
+ /** Returns a bound abort relay for use as an event listener. */
9
+ export function bindAbortRelay(controller) {
10
+ return relayAbort.bind(controller);
11
+ }
1
12
  /**
2
13
  * Fetch wrapper that adds timeout support via AbortController.
3
14
  *
@@ -10,7 +21,7 @@
10
21
  */
11
22
  export async function fetchWithTimeout(url, init, timeoutMs, fetchFn = fetch) {
12
23
  const controller = new AbortController();
13
- const timer = setTimeout(() => controller.abort(), Math.max(1, timeoutMs));
24
+ const timer = setTimeout(controller.abort.bind(controller), Math.max(1, timeoutMs));
14
25
  try {
15
26
  return await fetchFn(url, { ...init, signal: controller.signal });
16
27
  }