@poolzin/pool-bot 2026.3.4 → 2026.3.7

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 (81) 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/pi-tools.js +32 -2
  7. package/dist/agents/poolbot-tools.js +12 -0
  8. package/dist/agents/session-write-lock.js +93 -8
  9. package/dist/agents/tools/pdf-native-providers.js +102 -0
  10. package/dist/agents/tools/pdf-tool.helpers.js +86 -0
  11. package/dist/agents/tools/pdf-tool.js +508 -0
  12. package/dist/auto-reply/reply/get-reply.js +6 -0
  13. package/dist/auto-reply/reply/message-preprocess-hooks.js +17 -0
  14. package/dist/build-info.json +3 -3
  15. package/dist/cli/banner.js +20 -1
  16. package/dist/cli/security-cli.js +211 -2
  17. package/dist/cli/tagline.js +7 -0
  18. package/dist/config/types.cli.js +1 -0
  19. package/dist/config/types.security.js +33 -0
  20. package/dist/config/zod-schema.js +15 -0
  21. package/dist/config/zod-schema.providers-core.js +1 -0
  22. package/dist/config/zod-schema.security.js +113 -0
  23. package/dist/cron/normalize.js +3 -0
  24. package/dist/cron/service/jobs.js +48 -0
  25. package/dist/discord/monitor/message-handler.preflight.js +11 -2
  26. package/dist/gateway/http-common.js +6 -1
  27. package/dist/gateway/protocol/schema/cron.js +3 -0
  28. package/dist/gateway/server-channels.js +99 -14
  29. package/dist/gateway/server-cron.js +89 -0
  30. package/dist/gateway/server-health-probes.js +55 -0
  31. package/dist/gateway/server-http.js +5 -0
  32. package/dist/hooks/bundled/session-memory/handler.js +8 -2
  33. package/dist/hooks/fire-and-forget.js +6 -0
  34. package/dist/hooks/internal-hooks.js +64 -19
  35. package/dist/hooks/message-hook-mappers.js +179 -0
  36. package/dist/infra/abort-signal.js +12 -0
  37. package/dist/infra/boundary-file-read.js +118 -0
  38. package/dist/infra/boundary-path.js +594 -0
  39. package/dist/infra/file-identity.js +12 -0
  40. package/dist/infra/fs-safe.js +377 -12
  41. package/dist/infra/hardlink-guards.js +30 -0
  42. package/dist/infra/json-utf8-bytes.js +8 -0
  43. package/dist/infra/net/fetch-guard.js +63 -13
  44. package/dist/infra/net/proxy-env.js +17 -0
  45. package/dist/infra/net/ssrf.js +74 -272
  46. package/dist/infra/path-alias-guards.js +21 -0
  47. package/dist/infra/path-guards.js +13 -1
  48. package/dist/infra/ports-probe.js +19 -0
  49. package/dist/infra/prototype-keys.js +4 -0
  50. package/dist/infra/restart-stale-pids.js +254 -0
  51. package/dist/infra/safe-open-sync.js +71 -0
  52. package/dist/infra/secure-random.js +7 -0
  53. package/dist/media/ffmpeg-limits.js +4 -0
  54. package/dist/media/input-files.js +6 -2
  55. package/dist/media/temp-files.js +12 -0
  56. package/dist/memory/embedding-chunk-limits.js +5 -2
  57. package/dist/memory/embeddings-ollama.js +91 -138
  58. package/dist/memory/embeddings-remote-fetch.js +11 -10
  59. package/dist/memory/embeddings.js +25 -9
  60. package/dist/memory/manager-embedding-ops.js +1 -1
  61. package/dist/memory/post-json.js +23 -0
  62. package/dist/memory/qmd-manager.js +272 -77
  63. package/dist/memory/remote-http.js +33 -0
  64. package/dist/plugin-sdk/windows-spawn.js +214 -0
  65. package/dist/security/capability-guards.js +89 -0
  66. package/dist/security/capability-manager.js +76 -0
  67. package/dist/security/capability.js +147 -0
  68. package/dist/security/index.js +7 -0
  69. package/dist/security/middleware.js +105 -0
  70. package/dist/shared/net/ip-test-fixtures.js +1 -0
  71. package/dist/shared/net/ip.js +303 -0
  72. package/dist/shared/net/ipv4.js +8 -11
  73. package/dist/shared/pid-alive.js +59 -2
  74. package/dist/slack/monitor/context.js +1 -0
  75. package/dist/slack/monitor/message-handler/dispatch.js +14 -1
  76. package/dist/slack/monitor/provider.js +2 -0
  77. package/dist/test-helpers/ssrf.js +13 -0
  78. package/dist/tui/tui.js +9 -4
  79. package/dist/utils/fetch-timeout.js +12 -1
  80. package/docs/adr/003-feature-gap-analysis.md +112 -0
  81. package/package.json +10 -4
@@ -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
  }
