@poolzin/pool-bot 2026.2.7 → 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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poolzin/pool-bot",
3
- "version": "2026.2.7",
3
+ "version": "2026.2.8",
4
4
  "description": "🎱 Pool Bot - AI assistant with PLCODE integrations",
5
5
  "keywords": [],
6
6
  "license": "MIT",