negotium 0.2.26 → 0.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/agent-helpers.js +200 -118
  2. package/dist/agent-helpers.js.map +13 -13
  3. package/dist/hosted-agent.js +2 -2
  4. package/dist/hosted-agent.js.map +2 -2
  5. package/dist/main.js +1124 -511
  6. package/dist/main.js.map +43 -41
  7. package/dist/mcp-factories.js +294 -216
  8. package/dist/mcp-factories.js.map +13 -13
  9. package/dist/registry.js +2 -2
  10. package/dist/registry.js.map +2 -2
  11. package/dist/runtime/src/application/submit-runtime-gateway-turn.ts +8 -0
  12. package/dist/runtime/src/index.ts +6 -0
  13. package/dist/runtime/src/mcp/session-comm/default-host.ts +25 -6
  14. package/dist/runtime/src/mcp/session-comm/peer-forward.ts +14 -3
  15. package/dist/runtime/src/mcp/session-comm/server.ts +1 -1
  16. package/dist/runtime/src/mcp/session-comm/topics.ts +52 -16
  17. package/dist/runtime/src/mcp-runtime-host.ts +2 -2
  18. package/dist/runtime/src/node-host.ts +1 -1
  19. package/dist/runtime/src/runtime/turn-event-stream.ts +9 -1
  20. package/dist/runtime/src/runtime/turn-runner.ts +11 -2
  21. package/dist/runtime/src/storage/api-topics.ts +217 -32
  22. package/dist/runtime/src/storage/runtime-turn-requests.ts +26 -2
  23. package/dist/runtime/src/storage/token-stats.ts +27 -2
  24. package/dist/runtime/src/topics/create.ts +27 -1
  25. package/dist/runtime/src/topics/derive.ts +20 -6
  26. package/dist/runtime/src/topics/lifecycle.ts +2 -0
  27. package/dist/runtime/src/topics/personal-general.ts +28 -4
  28. package/dist/runtime/src/types/api.ts +7 -0
  29. package/dist/runtime/src/version.ts +1 -1
  30. package/dist/storage.js +141 -31
  31. package/dist/storage.js.map +4 -4
  32. package/dist/types/packages/core/src/mcp/session-comm/peer-forward.d.ts +7 -2
  33. package/dist/types/packages/core/src/runtime/turn-event-stream.d.ts +2 -0
  34. package/dist/types/packages/core/src/runtime/turn-runner.d.ts +3 -0
  35. package/dist/types/packages/core/src/storage/api-topics.d.ts +42 -3
  36. package/dist/types/packages/core/src/storage/runtime-turn-requests.d.ts +6 -0
  37. package/dist/types/packages/core/src/storage/token-stats.d.ts +2 -1
  38. package/dist/types/packages/core/src/topics/derive.d.ts +2 -0
  39. package/dist/types/packages/core/src/topics/personal-general.d.ts +4 -2
  40. package/dist/types/packages/core/src/types/api.d.ts +7 -0
  41. package/dist/types/packages/core/src/version.d.ts +1 -1
  42. package/package.json +1 -1
package/dist/registry.js CHANGED
@@ -122,7 +122,7 @@ import { createRequire } from "module";
122
122
  import { dirname, join as join2 } from "path";
123
123
 
124
124
  // ../../packages/core/src/version.ts
125
- var NEGOTIUM_VERSION = "0.2.26";
125
+ var NEGOTIUM_VERSION = "0.2.28";
126
126
 
127
127
  // ../../packages/core/src/agents/codex-native-multi-agent.ts
128
128
  var moduleRequire = createRequire(import.meta.url);
@@ -480,4 +480,4 @@ export {
480
480
  getRegistry2 as getRegistry
481
481
  };
482
482
 
483
- //# debugId=D08680114CEA736864756E2164756E21
483
+ //# debugId=22D96109DEE01FA164756E2164756E21
@@ -6,12 +6,12 @@
6
6
  "import { existsSync, unlinkSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { forkCodexSession } from \"#agents/codex-app-server\";\nimport type { AgentRegistry, AgentRegistryOperations } from \"#agents/contracts\";\nimport { hostedCodexHomePath } from \"#agents/execution-host\";\nimport { writeCodexRollout } from \"#agents/rollout/codex\";\nimport { logger } from \"#platform/logger\";\nimport { CODEX_EFFORT_VALUES, type EffortLevel } from \"#types\";\n\nconst VALID_EFFORTS = new Set<EffortLevel>(CODEX_EFFORT_VALUES);\n\n// Codex CLI's own empirical default is gpt-5.6-sol (`codex exec` 2026-07-10 →\n// \"model: gpt-5.6-sol\"), but we deliberately default to gpt-5.6-luna — the\n// cheapest/fastest GPT-5.6 tier — for a general always-on assistant where most\n// queries are light. Heavier work escalates to terra/sol via set_model. This\n// value is passed explicitly to the SDK (see event-processor resolveDefaultModel),\n// so the footer and the actual model stay in sync.\nexport const codexRegistry: AgentRegistry = {\n kind: \"codex\",\n defaultModel: \"gpt-5.6-luna\",\n // defaultEffort intentionally omitted — Codex SDK treats absence as\n // \"reasoning off\". Setting \"high\"/etc. would silently flip on reasoning.\n\n expandModelAlias(s) {\n return s;\n },\n\n validateModel(s) {\n // Codex doesn't publish a closed model list and OpenAI ships new IDs\n // (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, o3, ...) frequently. Best-effort: accept any\n // non-empty string. Bad IDs surface at SDK call time with a clear error.\n return typeof s === \"string\" && s.length > 0;\n },\n\n validEfforts: CODEX_EFFORT_VALUES,\n validateEffort(s) {\n // GPT-5.6 accepts low/medium/high/xhigh/max; 'minimal'\n // was removed because the Codex API rejects it when default tools\n // (image_gen, web_search) are active.\n return VALID_EFFORTS.has(s);\n },\n\n footerLabel(model, effort) {\n // Codex omits effort to mean \"reasoning off\". Show `(off)` explicitly so\n // the user can distinguish from claude (which always has a default).\n return `${model} · ${effort ?? \"(off)\"}`;\n },\n};\n\nexport const codexRegistryOperations: AgentRegistryOperations = {\n writeRollout(opts) {\n // Codex SDK exposes the resume key as `threadId`; AgentRegistry unifies\n // the name to `sessionId` so callers don't branch on agent.\n // `reuseSessionId` (if any) is forwarded as `threadId` so claude→codex→claude\n // round-trips also keep one continuous codex thread instead of orphaning a\n // fresh uuidv7 on every switch.\n const { threadId, rolloutPath } = writeCodexRollout({\n cwd: opts.cwd,\n entries: opts.entries,\n model: opts.model ?? codexRegistry.defaultModel,\n ...(opts.effort ? { effort: opts.effort } : {}),\n ...(opts.reuseSessionId ? { threadId: opts.reuseSessionId } : {}),\n });\n return { sessionId: threadId, rolloutPath };\n },\n\n // The TypeScript SDK does not expose forking yet, but the bundled Codex App\n // Server does. Native thread/fork preserves the provider's stored prefix,\n // including tool structure, which gives prompt caching the best chance to\n // reuse the parent context. Callers retain unified-log synthesis as fallback.\n async forkSession({ parentSessionId }) {\n return await forkCodexSession(parentSessionId);\n },\n\n // Codex stores rollouts at `~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-<ts>-<threadId>.jsonl`.\n // The date prefix is unknown at cleanup time (it's the *original* write\n // timestamp, not \"now\"), so we glob across the whole sessions tree by\n // threadId suffix. With at most a few thousand files in active use this\n // is well under a millisecond on a warm filesystem.\n async cleanupRollouts({ sessionIds }) {\n if (sessionIds.length === 0) return;\n const sessionsDir = join(hostedCodexHomePath(), \"sessions\");\n // No sessions directory means there are no provider rollouts left to remove.\n if (!existsSync(sessionsDir)) return;\n const failures: unknown[] = [];\n // One Glob per threadId so a single corrupt entry can't poison the rest.\n // Bun.Glob's `scan` yields paths relative to its base dir.\n for (const tid of sessionIds) {\n try {\n const glob = new Bun.Glob(`**/rollout-*-${tid}.jsonl`);\n for await (const rel of glob.scan({ cwd: sessionsDir, onlyFiles: true })) {\n const path = join(sessionsDir, rel);\n try {\n unlinkSync(path);\n } catch (e) {\n if ((e as NodeJS.ErrnoException)?.code !== \"ENOENT\") {\n logger.warn({ err: e, path }, \"codex cleanupRollouts: unlink failed\");\n failures.push(e);\n }\n }\n }\n } catch (e) {\n logger.warn({ err: e, threadId: tid }, \"codex cleanupRollouts: scan failed\");\n failures.push(e);\n }\n }\n if (failures.length > 0) {\n throw new AggregateError(failures, \"codex cleanupRollouts failed\");\n }\n },\n};\n",
