negotium 0.2.18 → 0.2.19

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.
@@ -23,7 +23,7 @@
23
23
  "import { createHmac } from \"node:crypto\";\n\nexport function deriveBgBashContextCapability(\n runtimeCapability: string,\n userId: string,\n topic: string,\n): string {\n return createHmac(\"sha256\", runtimeCapability).update(`${userId}\\0${topic}`).digest(\"hex\");\n}\n",
24
24
  "export type RuntimeMcpScope = \"dm\" | \"forum\" | \"fork\" | \"manager\" | \"cron\";\n\nexport interface RuntimeMcpPolicyEntry {\n scopes: readonly RuntimeMcpScope[];\n /** Forum entries marked required cannot be disabled by a topic whitelist. */\n forumRequired?: boolean;\n}\n\nexport const COMMON_RUNTIME_MCP_POLICY = {\n playwright: { scopes: [\"dm\", \"forum\", \"fork\", \"cron\"], forumRequired: true },\n runtime: { scopes: [\"forum\", \"manager\", \"fork\", \"cron\"], forumRequired: true },\n \"token-stats\": { scopes: [\"dm\", \"forum\", \"manager\", \"cron\"], forumRequired: true },\n task: { scopes: [\"dm\", \"forum\", \"manager\", \"cron\"], forumRequired: true },\n \"session-comm\": { scopes: [\"forum\", \"fork\", \"manager\"], forumRequired: true },\n wiki: { scopes: [\"dm\", \"forum\", \"manager\", \"cron\"], forumRequired: true },\n skills: { scopes: [\"dm\", \"forum\", \"manager\", \"cron\"], forumRequired: true },\n \"system-health\": { scopes: [\"dm\", \"forum\", \"manager\", \"cron\"], forumRequired: true },\n \"background-bash\": { scopes: [\"forum\"], forumRequired: true },\n \"agent-health\": { scopes: [\"forum\", \"manager\", \"cron\"], forumRequired: true },\n vault: { scopes: [\"dm\", \"forum\", \"manager\", \"cron\"], forumRequired: true },\n} as const satisfies Record<string, RuntimeMcpPolicyEntry>;\n\nexport type CommonRuntimeMcpName = keyof typeof COMMON_RUNTIME_MCP_POLICY;\n\nexport interface ForumMcpClassification {\n all: string[];\n required: string[];\n optional: string[];\n}\n\nexport function classifyForumMcpServers(\n catalog: Readonly<Record<string, RuntimeMcpPolicyEntry>>,\n): ForumMcpClassification {\n const all = Object.entries(catalog)\n .filter(([, entry]) => entry.scopes.includes(\"forum\"))\n .map(([name]) => name);\n const required = Object.entries(catalog)\n .filter(([, entry]) => entry.scopes.includes(\"forum\") && entry.forumRequired)\n .map(([name]) => name);\n const requiredSet = new Set(required);\n return { all, required, optional: all.filter((name) => !requiredSet.has(name)) };\n}\n\nexport function commonRuntimeMcpPolicy(name: CommonRuntimeMcpName): RuntimeMcpPolicyEntry {\n return COMMON_RUNTIME_MCP_POLICY[name];\n}\n\nexport function isForumRequiredMcp(name: string): boolean {\n const entry = COMMON_RUNTIME_MCP_POLICY[name as CommonRuntimeMcpName];\n return Boolean(entry?.scopes.includes(\"forum\") && entry.forumRequired);\n}\n",
25
25
  "import { createHmac } from \"node:crypto\";\n\nexport function browserOwnerCapability(capability: string, owner: string): string {\n return createHmac(\"sha256\", capability).update(owner).digest(\"hex\");\n}\n",
