@poolzin/pool-bot 2026.2.6 → 2026.2.8

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.
@@ -1,26 +1,48 @@
1
1
  import { diagnosticLogger as diag, logLaneDequeue, logLaneEnqueue } from "../logging/diagnostic.js";
2
+ /**
3
+ * Dedicated error type thrown when a queued command is rejected because
4
+ * its lane was cleared. Callers that fire-and-forget enqueued tasks can
5
+ * catch (or ignore) this specific type to avoid unhandled-rejection noise.
6
+ */
7
+ export class CommandLaneClearedError extends Error {
8
+ constructor(lane) {
9
+ super(lane ? `Command lane "${lane}" cleared` : "Command lane cleared");
10
+ this.name = "CommandLaneClearedError";
11
+ }
12
+ }
2
13
  const lanes = new Map();
14
+ let nextTaskId = 1;
3
15
  function getLaneState(lane) {
4
16
  const existing = lanes.get(lane);
5
- if (existing)
17
+ if (existing) {
6
18
  return existing;
19
+ }
7
20
  const created = {
8
21
  lane,
9
22
  queue: [],
10
- active: 0,
23
+ activeTaskIds: new Set(),
11
24
  maxConcurrent: 1,
12
25
  draining: false,
26
+ generation: 0,
13
27
  };
14
28
  lanes.set(lane, created);
15
29
  return created;
16
30
  }
