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.
- package/dist/assets/index-BtT047dk.css +1 -0
- package/dist/assets/{index-BTlyRwLl.js → index-C6blGSq5.js} +1 -1
- package/dist/assets/index-fKXFpB_J.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +6 -2
- package/dist-server/drivers/agents-proxy.js +133 -11
- package/dist-server/index.js +2602 -869
- package/dist-server/mcp-gate.js +28 -18
- package/dist-server/openmausbot.js +202 -120
- package/dist-server/pair-cli.js +202 -120
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/atomic.js +37 -1
- package/dist-server/server/bot-overview.js +5 -1
- package/dist-server/server/chief-of-staff.js +17 -12
- package/dist-server/server/delegations.js +177 -63
- package/dist-server/server/drivers/acp/hermes.js +21 -3
- package/dist-server/server/drivers/agents-proxy.js +113 -10
- package/dist-server/server/drivers/claude.js +31 -14
- package/dist-server/server/drivers/openai-chat.js +90 -67
- package/dist-server/server/drivers/openai-compat.js +9 -1
- package/dist-server/server/drivers/pi.js +18 -3
- package/dist-server/server/env-path.js +18 -17
- package/dist-server/server/index.js +1112 -312
- package/dist-server/server/message-db.js +21 -0
- package/dist-server/server/peer-roster.js +48 -9
- package/dist-server/server/procs.js +19 -3
- package/dist-server/server/room-handoffs.js +237 -0
- package/dist-server/server/section-context.js +64 -14
- package/dist-server/server/store.js +247 -8
- package/dist-server/server/surface.js +16 -5
- package/dist-server/server/team-backup.js +17 -1
- package/dist-server/server/team-setup-requests.js +252 -0
- package/dist-server/server/turn-dispatch-guard.js +31 -3
- package/dist-server/shared/approval-mode.js +7 -0
- package/dist-server/shared/team-backup.js +1 -0
- package/dist-server/shared/team-setup.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-CeMbvsgx.js +0 -305
- 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
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
89
|
-
*
|
|
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
|
-
|
|
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,
|
|
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
|
|
336
|
-
// that the target's own settling
|
|
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
|
|
357
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
520
|
-
* turn will run: a classic delegation lands in the
|
|
521
|
-
* so it
|
|
522
|
-
*
|
|
523
|
-
*
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
function
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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.
|
|
553
|
-
|
|
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:
|
|
674
|
+
tool: { name: waitingChipText(bus.store, target, item) },
|
|
558
675
|
});
|
|
559
|
-
return "requeued";
|
|
560
676
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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([]),
|
|
306
|
+
timer = setTimeout(() => done([]), hermesAcpModelsTimeoutMs(env));
|
|
289
307
|
child.once("error", () => done([]));
|
|
290
308
|
child.once("close", () => {
|
|
291
309
|
if (hardKillTimer)
|