openmausbot 0.1.74 → 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-CwoQdM0z.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 +3379 -1431
- package/dist-server/mcp-gate.js +28 -18
- package/dist-server/openmausbot.js +557 -325
- package/dist-server/pair-cli.js +557 -325
- package/dist-server/permission-proxy.js +119 -14
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/atomic.js +37 -1
- package/dist-server/server/auto-approve.js +15 -0
- 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/core.js +2 -1
- package/dist-server/server/drivers/acp/grok.js +12 -1
- 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 +91 -17
- package/dist-server/server/drivers/codex.js +15 -4
- 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/drivers/retry.js +4 -0
- package/dist-server/server/env-path.js +18 -17
- package/dist-server/server/index.js +1172 -302
- package/dist-server/server/message-db.js +21 -0
- package/dist-server/server/peer-roster.js +48 -9
- package/dist-server/server/permission-proxy.js +90 -15
- 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/setup-mode.js +6 -7
- package/dist-server/server/store.js +266 -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/thread-events.js +10 -1
- package/dist-server/server/turn-dispatch-guard.js +31 -3
- package/dist-server/server/workspace.js +13 -0
- package/dist-server/shared/approval-mode.js +7 -0
- package/dist-server/shared/ask-question.js +172 -0
- package/dist-server/shared/provider-safety.js +6 -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-BRLHsQ5g.js +0 -298
- package/dist/assets/index-ByLQ7a3F.css +0 -1
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
// server/permission-proxy.ts
|
|
2
2
|
import { connect } from "node:net";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
|
|
5
|
+
// shared/ask-question.ts
|
|
6
|
+
var MAX_QUESTIONS = 6;
|
|
7
|
+
var MAX_OPTIONS = 12;
|
|
8
|
+
var MAX_QUESTION_TEXT = 400;
|
|
9
|
+
var MAX_LABEL = 120;
|
|
10
|
+
var MAX_DESCRIPTION = 400;
|
|
11
|
+
function text(value, limit) {
|
|
12
|
+
if (typeof value !== "string") return void 0;
|
|
13
|
+
const trimmed = value.trim();
|
|
14
|
+
return trimmed ? trimmed.slice(0, limit) : void 0;
|
|
15
|
+
}
|
|
16
|
+
function isRecord(value) {
|
|
17
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
function parseOption(value) {
|
|
20
|
+
if (typeof value === "string") {
|
|
21
|
+
const label2 = text(value, MAX_LABEL);
|
|
22
|
+
return label2 ? { label: label2 } : null;
|
|
23
|
+
}
|
|
24
|
+
if (!isRecord(value)) return null;
|
|
25
|
+
const label = text(value.label, MAX_LABEL);
|
|
26
|
+
if (!label) return null;
|
|
27
|
+
const description = text(value.description, MAX_DESCRIPTION);
|
|
28
|
+
return description ? { label, description } : { label };
|
|
29
|
+
}
|
|
30
|
+
function parseQuestion(value) {
|
|
31
|
+
if (!isRecord(value)) return null;
|
|
32
|
+
const question = text(value.question, MAX_QUESTION_TEXT);
|
|
33
|
+
if (!question) return null;
|
|
34
|
+
const options = [];
|
|
35
|
+
const seen = /* @__PURE__ */ new Set();
|
|
36
|
+
for (const raw of Array.isArray(value.options) ? value.options : []) {
|
|
37
|
+
const option = parseOption(raw);
|
|
38
|
+
if (!option || seen.has(option.label)) continue;
|
|
39
|
+
seen.add(option.label);
|
|
40
|
+
options.push(option);
|
|
41
|
+
if (options.length === MAX_OPTIONS) break;
|
|
42
|
+
}
|
|
43
|
+
const header = text(value.header, MAX_LABEL);
|
|
44
|
+
return {
|
|
45
|
+
question,
|
|
46
|
+
...header ? { header } : {},
|
|
47
|
+
...value.multiSelect === true ? { multiSelect: true } : {},
|
|
48
|
+
options
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function parseAskQuestions(input) {
|
|
52
|
+
if (!isRecord(input) || !Array.isArray(input.questions)) return null;
|
|
53
|
+
const questions = [];
|
|
54
|
+
for (const raw of input.questions) {
|
|
55
|
+
const question = parseQuestion(raw);
|
|
56
|
+
if (!question) continue;
|
|
57
|
+
questions.push(question);
|
|
58
|
+
if (questions.length === MAX_QUESTIONS) break;
|
|
59
|
+
}
|
|
60
|
+
return questions.length ? questions : null;
|
|
61
|
+
}
|
|
62
|
+
function questionAnswersByQuestion(message, questions) {
|
|
63
|
+
const answers = {};
|
|
64
|
+
const known = new Map(questions.map((entry) => [entry.question, entry.question]));
|
|
65
|
+
for (const block of message.split("\n\n")) {
|
|
66
|
+
const match = /^Q: ([\s\S]+?)\nA: ([\s\S]+)$/.exec(block.trim());
|
|
67
|
+
if (!match) continue;
|
|
68
|
+
const question = known.get(match[1].trim());
|
|
69
|
+
if (question) answers[question] = match[2].trim();
|
|
70
|
+
}
|
|
71
|
+
if (Object.keys(answers).length) return answers;
|
|
72
|
+
const only = questions.length === 1 ? questions[0] : void 0;
|
|
73
|
+
const flat = message.trim();
|
|
74
|
+
return only && flat ? { [only.question]: flat } : {};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// server/permission-proxy.ts
|
|
4
78
|
var socketPath = process.argv[2] ?? "";
|
|
5
79
|
var waiting = /* @__PURE__ */ new Map();
|
|
6
80
|
var conn = connect(socketPath);
|
|
@@ -32,6 +106,40 @@ conn.on("data", (chunk) => {
|
|
|
32
106
|
}
|
|
33
107
|
});
|
|
34
108
|
var send = (obj) => process.stdout.write(JSON.stringify(obj) + "\n");
|
|
109
|
+
function askBroker(ask) {
|
|
110
|
+
return new Promise((resolve) => {
|
|
111
|
+
waiting.set(String(ask.id), resolve);
|
|
112
|
+
if (conn.destroyed) return dead();
|
|
113
|
+
try {
|
|
114
|
+
conn.write(JSON.stringify(ask) + "\n");
|
|
115
|
+
} catch {
|
|
116
|
+
dead();
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
var ASK_USER_QUESTION = "AskUserQuestion";
|
|
121
|
+
var asRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
122
|
+
async function answerNativeQuestions(input) {
|
|
123
|
+
const questions = parseAskQuestions(input);
|
|
124
|
+
if (!questions?.length) {
|
|
125
|
+
return JSON.stringify({
|
|
126
|
+
behavior: "deny",
|
|
127
|
+
message: "OpenMausBot: this AskUserQuestion call had no answerable question (each one needs question text), so nobody was shown it. Ask again with a well-formed call, or continue without it."
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const answer = await askBroker({
|
|
131
|
+
t: "ask",
|
|
132
|
+
id: randomUUID(),
|
|
133
|
+
kind: "question",
|
|
134
|
+
tool: ASK_USER_QUESTION,
|
|
135
|
+
input: { questions }
|
|
136
|
+
});
|
|
137
|
+
if (answer.behavior === "deny") {
|
|
138
|
+
return JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
|
|
139
|
+
}
|
|
140
|
+
const answers = answer.source === "user" && typeof answer.message === "string" ? questionAnswersByQuestion(answer.message, questions) : {};
|
|
141
|
+
return JSON.stringify({ behavior: "allow", updatedInput: { ...asRecord(input), answers } });
|
|
142
|
+
}
|
|
35
143
|
var TOOLS = [
|
|
36
144
|
{
|
|
37
145
|
name: "approve",
|
|
@@ -79,30 +187,27 @@ async function handle(msg) {
|
|
|
79
187
|
if (msg.method === "tools/call") {
|
|
80
188
|
const name = msg.params?.name;
|
|
81
189
|
const args = msg.params?.arguments ?? {};
|
|
190
|
+
const reply = (text3) => send({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text: text3 }] } });
|
|
191
|
+
if (name === "approve" && args.tool_name === ASK_USER_QUESTION) {
|
|
192
|
+
return reply(await answerNativeQuestions(args.input));
|
|
193
|
+
}
|
|
82
194
|
const askId = randomUUID();
|
|
83
195
|
const isQuestion = name === "ask_user";
|
|
84
196
|
const suggestions = Array.isArray(args.permission_suggestions) ? args.permission_suggestions : Array.isArray(args.suggestions) ? args.suggestions : null;
|
|
85
|
-
const answer = await
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
conn.write(JSON.stringify(ask) + "\n");
|
|
91
|
-
} catch {
|
|
92
|
-
dead();
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
let text = answer.message || "No answer was given \u2014 use your best judgment.";
|
|
197
|
+
const answer = await askBroker(
|
|
198
|
+
isQuestion ? { t: "ask", id: askId, kind: "question", tool: "ask_user", input: { question: args.question, choices: args.choices } } : { t: "ask", id: askId, tool: args.tool_name, input: args.input }
|
|
199
|
+
);
|
|
200
|
+
let text2 = answer.message || "No answer was given \u2014 use your best judgment.";
|
|
96
201
|
if (!isQuestion) {
|
|
97
202
|
if (answer.behavior === "allow") {
|
|
98
203
|
const result = { behavior: "allow", updatedInput: args.input ?? {} };
|
|
99
204
|
if (answer.always && suggestions) result.updatedPermissions = suggestions;
|
|
100
|
-
|
|
205
|
+
text2 = JSON.stringify(result);
|
|
101
206
|
} else {
|
|
102
|
-
|
|
207
|
+
text2 = JSON.stringify({ behavior: "deny", message: answer.message || "Denied from OpenMausBot" });
|
|
103
208
|
}
|
|
104
209
|
}
|
|
105
|
-
return
|
|
210
|
+
return reply(text2);
|
|
106
211
|
}
|
|
107
212
|
if (String(msg.method ?? "").startsWith("notifications/")) return;
|
|
108
213
|
if (msg.id != null) {
|
|
@@ -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) {
|
|
@@ -17,7 +17,22 @@ export function approvalModeForOrigin(mode, origin) {
|
|
|
17
17
|
return "auto";
|
|
18
18
|
return mode;
|
|
19
19
|
}
|
|
20
|
+
// Tools that ask a PERSON something. A question exists so that a human
|
|
21
|
+
// decides; any mode answering one on their behalf defeats the only reason
|
|
22
|
+
// it was asked. They normally arrive typed as questions and never reach a
|
|
23
|
+
// verdict at all — this is the backstop for the path where one arrives
|
|
24
|
+
// mis-typed as a permission (a malformed AskUserQuestion call falls back to
|
|
25
|
+
// the permission path in permission-proxy). Approving it there does not
|
|
26
|
+
// produce an answer: the CLI runs the tool with none and the model is told
|
|
27
|
+
// "The user did not answer the questions." — a question silently lost.
|
|
28
|
+
const ASKS_A_PERSON = new Set(["askuserquestion", "ask_user"]);
|
|
20
29
|
export function autoVerdict(mode, tool, context) {
|
|
30
|
+
// A question is for a person, whatever channel it arrived on — and
|
|
31
|
+
// whatever the mode: even Full has no answer to give, only an approval
|
|
32
|
+
// that would run the tool with none.
|
|
33
|
+
if (ASKS_A_PERSON.has(tool.replace(/^mcp__[^_]+__/, "").toLowerCase())) {
|
|
34
|
+
return { approve: null, source: "no-grant" };
|
|
35
|
+
}
|
|
21
36
|
// Full's promise is literal: even a sandbox widening is approved. Entering
|
|
22
37
|
// Full is separately consent-gated by the bot PATCH endpoint, and the
|
|
23
38
|
// request.opened caller invokes this for permissions only, never questions.
|
|
@@ -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");
|