26
- "import { canonicalMcpBridgeEnv } from \"#mcp/canonical-bridge-config\";\nimport {\n buildHostedMcpSpec,\n buildRuntimeMcpSpec,\n type HostedMcpSurface,\n RUNTIME_MCP_KEY,\n} from \"#mcp/runtime-spec\";\nimport { peerSessionBridgeIpcEnv } from \"#mcp/session-comm/bridge-ipc-config\";\nimport { bgBashContextCapability } from \"#platform/background-bash/manager\";\nimport {\n AGENT_HEALTH_SERVER,\n BROWSER_MCP_SSE_PROXY_SERVER,\n CANONICAL_MCP_PROXY_SERVER,\n envText,\n FALLBACK_AGENT,\n resolveTopicWorkspaceDir,\n SESSION_COMM_SERVER,\n SYSTEM_HEALTH_SERVER,\n TASK_SERVER,\n TOKEN_STATS_SERVER,\n TSCONFIG_PATH,\n TSX_LOADER,\n VAULT_SERVER,\n WIKI_SERVER,\n} from \"#platform/config\";\nimport { logger } from \"#platform/logger\";\nimport {\n classifyForumMcpServers,\n commonRuntimeMcpPolicy,\n type RuntimeMcpScope,\n} from \"#platform/mcp-catalog-policy\";\nimport { browserOwnerCapability } from \"#platform/playwright/capability\";\nimport type { AgentKind, AgentQueryOptions, PeerRuntimeBridgeContext } from \"#types\";\n\nexport type { RuntimeMcpScope } from \"#platform/mcp-catalog-policy\";\n\n/**\n * Build the stdio transport spec for a Otium MCP server entry.\n *\n * claude/maestro launch the .ts server directly with `bun run` (fast, native\n * TypeScript + bun:sqlite). codex CANNOT — codex 0.135's rmcp stdio client\n * fails to handshake with bun-spawned servers (initialize is dropped/raced, so\n * no tools reach the model), while pure-node servers connect reliably. So for\n * the `codex` agent we launch the SAME server file via node + tsx, passing the\n * tsconfig explicitly (servers run with cwd ≠ PROJECT_ROOT, so tsx can't find\n * it on its own) so `@/*` path aliases resolve. Per-turn agent-aware config\n * means switching agents mid-topic just works: each turn re-spawns with the\n * right runtime.\n */\nexport function buildStdioMcpServer(\n agent: AgentKind | undefined,\n serverFile: string,\n serverArgs: string[],\n env?: Record<string, string>,\n): Record<string, unknown> {\n if (agent === \"codex\") {\n return {\n command: \"node\",\n args: [\"--import\", TSX_LOADER, serverFile, ...serverArgs],\n // Merged onto codex's inherited env (not a replacement) — verified.\n env: { TSX_TSCONFIG_PATH: TSCONFIG_PATH, ...env },\n };\n }\n return {\n command: \"bun\",\n args: [\"run\", serverFile, ...serverArgs],\n ...(env ? { env } : {}),\n };\n}\n\nfunction useHostedBuiltinMcp(): boolean {\n return envText(\"NEGOTIUM_BUILTIN_MCP_TRANSPORT\") !== \"stdio\";\n}\n\nfunction buildBuiltinMcpServer(\n surface: HostedMcpSurface,\n ctx: RuntimeMcpBuildContext,\n stdio: () => Record<string, unknown>,\n): Record<string, unknown> {\n if (!useHostedBuiltinMcp()) return stdio();\n const agent = ctx.agent ?? FALLBACK_AGENT;\n return buildHostedMcpSpec(agent, surface, {\n userId: ctx.userId,\n topicTitle: ctx.session,\n ...(ctx.topicId ? { topicId: ctx.topicId } : {}),\n ...(ctx.queryId ? { queryId: ctx.queryId } : {}),\n ...(ctx.wikiTopicId ? { wikiTopicId: ctx.wikiTopicId } : {}),\n ...(ctx.subagentParentTopicId ? { subagentParentTopicId: ctx.subagentParentTopicId } : {}),\n cwd: ctx.cwd ?? (ctx.topicId ? resolveTopicWorkspaceDir(ctx.topicId) : process.cwd()),\n agent,\n ...(ctx.model ? { model: ctx.model } : {}),\n ...(ctx.depth !== undefined ? { depth: ctx.depth } : {}),\n ...(ctx.silent !== undefined ? { silent: ctx.silent } : {}),\n ...(ctx.peerBridge ? { peerBridge: ctx.peerBridge } : {}),\n });\n}\n\n// --- Playwright unavailable notifier + per-turn state ---\n//\n// When the playwright manager fails to allocate a long-lived MCP port,\n// the catalog used to fall back to a per-turn stdio chromium that ran as\n// a child of the agent CLI subprocess — which meant Chromium was bound\n// to the agent's process tree and died on cascade-kill. We now skip the\n// browser entry entirely (the playwright entry is omitted from the\n// catalog for that turn) AND surface the gap two ways:\n//\n// 1. `notifyPlaywrightUnavailable` — fire-and-forget callback for the host\n// (telegram bot) to push an alert to the current topic. Throttled\n// per (userId, topic) with a 5-minute cooldown so a flaky\n// manager doesn't spam the chat. Per-process state — restart clears.\n//\n// 2. `consumePlaywrightUnavailable` — the system-prompt builder reads\n// this flag to add an explicit \"Playwright unavailable this turn\"\n// reminder to the agent's user message, so the model knows to\n// route around browser tools instead of hallucinating a call to a\n// tool that was silently removed from the schema list.\n\ntype PlaywrightUnavailableContext = {\n userId: string;\n topic: string | undefined;\n agent: AgentKind | undefined;\n};\n\ntype PlaywrightUnavailableNotifier = (ctx: PlaywrightUnavailableContext) => void;\n\nlet _playwrightUnavailableNotifier: PlaywrightUnavailableNotifier | undefined;\nconst _playwrightUnavailableLastNotifiedAt = new Map<string, number>();\nconst _PLAYWRIGHT_UNAVAILABLE_COOLDOWN_MS = 5 * 60_000;\n\nconst _playwrightUnavailableThisTurn = new Set<string>();\n\nfunction _playwrightUnavailableKey(userId: string, topic: string | undefined): string {\n return `${userId}:${topic ?? \"\"}`;\n}\n\n/**\n * Register the host's \"playwright unavailable\" alert sink. Called once at bot\n * startup. Idempotent — last writer wins.\n */\nexport function setPlaywrightUnavailableNotifier(cb: PlaywrightUnavailableNotifier): void {\n _playwrightUnavailableNotifier = cb;\n}\n\n/**\n * Mark the current turn as missing long-lived Playwright and notify the host.\n * The playwright catalog entry calls this when it is built without a port, but\n * route-level callers can call it earlier after ensurePlaywright fails so the\n * system prompt for the same turn can include the unavailable reminder.\n */\nexport function markPlaywrightUnavailable(ctx: PlaywrightUnavailableContext): void {\n _markPlaywrightUnavailable(ctx);\n}\n\nfunction _markPlaywrightUnavailable(ctx: PlaywrightUnavailableContext): void {\n // Mark unavailable for the next system-prompt build, regardless of\n // notifier presence (the model-side reminder must always fire so the\n // agent doesn't try to call a missing tool).\n _playwrightUnavailableThisTurn.add(_playwrightUnavailableKey(ctx.userId, ctx.topic));\n\n if (!_playwrightUnavailableNotifier) return;\n const key = _playwrightUnavailableKey(ctx.userId, ctx.topic);\n const now = Date.now();\n const last = _playwrightUnavailableLastNotifiedAt.get(key) ?? 0;\n if (now - last < _PLAYWRIGHT_UNAVAILABLE_COOLDOWN_MS) return;\n _playwrightUnavailableLastNotifiedAt.set(key, now);\n try {\n _playwrightUnavailableNotifier(ctx);\n } catch (err) {\n logger.warn({ err }, \"playwright unavailable notifier threw\");\n }\n}\n\n/**\n * Read + clear the \"playwright unavailable this turn\" flag for a\n * (user, group, topic). Used by the system-prompt builder right before\n * the agent query starts. Single consumer pattern — calling more than\n * once per turn returns `false` for the second caller (intended; the\n * reminder only needs to ride one prompt).\n */\nexport function consumePlaywrightUnavailable(userId: string, topic: string | undefined): boolean {\n const key = _playwrightUnavailableKey(userId, topic);\n if (!_playwrightUnavailableThisTurn.has(key)) return false;\n _playwrightUnavailableThisTurn.delete(key);\n return true;\n}\n\n/**\n * Single source of truth for MCP server registration.\n *\n * Each entry declares which scopes it participates in (`dm`, `forum`, `fork`, `cron`)\n * and how to build its spawn config given a context. Public helpers\n * (`getDmMcpServers`, `getForumMcpServers`, `getForkMcpServers`) and the\n * derived `ALL_FORUM_MCP_SERVER_NAMES` / `REQUIRED_FORUM_MCP_SERVERS`\n * constants all read from this catalog, so adding a new MCP server is a\n * one-line append here instead of touching three places.\n *\n * Conventions:\n * - `scopes` lists every scope the server should appear in by default.\n * - `forumRequired: true` keeps the server even when a forum topic\n * restricts the enabled list via `configure_mcp` (forum scope only).\n * - `build(ctx)` returns the raw transport config (Claude-SDK shape).\n * Codex callers funnel through `toCodexMcpServers` separately.\n *\n * Insertion order is preserved by `Object.entries`, which determines the\n * order of server names in user-facing displays.\n */\n\nexport interface RuntimeMcpBuildContext {\n userId: string;\n /** Vault namespace when credentials belong to a different principal. */\n vaultUserId?: string;\n /** \"dm\" for DM scope, topic/session name for forum/fork. */\n session: string;\n /** REST/WS topic id when known. Prefer for authorization/scope checks. */\n topicId?: string;\n /** Direct parent topic id when the current topic is a subagent room. */\n subagentParentTopicId?: string;\n /** REST/WS query id when known. */\n queryId?: string;\n /** Topic id whose wiki memory should be read/written. */\n wikiTopicId?: string;\n agent?: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n depth?: number;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n autoContinue?: boolean;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n silent?: boolean;\n peerBridge?: PeerRuntimeBridgeContext;\n}\n\nexport interface RuntimeMcpCatalogEntry {\n scopes: readonly RuntimeMcpScope[];\n /** Forum scope only: cannot be removed via the enabled whitelist. */\n forumRequired?: boolean;\n /**\n * Returns the raw transport spec (Claude-SDK shape), or `null` to\n * signal \"this server is unavailable for this turn — omit it from the\n * catalog entirely.\" Used by the playwright entry when the manager\n * cannot provide a long-lived port.\n */\n build(ctx: RuntimeMcpBuildContext): Record<string, unknown> | null;\n}\n\n// --- Playwright transport builders ---\n\nexport const CODEX_BROWSER_CAPABILITY_ENV = \"NEGOTIUM_BROWSER_CAPABILITY\";\n\nexport { browserOwnerCapability };\n\nexport function browserOwnerForContext(ctx: {\n userId?: string;\n session?: string;\n topicId?: string;\n}): string | undefined {\n if (ctx.topicId) return `topic:${ctx.topicId}`;\n if (ctx.userId && ctx.session) return `user:${ctx.userId}:${ctx.session}`;\n return undefined;\n}\n\nfunction playwrightTransport(port: number, owner: string, capability: string, agent?: AgentKind) {\n const ownerCapability = browserOwnerCapability(capability, owner);\n if (agent === \"codex\") {\n const query = new URLSearchParams({ owner });\n return {\n url: `http://127.0.0.1:${port}/mcp?${query}`,\n env_http_headers: { \"X-Browser-Capability\": CODEX_BROWSER_CAPABILITY_ENV },\n };\n }\n const query = new URLSearchParams({ owner });\n if (agent === \"maestro\") {\n // maestro-agent-sdk does not expose SSE headers. Route it through a\n // tiny stdio bridge so the bearer capability stays in an environment\n // variable rather than appearing in URLs and request logs.\n return buildStdioMcpServer(\"maestro\", BROWSER_MCP_SSE_PROXY_SERVER, [], {\n NEGOTIUM_BROWSER_SSE_URL: `http://127.0.0.1:${port}/sse?${query}`,\n NEGOTIUM_BROWSER_OWNER_CAPABILITY: ownerCapability,\n });\n }\n return {\n type: \"sse\" as const,\n url: `http://127.0.0.1:${port}/sse?${query}`,\n headers: { \"X-Browser-Capability\": ownerCapability },\n };\n}\n\nfunction longLivedHttpMcp(agent: AgentKind | undefined, port: number) {\n return agent === \"codex\"\n ? { url: `http://127.0.0.1:${port}/mcp` }\n : { type: \"sse\" as const, url: `http://127.0.0.1:${port}/sse` };\n}\n\nfunction backgroundBashTransport(\n agent: AgentKind | undefined,\n port: number,\n userId: string,\n topic: string,\n) {\n const capability = bgBashContextCapability(userId, topic);\n const headers = {\n \"X-Background-Bash-User\": userId,\n \"X-Background-Bash-Topic\": topic,\n \"X-Background-Bash-Capability\": capability,\n };\n if (agent === \"codex\") return { url: `http://127.0.0.1:${port}/mcp`, http_headers: headers };\n if (agent === \"maestro\") {\n const query = new URLSearchParams({ user: userId, topic, capability });\n return { type: \"sse\" as const, url: `http://127.0.0.1:${port}/sse?${query}` };\n }\n return { type: \"sse\" as const, url: `http://127.0.0.1:${port}/sse`, headers };\n}\n\n// --- Catalog ---\n\nconst MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {\n playwright: {\n ...commonRuntimeMcpPolicy(\"playwright\"),\n build({ userId, session, topicId, playwrightPort, playwrightCapability, agent }) {\n // Codex uses streamable HTTP while Claude and Maestro use SSE. Both\n // transports terminate at the same long-lived browser/profile server.\n if (playwrightPort && playwrightCapability) {\n const owner = browserOwnerForContext({ userId, session, topicId });\n if (!owner) return null;\n return playwrightTransport(playwrightPort, owner, playwrightCapability, agent);\n }\n // No port available — the playwright manager could not allocate one\n // for this turn. The previous behavior was to fall back to a per-turn\n // stdio chromium, but that made Chromium a child of the agent CLI\n // process tree, so cascade-kill on agent exit took the browser down\n // with it (visible in pm2 as \"Chromium died with claude\"). We now\n // omit the playwright entry from this turn's catalog AND fire two\n // side-effects:\n // 1. notify the host so it can push an alert into the current\n // topic (rate-limited 5min per scope key);\n // 2. mark the (user, topic) as \"playwright unavailable\n // this turn\" so the system-prompt builder can tell the model\n // explicitly, instead of letting the model hallucinate a call\n // to a tool whose schema silently vanished.\n _markPlaywrightUnavailable({\n userId,\n topic: session,\n agent,\n });\n return null;\n },\n },\n [RUNTIME_MCP_KEY]: {\n ...commonRuntimeMcpPolicy(\"runtime\"),\n build({\n userId,\n session,\n topicId,\n queryId,\n agent,\n cwd,\n model,\n currentUserPrompt,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n peerBridge,\n }) {\n if (!topicId || !agent) return null;\n return buildRuntimeMcpSpec(agent, {\n userId,\n topicId,\n topicTitle: session,\n queryId,\n cwd: cwd ?? resolveTopicWorkspaceDir(topicId),\n agent,\n model,\n currentUserPrompt,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n peerBridge,\n });\n },\n },\n \"token-stats\": {\n ...commonRuntimeMcpPolicy(\"token-stats\"),\n build(ctx) {\n return buildBuiltinMcpServer(\"token-stats\", ctx, () =>\n buildStdioMcpServer(ctx.agent, TOKEN_STATS_SERVER, [`--user-id=${ctx.userId}`]),\n );\n },\n },\n // Otium-owned shared task system. This is the only authoritative task/todo\n // surface across claude/codex/maestro; provider-native task stores are\n // blocked or ignored because they do not survive agent switches.\n task: {\n ...commonRuntimeMcpPolicy(\"task\"),\n build(ctx) {\n const { userId, session, topicId, queryId, agent, peerBridge } = ctx;\n if (peerBridge) {\n if (!topicId || !queryId) return null;\n const env = canonicalMcpBridgeEnv({\n surface: \"task\",\n userId,\n topicId,\n queryId,\n peerBridge,\n });\n return env\n ? buildStdioMcpServer(agent, CANONICAL_MCP_PROXY_SERVER, [\"--surface=task\"], env)\n : null;\n }\n const args = [`--user-id=${userId}`, `--topic=${session}`];\n if (topicId) args.push(`--topic-id=${topicId}`);\n return buildBuiltinMcpServer(\"task\", ctx, () =>\n buildStdioMcpServer(agent, TASK_SERVER, args),\n );\n },\n },\n \"session-comm\": {\n // Exposed in `manager` scope as well so the General-topic manager agent\n // can wake fresh-created topics via tell_session/ask_session right after\n // create_topic. Without this, a newly-created topic would stay in the\n // \"fresh-start ready\" state until the user manually visits it.\n ...commonRuntimeMcpPolicy(\"session-comm\"),\n build(ctx) {\n const {\n userId,\n session,\n topicId,\n subagentParentTopicId,\n agent,\n depth = 0,\n silent,\n peerBridge,\n } = ctx;\n const effectiveAgent = agent ?? FALLBACK_AGENT;\n const args = [\n `--user-id=${userId}`,\n `--topic=${session}`,\n ...(topicId ? [`--topic-id=${topicId}`] : []),\n ...(subagentParentTopicId ? [`--subagent-parent-topic-id=${subagentParentTopicId}`] : []),\n `--depth=${depth}`,\n `--agent=${effectiveAgent}`,\n ...(silent ? [\"--reply-only=true\"] : []),\n ...(peerBridge ? [`--peer-host-query-id=${peerBridge.hostQueryId}`] : []),\n ];\n return buildBuiltinMcpServer(\"session-comm\", ctx, () =>\n buildStdioMcpServer(\n effectiveAgent,\n SESSION_COMM_SERVER,\n args,\n peerBridge ? peerSessionBridgeIpcEnv() : undefined,\n ),\n );\n },\n },\n wiki: {\n ...commonRuntimeMcpPolicy(\"wiki\"),\n build(ctx) {\n const { userId, session, topicId, queryId, wikiTopicId, agent, peerBridge } = ctx;\n if (peerBridge) {\n if (!topicId || !queryId) return null;\n const env = canonicalMcpBridgeEnv({\n surface: \"wiki\",\n userId,\n topicId,\n queryId,\n peerBridge,\n });\n return env\n ? buildStdioMcpServer(agent, CANONICAL_MCP_PROXY_SERVER, [\"--surface=wiki\"], env)\n : null;\n }\n const args = [`--user-id=${userId}`];\n if (topicId) args.push(`--current-topic-id=${topicId}`);\n // Prefer the REST topic id so save_wiki_entry writes filenames and SQLite\n // briefs keyed the same way the topic wiki API reads them.\n const resolvedWikiTopicId =\n wikiTopicId ?? topicId ?? (session !== \"dm\" ? session : undefined);\n if (resolvedWikiTopicId) args.push(`--topic-id=${resolvedWikiTopicId}`);\n args.push(\"--surface=wiki\");\n return buildBuiltinMcpServer(\"wiki\", { ...ctx, wikiTopicId: resolvedWikiTopicId }, () =>\n buildStdioMcpServer(agent, WIKI_SERVER, args),\n );\n },\n },\n skills: {\n ...commonRuntimeMcpPolicy(\"skills\"),\n build(ctx) {\n const { userId, topicId, agent } = ctx;\n const args = [`--user-id=${userId}`, \"--surface=skills\"];\n if (topicId) args.push(`--topic-id=${topicId}`);\n return buildBuiltinMcpServer(\"skills\", ctx, () =>\n buildStdioMcpServer(agent, WIKI_SERVER, args),\n );\n },\n },\n \"system-health\": {\n ...commonRuntimeMcpPolicy(\"system-health\"),\n build(ctx) {\n return buildBuiltinMcpServer(\"system-health\", ctx, () =>\n buildStdioMcpServer(ctx.agent, SYSTEM_HEALTH_SERVER, []),\n );\n },\n },\n \"background-bash\": {\n ...commonRuntimeMcpPolicy(\"background-bash\"),\n build({ agent, bgBashPort, userId, topicId }) {\n // Key the whole pipeline by the canonical topic id: it flows through the\n // capability, the request context, and the completion inbox filename\n // (sessionInboxPath) so the exiting process's output is routed back to\n // the exact originating topic. A title/session fallback would be encoded\n // as if it were an id and the inbox worker would fail to resolve it, so\n // background-bash is simply unavailable without a topic id.\n if (bgBashPort === undefined || !topicId) return null;\n return backgroundBashTransport(agent, bgBashPort, userId, topicId);\n },\n },\n \"agent-health\": {\n ...commonRuntimeMcpPolicy(\"agent-health\"),\n build(ctx) {\n const { userId, agent } = ctx;\n const args = [`--user-id=${userId}`];\n return buildBuiltinMcpServer(\"agent-health\", ctx, () =>\n buildStdioMcpServer(agent, AGENT_HEALTH_SERVER, args),\n );\n },\n },\n vault: {\n ...commonRuntimeMcpPolicy(\"vault\"),\n build(ctx) {\n const { userId, vaultUserId, agent } = ctx;\n // Normal turns use {{KEY}} directly in tool inputs. Keep the Vault MCP\n // surface focused on key discovery; broker tools remain available from\n // the public factory for compatibility but are no longer the default UX.\n const args = [`--user-id=${vaultUserId ?? userId}`];\n if (agent !== \"codex\") args.push(\"--list-only=true\");\n return buildBuiltinMcpServer(\"vault\", { ...ctx, userId: vaultUserId ?? userId }, () =>\n buildStdioMcpServer(agent, VAULT_SERVER, args),\n );\n },\n },\n};\n\n// --- Derived catalog views ---\n\n/** All forum-eligible MCP server names, in display order. */\nconst allForumMcpServerNames: string[] = [];\nexport const ALL_FORUM_MCP_SERVER_NAMES: readonly string[] = allForumMcpServerNames;\n\n/** Forum servers that cannot be removed via the enabled whitelist. */\nconst requiredForumMcpServers: string[] = [];\nexport const REQUIRED_FORUM_MCP_SERVERS: readonly string[] = requiredForumMcpServers;\n\n/** Forum servers that can be toggled via the enabled whitelist (not required). */\nconst optionalForumMcpServers: string[] = [];\nexport const OPTIONAL_FORUM_MCP_SERVERS: readonly string[] = optionalForumMcpServers;\n\nfunction refreshForumCatalogViews(): void {\n const { all, required, optional } = classifyForumMcpServers(MCP_CATALOG);\n allForumMcpServerNames.splice(0, allForumMcpServerNames.length, ...all);\n requiredForumMcpServers.splice(0, requiredForumMcpServers.length, ...required);\n optionalForumMcpServers.splice(0, optionalForumMcpServers.length, ...optional);\n}\n\nrefreshForumCatalogViews();\n\n/**\n * Mount one MCP capability supplied by an optional node module.\n *\n * Registration happens once at node startup, never in the per-event hot path.\n * The returned cleanup closure only removes the exact entry installed by this\n * call, making module stop/restart safe.\n */\nexport function registerRuntimeMcpServer(name: string, entry: RuntimeMcpCatalogEntry): () => void {\n const key = name.trim();\n if (!key || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(key)) {\n throw new Error(`invalid runtime MCP server name: ${name}`);\n }\n if (MCP_CATALOG[key]) throw new Error(`runtime MCP server already registered: ${key}`);\n MCP_CATALOG[key] = entry;\n refreshForumCatalogViews();\n return () => {\n if (MCP_CATALOG[key] !== entry) return;\n delete MCP_CATALOG[key];\n refreshForumCatalogViews();\n };\n}\n\n/**\n * Format the active MCP config for display (used by get_mcp_config /\n * get_topic_mcp / configure_mcp response).\n *\n * In whitelist mode the DB stores only the user-chosen optional servers;\n * required servers are always active but never written to the whitelist.\n * This helper surfaces that split so the model never confuses \"not in\n * whitelist\" with \"not active\".\n */\nexport function formatMcpStatus(config: {\n enabled: string[] | null;\n extra?: Record<string, unknown>;\n}): string[] {\n const extraNames = Object.keys(config.extra ?? {});\n\n if (config.enabled === null) {\n const total = ALL_FORUM_MCP_SERVER_NAMES.length + extraNames.length;\n return [\n `설정 방식: 기본값 (전체 활성)`,\n `활성 서버 (${total}개): ${[...ALL_FORUM_MCP_SERVER_NAMES, ...extraNames].join(\", \")}`,\n `추가 서버: ${extraNames.length > 0 ? extraNames.join(\", \") : \"없음\"}`,\n ];\n }\n\n // whitelist mode: stored list may or may not include required servers\n // (old entries might have them; new entries won't). Either way, split\n // by required vs optional for display.\n const optionalActive = config.enabled.filter((n) => !REQUIRED_FORUM_MCP_SERVERS.includes(n));\n const total = REQUIRED_FORUM_MCP_SERVERS.length + optionalActive.length + extraNames.length;\n return [\n `설정 방식: whitelist`,\n `필수 서버 (항상 활성, ${REQUIRED_FORUM_MCP_SERVERS.length}개): ${REQUIRED_FORUM_MCP_SERVERS.join(\", \")}`,\n `선택 서버 (whitelist, ${optionalActive.length}개): ${optionalActive.length > 0 ? optionalActive.join(\", \") : \"없음\"}`,\n `실제 활성 합계: ${total}개`,\n `추가 서버: ${extraNames.length > 0 ? extraNames.join(\", \") : \"없음\"}`,\n ``,\n `선택 가능 서버 전체 (${OPTIONAL_FORUM_MCP_SERVERS.length}개): ${OPTIONAL_FORUM_MCP_SERVERS.join(\", \")}`,\n ];\n}\n\n// --- Builders for each scope ---\n\n// ── Node-assigned MCP servers (mcp-host manifest) ───────────────────\n//\n// The per-node MCP manifest (`negotium mcp add`) lives in @negotium/mcp-host,\n// which depends on core — so core cannot read it directly. Instead the host\n// process resolves the manifest at node start (ensuring long-lived http\n// servers are running and have ports) and installs the result here. Entries\n// then ride every eligible scope's catalog, subject to the same per-topic\n// `enabled` whitelist as optional built-ins.\n\nexport type NodeMcpEntry =\n | { key: string; kind: \"http\"; port: number }\n | { key: string; kind: \"stdio\"; command: string; args?: string[]; env?: Record<string, string> };\n\nlet nodeMcpEntries: NodeMcpEntry[] = [];\n\n/** Install the node's assigned MCP servers. Last writer wins (host restart-safe). */\nexport function setNodeMcpServers(entries: NodeMcpEntry[]): void {\n nodeMcpEntries = entries.filter((entry) => !(entry.key in MCP_CATALOG));\n const dropped = entries.length - nodeMcpEntries.length;\n if (dropped > 0) {\n logger.warn({ dropped }, \"setNodeMcpServers: entries shadowing built-in catalog keys ignored\");\n }\n}\n\nexport function getNodeMcpServers(): readonly NodeMcpEntry[] {\n return nodeMcpEntries;\n}\n\nfunction buildNodeMcpSpecs(\n agent: AgentKind | undefined,\n filter: (name: string) => boolean,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const entry of nodeMcpEntries) {\n if (!filter(entry.key)) continue;\n out[entry.key] =\n entry.kind === \"http\"\n ? longLivedHttpMcp(agent, entry.port)\n : {\n command: entry.command,\n args: entry.args ?? [],\n ...(entry.env ? { env: entry.env } : {}),\n };\n }\n return out;\n}\n\nfunction buildScope(\n scope: RuntimeMcpScope,\n ctx: RuntimeMcpBuildContext,\n filter: (name: string) => boolean = () => true,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [name, entry] of Object.entries(MCP_CATALOG)) {\n if (!entry.scopes.includes(scope)) continue;\n if (!filter(name)) continue;\n // `null` from build() means \"skip this server for this turn\" —\n // currently used by the playwright entry when no long-lived port\n // is available.\n const spec = entry.build(ctx);\n if (spec === null) continue;\n out[name] = spec;\n }\n // Node-assigned MCPs join every scope except cron (scheduled runs keep the\n // deliberately narrow built-in set).\n if (scope !== \"cron\") {\n Object.assign(out, buildNodeMcpSpecs(ctx.agent, filter));\n }\n return out;\n}\n\n/** DM session: catalog entries with `dm` scope, no whitelist. */\nexport function getDmMcpServers(opts: {\n userId: string;\n agent?: AgentKind;\n playwrightPort?: number;\n playwrightCapability?: string;\n}) {\n return buildScope(\"dm\", {\n userId: opts.userId,\n session: \"dm\",\n agent: opts.agent,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n });\n}\n\n/**\n * Manager session: lives in the caller's private General API topic. Owns topic CRUD,\n * invite/user admin, and cross-topic MCP toggling through `topic-admin`,\n * plus general utilities. Browser-heavy work belongs in dedicated topics, so\n * Playwright is intentionally excluded here.\n * No whitelist — manager-scope catalog entries are loaded as-is.\n */\nexport function getManagerMcpServers(opts: {\n userId: string;\n session?: string;\n topicId?: string;\n queryId?: string;\n wikiTopicId?: string;\n agent?: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n autoContinue?: boolean;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n}) {\n if (!opts.topicId) {\n throw new Error(\"getManagerMcpServers: private General topicId is required\");\n }\n const topicId = opts.topicId;\n return buildScope(\"manager\", {\n userId: opts.userId,\n session: opts.session ?? \"General\",\n topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId ?? topicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.currentUserPrompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n autoContinue: opts.autoContinue,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n}\n\n/**\n * Forum session: catalog entries with `forum` scope.\n * - `enabled === null` → all forum servers.\n * - `enabled` is an array → whitelist; required servers are always included.\n * - `extra` is merged on top, allowing one-off injections per topic.\n */\nexport function getForumMcpServers(opts: {\n userId: string;\n vaultUserId?: string;\n session: string;\n topicId?: string;\n subagentParentTopicId?: string;\n queryId?: string;\n wikiTopicId?: string;\n agent: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n autoContinue?: boolean;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n depth?: number;\n enabled?: string[] | null;\n extra?: Record<string, unknown>;\n silent?: boolean;\n peerBridge?: PeerRuntimeBridgeContext;\n}) {\n const {\n userId,\n vaultUserId,\n session,\n topicId,\n subagentParentTopicId,\n queryId,\n wikiTopicId,\n agent,\n cwd,\n model,\n currentUserPrompt,\n playwrightPort,\n playwrightCapability,\n depth = 0,\n enabled = null,\n extra = {},\n silent = false,\n bgBashPort,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n peerBridge,\n } = opts;\n\n const filter = (name: string) => {\n if (silent && name === \"task\") return false;\n if (enabled === null) return true;\n return (\n enabled.includes(name) || (REQUIRED_FORUM_MCP_SERVERS as readonly string[]).includes(name)\n );\n };\n\n const base = buildScope(\n \"forum\",\n {\n userId,\n vaultUserId,\n session,\n topicId,\n subagentParentTopicId,\n queryId,\n wikiTopicId,\n agent,\n cwd,\n model,\n currentUserPrompt,\n depth,\n playwrightPort,\n playwrightCapability,\n bgBashPort,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n silent,\n peerBridge,\n },\n filter,\n );\n\n return { ...base, ...extra };\n}\n\n/**\n * Cron session: scheduled background run for a concrete API topic. Keep the\n * catalog narrower than forum scope: runtime delivery, topic memory/status,\n * vault utilities and a caller-provided disposable Playwright port.\n */\nexport function getCronMcpServers(opts: {\n userId: string;\n session: string;\n topicId: string;\n queryId?: string;\n wikiTopicId?: string;\n agent: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n}) {\n return buildScope(\"cron\", {\n userId: opts.userId,\n session: opts.session,\n topicId: opts.topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId ?? opts.topicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.currentUserPrompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n autoContinue: false,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n}\n\n/**\n * Pick the right MCP server bundle for an agent query. DM/ephemeral sessions\n * get the DM bundle; forum sessions get the forum bundle with full options\n * forwarded. Returned shape is the Claude-SDK shape; Codex callers funnel it\n * through their own translator (`toCodexMcpServers`).\n */\nexport function getMcpServersForQuery(opts: AgentQueryOptions): Record<string, unknown> {\n if (opts.toolPolicy === \"none\") return {};\n if (opts.toolPolicy === \"compaction-log\") {\n const compactLog = opts.mcpExtra?.compact_log;\n return compactLog ? { compact_log: compactLog } : {};\n }\n if (opts.sessionType === \"cron\") {\n if (!opts.topicId) throw new Error(\"getMcpServersForQuery: cron sessionType requires topicId\");\n return getCronMcpServers({\n userId: opts.userId || \"local\",\n session: opts.session || \"cron\",\n topicId: opts.topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.prompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n }\n if (opts.sessionType === \"dm\" || opts.sessionType === \"ephemeral\") {\n return getDmMcpServers({\n userId: opts.userId || \"local\",\n agent: opts.agent,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n });\n }\n if (opts.sessionType === \"manager\") {\n return getManagerMcpServers({\n userId: opts.userId || \"local\",\n session: opts.session,\n topicId: opts.topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.prompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n autoContinue: opts.autoContinue,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n }\n return getForumMcpServers({\n userId: opts.userId || \"local\",\n vaultUserId: opts.vaultUserId,\n session: opts.session || \"default\",\n topicId: opts.topicId,\n subagentParentTopicId: opts.subagentParentTopicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.prompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n bgBashPort: opts.bgBashPort,\n autoContinue: opts.autoContinue,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n depth: opts.depth,\n enabled: opts.mcpEnabled,\n extra: opts.mcpExtra,\n silent: opts.silent,\n peerBridge: opts.peerBridge,\n });\n}\n",
26
+ "import { canonicalMcpBridgeEnv } from \"#mcp/canonical-bridge-config\";\nimport {\n buildHostedMcpSpec,\n buildRuntimeMcpSpec,\n type HostedMcpSurface,\n RUNTIME_MCP_KEY,\n} from \"#mcp/runtime-spec\";\nimport { peerSessionBridgeIpcEnv } from \"#mcp/session-comm/bridge-ipc-config\";\nimport { bgBashContextCapability } from \"#platform/background-bash/manager\";\nimport {\n AGENT_HEALTH_SERVER,\n BROWSER_MCP_SSE_PROXY_SERVER,\n CANONICAL_MCP_PROXY_SERVER,\n envText,\n FALLBACK_AGENT,\n resolveTopicWorkspaceDir,\n SESSION_COMM_SERVER,\n SYSTEM_HEALTH_SERVER,\n TASK_SERVER,\n TOKEN_STATS_SERVER,\n TSCONFIG_PATH,\n TSX_LOADER,\n VAULT_SERVER,\n WIKI_SERVER,\n} from \"#platform/config\";\nimport { logger } from \"#platform/logger\";\nimport {\n classifyForumMcpServers,\n commonRuntimeMcpPolicy,\n type RuntimeMcpScope,\n} from \"#platform/mcp-catalog-policy\";\nimport { browserOwnerCapability } from \"#platform/playwright/capability\";\nimport type { AgentKind, AgentQueryOptions, PeerRuntimeBridgeContext } from \"#types\";\n\nexport type { RuntimeMcpScope } from \"#platform/mcp-catalog-policy\";\n\n/**\n * Build the stdio transport spec for a Otium MCP server entry.\n *\n * claude/maestro launch the .ts server directly with `bun run` (fast, native\n * TypeScript + bun:sqlite). codex CANNOT — codex 0.135's rmcp stdio client\n * fails to handshake with bun-spawned servers (initialize is dropped/raced, so\n * no tools reach the model), while pure-node servers connect reliably. So for\n * the `codex` agent we launch the SAME server file via node + tsx, passing the\n * tsconfig explicitly (servers run with cwd ≠ PROJECT_ROOT, so tsx can't find\n * it on its own) so `@/*` path aliases resolve. Per-turn agent-aware config\n * means switching agents mid-topic just works: each turn re-spawns with the\n * right runtime.\n */\nexport function buildStdioMcpServer(\n agent: AgentKind | undefined,\n serverFile: string,\n serverArgs: string[],\n env?: Record<string, string>,\n): Record<string, unknown> {\n if (agent === \"codex\") {\n return {\n command: \"node\",\n args: [\"--import\", TSX_LOADER, serverFile, ...serverArgs],\n // Merged onto codex's inherited env (not a replacement) — verified.\n env: { TSX_TSCONFIG_PATH: TSCONFIG_PATH, ...env },\n };\n }\n return {\n command: \"bun\",\n args: [\"run\", serverFile, ...serverArgs],\n ...(env ? { env } : {}),\n };\n}\n\nfunction useHostedBuiltinMcp(): boolean {\n return envText(\"NEGOTIUM_BUILTIN_MCP_TRANSPORT\") !== \"stdio\";\n}\n\nfunction buildBuiltinMcpServer(\n surface: HostedMcpSurface,\n ctx: RuntimeMcpBuildContext,\n stdio: () => Record<string, unknown>,\n): Record<string, unknown> {\n if (!useHostedBuiltinMcp()) return stdio();\n const agent = ctx.agent ?? FALLBACK_AGENT;\n return buildHostedMcpSpec(agent, surface, {\n userId: ctx.userId,\n topicTitle: ctx.session,\n ...(ctx.topicId ? { topicId: ctx.topicId } : {}),\n ...(ctx.queryId ? { queryId: ctx.queryId } : {}),\n ...(ctx.wikiTopicId ? { wikiTopicId: ctx.wikiTopicId } : {}),\n ...(ctx.subagentParentTopicId ? { subagentParentTopicId: ctx.subagentParentTopicId } : {}),\n cwd: ctx.cwd ?? (ctx.topicId ? resolveTopicWorkspaceDir(ctx.topicId) : process.cwd()),\n agent,\n ...(ctx.model ? { model: ctx.model } : {}),\n ...(ctx.depth !== undefined ? { depth: ctx.depth } : {}),\n ...(ctx.silent !== undefined ? { silent: ctx.silent } : {}),\n ...(ctx.peerBridge ? { peerBridge: ctx.peerBridge } : {}),\n });\n}\n\n// --- Playwright unavailable notifier + per-turn state ---\n//\n// When the playwright manager fails to allocate a long-lived MCP port,\n// the catalog used to fall back to a per-turn stdio chromium that ran as\n// a child of the agent CLI subprocess — which meant Chromium was bound\n// to the agent's process tree and died on cascade-kill. We now skip the\n// browser entry entirely (the playwright entry is omitted from the\n// catalog for that turn) AND surface the gap two ways:\n//\n// 1. `notifyPlaywrightUnavailable` — fire-and-forget callback for the host\n// (telegram bot) to push an alert to the current topic. Throttled\n// per (userId, topic) with a 5-minute cooldown so a flaky\n// manager doesn't spam the chat. Per-process state — restart clears.\n//\n// 2. `consumePlaywrightUnavailable` — the system-prompt builder reads\n// this flag to add an explicit \"Playwright unavailable this turn\"\n// reminder to the agent's user message, so the model knows to\n// route around browser tools instead of hallucinating a call to a\n// tool that was silently removed from the schema list.\n\ntype PlaywrightUnavailableContext = {\n userId: string;\n topic: string | undefined;\n agent: AgentKind | undefined;\n};\n\ntype PlaywrightUnavailableNotifier = (ctx: PlaywrightUnavailableContext) => void;\n\nlet _playwrightUnavailableNotifier: PlaywrightUnavailableNotifier | undefined;\nconst _playwrightUnavailableLastNotifiedAt = new Map<string, number>();\nconst _PLAYWRIGHT_UNAVAILABLE_COOLDOWN_MS = 5 * 60_000;\n\nconst _playwrightUnavailableThisTurn = new Set<string>();\n\nfunction _playwrightUnavailableKey(userId: string, topic: string | undefined): string {\n return `${userId}:${topic ?? \"\"}`;\n}\n\n/**\n * Register the host's \"playwright unavailable\" alert sink. Called once at bot\n * startup. Idempotent — last writer wins.\n */\nexport function setPlaywrightUnavailableNotifier(cb: PlaywrightUnavailableNotifier): void {\n _playwrightUnavailableNotifier = cb;\n}\n\n/**\n * Mark the current turn as missing long-lived Playwright and notify the host.\n * The playwright catalog entry calls this when it is built without a port, but\n * route-level callers can call it earlier after ensurePlaywright fails so the\n * system prompt for the same turn can include the unavailable reminder.\n */\nexport function markPlaywrightUnavailable(ctx: PlaywrightUnavailableContext): void {\n _markPlaywrightUnavailable(ctx);\n}\n\nfunction _markPlaywrightUnavailable(ctx: PlaywrightUnavailableContext): void {\n // Mark unavailable for the next system-prompt build, regardless of\n // notifier presence (the model-side reminder must always fire so the\n // agent doesn't try to call a missing tool).\n _playwrightUnavailableThisTurn.add(_playwrightUnavailableKey(ctx.userId, ctx.topic));\n\n if (!_playwrightUnavailableNotifier) return;\n const key = _playwrightUnavailableKey(ctx.userId, ctx.topic);\n const now = Date.now();\n const last = _playwrightUnavailableLastNotifiedAt.get(key) ?? 0;\n if (now - last < _PLAYWRIGHT_UNAVAILABLE_COOLDOWN_MS) return;\n _playwrightUnavailableLastNotifiedAt.set(key, now);\n try {\n _playwrightUnavailableNotifier(ctx);\n } catch (err) {\n logger.warn({ err }, \"playwright unavailable notifier threw\");\n }\n}\n\n/**\n * Read + clear the \"playwright unavailable this turn\" flag for a\n * (user, group, topic). Used by the system-prompt builder right before\n * the agent query starts. Single consumer pattern — calling more than\n * once per turn returns `false` for the second caller (intended; the\n * reminder only needs to ride one prompt).\n */\nexport function consumePlaywrightUnavailable(userId: string, topic: string | undefined): boolean {\n const key = _playwrightUnavailableKey(userId, topic);\n if (!_playwrightUnavailableThisTurn.has(key)) return false;\n _playwrightUnavailableThisTurn.delete(key);\n return true;\n}\n\n/**\n * Single source of truth for MCP server registration.\n *\n * Each entry declares which scopes it participates in (`dm`, `forum`, `fork`, `cron`)\n * and how to build its spawn config given a context. Public helpers\n * (`getDmMcpServers`, `getForumMcpServers`, `getForkMcpServers`) and the\n * derived `ALL_FORUM_MCP_SERVER_NAMES` / `REQUIRED_FORUM_MCP_SERVERS`\n * constants all read from this catalog, so adding a new MCP server is a\n * one-line append here instead of touching three places.\n *\n * Conventions:\n * - `scopes` lists every scope the server should appear in by default.\n * - `forumRequired: true` keeps the server even when a forum topic\n * restricts the enabled list via `configure_mcp` (forum scope only).\n * - `build(ctx)` returns the raw transport config (Claude-SDK shape).\n * Codex callers funnel through `toCodexMcpServers` separately.\n *\n * Insertion order is preserved by `Object.entries`, which determines the\n * order of server names in user-facing displays.\n */\n\nexport interface RuntimeMcpBuildContext {\n userId: string;\n /** Vault namespace when credentials belong to a different principal. */\n vaultUserId?: string;\n /** \"dm\" for DM scope, topic/session name for forum/fork. */\n session: string;\n /** REST/WS topic id when known. Prefer for authorization/scope checks. */\n topicId?: string;\n /** Direct parent topic id when the current topic is a subagent room. */\n subagentParentTopicId?: string;\n /** REST/WS query id when known. */\n queryId?: string;\n /** Topic id whose wiki memory should be read/written. */\n wikiTopicId?: string;\n agent?: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n depth?: number;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n autoContinue?: boolean;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n silent?: boolean;\n peerBridge?: PeerRuntimeBridgeContext;\n}\n\nexport interface RuntimeMcpCatalogEntry {\n scopes: readonly RuntimeMcpScope[];\n /** Forum scope only: cannot be removed via the enabled whitelist. */\n forumRequired?: boolean;\n /**\n * Returns the raw transport spec (Claude-SDK shape), or `null` to\n * signal \"this server is unavailable for this turn — omit it from the\n * catalog entirely.\" Used by the playwright entry when the manager\n * cannot provide a long-lived port.\n */\n build(ctx: RuntimeMcpBuildContext): Record<string, unknown> | null;\n}\n\n// --- Playwright transport builders ---\n\nexport const CODEX_BROWSER_CAPABILITY_ENV = \"NEGOTIUM_BROWSER_CAPABILITY\";\n\nexport { browserOwnerCapability };\n\nexport function browserOwnerForContext(ctx: {\n userId?: string;\n session?: string;\n topicId?: string;\n}): string | undefined {\n if (ctx.topicId) return `topic:${ctx.topicId}`;\n if (ctx.userId && ctx.session) return `user:${ctx.userId}:${ctx.session}`;\n return undefined;\n}\n\nfunction playwrightTransport(port: number, owner: string, capability: string, agent?: AgentKind) {\n const ownerCapability = browserOwnerCapability(capability, owner);\n if (agent === \"codex\") {\n const query = new URLSearchParams({ owner });\n return {\n url: `http://127.0.0.1:${port}/mcp?${query}`,\n env_http_headers: { \"X-Browser-Capability\": CODEX_BROWSER_CAPABILITY_ENV },\n };\n }\n const query = new URLSearchParams({ owner });\n if (agent === \"maestro\") {\n // maestro-agent-sdk does not expose SSE headers. Route it through a\n // tiny stdio bridge so the bearer capability stays in an environment\n // variable rather than appearing in URLs and request logs.\n return buildStdioMcpServer(\"maestro\", BROWSER_MCP_SSE_PROXY_SERVER, [], {\n NEGOTIUM_BROWSER_SSE_URL: `http://127.0.0.1:${port}/sse?${query}`,\n NEGOTIUM_BROWSER_OWNER_CAPABILITY: ownerCapability,\n });\n }\n return {\n type: \"sse\" as const,\n url: `http://127.0.0.1:${port}/sse?${query}`,\n headers: { \"X-Browser-Capability\": ownerCapability },\n };\n}\n\nfunction longLivedHttpMcp(agent: AgentKind | undefined, port: number) {\n return agent === \"codex\"\n ? { url: `http://127.0.0.1:${port}/mcp` }\n : { type: \"sse\" as const, url: `http://127.0.0.1:${port}/sse` };\n}\n\nfunction backgroundBashTransport(\n agent: AgentKind | undefined,\n port: number,\n userId: string,\n topic: string,\n) {\n const capability = bgBashContextCapability(userId, topic);\n const headers = {\n \"X-Background-Bash-User\": userId,\n \"X-Background-Bash-Topic\": topic,\n \"X-Background-Bash-Capability\": capability,\n };\n if (agent === \"codex\") return { url: `http://127.0.0.1:${port}/mcp`, http_headers: headers };\n if (agent === \"maestro\") {\n const query = new URLSearchParams({ user: userId, topic, capability });\n return { type: \"sse\" as const, url: `http://127.0.0.1:${port}/sse?${query}` };\n }\n return { type: \"sse\" as const, url: `http://127.0.0.1:${port}/sse`, headers };\n}\n\n// --- Catalog ---\n\nconst MCP_CATALOG: Record<string, RuntimeMcpCatalogEntry> = {\n playwright: {\n ...commonRuntimeMcpPolicy(\"playwright\"),\n build({ userId, session, topicId, playwrightPort, playwrightCapability, agent }) {\n // Codex uses streamable HTTP while Claude and Maestro use SSE. Both\n // transports terminate at the same long-lived browser/profile server.\n if (playwrightPort && playwrightCapability) {\n const owner = browserOwnerForContext({ userId, session, topicId });\n if (!owner) return null;\n return playwrightTransport(playwrightPort, owner, playwrightCapability, agent);\n }\n // No port available — the playwright manager could not allocate one\n // for this turn. The previous behavior was to fall back to a per-turn\n // stdio chromium, but that made Chromium a child of the agent CLI\n // process tree, so cascade-kill on agent exit took the browser down\n // with it (visible in pm2 as \"Chromium died with claude\"). We now\n // omit the playwright entry from this turn's catalog AND fire two\n // side-effects:\n // 1. notify the host so it can push an alert into the current\n // topic (rate-limited 5min per scope key);\n // 2. mark the (user, topic) as \"playwright unavailable\n // this turn\" so the system-prompt builder can tell the model\n // explicitly, instead of letting the model hallucinate a call\n // to a tool whose schema silently vanished.\n _markPlaywrightUnavailable({\n userId,\n topic: session,\n agent,\n });\n return null;\n },\n },\n [RUNTIME_MCP_KEY]: {\n ...commonRuntimeMcpPolicy(\"runtime\"),\n build({\n userId,\n session,\n topicId,\n queryId,\n agent,\n cwd,\n model,\n currentUserPrompt,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n peerBridge,\n }) {\n if (!topicId || !agent) return null;\n return buildRuntimeMcpSpec(agent, {\n userId,\n topicId,\n topicTitle: session,\n queryId,\n cwd: cwd ?? resolveTopicWorkspaceDir(topicId),\n agent,\n model,\n currentUserPrompt,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n peerBridge,\n });\n },\n },\n \"token-stats\": {\n ...commonRuntimeMcpPolicy(\"token-stats\"),\n build(ctx) {\n return buildBuiltinMcpServer(\"token-stats\", ctx, () =>\n buildStdioMcpServer(ctx.agent, TOKEN_STATS_SERVER, [`--user-id=${ctx.userId}`]),\n );\n },\n },\n // Otium-owned shared task system. This is the only authoritative task/todo\n // surface across claude/codex/maestro; provider-native task stores are\n // blocked or ignored because they do not survive agent switches.\n task: {\n ...commonRuntimeMcpPolicy(\"task\"),\n build(ctx) {\n const { userId, session, topicId, queryId, agent, peerBridge } = ctx;\n if (peerBridge) {\n if (!topicId || !queryId) return null;\n const env = canonicalMcpBridgeEnv({\n surface: \"task\",\n userId,\n topicId,\n queryId,\n peerBridge,\n });\n return env\n ? buildStdioMcpServer(agent, CANONICAL_MCP_PROXY_SERVER, [\"--surface=task\"], env)\n : null;\n }\n const args = [`--user-id=${userId}`, `--topic=${session}`];\n if (topicId) args.push(`--topic-id=${topicId}`);\n return buildBuiltinMcpServer(\"task\", ctx, () =>\n buildStdioMcpServer(agent, TASK_SERVER, args),\n );\n },\n },\n \"session-comm\": {\n // Exposed in `manager` scope as well so the General-topic manager agent\n // can wake fresh-created topics via tell_session/ask_session right after\n // create_topic. Without this, a newly-created topic would stay in the\n // \"fresh-start ready\" state until the user manually visits it.\n ...commonRuntimeMcpPolicy(\"session-comm\"),\n build(ctx) {\n const {\n userId,\n session,\n topicId,\n subagentParentTopicId,\n agent,\n depth = 0,\n silent,\n peerBridge,\n } = ctx;\n const effectiveAgent = agent ?? FALLBACK_AGENT;\n const args = [\n `--user-id=${userId}`,\n `--topic=${session}`,\n ...(topicId ? [`--topic-id=${topicId}`] : []),\n ...(subagentParentTopicId ? [`--subagent-parent-topic-id=${subagentParentTopicId}`] : []),\n `--depth=${depth}`,\n `--agent=${effectiveAgent}`,\n ...(silent ? [\"--reply-only=true\"] : []),\n ...(peerBridge ? [`--peer-host-query-id=${peerBridge.hostQueryId}`] : []),\n ];\n return buildBuiltinMcpServer(\"session-comm\", ctx, () =>\n buildStdioMcpServer(\n effectiveAgent,\n SESSION_COMM_SERVER,\n args,\n peerBridge ? peerSessionBridgeIpcEnv() : undefined,\n ),\n );\n },\n },\n wiki: {\n ...commonRuntimeMcpPolicy(\"wiki\"),\n build(ctx) {\n const { userId, session, topicId, queryId, wikiTopicId, agent, peerBridge } = ctx;\n if (peerBridge) {\n if (!topicId || !queryId) return null;\n const env = canonicalMcpBridgeEnv({\n surface: \"wiki\",\n userId,\n topicId,\n queryId,\n peerBridge,\n });\n return env\n ? buildStdioMcpServer(agent, CANONICAL_MCP_PROXY_SERVER, [\"--surface=wiki\"], env)\n : null;\n }\n const args = [`--user-id=${userId}`];\n if (topicId) args.push(`--current-topic-id=${topicId}`);\n // Prefer the REST topic id so wiki_write writes filenames and SQLite\n // briefs keyed the same way the topic wiki API reads them.\n const resolvedWikiTopicId =\n wikiTopicId ?? topicId ?? (session !== \"dm\" ? session : undefined);\n if (resolvedWikiTopicId) args.push(`--topic-id=${resolvedWikiTopicId}`);\n args.push(\"--surface=wiki\");\n return buildBuiltinMcpServer(\"wiki\", { ...ctx, wikiTopicId: resolvedWikiTopicId }, () =>\n buildStdioMcpServer(agent, WIKI_SERVER, args),\n );\n },\n },\n skills: {\n ...commonRuntimeMcpPolicy(\"skills\"),\n build(ctx) {\n const { userId, topicId, agent } = ctx;\n const args = [`--user-id=${userId}`, \"--surface=skills\"];\n if (topicId) args.push(`--topic-id=${topicId}`);\n return buildBuiltinMcpServer(\"skills\", ctx, () =>\n buildStdioMcpServer(agent, WIKI_SERVER, args),\n );\n },\n },\n \"system-health\": {\n ...commonRuntimeMcpPolicy(\"system-health\"),\n build(ctx) {\n return buildBuiltinMcpServer(\"system-health\", ctx, () =>\n buildStdioMcpServer(ctx.agent, SYSTEM_HEALTH_SERVER, []),\n );\n },\n },\n \"background-bash\": {\n ...commonRuntimeMcpPolicy(\"background-bash\"),\n build({ agent, bgBashPort, userId, topicId }) {\n // Key the whole pipeline by the canonical topic id: it flows through the\n // capability, the request context, and the completion inbox filename\n // (sessionInboxPath) so the exiting process's output is routed back to\n // the exact originating topic. A title/session fallback would be encoded\n // as if it were an id and the inbox worker would fail to resolve it, so\n // background-bash is simply unavailable without a topic id.\n if (bgBashPort === undefined || !topicId) return null;\n return backgroundBashTransport(agent, bgBashPort, userId, topicId);\n },\n },\n \"agent-health\": {\n ...commonRuntimeMcpPolicy(\"agent-health\"),\n build(ctx) {\n const { userId, agent } = ctx;\n const args = [`--user-id=${userId}`];\n return buildBuiltinMcpServer(\"agent-health\", ctx, () =>\n buildStdioMcpServer(agent, AGENT_HEALTH_SERVER, args),\n );\n },\n },\n vault: {\n ...commonRuntimeMcpPolicy(\"vault\"),\n build(ctx) {\n const { userId, vaultUserId, agent } = ctx;\n // Normal turns use {{KEY}} directly in tool inputs. Keep the Vault MCP\n // surface focused on key discovery; broker tools remain available from\n // the public factory for compatibility but are no longer the default UX.\n const args = [`--user-id=${vaultUserId ?? userId}`];\n if (agent !== \"codex\") args.push(\"--list-only=true\");\n return buildBuiltinMcpServer(\"vault\", { ...ctx, userId: vaultUserId ?? userId }, () =>\n buildStdioMcpServer(agent, VAULT_SERVER, args),\n );\n },\n },\n};\n\n// --- Derived catalog views ---\n\n/** All forum-eligible MCP server names, in display order. */\nconst allForumMcpServerNames: string[] = [];\nexport const ALL_FORUM_MCP_SERVER_NAMES: readonly string[] = allForumMcpServerNames;\n\n/** Forum servers that cannot be removed via the enabled whitelist. */\nconst requiredForumMcpServers: string[] = [];\nexport const REQUIRED_FORUM_MCP_SERVERS: readonly string[] = requiredForumMcpServers;\n\n/** Forum servers that can be toggled via the enabled whitelist (not required). */\nconst optionalForumMcpServers: string[] = [];\nexport const OPTIONAL_FORUM_MCP_SERVERS: readonly string[] = optionalForumMcpServers;\n\nfunction refreshForumCatalogViews(): void {\n const { all, required, optional } = classifyForumMcpServers(MCP_CATALOG);\n allForumMcpServerNames.splice(0, allForumMcpServerNames.length, ...all);\n requiredForumMcpServers.splice(0, requiredForumMcpServers.length, ...required);\n optionalForumMcpServers.splice(0, optionalForumMcpServers.length, ...optional);\n}\n\nrefreshForumCatalogViews();\n\n/**\n * Mount one MCP capability supplied by an optional node module.\n *\n * Registration happens once at node startup, never in the per-event hot path.\n * The returned cleanup closure only removes the exact entry installed by this\n * call, making module stop/restart safe.\n */\nexport function registerRuntimeMcpServer(name: string, entry: RuntimeMcpCatalogEntry): () => void {\n const key = name.trim();\n if (!key || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(key)) {\n throw new Error(`invalid runtime MCP server name: ${name}`);\n }\n if (MCP_CATALOG[key]) throw new Error(`runtime MCP server already registered: ${key}`);\n MCP_CATALOG[key] = entry;\n refreshForumCatalogViews();\n return () => {\n if (MCP_CATALOG[key] !== entry) return;\n delete MCP_CATALOG[key];\n refreshForumCatalogViews();\n };\n}\n\n/**\n * Format the active MCP config for display (used by get_mcp_config /\n * get_topic_mcp / configure_mcp response).\n *\n * In whitelist mode the DB stores only the user-chosen optional servers;\n * required servers are always active but never written to the whitelist.\n * This helper surfaces that split so the model never confuses \"not in\n * whitelist\" with \"not active\".\n */\nexport function formatMcpStatus(config: {\n enabled: string[] | null;\n extra?: Record<string, unknown>;\n}): string[] {\n const extraNames = Object.keys(config.extra ?? {});\n\n if (config.enabled === null) {\n const total = ALL_FORUM_MCP_SERVER_NAMES.length + extraNames.length;\n return [\n `설정 방식: 기본값 (전체 활성)`,\n `활성 서버 (${total}개): ${[...ALL_FORUM_MCP_SERVER_NAMES, ...extraNames].join(\", \")}`,\n `추가 서버: ${extraNames.length > 0 ? extraNames.join(\", \") : \"없음\"}`,\n ];\n }\n\n // whitelist mode: stored list may or may not include required servers\n // (old entries might have them; new entries won't). Either way, split\n // by required vs optional for display.\n const optionalActive = config.enabled.filter((n) => !REQUIRED_FORUM_MCP_SERVERS.includes(n));\n const total = REQUIRED_FORUM_MCP_SERVERS.length + optionalActive.length + extraNames.length;\n return [\n `설정 방식: whitelist`,\n `필수 서버 (항상 활성, ${REQUIRED_FORUM_MCP_SERVERS.length}개): ${REQUIRED_FORUM_MCP_SERVERS.join(\", \")}`,\n `선택 서버 (whitelist, ${optionalActive.length}개): ${optionalActive.length > 0 ? optionalActive.join(\", \") : \"없음\"}`,\n `실제 활성 합계: ${total}개`,\n `추가 서버: ${extraNames.length > 0 ? extraNames.join(\", \") : \"없음\"}`,\n ``,\n `선택 가능 서버 전체 (${OPTIONAL_FORUM_MCP_SERVERS.length}개): ${OPTIONAL_FORUM_MCP_SERVERS.join(\", \")}`,\n ];\n}\n\n// --- Builders for each scope ---\n\n// ── Node-assigned MCP servers (mcp-host manifest) ───────────────────\n//\n// The per-node MCP manifest (`negotium mcp add`) lives in @negotium/mcp-host,\n// which depends on core — so core cannot read it directly. Instead the host\n// process resolves the manifest at node start (ensuring long-lived http\n// servers are running and have ports) and installs the result here. Entries\n// then ride every eligible scope's catalog, subject to the same per-topic\n// `enabled` whitelist as optional built-ins.\n\nexport type NodeMcpEntry =\n | { key: string; kind: \"http\"; port: number }\n | { key: string; kind: \"stdio\"; command: string; args?: string[]; env?: Record<string, string> };\n\nlet nodeMcpEntries: NodeMcpEntry[] = [];\n\n/** Install the node's assigned MCP servers. Last writer wins (host restart-safe). */\nexport function setNodeMcpServers(entries: NodeMcpEntry[]): void {\n nodeMcpEntries = entries.filter((entry) => !(entry.key in MCP_CATALOG));\n const dropped = entries.length - nodeMcpEntries.length;\n if (dropped > 0) {\n logger.warn({ dropped }, \"setNodeMcpServers: entries shadowing built-in catalog keys ignored\");\n }\n}\n\nexport function getNodeMcpServers(): readonly NodeMcpEntry[] {\n return nodeMcpEntries;\n}\n\nfunction buildNodeMcpSpecs(\n agent: AgentKind | undefined,\n filter: (name: string) => boolean,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const entry of nodeMcpEntries) {\n if (!filter(entry.key)) continue;\n out[entry.key] =\n entry.kind === \"http\"\n ? longLivedHttpMcp(agent, entry.port)\n : {\n command: entry.command,\n args: entry.args ?? [],\n ...(entry.env ? { env: entry.env } : {}),\n };\n }\n return out;\n}\n\nfunction buildScope(\n scope: RuntimeMcpScope,\n ctx: RuntimeMcpBuildContext,\n filter: (name: string) => boolean = () => true,\n): Record<string, unknown> {\n const out: Record<string, unknown> = {};\n for (const [name, entry] of Object.entries(MCP_CATALOG)) {\n if (!entry.scopes.includes(scope)) continue;\n if (!filter(name)) continue;\n // `null` from build() means \"skip this server for this turn\" —\n // currently used by the playwright entry when no long-lived port\n // is available.\n const spec = entry.build(ctx);\n if (spec === null) continue;\n out[name] = spec;\n }\n // Node-assigned MCPs join every scope except cron (scheduled runs keep the\n // deliberately narrow built-in set).\n if (scope !== \"cron\") {\n Object.assign(out, buildNodeMcpSpecs(ctx.agent, filter));\n }\n return out;\n}\n\n/** DM session: catalog entries with `dm` scope, no whitelist. */\nexport function getDmMcpServers(opts: {\n userId: string;\n agent?: AgentKind;\n playwrightPort?: number;\n playwrightCapability?: string;\n}) {\n return buildScope(\"dm\", {\n userId: opts.userId,\n session: \"dm\",\n agent: opts.agent,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n });\n}\n\n/**\n * Manager session: lives in the caller's private General API topic. Owns topic CRUD,\n * invite/user admin, and cross-topic MCP toggling through `topic-admin`,\n * plus general utilities. Browser-heavy work belongs in dedicated topics, so\n * Playwright is intentionally excluded here.\n * No whitelist — manager-scope catalog entries are loaded as-is.\n */\nexport function getManagerMcpServers(opts: {\n userId: string;\n session?: string;\n topicId?: string;\n queryId?: string;\n wikiTopicId?: string;\n agent?: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n autoContinue?: boolean;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n}) {\n if (!opts.topicId) {\n throw new Error(\"getManagerMcpServers: private General topicId is required\");\n }\n const topicId = opts.topicId;\n return buildScope(\"manager\", {\n userId: opts.userId,\n session: opts.session ?? \"General\",\n topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId ?? topicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.currentUserPrompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n autoContinue: opts.autoContinue,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n}\n\n/**\n * Forum session: catalog entries with `forum` scope.\n * - `enabled === null` → all forum servers.\n * - `enabled` is an array → whitelist; required servers are always included.\n * - `extra` is merged on top, allowing one-off injections per topic.\n */\nexport function getForumMcpServers(opts: {\n userId: string;\n vaultUserId?: string;\n session: string;\n topicId?: string;\n subagentParentTopicId?: string;\n queryId?: string;\n wikiTopicId?: string;\n agent: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n bgBashPort?: number;\n autoContinue?: boolean;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n depth?: number;\n enabled?: string[] | null;\n extra?: Record<string, unknown>;\n silent?: boolean;\n peerBridge?: PeerRuntimeBridgeContext;\n}) {\n const {\n userId,\n vaultUserId,\n session,\n topicId,\n subagentParentTopicId,\n queryId,\n wikiTopicId,\n agent,\n cwd,\n model,\n currentUserPrompt,\n playwrightPort,\n playwrightCapability,\n depth = 0,\n enabled = null,\n extra = {},\n silent = false,\n bgBashPort,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n peerBridge,\n } = opts;\n\n const filter = (name: string) => {\n if (silent && name === \"task\") return false;\n if (enabled === null) return true;\n return (\n enabled.includes(name) || (REQUIRED_FORUM_MCP_SERVERS as readonly string[]).includes(name)\n );\n };\n\n const base = buildScope(\n \"forum\",\n {\n userId,\n vaultUserId,\n session,\n topicId,\n subagentParentTopicId,\n queryId,\n wikiTopicId,\n agent,\n cwd,\n model,\n currentUserPrompt,\n depth,\n playwrightPort,\n playwrightCapability,\n bgBashPort,\n autoContinue,\n visualTools,\n fileDeliveryTools,\n silent,\n peerBridge,\n },\n filter,\n );\n\n return { ...base, ...extra };\n}\n\n/**\n * Cron session: scheduled background run for a concrete API topic. Keep the\n * catalog narrower than forum scope: runtime delivery, topic memory/status,\n * vault utilities and a caller-provided disposable Playwright port.\n */\nexport function getCronMcpServers(opts: {\n userId: string;\n session: string;\n topicId: string;\n queryId?: string;\n wikiTopicId?: string;\n agent: AgentKind;\n cwd?: string;\n model?: string;\n currentUserPrompt?: string;\n playwrightPort?: number;\n playwrightCapability?: string;\n visualTools?: boolean;\n fileDeliveryTools?: boolean;\n}) {\n return buildScope(\"cron\", {\n userId: opts.userId,\n session: opts.session,\n topicId: opts.topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId ?? opts.topicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.currentUserPrompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n autoContinue: false,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n}\n\n/**\n * Pick the right MCP server bundle for an agent query. DM/ephemeral sessions\n * get the DM bundle; forum sessions get the forum bundle with full options\n * forwarded. Returned shape is the Claude-SDK shape; Codex callers funnel it\n * through their own translator (`toCodexMcpServers`).\n */\nexport function getMcpServersForQuery(opts: AgentQueryOptions): Record<string, unknown> {\n if (opts.toolPolicy === \"none\") return {};\n if (opts.toolPolicy === \"compaction-log\") {\n const compactLog = opts.mcpExtra?.compact_log;\n return compactLog ? { compact_log: compactLog } : {};\n }\n if (opts.sessionType === \"cron\") {\n if (!opts.topicId) throw new Error(\"getMcpServersForQuery: cron sessionType requires topicId\");\n return getCronMcpServers({\n userId: opts.userId || \"local\",\n session: opts.session || \"cron\",\n topicId: opts.topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.prompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n }\n if (opts.sessionType === \"dm\" || opts.sessionType === \"ephemeral\") {\n return getDmMcpServers({\n userId: opts.userId || \"local\",\n agent: opts.agent,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n });\n }\n if (opts.sessionType === \"manager\") {\n return getManagerMcpServers({\n userId: opts.userId || \"local\",\n session: opts.session,\n topicId: opts.topicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.prompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n autoContinue: opts.autoContinue,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n });\n }\n return getForumMcpServers({\n userId: opts.userId || \"local\",\n vaultUserId: opts.vaultUserId,\n session: opts.session || \"default\",\n topicId: opts.topicId,\n subagentParentTopicId: opts.subagentParentTopicId,\n queryId: opts.queryId,\n wikiTopicId: opts.wikiTopicId,\n agent: opts.agent,\n cwd: opts.cwd,\n model: opts.model,\n currentUserPrompt: opts.prompt,\n playwrightPort: opts.playwrightPort,\n playwrightCapability: opts.playwrightCapability,\n bgBashPort: opts.bgBashPort,\n autoContinue: opts.autoContinue,\n visualTools: opts.visualTools,\n fileDeliveryTools: opts.fileDeliveryTools,\n depth: opts.depth,\n enabled: opts.mcpEnabled,\n extra: opts.mcpExtra,\n silent: opts.silent,\n peerBridge: opts.peerBridge,\n });\n}\n",
27
27
  "import { chmodSync, mkdirSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { DATA_DIR, VAULT_MASTER_KEY } from \"#platform/config\";\nimport { Database } from \"#storage/sqlite\";\nimport { decryptVaultValue, encryptVaultValue } from \"#storage/vault-crypto\";\n\nexport type VaultDatabase = Pick<InstanceType<typeof Database>, \"exec\" | \"prepare\">;\n\nexport interface VaultStorageOptions {\n database?: VaultDatabase;\n dataDir?: string;\n masterKey?: string;\n}\n\nlet vaultDb: VaultDatabase | undefined;\nlet vaultMasterKey = VAULT_MASTER_KEY;\n\nfunction initializeVaultDatabase(database: VaultDatabase): void {\n database.exec(\"PRAGMA journal_mode = WAL\");\n database.exec(\"PRAGMA busy_timeout = 5000\");\n database.exec(`\n CREATE TABLE IF NOT EXISTS vault (\n user_id TEXT NOT NULL,\n key TEXT NOT NULL,\n value TEXT NOT NULL,\n description TEXT NOT NULL DEFAULT '',\n PRIMARY KEY (user_id, key)\n )\n `);\n\n // Migrate legacy schema where user_id was INTEGER (Telegram numeric ids only).\n // The Otium API authenticates with string user ids (\"otium-user-…\"), so user_id\n // is now TEXT for every channel. Rebuild + CAST existing rows so old Telegram\n // secrets survive; idempotent (no-op once user_id is already TEXT).\n {\n const cols = database.prepare(\"PRAGMA table_info(vault)\").all() as {\n name: string;\n type: string;\n }[];\n const uid = cols.find((c) => c.name === \"user_id\");\n if (uid && uid.type.toUpperCase() === \"INTEGER\") {\n database.exec(\"BEGIN\");\n database.exec(`\n CREATE TABLE vault_migrated (\n user_id TEXT NOT NULL,\n key TEXT NOT NULL,\n value TEXT NOT NULL,\n description TEXT NOT NULL DEFAULT '',\n PRIMARY KEY (user_id, key)\n )\n `);\n database.exec(\n \"INSERT INTO vault_migrated SELECT CAST(user_id AS TEXT), key, value, description FROM vault\",\n );\n database.exec(\"DROP TABLE vault\");\n database.exec(\"ALTER TABLE vault_migrated RENAME TO vault\");\n database.exec(\"COMMIT\");\n }\n }\n}\n\nfunction openVaultDatabase(dataDir: string): VaultDatabase {\n const vaultDir = join(dataDir, \"vault\");\n const path = join(vaultDir, \"vault.db\");\n mkdirSync(vaultDir, { recursive: true, mode: 0o700 });\n const database = new Database(path, { create: true });\n // Defense in depth: the database and its sidecars are owner-only, while each\n // value is also encrypted independently by vault-crypto.\n chmodSync(path, 0o600);\n initializeVaultDatabase(database);\n return database;\n}\n\nfunction activeVaultDatabase(): VaultDatabase {\n if (!vaultDb) vaultDb = openVaultDatabase(DATA_DIR);\n return vaultDb;\n}\n\n/** Configure process-wide vault storage during embedding-host bootstrap. */\nexport function configureVaultStorage(options: VaultStorageOptions): () => void {\n if (options.database && options.dataDir) {\n throw new Error(\"configureVaultStorage accepts either database or dataDir, not both\");\n }\n const previousDb = vaultDb;\n const previousMasterKey = vaultMasterKey;\n const configuredDb = options.database ?? openVaultDatabase(options.dataDir ?? DATA_DIR);\n if (options.database) initializeVaultDatabase(configuredDb);\n vaultDb = configuredDb;\n vaultMasterKey = options.masterKey ?? VAULT_MASTER_KEY;\n let disposed = false;\n return () => {\n if (disposed) return;\n disposed = true;\n if (!options.database && \"close\" in configuredDb) {\n (configuredDb as VaultDatabase & { close(): void }).close();\n }\n vaultDb = previousDb;\n vaultMasterKey = previousMasterKey;\n };\n}\n\nexport interface VaultEntry {\n key: string;\n description: string;\n}\n\nexport interface VaultEntryWithValue extends VaultEntry {\n value: string;\n}\n\nexport const VAULT_KEY_PATTERN = /^[A-Z][A-Z0-9_]{0,127}$/;\nexport const VAULT_VALUE_MIN_BYTES = 4;\nexport const VAULT_VALUE_MAX_BYTES = 64 * 1024;\nexport const VAULT_DESCRIPTION_MAX_LENGTH = 500;\n\nexport function normalizeVaultKey(key: string): string {\n return key.trim().toUpperCase();\n}\n\nexport function validateVaultKey(key: string): boolean {\n return VAULT_KEY_PATTERN.test(normalizeVaultKey(key));\n}\n\nfunction decryptRow(userId: string, key: string, storedValue: string): string {\n const database = activeVaultDatabase();\n const decoded = decryptVaultValue(userId, key, storedValue, vaultMasterKey);\n if (decoded.legacyPlaintext) {\n database\n .prepare(\"UPDATE vault SET value = ? WHERE user_id = ? AND key = ? AND value = ?\")\n .run(encryptVaultValue(userId, key, decoded.value, vaultMasterKey), userId, key, storedValue);\n }\n return decoded.value;\n}\n\nexport function vaultListWithValues(userId: string): VaultEntryWithValue[] {\n const rows = activeVaultDatabase()\n .prepare(\"SELECT key, description, value FROM vault WHERE user_id = ? ORDER BY key\")\n .all(userId) as VaultEntryWithValue[];\n return rows.map((row) => ({\n key: row.key,\n description: row.description,\n value: decryptRow(userId, row.key, row.value),\n }));\n}\n\n/**\n * Return only rows that can still be authenticated with the active master key.\n *\n * This is intentionally limited to redaction indexes: one damaged credential\n * must not disable unrelated tools, while direct access to that credential\n * continues to fail closed through vaultGetValue().\n */\nexport function vaultListDecryptableValues(userId: string): VaultEntryWithValue[] {\n const rows = activeVaultDatabase()\n .prepare(\"SELECT key, description, value FROM vault WHERE user_id = ? ORDER BY key\")\n .all(userId) as VaultEntryWithValue[];\n const entries: VaultEntryWithValue[] = [];\n for (const row of rows) {\n try {\n entries.push({\n key: row.key,\n description: row.description,\n value: decryptRow(userId, row.key, row.value),\n });\n } catch {\n // A value that cannot be authenticated cannot be exposed by substitution,\n // so there is no plaintext form available to add to the redaction index.\n }\n }\n return entries;\n}\n\nexport function vaultSet(userId: string, key: string, value: string, description = \"\"): void {\n const normalizedKey = normalizeVaultKey(key);\n activeVaultDatabase()\n .prepare(\n `INSERT INTO vault (user_id, key, value, description) VALUES (?, ?, ?, ?)\n ON CONFLICT (user_id, key) DO UPDATE\n SET value = excluded.value, description = excluded.description`,\n )\n .run(\n userId,\n normalizedKey,\n encryptVaultValue(userId, normalizedKey, value, vaultMasterKey),\n description,\n );\n}\n\nexport function vaultHasKey(userId: string, key: string): boolean {\n const row = activeVaultDatabase()\n .prepare(\"SELECT 1 FROM vault WHERE user_id = ? AND key = ?\")\n .get(userId, normalizeVaultKey(key));\n return row != null;\n}\n\nexport function vaultDel(userId: string, key: string): boolean {\n const result = activeVaultDatabase()\n .prepare(\"DELETE FROM vault WHERE user_id = ? AND key = ?\")\n .run(userId, normalizeVaultKey(key)) as { changes: number };\n return result.changes > 0;\n}\n\nexport function vaultDeleteAllForUser(userId: string): number {\n const result = activeVaultDatabase()\n .prepare(\"DELETE FROM vault WHERE user_id = ?\")\n .run(userId) as {\n changes: number;\n };\n return result.changes;\n}\n\nexport function vaultList(userId: string): VaultEntry[] {\n return activeVaultDatabase()\n .prepare(\"SELECT key, description FROM vault WHERE user_id = ? ORDER BY key\")\n .all(userId) as VaultEntry[];\n}\n\nexport function vaultGetValue(userId: string, key: string): string | undefined {\n const normalizedKey = normalizeVaultKey(key);\n const row = activeVaultDatabase()\n .prepare(\"SELECT key, value FROM vault WHERE user_id = ? AND key = ?\")\n .get(userId, normalizedKey) as { key: string; value: string } | undefined;\n return row ? decryptRow(userId, row.key, row.value) : undefined;\n}\n\nexport interface VaultSubstitutionResult {\n text: string;\n usedKeys: string[];\n}\n\n/** Replace {{KEY}} placeholders and report which credentials were consumed. */\nexport function vaultSubstituteDetailed(userId: string, text: string): VaultSubstitutionResult {\n const entries = new Map<string, string | undefined>();\n const usedKeys = new Set<string>();\n const substituted = text.replace(/\\{\\{([^}]+)\\}\\}/g, (match, rawKey: string) => {\n const key = normalizeVaultKey(rawKey);\n if (!entries.has(key)) entries.set(key, vaultGetValue(userId, key));\n const value = entries.get(key);\n if (value === undefined) return match;\n usedKeys.add(key);\n return value;\n });\n return { text: substituted, usedKeys: [...usedKeys] };\n}\n\n/** Detect real {{KEY}} references without decrypting any Vault values. */\nexport function valueReferencesVaultKey(userId: string, value: unknown): boolean {\n const keys = new Set(vaultList(userId).map((entry) => entry.key));\n const visit = (candidate: unknown): boolean => {\n if (typeof candidate === \"string\") {\n for (const match of candidate.matchAll(/\\{\\{([^}]+)\\}\\}/g)) {\n if (keys.has(normalizeVaultKey(match[1] ?? \"\"))) return true;\n }\n return false;\n }\n if (Array.isArray(candidate)) return candidate.some(visit);\n if (candidate && typeof candidate === \"object\") {\n return Object.values(candidate as Record<string, unknown>).some(visit);\n }\n return false;\n };\n return visit(value);\n}\n\nfunction encodedSecretForms(value: string): string[] {\n const forms = new Set<string>([\n value,\n encodeURIComponent(value),\n Buffer.from(value, \"utf8\").toString(\"base64\"),\n Buffer.from(value, \"utf8\").toString(\"base64url\"),\n Buffer.from(value, \"utf8\").toString(\"hex\"),\n ]);\n forms.delete(\"\");\n return [...forms].sort((a, b) => b.length - a.length);\n}\n\n/** Scrub raw and common encoded forms before tool output reaches a model. */\nexport function redactVaultSecrets(userId: string, text: string): string {\n const candidates = vaultListDecryptableValues(userId)\n .flatMap((entry) => encodedSecretForms(entry.value).map((form) => ({ form, key: entry.key })))\n .sort((a, b) => b.form.length - a.form.length || a.key.localeCompare(b.key));\n if (candidates.length === 0) return text;\n const candidatesByFirstCharacter = new Map<string, typeof candidates>();\n for (const candidate of candidates) {\n const first = candidate.form[0];\n if (!first) continue;\n const bucket = candidatesByFirstCharacter.get(first) ?? [];\n bucket.push(candidate);\n candidatesByFirstCharacter.set(first, bucket);\n }\n\n let redacted = \"\";\n let offset = 0;\n while (offset < text.length) {\n const match = candidatesByFirstCharacter\n .get(text[offset] ?? \"\")\n ?.find((candidate) => text.startsWith(candidate.form, offset));\n if (!match) {\n redacted += text[offset];\n offset += 1;\n continue;\n }\n redacted += `[REDACTED:${match.key}]`;\n offset += match.form.length;\n }\n return redacted;\n}\n",
