openmausbot 0.1.75 → 0.1.76

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 (39) hide show
  1. package/dist/assets/index-BtT047dk.css +1 -0
  2. package/dist/assets/{index-BTlyRwLl.js → index-C6blGSq5.js} +1 -1
  3. package/dist/assets/index-fKXFpB_J.js +305 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +6 -2
  6. package/dist-server/drivers/agents-proxy.js +133 -11
  7. package/dist-server/index.js +2602 -869
  8. package/dist-server/mcp-gate.js +28 -18
  9. package/dist-server/openmausbot.js +202 -120
  10. package/dist-server/pair-cli.js +202 -120
  11. package/dist-server/server/agent-tool-policy.js +1 -0
  12. package/dist-server/server/atomic.js +37 -1
  13. package/dist-server/server/bot-overview.js +5 -1
  14. package/dist-server/server/chief-of-staff.js +17 -12
  15. package/dist-server/server/delegations.js +177 -63
  16. package/dist-server/server/drivers/acp/hermes.js +21 -3
  17. package/dist-server/server/drivers/agents-proxy.js +113 -10
  18. package/dist-server/server/drivers/claude.js +31 -14
  19. package/dist-server/server/drivers/openai-chat.js +90 -67
  20. package/dist-server/server/drivers/openai-compat.js +9 -1
  21. package/dist-server/server/drivers/pi.js +18 -3
  22. package/dist-server/server/env-path.js +18 -17
  23. package/dist-server/server/index.js +1112 -312
  24. package/dist-server/server/message-db.js +21 -0
  25. package/dist-server/server/peer-roster.js +48 -9
  26. package/dist-server/server/procs.js +19 -3
  27. package/dist-server/server/room-handoffs.js +237 -0
  28. package/dist-server/server/section-context.js +64 -14
  29. package/dist-server/server/store.js +247 -8
  30. package/dist-server/server/surface.js +16 -5
  31. package/dist-server/server/team-backup.js +17 -1
  32. package/dist-server/server/team-setup-requests.js +252 -0
  33. package/dist-server/server/turn-dispatch-guard.js +31 -3
  34. package/dist-server/shared/approval-mode.js +7 -0
  35. package/dist-server/shared/team-backup.js +1 -0
  36. package/dist-server/shared/team-setup.js +1 -0
  37. package/package.json +1 -1
  38. package/dist/assets/index-CeMbvsgx.js +0 -305
  39. package/dist/assets/index-DH7Zax6L.css +0 -1