@@ -228,6 +228,7 @@ export function createSlackMonitorContext(params) {
228
228
  slashCommand: params.slashCommand,
229
229
  textLimit: params.textLimit,
230
230
  ackReactionScope: params.ackReactionScope,
231
+ typingReaction: params.typingReaction,
231
232
  mediaMaxBytes: params.mediaMaxBytes,
232
233
  removeAckAfterReply: params.removeAckAfterReply,
233
234
  logger,
@@ -8,7 +8,7 @@ import { createReplyPrefixOptions } from "../../../channels/reply-prefix.js";
8
8
  import { createTypingCallbacks } from "../../../channels/typing.js";
9
9
  import { resolveStorePath, updateLastRoute } from "../../../config/sessions.js";
10
10
  import { danger, logVerbose, shouldLogVerbose } from "../../../globals.js";
11
- import { removeSlackReaction } from "../../actions.js";
11
+ import { reactSlackMessage, removeSlackReaction } from "../../actions.js";
12
12
  import { createSlackDraftStream } from "../../draft-stream.js";
13
13
  import { applyAppendOnlyStreamUpdate, buildStatusFinalPreviewText, resolveSlackStreamingConfig, } from "../../stream-mode.js";
14
14
  import { appendSlackStream, startSlackStream, stopSlackStream } from "../../streaming.js";
@@ -78,6 +78,7 @@ export async function dispatchPreparedSlackMessage(prepared) {
78
78
  hasRepliedRef,
79
79
  });
80
80
  const typingTarget = statusThreadTs ? `${message.channel}/${statusThreadTs}` : message.channel;