7
7
  "import { type ChildProcessWithoutNullStreams, spawn } from \"node:child_process\";\nimport { codexCliScriptPath } from \"#agents/codex-native-multi-agent\";\nimport { hostedCodexHomePath } from \"#agents/execution-host\";\nimport { latestCodexRolloutPath } from \"#agents/rollout/codex\";\nimport { NEGOTIUM_VERSION } from \"#version\";\n\ninterface CodexAppServerForkResult {\n forkId: string;\n rolloutPath: string;\n}\n\ninterface CodexAppServerForkHost {\n spawnServer(): ChildProcessWithoutNullStreams;\n findRolloutPath(threadId: string): string | undefined;\n timeoutMs: number;\n}\n\ntype JsonRpcResponse = {\n id?: number;\n result?: { thread?: { id?: unknown } };\n error?: { message?: unknown };\n};\n\nexport function createCodexAppServerForker(host: CodexAppServerForkHost) {\n return async (parentThreadId: string): Promise<CodexAppServerForkResult> => {\n const child = host.spawnServer();\n\n return await new Promise<CodexAppServerForkResult>((resolve, reject) => {\n let settled = false;\n let stdoutBuffer = \"\";\n let stderr = \"\";\n const timer = setTimeout(\n () => finish(new Error(\"Codex thread fork timed out\")),\n host.timeoutMs,\n );\n\n const finish = (error?: Error, result?: CodexAppServerForkResult) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n child.stdin.end();\n child.kill();\n } catch {\n // The app server may already have exited after stdin closed.\n }\n if (error) reject(error);\n else if (result) resolve(result);\n else reject(new Error(\"Codex thread fork returned no result\"));\n };\n\n const send = (message: Record<string, unknown>) => {\n child.stdin.write(`${JSON.stringify(message)}\\n`);\n };\n\n child.stderr.on(\"data\", (chunk) => {\n if (stderr.length < 8_192) stderr += String(chunk);\n });\n child.on(\"error\", (error) => finish(error));\n child.on(\"exit\", (code, signal) => {\n if (settled) return;\n const detail = signal ? `signal ${signal}` : `code ${code ?? 1}`;\n finish(\n new Error(\n `Codex app server exited with ${detail}${stderr.trim() ? `: ${stderr.trim()}` : \"\"}`,\n ),\n );\n });\n child.stdout.on(\"data\", (chunk) => {\n stdoutBuffer += String(chunk);\n for (;;) {\n const newline = stdoutBuffer.indexOf(\"\\n\");\n if (newline < 0) break;\n const line = stdoutBuffer.slice(0, newline);\n stdoutBuffer = stdoutBuffer.slice(newline + 1);\n let message: JsonRpcResponse;\n try {\n message = JSON.parse(line) as JsonRpcResponse;\n } catch {\n continue;\n }\n\n if (message.id === 1) {\n if (message.error) {\n finish(new Error(String(message.error.message || \"Codex initialization failed\")));\n return;\n }\n send({ method: \"initialized\" });\n send({ id: 2, method: \"thread/fork\", params: { threadId: parentThreadId } });\n continue;\n }\n if (message.id !== 2) continue;\n if (message.error) {\n finish(new Error(String(message.error.message || \"Codex thread fork failed\")));\n return;\n }\n const forkId = message.result?.thread?.id;\n if (typeof forkId !== \"string\" || !forkId) {\n finish(new Error(\"Codex thread fork returned no thread id\"));\n return;\n }\n const rolloutPath = host.findRolloutPath(forkId);\n if (!rolloutPath) {\n finish(new Error(`Codex thread fork rollout was not found for ${forkId}`));\n return;\n }\n finish(undefined, { forkId, rolloutPath });\n return;\n }\n });\n\n send({\n id: 1,\n method: \"initialize\",\n params: {\n clientInfo: { name: \"negotium\", version: NEGOTIUM_VERSION },\n },\n });\n });\n };\n}\n\nconst forkCodexThread = createCodexAppServerForker({\n spawnServer() {\n return spawn(process.execPath, [codexCliScriptPath(), \"app-server\", \"--stdio\"], {\n env: { ...process.env, CODEX_HOME: hostedCodexHomePath() },\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n },\n findRolloutPath: latestCodexRolloutPath,\n timeoutMs: 15_000,\n});\n\nexport async function forkCodexSession(parentThreadId: string): Promise<CodexAppServerForkResult> {\n return await forkCodexThread(parentThreadId);\n}\n",
8
8
  "import { spawn } from \"node:child_process\";\nimport { randomUUID } from \"node:crypto\";\nimport {\n chmodSync,\n copyFileSync,\n existsSync,\n mkdtempSync,\n readFileSync,\n renameSync,\n rmSync,\n unlinkSync,\n writeFileSync,\n} from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport { tmpdir } from \"node:os\";\nimport { dirname, join } from \"node:path\";\nimport { NEGOTIUM_VERSION } from \"#version\";\n\ntype CodexModel = Record<string, unknown>;\ntype CodexModelCache = {\n client_version?: unknown;\n models?: unknown;\n};\n\nconst moduleRequire = createRequire(import.meta.url);\nconst codexSdkPackagePath = moduleRequire.resolve(\"@openai/codex-sdk/package.json\");\nconst codexSdkRequire = createRequire(codexSdkPackagePath);\nconst bundledCodexPackagePath = codexSdkRequire.resolve(\"@openai/codex/package.json\");\n\nfunction readPackageVersion(packageJsonPath: string): string {\n const parsed = JSON.parse(readFileSync(packageJsonPath, \"utf8\")) as { version?: unknown };\n if (typeof parsed.version !== \"string\" || !parsed.version.trim()) {\n throw new Error(`Codex package has no valid version: ${packageJsonPath}`);\n }\n return parsed.version;\n}\n\nexport const BUNDLED_CODEX_VERSION = readPackageVersion(bundledCodexPackagePath);\nconst SAFE_BUNDLED_CODEX_VERSION = BUNDLED_CODEX_VERSION.replace(/[^a-zA-Z0-9._-]/g, \"_\");\nconst NEGOTIUM_MODEL_CACHE = `negotium-models-cache-${SAFE_BUNDLED_CODEX_VERSION}.json`;\nconst NEGOTIUM_MODEL_CATALOG = `negotium-model-catalog-${SAFE_BUNDLED_CODEX_VERSION}.json`;\n\nexport function codexCliScriptPath(): string {\n return join(dirname(bundledCodexPackagePath), \"bin\", \"codex.js\");\n}\n\nfunction parseCodexModelCache(contents: string, sourcePath: string): CodexModelCache {\n let parsed: CodexModelCache;\n try {\n parsed = JSON.parse(contents) as CodexModelCache;\n } catch (error) {\n throw new Error(`Codex model cache is invalid JSON: ${sourcePath}`, { cause: error });\n }\n if (!Array.isArray(parsed.models) || parsed.models.length === 0) {\n throw new Error(`Codex model cache has no models: ${sourcePath}`);\n }\n return parsed;\n}\n\nfunction readCodexModelCache(cachePath: string): {\n contents: string;\n parsed: CodexModelCache;\n} {\n const contents = readFileSync(cachePath, \"utf8\");\n return { contents, parsed: parseCodexModelCache(contents, cachePath) };\n}\n\nfunction readCompatibleCodexModelCache(cachePath: string): {\n contents: string;\n parsed: CodexModelCache;\n} {\n const cache = readCodexModelCache(cachePath);\n if (cache.parsed.client_version !== BUNDLED_CODEX_VERSION) {\n const found =\n typeof cache.parsed.client_version === \"string\"\n ? cache.parsed.client_version\n : \"missing or invalid\";\n throw new Error(\n `Codex model cache version ${found} does not match Negotium's bundled Codex ${BUNDLED_CODEX_VERSION}: ${cachePath}`,\n );\n }\n return cache;\n}\n\nfunction writePrivateFileAtomic(path: string, contents: string): void {\n if (existsSync(path) && readFileSync(path, \"utf8\") === contents) return;\n\n const tempPath = `${path}.${process.pid}.${randomUUID()}.tmp`;\n try {\n writeFileSync(tempPath, contents, { encoding: \"utf8\", mode: 0o600 });\n renameSync(tempPath, path);\n chmodSync(path, 0o600);\n } finally {\n try {\n unlinkSync(tempPath);\n } catch {\n // renameSync normally consumed the temporary file.\n }\n }\n}\n\nexport function bundledCodexModelCachePath(authFilePath: string): string {\n return join(dirname(authFilePath), NEGOTIUM_MODEL_CACHE);\n}\n\nasync function bootstrapCodexModelCache(codexHome: string, cachePath: string): Promise<void> {\n const child = spawn(process.execPath, [codexCliScriptPath(), \"app-server\", \"--stdio\"], {\n env: { ...process.env, CODEX_HOME: codexHome },\n stdio: [\"pipe\", \"pipe\", \"pipe\"],\n });\n\n await new Promise<void>((resolve, reject) => {\n let settled = false;\n let stdoutBuffer = \"\";\n let stderr = \"\";\n const timer = setTimeout(\n () => finish(new Error(\"timed out while refreshing the Codex model catalog\")),\n 15_000,\n );\n\n const finish = (error?: Error) => {\n if (settled) return;\n settled = true;\n clearTimeout(timer);\n try {\n child.stdin.end();\n child.kill();\n } catch {\n // The app server may already have exited after stdin closed.\n }\n if (error) reject(error);\n else if (!existsSync(cachePath)) reject(new Error(\"Codex did not create its model cache\"));\n else resolve();\n };\n\n const send = (message: Record<string, unknown>) => {\n child.stdin.write(`${JSON.stringify(message)}\\n`);\n };\n\n child.stderr.on(\"data\", (chunk) => {\n if (stderr.length < 4_096) stderr += String(chunk);\n });\n child.on(\"error\", (error) => finish(error));\n child.on(\"exit\", (code, signal) => {\n if (!settled) {\n finish(\n new Error(\n `Codex model catalog refresh exited with ${signal ? `signal ${signal}` : `code ${code ?? 1}`}${stderr.trim() ? `: ${stderr.trim()}` : \"\"}`,\n ),\n );\n }\n });\n child.stdout.on(\"data\", (chunk) => {\n stdoutBuffer += String(chunk);\n for (;;) {\n const newline = stdoutBuffer.indexOf(\"\\n\");\n if (newline < 0) break;\n const line = stdoutBuffer.slice(0, newline);\n stdoutBuffer = stdoutBuffer.slice(newline + 1);\n let message: { id?: number; error?: { message?: string } };\n try {\n message = JSON.parse(line) as typeof message;\n } catch {\n continue;\n }\n if (message.id === 1) {\n if (message.error) {\n finish(new Error(message.error.message || \"Codex initialization failed\"));\n return;\n }\n send({ method: \"initialized\" });\n send({ id: 2, method: \"model/list\", params: { includeHidden: true } });\n } else if (message.id === 2) {\n if (message.error) {\n finish(new Error(message.error.message || \"Codex model listing failed\"));\n } else {\n finish();\n }\n return;\n }\n }\n });\n\n send({\n id: 1,\n method: \"initialize\",\n params: {\n clientInfo: { name: \"negotium\", version: NEGOTIUM_VERSION },\n capabilities: { experimentalApi: true },\n },\n });\n });\n}\n\nasync function bootstrapIsolatedCodexModelCache(\n authFilePath: string,\n bootstrap: (codexHome: string, cachePath: string) => Promise<void>,\n): Promise<string> {\n const sourceHome = dirname(authFilePath);\n const isolatedHome = mkdtempSync(join(tmpdir(), \"negotium-codex-models-\"));\n const isolatedCachePath = join(isolatedHome, \"models_cache.json\");\n\n try {\n const isolatedAuthPath = join(isolatedHome, \"auth.json\");\n copyFileSync(authFilePath, isolatedAuthPath);\n chmodSync(isolatedAuthPath, 0o600);\n\n // Preserve custom provider configuration while keeping the bundled CLI's\n // cache write completely outside the user's shared CODEX_HOME.\n const sourceConfigPath = join(sourceHome, \"config.toml\");\n if (existsSync(sourceConfigPath)) {\n const isolatedConfigPath = join(isolatedHome, \"config.toml\");\n copyFileSync(sourceConfigPath, isolatedConfigPath);\n chmodSync(isolatedConfigPath, 0o600);\n }\n\n await bootstrap(isolatedHome, isolatedCachePath);\n return readCompatibleCodexModelCache(isolatedCachePath).contents;\n } finally {\n rmSync(isolatedHome, { recursive: true, force: true });\n }\n}\n\nexport async function ensureCodexModelCache(\n authFilePath: string,\n bootstrap: (codexHome: string, cachePath: string) => Promise<void> = bootstrapCodexModelCache,\n): Promise<string> {\n const codexHome = dirname(authFilePath);\n const configuredCachePath = process.env.NEGOTIUM_CODEX_MODELS_CACHE_FILE;\n if (configuredCachePath) {\n if (!existsSync(configuredCachePath)) {\n throw new Error(`Configured Codex model cache does not exist: ${configuredCachePath}`);\n }\n readCompatibleCodexModelCache(configuredCachePath);\n return configuredCachePath;\n }\n\n // The global Codex CLI owns models_cache.json and may update it to a schema\n // newer than the SDK bundled by Negotium. Snapshot a cache generated for our\n // exact bundled version so later global CLI updates cannot break turns.\n const bundledCachePath = bundledCodexModelCachePath(authFilePath);\n const sharedCachePath = join(codexHome, \"models_cache.json\");\n if (existsSync(sharedCachePath)) {\n try {\n const shared = readCompatibleCodexModelCache(sharedCachePath);\n // Keep model metadata fresh while the global CLI remains compatible.\n writePrivateFileAtomic(bundledCachePath, shared.contents);\n return bundledCachePath;\n } catch {\n // A compatible private snapshot is safer than failing because another\n // process briefly exposed an incomplete shared-cache write.\n }\n }\n\n if (existsSync(bundledCachePath)) {\n try {\n readCompatibleCodexModelCache(bundledCachePath);\n return bundledCachePath;\n } catch {\n // Re-bootstrap below instead of passing a corrupt or version-mismatched\n // private snapshot into this SDK version.\n }\n }\n\n const refreshedContents = await bootstrapIsolatedCodexModelCache(authFilePath, bootstrap);\n writePrivateFileAtomic(bundledCachePath, refreshedContents);\n return bundledCachePath;\n}\n\n/**\n * Codex can resolve model metadata before `features.multi_agent=false`, so a\n * model-advertised v1/v2 value may still register native collaboration tools.\n * Feed Codex an authoritative copy of its own catalog with only that field\n * disabled. Runtime MCP delegation remains available independently.\n */\nexport function writeCodexCatalogWithNativeMultiAgentDisabled(\n authFilePath: string,\n sourcePath: string,\n): string {\n const codexHome = dirname(authFilePath);\n const outputPath = join(codexHome, NEGOTIUM_MODEL_CATALOG);\n\n const parsed = readCodexModelCache(sourcePath).parsed;\n\n const models = (parsed.models as unknown[]).map((model, index): CodexModel => {\n if (!model || typeof model !== \"object\" || Array.isArray(model)) {\n throw new Error(`Codex model cache entry ${index} is invalid: ${sourcePath}`);\n }\n return { ...(model as CodexModel), multi_agent_version: \"disabled\" };\n });\n const contents = `${JSON.stringify({ models }, null, 2)}\\n`;\n writePrivateFileAtomic(outputPath, contents);\n return outputPath;\n}\n",
