@poolzin/pool-bot 2026.2.10 → 2026.2.17

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 (71) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/agents/auth-profiles/usage.js +22 -0
  3. package/dist/agents/auth-profiles.js +1 -1
  4. package/dist/agents/bash-tools.exec.js +4 -6
  5. package/dist/agents/glob-pattern.js +42 -0
  6. package/dist/agents/memory-search.js +33 -0
  7. package/dist/agents/model-fallback.js +59 -8
  8. package/dist/agents/pi-tools.before-tool-call.js +145 -4
  9. package/dist/agents/pi-tools.js +27 -9
  10. package/dist/agents/pi-tools.policy.js +85 -92
  11. package/dist/agents/pi-tools.schema.js +54 -27
  12. package/dist/agents/sandbox/validate-sandbox-security.js +157 -0
  13. package/dist/agents/sandbox-tool-policy.js +26 -0
  14. package/dist/agents/sanitize-for-prompt.js +18 -0
  15. package/dist/agents/session-write-lock.js +203 -39
  16. package/dist/agents/system-prompt.js +52 -10
  17. package/dist/agents/tool-loop-detection.js +466 -0
  18. package/dist/agents/tool-policy.js +6 -0
  19. package/dist/auto-reply/reply/post-compaction-audit.js +96 -0
  20. package/dist/auto-reply/reply/post-compaction-context.js +98 -0
  21. package/dist/build-info.json +3 -3
  22. package/dist/config/zod-schema.agent-defaults.js +14 -0
  23. package/dist/config/zod-schema.agent-runtime.js +14 -0
  24. package/dist/infra/path-safety.js +16 -0
  25. package/dist/logging/diagnostic-session-state.js +73 -0
  26. package/dist/logging/diagnostic.js +22 -0
  27. package/dist/memory/embeddings.js +36 -9
  28. package/dist/memory/hybrid.js +24 -5
  29. package/dist/memory/manager.js +76 -28
  30. package/dist/memory/mmr.js +164 -0
  31. package/dist/memory/query-expansion.js +331 -0
  32. package/dist/memory/temporal-decay.js +119 -0
  33. package/dist/process/kill-tree.js +98 -0
  34. package/dist/shared/pid-alive.js +12 -0
  35. package/dist/shared/process-scoped-map.js +10 -0
  36. package/extensions/bluebubbles/package.json +1 -1
  37. package/extensions/copilot-proxy/package.json +1 -1
  38. package/extensions/diagnostics-otel/package.json +1 -1
  39. package/extensions/discord/package.json +1 -1
  40. package/extensions/google-antigravity-auth/package.json +1 -1
  41. package/extensions/google-gemini-cli-auth/package.json +1 -1
  42. package/extensions/googlechat/package.json +1 -1
  43. package/extensions/imessage/package.json +1 -1
  44. package/extensions/line/package.json +1 -1
  45. package/extensions/llm-task/package.json +1 -1
  46. package/extensions/lobster/package.json +1 -1
  47. package/extensions/matrix/CHANGELOG.md +5 -0
  48. package/extensions/matrix/package.json +1 -1
  49. package/extensions/mattermost/package.json +1 -1
  50. package/extensions/memory-core/package.json +1 -1
  51. package/extensions/memory-lancedb/package.json +1 -1
  52. package/extensions/msteams/CHANGELOG.md +5 -0
  53. package/extensions/msteams/package.json +1 -1
  54. package/extensions/nextcloud-talk/package.json +1 -1
  55. package/extensions/nostr/CHANGELOG.md +5 -0
  56. package/extensions/nostr/package.json +1 -1
  57. package/extensions/open-prose/package.json +1 -1
  58. package/extensions/signal/package.json +1 -1
  59. package/extensions/slack/package.json +1 -1
  60. package/extensions/telegram/package.json +1 -1
  61. package/extensions/tlon/package.json +1 -1
  62. package/extensions/twitch/CHANGELOG.md +5 -0
  63. package/extensions/twitch/package.json +1 -1
  64. package/extensions/voice-call/CHANGELOG.md +5 -0
  65. package/extensions/voice-call/package.json +1 -1
  66. package/extensions/whatsapp/package.json +1 -1
  67. package/extensions/zalo/CHANGELOG.md +5 -0
  68. package/extensions/zalo/package.json +1 -1
  69. package/extensions/zalouser/CHANGELOG.md +5 -0
  70. package/extensions/zalouser/package.json +1 -1
  71. package/package.json +1 -1