81
+ const typingReaction = ctx.typingReaction;
81
82
  const typingCallbacks = createTypingCallbacks({
82
83
  start: async () => {
83
84
  didSetStatus = true;
@@ -86,6 +87,12 @@ export async function dispatchPreparedSlackMessage(prepared) {
86
87
  threadTs: statusThreadTs,
87
88
  status: "is typing...",
88
89
  });
90
+ if (typingReaction && message.ts) {
91
+ await reactSlackMessage(message.channel, message.ts, typingReaction, {
92
+ token: ctx.botToken,
93
+ client: ctx.app.client,
94
+ }).catch(() => { });
95
+ }
89
96
  },
90
97
  stop: async () => {
91
98
  if (!didSetStatus) {
@@ -97,6 +104,12 @@ export async function dispatchPreparedSlackMessage(prepared) {
97
104
  threadTs: statusThreadTs,
98
105
  status: "",
99
106
  });
107
+ if (typingReaction && message.ts) {
108
+ await removeSlackReaction(message.channel, message.ts, typingReaction, {
109
+ token: ctx.botToken,
110
+ client: ctx.app.client,
111
+ }).catch(() => { });
112
+ }
100
113
  },
101
114
  onStartError: (err) => {
102
115
  logTypingFailure({
@@ -90,6 +90,7 @@ export async function monitorSlackProvider(opts = {}) {
90
90
  const ackReactionScope = cfg.messages?.ackReactionScope ?? "group-mentions";
91
91
  const mediaMaxBytes = (opts.mediaMaxMb ?? slackCfg.mediaMaxMb ?? 20) * 1024 * 1024;
92
92
  const removeAckAfterReply = cfg.messages?.removeAckAfterReply ?? false;
93
+ const typingReaction = slackCfg.typingReaction ?? "";
93
94
  const receiver = slackMode === "http"
94
95
  ? new HTTPReceiver({
95
96
  signingSecret: signingSecret ?? "",
@@ -160,6 +161,7 @@ export async function monitorSlackProvider(opts = {}) {
160
161
  slashCommand,
161
162
  textLimit,
162
163
  ackReactionScope,
164
+ typingReaction,
163
165
  mediaMaxBytes,
164
166
  removeAckAfterReply,
165
167
  });
@@ -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
  }
@@ -0,0 +1,112 @@
1
+ # ADR 003: Feature Gap Analysis — Pool Bot vs OpenClaw
2
+
3
+ **Status:** Accepted
4
+ **Date:** 2026-03-04
5
+ **Author:** PLCode analysis agent
6
+
7
+ ## Context
8
+
9
+ Pool Bot (v2026.3.4) was forked from OpenClaw. OpenClaw has continued shipping features through versions 2026.2.12 to 2026.3.3 that Pool Bot has not yet adopted. This document identifies the highest-value gaps and prioritizes implementation.
10
+
11
+ ## Analysis Method
12
+
13
+ - Full diff of both CHANGELOGs (2000+ OpenClaw lines vs 297 Pool Bot lines)
14
+ - Deep source-tree exploration of `src/`, `extensions/`, `docs/`, and config schemas
15
+ - Cross-reference of every OpenClaw feature against Pool Bot codebase using grep/AST search
16
+
17
+ ## Confirmed Present (No Gap)
18
+
19
+ These OpenClaw features already exist in Pool Bot:
20
+
21
+ | Feature | Evidence |
22
+ |---------|----------|
23
+ | SSRF protection | 91+ references across `src/infra/net/ssrf.ts` and callers |
24
+ | Draft streaming (Telegram/Discord/Slack) | Full implementation in channel modules |
25
+ | Config validation | 100+ Zod schema matches |
26
+ | Session compaction | Plugin hooks, auto-compaction, diagnostics |
27
+ | Feishu/LINE/Mattermost/IRC/MS Teams/Nextcloud Talk/Tlon | Extensions present |
28
+ | Ollama memory embeddings | v2026.3.4 |
29
+ | Channel auto-restart with exponential backoff | Cherry-picked from OpenClaw |
30
+ | Boundary file read / safe-open-sync | Cherry-picked from OpenClaw |
31
+ | Hardened fs-safe with atomic writes | Cherry-picked from OpenClaw |
32
+
33
+ ## Confirmed Missing — Critical & High Priority
34
+
35
+ ### 1. HTTP Container Health Probes
36
+
37
+ **Priority:** Critical | **Effort:** S (small)
38
+
39
+ OpenClaw 2026.3.1 added built-in HTTP liveness/readiness endpoints (`/health`, `/healthz`, `/ready`, `/readyz`) for Docker/Kubernetes orchestrators. Pool Bot's gateway has WS-based health only — no HTTP endpoint paths for container probes.
40
+
41
+ **Impact:** Blocks production Kubernetes/Docker deployments from proper health checking.
42
+
43
+ ### 2. First-class PDF Tool
44
+
45
+ **Priority:** High | **Effort:** M (medium)
46
+
47
+ OpenClaw 2026.3.2 added a native PDF document extraction tool using provider-native document understanding (Anthropic/Google). Pool Bot's browser-tool has a "pdf" action that renders pages to PDF via browser, but no dedicated extraction tool.
48
+
49
+ **Impact:** Users cannot extract text/data from PDF documents using provider-native capabilities.
50
+
51
+ ### 3. Cron Failure Alerts
52
+
53
+ **Priority:** High | **Effort:** L (large)
54
+
55
+ OpenClaw 2026.3.1 added `failureAlert.mode` (announce | webhook) with `failureAlert.accountId`, per-job `delivery.failureDestination`. OpenClaw 2026.3.3 extended this with repeated-failure alerting, per-job overrides, and Web UI editor support. Pool Bot's cron logs failures but has no notification mechanism.
56
+
57
+ **Impact:** Operators have no way to be notified when scheduled tasks fail.
58
+
59
+ ### 4. Tools/Diffs Plugin
60
+
61
+ **Priority:** High | **Effort:** M (medium)
62
+
63
+ OpenClaw 2026.3.1 added a `diffs` plugin tool for read-only diff rendering from before/after text or unified patches, with gateway viewer URLs. OpenClaw 2026.3.2 added PDF output support.
64
+
65
+ **Impact:** Agents cannot present structured diffs to users in a readable format.
66
+
67
+ ### 5. Head+Tail Truncation Strategy
68
+
69
+ **Priority:** Medium | **Effort:** S (small)
70
+
71
+ OpenClaw added head+tail truncation for long messages — keeping the beginning and end of messages while eliding the middle. Pool Bot only has simple character-limit truncation.
72
+
73
+ **Impact:** Long responses lose valuable ending content when truncated.
74
+
75
+ ## Confirmed Missing — Medium Priority
76
+
77
+ | Feature | OpenClaw Version | Effort |
78
+ |---------|-----------------|--------|
79
+ | Perplexity Search API integration | 2026.2.14 | M |
80
+ | Telegram per-topic agentId | 2026.2.25 | S |
81
+ | Slack DM typing indicators | 2026.2.28 | S |
82
+ | Discord `allowBots` flag | 2026.2.28 | S |
83
+ | `config validate` CLI command | 2026.3.1 | S |
84
+ | OpenAI Responses WS transport | 2026.3.1 | L |
85
+ | Discord thread binding lifecycle | 2026.3.1 | M |
86
+ | Telegram DM topics | 2026.3.1 | S |
87
+ | Plugin runtime API enhancements | 2026.3.2 | M |
88
+ | Adaptive thinking budget | 2026.3.2 | M |
89
+ | Gateway Permissions-Policy header | 2026.3.2 | S |
90
+ | Config heartbeat auto-migration | 2026.3.3 | S |
91
+
92
+ ## Confirmed Missing — Low Priority
93
+
94
+ | Feature | OpenClaw Version | Effort |
95
+ |---------|-----------------|--------|
96
+ | CLI banner tagline randomizer | 2026.2.14 | S |
97
+ | `config file` command | 2026.3.1 | S |
98
+ | Web UI locale additions | 2026.3.2 | S |
99
+ | SecretRef/Secrets management | 2026.3.1-3.3 | XL |
100
+
101
+ ## Recommended Implementation Order
102
+
103
+ 1. **HTTP Health Probes** — smallest effort, unblocks container orchestration
104
+ 2. **Head+tail truncation** — small effort, immediate UX improvement
105
+ 3. **Cron failure alerts** — high operator value, moderate effort
106
+ 4. **First-class PDF tool** — high user value, leverages existing patterns
107
+ 5. **Diffs plugin tool** — complements agent capabilities
108
+ 6. **SecretRef system** — XL effort, defer to dedicated sprint
109
+
110
+ ## Decision
111
+
112
+ Implement items 1-5 in a single feature batch. Defer SecretRef and medium/low items to subsequent releases.