@@ -6,6 +6,42 @@
6
6
  // that fails to parse on next boot and is silently treated as empty state.
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { closeSync, fsyncSync, openSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
9
+ /** Windows refuses a rename onto an existing path while anything else holds a
10
+ * handle to either file, and a virus scanner or the search indexer opening a
11
+ * just-closed file for a few milliseconds is enough. It surfaces as EPERM or
12
+ * EACCES from an operation that is correct and would succeed a moment later —
13
+ * so it is retried rather than reported. Everything else throws immediately;
14
+ * a real permission problem must not be papered over by a busy-wait.
15
+ *
16
+ * Total worst case is ~155 ms across 6 attempts. Kept synchronous because
17
+ * every caller is a synchronous save path, and making one of them async is a
18
+ * much larger change than this bug warrants.
19
+ *
20
+ * ponytail: fixed backoff, no jitter. If contention turns out to be heavy
21
+ * enough that these collide, jitter it then. */
22
+ const RENAME_RETRY_DELAYS_MS = [5, 10, 20, 40, 80];
23
+ const RETRYABLE_RENAME_CODES = new Set(["EPERM", "EACCES", "EBUSY"]);
24
+ function sleepSync(ms) {
25
+ // No synchronous sleep in Node without a syscall: a zero-length read on a
26
+ // shared array with a timeout is the standard trick and does not spin the CPU.
27
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
28
+ }
29
+ /** `rename` is injectable for tests only — there is no portable way to make a
30
+ * real filesystem produce a transient EPERM on demand. */
31
+ export function renameWithRetry(tmp, path, rename = renameSync) {
32
+ for (let attempt = 0;; attempt += 1) {
33
+ try {
34
+ rename(tmp, path);
35
+ return;
36
+ }
37
+ catch (e) {
38
+ const code = e.code;
39
+ if (!code || !RETRYABLE_RENAME_CODES.has(code) || attempt >= RENAME_RETRY_DELAYS_MS.length)
40
+ throw e;
41
+ sleepSync(RENAME_RETRY_DELAYS_MS[attempt]);
42
+ }
43
+ }
44
+ }
9
45
  export function writeFileAtomic(path, data, options = {}) {
10
46
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
11
47
  let fd = null;
@@ -18,7 +54,7 @@ export function writeFileAtomic(path, data, options = {}) {
18
54
  fsyncSync(fd);
19
55
  closeSync(fd);
20
56
  fd = null;
21
- renameSync(tmp, path);
57
+ renameWithRetry(tmp, path);
22
58
  }
23
59
  catch (e) {
24
60
  if (fd !== null) {
@@ -159,10 +159,14 @@ function reachesLines(facts) {
159
159
  if (facts.browserEnabled && facts.engine?.browserMcp && facts.bot.browser !== false && facts.bot.computer !== "off")
160
160
  lines.push("Has the built-in browser.");
161
161
  if (facts.engine?.agentsMcp && facts.sectionPeers > 0 && facts.bot.peers?.length !== 0) {
162
- lines.push(`Can talk to ${facts.sectionPeers} other bot${facts.sectionPeers === 1 ? "" : "s"} in its section.`);
162
+ const scope = facts.bot.chiefOfStaff && facts.bot.managedSections?.length ? "its allowed teams" : "its section";
163
+ lines.push(`Can talk to ${facts.sectionPeers} other bot${facts.sectionPeers === 1 ? "" : "s"} in ${scope}.`);
163
164
  }
164
165
  if (facts.bot.chiefOfStaff)
165
166
  lines.push("Coordinates its section as Chief of Staff.");
167
+ if (facts.bot.chiefOfStaff && facts.bot.managedSections?.length) {
168
+ lines.push(`May also coordinate these teams: ${facts.bot.managedSections.map(name => name || "General").join(", ")}.`);
169
+ }
166
170
  return lines;
167
171
  }
168
172
  function wontLines(facts) {
@@ -1,4 +1,4 @@
1
- import { renderRoster, reachablePeers } from "./peer-roster.js";
1
+ import { peerName, renderRoster, reachablePeers } from "./peer-roster.js";
2
2
  // The Chief's roster stays wider than an ordinary bot's (peer-roster.ts caps
3
3
  // that one at a dozen): staffing the section is this bot's whole job, so it
4
4
  // reads the team as a directory rather than as a nudge. The field-level caps
@@ -9,10 +9,10 @@ const sectionKey = (section) => section?.trim() || "";
9
9
  /** Dynamic system context for a section's Chief of Staff.
10
10
  * It names the current team on every turn, while list_bots remains the
11
11
  * authoritative tool for IDs and live availability at delegation time. */
12
- export function chiefOfStaffSystemPrompt(chiefId, bots, canDelegate, trustedOpenMausStatus = "") {
12
+ export function chiefOfStaffSystemPrompt(chiefId, bots, canDelegate, trustedOpenMausStatus = "", boundedCoordination = false) {
13
13
  const chief = bots.find((bot) => bot.id === chiefId);
14
14
  const chiefSection = sectionKey(chief?.section);
15
- const sectionName = chiefSection || "General";
15
+ const sectionName = peerName(chiefSection) || "General";
16
16
  // A Chief with its own allow-list is bound by it here too: the roster and
17
17
  // the endpoints must agree, or the prompt names teammates the tools will
18
18
  // then refuse to reach.
@@ -28,21 +28,26 @@ export function chiefOfStaffSystemPrompt(chiefId, bots, canDelegate, trustedOpen
28
28
  about: true,
29
29
  });
30
30
  const delegation = canDelegate
31
- ? [
32
- "Use list_bots to confirm the live roster and IDs. When assigning work to a teammate, use delegate_bot: it returns immediately, keeps you available to the user, and delivers the teammate's outcome back into this conversation automatically — success or failure. When the result arrives you are woken with it: report it to the user and act. If the teammate fails or stalls, tell the user plainly and decide the next step yourself.",
33
- "After delegate_bot accepts the task, acknowledge the handoff and continue with any independent work or end your turn. Do not call wait_delegation or repeatedly poll check_delegation in the same turn.",
34
- "Use ask_bot only for a brief consultation whose answer you must have before writing your current response. Never use ask_bot for an assigned task, background work, or anything potentially long-running.",
35
- "When the user asks you to assemble a team, use create_bot for each genuinely useful specialist. Give each one a clear role and instructions, then use delegate_bot to assign its work. Do not create duplicate or unnecessary bots.",
36
- "Delegate with a clear, self-contained brief. Say that the task is assigned, not completed; only claim completion after the teammate's result has actually arrived.",
37
- "You may assign work to more than one teammate when the request genuinely benefits. Stay responsive while they work, then combine their returned results when the user asks for a synthesis.",
38
- ].join(" ")
31
+ ? boundedCoordination
32
+ ? "Use list_bots or list_room_targets for the live reachable roster. Use coordinate_bots to ask actual teammates for advice or assign concrete work. Outside a room, each assignment gets a separate conversation using the recipient's own model and permissions. Busy teammates queue. Give self-contained briefs, then end your turn; you resume automatically after their results return. Leads can coordinate their own specialists. Do not poll, send acknowledgements as new work, or substitute native helpers for named bots. On return, verify the requested outcome, resolve decisions within the user's scope, request concrete corrections with rework=true when necessary, and return one consolidated answer. Consultations are advice, not proof that work or tests ran."
33
+ : [
34
+ "Use list_bots to confirm the live roster and IDs. When assigning work to a teammate, use delegate_bot: it returns immediately, keeps you available to the user, and delivers the teammate's outcome back into this conversation automatically — success or failure. When the result arrives you are woken with it: report it to the user and act. If the teammate fails or stalls, tell the user plainly and decide the next step yourself.",
35
+ "After delegate_bot accepts the task, acknowledge the handoff and continue with any independent work or end your turn. Do not call wait_delegation or repeatedly poll check_delegation in the same turn.",
36
+ "Use ask_bot only for a brief consultation whose answer you must have before writing your current response. Never use ask_bot for an assigned task, background work, or anything potentially long-running.",
37
+ "Delegate with a clear, self-contained brief. Say that the task is assigned, not completed; only claim completion after the teammate's result has actually arrived.",
38
+ "You may assign work to more than one teammate when the request genuinely benefits. Stay responsive while they work, then combine their returned results when the user asks for a synthesis.",
39
+ ].join(" ")
39
40
  : "Your current engine cannot contact teammates. Be honest about that limitation and ask the user to choose a delegation-compatible engine before promising coordinated work.";
40
41
  return [
41
42
  `You are the Chief of Staff for the ${sectionName} section. You are the user's primary contact for this section's team of bots.`,
43
+ chief?.managedSections?.length
44
+ ? `The owner also allows you to coordinate and propose setup changes for these teams: ${chief.managedSections.map(s => peerName(s) || "General").join(", ")}. You remain the user's single point of contact. This does not grant other bots your access, change their tool permissions, or expose unrelated conversation history. Use list_bots for the actual reachable roster.`
45
+ : "",
42
46
  "Own the outcome: understand the request, decide what to handle yourself, coordinate the right specialists when useful, and return one concise consolidated answer.",
43
47
  "Do not delegate trivial work merely to appear busy. Never invent a teammate's progress or result. Normal permission and approval rules still apply.",
44
48
  delegation,
45
- `Current ${sectionName} section team:`,
49
+ canDelegate ? "When the user asks you to assemble or configure a team, use list_team_setup for the exact authorized teams, bot IDs and model catalog, then propose_team_setup once with all named specialists and their profile/model changes. Include new teams explicitly; the combined card reviews their creation and your access. Existing thread models stay unchanged. End your turn after the proposal: the user's decision automatically resumes you once with a structured result. Do not ask for another yes, poll, or repeat the proposal. After successful setup, use the available coordination tools for already requested work. Use create_bot only for a single specialist when no combined setup was requested. For explicitly requested bot deletion, use propose_bot_deletion separately. Do not create duplicate or unnecessary bots." : "",
50
+ chief?.managedSections?.length ? "Reachable teammates in your allowed teams:" : `Current ${sectionName} section team:`,
46
51
  roster,
47
52
  trustedOpenMausStatus,
48
53
  ].filter(Boolean).join("\n");
@@ -17,8 +17,7 @@ import { getOrCreateChannel, mirrorExchange } from "./comms-visibility.js";
17
17
  import { DATA_DIR } from "./config.js";
18
18
  import { newId } from "./contracts.js";
19
19
  import { requestPeerApproval } from "./peer-approval.js";
20
- import { peerAllowed } from "./peer-roster.js";
21
- import { sectionKey } from "./store.js";
20
+ import { canAccessTeam, peerAllowed } from "./peer-roster.js";
22
21
  /** Per source-thread queue. Persisted to delegations.json on every change
23
22
  * and reloaded at boot: a handoff queued right before a restart runs after
24
23
  * it. (Provider PERMISSIONS still die with the process — nobody can answer
@@ -35,7 +34,12 @@ const RECEIPTS_FILE = join(DATA_DIR, "delegation-receipts.json");
35
34
  const MAX_RECEIPTS = 100;
36
35
  const RECEIPT_MAX_AGE_MS = 48 * 60 * 60 * 1000;
37
36
  const RESULT_MAX_CHARS = 4_000;
38
- export const MAX_BUSY_ATTEMPTS = 3;
37
+ /** A busy handoff's delivery window. An available target may still pick up
38
+ * an overdue item. At restart, elapsed windows are renewed before any boot
39
+ * dispatch, so the first recovered job cannot cause the remaining backlog
40
+ * to expire. This is not an uptime clock: sleep within a running process
41
+ * still counts, and a non-expired restored window keeps its deadline. */
42
+ export const DELEGATION_TTL_MS = 24 * 60 * 60 * 1000;
39
43
  let receipts = [];
40
44
  function saveReceipts() {
41
45
  try {
@@ -72,7 +76,7 @@ export function pendingDelegationInfo(id) {
72
76
  for (const [sourceThreadId, items] of pendingDelegations) {
73
77
  const item = items.find((candidate) => candidate.id === id);
74
78
  if (item)
75
- return { sourceThreadId, toBotId: item.toBotId, attempts: item.attempts };
79
+ return { sourceThreadId, toBotId: item.toBotId, queuedAt: item.queuedAt, waiting: item.waitingOnBusy === true };
76
80
  }
77
81
  return null;
78
82
  }
@@ -85,8 +89,10 @@ export function threadsWaitingOn(toBotId) {
85
89
  .map(([threadId]) => threadId);
86
90
  }
87
91
  /** Mark a target's observed busy period as finished and return the source
88
- * threads that should be retried. This makes retries count distinct busy
89
- * periods, not unrelated drain requests on the same source thread.
92
+ * threads that should be retried. A handoff waits until the target is free,
93
+ * bounded only by the 24-hour expiry — this just clears the "parked on a
94
+ * busy period" marker so the next drain re-evaluates it, rather than
95
+ * counting or limiting retries.
90
96
  * `only` narrows the release: a bot that is still busy in one thread has
91
97
  * nevertheless freed a slot for the fresh-thread handoffs waiting on it,
92
98
  * while its active-thread handoffs go on waiting for it to go idle. */
@@ -118,8 +124,10 @@ function savePending() {
118
124
  /** Load what a previous process left queued. Missing or corrupt → empty. */
119
125
  export function _loadPending() {
120
126
  pendingDelegations.clear();
127
+ let backfilled = false;
121
128
  try {
122
129
  const raw = JSON.parse(readFileSync(DELEGATIONS_FILE, "utf8"));
130
+ const now = Date.now();
123
131
  for (const [threadId, list] of Object.entries(raw)) {
124
132
  if (!Array.isArray(list))
125
133
  continue;
@@ -131,6 +139,20 @@ export function _loadPending() {
131
139
  typeof item.message !== "string" ||
132
140
  !Number.isFinite(item.depth))
133
141
  return [];
142
+ // Restore every elapsed window before boot dispatch. Merely checking
143
+ // whether the target is idle loses the second old job as soon as the
144
+ // first recovered job occupies it. Invalid/legacy timestamps get the
145
+ // same fresh window; valid, unexpired windows keep their deadline.
146
+ const hasUsableQueuedAt = Number.isFinite(item.queuedAt) && item.queuedAt <= now &&
147
+ now - item.queuedAt < DELEGATION_TTL_MS;
148
+ if (!hasUsableQueuedAt)
149
+ backfilled = true;
150
+ // A legacy item saved with attempts >= 1 already posted its old
151
+ // "retry n/3" chip under the since-removed bounded-retry scheme; load
152
+ // it as already announced so it doesn't post a second waiting chip
153
+ // the first time this queue drains.
154
+ const legacyAttempts = value.attempts;
155
+ const legacyAlreadyAnnounced = typeof legacyAttempts === "number" && Number.isFinite(legacyAttempts) && legacyAttempts > 0;
134
156
  const loaded = {
135
157
  id: typeof item.id === "string" && item.id ? item.id : newId(),
136
158
  sourceBotId: typeof item.sourceBotId === "string" && item.sourceBotId ? item.sourceBotId : "",
@@ -138,12 +160,14 @@ export function _loadPending() {
138
160
  message: item.message,
139
161
  ...(typeof item.reason === "string" ? { reason: item.reason } : {}),
140
162
  depth: Math.max(0, Math.trunc(item.depth)),
141
- attempts: Number.isFinite(item.attempts) ? Math.max(0, Math.trunc(item.attempts)) : 0,
163
+ queuedAt: hasUsableQueuedAt ? Math.min(item.queuedAt, now) : now,
142
164
  };
143
165
  if (item.approvalAlreadyGranted === true)
144
166
  loaded.approvalAlreadyGranted = true;
145
167
  if (item.waitingOnBusy === true)
146
168
  loaded.waitingOnBusy = true;
169
+ if (item.waitAnnounced === true || legacyAlreadyAnnounced)
170
+ loaded.waitAnnounced = true;
147
171
  if (typeof item.originatingGroupId === "string" && item.originatingGroupId) {
148
172
  loaded.originatingGroupId = item.originatingGroupId;
149
173
  }
@@ -159,6 +183,10 @@ export function _loadPending() {
159
183
  catch {
160
184
  /* fresh install, or unreadable — start empty */
161
185
  }
186
+ // Persist repaired/renewed windows before dispatch. A quick restart loop
187
+ // must retain that still-valid deadline, not renew it on every load.
188
+ if (backfilled)
189
+ savePending();
162
190
  receipts = [];
163
191
  try {
164
192
  const rawReceipts = JSON.parse(readFileSync(RECEIPTS_FILE, "utf8"));
@@ -239,7 +267,7 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
239
267
  ? originatingGroup.id
240
268
  : undefined;
241
269
  const id = newId();
242
- list.push({ ...item, id, sourceBotId: from.id, attempts: 0, ...(groupId ? { originatingGroupId: groupId } : {}) });
270
+ list.push({ ...item, id, sourceBotId: from.id, queuedAt: Date.now(), ...(groupId ? { originatingGroupId: groupId } : {}) });
243
271
  pendingDelegations.set(sourceThreadId, list);
244
272
  savePending();
245
273
  const sourceGroup = sourceThreadId ? bus.store.groupByThread(sourceThreadId) : undefined;
@@ -332,8 +360,9 @@ onSettled) {
332
360
  }
333
361
  }
334
362
  finally {
335
- // A requeued item (busy target, retries left) stays for the drain
336
- // that the target's own settling turn will trigger.
363
+ // A requeued item (target still busy, waiting until it's free or the
364
+ // 24-hour expiry) stays for the drain that the target's own settling
365
+ // turn will trigger.
337
366
  const stillQueued = pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id);
338
367
  if (outcome !== "requeued")
339
368
  acknowledgeDelegation(threadId, item.id);
@@ -353,8 +382,8 @@ onSettled) {
353
382
  drainingThreads.delete(threadId);
354
383
  // A later turn may have queued and settled while this thread was
355
384
  // waiting for approval. Only items OUTSIDE our snapshot warrant a fresh
356
- // drain — re-draining a just-requeued item would burn its bounded busy
357
- // retries in milliseconds instead of once per target settle.
385
+ // drain — re-draining a just-requeued item would spin it in a tight
386
+ // loop instead of once per target settle.
358
387
  const redrainRequested = queuedRedrains.delete(threadId);
359
388
  const snapshotIds = new Set(snapshot.map((item) => item.id));
360
389
  const hasNewItems = pendingDelegations.get(threadId)?.some((item) => !snapshotIds.has(item.id)) ?? false;
@@ -375,6 +404,85 @@ function acknowledgeDelegation(threadId, itemId) {
375
404
  pendingDelegations.delete(threadId);
376
405
  savePending();
377
406
  }
407
+ const isExpired = (item, now) => now - item.queuedAt >= DELEGATION_TTL_MS;
408
+ /** Record an expired handoff. The chip goes into the source thread only
409
+ * while it still belongs to the bot that owns the handoff — a deleted
410
+ * conversation gets the receipt and nothing else. */
411
+ function expireDelegation(bus, sourceThreadId, item, ownerId) {
412
+ const name = bus.store.bot(item.toBotId)?.name ?? item.toBotId;
413
+ recordDelegationReceipt({
414
+ id: item.id,
415
+ sourceThreadId,
416
+ toBotId: item.toBotId,
417
+ toBotName: name,
418
+ status: "expired",
419
+ result: `@${name} was not free to take this for 24 hours`,
420
+ });
421
+ if (!sourceThreadBelongsToBot(bus.store, ownerId, sourceThreadId))
422
+ return;
423
+ bus.store.appendMessage(sourceThreadId, {
424
+ role: "bot",
425
+ kind: "activity",
426
+ tool: { name: `Delegation to @${name} expired — not picked up within 24 hours`, ok: false },
427
+ });
428
+ }
429
+ /** Past its 24 hours AND unable to be delivered right now — the same rule
430
+ * `processOne` applies, so the hourly sweep and a live drain never disagree
431
+ * about which items are actually stuck. A target that was deleted counts as
432
+ * "cannot take the turn": there is nothing to wait on, so such items still
433
+ * expire even though there is no bot left to test busy/free against. */
434
+ function isDueForExpiry(bus, item, now) {
435
+ if (!isExpired(item, now))
436
+ return false;
437
+ const target = bus.store.bot(item.toBotId);
438
+ if (!target)
439
+ return true;
440
+ return !targetCanTakeTurn(bus, target, item);
441
+ }
442
+ /** Expire every queued handoff past DELEGATION_TTL_MS that still cannot be
443
+ * delivered, wherever it waits. A drain already expires what it touches;
444
+ * this covers the handoff nothing drains — a target that never settles
445
+ * while its source sits idle. A thread mid-drain is skipped: that drain
446
+ * owns its items and expires them itself. An item whose target could take
447
+ * the turn right now is left queued instead — the next drain on its source
448
+ * thread (or the next `retryDelegationsWaitingOn`) delivers it; the sweep
449
+ * only cleans up what is genuinely stuck. Each expiry is reported through
450
+ * `onSettled`, the same hook a drain uses to wake the delegating bot.
451
+ * Returns how many expired. */
452
+ export function expireStaleDelegations(bus, now, onSettled) {
453
+ const expired = [];
454
+ for (const [threadId, items] of pendingDelegations) {
455
+ if (drainingThreads.has(threadId))
456
+ continue;
457
+ const due = items.filter((item) => isDueForExpiry(bus, item, now));
458
+ if (!due.length)
459
+ continue;
460
+ const remaining = items.filter((item) => !isDueForExpiry(bus, item, now));
461
+ if (remaining.length)
462
+ pendingDelegations.set(threadId, remaining);
463
+ else
464
+ pendingDelegations.delete(threadId);
465
+ const ownerId = bus.store.botByThread(threadId)?.id;
466
+ for (const item of due) {
467
+ expireDelegation(bus, threadId, item, ownerId ?? item.sourceBotId);
468
+ const receipt = findDelegationReceipt(item.id);
469
+ if (receipt)
470
+ expired.push(receipt);
471
+ }
472
+ }
473
+ if (!expired.length)
474
+ return 0;
475
+ savePending();
476
+ for (const receipt of expired) {
477
+ try {
478
+ onSettled?.(receipt);
479
+ }
480
+ catch (error) {
481
+ console.error("delegation expired but its source could not be resumed", error);
482
+ }
483
+ }
484
+ return expired.length;
485
+ }
378
486
  /** Drop a thread's queued handoffs without running them, telling the user
379
487
  * they were dropped. Used when the queueing turn failed or was interrupted. */
380
488
  export function discardDelegations(bus, threadId) {
@@ -441,7 +549,17 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
441
549
  if (dropIfThreadGone(bus, target, sourceThreadId, item)) {
442
550
  return "settled";
443
551
  }
444
- const held = holdWhileTargetBusy(bus, target, sourceThreadId, item);
552
+ // Past its 24 hours AND the target still cannot take the turn: this is the
553
+ // bound on a busy wait. Use the same free/busy test as holdWhileTargetBusy;
554
+ // an available target gets even an overdue item. Restart recovery renews
555
+ // elapsed windows in _loadPending before any target becomes busy. Decide
556
+ // before announcing a wait so an item cannot post both chips in one pass.
557
+ const canTakeTurn = targetCanTakeTurn(bus, target, item);
558
+ if (!canTakeTurn && isExpired(item, Date.now())) {
559
+ expireDelegation(bus, sourceThreadId, item, sender.id);
560
+ return "settled";
561
+ }
562
+ const held = holdWhileTargetBusy(bus, target, sourceThreadId, item, canTakeTurn);
445
563
  if (held)
446
564
  return held;
447
565
  if (item.waitingOnBusy) {
@@ -494,7 +612,15 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
494
612
  if (dropIfThreadGone(bus, current, sourceThreadId, item)) {
495
613
  return "settled";
496
614
  }
497
- const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item);
615
+ // Approval may have waited for minutes (or, with approvalAlreadyGranted,
616
+ // up to 24h since the original ask_bot approval) — recheck the same
617
+ // free/busy-gated expiry as the pre-approval path before dispatching.
618
+ const canTakeTurnAfterApproval = targetCanTakeTurn(bus, current, item);
619
+ if (!canTakeTurnAfterApproval && isExpired(item, Date.now())) {
620
+ expireDelegation(bus, sourceThreadId, item, currentSender.id);
621
+ return "settled";
622
+ }
623
+ const heldAfterApproval = holdWhileTargetBusy(bus, current, sourceThreadId, item, canTakeTurnAfterApproval);
498
624
  if (heldAfterApproval)
499
625
  return heldAfterApproval;
500
626
  sender = currentSender;
@@ -516,62 +642,50 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
516
642
  await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id, item.targetThreadId);
517
643
  return "dispatched";
518
644
  }
519
- /** A busy target holds the handoff. What "busy" means depends on where the
520
- * turn will run: a classic delegation lands in the target's active thread,
521
- * so it waits for the bot to go idle, with bounded retries so a stuck peer
522
- * cannot pin the handoff forever. A fresh-thread handoff needs only a free
523
- * slot, and waits in line for one without a bound — it IS the line, the
524
- * same one a person's message joins at that limit. Returns null when the
525
- * target can take the turn now. */
526
- function holdWhileTargetBusy(bus, target, sourceThreadId, item) {
527
- if (item.targetThreadId) {
528
- if (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
529
- return null;
530
- if (item.waitingOnBusy)
531
- return "requeued";
532
- item.waitingOnBusy = true;
533
- savePending();
534
- // one chip per wait, not one per retry: the line moves on every settle
535
- if (item.attempts === 0) {
536
- item.attempts = 1;
537
- const title = bus.store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
538
- bus.store.appendMessage(sourceThreadId, {
539
- role: "bot",
540
- kind: "activity",
541
- tool: { name: `Thread #${title} on @${target.name} waiting for a free slot` },
542
- });
543
- }
544
- return "requeued";
545
- }
546
- if (!target.busy)
645
+ /** "Is the target free to take this handoff right now?" What "busy" means
646
+ * depends on where the turn will run: a classic delegation lands in the
647
+ * target's active thread, so it needs the bot to be idle; a fresh-thread
648
+ * handoff needs only a free slot. This is the single free/busy test shared
649
+ * by the expiry decision in `processOne` and the hold decision below, so
650
+ * the two can never disagree about whether a handoff could have been
651
+ * delivered right now. */
652
+ function targetCanTakeTurn(bus, target, item) {
653
+ return item.targetThreadId
654
+ ? (bus.threadSlotFree ? bus.threadSlotFree(target.id) : !target.busy)
655
+ : !target.busy;
656
+ }
657
+ /** A busy target holds the handoff. Neither counts busy periods — the only
658
+ * bound is DELEGATION_TTL_MS, checked in processOne before this runs (using
659
+ * the same `targetCanTakeTurn` test, passed in as `canTakeTurn` when the
660
+ * caller already computed it so the two checks can't disagree). One waiting
661
+ * chip per handoff, worded for what the target is actually doing. Returns
662
+ * null when the target can take the turn now. */
663
+ function holdWhileTargetBusy(bus, target, sourceThreadId, item, canTakeTurn = targetCanTakeTurn(bus, target, item)) {
664
+ if (canTakeTurn)
547
665
  return null;
548
666
  if (item.waitingOnBusy)
549
667
  return "requeued";
550
- item.attempts += 1;
551
668
  item.waitingOnBusy = true;
552
- if (item.attempts < MAX_BUSY_ATTEMPTS) {
553
- savePending();
669
+ if (!item.waitAnnounced) {
670
+ item.waitAnnounced = true;
554
671
  bus.store.appendMessage(sourceThreadId, {
555
672
  role: "bot",
556
673
  kind: "activity",
557
- tool: { name: `Delegation to @${target.name} waiting — they're busy (retry ${item.attempts}/${MAX_BUSY_ATTEMPTS} when they finish)` },
674
+ tool: { name: waitingChipText(bus.store, target, item) },
558
675
  });
559
- return "requeued";
560
676
  }
561
- recordDelegationReceipt({
562
- id: item.id,
563
- sourceThreadId,
564
- toBotId: target.id,
565
- toBotName: target.name,
566
- status: "busy_gave_up",
567
- result: `@${target.name} stayed busy through ${MAX_BUSY_ATTEMPTS} retries`,
568
- });
569
- bus.store.appendMessage(sourceThreadId, {
570
- role: "bot",
571
- kind: "activity",
572
- tool: { name: `Delegation to @${target.name} canceled — still busy after ${MAX_BUSY_ATTEMPTS} retries`, ok: false },
573
- });
574
- return "settled";
677
+ savePending();
678
+ return "requeued";
679
+ }
680
+ function waitingChipText(store, target, item) {
681
+ if (item.targetThreadId) {
682
+ const title = store.taskByThread(target.id, item.targetThreadId)?.title ?? "thread";
683
+ return `Thread #${title} on @${target.name} waiting for a free slot`;
684
+ }
685
+ if (target.activity === "waiting-on-you") {
686
+ return `Waiting for @${target.name}, who's waiting on you — it'll go through after you answer`;
687
+ }
688
+ return `Delegation to @${target.name} waiting — they're busy; it'll go through when they're free`;
575
689
  }
576
690
  /** The thread a fresh-thread handoff was opened in may be deleted while the
577
691
  * handoff waits. There is nowhere for the turn to run then, and running it
@@ -609,8 +723,8 @@ function sourceThreadBelongsToBot(store, botId, threadId) {
609
723
  * was queued must be checked again at the final dispatch edge — the user may
610
724
  * have moved either bot, or narrowed the sender's peers, in between. */
611
725
  function dropIfUnreachable(bus, sender, target, sourceThreadId, item) {
612
- const sectionsDiffer = sectionKey(sender.section) !== sectionKey(target.section);
613
- if (!sectionsDiffer && peerAllowed(sender, target.id))
726
+ const sectionsDiffer = !canAccessTeam(sender, target.section);
727
+ if (!sectionsDiffer && !target.hidden && peerAllowed(sender, target.id))
614
728
  return false;
615
729
  const reason = sectionsDiffer
616
730
  ? "bots now belong to different sections"
@@ -9,6 +9,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
9
  import { homedir } from "node:os";
10
10
  import { join } from "node:path";
11
11
  import { parse as parseYaml } from "yaml";
12
+ import { resolveCli } from "../../procs.js";
12
13
  import { decodeInjectId, hostApiKey, INJECT_SEP, localHost, mergeLocalInject } from "../local-inject.js";
13
14
  import { createAcpDriver } from "./core.js";
14
15
  const EMPTY = { default: "", options: [] };
@@ -249,11 +250,28 @@ export function hermesConfiguredModel(env = process.env) {
249
250
  * Failure is non-fatal and returns [] — a catalog probe must never be the
250
251
  * reason an agent becomes unselectable.
251
252
  */
252
- async function fetchHermesAcpModels(cli, env) {
253
+ export const HERMES_ACP_MODELS_TIMEOUT_ENV = "HERMES_ACP_MODELS_TIMEOUT_MS";
254
+ /** Overall deadline for the initialize + session/new catalog probe. A Hermes
255
+ * install with several authenticated providers answers `initialize` in about
256
+ * a second but needs ~6-7s for `session/new`, whose result carries the model
257
+ * list — the old 5s cap killed the probe every time on exactly the installs
258
+ * that have a catalog worth showing. */
259
+ export const HERMES_ACP_MODELS_DEFAULT_TIMEOUT_MS = 15_000;
260
+ function hermesAcpModelsTimeoutMs(env) {
261
+ const raw = env[HERMES_ACP_MODELS_TIMEOUT_ENV];
262
+ if (raw === undefined)
263
+ return HERMES_ACP_MODELS_DEFAULT_TIMEOUT_MS;
264
+ const parsed = Number(raw);
265
+ return Number.isInteger(parsed) && parsed > 0 && parsed <= 2_147_483_647
266
+ ? parsed
267
+ : HERMES_ACP_MODELS_DEFAULT_TIMEOUT_MS;
268
+ }
269
+ export async function fetchHermesAcpModels(cli, env) {
253
270
  return await new Promise((resolve) => {
254
271
  let child;
255
272
  try {
256
- child = spawn(cli, ["acp"], { stdio: ["pipe", "pipe", "ignore"], env: env });
273
+ const resolved = resolveCli(cli, ["acp"], env);
274
+ child = spawn(resolved.command, resolved.args, { stdio: ["pipe", "pipe", "ignore"], env: env, windowsHide: true });
257
275
  }
258
276
  catch {
259
277
  return resolve([]);
@@ -285,7 +303,7 @@ async function fetchHermesAcpModels(cli, env) {
285
303
  }
286
304
  resolve(out);
287
305
  };
288
- timer = setTimeout(() => done([]), 5_000);
306
+ timer = setTimeout(() => done([]), hermesAcpModelsTimeoutMs(env));
289
307
  child.once("error", () => done([]));
290
308
  child.once("close", () => {
291
309
  if (hardKillTimer)