9
- "export const NEGOTIUM_VERSION = \"0.2.26\";\n",
9
+ "export const NEGOTIUM_VERSION = \"0.2.28\";\n",
10
10
  "import { randomUUID } from \"node:crypto\";\nimport { existsSync, mkdirSync, readFileSync, writeFileSync } from \"node:fs\";\nimport { homedir } from \"node:os\";\nimport { join, resolve } from \"node:path\";\nimport type { AgentRegistry, AgentRegistryOperations } from \"#agents/contracts\";\nimport { assertUuidLike, ensureCwdExists, extractChatPairs } from \"#agents/rollout/shared\";\nimport { logger } from \"#platform/logger\";\nimport { MAESTRO_EFFORT_VALUES } from \"#types\";\n\nconst ALIAS_MAP: Record<string, string> = {\n \"deepseek-pro\": \"deepseek-v4-pro\",\n // \"deepseek-flash\" was disabled in 0.1.25 because DeepSeek had retired its\n // old flash model. \"DeepSeek-V4-Flash-0731\" (released 2026-07-31) is an\n // unrelated, currently-live model reusing a similar name — verified with a\n // live API call before re-enabling this alias.\n \"deepseek-flash\": \"deepseek-v4-flash\",\n kimi: \"kimi-k3\",\n \"kimi-pro\": \"kimi-k3\",\n \"kimi-code\": \"kimi-k2.7-code\",\n};\nconst VALID_MODELS = new Set([...Object.keys(ALIAS_MAP), ...Object.values(ALIAS_MAP)]);\nconst VALID_EFFORTS = new Set(MAESTRO_EFFORT_VALUES);\n\nexport const maestroRegistry: AgentRegistry = {\n kind: \"maestro\",\n defaultModel: \"deepseek-pro\",\n defaultEffort: \"medium\",\n expandModelAlias(model) {\n return ALIAS_MAP[model] ?? model;\n },\n validateModel(model) {\n return VALID_MODELS.has(model);\n },\n validEfforts: MAESTRO_EFFORT_VALUES,\n validateEffort(effort) {\n return VALID_EFFORTS.has(effort);\n },\n footerLabel(model, effort) {\n return effort ? `${model} · ${effort}` : model;\n },\n};\n\nfunction maestroSessionsDir(): string {\n return join(\n process.env.MAESTRO_DATA_DIR\n ? resolve(process.env.MAESTRO_DATA_DIR)\n : join(homedir(), \".maestro\"),\n \"sessions\",\n );\n}\n\nfunction maestroSessionPath(sessionId: string): string {\n return join(maestroSessionsDir(), `${sessionId}.jsonl`);\n}\n\nfunction maestroActiveSessionPath(sessionId: string): string {\n return join(maestroSessionsDir(), `${sessionId}.active.jsonl`);\n}\n\nfunction existingCreatedAt(path: string): string | undefined {\n if (!existsSync(path)) return undefined;\n try {\n const firstLine = readFileSync(path, \"utf8\").split(\"\\n\", 1)[0];\n const parsed = JSON.parse(firstLine) as { _meta?: { createdAt?: unknown } };\n return typeof parsed._meta?.createdAt === \"string\" ? parsed._meta.createdAt : undefined;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Placeholder for a synthesized turn whose text is blank.\n *\n * Moonshot (Kimi) hard-rejects the *whole* request when any history message has\n * empty content — `Kimi API 400: the message at position N with role 'user'\n * must not be empty` (verified against the live API, kimi-k3). DeepSeek's\n * validator is laxer today but the same shape is not worth relying on.\n *\n * maestro-agent-sdk 0.2.1 drops empty assistant slots, but only in the\n * *content-block* form (`content: []`); the pair encoder below writes plain\n * strings, and `content: \"\"` still reaches the wire and 400s. Blank sides are\n * reachable here from real conversations — an attachment-only user submission\n * records a `user_message` with `content: \"\"`, and `renderUserPromptBatch`\n * passes it through verbatim — so the synthesized session must not contain one.\n *\n * We substitute rather than drop: dropping a pair would silently delete a turn\n * from the historical narrative the next agent reads, while a one-line marker\n * keeps the turn boundary and tells the model why the slot is thin.\n */\nconst BLANK_TURN_PLACEHOLDER = \"(no text content in this turn)\";\n\nfunction nonEmptyTurnText(text: string): string {\n return text.trim().length > 0 ? text : BLANK_TURN_PLACEHOLDER;\n}\n\nfunction writeRollout(options: Parameters<AgentRegistryOperations[\"writeRollout\"]>[0]) {\n const sessionId = options.reuseSessionId ?? randomUUID();\n assertUuidLike(\"sessionId\", sessionId);\n ensureCwdExists(options.cwd);\n const path = maestroSessionPath(sessionId);\n mkdirSync(maestroSessionsDir(), { recursive: true });\n const messages = extractChatPairs(options.entries).flatMap((pair) => [\n { role: \"user\", content: nonEmptyTurnText(pair.userText) },\n { role: \"assistant\", content: nonEmptyTurnText(pair.assistantText) },\n ]);\n const lines = [\n {\n _meta: {\n version: 1,\n cwd: options.cwd,\n createdAt: existingCreatedAt(path) ?? new Date().toISOString(),\n // Deliberately a literal, not the SDK's `MAESTRO_SDK_VERSION`: this\n // module must not statically import maestro-agent-sdk (asserted by\n // tests/core/daemon-import-boundaries.test.ts — the SDK stays off the\n // daemon startup path). The value names the session *format* version\n // this encoder targets, which is what a reader of the header needs.\n sdkVersion: \"0.2.0\",\n },\n },\n ...messages,\n ];\n writeFileSync(path, `${lines.map((line) => JSON.stringify(line)).join(\"\\n\")}\\n`, {\n mode: 0o600,\n });\n return { sessionId, rolloutPath: path };\n}\n\nexport const maestroRegistryOperations: AgentRegistryOperations = {\n writeRollout(options) {\n return writeRollout(options);\n },\n async forkSession(options) {\n // Fork the full raw history while preserving the compacted working view.\n // A compacted parent that cannot copy its active projection is not a usable\n // cache-preserving fork, so let the caller fall back to bounded synthesis.\n const { deleteMaestroSession, forkSessionAt, loadRawMaestroSession } = await import(\n \"maestro-agent-sdk\"\n );\n const parentMessages = loadRawMaestroSession(options.parentSessionId);\n if (!parentMessages) {\n throw new Error(`Maestro parent session not found: ${options.parentSessionId}`);\n }\n const parentHasActiveProjection = existsSync(maestroActiveSessionPath(options.parentSessionId));\n const fork = forkSessionAt({\n parentSessionId: options.parentSessionId,\n messageIndex: parentMessages.length,\n cwd: options.cwd,\n userId: String(options.userId),\n });\n if (parentHasActiveProjection && !fork.activeProjectionForked) {\n deleteMaestroSession(fork.sessionId);\n throw new Error(`Maestro active projection could not be forked: ${options.parentSessionId}`);\n }\n const activePath = maestroActiveSessionPath(fork.sessionId);\n return {\n forkId: fork.sessionId,\n rolloutPath: fork.rolloutPath,\n ...(existsSync(activePath) ? { cleanupPaths: [activePath] } : {}),\n };\n },\n async cleanupRollouts(options) {\n // Keep the SDK off the daemon startup path, but use its canonical cleanup\n // once a Maestro session is actually being removed. It also clears memory,\n // task, todo, and in-process file-state sidecars.\n const { deleteMaestroSession } = await import(\"maestro-agent-sdk\");\n const failures: unknown[] = [];\n for (const sessionId of options.sessionIds) {\n try {\n deleteMaestroSession(sessionId);\n const remaining = [\n maestroSessionPath(sessionId),\n maestroActiveSessionPath(sessionId),\n ].filter(existsSync);\n if (remaining.length > 0) {\n throw new Error(`Maestro session files remain after cleanup: ${remaining.join(\", \")}`);\n }\n } catch (error) {\n logger.warn({ err: error, sessionId }, \"maestro cleanupRollouts: cleanup failed\");\n failures.push(error);\n }\n }\n if (failures.length > 0) {\n throw new AggregateError(failures, \"Maestro rollout cleanup failed\");\n }\n },\n};\n",