28
28
  "// Runtime-adaptive SQLite façade.\n//\n// Why this exists: Otium normally runs on bun and uses `bun:sqlite` (native,\n// fast, zero-build). But the `codex` agent's MCP servers must run on pure node\n// — codex 0.135's rmcp stdio client can't handshake with bun-spawned servers\n// (see serverLaunch in platform/mcp-config.ts). `bun:sqlite` doesn't exist\n// under node, so any MCP server that touches the DB would crash on import.\n//\n// This module picks the backend at runtime and re-exports it as `Database`:\n// - under bun → the real `bun:sqlite` Database (unchanged behavior)\n// - under node → a thin shim over node:sqlite's DatabaseSync exposing the\n// exact subset Otium uses: new Database(path, {readonly?,create?}),\n// .query()/.prepare() → statement, .exec(), .run(), .transaction(fn),\n// .close(); statements support positional `?` params via .run()/.get()/\n// .all(). (No named-object params / .values()/.iterate()/.as() are used in\n// this codebase, so the shim intentionally omits them.)\n//\n// The TYPE of the exported `Database` is bun:sqlite's own class type (imported\n// type-only, so it's erased at runtime and never resolved under node). That\n// keeps every existing call site — including generic `.query<Row, Params>()`\n// usages — type-checking exactly as before with zero changes.\n//\n// The bun branch uses a dynamic import so node never resolves the `bun:sqlite`\n// specifier (dead code under node), and the node branch's `node:sqlite` import\n// never runs under bun.\n\nimport type { Database as BunDatabase } from \"bun:sqlite\";\n\n// `mock.module()` can replace the Bun global while a test graph is being\n// evaluated. The runtime version flag is stable and is also the documented\n// cross-runtime discriminator.\nconst isBun = typeof process.versions.bun === \"string\";\n\ntype DatabaseCtor = typeof BunDatabase;\n\nlet Database: DatabaseCtor;\n\nif (isBun) {\n ({ Database } = await import(\"bun:sqlite\"));\n} else {\n // node:sqlite has no type declarations under our tsconfig (`types: bun-types`\n // only), and `bun-types` doesn't ship them — so type the dynamic import\n // locally and use a `string` specifier to keep tsc from trying to resolve it.\n type NodeStatement = {\n run(...params: unknown[]): unknown;\n get(...params: unknown[]): unknown;\n all(...params: unknown[]): unknown[];\n };\n type NodeDatabaseSync = {\n prepare(sql: string): NodeStatement;\n exec(sql: string): void;\n close(): void;\n };\n type NodeSqliteModule = {\n DatabaseSync: new (path: string, options?: { readOnly?: boolean }) => NodeDatabaseSync;\n };\n // Keep Bun's resolver from eagerly resolving the Node-only builtin even\n // though this branch is dead there (notably under `bun test` + mock.module).\n const nodeSqliteSpecifier = [\"node\", \"sqlite\"].join(\":\");\n const { DatabaseSync } = (await import(nodeSqliteSpecifier)) as NodeSqliteModule;\n\n type DbOptions = { readonly?: boolean; create?: boolean };\n\n class NodeDatabase {\n #db: NodeDatabaseSync;\n\n constructor(path: string, options: DbOptions = {}) {\n // node:sqlite opens read-write and creates-if-missing by default, which\n // matches bun's `{create:true}`. For readonly we must not create.\n this.#db = options.readonly\n ? new DatabaseSync(path, { readOnly: true })\n : new DatabaseSync(path);\n }\n\n query(sql: string) {\n return this.#db.prepare(sql);\n }\n\n prepare(sql: string) {\n return this.#db.prepare(sql);\n }\n\n exec(sql: string): void {\n this.#db.exec(sql);\n }\n\n run(sql: string, ...params: unknown[]) {\n return this.#db.prepare(sql).run(...params);\n }\n\n transaction<Args extends unknown[], R>(fn: (...args: Args) => R): (...args: Args) => R {\n // bun's db.transaction(fn) returns a function that runs fn inside a\n // transaction when called. node:sqlite has no helper, so emulate with\n // BEGIN/COMMIT/ROLLBACK. It also exposes deferred/immediate/exclusive\n // variants; mirror those so Node-launched MCP servers can share call\n // sites with Bun.\n const run =\n (begin: \"BEGIN\" | \"BEGIN DEFERRED\" | \"BEGIN IMMEDIATE\" | \"BEGIN EXCLUSIVE\") =>\n (...args: Args): R => {\n this.#db.exec(begin);\n try {\n const result = fn(...args);\n this.#db.exec(\"COMMIT\");\n return result;\n } catch (err) {\n this.#db.exec(\"ROLLBACK\");\n throw err;\n }\n };\n const tx = run(\"BEGIN\") as ((...args: Args) => R) & {\n deferred: (...args: Args) => R;\n immediate: (...args: Args) => R;\n exclusive: (...args: Args) => R;\n };\n tx.deferred = run(\"BEGIN DEFERRED\");\n tx.immediate = run(\"BEGIN IMMEDIATE\");\n tx.exclusive = run(\"BEGIN EXCLUSIVE\");\n return tx;\n }\n\n close(): void {\n this.#db.close();\n }\n }\n\n Database = NodeDatabase as unknown as DatabaseCtor;\n}\n\nexport { Database };\n",