31
+ function completeTask(state, taskId, taskGeneration) {
32
+ if (taskGeneration !== state.generation) {
33
+ return false;
34
+ }
35
+ state.activeTaskIds.delete(taskId);
36
+ return true;
37
+ }
17
38
  function drainLane(lane) {
18
39
  const state = getLaneState(lane);
19
- if (state.draining)
40
+ if (state.draining) {
20
41
  return;
42
+ }
21
43
  state.draining = true;
22
44
  const pump = () => {
23
- while (state.active < state.maxConcurrent && state.queue.length > 0) {
45
+ while (state.activeTaskIds.size < state.maxConcurrent && state.queue.length > 0) {
24
46
  const entry = state.queue.shift();
25
47
  const waitedMs = Date.now() - entry.enqueuedAt;
26
48
  if (waitedMs >= entry.warnAfterMs) {
@@ -28,23 +50,29 @@ function drainLane(lane) {
28
50
  diag.warn(`lane wait exceeded: lane=${lane} waitedMs=${waitedMs} queueAhead=${state.queue.length}`);
29
51
  }
30
52
  logLaneDequeue(lane, waitedMs, state.queue.length);
31
- state.active += 1;
53
+ const taskId = nextTaskId++;
54
+ const taskGeneration = state.generation;
55
+ state.activeTaskIds.add(taskId);
32
56
  void (async () => {
33
57
  const startTime = Date.now();
34
58
  try {
35
59
  const result = await entry.task();
36
- state.active -= 1;
37
- diag.debug(`lane task done: lane=${lane} durationMs=${Date.now() - startTime} active=${state.active} queued=${state.queue.length}`);
38
- pump();
60
+ const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
61
+ if (completedCurrentGeneration) {
62
+ diag.debug(`lane task done: lane=${lane} durationMs=${Date.now() - startTime} active=${state.activeTaskIds.size} queued=${state.queue.length}`);
63
+ pump();
64
+ }
39
65
  entry.resolve(result);
40
66
  }
41
67
  catch (err) {
42
- state.active -= 1;
68
+ const completedCurrentGeneration = completeTask(state, taskId, taskGeneration);
43
69
  const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-");
44
70
  if (!isProbeLane) {
45
71
  diag.error(`lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`);
46
72
  }
47
- pump();
73
+ if (completedCurrentGeneration) {
74
+ pump();
75
+ }
48
76
  entry.reject(err);
49
77
  }
50
78
  })();
@@ -72,7 +100,7 @@ export function enqueueCommandInLane(lane, task, opts) {
72
100
  warnAfterMs,
73
101
  onWait: opts?.onWait,
74
102
  });
75
- logLaneEnqueue(cleaned, state.queue.length + state.active);
103
+ logLaneEnqueue(cleaned, state.queue.length + state.activeTaskIds.size);
76
104
  drainLane(cleaned);
77
105
  });
78
106
  }
@@ -82,23 +110,117 @@ export function enqueueCommand(task, opts) {
82
110
  export function getQueueSize(lane = "main" /* CommandLane.Main */) {
83
111
  const resolved = lane.trim() || "main" /* CommandLane.Main */;
84
112
  const state = lanes.get(resolved);
85
- if (!state)
113
+ if (!state) {
86
114
  return 0;
87
- return state.queue.length + state.active;
115
+ }
116
+ return state.queue.length + state.activeTaskIds.size;
88
117
  }
89
118
  export function getTotalQueueSize() {
90
119
  let total = 0;
91
120
  for (const s of lanes.values()) {
92
- total += s.queue.length + s.active;
121
+ total += s.queue.length + s.activeTaskIds.size;
93
122
  }
94
123
  return total;
95
124
  }
96
125
  export function clearCommandLane(lane = "main" /* CommandLane.Main */) {
97
126
  const cleaned = lane.trim() || "main" /* CommandLane.Main */;
98
127
  const state = lanes.get(cleaned);
99
- if (!state)
128
+ if (!state) {
100
129
  return 0;
130
+ }
101
131
  const removed = state.queue.length;
102
- state.queue.length = 0;
132
+ const pending = state.queue.splice(0);
133
+ for (const entry of pending) {
134
+ entry.reject(new CommandLaneClearedError(cleaned));
135
+ }
103
136
  return removed;
104
137
  }
138
+ /**
139
+ * Reset all lane runtime state to idle. Used after SIGUSR1 in-process
140
+ * restarts where interrupted tasks' finally blocks may not run, leaving
141
+ * stale active task IDs that permanently block new work from draining.
142
+ *
143
+ * Bumps lane generation and clears execution counters so stale completions
144
+ * from old in-flight tasks are ignored. Queued entries are intentionally
145
+ * preserved — they represent pending user work that should still execute
146
+ * after restart.
147
+ *
148
+ * After resetting, drains any lanes that still have queued entries so
149
+ * preserved work is pumped immediately rather than waiting for a future
150
+ * `enqueueCommandInLane()` call (which may never come).
151
+ */
152
+ export function resetAllLanes() {
153
+ const lanesToDrain = [];
154
+ for (const state of lanes.values()) {
155
+ state.generation += 1;
156
+ state.activeTaskIds.clear();
157
+ state.draining = false;
158
+ if (state.queue.length > 0) {
159
+ lanesToDrain.push(state.lane);
160
+ }
161
+ }
162
+ // Drain after the full reset pass so all lanes are in a clean state first.
163
+ for (const lane of lanesToDrain) {
164
+ drainLane(lane);
165
+ }
166
+ }
167
+ /**
168
+ * Returns the total number of actively executing tasks across all lanes
169
+ * (excludes queued-but-not-started entries).
170
+ */
171
+ export function getActiveTaskCount() {
172
+ let total = 0;
173
+ for (const s of lanes.values()) {
174
+ total += s.activeTaskIds.size;
175
+ }
176
+ return total;
177
+ }
178
+ /**
179
+ * Wait for all currently active tasks across all lanes to finish.
180
+ * Polls at a short interval; resolves when no tasks are active or
181
+ * when `timeoutMs` elapses (whichever comes first).
182
+ *
183
+ * New tasks enqueued after this call are ignored — only tasks that are
184
+ * already executing are waited on.
185
+ */
186
+ export function waitForActiveTasks(timeoutMs) {
187
+ // Keep shutdown/drain checks responsive without busy looping.
188
+ const POLL_INTERVAL_MS = 50;
189
+ const deadline = Date.now() + timeoutMs;
190
+ const activeAtStart = new Set();
191
+ for (const state of lanes.values()) {
192
+ for (const taskId of state.activeTaskIds) {
193
+ activeAtStart.add(taskId);
194
+ }
195
+ }
196
+ return new Promise((resolve) => {
197
+ const check = () => {
198
+ if (activeAtStart.size === 0) {
199
+ resolve({ drained: true });
200
+ return;
201
+ }
202
+ let hasPending = false;
203
+ for (const state of lanes.values()) {
204
+ for (const taskId of state.activeTaskIds) {
205
+ if (activeAtStart.has(taskId)) {
206
+ hasPending = true;
207
+ break;
208
+ }
209
+ }
210
+ if (hasPending) {
211
+ break;
212
+ }
213
+ }
214
+ if (!hasPending) {
215
+ resolve({ drained: true });
216
+ return;
217
+ }
218
+ if (Date.now() >= deadline) {
219
+ resolve({ drained: false });
220
+ return;
221
+ }
222
+ setTimeout(check, POLL_INTERVAL_MS);
223
+ };
224
+ check();
225
+ });
226
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Returns an iteration hook for in-process restart loops.
3
+ * The first call is considered initial startup and does nothing.
4
+ * Each subsequent call represents a restart iteration and invokes `onRestart`.
5
+ */
6
+ export function createRestartIterationHook(onRestart) {
7
+ let isFirstIteration = true;
8
+ return () => {
9
+ if (isFirstIteration) {
10
+ isFirstIteration = false;
11
+ return false;
12
+ }
13
+ onRestart();
14
+ return true;
15
+ };
16
+ }
@@ -0,0 +1,34 @@
1
+ // Shared tool-risk constants.
2
+ // Keep these centralized so gateway HTTP restrictions, security audits, and ACP prompts don't drift.
3
+ /**
4
+ * Tools denied via Gateway HTTP `POST /tools/invoke` by default.
5
+ * These are high-risk because they enable session orchestration, control-plane actions,
6
+ * or interactive flows that don't make sense over a non-interactive HTTP surface.
7
+ */
8
+ export const DEFAULT_GATEWAY_HTTP_TOOL_DENY = [
9
+ // Session orchestration — spawning agents remotely is RCE
10
+ "sessions_spawn",
11
+ // Cross-session injection — message injection across sessions
12
+ "sessions_send",
13
+ // Gateway control plane — prevents gateway reconfiguration via HTTP
14
+ "gateway",
15
+ // Interactive setup — requires terminal QR scan, hangs on HTTP
16
+ "whatsapp_login",
17
+ ];
18
+ /**
19
+ * ACP tools that should always require explicit user approval.
20
+ * ACP is an automation surface; we never want "silent yes" for mutating/execution tools.
21
+ */
22
+ export const DANGEROUS_ACP_TOOL_NAMES = [
23
+ "exec",
24
+ "spawn",
25
+ "shell",
26
+ "sessions_spawn",
27
+ "sessions_send",
28
+ "gateway",
29
+ "fs_write",
30
+ "fs_delete",
31
+ "fs_move",
32
+ "apply_patch",
33
+ ];
34
+ export const DANGEROUS_ACP_TOOLS = new Set(DANGEROUS_ACP_TOOL_NAMES);
@@ -0,0 +1,12 @@
1
+ import { timingSafeEqual } from "node:crypto";
2
+ export function safeEqualSecret(provided, expected) {
3
+ if (typeof provided !== "string" || typeof expected !== "string") {
4
+ return false;
5
+ }
6
+ const providedBuffer = Buffer.from(provided);
7
+ const expectedBuffer = Buffer.from(expected);
8
+ if (providedBuffer.length !== expectedBuffer.length) {
9
+ return false;
10
+ }
11
+ return timingSafeEqual(providedBuffer, expectedBuffer);
12
+ }
@@ -1,4 +1,4 @@
1
- import { Input, matchesKey, SelectList, getEditorKeybindings, } from "@mariozechner/pi-tui";
1
+ import { Input, matchesKey, SelectList, getEditorKeybindings, truncateToWidth, } from "@mariozechner/pi-tui";
2
2
  import chalk from "chalk";
3
3
  import { fuzzyFilterLower, prepareSearchItems } from "./fuzzy-filter.js";
4
4
  /**
@@ -46,7 +46,10 @@ export class FilterableSelectList {
46
46
  // Select list
47
47
  const listLines = this.selectList.render(width);
48
48
  lines.push(...listLines);
49
- return lines;
49
+ // Defensive truncation: ensure no line exceeds the target width after
50
+ // all ANSI formatting. Prevents pi-tui compositeLineAt crash on
51
+ // ANSI-heavy backgrounds ("Rendered line N exceeds terminal width").
52
+ return lines.map((line) => truncateToWidth(line, width));
50
53
  }
51
54
  handleInput(keyData) {
52
55
  const allowVimNav = !this.filterText.trim();
@@ -149,7 +149,12 @@ export class SearchableSelectList {
149
149
  const scrollInfo = `${this.selectedIndex + 1}/${this.filteredItems.length}`;
150
150
  lines.push(this.theme.scrollInfo(` ${scrollInfo}`));
151
151
  }
152
- return lines;
152
+ // Defensive truncation: ensure no line exceeds the target width after
153
+ // all ANSI formatting has been applied. pi-tui's compositeLineAt can
154
+ // miscalculate visible widths on ANSI-heavy lines, causing a crash
155
+ // ("Rendered line N exceeds terminal width"). Truncating here is a
156
+ // safe final guard that prevents the crash.
157
+ return lines.map((line) => truncateToWidth(line, width));
153
158
  }
154
159
  renderItemLine(item, isSelected, width, query) {
155
160
  const prefix = isSelected ? "→ " : " ";
@@ -1,3 +1,4 @@
1
+ import { truncateToWidth } from "@mariozechner/pi-tui";
1
2
  import { formatThinkingLevels, normalizeUsageDisplay, resolveResponseUsageMode, } from "../auto-reply/thinking.js";
2
3
  import { normalizeAgentId } from "../routing/session-key.js";
3
4
  import { formatRelativeTime } from "../utils/time-format.js";
@@ -10,6 +11,10 @@ export function createCommandHandlers(context) {
10
11
  state.currentAgentId = normalizeAgentId(id);
11
12
  await setSession("");
12
13
  };
14
+ // Maximum visible width for overlay item strings. The overlay has its own
15
+ // padding/chrome (~4 chars). We subtract a generous margin so that even
16
+ // after ANSI formatting, lines stay within terminal bounds.
17
+ const overlayItemWidth = () => Math.max(30, (tui.terminal.columns ?? 80) - 6);
13
18
  const openModelSelector = async () => {
14
19
  try {
15
20
  const models = await client.listModels();
@@ -18,10 +23,11 @@ export function createCommandHandlers(context) {
18
23
  tui.requestRender();
19
24
  return;
20
25
  }
26
+ const maxW = overlayItemWidth();
21
27
  const items = models.map((model) => ({
22
28
  value: `${model.provider}/${model.id}`,
23
- label: `${model.provider}/${model.id}`,
24
- description: model.name && model.name !== model.id ? model.name : "",
29
+ label: truncateToWidth(`${model.provider}/${model.id}`, maxW),
30
+ description: model.name && model.name !== model.id ? truncateToWidth(model.name, maxW) : "",
25
31
  }));
26
32
  const selector = createSearchableSelectList(items, 9);
27
33
  selector.onSelect = (item) => {
@@ -60,9 +66,10 @@ export function createCommandHandlers(context) {
60
66
  tui.requestRender();
61
67
  return;
62
68
  }
69
+ const maxW = overlayItemWidth();
63
70
  const items = state.agents.map((agent) => ({
64
71
  value: agent.id,
65
- label: agent.name ? `${agent.id} (${agent.name})` : agent.id,
72
+ label: truncateToWidth(agent.name ? `${agent.id} (${agent.name})` : agent.id, maxW),
66
73
  description: agent.id === state.agentDefaultId ? "default" : "",
67
74
  }));
68
75
  const selector = createSearchableSelectList(items, 9);
@@ -89,6 +96,7 @@ export function createCommandHandlers(context) {
89
96
  includeLastMessage: true,
90
97
  agentId: state.currentAgentId,
91
98
  });
99
+ const maxW = overlayItemWidth();
92
100
  const items = result.sessions.map((session) => {
93
101
  const title = session.derivedTitle ?? session.displayName;
94
102
  const formattedKey = formatSessionKey(session.key);
@@ -100,8 +108,8 @@ export function createCommandHandlers(context) {
100
108
  const description = timePart && preview ? `${timePart} · ${preview}` : (preview ?? timePart);
101
109
  return {
102
110
  value: session.key,
103
- label,
104
- description,
111
+ label: truncateToWidth(label, maxW),
112
+ description: truncateToWidth(description, maxW),
105
113
  searchText: [
106
114
  session.displayName,
107
115
  session.label,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolzin/pool-bot",
3
- "version": "2026.2.6",
3
+ "version": "2026.2.8",
4
4
  "description": "🎱 Pool Bot - AI assistant with PLCODE integrations",
5
5
  "keywords": [],
6
6
  "license": "MIT",
@@ -110,10 +110,10 @@
110
110
  "@larksuiteoapi/node-sdk": "^1.58.0",
111
111
  "@line/bot-sdk": "^10.6.0",
112
112
  "@lydell/node-pty": "1.2.0-beta.3",
113
- "@mariozechner/pi-agent-core": "0.52.9",
114
- "@mariozechner/pi-ai": "0.52.9",
115
- "@mariozechner/pi-coding-agent": "0.52.9",
116
- "@mariozechner/pi-tui": "0.52.9",
113
+ "@mariozechner/pi-agent-core": "0.52.12",
114
+ "@mariozechner/pi-ai": "0.52.12",
115
+ "@mariozechner/pi-coding-agent": "0.52.12",
116
+ "@mariozechner/pi-tui": "0.52.12",
117
117
  "@mozilla/readability": "^0.6.0",
118
118
  "@sinclair/typebox": "0.34.48",
119
119
  "@slack/bolt": "^4.6.0",