11
11
  "import { claudeRegistry, claudeRegistryOperations } from \"#agents/claude-registry\";\nimport { codexRegistry, codexRegistryOperations } from \"#agents/codex-registry\";\nimport type { AgentRegistry, AgentRegistryOperations } from \"#agents/contracts\";\nimport { maestroRegistry, maestroRegistryOperations } from \"#agents/maestro-registry\";\nimport type { AgentKind } from \"#types\";\n\nexport type {\n AgentRegistry,\n AgentRegistryOperations,\n CleanupRolloutsOptions,\n ForkRegistryOptions,\n ForkRegistryResult,\n WriteRolloutOptions,\n WriteRolloutResult,\n} from \"#agents/contracts\";\n\nconst REGISTRIES: Record<AgentKind, AgentRegistry> = {\n claude: claudeRegistry,\n codex: codexRegistry,\n maestro: maestroRegistry,\n};\n\nexport function getRegistry(agent: AgentKind): AgentRegistry {\n return REGISTRIES[agent];\n}\n\nconst OPERATIONS: Record<AgentKind, AgentRegistryOperations> = {\n claude: claudeRegistryOperations,\n codex: codexRegistryOperations,\n maestro: maestroRegistryOperations,\n};\n\nexport function getRegistryOperations(agent: AgentKind): AgentRegistryOperations {\n return OPERATIONS[agent];\n}\n",
12
12
  "import {\n type AgentRegistry,\n type AgentRegistryOperations,\n type CleanupRolloutsOptions,\n type ForkRegistryOptions,\n type ForkRegistryResult,\n getRegistry as resolveCoreRegistry,\n getRegistryOperations as resolveCoreRegistryOperations,\n type WriteRolloutOptions,\n type WriteRolloutResult,\n} from \"@negotium/core/registry\";\n\nexport type {\n AgentRegistry,\n AgentRegistryOperations,\n CleanupRolloutsOptions,\n ForkRegistryOptions,\n ForkRegistryResult,\n WriteRolloutOptions,\n WriteRolloutResult,\n};\n\nexport const getRegistry: typeof resolveCoreRegistry = (agent) => resolveCoreRegistry(agent);\n\nexport const getRegistryOperations: typeof resolveCoreRegistryOperations = (agent) =>\n resolveCoreRegistryOperations(agent);\n"
13
13
  ],
