@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,6 +1,7 @@
1
1
  import { parseReplyDirectives } from "../auto-reply/reply/reply-directives.js";
2
2
  import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streaming-directives.js";
3
3
  import { formatToolAggregate } from "../auto-reply/tool-meta.js";
4
+ import { emitAgentEvent } from "../infra/agent-events.js";
4
5
  import { createSubsystemLogger } from "../logging/subsystem.js";
5
6
  import { buildCodeSpanIndex, createInlineCodeState } from "../markdown/code-spans.js";
6
7
  import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
@@ -46,7 +47,9 @@ export function subscribeEmbeddedPiSession(params) {
46
47
  compactionInFlight: false,
47
48
  pendingCompactionRetry: 0,
48
49
  compactionRetryResolve: undefined,
50
+ compactionRetryReject: undefined,
49
51
  compactionRetryPromise: null,
52
+ unsubscribed: false,
50
53
  messagingToolSentTexts: [],
51
54
  messagingToolSentTextsNormalized: [],
52
55
  messagingToolSentTargets: [],
@@ -168,8 +171,12 @@ export function subscribeEmbeddedPiSession(params) {
168
171
  };
169
172
  const ensureCompactionPromise = () => {
170
173
  if (!state.compactionRetryPromise) {
171
- state.compactionRetryPromise = new Promise((resolve) => {
174
+ state.compactionRetryPromise = new Promise((resolve, reject) => {
172
175
  state.compactionRetryResolve = resolve;
176
+ state.compactionRetryReject = reject;
177
+ });
178
+ state.compactionRetryPromise.catch((err) => {
179
+ log.debug(`compaction promise rejected (no waiter): ${String(err)}`);
173
180
  });
174
181
  }
175
182
  };
@@ -185,6 +192,7 @@ export function subscribeEmbeddedPiSession(params) {
185
192
  if (state.pendingCompactionRetry === 0 && !state.compactionInFlight) {
186
193
  state.compactionRetryResolve?.();
187
194
  state.compactionRetryResolve = undefined;
195
+ state.compactionRetryReject = undefined;
188
196
  state.compactionRetryPromise = null;
189
197
  }
190
198
  };
@@ -192,6 +200,7 @@ export function subscribeEmbeddedPiSession(params) {
192
200
  if (state.pendingCompactionRetry === 0 && !state.compactionInFlight) {
193
201
  state.compactionRetryResolve?.();
194
202
  state.compactionRetryResolve = undefined;
203
+ state.compactionRetryReject = undefined;
195
204
  state.compactionRetryPromise = null;
196
205
  }
197
206
  };
@@ -460,7 +469,17 @@ export function subscribeEmbeddedPiSession(params) {
460
469
  if (formatted === state.lastStreamedReasoning) {
461
470
  return;
462
471
  }
472
+ const prior = state.lastStreamedReasoning ?? "";
473
+ const delta = formatted.startsWith(prior) ? formatted.slice(prior.length) : formatted;
463
474
  state.lastStreamedReasoning = formatted;
475
+ emitAgentEvent({
476
+ runId: params.runId,
477
+ stream: "thinking",
478
+ data: {
479
+ text: formatted,
480
+ delta,
481
+ },
482
+ });
464
483
  void params.onReasoningStream({
465
484
  text: formatted,
466
485
  });
@@ -478,12 +497,19 @@ export function subscribeEmbeddedPiSession(params) {
478
497
  pendingMessagingTargets.clear();
479
498
  resetAssistantMessageState(0);
480
499
  };
500
+ const noteLastAssistant = (msg) => {
501
+ if (msg?.role === "assistant") {
502
+ state.lastAssistant = msg;
503
+ }
504
+ };
481
505
  const ctx = {
482
506
  params,
483
507
  state,
484
508
  log,
485
509
  blockChunking,
486
510
  blockChunker,
511
+ hookRunner: params.hookRunner,
512
+ noteLastAssistant,
487
513
  shouldEmitToolResult,
488
514
  shouldEmitToolOutput,
489
515
  emitToolSummary,
@@ -507,12 +533,30 @@ export function subscribeEmbeddedPiSession(params) {
507
533
  getUsageTotals,
508
534
  getCompactionCount: () => compactionCount,
509
535
  };
510
- const unsubscribe = params.session.subscribe(createEmbeddedPiSessionEventHandler(ctx));
536
+ const sessionUnsubscribe = params.session.subscribe(createEmbeddedPiSessionEventHandler(ctx));
537
+ const unsubscribe = () => {
538
+ if (state.unsubscribed) {
539
+ return;
540
+ }
541
+ state.unsubscribed = true;
542
+ if (state.compactionRetryPromise) {
543
+ log.debug(`unsubscribe: rejecting compaction wait runId=${params.runId}`);
544
+ const reject = state.compactionRetryReject;
545
+ state.compactionRetryResolve = undefined;
546
+ state.compactionRetryReject = undefined;
547
+ state.compactionRetryPromise = null;
548
+ const abortErr = new Error("Unsubscribed during compaction");
549
+ abortErr.name = "AbortError";
550
+ reject?.(abortErr);
551
+ }
552
+ sessionUnsubscribe();
553
+ };
511
554
  return {
512
555
  assistantTexts,
513
556
  toolMetas,
514
557
  unsubscribe,
515
558
  isCompacting: () => state.compactionInFlight || state.pendingCompactionRetry > 0,
559
+ isCompactionInFlight: () => state.compactionInFlight,
516
560
  getMessagingToolSentTexts: () => messagingToolSentTexts.slice(),
517
561
  getMessagingToolSentTargets: () => messagingToolSentTargets.slice(),
518
562
  // Returns true if any messaging tool successfully sent a message.
@@ -523,15 +567,26 @@ export function subscribeEmbeddedPiSession(params) {
523
567
  getUsageTotals,
524
568
  getCompactionCount: () => compactionCount,
525
569
  waitForCompactionRetry: () => {
570
+ if (state.unsubscribed) {
571
+ const err = new Error("Unsubscribed during compaction wait");
572
+ err.name = "AbortError";
573
+ return Promise.reject(err);
574
+ }
526
575
  if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
527
576
  ensureCompactionPromise();
528
577
  return state.compactionRetryPromise ?? Promise.resolve();
529
578
  }
530
- return new Promise((resolve) => {
579
+ return new Promise((resolve, reject) => {
531
580
  queueMicrotask(() => {
581
+ if (state.unsubscribed) {
582
+ const err = new Error("Unsubscribed during compaction wait");
583
+ err.name = "AbortError";
584
+ reject(err);
585
+ return;
586
+ }
532
587
  if (state.compactionInFlight || state.pendingCompactionRetry > 0) {
533
588
  ensureCompactionPromise();
534
- void (state.compactionRetryPromise ?? Promise.resolve()).then(resolve);
589
+ void (state.compactionRetryPromise ?? Promise.resolve()).then(resolve, reject);
535
590
  }
536
591
  else {
537
592
  resolve();
@@ -0,0 +1,164 @@
1
+ const MUTATING_TOOL_NAMES = new Set([
2
+ "write",
3
+ "edit",
4
+ "apply_patch",
5
+ "exec",
6
+ "bash",
7
+ "process",
8
+ "message",
9
+ "sessions_send",
10
+ "cron",
11
+ "gateway",
12
+ "canvas",
13
+ "nodes",
14
+ "session_status",
15
+ ]);
16
+ const READ_ONLY_ACTIONS = new Set([
17
+ "get",
18
+ "list",
19
+ "read",
20
+ "status",
21
+ "show",
22
+ "fetch",
23
+ "search",
24
+ "query",
25
+ "view",
26
+ "poll",
27
+ "log",
28
+ "inspect",
29
+ "check",
30
+ "probe",
31
+ ]);
32
+ const PROCESS_MUTATING_ACTIONS = new Set(["write", "send_keys", "submit", "paste", "kill"]);
33
+ const MESSAGE_MUTATING_ACTIONS = new Set([
34
+ "send",
35
+ "reply",
36
+ "thread_reply",
37
+ "threadreply",
38
+ "edit",
39
+ "delete",
40
+ "react",
41
+ "pin",
42
+ "unpin",
43
+ ]);
44
+ function asRecord(value) {
45
+ return value && typeof value === "object" ? value : undefined;
46
+ }
47
+ function normalizeActionName(value) {
48
+ if (typeof value !== "string") {
49
+ return undefined;
50
+ }
51
+ const normalized = value
52
+ .trim()
53
+ .toLowerCase()
54
+ .replace(/[\s-]+/g, "_");
55
+ return normalized || undefined;
56
+ }
57
+ function normalizeFingerprintValue(value) {
58
+ if (typeof value === "string") {
59
+ const normalized = value.trim();
60
+ return normalized ? normalized.toLowerCase() : undefined;
61
+ }
62
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") {
63
+ return String(value).toLowerCase();
64
+ }
65
+ return undefined;
66
+ }
67
+ export function isLikelyMutatingToolName(toolName) {
68
+ const normalized = toolName.trim().toLowerCase();
69
+ if (!normalized) {
70
+ return false;
71
+ }
72
+ return (MUTATING_TOOL_NAMES.has(normalized) ||
73
+ normalized.endsWith("_actions") ||
74
+ normalized.startsWith("message_") ||
75
+ normalized.includes("send"));
76
+ }
77
+ export function isMutatingToolCall(toolName, args) {
78
+ const normalized = toolName.trim().toLowerCase();
79
+ const record = asRecord(args);
80
+ const action = normalizeActionName(record?.action);
81
+ switch (normalized) {
82
+ case "write":
83
+ case "edit":
84
+ case "apply_patch":
85
+ case "exec":
86
+ case "bash":
87
+ case "sessions_send":
88
+ return true;
89
+ case "process":
90
+ return action != null && PROCESS_MUTATING_ACTIONS.has(action);
91
+ case "message":
92
+ return ((action != null && MESSAGE_MUTATING_ACTIONS.has(action)) ||
93
+ typeof record?.content === "string" ||
94
+ typeof record?.message === "string");
95
+ case "session_status":
96
+ return typeof record?.model === "string" && record.model.trim().length > 0;
97
+ default: {
98
+ if (normalized === "cron" || normalized === "gateway" || normalized === "canvas") {
99
+ return action == null || !READ_ONLY_ACTIONS.has(action);
100
+ }
101
+ if (normalized === "nodes") {
102
+ return action == null || action !== "list";
103
+ }
104
+ if (normalized.endsWith("_actions")) {
105
+ return action == null || !READ_ONLY_ACTIONS.has(action);
106
+ }
107
+ if (normalized.startsWith("message_") || normalized.includes("send")) {
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+ }
113
+ }
114
+ export function buildToolActionFingerprint(toolName, args, meta) {
115
+ if (!isMutatingToolCall(toolName, args)) {
116
+ return undefined;
117
+ }
118
+ const normalizedTool = toolName.trim().toLowerCase();
119
+ const record = asRecord(args);
120
+ const action = normalizeActionName(record?.action);
121
+ const parts = [`tool=${normalizedTool}`];
122
+ if (action) {
123
+ parts.push(`action=${action}`);
124
+ }
125
+ for (const key of [
126
+ "path",
127
+ "filePath",
128
+ "oldPath",
129
+ "newPath",
130
+ "to",
131
+ "target",
132
+ "messageId",
133
+ "sessionKey",
134
+ "jobId",
135
+ "id",
136
+ "model",
137
+ ]) {
138
+ const value = normalizeFingerprintValue(record?.[key]);
139
+ if (value) {
140
+ parts.push(`${key.toLowerCase()}=${value}`);
141
+ }
142
+ }
143
+ const normalizedMeta = meta?.trim().replace(/\s+/g, " ").toLowerCase();
144
+ if (normalizedMeta) {
145
+ parts.push(`meta=${normalizedMeta}`);
146
+ }
147
+ return parts.join("|");
148
+ }
149
+ export function buildToolMutationState(toolName, args, meta) {
150
+ const actionFingerprint = buildToolActionFingerprint(toolName, args, meta);
151
+ return {
152
+ mutatingAction: actionFingerprint != null,
153
+ actionFingerprint,
154
+ };
155
+ }
156
+ export function isSameToolMutationAction(existing, next) {
157
+ if (existing.actionFingerprint != null || next.actionFingerprint != null) {
158
+ // For mutating flows, fail closed: only clear when both fingerprints exist and match.
159
+ return (existing.actionFingerprint != null &&
160
+ next.actionFingerprint != null &&
161
+ existing.actionFingerprint === next.actionFingerprint);
162
+ }
163
+ return existing.toolName === next.toolName && (existing.meta ?? "") === (next.meta ?? "");
164
+ }
@@ -0,0 +1,69 @@
1
+ import { filterToolsByPolicy } from "./pi-tools.policy.js";
2
+ import { buildPluginToolGroups, expandPolicyWithPluginGroups, normalizeToolName, stripPluginOnlyAllowlist, } from "./tool-policy.js";
3
+ export function buildDefaultToolPolicyPipelineSteps(params) {
4
+ const agentId = params.agentId?.trim();
5
+ const profile = params.profile?.trim();
6
+ const providerProfile = params.providerProfile?.trim();
7
+ return [
8
+ {
9
+ policy: params.profilePolicy,
10
+ label: profile ? `tools.profile (${profile})` : "tools.profile",
11
+ stripPluginOnlyAllowlist: true,
12
+ },
13
+ {
14
+ policy: params.providerProfilePolicy,
15
+ label: providerProfile
16
+ ? `tools.byProvider.profile (${providerProfile})`
17
+ : "tools.byProvider.profile",
18
+ stripPluginOnlyAllowlist: true,
19
+ },
20
+ { policy: params.globalPolicy, label: "tools.allow", stripPluginOnlyAllowlist: true },
21
+ {
22
+ policy: params.globalProviderPolicy,
23
+ label: "tools.byProvider.allow",
24
+ stripPluginOnlyAllowlist: true,
25
+ },
26
+ {
27
+ policy: params.agentPolicy,
28
+ label: agentId ? `agents.${agentId}.tools.allow` : "agent tools.allow",
29
+ stripPluginOnlyAllowlist: true,
30
+ },
31
+ {
32
+ policy: params.agentProviderPolicy,
33
+ label: agentId ? `agents.${agentId}.tools.byProvider.allow` : "agent tools.byProvider.allow",
34
+ stripPluginOnlyAllowlist: true,
35
+ },
36
+ { policy: params.groupPolicy, label: "group tools.allow", stripPluginOnlyAllowlist: true },
37
+ ];
38
+ }
39
+ export function applyToolPolicyPipeline(params) {
40
+ const coreToolNames = new Set(params.tools
41
+ .filter((tool) => !params.toolMeta(tool))
42
+ .map((tool) => normalizeToolName(tool.name))
43
+ .filter(Boolean));
44
+ const pluginGroups = buildPluginToolGroups({
45
+ tools: params.tools,
46
+ toolMeta: params.toolMeta,
47
+ });
48
+ let filtered = params.tools;
49
+ for (const step of params.steps) {
50
+ if (!step.policy) {
51
+ continue;
52
+ }
53
+ let policy = step.policy;
54
+ if (step.stripPluginOnlyAllowlist) {
55
+ const resolved = stripPluginOnlyAllowlist(policy, pluginGroups, coreToolNames);
56
+ if (resolved.unknownAllowlist.length > 0) {
57
+ const entries = resolved.unknownAllowlist.join(", ");
58
+ const suffix = resolved.strippedAllowlist
59
+ ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
60
+ : "These entries won't match any tool unless the plugin is enabled.";
61
+ params.warn(`tools: ${step.label} allowlist contains unknown entries (${entries}). ${suffix}`);
62
+ }
63
+ policy = resolved.policy;
64
+ }
65
+ const expanded = expandPolicyWithPluginGroups(policy, pluginGroups);
66
+ filtered = expanded ? filterToolsByPolicy(filtered, expanded) : filtered;
67
+ }
68
+ return filtered;
69
+ }
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "2026.2.7",
3
- "commit": "60f0895a4604c140b6e6d000fc7141252efe40e9",
4
- "builtAt": "2026-02-15T06:21:58.829Z"
2
+ "version": "2026.2.8",
3
+ "commit": "4f7a76b8949932fd105f49e6b89efd93bc69d3cc",
4
+ "builtAt": "2026-02-15T16:46:23.867Z"
5
5
  }
@@ -1,6 +1,8 @@
1
1
  import { acquireGatewayLock } from "../../infra/gateway-lock.js";
2
2
  import { consumeGatewaySigusr1RestartAuthorization, isGatewaySigusr1RestartExternallyAllowed, } from "../../infra/restart.js";
3
3
  import { createSubsystemLogger } from "../../logging/subsystem.js";
4
+ import { getActiveTaskCount, resetAllLanes, waitForActiveTasks, } from "../../process/command-queue.js";
5
+ import { createRestartIterationHook } from "../../process/restart-recovery.js";
4
6
  const gatewayLog = createSubsystemLogger("gateway");
5
7
  export async function runGatewayLoop(params) {
6
8
  const lock = await acquireGatewayLock();
@@ -12,6 +14,8 @@ export async function runGatewayLoop(params) {
12
14
  process.removeListener("SIGINT", onSigint);
13
15
  process.removeListener("SIGUSR1", onSigusr1);
14
16
  };
17
+ const DRAIN_TIMEOUT_MS = 30_000;
18
+ const SHUTDOWN_TIMEOUT_MS = 5_000;
15
19
  const request = (action, signal) => {
16
20
  if (shuttingDown) {
17
21
  gatewayLog.info(`received ${signal} during shutdown; ignoring`);
@@ -20,13 +24,30 @@ export async function runGatewayLoop(params) {
20
24
  shuttingDown = true;
21
25
  const isRestart = action === "restart";
22
26
  gatewayLog.info(`received ${signal}; ${isRestart ? "restarting" : "shutting down"}`);
27
+ // Allow extra time for draining active turns on restart.
28
+ const forceExitMs = isRestart ? DRAIN_TIMEOUT_MS + SHUTDOWN_TIMEOUT_MS : SHUTDOWN_TIMEOUT_MS;
23
29
  const forceExitTimer = setTimeout(() => {
24
30
  gatewayLog.error("shutdown timed out; exiting without full cleanup");
25
31
  cleanupSignals();
26
32
  params.runtime.exit(0);
27
- }, 5000);
33
+ }, forceExitMs);
28
34
  void (async () => {
29
35
  try {
36
+ // On restart, wait for in-flight agent turns to finish before
37
+ // tearing down the server so buffered messages are delivered.
38
+ if (isRestart) {
39
+ const activeTasks = getActiveTaskCount();
40
+ if (activeTasks > 0) {
41
+ gatewayLog.info(`draining ${activeTasks} active task(s) before restart (timeout ${DRAIN_TIMEOUT_MS}ms)`);
42
+ const { drained } = await waitForActiveTasks(DRAIN_TIMEOUT_MS);
43
+ if (drained) {
44
+ gatewayLog.info("all active tasks drained");
45
+ }
46
+ else {
47
+ gatewayLog.warn("drain timeout reached; proceeding with restart");
48
+ }
49
+ }
50
+ }
30
51
  await server?.close({
31
52
  reason: isRestart ? "gateway restarting" : "gateway stopping",
32
53
  restartExpectedMs: isRestart ? 1500 : null,
@@ -39,6 +60,7 @@ export async function runGatewayLoop(params) {
39
60
  clearTimeout(forceExitTimer);
40
61
  server = null;
41
62
  if (isRestart) {
63
+ // In-process restart (no process-respawn in pool-bot).
42
64
  shuttingDown = false;
43
65
  restartResolver?.();
44
66
  }
@@ -70,10 +92,20 @@ export async function runGatewayLoop(params) {
70
92
  process.on("SIGINT", onSigint);
71
93
  process.on("SIGUSR1", onSigusr1);
72
94
  try {
95
+ const onIteration = createRestartIterationHook(() => {
96
+ // After an in-process restart (SIGUSR1), reset command-queue lane state.
97
+ // Interrupted tasks from the previous lifecycle may have left `active`
98
+ // counts elevated (their finally blocks never ran), permanently blocking
99
+ // new work from draining. This must happen here — at the restart
100
+ // coordinator level — rather than inside individual subsystem init
101
+ // functions, to avoid surprising cross-cutting side effects.
102
+ resetAllLanes();
103
+ });
73
104
  // Keep process alive; SIGUSR1 triggers an in-process restart (no supervisor required).
74
105
  // SIGTERM/SIGINT still exit after a graceful shutdown.
75
106
  // eslint-disable-next-line no-constant-condition
76
107
  while (true) {
108
+ onIteration();
77
109
  server = await params.start();
78
110
  await new Promise((resolve) => {
79
111
  restartResolver = resolve;
@@ -1,11 +1,6 @@
1
- import { timingSafeEqual } from "node:crypto";
1
+ import { safeEqualSecret } from "../security/secret-equal.js";
2
2
  import { readTailscaleWhoisIdentity } from "../infra/tailscale.js";
3
3
  import { isTrustedProxyAddress, parseForwardedForClientIp, resolveGatewayClientIp } from "./net.js";
4
- function safeEqual(a, b) {
5
- if (a.length !== b.length)
6
- return false;
7
- return timingSafeEqual(Buffer.from(a), Buffer.from(b));
8
- }
9
4
  function normalizeLogin(login) {
10
5
  return login.trim().toLowerCase();
11
6
  }
@@ -175,7 +170,7 @@ export async function authorizeGatewayConnect(params) {
175
170
  if (!connectAuth?.token) {
176
171
  return { ok: false, reason: "token_missing" };
177
172
  }
178
- if (!safeEqual(connectAuth.token, auth.token)) {
173
+ if (!safeEqualSecret(connectAuth.token, auth.token)) {
179
174
  return { ok: false, reason: "token_mismatch" };
180
175
  }
181
176
  return { ok: true, method: "token" };
@@ -188,7 +183,7 @@ export async function authorizeGatewayConnect(params) {
188
183
  if (!password) {
189
184
  return { ok: false, reason: "password_missing" };
190
185
  }
191
- if (!safeEqual(password, auth.password)) {
186
+ if (!safeEqualSecret(password, auth.password)) {
192
187
  return { ok: false, reason: "password_mismatch" };
193
188
  }
194
189
  return { ok: true, method: "password" };
@@ -477,10 +477,18 @@ async function resolveActiveModelEntry(params) {
477
477
  catch {
478
478
  return null;
479
479
  }
480
+ // Use the default vision/video model for the provider rather than the
481
+ // user's chat model — the chat model (e.g. glm-4.7) may not support
482
+ // vision, while the provider has a dedicated model (e.g. glm-4.6v).
483
+ const model = params.capability === "image"
484
+ ? (DEFAULT_IMAGE_MODELS[providerId] ?? params.activeModel?.model)
485
+ : params.capability === "audio"
486
+ ? (DEFAULT_AUDIO_MODELS[providerId] ?? params.activeModel?.model)
487
+ : params.activeModel?.model;
480
488
  return {
481
489
  type: "provider",
482
490
  provider: providerId,
483
- model: params.activeModel?.model,
491
+ model,
484
492
  };
485
493
  }
486
494
  function trimOutput(text, maxChars) {