29
29
  "import { createCipheriv, createDecipheriv, createHash, randomBytes } from \"node:crypto\";\n\nconst ENVELOPE_PREFIX = \"otium-vault:v1:\";\nconst IV_BYTES = 12;\nconst KEY_BYTES = 32;\n\nfunction encryptionKey(masterKey: string): Buffer {\n return createHash(\"sha256\")\n .update(\"otium-vault-value-v1\\0\", \"utf8\")\n .update(masterKey, \"utf8\")\n .digest()\n .subarray(0, KEY_BYTES);\n}\n\nfunction aad(userId: string, key: string): Buffer {\n return Buffer.from(`${userId}\\0${key.toUpperCase()}`, \"utf8\");\n}\n\nexport function isEncryptedVaultValue(value: string): boolean {\n return value.startsWith(ENVELOPE_PREFIX);\n}\n\nexport function encryptVaultValueWithKey(\n userId: string,\n key: string,\n value: string,\n masterKey: string,\n): string {\n const iv = randomBytes(IV_BYTES);\n const cipher = createCipheriv(\"aes-256-gcm\", encryptionKey(masterKey), iv);\n cipher.setAAD(aad(userId, key));\n const ciphertext = Buffer.concat([cipher.update(value, \"utf8\"), cipher.final()]);\n const tag = cipher.getAuthTag();\n return `${ENVELOPE_PREFIX}${iv.toString(\"base64url\")}.${ciphertext.toString(\"base64url\")}.${tag.toString(\"base64url\")}`;\n}\n\nexport function decryptVaultValueWithKey(\n userId: string,\n key: string,\n storedValue: string,\n masterKey: string,\n): { value: string; legacyPlaintext: boolean } {\n if (!isEncryptedVaultValue(storedValue)) {\n return { value: storedValue, legacyPlaintext: true };\n }\n\n const encoded = storedValue.slice(ENVELOPE_PREFIX.length);\n const [ivPart, ciphertextPart, tagPart, ...extra] = encoded.split(\".\");\n if (!ivPart || ciphertextPart === undefined || !tagPart || extra.length > 0) {\n throw new Error(\"Invalid encrypted vault value\");\n }\n\n const iv = Buffer.from(ivPart, \"base64url\");\n const ciphertext = Buffer.from(ciphertextPart, \"base64url\");\n const tag = Buffer.from(tagPart, \"base64url\");\n if (iv.length !== IV_BYTES || tag.length !== 16) {\n throw new Error(\"Invalid encrypted vault value\");\n }\n\n const decipher = createDecipheriv(\"aes-256-gcm\", encryptionKey(masterKey), iv);\n decipher.setAAD(aad(userId, key));\n decipher.setAuthTag(tag);\n const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]);\n return { value: plaintext.toString(\"utf8\"), legacyPlaintext: false };\n}\n",
@@ -2237,7 +2237,7 @@ import { tmpdir } from "os";
2237
2237
  import { dirname as dirname4, join as join3 } from "path";
2238
2238
 
2239
2239
  // ../../packages/core/src/version.ts
2240
- var NEGOTIUM_VERSION = "0.2.18";
2240
+ var NEGOTIUM_VERSION = "0.2.19";
2241
2241
 
2242
2242
  // ../../packages/core/src/agents/codex-native-multi-agent.ts
2243
2243
  var moduleRequire = createRequire2(import.meta.url);
@@ -3928,4 +3928,4 @@ export {
3928
3928
  buildClaudeDisallowedTools
3929
3929
  };
3930
3930
 
3931
- //# debugId=B7228547780E965864756E2164756E21
3931
+ //# debugId=AAFB3B393DF7264D64756E2164756E21