14
14
  "mappings": ";;;;;;;;;;;;;;;;;;;;;;AAAA;AACA;AACA;AAOA,IAAM,YAAoC;AAAA,EACxC,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,OAAO;AACT;AAEA,IAAM,gBAAgB,IAAI,IAAI,OAAO,KAAK,SAAS,CAAC;AACpD,IAAM,gBAAgB,IAAI,IAAiB,oBAAoB;AAExD,IAAM,iBAAgC;AAAA,EAC3C,MAAM;AAAA,EACN,cAAc;AAAA,EACd,eAAe;AAAA,EAEf,gBAAgB,CAAC,GAAG;AAAA,IAClB,OAAO,UAAU,MAAM;AAAA;AAAA,EAGzB,aAAa,CAAC,GAAG;AAAA,IACf,OAAO,cAAc,IAAI,CAAC;AAAA;AAAA,EAG5B,cAAc;AAAA,EACd,cAAc,CAAC,GAAG;AAAA,IAChB,OAAO,cAAc,IAAI,CAAC;AAAA;AAAA,EAG5B,WAAW,CAAC,OAAO,QAAQ;AAAA,IACzB,OAAO,SAAS,GAAG,cAAU,WAAW;AAAA;AAE5C;AAEO,IAAM,2BAAoD;AAAA,EAC/D,YAAY,CAAC,MAAM;AAAA,IAIjB,QAAQ,WAAW,gBAAgB,mBAAmB;AAAA,MACpD,KAAK,KAAK;AAAA,MACV,SAAS,KAAK;AAAA,MACd,OAAO,eAAe,iBAAiB,KAAK,SAAS,eAAe,YAAY;AAAA,SAC5E,KAAK,iBAAiB,EAAE,WAAW,KAAK,eAAe,IAAI,CAAC;AAAA,IAClE,CAAC;AAAA,IACD,OAAO,EAAE,WAAW,YAAY;AAAA;AAAA,OAY5B,YAAW,GAAG,iBAAiB,KAAK,SAAS;AAAA,IACjD,QAAQ,gBAAgB,MAAa;AAAA,IAGrC,MAAM,SAAS,MAAM,YAAY,iBAAiB;AAAA,SAC5C,QAAQ,EAAE,MAAM,IAAI,CAAC;AAAA,IAC3B,CAAC;AAAA,IACD,MAAM,eAAe,KAAK,QAAQ,GAAG,WAAW,UAAU;AAAA,IAC1D,MAAM,UAAU,KAAK,cAAc,gBAAgB,GAAG,CAAC;AAAA,IACvD,MAAM,WAAW,KAAK,SAAS,GAAG,OAAO,iBAAiB;AAAA,IAC1D,KAAK,WAAW,QAAQ,GAAG;AAAA,MACzB,MAAM,aAAa,YAAY,YAAY,EACxC,IAAI,CAAC,MAAM,KAAK,cAAc,GAAG,GAAG,OAAO,iBAAiB,CAAC,EAC7D,KAAK,CAAC,MAAM,WAAW,CAAC,CAAC;AAAA,MAC5B,KAAK,YAAY;AAAA,QACf,MAAM,IAAI,MACR,oCAAoC,OAAO,mCAAmC,cAChF;AAAA,MACF;AAAA,MACA,UAAU,SAAS,EAAE,WAAW,KAAK,CAAC;AAAA,MACtC,WAAW,YAAY,QAAQ;AAAA,IACjC;AAAA,IACA,OAAO;AAAA,MACL,QAAQ,OAAO;AAAA,MAIf,aAAa;AAAA,IACf;AAAA;AAAA,OAMI,gBAAe,GAAG,KAAK,cAAc;AAAA,IACzC,MAAM,cAAc,KAAK,QAAQ,GAAG,WAAW,YAAY,gBAAgB,GAAG,CAAC;AAAA,IAC/E,MAAM,WAAsB,CAAC;AAAA,IAC7B,WAAW,OAAO,YAAY;AAAA,MAC5B,MAAM,OAAO,KAAK,aAAa,GAAG,WAAW;AAAA,MAC7C,IAAI;AAAA,QACF,WAAW,IAAI;AAAA,QACf,OAAO,GAAG;AAAA,QACV,IAAK,GAA6B,SAAS,UAAU;AAAA,UACnD,OAAO,KAAK,EAAE,KAAK,GAAG,KAAK,GAAG,uCAAuC;AAAA,UACrE,SAAS,KAAK,CAAC;AAAA,QACjB;AAAA;AAAA,IAEJ;AAAA,IACA,IAAI,SAAS,SAAS,GAAG;AAAA,MACvB,MAAM,IAAI,eAAe,UAAU,+BAA+B;AAAA,IACpE;AAAA;AAEJ;;;ACpHA,uBAAS,2BAAY;AACrB,iBAAS;;;ACDT;;;ACEA;AAAA;AAAA;AAAA,gBAGE;AAAA;AAAA;AAAA,gBAGA;AAAA;AAAA,gBAEA;AAAA;AAAA;AAGF;AAEA,0BAAkB;;;ACfX,IAAM,mBAAmB;;;ADwBhC,IAAM,gBAAgB,cAAc,YAAY,GAAG;AACnD,IAAM,sBAAsB,cAAc,QAAQ,gCAAgC;AAClF,IAAM,kBAAkB,cAAc,mBAAmB;AACzD,IAAM,0BAA0B,gBAAgB,QAAQ,4BAA4B;AAEpF,SAAS,kBAAkB,CAAC,iBAAiC;AAAA,EAC3D,MAAM,SAAS,KAAK,MAAM,aAAa,iBAAiB,MAAM,CAAC;AAAA,EAC/D,IAAI,OAAO,OAAO,YAAY,aAAa,OAAO,QAAQ,KAAK,GAAG;AAAA,IAChE,MAAM,IAAI,MAAM,uCAAuC,iBAAiB;AAAA,EAC1E;AAAA,EACA,OAAO,OAAO;AAAA;AAGT,IAAM,wBAAwB,mBAAmB,uBAAuB;AAC/E,IAAM,6BAA6B,sBAAsB,QAAQ,oBAAoB,GAAG;AACxF,IAAM,uBAAuB,yBAAyB;AACtD,IAAM,yBAAyB,0BAA0B;AAElD,SAAS,kBAAkB,GAAW;AAAA,EAC3C,OAAO,MAAK,QAAQ,uBAAuB,GAAG,OAAO,UAAU;AAAA;;;ADpB1D,SAAS,0BAA0B,CAAC,MAA8B;AAAA,EACvE,OAAO,OAAO,mBAA8D;AAAA,IAC1E,MAAM,QAAQ,KAAK,YAAY;AAAA,IAE/B,OAAO,MAAM,IAAI,QAAkC,CAAC,SAAS,WAAW;AAAA,MACtE,IAAI,UAAU;AAAA,MACd,IAAI,eAAe;AAAA,MACnB,IAAI,SAAS;AAAA,MACb,MAAM,QAAQ,WACZ,MAAM,OAAO,IAAI,MAAM,6BAA6B,CAAC,GACrD,KAAK,SACP;AAAA,MAEA,MAAM,SAAS,CAAC,OAAe,WAAsC;AAAA,QACnE,IAAI;AAAA,UAAS;AAAA,QACb,UAAU;AAAA,QACV,aAAa,KAAK;AAAA,QAClB,IAAI;AAAA,UACF,MAAM,MAAM,IAAI;AAAA,UAChB,MAAM,KAAK;AAAA,UACX,MAAM;AAAA,QAGR,IAAI;AAAA,UAAO,OAAO,KAAK;AAAA,QAClB,SAAI;AAAA,UAAQ,QAAQ,MAAM;AAAA,QAC1B;AAAA,iBAAO,IAAI,MAAM,sCAAsC,CAAC;AAAA;AAAA,MAG/D,MAAM,OAAO,CAAC,YAAqC;AAAA,QACjD,MAAM,MAAM,MAAM,GAAG,KAAK,UAAU,OAAO;AAAA,CAAK;AAAA;AAAA,MAGlD,MAAM,OAAO,GAAG,QAAQ,CAAC,UAAU;AAAA,QACjC,IAAI,OAAO,SAAS;AAAA,UAAO,UAAU,OAAO,KAAK;AAAA,OAClD;AAAA,MACD,MAAM,GAAG,SAAS,CAAC,UAAU,OAAO,KAAK,CAAC;AAAA,MAC1C,MAAM,GAAG,QAAQ,CAAC,MAAM,WAAW;AAAA,QACjC,IAAI;AAAA,UAAS;AAAA,QACb,MAAM,SAAS,SAAS,UAAU,WAAW,QAAQ,QAAQ;AAAA,QAC7D,OACE,IAAI,MACF,gCAAgC,SAAS,OAAO,KAAK,IAAI,KAAK,OAAO,KAAK,MAAM,IAClF,CACF;AAAA,OACD;AAAA,MACD,MAAM,OAAO,GAAG,QAAQ,CAAC,UAAU;AAAA,QACjC,gBAAgB,OAAO,KAAK;AAAA,QAC5B,UAAS;AAAA,UACP,MAAM,UAAU,aAAa,QAAQ;AAAA,CAAI;AAAA,UACzC,IAAI,UAAU;AAAA,YAAG;AAAA,UACjB,MAAM,OAAO,aAAa,MAAM,GAAG,OAAO;AAAA,UAC1C,eAAe,aAAa,MAAM,UAAU,CAAC;AAAA,UAC7C,IAAI;AAAA,UACJ,IAAI;AAAA,YACF,UAAU,KAAK,MAAM,IAAI;AAAA,YACzB,MAAM;AAAA,YACN;AAAA;AAAA,UAGF,IAAI,QAAQ,OAAO,GAAG;AAAA,YACpB,IAAI,QAAQ,OAAO;AAAA,cACjB,OAAO,IAAI,MAAM,OAAO,QAAQ,MAAM,WAAW,6BAA6B,CAAC,CAAC;AAAA,cAChF;AAAA,YACF;AAAA,YACA,KAAK,EAAE,QAAQ,cAAc,CAAC;AAAA,YAC9B,KAAK,EAAE,IAAI,GAAG,QAAQ,eAAe,QAAQ,EAAE,UAAU,eAAe,EAAE,CAAC;AAAA,YAC3E;AAAA,UACF;AAAA,UACA,IAAI,QAAQ,OAAO;AAAA,YAAG;AAAA,UACtB,IAAI,QAAQ,OAAO;AAAA,YACjB,OAAO,IAAI,MAAM,OAAO,QAAQ,MAAM,WAAW,0BAA0B,CAAC,CAAC;AAAA,YAC7E;AAAA,UACF;AAAA,UACA,MAAM,SAAS,QAAQ,QAAQ,QAAQ;AAAA,UACvC,IAAI,OAAO,WAAW,aAAa,QAAQ;AAAA,YACzC,OAAO,IAAI,MAAM,yCAAyC,CAAC;AAAA,YAC3D;AAAA,UACF;AAAA,UACA,MAAM,cAAc,KAAK,gBAAgB,MAAM;AAAA,UAC/C,KAAK,aAAa;AAAA,YAChB,OAAO,IAAI,MAAM,+CAA+C,QAAQ,CAAC;AAAA,YACzE;AAAA,UACF;AAAA,UACA,OAAO,WAAW,EAAE,QAAQ,YAAY,CAAC;AAAA,UACzC;AAAA,QACF;AAAA,OACD;AAAA,MAED,KAAK;AAAA,QACH,IAAI;AAAA,QACJ,QAAQ;AAAA,QACR,QAAQ;AAAA,UACN,YAAY,EAAE,MAAM,YAAY,SAAS,iBAAiB;AAAA,QAC5D;AAAA,MACF,CAAC;AAAA,KACF;AAAA;AAAA;AAIL,IAAM,kBAAkB,2BAA2B;AAAA,EACjD,WAAW,GAAG;AAAA,IACZ,OAAO,MAAM,QAAQ,UAAU,CAAC,mBAAmB,GAAG,cAAc,SAAS,GAAG;AAAA,MAC9E,KAAK,KAAK,QAAQ,KAAK,YAAY,oBAAoB,EAAE;AAAA,MACzD,OAAO,CAAC,QAAQ,QAAQ,MAAM;AAAA,IAChC,CAAC;AAAA;AAAA,EAEH,iBAAiB;AAAA,EACjB,WAAW;AACb,CAAC;AAED,eAAsB,gBAAgB,CAAC,gBAA2D;AAAA,EAChG,OAAO,MAAM,gBAAgB,cAAc;AAAA;;;AD7H7C,IAAM,iBAAgB,IAAI,IAAiB,mBAAmB;AAQvD,IAAM,gBAA+B;AAAA,EAC1C,MAAM;AAAA,EACN,cAAc;AAAA,EAId,gBAAgB,CAAC,GAAG;AAAA,IAClB,OAAO;AAAA;AAAA,EAGT,aAAa,CAAC,GAAG;AAAA,IAIf,OAAO,OAAO,MAAM,YAAY,EAAE,SAAS;AAAA;AAAA,EAG7C,cAAc;AAAA,EACd,cAAc,CAAC,GAAG;AAAA,IAIhB,OAAO,eAAc,IAAI,CAAC;AAAA;AAAA,EAG5B,WAAW,CAAC,OAAO,QAAQ;AAAA,IAGzB,OAAO,GAAG,cAAU,UAAU;AAAA;AAElC;AAEO,IAAM,0BAAmD;AAAA,EAC9D,YAAY,CAAC,MAAM;AAAA,IAMjB,QAAQ,UAAU,gBAAgB,kBAAkB;AAAA,MAClD,KAAK,KAAK;AAAA,MACV,SAAS,KAAK;AAAA,MACd,OAAO,KAAK,SAAS,cAAc;AAAA,SAC/B,KAAK,SAAS,EAAE,QAAQ,KAAK,OAAO,IAAI,CAAC;AAAA,SACzC,KAAK,iBAAiB,EAAE,UAAU,KAAK,eAAe,IAAI,CAAC;AAAA,IACjE,CAAC;AAAA,IACD,OAAO,EAAE,WAAW,UAAU,YAAY;AAAA;AAAA,OAOtC,YAAW,GAAG,mBAAmB;AAAA,IACrC,OAAO,MAAM,iBAAiB,eAAe;AAAA;AAAA,OAQzC,gBAAe,GAAG,cAAc;AAAA,IACpC,IAAI,WAAW,WAAW;AAAA,MAAG;AAAA,IAC7B,MAAM,cAAc,MAAK,oBAAoB,GAAG,UAAU;AAAA,IAE1D,KAAK,YAAW,WAAW;AAAA,MAAG;AAAA,IAC9B,MAAM,WAAsB,CAAC;AAAA,IAG7B,WAAW,OAAO,YAAY;AAAA,MAC5B,IAAI;AAAA,QACF,MAAM,OAAO,IAAI,IAAI,KAAK,gBAAgB,WAAW;AAAA,QACrD,iBAAiB,OAAO,KAAK,KAAK,EAAE,KAAK,aAAa,WAAW,KAAK,CAAC,GAAG;AAAA,UACxE,MAAM,OAAO,MAAK,aAAa,GAAG;AAAA,UAClC,IAAI;AAAA,YACF,YAAW,IAAI;AAAA,YACf,OAAO,GAAG;AAAA,YACV,IAAK,GAA6B,SAAS,UAAU;AAAA,cACnD,OAAO,KAAK,EAAE,KAAK,GAAG,KAAK,GAAG,sCAAsC;AAAA,cACpE,SAAS,KAAK,CAAC;AAAA,YACjB;AAAA;AAAA,QAEJ;AAAA,QACA,OAAO,GAAG;AAAA,QACV,OAAO,KAAK,EAAE,KAAK,GAAG,UAAU,IAAI,GAAG,oCAAoC;AAAA,QAC3E,SAAS,KAAK,CAAC;AAAA;AAAA,IAEnB;AAAA,IACA,IAAI,SAAS,SAAS,GAAG;AAAA,MACvB,MAAM,IAAI,eAAe,UAAU,8BAA8B;AAAA,IACnE;AAAA;AAEJ;;;AI9GA;AACA,uBAAS,0BAAY,4BAAW,gCAAc;AAC9C,oBAAS;AACT,iBAAS;AAMT,IAAM,aAAoC;AAAA,EACxC,gBAAgB;AAAA,EAKhB,kBAAkB;AAAA,EAClB,MAAM;AAAA,EACN,YAAY;AAAA,EACZ,aAAa;AACf;AACA,IAAM,eAAe,IAAI,IAAI,CAAC,GAAG,OAAO,KAAK,UAAS,GAAG,GAAG,OAAO,OAAO,UAAS,CAAC,CAAC;AACrF,IAAM,iBAAgB,IAAI,IAAI,qBAAqB;AAE5C,IAAM,kBAAiC;AAAA,EAC5C,MAAM;AAAA,EACN,cAAc;AAAA,EACd,eAAe;AAAA,EACf,gBAAgB,CAAC,OAAO;AAAA,IACtB,OAAO,WAAU,UAAU;AAAA;AAAA,EAE7B,aAAa,CAAC,OAAO;AAAA,IACnB,OAAO,aAAa,IAAI,KAAK;AAAA;AAAA,EAE/B,cAAc;AAAA,EACd,cAAc,CAAC,QAAQ;AAAA,IACrB,OAAO,eAAc,IAAI,MAAM;AAAA;AAAA,EAEjC,WAAW,CAAC,OAAO,QAAQ;AAAA,IACzB,OAAO,SAAS,GAAG,cAAU,WAAW;AAAA;AAE5C;AAEA,SAAS,kBAAkB,GAAW;AAAA,EACpC,OAAO,MACL,QAAQ,IAAI,mBACR,QAAQ,QAAQ,IAAI,gBAAgB,IACpC,MAAK,SAAQ,GAAG,UAAU,GAC9B,UACF;AAAA;AAGF,SAAS,kBAAkB,CAAC,WAA2B;AAAA,EACrD,OAAO,MAAK,mBAAmB,GAAG,GAAG,iBAAiB;AAAA;AAGxD,SAAS,wBAAwB,CAAC,WAA2B;AAAA,EAC3D,OAAO,MAAK,mBAAmB,GAAG,GAAG,wBAAwB;AAAA;AAG/D,SAAS,iBAAiB,CAAC,MAAkC;AAAA,EAC3D,KAAK,YAAW,IAAI;AAAA,IAAG;AAAA,EACvB,IAAI;AAAA,IACF,MAAM,YAAY,cAAa,MAAM,MAAM,EAAE,MAAM;AAAA,GAAM,CAAC,EAAE;AAAA,IAC5D,MAAM,SAAS,KAAK,MAAM,SAAS;AAAA,IACnC,OAAO,OAAO,OAAO,OAAO,cAAc,WAAW,OAAO,MAAM,YAAY;AAAA,IAC9E,MAAM;AAAA,IACN;AAAA;AAAA;AAuBJ,IAAM,yBAAyB;AAE/B,SAAS,gBAAgB,CAAC,MAAsB;AAAA,EAC9C,OAAO,KAAK,KAAK,EAAE,SAAS,IAAI,OAAO;AAAA;AAGzC,SAAS,YAAY,CAAC,SAAiE;AAAA,EACrF,MAAM,YAAY,QAAQ,kBAAkB,WAAW;AAAA,EACvD,eAAe,aAAa,SAAS;AAAA,EACrC,gBAAgB,QAAQ,GAAG;AAAA,EAC3B,MAAM,OAAO,mBAAmB,SAAS;AAAA,EACzC,WAAU,mBAAmB,GAAG,EAAE,WAAW,KAAK,CAAC;AAAA,EACnD,MAAM,WAAW,iBAAiB,QAAQ,OAAO,EAAE,QAAQ,CAAC,SAAS;AAAA,IACnE,EAAE,MAAM,QAAQ,SAAS,iBAAiB,KAAK,QAAQ,EAAE;AAAA,IACzD,EAAE,MAAM,aAAa,SAAS,iBAAiB,KAAK,aAAa,EAAE;AAAA,EACrE,CAAC;AAAA,EACD,MAAM,QAAQ;AAAA,IACZ;AAAA,MACE,OAAO;AAAA,QACL,SAAS;AAAA,QACT,KAAK,QAAQ;AAAA,QACb,WAAW,kBAAkB,IAAI,KAAK,IAAI,KAAK,EAAE,YAAY;AAAA,QAM7D,YAAY;AAAA,MACd;AAAA,IACF;AAAA,IACA,GAAG;AAAA,EACL;AAAA,EACA,eAAc,MAAM,GAAG,MAAM,IAAI,CAAC,SAAS,KAAK,UAAU,IAAI,CAAC,EAAE,KAAK;AAAA,CAAI;AAAA,GAAO;AAAA,IAC/E,MAAM;AAAA,EACR,CAAC;AAAA,EACD,OAAO,EAAE,WAAW,aAAa,KAAK;AAAA;AAGjC,IAAM,4BAAqD;AAAA,EAChE,YAAY,CAAC,SAAS;AAAA,IACpB,OAAO,aAAa,OAAO;AAAA;AAAA,OAEvB,YAAW,CAAC,SAAS;AAAA,IAIzB,QAAQ,sBAAsB,eAAe,0BAA0B,MACrE;AAAA,IAEF,MAAM,iBAAiB,sBAAsB,QAAQ,eAAe;AAAA,IACpE,KAAK,gBAAgB;AAAA,MACnB,MAAM,IAAI,MAAM,qCAAqC,QAAQ,iBAAiB;AAAA,IAChF;AAAA,IACA,MAAM,4BAA4B,YAAW,yBAAyB,QAAQ,eAAe,CAAC;AAAA,IAC9F,MAAM,OAAO,cAAc;AAAA,MACzB,iBAAiB,QAAQ;AAAA,MACzB,cAAc,eAAe;AAAA,MAC7B,KAAK,QAAQ;AAAA,MACb,QAAQ,OAAO,QAAQ,MAAM;AAAA,IAC/B,CAAC;AAAA,IACD,IAAI,8BAA8B,KAAK,wBAAwB;AAAA,MAC7D,qBAAqB,KAAK,SAAS;AAAA,MACnC,MAAM,IAAI,MAAM,kDAAkD,QAAQ,iBAAiB;AAAA,IAC7F;AAAA,IACA,MAAM,aAAa,yBAAyB,KAAK,SAAS;AAAA,IAC1D,OAAO;AAAA,MACL,QAAQ,KAAK;AAAA,MACb,aAAa,KAAK;AAAA,SACd,YAAW,UAAU,IAAI,EAAE,cAAc,CAAC,UAAU,EAAE,IAAI,CAAC;AAAA,IACjE;AAAA;AAAA,OAEI,gBAAe,CAAC,SAAS;AAAA,IAI7B,QAAQ,yBAAyB,MAAa;AAAA,IAC9C,MAAM,WAAsB,CAAC;AAAA,IAC7B,WAAW,aAAa,QAAQ,YAAY;AAAA,MAC1C,IAAI;AAAA,QACF,qBAAqB,SAAS;AAAA,QAC9B,MAAM,YAAY;AAAA,UAChB,mBAAmB,SAAS;AAAA,UAC5B,yBAAyB,SAAS;AAAA,QACpC,EAAE,OAAO,WAAU;AAAA,QACnB,IAAI,UAAU,SAAS,GAAG;AAAA,UACxB,MAAM,IAAI,MAAM,+CAA+C,UAAU,KAAK,IAAI,GAAG;AAAA,QACvF;AAAA,QACA,OAAO,OAAO;AAAA,QACd,OAAO,KAAK,EAAE,KAAK,OAAO,UAAU,GAAG,yCAAyC;AAAA,QAChF,SAAS,KAAK,KAAK;AAAA;AAAA,IAEvB;AAAA,IACA,IAAI,SAAS,SAAS,GAAG;AAAA,MACvB,MAAM,IAAI,eAAe,UAAU,gCAAgC;AAAA,IACrE;AAAA;AAEJ;;;ACzKA,IAAM,aAA+C;AAAA,EACnD,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,SAAS;AACX;AAEO,SAAS,WAAW,CAAC,OAAiC;AAAA,EAC3D,OAAO,WAAW;AAAA;AAGpB,IAAM,aAAyD;AAAA,EAC7D,QAAQ;AAAA,EACR,OAAO;AAAA,EACP,SAAS;AACX;AAEO,SAAS,qBAAqB,CAAC,OAA2C;AAAA,EAC/E,OAAO,WAAW;AAAA;;;ACXb,IAAM,eAA0C,CAAC,UAAU,YAAoB,KAAK;AAEpF,IAAM,yBAA8D,CAAC,UAC1E,sBAA8B,KAAK;",