@@ -1,19 +1,89 @@
1
1
  import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
- const HELD_LOCKS = new Map();
4
+ import { isPidAlive } from "../shared/pid-alive.js";
5
+ import { resolveProcessScopedMap } from "../shared/process-scoped-map.js";
6
+ const HELD_LOCKS_KEY = Symbol.for("poolbot.sessionWriteLockHeldLocks");
7
+ const WATCHDOG_STATE_KEY = Symbol.for("poolbot.sessionWriteLockWatchdogState");
5
8
  const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"];
9
+ const DEFAULT_STALE_MS = 30 * 60 * 1000;
10
+ const DEFAULT_MAX_HOLD_MS = 5 * 60 * 1000;
11
+ const DEFAULT_WATCHDOG_INTERVAL_MS = 60_000;
12
+ const DEFAULT_TIMEOUT_GRACE_MS = 2 * 60 * 1000;
13
+ const MAX_LOCK_HOLD_MS = 2_147_000_000;
6
14
  const cleanupHandlers = new Map();
7
- function isAlive(pid) {
8
- if (!Number.isFinite(pid) || pid <= 0)
15
+ const HELD_LOCKS = resolveProcessScopedMap(HELD_LOCKS_KEY);
16
+ function resolveWatchdogState() {
17
+ const proc = process;
18
+ if (!proc[WATCHDOG_STATE_KEY]) {
19
+ proc[WATCHDOG_STATE_KEY] = {
20
+ started: false,
21
+ intervalMs: DEFAULT_WATCHDOG_INTERVAL_MS,
22
+ };
23
+ }
24
+ return proc[WATCHDOG_STATE_KEY];
25
+ }
26
+ function resolvePositiveMs(value, fallback, opts = {}) {
27
+ if (typeof value !== "number" || Number.isNaN(value) || value <= 0) {
28
+ return fallback;
29
+ }
30
+ if (value === Number.POSITIVE_INFINITY) {
31
+ return opts.allowInfinity ? value : fallback;
32
+ }
33
+ if (!Number.isFinite(value)) {
34
+ return fallback;
35
+ }
36
+ return value;
37
+ }
38
+ export function resolveSessionLockMaxHoldFromTimeout(params) {
39
+ const minMs = resolvePositiveMs(params.minMs, DEFAULT_MAX_HOLD_MS);
40
+ const timeoutMs = resolvePositiveMs(params.timeoutMs, minMs, { allowInfinity: true });
41
+ if (timeoutMs === Number.POSITIVE_INFINITY) {
42
+ return MAX_LOCK_HOLD_MS;
43
+ }
44
+ const graceMs = resolvePositiveMs(params.graceMs, DEFAULT_TIMEOUT_GRACE_MS);
45
+ return Math.min(MAX_LOCK_HOLD_MS, Math.max(minMs, timeoutMs + graceMs));
46
+ }
47
+ async function releaseHeldLock(normalizedSessionFile, held, opts = {}) {
48
+ const current = HELD_LOCKS.get(normalizedSessionFile);
49
+ if (current !== held) {
9
50
  return false;
10
- try {
11
- process.kill(pid, 0);
51
+ }
52
+ if (opts.force) {
53
+ held.count = 0;
54
+ }
55
+ else {
56
+ held.count -= 1;
57
+ if (held.count > 0) {
58
+ return false;
59
+ }
60
+ }
61
+ if (held.releasePromise) {
62
+ await held.releasePromise.catch(() => undefined);
12
63
  return true;
13
64
  }
14
- catch {
15
- return false;
65
+ HELD_LOCKS.delete(normalizedSessionFile);
66
+ held.releasePromise = (async () => {
67
+ try {
68
+ await held.handle.close();
69
+ }
70
+ catch {
71
+ // Ignore errors during cleanup - best effort.
72
+ }
73
+ try {
74
+ await fs.rm(held.lockPath, { force: true });
75
+ }
76
+ catch {
77
+ // Ignore errors during cleanup - best effort.
78
+ }
79
+ })();
80
+ try {
81
+ await held.releasePromise;
16
82
  }
83
+ finally {
84
+ held.releasePromise = undefined;
85
+ }
86
+ return true;
17
87
  }
18
88
  /**
19
89
  * Synchronously release all held locks.
@@ -74,24 +144,127 @@ function registerCleanupHandlers() {
74
144
  }
75
145
  }
76
146
  }
147
+ async function runLockWatchdogCheck(nowMs = Date.now()) {
148
+ let released = 0;
149
+ for (const [sessionFile, held] of HELD_LOCKS.entries()) {
150
+ const heldForMs = nowMs - held.acquiredAt;
151
+ if (heldForMs <= held.maxHoldMs) {
152
+ continue;
153
+ }
154
+ // eslint-disable-next-line no-console
155
+ console.warn(`[session-write-lock] releasing lock held for ${heldForMs}ms (max=${held.maxHoldMs}ms): ${held.lockPath}`);
156
+ const didRelease = await releaseHeldLock(sessionFile, held, { force: true });
157
+ if (didRelease) {
158
+ released += 1;
159
+ }
160
+ }
161
+ return released;
162
+ }
163
+ function ensureWatchdogStarted(intervalMs) {
164
+ const watchdogState = resolveWatchdogState();
165
+ if (watchdogState.started) {
166
+ return;
167
+ }
168
+ watchdogState.started = true;
169
+ watchdogState.intervalMs = intervalMs;
170
+ watchdogState.timer = setInterval(() => {
171
+ void runLockWatchdogCheck().catch(() => {
172
+ // Ignore watchdog errors - best effort cleanup only.
173
+ });
174
+ }, intervalMs);
175
+ watchdogState.timer.unref?.();
176
+ }
77
177
  async function readLockPayload(lockPath) {
78
178
  try {
79
179
  const raw = await fs.readFile(lockPath, "utf8");
80
180
  const parsed = JSON.parse(raw);
81
- if (typeof parsed.pid !== "number")
82
- return null;
83
- if (typeof parsed.createdAt !== "string")
84
- return null;
85
- return { pid: parsed.pid, createdAt: parsed.createdAt };
181
+ const payload = {};
182
+ if (typeof parsed.pid === "number") {
183
+ payload.pid = parsed.pid;
184
+ }
185
+ if (typeof parsed.createdAt === "string") {
186
+ payload.createdAt = parsed.createdAt;
187
+ }
188
+ return payload;
86
189
  }
87
190
  catch {
88
191
  return null;
89
192
  }
90
193
  }
194
+ function inspectLockPayload(payload, staleMs, nowMs) {
195
+ const pid = typeof payload?.pid === "number" ? payload.pid : null;
196
+ const pidAlive = pid !== null ? isPidAlive(pid) : false;
197
+ const createdAt = typeof payload?.createdAt === "string" ? payload.createdAt : null;
198
+ const createdAtMs = createdAt ? Date.parse(createdAt) : Number.NaN;
199
+ const ageMs = Number.isFinite(createdAtMs) ? Math.max(0, nowMs - createdAtMs) : null;
200
+ const staleReasons = [];
201
+ if (pid === null) {
202
+ staleReasons.push("missing-pid");
203
+ }
204
+ else if (!pidAlive) {
205
+ staleReasons.push("dead-pid");
206
+ }
207
+ if (ageMs === null) {
208
+ staleReasons.push("invalid-createdAt");
209
+ }
210
+ else if (ageMs > staleMs) {
211
+ staleReasons.push("too-old");
212
+ }
213
+ return {
214
+ pid,
215
+ pidAlive,
216
+ createdAt,
217
+ ageMs,
218
+ stale: staleReasons.length > 0,
219
+ staleReasons,
220
+ };
221
+ }
222
+ export async function cleanStaleLockFiles(params) {
223
+ const sessionsDir = path.resolve(params.sessionsDir);
224
+ const staleMs = resolvePositiveMs(params.staleMs, DEFAULT_STALE_MS);
225
+ const removeStale = params.removeStale !== false;
226
+ const nowMs = params.nowMs ?? Date.now();
227
+ let entries = [];
228
+ try {
229
+ entries = await fs.readdir(sessionsDir, { withFileTypes: true });
230
+ }
231
+ catch (err) {
232
+ const code = err.code;
233
+ if (code === "ENOENT") {
234
+ return { locks: [], cleaned: [] };
235
+ }
236
+ throw err;
237
+ }
238
+ const locks = [];
239
+ const cleaned = [];
240
+ const lockEntries = entries
241
+ .filter((entry) => entry.name.endsWith(".jsonl.lock"))
242
+ .toSorted((a, b) => a.name.localeCompare(b.name));
243
+ for (const entry of lockEntries) {
244
+ const lockPath = path.join(sessionsDir, entry.name);
245
+ const payload = await readLockPayload(lockPath);
246
+ const inspected = inspectLockPayload(payload, staleMs, nowMs);
247
+ const lockInfo = {
248
+ lockPath,
249
+ ...inspected,
250
+ removed: false,
251
+ };
252
+ if (lockInfo.stale && removeStale) {
253
+ await fs.rm(lockPath, { force: true });
254
+ lockInfo.removed = true;
255
+ cleaned.push(lockInfo);
256
+ params.log?.warn?.(`removed stale session lock: ${lockPath} (${lockInfo.staleReasons.join(", ") || "unknown"})`);
257
+ }
258
+ locks.push(lockInfo);
259
+ }
260
+ return { locks, cleaned };
261
+ }
91
262
  export async function acquireSessionWriteLock(params) {
92
263
  registerCleanupHandlers();
93
- const timeoutMs = params.timeoutMs ?? 10_000;
94
- const staleMs = params.staleMs ?? 30 * 60 * 1000;
264
+ ensureWatchdogStarted(DEFAULT_WATCHDOG_INTERVAL_MS);
265
+ const timeoutMs = resolvePositiveMs(params.timeoutMs, 10_000, { allowInfinity: true });
266
+ const staleMs = resolvePositiveMs(params.staleMs, DEFAULT_STALE_MS);
267
+ const maxHoldMs = resolvePositiveMs(params.maxHoldMs, DEFAULT_MAX_HOLD_MS);
95
268
  const sessionFile = path.resolve(params.sessionFile);
96
269
  const sessionDir = path.dirname(sessionFile);
97
270
  await fs.mkdir(sessionDir, { recursive: true });
@@ -109,15 +282,7 @@ export async function acquireSessionWriteLock(params) {
109
282
  held.count += 1;
110
283
  return {
111
284
  release: async () => {
112
- const current = HELD_LOCKS.get(normalizedSessionFile);
113
- if (!current)
114
- return;
115
- current.count -= 1;
116
- if (current.count > 0)
117
- return;
118
- HELD_LOCKS.delete(normalizedSessionFile);
119
- await current.handle.close();
120
- await fs.rm(current.lockPath, { force: true });
285
+ await releaseHeldLock(normalizedSessionFile, held);
121
286
  },
122
287
  };
123
288
  }
@@ -127,19 +292,19 @@ export async function acquireSessionWriteLock(params) {
127
292
  attempt += 1;
128
293
  try {
129
294
  const handle = await fs.open(lockPath, "wx");
130
- await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), "utf8");
131
- HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
295
+ const createdAt = new Date().toISOString();
296
+ await handle.writeFile(JSON.stringify({ pid: process.pid, createdAt }, null, 2), "utf8");
297
+ const createdHeld = {
298
+ count: 1,
299
+ handle,
300
+ lockPath,
301
+ acquiredAt: Date.now(),
302
+ maxHoldMs,
303
+ };
304
+ HELD_LOCKS.set(normalizedSessionFile, createdHeld);
132
305
  return {
133
306
  release: async () => {
134
- const current = HELD_LOCKS.get(normalizedSessionFile);
135
- if (!current)
136
- return;
137
- current.count -= 1;
138
- if (current.count > 0)
139
- return;
140
- HELD_LOCKS.delete(normalizedSessionFile);
141
- await current.handle.close();
142
- await fs.rm(current.lockPath, { force: true });
307
+ await releaseHeldLock(normalizedSessionFile, createdHeld);
143
308
  },
144
309
  };
145
310
  }
@@ -148,10 +313,8 @@ export async function acquireSessionWriteLock(params) {
148
313
  if (code !== "EEXIST")
149
314
  throw err;
150
315
  const payload = await readLockPayload(lockPath);
151
- const createdAt = payload?.createdAt ? Date.parse(payload.createdAt) : NaN;
152
- const stale = !Number.isFinite(createdAt) || Date.now() - createdAt > staleMs;
153
- const alive = payload?.pid ? isAlive(payload.pid) : false;
154
- if (stale || !alive) {
316
+ const inspected = inspectLockPayload(payload, staleMs, Date.now());
317
+ if (inspected.stale) {
155
318
  await fs.rm(lockPath, { force: true });
156
319
  continue;
157
320
  }
@@ -160,11 +323,12 @@ export async function acquireSessionWriteLock(params) {
160
323
  }
161
324
  }
162
325
  const payload = await readLockPayload(lockPath);
163
- const owner = payload?.pid ? `pid=${payload.pid}` : "unknown";
326
+ const owner = typeof payload?.pid === "number" ? `pid=${payload.pid}` : "unknown";
164
327
  throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`);
165
328
  }
166
329
  export const __testing = {
167
330
  cleanupSignals: [...CLEANUP_SIGNALS],
168
331
  handleTerminationSignal,
169
332
  releaseAllLocksSync,
333
+ runLockWatchdogCheck,
170
334
  };
@@ -1,5 +1,6 @@
1
1
  import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
2
2
  import { listDeliverableMessageChannels } from "../utils/message-channel.js";
3
+ import { sanitizeForPromptLiteral } from "./sanitize-for-prompt.js";
3
4
  function buildSkillsSection(params) {
4
5
  if (params.isMinimal)
5
6
  return [];
@@ -66,6 +67,9 @@ function buildMessagingSection(params) {
66
67
  "## Messaging",
67
68
  "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
68
69
  "- Cross-session messaging → use sessions_send(sessionKey, message)",
70
+ "- Sub-agent orchestration → use subagents(action=list|steer|kill)",
71
+ "- `[System Message] ...` blocks are internal context and are not user-visible by default.",
72
+ `- If a \`[System Message]\` reports completed cron/subagent work and asks for a user update, rewrite it in your normal assistant voice and send that update (do not forward raw system text or default to ${SILENT_REPLY_TOKEN}).`,
69
73
  "- Never use exec/curl for provider messaging; Pool Bot handles all routing internally.",
70
74
  params.availableTools.has("message")
71
75
  ? [
@@ -76,7 +80,7 @@ function buildMessagingSection(params) {
76
80
  `- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
77
81
  `- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
78
82
  params.inlineButtonsEnabled
79
- ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
83
+ ? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data,style?}]]`; `style` can be `primary`, `success`, or `danger`."
80
84
  : params.runtimeChannel
81
85
  ? `- Inline buttons not enabled for ${params.runtimeChannel}. If you need them, ask to set ${params.runtimeChannel}.capabilities.inlineButtons ("dm"|"group"|"all"|"allowlist").`
82
86
  : "",
@@ -96,6 +100,22 @@ function buildVoiceSection(params) {
96
100
  return [];
97
101
  return ["## Voice (TTS)", hint, ""];
98
102
  }
103
+ function buildLlmsTxtSection(params) {
104
+ if (params.isMinimal) {
105
+ return [];
106
+ }
107
+ if (!params.availableTools.has("web_fetch")) {
108
+ return [];
109
+ }
110
+ return [
111
+ "## llms.txt Discovery",
112
+ "When exploring a new domain or website (via web_fetch or browser), check for an llms.txt file that describes how AI agents should interact with the site:",
113
+ "- Try `/llms.txt` or `/.well-known/llms.txt` at the domain root",
114
+ "- If found, follow its guidance for interacting with that site's content and APIs",
115
+ "- llms.txt is an emerging standard (like robots.txt for AI) — not all sites have one, so don't warn if missing",
116
+ "",
117
+ ];
118
+ }
99
119
  function buildDocsSection(params) {
100
120
  const docsPath = params.docsPath?.trim();
101
121
  if (!docsPath || params.isMinimal)
@@ -137,6 +157,7 @@ export function buildAgentSystemPrompt(params) {
137
157
  sessions_history: "Fetch history for another session/sub-agent",
138
158
  sessions_send: "Send a message to another session/sub-agent",
139
159
  sessions_spawn: "Spawn a sub-agent session",
160
+ subagents: "List, steer, or kill sub-agent runs for this requester session",
140
161
  session_status: "Show a /status-equivalent status card (usage + time + Reasoning/Verbose/Elevated); use for model-use questions (📊 session_status); optional per-session model override",
141
162
  image: "Analyze an image with the configured image model",
142
163
  };
@@ -162,6 +183,8 @@ export function buildAgentSystemPrompt(params) {
162
183
  "sessions_list",
163
184
  "sessions_history",
164
185
  "sessions_send",
186
+ "sessions_spawn",
187
+ "subagents",
165
188
  "session_status",
166
189
  "image",
167
190
  ];
@@ -235,6 +258,17 @@ export function buildAgentSystemPrompt(params) {
235
258
  const messageChannelOptions = listDeliverableMessageChannels().join("|");
236
259
  const promptMode = params.promptMode ?? "full";
237
260
  const isMinimal = promptMode === "minimal" || promptMode === "none";
261
+ const sandboxContainerWorkspace = params.sandboxInfo?.containerWorkspaceDir?.trim();
262
+ const sanitizedWorkspaceDir = sanitizeForPromptLiteral(params.workspaceDir);
263
+ const sanitizedSandboxContainerWorkspace = sandboxContainerWorkspace
264
+ ? sanitizeForPromptLiteral(sandboxContainerWorkspace)
265
+ : "";
266
+ const displayWorkspaceDir = params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace
267
+ ? sanitizedSandboxContainerWorkspace
268
+ : sanitizedWorkspaceDir;
269
+ const workspaceGuidance = params.sandboxInfo?.enabled && sanitizedSandboxContainerWorkspace
270
+ ? `For read/write/edit/apply_patch, file paths resolve against host workspace: ${sanitizedWorkspaceDir}. For bash/exec commands, use sandbox container paths under ${sanitizedSandboxContainerWorkspace} (or relative paths from that workdir), not host paths. Prefer relative paths so both sandboxed exec and file tools work consistently.`
271
+ : "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.";
238
272
  const safetySection = [
239
273
  "## Safety",
240
274
  "You have no independent goals: do not pursue self-preservation, replication, resource acquisition, or power-seeking; avoid long-term plans beyond the user's request.",
@@ -285,10 +319,13 @@ export function buildAgentSystemPrompt(params) {
285
319
  "- sessions_list: list sessions",
286
320
  "- sessions_history: fetch session history",
287
321
  "- sessions_send: send to another session",
322
+ "- subagents: list/steer/kill sub-agent runs",
288
323
  '- session_status: show usage/time/model state and answer "what model are we using?"',
289
324
  ].join("\n"),
290
325
  "TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
291
- "If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
326
+ `For long waits, avoid rapid poll loops: use ${execToolName} with enough yieldMs or ${processToolName}(action=poll, timeout=<ms>).`,
327
+ "If a task is more complex or takes longer, spawn a sub-agent. Completion is push-based: it will auto-announce when done.",
328
+ "Do not poll `subagents list` / `sessions_list` in a loop; only check status on-demand (for intervention, debugging, or when explicitly asked).",
292
329
  "",
293
330
  "## Tool Call Style",
294
331
  "Default: do not narrate routine, low-risk tool calls (just call the tool).",
@@ -335,8 +372,8 @@ export function buildAgentSystemPrompt(params) {
335
372
  ? "If you need the current date, time, or day of week, run session_status (📊 session_status)."
336
373
  : "",
337
374
  "## Workspace",
338
- `Your working directory is: ${params.workspaceDir}`,
339
- "Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise.",
375
+ `Your working directory is: ${displayWorkspaceDir}`,
376
+ workspaceGuidance,
340
377
  ...workspaceNotes,
341
378
  "",
342
379
  ...docsSection,
@@ -346,17 +383,20 @@ export function buildAgentSystemPrompt(params) {
346
383
  "You are running in a sandboxed runtime (tools execute in Docker).",
347
384
  "Some tools may be unavailable due to sandbox policy.",
348
385
  "Sub-agents stay sandboxed (no elevated/host access). Need outside-sandbox read/write? Don't spawn; ask first.",
386
+ params.sandboxInfo.containerWorkspaceDir
387
+ ? `Sandbox container workdir: ${sanitizeForPromptLiteral(params.sandboxInfo.containerWorkspaceDir)}`
388
+ : "",
349
389
  params.sandboxInfo.workspaceDir
350
- ? `Sandbox workspace: ${params.sandboxInfo.workspaceDir}`
390
+ ? `Sandbox host mount source (file tools bridge only; not valid inside sandbox exec): ${sanitizeForPromptLiteral(params.sandboxInfo.workspaceDir)}`
351
391
  : "",
352
392
  params.sandboxInfo.workspaceAccess
353
393
  ? `Agent workspace access: ${params.sandboxInfo.workspaceAccess}${params.sandboxInfo.agentWorkspaceMount
354
- ? ` (mounted at ${params.sandboxInfo.agentWorkspaceMount})`
394
+ ? ` (mounted at ${sanitizeForPromptLiteral(params.sandboxInfo.agentWorkspaceMount)})`
355
395
  : ""}`
356
396
  : "",
357
397
  params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
358
398
  params.sandboxInfo.browserNoVncUrl
359
- ? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
399
+ ? `Sandbox browser observer (noVNC): ${sanitizeForPromptLiteral(params.sandboxInfo.browserNoVncUrl)}`
360
400
  : "",
361
401
  params.sandboxInfo.hostBrowserAllowed === true
362
402
  ? "Host browser control: allowed."
@@ -397,6 +437,7 @@ export function buildAgentSystemPrompt(params) {
397
437
  messageToolHints: params.messageToolHints,
398
438
  }),
399
439
  ...buildVoiceSection({ isMinimal, ttsHint: params.ttsHint }),
440
+ ...buildLlmsTxtSection({ isMinimal, availableTools }),
400
441
  ];
401
442
  if (extraSystemPrompt) {
402
443
  // Use "Subagent Context" header for minimal mode (subagents), otherwise "Group Chat Context"
@@ -429,8 +470,9 @@ export function buildAgentSystemPrompt(params) {
429
470
  lines.push("## Reasoning Format", reasoningHint, "");
430
471
  }
431
472
  const contextFiles = params.contextFiles ?? [];
432
- if (contextFiles.length > 0) {
433
- const hasSoulFile = contextFiles.some((file) => {
473
+ const validContextFiles = contextFiles.filter((file) => typeof file.path === "string" && file.path.trim().length > 0);
474
+ if (validContextFiles.length > 0) {
475
+ const hasSoulFile = validContextFiles.some((file) => {
434
476
  const normalizedPath = file.path.trim().replace(/\\/g, "/");
435
477
  const baseName = normalizedPath.split("/").pop() ?? normalizedPath;
436
478
  return baseName.toLowerCase() === "soul.md";
@@ -440,7 +482,7 @@ export function buildAgentSystemPrompt(params) {
440
482
  lines.push("If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.");
441
483
  }
442
484
  lines.push("");
443
- for (const file of contextFiles) {
485
+ for (const file of validContextFiles) {
444
486
  lines.push(`## ${file.path}`, "", file.content, "");
445
487
  }
446
488
  }