15
- "debugId": "D08680114CEA736864756E2164756E21",
15
+ "debugId": "22D96109DEE01FA164756E2164756E21",
16
16
  "names": []
17
17
  }
@@ -27,6 +27,8 @@ export interface SubmitRuntimeGatewayTurnParams {
27
27
  clientMessageId: string;
28
28
  requestId?: string;
29
29
  allowAutoContinue?: boolean;
30
+ /** Answer inside this thread instead of the room's main flow (S-13). */
31
+ threadRootId?: string;
30
32
  }
31
33
 
32
34
  export interface SubmitRuntimeGatewayTurnResult extends RuntimeGatewaySubmission {
@@ -95,6 +97,10 @@ function gatewayPayloadHash(
95
97
  params.clientMessageId,
96
98
  requestId,
97
99
  params.allowAutoContinue ?? true,
100
+ // Part of the identity of the turn: the same key asked in the channel
101
+ // and in a thread are different turns, and replaying one as the other
102
+ // would answer in the wrong place.
103
+ params.threadRootId ?? null,
98
104
  ]),
99
105
  )
100
106
  .digest("hex");
@@ -139,6 +145,7 @@ export function submitRuntimeGatewayTurn(
139
145
  sourceAdapter: "runtime-gateway",
140
146
  sourceMessageId: params.clientMessageId,
141
147
  text: params.text,
148
+ ...(params.threadRootId ? { threadRootId: params.threadRootId } : {}),
142
149
  createdAt,
143
150
  };
144
151
  const submission: RuntimeGatewaySubmission = {
@@ -175,6 +182,7 @@ export function submitRuntimeGatewayTurn(
175
182
  conversationPrompts: [params.text],
176
183
  loggedUserMessageCount: 0,
177
184
  vaultUserId: params.vaultUserId,
185
+ ...(params.threadRootId ? { threadRootId: params.threadRootId } : {}),
178
186
  },
179
187
  });
180
188
  const acceptedEvent = appendRuntimeEvent("runtime-gateway-ingress", {
@@ -294,19 +294,25 @@ export {
294
294
  export { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
295
295
  export {
296
296
  clearTopicSessionId,
297
+ defaultSurfaceScope,
297
298
  defaultTopicSurface,
298
299
  findTopicTitleConflict,
299
300
  getTopic,
300
301
  getTopicByNameForUser,
301
302
  getTopicSessionId,
302
303
  grantSubagentTellTarget,
304
+ isSurfaceScopeRequired,
303
305
  isTopicVisible,
304
306
  listSubagentTellTargetIds,
305
307
  listTopics,
308
+ normalizeSurfaceScope,
306
309
  normalizeTopicSurface,
307
310
  revokeSubagentTellTarget,
311
+ setDefaultSurfaceScope,
312
+ setSurfaceScopeRequired,
308
313
  setTopicSessionId,
309
314
  setTopicSurfaces,
315
+ stampUnscopedOtiumTopics,
310
316
  upsertTopic,
311
317
  } from "#storage/api-topics";
312
318
  export { getGlobalAiName } from "#storage/app-settings";
@@ -15,7 +15,13 @@ import { deleteManagedBrowserProfile } from "#platform/playwright/profile-manage
15
15
  import { sessionInboxPath } from "#query/session-inbox-path";
16
16
  import { sanitizeId } from "#security/sanitize";
17
17
  import { getApiTopicConfig, setApiTopicConfig } from "#storage/api-topic-config";
18
- import { defaultTopicSurface, getTopic, listTopics, upsertTopic } from "#storage/api-topics";
18
+ import {
19
+ defaultSurfaceScope,
20
+ defaultTopicSurface,
21
+ getTopic,
22
+ listTopics,
23
+ upsertTopic,
24
+ } from "#storage/api-topics";
19
25
  import {
20
26
  assignTopicBrowserProfile,
21
27
  getBrowserProfileOwner,
@@ -50,16 +56,25 @@ function currentTopic(context: SessionCommContext) {
50
56
  }
51
57
 
52
58
  function targetCatalog(context: SessionCommContext) {
53
- const surface =
54
- (context.currentTopicId ? getTopic(context.currentTopicId)?.surface : undefined) ??
55
- defaultTopicSurface();
59
+ const current = context.currentTopicId ? getTopic(context.currentTopicId) : null;
60
+ const surface = current?.surface ?? defaultTopicSurface();
61
+ // A room may only address rooms in its own workspace (M-8): with several
62
+ // Otium workspaces attached, "same surface" is no longer a boundary — two
63
+ // workspaces share the `otium` surface and must still be invisible to each
64
+ // other. A room with no scope addresses the other unscoped rooms.
65
+ const surfaceScope = current
66
+ ? (current.surfaceScope ?? null)
67
+ : surface === "otium"
68
+ ? defaultSurfaceScope()
69
+ : null;
56
70
  return createSessionTargetCatalog({
57
71
  currentTopicId: context.currentTopicId,
58
72
  currentTopicName: context.currentTopic,
59
73
  currentSurface: surface,
60
74
  isAgent: isAgentKind,
75
+ // Scoped in the store query, not after the fact.
61
76
  listRows: () =>
62
- listTopics()
77
+ listTopics({ surface, surfaceScope })
63
78
  .filter((topic) => topic.participants.some((p) => p.userId === context.userId))
64
79
  .map((topic) => ({
65
80
  id: topic.id,
@@ -132,7 +147,11 @@ export function createDefaultSessionCommMcpHost(): SessionCommMcpHost {
132
147
  `- ${key}: ${topic.sessionId ? "active" : "fresh-start ready"}${topic.description ? `\n description: ${topic.description.slice(0, 80)}` : ""}`,
133
148
  );
134
149
  if (!identity.restricted) {
135
- const peers = await peerSessionsForUser(context.userId, context.peerHostQueryId);
150
+ const peers = await peerSessionsForUser(
151
+ context.userId,
152
+ context.peerHostQueryId,
153
+ context.currentTopicId,
154
+ );
136
155
  for (const node of peers.nodes ?? []) {
137
156
  for (const session of node.sessions ?? []) {
138
157
  if (session.agent)
@@ -35,7 +35,16 @@ export interface RemoteReplyRoute {
35
35
 
36
36
  export interface PeerSessionBridge {
37
37
  forward(args: PeerForwardArgs): Promise<PeerForwardResult>;
38
- sessions(userId: string, sourceQueryId?: string): Promise<PeerSessionsResult>;
38
+ /**
39
+ * `fromTopicId` names the asking room so the bridge can answer within that
40
+ * room's workspace. Without it the answer is the union of every attached
41
+ * workspace's nodes, which leaks one workspace's topology into another.
42
+ */
43
+ sessions(
44
+ userId: string,
45
+ sourceQueryId?: string,
46
+ fromTopicId?: string,
47
+ ): Promise<PeerSessionsResult>;
39
48
  reply(
40
49
  route: RemoteReplyRoute,
41
50
  sourceTitle: string,
@@ -52,7 +61,7 @@ const PEER_BRIDGE_TIMEOUT_MS = 5_000;
52
61
 
53
62
  type IpcRequest =
54
63
  | { action: "forward"; args: PeerForwardArgs }
55
- | { action: "sessions"; userId: string; sourceQueryId?: string }
64
+ | { action: "sessions"; userId: string; sourceQueryId?: string; fromTopicId?: string }
56
65
  | {
57
66
  action: "reply";
58
67
  route: RemoteReplyRoute;
@@ -132,12 +141,14 @@ export interface PeerSessionsResult {
132
141
  export async function peerSessionsForUser(
133
142
  userId: string,
134
143
  sourceQueryId?: string,
144
+ fromTopicId?: string,
135
145
  ): Promise<PeerSessionsResult> {
136
- if (activeBridge) return activeBridge.sessions(userId, sourceQueryId);
146
+ if (activeBridge) return activeBridge.sessions(userId, sourceQueryId, fromTopicId);
137
147
  const sessions = await callLoopbackBridge<PeerSessionsResult>({
138
148
  action: "sessions",
139
149
  userId,
140
150
  sourceQueryId,
151
+ fromTopicId,
141
152
  });
142
153
  if (sessions.result) return sessions.result;
143
154
  if (sessions.configured) return { ok: false, nodes: [] };
@@ -170,7 +170,7 @@ server.tool(
170
170
  const remoteSections: string[] = [];
171
171
  const peers = currentSubagentRestricted
172
172
  ? { ok: true as const, nodes: [] }
173
- : await peerSessionsForUser(userId, peerHostQueryId || undefined);
173
+ : await peerSessionsForUser(userId, peerHostQueryId || undefined, currentTopicId);
174
174
  if (peers.ok && peers.nodes) {
175
175
  for (const node of peers.nodes) {
176
176
  if (!node.node) continue;
@@ -71,6 +71,40 @@ export function validateTarget(to: string): ValidateTargetResult {
71
71
  return sessionTargetCatalog.validateTarget(to);
72
72
  }
73
73
 
74
+ /**
75
+ * Surface of the room this server serves, read with its own narrow query so the
76
+ * target list below can be filtered *in SQL* rather than loaded whole and
77
+ * filtered in memory (S-6: the store decides what a surface can see).
78
+ */
79
+ interface CurrentPlacement {
80
+ surface: string | undefined;
81
+ /** Which Otium workspace owns this room; null on the single-instance surfaces. */
82
+ surfaceScope: string | null;
83
+ }
84
+
85
+ function readCurrentPlacement(): CurrentPlacement {
86
+ if (!existsSync(SESSIONS_DB)) return { surface: undefined, surfaceScope: null };
87
+ try {
88
+ return withDb((db) => {
89
+ const row = currentTopicId
90
+ ? (db
91
+ .query<{ surface: string | null; surface_scope: string | null }, string>(
92
+ "SELECT surface, surface_scope FROM api_topics WHERE id = ?",
93
+ )
94
+ .get(currentTopicId) ?? undefined)
95
+ : (db
96
+ .query<{ surface: string | null; surface_scope: string | null }, string>(
97
+ "SELECT surface, surface_scope FROM api_topics WHERE title = ? LIMIT 1",
98
+ )
99
+ .get(currentTopic) ?? undefined);
100
+ return { surface: row?.surface ?? undefined, surfaceScope: row?.surface_scope ?? null };
101
+ });
102
+ } catch (e) {
103
+ process.stderr.write(`warn: session-comm: failed to read the current surface: ${e}\n`);
104
+ return { surface: undefined, surfaceScope: null };
105
+ }
106
+ }
107
+
74
108
  function sessionTargetRows(): Array<{
75
109
  id: string;
76
110
  title: string;
@@ -82,6 +116,7 @@ function sessionTargetRows(): Array<{
82
116
  }> {
83
117
  if (!existsSync(SESSIONS_DB)) return [];
84
118
  try {
119
+ const { surface, surfaceScope } = currentSessionPlacement();
85
120
  return withDb((db) => {
86
121
  return db
87
122
  .query<
@@ -94,14 +129,19 @@ function sessionTargetRows(): Array<{
94
129
  description: string | null;
95
130
  surface: string | null;
96
131
  },
97
- string
132
+ (string | null)[]
98
133
  >(
99
134
  `SELECT t.id, t.title, t.kind, t.agent, t.session_id, t.description, t.surface
100
135
  FROM api_topics t
101
136
  INNER JOIN topic_members m ON m.topic_id = t.id
102
- WHERE m.user_id = ?`,
137
+ WHERE m.user_id = ?
138
+ AND (? IS NULL OR t.surface IS NULL OR t.surface = ?)
139
+ -- Two Otium workspaces share the otium surface and must still be
140
+ -- invisible to each other (M-8), so the workspace is part of the
141
+ -- boundary, not a refinement of it.
142
+ AND t.surface_scope IS ?`,
103
143
  )
104
- .all(userId);
144
+ .all(userId, surface ?? null, surface ?? null, surfaceScope);
105
145
  });
106
146
  } catch (e) {
107
147
  process.stderr.write(`warn: session-comm: failed to load topics from DB: ${e}\n`);
@@ -120,26 +160,22 @@ export function getTopicsForUser(): { [name: string]: TopicEntry } {
120
160
  }
121
161
 
122
162
  /**
123
- * Surface of the room this MCP server is serving. Read once from the canonical
124
- * store: session-comm only ever addresses sessions on the same surface.
163
+ * Where the room this MCP server is serving lives. Read once from the canonical
164
+ * store: session-comm only ever addresses sessions on the same surface, and
165
+ * within it, in the same workspace.
125
166
  */
126
- let cachedSessionSurface: { value: string | undefined } | null = null;
127
-
128
- function currentSessionSurface(): string | undefined {
129
- if (cachedSessionSurface) return cachedSessionSurface.value;
130
- const rows = sessionTargetRows();
131
- const mine = currentTopicId
132
- ? rows.find((row) => row.id === currentTopicId)
133
- : rows.find((row) => row.title === currentTopic);
134
- cachedSessionSurface = { value: mine?.surface ?? undefined };
135
- return cachedSessionSurface.value;
167
+ let cachedSessionPlacement: CurrentPlacement | null = null;
168
+
169
+ function currentSessionPlacement(): CurrentPlacement {
170
+ cachedSessionPlacement ??= readCurrentPlacement();
171
+ return cachedSessionPlacement;
136
172
  }
137
173
 
138
174
  const sessionTargetCatalog = createSessionTargetCatalog<AgentKind>({
139
175
  currentTopicId,
140
176
  currentTopicName: currentTopic,
141
177
  get currentSurface() {
142
- return currentSessionSurface();
178
+ return currentSessionPlacement().surface;
143
179
  },
144
180
  isAgent: isAgentKind,
145
181
  listRows: () =>
@@ -42,7 +42,7 @@ export { storeLocalFileAsUpload } from "#runtime/file-hooks";
42
42
  export { isSensitivePath } from "#security/sensitive-path";
43
43
  export { appendApiMessage } from "#storage/api-messages";
44
44
  export { getApiTopicConfig } from "#storage/api-topic-config";
45
- export { getTopic, getTopicByNameForUser } from "#storage/api-topics";
45
+ export { defaultTopicSurface, getTopic, getTopicByNameForUser } from "#storage/api-topics";
46
46
  export { registerTopic, TopicValidationError } from "#topics/create";
47
47
  export {
48
48
  getTopics,
@@ -56,4 +56,4 @@ export {
56
56
  export { restartTopicSession } from "#topics/session";
57
57
  export type { EffortLevel } from "#types";
58
58
  export { EFFORT_VALUES } from "#types";
59
- export type { MessageDto, TopicDto } from "#types/api";
59
+ export type { MessageDto, TopicDto, TopicSurface } from "#types/api";
@@ -65,7 +65,7 @@ export type { FileHooks, UploadAccess } from "#runtime/file-hooks";
65
65
  export { setFileHooks } from "#runtime/file-hooks";
66
66
  export { startSessionInboxWorker } from "#runtime/inbox";
67
67
  export { startAiTurn, startDurableTurnRequestWorker } from "#runtime/turn-runner";
68
- export { appendApiMessage, listApiMessages } from "#storage/api-messages";
68
+ export { appendApiMessage, getApiMessage, listApiMessages } from "#storage/api-messages";
69
69
  export { getTopic, upsertTopic } from "#storage/api-topics";
70
70
  export type { StoredRuntimeEvent } from "#storage/runtime-events";
71
71
  export {
@@ -132,6 +132,8 @@ export async function runTurnEventStream(
132
132
  silent?: boolean;
133
133
  peerBridge?: PeerRuntimeBridgeContext;
134
134
  sourceNode?: string;
135
+ /** Answer inside this thread instead of the room's main flow (S-13). */
136
+ threadRootId?: string;
135
137
  },
136
138
  ): Promise<StreamAgentOutcome> {
137
139
  const { appendSystemMessage, deliverAskCallbackToCaller, deliverAskError, redispatchInject } =
@@ -229,10 +231,16 @@ export async function runTurnEventStream(
229
231
  agentType,
230
232
  model,
231
233
  sourceNode: execution?.sourceNode,
234
+ ...(execution?.threadRootId ? { threadRootId: execution.threadRootId } : {}),
232
235
  usage,
233
236
  createdAt: new Date().toISOString(),
234
237
  };
235
- appendApiMessage(message);
238
+ // A threaded turn answers in its thread and leaves the room's ordering and
239
+ // unread state alone, exactly like a human thread reply.
240
+ appendApiMessage(
241
+ message,
242
+ execution?.threadRootId ? { updateTopicLastMessageAt: false } : undefined,
243
+ );
236
244
  hub.broadcastMessage(topicId, message);
237
245
  lastVisibleMessageId = message.id;
238
246
  visibleMessageIds.push(message.id);
@@ -276,7 +276,12 @@ export async function streamAgentEvents(
276
276
  userId: string,
277
277
  retryableSessionExpired = true,
278
278
  onSessionId?: (sessionId: string) => void,
279
- execution?: { silent?: boolean; peerBridge?: PeerRuntimeBridgeContext; sourceNode?: string },
279
+ execution?: {
280
+ silent?: boolean;
281
+ peerBridge?: PeerRuntimeBridgeContext;
282
+ sourceNode?: string;
283
+ threadRootId?: string;
284
+ },
280
285
  ): Promise<StreamAgentOutcome> {
281
286
  return runTurnEventStream(
282
287
  topicId,
@@ -608,6 +613,8 @@ export interface AiTurnExecutionOptions {
608
613
  origin?: string;
609
614
  /** Origin node for transcript echo suppression. */
610
615
  sourceNode?: string;
616
+ /** Answer inside this thread instead of the room's main flow (S-13). */
617
+ threadRootId?: string;
611
618
  /** Fired when the turn is actually dispatched, including after a defer. */
612
619
  onDispatched?: (queryId: string) => void;
613
620
  /** Inter-session requestId for queue dedup (session-inject only). */
@@ -841,6 +848,7 @@ async function drainOneDurableUserTurn(): Promise<void> {
841
848
  bridgeSessionFromHistory: execution?.bridgeSessionFromHistory,
842
849
  peerBridge: execution?.peerBridge,
843
850
  from: execution?.from,
851
+ threadRootId: execution?.threadRootId,
844
852
  _queryId: request.requestId,
845
853
  _runtimeEpoch: execution?.runtimeEpoch ?? request.topicEpoch,
846
854
  onSettled: () => {
@@ -934,6 +942,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
934
942
  const deferredSessionId =
935
943
  params.sessionId === undefined && !sessionResolution.isolated ? undefined : sessionId;
936
944
  const sourceNode = params.sourceNode;
945
+ const threadRootId = params.threadRootId;
937
946
  const topicId = topic.id;
938
947
  const requestId = params.requestId;
939
948
  const depth = params.depth;
@@ -1657,7 +1666,7 @@ export function startAiTurn(params: StartAiTurnParams): string | null {
1657
1666
  userId,
1658
1667
  !sessionRetried,
1659
1668
  onSessionId,
1660
- { silent, peerBridge, sourceNode },
1669
+ { silent, peerBridge, sourceNode, ...(threadRootId ? { threadRootId } : {}) },
1661
1670
  )
1662
1671
  .then(async (streamOutcome) => {
1663
1672
  let outcome = streamOutcome;